diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 82d5634..e5d050c 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -4,7 +4,8 @@ "Bash(./gradlew:*)", "Bash(git push:*)", "Bash(git branch:*)", - "Bash(git add:*)" + "Bash(git add:*)", + "Bash(git commit:*)" ], "deny": [] } diff --git a/README.md b/README.md index 2d193bd..4e1c43a 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ ### 2. 图表分析 (已完成 🎉) - [x] 支出/收入趋势图表 - [x] 分类占比饼图 -- [ ] 月度/年度报表 +- [x] 月度/年度报表 - [x] 成员消费分析 - [x] 自定义统计周期 @@ -82,7 +82,6 @@ ### 6. 体验优化 (持续进行 🔄) - [x] 深色模式支持 - [ ] 手势操作优化 -- [ ] 快速记账小组件 - [ ] 多语言支持 - [ ] 自定义主题 @@ -131,6 +130,12 @@ - 预算编辑对话框 - 预算状态可视化(进度条、超支提醒) - 预算导航集成 +- 数据分析优化 + - 月度/年度报表组件 + - 详细分析报表(分类统计明细) + - 收支对比、储蓄率、日均消费分析 + - TOP分类排行榜 + - 报表视图集成到分析页面 ### v1.4 - 数据安全功能 diff --git a/app/src/main/java/com/yovinchen/bookkeeping/ui/components/DetailedAnalysisReport.kt b/app/src/main/java/com/yovinchen/bookkeeping/ui/components/DetailedAnalysisReport.kt new file mode 100644 index 0000000..f52525e --- /dev/null +++ b/app/src/main/java/com/yovinchen/bookkeeping/ui/components/DetailedAnalysisReport.kt @@ -0,0 +1,310 @@ +package com.yovinchen.bookkeeping.ui.components + +import androidx.compose.foundation.background +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.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.yovinchen.bookkeeping.model.BookkeepingRecord +import com.yovinchen.bookkeeping.model.TransactionType +import java.text.NumberFormat +import java.time.YearMonth +import java.time.format.DateTimeFormatter +import java.util.* + +/** + * 详细分析报表组件 + * 显示按分类统计的详细信息 + */ +@Composable +fun DetailedAnalysisReport( + records: List, + startMonth: YearMonth, + endMonth: YearMonth, + modifier: Modifier = Modifier +) { + val currencyFormatter = NumberFormat.getCurrencyInstance(Locale.CHINA) + + // 按类型分组 + val incomeRecords = records.filter { it.type == TransactionType.INCOME } + val expenseRecords = records.filter { it.type == TransactionType.EXPENSE } + + // 按分类统计 + val incomeByCategory = incomeRecords.groupBy { it.category } + .mapValues { it.value.sumOf { record -> record.amount } } + .toList() + .sortedByDescending { it.second } + + val expenseByCategory = expenseRecords.groupBy { it.category } + .mapValues { it.value.sumOf { record -> record.amount } } + .toList() + .sortedByDescending { it.second } + + // 总收入和总支出 + val totalIncome = incomeRecords.sumOf { it.amount } + val totalExpense = expenseRecords.sumOf { it.amount } + + LazyColumn( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // 时间范围标题 + item { + Card( + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Text( + text = if (startMonth == endMonth) { + "统计期间:${startMonth.format(DateTimeFormatter.ofPattern("yyyy年MM月"))}" + } else { + "统计期间:${startMonth.format(DateTimeFormatter.ofPattern("yyyy年MM月"))} 至 ${endMonth.format(DateTimeFormatter.ofPattern("yyyy年MM月"))}" + }, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(16.dp), + textAlign = TextAlign.Center + ) + } + } + + // 支出分类详情 + if (expenseByCategory.isNotEmpty()) { + item { + CategoryDetailCard( + title = "支出分类明细", + categoryData = expenseByCategory, + total = totalExpense, + color = MaterialTheme.colorScheme.error, + currencyFormatter = currencyFormatter + ) + } + } + + // 收入分类详情 + if (incomeByCategory.isNotEmpty()) { + item { + CategoryDetailCard( + title = "收入分类明细", + categoryData = incomeByCategory, + total = totalIncome, + color = MaterialTheme.colorScheme.primary, + currencyFormatter = currencyFormatter + ) + } + } + + // 分类占比前5名 + if (expenseByCategory.isNotEmpty()) { + item { + TopCategoriesCard( + title = "支出TOP5", + categoryData = expenseByCategory.take(5), + total = totalExpense, + color = MaterialTheme.colorScheme.error, + currencyFormatter = currencyFormatter + ) + } + } + } +} + +/** + * 分类详情卡片 + */ +@Composable +private fun CategoryDetailCard( + title: String, + categoryData: List>, + total: Double, + color: Color, + currencyFormatter: NumberFormat +) { + Card( + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = color, + modifier = Modifier.padding(bottom = 16.dp) + ) + + // 总计 + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = "总计", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold + ) + Text( + text = currencyFormatter.format(total), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + color = color + ) + } + + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + // 分类列表 + categoryData.forEach { (category, amount) -> + val percentage = if (total > 0) (amount / total * 100) else 0.0 + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = category, + style = MaterialTheme.typography.bodyMedium + ) + Text( + text = "${String.format("%.1f", percentage)}%", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Text( + text = currencyFormatter.format(amount), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium + ) + } + + // 进度条 + LinearProgressIndicator( + progress = (percentage / 100).toFloat(), + modifier = Modifier + .fillMaxWidth() + .height(4.dp) + .clip(RoundedCornerShape(2.dp)), + color = color.copy(alpha = 0.8f), + trackColor = color.copy(alpha = 0.2f) + ) + } + } + } +} + +/** + * TOP分类卡片 + */ +@Composable +private fun TopCategoriesCard( + title: String, + categoryData: List>, + total: Double, + color: Color, + currencyFormatter: NumberFormat +) { + Card( + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 12.dp) + ) + + categoryData.forEachIndexed { index, (category, amount) -> + val percentage = if (total > 0) (amount / total * 100) else 0.0 + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + modifier = Modifier.weight(1f), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // 排名 + Box( + modifier = Modifier + .size(24.dp) + .clip(RoundedCornerShape(12.dp)) + .background( + when (index) { + 0 -> Color(0xFFFFD700) // 金色 + 1 -> Color(0xFFC0C0C0) // 银色 + 2 -> Color(0xFFCD7F32) // 铜色 + else -> MaterialTheme.colorScheme.surfaceVariant + } + ), + contentAlignment = Alignment.Center + ) { + Text( + text = "${index + 1}", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = if (index < 3) Color.White else MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Text( + text = category, + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (index == 0) FontWeight.Bold else FontWeight.Normal + ) + } + + Column( + horizontalAlignment = Alignment.End + ) { + Text( + text = currencyFormatter.format(amount), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium + ) + Text( + text = "${String.format("%.1f", percentage)}%", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/yovinchen/bookkeeping/ui/components/MonthlyYearlyReport.kt b/app/src/main/java/com/yovinchen/bookkeeping/ui/components/MonthlyYearlyReport.kt new file mode 100644 index 0000000..90e2946 --- /dev/null +++ b/app/src/main/java/com/yovinchen/bookkeeping/ui/components/MonthlyYearlyReport.kt @@ -0,0 +1,258 @@ +package com.yovinchen.bookkeeping.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.yovinchen.bookkeeping.model.BookkeepingRecord +import com.yovinchen.bookkeeping.model.TransactionType +import java.text.NumberFormat +import java.time.LocalDateTime +import java.time.YearMonth +import java.time.format.DateTimeFormatter +import java.util.* + +/** + * 月度/年度报表组件 + * 显示收支对比、盈余情况等统计信息 + */ +@Composable +fun MonthlyYearlyReport( + records: List, + period: String, // "月度" 或 "年度" + startMonth: YearMonth, + endMonth: YearMonth, + modifier: Modifier = Modifier +) { + val totalIncome = records + .filter { it.type == TransactionType.INCOME } + .sumOf { it.amount } + + val totalExpense = records + .filter { it.type == TransactionType.EXPENSE } + .sumOf { it.amount } + + val balance = totalIncome - totalExpense + val savingsRate = if (totalIncome > 0) { + ((totalIncome - totalExpense) / totalIncome * 100).coerceAtLeast(0.0) + } else 0.0 + + Card( + modifier = modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // 标题 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "$period 报表", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + + Text( + text = if (startMonth == endMonth) { + startMonth.format(DateTimeFormatter.ofPattern("yyyy年MM月")) + } else { + "${startMonth.format(DateTimeFormatter.ofPattern("yyyy年MM月"))} - ${endMonth.format(DateTimeFormatter.ofPattern("yyyy年MM月"))}" + }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + HorizontalDivider() + + // 收支对比 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + // 收入 + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.weight(1f) + ) { + Text( + text = "总收入", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = formatCurrency(totalIncome), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + } + + // 分隔线 + Box( + modifier = Modifier + .width(1.dp) + .height(40.dp) + .background(MaterialTheme.colorScheme.outlineVariant) + ) + + // 支出 + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.weight(1f) + ) { + Text( + text = "总支出", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = formatCurrency(totalExpense), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.error + ) + } + } + + // 盈余情况 + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = if (balance >= 0) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.errorContainer + } + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = if (balance >= 0) "盈余" else "亏损", + style = MaterialTheme.typography.bodyMedium, + color = if (balance >= 0) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onErrorContainer + } + ) + Text( + text = formatCurrency(kotlin.math.abs(balance)), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = if (balance >= 0) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onErrorContainer + } + ) + } + } + + // 储蓄率 + if (totalIncome > 0) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "储蓄率", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f) + ) + + Box( + modifier = Modifier.weight(2f) + ) { + LinearProgressIndicator( + progress = (savingsRate / 100).toFloat().coerceIn(0f, 1f), + modifier = Modifier + .fillMaxWidth() + .height(8.dp) + .clip(RoundedCornerShape(4.dp)), + color = when { + savingsRate >= 30 -> MaterialTheme.colorScheme.primary + savingsRate >= 10 -> MaterialTheme.colorScheme.tertiary + else -> MaterialTheme.colorScheme.error + } + ) + } + + Text( + text = "${String.format("%.1f", savingsRate)}%", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(start = 8.dp), + color = when { + savingsRate >= 30 -> MaterialTheme.colorScheme.primary + savingsRate >= 10 -> MaterialTheme.colorScheme.tertiary + else -> MaterialTheme.colorScheme.error + } + ) + } + } + + // 日均消费 + val dayCount = if (period == "月度") { + // 计算月度天数 + java.time.temporal.ChronoUnit.DAYS.between( + startMonth.atDay(1), + endMonth.atEndOfMonth() + ) + 1 + } else { + // 计算年度天数 + java.time.temporal.ChronoUnit.DAYS.between( + startMonth.atDay(1), + endMonth.atEndOfMonth() + ) + 1 + } + + val dailyAverage = if (dayCount > 0) totalExpense / dayCount else 0.0 + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = "日均消费", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = formatCurrency(dailyAverage), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold + ) + } + } + } +} + +/** + * 格式化货币 + */ +private fun formatCurrency(amount: Double): String { + val format = NumberFormat.getCurrencyInstance(Locale.CHINA) + return format.format(amount) +} \ No newline at end of file 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 1077147..06328e2 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,12 +38,14 @@ 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.DetailedAnalysisReport +import com.yovinchen.bookkeeping.ui.components.MonthlyYearlyReport import com.yovinchen.bookkeeping.ui.components.TrendLineChart import com.yovinchen.bookkeeping.viewmodel.AnalysisViewModel import java.time.YearMonth enum class ViewMode { - CATEGORY, MEMBER + CATEGORY, MEMBER, REPORT } @OptIn(ExperimentalMaterial3Api::class) @@ -93,7 +95,13 @@ fun AnalysisScreen( Button( onClick = { showViewModeMenu = true } ) { - Text(if (currentViewMode == ViewMode.CATEGORY) "分类" else "成员") + Text( + when { + currentViewMode == ViewMode.CATEGORY -> "分类" + currentViewMode == ViewMode.MEMBER -> "成员" + else -> "报表" + } + ) Icon(Icons.Default.ArrowDropDown, "切换视图") } DropdownMenu( @@ -114,6 +122,13 @@ fun AnalysisScreen( showViewModeMenu = false } ) + DropdownMenuItem( + text = { Text("报表") }, + onClick = { + currentViewMode = ViewMode.REPORT + showViewModeMenu = false + } + ) } } @@ -159,41 +174,68 @@ fun AnalysisScreen( } } 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) + if (currentViewMode == ViewMode.REPORT) { + // 报表视图 + item { + MonthlyYearlyReport( + records = records, + period = if (startMonth == endMonth) "月度" else "年度", + startMonth = startMonth, + endMonth = endMonth, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + ) + } + + // 详细分析报表 + item { + DetailedAnalysisReport( + records = records, + startMonth = startMonth, + endMonth = endMonth, + modifier = Modifier + .fillMaxWidth() + .padding(top = 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 + // 统计列表 + 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) + 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) + } } - } - ) + ) + } } } }