Compare commits
56 Commits
release/1.
...
v1.2.4
Author | SHA1 | Date | |
---|---|---|---|
6795b59431 | |||
0ca5e9d39e | |||
eea4d2441c | |||
e577744ed9 | |||
c8ebe27082 | |||
5cb620b875 | |||
02375747fc | |||
119ca539a6 | |||
d815960e40 | |||
80ebddfc13 | |||
f717f0ad36 | |||
37ecb77a28 | |||
f6e3acd646 | |||
b00e01dffb | |||
c7603c0f69 | |||
3296f6d154 | |||
c92cc18dde | |||
96d5fab40c | |||
abf529117f | |||
713037b266 | |||
a0d47864d8 | |||
63149f9abb | |||
70e79ec584 | |||
882435e25a | |||
37b91ded7f | |||
94fc7b2a7e | |||
380fdd5589 | |||
76d0286883 | |||
f134304646 | |||
8339d3d5da | |||
c3f108ab57 | |||
9772fd6e59 | |||
0a738fc7e1 | |||
6c3b366d45 | |||
3c080fbc05 | |||
e7e630921d | |||
025b0aade0 | |||
71deaaa288 | |||
47e202fa61 | |||
af880c23eb | |||
1ab75f4701 | |||
773c155d0c | |||
3ad8cf9184 | |||
1147bc47d7 | |||
30e9345d81 | |||
c75439d15a | |||
95b3233d5e | |||
df80dadfea | |||
e03149377c | |||
49e83cea90 | |||
6d9c5a27f7 | |||
d0bd40421a | |||
ea1dafd0d2 | |||
f59fda3de7 | |||
202e1f7fd7 | |||
f74471e162 |
8
.idea/deploymentTargetSelector.xml
generated
@@ -4,6 +4,14 @@
|
||||
<selectionStates>
|
||||
<SelectionState runConfigName="app">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
<DropdownSelection timestamp="2024-11-27T02:15:16.043756Z">
|
||||
<Target type="DEFAULT_BOOT">
|
||||
<handle>
|
||||
<DeviceId pluginId="LocalEmulator" identifier="path=/Users/yovinchen/.android/avd/Pixel_7a_API_34.avd" />
|
||||
</handle>
|
||||
</Target>
|
||||
</DropdownSelection>
|
||||
<DialogSelection />
|
||||
</SelectionState>
|
||||
</selectionStates>
|
||||
</component>
|
||||
|
1
.idea/gradle.xml
generated
@@ -1,5 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="GradleMigrationSettings" migrationVersion="1" />
|
||||
<component name="GradleSettings">
|
||||
<option name="linkedExternalProjectsSettings">
|
||||
<GradleProjectSettings>
|
||||
|
5
.idea/misc.xml
generated
@@ -1,5 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<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="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
|
||||
<output url="file://$PROJECT_DIR$/build/classes" />
|
||||
|
6
.idea/studiobot.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="StudioBotProjectSettings">
|
||||
<option name="shareContext" value="OptedIn" />
|
||||
</component>
|
||||
</project>
|
179
README.md
@@ -1,71 +1,174 @@
|
||||
# Bookkeeping App
|
||||
# 轻记账 (Lightweight Bookkeeping)
|
||||
|
||||
一个基于 Jetpack Compose 开发的现代化记账应用。
|
||||
一个轻量级的个人记账应用,专注于隐私和离线使用。
|
||||
|
||||
## 项目概述
|
||||
## 📖 项目概述
|
||||
|
||||
本项目是一个使用 Kotlin 和 Jetpack Compose 开发的 Android 记账应用,采用 MVVM 架构,提供简洁直观的用户界面和丰富的记账功能。
|
||||
|
||||
## 主要特性
|
||||
## ⭐️ 主要特性
|
||||
|
||||
- 💰 收入/支出记录管理
|
||||
- 📊 分类管理系统
|
||||
- 📅 自定义日期选择器
|
||||
- 📈 月度统计视图
|
||||
- 🎨 Material 3 设计风格
|
||||
- 🔒 完全离线运行,无需网络连接
|
||||
- 📱 极简权限要求,仅使用必要的系统权限
|
||||
- 💰 支持收入和支出记录
|
||||
- 👥 支持多人记账
|
||||
- 📊 按日期和类别统计
|
||||
|
||||
## 技术栈
|
||||
## 🛠 技术栈
|
||||
|
||||
- 开发语言:Kotlin
|
||||
- UI 框架:Jetpack Compose
|
||||
- 架构模式:MVVM
|
||||
- 数据存储:Room Database
|
||||
- 依赖注入:Hilt
|
||||
- 异步处理:Kotlin Coroutines
|
||||
- 💻 开发语言:Kotlin
|
||||
- 🎨 UI 框架:Jetpack Compose
|
||||
- 🏗️ 架构模式:MVVM
|
||||
- 💾 数据存储:Room Database
|
||||
- 💉 依赖注入:Hilt
|
||||
- ⚡️ 异步处理:Kotlin Coroutines
|
||||
|
||||
## 开发计划
|
||||
## 🗺 开发路线图
|
||||
|
||||
### 1. 数据统计与可视化 (feature/statistics)
|
||||
- [ ] 支出/收入趋势图表
|
||||
- [ ] 分类占比饼图
|
||||
### 0. 基础记账 (已完成 ✨)
|
||||
- [x] 收入/支出记录管理
|
||||
- [x] 分类管理系统
|
||||
- [x] 自定义日期选择器
|
||||
- [x] Material 3 设计界面
|
||||
- [x] 深色/浅色主题切换
|
||||
- [x] 主题色自定义
|
||||
|
||||
### 1. 成员系统 (已完成 🎉)
|
||||
- [x] 成员添加/编辑/删除
|
||||
- [x] 记账时选择相关成员
|
||||
- [x] 主页账单修改相关成员
|
||||
- [x] 成员消费统计
|
||||
|
||||
### 2. 图表分析 (已完成 🎉)
|
||||
- [x] 支出/收入趋势图表
|
||||
- [x] 分类占比饼图
|
||||
- [ ] 月度/年度报表
|
||||
- [x] 成员消费分析
|
||||
- [x] 自定义统计周期
|
||||
|
||||
### 2. 数据导出与备份 (feature/backup)
|
||||
### 3. 数据管理 (进行中 🚀)
|
||||
- [ ] 导出 CSV/Excel 功能
|
||||
- [ ] 云端备份支持
|
||||
- [ ] 数据迁移工具
|
||||
- [ ] 定期自动备份
|
||||
- [ ] 备份加密功能
|
||||
|
||||
### 3. 预算管理 (feature/budget)
|
||||
### 4. 预算管理 (计划中 💡)
|
||||
- [ ] 月度预算设置
|
||||
- [ ] 预算超支提醒
|
||||
- [ ] 分类预算管理
|
||||
- [ ] 成员预算管理
|
||||
- [ ] 预算分析报告
|
||||
|
||||
### 4. 用户体验优化 (feature/ux-enhancement)
|
||||
- [ ] 深色模式支持
|
||||
### 5. 体验优化 (持续进行 🔄)
|
||||
- [x] 深色模式支持
|
||||
- [ ] 手势操作优化
|
||||
- [ ] 快速记账小组件
|
||||
- [ ] 多语言支持
|
||||
- [ ] 自定义主题
|
||||
|
||||
### 5. 性能优化 (feature/performance)
|
||||
### 6. 性能提升 (持续进行 ⚡️)
|
||||
- [ ] 大数据量处理优化
|
||||
- [ ] 启动速度优化
|
||||
- [ ] 内存使用优化
|
||||
- [ ] 缓存策略优化
|
||||
- [ ] 数据库查询优化
|
||||
|
||||
## 分支管理
|
||||
## 🌲 分支管理
|
||||
|
||||
- `master`: 主分支,保持稳定可用
|
||||
- `develop`: 开发分支,新功能开发的基础分支
|
||||
- `feature/*`: 具体功能开发分支
|
||||
- `release/*`: 发布准备分支
|
||||
- `master`: 稳定主分支
|
||||
- `develop`: 主开发分支
|
||||
- `feature/*`: 功能开发分支
|
||||
- `release/*`: 版本发布分支
|
||||
- `hotfix/*`: 紧急修复分支
|
||||
|
||||
## 如何贡献
|
||||
## 🔄 提交规范
|
||||
|
||||
1. Fork 本仓库
|
||||
2. 创建你的功能分支 (`git checkout -b feature/AmazingFeature`)
|
||||
3. 提交你的改动 (`git commit -m 'Add some AmazingFeature'`)
|
||||
提交信息应遵循以下格式:`<type>: <description>`
|
||||
|
||||
### 提交类型(Type)
|
||||
|
||||
- `feat`: 新功能(feature)
|
||||
- `fix`: 修复bug
|
||||
- `docs`: 文档更新(documentation)
|
||||
- `style`: 代码格式(不影响代码运行的变动)
|
||||
- `refactor`: 代码重构(既不是新增功能,也不是修复bug)
|
||||
- `perf`: 性能优化
|
||||
- `test`: 测试相关
|
||||
- `build`: 构建相关
|
||||
- `ci`: 持续集成
|
||||
- `chore`: 构建过程或辅助工具的变动
|
||||
- `revert`: 回退提交
|
||||
- `improvement`: 改进
|
||||
|
||||
## 📝 版本历史
|
||||
|
||||
### v1.2.0 - v1.2.4
|
||||
- 分类数据可视化
|
||||
- 支出/收入分类饼图展示
|
||||
- 分类占比详细统计
|
||||
- 分类数据交互和筛选
|
||||
- 成员数据可视化
|
||||
- 成员消费饼图展示
|
||||
- 成员支出占比统计
|
||||
- 成员数据交互和筛选
|
||||
- 趋势分析
|
||||
- 日收支趋势折线图
|
||||
- 收入支出双线对比
|
||||
- 支持深色/浅色主题
|
||||
- 图表交互和缩放
|
||||
- 数据筛选
|
||||
- 支持按日期范围筛选
|
||||
- 支持按收入/支出类型筛选
|
||||
- 支持按成员/分类筛选
|
||||
|
||||
### v1.1.0
|
||||
- 成员管理功能
|
||||
- 成员添加/编辑/删除
|
||||
- 记账时选择相关成员
|
||||
- 成员消费统计
|
||||
- UI/UX 优化
|
||||
- 记录展示优化
|
||||
- 月度统计界面
|
||||
- 分组展示优化
|
||||
- 数据管理
|
||||
- 记录筛选增强
|
||||
- 数据库性能优化
|
||||
- 状态管理重构
|
||||
|
||||
### v1.0.0
|
||||
- 基础记账功能
|
||||
- 收入/支出记录
|
||||
- 金额、日期、分类、备注管理
|
||||
- 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
|
||||
5. 提交 Pull Request
|
||||
|
||||
## 许可证
|
||||
## 📄 许可证
|
||||
|
||||
本项目采用 GNU GPLv3 许可证 - 详见 [LICENSE](LICENSE) 文件
|
||||
本项目采用 MIT 许可证 - 查看 [LICENSE](LICENSE) 文件了解详细信息
|
||||
|
||||
## 📮 联系方式
|
||||
|
||||
- 作者:YovinChen
|
||||
- 邮箱:gzh298255@gmail.com
|
||||
- 博客:[blog.hhdxw.top](https://blog.hhdxw.top)
|
||||
|
||||
## 🙏 致谢
|
||||
|
||||
感谢所有为这个项目做出贡献的开发者!
|
||||
|
@@ -4,6 +4,10 @@ plugins {
|
||||
id("com.google.devtools.ksp")
|
||||
}
|
||||
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import com.android.build.api.variant.FilterConfiguration
|
||||
|
||||
android {
|
||||
namespace = "com.yovinchen.bookkeeping"
|
||||
compileSdk = 34
|
||||
@@ -12,8 +16,8 @@ android {
|
||||
applicationId = "com.yovinchen.bookkeeping"
|
||||
minSdk = 26
|
||||
targetSdk = 34
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
versionCode = 5
|
||||
versionName = "1.2.3"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
@@ -23,19 +27,37 @@ android {
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
signingConfig = signingConfigs.getByName("debug") // 使用debug签名,实际发布时应该使用正式的签名配置
|
||||
}
|
||||
}
|
||||
|
||||
applicationVariants.all {
|
||||
val variant = this
|
||||
variant.outputs
|
||||
.map { it as com.android.build.gradle.internal.api.BaseVariantOutputImpl }
|
||||
.forEach { output ->
|
||||
// 获取当前时间
|
||||
val date = SimpleDateFormat("yyyyMMdd_HHmm").format(Date())
|
||||
// 获取CPU架构,如果没有则使用universal
|
||||
val buildType = variant.buildType.name
|
||||
// 构建文件名
|
||||
val outputFileName = "轻记账_${buildType}_v${variant.versionName}_${date}.apk"
|
||||
output.outputFileName = outputFileName
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
jvmTarget = "11"
|
||||
}
|
||||
buildFeatures {
|
||||
compose = true
|
||||
@@ -67,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"
|
||||
@@ -74,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")
|
||||
|
@@ -15,7 +15,6 @@
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/Theme.Bookkeeping">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
@@ -3,6 +3,7 @@ package com.yovinchen.bookkeeping.data
|
||||
import androidx.room.*
|
||||
import com.yovinchen.bookkeeping.model.BookkeepingRecord
|
||||
import com.yovinchen.bookkeeping.model.Category
|
||||
import com.yovinchen.bookkeeping.model.MemberStat
|
||||
import com.yovinchen.bookkeeping.model.TransactionType
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import java.util.Date
|
||||
@@ -12,53 +13,201 @@ interface BookkeepingDao {
|
||||
@Query("SELECT * FROM bookkeeping_records ORDER BY date DESC")
|
||||
fun getAllRecords(): Flow<List<BookkeepingRecord>>
|
||||
|
||||
@Insert
|
||||
suspend fun insertRecord(record: BookkeepingRecord)
|
||||
@Query("SELECT * FROM bookkeeping_records WHERE memberId = :memberId OR memberId IS NULL ORDER BY date DESC")
|
||||
fun getRecordsByMember(memberId: Int): Flow<List<BookkeepingRecord>>
|
||||
|
||||
@Delete
|
||||
suspend fun deleteRecord(record: BookkeepingRecord)
|
||||
@Query("SELECT * FROM bookkeeping_records WHERE date BETWEEN :startDate AND :endDate ORDER BY date DESC")
|
||||
fun getRecordsByDateRange(startDate: Date, endDate: Date): Flow<List<BookkeepingRecord>>
|
||||
|
||||
@Query("SELECT * FROM bookkeeping_records WHERE (memberId = :memberId OR memberId IS NULL) AND date BETWEEN :startDate AND :endDate ORDER BY date DESC")
|
||||
fun getRecordsByMemberAndDateRange(memberId: Int, startDate: Date, endDate: Date): Flow<List<BookkeepingRecord>>
|
||||
|
||||
@Query("SELECT * FROM bookkeeping_records WHERE type = :type ORDER BY date DESC")
|
||||
fun getRecordsByType(type: TransactionType): Flow<List<BookkeepingRecord>>
|
||||
|
||||
@Query("SELECT SUM(amount) FROM bookkeeping_records WHERE type = :type AND (memberId = :memberId OR memberId IS NULL)")
|
||||
fun getTotalAmountByType(type: TransactionType, memberId: Int? = null): Flow<Double?>
|
||||
|
||||
@Query("""
|
||||
SELECT * FROM bookkeeping_records
|
||||
WHERE category = :category
|
||||
AND strftime('%Y-%m', datetime(date/1000, 'unixepoch')) = :yearMonth
|
||||
ORDER BY date DESC
|
||||
""")
|
||||
fun getRecordsByCategoryAndMonth(
|
||||
category: String,
|
||||
yearMonth: String
|
||||
): Flow<List<BookkeepingRecord>>
|
||||
|
||||
@Query("""
|
||||
SELECT * FROM bookkeeping_records
|
||||
WHERE memberId IN (SELECT id FROM members WHERE name = :memberName)
|
||||
AND strftime('%Y-%m', datetime(date/1000, 'unixepoch')) = :yearMonth
|
||||
ORDER BY date DESC
|
||||
""")
|
||||
fun getRecordsByMemberAndMonth(
|
||||
memberName: String,
|
||||
yearMonth: String
|
||||
): Flow<List<BookkeepingRecord>>
|
||||
|
||||
@Query("""
|
||||
SELECT
|
||||
m.name as member,
|
||||
SUM(r.amount) as amount,
|
||||
COUNT(*) as count,
|
||||
(SUM(r.amount) * 100.0 / (SELECT SUM(amount) FROM bookkeeping_records WHERE category = :category AND strftime('%Y-%m', datetime(date/1000, 'unixepoch')) = :yearMonth)) as percentage
|
||||
FROM bookkeeping_records r
|
||||
JOIN members m ON r.memberId = m.id
|
||||
WHERE r.category = :category
|
||||
AND strftime('%Y-%m', datetime(r.date/1000, 'unixepoch')) = :yearMonth
|
||||
GROUP BY m.name
|
||||
ORDER BY amount DESC
|
||||
""")
|
||||
fun getMemberStatsByCategory(
|
||||
category: String,
|
||||
yearMonth: String
|
||||
): Flow<List<MemberStat>>
|
||||
|
||||
@Query("""
|
||||
SELECT * FROM bookkeeping_records
|
||||
WHERE category = :category
|
||||
ORDER BY date DESC
|
||||
""")
|
||||
fun getRecordsByCategory(
|
||||
category: String
|
||||
): Flow<List<BookkeepingRecord>>
|
||||
|
||||
@Query("""
|
||||
SELECT * FROM bookkeeping_records
|
||||
WHERE category = :category
|
||||
AND date BETWEEN :startDate AND :endDate
|
||||
ORDER BY date DESC
|
||||
""")
|
||||
fun getRecordsByCategoryAndDateRange(
|
||||
category: String,
|
||||
startDate: Date,
|
||||
endDate: Date
|
||||
): Flow<List<BookkeepingRecord>>
|
||||
|
||||
@Query("""
|
||||
SELECT * FROM bookkeeping_records
|
||||
WHERE memberId IN (SELECT id FROM members WHERE name = :memberName)
|
||||
AND date BETWEEN :startDate AND :endDate
|
||||
AND (:transactionType IS NULL OR type = :transactionType)
|
||||
ORDER BY date DESC
|
||||
""")
|
||||
fun getRecordsByMemberAndDateRange(
|
||||
memberName: String,
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
transactionType: TransactionType?
|
||||
): Flow<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 = :transactionType)
|
||||
ORDER BY date DESC
|
||||
""")
|
||||
fun getRecordsByMemberCategoryAndDateRange(
|
||||
memberName: String,
|
||||
category: String,
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
transactionType: TransactionType?
|
||||
): Flow<List<BookkeepingRecord>>
|
||||
|
||||
@Query("""
|
||||
SELECT
|
||||
m.name as member,
|
||||
SUM(r.amount) as amount,
|
||||
COUNT(*) as count,
|
||||
(SUM(r.amount) * 100.0 / (SELECT SUM(amount) FROM bookkeeping_records WHERE category = :category AND date BETWEEN :startDate AND :endDate)) as percentage
|
||||
FROM bookkeeping_records r
|
||||
JOIN members m ON r.memberId = m.id
|
||||
WHERE r.category = :category
|
||||
AND r.date BETWEEN :startDate AND :endDate
|
||||
GROUP BY m.name
|
||||
ORDER BY amount DESC
|
||||
""")
|
||||
fun getMemberStatsByCategoryAndDateRange(
|
||||
category: String,
|
||||
startDate: Date,
|
||||
endDate: Date
|
||||
): Flow<List<MemberStat>>
|
||||
|
||||
@Insert
|
||||
suspend fun insertRecord(record: BookkeepingRecord): Long
|
||||
|
||||
@Update
|
||||
suspend fun updateRecord(record: BookkeepingRecord)
|
||||
|
||||
@Query("SELECT * FROM bookkeeping_records WHERE type = 'INCOME'")
|
||||
fun getAllIncome(): Flow<List<BookkeepingRecord>>
|
||||
@Delete
|
||||
suspend fun deleteRecord(record: BookkeepingRecord)
|
||||
|
||||
@Query("SELECT * FROM bookkeeping_records WHERE type = 'EXPENSE'")
|
||||
fun getAllExpense(): Flow<List<BookkeepingRecord>>
|
||||
|
||||
// 按日期查询
|
||||
@Query("SELECT * FROM bookkeeping_records WHERE date >= :startOfDay AND date < :endOfDay ORDER BY date DESC")
|
||||
fun getRecordsByDate(startOfDay: Date, endOfDay: Date): Flow<List<BookkeepingRecord>>
|
||||
|
||||
// 按日期范围查询
|
||||
@Query("SELECT * FROM bookkeeping_records WHERE date BETWEEN :startDate AND :endDate ORDER BY date DESC")
|
||||
fun getRecordsByDateRange(startDate: Date, endDate: Date): Flow<List<BookkeepingRecord>>
|
||||
|
||||
// 按类别查询
|
||||
@Query("SELECT * FROM bookkeeping_records WHERE category = :category ORDER BY date DESC")
|
||||
fun getRecordsByCategory(category: String): Flow<List<BookkeepingRecord>>
|
||||
|
||||
// 按类型查询
|
||||
@Query("SELECT * FROM bookkeeping_records WHERE type = :type ORDER BY date DESC")
|
||||
fun getRecordsByType(type: TransactionType): Flow<List<BookkeepingRecord>>
|
||||
|
||||
// Category related queries
|
||||
@Query("SELECT * FROM categories WHERE type = :type ORDER BY name ASC")
|
||||
fun getCategoriesByType(type: TransactionType): Flow<List<Category>>
|
||||
|
||||
@Insert
|
||||
suspend fun insertCategory(category: Category)
|
||||
|
||||
@Delete
|
||||
suspend fun deleteCategory(category: Category)
|
||||
suspend fun insertCategory(category: Category): Long
|
||||
|
||||
@Update
|
||||
suspend fun updateCategory(category: Category)
|
||||
|
||||
@Delete
|
||||
suspend fun deleteCategory(category: Category)
|
||||
|
||||
@Query("SELECT EXISTS(SELECT 1 FROM bookkeeping_records WHERE category = :categoryName LIMIT 1)")
|
||||
suspend fun isCategoryInUse(categoryName: String): Boolean
|
||||
|
||||
@Query("UPDATE bookkeeping_records SET category = :newName WHERE category = :oldName")
|
||||
suspend fun updateRecordCategories(oldName: String, newName: String)
|
||||
|
||||
@Query("""
|
||||
SELECT * FROM bookkeeping_records
|
||||
WHERE memberId IN (SELECT id FROM members WHERE name = :memberName)
|
||||
AND date BETWEEN :startDate AND :endDate
|
||||
AND (
|
||||
:transactionType IS NULL
|
||||
OR type = (
|
||||
CASE :transactionType
|
||||
WHEN 'INCOME' THEN 'INCOME'
|
||||
WHEN 'EXPENSE' THEN 'EXPENSE'
|
||||
END
|
||||
)
|
||||
)
|
||||
ORDER BY date DESC
|
||||
""")
|
||||
suspend fun getRecordsByMember(
|
||||
memberName: String,
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
transactionType: TransactionType?
|
||||
): List<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>
|
||||
}
|
||||
|
@@ -11,159 +11,164 @@ import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import com.yovinchen.bookkeeping.model.BookkeepingRecord
|
||||
import com.yovinchen.bookkeeping.model.Category
|
||||
import com.yovinchen.bookkeeping.model.Converters
|
||||
import com.yovinchen.bookkeeping.model.Member
|
||||
import com.yovinchen.bookkeeping.model.TransactionType
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Database(entities = [BookkeepingRecord::class, Category::class], version = 2, exportSchema = false)
|
||||
@Database(
|
||||
entities = [BookkeepingRecord::class, Category::class, Member::class],
|
||||
version = 3,
|
||||
exportSchema = false
|
||||
)
|
||||
@TypeConverters(Converters::class)
|
||||
abstract class BookkeepingDatabase : RoomDatabase() {
|
||||
abstract fun bookkeepingDao(): BookkeepingDao
|
||||
abstract fun categoryDao(): CategoryDao
|
||||
abstract fun memberDao(): MemberDao
|
||||
|
||||
companion object {
|
||||
private const val TAG = "BookkeepingDatabase"
|
||||
|
||||
@Volatile
|
||||
private var Instance: BookkeepingDatabase? = null
|
||||
|
||||
private val MIGRATION_1_2 = object : Migration(1, 2) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
try {
|
||||
Log.d(TAG, "Starting migration from version 1 to 2")
|
||||
|
||||
// 检查表是否存在
|
||||
val cursor = db.query("SELECT name FROM sqlite_master WHERE type='table' AND name='categories'")
|
||||
val tableExists = cursor.moveToFirst()
|
||||
cursor.close()
|
||||
|
||||
if (tableExists) {
|
||||
// 如果表存在,执行迁移
|
||||
Log.d(TAG, "Categories table exists, performing migration")
|
||||
db.execSQL("ALTER TABLE categories RENAME TO categories_old")
|
||||
|
||||
db.execSQL("""
|
||||
CREATE TABLE categories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
|
||||
db.execSQL("""
|
||||
INSERT INTO categories (name, type)
|
||||
SELECT name, type FROM categories_old
|
||||
""")
|
||||
|
||||
db.execSQL("DROP TABLE categories_old")
|
||||
} else {
|
||||
// 如果表不存在,直接创建新表
|
||||
Log.d(TAG, "Categories table does not exist, creating new table")
|
||||
db.execSQL("""
|
||||
CREATE TABLE IF NOT EXISTS categories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
}
|
||||
|
||||
// 确保 bookkeeping_records 表存在
|
||||
db.execSQL("""
|
||||
CREATE TABLE IF NOT EXISTS bookkeeping_records (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
amount REAL NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
date INTEGER NOT NULL
|
||||
)
|
||||
""")
|
||||
|
||||
Log.d(TAG, "Migration completed successfully")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error during migration", e)
|
||||
throw e
|
||||
}
|
||||
// 创建成员表
|
||||
db.execSQL("""
|
||||
CREATE TABLE IF NOT EXISTS members (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT ''
|
||||
)
|
||||
""")
|
||||
|
||||
// 插入默认成员
|
||||
db.execSQL("""
|
||||
INSERT INTO members (name, description)
|
||||
VALUES ('自己', '默认成员')
|
||||
""")
|
||||
|
||||
// 修改记账记录表,添加成员ID字段
|
||||
db.execSQL("""
|
||||
ALTER TABLE bookkeeping_records
|
||||
ADD COLUMN memberId INTEGER DEFAULT NULL
|
||||
REFERENCES members(id) ON DELETE SET NULL
|
||||
""")
|
||||
|
||||
// 更新现有记录,将其关联到默认成员
|
||||
db.execSQL("""
|
||||
UPDATE bookkeeping_records
|
||||
SET memberId = (SELECT id FROM members WHERE name = '我自己')
|
||||
""")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun populateDefaultCategories(dao: BookkeepingDao) {
|
||||
try {
|
||||
Log.d(TAG, "Starting to populate default categories")
|
||||
// 支出类别
|
||||
listOf(
|
||||
"餐饮",
|
||||
"交通",
|
||||
"购物",
|
||||
"娱乐",
|
||||
"医疗",
|
||||
"住房",
|
||||
"其他支出"
|
||||
).forEach { name ->
|
||||
try {
|
||||
dao.insertCategory(Category(name = name, type = TransactionType.EXPENSE))
|
||||
Log.d(TAG, "Added expense category: $name")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error adding expense category: $name", e)
|
||||
}
|
||||
}
|
||||
|
||||
// 收入类别
|
||||
listOf(
|
||||
"工资",
|
||||
"奖金",
|
||||
"投资",
|
||||
"其他收入"
|
||||
).forEach { name ->
|
||||
try {
|
||||
dao.insertCategory(Category(name = name, type = TransactionType.INCOME))
|
||||
Log.d(TAG, "Added income category: $name")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error adding income category: $name", e)
|
||||
}
|
||||
}
|
||||
Log.d(TAG, "Finished populating default categories")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error during category population", e)
|
||||
private val MIGRATION_2_3 = object : Migration(2, 3) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
// 重新创建记账记录表
|
||||
db.execSQL("""
|
||||
CREATE TABLE IF NOT EXISTS bookkeeping_records_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
amount REAL NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
date INTEGER NOT NULL,
|
||||
memberId INTEGER,
|
||||
FOREIGN KEY(memberId) REFERENCES members(id) ON DELETE SET NULL
|
||||
)
|
||||
""")
|
||||
|
||||
// 复制数据
|
||||
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
|
||||
""")
|
||||
|
||||
// 删除旧表
|
||||
db.execSQL("DROP TABLE bookkeeping_records")
|
||||
|
||||
// 重命名新表
|
||||
db.execSQL("ALTER TABLE bookkeeping_records_new RENAME TO bookkeeping_records")
|
||||
|
||||
// 重新创建分类表
|
||||
db.execSQL("""
|
||||
CREATE TABLE IF NOT EXISTS categories_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
|
||||
// 复制分类数据
|
||||
db.execSQL("""
|
||||
INSERT INTO categories_new (id, name, type)
|
||||
SELECT id, name, type FROM categories
|
||||
""")
|
||||
|
||||
// 删除旧表
|
||||
db.execSQL("DROP TABLE categories")
|
||||
|
||||
// 重命名新表
|
||||
db.execSQL("ALTER TABLE categories_new RENAME TO categories")
|
||||
}
|
||||
}
|
||||
|
||||
@Volatile
|
||||
private var INSTANCE: BookkeepingDatabase? = null
|
||||
|
||||
fun getDatabase(context: Context): BookkeepingDatabase {
|
||||
return Instance ?: synchronized(this) {
|
||||
try {
|
||||
Log.d(TAG, "Creating new database instance")
|
||||
val instance = Room.databaseBuilder(
|
||||
context.applicationContext,
|
||||
BookkeepingDatabase::class.java,
|
||||
"bookkeeping_database"
|
||||
)
|
||||
.addCallback(object : RoomDatabase.Callback() {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
val instance = Room.databaseBuilder(
|
||||
context.applicationContext,
|
||||
BookkeepingDatabase::class.java,
|
||||
"bookkeeping_database"
|
||||
)
|
||||
.addMigrations(MIGRATION_1_2, MIGRATION_2_3)
|
||||
.addCallback(object : Callback() {
|
||||
override fun onCreate(db: SupportSQLiteDatabase) {
|
||||
super.onCreate(db)
|
||||
Log.d(TAG, "Database created, initializing default categories")
|
||||
Log.d(TAG, "Database created, initializing default data")
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
Instance?.let { database ->
|
||||
populateDefaultCategories(database.bookkeepingDao())
|
||||
val database = getDatabase(context)
|
||||
|
||||
// 初始化默认成员
|
||||
database.memberDao().apply {
|
||||
if (getMemberCount() == 0) {
|
||||
insertMember(Member(name = "自己", description = "默认成员"))
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化默认分类
|
||||
database.categoryDao().apply {
|
||||
// 支出分类
|
||||
insertCategory(Category(name = "餐饮", type = TransactionType.EXPENSE))
|
||||
insertCategory(Category(name = "交通", type = TransactionType.EXPENSE))
|
||||
insertCategory(Category(name = "购物", type = TransactionType.EXPENSE))
|
||||
insertCategory(Category(name = "娱乐", type = TransactionType.EXPENSE))
|
||||
insertCategory(Category(name = "居住", type = TransactionType.EXPENSE))
|
||||
insertCategory(Category(name = "医疗", type = TransactionType.EXPENSE))
|
||||
insertCategory(Category(name = "教育", type = TransactionType.EXPENSE))
|
||||
insertCategory(Category(name = "其他支出", type = TransactionType.EXPENSE))
|
||||
|
||||
// 收入分类
|
||||
insertCategory(Category(name = "工资", type = TransactionType.INCOME))
|
||||
insertCategory(Category(name = "奖金", type = TransactionType.INCOME))
|
||||
insertCategory(Category(name = "投资", type = TransactionType.INCOME))
|
||||
insertCategory(Category(name = "其他收入", type = TransactionType.INCOME))
|
||||
}
|
||||
|
||||
Log.d(TAG, "Default data initialized successfully")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error in onCreate callback", e)
|
||||
Log.e(TAG, "Error initializing default data", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.addMigrations(MIGRATION_1_2)
|
||||
.fallbackToDestructiveMigration() // 如果迁移失败,允许重建数据库
|
||||
.build()
|
||||
|
||||
Instance = instance
|
||||
Log.d(TAG, "Database instance created successfully")
|
||||
instance
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error creating database", e)
|
||||
throw e
|
||||
}
|
||||
INSTANCE = instance
|
||||
instance
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -0,0 +1,30 @@
|
||||
package com.yovinchen.bookkeeping.data
|
||||
|
||||
import androidx.room.*
|
||||
import com.yovinchen.bookkeeping.model.Category
|
||||
import com.yovinchen.bookkeeping.model.TransactionType
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface CategoryDao {
|
||||
@Query("SELECT * FROM categories WHERE type = :type ORDER BY name ASC")
|
||||
fun getCategoriesByType(type: TransactionType): Flow<List<Category>>
|
||||
|
||||
@Query("SELECT * FROM categories ORDER BY type ASC, name ASC")
|
||||
fun getAllCategories(): Flow<List<Category>>
|
||||
|
||||
@Insert
|
||||
suspend fun insertCategory(category: Category): Long
|
||||
|
||||
@Update
|
||||
suspend fun updateCategory(category: Category)
|
||||
|
||||
@Delete
|
||||
suspend fun deleteCategory(category: Category)
|
||||
|
||||
@Query("SELECT EXISTS(SELECT 1 FROM bookkeeping_records WHERE category = :categoryName LIMIT 1)")
|
||||
suspend fun isCategoryInUse(categoryName: String): Boolean
|
||||
|
||||
@Query("SELECT COUNT(*) FROM categories WHERE type = :type")
|
||||
suspend fun getCategoryCountByType(type: TransactionType): Int
|
||||
}
|
@@ -3,6 +3,7 @@ package com.yovinchen.bookkeeping.data
|
||||
import androidx.room.TypeConverter
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.*
|
||||
|
||||
class Converters {
|
||||
private val formatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME
|
||||
@@ -18,4 +19,14 @@ class Converters {
|
||||
fun dateToTimestamp(date: LocalDateTime?): String? {
|
||||
return date?.format(formatter)
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun fromDate(value: Date?): String? {
|
||||
return value?.time?.toString()
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun toDate(timestamp: String?): Date? {
|
||||
return timestamp?.let { Date(it.toLong()) }
|
||||
}
|
||||
}
|
||||
|
@@ -0,0 +1,26 @@
|
||||
package com.yovinchen.bookkeeping.data
|
||||
|
||||
import androidx.room.*
|
||||
import com.yovinchen.bookkeeping.model.Member
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface MemberDao {
|
||||
@Query("SELECT * FROM members ORDER BY name ASC")
|
||||
fun getAllMembers(): Flow<List<Member>>
|
||||
|
||||
@Query("SELECT * FROM members WHERE id = :memberId")
|
||||
suspend fun getMemberById(memberId: Int): Member?
|
||||
|
||||
@Insert
|
||||
suspend fun insertMember(member: Member): Long
|
||||
|
||||
@Update
|
||||
suspend fun updateMember(member: Member)
|
||||
|
||||
@Delete
|
||||
suspend fun deleteMember(member: Member)
|
||||
|
||||
@Query("SELECT COUNT(*) FROM members")
|
||||
suspend fun getMemberCount(): Int
|
||||
}
|
@@ -12,5 +12,6 @@ data class Record(
|
||||
val category: String,
|
||||
val description: String,
|
||||
val dateTime: LocalDateTime = LocalDateTime.now(),
|
||||
val isExpense: Boolean = true
|
||||
val isExpense: Boolean = true,
|
||||
val member: String = "Default"
|
||||
)
|
||||
|
@@ -0,0 +1,7 @@
|
||||
package com.yovinchen.bookkeeping.model
|
||||
|
||||
enum class AnalysisType {
|
||||
EXPENSE,
|
||||
INCOME,
|
||||
TREND
|
||||
}
|
@@ -1,9 +1,12 @@
|
||||
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
|
||||
import com.yovinchen.bookkeeping.model.Member
|
||||
import java.util.Date
|
||||
|
||||
enum class TransactionType {
|
||||
@@ -32,7 +35,20 @@ class Converters {
|
||||
}
|
||||
}
|
||||
|
||||
@Entity(tableName = "bookkeeping_records")
|
||||
@Entity(
|
||||
tableName = "bookkeeping_records",
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = Member::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["memberId"],
|
||||
onDelete = ForeignKey.SET_NULL
|
||||
)
|
||||
],
|
||||
indices = [
|
||||
Index(value = ["memberId"])
|
||||
]
|
||||
)
|
||||
@TypeConverters(Converters::class)
|
||||
data class BookkeepingRecord(
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
@@ -41,5 +57,6 @@ data class BookkeepingRecord(
|
||||
val type: TransactionType,
|
||||
val category: String,
|
||||
val description: String,
|
||||
val date: Date
|
||||
val date: Date,
|
||||
val memberId: Int? = null // 可为空,表示未指定成员
|
||||
)
|
||||
|
@@ -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,17 @@
|
||||
package com.yovinchen.bookkeeping.model
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
|
||||
data class MemberStat(
|
||||
@ColumnInfo(name = "member")
|
||||
val member: String,
|
||||
|
||||
@ColumnInfo(name = "amount")
|
||||
val amount: Double,
|
||||
|
||||
@ColumnInfo(name = "count")
|
||||
val count: Int,
|
||||
|
||||
@ColumnInfo(name = "percentage")
|
||||
val percentage: Double = 0.0
|
||||
)
|
@@ -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,76 @@
|
||||
package com.yovinchen.bookkeeping.ui.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.yovinchen.bookkeeping.model.CategoryStat
|
||||
import com.yovinchen.bookkeeping.model.MemberStat
|
||||
import java.text.NumberFormat
|
||||
import java.util.*
|
||||
|
||||
@Composable
|
||||
fun CategoryStatItem(
|
||||
stat: Any,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val name = when (stat) {
|
||||
is CategoryStat -> stat.category
|
||||
is MemberStat -> stat.member
|
||||
else -> return
|
||||
}
|
||||
|
||||
val amount = when (stat) {
|
||||
is CategoryStat -> stat.amount
|
||||
is MemberStat -> stat.amount
|
||||
else -> return
|
||||
}
|
||||
|
||||
val count = when (stat) {
|
||||
is CategoryStat -> stat.count
|
||||
is MemberStat -> stat.count
|
||||
else -> return
|
||||
}
|
||||
|
||||
val percentage = when (stat) {
|
||||
is CategoryStat -> stat.percentage
|
||||
is MemberStat -> stat.percentage
|
||||
else -> return
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||
.clickable(onClick = onClick),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = name,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
Text(
|
||||
text = "${count}笔 · ${String.format("%.1f%%", percentage)}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = NumberFormat.getCurrencyInstance(Locale.CHINA).format(amount),
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,62 @@
|
||||
package com.yovinchen.bookkeeping.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import java.time.YearMonth
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun DateRangePicker(
|
||||
startMonth: YearMonth,
|
||||
endMonth: YearMonth,
|
||||
onStartMonthSelected: (YearMonth) -> Unit,
|
||||
onEndMonthSelected: (YearMonth) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var showStartMonthPicker by remember { mutableStateOf(false) }
|
||||
var showEndMonthPicker by remember { mutableStateOf(false) }
|
||||
val formatter = DateTimeFormatter.ofPattern("yyyy年MM月")
|
||||
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Button(onClick = { showStartMonthPicker = true }) {
|
||||
Text(startMonth.format(formatter))
|
||||
}
|
||||
Text("至")
|
||||
Button(onClick = { showEndMonthPicker = true }) {
|
||||
Text(endMonth.format(formatter))
|
||||
}
|
||||
}
|
||||
|
||||
if (showStartMonthPicker) {
|
||||
MonthYearPicker(
|
||||
selectedMonth = startMonth,
|
||||
onMonthSelected = {
|
||||
onStartMonthSelected(it)
|
||||
showStartMonthPicker = false
|
||||
},
|
||||
onDismiss = { showStartMonthPicker = false }
|
||||
)
|
||||
}
|
||||
|
||||
if (showEndMonthPicker) {
|
||||
MonthYearPicker(
|
||||
selectedMonth = endMonth,
|
||||
onMonthSelected = {
|
||||
onEndMonthSelected(it)
|
||||
showEndMonthPicker = false
|
||||
},
|
||||
onDismiss = { showEndMonthPicker = false }
|
||||
)
|
||||
}
|
||||
}
|
@@ -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("取消")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
@@ -0,0 +1,254 @@
|
||||
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.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import com.yovinchen.bookkeeping.model.TransactionType
|
||||
import java.time.YearMonth
|
||||
|
||||
@Composable
|
||||
fun MonthYearPickerDialog(
|
||||
selectedMonth: YearMonth, onMonthSelected: (YearMonth) -> Unit, onDismiss: () -> Unit
|
||||
) {
|
||||
var currentYearMonth by remember { mutableStateOf(selectedMonth) }
|
||||
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.wrapContentHeight(),
|
||||
shape = MaterialTheme.shapes.extraLarge,
|
||||
tonalElevation = 6.dp
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "选择年月",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier.padding(bottom = 16.dp)
|
||||
)
|
||||
|
||||
// 年份选择
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(onClick = {
|
||||
currentYearMonth = currentYearMonth.minusYears(1)
|
||||
}) {
|
||||
Text("<")
|
||||
}
|
||||
Text(
|
||||
text = "${currentYearMonth.year}年",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
IconButton(onClick = {
|
||||
currentYearMonth = currentYearMonth.plusYears(1)
|
||||
}) {
|
||||
Text(">")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// 月份网格
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(3), modifier = Modifier.height(200.dp)
|
||||
) {
|
||||
items(12) { index ->
|
||||
val month = index + 1
|
||||
val isSelected = month == currentYearMonth.monthValue
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.padding(4.dp)
|
||||
.aspectRatio(1.5f)
|
||||
.clickable {
|
||||
currentYearMonth = YearMonth.of(currentYearMonth.year, month)
|
||||
},
|
||||
shape = MaterialTheme.shapes.small,
|
||||
color = if (isSelected) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.surface
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
Text(
|
||||
text = "${month}月",
|
||||
color = if (isSelected) MaterialTheme.colorScheme.onPrimary
|
||||
else MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 按钮行
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 16.dp),
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("取消")
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Button(onClick = {
|
||||
onMonthSelected(currentYearMonth)
|
||||
onDismiss()
|
||||
}) {
|
||||
Text("确定")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("DefaultLocale")
|
||||
@Composable
|
||||
fun MonthlyStatistics(
|
||||
totalIncome: Double,
|
||||
totalExpense: Double,
|
||||
onIncomeClick: () -> Unit,
|
||||
onExpenseClick: () -> Unit,
|
||||
selectedType: TransactionType?,
|
||||
onClearFilter: () -> Unit,
|
||||
selectedMonth: YearMonth,
|
||||
onPreviousMonth: () -> Unit,
|
||||
onNextMonth: () -> Unit,
|
||||
onMonthSelected: (YearMonth) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var showMonthPicker by remember { mutableStateOf(false) }
|
||||
|
||||
Card(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
// 月份选择器
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(onClick = onPreviousMonth) {
|
||||
Icon(Icons.AutoMirrored.Filled.KeyboardArrowLeft, "上个月")
|
||||
}
|
||||
|
||||
Text(text = "${selectedMonth.year}年${selectedMonth.monthValue}月",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier.clickable { showMonthPicker = true })
|
||||
|
||||
IconButton(onClick = onNextMonth) {
|
||||
Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, "下个月")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
// 支出统计
|
||||
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(
|
||||
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)}",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(16.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(
|
||||
text = "¥${String.format("%.2f", totalIncome - totalExpense)}",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = if (totalIncome >= totalExpense) MaterialTheme.colorScheme.tertiary
|
||||
else MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedType != null) {
|
||||
TextButton(
|
||||
onClick = onClearFilter, modifier = Modifier.align(Alignment.End)
|
||||
) {
|
||||
Text("清除筛选")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showMonthPicker) {
|
||||
MonthYearPickerDialog(selectedMonth = selectedMonth,
|
||||
onMonthSelected = onMonthSelected,
|
||||
onDismiss = { showMonthPicker = false })
|
||||
}
|
||||
}
|
@@ -0,0 +1,101 @@
|
||||
package com.yovinchen.bookkeeping.ui.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.yovinchen.bookkeeping.model.BookkeepingRecord
|
||||
import com.yovinchen.bookkeeping.model.Member
|
||||
import com.yovinchen.bookkeeping.model.TransactionType
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
@Composable
|
||||
fun RecordItem(
|
||||
record: BookkeepingRecord,
|
||||
onClick: () -> Unit = {},
|
||||
onDelete: () -> Unit = {},
|
||||
modifier: Modifier = Modifier,
|
||||
members: List<Member> = emptyList()
|
||||
) {
|
||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||
val timeFormat = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) }
|
||||
val member = members.find { it.id == record.memberId }
|
||||
|
||||
Card(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||
.clickable(onClick = onClick),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
// 第一行:分类
|
||||
Text(
|
||||
text = record.category,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
|
||||
// 第二行:时间 | 成员 | 详情
|
||||
Text(
|
||||
text = buildString {
|
||||
append(timeFormat.format(record.date))
|
||||
append(" | ")
|
||||
append(member?.name ?: "自己")
|
||||
if (record.description.isNotEmpty()) {
|
||||
append(" | ")
|
||||
append(record.description)
|
||||
}
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
// 金额显示
|
||||
Text(
|
||||
text = String.format("%.2f", record.amount),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = if (record.type == TransactionType.EXPENSE)
|
||||
MaterialTheme.colorScheme.error
|
||||
else
|
||||
MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (showDeleteDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showDeleteDialog = false },
|
||||
title = { Text("确认删除") },
|
||||
text = { Text("确定要删除这条记录吗?") },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
onDelete()
|
||||
showDeleteDialog = false
|
||||
}
|
||||
) {
|
||||
Text("删除")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showDeleteDialog = false }) {
|
||||
Text("取消")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
@@ -0,0 +1,173 @@
|
||||
package com.yovinchen.bookkeeping.ui.components
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import com.github.mikephil.charting.charts.LineChart
|
||||
import com.github.mikephil.charting.components.XAxis
|
||||
import com.github.mikephil.charting.data.Entry
|
||||
import com.github.mikephil.charting.data.LineData
|
||||
import com.github.mikephil.charting.data.LineDataSet
|
||||
import com.github.mikephil.charting.formatter.ValueFormatter
|
||||
import com.yovinchen.bookkeeping.model.BookkeepingRecord
|
||||
import com.yovinchen.bookkeeping.model.TransactionType
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
@Composable
|
||||
fun TrendLineChart(
|
||||
records: List<BookkeepingRecord>,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
var textColor = if (isDarkTheme) {
|
||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.87f).toArgb()
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface.toArgb()
|
||||
}
|
||||
|
||||
var gridColor = if (isDarkTheme) {
|
||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f).toArgb()
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f).toArgb()
|
||||
}
|
||||
|
||||
val incomeColor = MaterialTheme.colorScheme.primary.toArgb()
|
||||
val expenseColor = MaterialTheme.colorScheme.error.toArgb()
|
||||
|
||||
AndroidView(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(300.dp),
|
||||
factory = { context ->
|
||||
LineChart(context).apply {
|
||||
description.isEnabled = false
|
||||
|
||||
// 基本设置
|
||||
setDrawGridBackground(false)
|
||||
setDrawBorders(false)
|
||||
|
||||
// X轴设置
|
||||
xAxis.apply {
|
||||
position = XAxis.XAxisPosition.BOTTOM
|
||||
this.textColor = textColor
|
||||
this.gridColor = gridColor
|
||||
setDrawGridLines(true)
|
||||
setDrawAxisLine(true)
|
||||
labelRotationAngle = -45f
|
||||
textSize = 12f
|
||||
yOffset = 10f
|
||||
}
|
||||
|
||||
// Y轴设置
|
||||
axisLeft.apply {
|
||||
this.textColor = textColor
|
||||
this.gridColor = gridColor
|
||||
setDrawGridLines(true)
|
||||
setDrawAxisLine(true)
|
||||
textSize = 12f
|
||||
valueFormatter = object : ValueFormatter() {
|
||||
override fun getFormattedValue(value: Float): String {
|
||||
return String.format("%.0f", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
axisRight.isEnabled = false
|
||||
|
||||
// 图例设置
|
||||
legend.apply {
|
||||
this.textColor = textColor
|
||||
this.textSize = 12f
|
||||
isEnabled = true
|
||||
yOffset = 10f
|
||||
}
|
||||
|
||||
// 交互设置
|
||||
setTouchEnabled(true)
|
||||
isDragEnabled = true
|
||||
setScaleEnabled(true)
|
||||
|
||||
// 边距设置
|
||||
setExtraOffsets(8f, 16f, 8f, 24f)
|
||||
}
|
||||
},
|
||||
update = { chart ->
|
||||
// 按日期分组计算收入和支出
|
||||
val dailyData = records
|
||||
.groupBy { record ->
|
||||
SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(record.date)
|
||||
}
|
||||
.mapValues { (_, dayRecords) ->
|
||||
val income = dayRecords
|
||||
.filter { it.type == TransactionType.INCOME }
|
||||
.sumOf { it.amount }
|
||||
.toFloat()
|
||||
val expense = dayRecords
|
||||
.filter { it.type == TransactionType.EXPENSE }
|
||||
.sumOf { it.amount }
|
||||
.toFloat()
|
||||
Pair(income, expense)
|
||||
}
|
||||
.toList()
|
||||
.sortedBy { it.first }
|
||||
|
||||
// 创建收入数据点
|
||||
val incomeEntries = dailyData.mapIndexed { index, (_, amounts) ->
|
||||
Entry(index.toFloat(), amounts.first)
|
||||
}
|
||||
|
||||
// 创建支出数据点
|
||||
val expenseEntries = dailyData.mapIndexed { index, (_, amounts) ->
|
||||
Entry(index.toFloat(), amounts.second)
|
||||
}
|
||||
|
||||
// 创建收入数据集
|
||||
val incomeDataSet = LineDataSet(incomeEntries, "收入").apply {
|
||||
color = incomeColor
|
||||
lineWidth = 2.5f
|
||||
setDrawCircles(true)
|
||||
circleRadius = 4f
|
||||
setCircleColor(incomeColor)
|
||||
valueTextColor = textColor
|
||||
valueTextSize = 12f
|
||||
setDrawFilled(true)
|
||||
fillColor = incomeColor
|
||||
fillAlpha = if (isDarkTheme) 40 else 50
|
||||
}
|
||||
|
||||
// 创建支出数据集
|
||||
val expenseDataSet = LineDataSet(expenseEntries, "支出").apply {
|
||||
color = expenseColor
|
||||
lineWidth = 2.5f
|
||||
setDrawCircles(true)
|
||||
circleRadius = 4f
|
||||
setCircleColor(expenseColor)
|
||||
valueTextColor = textColor
|
||||
valueTextSize = 12f
|
||||
setDrawFilled(true)
|
||||
fillColor = expenseColor
|
||||
fillAlpha = if (isDarkTheme) 40 else 50
|
||||
}
|
||||
|
||||
// 设置X轴标签
|
||||
chart.xAxis.valueFormatter = object : ValueFormatter() {
|
||||
override fun getFormattedValue(value: Float): String {
|
||||
return try {
|
||||
dailyData[value.toInt()].first.substring(5) // 只显示MM-dd
|
||||
} catch (e: Exception) {
|
||||
""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新图表数据
|
||||
chart.data = LineData(incomeDataSet, expenseDataSet)
|
||||
chart.invalidate()
|
||||
}
|
||||
)
|
||||
}
|
@@ -1,36 +1,57 @@
|
||||
@file:OptIn(ExperimentalMaterial3Api::class)
|
||||
|
||||
package com.yovinchen.bookkeeping.ui.dialog
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import com.yovinchen.bookkeeping.model.Category
|
||||
import com.yovinchen.bookkeeping.model.Member
|
||||
import com.yovinchen.bookkeeping.model.TransactionType
|
||||
import com.yovinchen.bookkeeping.ui.components.DateTimePicker
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
import java.util.Date
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AddRecordDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (TransactionType, Double, String, String) -> Unit,
|
||||
categories: List<Category>,
|
||||
selectedType: TransactionType,
|
||||
onTypeChange: (TransactionType) -> Unit,
|
||||
selectedDateTime: LocalDateTime,
|
||||
onDateTimeSelected: (LocalDateTime) -> Unit
|
||||
members: List<Member>,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (amount: Double, category: String, description: String, date: Date, type: TransactionType, memberId: Int?) -> Unit
|
||||
) {
|
||||
var amount by remember { mutableStateOf("") }
|
||||
var selectedCategory by remember { mutableStateOf<Category?>(null) }
|
||||
var description by remember { mutableStateOf("") }
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
var memberExpanded by remember { mutableStateOf(false) }
|
||||
var description by remember { mutableStateOf("") }
|
||||
var selectedType by remember { mutableStateOf(TransactionType.EXPENSE) }
|
||||
|
||||
// 找到默认成员("自己")
|
||||
val defaultMember = remember(members) {
|
||||
members.find { it.name == "自己" }
|
||||
}
|
||||
var currentSelectedMember by remember(defaultMember) {
|
||||
mutableStateOf(defaultMember)
|
||||
}
|
||||
|
||||
// 设置默认分类为"餐饮"
|
||||
var selectedCategory by remember {
|
||||
mutableStateOf(categories.find { it.type == selectedType && it.name == "餐饮" }?.name ?: categories.firstOrNull { it.type == selectedType }?.name ?: "")
|
||||
}
|
||||
|
||||
var selectedDateTime by remember {
|
||||
mutableStateOf(LocalDateTime.now())
|
||||
}
|
||||
|
||||
// 根据当前选择的类型过滤类别
|
||||
val filteredCategories = categories.filter { it.type == selectedType }
|
||||
// 当类型改变时更新分类
|
||||
LaunchedEffect(selectedType) {
|
||||
selectedCategory = categories.find { it.type == selectedType && it.name == "餐饮" }?.name
|
||||
?: categories.firstOrNull { it.type == selectedType }?.name
|
||||
?: ""
|
||||
}
|
||||
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
Card(
|
||||
@@ -51,74 +72,59 @@ fun AddRecordDialog(
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// 类型选择
|
||||
// 收入/支出选择
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
FilterChip(
|
||||
selected = selectedType == TransactionType.EXPENSE,
|
||||
onClick = {
|
||||
onTypeChange(TransactionType.EXPENSE)
|
||||
selectedCategory = null
|
||||
},
|
||||
onClick = { selectedType = TransactionType.EXPENSE },
|
||||
label = { Text("支出") }
|
||||
)
|
||||
FilterChip(
|
||||
selected = selectedType == TransactionType.INCOME,
|
||||
onClick = {
|
||||
onTypeChange(TransactionType.INCOME)
|
||||
selectedCategory = null
|
||||
},
|
||||
onClick = { selectedType = TransactionType.INCOME },
|
||||
label = { Text("收入") }
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// 日期时间选择
|
||||
DateTimePicker(
|
||||
selectedDateTime = selectedDateTime,
|
||||
onDateTimeSelected = onDateTimeSelected,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// 金额输入
|
||||
OutlinedTextField(
|
||||
value = amount,
|
||||
onValueChange = { amount = it },
|
||||
label = { Text("金额") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// 类别选择
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
onExpandedChange = { expanded = it }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = selectedCategory?.name ?: "",
|
||||
value = selectedCategory,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("类别") },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor()
|
||||
)
|
||||
|
||||
ExposedDropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false }
|
||||
) {
|
||||
filteredCategories.forEach { category ->
|
||||
categories.filter { it.type == selectedType }.forEach { category ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(category.name) },
|
||||
onClick = {
|
||||
selectedCategory = category
|
||||
selectedCategory = category.name
|
||||
expanded = false
|
||||
}
|
||||
)
|
||||
@@ -126,19 +132,59 @@ fun AddRecordDialog(
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// 描述输入
|
||||
OutlinedTextField(
|
||||
value = description,
|
||||
onValueChange = { description = it },
|
||||
label = { Text("描述") },
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = memberExpanded,
|
||||
onExpandedChange = { memberExpanded = it }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = currentSelectedMember?.name ?: "选择成员",
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("成员") },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = memberExpanded) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor()
|
||||
)
|
||||
|
||||
ExposedDropdownMenu(
|
||||
expanded = memberExpanded,
|
||||
onDismissRequest = { memberExpanded = false }
|
||||
) {
|
||||
members.forEach { member ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(member.name) },
|
||||
onClick = {
|
||||
currentSelectedMember = member
|
||||
memberExpanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
DateTimePicker(
|
||||
selectedDateTime = selectedDateTime,
|
||||
onDateTimeSelected = { selectedDateTime = it },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// 按钮
|
||||
OutlinedTextField(
|
||||
value = description,
|
||||
onValueChange = { description = it },
|
||||
label = { Text("备注") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End
|
||||
@@ -149,13 +195,21 @@ fun AddRecordDialog(
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
val amountValue = amount.toDoubleOrNull() ?: 0.0
|
||||
selectedCategory?.let { category ->
|
||||
onConfirm(selectedType, amountValue, category.name, description)
|
||||
onDismiss()
|
||||
val amountValue = amount.toDoubleOrNull()
|
||||
if (amountValue != null) {
|
||||
onConfirm(
|
||||
amountValue,
|
||||
selectedCategory,
|
||||
description,
|
||||
Date.from(
|
||||
selectedDateTime.atZone(ZoneId.systemDefault()).toInstant()
|
||||
),
|
||||
selectedType,
|
||||
currentSelectedMember?.id
|
||||
)
|
||||
}
|
||||
},
|
||||
enabled = amount.isNotEmpty() && selectedCategory != null
|
||||
enabled = amount.isNotEmpty() && selectedCategory.isNotEmpty()
|
||||
) {
|
||||
Text("确定")
|
||||
}
|
||||
|
@@ -2,15 +2,32 @@ package com.yovinchen.bookkeeping.ui.dialog
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
@@ -8,25 +8,33 @@ import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.yovinchen.bookkeeping.model.BookkeepingRecord
|
||||
import com.yovinchen.bookkeeping.model.Category
|
||||
import com.yovinchen.bookkeeping.model.Member
|
||||
import com.yovinchen.bookkeeping.ui.components.DateTimePicker
|
||||
import com.yovinchen.bookkeeping.viewmodel.HomeViewModel
|
||||
import java.time.Instant
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
import java.util.Date
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun RecordEditDialog(
|
||||
record: BookkeepingRecord,
|
||||
categories: List<Category>,
|
||||
members: List<Member>,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (BookkeepingRecord) -> Unit
|
||||
onConfirm: (BookkeepingRecord) -> Unit,
|
||||
viewModel: HomeViewModel = viewModel()
|
||||
) {
|
||||
var amount by remember { mutableStateOf(record.amount.toString()) }
|
||||
var selectedCategory by remember { mutableStateOf(record.category) }
|
||||
var description by remember { mutableStateOf(record.description) }
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
var memberExpanded by remember { mutableStateOf(false) }
|
||||
var currentSelectedMember by remember { mutableStateOf<Member?>(null) }
|
||||
var selectedDateTime by remember {
|
||||
mutableStateOf(
|
||||
LocalDateTime.ofInstant(
|
||||
@@ -36,6 +44,16 @@ fun RecordEditDialog(
|
||||
)
|
||||
}
|
||||
|
||||
// 加载原关联成员
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
LaunchedEffect(record.memberId) {
|
||||
if (record.memberId != null) {
|
||||
coroutineScope.launch {
|
||||
currentSelectedMember = viewModel.getMemberById(record.memberId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
@@ -55,24 +73,16 @@ fun RecordEditDialog(
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// 日期时间选择
|
||||
DateTimePicker(
|
||||
selectedDateTime = selectedDateTime,
|
||||
onDateTimeSelected = { selectedDateTime = it },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// 金额输入
|
||||
OutlinedTextField(
|
||||
value = amount,
|
||||
onValueChange = { amount = it },
|
||||
label = { Text("金额") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// 类别选择
|
||||
ExposedDropdownMenuBox(
|
||||
@@ -84,10 +94,12 @@ fun RecordEditDialog(
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("类别") },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor()
|
||||
)
|
||||
|
||||
ExposedDropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false }
|
||||
@@ -104,19 +116,72 @@ fun RecordEditDialog(
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// 描述输入
|
||||
OutlinedTextField(
|
||||
value = description,
|
||||
onValueChange = { description = it },
|
||||
label = { Text("描述") },
|
||||
// 成员选择
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = memberExpanded,
|
||||
onExpandedChange = { memberExpanded = it }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = currentSelectedMember?.name ?: "选择成员",
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("成员") },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = memberExpanded) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor()
|
||||
)
|
||||
|
||||
ExposedDropdownMenu(
|
||||
expanded = memberExpanded,
|
||||
onDismissRequest = { memberExpanded = false }
|
||||
) {
|
||||
// 添加一个"清除选择"选项
|
||||
DropdownMenuItem(
|
||||
text = { Text("清除选择") },
|
||||
onClick = {
|
||||
currentSelectedMember = null
|
||||
memberExpanded = false
|
||||
}
|
||||
)
|
||||
|
||||
members.forEach { member ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(member.name) },
|
||||
onClick = {
|
||||
currentSelectedMember = member
|
||||
memberExpanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// 日期时间选择
|
||||
DateTimePicker(
|
||||
selectedDateTime = selectedDateTime,
|
||||
onDateTimeSelected = { selectedDateTime = it },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// 按钮
|
||||
// 备注输入
|
||||
OutlinedTextField(
|
||||
value = description,
|
||||
onValueChange = { description = it },
|
||||
label = { Text("备注") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// 按钮行
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End
|
||||
@@ -127,15 +192,22 @@ fun RecordEditDialog(
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
val updatedRecord = record.copy(
|
||||
amount = amount.toDoubleOrNull() ?: record.amount,
|
||||
category = selectedCategory,
|
||||
description = description,
|
||||
date = Date.from(selectedDateTime.atZone(ZoneId.systemDefault()).toInstant())
|
||||
)
|
||||
onConfirm(updatedRecord)
|
||||
onDismiss()
|
||||
}
|
||||
val amountValue = amount.toDoubleOrNull()
|
||||
if (amountValue != null) {
|
||||
onConfirm(
|
||||
record.copy(
|
||||
amount = amountValue,
|
||||
category = selectedCategory,
|
||||
description = description,
|
||||
date = Date.from(
|
||||
selectedDateTime.atZone(ZoneId.systemDefault()).toInstant()
|
||||
),
|
||||
memberId = currentSelectedMember?.id
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
enabled = amount.isNotEmpty()
|
||||
) {
|
||||
Text("确定")
|
||||
}
|
||||
|
@@ -2,33 +2,70 @@ package com.yovinchen.bookkeeping.ui.navigation
|
||||
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
import androidx.compose.material.icons.automirrored.filled.List
|
||||
import androidx.compose.material.icons.filled.Analytics
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
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.compose.ui.graphics.Color
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import com.yovinchen.bookkeeping.model.AnalysisType
|
||||
import com.yovinchen.bookkeeping.model.ThemeMode
|
||||
import com.yovinchen.bookkeeping.ui.screen.HomeScreen
|
||||
import com.yovinchen.bookkeeping.ui.screen.SettingsScreen
|
||||
import com.yovinchen.bookkeeping.ui.screen.*
|
||||
import java.time.YearMonth
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
sealed class Screen(val route: String, val icon: @Composable () -> Unit, val label: String) {
|
||||
object Home : Screen(
|
||||
route = "home",
|
||||
icon = { Icon(Icons.Default.Home, contentDescription = "主页") },
|
||||
label = "主页"
|
||||
)
|
||||
object Settings : Screen(
|
||||
route = "settings",
|
||||
icon = { Icon(Icons.Default.Settings, contentDescription = "设置") },
|
||||
label = "设置"
|
||||
)
|
||||
sealed class Screen(
|
||||
val route: String,
|
||||
val title: String,
|
||||
val icon: ImageVector? = null
|
||||
) {
|
||||
object Home : Screen("home", "记账", Icons.AutoMirrored.Filled.List)
|
||||
object Analysis : Screen("analysis", "分析", Icons.Default.Analytics)
|
||||
object Settings : Screen("settings", "设置", Icons.Default.Settings)
|
||||
object CategoryDetail : Screen(
|
||||
"category_detail/{category}/{startMonth}/{endMonth}",
|
||||
"分类详情"
|
||||
) {
|
||||
fun createRoute(
|
||||
category: String,
|
||||
startMonth: YearMonth,
|
||||
endMonth: YearMonth
|
||||
): String {
|
||||
return "category_detail/$category/${startMonth.format(DateTimeFormatter.ofPattern("yyyy-MM"))}/${endMonth.format(DateTimeFormatter.ofPattern("yyyy-MM"))}"
|
||||
}
|
||||
}
|
||||
object MemberDetail : Screen(
|
||||
"member_detail/{memberName}/{category}/{startMonth}/{endMonth}?type={type}",
|
||||
"成员详情"
|
||||
) {
|
||||
fun createRoute(
|
||||
memberName: String,
|
||||
category: String,
|
||||
startMonth: YearMonth,
|
||||
endMonth: YearMonth,
|
||||
type: AnalysisType
|
||||
): String {
|
||||
return "member_detail/$memberName/$category/${startMonth.format(DateTimeFormatter.ofPattern("yyyy-MM"))}/${endMonth.format(DateTimeFormatter.ofPattern("yyyy-MM"))}?type=${type.name}"
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun bottomNavigationItems() = listOf(Home, Analysis, Settings)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@@ -38,22 +75,18 @@ fun MainNavigation(
|
||||
onThemeChange: (ThemeMode) -> Unit
|
||||
) {
|
||||
val navController = rememberNavController()
|
||||
val items = listOf(Screen.Home, Screen.Settings)
|
||||
|
||||
Scaffold(
|
||||
bottomBar = {
|
||||
NavigationBar(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
) {
|
||||
NavigationBar {
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentDestination = navBackStackEntry?.destination
|
||||
val currentRoute = navBackStackEntry?.destination?.route
|
||||
|
||||
items.forEach { screen ->
|
||||
Screen.bottomNavigationItems().forEach { screen ->
|
||||
NavigationBarItem(
|
||||
icon = screen.icon,
|
||||
label = { Text(screen.label) },
|
||||
selected = currentDestination?.hierarchy?.any { it.route == screen.route } == true,
|
||||
icon = { Icon(screen.icon!!, contentDescription = screen.title) },
|
||||
label = { Text(screen.title) },
|
||||
selected = currentRoute == screen.route,
|
||||
onClick = {
|
||||
navController.navigate(screen.route) {
|
||||
popUpTo(navController.graph.findStartDestination().id) {
|
||||
@@ -62,33 +95,107 @@ fun MainNavigation(
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
},
|
||||
colors = NavigationBarItemDefaults.colors(
|
||||
selectedIconColor = MaterialTheme.colorScheme.primary,
|
||||
selectedTextColor = MaterialTheme.colorScheme.primary,
|
||||
unselectedIconColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
|
||||
unselectedTextColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
|
||||
indicatorColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
) { paddingValues ->
|
||||
) { innerPadding ->
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = Screen.Home.route,
|
||||
modifier = Modifier.padding(paddingValues)
|
||||
modifier = Modifier.padding(innerPadding)
|
||||
) {
|
||||
composable(Screen.Home.route) {
|
||||
HomeScreen()
|
||||
composable(Screen.Home.route) { HomeScreen() }
|
||||
|
||||
composable(Screen.Analysis.route) {
|
||||
AnalysisScreen(
|
||||
onNavigateToCategoryDetail = { category, startMonth, endMonth ->
|
||||
navController.navigate(Screen.CategoryDetail.createRoute(category, startMonth, endMonth))
|
||||
},
|
||||
onNavigateToMemberDetail = { memberName, startMonth, endMonth, analysisType ->
|
||||
navController.navigate(Screen.MemberDetail.createRoute(memberName, "", startMonth, endMonth, analysisType))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
composable(Screen.Settings.route) {
|
||||
SettingsScreen(
|
||||
currentTheme = currentTheme,
|
||||
onThemeChange = onThemeChange
|
||||
)
|
||||
}
|
||||
|
||||
composable(
|
||||
route = Screen.CategoryDetail.route,
|
||||
arguments = listOf(
|
||||
navArgument("category") { type = NavType.StringType },
|
||||
navArgument("startMonth") { type = NavType.StringType },
|
||||
navArgument("endMonth") { type = NavType.StringType }
|
||||
)
|
||||
) { backStackEntry ->
|
||||
val category = backStackEntry.arguments?.getString("category") ?: ""
|
||||
val startMonth = YearMonth.parse(
|
||||
backStackEntry.arguments?.getString("startMonth") ?: "",
|
||||
DateTimeFormatter.ofPattern("yyyy-MM")
|
||||
)
|
||||
val endMonth = YearMonth.parse(
|
||||
backStackEntry.arguments?.getString("endMonth") ?: "",
|
||||
DateTimeFormatter.ofPattern("yyyy-MM")
|
||||
)
|
||||
CategoryDetailScreen(
|
||||
category = category,
|
||||
startMonth = startMonth,
|
||||
endMonth = endMonth,
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
onNavigateToMemberDetail = { memberName ->
|
||||
navController.navigate(
|
||||
Screen.MemberDetail.createRoute(
|
||||
memberName = memberName,
|
||||
category = category,
|
||||
startMonth = startMonth,
|
||||
endMonth = endMonth,
|
||||
type = AnalysisType.EXPENSE
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = Screen.MemberDetail.route,
|
||||
arguments = listOf(
|
||||
navArgument("memberName") { type = NavType.StringType },
|
||||
navArgument("category") { type = NavType.StringType },
|
||||
navArgument("startMonth") { type = NavType.StringType },
|
||||
navArgument("endMonth") { type = NavType.StringType },
|
||||
navArgument("type") {
|
||||
type = NavType.StringType
|
||||
defaultValue = AnalysisType.EXPENSE.name
|
||||
}
|
||||
)
|
||||
) { backStackEntry ->
|
||||
val memberName = backStackEntry.arguments?.getString("memberName") ?: ""
|
||||
val category = backStackEntry.arguments?.getString("category") ?: ""
|
||||
val startMonth = YearMonth.parse(
|
||||
backStackEntry.arguments?.getString("startMonth") ?: "",
|
||||
DateTimeFormatter.ofPattern("yyyy-MM")
|
||||
)
|
||||
val endMonth = YearMonth.parse(
|
||||
backStackEntry.arguments?.getString("endMonth") ?: "",
|
||||
DateTimeFormatter.ofPattern("yyyy-MM")
|
||||
)
|
||||
val type = AnalysisType.valueOf(
|
||||
backStackEntry.arguments?.getString("type") ?: AnalysisType.EXPENSE.name
|
||||
)
|
||||
MemberDetailScreen(
|
||||
memberName = memberName,
|
||||
category = category,
|
||||
startMonth = startMonth,
|
||||
endMonth = endMonth,
|
||||
analysisType = type,
|
||||
onNavigateBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -0,0 +1,203 @@
|
||||
package com.yovinchen.bookkeeping.ui.screen
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.yovinchen.bookkeeping.model.AnalysisType
|
||||
import com.yovinchen.bookkeeping.model.CategoryStat
|
||||
import com.yovinchen.bookkeeping.model.MemberStat
|
||||
import com.yovinchen.bookkeeping.ui.components.CategoryPieChart
|
||||
import com.yovinchen.bookkeeping.ui.components.CategoryStatItem
|
||||
import com.yovinchen.bookkeeping.ui.components.DateRangePicker
|
||||
import com.yovinchen.bookkeeping.ui.components.TrendLineChart
|
||||
import com.yovinchen.bookkeeping.viewmodel.AnalysisViewModel
|
||||
import java.time.YearMonth
|
||||
|
||||
enum class ViewMode {
|
||||
CATEGORY, MEMBER
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AnalysisScreen(
|
||||
onNavigateToCategoryDetail: (String, YearMonth, YearMonth) -> Unit,
|
||||
onNavigateToMemberDetail: (String, YearMonth, YearMonth, AnalysisType) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val viewModel: AnalysisViewModel = viewModel()
|
||||
val startMonth by viewModel.startMonth.collectAsState()
|
||||
val endMonth by viewModel.endMonth.collectAsState()
|
||||
val selectedAnalysisType by viewModel.selectedAnalysisType.collectAsState()
|
||||
val categoryStats by viewModel.categoryStats.collectAsState()
|
||||
val memberStats by viewModel.memberStats.collectAsState()
|
||||
val records by viewModel.records.collectAsState()
|
||||
|
||||
var showViewModeMenu by remember { mutableStateOf(false) }
|
||||
var currentViewMode by rememberSaveable { mutableStateOf(ViewMode.CATEGORY) }
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier.fillMaxSize()
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
) {
|
||||
// 时间区间选择
|
||||
DateRangePicker(
|
||||
startMonth = startMonth,
|
||||
endMonth = endMonth,
|
||||
onStartMonthSelected = viewModel::setStartMonth,
|
||||
onEndMonthSelected = viewModel::setEndMonth
|
||||
)
|
||||
|
||||
// 分析类型和视图模式选择行
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// 分类/成员切换下拉菜单
|
||||
Box {
|
||||
Button(
|
||||
onClick = { showViewModeMenu = true }
|
||||
) {
|
||||
Text(if (currentViewMode == ViewMode.CATEGORY) "分类" else "成员")
|
||||
Icon(Icons.Default.ArrowDropDown, "切换视图")
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = showViewModeMenu,
|
||||
onDismissRequest = { showViewModeMenu = false }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("分类") },
|
||||
onClick = {
|
||||
currentViewMode = ViewMode.CATEGORY
|
||||
showViewModeMenu = false
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("成员") },
|
||||
onClick = {
|
||||
currentViewMode = ViewMode.MEMBER
|
||||
showViewModeMenu = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 类型切换
|
||||
Row {
|
||||
AnalysisType.entries.forEach { type ->
|
||||
FilterChip(
|
||||
selected = selectedAnalysisType == type,
|
||||
onClick = { viewModel.setAnalysisType(type) },
|
||||
label = {
|
||||
Text(
|
||||
when (type) {
|
||||
AnalysisType.EXPENSE -> "支出"
|
||||
AnalysisType.INCOME -> "收入"
|
||||
AnalysisType.TREND -> "趋势"
|
||||
}
|
||||
)
|
||||
},
|
||||
modifier = Modifier.padding(horizontal = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 使用LazyColumn包含饼图和列表
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(16.dp)
|
||||
) {
|
||||
when (selectedAnalysisType) {
|
||||
AnalysisType.TREND -> {
|
||||
// 趋势视图
|
||||
item {
|
||||
if (records.isNotEmpty()) {
|
||||
TrendLineChart(
|
||||
records = records,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(300.dp)
|
||||
.padding(vertical = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
// 饼图视图
|
||||
item {
|
||||
CategoryPieChart(
|
||||
categoryData = categoryStats.map { Pair(it.category, it.percentage.toFloat()) },
|
||||
memberData = memberStats.map { Pair(it.member, it.percentage.toFloat()) },
|
||||
currentViewMode = currentViewMode == ViewMode.MEMBER,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(200.dp)
|
||||
.padding(bottom = 16.dp),
|
||||
onCategoryClick = { category ->
|
||||
if (currentViewMode == ViewMode.CATEGORY) {
|
||||
onNavigateToCategoryDetail(category, startMonth, endMonth)
|
||||
} else {
|
||||
onNavigateToMemberDetail(category, startMonth, endMonth, selectedAnalysisType)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// 统计列表
|
||||
items(if (currentViewMode == ViewMode.CATEGORY) categoryStats else memberStats) { stat ->
|
||||
val category = if (stat is CategoryStat) stat.category else null
|
||||
val member = if (stat is MemberStat) stat.member else null
|
||||
|
||||
CategoryStatItem(
|
||||
stat = stat,
|
||||
onClick = {
|
||||
if (currentViewMode == ViewMode.CATEGORY && category != null) {
|
||||
onNavigateToCategoryDetail(category, startMonth, endMonth)
|
||||
} else if (currentViewMode == ViewMode.MEMBER && member != null) {
|
||||
onNavigateToMemberDetail(member, startMonth, endMonth, selectedAnalysisType)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,291 @@
|
||||
package com.yovinchen.bookkeeping.ui.screen
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.yovinchen.bookkeeping.data.BookkeepingDatabase
|
||||
import com.yovinchen.bookkeeping.model.BookkeepingRecord
|
||||
import com.yovinchen.bookkeeping.model.MemberStat
|
||||
import com.yovinchen.bookkeeping.model.TransactionType
|
||||
import com.yovinchen.bookkeeping.ui.components.CategoryPieChart
|
||||
import com.yovinchen.bookkeeping.ui.components.RecordItem
|
||||
import com.yovinchen.bookkeeping.viewmodel.CategoryDetailViewModel
|
||||
import com.yovinchen.bookkeeping.viewmodel.CategoryDetailViewModelFactory
|
||||
import java.text.NumberFormat
|
||||
import java.text.SimpleDateFormat
|
||||
import java.time.YearMonth
|
||||
import java.util.Locale
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CategoryDetailScreen(
|
||||
category: String,
|
||||
startMonth: YearMonth,
|
||||
endMonth: YearMonth,
|
||||
onNavigateBack: () -> Unit,
|
||||
onNavigateToMemberDetail: (String) -> Unit,
|
||||
viewModel: CategoryDetailViewModel = viewModel(
|
||||
factory = CategoryDetailViewModelFactory(
|
||||
database = BookkeepingDatabase.getDatabase(LocalContext.current),
|
||||
category = category,
|
||||
startMonth = startMonth,
|
||||
endMonth = endMonth
|
||||
)
|
||||
),
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val records by viewModel.records.collectAsState()
|
||||
val memberStats by viewModel.memberStats.collectAsState()
|
||||
val total by viewModel.total.collectAsState()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(category) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
LazyColumn(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
// 第一部分:总支出
|
||||
item {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = if (records.isNotEmpty() && records.first().type == TransactionType.INCOME) "总收入" else "总支出",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = NumberFormat.getCurrencyInstance(Locale.CHINA).format(total),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 第二部分:成员统计
|
||||
item {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = "成员分布",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(bottom = 16.dp)
|
||||
)
|
||||
|
||||
// 饼状图
|
||||
CategoryPieChart(
|
||||
categoryData = emptyList(),
|
||||
memberData = memberStats.map { Pair(it.member, it.percentage.toFloat()) },
|
||||
currentViewMode = true,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(200.dp),
|
||||
onCategoryClick = { memberName ->
|
||||
onNavigateToMemberDetail(memberName)
|
||||
}
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// 成员列表
|
||||
Column {
|
||||
memberStats.forEach { stat ->
|
||||
MemberStatItem(
|
||||
stat = stat,
|
||||
onClick = { onNavigateToMemberDetail(stat.member) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 第三部分:详细信息
|
||||
item {
|
||||
Text(
|
||||
text = "详细记录",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(16.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// 按日期分组的记录列表
|
||||
val groupedRecords = records.groupBy { record ->
|
||||
SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(record.date)
|
||||
}.toSortedMap(compareByDescending { it })
|
||||
|
||||
groupedRecords.forEach { (date, dayRecords) ->
|
||||
item {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
// 日期标题和总金额
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = date,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Text(
|
||||
text = NumberFormat.getCurrencyInstance(Locale.CHINA)
|
||||
.format(dayRecords.sumOf { it.amount }),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// 当天的记录列表
|
||||
dayRecords.forEach { record ->
|
||||
RecordItem(record = record)
|
||||
if (record != dayRecords.last()) {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
color = MaterialTheme.colorScheme.outlineVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MemberStatItem(
|
||||
stat: MemberStat,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = { Text(stat.member) },
|
||||
supportingContent = {
|
||||
Text(
|
||||
buildString {
|
||||
append("金额: ¥%.2f".format(stat.amount))
|
||||
append(" | ")
|
||||
append("次数: ${stat.count}")
|
||||
append(" | ")
|
||||
append("占比: %.1f%%".format(stat.percentage))
|
||||
}
|
||||
)
|
||||
},
|
||||
modifier = modifier.clickable(onClick = onClick)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RecordItem(
|
||||
record: BookkeepingRecord,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text(
|
||||
text = record.memberId.toString(), // 暂时显示 memberId,后续可以通过 MemberDao 获取成员名称
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
if (record.description.isNotBlank()) {
|
||||
Text(
|
||||
text = record.description,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = SimpleDateFormat("HH:mm", Locale.getDefault()).format(record.date),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = NumberFormat.getCurrencyInstance(Locale.CHINA).format(record.amount),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
@@ -1,60 +1,73 @@
|
||||
package com.yovinchen.bookkeeping.ui.screen
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
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.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
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.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.yovinchen.bookkeeping.model.BookkeepingRecord
|
||||
import com.yovinchen.bookkeeping.model.TransactionType
|
||||
import com.yovinchen.bookkeeping.ui.components.MonthlyStatistics
|
||||
import com.yovinchen.bookkeeping.ui.components.RecordItem
|
||||
import com.yovinchen.bookkeeping.ui.dialog.AddRecordDialog
|
||||
import com.yovinchen.bookkeeping.ui.dialog.RecordEditDialog
|
||||
import com.yovinchen.bookkeeping.viewmodel.HomeViewModel
|
||||
import java.time.YearMonth
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import java.util.Locale
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun HomeScreen(
|
||||
modifier: Modifier = Modifier, viewModel: HomeViewModel = viewModel()
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: HomeViewModel = viewModel()
|
||||
) {
|
||||
val filteredRecords by viewModel.filteredRecords.collectAsState()
|
||||
val totalIncome by viewModel.totalIncome.collectAsState()
|
||||
val totalExpense by viewModel.totalExpense.collectAsState()
|
||||
val categories by viewModel.categories.collectAsState()
|
||||
val selectedRecordType by viewModel.selectedRecordType.collectAsState()
|
||||
val selectedMonth by viewModel.selectedMonth.collectAsState()
|
||||
|
||||
var showAddDialog by remember { mutableStateOf(false) }
|
||||
var selectedRecord by remember { mutableStateOf<BookkeepingRecord?>(null) }
|
||||
|
||||
val selectedMonth by viewModel.selectedMonth.collectAsState()
|
||||
val filteredRecords by viewModel.filteredRecords.collectAsState()
|
||||
val categories by viewModel.categories.collectAsState(initial = emptyList())
|
||||
val members by viewModel.members.collectAsState(initial = emptyList())
|
||||
val selectedMember by viewModel.selectedMember.collectAsState()
|
||||
val totalIncome by viewModel.totalIncome.collectAsState()
|
||||
val totalExpense by viewModel.totalExpense.collectAsState()
|
||||
|
||||
Scaffold(modifier = modifier.fillMaxSize(), floatingActionButton = {
|
||||
FloatingActionButton(onClick = { showAddDialog = true }) {
|
||||
Icon(Icons.Default.Add, contentDescription = "添加记录")
|
||||
Scaffold(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
floatingActionButton = {
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = { showAddDialog = true },
|
||||
icon = { Icon(Icons.Default.Add, contentDescription = null) },
|
||||
text = { Text("记一笔") }
|
||||
)
|
||||
}
|
||||
}, floatingActionButtonPosition = FabPosition.End, topBar = {
|
||||
TopAppBar(title = { Text("记账本") })
|
||||
}) { padding ->
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -62,408 +75,103 @@ fun HomeScreen(
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
) {
|
||||
// 顶部统计信息
|
||||
MonthlyStatistics(totalIncome = totalIncome,
|
||||
MonthlyStatistics(
|
||||
totalIncome = totalIncome,
|
||||
totalExpense = totalExpense,
|
||||
selectedType = null,
|
||||
onIncomeClick = { viewModel.setSelectedRecordType(TransactionType.INCOME) },
|
||||
onExpenseClick = { viewModel.setSelectedRecordType(TransactionType.EXPENSE) },
|
||||
selectedType = selectedRecordType,
|
||||
onClearFilter = { viewModel.setSelectedRecordType(null) },
|
||||
selectedMonth = selectedMonth,
|
||||
onPreviousMonth = { viewModel.setSelectedMonth(selectedMonth.minusMonths(1)) },
|
||||
onNextMonth = { viewModel.setSelectedMonth(selectedMonth.plusMonths(1)) },
|
||||
onMonthSelected = { viewModel.setSelectedMonth(it) })
|
||||
onPreviousMonth = { viewModel.moveMonth(false) },
|
||||
onNextMonth = { viewModel.moveMonth(true) },
|
||||
onMonthSelected = { viewModel.setSelectedMonth(it) }
|
||||
)
|
||||
|
||||
// 记录列表
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
filteredRecords.forEach { (date, records) ->
|
||||
item {
|
||||
Surface(
|
||||
items(filteredRecords.size) { index ->
|
||||
val (date, dayRecords) = filteredRecords.toList()[index]
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.7f),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
tonalElevation = 2.dp
|
||||
.padding(16.dp)
|
||||
) {
|
||||
// 日期标签
|
||||
Text(
|
||||
text = SimpleDateFormat(
|
||||
"yyyy年MM月dd日 E",
|
||||
Locale.CHINESE
|
||||
).format(date),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// 当天的记录
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
// 日期标签
|
||||
Text(
|
||||
text = SimpleDateFormat(
|
||||
"yyyy年MM月dd日 E", Locale.CHINESE
|
||||
).format(date),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// 当天的记录
|
||||
records.forEachIndexed { index, record ->
|
||||
RecordItem(record = record,
|
||||
dayRecords.forEachIndexed { recordIndex, record ->
|
||||
RecordItem(
|
||||
record = record,
|
||||
onClick = { selectedRecord = record },
|
||||
onDelete = { viewModel.deleteRecord(record) })
|
||||
onDelete = { viewModel.deleteRecord(record) },
|
||||
members = members
|
||||
)
|
||||
|
||||
if (index < records.size - 1) {
|
||||
if (recordIndex < dayRecords.size - 1) {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
modifier = Modifier.padding(vertical = 4.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
thickness = 0.5.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// 当天统计
|
||||
HorizontalDivider(
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
thickness = 0.5.dp
|
||||
)
|
||||
|
||||
val dayIncome = records.filter { it.type == TransactionType.INCOME }
|
||||
.sumOf { it.amount }
|
||||
val dayExpense =
|
||||
records.filter { it.type == TransactionType.EXPENSE }
|
||||
.sumOf { it.amount }
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 12.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = "收入: ¥%.2f".format(dayIncome),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
text = "支出: ¥%.2f".format(dayExpense),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 添加记录对话框
|
||||
if (showAddDialog) {
|
||||
val selectedDateTime by viewModel.selectedDateTime.collectAsState()
|
||||
val selectedCategoryType by viewModel.selectedCategoryType.collectAsState()
|
||||
AddRecordDialog(onDismiss = {
|
||||
// 添加记录对话框
|
||||
if (showAddDialog) {
|
||||
AddRecordDialog(
|
||||
categories = categories,
|
||||
members = members,
|
||||
onDismiss = { showAddDialog = false },
|
||||
onConfirm = { amount, category, description, date, type, memberId ->
|
||||
viewModel.addRecord(amount, category, description, date, type, memberId)
|
||||
showAddDialog = false
|
||||
viewModel.resetSelectedDateTime()
|
||||
},
|
||||
onConfirm = { type, amount, category, description ->
|
||||
viewModel.addRecord(type, amount, category, description)
|
||||
showAddDialog = false
|
||||
},
|
||||
categories = categories,
|
||||
selectedType = selectedCategoryType,
|
||||
onTypeChange = viewModel::setSelectedCategoryType,
|
||||
selectedDateTime = selectedDateTime,
|
||||
onDateTimeSelected = viewModel::setSelectedDateTime
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// 编辑记录对话框
|
||||
selectedRecord?.let { record ->
|
||||
RecordEditDialog(record = record,
|
||||
categories = categories,
|
||||
onDismiss = { selectedRecord = null },
|
||||
onConfirm = { updatedRecord ->
|
||||
viewModel.updateRecord(updatedRecord)
|
||||
selectedRecord = null
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MonthYearPickerDialog(
|
||||
selectedMonth: YearMonth, onMonthSelected: (YearMonth) -> Unit, onDismiss: () -> Unit
|
||||
) {
|
||||
var currentYearMonth by remember { mutableStateOf(selectedMonth) }
|
||||
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.wrapContentHeight(),
|
||||
shape = MaterialTheme.shapes.extraLarge,
|
||||
tonalElevation = 6.dp
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "选择年月",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier.padding(bottom = 16.dp)
|
||||
)
|
||||
|
||||
// 年份选择
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(onClick = {
|
||||
currentYearMonth = currentYearMonth.minusYears(1)
|
||||
}) {
|
||||
Text("<")
|
||||
}
|
||||
Text(
|
||||
text = "${currentYearMonth.year}年",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
IconButton(onClick = {
|
||||
currentYearMonth = currentYearMonth.plusYears(1)
|
||||
}) {
|
||||
Text(">")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// 月份网格
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(3), modifier = Modifier.height(200.dp)
|
||||
) {
|
||||
items(12) { index ->
|
||||
val month = index + 1
|
||||
val isSelected = month == currentYearMonth.monthValue
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.padding(4.dp)
|
||||
.aspectRatio(1.5f)
|
||||
.clickable {
|
||||
currentYearMonth = YearMonth.of(currentYearMonth.year, month)
|
||||
},
|
||||
shape = MaterialTheme.shapes.small,
|
||||
color = if (isSelected) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.surface
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
Text(
|
||||
text = "${month}月",
|
||||
color = if (isSelected) MaterialTheme.colorScheme.onPrimary
|
||||
else MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 按钮行
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 16.dp),
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("取消")
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Button(onClick = {
|
||||
onMonthSelected(currentYearMonth)
|
||||
onDismiss()
|
||||
}) {
|
||||
Text("确定")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MonthlyStatistics(
|
||||
totalIncome: Double,
|
||||
totalExpense: Double,
|
||||
onIncomeClick: () -> Unit,
|
||||
onExpenseClick: () -> Unit,
|
||||
selectedType: TransactionType?,
|
||||
onClearFilter: () -> Unit,
|
||||
selectedMonth: YearMonth,
|
||||
onPreviousMonth: () -> Unit,
|
||||
onNextMonth: () -> Unit,
|
||||
onMonthSelected: (YearMonth) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var showMonthPicker by remember { mutableStateOf(false) }
|
||||
|
||||
Card(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
// 月份选择器
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(onClick = onPreviousMonth) {
|
||||
Icon(Icons.AutoMirrored.Filled.KeyboardArrowLeft, "上个月")
|
||||
}
|
||||
|
||||
Text(text = "${selectedMonth.year}年${selectedMonth.monthValue}月",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier.clickable { showMonthPicker = true })
|
||||
|
||||
IconButton(onClick = onNextMonth) {
|
||||
Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, "下个月")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Row(
|
||||
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)) {
|
||||
Text(
|
||||
text = "收入", style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
Text(
|
||||
text = "¥${String.format("%.2f", totalIncome)}",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
|
||||
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)) {
|
||||
Text(
|
||||
text = "支出", style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
Text(
|
||||
text = "¥${String.format("%.2f", totalExpense)}",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedType != null) {
|
||||
TextButton(
|
||||
onClick = onClearFilter, modifier = Modifier.align(Alignment.End)
|
||||
) {
|
||||
Text("清除筛选")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showMonthPicker) {
|
||||
MonthYearPickerDialog(selectedMonth = selectedMonth,
|
||||
onMonthSelected = onMonthSelected,
|
||||
onDismiss = { showMonthPicker = false })
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RecordItem(
|
||||
record: BookkeepingRecord,
|
||||
onClick: () -> Unit = {},
|
||||
onDelete: () -> Unit = {},
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Card(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = record.category, style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
if (record.description.isNotEmpty()) {
|
||||
Text(
|
||||
text = record.description,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = SimpleDateFormat(
|
||||
"yyyy-MM-dd HH:mm", Locale.getDefault()
|
||||
).format(record.date),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = if (record.type == TransactionType.EXPENSE) "-" else "+",
|
||||
color = if (record.type == TransactionType.EXPENSE) MaterialTheme.colorScheme.error
|
||||
else MaterialTheme.colorScheme.primary,
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
Text(
|
||||
text = String.format("%.2f", record.amount),
|
||||
color = if (record.type == TransactionType.EXPENSE) MaterialTheme.colorScheme.error
|
||||
else MaterialTheme.colorScheme.primary,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(end = 8.dp)
|
||||
)
|
||||
IconButton(onClick = onDelete) {
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = "删除",
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 编辑记录对话框
|
||||
selectedRecord?.let { record ->
|
||||
RecordEditDialog(
|
||||
record = record,
|
||||
categories = categories,
|
||||
members = members,
|
||||
onDismiss = { selectedRecord = null },
|
||||
onConfirm = { updatedRecord ->
|
||||
viewModel.updateRecord(updatedRecord)
|
||||
selectedRecord = null
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@@ -0,0 +1,226 @@
|
||||
package com.yovinchen.bookkeeping.ui.screen
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.yovinchen.bookkeeping.data.Record
|
||||
import com.yovinchen.bookkeeping.model.AnalysisType
|
||||
import com.yovinchen.bookkeeping.model.TransactionType
|
||||
import com.yovinchen.bookkeeping.ui.components.CategoryPieChart
|
||||
import com.yovinchen.bookkeeping.ui.components.RecordItem
|
||||
import com.yovinchen.bookkeeping.viewmodel.MemberDetailViewModel
|
||||
import java.text.NumberFormat
|
||||
import java.text.SimpleDateFormat
|
||||
import java.time.YearMonth
|
||||
import java.util.Locale
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MemberDetailScreen(
|
||||
memberName: String,
|
||||
startMonth: YearMonth,
|
||||
endMonth: YearMonth,
|
||||
category: String = "",
|
||||
analysisType: AnalysisType = AnalysisType.EXPENSE,
|
||||
onNavigateBack: () -> Unit,
|
||||
viewModel: MemberDetailViewModel = viewModel()
|
||||
) {
|
||||
val records by viewModel.memberRecords.collectAsState(initial = emptyList())
|
||||
val totalAmount by viewModel.totalAmount.collectAsState(initial = 0.0)
|
||||
val categoryData by viewModel.categoryData.collectAsState(initial = emptyList())
|
||||
|
||||
LaunchedEffect(memberName, category, startMonth, endMonth, analysisType) {
|
||||
viewModel.loadMemberRecords(
|
||||
memberName = memberName,
|
||||
category = category,
|
||||
startMonth = startMonth,
|
||||
endMonth = endMonth,
|
||||
analysisType = analysisType
|
||||
)
|
||||
}
|
||||
|
||||
val groupedRecords = remember(records) {
|
||||
records.groupBy { record ->
|
||||
SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(record.date)
|
||||
}.toSortedMap(reverseOrder())
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(memberName)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, "返回")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
) {
|
||||
// 第一层:总金额卡片
|
||||
item {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "总金额",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = NumberFormat.getCurrencyInstance(Locale.CHINA)
|
||||
.format(totalAmount),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 当从成员视图进入时显示饼图
|
||||
if (category.isEmpty()) {
|
||||
item {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "分类统计",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
CategoryPieChart(
|
||||
categoryData = categoryData,
|
||||
memberData = emptyList(),
|
||||
currentViewMode = false,
|
||||
onCategoryClick = { selectedCategory ->
|
||||
// 暂时不处理点击事件
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 第二层:按日期分组的记录列表
|
||||
groupedRecords.forEach { (date, dayRecords) ->
|
||||
item {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = date,
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
Text(
|
||||
text = NumberFormat.getCurrencyInstance(Locale.CHINA)
|
||||
.format(dayRecords.sumOf { it.amount }),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
dayRecords.forEach { record ->
|
||||
RecordItem(record = record)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RecordItem(record: Record) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column {
|
||||
if (record.description.isNotBlank()) {
|
||||
Text(
|
||||
text = record.description,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = SimpleDateFormat("HH:mm", Locale.getDefault())
|
||||
.format(record.dateTime),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = NumberFormat.getCurrencyInstance(Locale.CHINA)
|
||||
.format(record.amount),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
@@ -1,19 +1,35 @@
|
||||
package com.yovinchen.bookkeeping.ui.screen
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Divider
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
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.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.Category
|
||||
import com.yovinchen.bookkeeping.model.ThemeMode
|
||||
import com.yovinchen.bookkeeping.model.TransactionType
|
||||
import com.yovinchen.bookkeeping.ui.components.ColorPicker
|
||||
import com.yovinchen.bookkeeping.ui.components.predefinedColors
|
||||
import com.yovinchen.bookkeeping.ui.dialog.CategoryManagementDialog
|
||||
import com.yovinchen.bookkeeping.ui.dialog.MemberManagementDialog
|
||||
import com.yovinchen.bookkeeping.viewmodel.MemberViewModel
|
||||
import com.yovinchen.bookkeeping.viewmodel.SettingsViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@@ -21,15 +37,27 @@ import com.yovinchen.bookkeeping.viewmodel.SettingsViewModel
|
||||
fun SettingsScreen(
|
||||
currentTheme: ThemeMode,
|
||||
onThemeChange: (ThemeMode) -> Unit,
|
||||
viewModel: SettingsViewModel = viewModel()
|
||||
viewModel: SettingsViewModel = viewModel(),
|
||||
memberViewModel: MemberViewModel = viewModel()
|
||||
) {
|
||||
var showThemeDialog by remember { mutableStateOf(false) }
|
||||
var showCategoryDialog by remember { mutableStateOf(false) }
|
||||
var showMemberDialog by remember { mutableStateOf(false) }
|
||||
|
||||
val categories by viewModel.categories.collectAsState()
|
||||
val selectedType by viewModel.selectedCategoryType.collectAsState()
|
||||
val members by memberViewModel.allMembers.collectAsState(initial = emptyList())
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// 成员管理设置项
|
||||
ListItem(
|
||||
headlineContent = { Text("成员管理") },
|
||||
supportingContent = { Text("管理账本成员") },
|
||||
modifier = Modifier.clickable { showMemberDialog = true }
|
||||
)
|
||||
|
||||
Divider()
|
||||
|
||||
// 类别管理设置项
|
||||
ListItem(
|
||||
headlineContent = { Text("类别管理") },
|
||||
@@ -131,6 +159,19 @@ fun SettingsScreen(
|
||||
onTypeChange = viewModel::setSelectedCategoryType
|
||||
)
|
||||
}
|
||||
|
||||
// 成员管理对话框
|
||||
if (showMemberDialog) {
|
||||
MemberManagementDialog(
|
||||
onDismiss = { showMemberDialog = false },
|
||||
members = members,
|
||||
onAddMember = memberViewModel::addMember,
|
||||
onDeleteMember = memberViewModel::deleteMember,
|
||||
onUpdateMember = { member, name, description ->
|
||||
memberViewModel.updateMember(member.copy(name = name, description = description))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
@@ -0,0 +1,139 @@
|
||||
package com.yovinchen.bookkeeping.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.yovinchen.bookkeeping.data.BookkeepingDatabase
|
||||
import com.yovinchen.bookkeeping.model.AnalysisType
|
||||
import com.yovinchen.bookkeeping.model.BookkeepingRecord
|
||||
import com.yovinchen.bookkeeping.model.CategoryStat
|
||||
import com.yovinchen.bookkeeping.model.MemberStat
|
||||
import com.yovinchen.bookkeeping.model.TransactionType
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.LocalDateTime
|
||||
import java.time.YearMonth
|
||||
import java.time.ZoneId
|
||||
import java.util.*
|
||||
|
||||
class AnalysisViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private val recordDao = BookkeepingDatabase.getDatabase(application).bookkeepingDao()
|
||||
private val memberDao = BookkeepingDatabase.getDatabase(application).memberDao()
|
||||
|
||||
private val _startMonth = MutableStateFlow(YearMonth.now())
|
||||
val startMonth: StateFlow<YearMonth> = _startMonth.asStateFlow()
|
||||
|
||||
private val _endMonth = MutableStateFlow(YearMonth.now())
|
||||
val endMonth: StateFlow<YearMonth> = _endMonth.asStateFlow()
|
||||
|
||||
private val _selectedAnalysisType = MutableStateFlow(AnalysisType.EXPENSE)
|
||||
val selectedAnalysisType: StateFlow<AnalysisType> = _selectedAnalysisType.asStateFlow()
|
||||
|
||||
private val _categoryStats = MutableStateFlow<List<CategoryStat>>(emptyList())
|
||||
val categoryStats: StateFlow<List<CategoryStat>> = _categoryStats.asStateFlow()
|
||||
|
||||
private val _memberStats = MutableStateFlow<List<MemberStat>>(emptyList())
|
||||
val memberStats: StateFlow<List<MemberStat>> = _memberStats.asStateFlow()
|
||||
|
||||
private val _records = MutableStateFlow<List<BookkeepingRecord>>(emptyList())
|
||||
val records: StateFlow<List<BookkeepingRecord>> = _records.asStateFlow()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
combine(startMonth, endMonth, selectedAnalysisType) { start, end, type ->
|
||||
Triple(start, end, type)
|
||||
}.collect { (start, end, type) ->
|
||||
updateStats(start, end, type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setStartMonth(month: YearMonth) {
|
||||
_startMonth.value = month
|
||||
}
|
||||
|
||||
fun setEndMonth(month: YearMonth) {
|
||||
_endMonth.value = month
|
||||
}
|
||||
|
||||
fun setAnalysisType(type: AnalysisType) {
|
||||
_selectedAnalysisType.value = type
|
||||
}
|
||||
|
||||
private suspend fun updateStats(startMonth: YearMonth, endMonth: YearMonth, type: AnalysisType) {
|
||||
val records = recordDao.getAllRecords().first()
|
||||
|
||||
// 过滤日期范围内的记录
|
||||
val monthRecords = records.filter {
|
||||
val recordDate = Date(it.date.time)
|
||||
val localDateTime = LocalDateTime.ofInstant(recordDate.toInstant(), ZoneId.systemDefault())
|
||||
val yearMonth = YearMonth.from(localDateTime)
|
||||
yearMonth.isAfter(startMonth.minusMonths(1)) &&
|
||||
yearMonth.isBefore(endMonth.plusMonths(1))
|
||||
}
|
||||
|
||||
// 更新记录数据
|
||||
_records.value = monthRecords
|
||||
|
||||
// 根据分析类型过滤记录
|
||||
val filteredRecords = if (type == AnalysisType.TREND) {
|
||||
monthRecords
|
||||
} else {
|
||||
monthRecords.filter {
|
||||
it.type == when(type) {
|
||||
AnalysisType.EXPENSE -> TransactionType.EXPENSE
|
||||
AnalysisType.INCOME -> TransactionType.INCOME
|
||||
else -> return@filter true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新统计数据
|
||||
updateCategoryStats(filteredRecords)
|
||||
updateMemberStats(filteredRecords)
|
||||
}
|
||||
|
||||
private suspend fun updateCategoryStats(records: List<BookkeepingRecord>) {
|
||||
// 按分类统计
|
||||
val categoryMap = records.groupBy { it.category }
|
||||
val categoryStats = categoryMap.map { (category, records) ->
|
||||
CategoryStat(
|
||||
category = category,
|
||||
amount = records.sumOf { it.amount },
|
||||
count = records.size
|
||||
)
|
||||
}.sortedByDescending { it.amount }
|
||||
|
||||
// 计算分类总额和百分比
|
||||
val categoryTotal = categoryStats.sumOf { it.amount }
|
||||
val categoryStatsWithPercentage = categoryStats.map {
|
||||
it.copy(percentage = if (categoryTotal > 0) it.amount / categoryTotal * 100 else 0.0)
|
||||
}
|
||||
|
||||
_categoryStats.value = categoryStatsWithPercentage
|
||||
}
|
||||
|
||||
private suspend fun updateMemberStats(records: List<BookkeepingRecord>) {
|
||||
// 按成员统计
|
||||
val members = memberDao.getAllMembers().first()
|
||||
val memberMap = records.groupBy { record ->
|
||||
members.find { it.id == record.memberId }?.name ?: "未分配"
|
||||
}
|
||||
|
||||
val memberStats = memberMap.map { (memberName, records) ->
|
||||
MemberStat(
|
||||
member = memberName,
|
||||
amount = records.sumOf { it.amount },
|
||||
count = records.size
|
||||
)
|
||||
}.sortedByDescending { it.amount }
|
||||
|
||||
// 计算成员总额和百分比
|
||||
val memberTotal = memberStats.sumOf { it.amount }
|
||||
val memberStatsWithPercentage = memberStats.map {
|
||||
it.copy(percentage = if (memberTotal > 0) it.amount / memberTotal * 100 else 0.0)
|
||||
}
|
||||
|
||||
_memberStats.value = memberStatsWithPercentage
|
||||
}
|
||||
}
|
@@ -0,0 +1,69 @@
|
||||
package com.yovinchen.bookkeeping.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.yovinchen.bookkeeping.data.BookkeepingDatabase
|
||||
import com.yovinchen.bookkeeping.model.BookkeepingRecord
|
||||
import com.yovinchen.bookkeeping.model.MemberStat
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import java.time.YearMonth
|
||||
import java.time.ZoneId
|
||||
import java.util.Date
|
||||
|
||||
class CategoryDetailViewModel(
|
||||
database: BookkeepingDatabase,
|
||||
category: String,
|
||||
startMonth: YearMonth,
|
||||
endMonth: YearMonth
|
||||
) : ViewModel() {
|
||||
private val recordDao = database.bookkeepingDao()
|
||||
|
||||
private val _records = MutableStateFlow<List<BookkeepingRecord>>(emptyList())
|
||||
val records: StateFlow<List<BookkeepingRecord>> = _records.asStateFlow()
|
||||
|
||||
private val _memberStats = MutableStateFlow<List<MemberStat>>(emptyList())
|
||||
val memberStats: StateFlow<List<MemberStat>> = _memberStats.asStateFlow()
|
||||
|
||||
val total: StateFlow<Double> = records
|
||||
.map { records -> records.sumOf { it.amount } }
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5000),
|
||||
initialValue = 0.0
|
||||
)
|
||||
|
||||
init {
|
||||
val startDate = startMonth.atDay(1).atStartOfDay()
|
||||
.atZone(ZoneId.systemDefault())
|
||||
.toInstant()
|
||||
.let { Date.from(it) }
|
||||
|
||||
val endDate = endMonth.atEndOfMonth().atTime(23, 59, 59)
|
||||
.atZone(ZoneId.systemDefault())
|
||||
.toInstant()
|
||||
.let { Date.from(it) }
|
||||
|
||||
recordDao.getRecordsByCategoryAndDateRange(
|
||||
category = category,
|
||||
startDate = startDate,
|
||||
endDate = endDate
|
||||
)
|
||||
.onEach { records -> _records.value = records }
|
||||
.launchIn(viewModelScope)
|
||||
|
||||
recordDao.getMemberStatsByCategoryAndDateRange(
|
||||
category = category,
|
||||
startDate = startDate,
|
||||
endDate = endDate
|
||||
)
|
||||
.onEach { stats -> _memberStats.value = stats }
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
}
|
@@ -0,0 +1,21 @@
|
||||
package com.yovinchen.bookkeeping.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import com.yovinchen.bookkeeping.data.BookkeepingDatabase
|
||||
import java.time.YearMonth
|
||||
|
||||
class CategoryDetailViewModelFactory(
|
||||
private val database: BookkeepingDatabase,
|
||||
private val category: String,
|
||||
private val startMonth: YearMonth,
|
||||
private val endMonth: YearMonth
|
||||
) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
if (modelClass.isAssignableFrom(CategoryDetailViewModel::class.java)) {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return CategoryDetailViewModel(database, category, startMonth, endMonth) as T
|
||||
}
|
||||
throw IllegalArgumentException("Unknown ViewModel class")
|
||||
}
|
||||
}
|
@@ -7,6 +7,7 @@ import androidx.lifecycle.viewModelScope
|
||||
import com.yovinchen.bookkeeping.data.BookkeepingDatabase
|
||||
import com.yovinchen.bookkeeping.model.BookkeepingRecord
|
||||
import com.yovinchen.bookkeeping.model.Category
|
||||
import com.yovinchen.bookkeeping.model.Member
|
||||
import com.yovinchen.bookkeeping.model.TransactionType
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.*
|
||||
@@ -14,37 +15,39 @@ import kotlinx.coroutines.launch
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
import java.time.YearMonth
|
||||
import java.util.Date
|
||||
import java.util.Calendar
|
||||
import java.util.*
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class HomeViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private val TAG = "HomeViewModel"
|
||||
private val dao = BookkeepingDatabase.getDatabase(application).bookkeepingDao()
|
||||
private val bookkeepingDao = BookkeepingDatabase.getDatabase(application).bookkeepingDao()
|
||||
private val memberDao = BookkeepingDatabase.getDatabase(application).memberDao()
|
||||
private val categoryDao = BookkeepingDatabase.getDatabase(application).categoryDao()
|
||||
|
||||
private val _selectedRecordType = MutableStateFlow<TransactionType?>(null)
|
||||
val selectedRecordType: StateFlow<TransactionType?> = _selectedRecordType.asStateFlow()
|
||||
|
||||
private val _selectedDateTime = MutableStateFlow(LocalDateTime.now())
|
||||
val selectedDateTime: StateFlow<LocalDateTime> = _selectedDateTime.asStateFlow()
|
||||
|
||||
private val _selectedCategoryType = MutableStateFlow(TransactionType.EXPENSE)
|
||||
val selectedCategoryType: StateFlow<TransactionType> = _selectedCategoryType.asStateFlow()
|
||||
|
||||
private val _selectedMonth = MutableStateFlow(YearMonth.now())
|
||||
val selectedMonth: StateFlow<YearMonth> = _selectedMonth.asStateFlow()
|
||||
|
||||
private val records = dao.getAllRecords()
|
||||
private val _selectedMember = MutableStateFlow<Member?>(null)
|
||||
val selectedMember: StateFlow<Member?> = _selectedMember.asStateFlow()
|
||||
|
||||
val members = memberDao.getAllMembers()
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5000),
|
||||
initialValue = emptyList()
|
||||
)
|
||||
|
||||
val categories: StateFlow<List<Category>> = _selectedCategoryType
|
||||
.flatMapLatest { type ->
|
||||
dao.getCategoriesByType(type)
|
||||
}
|
||||
val categories = categoryDao.getAllCategories()
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5000),
|
||||
initialValue = emptyList()
|
||||
)
|
||||
|
||||
private val allRecords = bookkeepingDao.getAllRecords()
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5000),
|
||||
@@ -52,26 +55,28 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
|
||||
)
|
||||
|
||||
val filteredRecords = combine(
|
||||
records,
|
||||
allRecords,
|
||||
_selectedRecordType,
|
||||
_selectedMonth
|
||||
) { records, selectedType, selectedMonth ->
|
||||
_selectedMonth,
|
||||
_selectedMember
|
||||
) { records, selectedType, selectedMonth, selectedMember ->
|
||||
records
|
||||
.filter { record ->
|
||||
val recordDate = record.date.toInstant()
|
||||
.atZone(ZoneId.systemDefault())
|
||||
.toLocalDate()
|
||||
val recordYearMonth = YearMonth.from(recordDate)
|
||||
|
||||
|
||||
val typeMatches = selectedType?.let { record.type == it } ?: true
|
||||
val monthMatches = recordYearMonth == selectedMonth
|
||||
|
||||
typeMatches && monthMatches
|
||||
val memberMatches = selectedMember?.let { record.memberId == it.id } ?: true
|
||||
|
||||
monthMatches && memberMatches && typeMatches
|
||||
}
|
||||
.sortedByDescending { it.date }
|
||||
.groupBy { record ->
|
||||
val calendar = Calendar.getInstance().apply { time = record.date }
|
||||
calendar.apply {
|
||||
Calendar.getInstance().apply {
|
||||
time = record.date
|
||||
set(Calendar.HOUR_OF_DAY, 0)
|
||||
set(Calendar.MINUTE, 0)
|
||||
set(Calendar.SECOND, 0)
|
||||
@@ -79,15 +84,16 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}.time
|
||||
}
|
||||
}.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.WhileSubscribed(5000),
|
||||
emptyMap()
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5000),
|
||||
initialValue = emptyMap()
|
||||
)
|
||||
|
||||
val totalIncome = combine(
|
||||
records,
|
||||
_selectedMonth
|
||||
) { records, selectedMonth ->
|
||||
allRecords,
|
||||
_selectedMonth,
|
||||
_selectedMember
|
||||
) { records, selectedMonth, selectedMember ->
|
||||
records
|
||||
.filter { record ->
|
||||
val recordDate = record.date.toInstant()
|
||||
@@ -95,19 +101,24 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
|
||||
.toLocalDate()
|
||||
val recordYearMonth = YearMonth.from(recordDate)
|
||||
|
||||
record.type == TransactionType.INCOME && recordYearMonth == selectedMonth
|
||||
val monthMatches = recordYearMonth == selectedMonth
|
||||
val memberMatches = selectedMember?.let { record.memberId == it.id } ?: true
|
||||
val typeMatches = record.type == TransactionType.INCOME
|
||||
|
||||
monthMatches && memberMatches && typeMatches
|
||||
}
|
||||
.sumOf { it.amount }
|
||||
}.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.WhileSubscribed(5000),
|
||||
0.0
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5000),
|
||||
initialValue = 0.0
|
||||
)
|
||||
|
||||
val totalExpense = combine(
|
||||
records,
|
||||
_selectedMonth
|
||||
) { records, selectedMonth ->
|
||||
allRecords,
|
||||
_selectedMonth,
|
||||
_selectedMember
|
||||
) { records, selectedMonth, selectedMember ->
|
||||
records
|
||||
.filter { record ->
|
||||
val recordDate = record.date.toInstant()
|
||||
@@ -115,111 +126,73 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
|
||||
.toLocalDate()
|
||||
val recordYearMonth = YearMonth.from(recordDate)
|
||||
|
||||
record.type == TransactionType.EXPENSE && recordYearMonth == selectedMonth
|
||||
val monthMatches = recordYearMonth == selectedMonth
|
||||
val memberMatches = selectedMember?.let { record.memberId == it.id } ?: true
|
||||
val typeMatches = record.type == TransactionType.EXPENSE
|
||||
|
||||
monthMatches && memberMatches && typeMatches
|
||||
}
|
||||
.sumOf { it.amount }
|
||||
}.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.WhileSubscribed(5000),
|
||||
0.0
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5000),
|
||||
initialValue = 0.0
|
||||
)
|
||||
|
||||
private fun updateTotals() {
|
||||
// 移除未使用的参数
|
||||
}
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
records.collect {
|
||||
updateTotals()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addRecord(type: TransactionType, amount: Double, category: String, description: String) {
|
||||
viewModelScope.launch {
|
||||
val record = BookkeepingRecord(
|
||||
amount = amount,
|
||||
type = type,
|
||||
category = category,
|
||||
description = description,
|
||||
date = Date.from(_selectedDateTime.value.atZone(ZoneId.systemDefault()).toInstant())
|
||||
)
|
||||
dao.insertRecord(record)
|
||||
resetSelectedDateTime()
|
||||
}
|
||||
}
|
||||
|
||||
fun setSelectedDateTime(dateTime: LocalDateTime) {
|
||||
_selectedDateTime.value = dateTime
|
||||
}
|
||||
|
||||
fun setSelectedRecordType(type: TransactionType?) {
|
||||
_selectedRecordType.value = type
|
||||
}
|
||||
|
||||
fun setSelectedCategoryType(type: TransactionType) {
|
||||
_selectedCategoryType.value = type
|
||||
}
|
||||
|
||||
fun setSelectedMonth(yearMonth: YearMonth) {
|
||||
_selectedMonth.value = yearMonth
|
||||
}
|
||||
|
||||
fun setSelectedMember(member: Member?) {
|
||||
_selectedMember.value = member
|
||||
}
|
||||
|
||||
fun moveMonth(forward: Boolean) {
|
||||
val current = _selectedMonth.value
|
||||
_selectedMonth.value = if (forward) {
|
||||
current.plusMonths(1)
|
||||
_selectedMonth.value.plusMonths(1)
|
||||
} else {
|
||||
current.minusMonths(1)
|
||||
_selectedMonth.value.minusMonths(1)
|
||||
}
|
||||
}
|
||||
|
||||
fun resetSelectedDateTime() {
|
||||
_selectedDateTime.value = LocalDateTime.now()
|
||||
suspend fun getMemberById(memberId: Int): Member? {
|
||||
return memberDao.getMemberById(memberId)
|
||||
}
|
||||
|
||||
fun addRecord(
|
||||
amount: Double,
|
||||
category: String,
|
||||
description: String,
|
||||
date: Date,
|
||||
type: TransactionType,
|
||||
memberId: Int?
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
val record = BookkeepingRecord(
|
||||
type = type,
|
||||
amount = amount,
|
||||
category = category,
|
||||
description = description,
|
||||
date = date,
|
||||
memberId = memberId
|
||||
)
|
||||
bookkeepingDao.insertRecord(record)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateRecord(record: BookkeepingRecord) {
|
||||
viewModelScope.launch {
|
||||
dao.updateRecord(record)
|
||||
bookkeepingDao.updateRecord(record)
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteRecord(record: BookkeepingRecord) {
|
||||
viewModelScope.launch {
|
||||
dao.deleteRecord(record)
|
||||
bookkeepingDao.deleteRecord(record)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取指定日期的记录
|
||||
fun getRecordsByDate(date: LocalDateTime): Flow<List<BookkeepingRecord>> {
|
||||
val calendar = Calendar.getInstance().apply {
|
||||
time = Date.from(date.atZone(ZoneId.systemDefault()).toInstant())
|
||||
set(Calendar.HOUR_OF_DAY, 0)
|
||||
set(Calendar.MINUTE, 0)
|
||||
set(Calendar.SECOND, 0)
|
||||
set(Calendar.MILLISECOND, 0)
|
||||
}
|
||||
val startOfDay = calendar.time
|
||||
calendar.add(Calendar.DAY_OF_MONTH, 1)
|
||||
val endOfDay = calendar.time
|
||||
return dao.getRecordsByDateRange(startOfDay, endOfDay)
|
||||
}
|
||||
|
||||
// 获取指定日期范围的记录
|
||||
fun getRecordsByDateRange(startDate: LocalDateTime, endDate: LocalDateTime): Flow<List<BookkeepingRecord>> {
|
||||
val start = Date.from(startDate.atZone(ZoneId.systemDefault()).toInstant())
|
||||
val end = Date.from(endDate.atZone(ZoneId.systemDefault()).toInstant())
|
||||
return dao.getRecordsByDateRange(start, end)
|
||||
}
|
||||
|
||||
// 获取指定类型的记录
|
||||
fun getRecordsByType(type: TransactionType): Flow<List<BookkeepingRecord>> {
|
||||
return dao.getRecordsByType(type)
|
||||
fun setSelectedRecordType(type: TransactionType?) {
|
||||
_selectedRecordType.value = type
|
||||
}
|
||||
}
|
||||
|
||||
data class UiState(
|
||||
val isAddingRecord: Boolean = false,
|
||||
val isManagingCategories: Boolean = false
|
||||
)
|
||||
|
@@ -0,0 +1,83 @@
|
||||
package com.yovinchen.bookkeeping.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.yovinchen.bookkeeping.data.BookkeepingDatabase
|
||||
import com.yovinchen.bookkeeping.model.BookkeepingRecord
|
||||
import com.yovinchen.bookkeeping.model.AnalysisType
|
||||
import com.yovinchen.bookkeeping.model.TransactionType
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.YearMonth
|
||||
import java.time.ZoneId
|
||||
import java.util.Date
|
||||
|
||||
class MemberDetailViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private val database = BookkeepingDatabase.getDatabase(application)
|
||||
private val recordDao = database.bookkeepingDao()
|
||||
|
||||
private val _memberRecords = MutableStateFlow<List<BookkeepingRecord>>(emptyList())
|
||||
val memberRecords: StateFlow<List<BookkeepingRecord>> = _memberRecords.asStateFlow()
|
||||
|
||||
private val _totalAmount = MutableStateFlow(0.0)
|
||||
val totalAmount: StateFlow<Double> = _totalAmount.asStateFlow()
|
||||
|
||||
private val _categoryData = MutableStateFlow<List<Pair<String, Float>>>(emptyList())
|
||||
val categoryData: StateFlow<List<Pair<String, Float>>> = _categoryData.asStateFlow()
|
||||
|
||||
fun loadMemberRecords(
|
||||
memberName: String,
|
||||
category: String,
|
||||
startMonth: YearMonth,
|
||||
endMonth: YearMonth,
|
||||
analysisType: AnalysisType
|
||||
) {
|
||||
val startDate = startMonth.atDay(1).atStartOfDay()
|
||||
.atZone(ZoneId.systemDefault())
|
||||
.toInstant()
|
||||
.let { Date.from(it) }
|
||||
|
||||
val endDate = endMonth.atEndOfMonth().atTime(23, 59, 59)
|
||||
.atZone(ZoneId.systemDefault())
|
||||
.toInstant()
|
||||
.let { Date.from(it) }
|
||||
|
||||
val transactionType = when (analysisType) {
|
||||
AnalysisType.INCOME -> TransactionType.INCOME
|
||||
AnalysisType.EXPENSE -> TransactionType.EXPENSE
|
||||
else -> null
|
||||
}
|
||||
|
||||
val recordsFlow = if (category.isEmpty()) {
|
||||
recordDao.getRecordsByMemberAndDateRange(
|
||||
memberName = memberName,
|
||||
startDate = startDate,
|
||||
endDate = endDate,
|
||||
transactionType = transactionType
|
||||
)
|
||||
} else {
|
||||
recordDao.getRecordsByMemberCategoryAndDateRange(
|
||||
memberName = memberName,
|
||||
category = category,
|
||||
startDate = startDate,
|
||||
endDate = endDate,
|
||||
transactionType = transactionType
|
||||
)
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
recordsFlow.collect { records ->
|
||||
_memberRecords.value = records
|
||||
_totalAmount.value = records.sumOf { it.amount }
|
||||
|
||||
// 计算分类数据
|
||||
val categoryAmounts = records.groupBy { it.category }
|
||||
.mapValues { (_, records) -> records.sumOf { it.amount }.toFloat() }
|
||||
.toList()
|
||||
.sortedByDescending { it.second }
|
||||
_categoryData.value = categoryAmounts
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,38 @@
|
||||
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.Member
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class MemberViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private val memberDao = BookkeepingDatabase.getDatabase(application).memberDao()
|
||||
|
||||
val allMembers: Flow<List<Member>> = memberDao.getAllMembers()
|
||||
|
||||
fun addMember(name: String, description: String = "") {
|
||||
viewModelScope.launch {
|
||||
val member = Member(name = name, description = description)
|
||||
memberDao.insertMember(member)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateMember(member: Member) {
|
||||
viewModelScope.launch {
|
||||
memberDao.updateMember(member)
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteMember(member: Member) {
|
||||
viewModelScope.launch {
|
||||
memberDao.deleteMember(member)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getMemberCount(): Int {
|
||||
return memberDao.getMemberCount()
|
||||
}
|
||||
}
|
After Width: | Height: | Size: 7.8 KiB |
After Width: | Height: | Size: 7.1 KiB |
After Width: | Height: | Size: 6.5 KiB |
After Width: | Height: | Size: 7.8 KiB |
After Width: | Height: | Size: 5.5 KiB |
After Width: | Height: | Size: 6.7 KiB |
After Width: | Height: | Size: 7.2 KiB |
After Width: | Height: | Size: 8.5 KiB |
After Width: | Height: | Size: 8.8 KiB |
After Width: | Height: | Size: 9.1 KiB |
After Width: | Height: | Size: 5.7 KiB |
After Width: | Height: | Size: 8.8 KiB |
After Width: | Height: | Size: 5.5 KiB |
After Width: | Height: | Size: 6.2 KiB |
After Width: | Height: | Size: 5.5 KiB |
After Width: | Height: | Size: 5.6 KiB |
After Width: | Height: | Size: 6.5 KiB |
After Width: | Height: | Size: 6.9 KiB |
After Width: | Height: | Size: 5.6 KiB |
After Width: | Height: | Size: 6.8 KiB |
After Width: | Height: | Size: 8.0 KiB |
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1733368452865" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2083" data-darkreader-inline-fill="" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M259.69 193.89h456.53c8.52 0 15.44 6.92 15.44 15.44v521.43H202.21v-479.4c0-31.72 25.75-57.47 57.47-57.47z" fill="#57B7F9" p-id="2084" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #0a5b92;"></path><path d="M259.69 665.92h471.97v125.74H259.69c-31.72 0-57.47-25.75-57.47-57.47V723.4c0-31.72 25.75-57.47 57.47-57.47z" fill="#33A1F2" p-id="2085" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #1067a6;"></path><path d="M267.14 701.58h453.9c0.67 0 1.21 0.54 1.21 1.21v52.1c0 0.67-0.54 1.21-1.21 1.21h-453.9c-15.04 0-27.26-12.21-27.26-27.26 0-15.04 12.21-27.26 27.26-27.26z" fill="#A1DCFF" p-id="2086" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #343839;"></path><path d="M262.27 193.66v472.26s-55.01 10.41-55.01 38.17V239.34s11.15-33.61 55.01-45.68z" fill="#33A1F2" p-id="2087" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #1067a6;"></path><path d="M515.07 801.66H265.09c-40.18 0-72.87-32.69-72.87-72.87s32.69-72.87 72.87-72.87h417.4c5.52 0 10 4.48 10 10s-4.48 10-10 10H265.08c-29.15 0-52.87 23.72-52.87 52.87s23.72 52.87 52.87 52.87h249.98c5.52 0 10 4.48 10 10s-4.48 10-10 10z" fill="#333333" p-id="2088" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #272a2b;"></path><path d="M202.21 741.89c-5.52 0-10-4.48-10-10V257.88c0-40.18 32.69-72.87 72.87-72.87h451.96c13.57 0 24.61 11.04 24.61 24.61v336.04c0 5.52-4.48 10-10 10s-10-4.48-10-10V209.63c0-2.54-2.07-4.61-4.61-4.61H265.08c-29.15 0-52.87 23.72-52.87 52.87V731.9c0 5.52-4.48 10-10 10z" fill="#333333" p-id="2089" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #272a2b;"></path><path d="M610.28 253.75h-58.59v154.39l58.59-28.83V253.75z" fill="#FFAC3B" p-id="2090" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #ffb148;"></path><path d="M610.28 253.75h58.6v154.39l-58.6-28.83V253.75z" fill="#FFD44A" p-id="2091" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #ffd753;"></path><path d="M789.78 646.03l-43.7-43.7L568.5 779.9l13.7 73.71 207.58-207.58z" fill="#FFAC3B" p-id="2092" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #a86205;"></path><path d="M789.78 646.03l43.7 43.7L655.9 867.31l-73.7-13.7 207.58-207.58z" fill="#FFD44A" p-id="2093" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #785c04;"></path><path d="M668.88 418.14c-1.51 0-3.02-0.34-4.41-1.03l-54.18-26.66-54.18 26.66a10.002 10.002 0 0 1-14.42-8.97V253.75c0-5.52 4.48-10 10-10h117.19c5.52 0 10 4.48 10 10v154.39a10 10 0 0 1-4.72 8.49c-1.61 1-3.45 1.51-5.28 1.51z m-58.59-48.83c1.51 0 3.02 0.34 4.42 1.03l44.18 21.74V263.75H561.7v128.33l44.18-21.74c1.39-0.69 2.9-1.03 4.42-1.03z" fill="#333333" p-id="2094" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #cbc5bc;"></path><path d="M813.81 573.291l48.7 48.698c10.677 10.678 10.684 28.023 0 38.708l-29.034 29.033-87.406-87.405 29.034-29.034c10.677-10.677 28.022-10.684 38.707 0z" fill="#F76C69" p-id="2095" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #f56d68;"></path><path d="M640.86 876.79c-0.51 0-1.02 0-1.54-0.03l-48.58-1.59c-16.35-0.54-29.58-13.76-30.11-30.11l-1.59-48.58c-0.43-13.08 4.63-25.89 13.88-35.14L765.4 568.86c13.44-13.44 35.31-13.44 48.75 0l52.79 52.79c13.44 13.44 13.44 35.31 0 48.75L674.46 862.88c-8.89 8.89-21.06 13.91-33.6 13.91z m148.91-298.03c-3.71 0-7.41 1.41-10.23 4.23L587.06 775.47a27.575 27.575 0 0 0-8.04 20.34l1.59 48.58c0.19 5.85 4.93 10.59 10.78 10.78l48.58 1.59c7.57 0.24 14.99-2.68 20.34-8.04l192.48-192.48c5.64-5.64 5.64-14.82 0-20.47L800 582.98a14.43 14.43 0 0 0-10.23-4.23z" fill="#333333" p-id="2096" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #272a2b;"></path><path d="M799.92 666.17c-2.56 0-5.12-0.98-7.07-2.93l-48.74-48.74c-3.91-3.91-3.91-10.24 0-14.14s10.24-3.91 14.14 0l48.74 48.74c3.91 3.91 3.91 10.24 0 14.14a9.973 9.973 0 0 1-7.07 2.93z" fill="#333333" p-id="2097" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #cbc5bc;"></path></svg>
|
After Width: | Height: | Size: 4.3 KiB |
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1733368445240" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1583" data-darkreader-inline-fill="" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M788.15 816.26H255.01c-49.14 0-88.98-39.84-88.98-88.98V475.71c0-49.14 39.84-88.98 88.98-88.98h155.68c155.29 0 301.1 74.66 391.87 200.65l0.26 0.37a276.537 276.537 0 0 1 52.17 161.66c0 36.92-29.93 66.85-66.85 66.85z" fill="#57B7F9" p-id="1584" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #0a5b92;"></path><path d="M506.75 396.43s0 88.27 87.97 163.08c74.13 63.04 230.31 59.19 230.31 59.19S700.77 413.67 506.75 396.42z" fill="#D4EFFF" p-id="1585" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #252729;"></path><path d="M817.24 628.74h-0.06c-15.5-0.1-56.38-1.2-101.09-9.37-56.13-10.26-99.15-27.83-127.85-52.24-50.61-43.04-73-90.57-82.87-122.87-1.61-5.28 1.36-10.87 6.64-12.49 5.28-1.61 10.87 1.36 12.49 6.64 9.09 29.74 29.77 73.57 76.7 113.48 25.99 22.1 65.86 38.19 118.49 47.8 43.16 7.88 82.64 8.95 97.62 9.05 5.52 0.03 9.97 4.54 9.94 10.06-0.03 5.5-4.51 9.94-10 9.94z" fill="#333333" p-id="1586" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #272a2b;"></path><path d="M384.59 618.75H177.34V501.3h104.04c42.46 0 82.33 20.42 107.15 54.87l0.07 0.1a75.653 75.653 0 0 1 14.27 44.2c0 10.09-8.18 18.28-18.28 18.28z" fill="#D4EFFF" p-id="1587" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #252729;"></path><path d="M165.5 723.68h688.29s11.23 92.58-65.64 92.58H247.82s-82.32-20.45-82.32-92.58z" fill="#33A1F2" p-id="1588" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #1067a6;"></path><path d="M788.15 826.26H255.01c-54.58 0-98.98-44.4-98.98-98.98v-30.54c0-5.52 4.48-10 10-10s10 4.48 10 10v30.54c0 43.55 35.43 78.98 78.98 78.98h533.14c31.34 0 56.85-25.5 56.85-56.85 0-56.27-17.39-110.15-50.28-155.81l-0.26-0.37c-43.62-60.54-101.77-110.51-168.19-144.52-66.41-34.01-140.96-51.98-215.57-51.98H255.01c-43.55 0-78.98 35.43-78.98 78.98v142.76c0 5.52-4.48 10-10 10s-10-4.48-10-10V475.71c0-54.58 44.4-98.98 98.98-98.98h155.68c77.77 0 155.46 18.73 224.69 54.18s129.84 87.53 175.3 150.63l0.26 0.37C846.3 630.99 865 688.92 865 749.41c0 42.37-34.47 76.85-76.85 76.85z" fill="#333333" p-id="1589" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #272a2b;"></path><path d="M754.79 733.68H170.87c-5.52 0-10-4.48-10-10s4.48-10 10-10h583.92c5.52 0 10 4.48 10 10s-4.48 10-10 10z" fill="#333333" p-id="1590" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #272a2b;"></path><path d="M638.22 278.13l120.21-93.43c1.6-1.25 3.66-1.8 5.68-1.57 14.18 1.57 26.44 1.95 41.48 5.19 2.43 0.52 3.51 3.38 2 5.35L717.5 310.78l-79.28-32.64z" fill="#A1DCFF" p-id="1591" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #90d5fb;"></path><path d="M717.5 311.77l62 27.6c2.06 0.94 4.45 0.84 6.43-0.27l65.04-36.36a9.275 9.275 0 0 1 7.6-0.66l16.74 5.87a7.736 7.736 0 0 1 3.33 12.31l-52.47 61.75c-15.4 18.36-48.05 9.04-48.05 9.04s-251.8-69.94-251.83-69.95c-45.45-13.7-51.3-23.32-51.3-37.31 0-18.65 37.31-23.32 37.31-23.32 8.61-4.28 27.72-14.13 54.54-4.93l71.38 23.58 79.28 32.64z" fill="#FFF6DB" p-id="1592" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #362902;"></path><path d="M482 271.73L782 369s33.47 8 55.23 0c0 0-21.97 30.11-58.1 21.05l-281.54-79.28s-27.58-10.3-15.58-39.04z" fill="#33A1F2" p-id="1593" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #1067a6;"></path><path d="M797.61 402.42h-0.27c-11.34-0.04-20.57-2.64-20.96-2.75q-251.74-69.93-251.79-69.94c-0.06-0.02-0.12-0.04-0.18-0.05C480.19 316.35 466 304.95 466 282.8c0-24.61 35.1-31.59 44.31-32.99 9.93-4.91 31.47-14.74 60.73-4.73l66.26 21.89 116-90.16c3.61-2.81 8.32-4.13 12.91-3.62 3.88 0.43 7.7 0.78 11.4 1.11 9.67 0.88 19.67 1.78 31.08 4.24 4.53 0.97 8.18 4.15 9.77 8.49 1.58 4.32 0.86 9.08-1.95 12.73l-64.75 84.18c-3.37 4.38-9.65 5.2-14.02 1.83-4.38-3.37-5.2-9.65-1.83-14.02l58.1-75.53c-6.17-0.9-12.08-1.44-18.2-1.99-3.53-0.32-7.18-0.65-10.96-1.06l-119.49 92.87a9.997 9.997 0 0 1-9.27 1.6l-71.38-23.58s-0.07-0.02-0.11-0.04c-22.45-7.7-38.05 0.05-46.43 4.22l-0.42 0.21c-1.01 0.5-2.09 0.83-3.21 0.97-13.19 1.69-28.55 7.91-28.55 13.4 0 6.75 0 14.4 44.08 27.7 5.88 1.63 251.72 69.92 251.72 69.92 0.31 0.09 26.88 7.08 37.71-5.83l50.37-59.29-13.32-4.67-64.75 36.2a17.182 17.182 0 0 1-15.44 0.65l-61.93-27.57c-5.05-2.25-7.32-8.16-5.07-13.2 2.25-5.05 8.16-7.31 13.2-5.07l60.74 27.04 63.78-35.66c4.82-2.7 10.58-3.19 15.79-1.37l16.74 5.87c2.06 0.72 3.94 1.8 5.6 3.21 7.46 6.33 8.37 17.54 2.05 25l-52.47 61.76c-8.18 9.75-21.05 14.93-37.18 14.93z m-13.08-73.2h0.03-0.03z" fill="#333333" p-id="1594" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #272a2b;"></path></svg>
|
After Width: | Height: | Size: 4.8 KiB |
After Width: | Height: | Size: 9.4 KiB |
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1733382768845" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2690" data-darkreader-inline-fill="" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M653 727.5c-2.9 0-5.8-0.8-8.4-2.5-7.1-4.6-9.1-14.1-4.4-21.2 14.3-21.9 48-43.7 69.3-49.6 8.1-2.2 16.6 2.5 18.8 10.7s-2.5 16.6-10.7 18.8c-15.2 4.2-42.1 22-51.8 36.8-2.9 4.5-7.8 7-12.8 7z" fill="#F5C73E" p-id="2691" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #f6cc4d;"></path><path d="M635.5 750.3c-4.6 0-9.2-2.1-12.2-6.1-5.1-6.8-3.7-16.3 3-21.4 30.8-23.2 69.1-30.4 92.6-29.5 8.4 0.3 15 7.4 14.7 15.9-0.3 8.4-7.4 15.1-15.9 14.7-19.2-0.7-50.3 6.3-73 23.4-2.8 2-6 3-9.2 3z" fill="#F5C73E" p-id="2692" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #f6cc4d;"></path><path d="M629.3 906.1H394.7l-77.9-74c23.4-89.3 83.4-130.1 83.4-130.1h223.7s59.9 40.8 83.4 130.1l-78 74z" fill="#64C4F6" p-id="2693" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #0d608a;"></path><path d="M394.7 827.1v79h-87.2c0-27.6 3.5-57.2 9.3-79h77.9zM716.5 906.1h-87.2v-79h77.9c5.7 21.8 9.3 51.4 9.3 79z" fill="#FFECE3" p-id="2694" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #431702;"></path><path d="M628.7 700.6c64.7 102.9-28.9 155.3-116.7 155.3s-181.4-52.3-116.7-155.3h233.4z" fill="#99D37A" p-id="2695" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #52762a;"></path><path d="M512 878.5c-56 0-122.2-19.9-149.3-68.9-13.4-24.2-21.9-64.9 13.4-121.1 6.7-10.6 20.6-13.8 31.2-7.1s13.8 20.6 7.1 31.2c-18.9 30.1-23 55.3-12.1 75.1 15 27.2 59.1 45.5 109.6 45.5s94.6-18.3 109.6-45.5c10.9-19.7 6.8-45-12.1-75.1-6.7-10.6-3.5-24.6 7.1-31.2 10.6-6.7 24.6-3.5 31.2 7.1 35.3 56.2 26.8 96.9 13.4 121.1-26.9 49-93.2 68.9-149.1 68.9z" fill="#FCE170" p-id="2696" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #665306;"></path><path d="M249.7 470.5m-76.8 0a76.8 76.8 0 1 0 153.6 0 76.8 76.8 0 1 0-153.6 0Z" fill="#F7DBD0" p-id="2697" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #f4c7b4;"></path><path d="M774.3 470.5m-76.8 0a76.8 76.8 0 1 0 153.6 0 76.8 76.8 0 1 0-153.6 0Z" fill="#F7DBD0" p-id="2698" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #f4c7b4;"></path><path d="M512 459.5m-269.3 0a269.3 269.3 0 1 0 538.6 0 269.3 269.3 0 1 0-538.6 0Z" fill="#FFECE3" p-id="2699" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #431702;"></path><path d="M509.3 265c-14 3-27.8-5.8-30.8-19.8s5.8-27.7 19.8-30.8c26-5.6 37.7-30.6 17-56.4-8.9-11.2-7.1-27.5 4.1-36.4 11.2-8.9 27.5-7.1 36.4 4.1 44.5 55.7 17 125.4-46.5 139.3z" fill="#47515E" p-id="2700" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #b7afa4;"></path><path d="M560 670.9h-96c-44.1 0-80-32.3-80-72s35.9-72 80-72h2.4l2 1.3c26.4 17.6 60.8 17.6 87.1 0l2-1.3h2.4c44.1 0 80 32.3 80 72s-35.8 72-79.9 72z" fill="#4EAEE0" p-id="2701" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #1f6b93;"></path><path d="M543.3 750.9h-62.7c-22.1 0-40-17.9-40-40 0-1 0-2.1 0.1-3.1l2.9-37.7c2.9-37.7 35.9-65.9 73.6-63 33.6 2.6 60.4 29.3 63 63l2.9 37.7c1.7 22-14.8 41.3-36.8 43-0.9 0-1.9 0.1-3 0.1z m-31.3-128c-27.4 0.1-50.1 21.2-52.3 48.5l-2.9 37.7c-1 13.2 8.9 24.8 22.1 25.8 0.6 0 1.2 0.1 1.9 0.1h62.7c13.3 0 24-10.7 24-24 0-0.6 0-1.2-0.1-1.9l-2.9-37.7c-2.4-27.4-25.1-48.4-52.5-48.5z" fill="#64C4F6" p-id="2702" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #69c4f2;"></path><path d="M512 622.9m-48 0a48 48 0 1 0 96 0 48 48 0 1 0-96 0Z" fill="#64C4F6" p-id="2703" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #69c4f2;"></path><path d="M600 582.9m-8 0a8 8 0 1 0 16 0 8 8 0 1 0-16 0Z" fill="#64C4F6" p-id="2704" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #69c4f2;"></path><path d="M600 614.9m-8 0a8 8 0 1 0 16 0 8 8 0 1 0-16 0Z" fill="#64C4F6" p-id="2705" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #69c4f2;"></path><path d="M424 582.9m-8 0a8 8 0 1 0 16 0 8 8 0 1 0-16 0Z" fill="#64C4F6" p-id="2706" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #69c4f2;"></path><path d="M424 614.9m-8 0a8 8 0 1 0 16 0 8 8 0 1 0-16 0Z" fill="#64C4F6" p-id="2707" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #69c4f2;"></path><path d="M436.3 516.3c-7 0-12.7-5.7-12.7-12.7v-10.2c0-7 5.7-12.7 12.7-12.7s12.7 5.7 12.7 12.7v10.2c0 7.1-5.7 12.7-12.7 12.7zM587.7 516.3c-7 0-12.7-5.7-12.7-12.7v-10.2c0-7 5.7-12.7 12.7-12.7s12.7 5.7 12.7 12.7v10.2c0 7.1-5.7 12.7-12.7 12.7z" fill="#450064" p-id="2708" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #cbc6bd;"></path></svg>
|
After Width: | Height: | Size: 4.7 KiB |
After Width: | Height: | Size: 5.6 KiB |
1
app/src/main/res/drawable/member/ic_member_boy_24dp.xml
Normal file
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1733382780211" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3211" data-darkreader-inline-fill="" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M629.3 906.1H394.7l-77.9-74c23.4-89.3 83.4-130.1 83.4-130.1h223.7s59.9 40.8 83.4 130.1l-78 74z" fill="#99D37A" p-id="3212" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #52762a;"></path><path d="M394.7 827.1v79h-87.2c0-27.6 3.5-57.2 9.3-79h77.9zM716.5 906.1h-87.2v-79h77.9c5.7 21.8 9.3 51.4 9.3 79z" fill="#FFECE3" p-id="3213" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #431702;"></path><path d="M438.4 707.7a73.6 47.4 0 1 0 147.2 0 73.6 47.4 0 1 0-147.2 0Z" fill="#F7DBD0" p-id="3214" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #f4c7b4;"></path><path d="M507.3 720l-0.3 0.5-0.3-0.5h0.3z" fill="#EFEAEB" p-id="3215" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #e0dbd5;"></path><path d="M507.3 720l-0.3 0.5-0.3-0.5h0.3z" fill="#EFEAEB" p-id="3216" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #e0dbd5;"></path><path d="M507.3 720l-0.3 0.5-0.3-0.5h0.3z" fill="#EFEAEB" p-id="3217" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #e0dbd5;"></path><path d="M507.3 720l-0.3 0.5-0.3-0.5h0.3z" fill="#EFEAEB" p-id="3218" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #e0dbd5;"></path><path d="M249.7 470.5m-76.8 0a76.8 76.8 0 1 0 153.6 0 76.8 76.8 0 1 0-153.6 0Z" fill="#F7DBD0" p-id="3219" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #f4c7b4;"></path><path d="M774.3 470.5m-76.8 0a76.8 76.8 0 1 0 153.6 0 76.8 76.8 0 1 0-153.6 0Z" fill="#F7DBD0" p-id="3220" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #f4c7b4;"></path><path d="M512 459.5m-269.3 0a269.3 269.3 0 1 0 538.6 0 269.3 269.3 0 1 0-538.6 0Z" fill="#FFECE3" p-id="3221" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #431702;"></path><path d="M436.3 526.3c-7 0-12.7-5.7-12.7-12.7v-10.2c0-7 5.7-12.7 12.7-12.7s12.7 5.7 12.7 12.7v10.2c0 7.1-5.7 12.7-12.7 12.7zM587.7 526.3c-7 0-12.7-5.7-12.7-12.7v-10.2c0-7 5.7-12.7 12.7-12.7s12.7 5.7 12.7 12.7v10.2c0 7.1-5.7 12.7-12.7 12.7zM512 597.7c-16.4 0-31.9-7.1-42.7-19.4-4.6-5.3-4-13.3 1.2-17.9 5.3-4.6 13.3-4 17.9 1.2 6 6.8 14.6 10.8 23.6 10.8s17.6-3.9 23.6-10.7c4.6-5.3 12.6-5.8 17.9-1.2 5.3 4.6 5.8 12.6 1.2 17.9-10.8 12.2-26.3 19.3-42.7 19.3z" fill="#450064" p-id="3222" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #cbc6bd;"></path><path d="M812.6 394c-4.1 0.1-8.3 0.2-12.4 0.2-83.8 0-162.2-23.1-229.1-63.4-69.3 44.5-151.8 70.4-240.4 70.4-40.9 0-80.6-5.5-118.3-15.9 11-87.8 60.1-164.1 130.1-211.9C390.9 140.3 449.3 121 512 121s121 19.4 169.4 52.4c15.7 10.7 30.4 22.9 43.8 36.3 48.4 48.3 80.5 112.7 87.4 184.3z" fill="#5C6A7C" p-id="3223" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #4c5662;"></path><path d="M342.6 328.1c0 10.3-4.2 19.6-11 26.4s-16.1 11-26.4 11H121.9c-20.5 0-37.3-16.8-37.3-37.3 0-10.3 4.2-19.6 11-26.4s16.1-11 26.4-11h183.4c20.4 0 37.2 16.8 37.2 37.3z" fill="#4EAEE0" p-id="3224" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #1f6b93;"></path><path d="M807 287.7C779.9 149.5 658.1 45.2 512 45.2S244.1 149.5 217 287.7c-3.7 18.8-5.6 38.2-5.6 58.1 0 3.6 0.1 7.1 0.2 10.6 0.2 5.1 4.3 9.1 9.4 9.1h582c5.1 0 9.2-4 9.4-9.1 0.1-3.5 0.2-7.1 0.2-10.6 0-19.9-1.9-39.3-5.6-58.1z m-223.9 19c-4.1 4.1-9.7 6.6-15.9 6.6H456.8c-12.4 0-22.5-10.1-22.5-22.5 0-6.2 2.5-11.8 6.6-15.9s9.7-6.6 15.9-6.6h110.4c12.4 0 22.5 10.1 22.5 22.5 0 6.2-2.5 11.8-6.6 15.9z" fill="#64C4F6" p-id="3225" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #0d608a;"></path></svg>
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1733382752696" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2177" data-darkreader-inline-fill="" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M903.5 836.3c0 23.5-0.6 46.7-1.6 69.8H122.1c-1-22.9-1.6-46.3-1.6-69.8C120.5 415.9 295.8 75 512 75s391.5 340.9 391.5 761.3z" fill="#F3F3F3" p-id="2178" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #202123;"></path><path d="M181.8 906c0-414.3 95.2-833.6 265.1-833.6 0 0 19.6-34.4 65.1-34.4s65.1 34.4 65.1 34.4c201.7 0 265.1 419.3 265.1 833.6H181.8z" fill="#DEE3E3" p-id="2179" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #2a2d2e;"></path><path d="M548.6 50.4c-12.6-5.7-26.6-8.9-41.3-8.9-55.5 0-100.4 45-100.4 100.4s45 100.4 100.4 100.4c14.7 0 28.7-3.2 41.3-8.9 34.8-15.8 59.1-50.8 59.1-91.5s-24.2-75.8-59.1-91.5z" fill="#47515E" p-id="2180" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #b7afa4;"></path><path d="M707.3 832.1l-78 74H394.7l-77.9-74c2-7.7 4.3-15 6.8-22 26.5-74 76.6-108.1 76.6-108.1h223.7s50 34.1 76.6 108.1c2.5 7 4.8 14.3 6.8 22z" fill="#FFECE3" p-id="2181" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #431702;"></path><path d="M707.3 832.1l-78 74H394.7l-77.9-74c2-7.7 4.3-15 6.8-22h376.8c2.6 7 4.9 14.3 6.9 22z" fill="#E96E67" p-id="2182" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #851c16;"></path><path d="M394.7 827.1v79h-87.2c0-27.6 3.5-57.2 9.3-79h77.9zM716.5 906.1h-87.2v-79h77.9c5.7 21.8 9.3 51.4 9.3 79z" fill="#FFECE3" p-id="2183" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #431702;"></path><path d="M249.7 470.5m-76.8 0a76.8 76.8 0 1 0 153.6 0 76.8 76.8 0 1 0-153.6 0Z" fill="#F7DBD0" p-id="2184" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #f4c7b4;"></path><path d="M774.3 470.5m-76.8 0a76.8 76.8 0 1 0 153.6 0 76.8 76.8 0 1 0-153.6 0Z" fill="#F7DBD0" p-id="2185" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #f4c7b4;"></path><path d="M512 459.5m-269.3 0a269.3 269.3 0 1 0 538.6 0 269.3 269.3 0 1 0-538.6 0Z" fill="#FFECE3" p-id="2186" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #431702;"></path><path d="M813.6 407.5c-113.6-0.1-218.4-38.2-302.2-102.3-83.5 63.9-187.8 101.9-300.9 102.3 5.7-112.3 72.7-208.3 168.2-255.4 40.2-19.8 85.5-31 133.4-31 47.5 0 92.4 11 132.4 30.5 96 46.8 163.4 143.2 169.1 255.9z" fill="#5C6A7C" p-id="2187" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #4c5662;"></path><path d="M704.9 827.1H319.7c-4.2 0-7-4.4-5.1-8.2l34.5-71.7c0.9-2 2.9-3.2 5.1-3.2h316.3c2.2 0 4.2 1.3 5.1 3.2l34.5 71.7c1.8 3.8-1 8.2-5.2 8.2z" fill="#F7867F" p-id="2188" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #7a110a;"></path><path d="M326.5 558.2a49.8 39.5 0 1 0 99.6 0 49.8 39.5 0 1 0-99.6 0Z" fill="#FFD7DF" p-id="2189" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #ffb3c0;"></path><path d="M597.9 558.2a49.8 39.5 0 1 0 99.6 0 49.8 39.5 0 1 0-99.6 0Z" fill="#FFD7DF" p-id="2190" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #ffb3c0;"></path><path d="M436.3 526.3c-7 0-12.7-5.7-12.7-12.7v-10.2c0-7 5.7-12.7 12.7-12.7s12.7 5.7 12.7 12.7v10.2c0 7.1-5.7 12.7-12.7 12.7zM587.7 526.3c-7 0-12.7-5.7-12.7-12.7v-10.2c0-7 5.7-12.7 12.7-12.7s12.7 5.7 12.7 12.7v10.2c0 7.1-5.7 12.7-12.7 12.7zM512 597.7c-16.4 0-31.9-7.1-42.7-19.4-4.6-5.3-4-13.3 1.2-17.9 5.3-4.6 13.3-4 17.9 1.2 6 6.8 14.6 10.8 23.6 10.8s17.6-3.9 23.6-10.7c4.6-5.3 12.6-5.8 17.9-1.2 5.3 4.6 5.8 12.6 1.2 17.9-10.8 12.2-26.3 19.3-42.7 19.3z" fill="#450064" p-id="2191" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #cbc6bd;"></path><path d="M606.2 94.4l-20.8 54.5H435l-21-55 0.8-0.5c13.2 22 24.6 34.7 33 29.9 14.5-8.1 13.9-25.3 13.5-42.4h0.7c8.3 19 16.7 36.4 27.2 35 18.1-2.3 19-31.3 20.8-57.8h0.8c1.7 26.5 2.7 55.6 20.8 57.8 6.2 0.8 11.6-4.9 16.9-13.5 3.6-6.1 7-13.6 10.5-21.5l0.7 0.1c-0.4 17.1-1 34.3 13.5 42.5 8.4 4.8 19.6-7.7 32.7-29.2l0.3 0.1z" fill="#FCE170" p-id="2192" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #fee26e;"></path><path d="M606.2 94.4c0.7 0.2 1.3 0.3 2.1 0.3 4.4 0 7.9-3.6 7.9-8 0.1-4.4-3.5-8-7.8-8-4.4 0-7.9 3.6-7.9 8 0 3.3 2.1 6.2 5 7.4M414.6 93.3c2.2-1.4 3.6-3.9 3.6-6.8 0-4.5-3.5-8-7.9-8s-7.8 3.7-7.8 8.1c0 4.5 3.5 8 7.9 8 1.2 0 2.5-0.3 3.5-0.9M461.8 81c5-0.6 9-5 9-10.2 0-5.7-4.5-10.3-10.1-10.3-5.5 0-10.1 4.6-10.1 10.3s4.5 10.3 10.1 10.3h0.4M510.6 58.1c6.9-0.2 12.4-5.8 12.4-12.8s-5.7-12.8-12.8-12.8c-7.1 0-12.8 5.7-12.8 12.8 0 6.9 5.5 12.6 12.4 12.8M559.3 81c0.4 0 0.7 0.1 1 0.1 5.5 0 10.1-4.6 10.1-10.3s-4.4-10.3-10.1-10.3c-5.6 0-10.1 4.6-10.1 10.3 0 5 3.6 9.3 8.4 10.1" fill="#FCE170" p-id="2193" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #665306;"></path></svg>
|
After Width: | Height: | Size: 4.8 KiB |
After Width: | Height: | Size: 7.8 KiB |
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1733382737759" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1666" data-darkreader-inline-fill="" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M707.3 832.1L631.5 904l-2.2 2.1H394.7l-2.2-2.1-75.7-71.9c23.4-89.3 83.4-130.1 83.4-130.1h223.7s59.9 40.8 83.4 130.1z" fill="#D79B89" p-id="1667" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #6c3726;"></path><path d="M394.7 827.1v79h-87.2c0-27.6 3.5-57.2 9.3-79h77.9zM716.5 906.1h-87.2v-79h77.9c5.7 21.8 9.3 51.4 9.3 79z" fill="#FFECE3" p-id="1668" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #431702;"></path><path d="M640.3 819.4c0 29.9-3.1 58.4-8.8 84.6l-2.2 2.1H394.7l-2.2-2.1c-5.7-26.2-8.8-54.8-8.8-84.6 0-42.9 6.5-83 17.7-117.4h58.5c1.8 74.9 24.4 134.2 52.1 134.2 27.7 0 50.3-59.2 52.1-134.2h58.5c11.3 34.4 17.7 74.6 17.7 117.4z" fill="#64C4F6" p-id="1669" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #0d608a;"></path><path d="M517.3 720.1l-26.8 38.6c-9.6 13.9-30 14.4-40.3 1l-46.4-60.4 113.5 20.8z" fill="#FFFFFF" p-id="1670" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #ece8e2;"></path><path d="M506.7 720.1l26.8 38.6c9.6 13.9 30 14.4 40.3 1l46.4-60.4-113.5 20.8z" fill="#FFFFFF" p-id="1671" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #ece8e2;"></path><path d="M507.3 720l-0.3 0.5-0.3-0.5h0.3z" fill="#EFEAEB" p-id="1672" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #e0dbd5;"></path><path d="M507.3 720l-0.3 0.5-0.3-0.5h0.3z" fill="#EFEAEB" p-id="1673" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #e0dbd5;"></path><path d="M507.3 720l-0.3 0.5-0.3-0.5h0.3z" fill="#EFEAEB" p-id="1674" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #e0dbd5;"></path><path d="M507.3 720l-0.3 0.5-0.3-0.5h0.3z" fill="#EFEAEB" p-id="1675" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #e0dbd5;"></path><path d="M249.7 470.5m-76.8 0a76.8 76.8 0 1 0 153.6 0 76.8 76.8 0 1 0-153.6 0Z" fill="#F7DBD0" p-id="1676" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #f4c7b4;"></path><path d="M774.3 470.5m-76.8 0a76.8 76.8 0 1 0 153.6 0 76.8 76.8 0 1 0-153.6 0Z" fill="#F7DBD0" p-id="1677" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #f4c7b4;"></path><path d="M512 459.5m-269.3 0a269.3 269.3 0 1 0 538.6 0 269.3 269.3 0 1 0-538.6 0Z" fill="#FFECE3" p-id="1678" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #431702;"></path><path d="M812.6 394c-4.1 0.1-8.3 0.2-12.4 0.2-83.8 0-162.2-23.1-229.1-63.4-69.3 44.5-151.8 70.4-240.4 70.4-40.9 0-80.6-5.5-118.3-15.9 11-87.8 60.1-164.1 130.1-211.9C390.9 140.3 449.3 121 512 121s121 19.4 169.4 52.4c15.7 10.7 30.4 22.9 43.8 36.3 48.4 48.3 80.5 112.7 87.4 184.3z" fill="#5C6A7C" p-id="1679" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #4c5662;"></path><path d="M436.3 526.3c-7 0-12.7-5.7-12.7-12.7v-10.2c0-7 5.7-12.7 12.7-12.7s12.7 5.7 12.7 12.7v10.2c0 7.1-5.7 12.7-12.7 12.7zM587.7 526.3c-7 0-12.7-5.7-12.7-12.7v-10.2c0-7 5.7-12.7 12.7-12.7s12.7 5.7 12.7 12.7v10.2c0 7.1-5.7 12.7-12.7 12.7zM512 645.3c-16.4 0-31.9-7.1-42.7-19.4-4.6-5.3-4-13.3 1.2-17.9 5.3-4.6 13.3-4 17.9 1.2 6 6.8 14.6 10.8 23.6 10.8s17.6-3.9 23.6-10.7c4.6-5.3 12.6-5.8 17.9-1.2 5.3 4.6 5.8 12.6 1.2 17.9-10.8 12.2-26.3 19.3-42.7 19.3z" fill="#450064" p-id="1680" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #cbc6bd;"></path><path d="M609.6 619H414.4c-5.9 0-10.7-4.8-10.7-10.7 0-28.1 23-51.1 51.1-51.1h114.4c28.1 0 51.1 23 51.1 51.1 0 5.9-4.8 10.7-10.7 10.7z" fill="#5C6A7C" p-id="1681" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #4c5662;"></path></svg>
|
After Width: | Height: | Size: 3.8 KiB |
1
app/src/main/res/drawable/member/ic_member_girl_24dp.xml
Normal file
After Width: | Height: | Size: 5.1 KiB |
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1733382759052" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2347" data-darkreader-inline-fill="" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M707.3 832.1L631.5 904l-2.2 2.1H394.7l-2.2-2.1-75.7-71.9c23.4-89.3 83.4-130.1 83.4-130.1h223.7s59.9 40.8 83.4 130.1z" fill="#64C4F6" p-id="2348" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #0d608a;"></path><path d="M394.7 827.1v79h-87.2c0-27.6 3.5-57.2 9.3-79h77.9zM716.5 906.1h-87.2v-79h77.9c5.7 21.8 9.3 51.4 9.3 79z" fill="#FFECE3" p-id="2349" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #431702;"></path><path d="M640.3 819.4c0 29.9-3.1 58.4-8.8 84.6l-2.2 2.1H394.7l-2.2-2.1c-5.7-26.2-8.8-54.8-8.8-84.6 0-42.9 6.5-83 17.7-117.4h58.5c1.8 74.9 24.4 134.2 52.1 134.2 27.7 0 50.3-59.2 52.1-134.2h58.5c11.3 34.4 17.7 74.6 17.7 117.4z" fill="#D79B89" p-id="2350" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #6c3726;"></path><path d="M517.3 720.1l-26.8 38.6c-9.6 13.9-30 14.4-40.3 1l-46.4-60.4 113.5 20.8z" fill="#FFFFFF" p-id="2351" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #ece8e2;"></path><path d="M506.7 720.1l26.8 38.6c9.6 13.9 30 14.4 40.3 1l46.4-60.4-113.5 20.8z" fill="#FFFFFF" p-id="2352" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #ece8e2;"></path><path d="M507.3 720l-0.3 0.5-0.3-0.5h0.3z" fill="#EFEAEB" p-id="2353" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #e0dbd5;"></path><path d="M507.3 720l-0.3 0.5-0.3-0.5h0.3z" fill="#EFEAEB" p-id="2354" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #e0dbd5;"></path><path d="M507.3 720l-0.3 0.5-0.3-0.5h0.3z" fill="#EFEAEB" p-id="2355" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #e0dbd5;"></path><path d="M507.3 720l-0.3 0.5-0.3-0.5h0.3z" fill="#EFEAEB" p-id="2356" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #e0dbd5;"></path><path d="M249.7 470.5m-76.8 0a76.8 76.8 0 1 0 153.6 0 76.8 76.8 0 1 0-153.6 0Z" fill="#F7DBD0" p-id="2357" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #f4c7b4;"></path><path d="M774.3 470.5m-76.8 0a76.8 76.8 0 1 0 153.6 0 76.8 76.8 0 1 0-153.6 0Z" fill="#F7DBD0" p-id="2358" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #f4c7b4;"></path><path d="M512 459.5m-269.3 0a269.3 269.3 0 1 0 538.6 0 269.3 269.3 0 1 0-538.6 0Z" fill="#FFECE3" p-id="2359" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #431702;"></path><path d="M450.9 552.3H328.7c-28.2 0-51.2-23-51.2-51.2v-17.8c0-28.2 23-51.2 51.2-51.2h122.2c28.2 0 51.2 23 51.2 51.2v17.8c0 28.2-23 51.2-51.2 51.2zM328.7 452.1c-17.2 0-31.2 14-31.2 31.2v17.8c0 17.2 14 31.2 31.2 31.2h122.2c17.2 0 31.2-14 31.2-31.2v-17.8c0-17.2-14-31.2-31.2-31.2H328.7zM695.5 552.3H573.3c-28.2 0-51.2-23-51.2-51.2v-17.8c0-28.2 23-51.2 51.2-51.2h122.2c28.2 0 51.2 23 51.2 51.2v17.8c0 28.2-23 51.2-51.2 51.2zM573.3 452.1c-17.2 0-31.2 14-31.2 31.2v17.8c0 17.2 14 31.2 31.2 31.2h122.2c17.2 0 31.2-14 31.2-31.2v-17.8c0-17.2-14-31.2-31.2-31.2H573.3z" fill="#450064" p-id="2360" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #36024d;"></path><path d="M719.6 459.4c-3 0-5.9-1.3-7.9-3.8-3.4-4.3-2.6-10.6 1.7-14l63.6-49.8c4.3-3.4 10.6-2.6 14 1.7 3.4 4.3 2.6 10.6-1.7 14l-63.6 49.8c-1.8 1.4-3.9 2.1-6.1 2.1zM304.6 459.4c-2.2 0-4.3-0.7-6.2-2.1l-63.6-49.8c-4.3-3.4-5.1-9.7-1.7-14 3.4-4.3 9.7-5.1 14-1.7l63.6 49.8c4.3 3.4 5.1 9.7 1.7 14-1.9 2.5-4.8 3.8-7.8 3.8zM532.1 511.1h-40c-5.5 0-10-4.5-10-10s4.5-10 10-10h40c5.5 0 10 4.5 10 10s-4.5 10-10 10z" fill="#450064" p-id="2361" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #36024d;"></path><path d="M242.4 418.4c52.1 2.2 55.6-121.9 90.9-160.4-79.2-46.8-128.2 30.1-137.4 73.5-9.1 43.5 5.6 85.1 46.5 86.9zM781.5 418.4c-52.1 2.2-55.6-121.9-90.9-160.4 79.2-46.8 128.2 30.1 137.4 73.5 9.2 43.5-5.5 85.1-46.5 86.9z" fill="#F3F3F3" p-id="2362" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #202123;"></path><path d="M436.3 526.3c-7 0-12.7-5.7-12.7-12.7v-10.2c0-7 5.7-12.7 12.7-12.7s12.7 5.7 12.7 12.7v10.2c0 7.1-5.7 12.7-12.7 12.7zM587.7 526.3c-7 0-12.7-5.7-12.7-12.7v-10.2c0-7 5.7-12.7 12.7-12.7s12.7 5.7 12.7 12.7v10.2c0 7.1-5.7 12.7-12.7 12.7zM512 597.7c-16.4 0-31.9-7.1-42.7-19.4-4.6-5.3-4-13.3 1.2-17.9 5.3-4.6 13.3-4 17.9 1.2 6 6.8 14.6 10.8 23.6 10.8s17.6-3.9 23.6-10.7c4.6-5.3 12.6-5.8 17.9-1.2 5.3 4.6 5.8 12.6 1.2 17.9-10.8 12.2-26.3 19.3-42.7 19.3z" fill="#450064" p-id="2363" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #cbc6bd;"></path></svg>
|
After Width: | Height: | Size: 4.7 KiB |
After Width: | Height: | Size: 5.4 KiB |
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1733382748360" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2007" data-darkreader-inline-fill="" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M629.3 906.1H394.7l-77.9-74c23.4-89.3 83.4-130.1 83.4-130.1h223.7s59.9 40.8 83.4 130.1l-78 74z" fill="#F3F3F3" p-id="2008" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #202123;"></path><path d="M394.7 827.1v79h-87.2c0-27.6 3.5-57.2 9.3-79h77.9zM716.5 906.1h-87.2v-79h77.9c5.7 21.8 9.3 51.4 9.3 79z" fill="#F3F3F3" p-id="2009" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #202123;"></path><path d="M549.3 735.6L538.5 764l-1.8 4.6-7.4 19.5h-37.6l-20-52.5z" fill="#E96E67" p-id="2010" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #e7726a;"></path><path d="M529.3 788.1h-37.6l-20 118h77.6z" fill="#F7867F" p-id="2011" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #f48077;"></path><path d="M640.3 819.4c0 29.9-3.1 58.4-8.8 84.6l-2.2 2.1H394.7l-2.2-2.1c-5.7-26.2-8.8-54.8-8.8-84.6 0-42.9 6.5-83 17.7-117.4h58.5c1.8 74.9 24.4 134.2 52.1 134.2 27.7 0 50.3-59.2 52.1-134.2h58.5c11.3 34.4 17.7 74.6 17.7 117.4z" fill="#47515E" p-id="2012" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #3a424a;"></path><path d="M517.3 720.1l-26.8 38.6c-9.6 13.9-30 14.4-40.3 1l-46.4-60.4 113.5 20.8z" fill="#FFFFFF" p-id="2013" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #ece8e2;"></path><path d="M506.7 720.1l26.8 38.6c9.6 13.9 30 14.4 40.3 1l46.4-60.4-113.5 20.8z" fill="#FFFFFF" p-id="2014" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #ece8e2;"></path><path d="M507.3 720l-0.3 0.5-0.3-0.5h0.3z" fill="#EFEAEB" p-id="2015" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #e0dbd5;"></path><path d="M507.3 720l-0.3 0.5-0.3-0.5h0.3z" fill="#EFEAEB" p-id="2016" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #e0dbd5;"></path><path d="M507.3 720l-0.3 0.5-0.3-0.5h0.3z" fill="#EFEAEB" p-id="2017" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #e0dbd5;"></path><path d="M507.3 720l-0.3 0.5-0.3-0.5h0.3z" fill="#EFEAEB" p-id="2018" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #e0dbd5;"></path><path d="M249.7 470.5m-76.8 0a76.8 76.8 0 1 0 153.6 0 76.8 76.8 0 1 0-153.6 0Z" fill="#F7DBD0" p-id="2019" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #f4c7b4;"></path><path d="M774.3 470.5m-76.8 0a76.8 76.8 0 1 0 153.6 0 76.8 76.8 0 1 0-153.6 0Z" fill="#F7DBD0" p-id="2020" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #f4c7b4;"></path><path d="M512 459.5m-269.3 0a269.3 269.3 0 1 0 538.6 0 269.3 269.3 0 1 0-538.6 0Z" fill="#FFECE3" p-id="2021" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #431702;"></path><path d="M813.6 407.5c-113.6-0.1-218.4-38.2-302.2-102.3-83.5 63.9-187.8 101.9-300.9 102.3 5.7-112.3 72.7-208.3 168.2-255.4 40.2-19.8 85.5-31 133.4-31 47.5 0 92.4 11 132.4 30.5 96 46.8 163.4 143.2 169.1 255.9z" fill="#5C6A7C" p-id="2022" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #4c5662;"></path><path d="M436.3 526.3c-7 0-12.7-5.7-12.7-12.7v-10.2c0-7 5.7-12.7 12.7-12.7s12.7 5.7 12.7 12.7v10.2c0 7.1-5.7 12.7-12.7 12.7zM587.7 526.3c-7 0-12.7-5.7-12.7-12.7v-10.2c0-7 5.7-12.7 12.7-12.7s12.7 5.7 12.7 12.7v10.2c0 7.1-5.7 12.7-12.7 12.7zM512 597.7c-16.4 0-31.9-7.1-42.7-19.4-4.6-5.3-4-13.3 1.2-17.9 5.3-4.6 13.3-4 17.9 1.2 6 6.8 14.6 10.8 23.6 10.8s17.6-3.9 23.6-10.7c4.6-5.3 12.6-5.8 17.9-1.2 5.3 4.6 5.8 12.6 1.2 17.9-10.8 12.2-26.3 19.3-42.7 19.3z" fill="#450064" p-id="2023" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #cbc6bd;"></path></svg>
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1733382743744" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1835" data-darkreader-inline-fill="" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M884.7 678.4c-39 0-70.7-31.7-70.7-70.7V422.9c0-9.7-0.5-19.4-1.5-28.9-6.9-71.5-39-136-87.3-184.3-13.4-13.4-28.1-25.6-43.8-36.3C633 140.4 574.7 121 512 121s-121.1 19.3-169.4 52.4c-70 47.8-119.1 124.1-130.1 211.9-1.6 12.3-2.4 24.9-2.4 37.6v184.8c0 39-31.7 70.7-70.7 70.7h-0.1c0 82.1 66.6 148.7 148.7 148.7h448c82.1 0 148.7-66.6 148.7-148.7z" fill="#AD6D5A" p-id="1836" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #855343;"></path><path d="M629.3 906.1H394.7l-77.9-74c23.4-89.3 83.4-130.1 83.4-130.1h223.7s59.9 40.8 83.4 130.1l-78 74z" fill="#F7867F" p-id="1837" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #7a110a;"></path><path d="M394.7 827.1v79h-87.2c0-27.6 3.5-57.2 9.3-79h77.9zM716.5 906.1h-87.2v-79h77.9c5.7 21.8 9.3 51.4 9.3 79z" fill="#FFECE3" p-id="1838" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #431702;"></path><path d="M517.3 720.1l-26.8 38.6c-9.6 13.9-30 14.4-40.3 1l-46.4-60.4 113.5 20.8z" fill="#FFFFFF" p-id="1839" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #ece8e2;"></path><path d="M506.7 720.1l26.8 38.6c9.6 13.9 30 14.4 40.3 1l46.4-60.4-113.5 20.8z" fill="#FFFFFF" p-id="1840" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #ece8e2;"></path><path d="M507.3 720l-0.3 0.5-0.3-0.5h0.3z" fill="#EFEAEB" p-id="1841" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #e0dbd5;"></path><path d="M507.3 720l-0.3 0.5-0.3-0.5h0.3z" fill="#EFEAEB" p-id="1842" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #e0dbd5;"></path><path d="M507.3 720l-0.3 0.5-0.3-0.5h0.3z" fill="#EFEAEB" p-id="1843" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #e0dbd5;"></path><path d="M507.3 720l-0.3 0.5-0.3-0.5h0.3z" fill="#EFEAEB" p-id="1844" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #e0dbd5;"></path><path d="M249.7 470.5m-76.8 0a76.8 76.8 0 1 0 153.6 0 76.8 76.8 0 1 0-153.6 0Z" fill="#F7DBD0" p-id="1845" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #f4c7b4;"></path><path d="M774.3 470.5m-76.8 0a76.8 76.8 0 1 0 153.6 0 76.8 76.8 0 1 0-153.6 0Z" fill="#F7DBD0" p-id="1846" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #f4c7b4;"></path><path d="M251.5 547.6m-36.9 0a36.9 36.9 0 1 0 73.8 0 36.9 36.9 0 1 0-73.8 0Z" fill="#FCE170" p-id="1847" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #fee26e;"></path><path d="M772.5 547.6m-36.9 0a36.9 36.9 0 1 0 73.8 0 36.9 36.9 0 1 0-73.8 0Z" fill="#FCE170" p-id="1848" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #fee26e;"></path><path d="M512 459.5m-269.3 0a269.3 269.3 0 1 0 538.6 0 269.3 269.3 0 1 0-538.6 0Z" fill="#FFECE3" p-id="1849" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #431702;"></path><path d="M326.5 558.2a49.8 39.5 0 1 0 99.6 0 49.8 39.5 0 1 0-99.6 0Z" fill="#FFD7DF" p-id="1850" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #ffb3c0;"></path><path d="M597.9 558.2a49.8 39.5 0 1 0 99.6 0 49.8 39.5 0 1 0-99.6 0Z" fill="#FFD7DF" p-id="1851" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #ffb3c0;"></path><path d="M436.3 526.3c-7 0-12.7-5.7-12.7-12.7v-10.2c0-7 5.7-12.7 12.7-12.7s12.7 5.7 12.7 12.7v10.2c0 7.1-5.7 12.7-12.7 12.7zM587.7 526.3c-7 0-12.7-5.7-12.7-12.7v-10.2c0-7 5.7-12.7 12.7-12.7s12.7 5.7 12.7 12.7v10.2c0 7.1-5.7 12.7-12.7 12.7zM512 597.7c-16.4 0-31.9-7.1-42.7-19.4-4.6-5.3-4-13.3 1.2-17.9 5.3-4.6 13.3-4 17.9 1.2 6 6.8 14.6 10.8 23.6 10.8s17.6-3.9 23.6-10.7c4.6-5.3 12.6-5.8 17.9-1.2 5.3 4.6 5.8 12.6 1.2 17.9-10.8 12.2-26.3 19.3-42.7 19.3z" fill="#450064" p-id="1852" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #cbc6bd;"></path><path d="M812.6 394c-4.1 0.1-8.3 0.2-12.4 0.2-83.8 0-162.2-23.1-229.1-63.4-69.3 44.5-151.8 70.4-240.4 70.4-40.9 0-80.6-5.5-118.3-15.9 11-87.8 60.1-164.1 130.1-211.9C390.9 140.3 449.3 121 512 121s121 19.4 169.4 52.4c15.7 10.7 30.4 22.9 43.8 36.3 48.4 48.3 80.5 112.7 87.4 184.3z" fill="#D79B89" p-id="1853" data-darkreader-inline-fill="" style="--darkreader-inline-fill: #6c3726;"></path></svg>
|
After Width: | Height: | Size: 4.4 KiB |
6
app/src/main/res/values-night/themes.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.Bookkeeping" parent="android:Theme.Material.NoActionBar">
|
||||
<item name="android:windowBackground">@android:color/black</item>
|
||||
</style>
|
||||
</resources>
|
@@ -1,5 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<style name="Theme.Bookkeeping" parent="android:Theme.Material.Light.NoActionBar" />
|
||||
<style name="Theme.Bookkeeping" parent="android:Theme.Material.Light.NoActionBar">
|
||||
<item name="android:windowBackground">@android:color/white</item>
|
||||
</style>
|
||||
</resources>
|
@@ -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") }
|
||||
}
|
||||
}
|
||||
|
||||
|