Compare commits
20 Commits
feature/me
...
abf529117f
Author | SHA1 | Date | |
---|---|---|---|
abf529117f | |||
713037b266 | |||
a0d47864d8 | |||
63149f9abb | |||
70e79ec584 | |||
882435e25a | |||
37b91ded7f | |||
94fc7b2a7e | |||
380fdd5589 | |||
76d0286883 | |||
f134304646 | |||
8339d3d5da | |||
c3f108ab57 | |||
9772fd6e59 | |||
0a738fc7e1 | |||
6c3b366d45 | |||
3c080fbc05 | |||
71deaaa288 | |||
47e202fa61 | |||
af880c23eb |
5
.idea/misc.xml
generated
5
.idea/misc.xml
generated
@@ -1,5 +1,10 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project version="4">
|
<project version="4">
|
||||||
|
<component name="EntryPointsManager">
|
||||||
|
<list size="1">
|
||||||
|
<item index="0" class="java.lang.String" itemvalue="androidx.compose.runtime.Composable" />
|
||||||
|
</list>
|
||||||
|
</component>
|
||||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
|
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
|
||||||
<output url="file://$PROJECT_DIR$/build/classes" />
|
<output url="file://$PROJECT_DIR$/build/classes" />
|
||||||
|
15
README.md
15
README.md
@@ -25,7 +25,7 @@
|
|||||||
|
|
||||||
## 🗺 开发路线图
|
## 🗺 开发路线图
|
||||||
|
|
||||||
### 1. 基础记账 (已完成 ✨)
|
### 0. 基础记账 (已完成 ✨)
|
||||||
- [x] 收入/支出记录管理
|
- [x] 收入/支出记录管理
|
||||||
- [x] 分类管理系统
|
- [x] 分类管理系统
|
||||||
- [x] 自定义日期选择器
|
- [x] 自定义日期选择器
|
||||||
@@ -33,41 +33,40 @@
|
|||||||
- [x] 深色/浅色主题切换
|
- [x] 深色/浅色主题切换
|
||||||
- [x] 主题色自定义
|
- [x] 主题色自定义
|
||||||
|
|
||||||
### 2. 成员系统 (已完成 🎉)
|
### 1. 成员系统 (已完成 🎉)
|
||||||
- [x] 成员添加/编辑/删除
|
- [x] 成员添加/编辑/删除
|
||||||
- [x] 记账时选择相关成员
|
- [x] 记账时选择相关成员
|
||||||
- [x] 主页账单修改相关成员
|
- [x] 主页账单修改相关成员
|
||||||
- [x] 成员消费统计
|
- [x] 成员消费统计
|
||||||
|
|
||||||
### 3. 数据分析 (进行中 🚀)
|
### 2. 图表分析 (进行中 🚀)
|
||||||
- [ ] 支出/收入趋势图表
|
- [ ] 支出/收入趋势图表
|
||||||
- [ ] 分类占比饼图
|
- [ ] 分类占比饼图
|
||||||
- [ ] 月度/年度报表
|
- [ ] 月度/年度报表
|
||||||
- [ ] 成员消费分析
|
- [ ] 成员消费分析
|
||||||
- [ ] 自定义统计周期
|
- [ ] 自定义统计周期
|
||||||
|
|
||||||
### 4. 数据管理 (计划中 📝)
|
### 3. 数据管理 (计划中 📝)
|
||||||
- [ ] 导出 CSV/Excel 功能
|
- [ ] 导出 CSV/Excel 功能
|
||||||
- [ ] 云端备份支持
|
|
||||||
- [ ] 数据迁移工具
|
- [ ] 数据迁移工具
|
||||||
- [ ] 定期自动备份
|
- [ ] 定期自动备份
|
||||||
- [ ] 备份加密功能
|
- [ ] 备份加密功能
|
||||||
|
|
||||||
### 5. 预算管理 (计划中 💡)
|
### 4. 预算管理 (计划中 💡)
|
||||||
- [ ] 月度预算设置
|
- [ ] 月度预算设置
|
||||||
- [ ] 预算超支提醒
|
- [ ] 预算超支提醒
|
||||||
- [ ] 分类预算管理
|
- [ ] 分类预算管理
|
||||||
- [ ] 成员预算管理
|
- [ ] 成员预算管理
|
||||||
- [ ] 预算分析报告
|
- [ ] 预算分析报告
|
||||||
|
|
||||||
### 6. 体验优化 (持续进行 🔄)
|
### 5. 体验优化 (持续进行 🔄)
|
||||||
- [x] 深色模式支持
|
- [x] 深色模式支持
|
||||||
- [ ] 手势操作优化
|
- [ ] 手势操作优化
|
||||||
- [ ] 快速记账小组件
|
- [ ] 快速记账小组件
|
||||||
- [ ] 多语言支持
|
- [ ] 多语言支持
|
||||||
- [ ] 自定义主题
|
- [ ] 自定义主题
|
||||||
|
|
||||||
### 7. 性能提升 (持续进行 ⚡️)
|
### 6. 性能提升 (持续进行 ⚡️)
|
||||||
- [ ] 大数据量处理优化
|
- [ ] 大数据量处理优化
|
||||||
- [ ] 启动速度优化
|
- [ ] 启动速度优化
|
||||||
- [ ] 内存使用优化
|
- [ ] 内存使用优化
|
||||||
|
@@ -16,8 +16,8 @@ android {
|
|||||||
applicationId = "com.yovinchen.bookkeeping"
|
applicationId = "com.yovinchen.bookkeeping"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 34
|
targetSdk = 34
|
||||||
versionCode = 1
|
versionCode = 4
|
||||||
versionName = "1.0.0"
|
versionName = "1.2.2"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
@@ -89,6 +89,7 @@ dependencies {
|
|||||||
implementation(libs.androidx.room.common)
|
implementation(libs.androidx.room.common)
|
||||||
implementation(libs.androidx.navigation.common.ktx)
|
implementation(libs.androidx.navigation.common.ktx)
|
||||||
implementation(libs.androidx.navigation.compose)
|
implementation(libs.androidx.navigation.compose)
|
||||||
|
implementation(libs.vision.internal.vkp)
|
||||||
|
|
||||||
// Room
|
// Room
|
||||||
val roomVersion = "2.6.1"
|
val roomVersion = "2.6.1"
|
||||||
@@ -96,6 +97,9 @@ dependencies {
|
|||||||
implementation("androidx.room:room-ktx:$roomVersion")
|
implementation("androidx.room:room-ktx:$roomVersion")
|
||||||
ksp("androidx.room:room-compiler:$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")
|
testImplementation("junit:junit:4.13.2")
|
||||||
androidTestImplementation("androidx.test.ext:junit:1.1.5")
|
androidTestImplementation("androidx.test.ext:junit:1.1.5")
|
||||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
|
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
|
||||||
|
@@ -3,6 +3,7 @@ package com.yovinchen.bookkeeping.data
|
|||||||
import androidx.room.*
|
import androidx.room.*
|
||||||
import com.yovinchen.bookkeeping.model.BookkeepingRecord
|
import com.yovinchen.bookkeeping.model.BookkeepingRecord
|
||||||
import com.yovinchen.bookkeeping.model.Category
|
import com.yovinchen.bookkeeping.model.Category
|
||||||
|
import com.yovinchen.bookkeeping.model.CategoryStat
|
||||||
import com.yovinchen.bookkeeping.model.TransactionType
|
import com.yovinchen.bookkeeping.model.TransactionType
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
@@ -27,6 +28,59 @@ interface BookkeepingDao {
|
|||||||
@Query("SELECT SUM(amount) FROM bookkeeping_records WHERE type = :type AND (memberId = :memberId OR memberId IS NULL)")
|
@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?>
|
fun getTotalAmountByType(type: TransactionType, memberId: Int? = null): Flow<Double?>
|
||||||
|
|
||||||
|
@Query("""
|
||||||
|
SELECT * FROM bookkeeping_records
|
||||||
|
WHERE category = :category
|
||||||
|
AND strftime('%Y-%m', datetime(date/1000, 'unixepoch')) = :yearMonth
|
||||||
|
ORDER BY date DESC
|
||||||
|
""")
|
||||||
|
fun getRecordsByCategoryAndMonth(
|
||||||
|
category: String,
|
||||||
|
yearMonth: String
|
||||||
|
): Flow<List<BookkeepingRecord>>
|
||||||
|
|
||||||
|
@Query("""
|
||||||
|
SELECT * FROM bookkeeping_records
|
||||||
|
WHERE memberId IN (SELECT id FROM members WHERE name = :memberName)
|
||||||
|
AND strftime('%Y-%m', datetime(date/1000, 'unixepoch')) = :yearMonth
|
||||||
|
ORDER BY date DESC
|
||||||
|
""")
|
||||||
|
fun getRecordsByMemberAndMonth(
|
||||||
|
memberName: String,
|
||||||
|
yearMonth: String
|
||||||
|
): Flow<List<BookkeepingRecord>>
|
||||||
|
|
||||||
|
@Query("""
|
||||||
|
SELECT m.name as category,
|
||||||
|
SUM(r.amount) as amount,
|
||||||
|
COUNT(*) as count,
|
||||||
|
(SUM(r.amount) * 100.0 / (
|
||||||
|
SELECT SUM(amount)
|
||||||
|
FROM bookkeeping_records
|
||||||
|
WHERE category = :category
|
||||||
|
AND strftime('%Y-%m', datetime(date/1000, 'unixepoch')) = :yearMonth
|
||||||
|
)) as percentage
|
||||||
|
FROM bookkeeping_records r
|
||||||
|
JOIN members m ON r.memberId = m.id
|
||||||
|
WHERE r.category = :category
|
||||||
|
AND strftime('%Y-%m', datetime(r.date/1000, 'unixepoch')) = :yearMonth
|
||||||
|
GROUP BY m.name
|
||||||
|
ORDER BY amount DESC
|
||||||
|
""")
|
||||||
|
fun getMemberStatsByCategory(
|
||||||
|
category: String,
|
||||||
|
yearMonth: String
|
||||||
|
): Flow<List<CategoryStat>>
|
||||||
|
|
||||||
|
@Query("""
|
||||||
|
SELECT * FROM bookkeeping_records
|
||||||
|
WHERE category = :category
|
||||||
|
ORDER BY date DESC
|
||||||
|
""")
|
||||||
|
fun getRecordsByCategory(
|
||||||
|
category: String
|
||||||
|
): Flow<List<BookkeepingRecord>>
|
||||||
|
|
||||||
@Insert
|
@Insert
|
||||||
suspend fun insertRecord(record: BookkeepingRecord): Long
|
suspend fun insertRecord(record: BookkeepingRecord): Long
|
||||||
|
|
||||||
@@ -53,4 +107,50 @@ interface BookkeepingDao {
|
|||||||
|
|
||||||
@Query("UPDATE bookkeeping_records SET category = :newName WHERE category = :oldName")
|
@Query("UPDATE bookkeeping_records SET category = :newName WHERE category = :oldName")
|
||||||
suspend fun updateRecordCategories(oldName: String, newName: String)
|
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<BookkeepingRecord>
|
||||||
|
|
||||||
|
@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<BookkeepingRecord>
|
||||||
}
|
}
|
||||||
|
@@ -32,9 +32,9 @@ abstract class BookkeepingDatabase : RoomDatabase() {
|
|||||||
private const val TAG = "BookkeepingDatabase"
|
private const val TAG = "BookkeepingDatabase"
|
||||||
|
|
||||||
private val MIGRATION_1_2 = object : Migration(1, 2) {
|
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 (
|
CREATE TABLE IF NOT EXISTS members (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
@@ -43,20 +43,20 @@ abstract class BookkeepingDatabase : RoomDatabase() {
|
|||||||
""")
|
""")
|
||||||
|
|
||||||
// 插入默认成员
|
// 插入默认成员
|
||||||
database.execSQL("""
|
db.execSQL("""
|
||||||
INSERT INTO members (name, description)
|
INSERT INTO members (name, description)
|
||||||
VALUES ('自己', '默认成员')
|
VALUES ('自己', '默认成员')
|
||||||
""")
|
""")
|
||||||
|
|
||||||
// 修改记账记录表,添加成员ID字段
|
// 修改记账记录表,添加成员ID字段
|
||||||
database.execSQL("""
|
db.execSQL("""
|
||||||
ALTER TABLE bookkeeping_records
|
ALTER TABLE bookkeeping_records
|
||||||
ADD COLUMN memberId INTEGER DEFAULT NULL
|
ADD COLUMN memberId INTEGER DEFAULT NULL
|
||||||
REFERENCES members(id) ON DELETE SET NULL
|
REFERENCES members(id) ON DELETE SET NULL
|
||||||
""")
|
""")
|
||||||
|
|
||||||
// 更新现有记录,将其关联到默认成员
|
// 更新现有记录,将其关联到默认成员
|
||||||
database.execSQL("""
|
db.execSQL("""
|
||||||
UPDATE bookkeeping_records
|
UPDATE bookkeeping_records
|
||||||
SET memberId = (SELECT id FROM members WHERE name = '我自己')
|
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) {
|
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 (
|
CREATE TABLE IF NOT EXISTS bookkeeping_records_new (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
amount REAL 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)
|
INSERT INTO bookkeeping_records_new (id, amount, type, category, description, date, memberId)
|
||||||
SELECT id, amount, type, category, description, date, memberId FROM bookkeeping_records
|
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 (
|
CREATE TABLE IF NOT EXISTS categories_new (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
name TEXT 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)
|
INSERT INTO categories_new (id, name, type)
|
||||||
SELECT id, name, type FROM categories
|
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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -3,6 +3,7 @@ package com.yovinchen.bookkeeping.data
|
|||||||
import androidx.room.TypeConverter
|
import androidx.room.TypeConverter
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
class Converters {
|
class Converters {
|
||||||
private val formatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME
|
private val formatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME
|
||||||
@@ -18,4 +19,14 @@ class Converters {
|
|||||||
fun dateToTimestamp(date: LocalDateTime?): String? {
|
fun dateToTimestamp(date: LocalDateTime?): String? {
|
||||||
return date?.format(formatter)
|
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()) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -12,5 +12,6 @@ data class Record(
|
|||||||
val category: String,
|
val category: String,
|
||||||
val description: String,
|
val description: String,
|
||||||
val dateTime: LocalDateTime = LocalDateTime.now(),
|
val dateTime: LocalDateTime = LocalDateTime.now(),
|
||||||
val isExpense: Boolean = true
|
val isExpense: Boolean = true,
|
||||||
|
val member: String = "Default"
|
||||||
)
|
)
|
||||||
|
@@ -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.Entity
|
||||||
import androidx.room.ForeignKey
|
import androidx.room.ForeignKey
|
||||||
|
import androidx.room.Index
|
||||||
import androidx.room.PrimaryKey
|
import androidx.room.PrimaryKey
|
||||||
import androidx.room.TypeConverter
|
import androidx.room.TypeConverter
|
||||||
import androidx.room.TypeConverters
|
import androidx.room.TypeConverters
|
||||||
@@ -43,6 +44,9 @@ class Converters {
|
|||||||
childColumns = ["memberId"],
|
childColumns = ["memberId"],
|
||||||
onDelete = ForeignKey.SET_NULL
|
onDelete = ForeignKey.SET_NULL
|
||||||
)
|
)
|
||||||
|
],
|
||||||
|
indices = [
|
||||||
|
Index(value = ["memberId"])
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
@TypeConverters(Converters::class)
|
@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,82 @@
|
|||||||
|
package com.yovinchen.bookkeeping.ui.components
|
||||||
|
|
||||||
|
import android.graphics.Color as AndroidColor
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.toArgb
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import com.github.mikephil.charting.charts.PieChart
|
||||||
|
import com.github.mikephil.charting.data.Entry
|
||||||
|
import com.github.mikephil.charting.data.PieData
|
||||||
|
import com.github.mikephil.charting.data.PieDataSet
|
||||||
|
import com.github.mikephil.charting.data.PieEntry
|
||||||
|
import com.github.mikephil.charting.formatter.PercentFormatter
|
||||||
|
import com.github.mikephil.charting.highlight.Highlight
|
||||||
|
import com.github.mikephil.charting.listener.OnChartValueSelectedListener
|
||||||
|
import com.github.mikephil.charting.utils.ColorTemplate
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CategoryPieChart(
|
||||||
|
categoryData: List<Pair<String, Float>>,
|
||||||
|
memberData: List<Pair<String, Float>>,
|
||||||
|
currentViewMode: Boolean = false, // false 为分类视图,true 为成员视图
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onCategoryClick: (String) -> Unit = {}
|
||||||
|
) {
|
||||||
|
val textColor = MaterialTheme.colorScheme.onSurface.toArgb()
|
||||||
|
val data = if (currentViewMode) memberData else categoryData
|
||||||
|
|
||||||
|
AndroidView(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(300.dp),
|
||||||
|
factory = { context ->
|
||||||
|
PieChart(context).apply {
|
||||||
|
description.isEnabled = false
|
||||||
|
setUsePercentValues(true)
|
||||||
|
setDrawEntryLabels(true)
|
||||||
|
legend.isEnabled = false
|
||||||
|
isDrawHoleEnabled = true
|
||||||
|
holeRadius = 40f
|
||||||
|
setHoleColor(AndroidColor.TRANSPARENT)
|
||||||
|
setTransparentCircleRadius(45f)
|
||||||
|
setEntryLabelColor(textColor)
|
||||||
|
setEntryLabelTextSize(12f)
|
||||||
|
setCenterTextColor(textColor)
|
||||||
|
|
||||||
|
setOnChartValueSelectedListener(object : OnChartValueSelectedListener {
|
||||||
|
override fun onValueSelected(e: Entry?, h: Highlight?) {
|
||||||
|
e?.let {
|
||||||
|
if (it is PieEntry) {
|
||||||
|
onCategoryClick(it.label ?: return)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onNothingSelected() {}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
update = { chart ->
|
||||||
|
val entries = data.map { (label, amount) ->
|
||||||
|
PieEntry(amount, label)
|
||||||
|
}
|
||||||
|
|
||||||
|
val dataSet = PieDataSet(entries, "").apply {
|
||||||
|
colors = ColorTemplate.MATERIAL_COLORS.toList()
|
||||||
|
valueTextSize = 14f
|
||||||
|
valueFormatter = PercentFormatter(chart)
|
||||||
|
valueTextColor = textColor
|
||||||
|
setDrawValues(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
val pieData = PieData(dataSet)
|
||||||
|
chart.data = pieData
|
||||||
|
chart.invalidate()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
@@ -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("取消")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
@@ -22,6 +22,7 @@ fun RecordItem(
|
|||||||
members: List<Member> = emptyList()
|
members: List<Member> = emptyList()
|
||||||
) {
|
) {
|
||||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||||
|
// val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) }
|
||||||
val timeFormat = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) }
|
val timeFormat = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) }
|
||||||
val member = members.find { it.id == record.memberId }
|
val member = members.find { it.id == record.memberId }
|
||||||
|
|
||||||
@@ -48,14 +49,18 @@ fun RecordItem(
|
|||||||
style = MaterialTheme.typography.bodyLarge
|
style = MaterialTheme.typography.bodyLarge
|
||||||
)
|
)
|
||||||
|
|
||||||
// 第二行:时间 | 成员 | 详情
|
// 第二行:日期和时间 | 成员 | 详情
|
||||||
Text(
|
Text(
|
||||||
text = buildString {
|
text = buildString {
|
||||||
|
// append(dateFormat.format(record.date))
|
||||||
|
// append(" ")
|
||||||
append(timeFormat.format(record.date))
|
append(timeFormat.format(record.date))
|
||||||
if (member != null && member.name != "自己") {
|
// if (member != null && member.name != "自己") {
|
||||||
append(" | ")
|
append(" | ")
|
||||||
|
if (member != null) {
|
||||||
append(member.name)
|
append(member.name)
|
||||||
}
|
}
|
||||||
|
// }
|
||||||
if (record.description.isNotEmpty()) {
|
if (record.description.isNotEmpty()) {
|
||||||
append(" | ")
|
append(" | ")
|
||||||
append(record.description)
|
append(record.description)
|
||||||
|
@@ -2,40 +2,54 @@ package com.yovinchen.bookkeeping.ui.navigation
|
|||||||
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material.icons.Icons
|
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.material.icons.filled.Settings
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.NavigationBar
|
import androidx.compose.material3.NavigationBar
|
||||||
import androidx.compose.material3.NavigationBarItem
|
import androidx.compose.material3.NavigationBarItem
|
||||||
import androidx.compose.material3.NavigationBarItemDefaults
|
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Modifier
|
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.NavGraph.Companion.findStartDestination
|
||||||
|
import androidx.navigation.NavType
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||||
import androidx.navigation.compose.rememberNavController
|
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.model.ThemeMode
|
||||||
import com.yovinchen.bookkeeping.ui.screen.HomeScreen
|
import com.yovinchen.bookkeeping.ui.screen.*
|
||||||
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) {
|
sealed class Screen(
|
||||||
object Home : Screen(
|
val route: String,
|
||||||
route = "home",
|
val title: String,
|
||||||
icon = { Icon(Icons.Default.Home, contentDescription = "主页") },
|
val icon: ImageVector? = null
|
||||||
label = "主页"
|
) {
|
||||||
)
|
object Home : Screen("home", "记账", Icons.AutoMirrored.Filled.List)
|
||||||
object Settings : Screen(
|
object Analysis : Screen("analysis", "分析", Icons.Default.Analytics)
|
||||||
route = "settings",
|
object Settings : Screen("settings", "设置", Icons.Default.Settings)
|
||||||
icon = { Icon(Icons.Default.Settings, contentDescription = "设置") },
|
object CategoryDetail : Screen("category_detail/{category}/{yearMonth}", "分类详情") {
|
||||||
label = "设置"
|
fun createRoute(category: String, yearMonth: YearMonth): String {
|
||||||
)
|
return "category_detail/$category/${yearMonth.format(DateTimeFormatter.ofPattern("yyyy-MM"))}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
object MemberDetail : Screen("member_detail/{memberName}/{category}/{yearMonth}?type={type}", "成员详情") {
|
||||||
|
fun createRoute(memberName: String, category: String, yearMonth: YearMonth, type: AnalysisType): String {
|
||||||
|
return "member_detail/$memberName/$category/${yearMonth.format(DateTimeFormatter.ofPattern("yyyy-MM"))}?type=${type.name}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun bottomNavigationItems() = listOf(Home, Analysis, Settings)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -45,22 +59,18 @@ fun MainNavigation(
|
|||||||
onThemeChange: (ThemeMode) -> Unit
|
onThemeChange: (ThemeMode) -> Unit
|
||||||
) {
|
) {
|
||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
val items = listOf(Screen.Home, Screen.Settings)
|
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
bottomBar = {
|
bottomBar = {
|
||||||
NavigationBar(
|
NavigationBar {
|
||||||
containerColor = MaterialTheme.colorScheme.surface,
|
|
||||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
|
||||||
) {
|
|
||||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||||
val currentDestination = navBackStackEntry?.destination
|
val currentRoute = navBackStackEntry?.destination?.route
|
||||||
|
|
||||||
items.forEach { screen ->
|
Screen.bottomNavigationItems().forEach { screen ->
|
||||||
NavigationBarItem(
|
NavigationBarItem(
|
||||||
icon = screen.icon,
|
icon = { Icon(screen.icon!!, contentDescription = screen.title) },
|
||||||
label = { Text(screen.label) },
|
label = { Text(screen.title) },
|
||||||
selected = currentDestination?.hierarchy?.any { it.route == screen.route } == true,
|
selected = currentRoute == screen.route,
|
||||||
onClick = {
|
onClick = {
|
||||||
navController.navigate(screen.route) {
|
navController.navigate(screen.route) {
|
||||||
popUpTo(navController.graph.findStartDestination().id) {
|
popUpTo(navController.graph.findStartDestination().id) {
|
||||||
@@ -69,33 +79,90 @@ fun MainNavigation(
|
|||||||
launchSingleTop = true
|
launchSingleTop = true
|
||||||
restoreState = 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(
|
NavHost(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
startDestination = Screen.Home.route,
|
startDestination = Screen.Home.route,
|
||||||
modifier = Modifier.padding(paddingValues)
|
modifier = Modifier.padding(innerPadding)
|
||||||
) {
|
) {
|
||||||
composable(Screen.Home.route) {
|
composable(Screen.Home.route) { HomeScreen() }
|
||||||
HomeScreen()
|
|
||||||
|
composable(Screen.Analysis.route) {
|
||||||
|
AnalysisScreen(
|
||||||
|
onNavigateToCategoryDetail = { category, yearMonth ->
|
||||||
|
navController.navigate(Screen.CategoryDetail.createRoute(category, yearMonth))
|
||||||
|
},
|
||||||
|
onNavigateToMemberDetail = { memberName, yearMonth, analysisType ->
|
||||||
|
navController.navigate(Screen.MemberDetail.createRoute(memberName, "", yearMonth, analysisType))
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
composable(Screen.Settings.route) {
|
composable(Screen.Settings.route) {
|
||||||
SettingsScreen(
|
SettingsScreen(
|
||||||
currentTheme = currentTheme,
|
currentTheme = currentTheme,
|
||||||
onThemeChange = onThemeChange
|
onThemeChange = onThemeChange
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
composable(
|
||||||
|
route = Screen.CategoryDetail.route,
|
||||||
|
arguments = listOf(
|
||||||
|
navArgument("category") { type = NavType.StringType },
|
||||||
|
navArgument("yearMonth") { type = NavType.StringType }
|
||||||
|
)
|
||||||
|
) { backStackEntry ->
|
||||||
|
val category = backStackEntry.arguments?.getString("category") ?: return@composable
|
||||||
|
val yearMonthStr = backStackEntry.arguments?.getString("yearMonth") ?: return@composable
|
||||||
|
val yearMonth = YearMonth.parse(yearMonthStr)
|
||||||
|
|
||||||
|
CategoryDetailScreen(
|
||||||
|
category = category,
|
||||||
|
yearMonth = yearMonth,
|
||||||
|
onNavigateBack = { navController.popBackStack() },
|
||||||
|
onNavigateToMemberDetail = { memberName ->
|
||||||
|
navController.navigate(Screen.MemberDetail.createRoute(memberName, category, yearMonth, AnalysisType.EXPENSE))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(
|
||||||
|
route = Screen.MemberDetail.route,
|
||||||
|
arguments = listOf(
|
||||||
|
navArgument("memberName") { type = NavType.StringType },
|
||||||
|
navArgument("category") { type = NavType.StringType },
|
||||||
|
navArgument("yearMonth") { type = NavType.StringType },
|
||||||
|
navArgument("type") {
|
||||||
|
type = NavType.StringType
|
||||||
|
defaultValue = AnalysisType.EXPENSE.name
|
||||||
|
}
|
||||||
|
)
|
||||||
|
) { backStackEntry ->
|
||||||
|
val memberName = backStackEntry.arguments?.getString("memberName") ?: return@composable
|
||||||
|
val category = backStackEntry.arguments?.getString("category") ?: return@composable
|
||||||
|
val yearMonthStr = backStackEntry.arguments?.getString("yearMonth") ?: return@composable
|
||||||
|
val yearMonth = YearMonth.parse(yearMonthStr)
|
||||||
|
val type = backStackEntry.arguments?.getString("type")?.let {
|
||||||
|
try {
|
||||||
|
AnalysisType.valueOf(it)
|
||||||
|
} catch (e: IllegalArgumentException) {
|
||||||
|
AnalysisType.EXPENSE
|
||||||
|
}
|
||||||
|
} ?: AnalysisType.EXPENSE
|
||||||
|
|
||||||
|
MemberDetailScreen(
|
||||||
|
memberName = memberName,
|
||||||
|
yearMonth = yearMonth,
|
||||||
|
category = category,
|
||||||
|
analysisType = type,
|
||||||
|
onNavigateBack = { navController.popBackStack() }
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -0,0 +1,194 @@
|
|||||||
|
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.ui.components.CategoryPieChart
|
||||||
|
import com.yovinchen.bookkeeping.ui.components.CategoryStatItem
|
||||||
|
import com.yovinchen.bookkeeping.ui.components.MonthYearPicker
|
||||||
|
import com.yovinchen.bookkeeping.viewmodel.AnalysisViewModel
|
||||||
|
import java.time.YearMonth
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
|
enum class ViewMode {
|
||||||
|
CATEGORY, MEMBER
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun AnalysisScreen(
|
||||||
|
onNavigateToCategoryDetail: (String, YearMonth) -> Unit,
|
||||||
|
onNavigateToMemberDetail: (String, YearMonth, AnalysisType) -> Unit
|
||||||
|
) {
|
||||||
|
val viewModel: AnalysisViewModel = viewModel()
|
||||||
|
val selectedMonth by viewModel.selectedMonth.collectAsState()
|
||||||
|
val selectedAnalysisType by viewModel.selectedAnalysisType.collectAsState()
|
||||||
|
val categoryStats by viewModel.categoryStats.collectAsState()
|
||||||
|
val memberStats by viewModel.memberStats.collectAsState()
|
||||||
|
|
||||||
|
var showMonthPicker by remember { mutableStateOf(false) }
|
||||||
|
var showViewModeMenu by remember { mutableStateOf(false) }
|
||||||
|
var currentViewMode by rememberSaveable { mutableStateOf(ViewMode.CATEGORY) }
|
||||||
|
|
||||||
|
Scaffold { padding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding)
|
||||||
|
) {
|
||||||
|
// 时间选择按钮行
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
horizontalArrangement = Arrangement.End,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Button(onClick = { showMonthPicker = true }) {
|
||||||
|
Text(selectedMonth.format(DateTimeFormatter.ofPattern("yyyy年MM月")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分析类型和视图模式选择行
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
// 分类/成员切换下拉菜单
|
||||||
|
Box {
|
||||||
|
Button(
|
||||||
|
onClick = { showViewModeMenu = true }
|
||||||
|
) {
|
||||||
|
Text(if (currentViewMode == ViewMode.CATEGORY) "分类" else "成员")
|
||||||
|
Icon(Icons.Default.ArrowDropDown, "切换视图")
|
||||||
|
}
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = showViewModeMenu,
|
||||||
|
onDismissRequest = { showViewModeMenu = false }
|
||||||
|
) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("分类") },
|
||||||
|
onClick = {
|
||||||
|
currentViewMode = ViewMode.CATEGORY
|
||||||
|
showViewModeMenu = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("成员") },
|
||||||
|
onClick = {
|
||||||
|
currentViewMode = ViewMode.MEMBER
|
||||||
|
showViewModeMenu = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 类型切换
|
||||||
|
Row {
|
||||||
|
AnalysisType.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)
|
||||||
|
) {
|
||||||
|
// 添加饼图作为第一个项目
|
||||||
|
if (selectedAnalysisType != AnalysisType.TREND) {
|
||||||
|
item {
|
||||||
|
CategoryPieChart(
|
||||||
|
categoryData = categoryStats.map { Pair(it.category, it.percentage.toFloat()) },
|
||||||
|
memberData = memberStats.map { Pair(it.category, it.percentage.toFloat()) },
|
||||||
|
currentViewMode = currentViewMode == ViewMode.MEMBER,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(200.dp)
|
||||||
|
.padding(bottom = 16.dp),
|
||||||
|
onCategoryClick = { category ->
|
||||||
|
if (currentViewMode == ViewMode.CATEGORY) {
|
||||||
|
onNavigateToCategoryDetail(category, selectedMonth)
|
||||||
|
} else {
|
||||||
|
onNavigateToMemberDetail(category, selectedMonth, selectedAnalysisType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加统计列表项目
|
||||||
|
items(if (currentViewMode == ViewMode.CATEGORY) categoryStats else memberStats) { stat ->
|
||||||
|
CategoryStatItem(
|
||||||
|
stat = stat,
|
||||||
|
onClick = {
|
||||||
|
if (currentViewMode == ViewMode.CATEGORY) {
|
||||||
|
onNavigateToCategoryDetail(stat.category, selectedMonth)
|
||||||
|
} else {
|
||||||
|
onNavigateToMemberDetail(stat.category, selectedMonth, selectedAnalysisType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showMonthPicker) {
|
||||||
|
MonthYearPicker(
|
||||||
|
selectedMonth = selectedMonth,
|
||||||
|
onMonthSelected = { month ->
|
||||||
|
viewModel.setSelectedMonth(month)
|
||||||
|
showMonthPicker = false
|
||||||
|
},
|
||||||
|
onDismiss = { showMonthPicker = false }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,249 @@
|
|||||||
|
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.HorizontalDivider
|
||||||
|
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.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.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.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,
|
||||||
|
yearMonth: YearMonth,
|
||||||
|
onNavigateBack: () -> Unit,
|
||||||
|
onNavigateToMemberDetail: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val database = remember { BookkeepingDatabase.getDatabase(context) }
|
||||||
|
val viewModel: CategoryDetailViewModel = viewModel(
|
||||||
|
factory = CategoryDetailViewModelFactory(database, category, yearMonth)
|
||||||
|
)
|
||||||
|
|
||||||
|
val records by viewModel.records.collectAsState()
|
||||||
|
val memberStats by viewModel.memberStats.collectAsState()
|
||||||
|
val total by viewModel.total.collectAsState()
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(category) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onNavigateBack) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { padding ->
|
||||||
|
LazyColumn(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
// 第一部分:总支出
|
||||||
|
item {
|
||||||
|
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(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "成员分布",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
modifier = Modifier.padding(bottom = 16.dp)
|
||||||
|
)
|
||||||
|
CategoryPieChart(
|
||||||
|
categoryData = memberStats.map { Pair(it.category, it.percentage.toFloat()) },
|
||||||
|
memberData = emptyList(),
|
||||||
|
currentViewMode = false,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(200.dp),
|
||||||
|
onCategoryClick = { memberName ->
|
||||||
|
if (records.isNotEmpty() && records.first().type == TransactionType.EXPENSE) {
|
||||||
|
onNavigateToMemberDetail(memberName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 第三部分:详细信息
|
||||||
|
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 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,183 @@
|
|||||||
|
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.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,
|
||||||
|
yearMonth: 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)
|
||||||
|
|
||||||
|
LaunchedEffect(memberName, category, yearMonth, analysisType) {
|
||||||
|
viewModel.loadMemberRecords(memberName, category, yearMonth, 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
|
||||||
|
.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(totalAmount),
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 第二层:按日期分组的记录列表
|
||||||
|
groupedRecords.forEach { (date, dayRecords) ->
|
||||||
|
item {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(bottom = 8.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = date,
|
||||||
|
style = MaterialTheme.typography.titleMedium
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = NumberFormat.getCurrencyInstance(Locale.CHINA)
|
||||||
|
.format(dayRecords.sumOf { it.amount }),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
dayRecords.forEach { record ->
|
||||||
|
RecordItem(record = record)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RecordItem(record: Record) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
if (record.description.isNotBlank()) {
|
||||||
|
Text(
|
||||||
|
text = record.description,
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = SimpleDateFormat("HH:mm", Locale.getDefault())
|
||||||
|
.format(record.dateTime),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = NumberFormat.getCurrencyInstance(Locale.CHINA)
|
||||||
|
.format(record.amount),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,104 @@
|
|||||||
|
package com.yovinchen.bookkeeping.viewmodel
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.lifecycle.AndroidViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.yovinchen.bookkeeping.data.BookkeepingDatabase
|
||||||
|
import com.yovinchen.bookkeeping.model.AnalysisType
|
||||||
|
import com.yovinchen.bookkeeping.model.CategoryStat
|
||||||
|
import com.yovinchen.bookkeeping.model.TransactionType
|
||||||
|
import kotlinx.coroutines.flow.*
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.YearMonth
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.util.Date
|
||||||
|
|
||||||
|
class AnalysisViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
|
private val recordDao = BookkeepingDatabase.getDatabase(application).bookkeepingDao()
|
||||||
|
private val memberDao = BookkeepingDatabase.getDatabase(application).memberDao()
|
||||||
|
|
||||||
|
private val _selectedMonth = MutableStateFlow(YearMonth.now())
|
||||||
|
val selectedMonth = _selectedMonth.asStateFlow()
|
||||||
|
|
||||||
|
private val _selectedAnalysisType = MutableStateFlow(AnalysisType.EXPENSE)
|
||||||
|
val selectedAnalysisType = _selectedAnalysisType.asStateFlow()
|
||||||
|
|
||||||
|
private val members = memberDao.getAllMembers()
|
||||||
|
|
||||||
|
val memberStats = combine(selectedMonth, selectedAnalysisType, members) { month, type, membersList ->
|
||||||
|
val records = recordDao.getAllRecords().first()
|
||||||
|
val monthRecords = records.filter {
|
||||||
|
val recordDate = Date(it.date.time)
|
||||||
|
val localDateTime = LocalDateTime.ofInstant(recordDate.toInstant(), ZoneId.systemDefault())
|
||||||
|
YearMonth.from(localDateTime) == month && it.type == when(type) {
|
||||||
|
AnalysisType.EXPENSE -> TransactionType.EXPENSE
|
||||||
|
AnalysisType.INCOME -> TransactionType.INCOME
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按成员统计
|
||||||
|
val memberMap = monthRecords.groupBy { record ->
|
||||||
|
membersList.find { it.id == record.memberId }?.name ?: "未分配"
|
||||||
|
}
|
||||||
|
|
||||||
|
val stats = memberMap.map { (memberName, records) ->
|
||||||
|
CategoryStat(
|
||||||
|
category = memberName,
|
||||||
|
amount = records.sumOf { it.amount },
|
||||||
|
count = records.size
|
||||||
|
)
|
||||||
|
}.sortedByDescending { it.amount }
|
||||||
|
|
||||||
|
// 计算总额
|
||||||
|
val total = stats.sumOf { it.amount }
|
||||||
|
|
||||||
|
// 计算百分比
|
||||||
|
stats.map { it.copy(percentage = if (total > 0) it.amount / total * 100 else 0.0) }
|
||||||
|
}.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5000),
|
||||||
|
initialValue = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
val categoryStats = combine(selectedMonth, selectedAnalysisType) { month, type ->
|
||||||
|
val records = recordDao.getAllRecords().first()
|
||||||
|
val monthRecords = records.filter {
|
||||||
|
val recordDate = Date(it.date.time)
|
||||||
|
val localDateTime = LocalDateTime.ofInstant(recordDate.toInstant(), ZoneId.systemDefault())
|
||||||
|
YearMonth.from(localDateTime) == month && it.type == when(type) {
|
||||||
|
AnalysisType.EXPENSE -> TransactionType.EXPENSE
|
||||||
|
AnalysisType.INCOME -> TransactionType.INCOME
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按分类统计
|
||||||
|
val categoryMap = monthRecords.groupBy { it.category }
|
||||||
|
val stats = categoryMap.map { (category, records) ->
|
||||||
|
CategoryStat(
|
||||||
|
category = category,
|
||||||
|
amount = records.sumOf { it.amount },
|
||||||
|
count = records.size
|
||||||
|
)
|
||||||
|
}.sortedByDescending { it.amount }
|
||||||
|
|
||||||
|
// 计算总额
|
||||||
|
val total = stats.sumOf { it.amount }
|
||||||
|
|
||||||
|
// 计算百分比
|
||||||
|
stats.map { it.copy(percentage = if (total > 0) it.amount / total * 100 else 0.0) }
|
||||||
|
}.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5000),
|
||||||
|
initialValue = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
fun setSelectedMonth(month: YearMonth) {
|
||||||
|
_selectedMonth.value = month
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setAnalysisType(type: AnalysisType) {
|
||||||
|
_selectedAnalysisType.value = type
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,54 @@
|
|||||||
|
package com.yovinchen.bookkeeping.viewmodel
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.yovinchen.bookkeeping.data.BookkeepingDatabase
|
||||||
|
import com.yovinchen.bookkeeping.model.BookkeepingRecord
|
||||||
|
import com.yovinchen.bookkeeping.model.CategoryStat
|
||||||
|
import kotlinx.coroutines.flow.*
|
||||||
|
import java.time.YearMonth
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
|
class CategoryDetailViewModel(
|
||||||
|
private val database: BookkeepingDatabase,
|
||||||
|
private val category: String,
|
||||||
|
private val month: YearMonth
|
||||||
|
) : ViewModel() {
|
||||||
|
private val recordDao = database.bookkeepingDao()
|
||||||
|
private val yearMonthStr = month.format(DateTimeFormatter.ofPattern("yyyy-MM"))
|
||||||
|
|
||||||
|
private val _records = MutableStateFlow<List<BookkeepingRecord>>(emptyList())
|
||||||
|
val records: StateFlow<List<BookkeepingRecord>> = _records.asStateFlow()
|
||||||
|
|
||||||
|
private val _memberStats = MutableStateFlow<List<CategoryStat>>(emptyList())
|
||||||
|
val memberStats: StateFlow<List<CategoryStat>> = _memberStats.asStateFlow()
|
||||||
|
|
||||||
|
val total: StateFlow<Double> = records
|
||||||
|
.map { records -> records.sumOf { it.amount } }
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5000),
|
||||||
|
initialValue = 0.0
|
||||||
|
)
|
||||||
|
|
||||||
|
init {
|
||||||
|
recordDao.getRecordsByCategory(category)
|
||||||
|
.onEach { records ->
|
||||||
|
_records.value = records.filter { record ->
|
||||||
|
val recordMonth = YearMonth.from(
|
||||||
|
DateTimeFormatter.ofPattern("yyyy-MM")
|
||||||
|
.parse(yearMonthStr)
|
||||||
|
)
|
||||||
|
YearMonth.from(record.date.toInstant().atZone(java.time.ZoneId.systemDefault()).toLocalDateTime()) == recordMonth
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.launchIn(viewModelScope)
|
||||||
|
|
||||||
|
recordDao.getMemberStatsByCategory(category, yearMonthStr)
|
||||||
|
.onEach { stats ->
|
||||||
|
_memberStats.value = stats
|
||||||
|
}
|
||||||
|
.launchIn(viewModelScope)
|
||||||
|
}
|
||||||
|
}
|
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,65 @@
|
|||||||
|
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.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.time.YearMonth
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.util.Date
|
||||||
|
|
||||||
|
class MemberDetailViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
|
private val database = BookkeepingDatabase.getDatabase(application)
|
||||||
|
private val recordDao = database.bookkeepingDao()
|
||||||
|
|
||||||
|
private val _memberRecords = MutableStateFlow<List<BookkeepingRecord>>(emptyList())
|
||||||
|
val memberRecords: StateFlow<List<BookkeepingRecord>> = _memberRecords
|
||||||
|
|
||||||
|
private val _totalAmount = MutableStateFlow(0.0)
|
||||||
|
val totalAmount: StateFlow<Double> = _totalAmount
|
||||||
|
|
||||||
|
fun loadMemberRecords(memberName: String, category: String, yearMonth: YearMonth, analysisType: AnalysisType) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val startDate = yearMonth.atDay(1).atStartOfDay()
|
||||||
|
.atZone(ZoneId.systemDefault())
|
||||||
|
.toInstant()
|
||||||
|
.let { Date.from(it) }
|
||||||
|
|
||||||
|
val endDate = yearMonth.atEndOfMonth().atTime(23, 59, 59)
|
||||||
|
.atZone(ZoneId.systemDefault())
|
||||||
|
.toInstant()
|
||||||
|
.let { Date.from(it) }
|
||||||
|
|
||||||
|
val transactionType = when (analysisType) {
|
||||||
|
AnalysisType.INCOME -> TransactionType.INCOME
|
||||||
|
AnalysisType.EXPENSE -> TransactionType.EXPENSE
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
val records = if (category.isEmpty()) {
|
||||||
|
recordDao.getRecordsByMember(
|
||||||
|
memberName = memberName,
|
||||||
|
startDate = startDate,
|
||||||
|
endDate = endDate,
|
||||||
|
transactionType = transactionType
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
recordDao.getRecordsByMemberAndCategory(
|
||||||
|
memberName = memberName,
|
||||||
|
category = category,
|
||||||
|
startDate = startDate,
|
||||||
|
endDate = endDate,
|
||||||
|
transactionType = transactionType
|
||||||
|
)
|
||||||
|
}
|
||||||
|
_memberRecords.value = records
|
||||||
|
_totalAmount.value = records.sumOf { it.amount }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -20,4 +20,6 @@ kotlin.code.style=official
|
|||||||
# Enables namespacing of each library's R class so that its R class includes only the
|
# 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,
|
# resources declared in the library itself and none from the library's dependencies,
|
||||||
# thereby reducing the size of the R class for that library
|
# 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"
|
roomCommon = "2.6.1"
|
||||||
navigationCommonKtx = "2.8.4"
|
navigationCommonKtx = "2.8.4"
|
||||||
navigationCompose = "2.8.4"
|
navigationCompose = "2.8.4"
|
||||||
|
visionInternalVkp = "18.2.3"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
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-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-common-ktx = { group = "androidx.navigation", name = "navigation-common-ktx", version.ref = "navigationCommonKtx" }
|
||||||
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
|
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]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
@@ -9,6 +9,7 @@ pluginManagement {
|
|||||||
}
|
}
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
gradlePluginPortal()
|
gradlePluginPortal()
|
||||||
|
maven { url = uri("https://jitpack.io") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
dependencyResolutionManagement {
|
dependencyResolutionManagement {
|
||||||
@@ -16,6 +17,7 @@ dependencyResolutionManagement {
|
|||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
|
maven { url = uri("https://jitpack.io") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user