From 1147bc47d73a9a0e8605511979de7b9d1f752485 Mon Sep 17 00:00:00 2001 From: yovinchen Date: Wed, 27 Nov 2024 16:07:10 +0800 Subject: [PATCH 01/34] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=AD=A6=E5=91=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../yovinchen/bookkeeping/ui/components/MonthlyStatistics.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/java/com/yovinchen/bookkeeping/ui/components/MonthlyStatistics.kt b/app/src/main/java/com/yovinchen/bookkeeping/ui/components/MonthlyStatistics.kt index a425ff8..fcd1e3e 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/ui/components/MonthlyStatistics.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/ui/components/MonthlyStatistics.kt @@ -1,5 +1,6 @@ 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.* @@ -123,6 +124,7 @@ fun MonthYearPickerDialog( } } +@SuppressLint("DefaultLocale") @Composable fun MonthlyStatistics( totalIncome: Double, From 3ad8cf9184d404528c7da5cdc0dc6ff9955a91c9 Mon Sep 17 00:00:00 2001 From: yovinchen Date: Wed, 27 Nov 2024 16:07:37 +0800 Subject: [PATCH 02/34] =?UTF-8?q?Revert=20"=E4=BF=AE=E5=A4=8D=E8=AD=A6?= =?UTF-8?q?=E5=91=8A"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 1147bc47d73a9a0e8605511979de7b9d1f752485. --- .../yovinchen/bookkeeping/ui/components/MonthlyStatistics.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/src/main/java/com/yovinchen/bookkeeping/ui/components/MonthlyStatistics.kt b/app/src/main/java/com/yovinchen/bookkeeping/ui/components/MonthlyStatistics.kt index fcd1e3e..a425ff8 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/ui/components/MonthlyStatistics.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/ui/components/MonthlyStatistics.kt @@ -1,6 +1,5 @@ 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.* @@ -124,7 +123,6 @@ fun MonthYearPickerDialog( } } -@SuppressLint("DefaultLocale") @Composable fun MonthlyStatistics( totalIncome: Double, From af880c23eb4fee0b466fed8f9f48cbaba36f7067 Mon Sep 17 00:00:00 2001 From: yovinchen Date: Wed, 27 Nov 2024 17:49:47 +0800 Subject: [PATCH 03/34] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=88=86=E6=9E=90?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=EF=BC=8C=E5=AE=8C=E5=96=84=E5=A4=A7=E4=BD=93?= =?UTF-8?q?=E5=B1=95=E7=A4=BA=E5=86=85=E5=AE=B9=20-=20=E9=A1=B6=E9=83=A8?= =?UTF-8?q?=E6=9C=88=E4=BB=BD=E9=80=89=E6=8B=A9=E5=99=A8=EF=BC=9A=E5=8F=AF?= =?UTF-8?q?=E4=BB=A5=E5=89=8D=E5=90=8E=E5=88=87=E6=8D=A2=E6=9C=88=E4=BB=BD?= =?UTF-8?q?=E6=88=96=E7=9B=B4=E6=8E=A5=E9=80=89=E6=8B=A9=E5=85=B7=E4=BD=93?= =?UTF-8?q?=E6=9C=88=E4=BB=BD=20-=20=E5=88=86=E6=9E=90=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?=E5=88=87=E6=8D=A2=EF=BC=9A=E6=94=AF=E5=87=BA=E5=88=86=E6=9E=90?= =?UTF-8?q?/=E6=94=B6=E5=85=A5=E5=88=86=E6=9E=90/=E6=94=B6=E6=94=AF?= =?UTF-8?q?=E8=B6=8B=E5=8A=BF=20-=20=E6=95=B0=E6=8D=AE=E5=8F=AF=E8=A7=86?= =?UTF-8?q?=E5=8C=96=EF=BC=9A=20-=20=E4=BD=BF=E7=94=A8=E9=A5=BC=E5=9B=BE?= =?UTF-8?q?=E5=B1=95=E7=A4=BA=E5=90=84=E5=88=86=E7=B1=BB=E5=8D=A0=E6=AF=94?= =?UTF-8?q?=20-=20=E4=BD=BF=E7=94=A8=E5=88=97=E8=A1=A8=E5=B1=95=E7=A4=BA?= =?UTF-8?q?=E8=AF=A6=E7=BB=86=E6=95=B0=E6=8D=AE=EF=BC=8C=E5=8C=85=E6=8B=AC?= =?UTF-8?q?=E9=87=91=E9=A2=9D=E3=80=81=E7=99=BE=E5=88=86=E6=AF=94=E5=92=8C?= =?UTF-8?q?=E8=BF=9B=E5=BA=A6=E6=9D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 15 +- app/build.gradle.kts | 4 + .../bookkeeping/model/BookkeepingRecord.kt | 4 + .../ui/components/CategoryPieChart.kt | 56 +++++ .../ui/components/MonthYearPicker.kt | 88 +++++++ .../ui/navigation/MainNavigation.kt | 65 +++--- .../bookkeeping/ui/screen/AnalysisScreen.kt | 214 ++++++++++++++++++ .../viewmodel/AnalysisViewModel.kt | 72 ++++++ gradle/libs.versions.toml | 2 + settings.gradle.kts | 2 + 10 files changed, 476 insertions(+), 46 deletions(-) create mode 100644 app/src/main/java/com/yovinchen/bookkeeping/ui/components/CategoryPieChart.kt create mode 100644 app/src/main/java/com/yovinchen/bookkeeping/ui/components/MonthYearPicker.kt create mode 100644 app/src/main/java/com/yovinchen/bookkeeping/ui/screen/AnalysisScreen.kt create mode 100644 app/src/main/java/com/yovinchen/bookkeeping/viewmodel/AnalysisViewModel.kt diff --git a/README.md b/README.md index f8ad555..1e03c8d 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ ## 🗺 开发路线图 -### 1. 基础记账 (已完成 ✨) +### 0. 基础记账 (已完成 ✨) - [x] 收入/支出记录管理 - [x] 分类管理系统 - [x] 自定义日期选择器 @@ -33,41 +33,40 @@ - [x] 深色/浅色主题切换 - [x] 主题色自定义 -### 2. 成员系统 (已完成 🎉) +### 1. 成员系统 (已完成 🎉) - [x] 成员添加/编辑/删除 - [x] 记账时选择相关成员 - [x] 主页账单修改相关成员 - [x] 成员消费统计 -### 3. 数据分析 (进行中 🚀) +### 2. 图表分析 (进行中 🚀) - [ ] 支出/收入趋势图表 - [ ] 分类占比饼图 - [ ] 月度/年度报表 - [ ] 成员消费分析 - [ ] 自定义统计周期 -### 4. 数据管理 (计划中 📝) +### 3. 数据管理 (计划中 📝) - [ ] 导出 CSV/Excel 功能 -- [ ] 云端备份支持 - [ ] 数据迁移工具 - [ ] 定期自动备份 - [ ] 备份加密功能 -### 5. 预算管理 (计划中 💡) +### 4. 预算管理 (计划中 💡) - [ ] 月度预算设置 - [ ] 预算超支提醒 - [ ] 分类预算管理 - [ ] 成员预算管理 - [ ] 预算分析报告 -### 6. 体验优化 (持续进行 🔄) +### 5. 体验优化 (持续进行 🔄) - [x] 深色模式支持 - [ ] 手势操作优化 - [ ] 快速记账小组件 - [ ] 多语言支持 - [ ] 自定义主题 -### 7. 性能提升 (持续进行 ⚡️) +### 6. 性能提升 (持续进行 ⚡️) - [ ] 大数据量处理优化 - [ ] 启动速度优化 - [ ] 内存使用优化 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e3d89d3..2920943 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -89,6 +89,7 @@ dependencies { implementation(libs.androidx.room.common) implementation(libs.androidx.navigation.common.ktx) implementation(libs.androidx.navigation.compose) + implementation(libs.vision.internal.vkp) // Room val roomVersion = "2.6.1" @@ -96,6 +97,9 @@ dependencies { implementation("androidx.room:room-ktx:$roomVersion") ksp("androidx.room:room-compiler:$roomVersion") + // 图表库 + implementation("com.github.PhilJay:MPAndroidChart:v3.1.0") + implementation("androidx.compose.material:material-icons-extended:1.4.3") testImplementation("junit:junit:4.13.2") androidTestImplementation("androidx.test.ext:junit:1.1.5") androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") diff --git a/app/src/main/java/com/yovinchen/bookkeeping/model/BookkeepingRecord.kt b/app/src/main/java/com/yovinchen/bookkeeping/model/BookkeepingRecord.kt index 07d90f8..5ec3bd4 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/model/BookkeepingRecord.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/model/BookkeepingRecord.kt @@ -2,6 +2,7 @@ package com.yovinchen.bookkeeping.model import androidx.room.Entity import androidx.room.ForeignKey +import androidx.room.Index import androidx.room.PrimaryKey import androidx.room.TypeConverter import androidx.room.TypeConverters @@ -43,6 +44,9 @@ class Converters { childColumns = ["memberId"], onDelete = ForeignKey.SET_NULL ) + ], + indices = [ + Index(value = ["memberId"]) ] ) @TypeConverters(Converters::class) diff --git a/app/src/main/java/com/yovinchen/bookkeeping/ui/components/CategoryPieChart.kt b/app/src/main/java/com/yovinchen/bookkeeping/ui/components/CategoryPieChart.kt new file mode 100644 index 0000000..0dc7325 --- /dev/null +++ b/app/src/main/java/com/yovinchen/bookkeeping/ui/components/CategoryPieChart.kt @@ -0,0 +1,56 @@ +package com.yovinchen.bookkeeping.ui.components + +import android.graphics.Color +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import com.github.mikephil.charting.charts.PieChart +import com.github.mikephil.charting.data.PieData +import com.github.mikephil.charting.data.PieDataSet +import com.github.mikephil.charting.data.PieEntry +import com.github.mikephil.charting.formatter.PercentFormatter +import com.github.mikephil.charting.utils.ColorTemplate + +@Composable +fun CategoryPieChart( + categoryData: List>, + modifier: Modifier = Modifier +) { + AndroidView( + modifier = modifier + .fillMaxWidth() + .height(300.dp), + factory = { context -> + PieChart(context).apply { + description.isEnabled = false + setUsePercentValues(true) + setDrawEntryLabels(true) + legend.isEnabled = true + isDrawHoleEnabled = true + holeRadius = 40f + setHoleColor(Color.TRANSPARENT) + setTransparentCircleRadius(45f) + } + }, + update = { chart -> + val entries = categoryData.map { (category, amount) -> + PieEntry(amount, category) + } + + val dataSet = PieDataSet(entries, "分类占比").apply { + colors = ColorTemplate.MATERIAL_COLORS.toList() + valueTextSize = 14f + valueFormatter = PercentFormatter(chart) + valueTextColor = Color.WHITE + setDrawValues(true) + } + + val pieData = PieData(dataSet) + chart.data = pieData + chart.invalidate() + } + ) +} diff --git a/app/src/main/java/com/yovinchen/bookkeeping/ui/components/MonthYearPicker.kt b/app/src/main/java/com/yovinchen/bookkeeping/ui/components/MonthYearPicker.kt new file mode 100644 index 0000000..45da91c --- /dev/null +++ b/app/src/main/java/com/yovinchen/bookkeeping/ui/components/MonthYearPicker.kt @@ -0,0 +1,88 @@ +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 + +@Composable +fun MonthYearPicker( + selectedMonth: YearMonth, + onMonthSelected: (YearMonth) -> Unit, + onDismiss: () -> Unit +) { + var year by remember { mutableStateOf(selectedMonth.year) } + var month by remember { mutableStateOf(selectedMonth.monthValue) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("选择月份") }, + text = { + Column( + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // 年份选择 + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text("年份:") + OutlinedButton( + onClick = { year-- } + ) { + Text("-") + } + Text(year.toString()) + OutlinedButton( + onClick = { year++ } + ) { + Text("+") + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // 月份选择 + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text("月份:") + OutlinedButton( + onClick = { + if (month > 1) month-- + } + ) { + Text("-") + } + Text(month.toString()) + OutlinedButton( + onClick = { + if (month < 12) month++ + } + ) { + Text("+") + } + } + } + }, + confirmButton = { + TextButton( + onClick = { + onMonthSelected(YearMonth.of(year, month)) + } + ) { + Text("确定") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("取消") + } + } + ) +} diff --git a/app/src/main/java/com/yovinchen/bookkeeping/ui/navigation/MainNavigation.kt b/app/src/main/java/com/yovinchen/bookkeeping/ui/navigation/MainNavigation.kt index a8d05d3..71f714e 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/ui/navigation/MainNavigation.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/ui/navigation/MainNavigation.kt @@ -4,38 +4,35 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.outlined.Analytics import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem -import androidx.compose.material3.NavigationBarItemDefaults import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.navigation.NavDestination.Companion.hierarchy +import androidx.compose.ui.graphics.vector.ImageVector import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import com.yovinchen.bookkeeping.model.ThemeMode +import com.yovinchen.bookkeeping.ui.screen.AnalysisScreen import com.yovinchen.bookkeeping.ui.screen.HomeScreen import com.yovinchen.bookkeeping.ui.screen.SettingsScreen -sealed class Screen(val route: String, val icon: @Composable () -> Unit, val label: String) { - object Home : Screen( - route = "home", - icon = { Icon(Icons.Default.Home, contentDescription = "主页") }, - label = "主页" - ) - object Settings : Screen( - route = "settings", - icon = { Icon(Icons.Default.Settings, contentDescription = "设置") }, - label = "设置" - ) +sealed class Screen( + val route: String, + val icon: ImageVector, + val label: String +) { + data object Home : Screen("home", Icons.Default.Home, "主页") + data object Analysis : Screen("analysis", Icons.Outlined.Analytics, "分析") + data object Settings : Screen("settings", Icons.Default.Settings, "设置") } @OptIn(ExperimentalMaterial3Api::class) @@ -45,22 +42,22 @@ fun MainNavigation( onThemeChange: (ThemeMode) -> Unit ) { val navController = rememberNavController() - val items = listOf(Screen.Home, Screen.Settings) Scaffold( bottomBar = { - NavigationBar( - containerColor = MaterialTheme.colorScheme.surface, - contentColor = MaterialTheme.colorScheme.onSurface, - ) { + NavigationBar { val navBackStackEntry by navController.currentBackStackEntryAsState() - val currentDestination = navBackStackEntry?.destination + val currentRoute = navBackStackEntry?.destination?.route - items.forEach { screen -> + listOf( + Screen.Home, + Screen.Analysis, + Screen.Settings + ).forEach { screen -> NavigationBarItem( - icon = screen.icon, + icon = { Icon(screen.icon, contentDescription = screen.label) }, label = { Text(screen.label) }, - selected = currentDestination?.hierarchy?.any { it.route == screen.route } == true, + selected = currentRoute == screen.route, onClick = { navController.navigate(screen.route) { popUpTo(navController.graph.findStartDestination().id) { @@ -69,32 +66,24 @@ fun MainNavigation( launchSingleTop = true restoreState = true } - }, - colors = NavigationBarItemDefaults.colors( - selectedIconColor = MaterialTheme.colorScheme.primary, - selectedTextColor = MaterialTheme.colorScheme.primary, - unselectedIconColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), - unselectedTextColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), - indicatorColor = MaterialTheme.colorScheme.surfaceVariant - ) + } ) } } } - ) { paddingValues -> + ) { innerPadding -> NavHost( navController = navController, startDestination = Screen.Home.route, - modifier = Modifier.padding(paddingValues) + modifier = Modifier.padding(innerPadding) ) { - composable(Screen.Home.route) { - HomeScreen() - } - composable(Screen.Settings.route) { + composable(Screen.Home.route) { HomeScreen() } + composable(Screen.Analysis.route) { AnalysisScreen() } + composable(Screen.Settings.route) { SettingsScreen( currentTheme = currentTheme, onThemeChange = onThemeChange - ) + ) } } } 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 new file mode 100644 index 0000000..8e35a7e --- /dev/null +++ b/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/AnalysisScreen.kt @@ -0,0 +1,214 @@ +package com.yovinchen.bookkeeping.ui.screen + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.yovinchen.bookkeeping.ui.components.CategoryPieChart +import com.yovinchen.bookkeeping.ui.components.MonthYearPicker +import com.yovinchen.bookkeeping.viewmodel.AnalysisType +import com.yovinchen.bookkeeping.viewmodel.AnalysisViewModel +import com.yovinchen.bookkeeping.viewmodel.CategoryStat +import java.time.YearMonth + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AnalysisScreen( + modifier: Modifier = Modifier, + viewModel: AnalysisViewModel = viewModel() +) { + val selectedMonth by viewModel.selectedMonth.collectAsState() + val selectedType by viewModel.selectedAnalysisType.collectAsState() + val categoryStats by viewModel.categoryStats.collectAsState() + var showMonthPicker by remember { mutableStateOf(false) } + + Column( + modifier = modifier + .fillMaxSize() + .padding(16.dp) + ) { + // 月份选择器 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + IconButton(onClick = { + viewModel.setSelectedMonth(selectedMonth.minusMonths(1)) + }) { + Icon(Icons.AutoMirrored.Filled.KeyboardArrowLeft, "上个月") + } + + Text( + text = "${selectedMonth.year}年${selectedMonth.monthValue}月", + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.clickable { showMonthPicker = true } + ) + + IconButton(onClick = { + viewModel.setSelectedMonth(selectedMonth.plusMonths(1)) + }) { + Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, "下个月") + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // 分析类型选择 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + AnalysisType.values().forEach { type -> + FilterChip( + selected = selectedType == type, + onClick = { viewModel.setAnalysisType(type) }, + label = { + Text( + when (type) { + AnalysisType.EXPENSE -> "支出分析" + AnalysisType.INCOME -> "收入分析" + AnalysisType.TREND -> "收支趋势" + } + ) + } + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // 统计内容 + when (selectedType) { + AnalysisType.EXPENSE, AnalysisType.INCOME -> { + Text( + text = if (selectedType == AnalysisType.EXPENSE) "支出分析" else "收入分析", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(vertical = 8.dp) + ) + + if (categoryStats.isEmpty()) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(300.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "暂无数据", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else { + // 添加饼图 + CategoryPieChart( + categoryData = categoryStats.map { + it.category to it.amount.toFloat() + } + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + // 分类列表 + LazyColumn { + items(categoryStats) { stat -> + CategoryStatItem(stat) + } + } + } + AnalysisType.TREND -> { + // TODO: 实现收支趋势图表 + Text( + text = "收支趋势", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(vertical = 8.dp) + ) + } + } + } + + if (showMonthPicker) { + MonthYearPicker( + selectedMonth = selectedMonth, + onMonthSelected = { yearMonth -> + viewModel.setSelectedMonth(yearMonth) + showMonthPicker = false + }, + onDismiss = { showMonthPicker = false } + ) + } +} + +@Composable +fun CategoryStatItem(stat: CategoryStat) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stat.category, + style = MaterialTheme.typography.titleMedium + ) + Text( + text = String.format("%.1f%%", stat.percentage), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + // 进度条 + LinearProgressIndicator( + progress = { (stat.percentage / 100).toFloat() }, + modifier = Modifier + .fillMaxWidth() + .height(8.dp), + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = "¥${String.format("%.2f", stat.amount)}", + style = MaterialTheme.typography.bodyMedium + ) + Text( + text = "${stat.count}笔", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.outline + ) + } + } + } +} diff --git a/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/AnalysisViewModel.kt b/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/AnalysisViewModel.kt new file mode 100644 index 0000000..5288525 --- /dev/null +++ b/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/AnalysisViewModel.kt @@ -0,0 +1,72 @@ +package com.yovinchen.bookkeeping.viewmodel + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.yovinchen.bookkeeping.data.BookkeepingDatabase +import com.yovinchen.bookkeeping.model.TransactionType +import kotlinx.coroutines.flow.* +import java.time.LocalDateTime +import java.time.YearMonth +import java.time.ZoneId + +class AnalysisViewModel(application: Application) : AndroidViewModel(application) { + private val recordDao = BookkeepingDatabase.getDatabase(application).bookkeepingDao() + + private val _selectedMonth = MutableStateFlow(YearMonth.now()) + val selectedMonth = _selectedMonth.asStateFlow() + + private val _selectedAnalysisType = MutableStateFlow(AnalysisType.EXPENSE) + val selectedAnalysisType = _selectedAnalysisType.asStateFlow() + + val categoryStats = combine(selectedMonth, selectedAnalysisType) { month, type -> + val records = recordDao.getAllRecords().first() + val monthRecords = records.filter { + val recordDate = LocalDateTime.ofInstant(it.date.toInstant(), ZoneId.systemDefault()) + YearMonth.from(recordDate) == month && it.type == when(type) { + AnalysisType.EXPENSE -> TransactionType.EXPENSE + AnalysisType.INCOME -> TransactionType.INCOME + else -> null + } + } + + // 按分类统计 + val categoryMap = monthRecords.groupBy { it.category } + val stats = categoryMap.map { (category, records) -> + CategoryStat( + category = category, + 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() + ) + + fun setSelectedMonth(month: YearMonth) { + _selectedMonth.value = month + } + + fun setAnalysisType(type: AnalysisType) { + _selectedAnalysisType.value = type + } +} + +enum class AnalysisType { + EXPENSE, INCOME, TREND +} + +data class CategoryStat( + val category: String, + val amount: Double, + val count: Int, + val percentage: Double = 0.0 +) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3efafc9..3ec9b45 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,6 +11,7 @@ composeBom = "2024.04.01" roomCommon = "2.6.1" navigationCommonKtx = "2.8.4" navigationCompose = "2.8.4" +visionInternalVkp = "18.2.3" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -30,6 +31,7 @@ androidx-material3 = { group = "androidx.compose.material3", name = "material3" androidx-room-common = { group = "androidx.room", name = "room-common", version.ref = "roomCommon" } androidx-navigation-common-ktx = { group = "androidx.navigation", name = "navigation-common-ktx", version.ref = "navigationCommonKtx" } androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } +vision-internal-vkp = { group = "com.google.mlkit", name = "vision-internal-vkp", version.ref = "visionInternalVkp" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } diff --git a/settings.gradle.kts b/settings.gradle.kts index c595d41..711d76a 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -9,6 +9,7 @@ pluginManagement { } mavenCentral() gradlePluginPortal() + maven { url = uri("https://jitpack.io") } } } dependencyResolutionManagement { @@ -16,6 +17,7 @@ dependencyResolutionManagement { repositories { google() mavenCentral() + maven { url = uri("https://jitpack.io") } } } From 47e202fa617bc6f854e27a5b6b48bfdeaac4e5af Mon Sep 17 00:00:00 2001 From: yovinchen Date: Wed, 27 Nov 2024 18:07:41 +0800 Subject: [PATCH 04/34] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E9=A5=BC?= =?UTF-8?q?=E5=9B=BE=E5=9C=A8=E6=B5=85=E8=89=B2=E6=A8=A1=E5=BC=8F=E4=B8=8B?= =?UTF-8?q?=E5=9B=BE=E4=BE=8B=E6=96=87=E5=AD=97=E9=A2=9C=E8=89=B2=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 使用 Material Theme 的 onSurface 颜色来设置图例文字颜色 - 确保文字颜色正确跟随系统主题 - 优化代码结构和注释 --- .../ui/components/CategoryPieChart.kt | 33 ++++++++++++++++--- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/yovinchen/bookkeeping/ui/components/CategoryPieChart.kt b/app/src/main/java/com/yovinchen/bookkeeping/ui/components/CategoryPieChart.kt index 0dc7325..7410719 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/ui/components/CategoryPieChart.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/ui/components/CategoryPieChart.kt @@ -1,13 +1,17 @@ package com.yovinchen.bookkeeping.ui.components -import android.graphics.Color +import android.graphics.Color as AndroidColor +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import com.github.mikephil.charting.charts.PieChart +import com.github.mikephil.charting.components.Legend import com.github.mikephil.charting.data.PieData import com.github.mikephil.charting.data.PieDataSet import com.github.mikephil.charting.data.PieEntry @@ -19,6 +23,9 @@ fun CategoryPieChart( categoryData: List>, modifier: Modifier = Modifier ) { + val isDarkTheme = isSystemInDarkTheme() + val textColor = MaterialTheme.colorScheme.onSurface.toArgb() + AndroidView( modifier = modifier .fillMaxWidth() @@ -28,11 +35,29 @@ fun CategoryPieChart( description.isEnabled = false setUsePercentValues(true) setDrawEntryLabels(true) - legend.isEnabled = true + + // 配置图例 + legend.apply { + isEnabled = true + this.textColor = textColor // 使用Material Theme的文字颜色 + textSize = 12f + form = Legend.LegendForm.CIRCLE + formSize = 12f + formToTextSpace = 8f + xEntrySpace = 16f + } + isDrawHoleEnabled = true holeRadius = 40f - setHoleColor(Color.TRANSPARENT) + setHoleColor(AndroidColor.TRANSPARENT) setTransparentCircleRadius(45f) + + // 设置标签文字颜色为白色(因为标签在彩色扇形上) + setEntryLabelColor(AndroidColor.WHITE) + setEntryLabelTextSize(12f) + + // 设置中心文字颜色跟随主题 + setCenterTextColor(textColor) } }, update = { chart -> @@ -44,7 +69,7 @@ fun CategoryPieChart( colors = ColorTemplate.MATERIAL_COLORS.toList() valueTextSize = 14f valueFormatter = PercentFormatter(chart) - valueTextColor = Color.WHITE + valueTextColor = AndroidColor.WHITE // 扇形上的数值文字保持白色 setDrawValues(true) } From 71deaaa2884ef8af887653cd166559feb9f82e86 Mon Sep 17 00:00:00 2001 From: yovinchen Date: Thu, 28 Nov 2024 09:10:03 +0800 Subject: [PATCH 05/34] =?UTF-8?q?style:=20=E7=AE=80=E5=8C=96=E9=A5=BC?= =?UTF-8?q?=E5=9B=BE=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 禁用饼图的图例显示 - 移除图例相关的配置代码 - 将 PieDataSet 的标题设置为空字符串 - 优化界面简洁度 --- .../bookkeeping/ui/components/CategoryPieChart.kt | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/yovinchen/bookkeeping/ui/components/CategoryPieChart.kt b/app/src/main/java/com/yovinchen/bookkeeping/ui/components/CategoryPieChart.kt index 7410719..caac939 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/ui/components/CategoryPieChart.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/ui/components/CategoryPieChart.kt @@ -36,16 +36,8 @@ fun CategoryPieChart( setUsePercentValues(true) setDrawEntryLabels(true) - // 配置图例 - legend.apply { - isEnabled = true - this.textColor = textColor // 使用Material Theme的文字颜色 - textSize = 12f - form = Legend.LegendForm.CIRCLE - formSize = 12f - formToTextSpace = 8f - xEntrySpace = 16f - } + // 禁用图例显示 + legend.isEnabled = false isDrawHoleEnabled = true holeRadius = 40f @@ -65,7 +57,7 @@ fun CategoryPieChart( PieEntry(amount, category) } - val dataSet = PieDataSet(entries, "分类占比").apply { + val dataSet = PieDataSet(entries, "").apply { // 将标题设为空字符串 colors = ColorTemplate.MATERIAL_COLORS.toList() valueTextSize = 14f valueFormatter = PercentFormatter(chart) From 025b0aade0e96c95862cc7143ab9779533e84d2f Mon Sep 17 00:00:00 2001 From: yovinchen Date: Thu, 28 Nov 2024 09:20:09 +0800 Subject: [PATCH 06/34] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E6=B7=B1?= =?UTF-8?q?=E8=89=B2=E6=A8=A1=E5=BC=8F=E4=B8=8B=E5=BC=80=E5=B1=8F=E7=99=BD?= =?UTF-8?q?=E5=B1=8F=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 values-night/themes.xml 中设置窗口背景色为黑色 - 在 values/themes.xml 中明确设置窗口背景色为白色 - 修改主题文件确保在不同主题模式下有正确的背景色 --- app/src/main/res/values-night/themes.xml | 6 ++++++ app/src/main/res/values/themes.xml | 5 +++-- 2 files changed, 9 insertions(+), 2 deletions(-) create mode 100644 app/src/main/res/values-night/themes.xml diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml new file mode 100644 index 0000000..1816f34 --- /dev/null +++ b/app/src/main/res/values-night/themes.xml @@ -0,0 +1,6 @@ + + + + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index b3a2f57..fd07bfd 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,5 +1,6 @@ - - \ No newline at end of file From 3c080fbc050753939b11082af59d8b3351c6c3fe Mon Sep 17 00:00:00 2001 From: yovinchen Date: Thu, 28 Nov 2024 10:51:01 +0800 Subject: [PATCH 07/34] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E6=9C=88?= =?UTF-8?q?=E4=BB=BD=E9=80=89=E6=8B=A9=E5=99=A8=E5=8F=82=E6=95=B0=E9=94=99?= =?UTF-8?q?=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将 MonthYearPicker 的 initialMonth 参数改为 selectedMonth - 保持与组件定义一致 --- .../bookkeeping/ui/screen/AnalysisScreen.kt | 270 +++++++++--------- 1 file changed, 134 insertions(+), 136 deletions(-) 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 8e35a7e..a15d6c7 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 @@ -35,109 +35,115 @@ fun AnalysisScreen( val categoryStats by viewModel.categoryStats.collectAsState() var showMonthPicker by remember { mutableStateOf(false) } - Column( + LazyColumn( modifier = modifier .fillMaxSize() .padding(16.dp) ) { // 月份选择器 - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - IconButton(onClick = { - viewModel.setSelectedMonth(selectedMonth.minusMonths(1)) - }) { - Icon(Icons.AutoMirrored.Filled.KeyboardArrowLeft, "上个月") - } + item { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + IconButton(onClick = { + viewModel.setSelectedMonth(selectedMonth.minusMonths(1)) + }) { + Icon(Icons.AutoMirrored.Filled.KeyboardArrowLeft, "上个月") + } - Text( - text = "${selectedMonth.year}年${selectedMonth.monthValue}月", - style = MaterialTheme.typography.titleLarge, - modifier = Modifier.clickable { showMonthPicker = true } - ) - - IconButton(onClick = { - viewModel.setSelectedMonth(selectedMonth.plusMonths(1)) - }) { - Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, "下个月") - } - } - - Spacer(modifier = Modifier.height(16.dp)) - - // 分析类型选择 - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceEvenly - ) { - AnalysisType.values().forEach { type -> - FilterChip( - selected = selectedType == type, - onClick = { viewModel.setAnalysisType(type) }, - label = { - Text( - when (type) { - AnalysisType.EXPENSE -> "支出分析" - AnalysisType.INCOME -> "收入分析" - AnalysisType.TREND -> "收支趋势" - } - ) - } - ) - } - } - - Spacer(modifier = Modifier.height(16.dp)) - - // 统计内容 - when (selectedType) { - AnalysisType.EXPENSE, AnalysisType.INCOME -> { Text( - text = if (selectedType == AnalysisType.EXPENSE) "支出分析" else "收入分析", - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(vertical = 8.dp) + text = "${selectedMonth.year}年${selectedMonth.monthValue}月", + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.clickable { showMonthPicker = true } ) - if (categoryStats.isEmpty()) { - Box( - modifier = Modifier - .fillMaxWidth() - .height(300.dp), - contentAlignment = Alignment.Center - ) { - Text( - text = "暂无数据", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } else { - // 添加饼图 - CategoryPieChart( - categoryData = categoryStats.map { - it.category to it.amount.toFloat() + IconButton(onClick = { + viewModel.setSelectedMonth(selectedMonth.plusMonths(1)) + }) { + Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, "下个月") + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // 分析类型选择 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + AnalysisType.values().forEach { type -> + FilterChip( + selected = selectedType == type, + onClick = { viewModel.setAnalysisType(type) }, + label = { + Text( + when (type) { + AnalysisType.EXPENSE -> "支出分析" + AnalysisType.INCOME -> "收入分析" + AnalysisType.TREND -> "收支趋势" + } + ) } ) } + } - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(16.dp)) - // 分类列表 - LazyColumn { - items(categoryStats) { stat -> - CategoryStatItem(stat) + // 统计内容 + when (selectedType) { + AnalysisType.EXPENSE, AnalysisType.INCOME -> { + Text( + text = if (selectedType == AnalysisType.EXPENSE) "支出分析" else "收入分析", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(vertical = 8.dp) + ) + + if (categoryStats.isNotEmpty()) { + val pieChartData = categoryStats.map { stat -> + stat.category to stat.percentage.toFloat() + } + CategoryPieChart( + categoryData = pieChartData, + modifier = Modifier + .fillMaxWidth() + .height(300.dp) + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "分类明细", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(vertical = 8.dp) + ) + } else { + Text( + text = "暂无数据", + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(32.dp) + ) } } + AnalysisType.TREND -> { + Text( + text = "收支趋势分析(开发中)", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(vertical = 8.dp) + ) + } } - AnalysisType.TREND -> { - // TODO: 实现收支趋势图表 - Text( - text = "收支趋势", - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(vertical = 8.dp) - ) + } + + // 分类统计列表 + if (selectedType != AnalysisType.TREND && categoryStats.isNotEmpty()) { + items(categoryStats) { stat -> + CategoryStatItem(stat) } } } @@ -145,8 +151,8 @@ fun AnalysisScreen( if (showMonthPicker) { MonthYearPicker( selectedMonth = selectedMonth, - onMonthSelected = { yearMonth -> - viewModel.setSelectedMonth(yearMonth) + onMonthSelected = { month -> + viewModel.setSelectedMonth(month) showMonthPicker = false }, onDismiss = { showMonthPicker = false } @@ -156,59 +162,51 @@ fun AnalysisScreen( @Composable fun CategoryStatItem(stat: CategoryStat) { - Card( + Column( modifier = Modifier .fillMaxWidth() - .padding(vertical = 4.dp), - elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + .padding(vertical = 8.dp) ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = stat.category, - style = MaterialTheme.typography.titleMedium - ) - Text( - text = String.format("%.1f%%", stat.percentage), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary - ) - } - - Spacer(modifier = Modifier.height(8.dp)) - - // 进度条 - LinearProgressIndicator( - progress = { (stat.percentage / 100).toFloat() }, - modifier = Modifier - .fillMaxWidth() - .height(8.dp), + 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 ) - - Spacer(modifier = Modifier.height(8.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - text = "¥${String.format("%.2f", stat.amount)}", - style = MaterialTheme.typography.bodyMedium - ) - Text( - text = "${stat.count}笔", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.outline - ) - } } } } From 6c3b366d457af328fc8c56e2739ba6a59d1b66a4 Mon Sep 17 00:00:00 2001 From: yovinchen Date: Thu, 28 Nov 2024 10:52:08 +0800 Subject: [PATCH 08/34] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=A4=9A?= =?UTF-8?q?=E4=BD=99=E6=96=87=E5=AD=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/yovinchen/bookkeeping/ui/screen/AnalysisScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 a15d6c7..de05639 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 @@ -96,7 +96,7 @@ fun AnalysisScreen( when (selectedType) { AnalysisType.EXPENSE, AnalysisType.INCOME -> { Text( - text = if (selectedType == AnalysisType.EXPENSE) "支出分析" else "收入分析", + text = if (selectedType == AnalysisType.EXPENSE) "" else "", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(vertical = 8.dp) ) From 0a738fc7e151032eb00309f8be07a17248666275 Mon Sep 17 00:00:00 2001 From: yovinchen Date: Thu, 28 Nov 2024 11:15:57 +0800 Subject: [PATCH 09/34] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E8=AD=A6?= =?UTF-8?q?=E5=91=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../bookkeeping/data/BookkeepingDatabase.kt | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/com/yovinchen/bookkeeping/data/BookkeepingDatabase.kt b/app/src/main/java/com/yovinchen/bookkeeping/data/BookkeepingDatabase.kt index 7d74855..8275c61 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/data/BookkeepingDatabase.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/data/BookkeepingDatabase.kt @@ -32,9 +32,9 @@ abstract class BookkeepingDatabase : RoomDatabase() { private const val TAG = "BookkeepingDatabase" private val MIGRATION_1_2 = object : Migration(1, 2) { - override fun migrate(database: SupportSQLiteDatabase) { + override fun migrate(db: SupportSQLiteDatabase) { // 创建成员表 - database.execSQL(""" + db.execSQL(""" CREATE TABLE IF NOT EXISTS members ( id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name TEXT NOT NULL, @@ -43,20 +43,20 @@ abstract class BookkeepingDatabase : RoomDatabase() { """) // 插入默认成员 - database.execSQL(""" + db.execSQL(""" INSERT INTO members (name, description) VALUES ('自己', '默认成员') """) // 修改记账记录表,添加成员ID字段 - database.execSQL(""" + db.execSQL(""" ALTER TABLE bookkeeping_records ADD COLUMN memberId INTEGER DEFAULT NULL REFERENCES members(id) ON DELETE SET NULL """) // 更新现有记录,将其关联到默认成员 - database.execSQL(""" + db.execSQL(""" UPDATE bookkeeping_records SET memberId = (SELECT id FROM members WHERE name = '我自己') """) @@ -64,9 +64,9 @@ abstract class BookkeepingDatabase : RoomDatabase() { } private val MIGRATION_2_3 = object : Migration(2, 3) { - override fun migrate(database: SupportSQLiteDatabase) { + override fun migrate(db: SupportSQLiteDatabase) { // 重新创建记账记录表 - database.execSQL(""" + db.execSQL(""" CREATE TABLE IF NOT EXISTS bookkeeping_records_new ( id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, amount REAL NOT NULL, @@ -80,19 +80,19 @@ abstract class BookkeepingDatabase : RoomDatabase() { """) // 复制数据 - database.execSQL(""" + db.execSQL(""" INSERT INTO bookkeeping_records_new (id, amount, type, category, description, date, memberId) SELECT id, amount, type, category, description, date, memberId FROM bookkeeping_records """) // 删除旧表 - database.execSQL("DROP TABLE bookkeeping_records") + db.execSQL("DROP TABLE bookkeeping_records") // 重命名新表 - database.execSQL("ALTER TABLE bookkeeping_records_new RENAME TO bookkeeping_records") + db.execSQL("ALTER TABLE bookkeeping_records_new RENAME TO bookkeeping_records") // 重新创建分类表 - database.execSQL(""" + db.execSQL(""" CREATE TABLE IF NOT EXISTS categories_new ( id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name TEXT NOT NULL, @@ -101,16 +101,16 @@ abstract class BookkeepingDatabase : RoomDatabase() { """) // 复制分类数据 - database.execSQL(""" + db.execSQL(""" INSERT INTO categories_new (id, name, type) SELECT id, name, type FROM categories """) // 删除旧表 - database.execSQL("DROP TABLE categories") + db.execSQL("DROP TABLE categories") // 重命名新表 - database.execSQL("ALTER TABLE categories_new RENAME TO categories") + db.execSQL("ALTER TABLE categories_new RENAME TO categories") } } From 9772fd6e59de6cbc39c5c3bc62ef80cbf6cfa37d Mon Sep 17 00:00:00 2001 From: yovinchen Date: Thu, 28 Nov 2024 11:17:07 +0800 Subject: [PATCH 10/34] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E8=AD=A6?= =?UTF-8?q?=E5=91=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../bookkeeping/ui/screen/AnalysisScreen.kt | 36 ++++++++++++++----- 1 file changed, 27 insertions(+), 9 deletions(-) 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 de05639..1e1bd1c 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 @@ -1,19 +1,38 @@ package com.yovinchen.bookkeeping.ui.screen +import android.annotation.SuppressLint import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel @@ -22,9 +41,7 @@ import com.yovinchen.bookkeeping.ui.components.MonthYearPicker import com.yovinchen.bookkeeping.viewmodel.AnalysisType import com.yovinchen.bookkeeping.viewmodel.AnalysisViewModel import com.yovinchen.bookkeeping.viewmodel.CategoryStat -import java.time.YearMonth -@OptIn(ExperimentalMaterial3Api::class) @Composable fun AnalysisScreen( modifier: Modifier = Modifier, @@ -73,7 +90,7 @@ fun AnalysisScreen( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly ) { - AnalysisType.values().forEach { type -> + AnalysisType.entries.forEach { type -> FilterChip( selected = selectedType == type, onClick = { viewModel.setAnalysisType(type) }, @@ -160,6 +177,7 @@ fun AnalysisScreen( } } +@SuppressLint("DefaultLocale") @Composable fun CategoryStatItem(stat: CategoryStat) { Column( @@ -190,14 +208,14 @@ fun CategoryStatItem(stat: CategoryStat) { verticalAlignment = Alignment.CenterVertically ) { LinearProgressIndicator( - progress = stat.percentage.toFloat() / 100f, + 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)) From c3f108ab57fb4718b576034c450078d0b4ec1a40 Mon Sep 17 00:00:00 2001 From: yovinchen Date: Thu, 28 Nov 2024 11:17:20 +0800 Subject: [PATCH 11/34] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E8=AD=A6?= =?UTF-8?q?=E5=91=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/yovinchen/bookkeeping/ui/screen/AnalysisScreen.kt | 1 - 1 file changed, 1 deletion(-) 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 1e1bd1c..0d0dbe9 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 @@ -18,7 +18,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon import androidx.compose.material3.IconButton From 8339d3d5da0871c373e174d14b3e593cbee4efc7 Mon Sep 17 00:00:00 2001 From: yovinchen Date: Thu, 28 Nov 2024 14:21:32 +0800 Subject: [PATCH 12/34] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E7=B1=BB?= =?UTF-8?q?=E5=88=AB=E8=AF=A6=E6=83=85=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 新增类别详情相关组件和视图模型 2. 优化饼图显示效果 3. 完善导航系统 4. 改进数据查询接口 --- .../bookkeeping/data/BookkeepingDao.kt | 11 + .../bookkeeping/model/AnalysisType.kt | 7 + .../bookkeeping/model/CategoryStat.kt | 8 + .../ui/components/CategoryPieChart.kt | 14 +- .../ui/components/CategoryStatItem.kt | 71 +++++ .../bookkeeping/ui/components/RecordItem.kt | 9 +- .../ui/navigation/MainNavigation.kt | 45 ++- .../bookkeeping/ui/screen/AnalysisScreen.kt | 265 +++++------------- .../ui/screen/CategoryDetailScreen.kt | 147 ++++++++++ .../viewmodel/AnalysisViewModel.kt | 13 +- .../viewmodel/CategoryDetailViewModel.kt | 52 ++++ .../CategoryDetailViewModelFactory.kt | 20 ++ gradle.properties | 4 +- 13 files changed, 450 insertions(+), 216 deletions(-) create mode 100644 app/src/main/java/com/yovinchen/bookkeeping/model/AnalysisType.kt create mode 100644 app/src/main/java/com/yovinchen/bookkeeping/model/CategoryStat.kt create mode 100644 app/src/main/java/com/yovinchen/bookkeeping/ui/components/CategoryStatItem.kt create mode 100644 app/src/main/java/com/yovinchen/bookkeeping/ui/screen/CategoryDetailScreen.kt create mode 100644 app/src/main/java/com/yovinchen/bookkeeping/viewmodel/CategoryDetailViewModel.kt create mode 100644 app/src/main/java/com/yovinchen/bookkeeping/viewmodel/CategoryDetailViewModelFactory.kt diff --git a/app/src/main/java/com/yovinchen/bookkeeping/data/BookkeepingDao.kt b/app/src/main/java/com/yovinchen/bookkeeping/data/BookkeepingDao.kt index 78aea1a..9d5bba7 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/data/BookkeepingDao.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/data/BookkeepingDao.kt @@ -27,6 +27,17 @@ interface BookkeepingDao { @Query("SELECT SUM(amount) FROM bookkeeping_records WHERE type = :type AND (memberId = :memberId OR memberId IS NULL)") fun getTotalAmountByType(type: TransactionType, memberId: Int? = null): Flow + @Query(""" + SELECT * FROM bookkeeping_records + WHERE category = :category + AND strftime('%Y-%m', datetime(date/1000, 'unixepoch')) = :yearMonth + ORDER BY date DESC + """) + fun getRecordsByCategoryAndMonth( + category: String, + yearMonth: String + ): Flow> + @Insert suspend fun insertRecord(record: BookkeepingRecord): Long diff --git a/app/src/main/java/com/yovinchen/bookkeeping/model/AnalysisType.kt b/app/src/main/java/com/yovinchen/bookkeeping/model/AnalysisType.kt new file mode 100644 index 0000000..1220cd3 --- /dev/null +++ b/app/src/main/java/com/yovinchen/bookkeeping/model/AnalysisType.kt @@ -0,0 +1,7 @@ +package com.yovinchen.bookkeeping.model + +enum class AnalysisType { + EXPENSE, + INCOME, + TREND +} diff --git a/app/src/main/java/com/yovinchen/bookkeeping/model/CategoryStat.kt b/app/src/main/java/com/yovinchen/bookkeeping/model/CategoryStat.kt new file mode 100644 index 0000000..0411e50 --- /dev/null +++ b/app/src/main/java/com/yovinchen/bookkeeping/model/CategoryStat.kt @@ -0,0 +1,8 @@ +package com.yovinchen.bookkeeping.model + +data class CategoryStat( + val category: String, + val amount: Double, + val count: Int = 0, + val percentage: Double = 0.0 +) diff --git a/app/src/main/java/com/yovinchen/bookkeeping/ui/components/CategoryPieChart.kt b/app/src/main/java/com/yovinchen/bookkeeping/ui/components/CategoryPieChart.kt index caac939..e0853bc 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/ui/components/CategoryPieChart.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/ui/components/CategoryPieChart.kt @@ -35,19 +35,19 @@ fun CategoryPieChart( description.isEnabled = false setUsePercentValues(true) setDrawEntryLabels(true) - + // 禁用图例显示 legend.isEnabled = false - + isDrawHoleEnabled = true holeRadius = 40f setHoleColor(AndroidColor.TRANSPARENT) setTransparentCircleRadius(45f) - - // 设置标签文字颜色为白色(因为标签在彩色扇形上) - setEntryLabelColor(AndroidColor.WHITE) + + // 设置标签文字颜色 + setEntryLabelColor(textColor) setEntryLabelTextSize(12f) - + // 设置中心文字颜色跟随主题 setCenterTextColor(textColor) } @@ -61,7 +61,7 @@ fun CategoryPieChart( colors = ColorTemplate.MATERIAL_COLORS.toList() valueTextSize = 14f valueFormatter = PercentFormatter(chart) - valueTextColor = AndroidColor.WHITE // 扇形上的数值文字保持白色 + valueTextColor = textColor setDrawValues(true) } 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 new file mode 100644 index 0000000..a783421 --- /dev/null +++ b/app/src/main/java/com/yovinchen/bookkeeping/ui/components/CategoryStatItem.kt @@ -0,0 +1,71 @@ +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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.yovinchen.bookkeeping.model.CategoryStat + +@SuppressLint("DefaultLocale") +@Composable +fun CategoryStatItem( + stat: CategoryStat, + onClick: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .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)) + + 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 + ) + } + } +} diff --git a/app/src/main/java/com/yovinchen/bookkeeping/ui/components/RecordItem.kt b/app/src/main/java/com/yovinchen/bookkeeping/ui/components/RecordItem.kt index cad2220..0b41365 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/ui/components/RecordItem.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/ui/components/RecordItem.kt @@ -22,6 +22,7 @@ fun RecordItem( members: List = emptyList() ) { var showDeleteDialog by remember { mutableStateOf(false) } +// val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) } val timeFormat = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) } val member = members.find { it.id == record.memberId } @@ -48,14 +49,18 @@ fun RecordItem( style = MaterialTheme.typography.bodyLarge ) - // 第二行:时间 | 成员 | 详情 + // 第二行:日期和时间 | 成员 | 详情 Text( text = buildString { +// append(dateFormat.format(record.date)) +// append(" ") append(timeFormat.format(record.date)) - if (member != null && member.name != "自己") { +// if (member != null && member.name != "自己") { append(" | ") + if (member != null) { append(member.name) } +// } if (record.description.isNotEmpty()) { append(" | ") append(record.description) diff --git a/app/src/main/java/com/yovinchen/bookkeeping/ui/navigation/MainNavigation.kt b/app/src/main/java/com/yovinchen/bookkeeping/ui/navigation/MainNavigation.kt index 71f714e..ee73056 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/ui/navigation/MainNavigation.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/ui/navigation/MainNavigation.kt @@ -3,6 +3,7 @@ package com.yovinchen.bookkeeping.ui.navigation import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.List import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.outlined.Analytics import androidx.compose.material3.ExperimentalMaterial3Api @@ -16,14 +17,19 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument import com.yovinchen.bookkeeping.model.ThemeMode import com.yovinchen.bookkeeping.ui.screen.AnalysisScreen +import com.yovinchen.bookkeeping.ui.screen.CategoryDetailScreen import com.yovinchen.bookkeeping.ui.screen.HomeScreen import com.yovinchen.bookkeeping.ui.screen.SettingsScreen +import java.time.YearMonth +import java.time.format.DateTimeFormatter sealed class Screen( val route: String, @@ -33,6 +39,14 @@ sealed class Screen( data object Home : Screen("home", Icons.Default.Home, "主页") data object Analysis : Screen("analysis", Icons.Outlined.Analytics, "分析") data object Settings : Screen("settings", Icons.Default.Settings, "设置") + data object CategoryDetail : Screen( + "category_detail/{category}/{yearMonth}", + Icons.Default.List, + "分类详情" + ) { + fun createRoute(category: String, yearMonth: String) = + "category_detail/$category/$yearMonth" + } } @OptIn(ExperimentalMaterial3Api::class) @@ -78,12 +92,37 @@ fun MainNavigation( modifier = Modifier.padding(innerPadding) ) { composable(Screen.Home.route) { HomeScreen() } - composable(Screen.Analysis.route) { AnalysisScreen() } - composable(Screen.Settings.route) { + composable(Screen.Analysis.route) { + AnalysisScreen( + onNavigateToCategoryDetail = { category, month -> + val monthStr = month.format(DateTimeFormatter.ofPattern("yyyy-MM")) + navController.navigate(Screen.CategoryDetail.createRoute(category, monthStr)) + } + ) + } + composable(Screen.Settings.route) { SettingsScreen( currentTheme = currentTheme, onThemeChange = onThemeChange - ) + ) + } + + composable( + route = Screen.CategoryDetail.route, + arguments = listOf( + navArgument("category") { type = NavType.StringType }, + navArgument("yearMonth") { type = NavType.StringType } + ) + ) { backStackEntry -> + val category = backStackEntry.arguments?.getString("category") ?: return@composable + val yearMonth = YearMonth.parse( + backStackEntry.arguments?.getString("yearMonth") ?: return@composable + ) + CategoryDetailScreen( + category = category, + month = yearMonth, + onBack = { navController.popBackStack() } + ) } } } 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 0d0dbe9..dc7708b 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 @@ -1,228 +1,109 @@ package com.yovinchen.bookkeeping.ui.screen -import android.annotation.SuppressLint -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft -import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight -import androidx.compose.material3.FilterChip -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue +import androidx.compose.material3.* +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel +import com.yovinchen.bookkeeping.model.AnalysisType 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.viewmodel.AnalysisType import com.yovinchen.bookkeeping.viewmodel.AnalysisViewModel -import com.yovinchen.bookkeeping.viewmodel.CategoryStat +import java.time.YearMonth +import java.time.format.DateTimeFormatter +@OptIn(ExperimentalMaterial3Api::class) @Composable fun AnalysisScreen( - modifier: Modifier = Modifier, - viewModel: AnalysisViewModel = viewModel() + onNavigateToCategoryDetail: (String, YearMonth) -> Unit ) { + val viewModel: AnalysisViewModel = viewModel() val selectedMonth by viewModel.selectedMonth.collectAsState() - val selectedType by viewModel.selectedAnalysisType.collectAsState() + val selectedAnalysisType by viewModel.selectedAnalysisType.collectAsState() val categoryStats by viewModel.categoryStats.collectAsState() + var showMonthPicker by remember { mutableStateOf(false) } - LazyColumn( - modifier = modifier - .fillMaxSize() - .padding(16.dp) - ) { - // 月份选择器 - item { + Scaffold { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + ) { + // 月份选择器和类型切换 Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - IconButton(onClick = { - viewModel.setSelectedMonth(selectedMonth.minusMonths(1)) - }) { - Icon(Icons.AutoMirrored.Filled.KeyboardArrowLeft, "上个月") + // 月份选择按钮 + Button(onClick = { showMonthPicker = true }) { + Text(selectedMonth.format(DateTimeFormatter.ofPattern("yyyy年MM月"))) } - Text( - text = "${selectedMonth.year}年${selectedMonth.monthValue}月", - style = MaterialTheme.typography.titleLarge, - modifier = Modifier.clickable { showMonthPicker = true } - ) - - IconButton(onClick = { - viewModel.setSelectedMonth(selectedMonth.plusMonths(1)) - }) { - Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, "下个月") - } - } - - Spacer(modifier = Modifier.height(16.dp)) - - // 分析类型选择 - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceEvenly - ) { - AnalysisType.entries.forEach { type -> - FilterChip( - selected = selectedType == type, - onClick = { viewModel.setAnalysisType(type) }, - label = { - Text( - when (type) { - AnalysisType.EXPENSE -> "支出分析" - AnalysisType.INCOME -> "收入分析" - AnalysisType.TREND -> "收支趋势" - } - ) - } - ) - } - } - - Spacer(modifier = Modifier.height(16.dp)) - - // 统计内容 - when (selectedType) { - AnalysisType.EXPENSE, AnalysisType.INCOME -> { - Text( - text = if (selectedType == AnalysisType.EXPENSE) "" else "", - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(vertical = 8.dp) - ) - - if (categoryStats.isNotEmpty()) { - val pieChartData = categoryStats.map { stat -> - stat.category to stat.percentage.toFloat() - } - CategoryPieChart( - categoryData = pieChartData, - modifier = Modifier - .fillMaxWidth() - .height(300.dp) - ) - - Spacer(modifier = Modifier.height(16.dp)) - - Text( - text = "分类明细", - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(vertical = 8.dp) - ) - } else { - Text( - text = "暂无数据", - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center, - modifier = Modifier - .fillMaxWidth() - .padding(32.dp) + // 类型切换 + Row { + AnalysisType.values().forEach { type -> + FilterChip( + selected = selectedAnalysisType == type, + onClick = { viewModel.setAnalysisType(type) }, + label = { + Text( + when (type) { + AnalysisType.EXPENSE -> "支出" + AnalysisType.INCOME -> "收入" + AnalysisType.TREND -> "趋势" + } + ) + }, + modifier = Modifier.padding(horizontal = 4.dp) ) } } - AnalysisType.TREND -> { - Text( - text = "收支趋势分析(开发中)", - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(vertical = 8.dp) + } + + // 饼图 + if (selectedAnalysisType != AnalysisType.TREND) { + CategoryPieChart( + categoryData = categoryStats.map { Pair(it.category, it.percentage.toFloat()) }, + modifier = Modifier + .fillMaxWidth() + .height(200.dp) + .padding(16.dp) + ) + } + + // 分类统计列表 + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp) + ) { + items(categoryStats) { stat -> + CategoryStatItem( + stat = stat, + onClick = { onNavigateToCategoryDetail(stat.category, selectedMonth) } ) } } } - // 分类统计列表 - if (selectedType != AnalysisType.TREND && categoryStats.isNotEmpty()) { - items(categoryStats) { stat -> - CategoryStatItem(stat) - } - } - } - - if (showMonthPicker) { - MonthYearPicker( - selectedMonth = selectedMonth, - onMonthSelected = { month -> - viewModel.setSelectedMonth(month) - showMonthPicker = false - }, - onDismiss = { showMonthPicker = false } - ) - } -} - -@SuppressLint("DefaultLocale") -@Composable -fun CategoryStatItem(stat: CategoryStat) { - Column( - modifier = Modifier - .fillMaxWidth() - .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)) - - 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 + // 月份选择器对话框 + if (showMonthPicker) { + MonthYearPicker( + selectedMonth = selectedMonth, + onMonthSelected = { + viewModel.setSelectedMonth(it) + showMonthPicker = false + }, + onDismiss = { showMonthPicker = false } ) } } diff --git a/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/CategoryDetailScreen.kt b/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/CategoryDetailScreen.kt new file mode 100644 index 0000000..5432d81 --- /dev/null +++ b/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/CategoryDetailScreen.kt @@ -0,0 +1,147 @@ +package com.yovinchen.bookkeeping.ui.screen + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.yovinchen.bookkeeping.data.BookkeepingDatabase +import com.yovinchen.bookkeeping.model.BookkeepingRecord +import com.yovinchen.bookkeeping.ui.components.RecordItem +import com.yovinchen.bookkeeping.viewmodel.CategoryDetailViewModel +import com.yovinchen.bookkeeping.viewmodel.CategoryDetailViewModelFactory +import java.text.SimpleDateFormat +import java.time.YearMonth +import java.time.format.DateTimeFormatter +import java.util.Locale + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CategoryDetailScreen( + category: String, + month: YearMonth, + onBack: () -> Unit +) { + val context = LocalContext.current + val database = remember { BookkeepingDatabase.getDatabase(context) } + val viewModel: CategoryDetailViewModel = viewModel( + factory = CategoryDetailViewModelFactory(database, category, month) + ) + + val records by viewModel.records.collectAsState() + val total by viewModel.total.collectAsState() + val members by viewModel.members.collectAsState() + val groupedRecords = remember(records) { + records.groupBy { record -> + SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(record.date) + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("$category - ${month.format(DateTimeFormatter.ofPattern("yyyy年MM月"))}") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.Default.ArrowBack, contentDescription = "返回") + } + } + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + ) { + // 总金额显示 + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "总金额", + style = MaterialTheme.typography.titleMedium + ) + Text( + text = String.format("%.2f", total), + style = MaterialTheme.typography.titleLarge + ) + } + } + + // 记录列表 + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + groupedRecords.forEach { (date, dayRecords) -> + item { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + // 日期标签 + Text( + text = SimpleDateFormat( + "yyyy年MM月dd日 E", + Locale.CHINESE + ).format(dayRecords.first().date), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // 当天的记录 + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + dayRecords.forEachIndexed { index, record -> + RecordItem( + record = record, + onClick = {}, + members = members + ) + + if (index < dayRecords.size - 1) { + HorizontalDivider( + modifier = Modifier.padding(vertical = 4.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + thickness = 0.5.dp + ) + } + } + } + } + } + } + } + } + } + } +} 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 5288525..3d07c3b 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/AnalysisViewModel.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/AnalysisViewModel.kt @@ -4,6 +4,8 @@ import android.app.Application import androidx.lifecycle.AndroidViewModel 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.TransactionType import kotlinx.coroutines.flow.* import java.time.LocalDateTime @@ -59,14 +61,3 @@ class AnalysisViewModel(application: Application) : AndroidViewModel(application _selectedAnalysisType.value = type } } - -enum class AnalysisType { - EXPENSE, INCOME, TREND -} - -data class CategoryStat( - val category: String, - val amount: Double, - val count: Int, - val percentage: Double = 0.0 -) diff --git a/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/CategoryDetailViewModel.kt b/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/CategoryDetailViewModel.kt new file mode 100644 index 0000000..1379818 --- /dev/null +++ b/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/CategoryDetailViewModel.kt @@ -0,0 +1,52 @@ +package com.yovinchen.bookkeeping.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.yovinchen.bookkeeping.data.BookkeepingDatabase +import com.yovinchen.bookkeeping.model.BookkeepingRecord +import com.yovinchen.bookkeeping.model.Member +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import java.time.YearMonth +import java.time.format.DateTimeFormatter + +class CategoryDetailViewModel( + private val database: BookkeepingDatabase, + private val category: String, + private val month: YearMonth +) : ViewModel() { + private val _records = MutableStateFlow>(emptyList()) + val records: StateFlow> = _records + + private val _total = MutableStateFlow(0.0) + val total: StateFlow = _total + + private val _members = MutableStateFlow>(emptyList()) + val members: StateFlow> = _members + + init { + loadRecords() + loadMembers() + } + + private fun loadRecords() { + viewModelScope.launch { + val monthStr = month.format(DateTimeFormatter.ofPattern("yyyy-MM")) + database.bookkeepingDao().getRecordsByCategoryAndMonth(category, monthStr) + .collect { records -> + _records.value = records + _total.value = records.sumOf { it.amount } + } + } + } + + private fun loadMembers() { + viewModelScope.launch { + database.memberDao().getAllMembers().collect { members -> + _members.value = members + } + } + } +} diff --git a/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/CategoryDetailViewModelFactory.kt b/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/CategoryDetailViewModelFactory.kt new file mode 100644 index 0000000..1a4e83d --- /dev/null +++ b/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/CategoryDetailViewModelFactory.kt @@ -0,0 +1,20 @@ +package com.yovinchen.bookkeeping.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.yovinchen.bookkeeping.data.BookkeepingDatabase +import java.time.YearMonth + +class CategoryDetailViewModelFactory( + private val database: BookkeepingDatabase, + private val category: String, + private val month: YearMonth +) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + if (modelClass.isAssignableFrom(CategoryDetailViewModel::class.java)) { + @Suppress("UNCHECKED_CAST") + return CategoryDetailViewModel(database, category, month) as T + } + throw IllegalArgumentException("Unknown ViewModel class") + } +} diff --git a/gradle.properties b/gradle.properties index 20e2a01..1ea80a2 100644 --- a/gradle.properties +++ b/gradle.properties @@ -20,4 +20,6 @@ kotlin.code.style=official # Enables namespacing of each library's R class so that its R class includes only the # resources declared in the library itself and none from the library's dependencies, # thereby reducing the size of the R class for that library -android.nonTransitiveRClass=true \ No newline at end of file +android.nonTransitiveRClass=true +# Kotlin +org.gradle.java.home=/Library/Java/JavaVirtualMachines/jdk-17.jdk/Contents/Home \ No newline at end of file From f1343046464dd8fb4b66c1d1cfe79b722314bfba Mon Sep 17 00:00:00 2001 From: yovinchen Date: Thu, 28 Nov 2024 14:34:24 +0800 Subject: [PATCH 13/34] =?UTF-8?q?feat:=20=E4=B8=BA=E9=A5=BC=E5=9B=BE?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E7=82=B9=E5=87=BB=E4=BA=8B=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 为CategoryPieChart添加点击事件处理 2. 点击饼图可跳转到对应类别详情页面 --- .../ui/components/CategoryPieChart.kt | 21 ++++++++++++- .../bookkeeping/ui/screen/AnalysisScreen.kt | 30 +++++++++++-------- 2 files changed, 38 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/com/yovinchen/bookkeeping/ui/components/CategoryPieChart.kt b/app/src/main/java/com/yovinchen/bookkeeping/ui/components/CategoryPieChart.kt index e0853bc..9a23fb1 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/ui/components/CategoryPieChart.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/ui/components/CategoryPieChart.kt @@ -12,16 +12,20 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import com.github.mikephil.charting.charts.PieChart import com.github.mikephil.charting.components.Legend +import com.github.mikephil.charting.data.Entry import com.github.mikephil.charting.data.PieData import com.github.mikephil.charting.data.PieDataSet import com.github.mikephil.charting.data.PieEntry import com.github.mikephil.charting.formatter.PercentFormatter +import com.github.mikephil.charting.highlight.Highlight +import com.github.mikephil.charting.listener.OnChartValueSelectedListener import com.github.mikephil.charting.utils.ColorTemplate @Composable fun CategoryPieChart( categoryData: List>, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + onCategoryClick: (String) -> Unit = {} ) { val isDarkTheme = isSystemInDarkTheme() val textColor = MaterialTheme.colorScheme.onSurface.toArgb() @@ -50,6 +54,21 @@ fun CategoryPieChart( // 设置中心文字颜色跟随主题 setCenterTextColor(textColor) + + // 添加点击事件监听器 + setOnChartValueSelectedListener(object : OnChartValueSelectedListener { + override fun onValueSelected(e: Entry?, h: Highlight?) { + e?.let { + if (it is PieEntry) { + onCategoryClick(it.label ?: return) + } + } + } + + override fun onNothingSelected() { + // 不需要处理 + } + }) } }, update = { chart -> 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 dc7708b..20ed14c 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 @@ -70,22 +70,28 @@ fun AnalysisScreen( } } - // 饼图 - if (selectedAnalysisType != AnalysisType.TREND) { - CategoryPieChart( - categoryData = categoryStats.map { Pair(it.category, it.percentage.toFloat()) }, - modifier = Modifier - .fillMaxWidth() - .height(200.dp) - .padding(16.dp) - ) - } - - // 分类统计列表 + // 使用LazyColumn包含饼图和列表 LazyColumn( modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(16.dp) ) { + // 添加饼图作为第一个项目 + if (selectedAnalysisType != AnalysisType.TREND) { + item { + CategoryPieChart( + categoryData = categoryStats.map { Pair(it.category, it.percentage.toFloat()) }, + modifier = Modifier + .fillMaxWidth() + .height(200.dp) + .padding(bottom = 16.dp), + onCategoryClick = { category -> + onNavigateToCategoryDetail(category, selectedMonth) + } + ) + } + } + + // 添加分类统计列表项目 items(categoryStats) { stat -> CategoryStatItem( stat = stat, From 76d028688322624c4ce4dafafcd54fd7f138d53d Mon Sep 17 00:00:00 2001 From: yovinchen Date: Thu, 28 Nov 2024 14:35:10 +0800 Subject: [PATCH 14/34] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E8=AD=A6?= =?UTF-8?q?=E5=91=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/yovinchen/bookkeeping/ui/components/CategoryPieChart.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/yovinchen/bookkeeping/ui/components/CategoryPieChart.kt b/app/src/main/java/com/yovinchen/bookkeeping/ui/components/CategoryPieChart.kt index 9a23fb1..a120c8a 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/ui/components/CategoryPieChart.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/ui/components/CategoryPieChart.kt @@ -27,7 +27,7 @@ fun CategoryPieChart( modifier: Modifier = Modifier, onCategoryClick: (String) -> Unit = {} ) { - val isDarkTheme = isSystemInDarkTheme() + isSystemInDarkTheme() val textColor = MaterialTheme.colorScheme.onSurface.toArgb() AndroidView( From 380fdd558915696ad9e5541c47f7863024a5bfdb Mon Sep 17 00:00:00 2001 From: yovinchen Date: Thu, 28 Nov 2024 16:14:49 +0800 Subject: [PATCH 15/34] =?UTF-8?q?feat:=20=E6=88=90=E5=91=98=E5=88=86?= =?UTF-8?q?=E6=9E=90=E4=B8=8E=E8=AF=A6=E6=83=85=E5=8A=9F=E8=83=BD=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 新增成员详情页面,按天分组显示记录 2. 优化分析页面,支持分类/成员视图切换 3. 使用 rememberSaveable 保持视图模式状态 4. 改进 UI 布局和交互体验 --- .../bookkeeping/data/BookkeepingDao.kt | 11 ++ .../com/yovinchen/bookkeeping/data/Record.kt | 3 +- .../ui/components/CategoryPieChart.kt | 28 ++-- .../ui/navigation/MainNavigation.kt | 62 +++++--- .../bookkeeping/ui/screen/AnalysisScreen.kt | 101 +++++++++--- .../ui/screen/MemberDetailScreen.kt | 149 ++++++++++++++++++ .../viewmodel/AnalysisViewModel.kt | 45 +++++- .../viewmodel/MemberDetailViewModel.kt | 34 ++++ 8 files changed, 372 insertions(+), 61 deletions(-) create mode 100644 app/src/main/java/com/yovinchen/bookkeeping/ui/screen/MemberDetailScreen.kt create mode 100644 app/src/main/java/com/yovinchen/bookkeeping/viewmodel/MemberDetailViewModel.kt diff --git a/app/src/main/java/com/yovinchen/bookkeeping/data/BookkeepingDao.kt b/app/src/main/java/com/yovinchen/bookkeeping/data/BookkeepingDao.kt index 9d5bba7..749ab2c 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/data/BookkeepingDao.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/data/BookkeepingDao.kt @@ -38,6 +38,17 @@ interface BookkeepingDao { yearMonth: String ): Flow> + @Query(""" + SELECT * FROM bookkeeping_records + WHERE memberId IN (SELECT id FROM members WHERE name = :memberName) + AND strftime('%Y-%m', datetime(date/1000, 'unixepoch')) = :yearMonth + ORDER BY date DESC + """) + fun getRecordsByMemberAndMonth( + memberName: String, + yearMonth: String + ): Flow> + @Insert suspend fun insertRecord(record: BookkeepingRecord): Long diff --git a/app/src/main/java/com/yovinchen/bookkeeping/data/Record.kt b/app/src/main/java/com/yovinchen/bookkeeping/data/Record.kt index d37049d..47c7366 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/data/Record.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/data/Record.kt @@ -12,5 +12,6 @@ data class Record( val category: String, val description: String, val dateTime: LocalDateTime = LocalDateTime.now(), - val isExpense: Boolean = true + val isExpense: Boolean = true, + val member: String = "Default" ) diff --git a/app/src/main/java/com/yovinchen/bookkeeping/ui/components/CategoryPieChart.kt b/app/src/main/java/com/yovinchen/bookkeeping/ui/components/CategoryPieChart.kt index a120c8a..7baae93 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/ui/components/CategoryPieChart.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/ui/components/CategoryPieChart.kt @@ -2,16 +2,14 @@ package com.yovinchen.bookkeeping.ui.components import android.graphics.Color as AndroidColor import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.* import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable +import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import com.github.mikephil.charting.charts.PieChart -import com.github.mikephil.charting.components.Legend import com.github.mikephil.charting.data.Entry import com.github.mikephil.charting.data.PieData import com.github.mikephil.charting.data.PieDataSet @@ -24,11 +22,13 @@ import com.github.mikephil.charting.utils.ColorTemplate @Composable fun CategoryPieChart( categoryData: List>, + memberData: List>, + currentViewMode: Boolean = false, // false 为分类视图,true 为成员视图 modifier: Modifier = Modifier, onCategoryClick: (String) -> Unit = {} ) { - isSystemInDarkTheme() val textColor = MaterialTheme.colorScheme.onSurface.toArgb() + val data = if (currentViewMode) memberData else categoryData AndroidView( modifier = modifier @@ -39,23 +39,15 @@ fun CategoryPieChart( description.isEnabled = false setUsePercentValues(true) setDrawEntryLabels(true) - - // 禁用图例显示 legend.isEnabled = false - isDrawHoleEnabled = true holeRadius = 40f setHoleColor(AndroidColor.TRANSPARENT) setTransparentCircleRadius(45f) - - // 设置标签文字颜色 setEntryLabelColor(textColor) setEntryLabelTextSize(12f) - - // 设置中心文字颜色跟随主题 setCenterTextColor(textColor) - // 添加点击事件监听器 setOnChartValueSelectedListener(object : OnChartValueSelectedListener { override fun onValueSelected(e: Entry?, h: Highlight?) { e?.let { @@ -65,18 +57,16 @@ fun CategoryPieChart( } } - override fun onNothingSelected() { - // 不需要处理 - } + override fun onNothingSelected() {} }) } }, update = { chart -> - val entries = categoryData.map { (category, amount) -> - PieEntry(amount, category) + val entries = data.map { (label, amount) -> + PieEntry(amount, label) } - val dataSet = PieDataSet(entries, "").apply { // 将标题设为空字符串 + val dataSet = PieDataSet(entries, "").apply { colors = ColorTemplate.MATERIAL_COLORS.toList() valueTextSize = 14f valueFormatter = PercentFormatter(chart) diff --git a/app/src/main/java/com/yovinchen/bookkeeping/ui/navigation/MainNavigation.kt b/app/src/main/java/com/yovinchen/bookkeeping/ui/navigation/MainNavigation.kt index ee73056..ea0bee9 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/ui/navigation/MainNavigation.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/ui/navigation/MainNavigation.kt @@ -6,6 +6,7 @@ import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.List import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.outlined.Analytics +import androidx.compose.material.icons.outlined.Home import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.NavigationBar @@ -24,28 +25,33 @@ import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument import com.yovinchen.bookkeeping.model.ThemeMode -import com.yovinchen.bookkeeping.ui.screen.AnalysisScreen -import com.yovinchen.bookkeeping.ui.screen.CategoryDetailScreen -import com.yovinchen.bookkeeping.ui.screen.HomeScreen -import com.yovinchen.bookkeeping.ui.screen.SettingsScreen +import com.yovinchen.bookkeeping.ui.screen.* import java.time.YearMonth import java.time.format.DateTimeFormatter sealed class Screen( val route: String, - val icon: ImageVector, - val label: String + val icon: ImageVector? = null, + val label: String? = null ) { - data object Home : Screen("home", Icons.Default.Home, "主页") + data object Home : Screen("home", Icons.Outlined.Home, "首页") data object Analysis : Screen("analysis", Icons.Outlined.Analytics, "分析") data object Settings : Screen("settings", Icons.Default.Settings, "设置") data object CategoryDetail : Screen( - "category_detail/{category}/{yearMonth}", + "category/{category}/{yearMonth}", Icons.Default.List, "分类详情" ) { - fun createRoute(category: String, yearMonth: String) = - "category_detail/$category/$yearMonth" + fun createRoute(category: String, yearMonth: YearMonth): String = + "category/$category/${yearMonth.format(DateTimeFormatter.ofPattern("yyyy-MM"))}" + } + data object MemberDetail : Screen( + "member/{memberName}/{yearMonth}", + Icons.Default.List, + "成员详情" + ) { + fun createRoute(memberName: String, yearMonth: YearMonth): String = + "member/$memberName/${yearMonth.format(DateTimeFormatter.ofPattern("yyyy-MM"))}" } } @@ -69,8 +75,8 @@ fun MainNavigation( Screen.Settings ).forEach { screen -> NavigationBarItem( - icon = { Icon(screen.icon, contentDescription = screen.label) }, - label = { Text(screen.label) }, + icon = { Icon(screen.icon!!, contentDescription = screen.label) }, + label = { Text(screen.label!!) }, selected = currentRoute == screen.route, onClick = { navController.navigate(screen.route) { @@ -92,14 +98,18 @@ fun MainNavigation( modifier = Modifier.padding(innerPadding) ) { composable(Screen.Home.route) { HomeScreen() } + composable(Screen.Analysis.route) { AnalysisScreen( - onNavigateToCategoryDetail = { category, month -> - val monthStr = month.format(DateTimeFormatter.ofPattern("yyyy-MM")) - navController.navigate(Screen.CategoryDetail.createRoute(category, monthStr)) + onNavigateToCategoryDetail = { category, yearMonth -> + navController.navigate(Screen.CategoryDetail.createRoute(category, yearMonth)) + }, + onNavigateToMemberDetail = { memberName, yearMonth -> + navController.navigate(Screen.MemberDetail.createRoute(memberName, yearMonth)) } ) } + composable(Screen.Settings.route) { SettingsScreen( currentTheme = currentTheme, @@ -115,15 +125,31 @@ fun MainNavigation( ) ) { backStackEntry -> val category = backStackEntry.arguments?.getString("category") ?: return@composable - val yearMonth = YearMonth.parse( - backStackEntry.arguments?.getString("yearMonth") ?: return@composable - ) + val yearMonthStr = backStackEntry.arguments?.getString("yearMonth") ?: return@composable + val yearMonth = YearMonth.parse(yearMonthStr, DateTimeFormatter.ofPattern("yyyy-MM")) CategoryDetailScreen( category = category, month = yearMonth, onBack = { navController.popBackStack() } ) } + + composable( + route = Screen.MemberDetail.route, + arguments = listOf( + navArgument("memberName") { type = NavType.StringType }, + navArgument("yearMonth") { type = NavType.StringType } + ) + ) { backStackEntry -> + val memberName = backStackEntry.arguments?.getString("memberName") ?: return@composable + val yearMonthStr = backStackEntry.arguments?.getString("yearMonth") ?: return@composable + val yearMonth = YearMonth.parse(yearMonthStr, DateTimeFormatter.ofPattern("yyyy-MM")) + MemberDetailScreen( + memberName = memberName, + yearMonth = yearMonth, + onNavigateBack = { navController.popBackStack() } + ) + } } } } 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 20ed14c..01ffd31 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 @@ -3,8 +3,11 @@ package com.yovinchen.bookkeeping.ui.screen import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material3.* import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -18,17 +21,25 @@ import com.yovinchen.bookkeeping.viewmodel.AnalysisViewModel import java.time.YearMonth import java.time.format.DateTimeFormatter +enum class ViewMode { + CATEGORY, MEMBER +} + @OptIn(ExperimentalMaterial3Api::class) @Composable fun AnalysisScreen( - onNavigateToCategoryDetail: (String, YearMonth) -> Unit + onNavigateToCategoryDetail: (String, YearMonth) -> Unit, + onNavigateToMemberDetail: (String, YearMonth) -> Unit ) { val viewModel: AnalysisViewModel = viewModel() val selectedMonth by viewModel.selectedMonth.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) } Scaffold { padding -> Column( @@ -36,17 +47,54 @@ fun AnalysisScreen( .fillMaxSize() .padding(padding) ) { - // 月份选择器和类型切换 + // 时间选择按钮行 Row( modifier = Modifier .fillMaxWidth() - .padding(16.dp), + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + Button(onClick = { showMonthPicker = true }) { + Text(selectedMonth.format(DateTimeFormatter.ofPattern("yyyy年MM月"))) + } + } + + // 分析类型和视图模式选择行 + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - // 月份选择按钮 - Button(onClick = { showMonthPicker = true }) { - Text(selectedMonth.format(DateTimeFormatter.ofPattern("yyyy年MM月"))) + // 分类/成员切换下拉菜单 + Box { + Button( + onClick = { showViewModeMenu = true } + ) { + Text(if (currentViewMode == ViewMode.CATEGORY) "分类" else "成员") + Icon(Icons.Default.ArrowDropDown, "切换视图") + } + DropdownMenu( + expanded = showViewModeMenu, + onDismissRequest = { showViewModeMenu = false } + ) { + DropdownMenuItem( + text = { Text("分类") }, + onClick = { + currentViewMode = ViewMode.CATEGORY + showViewModeMenu = false + } + ) + DropdownMenuItem( + text = { Text("成员") }, + onClick = { + currentViewMode = ViewMode.MEMBER + showViewModeMenu = false + } + ) + } } // 类型切换 @@ -80,37 +128,48 @@ fun AnalysisScreen( item { CategoryPieChart( categoryData = categoryStats.map { Pair(it.category, it.percentage.toFloat()) }, + memberData = memberStats.map { Pair(it.category, it.percentage.toFloat()) }, + currentViewMode = currentViewMode == ViewMode.MEMBER, modifier = Modifier .fillMaxWidth() .height(200.dp) .padding(bottom = 16.dp), onCategoryClick = { category -> - onNavigateToCategoryDetail(category, selectedMonth) + if (currentViewMode == ViewMode.CATEGORY) { + onNavigateToCategoryDetail(category, selectedMonth) + } else { + onNavigateToMemberDetail(category, selectedMonth) + } } ) } } - // 添加分类统计列表项目 - items(categoryStats) { stat -> + // 添加统计列表项目 + items(if (currentViewMode == ViewMode.CATEGORY) categoryStats else memberStats) { stat -> CategoryStatItem( stat = stat, - onClick = { onNavigateToCategoryDetail(stat.category, selectedMonth) } + onClick = { + if (currentViewMode == ViewMode.CATEGORY) { + onNavigateToCategoryDetail(stat.category, selectedMonth) + } else { + onNavigateToMemberDetail(stat.category, selectedMonth) + } + } ) } } - } - // 月份选择器对话框 - if (showMonthPicker) { - MonthYearPicker( - selectedMonth = selectedMonth, - onMonthSelected = { - viewModel.setSelectedMonth(it) - showMonthPicker = false - }, - onDismiss = { showMonthPicker = false } - ) + 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/ui/screen/MemberDetailScreen.kt b/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/MemberDetailScreen.kt new file mode 100644 index 0000000..99f6129 --- /dev/null +++ b/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/MemberDetailScreen.kt @@ -0,0 +1,149 @@ +package com.yovinchen.bookkeeping.ui.screen + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.yovinchen.bookkeeping.model.BookkeepingRecord +import com.yovinchen.bookkeeping.viewmodel.MemberDetailViewModel +import java.text.NumberFormat +import java.text.SimpleDateFormat +import java.time.YearMonth +import java.time.format.DateTimeFormatter +import java.util.* + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MemberDetailScreen( + memberName: String, + yearMonth: YearMonth, + onNavigateBack: () -> Unit, + viewModel: MemberDetailViewModel = viewModel() +) { + val records by viewModel.memberRecords.collectAsState(initial = emptyList()) + val totalAmount by viewModel.totalAmount.collectAsState(initial = 0.0) + + LaunchedEffect(memberName, yearMonth) { + viewModel.loadMemberRecords(memberName, yearMonth) + } + + val groupedRecords = remember(records) { + records.groupBy { record -> + SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(record.date) + }.toSortedMap(reverseOrder()) + } + + Scaffold( + topBar = { + TopAppBar( + title = { + Text("$memberName - ${yearMonth.format(DateTimeFormatter.ofPattern("yyyy年MM月"))}") + }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.Default.ArrowBack, "返回") + } + } + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + ) { + // 总金额显示 + Card( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Column( + modifier = Modifier + .padding(16.dp) + ) { + Text( + text = "总支出", + style = MaterialTheme.typography.titleMedium + ) + Text( + text = NumberFormat.getCurrencyInstance(Locale.CHINA) + .format(totalAmount), + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold + ) + } + } + + // 按日期分组的记录列表 + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + groupedRecords.forEach { (date, dayRecords) -> + item { + Text( + text = date, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(vertical = 8.dp) + ) + } + items(dayRecords.sortedByDescending { it.date }) { record -> + RecordItem(record = record) + } + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun RecordItem(record: BookkeepingRecord) { + Card( + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = record.category, + style = MaterialTheme.typography.titleMedium + ) + if (record.description.isNotBlank()) { + Text( + text = record.description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Text( + text = SimpleDateFormat("HH:mm", Locale.getDefault()).format(record.date), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Text( + text = NumberFormat.getCurrencyInstance(Locale.CHINA).format(record.amount), + style = MaterialTheme.typography.titleMedium, + color = if (record.amount < 0) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary + ) + } + } +} 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 3d07c3b..704f60e 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/AnalysisViewModel.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/AnalysisViewModel.kt @@ -11,9 +11,11 @@ import kotlinx.coroutines.flow.* import java.time.LocalDateTime import java.time.YearMonth import java.time.ZoneId +import java.util.Date 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() @@ -21,11 +23,50 @@ class AnalysisViewModel(application: Application) : AndroidViewModel(application private val _selectedAnalysisType = MutableStateFlow(AnalysisType.EXPENSE) val selectedAnalysisType = _selectedAnalysisType.asStateFlow() + private val members = memberDao.getAllMembers() + + 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 + } + } + + // 按成员统计 + 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 = LocalDateTime.ofInstant(it.date.toInstant(), ZoneId.systemDefault()) - YearMonth.from(recordDate) == month && it.type == when(type) { + 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 diff --git a/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/MemberDetailViewModel.kt b/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/MemberDetailViewModel.kt new file mode 100644 index 0000000..2e56cf7 --- /dev/null +++ b/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/MemberDetailViewModel.kt @@ -0,0 +1,34 @@ +package com.yovinchen.bookkeeping.viewmodel + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.yovinchen.bookkeeping.data.BookkeepingDatabase +import com.yovinchen.bookkeeping.model.BookkeepingRecord +import kotlinx.coroutines.flow.* +import java.time.YearMonth +import java.time.format.DateTimeFormatter + +class MemberDetailViewModel(application: Application) : AndroidViewModel(application) { + private val recordDao = BookkeepingDatabase.getDatabase(application).bookkeepingDao() + + private val _memberRecords = MutableStateFlow>(emptyList()) + val memberRecords: StateFlow> = _memberRecords.asStateFlow() + + val totalAmount: StateFlow = _memberRecords + .map { records -> records.sumOf { it.amount } } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = 0.0 + ) + + fun loadMemberRecords(memberName: String, yearMonth: YearMonth) { + recordDao.getRecordsByMemberAndMonth( + memberName, + yearMonth.format(DateTimeFormatter.ofPattern("yyyy-MM")) + ).onEach { records -> + _memberRecords.value = records + }.launchIn(viewModelScope) + } +} From 94fc7b2a7e1a1bf9b4c52473a633eb951b04e1ac Mon Sep 17 00:00:00 2001 From: yovinchen Date: Thu, 28 Nov 2024 17:38:54 +0800 Subject: [PATCH 16/34] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E8=AE=B0?= =?UTF-8?q?=E8=B4=A6=E5=88=86=E6=9E=90=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 重构导航系统,支持更细粒度的页面跳转 - 增强数据访问层,添加新的查询方法 - 优化界面布局和交互体验 - 添加成员分布分析功能 - 改进日期和金额的显示方式 --- .idea/misc.xml | 1 - .../bookkeeping/data/BookkeepingDao.kt | 46 +++++ .../yovinchen/bookkeeping/data/Converters.kt | 11 + .../ui/navigation/MainNavigation.kt | 72 +++---- .../ui/screen/CategoryDetailScreen.kt | 191 ++++++++++-------- .../ui/screen/MemberDetailScreen.kt | 155 +++++++------- .../viewmodel/CategoryDetailViewModel.kt | 60 +++--- .../viewmodel/MemberDetailViewModel.kt | 47 +++-- 8 files changed, 351 insertions(+), 232 deletions(-) diff --git a/.idea/misc.xml b/.idea/misc.xml index 74dd639..b2c751a 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,4 +1,3 @@ - diff --git a/app/src/main/java/com/yovinchen/bookkeeping/data/BookkeepingDao.kt b/app/src/main/java/com/yovinchen/bookkeeping/data/BookkeepingDao.kt index 749ab2c..0e33bae 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/data/BookkeepingDao.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/data/BookkeepingDao.kt @@ -3,6 +3,7 @@ package com.yovinchen.bookkeeping.data import androidx.room.* import com.yovinchen.bookkeeping.model.BookkeepingRecord import com.yovinchen.bookkeeping.model.Category +import com.yovinchen.bookkeeping.model.CategoryStat import com.yovinchen.bookkeeping.model.TransactionType import kotlinx.coroutines.flow.Flow import java.util.Date @@ -49,6 +50,37 @@ interface BookkeepingDao { yearMonth: String ): Flow> + @Query(""" + SELECT m.name as category, + SUM(r.amount) as amount, + COUNT(*) as count, + (SUM(r.amount) * 100.0 / ( + SELECT SUM(amount) + FROM bookkeeping_records + WHERE category = :category + AND strftime('%Y-%m', datetime(date/1000, 'unixepoch')) = :yearMonth + )) as percentage + FROM bookkeeping_records r + JOIN members m ON r.memberId = m.id + WHERE r.category = :category + AND strftime('%Y-%m', datetime(r.date/1000, 'unixepoch')) = :yearMonth + GROUP BY m.name + ORDER BY amount DESC + """) + fun getMemberStatsByCategory( + category: String, + yearMonth: String + ): Flow> + + @Query(""" + SELECT * FROM bookkeeping_records + WHERE category = :category + ORDER BY date DESC + """) + fun getRecordsByCategory( + category: String + ): Flow> + @Insert suspend fun insertRecord(record: BookkeepingRecord): Long @@ -75,4 +107,18 @@ interface BookkeepingDao { @Query("UPDATE bookkeeping_records SET category = :newName WHERE category = :oldName") suspend fun updateRecordCategories(oldName: String, newName: String) + + @Query(""" + SELECT * FROM bookkeeping_records + WHERE memberId IN (SELECT id FROM members WHERE name = :memberName) + AND category = :category + AND date BETWEEN :startDate AND :endDate + ORDER BY date DESC + """) + suspend fun getRecordsByMemberAndCategory( + memberName: String, + category: String, + startDate: Date, + endDate: Date + ): List } diff --git a/app/src/main/java/com/yovinchen/bookkeeping/data/Converters.kt b/app/src/main/java/com/yovinchen/bookkeeping/data/Converters.kt index 84c5bea..b23692a 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/data/Converters.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/data/Converters.kt @@ -3,6 +3,7 @@ package com.yovinchen.bookkeeping.data import androidx.room.TypeConverter import java.time.LocalDateTime import java.time.format.DateTimeFormatter +import java.util.* class Converters { private val formatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME @@ -18,4 +19,14 @@ class Converters { fun dateToTimestamp(date: LocalDateTime?): String? { return date?.format(formatter) } + + @TypeConverter + fun fromDate(value: Date?): String? { + return value?.time?.toString() + } + + @TypeConverter + fun toDate(timestamp: String?): Date? { + return timestamp?.let { Date(it.toLong()) } + } } diff --git a/app/src/main/java/com/yovinchen/bookkeeping/ui/navigation/MainNavigation.kt b/app/src/main/java/com/yovinchen/bookkeeping/ui/navigation/MainNavigation.kt index ea0bee9..fd7fe2b 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/ui/navigation/MainNavigation.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/ui/navigation/MainNavigation.kt @@ -2,11 +2,9 @@ package com.yovinchen.bookkeeping.ui.navigation import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Home -import androidx.compose.material.icons.filled.List +import androidx.compose.material.icons.automirrored.filled.List +import androidx.compose.material.icons.filled.Analytics import androidx.compose.material.icons.filled.Settings -import androidx.compose.material.icons.outlined.Analytics -import androidx.compose.material.icons.outlined.Home import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.NavigationBar @@ -31,27 +29,25 @@ import java.time.format.DateTimeFormatter sealed class Screen( val route: String, - val icon: ImageVector? = null, - val label: String? = null + val title: String, + val icon: ImageVector? = null ) { - data object Home : Screen("home", Icons.Outlined.Home, "首页") - data object Analysis : Screen("analysis", Icons.Outlined.Analytics, "分析") - data object Settings : Screen("settings", Icons.Default.Settings, "设置") - data object CategoryDetail : Screen( - "category/{category}/{yearMonth}", - Icons.Default.List, - "分类详情" - ) { - fun createRoute(category: String, yearMonth: YearMonth): String = - "category/$category/${yearMonth.format(DateTimeFormatter.ofPattern("yyyy-MM"))}" + object Home : Screen("home", "记账", Icons.AutoMirrored.Filled.List) + object Analysis : Screen("analysis", "分析", Icons.Default.Analytics) + object Settings : Screen("settings", "设置", Icons.Default.Settings) + object CategoryDetail : Screen("category_detail/{category}/{yearMonth}", "分类详情") { + fun createRoute(category: String, yearMonth: YearMonth): String { + return "category_detail/$category/${yearMonth.format(DateTimeFormatter.ofPattern("yyyy-MM"))}" + } } - data object MemberDetail : Screen( - "member/{memberName}/{yearMonth}", - Icons.Default.List, - "成员详情" - ) { - fun createRoute(memberName: String, yearMonth: YearMonth): String = - "member/$memberName/${yearMonth.format(DateTimeFormatter.ofPattern("yyyy-MM"))}" + object MemberDetail : Screen("member_detail/{memberName}/{category}/{yearMonth}", "成员详情") { + fun createRoute(memberName: String, category: String, yearMonth: YearMonth): String { + return "member_detail/$memberName/$category/${yearMonth.format(DateTimeFormatter.ofPattern("yyyy-MM"))}" + } + } + + companion object { + fun bottomNavigationItems() = listOf(Home, Analysis, Settings) } } @@ -69,14 +65,10 @@ fun MainNavigation( val navBackStackEntry by navController.currentBackStackEntryAsState() val currentRoute = navBackStackEntry?.destination?.route - listOf( - Screen.Home, - Screen.Analysis, - Screen.Settings - ).forEach { screen -> + Screen.bottomNavigationItems().forEach { screen -> NavigationBarItem( - icon = { Icon(screen.icon!!, contentDescription = screen.label) }, - label = { Text(screen.label!!) }, + icon = { Icon(screen.icon!!, contentDescription = screen.title) }, + label = { Text(screen.title) }, selected = currentRoute == screen.route, onClick = { navController.navigate(screen.route) { @@ -105,7 +97,9 @@ fun MainNavigation( navController.navigate(Screen.CategoryDetail.createRoute(category, yearMonth)) }, onNavigateToMemberDetail = { memberName, yearMonth -> - navController.navigate(Screen.MemberDetail.createRoute(memberName, yearMonth)) + // 在这里我们暂时使用一个默认分类,你需要根据实际情况修改这里的逻辑 + val defaultCategory = "默认" + navController.navigate(Screen.MemberDetail.createRoute(memberName, defaultCategory, yearMonth)) } ) } @@ -126,11 +120,15 @@ fun MainNavigation( ) { backStackEntry -> val category = backStackEntry.arguments?.getString("category") ?: return@composable val yearMonthStr = backStackEntry.arguments?.getString("yearMonth") ?: return@composable - val yearMonth = YearMonth.parse(yearMonthStr, DateTimeFormatter.ofPattern("yyyy-MM")) + val yearMonth = YearMonth.parse(yearMonthStr) + CategoryDetailScreen( category = category, - month = yearMonth, - onBack = { navController.popBackStack() } + yearMonth = yearMonth, + onNavigateBack = { navController.popBackStack() }, + onNavigateToMemberDetail = { memberName -> + navController.navigate(Screen.MemberDetail.createRoute(memberName, category, yearMonth)) + } ) } @@ -138,14 +136,18 @@ fun MainNavigation( route = Screen.MemberDetail.route, arguments = listOf( navArgument("memberName") { type = NavType.StringType }, + navArgument("category") { type = NavType.StringType }, navArgument("yearMonth") { type = NavType.StringType } ) ) { backStackEntry -> val memberName = backStackEntry.arguments?.getString("memberName") ?: return@composable + val category = backStackEntry.arguments?.getString("category") ?: return@composable val yearMonthStr = backStackEntry.arguments?.getString("yearMonth") ?: return@composable - val yearMonth = YearMonth.parse(yearMonthStr, DateTimeFormatter.ofPattern("yyyy-MM")) + val yearMonth = YearMonth.parse(yearMonthStr) + MemberDetailScreen( memberName = memberName, + category = category, yearMonth = yearMonth, onNavigateBack = { navController.popBackStack() } ) diff --git a/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/CategoryDetailScreen.kt b/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/CategoryDetailScreen.kt index 5432d81..9400144 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/CategoryDetailScreen.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/CategoryDetailScreen.kt @@ -4,138 +4,131 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.yovinchen.bookkeeping.data.BookkeepingDatabase import com.yovinchen.bookkeeping.model.BookkeepingRecord +import com.yovinchen.bookkeeping.ui.components.CategoryPieChart import com.yovinchen.bookkeeping.ui.components.RecordItem import com.yovinchen.bookkeeping.viewmodel.CategoryDetailViewModel import com.yovinchen.bookkeeping.viewmodel.CategoryDetailViewModelFactory +import java.text.NumberFormat import java.text.SimpleDateFormat import java.time.YearMonth import java.time.format.DateTimeFormatter -import java.util.Locale +import java.util.* @OptIn(ExperimentalMaterial3Api::class) @Composable fun CategoryDetailScreen( category: String, - month: YearMonth, - onBack: () -> Unit + yearMonth: YearMonth, + onNavigateBack: () -> Unit, + onNavigateToMemberDetail: (String) -> Unit, + modifier: Modifier = Modifier ) { val context = LocalContext.current val database = remember { BookkeepingDatabase.getDatabase(context) } val viewModel: CategoryDetailViewModel = viewModel( - factory = CategoryDetailViewModelFactory(database, category, month) + factory = CategoryDetailViewModelFactory(database, category, yearMonth) ) val records by viewModel.records.collectAsState() + val memberStats by viewModel.memberStats.collectAsState() val total by viewModel.total.collectAsState() - val members by viewModel.members.collectAsState() - val groupedRecords = remember(records) { - records.groupBy { record -> - SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(record.date) - } - } Scaffold( topBar = { TopAppBar( - title = { Text("$category - ${month.format(DateTimeFormatter.ofPattern("yyyy年MM月"))}") }, + title = { Text(category) }, navigationIcon = { - IconButton(onClick = onBack) { - Icon(Icons.Default.ArrowBack, contentDescription = "返回") + IconButton(onClick = onNavigateBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回") } } ) } ) { padding -> - Column( - modifier = Modifier + LazyColumn( + modifier = modifier .fillMaxSize() - .padding(padding) + .padding(padding), + horizontalAlignment = Alignment.CenterHorizontally ) { - // 总金额显示 - Card( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = "总金额", - style = MaterialTheme.typography.titleMedium - ) - Text( - text = String.format("%.2f", total), - style = MaterialTheme.typography.titleLarge - ) - } + item { + Text( + text = NumberFormat.getCurrencyInstance(Locale.CHINA).format(total), + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.padding(16.dp) + ) } - // 记录列表 - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - groupedRecords.forEach { (date, dayRecords) -> - item { - Card( + item { + CategoryPieChart( + categoryData = memberStats.map { Pair(it.category, it.percentage.toFloat()) }, + memberData = emptyList(), + currentViewMode = false, + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + onCategoryClick = { memberName -> onNavigateToMemberDetail(memberName) } + ) + } + + // 按日期分组记录 + val groupedRecords = records.groupBy { record -> + SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(record.date) + }.toSortedMap(compareByDescending { it }) + + groupedRecords.forEach { (date, dayRecords) -> + item { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + ) { + Column( modifier = Modifier .fillMaxWidth() - .padding(vertical = 4.dp), - elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + .padding(16.dp) ) { - Column( + // 日期标题和总金额 + Row( modifier = Modifier .fillMaxWidth() - .padding(16.dp) + .padding(bottom = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween ) { - // 日期标签 Text( - text = SimpleDateFormat( - "yyyy年MM月dd日 E", - Locale.CHINESE - ).format(dayRecords.first().date), + text = date, style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + fontWeight = FontWeight.Bold ) + Text( + text = NumberFormat.getCurrencyInstance(Locale.CHINA) + .format(dayRecords.sumOf { it.amount }), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.error + ) + } - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(8.dp)) - // 当天的记录 - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - dayRecords.forEachIndexed { index, record -> - RecordItem( - record = record, - onClick = {}, - members = members - ) - - if (index < dayRecords.size - 1) { - HorizontalDivider( - modifier = Modifier.padding(vertical = 4.dp), - color = MaterialTheme.colorScheme.surfaceVariant, - thickness = 0.5.dp - ) - } - } + // 当天的记录列表 + dayRecords.forEach { record -> + RecordItem(record = record) + if (record != dayRecords.last()) { + HorizontalDivider( + modifier = Modifier.padding(vertical = 8.dp), + color = MaterialTheme.colorScheme.outlineVariant + ) } } } @@ -145,3 +138,43 @@ fun CategoryDetailScreen( } } } + +@Composable +private fun RecordItem( + record: BookkeepingRecord, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = record.memberId.toString(), // 暂时显示 memberId,后续可以通过 MemberDao 获取成员名称 + style = MaterialTheme.typography.titleMedium + ) + if (record.description.isNotBlank()) { + Text( + text = record.description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Text( + text = SimpleDateFormat("HH:mm", Locale.getDefault()).format(record.date), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Text( + text = NumberFormat.getCurrencyInstance(Locale.CHINA).format(record.amount), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.error + ) + } +} diff --git a/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/MemberDetailScreen.kt b/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/MemberDetailScreen.kt index 99f6129..8237601 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/MemberDetailScreen.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/MemberDetailScreen.kt @@ -2,7 +2,6 @@ package com.yovinchen.bookkeeping.ui.screen import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material3.* @@ -12,7 +11,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel -import com.yovinchen.bookkeeping.model.BookkeepingRecord +import com.yovinchen.bookkeeping.data.Record +import com.yovinchen.bookkeeping.ui.components.RecordItem import com.yovinchen.bookkeeping.viewmodel.MemberDetailViewModel import java.text.NumberFormat import java.text.SimpleDateFormat @@ -24,6 +24,7 @@ import java.util.* @Composable fun MemberDetailScreen( memberName: String, + category: String, yearMonth: YearMonth, onNavigateBack: () -> Unit, viewModel: MemberDetailViewModel = viewModel() @@ -31,8 +32,8 @@ fun MemberDetailScreen( val records by viewModel.memberRecords.collectAsState(initial = emptyList()) val totalAmount by viewModel.totalAmount.collectAsState(initial = 0.0) - LaunchedEffect(memberName, yearMonth) { - viewModel.loadMemberRecords(memberName, yearMonth) + LaunchedEffect(memberName, category, yearMonth) { + viewModel.loadMemberRecords(memberName, category, yearMonth) } val groupedRecords = remember(records) { @@ -45,7 +46,7 @@ fun MemberDetailScreen( topBar = { TopAppBar( title = { - Text("$memberName - ${yearMonth.format(DateTimeFormatter.ofPattern("yyyy年MM月"))}") + Text("$category - $memberName") }, navigationIcon = { IconButton(onClick = onNavigateBack) { @@ -55,50 +56,72 @@ fun MemberDetailScreen( ) } ) { padding -> - Column( + LazyColumn( modifier = Modifier .fillMaxSize() .padding(padding) ) { - // 总金额显示 - Card( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - ) { - Column( + // 第一层:总金额卡片 + item { + Card( modifier = Modifier - .padding(16.dp) + .fillMaxWidth() + .padding(16.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) ) { - Text( - text = "总支出", - style = MaterialTheme.typography.titleMedium - ) - Text( - text = NumberFormat.getCurrencyInstance(Locale.CHINA) - .format(totalAmount), - style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.Bold - ) + Column( + modifier = Modifier + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "当前分类总支出", + style = MaterialTheme.typography.titleMedium + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = NumberFormat.getCurrencyInstance(Locale.CHINA) + .format(totalAmount), + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold + ) + } } } - // 按日期分组的记录列表 - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - groupedRecords.forEach { (date, dayRecords) -> - item { - Text( - text = date, - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(vertical = 8.dp) - ) - } - items(dayRecords.sortedByDescending { it.date }) { record -> - RecordItem(record = record) + // 第二层:按日期分组的记录列表 + groupedRecords.forEach { (date, dayRecords) -> + item { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = date, + style = MaterialTheme.typography.titleMedium + ) + Text( + text = NumberFormat.getCurrencyInstance(Locale.CHINA) + .format(dayRecords.sumOf { it.amount }), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary + ) + } + dayRecords.forEach { record -> + RecordItem(record = record) + } + } } } } @@ -106,44 +129,34 @@ fun MemberDetailScreen( } } -@OptIn(ExperimentalMaterial3Api::class) @Composable -private fun RecordItem(record: BookkeepingRecord) { - Card( - modifier = Modifier.fillMaxWidth() +private fun RecordItem(record: Record) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column( - modifier = Modifier.weight(1f) - ) { + Column { + if (record.description.isNotBlank()) { Text( - text = record.category, - style = MaterialTheme.typography.titleMedium - ) - if (record.description.isNotBlank()) { - Text( - text = record.description, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - Text( - text = SimpleDateFormat("HH:mm", Locale.getDefault()).format(record.date), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + text = record.description, + style = MaterialTheme.typography.bodyMedium ) } Text( - text = NumberFormat.getCurrencyInstance(Locale.CHINA).format(record.amount), - style = MaterialTheme.typography.titleMedium, - color = if (record.amount < 0) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary + text = SimpleDateFormat("HH:mm", Locale.getDefault()) + .format(record.dateTime), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant ) } + Text( + text = NumberFormat.getCurrencyInstance(Locale.CHINA) + .format(record.amount), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary + ) } } diff --git a/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/CategoryDetailViewModel.kt b/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/CategoryDetailViewModel.kt index 1379818..ada1d52 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/CategoryDetailViewModel.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/CategoryDetailViewModel.kt @@ -1,14 +1,12 @@ package com.yovinchen.bookkeeping.viewmodel import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import com.yovinchen.bookkeeping.data.BookkeepingDatabase import com.yovinchen.bookkeeping.model.BookkeepingRecord -import com.yovinchen.bookkeeping.model.Member -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch +import com.yovinchen.bookkeeping.model.CategoryStat +import kotlinx.coroutines.flow.* import java.time.YearMonth import java.time.format.DateTimeFormatter @@ -17,36 +15,40 @@ class CategoryDetailViewModel( private val category: String, private val month: YearMonth ) : ViewModel() { + private val recordDao = database.bookkeepingDao() + private val yearMonthStr = month.format(DateTimeFormatter.ofPattern("yyyy-MM")) + private val _records = MutableStateFlow>(emptyList()) - val records: StateFlow> = _records + val records: StateFlow> = _records.asStateFlow() - private val _total = MutableStateFlow(0.0) - val total: StateFlow = _total + private val _memberStats = MutableStateFlow>(emptyList()) + val memberStats: StateFlow> = _memberStats.asStateFlow() - private val _members = MutableStateFlow>(emptyList()) - val members: StateFlow> = _members + val total: StateFlow = records + .map { records -> records.sumOf { it.amount } } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = 0.0 + ) init { - loadRecords() - loadMembers() - } - - private fun loadRecords() { - viewModelScope.launch { - val monthStr = month.format(DateTimeFormatter.ofPattern("yyyy-MM")) - database.bookkeepingDao().getRecordsByCategoryAndMonth(category, monthStr) - .collect { records -> - _records.value = records - _total.value = records.sumOf { it.amount } + recordDao.getRecordsByCategory(category) + .onEach { records -> + _records.value = records.filter { record -> + val recordMonth = YearMonth.from( + DateTimeFormatter.ofPattern("yyyy-MM") + .parse(yearMonthStr) + ) + YearMonth.from(record.date.toInstant().atZone(java.time.ZoneId.systemDefault()).toLocalDateTime()) == recordMonth } - } - } - - private fun loadMembers() { - viewModelScope.launch { - database.memberDao().getAllMembers().collect { members -> - _members.value = members } - } + .launchIn(viewModelScope) + + recordDao.getMemberStatsByCategory(category, yearMonthStr) + .onEach { stats -> + _memberStats.value = stats + } + .launchIn(viewModelScope) } } diff --git a/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/MemberDetailViewModel.kt b/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/MemberDetailViewModel.kt index 2e56cf7..939168b 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/MemberDetailViewModel.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/MemberDetailViewModel.kt @@ -5,30 +5,43 @@ import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.yovinchen.bookkeeping.data.BookkeepingDatabase import com.yovinchen.bookkeeping.model.BookkeepingRecord -import kotlinx.coroutines.flow.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch import java.time.YearMonth -import java.time.format.DateTimeFormatter +import java.time.ZoneId +import java.util.Date class MemberDetailViewModel(application: Application) : AndroidViewModel(application) { - private val recordDao = BookkeepingDatabase.getDatabase(application).bookkeepingDao() + private val database = BookkeepingDatabase.getDatabase(application) + private val recordDao = database.bookkeepingDao() private val _memberRecords = MutableStateFlow>(emptyList()) - val memberRecords: StateFlow> = _memberRecords.asStateFlow() + val memberRecords: StateFlow> = _memberRecords - val totalAmount: StateFlow = _memberRecords - .map { records -> records.sumOf { it.amount } } - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5000), - initialValue = 0.0 - ) + private val _totalAmount = MutableStateFlow(0.0) + val totalAmount: StateFlow = _totalAmount - fun loadMemberRecords(memberName: String, yearMonth: YearMonth) { - recordDao.getRecordsByMemberAndMonth( - memberName, - yearMonth.format(DateTimeFormatter.ofPattern("yyyy-MM")) - ).onEach { records -> + fun loadMemberRecords(memberName: String, category: String, yearMonth: YearMonth) { + viewModelScope.launch { + val startDate = yearMonth.atDay(1).atStartOfDay() + .atZone(ZoneId.systemDefault()) + .toInstant() + .let { Date.from(it) } + + val endDate = yearMonth.atEndOfMonth().atTime(23, 59, 59) + .atZone(ZoneId.systemDefault()) + .toInstant() + .let { Date.from(it) } + + val records = recordDao.getRecordsByMemberAndCategory( + memberName = memberName, + category = category, + startDate = startDate, + endDate = endDate + ) _memberRecords.value = records - }.launchIn(viewModelScope) + _totalAmount.value = records.sumOf { it.amount } + } } } From 37b91ded7fb0e5519b928492f2e40c8697dd7ee8 Mon Sep 17 00:00:00 2001 From: yovinchen Date: Thu, 28 Nov 2024 18:01:55 +0800 Subject: [PATCH 17/34] =?UTF-8?q?refactor:=20UI=E7=95=8C=E9=9D=A2=E5=92=8C?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E9=87=8D=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 简化 AnalysisViewModel 使用 Flow 组合 2. 改进 AnalysisScreen 的布局结构 3. 优化 CategoryDetailScreen 的视觉层次 4. 修复统计中成员名称的处理 --- .../ui/screen/CategoryDetailScreen.kt | 77 +++++++++++++++---- .../ui/screen/MemberDetailScreen.kt | 2 +- 2 files changed, 63 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/CategoryDetailScreen.kt b/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/CategoryDetailScreen.kt index 9400144..2460a98 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/CategoryDetailScreen.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/CategoryDetailScreen.kt @@ -62,27 +62,74 @@ fun CategoryDetailScreen( .padding(padding), horizontalAlignment = Alignment.CenterHorizontally ) { + // 第一部分:总支出 + item { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) + ) { + Column( + modifier = Modifier + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "总支出", + style = MaterialTheme.typography.titleMedium + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = NumberFormat.getCurrencyInstance(Locale.CHINA).format(total), + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold + ) + } + } + } + + // 第二部分:扇形图 + item { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) + ) { + Column( + modifier = Modifier + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "成员分布", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(bottom = 16.dp) + ) + CategoryPieChart( + categoryData = memberStats.map { Pair(it.category, it.percentage.toFloat()) }, + memberData = emptyList(), + currentViewMode = false, + modifier = Modifier + .fillMaxWidth() + .height(200.dp), + onCategoryClick = { memberName -> onNavigateToMemberDetail(memberName) } + ) + } + } + } + + // 第三部分:详细信息 item { Text( - text = NumberFormat.getCurrencyInstance(Locale.CHINA).format(total), - style = MaterialTheme.typography.headlineMedium, + text = "详细记录", + style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(16.dp) ) } - item { - CategoryPieChart( - categoryData = memberStats.map { Pair(it.category, it.percentage.toFloat()) }, - memberData = emptyList(), - currentViewMode = false, - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - onCategoryClick = { memberName -> onNavigateToMemberDetail(memberName) } - ) - } - - // 按日期分组记录 + // 按日期分组的记录列表 val groupedRecords = records.groupBy { record -> SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(record.date) }.toSortedMap(compareByDescending { it }) diff --git a/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/MemberDetailScreen.kt b/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/MemberDetailScreen.kt index 8237601..77574aa 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/MemberDetailScreen.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/MemberDetailScreen.kt @@ -75,7 +75,7 @@ fun MemberDetailScreen( horizontalAlignment = Alignment.CenterHorizontally ) { Text( - text = "当前分类总支出", + text = "总支出", style = MaterialTheme.typography.titleMedium ) Spacer(modifier = Modifier.height(8.dp)) From 882435e25a9384aeee398200e2d83f89afa1bfa1 Mon Sep 17 00:00:00 2001 From: yovinchen Date: Thu, 28 Nov 2024 18:03:35 +0800 Subject: [PATCH 18/34] =?UTF-8?q?chore:=20=E6=9B=B4=E6=96=B0=E5=BA=94?= =?UTF-8?q?=E7=94=A8=E7=89=88=E6=9C=AC=E5=8F=B7=E5=88=B0=20v1.2.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2920943..abdcf9d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -16,8 +16,8 @@ android { applicationId = "com.yovinchen.bookkeeping" minSdk = 26 targetSdk = 34 - versionCode = 1 - versionName = "1.0.0" + versionCode = 4 + versionName = "1.2.2" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { From 70e79ec584ac521f2bdd3684f12d177c41c96aa4 Mon Sep 17 00:00:00 2001 From: yovinchen Date: Thu, 28 Nov 2024 23:25:51 +0800 Subject: [PATCH 19/34] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E6=96=87?= =?UTF-8?q?=E5=AD=97=E6=98=BE=E7=A4=BA=E9=94=99=E8=AF=AF=20=E6=94=B9?= =?UTF-8?q?=E8=BF=9B=E5=AF=BC=E5=85=A5=E8=AF=AD=E5=8F=A5=E5=92=8CUI?= =?UTF-8?q?=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ui/screen/CategoryDetailScreen.kt | 32 +++++++++++++---- .../ui/screen/MemberDetailScreen.kt | 35 ++++++++++++++----- 2 files changed, 52 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/CategoryDetailScreen.kt b/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/CategoryDetailScreen.kt index 2460a98..2f6f2be 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/CategoryDetailScreen.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/CategoryDetailScreen.kt @@ -1,12 +1,30 @@ package com.yovinchen.bookkeeping.ui.screen -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -15,6 +33,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.yovinchen.bookkeeping.data.BookkeepingDatabase import com.yovinchen.bookkeeping.model.BookkeepingRecord +import com.yovinchen.bookkeeping.model.TransactionType import com.yovinchen.bookkeeping.ui.components.CategoryPieChart import com.yovinchen.bookkeeping.ui.components.RecordItem import com.yovinchen.bookkeeping.viewmodel.CategoryDetailViewModel @@ -22,8 +41,7 @@ import com.yovinchen.bookkeeping.viewmodel.CategoryDetailViewModelFactory import java.text.NumberFormat import java.text.SimpleDateFormat import java.time.YearMonth -import java.time.format.DateTimeFormatter -import java.util.* +import java.util.Locale @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -76,7 +94,7 @@ fun CategoryDetailScreen( horizontalAlignment = Alignment.CenterHorizontally ) { Text( - text = "总支出", + text = if (records.isNotEmpty() && records.first().type == TransactionType.INCOME) "总收入" else "总支出", style = MaterialTheme.typography.titleMedium ) Spacer(modifier = Modifier.height(8.dp)) diff --git a/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/MemberDetailScreen.kt b/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/MemberDetailScreen.kt index 77574aa..896a86f 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/MemberDetailScreen.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/MemberDetailScreen.kt @@ -1,24 +1,43 @@ package com.yovinchen.bookkeeping.ui.screen -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowBack -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.yovinchen.bookkeeping.data.Record +import com.yovinchen.bookkeeping.model.TransactionType import com.yovinchen.bookkeeping.ui.components.RecordItem import com.yovinchen.bookkeeping.viewmodel.MemberDetailViewModel import java.text.NumberFormat import java.text.SimpleDateFormat import java.time.YearMonth -import java.time.format.DateTimeFormatter -import java.util.* +import java.util.Locale @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -50,7 +69,7 @@ fun MemberDetailScreen( }, navigationIcon = { IconButton(onClick = onNavigateBack) { - Icon(Icons.Default.ArrowBack, "返回") + Icon(Icons.AutoMirrored.Filled.ArrowBack, "返回") } } ) @@ -75,7 +94,7 @@ fun MemberDetailScreen( horizontalAlignment = Alignment.CenterHorizontally ) { Text( - text = "总支出", + text = if (records.isNotEmpty() && records.first().type == TransactionType.INCOME) "总收入" else "总支出", style = MaterialTheme.typography.titleMedium ) Spacer(modifier = Modifier.height(8.dp)) From 63149f9abb2acc8751b9fa936d169479bfdd9381 Mon Sep 17 00:00:00 2001 From: yovinchen Date: Thu, 5 Dec 2024 11:26:21 +0800 Subject: [PATCH 20/34] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E6=88=90?= =?UTF-8?q?=E5=91=98=E8=A7=86=E5=9B=BE=E5=B1=95=E7=A4=BA=E9=80=BB=E8=BE=91?= =?UTF-8?q?=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../bookkeeping/data/BookkeepingDao.kt | 38 +++++++++++++++++-- .../ui/navigation/MainNavigation.kt | 31 ++++++++++----- .../bookkeeping/ui/screen/AnalysisScreen.kt | 8 ++-- .../ui/screen/MemberDetailScreen.kt | 11 ++++-- .../viewmodel/MemberDetailViewModel.kt | 32 ++++++++++++---- 5 files changed, 92 insertions(+), 28 deletions(-) diff --git a/app/src/main/java/com/yovinchen/bookkeeping/data/BookkeepingDao.kt b/app/src/main/java/com/yovinchen/bookkeeping/data/BookkeepingDao.kt index 0e33bae..3bcecb2 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/data/BookkeepingDao.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/data/BookkeepingDao.kt @@ -110,15 +110,47 @@ interface BookkeepingDao { @Query(""" SELECT * FROM bookkeeping_records - WHERE memberId IN (SELECT id FROM members WHERE name = :memberName) - AND category = :category + WHERE memberId IN (SELECT id FROM members WHERE name = :memberName) AND date BETWEEN :startDate AND :endDate + AND ( + :transactionType IS NULL + OR type = ( + CASE :transactionType + WHEN 'INCOME' THEN 'INCOME' + WHEN 'EXPENSE' THEN 'EXPENSE' + END + ) + ) + ORDER BY date DESC + """) + suspend fun getRecordsByMember( + memberName: String, + startDate: Date, + endDate: Date, + transactionType: TransactionType? + ): List + + @Query(""" + SELECT * FROM bookkeeping_records + WHERE memberId IN (SELECT id FROM members WHERE name = :memberName) + AND category = :category + AND date BETWEEN :startDate AND :endDate + AND ( + :transactionType IS NULL + OR type = ( + CASE :transactionType + WHEN 'INCOME' THEN 'INCOME' + WHEN 'EXPENSE' THEN 'EXPENSE' + END + ) + ) ORDER BY date DESC """) suspend fun getRecordsByMemberAndCategory( memberName: String, category: String, startDate: Date, - endDate: Date + endDate: Date, + transactionType: TransactionType? ): List } diff --git a/app/src/main/java/com/yovinchen/bookkeeping/ui/navigation/MainNavigation.kt b/app/src/main/java/com/yovinchen/bookkeeping/ui/navigation/MainNavigation.kt index fd7fe2b..201bf25 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/ui/navigation/MainNavigation.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/ui/navigation/MainNavigation.kt @@ -22,6 +22,7 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument +import com.yovinchen.bookkeeping.model.AnalysisType import com.yovinchen.bookkeeping.model.ThemeMode import com.yovinchen.bookkeeping.ui.screen.* import java.time.YearMonth @@ -40,9 +41,9 @@ sealed class Screen( return "category_detail/$category/${yearMonth.format(DateTimeFormatter.ofPattern("yyyy-MM"))}" } } - object MemberDetail : Screen("member_detail/{memberName}/{category}/{yearMonth}", "成员详情") { - fun createRoute(memberName: String, category: String, yearMonth: YearMonth): String { - return "member_detail/$memberName/$category/${yearMonth.format(DateTimeFormatter.ofPattern("yyyy-MM"))}" + object MemberDetail : Screen("member_detail/{memberName}/{category}/{yearMonth}?type={type}", "成员详情") { + fun createRoute(memberName: String, category: String, yearMonth: YearMonth, type: AnalysisType): String { + return "member_detail/$memberName/$category/${yearMonth.format(DateTimeFormatter.ofPattern("yyyy-MM"))}?type=${type.name}" } } @@ -96,10 +97,8 @@ fun MainNavigation( onNavigateToCategoryDetail = { category, yearMonth -> navController.navigate(Screen.CategoryDetail.createRoute(category, yearMonth)) }, - onNavigateToMemberDetail = { memberName, yearMonth -> - // 在这里我们暂时使用一个默认分类,你需要根据实际情况修改这里的逻辑 - val defaultCategory = "默认" - navController.navigate(Screen.MemberDetail.createRoute(memberName, defaultCategory, yearMonth)) + onNavigateToMemberDetail = { memberName, yearMonth, analysisType -> + navController.navigate(Screen.MemberDetail.createRoute(memberName, "", yearMonth, analysisType)) } ) } @@ -127,7 +126,7 @@ fun MainNavigation( yearMonth = yearMonth, onNavigateBack = { navController.popBackStack() }, onNavigateToMemberDetail = { memberName -> - navController.navigate(Screen.MemberDetail.createRoute(memberName, category, yearMonth)) + navController.navigate(Screen.MemberDetail.createRoute(memberName, category, yearMonth, AnalysisType.EXPENSE)) } ) } @@ -137,18 +136,30 @@ fun MainNavigation( arguments = listOf( navArgument("memberName") { type = NavType.StringType }, navArgument("category") { type = NavType.StringType }, - navArgument("yearMonth") { type = NavType.StringType } + navArgument("yearMonth") { type = NavType.StringType }, + navArgument("type") { + type = NavType.StringType + defaultValue = AnalysisType.EXPENSE.name + } ) ) { backStackEntry -> val memberName = backStackEntry.arguments?.getString("memberName") ?: return@composable val category = backStackEntry.arguments?.getString("category") ?: return@composable val yearMonthStr = backStackEntry.arguments?.getString("yearMonth") ?: return@composable val yearMonth = YearMonth.parse(yearMonthStr) + val type = backStackEntry.arguments?.getString("type")?.let { + try { + AnalysisType.valueOf(it) + } catch (e: IllegalArgumentException) { + AnalysisType.EXPENSE + } + } ?: AnalysisType.EXPENSE MemberDetailScreen( memberName = memberName, - category = category, yearMonth = yearMonth, + category = category, + analysisType = type, onNavigateBack = { navController.popBackStack() } ) } 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 01ffd31..f150084 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 @@ -29,7 +29,7 @@ enum class ViewMode { @Composable fun AnalysisScreen( onNavigateToCategoryDetail: (String, YearMonth) -> Unit, - onNavigateToMemberDetail: (String, YearMonth) -> Unit + onNavigateToMemberDetail: (String, YearMonth, AnalysisType) -> Unit ) { val viewModel: AnalysisViewModel = viewModel() val selectedMonth by viewModel.selectedMonth.collectAsState() @@ -138,7 +138,7 @@ fun AnalysisScreen( if (currentViewMode == ViewMode.CATEGORY) { onNavigateToCategoryDetail(category, selectedMonth) } else { - onNavigateToMemberDetail(category, selectedMonth) + onNavigateToMemberDetail(category, selectedMonth, selectedAnalysisType) } } ) @@ -149,11 +149,11 @@ fun AnalysisScreen( items(if (currentViewMode == ViewMode.CATEGORY) categoryStats else memberStats) { stat -> CategoryStatItem( stat = stat, - onClick = { + onClick = { if (currentViewMode == ViewMode.CATEGORY) { onNavigateToCategoryDetail(stat.category, selectedMonth) } else { - onNavigateToMemberDetail(stat.category, selectedMonth) + onNavigateToMemberDetail(stat.category, selectedMonth, selectedAnalysisType) } } ) diff --git a/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/MemberDetailScreen.kt b/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/MemberDetailScreen.kt index 896a86f..03af6bd 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/MemberDetailScreen.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/MemberDetailScreen.kt @@ -25,6 +25,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight @@ -32,6 +33,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.yovinchen.bookkeeping.data.Record import com.yovinchen.bookkeeping.model.TransactionType +import com.yovinchen.bookkeeping.model.AnalysisType import com.yovinchen.bookkeeping.ui.components.RecordItem import com.yovinchen.bookkeeping.viewmodel.MemberDetailViewModel import java.text.NumberFormat @@ -43,16 +45,17 @@ import java.util.Locale @Composable fun MemberDetailScreen( memberName: String, - category: String, yearMonth: YearMonth, + category: String = "", + analysisType: AnalysisType = AnalysisType.EXPENSE, onNavigateBack: () -> Unit, viewModel: MemberDetailViewModel = viewModel() ) { val records by viewModel.memberRecords.collectAsState(initial = emptyList()) val totalAmount by viewModel.totalAmount.collectAsState(initial = 0.0) - LaunchedEffect(memberName, category, yearMonth) { - viewModel.loadMemberRecords(memberName, category, yearMonth) + LaunchedEffect(memberName, category, yearMonth, analysisType) { + viewModel.loadMemberRecords(memberName, category, yearMonth, analysisType) } val groupedRecords = remember(records) { @@ -65,7 +68,7 @@ fun MemberDetailScreen( topBar = { TopAppBar( title = { - Text("$category - $memberName") + Text(memberName) }, navigationIcon = { IconButton(onClick = onNavigateBack) { diff --git a/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/MemberDetailViewModel.kt b/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/MemberDetailViewModel.kt index 939168b..c623216 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/MemberDetailViewModel.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/MemberDetailViewModel.kt @@ -5,6 +5,8 @@ import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.yovinchen.bookkeeping.data.BookkeepingDatabase import com.yovinchen.bookkeeping.model.BookkeepingRecord +import com.yovinchen.bookkeeping.model.AnalysisType +import com.yovinchen.bookkeeping.model.TransactionType import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch @@ -22,7 +24,7 @@ class MemberDetailViewModel(application: Application) : AndroidViewModel(applica private val _totalAmount = MutableStateFlow(0.0) val totalAmount: StateFlow = _totalAmount - fun loadMemberRecords(memberName: String, category: String, yearMonth: YearMonth) { + fun loadMemberRecords(memberName: String, category: String, yearMonth: YearMonth, analysisType: AnalysisType) { viewModelScope.launch { val startDate = yearMonth.atDay(1).atStartOfDay() .atZone(ZoneId.systemDefault()) @@ -34,12 +36,28 @@ class MemberDetailViewModel(application: Application) : AndroidViewModel(applica .toInstant() .let { Date.from(it) } - val records = recordDao.getRecordsByMemberAndCategory( - memberName = memberName, - category = category, - startDate = startDate, - endDate = endDate - ) + val transactionType = when (analysisType) { + AnalysisType.INCOME -> TransactionType.INCOME + AnalysisType.EXPENSE -> TransactionType.EXPENSE + else -> null + } + + val records = if (category.isEmpty()) { + recordDao.getRecordsByMember( + memberName = memberName, + startDate = startDate, + endDate = endDate, + transactionType = transactionType + ) + } else { + recordDao.getRecordsByMemberAndCategory( + memberName = memberName, + category = category, + startDate = startDate, + endDate = endDate, + transactionType = transactionType + ) + } _memberRecords.value = records _totalAmount.value = records.sumOf { it.amount } } From a0d47864d872d7418c4565820986b1e1e69616c7 Mon Sep 17 00:00:00 2001 From: yovinchen Date: Thu, 5 Dec 2024 11:43:44 +0800 Subject: [PATCH 21/34] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=88=86?= =?UTF-8?q?=E7=B1=BB=E8=A7=86=E5=9B=BE=E5=B1=95=E7=A4=BA=E9=80=BB=E8=BE=91?= =?UTF-8?q?=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../yovinchen/bookkeeping/ui/screen/CategoryDetailScreen.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/CategoryDetailScreen.kt b/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/CategoryDetailScreen.kt index 2f6f2be..78b12ab 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/CategoryDetailScreen.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/CategoryDetailScreen.kt @@ -132,7 +132,11 @@ fun CategoryDetailScreen( modifier = Modifier .fillMaxWidth() .height(200.dp), - onCategoryClick = { memberName -> onNavigateToMemberDetail(memberName) } + onCategoryClick = { memberName -> + if (records.isNotEmpty() && records.first().type == TransactionType.EXPENSE) { + onNavigateToMemberDetail(memberName) + } + } ) } } From 713037b2669805f81276ce260e3f3d044d3adc8c Mon Sep 17 00:00:00 2001 From: yovinchen Date: Thu, 5 Dec 2024 11:46:39 +0800 Subject: [PATCH 22/34] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E8=AD=A6?= =?UTF-8?q?=E5=91=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../bookkeeping/ui/screen/AnalysisScreen.kt | 29 +++++++++++++++---- .../ui/screen/MemberDetailScreen.kt | 3 +- 2 files changed, 25 insertions(+), 7 deletions(-) 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 f150084..fdd7645 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 @@ -1,16 +1,35 @@ package com.yovinchen.bookkeeping.ui.screen -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowDropDown -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material3.Button +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.yovinchen.bookkeeping.model.AnalysisType @@ -99,7 +118,7 @@ fun AnalysisScreen( // 类型切换 Row { - AnalysisType.values().forEach { type -> + AnalysisType.entries.forEach { type -> FilterChip( selected = selectedAnalysisType == type, onClick = { viewModel.setAnalysisType(type) }, diff --git a/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/MemberDetailScreen.kt b/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/MemberDetailScreen.kt index 03af6bd..17079a7 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/MemberDetailScreen.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/MemberDetailScreen.kt @@ -25,15 +25,14 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.yovinchen.bookkeeping.data.Record -import com.yovinchen.bookkeeping.model.TransactionType import com.yovinchen.bookkeeping.model.AnalysisType +import com.yovinchen.bookkeeping.model.TransactionType import com.yovinchen.bookkeeping.ui.components.RecordItem import com.yovinchen.bookkeeping.viewmodel.MemberDetailViewModel import java.text.NumberFormat From abf529117fee32635d82d49e792771a4ec97fbd5 Mon Sep 17 00:00:00 2001 From: yovinchen Date: Thu, 5 Dec 2024 11:52:50 +0800 Subject: [PATCH 23/34] chore: update misc.xml --- .idea/misc.xml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.idea/misc.xml b/.idea/misc.xml index b2c751a..312151c 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,4 +1,10 @@ + + + + + + From 96d5fab40c1f9c79e0eadd6f81dcfccaa3d4ac85 Mon Sep 17 00:00:00 2001 From: yovinchen Date: Thu, 5 Dec 2024 11:56:43 +0800 Subject: [PATCH 24/34] =?UTF-8?q?feat:=20=E7=BB=9F=E4=B8=80=20RecordItem?= =?UTF-8?q?=20=E5=B1=95=E7=A4=BA=E9=80=BB=E8=BE=91=EF=BC=8C=E4=B8=8E?= =?UTF-8?q?=E9=A6=96=E9=A1=B5=E4=BF=9D=E6=8C=81=E4=B8=80=E8=87=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../bookkeeping/ui/components/RecordItem.kt | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/yovinchen/bookkeeping/ui/components/RecordItem.kt b/app/src/main/java/com/yovinchen/bookkeeping/ui/components/RecordItem.kt index 0b41365..cf40b1a 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/ui/components/RecordItem.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/ui/components/RecordItem.kt @@ -22,7 +22,6 @@ fun RecordItem( members: List = emptyList() ) { var showDeleteDialog by remember { mutableStateOf(false) } -// val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) } val timeFormat = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) } val member = members.find { it.id == record.memberId } @@ -49,18 +48,12 @@ fun RecordItem( style = MaterialTheme.typography.bodyLarge ) - // 第二行:日期和时间 | 成员 | 详情 + // 第二行:时间 | 成员 | 详情 Text( text = buildString { -// append(dateFormat.format(record.date)) -// append(" ") append(timeFormat.format(record.date)) -// if (member != null && member.name != "自己") { - append(" | ") - if (member != null) { - append(member.name) - } -// } + append(" | ") + append(member?.name ?: "自己") if (record.description.isNotEmpty()) { append(" | ") append(record.description) From c92cc18dde981427d6dfc4b8ad9af38a9792b1c9 Mon Sep 17 00:00:00 2001 From: yovinchen Date: Thu, 5 Dec 2024 13:46:17 +0800 Subject: [PATCH 25/34] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E6=97=B6?= =?UTF-8?q?=E9=97=B4=E5=8C=BA=E9=97=B4=E9=80=89=E6=8B=A9=E5=92=8C=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=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 } } From 3296f6d154a4069badd684b80c816421326b7c63 Mon Sep 17 00:00:00 2001 From: yovinchen Date: Thu, 5 Dec 2024 14:35:01 +0800 Subject: [PATCH 26/34] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=E6=97=B6?= =?UTF-8?q?=E9=97=B4=E8=8C=83=E5=9B=B4=E7=AD=9B=E9=80=89=E5=92=8C=E6=88=90?= =?UTF-8?q?=E5=91=98=E7=BB=9F=E8=AE=A1=E6=98=BE=E7=A4=BA=201.=20=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=20BookkeepingDao=20=E6=94=AF=E6=8C=81=E6=97=B6?= =?UTF-8?q?=E9=97=B4=E8=8C=83=E5=9B=B4=E7=AD=9B=E9=80=89=202.=20=E9=87=8D?= =?UTF-8?q?=E6=9E=84=20CategoryDetailViewModel=20=E5=8F=8A=E5=85=B6?= =?UTF-8?q?=E5=B7=A5=E5=8E=82=E7=B1=BB=203.=20=E4=B8=BA=20MemberStat=20?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=20Room=20=E6=B3=A8=E8=A7=A3=204.=20=E6=94=B9?= =?UTF-8?q?=E8=BF=9B=20CategoryDetailScreen=EF=BC=8C=E7=BB=93=E5=90=88?= =?UTF-8?q?=E9=A5=BC=E7=8A=B6=E5=9B=BE=E5=92=8C=E5=88=97=E8=A1=A8=E8=A7=86?= =?UTF-8?q?=E5=9B=BE=205.=20=E4=BC=98=E5=8C=96=E6=95=B0=E6=8D=AE=E5=BA=93?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2=E5=92=8C=E7=8A=B6=E6=80=81=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../bookkeeping/data/BookkeepingDao.kt | 79 ++++++++++++--- .../yovinchen/bookkeeping/model/MemberStat.kt | 9 ++ .../ui/navigation/MainNavigation.kt | 99 ++++++++++++------- .../bookkeeping/ui/screen/AnalysisScreen.kt | 19 ++-- .../ui/screen/CategoryDetailScreen.kt | 75 +++++++++++--- .../ui/screen/MemberDetailScreen.kt | 13 ++- .../viewmodel/CategoryDetailViewModel.kt | 53 +++++----- .../CategoryDetailViewModelFactory.kt | 5 +- .../viewmodel/MemberDetailViewModel.kt | 85 ++++++++-------- 9 files changed, 303 insertions(+), 134 deletions(-) diff --git a/app/src/main/java/com/yovinchen/bookkeeping/data/BookkeepingDao.kt b/app/src/main/java/com/yovinchen/bookkeeping/data/BookkeepingDao.kt index 3bcecb2..aea2425 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/data/BookkeepingDao.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/data/BookkeepingDao.kt @@ -3,7 +3,7 @@ package com.yovinchen.bookkeeping.data import androidx.room.* import com.yovinchen.bookkeeping.model.BookkeepingRecord import com.yovinchen.bookkeeping.model.Category -import com.yovinchen.bookkeeping.model.CategoryStat +import com.yovinchen.bookkeeping.model.MemberStat import com.yovinchen.bookkeeping.model.TransactionType import kotlinx.coroutines.flow.Flow import java.util.Date @@ -51,15 +51,11 @@ interface BookkeepingDao { ): Flow> @Query(""" - SELECT m.name as category, - SUM(r.amount) as amount, - COUNT(*) as count, - (SUM(r.amount) * 100.0 / ( - SELECT SUM(amount) - FROM bookkeeping_records - WHERE category = :category - AND strftime('%Y-%m', datetime(date/1000, 'unixepoch')) = :yearMonth - )) as percentage + SELECT + m.name as member, + SUM(r.amount) as amount, + COUNT(*) as count, + (SUM(r.amount) * 100.0 / (SELECT SUM(amount) FROM bookkeeping_records WHERE category = :category AND strftime('%Y-%m', datetime(date/1000, 'unixepoch')) = :yearMonth)) as percentage FROM bookkeeping_records r JOIN members m ON r.memberId = m.id WHERE r.category = :category @@ -70,7 +66,7 @@ interface BookkeepingDao { fun getMemberStatsByCategory( category: String, yearMonth: String - ): Flow> + ): Flow> @Query(""" SELECT * FROM bookkeeping_records @@ -81,6 +77,67 @@ interface BookkeepingDao { category: String ): Flow> + @Query(""" + SELECT * FROM bookkeeping_records + WHERE category = :category + AND date BETWEEN :startDate AND :endDate + ORDER BY date DESC + """) + fun getRecordsByCategoryAndDateRange( + category: String, + startDate: Date, + endDate: Date + ): Flow> + + @Query(""" + SELECT * FROM bookkeeping_records + WHERE memberId IN (SELECT id FROM members WHERE name = :memberName) + AND date BETWEEN :startDate AND :endDate + AND (:transactionType IS NULL OR type = :transactionType) + ORDER BY date DESC + """) + fun getRecordsByMemberAndDateRange( + memberName: String, + startDate: Date, + endDate: Date, + transactionType: TransactionType? + ): Flow> + + @Query(""" + SELECT * FROM bookkeeping_records + WHERE memberId IN (SELECT id FROM members WHERE name = :memberName) + AND category = :category + AND date BETWEEN :startDate AND :endDate + AND (:transactionType IS NULL OR type = :transactionType) + ORDER BY date DESC + """) + fun getRecordsByMemberCategoryAndDateRange( + memberName: String, + category: String, + startDate: Date, + endDate: Date, + transactionType: TransactionType? + ): Flow> + + @Query(""" + SELECT + m.name as member, + SUM(r.amount) as amount, + COUNT(*) as count, + (SUM(r.amount) * 100.0 / (SELECT SUM(amount) FROM bookkeeping_records WHERE category = :category AND date BETWEEN :startDate AND :endDate)) as percentage + FROM bookkeeping_records r + JOIN members m ON r.memberId = m.id + WHERE r.category = :category + AND r.date BETWEEN :startDate AND :endDate + GROUP BY m.name + ORDER BY amount DESC + """) + fun getMemberStatsByCategoryAndDateRange( + category: String, + startDate: Date, + endDate: Date + ): Flow> + @Insert suspend fun insertRecord(record: BookkeepingRecord): Long diff --git a/app/src/main/java/com/yovinchen/bookkeeping/model/MemberStat.kt b/app/src/main/java/com/yovinchen/bookkeeping/model/MemberStat.kt index 75dce91..3c8be05 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/model/MemberStat.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/model/MemberStat.kt @@ -1,8 +1,17 @@ package com.yovinchen.bookkeeping.model +import androidx.room.ColumnInfo + data class MemberStat( + @ColumnInfo(name = "member") val member: String, + + @ColumnInfo(name = "amount") val amount: Double, + + @ColumnInfo(name = "count") val count: Int, + + @ColumnInfo(name = "percentage") val percentage: Double = 0.0 ) diff --git a/app/src/main/java/com/yovinchen/bookkeeping/ui/navigation/MainNavigation.kt b/app/src/main/java/com/yovinchen/bookkeeping/ui/navigation/MainNavigation.kt index 201bf25..aa7eb0f 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/ui/navigation/MainNavigation.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/ui/navigation/MainNavigation.kt @@ -36,14 +36,30 @@ sealed class Screen( object Home : Screen("home", "记账", Icons.AutoMirrored.Filled.List) object Analysis : Screen("analysis", "分析", Icons.Default.Analytics) object Settings : Screen("settings", "设置", Icons.Default.Settings) - object CategoryDetail : Screen("category_detail/{category}/{yearMonth}", "分类详情") { - fun createRoute(category: String, yearMonth: YearMonth): String { - return "category_detail/$category/${yearMonth.format(DateTimeFormatter.ofPattern("yyyy-MM"))}" + object CategoryDetail : Screen( + "category_detail/{category}/{startMonth}/{endMonth}", + "分类详情" + ) { + fun createRoute( + category: String, + startMonth: YearMonth, + endMonth: YearMonth + ): String { + return "category_detail/$category/${startMonth.format(DateTimeFormatter.ofPattern("yyyy-MM"))}/${endMonth.format(DateTimeFormatter.ofPattern("yyyy-MM"))}" } } - object MemberDetail : Screen("member_detail/{memberName}/{category}/{yearMonth}?type={type}", "成员详情") { - fun createRoute(memberName: String, category: String, yearMonth: YearMonth, type: AnalysisType): String { - return "member_detail/$memberName/$category/${yearMonth.format(DateTimeFormatter.ofPattern("yyyy-MM"))}?type=${type.name}" + object MemberDetail : Screen( + "member_detail/{memberName}/{category}/{startMonth}/{endMonth}?type={type}", + "成员详情" + ) { + fun createRoute( + memberName: String, + category: String, + startMonth: YearMonth, + endMonth: YearMonth, + type: AnalysisType + ): String { + return "member_detail/$memberName/$category/${startMonth.format(DateTimeFormatter.ofPattern("yyyy-MM"))}/${endMonth.format(DateTimeFormatter.ofPattern("yyyy-MM"))}?type=${type.name}" } } @@ -94,11 +110,11 @@ fun MainNavigation( composable(Screen.Analysis.route) { AnalysisScreen( - onNavigateToCategoryDetail = { category, yearMonth -> - navController.navigate(Screen.CategoryDetail.createRoute(category, yearMonth)) + onNavigateToCategoryDetail = { category, startMonth, endMonth -> + navController.navigate(Screen.CategoryDetail.createRoute(category, startMonth, endMonth)) }, - onNavigateToMemberDetail = { memberName, yearMonth, analysisType -> - navController.navigate(Screen.MemberDetail.createRoute(memberName, "", yearMonth, analysisType)) + onNavigateToMemberDetail = { memberName, startMonth, endMonth, analysisType -> + navController.navigate(Screen.MemberDetail.createRoute(memberName, "", startMonth, endMonth, analysisType)) } ) } @@ -114,51 +130,68 @@ fun MainNavigation( route = Screen.CategoryDetail.route, arguments = listOf( navArgument("category") { type = NavType.StringType }, - navArgument("yearMonth") { type = NavType.StringType } + navArgument("startMonth") { type = NavType.StringType }, + navArgument("endMonth") { type = NavType.StringType } ) ) { backStackEntry -> - val category = backStackEntry.arguments?.getString("category") ?: return@composable - val yearMonthStr = backStackEntry.arguments?.getString("yearMonth") ?: return@composable - val yearMonth = YearMonth.parse(yearMonthStr) - + val category = backStackEntry.arguments?.getString("category") ?: "" + val startMonth = YearMonth.parse( + backStackEntry.arguments?.getString("startMonth") ?: "", + DateTimeFormatter.ofPattern("yyyy-MM") + ) + val endMonth = YearMonth.parse( + backStackEntry.arguments?.getString("endMonth") ?: "", + DateTimeFormatter.ofPattern("yyyy-MM") + ) CategoryDetailScreen( category = category, - yearMonth = yearMonth, + startMonth = startMonth, + endMonth = endMonth, onNavigateBack = { navController.popBackStack() }, onNavigateToMemberDetail = { memberName -> - navController.navigate(Screen.MemberDetail.createRoute(memberName, category, yearMonth, AnalysisType.EXPENSE)) + navController.navigate( + Screen.MemberDetail.createRoute( + memberName = memberName, + category = category, + startMonth = startMonth, + endMonth = endMonth, + type = AnalysisType.EXPENSE + ) + ) } ) } - composable( route = Screen.MemberDetail.route, arguments = listOf( navArgument("memberName") { type = NavType.StringType }, navArgument("category") { type = NavType.StringType }, - navArgument("yearMonth") { type = NavType.StringType }, - navArgument("type") { + navArgument("startMonth") { type = NavType.StringType }, + navArgument("endMonth") { type = NavType.StringType }, + navArgument("type") { type = NavType.StringType defaultValue = AnalysisType.EXPENSE.name } ) ) { backStackEntry -> - val memberName = backStackEntry.arguments?.getString("memberName") ?: return@composable - val category = backStackEntry.arguments?.getString("category") ?: return@composable - val yearMonthStr = backStackEntry.arguments?.getString("yearMonth") ?: return@composable - val yearMonth = YearMonth.parse(yearMonthStr) - val type = backStackEntry.arguments?.getString("type")?.let { - try { - AnalysisType.valueOf(it) - } catch (e: IllegalArgumentException) { - AnalysisType.EXPENSE - } - } ?: AnalysisType.EXPENSE - + val memberName = backStackEntry.arguments?.getString("memberName") ?: "" + val category = backStackEntry.arguments?.getString("category") ?: "" + val startMonth = YearMonth.parse( + backStackEntry.arguments?.getString("startMonth") ?: "", + DateTimeFormatter.ofPattern("yyyy-MM") + ) + val endMonth = YearMonth.parse( + backStackEntry.arguments?.getString("endMonth") ?: "", + DateTimeFormatter.ofPattern("yyyy-MM") + ) + val type = AnalysisType.valueOf( + backStackEntry.arguments?.getString("type") ?: AnalysisType.EXPENSE.name + ) MemberDetailScreen( memberName = memberName, - yearMonth = yearMonth, category = category, + startMonth = startMonth, + endMonth = endMonth, analysisType = type, onNavigateBack = { navController.popBackStack() } ) 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 f3a2309..ebfb993 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 @@ -48,8 +48,9 @@ enum class ViewMode { @OptIn(ExperimentalMaterial3Api::class) @Composable fun AnalysisScreen( - onNavigateToCategoryDetail: (String, YearMonth) -> Unit, - onNavigateToMemberDetail: (String, YearMonth, AnalysisType) -> Unit + onNavigateToCategoryDetail: (String, YearMonth, YearMonth) -> Unit, + onNavigateToMemberDetail: (String, YearMonth, YearMonth, AnalysisType) -> Unit, + modifier: Modifier = Modifier ) { val viewModel: AnalysisViewModel = viewModel() val startMonth by viewModel.startMonth.collectAsState() @@ -61,11 +62,13 @@ fun AnalysisScreen( var showViewModeMenu by remember { mutableStateOf(false) } var currentViewMode by rememberSaveable { mutableStateOf(ViewMode.CATEGORY) } - Scaffold { padding -> + Scaffold( + modifier = modifier.fillMaxSize() + ) { paddingValues -> Column( modifier = Modifier .fillMaxSize() - .padding(padding) + .padding(paddingValues) ) { // 时间区间选择 DateRangePicker( @@ -151,9 +154,9 @@ fun AnalysisScreen( .padding(bottom = 16.dp), onCategoryClick = { category -> if (currentViewMode == ViewMode.CATEGORY) { - onNavigateToCategoryDetail(category, startMonth) + onNavigateToCategoryDetail(category, startMonth, endMonth) } else { - onNavigateToMemberDetail(category, startMonth, selectedAnalysisType) + onNavigateToMemberDetail(category, startMonth, endMonth, selectedAnalysisType) } } ) @@ -169,9 +172,9 @@ fun AnalysisScreen( stat = stat, onClick = { if (currentViewMode == ViewMode.CATEGORY && category != null) { - onNavigateToCategoryDetail(category, startMonth) + onNavigateToCategoryDetail(category, startMonth, endMonth) } else if (currentViewMode == ViewMode.MEMBER && member != null) { - onNavigateToMemberDetail(member, startMonth, selectedAnalysisType) + onNavigateToMemberDetail(member, startMonth, endMonth, selectedAnalysisType) } } ) diff --git a/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/CategoryDetailScreen.kt b/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/CategoryDetailScreen.kt index 78b12ab..da17a00 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/CategoryDetailScreen.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/CategoryDetailScreen.kt @@ -1,5 +1,6 @@ package com.yovinchen.bookkeeping.ui.screen +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -17,6 +18,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text @@ -33,6 +35,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.yovinchen.bookkeeping.data.BookkeepingDatabase import com.yovinchen.bookkeeping.model.BookkeepingRecord +import com.yovinchen.bookkeeping.model.MemberStat import com.yovinchen.bookkeeping.model.TransactionType import com.yovinchen.bookkeeping.ui.components.CategoryPieChart import com.yovinchen.bookkeeping.ui.components.RecordItem @@ -47,17 +50,20 @@ import java.util.Locale @Composable fun CategoryDetailScreen( category: String, - yearMonth: YearMonth, + startMonth: YearMonth, + endMonth: YearMonth, onNavigateBack: () -> Unit, onNavigateToMemberDetail: (String) -> Unit, + viewModel: CategoryDetailViewModel = viewModel( + factory = CategoryDetailViewModelFactory( + database = BookkeepingDatabase.getDatabase(LocalContext.current), + category = category, + startMonth = startMonth, + endMonth = endMonth + ) + ), modifier: Modifier = Modifier ) { - val context = LocalContext.current - val database = remember { BookkeepingDatabase.getDatabase(context) } - val viewModel: CategoryDetailViewModel = viewModel( - factory = CategoryDetailViewModelFactory(database, category, yearMonth) - ) - val records by viewModel.records.collectAsState() val memberStats by viewModel.memberStats.collectAsState() val total by viewModel.total.collectAsState() @@ -107,16 +113,16 @@ fun CategoryDetailScreen( } } - // 第二部分:扇形图 + // 第二部分:成员统计 item { Card( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) + .padding(16.dp) ) { Column( modifier = Modifier + .fillMaxWidth() .padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally ) { @@ -125,19 +131,33 @@ fun CategoryDetailScreen( style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(bottom = 16.dp) ) + + // 饼状图 CategoryPieChart( - categoryData = memberStats.map { Pair(it.category, it.percentage.toFloat()) }, - memberData = emptyList(), - currentViewMode = false, + categoryData = emptyList(), + memberData = memberStats.map { Pair(it.member, it.percentage.toFloat()) }, + currentViewMode = true, modifier = Modifier .fillMaxWidth() .height(200.dp), onCategoryClick = { memberName -> - if (records.isNotEmpty() && records.first().type == TransactionType.EXPENSE) { - onNavigateToMemberDetail(memberName) - } + onNavigateToMemberDetail(memberName) } ) + + Spacer(modifier = Modifier.height(16.dp)) + HorizontalDivider() + Spacer(modifier = Modifier.height(16.dp)) + + // 成员列表 + Column { + memberStats.forEach { stat -> + MemberStatItem( + stat = stat, + onClick = { onNavigateToMemberDetail(stat.member) } + ) + } + } } } } @@ -208,6 +228,29 @@ fun CategoryDetailScreen( } } +@Composable +private fun MemberStatItem( + stat: MemberStat, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + ListItem( + headlineContent = { Text(stat.member) }, + supportingContent = { + Text( + buildString { + append("金额: ¥%.2f".format(stat.amount)) + append(" | ") + append("次数: ${stat.count}") + append(" | ") + append("占比: %.1f%%".format(stat.percentage)) + } + ) + }, + modifier = modifier.clickable(onClick = onClick) + ) +} + @Composable private fun RecordItem( record: BookkeepingRecord, diff --git a/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/MemberDetailScreen.kt b/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/MemberDetailScreen.kt index 17079a7..4d145f9 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/MemberDetailScreen.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/MemberDetailScreen.kt @@ -44,7 +44,8 @@ import java.util.Locale @Composable fun MemberDetailScreen( memberName: String, - yearMonth: YearMonth, + startMonth: YearMonth, + endMonth: YearMonth, category: String = "", analysisType: AnalysisType = AnalysisType.EXPENSE, onNavigateBack: () -> Unit, @@ -53,8 +54,14 @@ fun MemberDetailScreen( val records by viewModel.memberRecords.collectAsState(initial = emptyList()) val totalAmount by viewModel.totalAmount.collectAsState(initial = 0.0) - LaunchedEffect(memberName, category, yearMonth, analysisType) { - viewModel.loadMemberRecords(memberName, category, yearMonth, analysisType) + LaunchedEffect(memberName, category, startMonth, endMonth, analysisType) { + viewModel.loadMemberRecords( + memberName = memberName, + category = category, + startMonth = startMonth, + endMonth = endMonth, + analysisType = analysisType + ) } val groupedRecords = remember(records) { diff --git a/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/CategoryDetailViewModel.kt b/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/CategoryDetailViewModel.kt index ada1d52..295f113 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/CategoryDetailViewModel.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/CategoryDetailViewModel.kt @@ -5,24 +5,25 @@ import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import com.yovinchen.bookkeeping.data.BookkeepingDatabase import com.yovinchen.bookkeeping.model.BookkeepingRecord -import com.yovinchen.bookkeeping.model.CategoryStat +import com.yovinchen.bookkeeping.model.MemberStat import kotlinx.coroutines.flow.* import java.time.YearMonth -import java.time.format.DateTimeFormatter +import java.time.ZoneId +import java.util.Date class CategoryDetailViewModel( private val database: BookkeepingDatabase, private val category: String, - private val month: YearMonth + private val startMonth: YearMonth, + private val endMonth: YearMonth ) : ViewModel() { private val recordDao = database.bookkeepingDao() - private val yearMonthStr = month.format(DateTimeFormatter.ofPattern("yyyy-MM")) private val _records = MutableStateFlow>(emptyList()) val records: StateFlow> = _records.asStateFlow() - private val _memberStats = MutableStateFlow>(emptyList()) - val memberStats: StateFlow> = _memberStats.asStateFlow() + private val _memberStats = MutableStateFlow>(emptyList()) + val memberStats: StateFlow> = _memberStats.asStateFlow() val total: StateFlow = records .map { records -> records.sumOf { it.amount } } @@ -33,22 +34,30 @@ class CategoryDetailViewModel( ) init { - recordDao.getRecordsByCategory(category) - .onEach { records -> - _records.value = records.filter { record -> - val recordMonth = YearMonth.from( - DateTimeFormatter.ofPattern("yyyy-MM") - .parse(yearMonthStr) - ) - YearMonth.from(record.date.toInstant().atZone(java.time.ZoneId.systemDefault()).toLocalDateTime()) == recordMonth - } - } - .launchIn(viewModelScope) + val startDate = startMonth.atDay(1).atStartOfDay() + .atZone(ZoneId.systemDefault()) + .toInstant() + .let { Date.from(it) } - recordDao.getMemberStatsByCategory(category, yearMonthStr) - .onEach { stats -> - _memberStats.value = stats - } - .launchIn(viewModelScope) + val endDate = endMonth.atEndOfMonth().atTime(23, 59, 59) + .atZone(ZoneId.systemDefault()) + .toInstant() + .let { Date.from(it) } + + recordDao.getRecordsByCategoryAndDateRange( + category = category, + startDate = startDate, + endDate = endDate + ) + .onEach { records -> _records.value = records } + .launchIn(viewModelScope) + + recordDao.getMemberStatsByCategoryAndDateRange( + category = category, + startDate = startDate, + endDate = endDate + ) + .onEach { stats -> _memberStats.value = stats } + .launchIn(viewModelScope) } } diff --git a/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/CategoryDetailViewModelFactory.kt b/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/CategoryDetailViewModelFactory.kt index 1a4e83d..61297a8 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/CategoryDetailViewModelFactory.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/CategoryDetailViewModelFactory.kt @@ -8,12 +8,13 @@ import java.time.YearMonth class CategoryDetailViewModelFactory( private val database: BookkeepingDatabase, private val category: String, - private val month: YearMonth + private val startMonth: YearMonth, + private val endMonth: YearMonth ) : ViewModelProvider.Factory { override fun create(modelClass: Class): T { if (modelClass.isAssignableFrom(CategoryDetailViewModel::class.java)) { @Suppress("UNCHECKED_CAST") - return CategoryDetailViewModel(database, category, month) as T + return CategoryDetailViewModel(database, category, startMonth, endMonth) as T } throw IllegalArgumentException("Unknown ViewModel class") } diff --git a/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/MemberDetailViewModel.kt b/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/MemberDetailViewModel.kt index c623216..5cb290d 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/MemberDetailViewModel.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/MemberDetailViewModel.kt @@ -7,9 +7,7 @@ import com.yovinchen.bookkeeping.data.BookkeepingDatabase import com.yovinchen.bookkeeping.model.BookkeepingRecord import com.yovinchen.bookkeeping.model.AnalysisType import com.yovinchen.bookkeeping.model.TransactionType -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.* import java.time.YearMonth import java.time.ZoneId import java.util.Date @@ -19,47 +17,56 @@ class MemberDetailViewModel(application: Application) : AndroidViewModel(applica private val recordDao = database.bookkeepingDao() private val _memberRecords = MutableStateFlow>(emptyList()) - val memberRecords: StateFlow> = _memberRecords + val memberRecords: StateFlow> = _memberRecords.asStateFlow() private val _totalAmount = MutableStateFlow(0.0) - val totalAmount: StateFlow = _totalAmount + val totalAmount: StateFlow = _totalAmount.asStateFlow() - fun loadMemberRecords(memberName: String, category: String, yearMonth: YearMonth, analysisType: AnalysisType) { - viewModelScope.launch { - val startDate = yearMonth.atDay(1).atStartOfDay() - .atZone(ZoneId.systemDefault()) - .toInstant() - .let { Date.from(it) } + fun loadMemberRecords( + memberName: String, + category: String, + startMonth: YearMonth, + endMonth: YearMonth, + analysisType: AnalysisType + ) { + val startDate = startMonth.atDay(1).atStartOfDay() + .atZone(ZoneId.systemDefault()) + .toInstant() + .let { Date.from(it) } - val endDate = yearMonth.atEndOfMonth().atTime(23, 59, 59) - .atZone(ZoneId.systemDefault()) - .toInstant() - .let { Date.from(it) } + val endDate = endMonth.atEndOfMonth().atTime(23, 59, 59) + .atZone(ZoneId.systemDefault()) + .toInstant() + .let { Date.from(it) } - val transactionType = when (analysisType) { - AnalysisType.INCOME -> TransactionType.INCOME - AnalysisType.EXPENSE -> TransactionType.EXPENSE - else -> null - } - - val records = if (category.isEmpty()) { - recordDao.getRecordsByMember( - memberName = memberName, - startDate = startDate, - endDate = endDate, - transactionType = transactionType - ) - } else { - recordDao.getRecordsByMemberAndCategory( - memberName = memberName, - category = category, - startDate = startDate, - endDate = endDate, - transactionType = transactionType - ) - } - _memberRecords.value = records - _totalAmount.value = records.sumOf { it.amount } + val transactionType = when (analysisType) { + AnalysisType.INCOME -> TransactionType.INCOME + AnalysisType.EXPENSE -> TransactionType.EXPENSE + else -> null } + + val recordsFlow = if (category.isEmpty()) { + recordDao.getRecordsByMemberAndDateRange( + memberName = memberName, + startDate = startDate, + endDate = endDate, + transactionType = transactionType + ) + } else { + recordDao.getRecordsByMemberCategoryAndDateRange( + memberName = memberName, + category = category, + startDate = startDate, + endDate = endDate, + transactionType = transactionType + ) + } + + recordsFlow + .onEach { records -> + _memberRecords.value = records + _totalAmount.value = records.sumOf { it.amount } + } + .launchIn(viewModelScope) } } From c7603c0f69a86a084c586a38a2bea54f3364c828 Mon Sep 17 00:00:00 2001 From: yovinchen Date: Thu, 5 Dec 2024 14:36:34 +0800 Subject: [PATCH 27/34] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=91=8A?= =?UTF-8?q?=E8=AD=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ui/screen/CategoryDetailScreen.kt | 1 - .../viewmodel/CategoryDetailViewModel.kt | 18 ++++++++++++------ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/CategoryDetailScreen.kt b/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/CategoryDetailScreen.kt index da17a00..24ea722 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/CategoryDetailScreen.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/CategoryDetailScreen.kt @@ -26,7 +26,6 @@ import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext diff --git a/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/CategoryDetailViewModel.kt b/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/CategoryDetailViewModel.kt index 295f113..3ddd877 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/CategoryDetailViewModel.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/CategoryDetailViewModel.kt @@ -1,21 +1,27 @@ package com.yovinchen.bookkeeping.viewmodel import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import com.yovinchen.bookkeeping.data.BookkeepingDatabase import com.yovinchen.bookkeeping.model.BookkeepingRecord import com.yovinchen.bookkeeping.model.MemberStat -import kotlinx.coroutines.flow.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn import java.time.YearMonth import java.time.ZoneId import java.util.Date class CategoryDetailViewModel( - private val database: BookkeepingDatabase, - private val category: String, - private val startMonth: YearMonth, - private val endMonth: YearMonth + database: BookkeepingDatabase, + category: String, + startMonth: YearMonth, + endMonth: YearMonth ) : ViewModel() { private val recordDao = database.bookkeepingDao() From b00e01dffb715aaf911e085353ce7f352654a74f Mon Sep 17 00:00:00 2001 From: yovinchen Date: Thu, 5 Dec 2024 15:23:03 +0800 Subject: [PATCH 28/34] =?UTF-8?q?init:=20=E5=88=9D=E5=A7=8B=E5=8C=96?= =?UTF-8?q?=E5=9B=BE=E6=A0=87=201.=20=E8=A7=84=E8=8C=83=E5=8C=96=E5=88=86?= =?UTF-8?q?=E7=B1=BB=E5=9B=BE=E6=A0=87=E5=91=BD=E5=90=8D=E6=A0=BC=E5=BC=8F?= =?UTF-8?q?=E4=B8=BA=20ic=5Fcategory=5F[name]=5F24dp.xml=202.=20=E8=A7=84?= =?UTF-8?q?=E8=8C=83=E5=8C=96=E6=88=90=E5=91=98=E5=9B=BE=E6=A0=87=E5=91=BD?= =?UTF-8?q?=E5=90=8D=E6=A0=BC=E5=BC=8F=E4=B8=BA=20ic=5Fmember=5F[name]=5F2?= =?UTF-8?q?4dp.xml=203.=20=E7=BB=9F=E4=B8=80=E4=BD=BF=E7=94=A8=E8=8B=B1?= =?UTF-8?q?=E6=96=87=E5=91=BD=E5=90=8D=EF=BC=8C=E4=BE=BF=E4=BA=8E=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E5=BC=95=E7=94=A8=204.=20=E5=B0=86=20SVG=20=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E8=BD=AC=E6=8D=A2=E4=B8=BA=20Android=20Vector=20Drawa?= =?UTF-8?q?ble=20=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/res/drawable/category/ic_category_baby_24dp.xml | 1 + app/src/main/res/drawable/category/ic_category_bar_24dp.xml | 1 + app/src/main/res/drawable/category/ic_category_clothes_24dp.xml | 1 + .../main/res/drawable/category/ic_category_convenience_24dp.xml | 1 + .../main/res/drawable/category/ic_category_cosmetics_24dp.xml | 1 + app/src/main/res/drawable/category/ic_category_delivery_24dp.xml | 1 + app/src/main/res/drawable/category/ic_category_digital_24dp.xml | 1 + app/src/main/res/drawable/category/ic_category_drink_24dp.xml | 1 + app/src/main/res/drawable/category/ic_category_flower_24dp.xml | 1 + app/src/main/res/drawable/category/ic_category_food_24dp.xml | 1 + app/src/main/res/drawable/category/ic_category_fruit_24dp.xml | 1 + app/src/main/res/drawable/category/ic_category_gift_24dp.xml | 1 + app/src/main/res/drawable/category/ic_category_hotel_24dp.xml | 1 + app/src/main/res/drawable/category/ic_category_medicine_24dp.xml | 1 + .../main/res/drawable/category/ic_category_membership_24dp.xml | 1 + app/src/main/res/drawable/category/ic_category_more_24dp.xml | 1 + app/src/main/res/drawable/category/ic_category_pet_24dp.xml | 1 + app/src/main/res/drawable/category/ic_category_scenic_24dp.xml | 1 + app/src/main/res/drawable/category/ic_category_snack_24dp.xml | 1 + .../main/res/drawable/category/ic_category_supermarket_24dp.xml | 1 + app/src/main/res/drawable/category/ic_category_taxi_24dp.xml | 1 + app/src/main/res/drawable/category/ic_category_training_24dp.xml | 1 + app/src/main/res/drawable/category/ic_category_travel_24dp.xml | 1 + .../main/res/drawable/category/ic_category_vegetable_24dp.xml | 1 + app/src/main/res/drawable/member/ic_member_baby_boy_24dp.xml | 1 + app/src/main/res/drawable/member/ic_member_baby_girl_24dp.xml | 1 + app/src/main/res/drawable/member/ic_member_boy_24dp.xml | 1 + app/src/main/res/drawable/member/ic_member_bride_24dp.xml | 1 + app/src/main/res/drawable/member/ic_member_family_24dp.xml | 1 + app/src/main/res/drawable/member/ic_member_father_24dp.xml | 1 + app/src/main/res/drawable/member/ic_member_girl_24dp.xml | 1 + app/src/main/res/drawable/member/ic_member_grandfather_24dp.xml | 1 + app/src/main/res/drawable/member/ic_member_grandmother_24dp.xml | 1 + app/src/main/res/drawable/member/ic_member_groom_24dp.xml | 1 + app/src/main/res/drawable/member/ic_member_mother_24dp.xml | 1 + 35 files changed, 35 insertions(+) create mode 100644 app/src/main/res/drawable/category/ic_category_baby_24dp.xml create mode 100644 app/src/main/res/drawable/category/ic_category_bar_24dp.xml create mode 100644 app/src/main/res/drawable/category/ic_category_clothes_24dp.xml create mode 100644 app/src/main/res/drawable/category/ic_category_convenience_24dp.xml create mode 100644 app/src/main/res/drawable/category/ic_category_cosmetics_24dp.xml create mode 100644 app/src/main/res/drawable/category/ic_category_delivery_24dp.xml create mode 100644 app/src/main/res/drawable/category/ic_category_digital_24dp.xml create mode 100644 app/src/main/res/drawable/category/ic_category_drink_24dp.xml create mode 100644 app/src/main/res/drawable/category/ic_category_flower_24dp.xml create mode 100644 app/src/main/res/drawable/category/ic_category_food_24dp.xml create mode 100644 app/src/main/res/drawable/category/ic_category_fruit_24dp.xml create mode 100644 app/src/main/res/drawable/category/ic_category_gift_24dp.xml create mode 100644 app/src/main/res/drawable/category/ic_category_hotel_24dp.xml create mode 100644 app/src/main/res/drawable/category/ic_category_medicine_24dp.xml create mode 100644 app/src/main/res/drawable/category/ic_category_membership_24dp.xml create mode 100644 app/src/main/res/drawable/category/ic_category_more_24dp.xml create mode 100644 app/src/main/res/drawable/category/ic_category_pet_24dp.xml create mode 100644 app/src/main/res/drawable/category/ic_category_scenic_24dp.xml create mode 100644 app/src/main/res/drawable/category/ic_category_snack_24dp.xml create mode 100644 app/src/main/res/drawable/category/ic_category_supermarket_24dp.xml create mode 100644 app/src/main/res/drawable/category/ic_category_taxi_24dp.xml create mode 100644 app/src/main/res/drawable/category/ic_category_training_24dp.xml create mode 100644 app/src/main/res/drawable/category/ic_category_travel_24dp.xml create mode 100644 app/src/main/res/drawable/category/ic_category_vegetable_24dp.xml create mode 100644 app/src/main/res/drawable/member/ic_member_baby_boy_24dp.xml create mode 100644 app/src/main/res/drawable/member/ic_member_baby_girl_24dp.xml create mode 100644 app/src/main/res/drawable/member/ic_member_boy_24dp.xml create mode 100644 app/src/main/res/drawable/member/ic_member_bride_24dp.xml create mode 100644 app/src/main/res/drawable/member/ic_member_family_24dp.xml create mode 100644 app/src/main/res/drawable/member/ic_member_father_24dp.xml create mode 100644 app/src/main/res/drawable/member/ic_member_girl_24dp.xml create mode 100644 app/src/main/res/drawable/member/ic_member_grandfather_24dp.xml create mode 100644 app/src/main/res/drawable/member/ic_member_grandmother_24dp.xml create mode 100644 app/src/main/res/drawable/member/ic_member_groom_24dp.xml create mode 100644 app/src/main/res/drawable/member/ic_member_mother_24dp.xml diff --git a/app/src/main/res/drawable/category/ic_category_baby_24dp.xml b/app/src/main/res/drawable/category/ic_category_baby_24dp.xml new file mode 100644 index 0000000..68e7d09 --- /dev/null +++ b/app/src/main/res/drawable/category/ic_category_baby_24dp.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/main/res/drawable/category/ic_category_bar_24dp.xml b/app/src/main/res/drawable/category/ic_category_bar_24dp.xml new file mode 100644 index 0000000..d4bffcb --- /dev/null +++ b/app/src/main/res/drawable/category/ic_category_bar_24dp.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/main/res/drawable/category/ic_category_clothes_24dp.xml b/app/src/main/res/drawable/category/ic_category_clothes_24dp.xml new file mode 100644 index 0000000..e72a9fe --- /dev/null +++ b/app/src/main/res/drawable/category/ic_category_clothes_24dp.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/main/res/drawable/category/ic_category_convenience_24dp.xml b/app/src/main/res/drawable/category/ic_category_convenience_24dp.xml new file mode 100644 index 0000000..6fb05ce --- /dev/null +++ b/app/src/main/res/drawable/category/ic_category_convenience_24dp.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/main/res/drawable/category/ic_category_cosmetics_24dp.xml b/app/src/main/res/drawable/category/ic_category_cosmetics_24dp.xml new file mode 100644 index 0000000..ae14563 --- /dev/null +++ b/app/src/main/res/drawable/category/ic_category_cosmetics_24dp.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/main/res/drawable/category/ic_category_delivery_24dp.xml b/app/src/main/res/drawable/category/ic_category_delivery_24dp.xml new file mode 100644 index 0000000..3921e77 --- /dev/null +++ b/app/src/main/res/drawable/category/ic_category_delivery_24dp.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/main/res/drawable/category/ic_category_digital_24dp.xml b/app/src/main/res/drawable/category/ic_category_digital_24dp.xml new file mode 100644 index 0000000..ab76ef0 --- /dev/null +++ b/app/src/main/res/drawable/category/ic_category_digital_24dp.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/main/res/drawable/category/ic_category_drink_24dp.xml b/app/src/main/res/drawable/category/ic_category_drink_24dp.xml new file mode 100644 index 0000000..14e09ee --- /dev/null +++ b/app/src/main/res/drawable/category/ic_category_drink_24dp.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/main/res/drawable/category/ic_category_flower_24dp.xml b/app/src/main/res/drawable/category/ic_category_flower_24dp.xml new file mode 100644 index 0000000..ba15791 --- /dev/null +++ b/app/src/main/res/drawable/category/ic_category_flower_24dp.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/main/res/drawable/category/ic_category_food_24dp.xml b/app/src/main/res/drawable/category/ic_category_food_24dp.xml new file mode 100644 index 0000000..9f43cf9 --- /dev/null +++ b/app/src/main/res/drawable/category/ic_category_food_24dp.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/main/res/drawable/category/ic_category_fruit_24dp.xml b/app/src/main/res/drawable/category/ic_category_fruit_24dp.xml new file mode 100644 index 0000000..266be13 --- /dev/null +++ b/app/src/main/res/drawable/category/ic_category_fruit_24dp.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/main/res/drawable/category/ic_category_gift_24dp.xml b/app/src/main/res/drawable/category/ic_category_gift_24dp.xml new file mode 100644 index 0000000..a5b3227 --- /dev/null +++ b/app/src/main/res/drawable/category/ic_category_gift_24dp.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/main/res/drawable/category/ic_category_hotel_24dp.xml b/app/src/main/res/drawable/category/ic_category_hotel_24dp.xml new file mode 100644 index 0000000..05db34d --- /dev/null +++ b/app/src/main/res/drawable/category/ic_category_hotel_24dp.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/main/res/drawable/category/ic_category_medicine_24dp.xml b/app/src/main/res/drawable/category/ic_category_medicine_24dp.xml new file mode 100644 index 0000000..b72e248 --- /dev/null +++ b/app/src/main/res/drawable/category/ic_category_medicine_24dp.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/main/res/drawable/category/ic_category_membership_24dp.xml b/app/src/main/res/drawable/category/ic_category_membership_24dp.xml new file mode 100644 index 0000000..56fcac2 --- /dev/null +++ b/app/src/main/res/drawable/category/ic_category_membership_24dp.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/main/res/drawable/category/ic_category_more_24dp.xml b/app/src/main/res/drawable/category/ic_category_more_24dp.xml new file mode 100644 index 0000000..cebd965 --- /dev/null +++ b/app/src/main/res/drawable/category/ic_category_more_24dp.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/main/res/drawable/category/ic_category_pet_24dp.xml b/app/src/main/res/drawable/category/ic_category_pet_24dp.xml new file mode 100644 index 0000000..d26e793 --- /dev/null +++ b/app/src/main/res/drawable/category/ic_category_pet_24dp.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/main/res/drawable/category/ic_category_scenic_24dp.xml b/app/src/main/res/drawable/category/ic_category_scenic_24dp.xml new file mode 100644 index 0000000..9754aa1 --- /dev/null +++ b/app/src/main/res/drawable/category/ic_category_scenic_24dp.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/main/res/drawable/category/ic_category_snack_24dp.xml b/app/src/main/res/drawable/category/ic_category_snack_24dp.xml new file mode 100644 index 0000000..cf1b226 --- /dev/null +++ b/app/src/main/res/drawable/category/ic_category_snack_24dp.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/main/res/drawable/category/ic_category_supermarket_24dp.xml b/app/src/main/res/drawable/category/ic_category_supermarket_24dp.xml new file mode 100644 index 0000000..cf281dc --- /dev/null +++ b/app/src/main/res/drawable/category/ic_category_supermarket_24dp.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/main/res/drawable/category/ic_category_taxi_24dp.xml b/app/src/main/res/drawable/category/ic_category_taxi_24dp.xml new file mode 100644 index 0000000..fa45d23 --- /dev/null +++ b/app/src/main/res/drawable/category/ic_category_taxi_24dp.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/main/res/drawable/category/ic_category_training_24dp.xml b/app/src/main/res/drawable/category/ic_category_training_24dp.xml new file mode 100644 index 0000000..4db98ca --- /dev/null +++ b/app/src/main/res/drawable/category/ic_category_training_24dp.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/main/res/drawable/category/ic_category_travel_24dp.xml b/app/src/main/res/drawable/category/ic_category_travel_24dp.xml new file mode 100644 index 0000000..0fdee3e --- /dev/null +++ b/app/src/main/res/drawable/category/ic_category_travel_24dp.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/main/res/drawable/category/ic_category_vegetable_24dp.xml b/app/src/main/res/drawable/category/ic_category_vegetable_24dp.xml new file mode 100644 index 0000000..a0a21c5 --- /dev/null +++ b/app/src/main/res/drawable/category/ic_category_vegetable_24dp.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/main/res/drawable/member/ic_member_baby_boy_24dp.xml b/app/src/main/res/drawable/member/ic_member_baby_boy_24dp.xml new file mode 100644 index 0000000..2b24182 --- /dev/null +++ b/app/src/main/res/drawable/member/ic_member_baby_boy_24dp.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/main/res/drawable/member/ic_member_baby_girl_24dp.xml b/app/src/main/res/drawable/member/ic_member_baby_girl_24dp.xml new file mode 100644 index 0000000..5089a2e --- /dev/null +++ b/app/src/main/res/drawable/member/ic_member_baby_girl_24dp.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/main/res/drawable/member/ic_member_boy_24dp.xml b/app/src/main/res/drawable/member/ic_member_boy_24dp.xml new file mode 100644 index 0000000..2a20f4f --- /dev/null +++ b/app/src/main/res/drawable/member/ic_member_boy_24dp.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/main/res/drawable/member/ic_member_bride_24dp.xml b/app/src/main/res/drawable/member/ic_member_bride_24dp.xml new file mode 100644 index 0000000..c1e6f72 --- /dev/null +++ b/app/src/main/res/drawable/member/ic_member_bride_24dp.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/main/res/drawable/member/ic_member_family_24dp.xml b/app/src/main/res/drawable/member/ic_member_family_24dp.xml new file mode 100644 index 0000000..a309d98 --- /dev/null +++ b/app/src/main/res/drawable/member/ic_member_family_24dp.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/main/res/drawable/member/ic_member_father_24dp.xml b/app/src/main/res/drawable/member/ic_member_father_24dp.xml new file mode 100644 index 0000000..6c68ed8 --- /dev/null +++ b/app/src/main/res/drawable/member/ic_member_father_24dp.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/main/res/drawable/member/ic_member_girl_24dp.xml b/app/src/main/res/drawable/member/ic_member_girl_24dp.xml new file mode 100644 index 0000000..0d38006 --- /dev/null +++ b/app/src/main/res/drawable/member/ic_member_girl_24dp.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/main/res/drawable/member/ic_member_grandfather_24dp.xml b/app/src/main/res/drawable/member/ic_member_grandfather_24dp.xml new file mode 100644 index 0000000..d439929 --- /dev/null +++ b/app/src/main/res/drawable/member/ic_member_grandfather_24dp.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/main/res/drawable/member/ic_member_grandmother_24dp.xml b/app/src/main/res/drawable/member/ic_member_grandmother_24dp.xml new file mode 100644 index 0000000..b5d52ba --- /dev/null +++ b/app/src/main/res/drawable/member/ic_member_grandmother_24dp.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/main/res/drawable/member/ic_member_groom_24dp.xml b/app/src/main/res/drawable/member/ic_member_groom_24dp.xml new file mode 100644 index 0000000..e15c714 --- /dev/null +++ b/app/src/main/res/drawable/member/ic_member_groom_24dp.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/main/res/drawable/member/ic_member_mother_24dp.xml b/app/src/main/res/drawable/member/ic_member_mother_24dp.xml new file mode 100644 index 0000000..6ad2a73 --- /dev/null +++ b/app/src/main/res/drawable/member/ic_member_mother_24dp.xml @@ -0,0 +1 @@ + \ No newline at end of file From f717f0ad363b5af78e474b252a07fca78c713969 Mon Sep 17 00:00:00 2001 From: yovinchen Date: Thu, 5 Dec 2024 15:39:57 +0800 Subject: [PATCH 29/34] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index abdcf9d..eb27d92 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -16,8 +16,8 @@ android { applicationId = "com.yovinchen.bookkeeping" minSdk = 26 targetSdk = 34 - versionCode = 4 - versionName = "1.2.2" + versionCode = 5 + versionName = "1.2.3" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { From 80ebddfc13b3ffd0e41002d909801c6248659e9e Mon Sep 17 00:00:00 2001 From: yovinchen Date: Thu, 5 Dec 2024 15:51:06 +0800 Subject: [PATCH 30/34] =?UTF-8?q?feat:=20=E5=B0=86=E7=B1=BB=E5=88=AB?= =?UTF-8?q?=E9=A5=BC=E5=9B=BE=E6=B7=BB=E5=8A=A0=E5=88=B0=E6=88=90=E5=91=98?= =?UTF-8?q?=E8=AF=A6=E7=BB=86=E4=BF=A1=E6=81=AF=E5=B1=8F=E5=B9=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在DeliverDetailView模型中添加类别数据状态流 - 从成员视图访问时,在DeliverDetailScreen中显示饼图 - 计算并显示会员记录的类别分布 --- .../ui/screen/MemberDetailScreen.kt | 48 ++++++++++++++++--- .../viewmodel/MemberDetailViewModel.kt | 17 +++++-- 2 files changed, 56 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/MemberDetailScreen.kt b/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/MemberDetailScreen.kt index 4d145f9..9b055bd 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/MemberDetailScreen.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/MemberDetailScreen.kt @@ -33,6 +33,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.yovinchen.bookkeeping.data.Record import com.yovinchen.bookkeeping.model.AnalysisType import com.yovinchen.bookkeeping.model.TransactionType +import com.yovinchen.bookkeeping.ui.components.CategoryPieChart import com.yovinchen.bookkeeping.ui.components.RecordItem import com.yovinchen.bookkeeping.viewmodel.MemberDetailViewModel import java.text.NumberFormat @@ -53,7 +54,8 @@ fun MemberDetailScreen( ) { val records by viewModel.memberRecords.collectAsState(initial = emptyList()) val totalAmount by viewModel.totalAmount.collectAsState(initial = 0.0) - + val categoryData by viewModel.categoryData.collectAsState(initial = emptyList()) + LaunchedEffect(memberName, category, startMonth, endMonth, analysisType) { viewModel.loadMemberRecords( memberName = memberName, @@ -99,24 +101,58 @@ fun MemberDetailScreen( ) { Column( modifier = Modifier - .padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally + .fillMaxWidth() + .padding(16.dp) ) { Text( - text = if (records.isNotEmpty() && records.first().type == TransactionType.INCOME) "总收入" else "总支出", - style = MaterialTheme.typography.titleMedium + text = "总金额", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold ) Spacer(modifier = Modifier.height(8.dp)) Text( text = NumberFormat.getCurrencyInstance(Locale.CHINA) .format(totalAmount), style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.Bold + color = MaterialTheme.colorScheme.primary ) } } } + // 当从成员视图进入时显示饼图 + if (category.isEmpty()) { + item { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Text( + text = "分类统计", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(16.dp)) + CategoryPieChart( + categoryData = categoryData, + memberData = emptyList(), + currentViewMode = false, + onCategoryClick = { selectedCategory -> + // 暂时不处理点击事件 + } + ) + } + } + } + } + // 第二层:按日期分组的记录列表 groupedRecords.forEach { (date, dayRecords) -> item { diff --git a/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/MemberDetailViewModel.kt b/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/MemberDetailViewModel.kt index 5cb290d..724cfc7 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/MemberDetailViewModel.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/MemberDetailViewModel.kt @@ -8,6 +8,7 @@ import com.yovinchen.bookkeeping.model.BookkeepingRecord import com.yovinchen.bookkeeping.model.AnalysisType import com.yovinchen.bookkeeping.model.TransactionType import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch import java.time.YearMonth import java.time.ZoneId import java.util.Date @@ -22,6 +23,9 @@ class MemberDetailViewModel(application: Application) : AndroidViewModel(applica private val _totalAmount = MutableStateFlow(0.0) val totalAmount: StateFlow = _totalAmount.asStateFlow() + private val _categoryData = MutableStateFlow>>(emptyList()) + val categoryData: StateFlow>> = _categoryData.asStateFlow() + fun loadMemberRecords( memberName: String, category: String, @@ -62,11 +66,18 @@ class MemberDetailViewModel(application: Application) : AndroidViewModel(applica ) } - recordsFlow - .onEach { records -> + viewModelScope.launch { + recordsFlow.collect { records -> _memberRecords.value = records _totalAmount.value = records.sumOf { it.amount } + + // 计算分类数据 + val categoryAmounts = records.groupBy { it.category } + .mapValues { (_, records) -> records.sumOf { it.amount }.toFloat() } + .toList() + .sortedByDescending { it.second } + _categoryData.value = categoryAmounts } - .launchIn(viewModelScope) + } } } From 119ca539a6dae2c688803c58cf3b45340cfc7b6c Mon Sep 17 00:00:00 2001 From: yovinchen Date: Thu, 5 Dec 2024 15:55:08 +0800 Subject: [PATCH 31/34] =?UTF-8?q?docs:=20=E8=A7=84=E8=8C=83=E6=96=87?= =?UTF-8?q?=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/README.md b/README.md index 1e03c8d..5fa29fa 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,20 @@ - `release/*`: 版本发布分支 - `hotfix/*`: 紧急修复分支 +feat: 新功能(feature) +fix: 修补bug +docs: 文档(documentation) +style: 格式(不影响代码运行的变动) +refactor: 重构(即不是新增功能,也不是修改bug的代码变动) +chore: 构建过程或辅助工具的变动 +revert: 撤销,版本回退 +perf: 性能优化 +test:测试 +improvement: 改进 +build: 打包 +ci: 持续集成 + + ## 📝 版本历史 ### v1.1.0 (2024-01-10) From 02375747fce33fb437e182bff4afc3b5605819fe Mon Sep 17 00:00:00 2001 From: yovinchen Date: Thu, 5 Dec 2024 16:01:02 +0800 Subject: [PATCH 32/34] =?UTF-8?q?docs:=20=E8=A7=84=E8=8C=83=E6=96=87?= =?UTF-8?q?=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 5fa29fa..a2e93d1 100644 --- a/README.md +++ b/README.md @@ -81,19 +81,24 @@ - `release/*`: 版本发布分支 - `hotfix/*`: 紧急修复分支 -feat: 新功能(feature) -fix: 修补bug -docs: 文档(documentation) -style: 格式(不影响代码运行的变动) -refactor: 重构(即不是新增功能,也不是修改bug的代码变动) -chore: 构建过程或辅助工具的变动 -revert: 撤销,版本回退 -perf: 性能优化 -test:测试 -improvement: 改进 -build: 打包 -ci: 持续集成 +## 🔄 提交规范 +提交信息应遵循以下格式:`: ` + +### 提交类型(Type) + +- `feat`: 新功能(feature) +- `fix`: 修复bug +- `docs`: 文档更新(documentation) +- `style`: 代码格式(不影响代码运行的变动) +- `refactor`: 代码重构(既不是新增功能,也不是修复bug) +- `perf`: 性能优化 +- `test`: 测试相关 +- `build`: 构建相关 +- `ci`: 持续集成 +- `chore`: 构建过程或辅助工具的变动 +- `revert`: 回退提交 +- `improvement`: 改进 ## 📝 版本历史 From 5cb620b8756278ecbddb52bb66f588c4027ebaf0 Mon Sep 17 00:00:00 2001 From: yovinchen Date: Thu, 5 Dec 2024 16:43:48 +0800 Subject: [PATCH 33/34] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E8=B6=8B?= =?UTF-8?q?=E5=8A=BF=E5=88=86=E6=9E=90=E5=9B=BE=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增趋势图组件,分别显示收入和支出折线 - 更新分析页面ViewModel,处理趋势数据 - 修改分析页面,集成趋势图显示 - 支持深色/浅色主题适配 - 优化图表布局和可读性 --- .../ui/components/TrendLineChart.kt | 173 ++++++++++++++++++ .../bookkeeping/ui/screen/AnalysisScreen.kt | 88 +++++---- .../viewmodel/AnalysisViewModel.kt | 42 ++++- 3 files changed, 260 insertions(+), 43 deletions(-) create mode 100644 app/src/main/java/com/yovinchen/bookkeeping/ui/components/TrendLineChart.kt diff --git a/app/src/main/java/com/yovinchen/bookkeeping/ui/components/TrendLineChart.kt b/app/src/main/java/com/yovinchen/bookkeeping/ui/components/TrendLineChart.kt new file mode 100644 index 0000000..aaab22f --- /dev/null +++ b/app/src/main/java/com/yovinchen/bookkeeping/ui/components/TrendLineChart.kt @@ -0,0 +1,173 @@ +package com.yovinchen.bookkeeping.ui.components + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.* +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import com.github.mikephil.charting.charts.LineChart +import com.github.mikephil.charting.components.XAxis +import com.github.mikephil.charting.data.Entry +import com.github.mikephil.charting.data.LineData +import com.github.mikephil.charting.data.LineDataSet +import com.github.mikephil.charting.formatter.ValueFormatter +import com.yovinchen.bookkeeping.model.BookkeepingRecord +import com.yovinchen.bookkeeping.model.TransactionType +import java.text.SimpleDateFormat +import java.util.* + +@Composable +fun TrendLineChart( + records: List, + modifier: Modifier = Modifier +) { + val isDarkTheme = isSystemInDarkTheme() + var textColor = if (isDarkTheme) { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.87f).toArgb() + } else { + MaterialTheme.colorScheme.onSurface.toArgb() + } + + var gridColor = if (isDarkTheme) { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f).toArgb() + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f).toArgb() + } + + val incomeColor = MaterialTheme.colorScheme.primary.toArgb() + val expenseColor = MaterialTheme.colorScheme.error.toArgb() + + AndroidView( + modifier = modifier + .fillMaxWidth() + .height(300.dp), + factory = { context -> + LineChart(context).apply { + description.isEnabled = false + + // 基本设置 + setDrawGridBackground(false) + setDrawBorders(false) + + // X轴设置 + xAxis.apply { + position = XAxis.XAxisPosition.BOTTOM + this.textColor = textColor + this.gridColor = gridColor + setDrawGridLines(true) + setDrawAxisLine(true) + labelRotationAngle = -45f + textSize = 12f + yOffset = 10f + } + + // Y轴设置 + axisLeft.apply { + this.textColor = textColor + this.gridColor = gridColor + setDrawGridLines(true) + setDrawAxisLine(true) + textSize = 12f + valueFormatter = object : ValueFormatter() { + override fun getFormattedValue(value: Float): String { + return String.format("%.0f", value) + } + } + } + axisRight.isEnabled = false + + // 图例设置 + legend.apply { + this.textColor = textColor + this.textSize = 12f + isEnabled = true + yOffset = 10f + } + + // 交互设置 + setTouchEnabled(true) + isDragEnabled = true + setScaleEnabled(true) + + // 边距设置 + setExtraOffsets(8f, 16f, 8f, 24f) + } + }, + update = { chart -> + // 按日期分组计算收入和支出 + val dailyData = records + .groupBy { record -> + SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(record.date) + } + .mapValues { (_, dayRecords) -> + val income = dayRecords + .filter { it.type == TransactionType.INCOME } + .sumOf { it.amount } + .toFloat() + val expense = dayRecords + .filter { it.type == TransactionType.EXPENSE } + .sumOf { it.amount } + .toFloat() + Pair(income, expense) + } + .toList() + .sortedBy { it.first } + + // 创建收入数据点 + val incomeEntries = dailyData.mapIndexed { index, (_, amounts) -> + Entry(index.toFloat(), amounts.first) + } + + // 创建支出数据点 + val expenseEntries = dailyData.mapIndexed { index, (_, amounts) -> + Entry(index.toFloat(), amounts.second) + } + + // 创建收入数据集 + val incomeDataSet = LineDataSet(incomeEntries, "收入").apply { + color = incomeColor + lineWidth = 2.5f + setDrawCircles(true) + circleRadius = 4f + setCircleColor(incomeColor) + valueTextColor = textColor + valueTextSize = 12f + setDrawFilled(true) + fillColor = incomeColor + fillAlpha = if (isDarkTheme) 40 else 50 + } + + // 创建支出数据集 + val expenseDataSet = LineDataSet(expenseEntries, "支出").apply { + color = expenseColor + lineWidth = 2.5f + setDrawCircles(true) + circleRadius = 4f + setCircleColor(expenseColor) + valueTextColor = textColor + valueTextSize = 12f + setDrawFilled(true) + fillColor = expenseColor + fillAlpha = if (isDarkTheme) 40 else 50 + } + + // 设置X轴标签 + chart.xAxis.valueFormatter = object : ValueFormatter() { + override fun getFormattedValue(value: Float): String { + return try { + dailyData[value.toInt()].first.substring(5) // 只显示MM-dd + } catch (e: Exception) { + "" + } + } + } + + // 更新图表数据 + chart.data = LineData(incomeDataSet, expenseDataSet) + chart.invalidate() + } + ) +} 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 ebfb993..1077147 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 @@ -38,6 +38,7 @@ 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.DateRangePicker +import com.yovinchen.bookkeeping.ui.components.TrendLineChart import com.yovinchen.bookkeeping.viewmodel.AnalysisViewModel import java.time.YearMonth @@ -58,6 +59,7 @@ fun AnalysisScreen( val selectedAnalysisType by viewModel.selectedAnalysisType.collectAsState() val categoryStats by viewModel.categoryStats.collectAsState() val memberStats by viewModel.memberStats.collectAsState() + val records by viewModel.records.collectAsState() var showViewModeMenu by remember { mutableStateOf(false) } var currentViewMode by rememberSaveable { mutableStateOf(ViewMode.CATEGORY) } @@ -141,43 +143,59 @@ fun AnalysisScreen( modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(16.dp) ) { - // 添加饼图作为第一个项目 - if (selectedAnalysisType != AnalysisType.TREND) { - item { - CategoryPieChart( - categoryData = categoryStats.map { Pair(it.category, it.percentage.toFloat()) }, - memberData = memberStats.map { Pair(it.member, it.percentage.toFloat()) }, - currentViewMode = currentViewMode == ViewMode.MEMBER, - modifier = Modifier - .fillMaxWidth() - .height(200.dp) - .padding(bottom = 16.dp), - onCategoryClick = { category -> - if (currentViewMode == ViewMode.CATEGORY) { - onNavigateToCategoryDetail(category, startMonth, endMonth) - } else { - onNavigateToMemberDetail(category, startMonth, endMonth, selectedAnalysisType) - } - } - ) - } - } - - // 添加统计列表项目 - 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 && category != null) { - onNavigateToCategoryDetail(category, startMonth, endMonth) - } else if (currentViewMode == ViewMode.MEMBER && member != null) { - onNavigateToMemberDetail(member, startMonth, endMonth, selectedAnalysisType) + when (selectedAnalysisType) { + AnalysisType.TREND -> { + // 趋势视图 + item { + if (records.isNotEmpty()) { + TrendLineChart( + records = records, + modifier = Modifier + .fillMaxWidth() + .height(300.dp) + .padding(vertical = 16.dp) + ) } } - ) + } + else -> { + // 饼图视图 + item { + CategoryPieChart( + categoryData = categoryStats.map { Pair(it.category, it.percentage.toFloat()) }, + memberData = memberStats.map { Pair(it.member, it.percentage.toFloat()) }, + currentViewMode = currentViewMode == ViewMode.MEMBER, + modifier = Modifier + .fillMaxWidth() + .height(200.dp) + .padding(bottom = 16.dp), + onCategoryClick = { category -> + if (currentViewMode == ViewMode.CATEGORY) { + onNavigateToCategoryDetail(category, startMonth, endMonth) + } else { + onNavigateToMemberDetail(category, startMonth, endMonth, selectedAnalysisType) + } + } + ) + } + + // 统计列表 + 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 && category != null) { + onNavigateToCategoryDetail(category, startMonth, endMonth) + } else if (currentViewMode == ViewMode.MEMBER && member != null) { + onNavigateToMemberDetail(member, startMonth, endMonth, selectedAnalysisType) + } + } + ) + } + } } } } 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 19bb68e..eaba8e9 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/AnalysisViewModel.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/AnalysisViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.yovinchen.bookkeeping.data.BookkeepingDatabase import com.yovinchen.bookkeeping.model.AnalysisType +import com.yovinchen.bookkeeping.model.BookkeepingRecord import com.yovinchen.bookkeeping.model.CategoryStat import com.yovinchen.bookkeeping.model.MemberStat import com.yovinchen.bookkeeping.model.TransactionType @@ -34,6 +35,9 @@ class AnalysisViewModel(application: Application) : AndroidViewModel(application private val _memberStats = MutableStateFlow>(emptyList()) val memberStats: StateFlow> = _memberStats.asStateFlow() + private val _records = MutableStateFlow>(emptyList()) + val records: StateFlow> = _records.asStateFlow() + init { viewModelScope.launch { combine(startMonth, endMonth, selectedAnalysisType) { start, end, type -> @@ -58,21 +62,40 @@ class AnalysisViewModel(application: Application) : AndroidViewModel(application 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()) 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 + yearMonth.isBefore(endMonth.plusMonths(1)) + } + + // 更新记录数据 + _records.value = monthRecords + + // 根据分析类型过滤记录 + val filteredRecords = if (type == AnalysisType.TREND) { + monthRecords + } else { + monthRecords.filter { + it.type == when(type) { + AnalysisType.EXPENSE -> TransactionType.EXPENSE + AnalysisType.INCOME -> TransactionType.INCOME + else -> return@filter true + } } } + // 更新统计数据 + updateCategoryStats(filteredRecords) + updateMemberStats(filteredRecords) + } + + private suspend fun updateCategoryStats(records: List) { // 按分类统计 - val categoryMap = monthRecords.groupBy { it.category } + val categoryMap = records.groupBy { it.category } val categoryStats = categoryMap.map { (category, records) -> CategoryStat( category = category, @@ -87,9 +110,13 @@ class AnalysisViewModel(application: Application) : AndroidViewModel(application it.copy(percentage = if (categoryTotal > 0) it.amount / categoryTotal * 100 else 0.0) } + _categoryStats.value = categoryStatsWithPercentage + } + + private suspend fun updateMemberStats(records: List) { // 按成员统计 val members = memberDao.getAllMembers().first() - val memberMap = monthRecords.groupBy { record -> + val memberMap = records.groupBy { record -> members.find { it.id == record.memberId }?.name ?: "未分配" } @@ -107,7 +134,6 @@ class AnalysisViewModel(application: Application) : AndroidViewModel(application it.copy(percentage = if (memberTotal > 0) it.amount / memberTotal * 100 else 0.0) } - _categoryStats.value = categoryStatsWithPercentage _memberStats.value = memberStatsWithPercentage } } From c8ebe270820e26fd379fdd329c531fa10b2f83cf Mon Sep 17 00:00:00 2001 From: yovinchen Date: Thu, 5 Dec 2024 16:49:16 +0800 Subject: [PATCH 34/34] =?UTF-8?q?docs:=20=E6=9B=B4=E6=96=B0README=E6=96=87?= =?UTF-8?q?=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加v1.1.0版本成员管理系统的详细说明 - 添加v1.2.0-v1.2.4图表分析系统的功能说明 - 完善版本历史文档 README.md --- README.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/README.md b/README.md index a2e93d1..5c8dd46 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,39 @@ - 数据库性能优化 - 状态管理重构 +### v1.1.0 +#### 成员管理系统 +- 新增成员管理功能 + - 支持添加、编辑和删除成员信息 + - 为每笔记录分配对应的成员 + - 成员列表显示和管理 +- 记录关联 + - 在记账时可选择相关成员 + - 支持按成员筛选和查看记录 +- 数据统计 + - 成员消费统计和分析 + - 成员支出占比展示 + +### v1.2.0 - v1.2.4 +#### 图表分析系统 +- 分类数据可视化 + - 支出/收入分类饼图展示 + - 分类占比详细统计 + - 分类数据交互和筛选 +- 成员数据可视化 + - 成员消费饼图展示 + - 成员支出占比统计 + - 成员数据交互和筛选 +- 趋势分析 + - 日收支趋势折线图 + - 收入支出双线对比 + - 支持深色/浅色主题 + - 图表交互和缩放 +- 数据筛选 + - 支持按日期范围筛选 + - 支持按收入/支出类型筛选 + - 支持按成员/分类筛选 + ### v1.0.0 (2024-01-05) - 基础记账功能 - 收入/支出记录