1.2.4稳定版 #3

Merged
yovinchen merged 40 commits from develop into master 2024-12-05 16:52:26 +08:00
5 changed files with 207 additions and 135 deletions
Showing only changes of commit c92cc18dde - Show all commits

View File

@ -0,0 +1,8 @@
package com.yovinchen.bookkeeping.model
data class MemberStat(
val member: String,
val amount: Double,
val count: Int,
val percentage: Double = 0.0
)

View File

@ -1,71 +1,76 @@
package com.yovinchen.bookkeeping.ui.components package com.yovinchen.bookkeeping.ui.components
import android.annotation.SuppressLint
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.*
import androidx.compose.material3.LinearProgressIndicator import androidx.compose.runtime.*
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.yovinchen.bookkeeping.model.CategoryStat import com.yovinchen.bookkeeping.model.CategoryStat
import com.yovinchen.bookkeeping.model.MemberStat
import java.text.NumberFormat
import java.util.*
@SuppressLint("DefaultLocale")
@Composable @Composable
fun CategoryStatItem( fun CategoryStatItem(
stat: CategoryStat, stat: Any,
onClick: () -> Unit onClick: () -> Unit,
modifier: Modifier = Modifier
) { ) {
Column( val name = when (stat) {
modifier = Modifier is CategoryStat -> stat.category
.fillMaxWidth() is MemberStat -> stat.member
.clickable(onClick = onClick) else -> return
.padding(vertical = 8.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stat.category,
style = MaterialTheme.typography.bodyLarge
)
Text(
text = String.format("%.2f", stat.amount),
style = MaterialTheme.typography.bodyLarge
)
} }
Spacer(modifier = Modifier.height(4.dp)) val amount = when (stat) {
is CategoryStat -> stat.amount
is MemberStat -> stat.amount
else -> return
}
val count = when (stat) {
is CategoryStat -> stat.count
is MemberStat -> stat.count
else -> return
}
val percentage = when (stat) {
is CategoryStat -> stat.percentage
is MemberStat -> stat.percentage
else -> return
}
Card(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp)
.clickable(onClick = onClick),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
LinearProgressIndicator( Column(modifier = Modifier.weight(1f)) {
progress = { stat.percentage.toFloat() / 100f },
modifier = Modifier
.weight(1f)
.height(8.dp)
.background(
MaterialTheme.colorScheme.surfaceVariant,
RoundedCornerShape(4.dp)
),
)
Spacer(modifier = Modifier.width(8.dp))
Text( Text(
text = String.format("%.1f%%", stat.percentage), text = name,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyLarge
)
Text(
text = "${count}笔 · ${String.format("%.1f%%", percentage)}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
Text(
text = NumberFormat.getCurrencyInstance(Locale.CHINA).format(amount),
style = MaterialTheme.typography.titleMedium
)
}
} }
} }

View File

@ -0,0 +1,62 @@
package com.yovinchen.bookkeeping.ui.components
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import java.time.YearMonth
import java.time.format.DateTimeFormatter
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DateRangePicker(
startMonth: YearMonth,
endMonth: YearMonth,
onStartMonthSelected: (YearMonth) -> Unit,
onEndMonthSelected: (YearMonth) -> Unit,
modifier: Modifier = Modifier
) {
var showStartMonthPicker by remember { mutableStateOf(false) }
var showEndMonthPicker by remember { mutableStateOf(false) }
val formatter = DateTimeFormatter.ofPattern("yyyy年MM月")
Row(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Button(onClick = { showStartMonthPicker = true }) {
Text(startMonth.format(formatter))
}
Text("")
Button(onClick = { showEndMonthPicker = true }) {
Text(endMonth.format(formatter))
}
}
if (showStartMonthPicker) {
MonthYearPicker(
selectedMonth = startMonth,
onMonthSelected = {
onStartMonthSelected(it)
showStartMonthPicker = false
},
onDismiss = { showStartMonthPicker = false }
)
}
if (showEndMonthPicker) {
MonthYearPicker(
selectedMonth = endMonth,
onMonthSelected = {
onEndMonthSelected(it)
showEndMonthPicker = false
},
onDismiss = { showEndMonthPicker = false }
)
}
}

View File

@ -33,12 +33,13 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.yovinchen.bookkeeping.model.AnalysisType import com.yovinchen.bookkeeping.model.AnalysisType
import com.yovinchen.bookkeeping.model.CategoryStat
import com.yovinchen.bookkeeping.model.MemberStat
import com.yovinchen.bookkeeping.ui.components.CategoryPieChart import com.yovinchen.bookkeeping.ui.components.CategoryPieChart
import com.yovinchen.bookkeeping.ui.components.CategoryStatItem import com.yovinchen.bookkeeping.ui.components.CategoryStatItem
import com.yovinchen.bookkeeping.ui.components.MonthYearPicker import com.yovinchen.bookkeeping.ui.components.DateRangePicker
import com.yovinchen.bookkeeping.viewmodel.AnalysisViewModel import com.yovinchen.bookkeeping.viewmodel.AnalysisViewModel
import java.time.YearMonth import java.time.YearMonth
import java.time.format.DateTimeFormatter
enum class ViewMode { enum class ViewMode {
CATEGORY, MEMBER CATEGORY, MEMBER
@ -51,12 +52,12 @@ fun AnalysisScreen(
onNavigateToMemberDetail: (String, YearMonth, AnalysisType) -> Unit onNavigateToMemberDetail: (String, YearMonth, AnalysisType) -> Unit
) { ) {
val viewModel: AnalysisViewModel = viewModel() val viewModel: AnalysisViewModel = viewModel()
val selectedMonth by viewModel.selectedMonth.collectAsState() val startMonth by viewModel.startMonth.collectAsState()
val endMonth by viewModel.endMonth.collectAsState()
val selectedAnalysisType by viewModel.selectedAnalysisType.collectAsState() val selectedAnalysisType by viewModel.selectedAnalysisType.collectAsState()
val categoryStats by viewModel.categoryStats.collectAsState() val categoryStats by viewModel.categoryStats.collectAsState()
val memberStats by viewModel.memberStats.collectAsState() val memberStats by viewModel.memberStats.collectAsState()
var showMonthPicker by remember { mutableStateOf(false) }
var showViewModeMenu by remember { mutableStateOf(false) } var showViewModeMenu by remember { mutableStateOf(false) }
var currentViewMode by rememberSaveable { mutableStateOf(ViewMode.CATEGORY) } var currentViewMode by rememberSaveable { mutableStateOf(ViewMode.CATEGORY) }
@ -66,18 +67,13 @@ fun AnalysisScreen(
.fillMaxSize() .fillMaxSize()
.padding(padding) .padding(padding)
) { ) {
// 时间选择按钮行 // 时间区间选择
Row( DateRangePicker(
modifier = Modifier startMonth = startMonth,
.fillMaxWidth() endMonth = endMonth,
.padding(horizontal = 16.dp, vertical = 8.dp), onStartMonthSelected = viewModel::setStartMonth,
horizontalArrangement = Arrangement.End, onEndMonthSelected = viewModel::setEndMonth
verticalAlignment = Alignment.CenterVertically )
) {
Button(onClick = { showMonthPicker = true }) {
Text(selectedMonth.format(DateTimeFormatter.ofPattern("yyyy年MM月")))
}
}
// 分析类型和视图模式选择行 // 分析类型和视图模式选择行
Row( Row(
@ -147,7 +143,7 @@ fun AnalysisScreen(
item { item {
CategoryPieChart( CategoryPieChart(
categoryData = categoryStats.map { Pair(it.category, it.percentage.toFloat()) }, categoryData = categoryStats.map { Pair(it.category, it.percentage.toFloat()) },
memberData = memberStats.map { Pair(it.category, it.percentage.toFloat()) }, memberData = memberStats.map { Pair(it.member, it.percentage.toFloat()) },
currentViewMode = currentViewMode == ViewMode.MEMBER, currentViewMode = currentViewMode == ViewMode.MEMBER,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@ -155,9 +151,9 @@ fun AnalysisScreen(
.padding(bottom = 16.dp), .padding(bottom = 16.dp),
onCategoryClick = { category -> onCategoryClick = { category ->
if (currentViewMode == ViewMode.CATEGORY) { if (currentViewMode == ViewMode.CATEGORY) {
onNavigateToCategoryDetail(category, selectedMonth) onNavigateToCategoryDetail(category, startMonth)
} else { } else {
onNavigateToMemberDetail(category, selectedMonth, selectedAnalysisType) onNavigateToMemberDetail(category, startMonth, selectedAnalysisType)
} }
} }
) )
@ -166,29 +162,21 @@ fun AnalysisScreen(
// 添加统计列表项目 // 添加统计列表项目
items(if (currentViewMode == ViewMode.CATEGORY) categoryStats else memberStats) { stat -> items(if (currentViewMode == ViewMode.CATEGORY) categoryStats else memberStats) { stat ->
val category = if (stat is CategoryStat) stat.category else null
val member = if (stat is MemberStat) stat.member else null
CategoryStatItem( CategoryStatItem(
stat = stat, stat = stat,
onClick = { onClick = {
if (currentViewMode == ViewMode.CATEGORY) { if (currentViewMode == ViewMode.CATEGORY && category != null) {
onNavigateToCategoryDetail(stat.category, selectedMonth) onNavigateToCategoryDetail(category, startMonth)
} else { } else if (currentViewMode == ViewMode.MEMBER && member != null) {
onNavigateToMemberDetail(stat.category, selectedMonth, selectedAnalysisType) onNavigateToMemberDetail(member, startMonth, selectedAnalysisType)
} }
} }
) )
} }
} }
if (showMonthPicker) {
MonthYearPicker(
selectedMonth = selectedMonth,
onMonthSelected = { month ->
viewModel.setSelectedMonth(month)
showMonthPicker = false
},
onDismiss = { showMonthPicker = false }
)
}
} }
} }
} }

View File

@ -6,67 +6,65 @@ import androidx.lifecycle.viewModelScope
import com.yovinchen.bookkeeping.data.BookkeepingDatabase import com.yovinchen.bookkeeping.data.BookkeepingDatabase
import com.yovinchen.bookkeeping.model.AnalysisType import com.yovinchen.bookkeeping.model.AnalysisType
import com.yovinchen.bookkeeping.model.CategoryStat import com.yovinchen.bookkeeping.model.CategoryStat
import com.yovinchen.bookkeeping.model.MemberStat
import com.yovinchen.bookkeeping.model.TransactionType import com.yovinchen.bookkeeping.model.TransactionType
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.YearMonth import java.time.YearMonth
import java.time.ZoneId import java.time.ZoneId
import java.util.Date import java.util.*
class AnalysisViewModel(application: Application) : AndroidViewModel(application) { class AnalysisViewModel(application: Application) : AndroidViewModel(application) {
private val recordDao = BookkeepingDatabase.getDatabase(application).bookkeepingDao() private val recordDao = BookkeepingDatabase.getDatabase(application).bookkeepingDao()
private val memberDao = BookkeepingDatabase.getDatabase(application).memberDao() private val memberDao = BookkeepingDatabase.getDatabase(application).memberDao()
private val _selectedMonth = MutableStateFlow(YearMonth.now()) private val _startMonth = MutableStateFlow(YearMonth.now())
val selectedMonth = _selectedMonth.asStateFlow() val startMonth: StateFlow<YearMonth> = _startMonth.asStateFlow()
private val _endMonth = MutableStateFlow(YearMonth.now())
val endMonth: StateFlow<YearMonth> = _endMonth.asStateFlow()
private val _selectedAnalysisType = MutableStateFlow(AnalysisType.EXPENSE) private val _selectedAnalysisType = MutableStateFlow(AnalysisType.EXPENSE)
val selectedAnalysisType = _selectedAnalysisType.asStateFlow() val selectedAnalysisType: StateFlow<AnalysisType> = _selectedAnalysisType.asStateFlow()
private val members = memberDao.getAllMembers() private val _categoryStats = MutableStateFlow<List<CategoryStat>>(emptyList())
val categoryStats: StateFlow<List<CategoryStat>> = _categoryStats.asStateFlow()
val memberStats = combine(selectedMonth, selectedAnalysisType, members) { month, type, membersList -> private val _memberStats = MutableStateFlow<List<MemberStat>>(emptyList())
val memberStats: StateFlow<List<MemberStat>> = _memberStats.asStateFlow()
init {
viewModelScope.launch {
combine(startMonth, endMonth, selectedAnalysisType) { start, end, type ->
Triple(start, end, type)
}.collect { (start, end, type) ->
updateStats(start, end, type)
}
}
}
fun setStartMonth(month: YearMonth) {
_startMonth.value = month
}
fun setEndMonth(month: YearMonth) {
_endMonth.value = month
}
fun setAnalysisType(type: AnalysisType) {
_selectedAnalysisType.value = type
}
private suspend fun updateStats(startMonth: YearMonth, endMonth: YearMonth, type: AnalysisType) {
val records = recordDao.getAllRecords().first() val records = recordDao.getAllRecords().first()
val monthRecords = records.filter { val monthRecords = records.filter {
val recordDate = Date(it.date.time) val recordDate = Date(it.date.time)
val localDateTime = LocalDateTime.ofInstant(recordDate.toInstant(), ZoneId.systemDefault()) val localDateTime = LocalDateTime.ofInstant(recordDate.toInstant(), ZoneId.systemDefault())
YearMonth.from(localDateTime) == month && it.type == when(type) { val yearMonth = YearMonth.from(localDateTime)
AnalysisType.EXPENSE -> TransactionType.EXPENSE yearMonth.isAfter(startMonth.minusMonths(1)) &&
AnalysisType.INCOME -> TransactionType.INCOME yearMonth.isBefore(endMonth.plusMonths(1)) &&
else -> null it.type == when(type) {
}
}
// 按成员统计
val memberMap = monthRecords.groupBy { record ->
membersList.find { it.id == record.memberId }?.name ?: "未分配"
}
val stats = memberMap.map { (memberName, records) ->
CategoryStat(
category = memberName,
amount = records.sumOf { it.amount },
count = records.size
)
}.sortedByDescending { it.amount }
// 计算总额
val total = stats.sumOf { it.amount }
// 计算百分比
stats.map { it.copy(percentage = if (total > 0) it.amount / total * 100 else 0.0) }
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = emptyList()
)
val categoryStats = combine(selectedMonth, selectedAnalysisType) { month, type ->
val records = recordDao.getAllRecords().first()
val monthRecords = records.filter {
val recordDate = Date(it.date.time)
val localDateTime = LocalDateTime.ofInstant(recordDate.toInstant(), ZoneId.systemDefault())
YearMonth.from(localDateTime) == month && it.type == when(type) {
AnalysisType.EXPENSE -> TransactionType.EXPENSE AnalysisType.EXPENSE -> TransactionType.EXPENSE
AnalysisType.INCOME -> TransactionType.INCOME AnalysisType.INCOME -> TransactionType.INCOME
else -> null else -> null
@ -75,7 +73,7 @@ class AnalysisViewModel(application: Application) : AndroidViewModel(application
// 按分类统计 // 按分类统计
val categoryMap = monthRecords.groupBy { it.category } val categoryMap = monthRecords.groupBy { it.category }
val stats = categoryMap.map { (category, records) -> val categoryStats = categoryMap.map { (category, records) ->
CategoryStat( CategoryStat(
category = category, category = category,
amount = records.sumOf { it.amount }, amount = records.sumOf { it.amount },
@ -83,22 +81,33 @@ class AnalysisViewModel(application: Application) : AndroidViewModel(application
) )
}.sortedByDescending { it.amount } }.sortedByDescending { it.amount }
// 计算总额 // 计算分类总额和百分比
val total = stats.sumOf { it.amount } val categoryTotal = categoryStats.sumOf { it.amount }
val categoryStatsWithPercentage = categoryStats.map {
it.copy(percentage = if (categoryTotal > 0) it.amount / categoryTotal * 100 else 0.0)
}
// 计算百分比 // 按成员统计
stats.map { it.copy(percentage = if (total > 0) it.amount / total * 100 else 0.0) } val members = memberDao.getAllMembers().first()
}.stateIn( val memberMap = monthRecords.groupBy { record ->
scope = viewModelScope, members.find { it.id == record.memberId }?.name ?: "未分配"
started = SharingStarted.WhileSubscribed(5000), }
initialValue = emptyList()
val memberStats = memberMap.map { (memberName, records) ->
MemberStat(
member = memberName,
amount = records.sumOf { it.amount },
count = records.size
) )
}.sortedByDescending { it.amount }
fun setSelectedMonth(month: YearMonth) { // 计算成员总额和百分比
_selectedMonth.value = month val memberTotal = memberStats.sumOf { it.amount }
val memberStatsWithPercentage = memberStats.map {
it.copy(percentage = if (memberTotal > 0) it.amount / memberTotal * 100 else 0.0)
} }
fun setAnalysisType(type: AnalysisType) { _categoryStats.value = categoryStatsWithPercentage
_selectedAnalysisType.value = type _memberStats.value = memberStatsWithPercentage
} }
} }