Compare commits
17 Commits
Author | SHA1 | Date | |
---|---|---|---|
0187517099 | |||
45b448ee57 | |||
a2489c4987 | |||
562617ca11 | |||
74cc6f36a9 | |||
e651086e6d | |||
7fc76df829 | |||
7933452ab5 | |||
026df11933 | |||
316176bf6a | |||
bdf01f6bbe | |||
2339e5b980 | |||
f4f03ce0a4 | |||
439080499b | |||
a86898011d | |||
4c1aa501e6 | |||
8bc3e987aa |
12
.claude/settings.local.json
Normal file
12
.claude/settings.local.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(./gradlew:*)",
|
||||||
|
"Bash(git push:*)",
|
||||||
|
"Bash(git branch:*)",
|
||||||
|
"Bash(git add:*)",
|
||||||
|
"Bash(git commit:*)"
|
||||||
|
],
|
||||||
|
"deny": []
|
||||||
|
}
|
||||||
|
}
|
6
.idea/AndroidProjectSystem.xml
generated
Normal file
6
.idea/AndroidProjectSystem.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="AndroidProjectSystem">
|
||||||
|
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
|
||||||
|
</component>
|
||||||
|
</project>
|
3
.idea/inspectionProfiles/Project_Default.xml
generated
3
.idea/inspectionProfiles/Project_Default.xml
generated
@@ -49,6 +49,9 @@
|
|||||||
<option name="composableFile" value="true" />
|
<option name="composableFile" value="true" />
|
||||||
<option name="previewFile" value="true" />
|
<option name="previewFile" value="true" />
|
||||||
</inspection_tool>
|
</inspection_tool>
|
||||||
|
<inspection_tool class="PreviewParameterProviderOnFirstParameter" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
<option name="composableFile" value="true" />
|
<option name="composableFile" value="true" />
|
||||||
<option name="previewFile" value="true" />
|
<option name="previewFile" value="true" />
|
||||||
|
117
CLAUDE.md
Normal file
117
CLAUDE.md
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## 项目概述
|
||||||
|
|
||||||
|
轻记账是一个使用 Kotlin 和 Jetpack Compose 开发的 Android 记账应用,采用 MVVM 架构。项目强调隐私保护,完全离线运行。
|
||||||
|
|
||||||
|
## 常用命令
|
||||||
|
|
||||||
|
### 构建和运行
|
||||||
|
```bash
|
||||||
|
./gradlew build # 构建项目
|
||||||
|
./gradlew assembleDebug # 构建调试 APK
|
||||||
|
./gradlew assembleRelease # 构建发布 APK
|
||||||
|
./gradlew installDebug # 安装调试版本到设备
|
||||||
|
./gradlew clean # 清理构建产物
|
||||||
|
```
|
||||||
|
|
||||||
|
### 测试相关
|
||||||
|
```bash
|
||||||
|
./gradlew test # 运行单元测试
|
||||||
|
./gradlew connectedAndroidTest # 运行设备测试
|
||||||
|
```
|
||||||
|
|
||||||
|
## 架构概览
|
||||||
|
|
||||||
|
### MVVM 架构分层
|
||||||
|
- **Model 层**: Room 数据库实体 (`model/` 目录下的 BookkeepingRecord, Category, Member, Settings)
|
||||||
|
- **Data 层**: DAO 接口和 Repository 模式实现数据访问
|
||||||
|
- **ViewModel 层**: 每个功能模块独立的 ViewModel (HomeViewModel, AnalysisViewModel 等)
|
||||||
|
- **View 层**: Jetpack Compose UI,包含 screens、components 和 dialogs
|
||||||
|
|
||||||
|
### 核心数据流
|
||||||
|
1. UI 层通过 ViewModel 发起数据请求
|
||||||
|
2. ViewModel 调用 Repository 获取数据
|
||||||
|
3. Repository 通过 DAO 访问 Room 数据库
|
||||||
|
4. 数据通过 Flow/StateFlow 响应式传递回 UI
|
||||||
|
|
||||||
|
### 数据库架构
|
||||||
|
- **主数据库**: BookkeepingDatabase (当前版本 5)
|
||||||
|
- **核心表**: bookkeeping_records, categories, members, settings
|
||||||
|
- **关键关系**: records 通过 memberId 关联 members 表
|
||||||
|
- **迁移策略**: 使用 Room 的 Migration 机制处理版本升级
|
||||||
|
|
||||||
|
### 导航架构
|
||||||
|
使用 Jetpack Navigation Compose,主要页面:
|
||||||
|
- HomeScreen: 主页记账列表
|
||||||
|
- AnalysisScreen: 数据分析图表
|
||||||
|
- AddRecordScreen: 添加/编辑记录
|
||||||
|
- CategoryManagementScreen: 分类管理
|
||||||
|
- MemberManagementScreen: 成员管理
|
||||||
|
- SettingsScreen: 设置页面
|
||||||
|
|
||||||
|
## 关键技术决策
|
||||||
|
|
||||||
|
### UI 框架
|
||||||
|
- 完全使用 Jetpack Compose 构建 UI
|
||||||
|
- Material 3 设计系统,支持深色/浅色主题
|
||||||
|
- 自定义主题色功能通过 ColorThemeDialog 实现
|
||||||
|
|
||||||
|
### 数据可视化
|
||||||
|
- 使用 MPAndroidChart 实现图表功能
|
||||||
|
- ChartManager 统一管理图表样式和行为
|
||||||
|
- 支持饼图和折线图两种展示方式
|
||||||
|
|
||||||
|
### 文件导入导出
|
||||||
|
- Apache POI 处理 Excel 文件
|
||||||
|
- OpenCSV 处理 CSV 文件
|
||||||
|
- FileUtils 提供统一的文件操作接口
|
||||||
|
|
||||||
|
### 图标系统
|
||||||
|
- 所有图标使用 Material Icons 和自定义矢量图标
|
||||||
|
- CategoryIcon 和 MemberIcon 枚举管理图标映射
|
||||||
|
- 支持动态图标选择和预览
|
||||||
|
|
||||||
|
## 开发注意事项
|
||||||
|
|
||||||
|
### 分支策略
|
||||||
|
- master: 稳定主分支
|
||||||
|
- develop: 开发分支
|
||||||
|
- feature/*: 功能开发
|
||||||
|
- release/*: 版本发布
|
||||||
|
- hotfix/*: 紧急修复
|
||||||
|
|
||||||
|
### 提交规范
|
||||||
|
使用约定式提交: `<type>: <description>`
|
||||||
|
- feat: 新功能
|
||||||
|
- fix: 修复 bug
|
||||||
|
- docs: 文档更新
|
||||||
|
- style: 代码格式
|
||||||
|
- refactor: 代码重构
|
||||||
|
- perf: 性能优化
|
||||||
|
- test: 测试相关
|
||||||
|
- build: 构建相关
|
||||||
|
|
||||||
|
### 重要功能模块
|
||||||
|
|
||||||
|
#### 月度记账开始日期 (Settings 中的 monthStartDay)
|
||||||
|
- 支持自定义每月记账的开始日期 (1-31)
|
||||||
|
- 影响月度统计和分析的日期范围计算
|
||||||
|
- 默认值为 1 (每月 1 日开始)
|
||||||
|
|
||||||
|
#### 加密功能 (EncryptionUtils)
|
||||||
|
- 预留的备份加密功能接口
|
||||||
|
- 使用 AES 加密算法
|
||||||
|
- 目前尚未集成到备份功能中
|
||||||
|
|
||||||
|
#### 默认数据初始化
|
||||||
|
- 首次启动时自动创建默认分类
|
||||||
|
- 包含常用的收入和支出分类
|
||||||
|
- 可通过 insertDefaultCategories() 查看默认分类列表
|
||||||
|
|
||||||
|
### 性能考虑
|
||||||
|
- 使用 Room 的 Flow 实现数据的响应式更新
|
||||||
|
- 图表数据计算在 ViewModel 中异步处理
|
||||||
|
- 大量数据导入时使用批量插入优化性能
|
101
README.md
101
README.md
@@ -6,6 +6,35 @@
|
|||||||
|
|
||||||
本项目是一个使用 Kotlin 和 Jetpack Compose 开发的 Android 记账应用,采用 MVVM 架构,提供简洁直观的用户界面和丰富的记账功能。
|
本项目是一个使用 Kotlin 和 Jetpack Compose 开发的 Android 记账应用,采用 MVVM 架构,提供简洁直观的用户界面和丰富的记账功能。
|
||||||
|
|
||||||
|
## 🔥 功能亮点
|
||||||
|
|
||||||
|
### 📊 智能数据分析
|
||||||
|
- **月度/年度报表**:收支对比、储蓄率、日均消费等关键指标
|
||||||
|
- **详细分析报表**:分类统计明细、TOP排行榜、可视化进度条
|
||||||
|
- **多维度统计**:支持按分类、成员、时间等多维度数据分析
|
||||||
|
|
||||||
|
### 💼 预算管理系统
|
||||||
|
- **多层级预算**:支持总预算、分类预算、成员预算
|
||||||
|
- **实时监控**:预算使用情况可视化,超支警告提醒
|
||||||
|
- **灵活配置**:可启用/禁用预算,自定义预警阈值
|
||||||
|
|
||||||
|
### 🔐 数据安全保障
|
||||||
|
- **备份加密**:使用 Android Keystore 加密导出文件
|
||||||
|
- **离线优先**:完全本地存储,无网络依赖
|
||||||
|
- **隐私保护**:极简权限,数据完全掌控
|
||||||
|
|
||||||
|
### 🎨 现代化设计
|
||||||
|
- **Material 3**:遵循最新设计规范
|
||||||
|
- **深色模式**:支持系统主题切换
|
||||||
|
- **响应式布局**:适配不同屏幕尺寸
|
||||||
|
- **流畅动画**:优化页面切换和交互动效
|
||||||
|
|
||||||
|
### 🚀 最新优化
|
||||||
|
- **智能收起式统计栏**:主页统计信息随滑动自动收起,释放更多空间
|
||||||
|
- **优化数字显示**:支持十万级金额智能格式化(使用"万"为单位)
|
||||||
|
- **增强动画效果**:导航切换、列表加载、对话框显示均有流畅动画
|
||||||
|
- **改进布局设计**:统计栏占页面20%,自适应不同屏幕尺寸
|
||||||
|
|
||||||
## ⭐️ 主要特性
|
## ⭐️ 主要特性
|
||||||
|
|
||||||
- 🔒 完全离线运行,无需网络连接
|
- 🔒 完全离线运行,无需网络连接
|
||||||
@@ -13,6 +42,10 @@
|
|||||||
- 💰 支持收入和支出记录
|
- 💰 支持收入和支出记录
|
||||||
- 👥 支持多人记账
|
- 👥 支持多人记账
|
||||||
- 📊 按日期和类别统计
|
- 📊 按日期和类别统计
|
||||||
|
- 🔐 备份文件加密保护
|
||||||
|
- 📅 自定义月度记账周期
|
||||||
|
- 💼 预算管理(基本完成)
|
||||||
|
- 📈 详细数据分析报表
|
||||||
|
|
||||||
## 🛠 技术栈
|
## 🛠 技术栈
|
||||||
|
|
||||||
@@ -32,6 +65,7 @@
|
|||||||
- [x] Material 3 设计界面
|
- [x] Material 3 设计界面
|
||||||
- [x] 深色/浅色主题切换
|
- [x] 深色/浅色主题切换
|
||||||
- [x] 主题色自定义
|
- [x] 主题色自定义
|
||||||
|
- [x] 月度记账开始日期
|
||||||
|
|
||||||
### 1. 成员系统 (已完成 🎉)
|
### 1. 成员系统 (已完成 🎉)
|
||||||
- [x] 成员添加/编辑/删除
|
- [x] 成员添加/编辑/删除
|
||||||
@@ -42,7 +76,7 @@
|
|||||||
### 2. 图表分析 (已完成 🎉)
|
### 2. 图表分析 (已完成 🎉)
|
||||||
- [x] 支出/收入趋势图表
|
- [x] 支出/收入趋势图表
|
||||||
- [x] 分类占比饼图
|
- [x] 分类占比饼图
|
||||||
- [ ] 月度/年度报表
|
- [x] 月度/年度报表
|
||||||
- [x] 成员消费分析
|
- [x] 成员消费分析
|
||||||
- [x] 自定义统计周期
|
- [x] 自定义统计周期
|
||||||
|
|
||||||
@@ -58,26 +92,31 @@
|
|||||||
- [x] 收入类图标 (工资、奖金、理财等)
|
- [x] 收入类图标 (工资、奖金、理财等)
|
||||||
- [x] 成员图标 (家人、朋友、同事等)
|
- [x] 成员图标 (家人、朋友、同事等)
|
||||||
|
|
||||||
### 4. 数据管理 (进行中 🚀)
|
### 4. 数据管理 (已完成 🎉)
|
||||||
- [x] 导出 CSV/Excel 功能
|
- [x] 导出 CSV/Excel 功能
|
||||||
- [x] 数据导入
|
- [x] 数据导入
|
||||||
- [x] 数据迁移工具
|
- [x] 数据迁移工具
|
||||||
- [x] 定期自动备份
|
- [x] 定期自动备份
|
||||||
- [ ] 备份加密功能
|
- [x] 备份加密功能
|
||||||
|
|
||||||
### 5. 预算管理 (计划中 💡)
|
### 5. 预算管理 (基本完成 ✨)
|
||||||
- [ ] 月度预算设置
|
- [x] 预算数据模型设计
|
||||||
|
- [x] 数据库架构实现
|
||||||
|
- [x] 预算管理界面
|
||||||
|
- [x] 月度预算设置
|
||||||
- [ ] 预算超支提醒
|
- [ ] 预算超支提醒
|
||||||
- [ ] 分类预算管理
|
- [x] 分类预算管理
|
||||||
- [ ] 成员预算管理
|
- [x] 成员预算管理
|
||||||
- [ ] 预算分析报告
|
- [ ] 预算分析报告
|
||||||
|
|
||||||
### 6. 体验优化 (持续进行 🔄)
|
### 6. 体验优化 (进行中 🚀)
|
||||||
- [x] 深色模式支持
|
- [x] 深色模式支持
|
||||||
|
- [x] 流畅页面动画
|
||||||
|
- [x] 智能收起式统计栏
|
||||||
|
- [x] 优化数字显示格式
|
||||||
- [ ] 手势操作优化
|
- [ ] 手势操作优化
|
||||||
- [ ] 快速记账小组件
|
|
||||||
- [ ] 多语言支持
|
- [ ] 多语言支持
|
||||||
- [ ] 自定义主题
|
- [x] 自定义主题
|
||||||
|
|
||||||
### 7. 性能提升 (持续进行 ⚡️)
|
### 7. 性能提升 (持续进行 ⚡️)
|
||||||
- [ ] 大数据量处理优化
|
- [ ] 大数据量处理优化
|
||||||
@@ -115,6 +154,48 @@
|
|||||||
|
|
||||||
## 📝 版本历史
|
## 📝 版本历史
|
||||||
|
|
||||||
|
### v1.6 (开发中)
|
||||||
|
- 用户体验优化
|
||||||
|
- 智能收起式统计栏(占页面20%,随滑动自动收起)
|
||||||
|
- 优化数字显示(十万以上使用"万"为单位)
|
||||||
|
- 增强动画效果
|
||||||
|
- 导航栏切换动画(图标缩放、文字样式变化)
|
||||||
|
- 页面切换动画(方向感知的滑动效果)
|
||||||
|
- 列表项渐进式加载动画
|
||||||
|
- 对话框弹出动画(AnimatedDialog组件)
|
||||||
|
- 布局响应式优化
|
||||||
|
- 细节改进
|
||||||
|
- 修复RecordEditDialog参数问题
|
||||||
|
- 优化MonthlyStatistics布局间距
|
||||||
|
- 改进图标大小和间距
|
||||||
|
|
||||||
|
### v1.5 (开发中)
|
||||||
|
- 预算管理功能
|
||||||
|
- 预算数据模型设计
|
||||||
|
- 支持总预算、分类预算、成员预算
|
||||||
|
- 数据库架构实现(升级到版本6)
|
||||||
|
- 预算管理界面设计
|
||||||
|
- 预算编辑对话框
|
||||||
|
- 预算状态可视化(进度条、超支提醒)
|
||||||
|
- 预算导航集成
|
||||||
|
- 数据分析优化
|
||||||
|
- 月度/年度报表组件
|
||||||
|
- 详细分析报表(分类统计明细)
|
||||||
|
- 收支对比、储蓄率、日均消费分析
|
||||||
|
- TOP分类排行榜(金银铜奖牌设计)
|
||||||
|
- 报表视图集成到分析页面
|
||||||
|
- 修复嵌套滚动组件崩溃问题
|
||||||
|
|
||||||
|
### v1.4
|
||||||
|
- 数据安全功能
|
||||||
|
- 备份文件加密(使用Android Keystore)
|
||||||
|
- 支持加密CSV/Excel导出
|
||||||
|
- 自动检测和解密加密备份
|
||||||
|
- 设置页面加密开关
|
||||||
|
- 月度记账优化
|
||||||
|
- 自定义月度开始日期(1-28号)
|
||||||
|
- 所有统计基于自定义周期
|
||||||
|
|
||||||
### v1.3
|
### v1.3
|
||||||
- 图标美化计划
|
- 图标美化计划
|
||||||
- 增加图标美化
|
- 增加图标美化
|
||||||
|
@@ -24,40 +24,66 @@ import com.yovinchen.bookkeeping.ui.navigation.MainNavigation
|
|||||||
import com.yovinchen.bookkeeping.ui.theme.BookkeepingTheme
|
import com.yovinchen.bookkeeping.ui.theme.BookkeepingTheme
|
||||||
import com.yovinchen.bookkeeping.utils.FilePickerUtil
|
import com.yovinchen.bookkeeping.utils.FilePickerUtil
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全局文件选择器启动器
|
||||||
|
* 用于在整个应用程序中共享同一个文件选择器实例
|
||||||
|
*/
|
||||||
private var filePickerLauncher: ActivityResultLauncher<Array<String>>? = null
|
private var filePickerLauncher: ActivityResultLauncher<Array<String>>? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取预先注册的文件选择器启动器的扩展函数
|
||||||
|
*
|
||||||
|
* @return 预先注册的文件选择器启动器
|
||||||
|
* @throws IllegalStateException 如果文件选择器未初始化
|
||||||
|
*/
|
||||||
fun ComponentActivity.getPreregisteredFilePickerLauncher(): ActivityResultLauncher<Array<String>> {
|
fun ComponentActivity.getPreregisteredFilePickerLauncher(): ActivityResultLauncher<Array<String>> {
|
||||||
return filePickerLauncher ?: throw IllegalStateException("FilePickerLauncher not initialized")
|
return filePickerLauncher ?: throw IllegalStateException("FilePickerLauncher not initialized")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用程序的主活动
|
||||||
|
* 负责初始化应用界面和必要的系统组件
|
||||||
|
*/
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
// 设置系统窗口装饰,确保内容能够扩展到系统栏区域
|
||||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||||
|
|
||||||
// 预注册文件选择器
|
// 预注册文件选择器,用于处理文件选择操作
|
||||||
filePickerLauncher = registerForActivityResult(
|
filePickerLauncher = registerForActivityResult(
|
||||||
ActivityResultContracts.OpenDocument()
|
ActivityResultContracts.OpenDocument()
|
||||||
) { uri: Uri? ->
|
) { uri: Uri? ->
|
||||||
|
// 当用户选择文件后,调用工具类处理文件选择结果
|
||||||
FilePickerUtil.handleFileSelection(this, uri)
|
FilePickerUtil.handleFileSelection(this, uri)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 设置应用的主Compose内容
|
||||||
setContent {
|
setContent {
|
||||||
BookkeepingApp()
|
BookkeepingApp()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 系统状态栏和导航栏颜色设置
|
||||||
|
* 根据当前主题模式设置系统UI元素的颜色和外观
|
||||||
|
*
|
||||||
|
* @param isDarkTheme 是否为暗色主题
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun SystemBarColor(isDarkTheme: Boolean) {
|
private fun SystemBarColor(isDarkTheme: Boolean) {
|
||||||
val view = LocalView.current
|
val view = LocalView.current
|
||||||
if (!view.isInEditMode) {
|
if (!view.isInEditMode) {
|
||||||
|
// 获取当前主题的表面颜色用于系统栏
|
||||||
val surfaceColor = MaterialTheme.colorScheme.surface.toArgb()
|
val surfaceColor = MaterialTheme.colorScheme.surface.toArgb()
|
||||||
val currentWindow = (view.context as? Activity)?.window
|
val currentWindow = (view.context as? Activity)?.window
|
||||||
SideEffect {
|
SideEffect {
|
||||||
currentWindow?.let { window ->
|
currentWindow?.let { window ->
|
||||||
|
// 设置状态栏和导航栏颜色
|
||||||
window.statusBarColor = surfaceColor
|
window.statusBarColor = surfaceColor
|
||||||
window.navigationBarColor = surfaceColor
|
window.navigationBarColor = surfaceColor
|
||||||
|
// 设置系统栏图标的亮暗模式,以确保在不同背景下的可见性
|
||||||
WindowCompat.getInsetsController(window, view).apply {
|
WindowCompat.getInsetsController(window, view).apply {
|
||||||
isAppearanceLightStatusBars = !isDarkTheme
|
isAppearanceLightStatusBars = !isDarkTheme
|
||||||
isAppearanceLightNavigationBars = !isDarkTheme
|
isAppearanceLightNavigationBars = !isDarkTheme
|
||||||
@@ -67,27 +93,37 @@ private fun SystemBarColor(isDarkTheme: Boolean) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记账应用的主Compose函数
|
||||||
|
* 处理主题设置并启动主导航组件
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun BookkeepingApp() {
|
fun BookkeepingApp() {
|
||||||
|
// 跟踪当前应用的主题模式状态
|
||||||
var themeMode by remember { mutableStateOf<ThemeMode>(ThemeMode.FOLLOW_SYSTEM) }
|
var themeMode by remember { mutableStateOf<ThemeMode>(ThemeMode.FOLLOW_SYSTEM) }
|
||||||
|
|
||||||
|
// 根据主题模式确定是否使用暗色主题
|
||||||
val isDarkTheme = when (themeMode) {
|
val isDarkTheme = when (themeMode) {
|
||||||
is ThemeMode.FOLLOW_SYSTEM -> isSystemInDarkTheme()
|
is ThemeMode.FOLLOW_SYSTEM -> isSystemInDarkTheme() // 跟随系统设置
|
||||||
is ThemeMode.LIGHT -> false
|
is ThemeMode.LIGHT -> false // 强制使用亮色主题
|
||||||
is ThemeMode.DARK -> true
|
is ThemeMode.DARK -> true // 强制使用暗色主题
|
||||||
is ThemeMode.CUSTOM -> isSystemInDarkTheme()
|
is ThemeMode.CUSTOM -> isSystemInDarkTheme() // 自定义主题下的基础亮暗模式仍跟随系统
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 处理自定义主题颜色方案
|
||||||
val customColorScheme = when (themeMode) {
|
val customColorScheme = when (themeMode) {
|
||||||
is ThemeMode.CUSTOM -> {
|
is ThemeMode.CUSTOM -> {
|
||||||
|
// 从主题模式中提取自定义主色
|
||||||
val primaryColor = (themeMode as ThemeMode.CUSTOM).primaryColor
|
val primaryColor = (themeMode as ThemeMode.CUSTOM).primaryColor
|
||||||
if (isDarkTheme) {
|
if (isDarkTheme) {
|
||||||
|
// 暗色模式下的自定义颜色方案
|
||||||
MaterialTheme.colorScheme.copy(
|
MaterialTheme.colorScheme.copy(
|
||||||
primary = primaryColor,
|
primary = primaryColor,
|
||||||
secondary = primaryColor.copy(alpha = 0.7f),
|
secondary = primaryColor.copy(alpha = 0.7f),
|
||||||
tertiary = primaryColor.copy(alpha = 0.5f)
|
tertiary = primaryColor.copy(alpha = 0.5f)
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
// 亮色模式下的自定义颜色方案
|
||||||
MaterialTheme.colorScheme.copy(
|
MaterialTheme.colorScheme.copy(
|
||||||
primary = primaryColor,
|
primary = primaryColor,
|
||||||
secondary = primaryColor.copy(alpha = 0.7f),
|
secondary = primaryColor.copy(alpha = 0.7f),
|
||||||
@@ -95,27 +131,38 @@ fun BookkeepingApp() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else -> null
|
else -> null // 非自定义主题模式使用默认颜色方案
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 应用主题到整个应用内容
|
||||||
BookkeepingTheme(
|
BookkeepingTheme(
|
||||||
darkTheme = isDarkTheme,
|
darkTheme = isDarkTheme,
|
||||||
customColorScheme = customColorScheme
|
customColorScheme = customColorScheme
|
||||||
) {
|
) {
|
||||||
|
// 设置系统状态栏和导航栏颜色
|
||||||
SystemBarColor(isDarkTheme)
|
SystemBarColor(isDarkTheme)
|
||||||
|
|
||||||
|
// 创建填充整个屏幕的基础Surface
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
color = MaterialTheme.colorScheme.surface
|
color = MaterialTheme.colorScheme.surface
|
||||||
) {
|
) {
|
||||||
|
// 启动主导航组件,并传递主题相关参数
|
||||||
MainNavigation(
|
MainNavigation(
|
||||||
currentTheme = themeMode,
|
currentTheme = themeMode,
|
||||||
onThemeChange = { themeMode = it }
|
onThemeChange = { themeMode = it } // 允许导航组件中的屏幕更改主题
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 示例问候函数
|
||||||
|
* 仅用于开发预览和测试目的
|
||||||
|
*
|
||||||
|
* @param name 要显示的名称
|
||||||
|
* @param modifier 应用于Text组件的修饰符
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun Greeting(name: String, modifier: Modifier = Modifier) {
|
fun Greeting(name: String, modifier: Modifier = Modifier) {
|
||||||
Text(
|
Text(
|
||||||
@@ -124,6 +171,9 @@ fun Greeting(name: String, modifier: Modifier = Modifier) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Greeting组件的预览函数
|
||||||
|
*/
|
||||||
@Preview(showBackground = true)
|
@Preview(showBackground = true)
|
||||||
@Composable
|
@Composable
|
||||||
fun GreetingPreview() {
|
fun GreetingPreview() {
|
||||||
@@ -132,6 +182,9 @@ fun GreetingPreview() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 整个应用的预览函数
|
||||||
|
*/
|
||||||
@Preview(showBackground = true)
|
@Preview(showBackground = true)
|
||||||
@Composable
|
@Composable
|
||||||
fun BookkeepingAppPreview() {
|
fun BookkeepingAppPreview() {
|
||||||
|
@@ -10,17 +10,19 @@ import androidx.room.migration.Migration
|
|||||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
import com.yovinchen.bookkeeping.R
|
import com.yovinchen.bookkeeping.R
|
||||||
import com.yovinchen.bookkeeping.model.BookkeepingRecord
|
import com.yovinchen.bookkeeping.model.BookkeepingRecord
|
||||||
|
import com.yovinchen.bookkeeping.model.Budget
|
||||||
import com.yovinchen.bookkeeping.model.Category
|
import com.yovinchen.bookkeeping.model.Category
|
||||||
import com.yovinchen.bookkeeping.model.Converters
|
import com.yovinchen.bookkeeping.model.Converters
|
||||||
import com.yovinchen.bookkeeping.model.Member
|
import com.yovinchen.bookkeeping.model.Member
|
||||||
|
import com.yovinchen.bookkeeping.model.Settings
|
||||||
import com.yovinchen.bookkeeping.model.TransactionType
|
import com.yovinchen.bookkeeping.model.TransactionType
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@Database(
|
@Database(
|
||||||
entities = [BookkeepingRecord::class, Category::class, Member::class],
|
entities = [BookkeepingRecord::class, Category::class, Member::class, Settings::class, Budget::class],
|
||||||
version = 4,
|
version = 6,
|
||||||
exportSchema = false
|
exportSchema = false
|
||||||
)
|
)
|
||||||
@TypeConverters(Converters::class)
|
@TypeConverters(Converters::class)
|
||||||
@@ -28,6 +30,8 @@ abstract class BookkeepingDatabase : RoomDatabase() {
|
|||||||
abstract fun bookkeepingDao(): BookkeepingDao
|
abstract fun bookkeepingDao(): BookkeepingDao
|
||||||
abstract fun categoryDao(): CategoryDao
|
abstract fun categoryDao(): CategoryDao
|
||||||
abstract fun memberDao(): MemberDao
|
abstract fun memberDao(): MemberDao
|
||||||
|
abstract fun settingsDao(): SettingsDao
|
||||||
|
abstract fun budgetDao(): BudgetDao
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "BookkeepingDatabase"
|
private const val TAG = "BookkeepingDatabase"
|
||||||
@@ -124,6 +128,52 @@ abstract class BookkeepingDatabase : RoomDatabase() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val MIGRATION_4_5 = object : Migration(4, 5) {
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
// 创建设置表
|
||||||
|
db.execSQL("""
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
id INTEGER PRIMARY KEY NOT NULL DEFAULT 1,
|
||||||
|
monthStartDay INTEGER NOT NULL DEFAULT 1,
|
||||||
|
themeMode TEXT NOT NULL DEFAULT 'FOLLOW_SYSTEM',
|
||||||
|
autoBackupEnabled INTEGER NOT NULL DEFAULT 0,
|
||||||
|
autoBackupInterval INTEGER NOT NULL DEFAULT 7,
|
||||||
|
lastBackupTime INTEGER NOT NULL DEFAULT 0
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
// 插入默认设置
|
||||||
|
db.execSQL("""
|
||||||
|
INSERT OR IGNORE INTO settings (id, monthStartDay, themeMode, autoBackupEnabled, autoBackupInterval, lastBackupTime)
|
||||||
|
VALUES (1, 1, 'FOLLOW_SYSTEM', 0, 7, 0)
|
||||||
|
""")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val MIGRATION_5_6 = object : Migration(5, 6) {
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
// 创建预算表
|
||||||
|
db.execSQL("""
|
||||||
|
CREATE TABLE IF NOT EXISTS budgets (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
categoryName TEXT,
|
||||||
|
memberId INTEGER,
|
||||||
|
amount REAL NOT NULL,
|
||||||
|
startDate INTEGER NOT NULL,
|
||||||
|
endDate INTEGER NOT NULL,
|
||||||
|
isEnabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
alertThreshold REAL NOT NULL DEFAULT 0.8,
|
||||||
|
createdAt INTEGER NOT NULL,
|
||||||
|
updatedAt INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
// 在 settings 表中添加 encryptBackup 列
|
||||||
|
db.execSQL("ALTER TABLE settings ADD COLUMN encryptBackup INTEGER NOT NULL DEFAULT 1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
private var INSTANCE: BookkeepingDatabase? = null
|
private var INSTANCE: BookkeepingDatabase? = null
|
||||||
|
|
||||||
@@ -134,7 +184,7 @@ abstract class BookkeepingDatabase : RoomDatabase() {
|
|||||||
BookkeepingDatabase::class.java,
|
BookkeepingDatabase::class.java,
|
||||||
"bookkeeping_database"
|
"bookkeeping_database"
|
||||||
)
|
)
|
||||||
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4)
|
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6)
|
||||||
.addCallback(object : Callback() {
|
.addCallback(object : Callback() {
|
||||||
override fun onCreate(db: SupportSQLiteDatabase) {
|
override fun onCreate(db: SupportSQLiteDatabase) {
|
||||||
super.onCreate(db)
|
super.onCreate(db)
|
||||||
@@ -143,6 +193,11 @@ abstract class BookkeepingDatabase : RoomDatabase() {
|
|||||||
try {
|
try {
|
||||||
val database = getDatabase(context)
|
val database = getDatabase(context)
|
||||||
|
|
||||||
|
// 初始化默认设置
|
||||||
|
database.settingsDao().apply {
|
||||||
|
updateSettings(Settings())
|
||||||
|
}
|
||||||
|
|
||||||
// 初始化默认成员
|
// 初始化默认成员
|
||||||
database.memberDao().apply {
|
database.memberDao().apply {
|
||||||
if (getMemberCount() == 0) {
|
if (getMemberCount() == 0) {
|
||||||
|
105
app/src/main/java/com/yovinchen/bookkeeping/data/BudgetDao.kt
Normal file
105
app/src/main/java/com/yovinchen/bookkeeping/data/BudgetDao.kt
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
package com.yovinchen.bookkeeping.data
|
||||||
|
|
||||||
|
import androidx.room.*
|
||||||
|
import com.yovinchen.bookkeeping.model.Budget
|
||||||
|
import com.yovinchen.bookkeeping.model.BudgetType
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import java.util.Date
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预算数据访问对象
|
||||||
|
* 提供预算相关的数据库操作接口
|
||||||
|
*/
|
||||||
|
@Dao
|
||||||
|
interface BudgetDao {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 插入新预算
|
||||||
|
*/
|
||||||
|
@Insert
|
||||||
|
suspend fun insertBudget(budget: Budget): Long
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新预算
|
||||||
|
*/
|
||||||
|
@Update
|
||||||
|
suspend fun updateBudget(budget: Budget)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除预算
|
||||||
|
*/
|
||||||
|
@Delete
|
||||||
|
suspend fun deleteBudget(budget: Budget)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据ID获取预算
|
||||||
|
*/
|
||||||
|
@Query("SELECT * FROM budgets WHERE id = :budgetId")
|
||||||
|
suspend fun getBudgetById(budgetId: Int): Budget?
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有启用的预算
|
||||||
|
*/
|
||||||
|
@Query("SELECT * FROM budgets WHERE isEnabled = 1 ORDER BY type, amount DESC")
|
||||||
|
fun getAllEnabledBudgets(): Flow<List<Budget>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有预算(包括禁用的)
|
||||||
|
*/
|
||||||
|
@Query("SELECT * FROM budgets ORDER BY type, amount DESC")
|
||||||
|
fun getAllBudgets(): Flow<List<Budget>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据类型获取预算
|
||||||
|
*/
|
||||||
|
@Query("SELECT * FROM budgets WHERE type = :type AND isEnabled = 1")
|
||||||
|
fun getBudgetsByType(type: BudgetType): Flow<List<Budget>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取总预算
|
||||||
|
*/
|
||||||
|
@Query("SELECT * FROM budgets WHERE type = 'TOTAL' AND isEnabled = 1 AND :date BETWEEN startDate AND endDate LIMIT 1")
|
||||||
|
suspend fun getTotalBudget(date: Date): Budget?
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取指定分类的预算
|
||||||
|
*/
|
||||||
|
@Query("SELECT * FROM budgets WHERE type = 'CATEGORY' AND categoryName = :categoryName AND isEnabled = 1 AND :date BETWEEN startDate AND endDate LIMIT 1")
|
||||||
|
suspend fun getCategoryBudget(categoryName: String, date: Date): Budget?
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取指定成员的预算
|
||||||
|
*/
|
||||||
|
@Query("SELECT * FROM budgets WHERE type = 'MEMBER' AND memberId = :memberId AND isEnabled = 1 AND :date BETWEEN startDate AND endDate LIMIT 1")
|
||||||
|
suspend fun getMemberBudget(memberId: Int, date: Date): Budget?
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前有效的所有预算
|
||||||
|
*/
|
||||||
|
@Query("SELECT * FROM budgets WHERE isEnabled = 1 AND :date BETWEEN startDate AND endDate ORDER BY type, amount DESC")
|
||||||
|
fun getActiveBudgets(date: Date): Flow<List<Budget>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新预算的启用状态
|
||||||
|
*/
|
||||||
|
@Query("UPDATE budgets SET isEnabled = :enabled, updatedAt = :updatedAt WHERE id = :budgetId")
|
||||||
|
suspend fun updateBudgetEnabled(budgetId: Int, enabled: Boolean, updatedAt: Date = Date())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除所有过期的预算(可选)
|
||||||
|
*/
|
||||||
|
@Query("DELETE FROM budgets WHERE endDate < :date AND isEnabled = 0")
|
||||||
|
suspend fun deleteExpiredBudgets(date: Date)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取指定日期范围内的分类预算
|
||||||
|
*/
|
||||||
|
@Query("SELECT * FROM budgets WHERE type = 'CATEGORY' AND isEnabled = 1 AND :date BETWEEN startDate AND endDate")
|
||||||
|
fun getCategoryBudgetsForDate(date: Date): Flow<List<Budget>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取指定日期范围内的成员预算
|
||||||
|
*/
|
||||||
|
@Query("SELECT * FROM budgets WHERE type = 'MEMBER' AND isEnabled = 1 AND :date BETWEEN startDate AND endDate")
|
||||||
|
fun getMemberBudgetsForDate(date: Date): Flow<List<Budget>>
|
||||||
|
}
|
@@ -0,0 +1,215 @@
|
|||||||
|
package com.yovinchen.bookkeeping.data
|
||||||
|
|
||||||
|
import com.yovinchen.bookkeeping.model.*
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import java.util.Date
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预算仓库类
|
||||||
|
* 提供预算相关的业务逻辑和数据访问
|
||||||
|
*/
|
||||||
|
class BudgetRepository(
|
||||||
|
private val budgetDao: BudgetDao,
|
||||||
|
private val bookkeepingDao: BookkeepingDao,
|
||||||
|
private val memberDao: MemberDao
|
||||||
|
) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建新预算
|
||||||
|
*/
|
||||||
|
suspend fun createBudget(budget: Budget): Long {
|
||||||
|
return budgetDao.insertBudget(budget)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新预算
|
||||||
|
*/
|
||||||
|
suspend fun updateBudget(budget: Budget) {
|
||||||
|
budgetDao.updateBudget(budget.copy(updatedAt = Date()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除预算
|
||||||
|
*/
|
||||||
|
suspend fun deleteBudget(budget: Budget) {
|
||||||
|
budgetDao.deleteBudget(budget)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有预算
|
||||||
|
*/
|
||||||
|
fun getAllBudgets(): Flow<List<Budget>> {
|
||||||
|
return budgetDao.getAllBudgets()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有启用的预算
|
||||||
|
*/
|
||||||
|
fun getAllEnabledBudgets(): Flow<List<Budget>> {
|
||||||
|
return budgetDao.getAllEnabledBudgets()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前有效的预算状态
|
||||||
|
*/
|
||||||
|
fun getActiveBudgetStatuses(date: Date = Date()): Flow<List<BudgetStatus>> {
|
||||||
|
return combine(
|
||||||
|
budgetDao.getActiveBudgets(date),
|
||||||
|
bookkeepingDao.getAllRecords(),
|
||||||
|
memberDao.getAllMembers()
|
||||||
|
) { budgets, records, members ->
|
||||||
|
budgets.map { budget ->
|
||||||
|
val spent = calculateSpent(budget, records, members, date)
|
||||||
|
val remaining = budget.amount - spent
|
||||||
|
val percentage = if (budget.amount > 0) spent / budget.amount else 0.0
|
||||||
|
|
||||||
|
BudgetStatus(
|
||||||
|
budget = budget,
|
||||||
|
spent = spent,
|
||||||
|
remaining = remaining,
|
||||||
|
percentage = percentage,
|
||||||
|
isOverBudget = spent > budget.amount,
|
||||||
|
isNearLimit = percentage >= budget.alertThreshold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算预算已花费金额
|
||||||
|
*/
|
||||||
|
private fun calculateSpent(
|
||||||
|
budget: Budget,
|
||||||
|
records: List<BookkeepingRecord>,
|
||||||
|
members: List<Member>,
|
||||||
|
currentDate: Date
|
||||||
|
): Double {
|
||||||
|
// 只计算支出类型的记录
|
||||||
|
val expenseRecords = records.filter {
|
||||||
|
it.type == TransactionType.EXPENSE &&
|
||||||
|
it.date >= budget.startDate &&
|
||||||
|
it.date <= budget.endDate &&
|
||||||
|
it.date <= currentDate
|
||||||
|
}
|
||||||
|
|
||||||
|
return when (budget.type) {
|
||||||
|
BudgetType.TOTAL -> {
|
||||||
|
// 总预算:计算所有支出
|
||||||
|
expenseRecords.sumOf { it.amount }
|
||||||
|
}
|
||||||
|
BudgetType.CATEGORY -> {
|
||||||
|
// 分类预算:计算指定分类的支出
|
||||||
|
expenseRecords
|
||||||
|
.filter { it.category == budget.categoryName }
|
||||||
|
.sumOf { it.amount }
|
||||||
|
}
|
||||||
|
BudgetType.MEMBER -> {
|
||||||
|
// 成员预算:计算指定成员的支出
|
||||||
|
expenseRecords
|
||||||
|
.filter { it.memberId == budget.memberId }
|
||||||
|
.sumOf { it.amount }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取总预算状态
|
||||||
|
*/
|
||||||
|
suspend fun getTotalBudgetStatus(date: Date = Date()): BudgetStatus? {
|
||||||
|
val budget = budgetDao.getTotalBudget(date) ?: return null
|
||||||
|
val records = bookkeepingDao.getAllRecords().first()
|
||||||
|
val members = memberDao.getAllMembers().first()
|
||||||
|
|
||||||
|
val spent = calculateSpent(budget, records, members, date)
|
||||||
|
val remaining = budget.amount - spent
|
||||||
|
val percentage = if (budget.amount > 0) spent / budget.amount else 0.0
|
||||||
|
|
||||||
|
return BudgetStatus(
|
||||||
|
budget = budget,
|
||||||
|
spent = spent,
|
||||||
|
remaining = remaining,
|
||||||
|
percentage = percentage,
|
||||||
|
isOverBudget = spent > budget.amount,
|
||||||
|
isNearLimit = percentage >= budget.alertThreshold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取分类预算状态
|
||||||
|
*/
|
||||||
|
fun getCategoryBudgetStatuses(date: Date = Date()): Flow<List<BudgetStatus>> {
|
||||||
|
return combine(
|
||||||
|
budgetDao.getCategoryBudgetsForDate(date),
|
||||||
|
bookkeepingDao.getAllRecords(),
|
||||||
|
memberDao.getAllMembers()
|
||||||
|
) { budgets, records, members ->
|
||||||
|
budgets.map { budget ->
|
||||||
|
val spent = calculateSpent(budget, records, members, date)
|
||||||
|
val remaining = budget.amount - spent
|
||||||
|
val percentage = if (budget.amount > 0) spent / budget.amount else 0.0
|
||||||
|
|
||||||
|
BudgetStatus(
|
||||||
|
budget = budget,
|
||||||
|
spent = spent,
|
||||||
|
remaining = remaining,
|
||||||
|
percentage = percentage,
|
||||||
|
isOverBudget = spent > budget.amount,
|
||||||
|
isNearLimit = percentage >= budget.alertThreshold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取成员预算状态
|
||||||
|
*/
|
||||||
|
fun getMemberBudgetStatuses(date: Date = Date()): Flow<List<BudgetStatus>> {
|
||||||
|
return combine(
|
||||||
|
budgetDao.getMemberBudgetsForDate(date),
|
||||||
|
bookkeepingDao.getAllRecords(),
|
||||||
|
memberDao.getAllMembers()
|
||||||
|
) { budgets, records, members ->
|
||||||
|
budgets.map { budget ->
|
||||||
|
val spent = calculateSpent(budget, records, members, date)
|
||||||
|
val remaining = budget.amount - spent
|
||||||
|
val percentage = if (budget.amount > 0) spent / budget.amount else 0.0
|
||||||
|
|
||||||
|
BudgetStatus(
|
||||||
|
budget = budget,
|
||||||
|
spent = spent,
|
||||||
|
remaining = remaining,
|
||||||
|
percentage = percentage,
|
||||||
|
isOverBudget = spent > budget.amount,
|
||||||
|
isNearLimit = percentage >= budget.alertThreshold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否有预算超支或接近限制
|
||||||
|
*/
|
||||||
|
suspend fun checkBudgetAlerts(date: Date = Date()): List<BudgetStatus> {
|
||||||
|
val allStatuses = getActiveBudgetStatuses(date).first()
|
||||||
|
return allStatuses.filter { it.isOverBudget || it.isNearLimit }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新预算启用状态
|
||||||
|
*/
|
||||||
|
suspend fun updateBudgetEnabled(budgetId: Int, enabled: Boolean) {
|
||||||
|
budgetDao.updateBudgetEnabled(budgetId, enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理过期的禁用预算
|
||||||
|
*/
|
||||||
|
suspend fun cleanupExpiredBudgets() {
|
||||||
|
budgetDao.deleteExpiredBudgets(Date())
|
||||||
|
}
|
||||||
|
}
|
@@ -1,6 +1,7 @@
|
|||||||
package com.yovinchen.bookkeeping.data
|
package com.yovinchen.bookkeeping.data
|
||||||
|
|
||||||
import androidx.room.TypeConverter
|
import androidx.room.TypeConverter
|
||||||
|
import com.yovinchen.bookkeeping.model.BudgetType
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
import java.util.*
|
import java.util.*
|
||||||
@@ -29,4 +30,14 @@ class Converters {
|
|||||||
fun toDate(timestamp: String?): Date? {
|
fun toDate(timestamp: String?): Date? {
|
||||||
return timestamp?.let { Date(it.toLong()) }
|
return timestamp?.let { Date(it.toLong()) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun fromBudgetType(budgetType: BudgetType?): String? {
|
||||||
|
return budgetType?.name
|
||||||
|
}
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun toBudgetType(budgetType: String?): BudgetType? {
|
||||||
|
return budgetType?.let { BudgetType.valueOf(it) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -0,0 +1,35 @@
|
|||||||
|
package com.yovinchen.bookkeeping.data
|
||||||
|
|
||||||
|
import androidx.room.*
|
||||||
|
import com.yovinchen.bookkeeping.model.Settings
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface SettingsDao {
|
||||||
|
@Query("SELECT * FROM settings WHERE id = 1")
|
||||||
|
fun getSettings(): Flow<Settings?>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM settings WHERE id = 1")
|
||||||
|
suspend fun getSettingsOnce(): Settings?
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun updateSettings(settings: Settings)
|
||||||
|
|
||||||
|
@Query("UPDATE settings SET monthStartDay = :day WHERE id = 1")
|
||||||
|
suspend fun updateMonthStartDay(day: Int)
|
||||||
|
|
||||||
|
@Query("UPDATE settings SET themeMode = :mode WHERE id = 1")
|
||||||
|
suspend fun updateThemeMode(mode: String)
|
||||||
|
|
||||||
|
@Query("UPDATE settings SET autoBackupEnabled = :enabled WHERE id = 1")
|
||||||
|
suspend fun updateAutoBackupEnabled(enabled: Boolean)
|
||||||
|
|
||||||
|
@Query("UPDATE settings SET autoBackupInterval = :interval WHERE id = 1")
|
||||||
|
suspend fun updateAutoBackupInterval(interval: Int)
|
||||||
|
|
||||||
|
@Query("UPDATE settings SET lastBackupTime = :time WHERE id = 1")
|
||||||
|
suspend fun updateLastBackupTime(time: Long)
|
||||||
|
|
||||||
|
@Query("UPDATE settings SET encryptBackup = :encrypt WHERE id = 1")
|
||||||
|
suspend fun updateEncryptBackup(encrypt: Boolean)
|
||||||
|
}
|
@@ -0,0 +1,45 @@
|
|||||||
|
package com.yovinchen.bookkeeping.data
|
||||||
|
|
||||||
|
import com.yovinchen.bookkeeping.model.Settings
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
class SettingsRepository(private val settingsDao: SettingsDao) {
|
||||||
|
|
||||||
|
fun getSettings(): Flow<Settings?> = settingsDao.getSettings()
|
||||||
|
|
||||||
|
suspend fun getSettingsOnce(): Settings {
|
||||||
|
return settingsDao.getSettingsOnce() ?: Settings()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updateSettings(settings: Settings) {
|
||||||
|
settingsDao.updateSettings(settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updateMonthStartDay(day: Int) {
|
||||||
|
// 确保日期在有效范围内 (1-28)
|
||||||
|
val validDay = day.coerceIn(1, 28)
|
||||||
|
settingsDao.updateMonthStartDay(validDay)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updateThemeMode(mode: String) {
|
||||||
|
settingsDao.updateThemeMode(mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updateAutoBackupEnabled(enabled: Boolean) {
|
||||||
|
settingsDao.updateAutoBackupEnabled(enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updateAutoBackupInterval(interval: Int) {
|
||||||
|
settingsDao.updateAutoBackupInterval(interval)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updateLastBackupTime(time: Long) {
|
||||||
|
settingsDao.updateLastBackupTime(time)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun ensureSettingsExist() {
|
||||||
|
if (settingsDao.getSettingsOnce() == null) {
|
||||||
|
settingsDao.updateSettings(Settings())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -1,7 +1,13 @@
|
|||||||
package com.yovinchen.bookkeeping.model
|
package com.yovinchen.bookkeeping.model
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分析类型枚举
|
||||||
|
* 定义记账应用中不同的数据分析视图类型
|
||||||
|
*
|
||||||
|
* 用于在数据分析模块中区分不同的分析维度和图表类型
|
||||||
|
*/
|
||||||
enum class AnalysisType {
|
enum class AnalysisType {
|
||||||
EXPENSE,
|
EXPENSE, // 支出分析,用于分析用户的支出情况
|
||||||
INCOME,
|
INCOME, // 收入分析,用于分析用户的收入情况
|
||||||
TREND
|
TREND // 趋势分析,用于分析用户收支随时间的变化趋势
|
||||||
}
|
}
|
||||||
|
@@ -9,32 +9,71 @@ import androidx.room.TypeConverters
|
|||||||
import com.yovinchen.bookkeeping.model.Member
|
import com.yovinchen.bookkeeping.model.Member
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 交易类型枚举
|
||||||
|
* 定义记账记录的交易类型
|
||||||
|
*/
|
||||||
enum class TransactionType {
|
enum class TransactionType {
|
||||||
INCOME, EXPENSE
|
INCOME, // 收入
|
||||||
|
EXPENSE // 支出
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Room数据库类型转换器
|
||||||
|
* 用于在数据库中存储和检索复杂类型
|
||||||
|
*/
|
||||||
class Converters {
|
class Converters {
|
||||||
|
/**
|
||||||
|
* 将时间戳转换为Date对象
|
||||||
|
*
|
||||||
|
* @param value 时间戳(毫秒)
|
||||||
|
* @return 对应的Date对象,如果输入为null则返回null
|
||||||
|
*/
|
||||||
@TypeConverter
|
@TypeConverter
|
||||||
fun fromTimestamp(value: Long?): Date? {
|
fun fromTimestamp(value: Long?): Date? {
|
||||||
return value?.let { Date(it) }
|
return value?.let { Date(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将Date对象转换为时间戳
|
||||||
|
*
|
||||||
|
* @param date Date对象
|
||||||
|
* @return 对应的时间戳(毫秒),如果输入为null则返回null
|
||||||
|
*/
|
||||||
@TypeConverter
|
@TypeConverter
|
||||||
fun dateToTimestamp(date: Date?): Long? {
|
fun dateToTimestamp(date: Date?): Long? {
|
||||||
return date?.time
|
return date?.time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将字符串转换为TransactionType枚举
|
||||||
|
*
|
||||||
|
* @param value 交易类型的字符串表示
|
||||||
|
* @return 对应的TransactionType枚举值
|
||||||
|
*/
|
||||||
@TypeConverter
|
@TypeConverter
|
||||||
fun fromTransactionType(value: String): TransactionType {
|
fun fromTransactionType(value: String): TransactionType {
|
||||||
return enumValueOf<TransactionType>(value)
|
return enumValueOf<TransactionType>(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将TransactionType枚举转换为字符串
|
||||||
|
*
|
||||||
|
* @param type TransactionType枚举值
|
||||||
|
* @return 对应的字符串表示
|
||||||
|
*/
|
||||||
@TypeConverter
|
@TypeConverter
|
||||||
fun transactionTypeToString(type: TransactionType): String {
|
fun transactionTypeToString(type: TransactionType): String {
|
||||||
return type.name
|
return type.name
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记账记录实体类
|
||||||
|
* 用于在Room数据库中存储用户的收支记录
|
||||||
|
*
|
||||||
|
* 该实体与Member实体存在外键关系,表示每条记录可以关联到一个家庭成员
|
||||||
|
*/
|
||||||
@Entity(
|
@Entity(
|
||||||
tableName = "bookkeeping_records",
|
tableName = "bookkeeping_records",
|
||||||
foreignKeys = [
|
foreignKeys = [
|
||||||
@@ -42,21 +81,21 @@ class Converters {
|
|||||||
entity = Member::class,
|
entity = Member::class,
|
||||||
parentColumns = ["id"],
|
parentColumns = ["id"],
|
||||||
childColumns = ["memberId"],
|
childColumns = ["memberId"],
|
||||||
onDelete = ForeignKey.SET_NULL
|
onDelete = ForeignKey.SET_NULL // 当关联的成员被删除时,将此字段设为NULL
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
indices = [
|
indices = [
|
||||||
Index(value = ["memberId"])
|
Index(value = ["memberId"]) // 在memberId上创建索引以提高查询性能
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
@TypeConverters(Converters::class)
|
@TypeConverters(Converters::class) // 应用类型转换器
|
||||||
data class BookkeepingRecord(
|
data class BookkeepingRecord(
|
||||||
@PrimaryKey(autoGenerate = true)
|
@PrimaryKey(autoGenerate = true)
|
||||||
val id: Long = 0,
|
val id: Long = 0, // 记录ID,自动生成
|
||||||
val amount: Double,
|
val amount: Double, // 金额
|
||||||
val type: TransactionType,
|
val type: TransactionType, // 交易类型(收入或支出)
|
||||||
val category: String,
|
val category: String, // 分类
|
||||||
val description: String,
|
val description: String, // 描述
|
||||||
val date: Date,
|
val date: Date, // 日期
|
||||||
val memberId: Int? = null // 可为空,表示未指定成员
|
val memberId: Int? = null // 关联的成员ID,可为空表示未指定成员
|
||||||
)
|
)
|
||||||
|
86
app/src/main/java/com/yovinchen/bookkeeping/model/Budget.kt
Normal file
86
app/src/main/java/com/yovinchen/bookkeeping/model/Budget.kt
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
package com.yovinchen.bookkeeping.model
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
import java.util.Date
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预算实体类
|
||||||
|
* 用于设置和跟踪月度预算、分类预算和成员预算
|
||||||
|
*/
|
||||||
|
@Entity(tableName = "budgets")
|
||||||
|
data class Budget(
|
||||||
|
@PrimaryKey(autoGenerate = true)
|
||||||
|
val id: Int = 0,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预算类型:TOTAL(总预算), CATEGORY(分类预算), MEMBER(成员预算)
|
||||||
|
*/
|
||||||
|
val type: BudgetType,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预算关联的分类名称(仅在 type 为 CATEGORY 时使用)
|
||||||
|
*/
|
||||||
|
val categoryName: String? = null,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预算关联的成员ID(仅在 type 为 MEMBER 时使用)
|
||||||
|
*/
|
||||||
|
val memberId: Int? = null,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预算金额
|
||||||
|
*/
|
||||||
|
val amount: Double,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预算生效开始日期
|
||||||
|
*/
|
||||||
|
val startDate: Date,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预算生效结束日期
|
||||||
|
*/
|
||||||
|
val endDate: Date,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否启用此预算
|
||||||
|
*/
|
||||||
|
val isEnabled: Boolean = true,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提醒阈值(百分比,如 0.8 表示达到 80% 时提醒)
|
||||||
|
*/
|
||||||
|
val alertThreshold: Double = 0.8,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建时间
|
||||||
|
*/
|
||||||
|
val createdAt: Date = Date(),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新时间
|
||||||
|
*/
|
||||||
|
val updatedAt: Date = Date()
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预算类型枚举
|
||||||
|
*/
|
||||||
|
enum class BudgetType {
|
||||||
|
TOTAL, // 总预算
|
||||||
|
CATEGORY, // 分类预算
|
||||||
|
MEMBER // 成员预算
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预算状态数据类,用于展示预算使用情况
|
||||||
|
*/
|
||||||
|
data class BudgetStatus(
|
||||||
|
val budget: Budget,
|
||||||
|
val spent: Double, // 已花费金额
|
||||||
|
val remaining: Double, // 剩余金额
|
||||||
|
val percentage: Double, // 使用百分比
|
||||||
|
val isOverBudget: Boolean, // 是否超预算
|
||||||
|
val isNearLimit: Boolean // 是否接近预算限制
|
||||||
|
)
|
@@ -3,11 +3,18 @@ package com.yovinchen.bookkeeping.model
|
|||||||
import androidx.room.Entity
|
import androidx.room.Entity
|
||||||
import androidx.room.PrimaryKey
|
import androidx.room.PrimaryKey
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 交易分类实体类
|
||||||
|
* 用于在Room数据库中存储收支分类信息
|
||||||
|
*
|
||||||
|
* 在记账应用中,每条记账记录都属于某个分类,
|
||||||
|
* 如"餐饮"、"交通"、"工资"等,便于用户对支出和收入进行分类统计
|
||||||
|
*/
|
||||||
@Entity(tableName = "categories")
|
@Entity(tableName = "categories")
|
||||||
data class Category(
|
data class Category(
|
||||||
@PrimaryKey(autoGenerate = true)
|
@PrimaryKey(autoGenerate = true)
|
||||||
val id: Long = 0,
|
val id: Long = 0, // 分类ID,自动生成
|
||||||
val name: String,
|
val name: String, // 分类名称
|
||||||
val type: TransactionType,
|
val type: TransactionType, // 分类关联的交易类型(收入或支出)
|
||||||
val icon: Int? = null
|
val icon: Int? = null // 分类图标资源ID,可选,默认为null
|
||||||
)
|
)
|
||||||
|
@@ -1,8 +1,14 @@
|
|||||||
package com.yovinchen.bookkeeping.model
|
package com.yovinchen.bookkeeping.model
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分类统计数据类
|
||||||
|
* 用于表示某个分类的统计信息,通常用于数据分析和图表展示
|
||||||
|
*
|
||||||
|
* 该类不是数据库实体,而是从数据库查询结果中聚合生成的统计数据
|
||||||
|
*/
|
||||||
data class CategoryStat(
|
data class CategoryStat(
|
||||||
val category: String,
|
val category: String, // 分类名称
|
||||||
val amount: Double,
|
val amount: Double, // 该分类的总金额
|
||||||
val count: Int = 0,
|
val count: Int = 0, // 该分类下的记录数量
|
||||||
val percentage: Double = 0.0
|
val percentage: Double = 0.0 // 该分类金额占总金额的百分比(0.0-100.0)
|
||||||
)
|
)
|
||||||
|
@@ -3,11 +3,18 @@ package com.yovinchen.bookkeeping.model
|
|||||||
import androidx.room.Entity
|
import androidx.room.Entity
|
||||||
import androidx.room.PrimaryKey
|
import androidx.room.PrimaryKey
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 家庭成员实体类
|
||||||
|
* 用于在Room数据库中存储家庭成员信息
|
||||||
|
*
|
||||||
|
* 在记账应用中,每条记账记录可以关联到特定的家庭成员,
|
||||||
|
* 以便追踪不同成员的收支情况
|
||||||
|
*/
|
||||||
@Entity(tableName = "members")
|
@Entity(tableName = "members")
|
||||||
data class Member(
|
data class Member(
|
||||||
@PrimaryKey(autoGenerate = true)
|
@PrimaryKey(autoGenerate = true)
|
||||||
val id: Int = 0,
|
val id: Int = 0, // 成员ID,自动生成
|
||||||
val name: String,
|
val name: String, // 成员姓名
|
||||||
val description: String = "", // 可选的描述信息
|
val description: String = "", // 成员描述信息,可选,默认为空字符串
|
||||||
val icon: Int? = null // 新增icon字段,可为空
|
val icon: Int? = null // 成员图标资源ID,可选,默认为null
|
||||||
)
|
)
|
||||||
|
@@ -2,16 +2,39 @@ package com.yovinchen.bookkeeping.model
|
|||||||
|
|
||||||
import androidx.room.ColumnInfo
|
import androidx.room.ColumnInfo
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 家庭成员统计数据类
|
||||||
|
* 用于表示某个成员的统计信息,通常用于数据分析和图表展示
|
||||||
|
*
|
||||||
|
* 该类不是数据库实体,而是通过数据库查询直接映射的结果类,
|
||||||
|
* 表示按成员分组的聚合数据
|
||||||
|
*/
|
||||||
data class MemberStat(
|
data class MemberStat(
|
||||||
|
/**
|
||||||
|
* 成员名称
|
||||||
|
* 映射数据库查询结果中的member列
|
||||||
|
*/
|
||||||
@ColumnInfo(name = "member")
|
@ColumnInfo(name = "member")
|
||||||
val member: String,
|
val member: String,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 该成员的总金额
|
||||||
|
* 映射数据库查询结果中的amount列
|
||||||
|
*/
|
||||||
@ColumnInfo(name = "amount")
|
@ColumnInfo(name = "amount")
|
||||||
val amount: Double,
|
val amount: Double,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 该成员下的记录数量
|
||||||
|
* 映射数据库查询结果中的count列
|
||||||
|
*/
|
||||||
@ColumnInfo(name = "count")
|
@ColumnInfo(name = "count")
|
||||||
val count: Int,
|
val count: Int,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 该成员金额占总金额的百分比(0.0-100.0)
|
||||||
|
* 映射数据库查询结果中的percentage列
|
||||||
|
*/
|
||||||
@ColumnInfo(name = "percentage")
|
@ColumnInfo(name = "percentage")
|
||||||
val percentage: Double = 0.0
|
val percentage: Double = 0.0
|
||||||
)
|
)
|
||||||
|
@@ -0,0 +1,15 @@
|
|||||||
|
package com.yovinchen.bookkeeping.model
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
|
||||||
|
@Entity(tableName = "settings")
|
||||||
|
data class Settings(
|
||||||
|
@PrimaryKey val id: Int = 1,
|
||||||
|
val monthStartDay: Int = 1, // 月度开始日期,1-28,默认为1号
|
||||||
|
val themeMode: String = "FOLLOW_SYSTEM", // 主题模式:FOLLOW_SYSTEM, LIGHT, DARK
|
||||||
|
val autoBackupEnabled: Boolean = false, // 自动备份开关
|
||||||
|
val autoBackupInterval: Int = 7, // 自动备份间隔(天)
|
||||||
|
val lastBackupTime: Long = 0L, // 上次备份时间
|
||||||
|
val encryptBackup: Boolean = true // 备份加密开关,默认开启
|
||||||
|
)
|
@@ -2,9 +2,35 @@ package com.yovinchen.bookkeeping.model
|
|||||||
|
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主题模式密封类
|
||||||
|
* 用于表示应用程序的不同主题设置选项
|
||||||
|
* 通过密封类实现,限制可能的主题模式类型
|
||||||
|
*/
|
||||||
sealed class ThemeMode {
|
sealed class ThemeMode {
|
||||||
|
/**
|
||||||
|
* 跟随系统主题模式
|
||||||
|
* 应用将根据设备系统的暗色/亮色主题设置自动调整
|
||||||
|
*/
|
||||||
object FOLLOW_SYSTEM : ThemeMode()
|
object FOLLOW_SYSTEM : ThemeMode()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 固定亮色主题模式
|
||||||
|
* 无论设备系统设置如何,应用将始终使用亮色主题
|
||||||
|
*/
|
||||||
object LIGHT : ThemeMode()
|
object LIGHT : ThemeMode()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 固定暗色主题模式
|
||||||
|
* 无论设备系统设置如何,应用将始终使用暗色主题
|
||||||
|
*/
|
||||||
object DARK : ThemeMode()
|
object DARK : ThemeMode()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自定义主题模式
|
||||||
|
* 允许用户选择自定义的主题颜色
|
||||||
|
*
|
||||||
|
* @property primaryColor 用户选择的主要颜色,将影响应用的主色调
|
||||||
|
*/
|
||||||
data class CUSTOM(val primaryColor: Color) : ThemeMode()
|
data class CUSTOM(val primaryColor: Color) : ThemeMode()
|
||||||
}
|
}
|
||||||
|
@@ -0,0 +1,111 @@
|
|||||||
|
package com.yovinchen.bookkeeping.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.core.*
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.scaleIn
|
||||||
|
import androidx.compose.animation.scaleOut
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AnimatedDialog(
|
||||||
|
visible: Boolean,
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
content: @Composable () -> Unit
|
||||||
|
) {
|
||||||
|
var showDialog by remember { mutableStateOf(false) }
|
||||||
|
var showContent by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
LaunchedEffect(visible) {
|
||||||
|
if (visible) {
|
||||||
|
showDialog = true
|
||||||
|
delay(50)
|
||||||
|
showContent = true
|
||||||
|
} else {
|
||||||
|
showContent = false
|
||||||
|
delay(300)
|
||||||
|
showDialog = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showDialog) {
|
||||||
|
Dialog(
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
properties = DialogProperties(
|
||||||
|
dismissOnBackPress = true,
|
||||||
|
dismissOnClickOutside = true,
|
||||||
|
usePlatformDefaultWidth = false
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
// 背景遮罩动画
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = showContent,
|
||||||
|
enter = fadeIn(
|
||||||
|
animationSpec = tween(
|
||||||
|
durationMillis = 200,
|
||||||
|
easing = FastOutSlowInEasing
|
||||||
|
)
|
||||||
|
),
|
||||||
|
exit = fadeOut(
|
||||||
|
animationSpec = tween(
|
||||||
|
durationMillis = 150,
|
||||||
|
easing = FastOutSlowInEasing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color.Black.copy(alpha = 0.5f))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 对话框内容动画
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = showContent,
|
||||||
|
enter = scaleIn(
|
||||||
|
initialScale = 0.8f,
|
||||||
|
animationSpec = spring(
|
||||||
|
dampingRatio = Spring.DampingRatioLowBouncy,
|
||||||
|
stiffness = Spring.StiffnessMedium
|
||||||
|
)
|
||||||
|
) + fadeIn(
|
||||||
|
animationSpec = tween(
|
||||||
|
durationMillis = 300,
|
||||||
|
easing = FastOutSlowInEasing
|
||||||
|
)
|
||||||
|
),
|
||||||
|
exit = scaleOut(
|
||||||
|
targetScale = 0.8f,
|
||||||
|
animationSpec = tween(
|
||||||
|
durationMillis = 200,
|
||||||
|
easing = FastOutSlowInEasing
|
||||||
|
)
|
||||||
|
) + fadeOut(
|
||||||
|
animationSpec = tween(
|
||||||
|
durationMillis = 200,
|
||||||
|
easing = FastOutSlowInEasing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,302 @@
|
|||||||
|
package com.yovinchen.bookkeeping.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.yovinchen.bookkeeping.model.BookkeepingRecord
|
||||||
|
import com.yovinchen.bookkeeping.model.TransactionType
|
||||||
|
import java.text.NumberFormat
|
||||||
|
import java.time.YearMonth
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 详细分析报表组件
|
||||||
|
* 显示按分类统计的详细信息
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun DetailedAnalysisReport(
|
||||||
|
records: List<BookkeepingRecord>,
|
||||||
|
startMonth: YearMonth,
|
||||||
|
endMonth: YearMonth,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val currencyFormatter = NumberFormat.getCurrencyInstance(Locale.CHINA)
|
||||||
|
|
||||||
|
// 按类型分组
|
||||||
|
val incomeRecords = records.filter { it.type == TransactionType.INCOME }
|
||||||
|
val expenseRecords = records.filter { it.type == TransactionType.EXPENSE }
|
||||||
|
|
||||||
|
// 按分类统计
|
||||||
|
val incomeByCategory = incomeRecords.groupBy { it.category }
|
||||||
|
.mapValues { it.value.sumOf { record -> record.amount } }
|
||||||
|
.toList()
|
||||||
|
.sortedByDescending { it.second }
|
||||||
|
|
||||||
|
val expenseByCategory = expenseRecords.groupBy { it.category }
|
||||||
|
.mapValues { it.value.sumOf { record -> record.amount } }
|
||||||
|
.toList()
|
||||||
|
.sortedByDescending { it.second }
|
||||||
|
|
||||||
|
// 总收入和总支出
|
||||||
|
val totalIncome = incomeRecords.sumOf { it.amount }
|
||||||
|
val totalExpense = expenseRecords.sumOf { it.amount }
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
// 时间范围标题
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = if (startMonth == endMonth) {
|
||||||
|
"统计期间:${startMonth.format(DateTimeFormatter.ofPattern("yyyy年MM月"))}"
|
||||||
|
} else {
|
||||||
|
"统计期间:${startMonth.format(DateTimeFormatter.ofPattern("yyyy年MM月"))} 至 ${endMonth.format(DateTimeFormatter.ofPattern("yyyy年MM月"))}"
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 支出分类详情
|
||||||
|
if (expenseByCategory.isNotEmpty()) {
|
||||||
|
CategoryDetailCard(
|
||||||
|
title = "支出分类明细",
|
||||||
|
categoryData = expenseByCategory,
|
||||||
|
total = totalExpense,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
currencyFormatter = currencyFormatter
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 收入分类详情
|
||||||
|
if (incomeByCategory.isNotEmpty()) {
|
||||||
|
CategoryDetailCard(
|
||||||
|
title = "收入分类明细",
|
||||||
|
categoryData = incomeByCategory,
|
||||||
|
total = totalIncome,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
currencyFormatter = currencyFormatter
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分类占比前5名
|
||||||
|
if (expenseByCategory.isNotEmpty()) {
|
||||||
|
TopCategoriesCard(
|
||||||
|
title = "支出TOP5",
|
||||||
|
categoryData = expenseByCategory.take(5),
|
||||||
|
total = totalExpense,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
currencyFormatter = currencyFormatter
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分类详情卡片
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun CategoryDetailCard(
|
||||||
|
title: String,
|
||||||
|
categoryData: List<Pair<String, Double>>,
|
||||||
|
total: Double,
|
||||||
|
color: Color,
|
||||||
|
currencyFormatter: NumberFormat
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = color,
|
||||||
|
modifier = Modifier.padding(bottom = 16.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
// 总计
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(bottom = 12.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "总计",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = currencyFormatter.format(total),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = color
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||||
|
|
||||||
|
// 分类列表
|
||||||
|
categoryData.forEach { (category, amount) ->
|
||||||
|
val percentage = if (total > 0) (amount / total * 100) else 0.0
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 8.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = category,
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "${String.format("%.1f", percentage)}%",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = currencyFormatter.format(amount),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 进度条
|
||||||
|
LinearProgressIndicator(
|
||||||
|
progress = (percentage / 100).toFloat(),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(4.dp)
|
||||||
|
.clip(RoundedCornerShape(2.dp)),
|
||||||
|
color = color.copy(alpha = 0.8f),
|
||||||
|
trackColor = color.copy(alpha = 0.2f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TOP分类卡片
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun TopCategoriesCard(
|
||||||
|
title: String,
|
||||||
|
categoryData: List<Pair<String, Double>>,
|
||||||
|
total: Double,
|
||||||
|
color: Color,
|
||||||
|
currencyFormatter: NumberFormat
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.secondaryContainer
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.padding(bottom = 12.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
categoryData.forEachIndexed { index, (category, amount) ->
|
||||||
|
val percentage = if (total > 0) (amount / total * 100) else 0.0
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
// 排名
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(24.dp)
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.background(
|
||||||
|
when (index) {
|
||||||
|
0 -> Color(0xFFFFD700) // 金色
|
||||||
|
1 -> Color(0xFFC0C0C0) // 银色
|
||||||
|
2 -> Color(0xFFCD7F32) // 铜色
|
||||||
|
else -> MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
}
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "${index + 1}",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = if (index < 3) Color.White else MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = category,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = if (index == 0) FontWeight.Bold else FontWeight.Normal
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.End
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = currencyFormatter.format(amount),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "${String.format("%.1f", percentage)}%",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -11,6 +11,9 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
|
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
|
||||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.TrendingDown
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.TrendingUp
|
||||||
|
import androidx.compose.material.icons.filled.AccountBalanceWallet
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -19,25 +22,86 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.window.Dialog
|
import androidx.compose.ui.window.Dialog
|
||||||
import com.yovinchen.bookkeeping.model.TransactionType
|
import com.yovinchen.bookkeeping.model.TransactionType
|
||||||
|
import androidx.compose.animation.*
|
||||||
|
import androidx.compose.animation.core.*
|
||||||
|
import androidx.compose.ui.draw.shadow
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import com.yovinchen.bookkeeping.ui.theme.IncomeColor
|
||||||
|
import com.yovinchen.bookkeeping.ui.theme.ExpenseColor
|
||||||
|
import com.yovinchen.bookkeeping.ui.theme.BalancePositive
|
||||||
|
import com.yovinchen.bookkeeping.ui.theme.BalanceNegative
|
||||||
import java.time.YearMonth
|
import java.time.YearMonth
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import kotlin.math.abs
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
// 格式化金额显示,适应不同数量级
|
||||||
|
private fun formatAmount(amount: Float): String {
|
||||||
|
val absAmount = abs(amount)
|
||||||
|
return when {
|
||||||
|
absAmount >= 100000 -> {
|
||||||
|
// 十万以上显示为万为单位
|
||||||
|
val wan = amount / 10000
|
||||||
|
"¥${String.format(Locale.getDefault(), "%.1f", wan)}万"
|
||||||
|
}
|
||||||
|
absAmount >= 10000 -> {
|
||||||
|
// 万级显示一位小数
|
||||||
|
"¥${String.format(Locale.getDefault(), "%.1f", amount)}"
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
// 千级以下显示两位小数
|
||||||
|
"¥${String.format(Locale.getDefault(), "%.2f", amount)}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalAnimationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun MonthYearPickerDialog(
|
fun MonthYearPickerDialog(
|
||||||
selectedMonth: YearMonth, onMonthSelected: (YearMonth) -> Unit, onDismiss: () -> Unit
|
selectedMonth: YearMonth, onMonthSelected: (YearMonth) -> Unit, onDismiss: () -> Unit
|
||||||
) {
|
) {
|
||||||
var currentYearMonth by remember { mutableStateOf(selectedMonth) }
|
var currentYearMonth by remember { mutableStateOf(selectedMonth) }
|
||||||
|
var isVisible by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
isVisible = true
|
||||||
|
}
|
||||||
|
|
||||||
Dialog(onDismissRequest = onDismiss) {
|
Dialog(onDismissRequest = {
|
||||||
Surface(
|
isVisible = false
|
||||||
modifier = Modifier
|
onDismiss()
|
||||||
.fillMaxWidth()
|
}) {
|
||||||
.wrapContentHeight(),
|
AnimatedVisibility(
|
||||||
shape = MaterialTheme.shapes.extraLarge,
|
visible = isVisible,
|
||||||
tonalElevation = 6.dp
|
enter = scaleIn(
|
||||||
|
initialScale = 0.8f,
|
||||||
|
animationSpec = spring(
|
||||||
|
dampingRatio = Spring.DampingRatioLowBouncy,
|
||||||
|
stiffness = Spring.StiffnessMedium
|
||||||
|
)
|
||||||
|
) + fadeIn(animationSpec = tween(300)),
|
||||||
|
exit = scaleOut(targetScale = 0.8f) + fadeOut(animationSpec = tween(200))
|
||||||
) {
|
) {
|
||||||
Column(
|
Surface(
|
||||||
modifier = Modifier.padding(16.dp)
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.wrapContentHeight()
|
||||||
|
.animateContentSize(
|
||||||
|
animationSpec = spring(
|
||||||
|
dampingRatio = Spring.DampingRatioMediumBouncy
|
||||||
|
)
|
||||||
|
),
|
||||||
|
shape = MaterialTheme.shapes.extraLarge,
|
||||||
|
tonalElevation = 6.dp
|
||||||
) {
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp)
|
||||||
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "选择年月",
|
text = "选择年月",
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
@@ -53,16 +117,39 @@ fun MonthYearPickerDialog(
|
|||||||
IconButton(onClick = {
|
IconButton(onClick = {
|
||||||
currentYearMonth = currentYearMonth.minusYears(1)
|
currentYearMonth = currentYearMonth.minusYears(1)
|
||||||
}) {
|
}) {
|
||||||
Text("<")
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.KeyboardArrowLeft,
|
||||||
|
contentDescription = "上一年",
|
||||||
|
modifier = Modifier.animateContentSize()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
AnimatedContent(
|
||||||
|
targetState = currentYearMonth.year,
|
||||||
|
transitionSpec = {
|
||||||
|
if (targetState > initialState) {
|
||||||
|
slideInVertically { -it } + fadeIn() togetherWith
|
||||||
|
slideOutVertically { it } + fadeOut()
|
||||||
|
} else {
|
||||||
|
slideInVertically { it } + fadeIn() togetherWith
|
||||||
|
slideOutVertically { -it } + fadeOut()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) { year ->
|
||||||
|
Text(
|
||||||
|
text = "${year}年",
|
||||||
|
style = MaterialTheme.typography.titleMedium.copy(
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
Text(
|
|
||||||
text = "${currentYearMonth.year}年",
|
|
||||||
style = MaterialTheme.typography.titleMedium
|
|
||||||
)
|
|
||||||
IconButton(onClick = {
|
IconButton(onClick = {
|
||||||
currentYearMonth = currentYearMonth.plusYears(1)
|
currentYearMonth = currentYearMonth.plusYears(1)
|
||||||
}) {
|
}) {
|
||||||
Text(">")
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||||
|
contentDescription = "下一年",
|
||||||
|
modifier = Modifier.animateContentSize()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,6 +208,7 @@ fun MonthYearPickerDialog(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,17 +228,52 @@ fun MonthlyStatistics(
|
|||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
var showMonthPicker by remember { mutableStateOf(false) }
|
var showMonthPicker by remember { mutableStateOf(false) }
|
||||||
|
val balance = totalIncome - totalExpense
|
||||||
|
|
||||||
|
// 添加动画效果
|
||||||
|
val animatedIncome by animateFloatAsState(
|
||||||
|
targetValue = totalIncome.toFloat(),
|
||||||
|
animationSpec = tween(600, easing = FastOutSlowInEasing)
|
||||||
|
)
|
||||||
|
val animatedExpense by animateFloatAsState(
|
||||||
|
targetValue = totalExpense.toFloat(),
|
||||||
|
animationSpec = tween(600, easing = FastOutSlowInEasing)
|
||||||
|
)
|
||||||
|
val animatedBalance by animateFloatAsState(
|
||||||
|
targetValue = balance.toFloat(),
|
||||||
|
animationSpec = tween(600, easing = FastOutSlowInEasing)
|
||||||
|
)
|
||||||
|
|
||||||
Card(
|
Card(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(16.dp),
|
.fillMaxHeight()
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
|
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||||
|
.shadow(
|
||||||
|
elevation = 8.dp,
|
||||||
|
shape = RoundedCornerShape(20.dp),
|
||||||
|
clip = false
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(20.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
|
||||||
|
),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(16.dp)
|
.fillMaxHeight()
|
||||||
|
.background(
|
||||||
|
brush = Brush.verticalGradient(
|
||||||
|
colors = listOf(
|
||||||
|
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f),
|
||||||
|
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.2f)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.padding(12.dp),
|
||||||
|
verticalArrangement = Arrangement.SpaceEvenly
|
||||||
) {
|
) {
|
||||||
// 月份选择器
|
// 月份选择器
|
||||||
Row(
|
Row(
|
||||||
@@ -163,7 +286,7 @@ fun MonthlyStatistics(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Text(text = "${selectedMonth.year}年${selectedMonth.monthValue}月",
|
Text(text = "${selectedMonth.year}年${selectedMonth.monthValue}月",
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
modifier = Modifier.clickable { showMonthPicker = true })
|
modifier = Modifier.clickable { showMonthPicker = true })
|
||||||
|
|
||||||
IconButton(onClick = onNextMonth) {
|
IconButton(onClick = onNextMonth) {
|
||||||
@@ -171,68 +294,137 @@ fun MonthlyStatistics(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween
|
modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween
|
||||||
) {
|
) {
|
||||||
// 支出统计
|
// 支出统计
|
||||||
Column(modifier = Modifier
|
Surface(
|
||||||
.weight(1f)
|
modifier = Modifier
|
||||||
.clickable { onExpenseClick() }
|
.weight(1f)
|
||||||
.background(
|
.clickable { onExpenseClick() },
|
||||||
if (selectedType == TransactionType.EXPENSE) MaterialTheme.colorScheme.primaryContainer
|
shape = RoundedCornerShape(12.dp),
|
||||||
else Color.Transparent, RoundedCornerShape(8.dp)
|
color = if (selectedType == TransactionType.EXPENSE)
|
||||||
)
|
ExpenseColor.copy(alpha = 0.15f)
|
||||||
.padding(8.dp)) {
|
else
|
||||||
Text(
|
MaterialTheme.colorScheme.surface.copy(alpha = 0.6f)
|
||||||
text = "支出", style = MaterialTheme.typography.titleMedium
|
) {
|
||||||
)
|
Column(
|
||||||
Text(
|
modifier = Modifier.padding(8.dp),
|
||||||
text = "¥${String.format("%.2f", totalExpense)}",
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
) {
|
||||||
color = MaterialTheme.colorScheme.error
|
Row(
|
||||||
)
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.TrendingDown,
|
||||||
|
contentDescription = "支出",
|
||||||
|
modifier = Modifier.size(14.dp),
|
||||||
|
tint = ExpenseColor
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text(
|
||||||
|
text = "支出",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
|
Text(
|
||||||
|
text = formatAmount(animatedExpense),
|
||||||
|
style = MaterialTheme.typography.bodyLarge.copy(
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
),
|
||||||
|
color = ExpenseColor,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.width(16.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
// 收入统计
|
// 收入统计
|
||||||
Column(modifier = Modifier
|
Surface(
|
||||||
.weight(1f)
|
modifier = Modifier
|
||||||
.clickable { onIncomeClick() }
|
.weight(1f)
|
||||||
.background(
|
.clickable { onIncomeClick() },
|
||||||
if (selectedType == TransactionType.INCOME) MaterialTheme.colorScheme.primaryContainer
|
shape = RoundedCornerShape(12.dp),
|
||||||
else Color.Transparent, RoundedCornerShape(8.dp)
|
color = if (selectedType == TransactionType.INCOME)
|
||||||
)
|
IncomeColor.copy(alpha = 0.15f)
|
||||||
.padding(8.dp)) {
|
else
|
||||||
Text(
|
MaterialTheme.colorScheme.surface.copy(alpha = 0.6f)
|
||||||
text = "收入", style = MaterialTheme.typography.titleMedium
|
) {
|
||||||
)
|
Column(
|
||||||
Text(
|
modifier = Modifier.padding(8.dp),
|
||||||
text = "¥${String.format("%.2f", totalIncome)}",
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
) {
|
||||||
color = MaterialTheme.colorScheme.primary
|
Row(
|
||||||
)
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.TrendingUp,
|
||||||
|
contentDescription = "收入",
|
||||||
|
modifier = Modifier.size(14.dp),
|
||||||
|
tint = IncomeColor
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text(
|
||||||
|
text = "收入",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
|
Text(
|
||||||
|
text = formatAmount(animatedIncome),
|
||||||
|
style = MaterialTheme.typography.bodyLarge.copy(
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
),
|
||||||
|
color = IncomeColor,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.width(16.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
// 结余统计
|
// 结余统计
|
||||||
Column(modifier = Modifier
|
Surface(
|
||||||
.weight(1f)
|
modifier = Modifier
|
||||||
.clickable { onClearFilter() }
|
.weight(1f)
|
||||||
.background(
|
.clickable { onClearFilter() },
|
||||||
if (selectedType == TransactionType.INCOME) MaterialTheme.colorScheme.primaryContainer
|
shape = RoundedCornerShape(12.dp),
|
||||||
else Color.Transparent, RoundedCornerShape(8.dp)
|
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.6f)
|
||||||
)
|
) {
|
||||||
.padding(8.dp)) {
|
Column(
|
||||||
Text(
|
modifier = Modifier.padding(8.dp),
|
||||||
text = "结余", style = MaterialTheme.typography.titleMedium
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
)
|
) {
|
||||||
Text(
|
Row(
|
||||||
text = "¥${String.format("%.2f", totalIncome - totalExpense)}",
|
verticalAlignment = Alignment.CenterVertically
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
) {
|
||||||
color = if (totalIncome >= totalExpense) MaterialTheme.colorScheme.tertiary
|
Icon(
|
||||||
else MaterialTheme.colorScheme.error
|
imageVector = Icons.Default.AccountBalanceWallet,
|
||||||
)
|
contentDescription = "结余",
|
||||||
|
modifier = Modifier.size(14.dp),
|
||||||
|
tint = if (animatedBalance >= 0) BalancePositive else BalanceNegative
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text(
|
||||||
|
text = "结余",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
|
Text(
|
||||||
|
text = formatAmount(animatedBalance),
|
||||||
|
style = MaterialTheme.typography.bodyLarge.copy(
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
),
|
||||||
|
color = if (animatedBalance >= 0) BalancePositive else BalanceNegative,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -0,0 +1,258 @@
|
|||||||
|
package com.yovinchen.bookkeeping.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.yovinchen.bookkeeping.model.BookkeepingRecord
|
||||||
|
import com.yovinchen.bookkeeping.model.TransactionType
|
||||||
|
import java.text.NumberFormat
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.YearMonth
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 月度/年度报表组件
|
||||||
|
* 显示收支对比、盈余情况等统计信息
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun MonthlyYearlyReport(
|
||||||
|
records: List<BookkeepingRecord>,
|
||||||
|
period: String, // "月度" 或 "年度"
|
||||||
|
startMonth: YearMonth,
|
||||||
|
endMonth: YearMonth,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val totalIncome = records
|
||||||
|
.filter { it.type == TransactionType.INCOME }
|
||||||
|
.sumOf { it.amount }
|
||||||
|
|
||||||
|
val totalExpense = records
|
||||||
|
.filter { it.type == TransactionType.EXPENSE }
|
||||||
|
.sumOf { it.amount }
|
||||||
|
|
||||||
|
val balance = totalIncome - totalExpense
|
||||||
|
val savingsRate = if (totalIncome > 0) {
|
||||||
|
((totalIncome - totalExpense) / totalIncome * 100).coerceAtLeast(0.0)
|
||||||
|
} else 0.0
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
// 标题
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "$period 报表",
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = if (startMonth == endMonth) {
|
||||||
|
startMonth.format(DateTimeFormatter.ofPattern("yyyy年MM月"))
|
||||||
|
} else {
|
||||||
|
"${startMonth.format(DateTimeFormatter.ofPattern("yyyy年MM月"))} - ${endMonth.format(DateTimeFormatter.ofPattern("yyyy年MM月"))}"
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider()
|
||||||
|
|
||||||
|
// 收支对比
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceEvenly
|
||||||
|
) {
|
||||||
|
// 收入
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "总收入",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = formatCurrency(totalIncome),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分隔线
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.width(1.dp)
|
||||||
|
.height(40.dp)
|
||||||
|
.background(MaterialTheme.colorScheme.outlineVariant)
|
||||||
|
)
|
||||||
|
|
||||||
|
// 支出
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "总支出",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = formatCurrency(totalExpense),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 盈余情况
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = if (balance >= 0) {
|
||||||
|
MaterialTheme.colorScheme.primaryContainer
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.errorContainer
|
||||||
|
}
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(12.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = if (balance >= 0) "盈余" else "亏损",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = if (balance >= 0) {
|
||||||
|
MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onErrorContainer
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = formatCurrency(kotlin.math.abs(balance)),
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = if (balance >= 0) {
|
||||||
|
MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onErrorContainer
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 储蓄率
|
||||||
|
if (totalIncome > 0) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "储蓄率",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.weight(2f)
|
||||||
|
) {
|
||||||
|
LinearProgressIndicator(
|
||||||
|
progress = (savingsRate / 100).toFloat().coerceIn(0f, 1f),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(8.dp)
|
||||||
|
.clip(RoundedCornerShape(4.dp)),
|
||||||
|
color = when {
|
||||||
|
savingsRate >= 30 -> MaterialTheme.colorScheme.primary
|
||||||
|
savingsRate >= 10 -> MaterialTheme.colorScheme.tertiary
|
||||||
|
else -> MaterialTheme.colorScheme.error
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "${String.format("%.1f", savingsRate)}%",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.padding(start = 8.dp),
|
||||||
|
color = when {
|
||||||
|
savingsRate >= 30 -> MaterialTheme.colorScheme.primary
|
||||||
|
savingsRate >= 10 -> MaterialTheme.colorScheme.tertiary
|
||||||
|
else -> MaterialTheme.colorScheme.error
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 日均消费
|
||||||
|
val dayCount = if (period == "月度") {
|
||||||
|
// 计算月度天数
|
||||||
|
java.time.temporal.ChronoUnit.DAYS.between(
|
||||||
|
startMonth.atDay(1),
|
||||||
|
endMonth.atEndOfMonth()
|
||||||
|
) + 1
|
||||||
|
} else {
|
||||||
|
// 计算年度天数
|
||||||
|
java.time.temporal.ChronoUnit.DAYS.between(
|
||||||
|
startMonth.atDay(1),
|
||||||
|
endMonth.atEndOfMonth()
|
||||||
|
) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
val dailyAverage = if (dayCount > 0) totalExpense / dayCount else 0.0
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "日均消费",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = formatCurrency(dailyAverage),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化货币
|
||||||
|
*/
|
||||||
|
private fun formatCurrency(amount: Double): String {
|
||||||
|
val format = NumberFormat.getCurrencyInstance(Locale.CHINA)
|
||||||
|
return format.format(amount)
|
||||||
|
}
|
@@ -11,11 +11,24 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
|||||||
import androidx.compose.ui.res.vectorResource
|
import androidx.compose.ui.res.vectorResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.yovinchen.bookkeeping.model.BookkeepingRecord
|
import com.yovinchen.bookkeeping.model.BookkeepingRecord
|
||||||
|
import androidx.compose.animation.*
|
||||||
|
import androidx.compose.animation.core.*
|
||||||
|
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import com.yovinchen.bookkeeping.model.Member
|
import com.yovinchen.bookkeeping.model.Member
|
||||||
import com.yovinchen.bookkeeping.model.TransactionType
|
import com.yovinchen.bookkeeping.model.TransactionType
|
||||||
import com.yovinchen.bookkeeping.utils.IconManager
|
import com.yovinchen.bookkeeping.utils.IconManager
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.*
|
import androidx.compose.ui.draw.shadow
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import com.yovinchen.bookkeeping.ui.theme.IncomeColor
|
||||||
|
import com.yovinchen.bookkeeping.ui.theme.ExpenseColor
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun RecordItem(
|
fun RecordItem(
|
||||||
@@ -29,29 +42,108 @@ fun RecordItem(
|
|||||||
val timeFormat = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) }
|
val timeFormat = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) }
|
||||||
val member = members.find { it.id == record.memberId }
|
val member = members.find { it.id == record.memberId }
|
||||||
val categoryIcon = IconManager.getCategoryIconVector(record.category)
|
val categoryIcon = IconManager.getCategoryIconVector(record.category)
|
||||||
|
|
||||||
|
// 添加滑动和缩放动画状态
|
||||||
|
var offsetX by remember { mutableStateOf(0f) }
|
||||||
|
val animatedOffset by animateFloatAsState(
|
||||||
|
targetValue = offsetX,
|
||||||
|
animationSpec = spring(
|
||||||
|
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||||
|
stiffness = Spring.StiffnessLow
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
var isPressed by remember { mutableStateOf(false) }
|
||||||
|
val scale by animateFloatAsState(
|
||||||
|
targetValue = if (isPressed) 0.95f else 1f,
|
||||||
|
animationSpec = spring(
|
||||||
|
dampingRatio = Spring.DampingRatioMediumBouncy
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
Card(
|
Card(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
.padding(horizontal = 0.dp, vertical = 4.dp)
|
||||||
|
.shadow(
|
||||||
|
elevation = 2.dp,
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
clip = false
|
||||||
|
)
|
||||||
|
.graphicsLayer {
|
||||||
|
translationX = animatedOffset
|
||||||
|
scaleX = scale
|
||||||
|
scaleY = scale
|
||||||
|
}
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
detectHorizontalDragGestures(
|
||||||
|
onDragStart = { isPressed = true },
|
||||||
|
onDragEnd = {
|
||||||
|
isPressed = false
|
||||||
|
if (offsetX < -100) {
|
||||||
|
showDeleteDialog = true
|
||||||
|
}
|
||||||
|
offsetX = 0f
|
||||||
|
},
|
||||||
|
onDragCancel = {
|
||||||
|
isPressed = false
|
||||||
|
offsetX = 0f
|
||||||
|
}
|
||||||
|
) { _, dragAmount ->
|
||||||
|
offsetX = (offsetX + dragAmount).coerceIn(-200f, 0f)
|
||||||
|
}
|
||||||
|
}
|
||||||
.clickable(onClick = onClick),
|
.clickable(onClick = onClick),
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface
|
||||||
|
),
|
||||||
|
elevation = CardDefaults.cardElevation(
|
||||||
|
defaultElevation = 0.dp,
|
||||||
|
pressedElevation = 4.dp
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
.background(
|
||||||
|
if (record.type == TransactionType.INCOME) {
|
||||||
|
IncomeColor.copy(alpha = 0.05f)
|
||||||
|
} else {
|
||||||
|
ExpenseColor.copy(alpha = 0.05f)
|
||||||
|
}
|
||||||
|
)
|
||||||
.padding(16.dp),
|
.padding(16.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
// 左侧分类图标
|
// 左侧分类图标
|
||||||
if (categoryIcon != null) {
|
Surface(
|
||||||
Icon(
|
shape = RoundedCornerShape(10.dp),
|
||||||
imageVector = categoryIcon,
|
color = if (record.type == TransactionType.INCOME) {
|
||||||
contentDescription = record.category,
|
IncomeColor.copy(alpha = 0.1f)
|
||||||
modifier = Modifier.size(24.dp),
|
} else {
|
||||||
tint = Color.Unspecified
|
ExpenseColor.copy(alpha = 0.1f)
|
||||||
)
|
},
|
||||||
|
modifier = Modifier.size(44.dp)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
) {
|
||||||
|
if (categoryIcon != null) {
|
||||||
|
Icon(
|
||||||
|
imageVector = categoryIcon,
|
||||||
|
contentDescription = record.category,
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
tint = if (record.type == TransactionType.INCOME) {
|
||||||
|
IncomeColor
|
||||||
|
} else {
|
||||||
|
ExpenseColor
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 中间内容区域
|
// 中间内容区域
|
||||||
@@ -81,19 +173,40 @@ fun RecordItem(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 右侧金额显示
|
// 右侧金额显示
|
||||||
Text(
|
Column(
|
||||||
text = String.format("%.2f", record.amount),
|
horizontalAlignment = Alignment.End
|
||||||
style = MaterialTheme.typography.titleMedium,
|
) {
|
||||||
color = if (record.type == TransactionType.EXPENSE)
|
Text(
|
||||||
MaterialTheme.colorScheme.error
|
text = String.format(Locale.getDefault(), "%.2f", record.amount),
|
||||||
else
|
style = MaterialTheme.typography.titleMedium.copy(
|
||||||
MaterialTheme.colorScheme.primary
|
fontWeight = FontWeight.Bold
|
||||||
)
|
),
|
||||||
|
color = if (record.type == TransactionType.EXPENSE)
|
||||||
|
ExpenseColor
|
||||||
|
else
|
||||||
|
IncomeColor
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = if (record.type == TransactionType.EXPENSE) "支出" else "收入",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showDeleteDialog) {
|
if (showDeleteDialog) {
|
||||||
AlertDialog(
|
AnimatedVisibility(
|
||||||
|
visible = showDeleteDialog,
|
||||||
|
enter = fadeIn(animationSpec = tween(300)) + scaleIn(
|
||||||
|
initialScale = 0.8f,
|
||||||
|
animationSpec = spring(
|
||||||
|
dampingRatio = Spring.DampingRatioMediumBouncy
|
||||||
|
)
|
||||||
|
),
|
||||||
|
exit = fadeOut(animationSpec = tween(200)) + scaleOut(targetScale = 0.8f)
|
||||||
|
) {
|
||||||
|
AlertDialog(
|
||||||
onDismissRequest = { showDeleteDialog = false },
|
onDismissRequest = { showDeleteDialog = false },
|
||||||
title = { Text("确认删除") },
|
title = { Text("确认删除") },
|
||||||
text = { Text("确定要删除这条记录吗?") },
|
text = { Text("确定要删除这条记录吗?") },
|
||||||
@@ -112,6 +225,7 @@ fun RecordItem(
|
|||||||
Text("取消")
|
Text("取消")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -7,7 +7,16 @@ import androidx.compose.material3.*
|
|||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.window.Dialog
|
import com.yovinchen.bookkeeping.ui.components.AnimatedDialog
|
||||||
|
import androidx.compose.animation.*
|
||||||
|
import androidx.compose.animation.core.*
|
||||||
|
import androidx.compose.ui.draw.shadow
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.material3.FilterChipDefaults
|
||||||
|
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||||
|
import com.yovinchen.bookkeeping.ui.theme.ExpenseColor
|
||||||
|
import com.yovinchen.bookkeeping.ui.theme.IncomeColor
|
||||||
import com.yovinchen.bookkeeping.model.Category
|
import com.yovinchen.bookkeeping.model.Category
|
||||||
import com.yovinchen.bookkeeping.model.Member
|
import com.yovinchen.bookkeeping.model.Member
|
||||||
import com.yovinchen.bookkeeping.model.TransactionType
|
import com.yovinchen.bookkeeping.model.TransactionType
|
||||||
@@ -53,40 +62,88 @@ fun AddRecordDialog(
|
|||||||
?: ""
|
?: ""
|
||||||
}
|
}
|
||||||
|
|
||||||
Dialog(onDismissRequest = onDismiss) {
|
AnimatedDialog(
|
||||||
|
visible = true,
|
||||||
|
onDismissRequest = onDismiss
|
||||||
|
) {
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(16.dp),
|
.padding(16.dp)
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
|
.animateContentSize(
|
||||||
|
animationSpec = spring(
|
||||||
|
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||||
|
stiffness = Spring.StiffnessMedium
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.shadow(
|
||||||
|
elevation = 8.dp,
|
||||||
|
shape = RoundedCornerShape(20.dp),
|
||||||
|
clip = false
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(20.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface
|
||||||
|
),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(16.dp)
|
.padding(16.dp)
|
||||||
|
.animateContentSize(
|
||||||
|
animationSpec = spring(
|
||||||
|
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||||
|
stiffness = Spring.StiffnessLow
|
||||||
|
)
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "添加记录",
|
text = "添加记录",
|
||||||
style = MaterialTheme.typography.titleLarge
|
style = MaterialTheme.typography.headlineSmall.copy(
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
),
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
// 收入/支出选择
|
// 收入/支出选择
|
||||||
Row(
|
AnimatedVisibility(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
visible = true,
|
||||||
horizontalArrangement = Arrangement.SpaceEvenly
|
enter = fadeIn() + expandVertically()
|
||||||
) {
|
) {
|
||||||
FilterChip(
|
Surface(
|
||||||
selected = selectedType == TransactionType.EXPENSE,
|
shape = RoundedCornerShape(12.dp),
|
||||||
onClick = { selectedType = TransactionType.EXPENSE },
|
color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f),
|
||||||
label = { Text("支出") }
|
modifier = Modifier.fillMaxWidth()
|
||||||
)
|
) {
|
||||||
FilterChip(
|
Row(
|
||||||
selected = selectedType == TransactionType.INCOME,
|
modifier = Modifier
|
||||||
onClick = { selectedType = TransactionType.INCOME },
|
.fillMaxWidth()
|
||||||
label = { Text("收入") }
|
.padding(4.dp),
|
||||||
)
|
horizontalArrangement = Arrangement.SpaceEvenly
|
||||||
|
) {
|
||||||
|
FilterChip(
|
||||||
|
selected = selectedType == TransactionType.EXPENSE,
|
||||||
|
onClick = { selectedType = TransactionType.EXPENSE },
|
||||||
|
label = { Text("支出") },
|
||||||
|
colors = FilterChipDefaults.filterChipColors(
|
||||||
|
selectedContainerColor = ExpenseColor.copy(alpha = 0.2f),
|
||||||
|
selectedLabelColor = ExpenseColor
|
||||||
|
)
|
||||||
|
)
|
||||||
|
FilterChip(
|
||||||
|
selected = selectedType == TransactionType.INCOME,
|
||||||
|
onClick = { selectedType = TransactionType.INCOME },
|
||||||
|
label = { Text("收入") },
|
||||||
|
colors = FilterChipDefaults.filterChipColors(
|
||||||
|
selectedContainerColor = IncomeColor.copy(alpha = 0.2f),
|
||||||
|
selectedLabelColor = IncomeColor
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
@@ -96,7 +153,12 @@ fun AddRecordDialog(
|
|||||||
onValueChange = { amount = it },
|
onValueChange = { amount = it },
|
||||||
label = { Text("金额") },
|
label = { Text("金额") },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
singleLine = true
|
singleLine = true,
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
|
focusedBorderColor = MaterialTheme.colorScheme.primary,
|
||||||
|
unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
@@ -0,0 +1,223 @@
|
|||||||
|
package com.yovinchen.bookkeeping.ui.dialog
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.yovinchen.bookkeeping.model.*
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预算编辑对话框
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun BudgetEditDialog(
|
||||||
|
budget: Budget? = null,
|
||||||
|
categories: List<Category>,
|
||||||
|
members: List<Member>,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onConfirm: (Budget) -> Unit
|
||||||
|
) {
|
||||||
|
var selectedType by remember { mutableStateOf(budget?.type ?: BudgetType.TOTAL) }
|
||||||
|
var amount by remember { mutableStateOf(budget?.amount?.toString() ?: "") }
|
||||||
|
var selectedCategory by remember { mutableStateOf(budget?.categoryName) }
|
||||||
|
var selectedMemberId by remember { mutableStateOf(budget?.memberId) }
|
||||||
|
var alertThreshold by remember { mutableStateOf((budget?.alertThreshold ?: 0.8) * 100) }
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = {
|
||||||
|
Text(if (budget == null) "添加预算" else "编辑预算")
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
// 预算类型选择
|
||||||
|
Text(
|
||||||
|
text = "预算类型",
|
||||||
|
style = MaterialTheme.typography.labelMedium
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
FilterChip(
|
||||||
|
selected = selectedType == BudgetType.TOTAL,
|
||||||
|
onClick = { selectedType = BudgetType.TOTAL },
|
||||||
|
label = { Text("总预算") }
|
||||||
|
)
|
||||||
|
FilterChip(
|
||||||
|
selected = selectedType == BudgetType.CATEGORY,
|
||||||
|
onClick = { selectedType = BudgetType.CATEGORY },
|
||||||
|
label = { Text("分类预算") }
|
||||||
|
)
|
||||||
|
FilterChip(
|
||||||
|
selected = selectedType == BudgetType.MEMBER,
|
||||||
|
onClick = { selectedType = BudgetType.MEMBER },
|
||||||
|
label = { Text("成员预算") }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 金额输入
|
||||||
|
OutlinedTextField(
|
||||||
|
value = amount,
|
||||||
|
onValueChange = { amount = it.filter { char -> char.isDigit() || char == '.' } },
|
||||||
|
label = { Text("预算金额") },
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true
|
||||||
|
)
|
||||||
|
|
||||||
|
// 分类选择(仅在分类预算时显示)
|
||||||
|
if (selectedType == BudgetType.CATEGORY) {
|
||||||
|
var expanded by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
ExposedDropdownMenuBox(
|
||||||
|
expanded = expanded,
|
||||||
|
onExpandedChange = { expanded = !expanded }
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = selectedCategory ?: "",
|
||||||
|
onValueChange = {},
|
||||||
|
label = { Text("选择分类") },
|
||||||
|
readOnly = true,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.menuAnchor(),
|
||||||
|
trailingIcon = {
|
||||||
|
ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
ExposedDropdownMenu(
|
||||||
|
expanded = expanded,
|
||||||
|
onDismissRequest = { expanded = false }
|
||||||
|
) {
|
||||||
|
categories.forEach { category ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(category.name) },
|
||||||
|
onClick = {
|
||||||
|
selectedCategory = category.name
|
||||||
|
expanded = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 成员选择(仅在成员预算时显示)
|
||||||
|
if (selectedType == BudgetType.MEMBER) {
|
||||||
|
var expanded by remember { mutableStateOf(false) }
|
||||||
|
val selectedMember = members.find { it.id == selectedMemberId }
|
||||||
|
|
||||||
|
ExposedDropdownMenuBox(
|
||||||
|
expanded = expanded,
|
||||||
|
onExpandedChange = { expanded = !expanded }
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = selectedMember?.name ?: "",
|
||||||
|
onValueChange = {},
|
||||||
|
label = { Text("选择成员") },
|
||||||
|
readOnly = true,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.menuAnchor(),
|
||||||
|
trailingIcon = {
|
||||||
|
ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
ExposedDropdownMenu(
|
||||||
|
expanded = expanded,
|
||||||
|
onDismissRequest = { expanded = false }
|
||||||
|
) {
|
||||||
|
members.forEach { member ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(member.name) },
|
||||||
|
onClick = {
|
||||||
|
selectedMemberId = member.id
|
||||||
|
expanded = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 预警阈值
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = "预警阈值: ${alertThreshold.toInt()}%",
|
||||||
|
style = MaterialTheme.typography.labelMedium
|
||||||
|
)
|
||||||
|
Slider(
|
||||||
|
value = alertThreshold.toFloat(),
|
||||||
|
onValueChange = { alertThreshold = it.toDouble() },
|
||||||
|
valueRange = 50f..95f,
|
||||||
|
steps = 8,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "当使用金额达到预算的 ${alertThreshold.toInt()}% 时提醒",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
val amountValue = amount.toDoubleOrNull()
|
||||||
|
if (amountValue != null && amountValue > 0) {
|
||||||
|
val calendar = Calendar.getInstance()
|
||||||
|
val startDate = budget?.startDate ?: calendar.time
|
||||||
|
|
||||||
|
// 设置结束日期为当月最后一天
|
||||||
|
calendar.set(Calendar.DAY_OF_MONTH, calendar.getActualMaximum(Calendar.DAY_OF_MONTH))
|
||||||
|
calendar.set(Calendar.HOUR_OF_DAY, 23)
|
||||||
|
calendar.set(Calendar.MINUTE, 59)
|
||||||
|
calendar.set(Calendar.SECOND, 59)
|
||||||
|
val endDate = calendar.time
|
||||||
|
|
||||||
|
val newBudget = Budget(
|
||||||
|
id = budget?.id ?: 0,
|
||||||
|
type = selectedType,
|
||||||
|
amount = amountValue,
|
||||||
|
categoryName = if (selectedType == BudgetType.CATEGORY) selectedCategory else null,
|
||||||
|
memberId = if (selectedType == BudgetType.MEMBER) selectedMemberId else null,
|
||||||
|
startDate = startDate,
|
||||||
|
endDate = endDate,
|
||||||
|
alertThreshold = alertThreshold / 100,
|
||||||
|
isEnabled = budget?.isEnabled ?: true,
|
||||||
|
createdAt = budget?.createdAt ?: Date(),
|
||||||
|
updatedAt = Date()
|
||||||
|
)
|
||||||
|
onConfirm(newBudget)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = amount.toDoubleOrNull() != null && amount.toDouble() > 0 &&
|
||||||
|
(selectedType != BudgetType.CATEGORY || selectedCategory != null) &&
|
||||||
|
(selectedType != BudgetType.MEMBER || selectedMemberId != null)
|
||||||
|
) {
|
||||||
|
Text("确定")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text("取消")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
@@ -21,8 +21,23 @@ import com.yovinchen.bookkeeping.model.ThemeMode
|
|||||||
import com.yovinchen.bookkeeping.ui.screen.*
|
import com.yovinchen.bookkeeping.ui.screen.*
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.draw.shadow
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
import java.time.YearMonth
|
import java.time.YearMonth
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
|
import androidx.compose.animation.*
|
||||||
|
import androidx.compose.animation.core.*
|
||||||
|
import androidx.compose.animation.AnimatedContentTransitionScope
|
||||||
|
import androidx.compose.animation.AnimatedContentScope
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.slideInHorizontally
|
||||||
|
import androidx.compose.animation.slideOutHorizontally
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
|
||||||
sealed class Screen(
|
sealed class Screen(
|
||||||
val route: String,
|
val route: String,
|
||||||
@@ -47,6 +62,10 @@ sealed class Screen(
|
|||||||
"设置",
|
"设置",
|
||||||
iconResId = R.drawable.setting
|
iconResId = R.drawable.setting
|
||||||
)
|
)
|
||||||
|
object Budget : Screen(
|
||||||
|
"budget",
|
||||||
|
"预算管理"
|
||||||
|
)
|
||||||
object CategoryDetail : Screen(
|
object CategoryDetail : Screen(
|
||||||
"category_detail/{category}/{startMonth}/{endMonth}",
|
"category_detail/{category}/{startMonth}/{endMonth}",
|
||||||
"分类详情"
|
"分类详情"
|
||||||
@@ -79,7 +98,7 @@ sealed class Screen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun MainNavigation(
|
fun MainNavigation(
|
||||||
currentTheme: ThemeMode,
|
currentTheme: ThemeMode,
|
||||||
@@ -103,15 +122,58 @@ fun MainNavigation(
|
|||||||
NavigationBarItem(
|
NavigationBarItem(
|
||||||
icon = {
|
icon = {
|
||||||
screen.icon()?.let { icon ->
|
screen.icon()?.let { icon ->
|
||||||
Icon(
|
androidx.compose.animation.AnimatedContent(
|
||||||
imageVector = icon,
|
targetState = selected,
|
||||||
contentDescription = screen.title,
|
transitionSpec = {
|
||||||
modifier = Modifier.size(24.dp),
|
scaleIn(
|
||||||
tint = Color.Unspecified
|
animationSpec = spring(
|
||||||
|
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||||
|
stiffness = Spring.StiffnessMedium
|
||||||
|
)
|
||||||
|
) + fadeIn() togetherWith
|
||||||
|
scaleOut(
|
||||||
|
animationSpec = spring(
|
||||||
|
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||||
|
stiffness = Spring.StiffnessMedium
|
||||||
|
)
|
||||||
|
) + fadeOut()
|
||||||
|
}
|
||||||
|
) { isSelected ->
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = screen.title,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(if (isSelected) 28.dp else 24.dp)
|
||||||
|
.animateContentSize(
|
||||||
|
animationSpec = spring(
|
||||||
|
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||||
|
stiffness = Spring.StiffnessMedium
|
||||||
|
)
|
||||||
|
),
|
||||||
|
tint = Color.Unspecified
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
label = {
|
||||||
|
AnimatedContent(
|
||||||
|
targetState = selected,
|
||||||
|
transitionSpec = {
|
||||||
|
(fadeIn(animationSpec = tween(300)) +
|
||||||
|
expandVertically(animationSpec = tween(300))) togetherWith
|
||||||
|
(fadeOut(animationSpec = tween(200)) +
|
||||||
|
shrinkVertically(animationSpec = tween(200)))
|
||||||
|
}
|
||||||
|
) { isSelected ->
|
||||||
|
Text(
|
||||||
|
text = screen.title,
|
||||||
|
style = if (isSelected)
|
||||||
|
MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold)
|
||||||
|
else
|
||||||
|
MaterialTheme.typography.labelMedium
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
label = { Text(screen.title) },
|
|
||||||
selected = selected,
|
selected = selected,
|
||||||
onClick = {
|
onClick = {
|
||||||
navController.navigate(screen.route) {
|
navController.navigate(screen.route) {
|
||||||
@@ -130,11 +192,93 @@ fun MainNavigation(
|
|||||||
NavHost(
|
NavHost(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
startDestination = Screen.Home.route,
|
startDestination = Screen.Home.route,
|
||||||
modifier = Modifier.padding(innerPadding)
|
modifier = Modifier.padding(innerPadding),
|
||||||
|
enterTransition = {
|
||||||
|
slideIntoContainer(
|
||||||
|
AnimatedContentTransitionScope.SlideDirection.Left,
|
||||||
|
animationSpec = tween(300, easing = FastOutSlowInEasing)
|
||||||
|
) + fadeIn(animationSpec = tween(300))
|
||||||
|
},
|
||||||
|
exitTransition = {
|
||||||
|
slideOutOfContainer(
|
||||||
|
AnimatedContentTransitionScope.SlideDirection.Left,
|
||||||
|
animationSpec = tween(300, easing = FastOutSlowInEasing)
|
||||||
|
) + fadeOut(animationSpec = tween(200))
|
||||||
|
},
|
||||||
|
popEnterTransition = {
|
||||||
|
slideIntoContainer(
|
||||||
|
AnimatedContentTransitionScope.SlideDirection.Right,
|
||||||
|
animationSpec = tween(300, easing = FastOutSlowInEasing)
|
||||||
|
) + fadeIn(animationSpec = tween(300))
|
||||||
|
},
|
||||||
|
popExitTransition = {
|
||||||
|
slideOutOfContainer(
|
||||||
|
AnimatedContentTransitionScope.SlideDirection.Right,
|
||||||
|
animationSpec = tween(300, easing = FastOutSlowInEasing)
|
||||||
|
) + fadeOut(animationSpec = tween(200))
|
||||||
|
}
|
||||||
) {
|
) {
|
||||||
composable(Screen.Home.route) { HomeScreen() }
|
composable(
|
||||||
|
Screen.Home.route,
|
||||||
|
enterTransition = {
|
||||||
|
when (initialState.destination.route) {
|
||||||
|
Screen.Analysis.route -> slideIntoContainer(
|
||||||
|
AnimatedContentTransitionScope.SlideDirection.Right,
|
||||||
|
animationSpec = tween(400, easing = FastOutSlowInEasing)
|
||||||
|
) + fadeIn(animationSpec = tween(400))
|
||||||
|
Screen.Settings.route -> slideIntoContainer(
|
||||||
|
AnimatedContentTransitionScope.SlideDirection.Left,
|
||||||
|
animationSpec = tween(400, easing = FastOutSlowInEasing)
|
||||||
|
) + fadeIn(animationSpec = tween(400))
|
||||||
|
else -> fadeIn(animationSpec = tween(300))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
exitTransition = {
|
||||||
|
when (targetState.destination.route) {
|
||||||
|
Screen.Analysis.route -> slideOutOfContainer(
|
||||||
|
AnimatedContentTransitionScope.SlideDirection.Left,
|
||||||
|
animationSpec = tween(400, easing = FastOutSlowInEasing)
|
||||||
|
) + fadeOut(animationSpec = tween(200))
|
||||||
|
Screen.Settings.route -> slideOutOfContainer(
|
||||||
|
AnimatedContentTransitionScope.SlideDirection.Right,
|
||||||
|
animationSpec = tween(400, easing = FastOutSlowInEasing)
|
||||||
|
) + fadeOut(animationSpec = tween(200))
|
||||||
|
else -> fadeOut(animationSpec = tween(300))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
HomeScreen()
|
||||||
|
}
|
||||||
|
|
||||||
composable(Screen.Analysis.route) {
|
composable(
|
||||||
|
Screen.Analysis.route,
|
||||||
|
enterTransition = {
|
||||||
|
when (initialState.destination.route) {
|
||||||
|
Screen.Home.route -> slideIntoContainer(
|
||||||
|
AnimatedContentTransitionScope.SlideDirection.Left,
|
||||||
|
animationSpec = tween(400, easing = FastOutSlowInEasing)
|
||||||
|
) + fadeIn(animationSpec = tween(400))
|
||||||
|
Screen.Settings.route -> slideIntoContainer(
|
||||||
|
AnimatedContentTransitionScope.SlideDirection.Right,
|
||||||
|
animationSpec = tween(400, easing = FastOutSlowInEasing)
|
||||||
|
) + fadeIn(animationSpec = tween(400))
|
||||||
|
else -> fadeIn(animationSpec = tween(300))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
exitTransition = {
|
||||||
|
when (targetState.destination.route) {
|
||||||
|
Screen.Home.route -> slideOutOfContainer(
|
||||||
|
AnimatedContentTransitionScope.SlideDirection.Right,
|
||||||
|
animationSpec = tween(400, easing = FastOutSlowInEasing)
|
||||||
|
) + fadeOut(animationSpec = tween(200))
|
||||||
|
Screen.Settings.route -> slideOutOfContainer(
|
||||||
|
AnimatedContentTransitionScope.SlideDirection.Left,
|
||||||
|
animationSpec = tween(400, easing = FastOutSlowInEasing)
|
||||||
|
) + fadeOut(animationSpec = tween(200))
|
||||||
|
else -> fadeOut(animationSpec = tween(300))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
AnalysisScreen(
|
AnalysisScreen(
|
||||||
onNavigateToCategoryDetail = { category, startMonth, endMonth ->
|
onNavigateToCategoryDetail = { category, startMonth, endMonth ->
|
||||||
navController.navigate(Screen.CategoryDetail.createRoute(category, startMonth, endMonth))
|
navController.navigate(Screen.CategoryDetail.createRoute(category, startMonth, endMonth))
|
||||||
@@ -145,12 +289,47 @@ fun MainNavigation(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
composable(Screen.Settings.route) {
|
composable(
|
||||||
|
Screen.Settings.route,
|
||||||
|
enterTransition = {
|
||||||
|
when (initialState.destination.route) {
|
||||||
|
Screen.Home.route -> slideIntoContainer(
|
||||||
|
AnimatedContentTransitionScope.SlideDirection.Right,
|
||||||
|
animationSpec = tween(400, easing = FastOutSlowInEasing)
|
||||||
|
) + fadeIn(animationSpec = tween(400))
|
||||||
|
Screen.Analysis.route -> slideIntoContainer(
|
||||||
|
AnimatedContentTransitionScope.SlideDirection.Left,
|
||||||
|
animationSpec = tween(400, easing = FastOutSlowInEasing)
|
||||||
|
) + fadeIn(animationSpec = tween(400))
|
||||||
|
else -> fadeIn(animationSpec = tween(300))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
exitTransition = {
|
||||||
|
when (targetState.destination.route) {
|
||||||
|
Screen.Home.route -> slideOutOfContainer(
|
||||||
|
AnimatedContentTransitionScope.SlideDirection.Left,
|
||||||
|
animationSpec = tween(400, easing = FastOutSlowInEasing)
|
||||||
|
) + fadeOut(animationSpec = tween(200))
|
||||||
|
Screen.Analysis.route -> slideOutOfContainer(
|
||||||
|
AnimatedContentTransitionScope.SlideDirection.Right,
|
||||||
|
animationSpec = tween(400, easing = FastOutSlowInEasing)
|
||||||
|
) + fadeOut(animationSpec = tween(200))
|
||||||
|
else -> fadeOut(animationSpec = tween(300))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
SettingsScreen(
|
SettingsScreen(
|
||||||
currentTheme = currentTheme,
|
currentTheme = currentTheme,
|
||||||
onThemeChange = onThemeChange
|
onThemeChange = onThemeChange,
|
||||||
|
onNavigateToBudget = {
|
||||||
|
navController.navigate(Screen.Budget.route)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
composable(Screen.Budget.route) {
|
||||||
|
BudgetScreen()
|
||||||
|
}
|
||||||
|
|
||||||
composable(
|
composable(
|
||||||
route = Screen.CategoryDetail.route,
|
route = Screen.CategoryDetail.route,
|
||||||
|
@@ -32,18 +32,24 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import androidx.compose.animation.*
|
||||||
|
import androidx.compose.animation.core.*
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import com.yovinchen.bookkeeping.model.AnalysisType
|
import com.yovinchen.bookkeeping.model.AnalysisType
|
||||||
import com.yovinchen.bookkeeping.model.CategoryStat
|
import com.yovinchen.bookkeeping.model.CategoryStat
|
||||||
import com.yovinchen.bookkeeping.model.MemberStat
|
import com.yovinchen.bookkeeping.model.MemberStat
|
||||||
import com.yovinchen.bookkeeping.ui.components.CategoryPieChart
|
import com.yovinchen.bookkeeping.ui.components.CategoryPieChart
|
||||||
import com.yovinchen.bookkeeping.ui.components.CategoryStatItem
|
import com.yovinchen.bookkeeping.ui.components.CategoryStatItem
|
||||||
import com.yovinchen.bookkeeping.ui.components.DateRangePicker
|
import com.yovinchen.bookkeeping.ui.components.DateRangePicker
|
||||||
|
import com.yovinchen.bookkeeping.ui.components.DetailedAnalysisReport
|
||||||
|
import com.yovinchen.bookkeeping.ui.components.MonthlyYearlyReport
|
||||||
import com.yovinchen.bookkeeping.ui.components.TrendLineChart
|
import com.yovinchen.bookkeeping.ui.components.TrendLineChart
|
||||||
import com.yovinchen.bookkeeping.viewmodel.AnalysisViewModel
|
import com.yovinchen.bookkeeping.viewmodel.AnalysisViewModel
|
||||||
import java.time.YearMonth
|
import java.time.YearMonth
|
||||||
|
|
||||||
enum class ViewMode {
|
enum class ViewMode {
|
||||||
CATEGORY, MEMBER
|
CATEGORY, MEMBER, REPORT
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -93,7 +99,13 @@ fun AnalysisScreen(
|
|||||||
Button(
|
Button(
|
||||||
onClick = { showViewModeMenu = true }
|
onClick = { showViewModeMenu = true }
|
||||||
) {
|
) {
|
||||||
Text(if (currentViewMode == ViewMode.CATEGORY) "分类" else "成员")
|
Text(
|
||||||
|
when {
|
||||||
|
currentViewMode == ViewMode.CATEGORY -> "分类"
|
||||||
|
currentViewMode == ViewMode.MEMBER -> "成员"
|
||||||
|
else -> "报表"
|
||||||
|
}
|
||||||
|
)
|
||||||
Icon(Icons.Default.ArrowDropDown, "切换视图")
|
Icon(Icons.Default.ArrowDropDown, "切换视图")
|
||||||
}
|
}
|
||||||
DropdownMenu(
|
DropdownMenu(
|
||||||
@@ -114,6 +126,13 @@ fun AnalysisScreen(
|
|||||||
showViewModeMenu = false
|
showViewModeMenu = false
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("报表") },
|
||||||
|
onClick = {
|
||||||
|
currentViewMode = ViewMode.REPORT
|
||||||
|
showViewModeMenu = false
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,7 +166,16 @@ fun AnalysisScreen(
|
|||||||
AnalysisType.TREND -> {
|
AnalysisType.TREND -> {
|
||||||
// 趋势视图
|
// 趋势视图
|
||||||
item {
|
item {
|
||||||
if (records.isNotEmpty()) {
|
AnimatedVisibility(
|
||||||
|
visible = records.isNotEmpty(),
|
||||||
|
enter = fadeIn(animationSpec = tween(500)) + expandVertically(
|
||||||
|
animationSpec = spring(
|
||||||
|
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||||
|
stiffness = Spring.StiffnessLow
|
||||||
|
)
|
||||||
|
),
|
||||||
|
exit = fadeOut() + shrinkVertically()
|
||||||
|
) {
|
||||||
TrendLineChart(
|
TrendLineChart(
|
||||||
records = records,
|
records = records,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -159,41 +187,80 @@ fun AnalysisScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
// 饼图视图
|
if (currentViewMode == ViewMode.REPORT) {
|
||||||
item {
|
// 报表视图
|
||||||
CategoryPieChart(
|
item {
|
||||||
categoryData = categoryStats.map { Pair(it.category, it.percentage.toFloat()) },
|
MonthlyYearlyReport(
|
||||||
memberData = memberStats.map { Pair(it.member, it.percentage.toFloat()) },
|
records = records,
|
||||||
currentViewMode = currentViewMode == ViewMode.MEMBER,
|
period = if (startMonth == endMonth) "月度" else "年度",
|
||||||
modifier = Modifier
|
startMonth = startMonth,
|
||||||
.fillMaxWidth()
|
endMonth = endMonth,
|
||||||
.height(200.dp)
|
modifier = Modifier
|
||||||
.padding(bottom = 16.dp),
|
.fillMaxWidth()
|
||||||
onCategoryClick = { category ->
|
.padding(vertical = 8.dp)
|
||||||
if (currentViewMode == ViewMode.CATEGORY) {
|
)
|
||||||
onNavigateToCategoryDetail(category, startMonth, endMonth)
|
}
|
||||||
} else {
|
|
||||||
onNavigateToMemberDetail(category, startMonth, endMonth, selectedAnalysisType)
|
// 详细分析报表
|
||||||
|
item {
|
||||||
|
DetailedAnalysisReport(
|
||||||
|
records = records,
|
||||||
|
startMonth = startMonth,
|
||||||
|
endMonth = endMonth,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 饼图视图
|
||||||
|
item {
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = true,
|
||||||
|
enter = fadeIn(animationSpec = tween(500)) + scaleIn(
|
||||||
|
initialScale = 0.8f,
|
||||||
|
animationSpec = spring(
|
||||||
|
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||||
|
stiffness = Spring.StiffnessLow
|
||||||
|
)
|
||||||
|
),
|
||||||
|
exit = fadeOut() + scaleOut()
|
||||||
|
) {
|
||||||
|
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 ->
|
items(if (currentViewMode == ViewMode.CATEGORY) categoryStats else memberStats) { stat ->
|
||||||
val category = if (stat is CategoryStat) stat.category else null
|
val category = if (stat is CategoryStat) stat.category else null
|
||||||
val member = if (stat is MemberStat) stat.member else null
|
val member = if (stat is MemberStat) stat.member else null
|
||||||
|
|
||||||
CategoryStatItem(
|
CategoryStatItem(
|
||||||
stat = stat,
|
stat = stat,
|
||||||
onClick = {
|
onClick = {
|
||||||
if (currentViewMode == ViewMode.CATEGORY && category != null) {
|
if (currentViewMode == ViewMode.CATEGORY && category != null) {
|
||||||
onNavigateToCategoryDetail(category, startMonth, endMonth)
|
onNavigateToCategoryDetail(category, startMonth, endMonth)
|
||||||
} else if (currentViewMode == ViewMode.MEMBER && member != null) {
|
} else if (currentViewMode == ViewMode.MEMBER && member != null) {
|
||||||
onNavigateToMemberDetail(member, startMonth, endMonth, selectedAnalysisType)
|
onNavigateToMemberDetail(member, startMonth, endMonth, selectedAnalysisType)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
)
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -0,0 +1,411 @@
|
|||||||
|
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.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.yovinchen.bookkeeping.model.*
|
||||||
|
import com.yovinchen.bookkeeping.ui.dialog.BudgetEditDialog
|
||||||
|
import com.yovinchen.bookkeeping.viewmodel.BudgetViewModel
|
||||||
|
import com.yovinchen.bookkeeping.viewmodel.HomeViewModel
|
||||||
|
import com.yovinchen.bookkeeping.viewmodel.MemberViewModel
|
||||||
|
import java.text.NumberFormat
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预算管理界面
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun BudgetScreen(
|
||||||
|
viewModel: BudgetViewModel = viewModel(),
|
||||||
|
homeViewModel: HomeViewModel = viewModel(),
|
||||||
|
memberViewModel: MemberViewModel = viewModel()
|
||||||
|
) {
|
||||||
|
val budgetStatuses by viewModel.activeBudgetStatuses.collectAsState()
|
||||||
|
val totalBudgetStatus by viewModel.totalBudgetStatus.collectAsState()
|
||||||
|
val categoryBudgetStatuses by viewModel.categoryBudgetStatuses.collectAsState()
|
||||||
|
val memberBudgetStatuses by viewModel.memberBudgetStatuses.collectAsState()
|
||||||
|
val showBudgetDialog by viewModel.showBudgetDialog.collectAsState()
|
||||||
|
val editingBudget by viewModel.editingBudget.collectAsState()
|
||||||
|
|
||||||
|
val categories by homeViewModel.categories.collectAsState()
|
||||||
|
val members by memberViewModel.allMembers.collectAsState(initial = emptyList())
|
||||||
|
|
||||||
|
var selectedTab by remember { mutableStateOf(0) }
|
||||||
|
val tabs = listOf("总览", "分类预算", "成员预算")
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
// 页面标题
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "预算管理",
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
// 添加预算按钮
|
||||||
|
IconButton(
|
||||||
|
onClick = { viewModel.showEditBudgetDialog() }
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Add,
|
||||||
|
contentDescription = "添加预算",
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// 总预算概览卡片
|
||||||
|
totalBudgetStatus?.let { status ->
|
||||||
|
BudgetOverviewCard(status)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tab 选择器
|
||||||
|
TabRow(selectedTabIndex = selectedTab) {
|
||||||
|
tabs.forEachIndexed { index, title ->
|
||||||
|
Tab(
|
||||||
|
selected = selectedTab == index,
|
||||||
|
onClick = { selectedTab = index },
|
||||||
|
text = { Text(title) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Tab 内容
|
||||||
|
when (selectedTab) {
|
||||||
|
0 -> BudgetOverviewTab(budgetStatuses, viewModel)
|
||||||
|
1 -> CategoryBudgetTab(categoryBudgetStatuses, viewModel)
|
||||||
|
2 -> MemberBudgetTab(memberBudgetStatuses, viewModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 预算编辑对话框
|
||||||
|
if (showBudgetDialog) {
|
||||||
|
BudgetEditDialog(
|
||||||
|
budget = editingBudget,
|
||||||
|
categories = categories.filter { it.type == TransactionType.EXPENSE },
|
||||||
|
members = members,
|
||||||
|
onDismiss = { viewModel.hideBudgetDialog() },
|
||||||
|
onConfirm = { budget ->
|
||||||
|
if (editingBudget == null) {
|
||||||
|
viewModel.createBudget(
|
||||||
|
type = budget.type,
|
||||||
|
amount = budget.amount,
|
||||||
|
categoryName = budget.categoryName,
|
||||||
|
memberId = budget.memberId,
|
||||||
|
alertThreshold = budget.alertThreshold
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
viewModel.updateBudget(budget)
|
||||||
|
}
|
||||||
|
viewModel.hideBudgetDialog()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预算概览卡片
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun BudgetOverviewCard(budgetStatus: BudgetStatus) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "本月总预算",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = formatCurrency(budgetStatus.budget.amount),
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
// 进度条
|
||||||
|
LinearProgressIndicator(
|
||||||
|
progress = budgetStatus.percentage.toFloat().coerceIn(0f, 1f),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(8.dp)
|
||||||
|
.clip(RoundedCornerShape(4.dp)),
|
||||||
|
color = when {
|
||||||
|
budgetStatus.isOverBudget -> MaterialTheme.colorScheme.error
|
||||||
|
budgetStatus.isNearLimit -> MaterialTheme.colorScheme.tertiary
|
||||||
|
else -> MaterialTheme.colorScheme.primary
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = "已使用",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = formatCurrency(budgetStatus.spent),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(horizontalAlignment = Alignment.End) {
|
||||||
|
Text(
|
||||||
|
text = "剩余",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = formatCurrency(budgetStatus.remaining),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = when {
|
||||||
|
budgetStatus.isOverBudget -> MaterialTheme.colorScheme.error
|
||||||
|
budgetStatus.isNearLimit -> MaterialTheme.colorScheme.tertiary
|
||||||
|
else -> MaterialTheme.colorScheme.primary
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (budgetStatus.isOverBudget) {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = "⚠️ 已超出预算 ${formatCurrency(kotlin.math.abs(budgetStatus.remaining))}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
} else if (budgetStatus.isNearLimit) {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = "⚠️ 接近预算限制",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.tertiary,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预算总览标签页
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun BudgetOverviewTab(
|
||||||
|
budgetStatuses: List<BudgetStatus>,
|
||||||
|
viewModel: BudgetViewModel
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
items(budgetStatuses) { status ->
|
||||||
|
BudgetItem(
|
||||||
|
budgetStatus = status,
|
||||||
|
onClick = { viewModel.showEditBudgetDialog(status.budget) },
|
||||||
|
onToggleEnabled = { viewModel.toggleBudgetEnabled(status.budget) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分类预算标签页
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun CategoryBudgetTab(
|
||||||
|
categoryBudgetStatuses: List<BudgetStatus>,
|
||||||
|
viewModel: BudgetViewModel
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
items(categoryBudgetStatuses) { status ->
|
||||||
|
BudgetItem(
|
||||||
|
budgetStatus = status,
|
||||||
|
onClick = { viewModel.showEditBudgetDialog(status.budget) },
|
||||||
|
onToggleEnabled = { viewModel.toggleBudgetEnabled(status.budget) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 成员预算标签页
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun MemberBudgetTab(
|
||||||
|
memberBudgetStatuses: List<BudgetStatus>,
|
||||||
|
viewModel: BudgetViewModel
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
items(memberBudgetStatuses) { status ->
|
||||||
|
BudgetItem(
|
||||||
|
budgetStatus = status,
|
||||||
|
onClick = { viewModel.showEditBudgetDialog(status.budget) },
|
||||||
|
onToggleEnabled = { viewModel.toggleBudgetEnabled(status.budget) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预算项目组件
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun BudgetItem(
|
||||||
|
budgetStatus: BudgetStatus,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onToggleEnabled: () -> Unit
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { onClick() },
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = if (budgetStatus.budget.isEnabled) {
|
||||||
|
MaterialTheme.colorScheme.surface
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = when (budgetStatus.budget.type) {
|
||||||
|
BudgetType.TOTAL -> "总预算"
|
||||||
|
BudgetType.CATEGORY -> budgetStatus.budget.categoryName ?: "未知分类"
|
||||||
|
BudgetType.MEMBER -> "成员预算"
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "预算: ${formatCurrency(budgetStatus.budget.amount)}",
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "已用: ${formatCurrency(budgetStatus.spent)}",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = when {
|
||||||
|
budgetStatus.isOverBudget -> MaterialTheme.colorScheme.error
|
||||||
|
budgetStatus.isNearLimit -> MaterialTheme.colorScheme.tertiary
|
||||||
|
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
LinearProgressIndicator(
|
||||||
|
progress = budgetStatus.percentage.toFloat().coerceIn(0f, 1f),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(4.dp)
|
||||||
|
.clip(RoundedCornerShape(2.dp)),
|
||||||
|
color = when {
|
||||||
|
budgetStatus.isOverBudget -> MaterialTheme.colorScheme.error
|
||||||
|
budgetStatus.isNearLimit -> MaterialTheme.colorScheme.tertiary
|
||||||
|
else -> MaterialTheme.colorScheme.primary
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
IconButton(onClick = onToggleEnabled) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (budgetStatus.budget.isEnabled) {
|
||||||
|
Icons.Default.CheckCircle
|
||||||
|
} else {
|
||||||
|
Icons.Default.Cancel
|
||||||
|
},
|
||||||
|
contentDescription = if (budgetStatus.budget.isEnabled) "禁用" else "启用",
|
||||||
|
tint = if (budgetStatus.budget.isEnabled) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化货币
|
||||||
|
*/
|
||||||
|
private fun formatCurrency(amount: Double): String {
|
||||||
|
val format = NumberFormat.getCurrencyInstance(Locale.CHINA)
|
||||||
|
return format.format(amount)
|
||||||
|
}
|
@@ -1,14 +1,21 @@
|
|||||||
package com.yovinchen.bookkeeping.ui.screen
|
package com.yovinchen.bookkeeping.ui.screen
|
||||||
|
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.ui.draw.shadow
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
@@ -20,7 +27,6 @@ import androidx.compose.material3.HorizontalDivider
|
|||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
@@ -31,6 +37,10 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import androidx.compose.animation.*
|
||||||
|
import androidx.compose.animation.core.*
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import com.yovinchen.bookkeeping.model.BookkeepingRecord
|
import com.yovinchen.bookkeeping.model.BookkeepingRecord
|
||||||
import com.yovinchen.bookkeeping.model.TransactionType
|
import com.yovinchen.bookkeeping.model.TransactionType
|
||||||
import com.yovinchen.bookkeeping.ui.components.MonthlyStatistics
|
import com.yovinchen.bookkeeping.ui.components.MonthlyStatistics
|
||||||
@@ -40,6 +50,21 @@ import com.yovinchen.bookkeeping.ui.dialog.RecordEditDialog
|
|||||||
import com.yovinchen.bookkeeping.viewmodel.HomeViewModel
|
import com.yovinchen.bookkeeping.viewmodel.HomeViewModel
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import androidx.compose.foundation.lazy.LazyItemScope
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.systemBars
|
||||||
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -56,87 +81,214 @@ fun HomeScreen(
|
|||||||
val members by viewModel.members.collectAsState(initial = emptyList())
|
val members by viewModel.members.collectAsState(initial = emptyList())
|
||||||
val totalIncome by viewModel.totalIncome.collectAsState()
|
val totalIncome by viewModel.totalIncome.collectAsState()
|
||||||
val totalExpense by viewModel.totalExpense.collectAsState()
|
val totalExpense by viewModel.totalExpense.collectAsState()
|
||||||
|
|
||||||
|
// 获取屏幕高度
|
||||||
|
val configuration = LocalConfiguration.current
|
||||||
|
val screenHeight = configuration.screenHeightDp.dp
|
||||||
|
val headerHeight = screenHeight * 0.2f // 20% 的屏幕高度
|
||||||
|
|
||||||
|
// LazyColumn 滑动状态
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
|
||||||
|
// 计算滑动偏移量
|
||||||
|
val scrollOffset by remember {
|
||||||
|
derivedStateOf {
|
||||||
|
if (listState.firstVisibleItemIndex > 0) {
|
||||||
|
1f // 完全收起
|
||||||
|
} else {
|
||||||
|
val offset = listState.firstVisibleItemScrollOffset.toFloat()
|
||||||
|
(offset / headerHeight.value).coerceIn(0f, 1f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 动画化的偏移量
|
||||||
|
val animatedScrollOffset by animateFloatAsState(
|
||||||
|
targetValue = scrollOffset,
|
||||||
|
animationSpec = tween(
|
||||||
|
durationMillis = 100,
|
||||||
|
easing = LinearEasing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = modifier.fillMaxSize(),
|
modifier = modifier.fillMaxSize(),
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
ExtendedFloatingActionButton(
|
AnimatedVisibility(
|
||||||
onClick = { showAddDialog = true },
|
visible = true,
|
||||||
icon = { Icon(Icons.Default.Add, contentDescription = null) },
|
enter = scaleIn(
|
||||||
text = { Text("记一笔") }
|
initialScale = 0f,
|
||||||
)
|
animationSpec = spring(
|
||||||
|
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||||
|
stiffness = Spring.StiffnessLow
|
||||||
|
)
|
||||||
|
),
|
||||||
|
exit = scaleOut(targetScale = 0f)
|
||||||
|
) {
|
||||||
|
ExtendedFloatingActionButton(
|
||||||
|
onClick = { showAddDialog = true },
|
||||||
|
icon = { Icon(Icons.Default.Add, contentDescription = null) },
|
||||||
|
text = { Text("记一笔") }
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
) { padding ->
|
) { padding ->
|
||||||
Column(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(padding)
|
.padding(padding)
|
||||||
.background(MaterialTheme.colorScheme.background)
|
.background(MaterialTheme.colorScheme.background)
|
||||||
) {
|
) {
|
||||||
// 顶部统计信息
|
// 记录列表(背景层)
|
||||||
MonthlyStatistics(
|
|
||||||
totalIncome = totalIncome,
|
|
||||||
totalExpense = totalExpense,
|
|
||||||
selectedType = null,
|
|
||||||
onIncomeClick = { viewModel.setSelectedRecordType(TransactionType.INCOME) },
|
|
||||||
onExpenseClick = { viewModel.setSelectedRecordType(TransactionType.EXPENSE) },
|
|
||||||
onClearFilter = { viewModel.setSelectedRecordType(null) },
|
|
||||||
selectedMonth = selectedMonth,
|
|
||||||
onPreviousMonth = { viewModel.moveMonth(false) },
|
|
||||||
onNextMonth = { viewModel.moveMonth(true) },
|
|
||||||
onMonthSelected = { viewModel.setSelectedMonth(it) }
|
|
||||||
)
|
|
||||||
|
|
||||||
// 记录列表
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
|
state = listState,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentPadding = PaddingValues(16.dp),
|
contentPadding = PaddingValues(
|
||||||
|
top = headerHeight + 16.dp,
|
||||||
|
start = 16.dp,
|
||||||
|
end = 16.dp,
|
||||||
|
bottom = 16.dp
|
||||||
|
),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
) {
|
) {
|
||||||
items(filteredRecords.size) { index ->
|
itemsIndexed(
|
||||||
val (date, dayRecords) = filteredRecords.toList()[index]
|
items = filteredRecords.toList(),
|
||||||
Card(
|
key = { _, (date, _) -> date }
|
||||||
modifier = Modifier
|
) { index, (date, dayRecords) ->
|
||||||
.fillMaxWidth()
|
val animationDelay = remember { (index * 30).coerceAtMost(300) }
|
||||||
.padding(vertical = 4.dp),
|
var isVisible by remember { mutableStateOf(false) }
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
|
||||||
|
LaunchedEffect(key1 = date) {
|
||||||
|
delay(animationDelay.toLong())
|
||||||
|
isVisible = true
|
||||||
|
}
|
||||||
|
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = isVisible,
|
||||||
|
enter = fadeIn(
|
||||||
|
animationSpec = tween(
|
||||||
|
durationMillis = 400,
|
||||||
|
easing = FastOutSlowInEasing
|
||||||
|
)
|
||||||
|
) + slideInVertically(
|
||||||
|
initialOffsetY = { it / 4 },
|
||||||
|
animationSpec = spring(
|
||||||
|
dampingRatio = Spring.DampingRatioLowBouncy,
|
||||||
|
stiffness = Spring.StiffnessMediumLow
|
||||||
|
)
|
||||||
|
) + scaleIn(
|
||||||
|
initialScale = 0.85f,
|
||||||
|
animationSpec = spring(
|
||||||
|
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||||
|
stiffness = Spring.StiffnessMedium
|
||||||
|
)
|
||||||
|
),
|
||||||
|
exit = fadeOut(
|
||||||
|
animationSpec = tween(200)
|
||||||
|
) + slideOutVertically(
|
||||||
|
targetOffsetY = { -it / 4 },
|
||||||
|
animationSpec = tween(200)
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
Column(
|
Card(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(16.dp)
|
.padding(vertical = 4.dp)
|
||||||
) {
|
.graphicsLayer {
|
||||||
// 日期标签
|
shadowElevation = 8.dp.toPx()
|
||||||
Text(
|
}
|
||||||
text = SimpleDateFormat(
|
.animateContentSize(
|
||||||
"yyyy年MM月dd日 E",
|
animationSpec = spring(
|
||||||
Locale.CHINESE
|
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||||
).format(date),
|
stiffness = Spring.StiffnessMedium
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
|
|
||||||
// 当天的记录
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
|
||||||
) {
|
|
||||||
dayRecords.forEachIndexed { recordIndex, record ->
|
|
||||||
RecordItem(
|
|
||||||
record = record,
|
|
||||||
onClick = { selectedRecord = record },
|
|
||||||
onDelete = { viewModel.deleteRecord(record) },
|
|
||||||
members = members
|
|
||||||
)
|
)
|
||||||
|
),
|
||||||
if (recordIndex < dayRecords.size - 1) {
|
shape = RoundedCornerShape(16.dp),
|
||||||
HorizontalDivider(
|
colors = CardDefaults.cardColors(
|
||||||
modifier = Modifier.padding(vertical = 4.dp),
|
containerColor = MaterialTheme.colorScheme.surface
|
||||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
),
|
||||||
thickness = 0.5.dp
|
elevation = CardDefaults.cardElevation(
|
||||||
|
defaultElevation = 4.dp
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(
|
||||||
|
brush = Brush.verticalGradient(
|
||||||
|
colors = listOf(
|
||||||
|
MaterialTheme.colorScheme.surface,
|
||||||
|
MaterialTheme.colorScheme.surface.copy(alpha = 0.95f)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
.padding(20.dp)
|
||||||
|
) {
|
||||||
|
// 日期标签
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(bottom = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
color = MaterialTheme.colorScheme.primaryContainer,
|
||||||
|
modifier = Modifier.padding(end = 8.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = SimpleDateFormat(
|
||||||
|
"dd",
|
||||||
|
Locale.CHINESE
|
||||||
|
).format(date),
|
||||||
|
style = MaterialTheme.typography.titleLarge.copy(
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
),
|
||||||
|
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = SimpleDateFormat(
|
||||||
|
"yyyy年MM月",
|
||||||
|
Locale.CHINESE
|
||||||
|
).format(date),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = SimpleDateFormat(
|
||||||
|
"EEEE",
|
||||||
|
Locale.CHINESE
|
||||||
|
).format(date),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// 当天的记录
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
dayRecords.forEachIndexed { recordIndex, record ->
|
||||||
|
RecordItem(
|
||||||
|
record = record,
|
||||||
|
onClick = { selectedRecord = record },
|
||||||
|
onDelete = { viewModel.deleteRecord(record) },
|
||||||
|
members = members
|
||||||
|
)
|
||||||
|
if (recordIndex < dayRecords.size - 1) {
|
||||||
|
HorizontalDivider(
|
||||||
|
modifier = Modifier.padding(vertical = 4.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
thickness = 0.5.dp
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -144,6 +296,35 @@ fun HomeScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 顶部统计信息(悬浮层)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(headerHeight)
|
||||||
|
.graphicsLayer {
|
||||||
|
alpha = 1f - animatedScrollOffset * 0.3f // 淡出效果更柔和
|
||||||
|
translationY = -animatedScrollOffset * headerHeight.value * 0.5f
|
||||||
|
scaleY = 1f - animatedScrollOffset * 0.5f
|
||||||
|
transformOrigin = androidx.compose.ui.graphics.TransformOrigin(0.5f, 0f)
|
||||||
|
}
|
||||||
|
.background(MaterialTheme.colorScheme.background)
|
||||||
|
) {
|
||||||
|
if (animatedScrollOffset < 0.9f) {
|
||||||
|
MonthlyStatistics(
|
||||||
|
totalIncome = totalIncome,
|
||||||
|
totalExpense = totalExpense,
|
||||||
|
selectedType = null,
|
||||||
|
onIncomeClick = { viewModel.setSelectedRecordType(TransactionType.INCOME) },
|
||||||
|
onExpenseClick = { viewModel.setSelectedRecordType(TransactionType.EXPENSE) },
|
||||||
|
onClearFilter = { viewModel.setSelectedRecordType(null) },
|
||||||
|
selectedMonth = selectedMonth,
|
||||||
|
onPreviousMonth = { viewModel.moveMonth(false) },
|
||||||
|
onNextMonth = { viewModel.moveMonth(true) },
|
||||||
|
onMonthSelected = { viewModel.setSelectedMonth(it) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,4 +354,4 @@ fun HomeScreen(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
@@ -4,26 +4,41 @@ import android.content.Context
|
|||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
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.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import androidx.compose.animation.*
|
||||||
|
import androidx.compose.animation.core.*
|
||||||
|
import com.yovinchen.bookkeeping.model.Settings
|
||||||
|
import androidx.compose.ui.draw.shadow
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.ui.draw.scale
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import com.yovinchen.bookkeeping.model.ThemeMode
|
import com.yovinchen.bookkeeping.model.ThemeMode
|
||||||
import com.yovinchen.bookkeeping.ui.components.*
|
import com.yovinchen.bookkeeping.ui.components.*
|
||||||
import com.yovinchen.bookkeeping.ui.dialog.*
|
import com.yovinchen.bookkeeping.ui.dialog.*
|
||||||
import com.yovinchen.bookkeeping.utils.FilePickerUtil
|
import com.yovinchen.bookkeeping.utils.FilePickerUtil
|
||||||
import com.yovinchen.bookkeeping.viewmodel.*
|
import com.yovinchen.bookkeeping.viewmodel.*
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsScreen(
|
fun SettingsScreen(
|
||||||
currentTheme: ThemeMode,
|
currentTheme: ThemeMode,
|
||||||
onThemeChange: (ThemeMode) -> Unit,
|
onThemeChange: (ThemeMode) -> Unit,
|
||||||
|
onNavigateToBudget: () -> Unit = {},
|
||||||
viewModel: SettingsViewModel = viewModel(),
|
viewModel: SettingsViewModel = viewModel(),
|
||||||
memberViewModel: MemberViewModel = viewModel()
|
memberViewModel: MemberViewModel = viewModel()
|
||||||
) {
|
) {
|
||||||
@@ -36,51 +51,356 @@ fun SettingsScreen(
|
|||||||
val categories by viewModel.categories.collectAsState()
|
val categories by viewModel.categories.collectAsState()
|
||||||
val selectedType by viewModel.selectedCategoryType.collectAsState()
|
val selectedType by viewModel.selectedCategoryType.collectAsState()
|
||||||
val members by memberViewModel.allMembers.collectAsState(initial = emptyList())
|
val members by memberViewModel.allMembers.collectAsState(initial = emptyList())
|
||||||
|
val monthStartDay by viewModel.monthStartDay.collectAsState()
|
||||||
|
val settings by viewModel.settings.collectAsState()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
var showMonthStartDayDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(
|
||||||
// 成员管理设置项
|
modifier = Modifier
|
||||||
ListItem(
|
.fillMaxSize()
|
||||||
headlineContent = { Text("成员管理") },
|
.background(MaterialTheme.colorScheme.background)
|
||||||
supportingContent = { Text("管理账本成员") },
|
.animateContentSize(
|
||||||
modifier = Modifier.clickable { showMemberDialog = true }
|
animationSpec = spring(
|
||||||
)
|
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||||
|
stiffness = Spring.StiffnessLow
|
||||||
HorizontalDivider()
|
)
|
||||||
|
)
|
||||||
// 类别管理设置项
|
) {
|
||||||
ListItem(
|
// 设置页面标题
|
||||||
headlineContent = { Text("类别管理") },
|
Surface(
|
||||||
supportingContent = { Text("管理收入和支出类别") },
|
modifier = Modifier
|
||||||
modifier = Modifier.clickable { showCategoryDialog = true }
|
.fillMaxWidth()
|
||||||
)
|
.shadow(
|
||||||
|
elevation = 4.dp,
|
||||||
HorizontalDivider()
|
shape = RoundedCornerShape(bottomStart = 20.dp, bottomEnd = 20.dp),
|
||||||
|
clip = false
|
||||||
// 数据备份设置项
|
),
|
||||||
ListItem(
|
shape = RoundedCornerShape(bottomStart = 20.dp, bottomEnd = 20.dp),
|
||||||
headlineContent = { Text("数据备份") },
|
color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
|
||||||
supportingContent = { Text("备份和恢复数据") },
|
) {
|
||||||
modifier = Modifier.clickable { showBackupDialog = true }
|
Box(
|
||||||
)
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
HorizontalDivider()
|
.padding(vertical = 24.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
// 主题设置项
|
) {
|
||||||
ListItem(
|
|
||||||
headlineContent = { Text("主题设置") },
|
|
||||||
supportingContent = {
|
|
||||||
Text(
|
Text(
|
||||||
when (currentTheme) {
|
text = "设置",
|
||||||
is ThemeMode.FOLLOW_SYSTEM -> "跟随系统"
|
style = MaterialTheme.typography.headlineMedium.copy(
|
||||||
is ThemeMode.LIGHT -> "浅色"
|
fontWeight = FontWeight.Bold
|
||||||
is ThemeMode.DARK -> "深色"
|
),
|
||||||
is ThemeMode.CUSTOM -> "自定义颜色"
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
// 成员管理设置项
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = true,
|
||||||
|
enter = fadeIn(animationSpec = tween(300)) + slideInVertically(
|
||||||
|
initialOffsetY = { -40 },
|
||||||
|
animationSpec = tween(300)
|
||||||
|
),
|
||||||
|
exit = fadeOut() + slideOutVertically()
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||||
|
.clickable { showMemberDialog = true },
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surface,
|
||||||
|
tonalElevation = 2.dp
|
||||||
|
) {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
"成员管理",
|
||||||
|
style = MaterialTheme.typography.titleMedium
|
||||||
|
)
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
Text(
|
||||||
|
"管理账本成员",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
},
|
||||||
|
leadingContent = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Group,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trailingContent = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ChevronRight,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
},
|
}
|
||||||
modifier = Modifier.clickable { showThemeDialog = true }
|
}
|
||||||
)
|
|
||||||
|
// 类别管理设置项
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = true,
|
||||||
|
enter = fadeIn(animationSpec = tween(300, delayMillis = 50)) + slideInVertically(
|
||||||
|
initialOffsetY = { -40 },
|
||||||
|
animationSpec = tween(300, delayMillis = 50)
|
||||||
|
),
|
||||||
|
exit = fadeOut() + slideOutVertically()
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||||
|
.clickable { showCategoryDialog = true },
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surface,
|
||||||
|
tonalElevation = 2.dp
|
||||||
|
) {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
"类别管理",
|
||||||
|
style = MaterialTheme.typography.titleMedium
|
||||||
|
)
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
Text(
|
||||||
|
"管理收入和支出类别",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
},
|
||||||
|
leadingContent = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Category,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trailingContent = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ChevronRight,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 数据备份设置项
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = true,
|
||||||
|
enter = fadeIn(animationSpec = tween(300, delayMillis = 100)) + slideInVertically(
|
||||||
|
initialOffsetY = { -40 },
|
||||||
|
animationSpec = tween(300, delayMillis = 100)
|
||||||
|
),
|
||||||
|
exit = fadeOut() + slideOutVertically()
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||||
|
.clickable { showBackupDialog = true },
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surface,
|
||||||
|
tonalElevation = 2.dp
|
||||||
|
) {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
"数据备份",
|
||||||
|
style = MaterialTheme.typography.titleMedium
|
||||||
|
)
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
Text(
|
||||||
|
"备份和恢复数据",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
},
|
||||||
|
leadingContent = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Backup,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trailingContent = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ChevronRight,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 预算管理设置项
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = true,
|
||||||
|
enter = fadeIn(animationSpec = tween(300, delayMillis = 150)) + slideInVertically(
|
||||||
|
initialOffsetY = { -40 },
|
||||||
|
animationSpec = tween(300, delayMillis = 150)
|
||||||
|
),
|
||||||
|
exit = fadeOut() + slideOutVertically()
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||||
|
.clickable { onNavigateToBudget() },
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surface,
|
||||||
|
tonalElevation = 2.dp
|
||||||
|
) {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
"预算管理",
|
||||||
|
style = MaterialTheme.typography.titleMedium
|
||||||
|
)
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
Text(
|
||||||
|
"设置和管理预算",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
},
|
||||||
|
leadingContent = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.AccountBalance,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trailingContent = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ChevronRight,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 主题设置项
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = true,
|
||||||
|
enter = fadeIn(animationSpec = tween(300, delayMillis = 200)) + slideInVertically(
|
||||||
|
initialOffsetY = { -40 },
|
||||||
|
animationSpec = tween(300, delayMillis = 200)
|
||||||
|
),
|
||||||
|
exit = fadeOut() + slideOutVertically()
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||||
|
.clickable { showThemeDialog = true },
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surface,
|
||||||
|
tonalElevation = 2.dp
|
||||||
|
) {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
"主题设置",
|
||||||
|
style = MaterialTheme.typography.titleMedium
|
||||||
|
)
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
Text(
|
||||||
|
when (currentTheme) {
|
||||||
|
is ThemeMode.FOLLOW_SYSTEM -> "跟随系统"
|
||||||
|
is ThemeMode.LIGHT -> "浅色"
|
||||||
|
is ThemeMode.DARK -> "深色"
|
||||||
|
is ThemeMode.CUSTOM -> "自定义颜色"
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
},
|
||||||
|
leadingContent = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Palette,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trailingContent = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ChevronRight,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 月度开始日期设置项
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = true,
|
||||||
|
enter = fadeIn(animationSpec = tween(300, delayMillis = 250)) + slideInVertically(
|
||||||
|
initialOffsetY = { -40 },
|
||||||
|
animationSpec = tween(300, delayMillis = 250)
|
||||||
|
),
|
||||||
|
exit = fadeOut() + slideOutVertically()
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||||
|
.clickable { showMonthStartDayDialog = true },
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surface,
|
||||||
|
tonalElevation = 2.dp
|
||||||
|
) {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
"月度开始日期",
|
||||||
|
style = MaterialTheme.typography.titleMedium
|
||||||
|
)
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
Text(
|
||||||
|
"每月从${monthStartDay}号开始计算",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
},
|
||||||
|
leadingContent = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.CalendarMonth,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trailingContent = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ChevronRight,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (showThemeDialog) {
|
if (showThemeDialog) {
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
@@ -144,6 +464,85 @@ fun SettingsScreen(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 月度开始日期对话框
|
||||||
|
if (showMonthStartDayDialog) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showMonthStartDayDialog = false },
|
||||||
|
title = { Text("选择月度开始日期") },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
Text("选择每月记账的开始日期(1-28号)")
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// 日期选择器
|
||||||
|
val days = (1..28).toList()
|
||||||
|
LazyVerticalGrid(
|
||||||
|
columns = GridCells.Fixed(7),
|
||||||
|
modifier = Modifier.fillMaxWidth().height(280.dp),
|
||||||
|
contentPadding = PaddingValues(4.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
items(days) { day ->
|
||||||
|
Surface(
|
||||||
|
onClick = {
|
||||||
|
viewModel.setMonthStartDay(day)
|
||||||
|
showMonthStartDayDialog = false
|
||||||
|
},
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
color = if (day == monthStartDay) {
|
||||||
|
MaterialTheme.colorScheme.primaryContainer
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.surface
|
||||||
|
},
|
||||||
|
border = BorderStroke(
|
||||||
|
width = 1.dp,
|
||||||
|
color = if (day == monthStartDay) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.outline
|
||||||
|
}
|
||||||
|
),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.aspectRatio(1f)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.scale(
|
||||||
|
animateFloatAsState(
|
||||||
|
targetValue = if (day == monthStartDay) 1.1f else 1f,
|
||||||
|
animationSpec = spring(
|
||||||
|
dampingRatio = Spring.DampingRatioMediumBouncy
|
||||||
|
)
|
||||||
|
).value
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = day.toString(),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = if (day == monthStartDay) {
|
||||||
|
MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurface
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = { showMonthStartDayDialog = false }) {
|
||||||
|
Text("取消")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// 备份对话框
|
// 备份对话框
|
||||||
if (showBackupDialog) {
|
if (showBackupDialog) {
|
||||||
@@ -189,6 +588,29 @@ fun SettingsScreen(
|
|||||||
style = MaterialTheme.typography.bodySmall
|
style = MaterialTheme.typography.bodySmall
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||||
|
|
||||||
|
// 备份加密开关
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text("备份加密", modifier = Modifier.weight(1f))
|
||||||
|
Switch(
|
||||||
|
checked = settings?.encryptBackup ?: true,
|
||||||
|
onCheckedChange = { enabled ->
|
||||||
|
viewModel.updateSettings(
|
||||||
|
settings?.copy(encryptBackup = enabled) ?: Settings(encryptBackup = enabled)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
"开启后,导出的备份文件将被加密保护",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
|
@@ -2,16 +2,59 @@ package com.yovinchen.bookkeeping.ui.theme
|
|||||||
|
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
// Dark Theme Colors
|
// Modern Color Palette - 现代色彩调色板
|
||||||
val DarkPrimary = Color(0xFF9B7EE3) // 深紫色
|
|
||||||
val DarkSecondary = Color(0xFF6C5B7B) // 暗紫灰色
|
|
||||||
val DarkBackground = Color(0xFF121212) // 深黑色
|
|
||||||
val DarkSurface = Color(0xFF1E1E1E) // 深灰色
|
|
||||||
val DarkError = Color(0xFFCF6679) // 深红色
|
|
||||||
|
|
||||||
// Light Theme Colors
|
// Primary Colors - 主色调
|
||||||
val LightPrimary = Color(0xFF6200EE) // 亮紫色
|
val PrimaryLight = Color(0xFF6366F1) // 优雅的蓝紫色
|
||||||
val LightSecondary = Color(0xFF8E8E93) // 浅灰色
|
val PrimaryDark = Color(0xFF818CF8) // 明亮的蓝紫色
|
||||||
val LightBackground = Color(0xFFF5F5F5) // 浅灰白色
|
val PrimaryContainer = Color(0xFFE0E7FF) // 浅蓝紫色背景
|
||||||
val LightSurface = Color(0xFFFFFFFF) // 纯白色
|
|
||||||
val LightError = Color(0xFFB00020) // 红色
|
// Secondary Colors - 辅助色
|
||||||
|
val SecondaryLight = Color(0xFF10B981) // 翠绿色 - 收入
|
||||||
|
val SecondaryDark = Color(0xFF34D399) // 明亮翠绿色
|
||||||
|
|
||||||
|
// Tertiary Colors - 第三色彩
|
||||||
|
val TertiaryLight = Color(0xFFF59E0B) // 琥珀色 - 结余
|
||||||
|
val TertiaryDark = Color(0xFFFBBF24) // 明亮琥珀色
|
||||||
|
|
||||||
|
// Error Colors - 错误/支出色
|
||||||
|
val ErrorLight = Color(0xFFEF4444) // 现代红色 - 支出
|
||||||
|
val ErrorDark = Color(0xFFF87171) // 明亮红色
|
||||||
|
|
||||||
|
// Background Colors - 背景色
|
||||||
|
val BackgroundLight = Color(0xFFFAFAFA) // 非常浅的灰色
|
||||||
|
val BackgroundDark = Color(0xFF0F172A) // 深蓝灰色
|
||||||
|
val SurfaceLight = Color(0xFFFFFFFF) // 纯白色
|
||||||
|
val SurfaceDark = Color(0xFF1E293B) // 深蓝灰色
|
||||||
|
|
||||||
|
// Surface Variants - 表面变体
|
||||||
|
val SurfaceVariantLight = Color(0xFFF8FAFC) // 微灰色
|
||||||
|
val SurfaceVariantDark = Color(0xFF334155) // 中灰蓝色
|
||||||
|
|
||||||
|
// On Colors - 内容色
|
||||||
|
val OnPrimaryLight = Color(0xFFFFFFFF)
|
||||||
|
val OnPrimaryDark = Color(0xFF1E293B)
|
||||||
|
val OnSecondaryLight = Color(0xFFFFFFFF)
|
||||||
|
val OnSecondaryDark = Color(0xFF064E3B)
|
||||||
|
val OnBackgroundLight = Color(0xFF1F2937)
|
||||||
|
val OnBackgroundDark = Color(0xFFF1F5F9)
|
||||||
|
val OnSurfaceLight = Color(0xFF1F2937)
|
||||||
|
val OnSurfaceDark = Color(0xFFF1F5F9)
|
||||||
|
|
||||||
|
// Divider Colors - 分割线色
|
||||||
|
val DividerLight = Color(0xFFE5E7EB)
|
||||||
|
val DividerDark = Color(0xFF475569)
|
||||||
|
|
||||||
|
// Special Colors - 特殊色彩
|
||||||
|
val IncomeColor = Color(0xFF10B981) // 收入绿色
|
||||||
|
val ExpenseColor = Color(0xFFEF4444) // 支出红色
|
||||||
|
val BalancePositive = Color(0xFFF59E0B) // 正结余琥珀色
|
||||||
|
val BalanceNegative = Color(0xFF7C3AED) // 负结余紫色
|
||||||
|
|
||||||
|
// Card Colors - 卡片颜色
|
||||||
|
val CardLight = Color(0xFFFFFFFF)
|
||||||
|
val CardDark = Color(0xFF1E293B)
|
||||||
|
|
||||||
|
// Gradient Colors - 渐变色
|
||||||
|
val GradientStart = Color(0xFF6366F1)
|
||||||
|
val GradientEnd = Color(0xFF8B5CF6)
|
@@ -16,31 +16,72 @@ import androidx.compose.ui.graphics.toArgb
|
|||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalView
|
import androidx.compose.ui.platform.LocalView
|
||||||
import androidx.core.view.WindowCompat
|
import androidx.core.view.WindowCompat
|
||||||
|
import com.yovinchen.bookkeeping.ui.theme.*
|
||||||
|
|
||||||
private val DarkColorScheme = darkColorScheme(
|
private val DarkColorScheme = darkColorScheme(
|
||||||
primary = DarkPrimary,
|
primary = PrimaryDark,
|
||||||
secondary = DarkSecondary,
|
onPrimary = OnPrimaryDark,
|
||||||
background = DarkBackground,
|
primaryContainer = PrimaryDark.copy(alpha = 0.12f),
|
||||||
surface = DarkSurface,
|
onPrimaryContainer = PrimaryDark,
|
||||||
error = DarkError,
|
|
||||||
onPrimary = Color.White,
|
secondary = SecondaryDark,
|
||||||
onSecondary = Color.White,
|
onSecondary = OnSecondaryDark,
|
||||||
onBackground = Color.White,
|
secondaryContainer = SecondaryDark.copy(alpha = 0.12f),
|
||||||
onSurface = Color.White,
|
onSecondaryContainer = SecondaryDark,
|
||||||
onError = Color.Black
|
|
||||||
|
tertiary = TertiaryDark,
|
||||||
|
onTertiary = Color.Black,
|
||||||
|
tertiaryContainer = TertiaryDark.copy(alpha = 0.12f),
|
||||||
|
onTertiaryContainer = TertiaryDark,
|
||||||
|
|
||||||
|
background = BackgroundDark,
|
||||||
|
onBackground = OnBackgroundDark,
|
||||||
|
|
||||||
|
surface = SurfaceDark,
|
||||||
|
onSurface = OnSurfaceDark,
|
||||||
|
surfaceVariant = SurfaceVariantDark,
|
||||||
|
onSurfaceVariant = OnSurfaceDark.copy(alpha = 0.7f),
|
||||||
|
|
||||||
|
error = ErrorDark,
|
||||||
|
onError = Color.Black,
|
||||||
|
errorContainer = ErrorDark.copy(alpha = 0.12f),
|
||||||
|
onErrorContainer = ErrorDark,
|
||||||
|
|
||||||
|
outline = DividerDark,
|
||||||
|
outlineVariant = DividerDark.copy(alpha = 0.5f)
|
||||||
)
|
)
|
||||||
|
|
||||||
private val LightColorScheme = lightColorScheme(
|
private val LightColorScheme = lightColorScheme(
|
||||||
primary = LightPrimary,
|
primary = PrimaryLight,
|
||||||
secondary = LightSecondary,
|
onPrimary = OnPrimaryLight,
|
||||||
background = LightBackground,
|
primaryContainer = PrimaryContainer,
|
||||||
surface = LightSurface,
|
onPrimaryContainer = PrimaryLight,
|
||||||
error = LightError,
|
|
||||||
onPrimary = Color.White,
|
secondary = SecondaryLight,
|
||||||
onSecondary = Color.White,
|
onSecondary = OnSecondaryLight,
|
||||||
onBackground = Color.Black,
|
secondaryContainer = SecondaryLight.copy(alpha = 0.12f),
|
||||||
onSurface = Color.Black,
|
onSecondaryContainer = SecondaryLight,
|
||||||
onError = Color.White
|
|
||||||
|
tertiary = TertiaryLight,
|
||||||
|
onTertiary = Color.White,
|
||||||
|
tertiaryContainer = TertiaryLight.copy(alpha = 0.12f),
|
||||||
|
onTertiaryContainer = TertiaryLight,
|
||||||
|
|
||||||
|
background = BackgroundLight,
|
||||||
|
onBackground = OnBackgroundLight,
|
||||||
|
|
||||||
|
surface = SurfaceLight,
|
||||||
|
onSurface = OnSurfaceLight,
|
||||||
|
surfaceVariant = SurfaceVariantLight,
|
||||||
|
onSurfaceVariant = OnSurfaceLight.copy(alpha = 0.7f),
|
||||||
|
|
||||||
|
error = ErrorLight,
|
||||||
|
onError = Color.White,
|
||||||
|
errorContainer = ErrorLight.copy(alpha = 0.12f),
|
||||||
|
onErrorContainer = ErrorLight,
|
||||||
|
|
||||||
|
outline = DividerLight,
|
||||||
|
outlineVariant = DividerLight.copy(alpha = 0.5f)
|
||||||
)
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -63,7 +104,7 @@ fun BookkeepingTheme(
|
|||||||
if (!view.isInEditMode) {
|
if (!view.isInEditMode) {
|
||||||
SideEffect {
|
SideEffect {
|
||||||
val window = (view.context as Activity).window
|
val window = (view.context as Activity).window
|
||||||
window.statusBarColor = colorScheme.primary.toArgb()
|
window.statusBarColor = colorScheme.background.toArgb()
|
||||||
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
|
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -0,0 +1,85 @@
|
|||||||
|
package com.yovinchen.bookkeeping.utils
|
||||||
|
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.YearMonth
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.util.Date
|
||||||
|
|
||||||
|
object DateUtils {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据月度开始日期计算给定日期所属的记账月份
|
||||||
|
* @param date 要判断的日期
|
||||||
|
* @param monthStartDay 月度开始日期(1-28)
|
||||||
|
* @return 该日期所属的记账月份
|
||||||
|
*/
|
||||||
|
fun getAccountingMonth(date: Date, monthStartDay: Int): YearMonth {
|
||||||
|
val localDate = date.toInstant().atZone(ZoneId.systemDefault()).toLocalDate()
|
||||||
|
return getAccountingMonth(localDate, monthStartDay)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据月度开始日期计算给定日期所属的记账月份
|
||||||
|
* @param date 要判断的日期
|
||||||
|
* @param monthStartDay 月度开始日期(1-28)
|
||||||
|
* @return 该日期所属的记账月份
|
||||||
|
*/
|
||||||
|
fun getAccountingMonth(date: LocalDate, monthStartDay: Int): YearMonth {
|
||||||
|
val dayOfMonth = date.dayOfMonth
|
||||||
|
|
||||||
|
return if (dayOfMonth >= monthStartDay) {
|
||||||
|
// 当前日期大于等于开始日期,属于当前月
|
||||||
|
YearMonth.from(date)
|
||||||
|
} else {
|
||||||
|
// 当前日期小于开始日期,属于上个月
|
||||||
|
YearMonth.from(date.minusMonths(1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取记账月份的开始日期
|
||||||
|
* @param yearMonth 记账月份
|
||||||
|
* @param monthStartDay 月度开始日期(1-28)
|
||||||
|
* @return 该记账月份的开始日期
|
||||||
|
*/
|
||||||
|
fun getMonthStartDate(yearMonth: YearMonth, monthStartDay: Int): LocalDate {
|
||||||
|
return yearMonth.atDay(monthStartDay)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取记账月份的结束日期
|
||||||
|
* @param yearMonth 记账月份
|
||||||
|
* @param monthStartDay 月度开始日期(1-28)
|
||||||
|
* @return 该记账月份的结束日期
|
||||||
|
*/
|
||||||
|
fun getMonthEndDate(yearMonth: YearMonth, monthStartDay: Int): LocalDate {
|
||||||
|
val nextMonth = yearMonth.plusMonths(1)
|
||||||
|
return nextMonth.atDay(monthStartDay).minusDays(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查日期是否在指定的记账月份内
|
||||||
|
* @param date 要检查的日期
|
||||||
|
* @param yearMonth 记账月份
|
||||||
|
* @param monthStartDay 月度开始日期(1-28)
|
||||||
|
* @return 是否在该记账月份内
|
||||||
|
*/
|
||||||
|
fun isInAccountingMonth(date: Date, yearMonth: YearMonth, monthStartDay: Int): Boolean {
|
||||||
|
val localDate = date.toInstant().atZone(ZoneId.systemDefault()).toLocalDate()
|
||||||
|
return isInAccountingMonth(localDate, yearMonth, monthStartDay)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查日期是否在指定的记账月份内
|
||||||
|
* @param date 要检查的日期
|
||||||
|
* @param yearMonth 记账月份
|
||||||
|
* @param monthStartDay 月度开始日期(1-28)
|
||||||
|
* @return 是否在该记账月份内
|
||||||
|
*/
|
||||||
|
fun isInAccountingMonth(date: LocalDate, yearMonth: YearMonth, monthStartDay: Int): Boolean {
|
||||||
|
val startDate = getMonthStartDate(yearMonth, monthStartDay)
|
||||||
|
val endDate = getMonthEndDate(yearMonth, monthStartDay)
|
||||||
|
|
||||||
|
return date >= startDate && date <= endDate
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,136 @@
|
|||||||
|
package com.yovinchen.bookkeeping.utils
|
||||||
|
|
||||||
|
import android.security.keystore.KeyGenParameterSpec
|
||||||
|
import android.security.keystore.KeyProperties
|
||||||
|
import android.util.Base64
|
||||||
|
import java.security.KeyStore
|
||||||
|
import javax.crypto.Cipher
|
||||||
|
import javax.crypto.KeyGenerator
|
||||||
|
import javax.crypto.SecretKey
|
||||||
|
import javax.crypto.spec.GCMParameterSpec
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加密工具类,使用 Android Keystore 系统安全地存储加密密钥
|
||||||
|
*/
|
||||||
|
object EncryptionUtils {
|
||||||
|
private const val ANDROID_KEYSTORE = "AndroidKeyStore"
|
||||||
|
private const val TRANSFORMATION = "AES/GCM/NoPadding"
|
||||||
|
private const val KEY_ALIAS = "BookkeepingBackupKey"
|
||||||
|
private const val GCM_TAG_LENGTH = 128
|
||||||
|
|
||||||
|
init {
|
||||||
|
generateKey()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成或获取加密密钥
|
||||||
|
*/
|
||||||
|
private fun generateKey() {
|
||||||
|
val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE)
|
||||||
|
keyStore.load(null)
|
||||||
|
|
||||||
|
// 如果密钥已存在,则不需要重新生成
|
||||||
|
if (keyStore.containsAlias(KEY_ALIAS)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE)
|
||||||
|
val keyGenParameterSpec = KeyGenParameterSpec.Builder(
|
||||||
|
KEY_ALIAS,
|
||||||
|
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
|
||||||
|
)
|
||||||
|
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||||
|
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||||
|
.setRandomizedEncryptionRequired(true)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
keyGenerator.init(keyGenParameterSpec)
|
||||||
|
keyGenerator.generateKey()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取密钥
|
||||||
|
*/
|
||||||
|
private fun getKey(): SecretKey {
|
||||||
|
val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE)
|
||||||
|
keyStore.load(null)
|
||||||
|
return keyStore.getKey(KEY_ALIAS, null) as SecretKey
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加密字符串
|
||||||
|
* @param plainText 要加密的明文
|
||||||
|
* @return Base64编码的加密数据(包含IV)
|
||||||
|
*/
|
||||||
|
fun encrypt(plainText: String): String {
|
||||||
|
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||||
|
cipher.init(Cipher.ENCRYPT_MODE, getKey())
|
||||||
|
|
||||||
|
val iv = cipher.iv
|
||||||
|
val encryptedBytes = cipher.doFinal(plainText.toByteArray(Charsets.UTF_8))
|
||||||
|
|
||||||
|
// 将IV和加密数据组合
|
||||||
|
val combined = ByteArray(iv.size + encryptedBytes.size)
|
||||||
|
System.arraycopy(iv, 0, combined, 0, iv.size)
|
||||||
|
System.arraycopy(encryptedBytes, 0, combined, iv.size, encryptedBytes.size)
|
||||||
|
|
||||||
|
return Base64.encodeToString(combined, Base64.DEFAULT)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解密字符串
|
||||||
|
* @param encryptedData Base64编码的加密数据
|
||||||
|
* @return 解密后的明文
|
||||||
|
*/
|
||||||
|
fun decrypt(encryptedData: String): String {
|
||||||
|
val combined = Base64.decode(encryptedData, Base64.DEFAULT)
|
||||||
|
|
||||||
|
// 提取IV(前12字节)
|
||||||
|
val iv = combined.sliceArray(0..11)
|
||||||
|
val encryptedBytes = combined.sliceArray(12 until combined.size)
|
||||||
|
|
||||||
|
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||||
|
val spec = GCMParameterSpec(GCM_TAG_LENGTH, iv)
|
||||||
|
cipher.init(Cipher.DECRYPT_MODE, getKey(), spec)
|
||||||
|
|
||||||
|
val decryptedBytes = cipher.doFinal(encryptedBytes)
|
||||||
|
return String(decryptedBytes, Charsets.UTF_8)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加密字节数组
|
||||||
|
* @param data 要加密的数据
|
||||||
|
* @return 加密后的数据(包含IV)
|
||||||
|
*/
|
||||||
|
fun encryptBytes(data: ByteArray): ByteArray {
|
||||||
|
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||||
|
cipher.init(Cipher.ENCRYPT_MODE, getKey())
|
||||||
|
|
||||||
|
val iv = cipher.iv
|
||||||
|
val encryptedBytes = cipher.doFinal(data)
|
||||||
|
|
||||||
|
// 将IV和加密数据组合
|
||||||
|
val combined = ByteArray(iv.size + encryptedBytes.size)
|
||||||
|
System.arraycopy(iv, 0, combined, 0, iv.size)
|
||||||
|
System.arraycopy(encryptedBytes, 0, combined, iv.size, encryptedBytes.size)
|
||||||
|
|
||||||
|
return combined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解密字节数组
|
||||||
|
* @param encryptedData 加密的数据
|
||||||
|
* @return 解密后的数据
|
||||||
|
*/
|
||||||
|
fun decryptBytes(encryptedData: ByteArray): ByteArray {
|
||||||
|
// 提取IV(前12字节)
|
||||||
|
val iv = encryptedData.sliceArray(0..11)
|
||||||
|
val encryptedBytes = encryptedData.sliceArray(12 until encryptedData.size)
|
||||||
|
|
||||||
|
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||||
|
val spec = GCMParameterSpec(GCM_TAG_LENGTH, iv)
|
||||||
|
cipher.init(Cipher.DECRYPT_MODE, getKey(), spec)
|
||||||
|
|
||||||
|
return cipher.doFinal(encryptedBytes)
|
||||||
|
}
|
||||||
|
}
|
@@ -10,40 +10,72 @@ import com.yovinchen.bookkeeping.getPreregisteredFilePickerLauncher
|
|||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileOutputStream
|
import java.io.FileOutputStream
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件选择器工具类
|
||||||
|
* 用于处理文件选择、权限获取和文件处理的工具类
|
||||||
|
*
|
||||||
|
* 主要功能:
|
||||||
|
* 1. 启动系统文件选择器
|
||||||
|
* 2. 处理选择结果
|
||||||
|
* 3. 将选择的文件复制到应用缓存目录
|
||||||
|
* 4. 文件类型验证
|
||||||
|
*/
|
||||||
object FilePickerUtil {
|
object FilePickerUtil {
|
||||||
|
/**
|
||||||
|
* 当前活跃的文件选择回调
|
||||||
|
* 用于在文件选择完成后调用
|
||||||
|
*/
|
||||||
private var currentCallback: ((File) -> Unit)? = null
|
private var currentCallback: ((File) -> Unit)? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动文件选择器
|
||||||
|
*
|
||||||
|
* @param activity 当前活动,用于启动文件选择器
|
||||||
|
* @param onFileSelected 文件选择完成后的回调函数,参数为选中的文件
|
||||||
|
*/
|
||||||
fun startFilePicker(activity: ComponentActivity, onFileSelected: (File) -> Unit) {
|
fun startFilePicker(activity: ComponentActivity, onFileSelected: (File) -> Unit) {
|
||||||
currentCallback = onFileSelected
|
currentCallback = onFileSelected
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// 设置可选择的文件类型,限制为CSV和Excel文件
|
||||||
val mimeTypes = arrayOf(
|
val mimeTypes = arrayOf(
|
||||||
"text/csv",
|
"text/csv",
|
||||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
"application/vnd.ms-excel"
|
"application/vnd.ms-excel"
|
||||||
)
|
)
|
||||||
|
// 使用预注册的文件选择器启动文件选择流程
|
||||||
activity.getPreregisteredFilePickerLauncher().launch(mimeTypes)
|
activity.getPreregisteredFilePickerLauncher().launch(mimeTypes)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
// 文件选择器启动失败时显示错误提示
|
||||||
Toast.makeText(activity, "无法启动文件选择器:${e.message}", Toast.LENGTH_SHORT).show()
|
Toast.makeText(activity, "无法启动文件选择器:${e.message}", Toast.LENGTH_SHORT).show()
|
||||||
currentCallback = null
|
currentCallback = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理文件选择结果
|
||||||
|
*
|
||||||
|
* @param context 上下文对象,用于访问ContentResolver
|
||||||
|
* @param uri 选中文件的URI,如果用户取消选择则为null
|
||||||
|
*/
|
||||||
fun handleFileSelection(context: Context, uri: Uri?) {
|
fun handleFileSelection(context: Context, uri: Uri?) {
|
||||||
if (uri == null) {
|
if (uri == null) {
|
||||||
|
// 用户未选择文件时显示提示
|
||||||
Toast.makeText(context, "未选择文件", Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, "未选择文件", Toast.LENGTH_SHORT).show()
|
||||||
currentCallback = null
|
currentCallback = null
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// 获取文件MIME类型
|
||||||
val mimeType = context.contentResolver.getType(uri)
|
val mimeType = context.contentResolver.getType(uri)
|
||||||
|
// 验证文件类型是否合法
|
||||||
if (!isValidFileType(uri.toString(), mimeType)) {
|
if (!isValidFileType(uri.toString(), mimeType)) {
|
||||||
Toast.makeText(context, "请选择CSV或Excel文件", Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, "请选择CSV或Excel文件", Toast.LENGTH_SHORT).show()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取持久性权限
|
// 获取持久性权限,确保应用在重启后仍能访问该文件
|
||||||
val takeFlags: Int = Intent.FLAG_GRANT_READ_URI_PERMISSION or
|
val takeFlags: Int = Intent.FLAG_GRANT_READ_URI_PERMISSION or
|
||||||
Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||||
context.contentResolver.takePersistableUriPermission(uri, takeFlags)
|
context.contentResolver.takePersistableUriPermission(uri, takeFlags)
|
||||||
@@ -51,6 +83,7 @@ object FilePickerUtil {
|
|||||||
// 将选中的文件复制到应用私有目录
|
// 将选中的文件复制到应用私有目录
|
||||||
val tempFile = copyUriToTempFile(context, uri)
|
val tempFile = copyUriToTempFile(context, uri)
|
||||||
if (tempFile != null) {
|
if (tempFile != null) {
|
||||||
|
// 调用回调函数,传递临时文件
|
||||||
currentCallback?.invoke(tempFile)
|
currentCallback?.invoke(tempFile)
|
||||||
} else {
|
} else {
|
||||||
Toast.makeText(context, "文件处理失败,请重试", Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, "文件处理失败,请重试", Toast.LENGTH_SHORT).show()
|
||||||
@@ -59,25 +92,47 @@ object FilePickerUtil {
|
|||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
Toast.makeText(context, "文件处理出错:${e.message}", Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, "文件处理出错:${e.message}", Toast.LENGTH_SHORT).show()
|
||||||
} finally {
|
} finally {
|
||||||
|
// 清除回调引用,避免内存泄漏
|
||||||
currentCallback = null
|
currentCallback = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证文件类型是否合法
|
||||||
|
*
|
||||||
|
* @param fileName 文件名,用于检查文件扩展名
|
||||||
|
* @param mimeType 文件MIME类型
|
||||||
|
* @return 如果文件类型合法则返回true,否则返回false
|
||||||
|
*/
|
||||||
private fun isValidFileType(fileName: String, mimeType: String?): Boolean {
|
private fun isValidFileType(fileName: String, mimeType: String?): Boolean {
|
||||||
val fileExtension = fileName.lowercase()
|
val fileExtension = fileName.lowercase()
|
||||||
return fileExtension.endsWith(".csv") ||
|
return fileExtension.endsWith(".csv") ||
|
||||||
|
fileExtension.endsWith(".csv.enc") ||
|
||||||
fileExtension.endsWith(".xlsx") ||
|
fileExtension.endsWith(".xlsx") ||
|
||||||
|
fileExtension.endsWith(".xlsx.enc") ||
|
||||||
fileExtension.endsWith(".xls") ||
|
fileExtension.endsWith(".xls") ||
|
||||||
|
fileExtension.endsWith(".xls.enc") ||
|
||||||
|
fileExtension.endsWith(".enc") ||
|
||||||
mimeType == "text/csv" ||
|
mimeType == "text/csv" ||
|
||||||
mimeType == "application/vnd.ms-excel" ||
|
mimeType == "application/vnd.ms-excel" ||
|
||||||
mimeType == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
mimeType == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" ||
|
||||||
|
mimeType == "application/octet-stream" // 加密文件可能被识别为二进制流
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将URI指向的文件复制到应用缓存目录
|
||||||
|
*
|
||||||
|
* @param context 上下文对象,用于访问ContentResolver和缓存目录
|
||||||
|
* @param uri 要复制的文件URI
|
||||||
|
* @return 复制后的临时文件,如果复制失败则返回null
|
||||||
|
*/
|
||||||
private fun copyUriToTempFile(context: Context, uri: Uri): File? {
|
private fun copyUriToTempFile(context: Context, uri: Uri): File? {
|
||||||
return try {
|
return try {
|
||||||
|
// 获取文件名,如果无法获取则使用时间戳作为文件名
|
||||||
val fileName = getFileName(context, uri) ?: "temp_backup_${System.currentTimeMillis()}"
|
val fileName = getFileName(context, uri) ?: "temp_backup_${System.currentTimeMillis()}"
|
||||||
val tempFile = File(context.cacheDir, fileName)
|
val tempFile = File(context.cacheDir, fileName)
|
||||||
|
|
||||||
|
// 从URI读取内容并写入临时文件
|
||||||
context.contentResolver.openInputStream(uri)?.use { inputStream ->
|
context.contentResolver.openInputStream(uri)?.use { inputStream ->
|
||||||
FileOutputStream(tempFile).use { outputStream ->
|
FileOutputStream(tempFile).use { outputStream ->
|
||||||
inputStream.copyTo(outputStream)
|
inputStream.copyTo(outputStream)
|
||||||
@@ -90,6 +145,13 @@ object FilePickerUtil {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从URI中获取文件名
|
||||||
|
*
|
||||||
|
* @param context 上下文对象,用于访问ContentResolver
|
||||||
|
* @param uri 文件URI
|
||||||
|
* @return 文件名,如果无法获取则返回null
|
||||||
|
*/
|
||||||
private fun getFileName(context: Context, uri: Uri): String? {
|
private fun getFileName(context: Context, uri: Uri): String? {
|
||||||
var fileName: String? = null
|
var fileName: String? = null
|
||||||
context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||||
|
@@ -6,77 +6,126 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
|||||||
import androidx.compose.ui.res.vectorResource
|
import androidx.compose.ui.res.vectorResource
|
||||||
import com.yovinchen.bookkeeping.R
|
import com.yovinchen.bookkeeping.R
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图标管理器
|
||||||
|
* 集中管理应用中使用的各类图标资源
|
||||||
|
*
|
||||||
|
* 主要功能:
|
||||||
|
* 1. 管理分类图标和成员图标的映射关系
|
||||||
|
* 2. 提供根据名称获取对应图标的方法
|
||||||
|
* 3. 提供获取所有可用图标的方法
|
||||||
|
*/
|
||||||
object IconManager {
|
object IconManager {
|
||||||
// 类别图标映射
|
/**
|
||||||
|
* 类别图标映射
|
||||||
|
* 将分类名称映射到对应的图标资源ID
|
||||||
|
*/
|
||||||
private val categoryIcons = mapOf(
|
private val categoryIcons = mapOf(
|
||||||
"餐饮" to R.drawable.ic_category_food_24dp,
|
"餐饮" to R.drawable.ic_category_food_24dp, // 餐饮类别对应食物图标
|
||||||
"交通" to R.drawable.ic_category_taxi_24dp,
|
"交通" to R.drawable.ic_category_taxi_24dp, // 交通类别对应出租车图标
|
||||||
"购物" to R.drawable.ic_category_supermarket_24dp,
|
"购物" to R.drawable.ic_category_supermarket_24dp, // 购物类别对应超市图标
|
||||||
"娱乐" to R.drawable.ic_category_bar_24dp,
|
"娱乐" to R.drawable.ic_category_bar_24dp, // 娱乐类别对应酒吧图标
|
||||||
"居住" to R.drawable.ic_category_hotel_24dp,
|
"居住" to R.drawable.ic_category_hotel_24dp, // 居住类别对应酒店图标
|
||||||
"医疗" to R.drawable.ic_category_medicine_24dp,
|
"医疗" to R.drawable.ic_category_medicine_24dp, // 医疗类别对应药品图标
|
||||||
"教育" to R.drawable.ic_category_training_24dp,
|
"教育" to R.drawable.ic_category_training_24dp, // 教育类别对应培训图标
|
||||||
"宠物" to R.drawable.ic_category_pet_24dp,
|
"宠物" to R.drawable.ic_category_pet_24dp, // 宠物类别对应宠物图标
|
||||||
"鲜花" to R.drawable.ic_category_flower_24dp,
|
"鲜花" to R.drawable.ic_category_flower_24dp, // 鲜花类别对应花图标
|
||||||
"外卖" to R.drawable.ic_category_delivery_24dp,
|
"外卖" to R.drawable.ic_category_delivery_24dp, // 外卖类别对应外卖图标
|
||||||
"数码" to R.drawable.ic_category_digital_24dp,
|
"数码" to R.drawable.ic_category_digital_24dp, // 数码类别对应数码产品图标
|
||||||
"化妆品" to R.drawable.ic_category_cosmetics_24dp,
|
"化妆品" to R.drawable.ic_category_cosmetics_24dp, // 化妆品类别对应化妆品图标
|
||||||
"水果" to R.drawable.ic_category_fruit_24dp,
|
"水果" to R.drawable.ic_category_fruit_24dp, // 水果类别对应水果图标
|
||||||
"零食" to R.drawable.ic_category_snack_24dp,
|
"零食" to R.drawable.ic_category_snack_24dp, // 零食类别对应零食图标
|
||||||
"蔬菜" to R.drawable.ic_category_vegetable_24dp,
|
"蔬菜" to R.drawable.ic_category_vegetable_24dp, // 蔬菜类别对应蔬菜图标
|
||||||
"工资" to R.drawable.ic_category_membership_24dp,
|
"工资" to R.drawable.ic_category_membership_24dp, // 工资类别对应会员图标
|
||||||
"礼物" to R.drawable.ic_category_gift_24dp,
|
"礼物" to R.drawable.ic_category_gift_24dp, // 礼物类别对应礼物图标
|
||||||
"其他" to R.drawable.ic_category_more_24dp,
|
"其他" to R.drawable.ic_category_more_24dp, // 其他类别对应更多图标
|
||||||
"工资" to R.drawable.ic_category_membership_24dp,
|
"会员" to R.drawable.ic_category_membership_24dp, // 会员类别对应会员图标
|
||||||
"会员" to R.drawable.ic_category_membership_24dp,
|
"奖金" to R.drawable.ic_category_gift_24dp, // 奖金类别对应礼物图标
|
||||||
"奖金" to R.drawable.ic_category_gift_24dp,
|
"投资" to R.drawable.ic_category_digital_24dp // 投资类别对应数码图标
|
||||||
"投资" to R.drawable.ic_category_digital_24dp,
|
|
||||||
"其他" to R.drawable.ic_category_more_24dp
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// 成员图标映射
|
/**
|
||||||
|
* 成员图标映射
|
||||||
|
* 将成员角色名称映射到对应的图标资源ID
|
||||||
|
*/
|
||||||
private val memberIcons = mapOf(
|
private val memberIcons = mapOf(
|
||||||
"自己" to R.drawable.ic_member_boy_24dp,
|
"自己" to R.drawable.ic_member_boy_24dp, // 自己对应男孩图标
|
||||||
"老婆" to R.drawable.ic_member_bride_24dp,
|
"老婆" to R.drawable.ic_member_bride_24dp, // 老婆对应新娘图标
|
||||||
"老公" to R.drawable.ic_member_groom_24dp,
|
"老公" to R.drawable.ic_member_groom_24dp, // 老公对应新郎图标
|
||||||
"家庭" to R.drawable.ic_member_family_24dp,
|
"家庭" to R.drawable.ic_member_family_24dp, // 家庭对应家庭图标
|
||||||
"儿子" to R.drawable.ic_member_baby_boy_24dp,
|
"儿子" to R.drawable.ic_member_baby_boy_24dp, // 儿子对应男婴图标
|
||||||
"女儿" to R.drawable.ic_member_baby_girl_24dp,
|
"女儿" to R.drawable.ic_member_baby_girl_24dp, // 女儿对应女婴图标
|
||||||
"爸爸" to R.drawable.ic_member_father_24dp,
|
"爸爸" to R.drawable.ic_member_father_24dp, // 爸爸对应父亲图标
|
||||||
"妈妈" to R.drawable.ic_member_mother_24dp,
|
"妈妈" to R.drawable.ic_member_mother_24dp, // 妈妈对应母亲图标
|
||||||
"爷爷" to R.drawable.ic_member_grandfather_24dp,
|
"爷爷" to R.drawable.ic_member_grandfather_24dp, // 爷爷对应祖父图标
|
||||||
"奶奶" to R.drawable.ic_member_grandmother_24dp,
|
"奶奶" to R.drawable.ic_member_grandmother_24dp, // 奶奶对应祖母图标
|
||||||
"男生" to R.drawable.ic_member_boy_24dp,
|
"男生" to R.drawable.ic_member_boy_24dp, // 男生对应男孩图标
|
||||||
"女生" to R.drawable.ic_member_girl_24dp,
|
"女生" to R.drawable.ic_member_girl_24dp, // 女生对应女孩图标
|
||||||
"外公" to R.drawable.ic_member_grandfather_24dp,
|
"外公" to R.drawable.ic_member_grandfather_24dp, // 外公对应祖父图标
|
||||||
"外婆" to R.drawable.ic_member_grandmother_24dp,
|
"外婆" to R.drawable.ic_member_grandmother_24dp, // 外婆对应祖母图标
|
||||||
"其他" to R.drawable.ic_member_girl_24dp
|
"其他" to R.drawable.ic_member_girl_24dp // 其他成员使用女孩图标作为默认值
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取分类对应的图标向量
|
||||||
|
* 用于在Compose UI中直接使用
|
||||||
|
*
|
||||||
|
* @param name 分类名称
|
||||||
|
* @return 对应的图标向量,如果未找到则返回null
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun getCategoryIconVector(name: String): ImageVector? {
|
fun getCategoryIconVector(name: String): ImageVector? {
|
||||||
return categoryIcons[name]?.let { ImageVector.vectorResource(id = it) }
|
return categoryIcons[name]?.let { ImageVector.vectorResource(id = it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取成员对应的图标向量
|
||||||
|
* 用于在Compose UI中直接使用
|
||||||
|
*
|
||||||
|
* @param name 成员名称
|
||||||
|
* @return 对应的图标向量,如果未找到则返回null
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun getMemberIconVector(name: String): ImageVector? {
|
fun getMemberIconVector(name: String): ImageVector? {
|
||||||
return memberIcons[name]?.let { ImageVector.vectorResource(id = it) }
|
return memberIcons[name]?.let { ImageVector.vectorResource(id = it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取分类对应的图标资源ID
|
||||||
|
*
|
||||||
|
* @param name 分类名称
|
||||||
|
* @return 对应的图标资源ID,如果未找到则返回null
|
||||||
|
*/
|
||||||
@DrawableRes
|
@DrawableRes
|
||||||
fun getCategoryIcon(name: String): Int? {
|
fun getCategoryIcon(name: String): Int? {
|
||||||
return categoryIcons[name]
|
return categoryIcons[name]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取成员对应的图标资源ID
|
||||||
|
*
|
||||||
|
* @param name 成员名称
|
||||||
|
* @return 对应的图标资源ID,如果未找到则返回null
|
||||||
|
*/
|
||||||
@DrawableRes
|
@DrawableRes
|
||||||
fun getMemberIcon(name: String): Int? {
|
fun getMemberIcon(name: String): Int? {
|
||||||
return memberIcons[name]
|
return memberIcons[name]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有可用的分类图标资源ID列表
|
||||||
|
*
|
||||||
|
* @return 所有分类图标的资源ID列表
|
||||||
|
*/
|
||||||
fun getAllCategoryIcons(): List<Int> {
|
fun getAllCategoryIcons(): List<Int> {
|
||||||
return categoryIcons.values.toList()
|
return categoryIcons.values.toList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有可用的成员图标资源ID列表
|
||||||
|
*
|
||||||
|
* @return 所有成员图标的资源ID列表
|
||||||
|
*/
|
||||||
fun getAllMemberIcons(): List<Int> {
|
fun getAllMemberIcons(): List<Int> {
|
||||||
return memberIcons.values.toList()
|
return memberIcons.values.toList()
|
||||||
}
|
}
|
||||||
|
@@ -4,21 +4,22 @@ import android.app.Application
|
|||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.yovinchen.bookkeeping.data.BookkeepingDatabase
|
import com.yovinchen.bookkeeping.data.BookkeepingDatabase
|
||||||
|
import com.yovinchen.bookkeeping.data.SettingsRepository
|
||||||
import com.yovinchen.bookkeeping.model.AnalysisType
|
import com.yovinchen.bookkeeping.model.AnalysisType
|
||||||
import com.yovinchen.bookkeeping.model.BookkeepingRecord
|
import com.yovinchen.bookkeeping.model.BookkeepingRecord
|
||||||
import com.yovinchen.bookkeeping.model.CategoryStat
|
import com.yovinchen.bookkeeping.model.CategoryStat
|
||||||
import com.yovinchen.bookkeeping.model.MemberStat
|
import com.yovinchen.bookkeeping.model.MemberStat
|
||||||
import com.yovinchen.bookkeeping.model.TransactionType
|
import com.yovinchen.bookkeeping.model.TransactionType
|
||||||
|
import com.yovinchen.bookkeeping.utils.DateUtils
|
||||||
import kotlinx.coroutines.flow.*
|
import kotlinx.coroutines.flow.*
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.time.LocalDateTime
|
|
||||||
import java.time.YearMonth
|
import java.time.YearMonth
|
||||||
import java.time.ZoneId
|
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
class AnalysisViewModel(application: Application) : AndroidViewModel(application) {
|
class AnalysisViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
private val recordDao = BookkeepingDatabase.getDatabase(application).bookkeepingDao()
|
private val recordDao = BookkeepingDatabase.getDatabase(application).bookkeepingDao()
|
||||||
private val memberDao = BookkeepingDatabase.getDatabase(application).memberDao()
|
private val memberDao = BookkeepingDatabase.getDatabase(application).memberDao()
|
||||||
|
private val settingsRepository = SettingsRepository(BookkeepingDatabase.getDatabase(application).settingsDao())
|
||||||
|
|
||||||
private val _startMonth = MutableStateFlow(YearMonth.now())
|
private val _startMonth = MutableStateFlow(YearMonth.now())
|
||||||
val startMonth: StateFlow<YearMonth> = _startMonth.asStateFlow()
|
val startMonth: StateFlow<YearMonth> = _startMonth.asStateFlow()
|
||||||
@@ -38,16 +39,41 @@ class AnalysisViewModel(application: Application) : AndroidViewModel(application
|
|||||||
private val _records = MutableStateFlow<List<BookkeepingRecord>>(emptyList())
|
private val _records = MutableStateFlow<List<BookkeepingRecord>>(emptyList())
|
||||||
val records: StateFlow<List<BookkeepingRecord>> = _records.asStateFlow()
|
val records: StateFlow<List<BookkeepingRecord>> = _records.asStateFlow()
|
||||||
|
|
||||||
|
// 存储月度开始日期设置
|
||||||
|
private val _monthStartDay = MutableStateFlow(1)
|
||||||
|
val monthStartDay: StateFlow<Int> = _monthStartDay.asStateFlow()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
// 订阅设置变化,获取月度开始日期
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
combine(startMonth, endMonth, selectedAnalysisType) { start, end, type ->
|
settingsRepository.getSettings().collect { settings ->
|
||||||
Triple(start, end, type)
|
_monthStartDay.value = settings?.monthStartDay ?: 1
|
||||||
}.collect { (start, end, type) ->
|
}
|
||||||
updateStats(start, end, type)
|
}
|
||||||
|
|
||||||
|
// 当月度开始日期、起始月份、结束月份或分析类型变化时,更新统计数据
|
||||||
|
viewModelScope.launch {
|
||||||
|
combine(
|
||||||
|
startMonth,
|
||||||
|
endMonth,
|
||||||
|
selectedAnalysisType,
|
||||||
|
monthStartDay
|
||||||
|
) { start, end, type, startDay ->
|
||||||
|
UpdateParams(start, end, type, startDay)
|
||||||
|
}.collect { params ->
|
||||||
|
updateStats(params.start, params.end, params.type, params.startDay)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 用于传递更新参数的数据类
|
||||||
|
private data class UpdateParams(
|
||||||
|
val start: YearMonth,
|
||||||
|
val end: YearMonth,
|
||||||
|
val type: AnalysisType,
|
||||||
|
val startDay: Int
|
||||||
|
)
|
||||||
|
|
||||||
fun setStartMonth(month: YearMonth) {
|
fun setStartMonth(month: YearMonth) {
|
||||||
_startMonth.value = month
|
_startMonth.value = month
|
||||||
}
|
}
|
||||||
@@ -60,16 +86,16 @@ class AnalysisViewModel(application: Application) : AndroidViewModel(application
|
|||||||
_selectedAnalysisType.value = type
|
_selectedAnalysisType.value = type
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun updateStats(startMonth: YearMonth, endMonth: YearMonth, type: AnalysisType) {
|
private suspend fun updateStats(startMonth: YearMonth, endMonth: YearMonth, type: AnalysisType, monthStartDay: Int) {
|
||||||
val records = recordDao.getAllRecords().first()
|
val records = recordDao.getAllRecords().first()
|
||||||
|
|
||||||
// 过滤日期范围内的记录
|
// 使用 DateUtils 过滤日期范围内的记录
|
||||||
val monthRecords = records.filter {
|
val monthRecords = records.filter { record ->
|
||||||
val recordDate = Date(it.date.time)
|
val recordDate = Date(record.date.time)
|
||||||
val localDateTime = LocalDateTime.ofInstant(recordDate.toInstant(), ZoneId.systemDefault())
|
val accountingMonth = DateUtils.getAccountingMonth(recordDate, monthStartDay)
|
||||||
val yearMonth = YearMonth.from(localDateTime)
|
|
||||||
yearMonth.isAfter(startMonth.minusMonths(1)) &&
|
// 检查记账月份是否在选定的范围内
|
||||||
yearMonth.isBefore(endMonth.plusMonths(1))
|
accountingMonth >= startMonth && accountingMonth <= endMonth
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新记录数据
|
// 更新记录数据
|
||||||
|
@@ -0,0 +1,185 @@
|
|||||||
|
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.data.BudgetRepository
|
||||||
|
import com.yovinchen.bookkeeping.model.*
|
||||||
|
import kotlinx.coroutines.flow.*
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.util.*
|
||||||
|
import java.util.Calendar
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预算管理 ViewModel
|
||||||
|
* 负责预算相关的业务逻辑和状态管理
|
||||||
|
*/
|
||||||
|
class BudgetViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
|
private val database = BookkeepingDatabase.getDatabase(application)
|
||||||
|
private val budgetRepository = BudgetRepository(
|
||||||
|
database.budgetDao(),
|
||||||
|
database.bookkeepingDao(),
|
||||||
|
database.memberDao()
|
||||||
|
)
|
||||||
|
|
||||||
|
// 所有预算列表
|
||||||
|
val allBudgets = budgetRepository.getAllBudgets()
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5000),
|
||||||
|
initialValue = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
// 当前活跃的预算状态
|
||||||
|
val activeBudgetStatuses = budgetRepository.getActiveBudgetStatuses()
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5000),
|
||||||
|
initialValue = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
// 总预算状态
|
||||||
|
private val _totalBudgetStatus = MutableStateFlow<BudgetStatus?>(null)
|
||||||
|
val totalBudgetStatus: StateFlow<BudgetStatus?> = _totalBudgetStatus.asStateFlow()
|
||||||
|
|
||||||
|
// 分类预算状态
|
||||||
|
val categoryBudgetStatuses = budgetRepository.getCategoryBudgetStatuses()
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5000),
|
||||||
|
initialValue = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
// 成员预算状态
|
||||||
|
val memberBudgetStatuses = budgetRepository.getMemberBudgetStatuses()
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5000),
|
||||||
|
initialValue = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
// 编辑中的预算
|
||||||
|
private val _editingBudget = MutableStateFlow<Budget?>(null)
|
||||||
|
val editingBudget: StateFlow<Budget?> = _editingBudget.asStateFlow()
|
||||||
|
|
||||||
|
// 对话框显示状态
|
||||||
|
private val _showBudgetDialog = MutableStateFlow(false)
|
||||||
|
val showBudgetDialog: StateFlow<Boolean> = _showBudgetDialog.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
// 初始化时加载总预算状态
|
||||||
|
loadTotalBudgetStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载总预算状态
|
||||||
|
*/
|
||||||
|
private fun loadTotalBudgetStatus() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_totalBudgetStatus.value = budgetRepository.getTotalBudgetStatus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建新预算
|
||||||
|
*/
|
||||||
|
fun createBudget(
|
||||||
|
type: BudgetType,
|
||||||
|
amount: Double,
|
||||||
|
categoryName: String? = null,
|
||||||
|
memberId: Int? = null,
|
||||||
|
alertThreshold: Double = 0.8
|
||||||
|
) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val calendar = Calendar.getInstance()
|
||||||
|
val startDate = calendar.time
|
||||||
|
|
||||||
|
// 设置结束日期为当月最后一天
|
||||||
|
calendar.set(Calendar.DAY_OF_MONTH, calendar.getActualMaximum(Calendar.DAY_OF_MONTH))
|
||||||
|
calendar.set(Calendar.HOUR_OF_DAY, 23)
|
||||||
|
calendar.set(Calendar.MINUTE, 59)
|
||||||
|
calendar.set(Calendar.SECOND, 59)
|
||||||
|
val endDate = calendar.time
|
||||||
|
|
||||||
|
val budget = Budget(
|
||||||
|
type = type,
|
||||||
|
amount = amount,
|
||||||
|
categoryName = categoryName,
|
||||||
|
memberId = memberId,
|
||||||
|
startDate = startDate,
|
||||||
|
endDate = endDate,
|
||||||
|
alertThreshold = alertThreshold
|
||||||
|
)
|
||||||
|
|
||||||
|
budgetRepository.createBudget(budget)
|
||||||
|
loadTotalBudgetStatus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新预算
|
||||||
|
*/
|
||||||
|
fun updateBudget(budget: Budget) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
budgetRepository.updateBudget(budget)
|
||||||
|
loadTotalBudgetStatus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除预算
|
||||||
|
*/
|
||||||
|
fun deleteBudget(budget: Budget) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
budgetRepository.deleteBudget(budget)
|
||||||
|
loadTotalBudgetStatus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换预算启用状态
|
||||||
|
*/
|
||||||
|
fun toggleBudgetEnabled(budget: Budget) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
budgetRepository.updateBudgetEnabled(budget.id, !budget.isEnabled)
|
||||||
|
loadTotalBudgetStatus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 显示预算编辑对话框
|
||||||
|
*/
|
||||||
|
fun showEditBudgetDialog(budget: Budget? = null) {
|
||||||
|
_editingBudget.value = budget
|
||||||
|
_showBudgetDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 隐藏预算编辑对话框
|
||||||
|
*/
|
||||||
|
fun hideBudgetDialog() {
|
||||||
|
_showBudgetDialog.value = false
|
||||||
|
_editingBudget.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查预算警报
|
||||||
|
*/
|
||||||
|
fun checkBudgetAlerts() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val alerts = budgetRepository.checkBudgetAlerts()
|
||||||
|
// 这里可以触发通知或其他警报机制
|
||||||
|
// 暂时先不实现,等完成通知功能后再补充
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理过期预算
|
||||||
|
*/
|
||||||
|
fun cleanupExpiredBudgets() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
budgetRepository.cleanupExpiredBudgets()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -4,10 +4,12 @@ import android.app.Application
|
|||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.yovinchen.bookkeeping.data.BookkeepingDatabase
|
import com.yovinchen.bookkeeping.data.BookkeepingDatabase
|
||||||
|
import com.yovinchen.bookkeeping.data.SettingsRepository
|
||||||
import com.yovinchen.bookkeeping.model.BookkeepingRecord
|
import com.yovinchen.bookkeeping.model.BookkeepingRecord
|
||||||
import com.yovinchen.bookkeeping.model.Category
|
import com.yovinchen.bookkeeping.model.Category
|
||||||
import com.yovinchen.bookkeeping.model.Member
|
import com.yovinchen.bookkeeping.model.Member
|
||||||
import com.yovinchen.bookkeeping.model.TransactionType
|
import com.yovinchen.bookkeeping.model.TransactionType
|
||||||
|
import com.yovinchen.bookkeeping.utils.DateUtils
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.*
|
import kotlinx.coroutines.flow.*
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -18,9 +20,26 @@ import java.util.*
|
|||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
class HomeViewModel(application: Application) : AndroidViewModel(application) {
|
class HomeViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
private val bookkeepingDao = BookkeepingDatabase.getDatabase(application).bookkeepingDao()
|
private val database = BookkeepingDatabase.getDatabase(application)
|
||||||
private val memberDao = BookkeepingDatabase.getDatabase(application).memberDao()
|
private val bookkeepingDao = database.bookkeepingDao()
|
||||||
private val categoryDao = BookkeepingDatabase.getDatabase(application).categoryDao()
|
private val memberDao = database.memberDao()
|
||||||
|
private val categoryDao = database.categoryDao()
|
||||||
|
private val settingsRepository = SettingsRepository(database.settingsDao())
|
||||||
|
|
||||||
|
// 设置相关
|
||||||
|
private val _monthStartDay = MutableStateFlow(1)
|
||||||
|
val monthStartDay: StateFlow<Int> = _monthStartDay.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
settingsRepository.ensureSettingsExist()
|
||||||
|
settingsRepository.getSettings().collect { settings ->
|
||||||
|
settings?.let {
|
||||||
|
_monthStartDay.value = it.monthStartDay
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private val _selectedRecordType = MutableStateFlow<TransactionType?>(null)
|
private val _selectedRecordType = MutableStateFlow<TransactionType?>(null)
|
||||||
val selectedRecordType: StateFlow<TransactionType?> = _selectedRecordType.asStateFlow()
|
val selectedRecordType: StateFlow<TransactionType?> = _selectedRecordType.asStateFlow()
|
||||||
@@ -56,17 +75,13 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
allRecords,
|
allRecords,
|
||||||
_selectedRecordType,
|
_selectedRecordType,
|
||||||
_selectedMonth,
|
_selectedMonth,
|
||||||
_selectedMember
|
_selectedMember,
|
||||||
) { records, selectedType, selectedMonth, selectedMember ->
|
_monthStartDay
|
||||||
|
) { records, selectedType, selectedMonth, selectedMember, monthStartDay ->
|
||||||
records
|
records
|
||||||
.filter { record ->
|
.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 typeMatches = selectedType?.let { record.type == it } ?: true
|
||||||
val monthMatches = recordYearMonth == selectedMonth
|
val monthMatches = DateUtils.isInAccountingMonth(record.date, selectedMonth, monthStartDay)
|
||||||
val memberMatches = selectedMember?.let { record.memberId == it.id } ?: true
|
val memberMatches = selectedMember?.let { record.memberId == it.id } ?: true
|
||||||
|
|
||||||
monthMatches && memberMatches && typeMatches
|
monthMatches && memberMatches && typeMatches
|
||||||
@@ -90,16 +105,12 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
val totalIncome = combine(
|
val totalIncome = combine(
|
||||||
allRecords,
|
allRecords,
|
||||||
_selectedMonth,
|
_selectedMonth,
|
||||||
_selectedMember
|
_selectedMember,
|
||||||
) { records, selectedMonth, selectedMember ->
|
_monthStartDay
|
||||||
|
) { records, selectedMonth, selectedMember, monthStartDay ->
|
||||||
records
|
records
|
||||||
.filter { record ->
|
.filter { record ->
|
||||||
val recordDate = record.date.toInstant()
|
val monthMatches = DateUtils.isInAccountingMonth(record.date, selectedMonth, monthStartDay)
|
||||||
.atZone(ZoneId.systemDefault())
|
|
||||||
.toLocalDate()
|
|
||||||
val recordYearMonth = YearMonth.from(recordDate)
|
|
||||||
|
|
||||||
val monthMatches = recordYearMonth == selectedMonth
|
|
||||||
val memberMatches = selectedMember?.let { record.memberId == it.id } ?: true
|
val memberMatches = selectedMember?.let { record.memberId == it.id } ?: true
|
||||||
val typeMatches = record.type == TransactionType.INCOME
|
val typeMatches = record.type == TransactionType.INCOME
|
||||||
|
|
||||||
@@ -115,16 +126,12 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
val totalExpense = combine(
|
val totalExpense = combine(
|
||||||
allRecords,
|
allRecords,
|
||||||
_selectedMonth,
|
_selectedMonth,
|
||||||
_selectedMember
|
_selectedMember,
|
||||||
) { records, selectedMonth, selectedMember ->
|
_monthStartDay
|
||||||
|
) { records, selectedMonth, selectedMember, monthStartDay ->
|
||||||
records
|
records
|
||||||
.filter { record ->
|
.filter { record ->
|
||||||
val recordDate = record.date.toInstant()
|
val monthMatches = DateUtils.isInAccountingMonth(record.date, selectedMonth, monthStartDay)
|
||||||
.atZone(ZoneId.systemDefault())
|
|
||||||
.toLocalDate()
|
|
||||||
val recordYearMonth = YearMonth.from(recordDate)
|
|
||||||
|
|
||||||
val monthMatches = recordYearMonth == selectedMonth
|
|
||||||
val memberMatches = selectedMember?.let { record.memberId == it.id } ?: true
|
val memberMatches = selectedMember?.let { record.memberId == it.id } ?: true
|
||||||
val typeMatches = record.type == TransactionType.EXPENSE
|
val typeMatches = record.type == TransactionType.EXPENSE
|
||||||
|
|
||||||
|
@@ -9,9 +9,12 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import com.opencsv.CSVReader
|
import com.opencsv.CSVReader
|
||||||
import com.opencsv.CSVWriter
|
import com.opencsv.CSVWriter
|
||||||
import com.yovinchen.bookkeeping.data.BookkeepingDatabase
|
import com.yovinchen.bookkeeping.data.BookkeepingDatabase
|
||||||
|
import com.yovinchen.bookkeeping.data.SettingsRepository
|
||||||
import com.yovinchen.bookkeeping.model.BookkeepingRecord
|
import com.yovinchen.bookkeeping.model.BookkeepingRecord
|
||||||
import com.yovinchen.bookkeeping.model.Category
|
import com.yovinchen.bookkeeping.model.Category
|
||||||
|
import com.yovinchen.bookkeeping.model.Settings
|
||||||
import com.yovinchen.bookkeeping.model.TransactionType
|
import com.yovinchen.bookkeeping.model.TransactionType
|
||||||
|
import com.yovinchen.bookkeeping.utils.EncryptionUtils
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
@@ -28,6 +31,7 @@ import org.apache.poi.xssf.usermodel.XSSFWorkbook
|
|||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileReader
|
import java.io.FileReader
|
||||||
import java.io.FileWriter
|
import java.io.FileWriter
|
||||||
|
import java.io.StringWriter
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
@@ -38,8 +42,36 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
private val database = BookkeepingDatabase.getDatabase(application)
|
private val database = BookkeepingDatabase.getDatabase(application)
|
||||||
private val dao = database.bookkeepingDao()
|
private val dao = database.bookkeepingDao()
|
||||||
private val memberDao = database.memberDao()
|
private val memberDao = database.memberDao()
|
||||||
|
private val settingsRepository = SettingsRepository(database.settingsDao())
|
||||||
|
|
||||||
|
// 设置相关的状态
|
||||||
|
val settings: StateFlow<Settings?> = settingsRepository.getSettings()
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5000),
|
||||||
|
initialValue = null
|
||||||
|
)
|
||||||
|
|
||||||
private val _isAutoBackupEnabled = MutableStateFlow(false)
|
private val _isAutoBackupEnabled = MutableStateFlow(false)
|
||||||
val isAutoBackupEnabled: StateFlow<Boolean> = _isAutoBackupEnabled.asStateFlow()
|
val isAutoBackupEnabled: StateFlow<Boolean> = _isAutoBackupEnabled.asStateFlow()
|
||||||
|
|
||||||
|
private val _monthStartDay = MutableStateFlow(1)
|
||||||
|
val monthStartDay: StateFlow<Int> = _monthStartDay.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
// 确保设置存在
|
||||||
|
settingsRepository.ensureSettingsExist()
|
||||||
|
|
||||||
|
// 监听设置变化
|
||||||
|
settings.collect { settings ->
|
||||||
|
settings?.let {
|
||||||
|
_isAutoBackupEnabled.value = it.autoBackupEnabled
|
||||||
|
_monthStartDay.value = it.monthStartDay
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private val _selectedCategoryType = MutableStateFlow(TransactionType.EXPENSE)
|
private val _selectedCategoryType = MutableStateFlow(TransactionType.EXPENSE)
|
||||||
val selectedCategoryType: StateFlow<TransactionType> = _selectedCategoryType.asStateFlow()
|
val selectedCategoryType: StateFlow<TransactionType> = _selectedCategoryType.asStateFlow()
|
||||||
@@ -85,11 +117,19 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
fun setAutoBackup(enabled: Boolean) {
|
fun setAutoBackup(enabled: Boolean) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_isAutoBackupEnabled.value = enabled
|
_isAutoBackupEnabled.value = enabled
|
||||||
|
settingsRepository.updateAutoBackupEnabled(enabled)
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
schedulePeriodicBackup()
|
schedulePeriodicBackup()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setMonthStartDay(day: Int) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_monthStartDay.value = day
|
||||||
|
settingsRepository.updateMonthStartDay(day)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun schedulePeriodicBackup() {
|
private fun schedulePeriodicBackup() {
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
@@ -119,15 +159,24 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
fun exportToCSV(context: Context, customDir: File? = null) {
|
fun exportToCSV(context: Context, customDir: File? = null) {
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
|
val currentSettings = settings.value ?: Settings()
|
||||||
val timestamp =
|
val timestamp =
|
||||||
SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
|
SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
|
||||||
val fileName = "bookkeeping_backup_$timestamp.csv"
|
val shouldEncrypt = currentSettings.encryptBackup
|
||||||
|
val fileName = if (shouldEncrypt) {
|
||||||
|
"bookkeeping_backup_$timestamp.csv.enc"
|
||||||
|
} else {
|
||||||
|
"bookkeeping_backup_$timestamp.csv"
|
||||||
|
}
|
||||||
val downloadsDir = customDir ?: Environment.getExternalStoragePublicDirectory(
|
val downloadsDir = customDir ?: Environment.getExternalStoragePublicDirectory(
|
||||||
Environment.DIRECTORY_DOWNLOADS
|
Environment.DIRECTORY_DOWNLOADS
|
||||||
)
|
)
|
||||||
val file = File(downloadsDir, fileName)
|
val file = File(downloadsDir, fileName)
|
||||||
|
|
||||||
CSVWriter(FileWriter(file)).use { writer ->
|
// 先创建CSV内容到字符串
|
||||||
|
val csvContent = StringWriter().use { stringWriter ->
|
||||||
|
val writer = CSVWriter(stringWriter)
|
||||||
|
|
||||||
// 写入头部
|
// 写入头部
|
||||||
writer.writeNext(arrayOf("日期", "类型", "金额", "类别", "备注", "成员"))
|
writer.writeNext(arrayOf("日期", "类型", "金额", "类别", "备注", "成员"))
|
||||||
|
|
||||||
@@ -151,11 +200,25 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
writer.close()
|
||||||
|
stringWriter.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据设置决定是否加密
|
||||||
|
if (shouldEncrypt) {
|
||||||
|
val encryptedContent = EncryptionUtils.encrypt(csvContent)
|
||||||
|
file.writeText(encryptedContent)
|
||||||
|
} else {
|
||||||
|
file.writeText(csvContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
Toast.makeText(context, "CSV导出成功: ${file.absolutePath}", Toast.LENGTH_LONG)
|
val message = if (shouldEncrypt) {
|
||||||
.show()
|
"CSV导出成功(已加密): ${file.absolutePath}"
|
||||||
|
} else {
|
||||||
|
"CSV导出成功: ${file.absolutePath}"
|
||||||
|
}
|
||||||
|
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
@@ -169,6 +232,9 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
fun exportToExcel(context: Context) {
|
fun exportToExcel(context: Context) {
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
|
val currentSettings = settings.value ?: Settings()
|
||||||
|
val shouldEncrypt = currentSettings.encryptBackup
|
||||||
|
|
||||||
val workbook = XSSFWorkbook()
|
val workbook = XSSFWorkbook()
|
||||||
val sheet = workbook.createSheet("账目记录")
|
val sheet = workbook.createSheet("账目记录")
|
||||||
|
|
||||||
@@ -201,18 +267,39 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
|
|
||||||
val timestamp =
|
val timestamp =
|
||||||
SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
|
SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
|
||||||
val fileName = "bookkeeping_backup_$timestamp.xlsx"
|
val fileName = if (shouldEncrypt) {
|
||||||
|
"bookkeeping_backup_$timestamp.xlsx.enc"
|
||||||
|
} else {
|
||||||
|
"bookkeeping_backup_$timestamp.xlsx"
|
||||||
|
}
|
||||||
val downloadsDir =
|
val downloadsDir =
|
||||||
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
|
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
|
||||||
val file = File(downloadsDir, fileName)
|
val file = File(downloadsDir, fileName)
|
||||||
|
|
||||||
workbook.write(file.outputStream())
|
if (shouldEncrypt) {
|
||||||
|
// 将 workbook 写入字节数组
|
||||||
|
val byteArrayOutputStream = java.io.ByteArrayOutputStream()
|
||||||
|
workbook.write(byteArrayOutputStream)
|
||||||
|
val excelBytes = byteArrayOutputStream.toByteArray()
|
||||||
|
|
||||||
|
// 加密字节数组
|
||||||
|
val encryptedBytes = EncryptionUtils.encryptBytes(excelBytes)
|
||||||
|
|
||||||
|
// 写入加密文件
|
||||||
|
file.writeBytes(encryptedBytes)
|
||||||
|
} else {
|
||||||
|
workbook.write(file.outputStream())
|
||||||
|
}
|
||||||
|
|
||||||
workbook.close()
|
workbook.close()
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
Toast.makeText(
|
val message = if (shouldEncrypt) {
|
||||||
context, "Excel导出成功: ${file.absolutePath}", Toast.LENGTH_LONG
|
"Excel导出成功(已加密): ${file.absolutePath}"
|
||||||
).show()
|
} else {
|
||||||
|
"Excel导出成功: ${file.absolutePath}"
|
||||||
|
}
|
||||||
|
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
@@ -227,10 +314,30 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
when {
|
when {
|
||||||
|
backupFile.name.endsWith(".csv.enc", ignoreCase = true) -> {
|
||||||
|
// 解密CSV文件
|
||||||
|
val encryptedContent = backupFile.readText()
|
||||||
|
val decryptedContent = EncryptionUtils.decrypt(encryptedContent)
|
||||||
|
val tempFile = File(context.cacheDir, "temp_decrypted.csv")
|
||||||
|
tempFile.writeText(decryptedContent)
|
||||||
|
restoreFromCSV(tempFile)
|
||||||
|
tempFile.delete()
|
||||||
|
}
|
||||||
|
|
||||||
backupFile.name.endsWith(".csv", ignoreCase = true) -> {
|
backupFile.name.endsWith(".csv", ignoreCase = true) -> {
|
||||||
restoreFromCSV(backupFile)
|
restoreFromCSV(backupFile)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
backupFile.name.endsWith(".xlsx.enc", ignoreCase = true) -> {
|
||||||
|
// 解密Excel文件
|
||||||
|
val encryptedBytes = backupFile.readBytes()
|
||||||
|
val decryptedBytes = EncryptionUtils.decryptBytes(encryptedBytes)
|
||||||
|
val tempFile = File(context.cacheDir, "temp_decrypted.xlsx")
|
||||||
|
tempFile.writeBytes(decryptedBytes)
|
||||||
|
restoreFromExcel(tempFile)
|
||||||
|
tempFile.delete()
|
||||||
|
}
|
||||||
|
|
||||||
backupFile.name.endsWith(".xlsx", ignoreCase = true) -> {
|
backupFile.name.endsWith(".xlsx", ignoreCase = true) -> {
|
||||||
restoreFromExcel(backupFile)
|
restoreFromExcel(backupFile)
|
||||||
}
|
}
|
||||||
@@ -249,7 +356,11 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
Toast.makeText(context, "数据恢复失败: ${e.message}", Toast.LENGTH_LONG).show()
|
val errorMessage = when {
|
||||||
|
e.message?.contains("decrypt") == true -> "解密失败,请确认文件未损坏"
|
||||||
|
else -> "数据恢复失败: ${e.message}"
|
||||||
|
}
|
||||||
|
Toast.makeText(context, errorMessage, Toast.LENGTH_LONG).show()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -305,4 +416,10 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
private suspend fun findMemberIdByName(name: String): Int? {
|
private suspend fun findMemberIdByName(name: String): Int? {
|
||||||
return memberDao.getAllMembers().first().find { member -> member.name == name }?.id
|
return memberDao.getAllMembers().first().find { member -> member.name == name }?.id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun updateSettings(settings: Settings) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
settingsRepository.updateSettings(settings)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user