Compare commits
18 Commits
v1.1.0
...
f134304646
Author | SHA1 | Date | |
---|---|---|---|
f134304646 | |||
8339d3d5da | |||
c3f108ab57 | |||
9772fd6e59 | |||
0a738fc7e1 | |||
6c3b366d45 | |||
3c080fbc05 | |||
71deaaa288 | |||
47e202fa61 | |||
af880c23eb | |||
773c155d0c | |||
30e9345d81 | |||
c75439d15a | |||
95b3233d5e | |||
df80dadfea | |||
e03149377c | |||
49e83cea90 | |||
6d9c5a27f7 |
147
README.md
147
README.md
@@ -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)
|
||||
|
||||
## 🙏 致谢
|
||||
|
||||
感谢所有为这个项目做出贡献的开发者!
|
||||
|
@@ -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")
|
||||
|
@@ -27,6 +27,17 @@ 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>>
|
||||
|
||||
@Insert
|
||||
suspend fun insertRecord(record: BookkeepingRecord): Long
|
||||
|
||||
|
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -0,0 +1,7 @@
|
||||
package com.yovinchen.bookkeeping.model
|
||||
|
||||
enum class AnalysisType {
|
||||
EXPENSE,
|
||||
INCOME,
|
||||
TREND
|
||||
}
|
@@ -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)
|
||||
|
@@ -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
|
||||
)
|
@@ -0,0 +1,92 @@
|
||||
package com.yovinchen.bookkeeping.ui.components
|
||||
|
||||
import android.graphics.Color as AndroidColor
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import com.github.mikephil.charting.charts.PieChart
|
||||
import com.github.mikephil.charting.components.Legend
|
||||
import com.github.mikephil.charting.data.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>>,
|
||||
modifier: Modifier = Modifier,
|
||||
onCategoryClick: (String) -> Unit = {}
|
||||
) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val textColor = MaterialTheme.colorScheme.onSurface.toArgb()
|
||||
|
||||
AndroidView(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(300.dp),
|
||||
factory = { context ->
|
||||
PieChart(context).apply {
|
||||
description.isEnabled = false
|
||||
setUsePercentValues(true)
|
||||
setDrawEntryLabels(true)
|
||||
|
||||
// 禁用图例显示
|
||||
legend.isEnabled = false
|
||||
|
||||
isDrawHoleEnabled = true
|
||||
holeRadius = 40f
|
||||
setHoleColor(AndroidColor.TRANSPARENT)
|
||||
setTransparentCircleRadius(45f)
|
||||
|
||||
// 设置标签文字颜色
|
||||
setEntryLabelColor(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 = categoryData.map { (category, amount) ->
|
||||
PieEntry(amount, category)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
)
|
||||
}
|
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@@ -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("取消")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
@@ -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)
|
||||
|
@@ -3,39 +3,50 @@ 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.filled.List
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.outlined.Analytics
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.NavigationBarItemDefaults
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.navigation.NavDestination.Companion.hierarchy
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.navigation.NavGraph.Companion.findStartDestination
|
||||
import androidx.navigation.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.AnalysisScreen
|
||||
import com.yovinchen.bookkeeping.ui.screen.CategoryDetailScreen
|
||||
import com.yovinchen.bookkeeping.ui.screen.HomeScreen
|
||||
import com.yovinchen.bookkeeping.ui.screen.SettingsScreen
|
||||
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 icon: ImageVector,
|
||||
val label: String
|
||||
) {
|
||||
data object Home : Screen("home", Icons.Default.Home, "主页")
|
||||
data object Analysis : Screen("analysis", Icons.Outlined.Analytics, "分析")
|
||||
data object Settings : Screen("settings", Icons.Default.Settings, "设置")
|
||||
data object CategoryDetail : Screen(
|
||||
"category_detail/{category}/{yearMonth}",
|
||||
Icons.Default.List,
|
||||
"分类详情"
|
||||
) {
|
||||
fun createRoute(category: String, yearMonth: String) =
|
||||
"category_detail/$category/$yearMonth"
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@@ -45,22 +56,22 @@ fun MainNavigation(
|
||||
onThemeChange: (ThemeMode) -> Unit
|
||||
) {
|
||||
val navController = rememberNavController()
|
||||
val items = listOf(Screen.Home, Screen.Settings)
|
||||
|
||||
Scaffold(
|
||||
bottomBar = {
|
||||
NavigationBar(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
) {
|
||||
NavigationBar {
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentDestination = navBackStackEntry?.destination
|
||||
val currentRoute = navBackStackEntry?.destination?.route
|
||||
|
||||
items.forEach { screen ->
|
||||
listOf(
|
||||
Screen.Home,
|
||||
Screen.Analysis,
|
||||
Screen.Settings
|
||||
).forEach { screen ->
|
||||
NavigationBarItem(
|
||||
icon = screen.icon,
|
||||
icon = { Icon(screen.icon, contentDescription = screen.label) },
|
||||
label = { Text(screen.label) },
|
||||
selected = currentDestination?.hierarchy?.any { it.route == screen.route } == true,
|
||||
selected = currentRoute == screen.route,
|
||||
onClick = {
|
||||
navController.navigate(screen.route) {
|
||||
popUpTo(navController.graph.findStartDestination().id) {
|
||||
@@ -69,26 +80,25 @@ 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, month ->
|
||||
val monthStr = month.format(DateTimeFormatter.ofPattern("yyyy-MM"))
|
||||
navController.navigate(Screen.CategoryDetail.createRoute(category, monthStr))
|
||||
}
|
||||
)
|
||||
}
|
||||
composable(Screen.Settings.route) {
|
||||
SettingsScreen(
|
||||
@@ -96,6 +106,24 @@ fun MainNavigation(
|
||||
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 yearMonth = YearMonth.parse(
|
||||
backStackEntry.arguments?.getString("yearMonth") ?: return@composable
|
||||
)
|
||||
CategoryDetailScreen(
|
||||
category = category,
|
||||
month = yearMonth,
|
||||
onBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -0,0 +1,116 @@
|
||||
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.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.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
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AnalysisScreen(
|
||||
onNavigateToCategoryDetail: (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()
|
||||
|
||||
var showMonthPicker by remember { mutableStateOf(false) }
|
||||
|
||||
Scaffold { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
) {
|
||||
// 月份选择器和类型切换
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// 月份选择按钮
|
||||
Button(onClick = { showMonthPicker = true }) {
|
||||
Text(selectedMonth.format(DateTimeFormatter.ofPattern("yyyy年MM月")))
|
||||
}
|
||||
|
||||
// 类型切换
|
||||
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()) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(200.dp)
|
||||
.padding(bottom = 16.dp),
|
||||
onCategoryClick = { category ->
|
||||
onNavigateToCategoryDetail(category, selectedMonth)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 添加分类统计列表项目
|
||||
items(categoryStats) { stat ->
|
||||
CategoryStatItem(
|
||||
stat = stat,
|
||||
onClick = { onNavigateToCategoryDetail(stat.category, selectedMonth) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 月份选择器对话框
|
||||
if (showMonthPicker) {
|
||||
MonthYearPicker(
|
||||
selectedMonth = selectedMonth,
|
||||
onMonthSelected = {
|
||||
viewModel.setSelectedMonth(it)
|
||||
showMonthPicker = false
|
||||
},
|
||||
onDismiss = { showMonthPicker = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,147 @@
|
||||
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.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.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.RecordItem
|
||||
import com.yovinchen.bookkeeping.viewmodel.CategoryDetailViewModel
|
||||
import com.yovinchen.bookkeeping.viewmodel.CategoryDetailViewModelFactory
|
||||
import java.text.SimpleDateFormat
|
||||
import java.time.YearMonth
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CategoryDetailScreen(
|
||||
category: String,
|
||||
month: YearMonth,
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val database = remember { BookkeepingDatabase.getDatabase(context) }
|
||||
val viewModel: CategoryDetailViewModel = viewModel(
|
||||
factory = CategoryDetailViewModelFactory(database, category, month)
|
||||
)
|
||||
|
||||
val records by viewModel.records.collectAsState()
|
||||
val total by viewModel.total.collectAsState()
|
||||
val members by viewModel.members.collectAsState()
|
||||
val groupedRecords = remember(records) {
|
||||
records.groupBy { record ->
|
||||
SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(record.date)
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("$category - ${month.format(DateTimeFormatter.ofPattern("yyyy年MM月"))}") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.Default.ArrowBack, contentDescription = "返回")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
) {
|
||||
// 总金额显示
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "总金额",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
Text(
|
||||
text = String.format("%.2f", total),
|
||||
style = MaterialTheme.typography.titleLarge
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 记录列表
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
groupedRecords.forEach { (date, dayRecords) ->
|
||||
item {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
// 日期标签
|
||||
Text(
|
||||
text = SimpleDateFormat(
|
||||
"yyyy年MM月dd日 E",
|
||||
Locale.CHINESE
|
||||
).format(dayRecords.first().date),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// 当天的记录
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
dayRecords.forEachIndexed { index, record ->
|
||||
RecordItem(
|
||||
record = record,
|
||||
onClick = {},
|
||||
members = members
|
||||
)
|
||||
|
||||
if (index < dayRecords.size - 1) {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(vertical = 4.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
thickness = 0.5.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,63 @@
|
||||
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
|
||||
|
||||
class AnalysisViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private val recordDao = BookkeepingDatabase.getDatabase(application).bookkeepingDao()
|
||||
|
||||
private val _selectedMonth = MutableStateFlow(YearMonth.now())
|
||||
val selectedMonth = _selectedMonth.asStateFlow()
|
||||
|
||||
private val _selectedAnalysisType = MutableStateFlow(AnalysisType.EXPENSE)
|
||||
val selectedAnalysisType = _selectedAnalysisType.asStateFlow()
|
||||
|
||||
val categoryStats = combine(selectedMonth, selectedAnalysisType) { month, type ->
|
||||
val records = recordDao.getAllRecords().first()
|
||||
val monthRecords = records.filter {
|
||||
val recordDate = LocalDateTime.ofInstant(it.date.toInstant(), ZoneId.systemDefault())
|
||||
YearMonth.from(recordDate) == month && it.type == when(type) {
|
||||
AnalysisType.EXPENSE -> TransactionType.EXPENSE
|
||||
AnalysisType.INCOME -> TransactionType.INCOME
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
// 按分类统计
|
||||
val categoryMap = monthRecords.groupBy { it.category }
|
||||
val stats = categoryMap.map { (category, records) ->
|
||||
CategoryStat(
|
||||
category = category,
|
||||
amount = records.sumOf { it.amount },
|
||||
count = records.size
|
||||
)
|
||||
}.sortedByDescending { it.amount }
|
||||
|
||||
// 计算总额
|
||||
val total = stats.sumOf { it.amount }
|
||||
|
||||
// 计算百分比
|
||||
stats.map { it.copy(percentage = if (total > 0) it.amount / total * 100 else 0.0) }
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5000),
|
||||
initialValue = emptyList()
|
||||
)
|
||||
|
||||
fun setSelectedMonth(month: YearMonth) {
|
||||
_selectedMonth.value = month
|
||||
}
|
||||
|
||||
fun setAnalysisType(type: AnalysisType) {
|
||||
_selectedAnalysisType.value = type
|
||||
}
|
||||
}
|
@@ -0,0 +1,52 @@
|
||||
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.Member
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
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 _records = MutableStateFlow<List<BookkeepingRecord>>(emptyList())
|
||||
val records: StateFlow<List<BookkeepingRecord>> = _records
|
||||
|
||||
private val _total = MutableStateFlow(0.0)
|
||||
val total: StateFlow<Double> = _total
|
||||
|
||||
private val _members = MutableStateFlow<List<Member>>(emptyList())
|
||||
val members: StateFlow<List<Member>> = _members
|
||||
|
||||
init {
|
||||
loadRecords()
|
||||
loadMembers()
|
||||
}
|
||||
|
||||
private fun loadRecords() {
|
||||
viewModelScope.launch {
|
||||
val monthStr = month.format(DateTimeFormatter.ofPattern("yyyy-MM"))
|
||||
database.bookkeepingDao().getRecordsByCategoryAndMonth(category, monthStr)
|
||||
.collect { records ->
|
||||
_records.value = records
|
||||
_total.value = records.sumOf { it.amount }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadMembers() {
|
||||
viewModelScope.launch {
|
||||
database.memberDao().getAllMembers().collect { members ->
|
||||
_members.value = members
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -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")
|
||||
}
|
||||
}
|
@@ -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
|
@@ -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" }
|
||||
|
@@ -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") }
|
||||
}
|
||||
}
|
||||
|
||||
|
Reference in New Issue
Block a user