From c92cc18dde981427d6dfc4b8ad9af38a9792b1c9 Mon Sep 17 00:00:00 2001 From: yovinchen Date: Thu, 5 Dec 2024 13:46:17 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E6=97=B6=E9=97=B4?= =?UTF-8?q?=E5=8C=BA=E9=97=B4=E9=80=89=E6=8B=A9=E5=92=8C=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E7=BB=9F=E8=AE=A1=E6=94=B9=E8=BF=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 添加 DateRangePicker 组件用于时间区间选择 2. 新增 MemberStat 模型用于成员统计 3. 重构 CategoryStatItem 以支持多类型统计数据 4. 更新 AnalysisViewModel 以支持时间区间统计 5. 改进分类和成员视图的切换逻辑 --- .../yovinchen/bookkeeping/model/MemberStat.kt | 8 ++ .../ui/components/CategoryStatItem.kt | 101 +++++++-------- .../ui/components/DateRangePicker.kt | 62 ++++++++++ .../bookkeeping/ui/screen/AnalysisScreen.kt | 56 ++++----- .../viewmodel/AnalysisViewModel.kt | 115 ++++++++++-------- 5 files changed, 207 insertions(+), 135 deletions(-) create mode 100644 app/src/main/java/com/yovinchen/bookkeeping/model/MemberStat.kt create mode 100644 app/src/main/java/com/yovinchen/bookkeeping/ui/components/DateRangePicker.kt diff --git a/app/src/main/java/com/yovinchen/bookkeeping/model/MemberStat.kt b/app/src/main/java/com/yovinchen/bookkeeping/model/MemberStat.kt new file mode 100644 index 0000000..75dce91 --- /dev/null +++ b/app/src/main/java/com/yovinchen/bookkeeping/model/MemberStat.kt @@ -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 +) diff --git a/app/src/main/java/com/yovinchen/bookkeeping/ui/components/CategoryStatItem.kt b/app/src/main/java/com/yovinchen/bookkeeping/ui/components/CategoryStatItem.kt index a783421..5ec354a 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/ui/components/CategoryStatItem.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/ui/components/CategoryStatItem.kt @@ -1,70 +1,75 @@ package com.yovinchen.bookkeeping.ui.components -import android.annotation.SuppressLint -import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable +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 com.yovinchen.bookkeeping.model.CategoryStat +import com.yovinchen.bookkeeping.model.MemberStat +import java.text.NumberFormat +import java.util.* -@SuppressLint("DefaultLocale") @Composable fun CategoryStatItem( - stat: CategoryStat, - onClick: () -> Unit + stat: Any, + onClick: () -> Unit, + modifier: Modifier = Modifier ) { - Column( - modifier = Modifier + val name = when (stat) { + is CategoryStat -> stat.category + is MemberStat -> stat.member + else -> return + } + + 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() - .clickable(onClick = onClick) - .padding(vertical = 8.dp) + .padding(horizontal = 16.dp, vertical = 4.dp) + .clickable(onClick = onClick), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) ) { Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = name, + style = MaterialTheme.typography.bodyLarge + ) + Text( + text = "${count}笔 · ${String.format("%.1f%%", percentage)}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } 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)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - LinearProgressIndicator( - 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 = String.format("%.1f%%", stat.percentage), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + text = NumberFormat.getCurrencyInstance(Locale.CHINA).format(amount), + style = MaterialTheme.typography.titleMedium ) } } diff --git a/app/src/main/java/com/yovinchen/bookkeeping/ui/components/DateRangePicker.kt b/app/src/main/java/com/yovinchen/bookkeeping/ui/components/DateRangePicker.kt new file mode 100644 index 0000000..73e79df --- /dev/null +++ b/app/src/main/java/com/yovinchen/bookkeeping/ui/components/DateRangePicker.kt @@ -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 } + ) + } +} diff --git a/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/AnalysisScreen.kt b/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/AnalysisScreen.kt index fdd7645..f3a2309 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/AnalysisScreen.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/AnalysisScreen.kt @@ -33,12 +33,13 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel 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.CategoryStatItem -import com.yovinchen.bookkeeping.ui.components.MonthYearPicker +import com.yovinchen.bookkeeping.ui.components.DateRangePicker import com.yovinchen.bookkeeping.viewmodel.AnalysisViewModel import java.time.YearMonth -import java.time.format.DateTimeFormatter enum class ViewMode { CATEGORY, MEMBER @@ -51,12 +52,12 @@ fun AnalysisScreen( onNavigateToMemberDetail: (String, YearMonth, AnalysisType) -> Unit ) { 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 categoryStats by viewModel.categoryStats.collectAsState() val memberStats by viewModel.memberStats.collectAsState() - var showMonthPicker by remember { mutableStateOf(false) } var showViewModeMenu by remember { mutableStateOf(false) } var currentViewMode by rememberSaveable { mutableStateOf(ViewMode.CATEGORY) } @@ -66,18 +67,13 @@ fun AnalysisScreen( .fillMaxSize() .padding(padding) ) { - // 时间选择按钮行 - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically - ) { - Button(onClick = { showMonthPicker = true }) { - Text(selectedMonth.format(DateTimeFormatter.ofPattern("yyyy年MM月"))) - } - } + // 时间区间选择 + DateRangePicker( + startMonth = startMonth, + endMonth = endMonth, + onStartMonthSelected = viewModel::setStartMonth, + onEndMonthSelected = viewModel::setEndMonth + ) // 分析类型和视图模式选择行 Row( @@ -147,7 +143,7 @@ fun AnalysisScreen( item { CategoryPieChart( 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, modifier = Modifier .fillMaxWidth() @@ -155,9 +151,9 @@ fun AnalysisScreen( .padding(bottom = 16.dp), onCategoryClick = { category -> if (currentViewMode == ViewMode.CATEGORY) { - onNavigateToCategoryDetail(category, selectedMonth) + onNavigateToCategoryDetail(category, startMonth) } else { - onNavigateToMemberDetail(category, selectedMonth, selectedAnalysisType) + onNavigateToMemberDetail(category, startMonth, selectedAnalysisType) } } ) @@ -166,29 +162,21 @@ fun AnalysisScreen( // 添加统计列表项目 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( stat = stat, onClick = { - if (currentViewMode == ViewMode.CATEGORY) { - onNavigateToCategoryDetail(stat.category, selectedMonth) - } else { - onNavigateToMemberDetail(stat.category, selectedMonth, selectedAnalysisType) + if (currentViewMode == ViewMode.CATEGORY && category != null) { + onNavigateToCategoryDetail(category, startMonth) + } else if (currentViewMode == ViewMode.MEMBER && member != null) { + onNavigateToMemberDetail(member, startMonth, selectedAnalysisType) } } ) } } - - if (showMonthPicker) { - MonthYearPicker( - selectedMonth = selectedMonth, - onMonthSelected = { month -> - viewModel.setSelectedMonth(month) - showMonthPicker = false - }, - onDismiss = { showMonthPicker = false } - ) - } } } } diff --git a/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/AnalysisViewModel.kt b/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/AnalysisViewModel.kt index 704f60e..19bb68e 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/AnalysisViewModel.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/AnalysisViewModel.kt @@ -6,67 +6,65 @@ import androidx.lifecycle.viewModelScope import com.yovinchen.bookkeeping.data.BookkeepingDatabase import com.yovinchen.bookkeeping.model.AnalysisType import com.yovinchen.bookkeeping.model.CategoryStat +import com.yovinchen.bookkeeping.model.MemberStat import com.yovinchen.bookkeeping.model.TransactionType import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch import java.time.LocalDateTime import java.time.YearMonth import java.time.ZoneId -import java.util.Date +import java.util.* class AnalysisViewModel(application: Application) : AndroidViewModel(application) { private val recordDao = BookkeepingDatabase.getDatabase(application).bookkeepingDao() private val memberDao = BookkeepingDatabase.getDatabase(application).memberDao() - private val _selectedMonth = MutableStateFlow(YearMonth.now()) - val selectedMonth = _selectedMonth.asStateFlow() + private val _startMonth = MutableStateFlow(YearMonth.now()) + val startMonth: StateFlow = _startMonth.asStateFlow() + + private val _endMonth = MutableStateFlow(YearMonth.now()) + val endMonth: StateFlow = _endMonth.asStateFlow() private val _selectedAnalysisType = MutableStateFlow(AnalysisType.EXPENSE) - val selectedAnalysisType = _selectedAnalysisType.asStateFlow() + val selectedAnalysisType: StateFlow = _selectedAnalysisType.asStateFlow() - private val members = memberDao.getAllMembers() + private val _categoryStats = MutableStateFlow>(emptyList()) + val categoryStats: StateFlow> = _categoryStats.asStateFlow() - val memberStats = combine(selectedMonth, selectedAnalysisType, members) { month, type, membersList -> - 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.INCOME -> TransactionType.INCOME - else -> null + private val _memberStats = MutableStateFlow>(emptyList()) + val memberStats: StateFlow> = _memberStats.asStateFlow() + + init { + viewModelScope.launch { + combine(startMonth, endMonth, selectedAnalysisType) { start, end, type -> + Triple(start, end, type) + }.collect { (start, end, type) -> + updateStats(start, end, 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 } + fun setStartMonth(month: YearMonth) { + _startMonth.value = month + } - // 计算总额 - 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() - ) + fun setEndMonth(month: YearMonth) { + _endMonth.value = month + } - val categoryStats = combine(selectedMonth, selectedAnalysisType) { month, type -> + fun setAnalysisType(type: AnalysisType) { + _selectedAnalysisType.value = type + } + + private suspend fun updateStats(startMonth: YearMonth, endMonth: YearMonth, type: AnalysisType) { 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) { + val yearMonth = YearMonth.from(localDateTime) + yearMonth.isAfter(startMonth.minusMonths(1)) && + yearMonth.isBefore(endMonth.plusMonths(1)) && + it.type == when(type) { AnalysisType.EXPENSE -> TransactionType.EXPENSE AnalysisType.INCOME -> TransactionType.INCOME else -> null @@ -75,7 +73,7 @@ class AnalysisViewModel(application: Application) : AndroidViewModel(application // 按分类统计 val categoryMap = monthRecords.groupBy { it.category } - val stats = categoryMap.map { (category, records) -> + val categoryStats = categoryMap.map { (category, records) -> CategoryStat( category = category, amount = records.sumOf { it.amount }, @@ -83,22 +81,33 @@ class AnalysisViewModel(application: Application) : AndroidViewModel(application ) }.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) + } + + // 按成员统计 + val members = memberDao.getAllMembers().first() + val memberMap = monthRecords.groupBy { record -> + members.find { it.id == record.memberId }?.name ?: "未分配" + } - // 计算百分比 - 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 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) { - _selectedAnalysisType.value = type + _categoryStats.value = categoryStatsWithPercentage + _memberStats.value = memberStatsWithPercentage } }