diff --git a/.idea/misc.xml b/.idea/misc.xml index 74dd639..312151c 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,5 +1,10 @@ + + + + + diff --git a/README.md b/README.md index f8ad555..5c8dd46 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ ## 🗺 开发路线图 -### 1. 基础记账 (已完成 ✨) +### 0. 基础记账 (已完成 ✨) - [x] 收入/支出记录管理 - [x] 分类管理系统 - [x] 自定义日期选择器 @@ -33,41 +33,40 @@ - [x] 深色/浅色主题切换 - [x] 主题色自定义 -### 2. 成员系统 (已完成 🎉) +### 1. 成员系统 (已完成 🎉) - [x] 成员添加/编辑/删除 - [x] 记账时选择相关成员 - [x] 主页账单修改相关成员 - [x] 成员消费统计 -### 3. 数据分析 (进行中 🚀) +### 2. 图表分析 (进行中 🚀) - [ ] 支出/收入趋势图表 - [ ] 分类占比饼图 - [ ] 月度/年度报表 - [ ] 成员消费分析 - [ ] 自定义统计周期 -### 4. 数据管理 (计划中 📝) +### 3. 数据管理 (计划中 📝) - [ ] 导出 CSV/Excel 功能 -- [ ] 云端备份支持 - [ ] 数据迁移工具 - [ ] 定期自动备份 - [ ] 备份加密功能 -### 5. 预算管理 (计划中 💡) +### 4. 预算管理 (计划中 💡) - [ ] 月度预算设置 - [ ] 预算超支提醒 - [ ] 分类预算管理 - [ ] 成员预算管理 - [ ] 预算分析报告 -### 6. 体验优化 (持续进行 🔄) +### 5. 体验优化 (持续进行 🔄) - [x] 深色模式支持 - [ ] 手势操作优化 - [ ] 快速记账小组件 - [ ] 多语言支持 - [ ] 自定义主题 -### 7. 性能提升 (持续进行 ⚡️) +### 6. 性能提升 (持续进行 ⚡️) - [ ] 大数据量处理优化 - [ ] 启动速度优化 - [ ] 内存使用优化 @@ -82,6 +81,25 @@ - `release/*`: 版本发布分支 - `hotfix/*`: 紧急修复分支 +## 🔄 提交规范 + +提交信息应遵循以下格式:`: ` + +### 提交类型(Type) + +- `feat`: 新功能(feature) +- `fix`: 修复bug +- `docs`: 文档更新(documentation) +- `style`: 代码格式(不影响代码运行的变动) +- `refactor`: 代码重构(既不是新增功能,也不是修复bug) +- `perf`: 性能优化 +- `test`: 测试相关 +- `build`: 构建相关 +- `ci`: 持续集成 +- `chore`: 构建过程或辅助工具的变动 +- `revert`: 回退提交 +- `improvement`: 改进 + ## 📝 版本历史 ### v1.1.0 (2024-01-10) @@ -98,6 +116,39 @@ - 数据库性能优化 - 状态管理重构 +### v1.1.0 +#### 成员管理系统 +- 新增成员管理功能 + - 支持添加、编辑和删除成员信息 + - 为每笔记录分配对应的成员 + - 成员列表显示和管理 +- 记录关联 + - 在记账时可选择相关成员 + - 支持按成员筛选和查看记录 +- 数据统计 + - 成员消费统计和分析 + - 成员支出占比展示 + +### v1.2.0 - v1.2.4 +#### 图表分析系统 +- 分类数据可视化 + - 支出/收入分类饼图展示 + - 分类占比详细统计 + - 分类数据交互和筛选 +- 成员数据可视化 + - 成员消费饼图展示 + - 成员支出占比统计 + - 成员数据交互和筛选 +- 趋势分析 + - 日收支趋势折线图 + - 收入支出双线对比 + - 支持深色/浅色主题 + - 图表交互和缩放 +- 数据筛选 + - 支持按日期范围筛选 + - 支持按收入/支出类型筛选 + - 支持按成员/分类筛选 + ### v1.0.0 (2024-01-05) - 基础记账功能 - 收入/支出记录 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e3d89d3..eb27d92 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -16,8 +16,8 @@ android { applicationId = "com.yovinchen.bookkeeping" minSdk = 26 targetSdk = 34 - versionCode = 1 - versionName = "1.0.0" + versionCode = 5 + versionName = "1.2.3" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { @@ -89,6 +89,7 @@ dependencies { implementation(libs.androidx.room.common) implementation(libs.androidx.navigation.common.ktx) implementation(libs.androidx.navigation.compose) + implementation(libs.vision.internal.vkp) // Room val roomVersion = "2.6.1" @@ -96,6 +97,9 @@ dependencies { implementation("androidx.room:room-ktx:$roomVersion") ksp("androidx.room:room-compiler:$roomVersion") + // 图表库 + implementation("com.github.PhilJay:MPAndroidChart:v3.1.0") + implementation("androidx.compose.material:material-icons-extended:1.4.3") testImplementation("junit:junit:4.13.2") androidTestImplementation("androidx.test.ext:junit:1.1.5") androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") diff --git a/app/src/main/java/com/yovinchen/bookkeeping/data/BookkeepingDao.kt b/app/src/main/java/com/yovinchen/bookkeeping/data/BookkeepingDao.kt index 78aea1a..aea2425 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/data/BookkeepingDao.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/data/BookkeepingDao.kt @@ -3,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.MemberStat import com.yovinchen.bookkeeping.model.TransactionType import kotlinx.coroutines.flow.Flow import java.util.Date @@ -27,6 +28,116 @@ interface BookkeepingDao { @Query("SELECT SUM(amount) FROM bookkeeping_records WHERE type = :type AND (memberId = :memberId OR memberId IS NULL)") fun getTotalAmountByType(type: TransactionType, memberId: Int? = null): Flow + @Query(""" + SELECT * FROM bookkeeping_records + WHERE category = :category + AND strftime('%Y-%m', datetime(date/1000, 'unixepoch')) = :yearMonth + ORDER BY date DESC + """) + fun getRecordsByCategoryAndMonth( + category: String, + yearMonth: String + ): Flow> + + @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> + + @Query(""" + SELECT + m.name as member, + SUM(r.amount) as amount, + COUNT(*) as count, + (SUM(r.amount) * 100.0 / (SELECT SUM(amount) FROM bookkeeping_records WHERE category = :category AND strftime('%Y-%m', datetime(date/1000, 'unixepoch')) = :yearMonth)) as percentage + FROM bookkeeping_records r + JOIN members m ON r.memberId = m.id + WHERE r.category = :category + AND strftime('%Y-%m', datetime(r.date/1000, 'unixepoch')) = :yearMonth + GROUP BY m.name + ORDER BY amount DESC + """) + fun getMemberStatsByCategory( + category: String, + yearMonth: String + ): Flow> + + @Query(""" + SELECT * FROM bookkeeping_records + WHERE category = :category + ORDER BY date DESC + """) + fun getRecordsByCategory( + category: String + ): Flow> + + @Query(""" + SELECT * FROM bookkeeping_records + WHERE category = :category + AND date BETWEEN :startDate AND :endDate + ORDER BY date DESC + """) + fun getRecordsByCategoryAndDateRange( + category: String, + startDate: Date, + endDate: Date + ): Flow> + + @Query(""" + SELECT * FROM bookkeeping_records + WHERE memberId IN (SELECT id FROM members WHERE name = :memberName) + AND date BETWEEN :startDate AND :endDate + AND (:transactionType IS NULL OR type = :transactionType) + ORDER BY date DESC + """) + fun getRecordsByMemberAndDateRange( + memberName: String, + startDate: Date, + endDate: Date, + transactionType: TransactionType? + ): Flow> + + @Query(""" + SELECT * FROM bookkeeping_records + WHERE memberId IN (SELECT id FROM members WHERE name = :memberName) + AND category = :category + AND date BETWEEN :startDate AND :endDate + AND (:transactionType IS NULL OR type = :transactionType) + ORDER BY date DESC + """) + fun getRecordsByMemberCategoryAndDateRange( + memberName: String, + category: String, + startDate: Date, + endDate: Date, + transactionType: TransactionType? + ): Flow> + + @Query(""" + SELECT + m.name as member, + SUM(r.amount) as amount, + COUNT(*) as count, + (SUM(r.amount) * 100.0 / (SELECT SUM(amount) FROM bookkeeping_records WHERE category = :category AND date BETWEEN :startDate AND :endDate)) as percentage + FROM bookkeeping_records r + JOIN members m ON r.memberId = m.id + WHERE r.category = :category + AND r.date BETWEEN :startDate AND :endDate + GROUP BY m.name + ORDER BY amount DESC + """) + fun getMemberStatsByCategoryAndDateRange( + category: String, + startDate: Date, + endDate: Date + ): Flow> + @Insert suspend fun insertRecord(record: BookkeepingRecord): Long @@ -53,4 +164,50 @@ 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 date BETWEEN :startDate AND :endDate + AND ( + :transactionType IS NULL + OR type = ( + CASE :transactionType + WHEN 'INCOME' THEN 'INCOME' + WHEN 'EXPENSE' THEN 'EXPENSE' + END + ) + ) + ORDER BY date DESC + """) + suspend fun getRecordsByMember( + memberName: String, + startDate: Date, + endDate: Date, + transactionType: TransactionType? + ): List + + @Query(""" + SELECT * FROM bookkeeping_records + WHERE memberId IN (SELECT id FROM members WHERE name = :memberName) + AND category = :category + AND date BETWEEN :startDate AND :endDate + AND ( + :transactionType IS NULL + OR type = ( + CASE :transactionType + WHEN 'INCOME' THEN 'INCOME' + WHEN 'EXPENSE' THEN 'EXPENSE' + END + ) + ) + ORDER BY date DESC + """) + suspend fun getRecordsByMemberAndCategory( + memberName: String, + category: String, + startDate: Date, + endDate: Date, + transactionType: TransactionType? + ): List } diff --git a/app/src/main/java/com/yovinchen/bookkeeping/data/BookkeepingDatabase.kt b/app/src/main/java/com/yovinchen/bookkeeping/data/BookkeepingDatabase.kt index 7d74855..8275c61 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/data/BookkeepingDatabase.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/data/BookkeepingDatabase.kt @@ -32,9 +32,9 @@ abstract class BookkeepingDatabase : RoomDatabase() { private const val TAG = "BookkeepingDatabase" private val MIGRATION_1_2 = object : Migration(1, 2) { - override fun migrate(database: SupportSQLiteDatabase) { + override fun migrate(db: SupportSQLiteDatabase) { // 创建成员表 - database.execSQL(""" + db.execSQL(""" CREATE TABLE IF NOT EXISTS members ( id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name TEXT NOT NULL, @@ -43,20 +43,20 @@ abstract class BookkeepingDatabase : RoomDatabase() { """) // 插入默认成员 - database.execSQL(""" + db.execSQL(""" INSERT INTO members (name, description) VALUES ('自己', '默认成员') """) // 修改记账记录表,添加成员ID字段 - database.execSQL(""" + db.execSQL(""" ALTER TABLE bookkeeping_records ADD COLUMN memberId INTEGER DEFAULT NULL REFERENCES members(id) ON DELETE SET NULL """) // 更新现有记录,将其关联到默认成员 - database.execSQL(""" + db.execSQL(""" UPDATE bookkeeping_records SET memberId = (SELECT id FROM members WHERE name = '我自己') """) @@ -64,9 +64,9 @@ abstract class BookkeepingDatabase : RoomDatabase() { } private val MIGRATION_2_3 = object : Migration(2, 3) { - override fun migrate(database: SupportSQLiteDatabase) { + override fun migrate(db: SupportSQLiteDatabase) { // 重新创建记账记录表 - database.execSQL(""" + db.execSQL(""" CREATE TABLE IF NOT EXISTS bookkeeping_records_new ( id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, amount REAL NOT NULL, @@ -80,19 +80,19 @@ abstract class BookkeepingDatabase : RoomDatabase() { """) // 复制数据 - database.execSQL(""" + db.execSQL(""" INSERT INTO bookkeeping_records_new (id, amount, type, category, description, date, memberId) SELECT id, amount, type, category, description, date, memberId FROM bookkeeping_records """) // 删除旧表 - database.execSQL("DROP TABLE bookkeeping_records") + db.execSQL("DROP TABLE bookkeeping_records") // 重命名新表 - database.execSQL("ALTER TABLE bookkeeping_records_new RENAME TO bookkeeping_records") + db.execSQL("ALTER TABLE bookkeeping_records_new RENAME TO bookkeeping_records") // 重新创建分类表 - database.execSQL(""" + db.execSQL(""" CREATE TABLE IF NOT EXISTS categories_new ( id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name TEXT NOT NULL, @@ -101,16 +101,16 @@ abstract class BookkeepingDatabase : RoomDatabase() { """) // 复制分类数据 - database.execSQL(""" + db.execSQL(""" INSERT INTO categories_new (id, name, type) SELECT id, name, type FROM categories """) // 删除旧表 - database.execSQL("DROP TABLE categories") + db.execSQL("DROP TABLE categories") // 重命名新表 - database.execSQL("ALTER TABLE categories_new RENAME TO categories") + db.execSQL("ALTER TABLE categories_new RENAME TO categories") } } diff --git a/app/src/main/java/com/yovinchen/bookkeeping/data/Converters.kt b/app/src/main/java/com/yovinchen/bookkeeping/data/Converters.kt index 84c5bea..b23692a 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/data/Converters.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/data/Converters.kt @@ -3,6 +3,7 @@ package com.yovinchen.bookkeeping.data import androidx.room.TypeConverter import java.time.LocalDateTime import java.time.format.DateTimeFormatter +import java.util.* class Converters { private val formatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME @@ -18,4 +19,14 @@ class Converters { fun dateToTimestamp(date: LocalDateTime?): String? { return date?.format(formatter) } + + @TypeConverter + fun fromDate(value: Date?): String? { + return value?.time?.toString() + } + + @TypeConverter + fun toDate(timestamp: String?): Date? { + return timestamp?.let { Date(it.toLong()) } + } } diff --git a/app/src/main/java/com/yovinchen/bookkeeping/data/Record.kt b/app/src/main/java/com/yovinchen/bookkeeping/data/Record.kt index d37049d..47c7366 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/data/Record.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/data/Record.kt @@ -12,5 +12,6 @@ data class Record( val category: String, val description: String, val dateTime: LocalDateTime = LocalDateTime.now(), - val isExpense: Boolean = true + val isExpense: Boolean = true, + val member: String = "Default" ) diff --git a/app/src/main/java/com/yovinchen/bookkeeping/model/AnalysisType.kt b/app/src/main/java/com/yovinchen/bookkeeping/model/AnalysisType.kt new file mode 100644 index 0000000..1220cd3 --- /dev/null +++ b/app/src/main/java/com/yovinchen/bookkeeping/model/AnalysisType.kt @@ -0,0 +1,7 @@ +package com.yovinchen.bookkeeping.model + +enum class AnalysisType { + EXPENSE, + INCOME, + TREND +} diff --git a/app/src/main/java/com/yovinchen/bookkeeping/model/BookkeepingRecord.kt b/app/src/main/java/com/yovinchen/bookkeeping/model/BookkeepingRecord.kt index 07d90f8..5ec3bd4 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/model/BookkeepingRecord.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/model/BookkeepingRecord.kt @@ -2,6 +2,7 @@ package com.yovinchen.bookkeeping.model import androidx.room.Entity import androidx.room.ForeignKey +import androidx.room.Index import androidx.room.PrimaryKey import androidx.room.TypeConverter import androidx.room.TypeConverters @@ -43,6 +44,9 @@ class Converters { childColumns = ["memberId"], onDelete = ForeignKey.SET_NULL ) + ], + indices = [ + Index(value = ["memberId"]) ] ) @TypeConverters(Converters::class) diff --git a/app/src/main/java/com/yovinchen/bookkeeping/model/CategoryStat.kt b/app/src/main/java/com/yovinchen/bookkeeping/model/CategoryStat.kt new file mode 100644 index 0000000..0411e50 --- /dev/null +++ b/app/src/main/java/com/yovinchen/bookkeeping/model/CategoryStat.kt @@ -0,0 +1,8 @@ +package com.yovinchen.bookkeeping.model + +data class CategoryStat( + val category: String, + val amount: Double, + val count: Int = 0, + val percentage: Double = 0.0 +) diff --git a/app/src/main/java/com/yovinchen/bookkeeping/model/MemberStat.kt b/app/src/main/java/com/yovinchen/bookkeeping/model/MemberStat.kt new file mode 100644 index 0000000..3c8be05 --- /dev/null +++ b/app/src/main/java/com/yovinchen/bookkeeping/model/MemberStat.kt @@ -0,0 +1,17 @@ +package com.yovinchen.bookkeeping.model + +import androidx.room.ColumnInfo + +data class MemberStat( + @ColumnInfo(name = "member") + val member: String, + + @ColumnInfo(name = "amount") + val amount: Double, + + @ColumnInfo(name = "count") + val count: Int, + + @ColumnInfo(name = "percentage") + val percentage: Double = 0.0 +) diff --git a/app/src/main/java/com/yovinchen/bookkeeping/ui/components/CategoryPieChart.kt b/app/src/main/java/com/yovinchen/bookkeeping/ui/components/CategoryPieChart.kt new file mode 100644 index 0000000..7baae93 --- /dev/null +++ b/app/src/main/java/com/yovinchen/bookkeeping/ui/components/CategoryPieChart.kt @@ -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>, + memberData: List>, + 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() + } + ) +} diff --git a/app/src/main/java/com/yovinchen/bookkeeping/ui/components/CategoryStatItem.kt b/app/src/main/java/com/yovinchen/bookkeeping/ui/components/CategoryStatItem.kt new file mode 100644 index 0000000..5ec354a --- /dev/null +++ b/app/src/main/java/com/yovinchen/bookkeeping/ui/components/CategoryStatItem.kt @@ -0,0 +1,76 @@ +package com.yovinchen.bookkeeping.ui.components + +import androidx.compose.foundation.clickable +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 com.yovinchen.bookkeeping.model.CategoryStat +import com.yovinchen.bookkeeping.model.MemberStat +import java.text.NumberFormat +import java.util.* + +@Composable +fun CategoryStatItem( + stat: Any, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val name = when (stat) { + is CategoryStat -> stat.category + is MemberStat -> stat.member + else -> return + } + + val amount = when (stat) { + is CategoryStat -> stat.amount + is MemberStat -> stat.amount + else -> return + } + + val count = when (stat) { + is CategoryStat -> stat.count + is MemberStat -> stat.count + else -> return + } + + val percentage = when (stat) { + is CategoryStat -> stat.percentage + is MemberStat -> stat.percentage + else -> return + } + + Card( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp) + .clickable(onClick = onClick), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = name, + style = MaterialTheme.typography.bodyLarge + ) + Text( + text = "${count}笔 · ${String.format("%.1f%%", percentage)}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Text( + text = NumberFormat.getCurrencyInstance(Locale.CHINA).format(amount), + style = MaterialTheme.typography.titleMedium + ) + } + } +} diff --git a/app/src/main/java/com/yovinchen/bookkeeping/ui/components/DateRangePicker.kt b/app/src/main/java/com/yovinchen/bookkeeping/ui/components/DateRangePicker.kt new file mode 100644 index 0000000..73e79df --- /dev/null +++ b/app/src/main/java/com/yovinchen/bookkeeping/ui/components/DateRangePicker.kt @@ -0,0 +1,62 @@ +package com.yovinchen.bookkeeping.ui.components + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import java.time.YearMonth +import java.time.format.DateTimeFormatter + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DateRangePicker( + startMonth: YearMonth, + endMonth: YearMonth, + onStartMonthSelected: (YearMonth) -> Unit, + onEndMonthSelected: (YearMonth) -> Unit, + modifier: Modifier = Modifier +) { + var showStartMonthPicker by remember { mutableStateOf(false) } + var showEndMonthPicker by remember { mutableStateOf(false) } + val formatter = DateTimeFormatter.ofPattern("yyyy年MM月") + + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Button(onClick = { showStartMonthPicker = true }) { + Text(startMonth.format(formatter)) + } + Text("至") + Button(onClick = { showEndMonthPicker = true }) { + Text(endMonth.format(formatter)) + } + } + + if (showStartMonthPicker) { + MonthYearPicker( + selectedMonth = startMonth, + onMonthSelected = { + onStartMonthSelected(it) + showStartMonthPicker = false + }, + onDismiss = { showStartMonthPicker = false } + ) + } + + if (showEndMonthPicker) { + MonthYearPicker( + selectedMonth = endMonth, + onMonthSelected = { + onEndMonthSelected(it) + showEndMonthPicker = false + }, + onDismiss = { showEndMonthPicker = false } + ) + } +} diff --git a/app/src/main/java/com/yovinchen/bookkeeping/ui/components/MonthYearPicker.kt b/app/src/main/java/com/yovinchen/bookkeeping/ui/components/MonthYearPicker.kt new file mode 100644 index 0000000..45da91c --- /dev/null +++ b/app/src/main/java/com/yovinchen/bookkeeping/ui/components/MonthYearPicker.kt @@ -0,0 +1,88 @@ +package com.yovinchen.bookkeeping.ui.components + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import java.time.YearMonth + +@Composable +fun MonthYearPicker( + selectedMonth: YearMonth, + onMonthSelected: (YearMonth) -> Unit, + onDismiss: () -> Unit +) { + var year by remember { mutableStateOf(selectedMonth.year) } + var month by remember { mutableStateOf(selectedMonth.monthValue) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("选择月份") }, + text = { + Column( + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // 年份选择 + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text("年份:") + OutlinedButton( + onClick = { year-- } + ) { + Text("-") + } + Text(year.toString()) + OutlinedButton( + onClick = { year++ } + ) { + Text("+") + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // 月份选择 + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text("月份:") + OutlinedButton( + onClick = { + if (month > 1) month-- + } + ) { + Text("-") + } + Text(month.toString()) + OutlinedButton( + onClick = { + if (month < 12) month++ + } + ) { + Text("+") + } + } + } + }, + confirmButton = { + TextButton( + onClick = { + onMonthSelected(YearMonth.of(year, month)) + } + ) { + Text("确定") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("取消") + } + } + ) +} diff --git a/app/src/main/java/com/yovinchen/bookkeeping/ui/components/RecordItem.kt b/app/src/main/java/com/yovinchen/bookkeeping/ui/components/RecordItem.kt index cad2220..cf40b1a 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/ui/components/RecordItem.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/ui/components/RecordItem.kt @@ -52,10 +52,8 @@ fun RecordItem( Text( text = buildString { append(timeFormat.format(record.date)) - if (member != null && member.name != "自己") { - append(" | ") - append(member.name) - } + append(" | ") + append(member?.name ?: "自己") if (record.description.isNotEmpty()) { append(" | ") append(record.description) diff --git a/app/src/main/java/com/yovinchen/bookkeeping/ui/components/TrendLineChart.kt b/app/src/main/java/com/yovinchen/bookkeeping/ui/components/TrendLineChart.kt new file mode 100644 index 0000000..aaab22f --- /dev/null +++ b/app/src/main/java/com/yovinchen/bookkeeping/ui/components/TrendLineChart.kt @@ -0,0 +1,173 @@ +package com.yovinchen.bookkeeping.ui.components + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.* +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import com.github.mikephil.charting.charts.LineChart +import com.github.mikephil.charting.components.XAxis +import com.github.mikephil.charting.data.Entry +import com.github.mikephil.charting.data.LineData +import com.github.mikephil.charting.data.LineDataSet +import com.github.mikephil.charting.formatter.ValueFormatter +import com.yovinchen.bookkeeping.model.BookkeepingRecord +import com.yovinchen.bookkeeping.model.TransactionType +import java.text.SimpleDateFormat +import java.util.* + +@Composable +fun TrendLineChart( + records: List, + modifier: Modifier = Modifier +) { + val isDarkTheme = isSystemInDarkTheme() + var textColor = if (isDarkTheme) { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.87f).toArgb() + } else { + MaterialTheme.colorScheme.onSurface.toArgb() + } + + var gridColor = if (isDarkTheme) { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f).toArgb() + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f).toArgb() + } + + val incomeColor = MaterialTheme.colorScheme.primary.toArgb() + val expenseColor = MaterialTheme.colorScheme.error.toArgb() + + AndroidView( + modifier = modifier + .fillMaxWidth() + .height(300.dp), + factory = { context -> + LineChart(context).apply { + description.isEnabled = false + + // 基本设置 + setDrawGridBackground(false) + setDrawBorders(false) + + // X轴设置 + xAxis.apply { + position = XAxis.XAxisPosition.BOTTOM + this.textColor = textColor + this.gridColor = gridColor + setDrawGridLines(true) + setDrawAxisLine(true) + labelRotationAngle = -45f + textSize = 12f + yOffset = 10f + } + + // Y轴设置 + axisLeft.apply { + this.textColor = textColor + this.gridColor = gridColor + setDrawGridLines(true) + setDrawAxisLine(true) + textSize = 12f + valueFormatter = object : ValueFormatter() { + override fun getFormattedValue(value: Float): String { + return String.format("%.0f", value) + } + } + } + axisRight.isEnabled = false + + // 图例设置 + legend.apply { + this.textColor = textColor + this.textSize = 12f + isEnabled = true + yOffset = 10f + } + + // 交互设置 + setTouchEnabled(true) + isDragEnabled = true + setScaleEnabled(true) + + // 边距设置 + setExtraOffsets(8f, 16f, 8f, 24f) + } + }, + update = { chart -> + // 按日期分组计算收入和支出 + val dailyData = records + .groupBy { record -> + SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(record.date) + } + .mapValues { (_, dayRecords) -> + val income = dayRecords + .filter { it.type == TransactionType.INCOME } + .sumOf { it.amount } + .toFloat() + val expense = dayRecords + .filter { it.type == TransactionType.EXPENSE } + .sumOf { it.amount } + .toFloat() + Pair(income, expense) + } + .toList() + .sortedBy { it.first } + + // 创建收入数据点 + val incomeEntries = dailyData.mapIndexed { index, (_, amounts) -> + Entry(index.toFloat(), amounts.first) + } + + // 创建支出数据点 + val expenseEntries = dailyData.mapIndexed { index, (_, amounts) -> + Entry(index.toFloat(), amounts.second) + } + + // 创建收入数据集 + val incomeDataSet = LineDataSet(incomeEntries, "收入").apply { + color = incomeColor + lineWidth = 2.5f + setDrawCircles(true) + circleRadius = 4f + setCircleColor(incomeColor) + valueTextColor = textColor + valueTextSize = 12f + setDrawFilled(true) + fillColor = incomeColor + fillAlpha = if (isDarkTheme) 40 else 50 + } + + // 创建支出数据集 + val expenseDataSet = LineDataSet(expenseEntries, "支出").apply { + color = expenseColor + lineWidth = 2.5f + setDrawCircles(true) + circleRadius = 4f + setCircleColor(expenseColor) + valueTextColor = textColor + valueTextSize = 12f + setDrawFilled(true) + fillColor = expenseColor + fillAlpha = if (isDarkTheme) 40 else 50 + } + + // 设置X轴标签 + chart.xAxis.valueFormatter = object : ValueFormatter() { + override fun getFormattedValue(value: Float): String { + return try { + dailyData[value.toInt()].first.substring(5) // 只显示MM-dd + } catch (e: Exception) { + "" + } + } + } + + // 更新图表数据 + chart.data = LineData(incomeDataSet, expenseDataSet) + chart.invalidate() + } + ) +} diff --git a/app/src/main/java/com/yovinchen/bookkeeping/ui/navigation/MainNavigation.kt b/app/src/main/java/com/yovinchen/bookkeeping/ui/navigation/MainNavigation.kt index a8d05d3..aa7eb0f 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/ui/navigation/MainNavigation.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/ui/navigation/MainNavigation.kt @@ -2,40 +2,70 @@ 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.AnalysisType 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}/{startMonth}/{endMonth}", + "分类详情" + ) { + fun createRoute( + category: String, + startMonth: YearMonth, + endMonth: YearMonth + ): String { + return "category_detail/$category/${startMonth.format(DateTimeFormatter.ofPattern("yyyy-MM"))}/${endMonth.format(DateTimeFormatter.ofPattern("yyyy-MM"))}" + } + } + object MemberDetail : Screen( + "member_detail/{memberName}/{category}/{startMonth}/{endMonth}?type={type}", + "成员详情" + ) { + fun createRoute( + memberName: String, + category: String, + startMonth: YearMonth, + endMonth: YearMonth, + type: AnalysisType + ): String { + return "member_detail/$memberName/$category/${startMonth.format(DateTimeFormatter.ofPattern("yyyy-MM"))}/${endMonth.format(DateTimeFormatter.ofPattern("yyyy-MM"))}?type=${type.name}" + } + } + + companion object { + fun bottomNavigationItems() = listOf(Home, Analysis, Settings) + } } @OptIn(ExperimentalMaterial3Api::class) @@ -45,22 +75,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 +95,107 @@ 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, startMonth, endMonth -> + navController.navigate(Screen.CategoryDetail.createRoute(category, startMonth, endMonth)) + }, + onNavigateToMemberDetail = { memberName, startMonth, endMonth, analysisType -> + navController.navigate(Screen.MemberDetail.createRoute(memberName, "", startMonth, endMonth, analysisType)) + } + ) } + composable(Screen.Settings.route) { SettingsScreen( currentTheme = currentTheme, onThemeChange = onThemeChange ) } + + composable( + route = Screen.CategoryDetail.route, + arguments = listOf( + navArgument("category") { type = NavType.StringType }, + navArgument("startMonth") { type = NavType.StringType }, + navArgument("endMonth") { type = NavType.StringType } + ) + ) { backStackEntry -> + val category = backStackEntry.arguments?.getString("category") ?: "" + val startMonth = YearMonth.parse( + backStackEntry.arguments?.getString("startMonth") ?: "", + DateTimeFormatter.ofPattern("yyyy-MM") + ) + val endMonth = YearMonth.parse( + backStackEntry.arguments?.getString("endMonth") ?: "", + DateTimeFormatter.ofPattern("yyyy-MM") + ) + CategoryDetailScreen( + category = category, + startMonth = startMonth, + endMonth = endMonth, + onNavigateBack = { navController.popBackStack() }, + onNavigateToMemberDetail = { memberName -> + navController.navigate( + Screen.MemberDetail.createRoute( + memberName = memberName, + category = category, + startMonth = startMonth, + endMonth = endMonth, + type = AnalysisType.EXPENSE + ) + ) + } + ) + } + composable( + route = Screen.MemberDetail.route, + arguments = listOf( + navArgument("memberName") { type = NavType.StringType }, + navArgument("category") { type = NavType.StringType }, + navArgument("startMonth") { type = NavType.StringType }, + navArgument("endMonth") { type = NavType.StringType }, + navArgument("type") { + type = NavType.StringType + defaultValue = AnalysisType.EXPENSE.name + } + ) + ) { backStackEntry -> + val memberName = backStackEntry.arguments?.getString("memberName") ?: "" + val category = backStackEntry.arguments?.getString("category") ?: "" + val startMonth = YearMonth.parse( + backStackEntry.arguments?.getString("startMonth") ?: "", + DateTimeFormatter.ofPattern("yyyy-MM") + ) + val endMonth = YearMonth.parse( + backStackEntry.arguments?.getString("endMonth") ?: "", + DateTimeFormatter.ofPattern("yyyy-MM") + ) + val type = AnalysisType.valueOf( + backStackEntry.arguments?.getString("type") ?: AnalysisType.EXPENSE.name + ) + MemberDetailScreen( + memberName = memberName, + category = category, + startMonth = startMonth, + endMonth = endMonth, + analysisType = type, + onNavigateBack = { navController.popBackStack() } + ) + } } } } diff --git a/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/AnalysisScreen.kt b/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/AnalysisScreen.kt new file mode 100644 index 0000000..1077147 --- /dev/null +++ b/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/AnalysisScreen.kt @@ -0,0 +1,203 @@ +package com.yovinchen.bookkeeping.ui.screen + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material3.Button +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.yovinchen.bookkeeping.model.AnalysisType +import com.yovinchen.bookkeeping.model.CategoryStat +import com.yovinchen.bookkeeping.model.MemberStat +import com.yovinchen.bookkeeping.ui.components.CategoryPieChart +import com.yovinchen.bookkeeping.ui.components.CategoryStatItem +import com.yovinchen.bookkeeping.ui.components.DateRangePicker +import com.yovinchen.bookkeeping.ui.components.TrendLineChart +import com.yovinchen.bookkeeping.viewmodel.AnalysisViewModel +import java.time.YearMonth + +enum class ViewMode { + CATEGORY, MEMBER +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AnalysisScreen( + onNavigateToCategoryDetail: (String, YearMonth, YearMonth) -> Unit, + onNavigateToMemberDetail: (String, YearMonth, YearMonth, AnalysisType) -> Unit, + modifier: Modifier = Modifier +) { + val viewModel: AnalysisViewModel = viewModel() + val startMonth by viewModel.startMonth.collectAsState() + val endMonth by viewModel.endMonth.collectAsState() + val selectedAnalysisType by viewModel.selectedAnalysisType.collectAsState() + val categoryStats by viewModel.categoryStats.collectAsState() + val memberStats by viewModel.memberStats.collectAsState() + val records by viewModel.records.collectAsState() + + var showViewModeMenu by remember { mutableStateOf(false) } + var currentViewMode by rememberSaveable { mutableStateOf(ViewMode.CATEGORY) } + + Scaffold( + modifier = modifier.fillMaxSize() + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + // 时间区间选择 + DateRangePicker( + startMonth = startMonth, + endMonth = endMonth, + onStartMonthSelected = viewModel::setStartMonth, + onEndMonthSelected = viewModel::setEndMonth + ) + + // 分析类型和视图模式选择行 + 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.entries.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) + ) { + when (selectedAnalysisType) { + AnalysisType.TREND -> { + // 趋势视图 + item { + if (records.isNotEmpty()) { + TrendLineChart( + records = records, + modifier = Modifier + .fillMaxWidth() + .height(300.dp) + .padding(vertical = 16.dp) + ) + } + } + } + else -> { + // 饼图视图 + item { + CategoryPieChart( + categoryData = categoryStats.map { Pair(it.category, it.percentage.toFloat()) }, + memberData = memberStats.map { Pair(it.member, it.percentage.toFloat()) }, + currentViewMode = currentViewMode == ViewMode.MEMBER, + modifier = Modifier + .fillMaxWidth() + .height(200.dp) + .padding(bottom = 16.dp), + onCategoryClick = { category -> + if (currentViewMode == ViewMode.CATEGORY) { + onNavigateToCategoryDetail(category, startMonth, endMonth) + } else { + onNavigateToMemberDetail(category, startMonth, endMonth, selectedAnalysisType) + } + } + ) + } + + // 统计列表 + items(if (currentViewMode == ViewMode.CATEGORY) categoryStats else memberStats) { stat -> + val category = if (stat is CategoryStat) stat.category else null + val member = if (stat is MemberStat) stat.member else null + + CategoryStatItem( + stat = stat, + onClick = { + if (currentViewMode == ViewMode.CATEGORY && category != null) { + onNavigateToCategoryDetail(category, startMonth, endMonth) + } else if (currentViewMode == ViewMode.MEMBER && member != null) { + onNavigateToMemberDetail(member, startMonth, endMonth, selectedAnalysisType) + } + } + ) + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/CategoryDetailScreen.kt b/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/CategoryDetailScreen.kt new file mode 100644 index 0000000..24ea722 --- /dev/null +++ b/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/CategoryDetailScreen.kt @@ -0,0 +1,291 @@ +package com.yovinchen.bookkeeping.ui.screen + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.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.model.MemberStat +import com.yovinchen.bookkeeping.model.TransactionType +import com.yovinchen.bookkeeping.ui.components.CategoryPieChart +import com.yovinchen.bookkeeping.ui.components.RecordItem +import com.yovinchen.bookkeeping.viewmodel.CategoryDetailViewModel +import com.yovinchen.bookkeeping.viewmodel.CategoryDetailViewModelFactory +import java.text.NumberFormat +import java.text.SimpleDateFormat +import java.time.YearMonth +import java.util.Locale + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CategoryDetailScreen( + category: String, + startMonth: YearMonth, + endMonth: YearMonth, + onNavigateBack: () -> Unit, + onNavigateToMemberDetail: (String) -> Unit, + viewModel: CategoryDetailViewModel = viewModel( + factory = CategoryDetailViewModelFactory( + database = BookkeepingDatabase.getDatabase(LocalContext.current), + category = category, + startMonth = startMonth, + endMonth = endMonth + ) + ), + modifier: Modifier = Modifier +) { + val 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 { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) + ) { + Column( + modifier = Modifier + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = if (records.isNotEmpty() && records.first().type == TransactionType.INCOME) "总收入" else "总支出", + style = MaterialTheme.typography.titleMedium + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = NumberFormat.getCurrencyInstance(Locale.CHINA).format(total), + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold + ) + } + } + } + + // 第二部分:成员统计 + item { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "成员分布", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(bottom = 16.dp) + ) + + // 饼状图 + CategoryPieChart( + categoryData = emptyList(), + memberData = memberStats.map { Pair(it.member, it.percentage.toFloat()) }, + currentViewMode = true, + modifier = Modifier + .fillMaxWidth() + .height(200.dp), + onCategoryClick = { memberName -> + onNavigateToMemberDetail(memberName) + } + ) + + Spacer(modifier = Modifier.height(16.dp)) + HorizontalDivider() + Spacer(modifier = Modifier.height(16.dp)) + + // 成员列表 + Column { + memberStats.forEach { stat -> + MemberStatItem( + stat = stat, + onClick = { onNavigateToMemberDetail(stat.member) } + ) + } + } + } + } + } + + // 第三部分:详细信息 + item { + Text( + text = "详细记录", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(16.dp) + ) + } + + // 按日期分组的记录列表 + 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 MemberStatItem( + stat: MemberStat, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + ListItem( + headlineContent = { Text(stat.member) }, + supportingContent = { + Text( + buildString { + append("金额: ¥%.2f".format(stat.amount)) + append(" | ") + append("次数: ${stat.count}") + append(" | ") + append("占比: %.1f%%".format(stat.percentage)) + } + ) + }, + modifier = modifier.clickable(onClick = onClick) + ) +} + +@Composable +private fun RecordItem( + record: BookkeepingRecord, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = record.memberId.toString(), // 暂时显示 memberId,后续可以通过 MemberDao 获取成员名称 + style = MaterialTheme.typography.titleMedium + ) + if (record.description.isNotBlank()) { + Text( + text = record.description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Text( + text = SimpleDateFormat("HH:mm", Locale.getDefault()).format(record.date), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Text( + text = NumberFormat.getCurrencyInstance(Locale.CHINA).format(record.amount), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.error + ) + } +} diff --git a/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/MemberDetailScreen.kt b/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/MemberDetailScreen.kt new file mode 100644 index 0000000..9b055bd --- /dev/null +++ b/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/MemberDetailScreen.kt @@ -0,0 +1,226 @@ +package com.yovinchen.bookkeeping.ui.screen + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.yovinchen.bookkeeping.data.Record +import com.yovinchen.bookkeeping.model.AnalysisType +import com.yovinchen.bookkeeping.model.TransactionType +import com.yovinchen.bookkeeping.ui.components.CategoryPieChart +import com.yovinchen.bookkeeping.ui.components.RecordItem +import com.yovinchen.bookkeeping.viewmodel.MemberDetailViewModel +import java.text.NumberFormat +import java.text.SimpleDateFormat +import java.time.YearMonth +import java.util.Locale + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MemberDetailScreen( + memberName: String, + startMonth: YearMonth, + endMonth: YearMonth, + category: String = "", + analysisType: AnalysisType = AnalysisType.EXPENSE, + onNavigateBack: () -> Unit, + viewModel: MemberDetailViewModel = viewModel() +) { + val records by viewModel.memberRecords.collectAsState(initial = emptyList()) + val totalAmount by viewModel.totalAmount.collectAsState(initial = 0.0) + val categoryData by viewModel.categoryData.collectAsState(initial = emptyList()) + + LaunchedEffect(memberName, category, startMonth, endMonth, analysisType) { + viewModel.loadMemberRecords( + memberName = memberName, + category = category, + startMonth = startMonth, + endMonth = endMonth, + analysisType = analysisType + ) + } + + val groupedRecords = remember(records) { + records.groupBy { record -> + SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(record.date) + }.toSortedMap(reverseOrder()) + } + + Scaffold( + topBar = { + TopAppBar( + title = { + Text(memberName) + }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.AutoMirrored.Filled.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 + .fillMaxWidth() + .padding(16.dp) + ) { + Text( + text = "总金额", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = NumberFormat.getCurrencyInstance(Locale.CHINA) + .format(totalAmount), + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.primary + ) + } + } + } + + // 当从成员视图进入时显示饼图 + if (category.isEmpty()) { + item { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Text( + text = "分类统计", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(16.dp)) + CategoryPieChart( + categoryData = categoryData, + memberData = emptyList(), + currentViewMode = false, + onCategoryClick = { selectedCategory -> + // 暂时不处理点击事件 + } + ) + } + } + } + } + + // 第二层:按日期分组的记录列表 + groupedRecords.forEach { (date, dayRecords) -> + item { + 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 + ) + } +} diff --git a/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/AnalysisViewModel.kt b/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/AnalysisViewModel.kt new file mode 100644 index 0000000..eaba8e9 --- /dev/null +++ b/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/AnalysisViewModel.kt @@ -0,0 +1,139 @@ +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.BookkeepingRecord +import com.yovinchen.bookkeeping.model.CategoryStat +import com.yovinchen.bookkeeping.model.MemberStat +import com.yovinchen.bookkeeping.model.TransactionType +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import java.time.LocalDateTime +import java.time.YearMonth +import java.time.ZoneId +import java.util.* + +class AnalysisViewModel(application: Application) : AndroidViewModel(application) { + private val recordDao = BookkeepingDatabase.getDatabase(application).bookkeepingDao() + private val memberDao = BookkeepingDatabase.getDatabase(application).memberDao() + + private val _startMonth = MutableStateFlow(YearMonth.now()) + val startMonth: StateFlow = _startMonth.asStateFlow() + + private val _endMonth = MutableStateFlow(YearMonth.now()) + val endMonth: StateFlow = _endMonth.asStateFlow() + + private val _selectedAnalysisType = MutableStateFlow(AnalysisType.EXPENSE) + val selectedAnalysisType: StateFlow = _selectedAnalysisType.asStateFlow() + + private val _categoryStats = MutableStateFlow>(emptyList()) + val categoryStats: StateFlow> = _categoryStats.asStateFlow() + + private val _memberStats = MutableStateFlow>(emptyList()) + val memberStats: StateFlow> = _memberStats.asStateFlow() + + private val _records = MutableStateFlow>(emptyList()) + val records: StateFlow> = _records.asStateFlow() + + init { + viewModelScope.launch { + combine(startMonth, endMonth, selectedAnalysisType) { start, end, type -> + Triple(start, end, type) + }.collect { (start, end, type) -> + updateStats(start, end, type) + } + } + } + + fun setStartMonth(month: YearMonth) { + _startMonth.value = month + } + + fun setEndMonth(month: YearMonth) { + _endMonth.value = month + } + + fun setAnalysisType(type: AnalysisType) { + _selectedAnalysisType.value = type + } + + private suspend fun updateStats(startMonth: YearMonth, endMonth: YearMonth, type: AnalysisType) { + val records = recordDao.getAllRecords().first() + + // 过滤日期范围内的记录 + val monthRecords = records.filter { + val recordDate = Date(it.date.time) + val localDateTime = LocalDateTime.ofInstant(recordDate.toInstant(), ZoneId.systemDefault()) + val yearMonth = YearMonth.from(localDateTime) + yearMonth.isAfter(startMonth.minusMonths(1)) && + yearMonth.isBefore(endMonth.plusMonths(1)) + } + + // 更新记录数据 + _records.value = monthRecords + + // 根据分析类型过滤记录 + val filteredRecords = if (type == AnalysisType.TREND) { + monthRecords + } else { + monthRecords.filter { + it.type == when(type) { + AnalysisType.EXPENSE -> TransactionType.EXPENSE + AnalysisType.INCOME -> TransactionType.INCOME + else -> return@filter true + } + } + } + + // 更新统计数据 + updateCategoryStats(filteredRecords) + updateMemberStats(filteredRecords) + } + + private suspend fun updateCategoryStats(records: List) { + // 按分类统计 + val categoryMap = records.groupBy { it.category } + val categoryStats = categoryMap.map { (category, records) -> + CategoryStat( + category = category, + amount = records.sumOf { it.amount }, + count = records.size + ) + }.sortedByDescending { it.amount } + + // 计算分类总额和百分比 + val categoryTotal = categoryStats.sumOf { it.amount } + val categoryStatsWithPercentage = categoryStats.map { + it.copy(percentage = if (categoryTotal > 0) it.amount / categoryTotal * 100 else 0.0) + } + + _categoryStats.value = categoryStatsWithPercentage + } + + private suspend fun updateMemberStats(records: List) { + // 按成员统计 + val members = memberDao.getAllMembers().first() + val memberMap = records.groupBy { record -> + members.find { it.id == record.memberId }?.name ?: "未分配" + } + + val memberStats = memberMap.map { (memberName, records) -> + MemberStat( + member = memberName, + amount = records.sumOf { it.amount }, + count = records.size + ) + }.sortedByDescending { it.amount } + + // 计算成员总额和百分比 + val memberTotal = memberStats.sumOf { it.amount } + val memberStatsWithPercentage = memberStats.map { + it.copy(percentage = if (memberTotal > 0) it.amount / memberTotal * 100 else 0.0) + } + + _memberStats.value = memberStatsWithPercentage + } +} diff --git a/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/CategoryDetailViewModel.kt b/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/CategoryDetailViewModel.kt new file mode 100644 index 0000000..3ddd877 --- /dev/null +++ b/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/CategoryDetailViewModel.kt @@ -0,0 +1,69 @@ +package com.yovinchen.bookkeeping.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.yovinchen.bookkeeping.data.BookkeepingDatabase +import com.yovinchen.bookkeeping.model.BookkeepingRecord +import com.yovinchen.bookkeeping.model.MemberStat +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn +import java.time.YearMonth +import java.time.ZoneId +import java.util.Date + +class CategoryDetailViewModel( + database: BookkeepingDatabase, + category: String, + startMonth: YearMonth, + endMonth: YearMonth +) : ViewModel() { + private val recordDao = database.bookkeepingDao() + + private val _records = MutableStateFlow>(emptyList()) + val records: StateFlow> = _records.asStateFlow() + + private val _memberStats = MutableStateFlow>(emptyList()) + val memberStats: StateFlow> = _memberStats.asStateFlow() + + val total: StateFlow = records + .map { records -> records.sumOf { it.amount } } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = 0.0 + ) + + init { + val startDate = startMonth.atDay(1).atStartOfDay() + .atZone(ZoneId.systemDefault()) + .toInstant() + .let { Date.from(it) } + + val endDate = endMonth.atEndOfMonth().atTime(23, 59, 59) + .atZone(ZoneId.systemDefault()) + .toInstant() + .let { Date.from(it) } + + recordDao.getRecordsByCategoryAndDateRange( + category = category, + startDate = startDate, + endDate = endDate + ) + .onEach { records -> _records.value = records } + .launchIn(viewModelScope) + + recordDao.getMemberStatsByCategoryAndDateRange( + category = category, + startDate = startDate, + endDate = endDate + ) + .onEach { stats -> _memberStats.value = stats } + .launchIn(viewModelScope) + } +} diff --git a/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/CategoryDetailViewModelFactory.kt b/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/CategoryDetailViewModelFactory.kt new file mode 100644 index 0000000..61297a8 --- /dev/null +++ b/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/CategoryDetailViewModelFactory.kt @@ -0,0 +1,21 @@ +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 startMonth: YearMonth, + private val endMonth: YearMonth +) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + if (modelClass.isAssignableFrom(CategoryDetailViewModel::class.java)) { + @Suppress("UNCHECKED_CAST") + return CategoryDetailViewModel(database, category, startMonth, endMonth) as T + } + throw IllegalArgumentException("Unknown ViewModel class") + } +} diff --git a/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/MemberDetailViewModel.kt b/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/MemberDetailViewModel.kt new file mode 100644 index 0000000..724cfc7 --- /dev/null +++ b/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/MemberDetailViewModel.kt @@ -0,0 +1,83 @@ +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 com.yovinchen.bookkeeping.model.AnalysisType +import com.yovinchen.bookkeeping.model.TransactionType +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import java.time.YearMonth +import java.time.ZoneId +import java.util.Date + +class MemberDetailViewModel(application: Application) : AndroidViewModel(application) { + private val database = BookkeepingDatabase.getDatabase(application) + private val recordDao = database.bookkeepingDao() + + private val _memberRecords = MutableStateFlow>(emptyList()) + val memberRecords: StateFlow> = _memberRecords.asStateFlow() + + private val _totalAmount = MutableStateFlow(0.0) + val totalAmount: StateFlow = _totalAmount.asStateFlow() + + private val _categoryData = MutableStateFlow>>(emptyList()) + val categoryData: StateFlow>> = _categoryData.asStateFlow() + + fun loadMemberRecords( + memberName: String, + category: String, + startMonth: YearMonth, + endMonth: YearMonth, + analysisType: AnalysisType + ) { + val startDate = startMonth.atDay(1).atStartOfDay() + .atZone(ZoneId.systemDefault()) + .toInstant() + .let { Date.from(it) } + + val endDate = endMonth.atEndOfMonth().atTime(23, 59, 59) + .atZone(ZoneId.systemDefault()) + .toInstant() + .let { Date.from(it) } + + val transactionType = when (analysisType) { + AnalysisType.INCOME -> TransactionType.INCOME + AnalysisType.EXPENSE -> TransactionType.EXPENSE + else -> null + } + + val recordsFlow = if (category.isEmpty()) { + recordDao.getRecordsByMemberAndDateRange( + memberName = memberName, + startDate = startDate, + endDate = endDate, + transactionType = transactionType + ) + } else { + recordDao.getRecordsByMemberCategoryAndDateRange( + memberName = memberName, + category = category, + startDate = startDate, + endDate = endDate, + transactionType = transactionType + ) + } + + viewModelScope.launch { + recordsFlow.collect { records -> + _memberRecords.value = records + _totalAmount.value = records.sumOf { it.amount } + + // 计算分类数据 + val categoryAmounts = records.groupBy { it.category } + .mapValues { (_, records) -> records.sumOf { it.amount }.toFloat() } + .toList() + .sortedByDescending { it.second } + _categoryData.value = categoryAmounts + } + } + } +} diff --git a/app/src/main/res/drawable/category/ic_category_baby_24dp.xml b/app/src/main/res/drawable/category/ic_category_baby_24dp.xml new file mode 100644 index 0000000..68e7d09 --- /dev/null +++ b/app/src/main/res/drawable/category/ic_category_baby_24dp.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/main/res/drawable/category/ic_category_bar_24dp.xml b/app/src/main/res/drawable/category/ic_category_bar_24dp.xml new file mode 100644 index 0000000..d4bffcb --- /dev/null +++ b/app/src/main/res/drawable/category/ic_category_bar_24dp.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/main/res/drawable/category/ic_category_clothes_24dp.xml b/app/src/main/res/drawable/category/ic_category_clothes_24dp.xml new file mode 100644 index 0000000..e72a9fe --- /dev/null +++ b/app/src/main/res/drawable/category/ic_category_clothes_24dp.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/main/res/drawable/category/ic_category_convenience_24dp.xml b/app/src/main/res/drawable/category/ic_category_convenience_24dp.xml new file mode 100644 index 0000000..6fb05ce --- /dev/null +++ b/app/src/main/res/drawable/category/ic_category_convenience_24dp.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/main/res/drawable/category/ic_category_cosmetics_24dp.xml b/app/src/main/res/drawable/category/ic_category_cosmetics_24dp.xml new file mode 100644 index 0000000..ae14563 --- /dev/null +++ b/app/src/main/res/drawable/category/ic_category_cosmetics_24dp.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/main/res/drawable/category/ic_category_delivery_24dp.xml b/app/src/main/res/drawable/category/ic_category_delivery_24dp.xml new file mode 100644 index 0000000..3921e77 --- /dev/null +++ b/app/src/main/res/drawable/category/ic_category_delivery_24dp.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/main/res/drawable/category/ic_category_digital_24dp.xml b/app/src/main/res/drawable/category/ic_category_digital_24dp.xml new file mode 100644 index 0000000..ab76ef0 --- /dev/null +++ b/app/src/main/res/drawable/category/ic_category_digital_24dp.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/main/res/drawable/category/ic_category_drink_24dp.xml b/app/src/main/res/drawable/category/ic_category_drink_24dp.xml new file mode 100644 index 0000000..14e09ee --- /dev/null +++ b/app/src/main/res/drawable/category/ic_category_drink_24dp.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/main/res/drawable/category/ic_category_flower_24dp.xml b/app/src/main/res/drawable/category/ic_category_flower_24dp.xml new file mode 100644 index 0000000..ba15791 --- /dev/null +++ b/app/src/main/res/drawable/category/ic_category_flower_24dp.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/main/res/drawable/category/ic_category_food_24dp.xml b/app/src/main/res/drawable/category/ic_category_food_24dp.xml new file mode 100644 index 0000000..9f43cf9 --- /dev/null +++ b/app/src/main/res/drawable/category/ic_category_food_24dp.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/main/res/drawable/category/ic_category_fruit_24dp.xml b/app/src/main/res/drawable/category/ic_category_fruit_24dp.xml new file mode 100644 index 0000000..266be13 --- /dev/null +++ b/app/src/main/res/drawable/category/ic_category_fruit_24dp.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/main/res/drawable/category/ic_category_gift_24dp.xml b/app/src/main/res/drawable/category/ic_category_gift_24dp.xml new file mode 100644 index 0000000..a5b3227 --- /dev/null +++ b/app/src/main/res/drawable/category/ic_category_gift_24dp.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/main/res/drawable/category/ic_category_hotel_24dp.xml b/app/src/main/res/drawable/category/ic_category_hotel_24dp.xml new file mode 100644 index 0000000..05db34d --- /dev/null +++ b/app/src/main/res/drawable/category/ic_category_hotel_24dp.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/main/res/drawable/category/ic_category_medicine_24dp.xml b/app/src/main/res/drawable/category/ic_category_medicine_24dp.xml new file mode 100644 index 0000000..b72e248 --- /dev/null +++ b/app/src/main/res/drawable/category/ic_category_medicine_24dp.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/main/res/drawable/category/ic_category_membership_24dp.xml b/app/src/main/res/drawable/category/ic_category_membership_24dp.xml new file mode 100644 index 0000000..56fcac2 --- /dev/null +++ b/app/src/main/res/drawable/category/ic_category_membership_24dp.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/main/res/drawable/category/ic_category_more_24dp.xml b/app/src/main/res/drawable/category/ic_category_more_24dp.xml new file mode 100644 index 0000000..cebd965 --- /dev/null +++ b/app/src/main/res/drawable/category/ic_category_more_24dp.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/main/res/drawable/category/ic_category_pet_24dp.xml b/app/src/main/res/drawable/category/ic_category_pet_24dp.xml new file mode 100644 index 0000000..d26e793 --- /dev/null +++ b/app/src/main/res/drawable/category/ic_category_pet_24dp.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/main/res/drawable/category/ic_category_scenic_24dp.xml b/app/src/main/res/drawable/category/ic_category_scenic_24dp.xml new file mode 100644 index 0000000..9754aa1 --- /dev/null +++ b/app/src/main/res/drawable/category/ic_category_scenic_24dp.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/main/res/drawable/category/ic_category_snack_24dp.xml b/app/src/main/res/drawable/category/ic_category_snack_24dp.xml new file mode 100644 index 0000000..cf1b226 --- /dev/null +++ b/app/src/main/res/drawable/category/ic_category_snack_24dp.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/main/res/drawable/category/ic_category_supermarket_24dp.xml b/app/src/main/res/drawable/category/ic_category_supermarket_24dp.xml new file mode 100644 index 0000000..cf281dc --- /dev/null +++ b/app/src/main/res/drawable/category/ic_category_supermarket_24dp.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/main/res/drawable/category/ic_category_taxi_24dp.xml b/app/src/main/res/drawable/category/ic_category_taxi_24dp.xml new file mode 100644 index 0000000..fa45d23 --- /dev/null +++ b/app/src/main/res/drawable/category/ic_category_taxi_24dp.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/main/res/drawable/category/ic_category_training_24dp.xml b/app/src/main/res/drawable/category/ic_category_training_24dp.xml new file mode 100644 index 0000000..4db98ca --- /dev/null +++ b/app/src/main/res/drawable/category/ic_category_training_24dp.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/main/res/drawable/category/ic_category_travel_24dp.xml b/app/src/main/res/drawable/category/ic_category_travel_24dp.xml new file mode 100644 index 0000000..0fdee3e --- /dev/null +++ b/app/src/main/res/drawable/category/ic_category_travel_24dp.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/main/res/drawable/category/ic_category_vegetable_24dp.xml b/app/src/main/res/drawable/category/ic_category_vegetable_24dp.xml new file mode 100644 index 0000000..a0a21c5 --- /dev/null +++ b/app/src/main/res/drawable/category/ic_category_vegetable_24dp.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/main/res/drawable/member/ic_member_baby_boy_24dp.xml b/app/src/main/res/drawable/member/ic_member_baby_boy_24dp.xml new file mode 100644 index 0000000..2b24182 --- /dev/null +++ b/app/src/main/res/drawable/member/ic_member_baby_boy_24dp.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/main/res/drawable/member/ic_member_baby_girl_24dp.xml b/app/src/main/res/drawable/member/ic_member_baby_girl_24dp.xml new file mode 100644 index 0000000..5089a2e --- /dev/null +++ b/app/src/main/res/drawable/member/ic_member_baby_girl_24dp.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/main/res/drawable/member/ic_member_boy_24dp.xml b/app/src/main/res/drawable/member/ic_member_boy_24dp.xml new file mode 100644 index 0000000..2a20f4f --- /dev/null +++ b/app/src/main/res/drawable/member/ic_member_boy_24dp.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/main/res/drawable/member/ic_member_bride_24dp.xml b/app/src/main/res/drawable/member/ic_member_bride_24dp.xml new file mode 100644 index 0000000..c1e6f72 --- /dev/null +++ b/app/src/main/res/drawable/member/ic_member_bride_24dp.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/main/res/drawable/member/ic_member_family_24dp.xml b/app/src/main/res/drawable/member/ic_member_family_24dp.xml new file mode 100644 index 0000000..a309d98 --- /dev/null +++ b/app/src/main/res/drawable/member/ic_member_family_24dp.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/main/res/drawable/member/ic_member_father_24dp.xml b/app/src/main/res/drawable/member/ic_member_father_24dp.xml new file mode 100644 index 0000000..6c68ed8 --- /dev/null +++ b/app/src/main/res/drawable/member/ic_member_father_24dp.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/main/res/drawable/member/ic_member_girl_24dp.xml b/app/src/main/res/drawable/member/ic_member_girl_24dp.xml new file mode 100644 index 0000000..0d38006 --- /dev/null +++ b/app/src/main/res/drawable/member/ic_member_girl_24dp.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/main/res/drawable/member/ic_member_grandfather_24dp.xml b/app/src/main/res/drawable/member/ic_member_grandfather_24dp.xml new file mode 100644 index 0000000..d439929 --- /dev/null +++ b/app/src/main/res/drawable/member/ic_member_grandfather_24dp.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/main/res/drawable/member/ic_member_grandmother_24dp.xml b/app/src/main/res/drawable/member/ic_member_grandmother_24dp.xml new file mode 100644 index 0000000..b5d52ba --- /dev/null +++ b/app/src/main/res/drawable/member/ic_member_grandmother_24dp.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/main/res/drawable/member/ic_member_groom_24dp.xml b/app/src/main/res/drawable/member/ic_member_groom_24dp.xml new file mode 100644 index 0000000..e15c714 --- /dev/null +++ b/app/src/main/res/drawable/member/ic_member_groom_24dp.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/main/res/drawable/member/ic_member_mother_24dp.xml b/app/src/main/res/drawable/member/ic_member_mother_24dp.xml new file mode 100644 index 0000000..6ad2a73 --- /dev/null +++ b/app/src/main/res/drawable/member/ic_member_mother_24dp.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml new file mode 100644 index 0000000..1816f34 --- /dev/null +++ b/app/src/main/res/values-night/themes.xml @@ -0,0 +1,6 @@ + + + + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index b3a2f57..fd07bfd 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,5 +1,6 @@ - - \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 20e2a01..1ea80a2 100644 --- a/gradle.properties +++ b/gradle.properties @@ -20,4 +20,6 @@ kotlin.code.style=official # Enables namespacing of each library's R class so that its R class includes only the # resources declared in the library itself and none from the library's dependencies, # thereby reducing the size of the R class for that library -android.nonTransitiveRClass=true \ No newline at end of file +android.nonTransitiveRClass=true +# Kotlin +org.gradle.java.home=/Library/Java/JavaVirtualMachines/jdk-17.jdk/Contents/Home \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3efafc9..3ec9b45 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,6 +11,7 @@ composeBom = "2024.04.01" roomCommon = "2.6.1" navigationCommonKtx = "2.8.4" navigationCompose = "2.8.4" +visionInternalVkp = "18.2.3" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -30,6 +31,7 @@ androidx-material3 = { group = "androidx.compose.material3", name = "material3" androidx-room-common = { group = "androidx.room", name = "room-common", version.ref = "roomCommon" } androidx-navigation-common-ktx = { group = "androidx.navigation", name = "navigation-common-ktx", version.ref = "navigationCommonKtx" } androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } +vision-internal-vkp = { group = "com.google.mlkit", name = "vision-internal-vkp", version.ref = "visionInternalVkp" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } diff --git a/settings.gradle.kts b/settings.gradle.kts index c595d41..711d76a 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -9,6 +9,7 @@ pluginManagement { } mavenCentral() gradlePluginPortal() + maven { url = uri("https://jitpack.io") } } } dependencyResolutionManagement { @@ -16,6 +17,7 @@ dependencyResolutionManagement { repositories { google() mavenCentral() + maven { url = uri("https://jitpack.io") } } }