21 Commits

Author SHA1 Message Date
94fc7b2a7e feat: 优化记账分析功能
- 重构导航系统,支持更细粒度的页面跳转
- 增强数据访问层,添加新的查询方法
- 优化界面布局和交互体验
- 添加成员分布分析功能
- 改进日期和金额的显示方式
2024-11-28 17:38:54 +08:00
380fdd5589 feat: 成员分析与详情功能实现
1. 新增成员详情页面,按天分组显示记录
2. 优化分析页面,支持分类/成员视图切换
3. 使用 rememberSaveable 保持视图模式状态
4. 改进 UI 布局和交互体验
2024-11-28 16:14:49 +08:00
76d0286883 fix: 修复警告 2024-11-28 14:35:10 +08:00
f134304646 feat: 为饼图添加点击事件
1. 为CategoryPieChart添加点击事件处理
2. 点击饼图可跳转到对应类别详情页面
2024-11-28 14:34:24 +08:00
8339d3d5da feat: 添加类别详情页面
1. 新增类别详情相关组件和视图模型
2. 优化饼图显示效果
3. 完善导航系统
4. 改进数据查询接口
2024-11-28 14:21:32 +08:00
c3f108ab57 fix: 修复警告 2024-11-28 11:17:20 +08:00
9772fd6e59 fix: 修复警告 2024-11-28 11:17:07 +08:00
0a738fc7e1 fix: 修复警告 2024-11-28 11:15:57 +08:00
6c3b366d45 fix: 修复多余文字 2024-11-28 10:52:08 +08:00
3c080fbc05 fix: 修复月份选择器参数错误
- 将 MonthYearPicker 的 initialMonth 参数改为 selectedMonth
- 保持与组件定义一致
2024-11-28 10:51:49 +08:00
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
26 changed files with 1374 additions and 141 deletions

1
.idea/misc.xml generated
View File

@@ -1,4 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">

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

@@ -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
@@ -27,6 +28,59 @@ 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<Double?>
@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<List<BookkeepingRecord>>
@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<List<BookkeepingRecord>>
@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<List<CategoryStat>>
@Query("""
SELECT * FROM bookkeeping_records
WHERE category = :category
ORDER BY date DESC
""")
fun getRecordsByCategory(
category: String
): Flow<List<BookkeepingRecord>>
@Insert
suspend fun insertRecord(record: BookkeepingRecord): Long
@@ -53,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<BookkeepingRecord>
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
package com.yovinchen.bookkeeping.model
enum class AnalysisType {
EXPENSE,
INCOME,
TREND
}

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,8 @@
package com.yovinchen.bookkeeping.model
data class CategoryStat(
val category: String,
val amount: Double,
val count: Int = 0,
val percentage: Double = 0.0
)

View File

@@ -0,0 +1,82 @@
package com.yovinchen.bookkeeping.ui.components
import android.graphics.Color as AndroidColor
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.PieChart
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<Pair<String, Float>>,
memberData: List<Pair<String, Float>>,
currentViewMode: Boolean = false, // false 为分类视图true 为成员视图
modifier: Modifier = Modifier,
onCategoryClick: (String) -> Unit = {}
) {
val textColor = MaterialTheme.colorScheme.onSurface.toArgb()
val data = if (currentViewMode) memberData else categoryData
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(textColor)
setEntryLabelTextSize(12f)
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 ->
val entries = data.map { (label, amount) ->
PieEntry(amount, label)
}
val dataSet = PieDataSet(entries, "").apply {
colors = ColorTemplate.MATERIAL_COLORS.toList()
valueTextSize = 14f
valueFormatter = PercentFormatter(chart)
valueTextColor = textColor
setDrawValues(true)
}
val pieData = PieData(dataSet)
chart.data = pieData
chart.invalidate()
}
)
}

View File

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

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

@@ -22,6 +22,7 @@ fun RecordItem(
members: List<Member> = 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)

View File

@@ -2,40 +2,53 @@ 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.automirrored.filled.List
import androidx.compose.material.icons.filled.Analytics
import androidx.compose.material.icons.filled.Settings
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.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.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: @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 title: String,
val icon: ImageVector? = null
) {
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 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)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@@ -45,22 +58,18 @@ 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 ->
Screen.bottomNavigationItems().forEach { screen ->
NavigationBarItem(
icon = screen.icon,
label = { Text(screen.label) },
selected = currentDestination?.hierarchy?.any { it.route == screen.route } == true,
icon = { Icon(screen.icon!!, contentDescription = screen.title) },
label = { Text(screen.title) },
selected = currentRoute == screen.route,
onClick = {
navController.navigate(screen.route) {
popUpTo(navController.graph.findStartDestination().id) {
@@ -69,33 +78,80 @@ 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.Home.route) { HomeScreen() }
composable(Screen.Analysis.route) {
AnalysisScreen(
onNavigateToCategoryDetail = { category, yearMonth ->
navController.navigate(Screen.CategoryDetail.createRoute(category, yearMonth))
},
onNavigateToMemberDetail = { memberName, yearMonth ->
// 在这里我们暂时使用一个默认分类,你需要根据实际情况修改这里的逻辑
val defaultCategory = "默认"
navController.navigate(Screen.MemberDetail.createRoute(memberName, defaultCategory, yearMonth))
}
)
}
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 yearMonthStr = backStackEntry.arguments?.getString("yearMonth") ?: return@composable
val yearMonth = YearMonth.parse(yearMonthStr)
CategoryDetailScreen(
category = category,
yearMonth = yearMonth,
onNavigateBack = { navController.popBackStack() },
onNavigateToMemberDetail = { memberName ->
navController.navigate(Screen.MemberDetail.createRoute(memberName, category, yearMonth))
}
)
}
composable(
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)
MemberDetailScreen(
memberName = memberName,
category = category,
yearMonth = yearMonth,
onNavigateBack = { navController.popBackStack() }
)
}
}
}
}

View File

@@ -0,0 +1,175 @@
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
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.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,
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(
modifier = Modifier
.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月")))
}
}
// 分析类型和视图模式选择行
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
// 分类/成员切换下拉菜单
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
}
)
}
}
// 类型切换
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)
)
}
}
}
// 使用LazyColumn包含饼图和列表
LazyColumn(
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.category, 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, selectedMonth)
} else {
onNavigateToMemberDetail(category, selectedMonth)
}
}
)
}
}
// 添加统计列表项目
items(if (currentViewMode == ViewMode.CATEGORY) categoryStats else memberStats) { stat ->
CategoryStatItem(
stat = stat,
onClick = {
if (currentViewMode == ViewMode.CATEGORY) {
onNavigateToCategoryDetail(stat.category, selectedMonth)
} else {
onNavigateToMemberDetail(stat.category, selectedMonth)
}
}
)
}
}
if (showMonthPicker) {
MonthYearPicker(
selectedMonth = selectedMonth,
onMonthSelected = { month ->
viewModel.setSelectedMonth(month)
showMonthPicker = false
},
onDismiss = { showMonthPicker = false }
)
}
}
}
}

View File

@@ -0,0 +1,180 @@
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.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.*
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CategoryDetailScreen(
category: String,
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, yearMonth)
)
val records by viewModel.records.collectAsState()
val memberStats by viewModel.memberStats.collectAsState()
val total by viewModel.total.collectAsState()
Scaffold(
topBar = {
TopAppBar(
title = { Text(category) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回")
}
}
)
}
) { padding ->
LazyColumn(
modifier = modifier
.fillMaxSize()
.padding(padding),
horizontalAlignment = Alignment.CenterHorizontally
) {
item {
Text(
text = NumberFormat.getCurrencyInstance(Locale.CHINA).format(total),
style = MaterialTheme.typography.headlineMedium,
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 })
groupedRecords.forEach { (date, dayRecords) ->
item {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
// 日期标题和总金额
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = date,
style = MaterialTheme.typography.titleMedium,
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))
// 当天的记录列表
dayRecords.forEach { record ->
RecordItem(record = record)
if (record != dayRecords.last()) {
HorizontalDivider(
modifier = Modifier.padding(vertical = 8.dp),
color = MaterialTheme.colorScheme.outlineVariant
)
}
}
}
}
}
}
}
}
}
@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
)
}
}

View File

@@ -0,0 +1,162 @@
package com.yovinchen.bookkeeping.ui.screen
import androidx.compose.foundation.layout.*
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.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.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.*
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MemberDetailScreen(
memberName: String,
category: 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, category, yearMonth) {
viewModel.loadMemberRecords(memberName, category, yearMonth)
}
val groupedRecords = remember(records) {
records.groupBy { record ->
SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(record.date)
}.toSortedMap(reverseOrder())
}
Scaffold(
topBar = {
TopAppBar(
title = {
Text("$category - $memberName")
},
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, "返回")
}
}
)
}
) { padding ->
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
// 第一层:总金额卡片
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(totalAmount),
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
}
}
}
// 第二层:按日期分组的记录列表
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)
}
}
}
}
}
}
}
}
@Composable
private fun RecordItem(record: Record) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
if (record.description.isNotBlank()) {
Text(
text = record.description,
style = MaterialTheme.typography.bodyMedium
)
}
Text(
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
)
}
}

View File

@@ -0,0 +1,104 @@
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.AnalysisType
import com.yovinchen.bookkeeping.model.CategoryStat
import com.yovinchen.bookkeeping.model.TransactionType
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()
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 = 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 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
}
}

View File

@@ -0,0 +1,54 @@
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.CategoryStat
import kotlinx.coroutines.flow.*
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 recordDao = database.bookkeepingDao()
private val yearMonthStr = month.format(DateTimeFormatter.ofPattern("yyyy-MM"))
private val _records = MutableStateFlow<List<BookkeepingRecord>>(emptyList())
val records: StateFlow<List<BookkeepingRecord>> = _records.asStateFlow()
private val _memberStats = MutableStateFlow<List<CategoryStat>>(emptyList())
val memberStats: StateFlow<List<CategoryStat>> = _memberStats.asStateFlow()
val total: StateFlow<Double> = records
.map { records -> records.sumOf { it.amount } }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = 0.0
)
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)
recordDao.getMemberStatsByCategory(category, yearMonthStr)
.onEach { stats ->
_memberStats.value = stats
}
.launchIn(viewModelScope)
}
}

View File

@@ -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 <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(CategoryDetailViewModel::class.java)) {
@Suppress("UNCHECKED_CAST")
return CategoryDetailViewModel(database, category, month) as T
}
throw IllegalArgumentException("Unknown ViewModel class")
}
}

View File

@@ -0,0 +1,47 @@
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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import java.time.YearMonth
import java.time.ZoneId
import java.util.Date
class MemberDetailViewModel(application: Application) : AndroidViewModel(application) {
private val database = BookkeepingDatabase.getDatabase(application)
private val recordDao = database.bookkeepingDao()
private val _memberRecords = MutableStateFlow<List<BookkeepingRecord>>(emptyList())
val memberRecords: StateFlow<List<BookkeepingRecord>> = _memberRecords
private val _totalAmount = MutableStateFlow(0.0)
val totalAmount: StateFlow<Double> = _totalAmount
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
_totalAmount.value = records.sumOf { it.amount }
}
}
}

View File

@@ -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
android.nonTransitiveRClass=true
# Kotlin
org.gradle.java.home=/Library/Java/JavaVirtualMachines/jdk-17.jdk/Contents/Home

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