11 Commits

Author SHA1 Message Date
71deaaa288 style: 简化饼图显示
- 禁用饼图的图例显示
- 移除图例相关的配置代码
- 将 PieDataSet 的标题设置为空字符串
- 优化界面简洁度
2024-11-28 09:10:03 +08:00
47e202fa61 fix: 修复饼图在浅色模式下图例文字颜色显示问题
- 使用 Material Theme 的 onSurface 颜色来设置图例文字颜色
- 确保文字颜色正确跟随系统主题
- 优化代码结构和注释
2024-11-27 18:07:41 +08:00
af880c23eb 新增分析页面,完善大体展示内容
- 顶部月份选择器:可以前后切换月份或直接选择具体月份
- 分析类型切换:支出分析/收入分析/收支趋势
- 数据可视化:
- 使用饼图展示各分类占比
- 使用列表展示详细数据,包括金额、百分比和进度条
2024-11-27 17:49:47 +08:00
773c155d0c 修复警告 2024-11-27 16:08:34 +08:00
30e9345d81 优化: 主页统计功能改进
- 调整主页统计区域布局和样式
- 优化支出、收入、结余的显示顺序
- 改进结余区域的高亮显示逻辑
- 简化代码结构和格式
2024-11-27 14:27:26 +08:00
c75439d15a 修改README 2024-11-27 13:50:53 +08:00
95b3233d5e docs: update README.md with checkboxes and status icons 2024-11-27 13:46:15 +08:00
df80dadfea docs: update README.md with complete formatting 2024-11-27 13:43:07 +08:00
e03149377c docs: update README.md with new format and roadmap 2024-11-27 13:37:01 +08:00
49e83cea90 docs: update README.md with new email and roadmap 2024-11-27 13:29:29 +08:00
6d9c5a27f7 修改README 2024-11-27 13:09:28 +08:00
11 changed files with 650 additions and 121 deletions

147
README.md
View File

@@ -2,58 +2,135 @@
一个轻量级的个人记账应用,专注于隐私和离线使用。
## 🌟 特点
## 📖 项目概述
本项目是一个使用 Kotlin 和 Jetpack Compose 开发的 Android 记账应用,采用 MVVM 架构,提供简洁直观的用户界面和丰富的记账功能。
## ⭐️ 主要特性
- 🔒 完全离线运行,无需网络连接
- 📱 极简权限要求,仅使用必要的系统权限
- 💰 支持收入和支出记录
- 👥 支持多人记账
- 📊 按日期和类别统计
- 🎨 Material You 设计风格
## 🛠 技术栈
- 语言Kotlin
- UI框架Jetpack Compose
- 数据库Room
- 架构MVVM
- 💻 开发语言Kotlin
- 🎨 UI 框架Jetpack Compose
- 🏗️ 架构模式MVVM
- 💾 数据存储Room Database
- 💉 依赖注入Hilt
- ⚡️ 异步处理Kotlin Coroutines
## 📱 功能
## 🗺 开发路线图
### 记账管理
- 收入支出记录
- 自定义分类管理
- 日期和时间选择
- 备注说明
### 0. 基础记账 (已完成 ✨)
- [x] 收入/支出记录管理
- [x] 分类管理系统
- [x] 自定义日期选择
- [x] Material 3 设计界面
- [x] 深色/浅色主题切换
- [x] 主题色自定义
### 成员管理
- 多人记账支持
- 成员关联记录
- 按成员筛选统计
### 1. 成员系统 (已完成 🎉)
- [x] 成员添加/编辑/删除
- [x] 记账时选择相关成员
- [x] 主页账单修改相关成员
- [x] 成员消费统计
### 数据统计
- 月度收支统计
- 分类统计
- 每日收支明细
### 2. 图表分析 (进行中 🚀)
- [ ] 支出/收入趋势图表
- [ ] 分类占比饼图
- [ ] 月度/年度报表
- [ ] 成员消费分析
- [ ] 自定义统计周期
## 🔒 隐私保护
### 3. 数据管理 (计划中 📝)
- [ ] 导出 CSV/Excel 功能
- [ ] 数据迁移工具
- [ ] 定期自动备份
- [ ] 备份加密功能
- 完全离线运行,数据存储在本地
- 无需任何网络权限
- 最小化系统权限要求
### 4. 预算管理 (计划中 💡)
- [ ] 月度预算设置
- [ ] 预算超支提醒
- [ ] 分类预算管理
- [ ] 成员预算管理
- [ ] 预算分析报告
## 📝 系统要求
- Android 5.0 (API 21) 或更高版本
- 存储权限(用于数据备份,可选)
## 🔜 未来计划
- [ ] 数据导出和备份
- [ ] 预算管理
- [ ] 更多统计图表
### 5. 体验优化 (持续进行 🔄)
- [x] 深色模式支持
- [ ] 手势操作优化
- [ ] 快速记账小组件
- [ ] 多语言支持
- [ ] 自定义主题
### 6. 性能提升 (持续进行 ⚡️)
- [ ] 大数据量处理优化
- [ ] 启动速度优化
- [ ] 内存使用优化
- [ ] 缓存策略优化
- [ ] 数据库查询优化
## 🌲 分支管理
- `master`: 稳定主分支
- `develop`: 主开发分支
- `feature/*`: 功能开发分支
- `release/*`: 版本发布分支
- `hotfix/*`: 紧急修复分支
## 📝 版本历史
### v1.1.0 (2024-01-10)
- 成员管理功能
- 成员添加/编辑/删除
- 记账时选择相关成员
- 成员消费统计
- UI/UX 优化
- 记录展示优化
- 月度统计界面
- 分组展示优化
- 数据管理
- 记录筛选增强
- 数据库性能优化
- 状态管理重构
### v1.0.0 (2024-01-05)
- 基础记账功能
- 收入/支出记录
- 金额、日期、分类、备注管理
- Material 3 设计界面
- 深色/浅色主题切换
- 主题色自定义
- 分类管理
- 默认分类预设
- 自定义分类支持
- 分类编辑与删除
- 月度统计
- 月度收支总览
- 月份快速切换
- 自定义日期选择器
## 🤝 贡献指南
1. Fork 项目
2. 创建功能分支 (`git checkout -b feature/AmazingFeature`)
3. 提交更改 (`git commit -m 'feat: Add some AmazingFeature'`)
4. 推送到分支 (`git push origin feature/AmazingFeature`)
5. 提交 Pull Request
## 📄 许可证
[MIT License](LICENSE)
本项目采用 MIT 许可证 - 查看 [LICENSE](LICENSE) 文件了解详细信息
## 📮 联系方式
- 作者YovinChen
- 邮箱gzh298255@gmail.com
- 博客:[blog.hhdxw.top](https://blog.hhdxw.top)
## 🙏 致谢
感谢所有为这个项目做出贡献的开发者!

View File

@@ -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")

View File

@@ -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)

View File

@@ -0,0 +1,73 @@
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.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
import com.github.mikephil.charting.formatter.PercentFormatter
import com.github.mikephil.charting.utils.ColorTemplate
@Composable
fun CategoryPieChart(
categoryData: List<Pair<String, Float>>,
modifier: Modifier = Modifier
) {
val isDarkTheme = isSystemInDarkTheme()
val textColor = MaterialTheme.colorScheme.onSurface.toArgb()
AndroidView(
modifier = modifier
.fillMaxWidth()
.height(300.dp),
factory = { context ->
PieChart(context).apply {
description.isEnabled = false
setUsePercentValues(true)
setDrawEntryLabels(true)
// 禁用图例显示
legend.isEnabled = false
isDrawHoleEnabled = true
holeRadius = 40f
setHoleColor(AndroidColor.TRANSPARENT)
setTransparentCircleRadius(45f)
// 设置标签文字颜色为白色(因为标签在彩色扇形上)
setEntryLabelColor(AndroidColor.WHITE)
setEntryLabelTextSize(12f)
// 设置中心文字颜色跟随主题
setCenterTextColor(textColor)
}
},
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 = AndroidColor.WHITE // 扇形上的数值文字保持白色
setDrawValues(true)
}
val pieData = PieData(dataSet)
chart.data = pieData
chart.invalidate()
}
)
}

View File

@@ -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("取消")
}
}
)
}

View File

@@ -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.*
@@ -22,9 +23,7 @@ import java.time.YearMonth
@Composable
fun MonthYearPickerDialog(
selectedMonth: YearMonth,
onMonthSelected: (YearMonth) -> Unit,
onDismiss: () -> Unit
selectedMonth: YearMonth, onMonthSelected: (YearMonth) -> Unit, onDismiss: () -> Unit
) {
var currentYearMonth by remember { mutableStateOf(selectedMonth) }
@@ -71,8 +70,7 @@ fun MonthYearPickerDialog(
// 月份网格
LazyVerticalGrid(
columns = GridCells.Fixed(3),
modifier = Modifier.height(200.dp)
columns = GridCells.Fixed(3), modifier = Modifier.height(200.dp)
) {
items(12) { index ->
val month = index + 1
@@ -126,6 +124,7 @@ fun MonthYearPickerDialog(
}
}
@SuppressLint("DefaultLocale")
@Composable
fun MonthlyStatistics(
totalIncome: Double,
@@ -163,11 +162,9 @@ fun MonthlyStatistics(
Icon(Icons.AutoMirrored.Filled.KeyboardArrowLeft, "上个月")
}
Text(
text = "${selectedMonth.year}${selectedMonth.monthValue}",
Text(text = "${selectedMonth.year}${selectedMonth.monthValue}",
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.clickable { showMonthPicker = true }
)
modifier = Modifier.clickable { showMonthPicker = true })
IconButton(onClick = onNextMonth) {
Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, "下个月")
@@ -177,24 +174,38 @@ fun MonthlyStatistics(
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween
) {
// 收入统计
Column(
modifier = Modifier
.weight(1f)
.clickable { onIncomeClick() }
.background(
if (selectedType == TransactionType.INCOME) MaterialTheme.colorScheme.primaryContainer
else Color.Transparent,
RoundedCornerShape(8.dp)
)
.padding(8.dp)
) {
// 支出统计
Column(modifier = Modifier
.weight(1f)
.clickable { onExpenseClick() }
.background(
if (selectedType == TransactionType.EXPENSE) MaterialTheme.colorScheme.primaryContainer
else Color.Transparent, RoundedCornerShape(8.dp)
)
.padding(8.dp)) {
Text(
text = "收入",
style = MaterialTheme.typography.titleMedium
text = "支出", style = MaterialTheme.typography.titleMedium
)
Text(
text = "¥${String.format("%.2f", totalExpense)}",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.error
)
}
Spacer(modifier = Modifier.width(16.dp))
// 收入统计
Column(modifier = Modifier
.weight(1f)
.clickable { onIncomeClick() }
.background(
if (selectedType == TransactionType.INCOME) MaterialTheme.colorScheme.primaryContainer
else Color.Transparent, RoundedCornerShape(8.dp)
)
.padding(8.dp)) {
Text(
text = "收入", style = MaterialTheme.typography.titleMedium
)
Text(
text = "¥${String.format("%.2f", totalIncome)}",
@@ -204,35 +215,30 @@ fun MonthlyStatistics(
}
Spacer(modifier = Modifier.width(16.dp))
// 支出统计
Column(
modifier = Modifier
.weight(1f)
.clickable { onExpenseClick() }
.background(
if (selectedType == TransactionType.EXPENSE) MaterialTheme.colorScheme.primaryContainer
else Color.Transparent,
RoundedCornerShape(8.dp)
)
.padding(8.dp)
) {
// 结余统计
Column(modifier = Modifier
.weight(1f)
.clickable { onClearFilter() }
.background(
if (selectedType == TransactionType.INCOME) MaterialTheme.colorScheme.primaryContainer
else Color.Transparent, RoundedCornerShape(8.dp)
)
.padding(8.dp)) {
Text(
text = "支出",
style = MaterialTheme.typography.titleMedium
text = "结余", style = MaterialTheme.typography.titleMedium
)
Text(
text = "¥${String.format("%.2f", totalExpense)}",
text = "¥${String.format("%.2f", totalIncome - totalExpense)}",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.error
color = if (totalIncome >= totalExpense) MaterialTheme.colorScheme.tertiary
else MaterialTheme.colorScheme.error
)
}
}
if (selectedType != null) {
TextButton(
onClick = onClearFilter,
modifier = Modifier.align(Alignment.End)
onClick = onClearFilter, modifier = Modifier.align(Alignment.End)
) {
Text("清除筛选")
}
@@ -241,10 +247,8 @@ fun MonthlyStatistics(
}
if (showMonthPicker) {
MonthYearPickerDialog(
selectedMonth = selectedMonth,
MonthYearPickerDialog(selectedMonth = selectedMonth,
onMonthSelected = onMonthSelected,
onDismiss = { showMonthPicker = false }
)
onDismiss = { showMonthPicker = false })
}
}

View File

@@ -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
)
)
}
}
}

View File

@@ -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
)
}
}
}
}

View File

@@ -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
)

View File

@@ -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" }

View File

@@ -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") }
}
}