Compare commits
3 Commits
e651086e6d
...
a2489c4987
Author | SHA1 | Date | |
---|---|---|---|
a2489c4987 | |||
562617ca11 | |||
74cc6f36a9 |
@@ -4,7 +4,8 @@
|
|||||||
"Bash(./gradlew:*)",
|
"Bash(./gradlew:*)",
|
||||||
"Bash(git push:*)",
|
"Bash(git push:*)",
|
||||||
"Bash(git branch:*)",
|
"Bash(git branch:*)",
|
||||||
"Bash(git add:*)"
|
"Bash(git add:*)",
|
||||||
|
"Bash(git commit:*)"
|
||||||
],
|
],
|
||||||
"deny": []
|
"deny": []
|
||||||
}
|
}
|
||||||
|
35
README.md
35
README.md
@@ -6,6 +6,28 @@
|
|||||||
|
|
||||||
本项目是一个使用 Kotlin 和 Jetpack Compose 开发的 Android 记账应用,采用 MVVM 架构,提供简洁直观的用户界面和丰富的记账功能。
|
本项目是一个使用 Kotlin 和 Jetpack Compose 开发的 Android 记账应用,采用 MVVM 架构,提供简洁直观的用户界面和丰富的记账功能。
|
||||||
|
|
||||||
|
## 🔥 功能亮点
|
||||||
|
|
||||||
|
### 📊 智能数据分析
|
||||||
|
- **月度/年度报表**:收支对比、储蓄率、日均消费等关键指标
|
||||||
|
- **详细分析报表**:分类统计明细、TOP排行榜、可视化进度条
|
||||||
|
- **多维度统计**:支持按分类、成员、时间等多维度数据分析
|
||||||
|
|
||||||
|
### 💼 预算管理系统
|
||||||
|
- **多层级预算**:支持总预算、分类预算、成员预算
|
||||||
|
- **实时监控**:预算使用情况可视化,超支警告提醒
|
||||||
|
- **灵活配置**:可启用/禁用预算,自定义预警阈值
|
||||||
|
|
||||||
|
### 🔐 数据安全保障
|
||||||
|
- **备份加密**:使用 Android Keystore 加密导出文件
|
||||||
|
- **离线优先**:完全本地存储,无网络依赖
|
||||||
|
- **隐私保护**:极简权限,数据完全掌控
|
||||||
|
|
||||||
|
### 🎨 现代化设计
|
||||||
|
- **Material 3**:遵循最新设计规范
|
||||||
|
- **深色模式**:支持系统主题切换
|
||||||
|
- **响应式布局**:适配不同屏幕尺寸
|
||||||
|
|
||||||
## ⭐️ 主要特性
|
## ⭐️ 主要特性
|
||||||
|
|
||||||
- 🔒 完全离线运行,无需网络连接
|
- 🔒 完全离线运行,无需网络连接
|
||||||
@@ -15,7 +37,8 @@
|
|||||||
- 📊 按日期和类别统计
|
- 📊 按日期和类别统计
|
||||||
- 🔐 备份文件加密保护
|
- 🔐 备份文件加密保护
|
||||||
- 📅 自定义月度记账周期
|
- 📅 自定义月度记账周期
|
||||||
- 💼 预算管理(开发中)
|
- 💼 预算管理(基本完成)
|
||||||
|
- 📈 详细数据分析报表
|
||||||
|
|
||||||
## 🛠 技术栈
|
## 🛠 技术栈
|
||||||
|
|
||||||
@@ -46,7 +69,7 @@
|
|||||||
### 2. 图表分析 (已完成 🎉)
|
### 2. 图表分析 (已完成 🎉)
|
||||||
- [x] 支出/收入趋势图表
|
- [x] 支出/收入趋势图表
|
||||||
- [x] 分类占比饼图
|
- [x] 分类占比饼图
|
||||||
- [ ] 月度/年度报表
|
- [x] 月度/年度报表
|
||||||
- [x] 成员消费分析
|
- [x] 成员消费分析
|
||||||
- [x] 自定义统计周期
|
- [x] 自定义统计周期
|
||||||
|
|
||||||
@@ -82,7 +105,6 @@
|
|||||||
### 6. 体验优化 (持续进行 🔄)
|
### 6. 体验优化 (持续进行 🔄)
|
||||||
- [x] 深色模式支持
|
- [x] 深色模式支持
|
||||||
- [ ] 手势操作优化
|
- [ ] 手势操作优化
|
||||||
- [ ] 快速记账小组件
|
|
||||||
- [ ] 多语言支持
|
- [ ] 多语言支持
|
||||||
- [ ] 自定义主题
|
- [ ] 自定义主题
|
||||||
|
|
||||||
@@ -131,6 +153,13 @@
|
|||||||
- 预算编辑对话框
|
- 预算编辑对话框
|
||||||
- 预算状态可视化(进度条、超支提醒)
|
- 预算状态可视化(进度条、超支提醒)
|
||||||
- 预算导航集成
|
- 预算导航集成
|
||||||
|
- 数据分析优化
|
||||||
|
- 月度/年度报表组件
|
||||||
|
- 详细分析报表(分类统计明细)
|
||||||
|
- 收支对比、储蓄率、日均消费分析
|
||||||
|
- TOP分类排行榜(金银铜奖牌设计)
|
||||||
|
- 报表视图集成到分析页面
|
||||||
|
- 修复嵌套滚动组件崩溃问题
|
||||||
|
|
||||||
### v1.4
|
### v1.4
|
||||||
- 数据安全功能
|
- 数据安全功能
|
||||||
|
@@ -0,0 +1,302 @@
|
|||||||
|
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<BookkeepingRecord>,
|
||||||
|
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 }
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
// 时间范围标题
|
||||||
|
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()) {
|
||||||
|
CategoryDetailCard(
|
||||||
|
title = "支出分类明细",
|
||||||
|
categoryData = expenseByCategory,
|
||||||
|
total = totalExpense,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
currencyFormatter = currencyFormatter
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 收入分类详情
|
||||||
|
if (incomeByCategory.isNotEmpty()) {
|
||||||
|
CategoryDetailCard(
|
||||||
|
title = "收入分类明细",
|
||||||
|
categoryData = incomeByCategory,
|
||||||
|
total = totalIncome,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
currencyFormatter = currencyFormatter
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分类占比前5名
|
||||||
|
if (expenseByCategory.isNotEmpty()) {
|
||||||
|
TopCategoriesCard(
|
||||||
|
title = "支出TOP5",
|
||||||
|
categoryData = expenseByCategory.take(5),
|
||||||
|
total = totalExpense,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
currencyFormatter = currencyFormatter
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分类详情卡片
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun CategoryDetailCard(
|
||||||
|
title: String,
|
||||||
|
categoryData: List<Pair<String, Double>>,
|
||||||
|
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<Pair<String, Double>>,
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -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<BookkeepingRecord>,
|
||||||
|
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)
|
||||||
|
}
|
@@ -38,12 +38,14 @@ import com.yovinchen.bookkeeping.model.MemberStat
|
|||||||
import com.yovinchen.bookkeeping.ui.components.CategoryPieChart
|
import com.yovinchen.bookkeeping.ui.components.CategoryPieChart
|
||||||
import com.yovinchen.bookkeeping.ui.components.CategoryStatItem
|
import com.yovinchen.bookkeeping.ui.components.CategoryStatItem
|
||||||
import com.yovinchen.bookkeeping.ui.components.DateRangePicker
|
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.ui.components.TrendLineChart
|
||||||
import com.yovinchen.bookkeeping.viewmodel.AnalysisViewModel
|
import com.yovinchen.bookkeeping.viewmodel.AnalysisViewModel
|
||||||
import java.time.YearMonth
|
import java.time.YearMonth
|
||||||
|
|
||||||
enum class ViewMode {
|
enum class ViewMode {
|
||||||
CATEGORY, MEMBER
|
CATEGORY, MEMBER, REPORT
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -93,7 +95,13 @@ fun AnalysisScreen(
|
|||||||
Button(
|
Button(
|
||||||
onClick = { showViewModeMenu = true }
|
onClick = { showViewModeMenu = true }
|
||||||
) {
|
) {
|
||||||
Text(if (currentViewMode == ViewMode.CATEGORY) "分类" else "成员")
|
Text(
|
||||||
|
when {
|
||||||
|
currentViewMode == ViewMode.CATEGORY -> "分类"
|
||||||
|
currentViewMode == ViewMode.MEMBER -> "成员"
|
||||||
|
else -> "报表"
|
||||||
|
}
|
||||||
|
)
|
||||||
Icon(Icons.Default.ArrowDropDown, "切换视图")
|
Icon(Icons.Default.ArrowDropDown, "切换视图")
|
||||||
}
|
}
|
||||||
DropdownMenu(
|
DropdownMenu(
|
||||||
@@ -114,6 +122,13 @@ fun AnalysisScreen(
|
|||||||
showViewModeMenu = false
|
showViewModeMenu = false
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("报表") },
|
||||||
|
onClick = {
|
||||||
|
currentViewMode = ViewMode.REPORT
|
||||||
|
showViewModeMenu = false
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,41 +174,68 @@ fun AnalysisScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
// 饼图视图
|
if (currentViewMode == ViewMode.REPORT) {
|
||||||
item {
|
// 报表视图
|
||||||
CategoryPieChart(
|
item {
|
||||||
categoryData = categoryStats.map { Pair(it.category, it.percentage.toFloat()) },
|
MonthlyYearlyReport(
|
||||||
memberData = memberStats.map { Pair(it.member, it.percentage.toFloat()) },
|
records = records,
|
||||||
currentViewMode = currentViewMode == ViewMode.MEMBER,
|
period = if (startMonth == endMonth) "月度" else "年度",
|
||||||
modifier = Modifier
|
startMonth = startMonth,
|
||||||
.fillMaxWidth()
|
endMonth = endMonth,
|
||||||
.height(200.dp)
|
modifier = Modifier
|
||||||
.padding(bottom = 16.dp),
|
.fillMaxWidth()
|
||||||
onCategoryClick = { category ->
|
.padding(vertical = 8.dp)
|
||||||
if (currentViewMode == ViewMode.CATEGORY) {
|
)
|
||||||
onNavigateToCategoryDetail(category, startMonth, endMonth)
|
}
|
||||||
} else {
|
|
||||||
onNavigateToMemberDetail(category, startMonth, endMonth, selectedAnalysisType)
|
// 详细分析报表
|
||||||
|
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 ->
|
items(if (currentViewMode == ViewMode.CATEGORY) categoryStats else memberStats) { stat ->
|
||||||
val category = if (stat is CategoryStat) stat.category else null
|
val category = if (stat is CategoryStat) stat.category else null
|
||||||
val member = if (stat is MemberStat) stat.member else null
|
val member = if (stat is MemberStat) stat.member else null
|
||||||
|
|
||||||
CategoryStatItem(
|
CategoryStatItem(
|
||||||
stat = stat,
|
stat = stat,
|
||||||
onClick = {
|
onClick = {
|
||||||
if (currentViewMode == ViewMode.CATEGORY && category != null) {
|
if (currentViewMode == ViewMode.CATEGORY && category != null) {
|
||||||
onNavigateToCategoryDetail(category, startMonth, endMonth)
|
onNavigateToCategoryDetail(category, startMonth, endMonth)
|
||||||
} else if (currentViewMode == ViewMode.MEMBER && member != null) {
|
} else if (currentViewMode == ViewMode.MEMBER && member != null) {
|
||||||
onNavigateToMemberDetail(member, startMonth, endMonth, selectedAnalysisType)
|
onNavigateToMemberDetail(member, startMonth, endMonth, selectedAnalysisType)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
)
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user