12 Commits

Author SHA1 Message Date
a2489c4987 docs: 更新README文档,突出项目功能亮点
- 新增"功能亮点"部分,重点展示已完成的核心功能
  - 智能数据分析:月度/年度报表、详细分析、多维度统计
  - 预算管理系统:多层级预算、实时监控、灵活配置
  - 数据安全保障:备份加密、离线优先、隐私保护
  - 现代化设计:Material 3、深色模式、响应式布局
- 更新主要特性,标注预算管理为"基本完成"
- 更新v1.5版本历史,记录崩溃修复和功能优化
- 优化文档结构,提升可读性和专业度

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-20 00:25:07 +08:00
562617ca11 fix: 修复DetailedAnalysisReport中嵌套LazyColumn导致的崩溃
- 将DetailedAnalysisReport中的LazyColumn改为Column
- 移除所有item{}包装,直接使用普通组件布局
- 解决'Vertically scrollable component was measured with an infinity maximum height constraints'错误

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-20 00:20:59 +08:00
74cc6f36a9 feat: 优化月度/年度报表和数据分析页面
- 创建 MonthlyYearlyReport 组件,显示收支对比、盈余状况、储蓄率和日均消费
- 创建 DetailedAnalysisReport 组件,提供详细的分类统计分析
  - 支出/收入分类明细与占比
  - TOP5分类排行榜(金银铜奖牌设计)
  - 可视化进度条和百分比显示
- 在 AnalysisScreen 中新增"报表"视图模式
  - 支持分类、成员、报表三种视图切换
  - 集成月度/年度报表和详细分析报表
- 更新 README:标记月度/年度报表功能为已完成
- 更新 v1.5 版本历史,记录数据分析优化内容

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-20 00:17:49 +08:00
e651086e6d feat: 实现预算管理功能界面
1. 预算管理界面
   - 创建 BudgetScreen 预算管理主界面
   - 支持总览、分类预算、成员预算三个标签页
   - 实现预算状态可视化(进度条、超支提醒)
   - 预算项目的启用/禁用切换

2. 预算编辑功能
   - 创建 BudgetEditDialog 预算编辑对话框
   - 支持设置预算类型、金额、预警阈值
   - 分类预算和成员预算的选择器
   - 自动设置月度周期

3. 业务逻辑
   - 创建 BudgetViewModel 管理预算状态
   - 实现预算的创建、更新、删除功能
   - 预算状态的实时计算和更新

4. 导航集成
   - 在设置页面添加预算管理入口
   - 更新导航系统支持预算管理界面
   - 添加预算管理路由

5. 文档更新
   - 更新 README 版本历史
   - 标记预算管理功能为基本完成
   - 更新功能进度状态

注:界面已完成,待实现预算超支提醒和分析报告功能

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-19 23:09:38 +08:00
7fc76df829 docs: 更新 README 文件,反映最新的项目进度
- 标记月度记账开始日期功能为已完成
- 标记数据管理功能为已完成(包括备份加密)
- 更新预算管理功能状态为进行中
- 添加 v1.4 和 v1.5(开发中)版本历史
- 在主要特性中添加备份加密和自定义周期功能

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-19 22:50:05 +08:00
7933452ab5 Merge branch 'feature/encryption-budget' into develop 2025-07-19 22:31:57 +08:00
026df11933 feat: 实现备份加密功能和预算管理基础架构
1. 备份加密功能
   - 添加 EncryptionUtils 使用 Android Keystore 安全存储密钥
   - 修改导出功能支持 CSV 和 Excel 文件加密
   - 实现加密文件的自动解密导入
   - 在设置页面添加备份加密开关

2. 预算管理基础架构
   - 创建 Budget 数据模型,支持总预算、分类预算和成员预算
   - 创建 BudgetDao 提供数据库操作接口
   - 创建 BudgetRepository 实现预算业务逻辑
   - 更新数据库版本至 v6 并添加迁移

3. 其他改进
   - 创建 CLAUDE.md 文件提供项目指导
   - 修复编译错误和类型安全问题
   - 更新 FilePickerUtil 支持加密文件格式

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-19 22:26:17 +08:00
316176bf6a feat: 实现月度记账开始日期功能
- 添加 Settings 实体和 DAO 来持久化存储设置
- 创建 SettingsRepository 管理设置数据
- 添加数据库迁移从版本 4 到版本 5
- 在设置界面添加月度开始日期选择器(1-28号)
- 创建 DateUtils 工具类处理基于月度开始日期的日期计算
- 更新 HomeViewModel 和 AnalysisViewModel 使用月度开始日期进行统计
- 修复日期选择器中数字显示不完整的问题
2025-07-19 22:19:50 +08:00
bdf01f6bbe feat: 实现月度记账开始日期功能
- 添加 Settings 实体和 DAO 来持久化存储设置
- 创建 SettingsRepository 管理设置数据
- 添加数据库迁移从版本 4 到版本 5
- 在设置界面添加月度开始日期选择器(1-28号)
- 创建 DateUtils 工具类处理基于月度开始日期的日期计算
- 更新 HomeViewModel 和 AnalysisViewModel 使用月度开始日期进行统计
- 修复日期选择器中数字显示不完整的问题
2025-07-19 22:19:43 +08:00
2339e5b980 Merge pull request 'feat: 实现月度记账开始日期功能' (#4) from detached into develop
Reviewed-on: #4
2025-07-14 15:19:51 +08:00
439080499b Revert "feat: 实现月度记账开始日期功能"
This reverts commit a86898011d.
2025-07-14 15:10:57 +08:00
a86898011d feat: 实现月度记账开始日期功能
- 添加 Settings 实体和 DAO 来持久化存储设置
- 创建 SettingsRepository 管理设置数据
- 添加数据库迁移从版本 4 到版本 5
- 在设置界面添加月度开始日期选择器(1-28号)
- 创建 DateUtils 工具类处理基于月度开始日期的日期计算
- 更新 HomeViewModel 和 AnalysisViewModel 使用月度开始日期进行统计
- 修复日期选择器中数字显示不完整的问题
- 创建 CLAUDE.md 文件记录项目开发指南

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-14 15:08:12 +08:00
23 changed files with 2387 additions and 58 deletions

View 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
View 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>

View File

@@ -49,6 +49,9 @@
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</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">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />

117
CLAUDE.md Normal file
View 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 中异步处理
- 大量数据导入时使用批量插入优化性能

View File

@@ -6,6 +6,28 @@
本项目是一个使用 Kotlin 和 Jetpack Compose 开发的 Android 记账应用,采用 MVVM 架构,提供简洁直观的用户界面和丰富的记账功能。
## 🔥 功能亮点
### 📊 智能数据分析
- **月度/年度报表**:收支对比、储蓄率、日均消费等关键指标
- **详细分析报表**分类统计明细、TOP排行榜、可视化进度条
- **多维度统计**:支持按分类、成员、时间等多维度数据分析
### 💼 预算管理系统
- **多层级预算**:支持总预算、分类预算、成员预算
- **实时监控**:预算使用情况可视化,超支警告提醒
- **灵活配置**:可启用/禁用预算,自定义预警阈值
### 🔐 数据安全保障
- **备份加密**:使用 Android Keystore 加密导出文件
- **离线优先**:完全本地存储,无网络依赖
- **隐私保护**:极简权限,数据完全掌控
### 🎨 现代化设计
- **Material 3**:遵循最新设计规范
- **深色模式**:支持系统主题切换
- **响应式布局**:适配不同屏幕尺寸
## ⭐️ 主要特性
- 🔒 完全离线运行,无需网络连接
@@ -13,6 +35,10 @@
- 💰 支持收入和支出记录
- 👥 支持多人记账
- 📊 按日期和类别统计
- 🔐 备份文件加密保护
- 📅 自定义月度记账周期
- 💼 预算管理(基本完成)
- 📈 详细数据分析报表
## 🛠 技术栈
@@ -32,7 +58,7 @@
- [x] Material 3 设计界面
- [x] 深色/浅色主题切换
- [x] 主题色自定义
- [ ] 月度记账开始日期
- [x] 月度记账开始日期
### 1. 成员系统 (已完成 🎉)
- [x] 成员添加/编辑/删除
@@ -43,7 +69,7 @@
### 2. 图表分析 (已完成 🎉)
- [x] 支出/收入趋势图表
- [x] 分类占比饼图
- [ ] 月度/年度报表
- [x] 月度/年度报表
- [x] 成员消费分析
- [x] 自定义统计周期
@@ -59,24 +85,26 @@
- [x] 收入类图标 (工资、奖金、理财等)
- [x] 成员图标 (家人、朋友、同事等)
### 4. 数据管理 (进行中 🚀)
### 4. 数据管理 (已完成 🎉)
- [x] 导出 CSV/Excel 功能
- [x] 数据导入
- [x] 数据迁移工具
- [x] 定期自动备份
- [ ] 备份加密功能
- [x] 备份加密功能
### 5. 预算管理 (计划中 💡)
- [ ] 月度预算设置
### 5. 预算管理 (基本完成 ✨)
- [x] 预算数据模型设计
- [x] 数据库架构实现
- [x] 预算管理界面
- [x] 月度预算设置
- [ ] 预算超支提醒
- [ ] 分类预算管理
- [ ] 成员预算管理
- [x] 分类预算管理
- [x] 成员预算管理
- [ ] 预算分析报告
### 6. 体验优化 (持续进行 🔄)
- [x] 深色模式支持
- [ ] 手势操作优化
- [ ] 快速记账小组件
- [ ] 多语言支持
- [ ] 自定义主题
@@ -116,6 +144,33 @@
## 📝 版本历史
### v1.5 (开发中)
- 预算管理功能
- 预算数据模型设计
- 支持总预算、分类预算、成员预算
- 数据库架构实现升级到版本6
- 预算管理界面设计
- 预算编辑对话框
- 预算状态可视化(进度条、超支提醒)
- 预算导航集成
- 数据分析优化
- 月度/年度报表组件
- 详细分析报表(分类统计明细)
- 收支对比、储蓄率、日均消费分析
- TOP分类排行榜金银铜奖牌设计
- 报表视图集成到分析页面
- 修复嵌套滚动组件崩溃问题
### v1.4
- 数据安全功能
- 备份文件加密使用Android Keystore
- 支持加密CSV/Excel导出
- 自动检测和解密加密备份
- 设置页面加密开关
- 月度记账优化
- 自定义月度开始日期1-28号
- 所有统计基于自定义周期
### v1.3
- 图标美化计划
- 增加图标美化

View File

@@ -10,6 +10,7 @@ import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import com.yovinchen.bookkeeping.R
import com.yovinchen.bookkeeping.model.BookkeepingRecord
import com.yovinchen.bookkeeping.model.Budget
import com.yovinchen.bookkeeping.model.Category
import com.yovinchen.bookkeeping.model.Converters
import com.yovinchen.bookkeeping.model.Member
@@ -20,8 +21,8 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Database(
entities = [BookkeepingRecord::class, Category::class, Member::class, Settings::class],
version = 5,
entities = [BookkeepingRecord::class, Category::class, Member::class, Settings::class, Budget::class],
version = 6,
exportSchema = false
)
@TypeConverters(Converters::class)
@@ -30,6 +31,7 @@ abstract class BookkeepingDatabase : RoomDatabase() {
abstract fun categoryDao(): CategoryDao
abstract fun memberDao(): MemberDao
abstract fun settingsDao(): SettingsDao
abstract fun budgetDao(): BudgetDao
companion object {
private const val TAG = "BookkeepingDatabase"
@@ -147,6 +149,30 @@ abstract class BookkeepingDatabase : RoomDatabase() {
""")
}
}
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
private var INSTANCE: BookkeepingDatabase? = null
@@ -158,7 +184,7 @@ abstract class BookkeepingDatabase : RoomDatabase() {
BookkeepingDatabase::class.java,
"bookkeeping_database"
)
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5)
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6)
.addCallback(object : Callback() {
override fun onCreate(db: SupportSQLiteDatabase) {
super.onCreate(db)

View 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>>
}

View File

@@ -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())
}
}

View File

@@ -1,6 +1,7 @@
package com.yovinchen.bookkeeping.data
import androidx.room.TypeConverter
import com.yovinchen.bookkeeping.model.BudgetType
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.util.*
@@ -29,4 +30,14 @@ class Converters {
fun toDate(timestamp: String?): Date? {
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) }
}
}

View File

@@ -29,4 +29,7 @@ interface SettingsDao {
@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)
}

View 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 // 是否接近预算限制
)

View File

@@ -10,5 +10,6 @@ data class Settings(
val themeMode: String = "FOLLOW_SYSTEM", // 主题模式FOLLOW_SYSTEM, LIGHT, DARK
val autoBackupEnabled: Boolean = false, // 自动备份开关
val autoBackupInterval: Int = 7, // 自动备份间隔(天)
val lastBackupTime: Long = 0L // 上次备份时间
val lastBackupTime: Long = 0L, // 上次备份时间
val encryptBackup: Boolean = true // 备份加密开关,默认开启
)

View File

@@ -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
)
}
}
}
}
}
}

View File

@@ -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)
}

View File

@@ -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("取消")
}
}
)
}

View File

@@ -47,6 +47,10 @@ sealed class Screen(
"设置",
iconResId = R.drawable.setting
)
object Budget : Screen(
"budget",
"预算管理"
)
object CategoryDetail : Screen(
"category_detail/{category}/{startMonth}/{endMonth}",
"分类详情"
@@ -148,9 +152,16 @@ fun MainNavigation(
composable(Screen.Settings.route) {
SettingsScreen(
currentTheme = currentTheme,
onThemeChange = onThemeChange
onThemeChange = onThemeChange,
onNavigateToBudget = {
navController.navigate(Screen.Budget.route)
}
)
}
composable(Screen.Budget.route) {
BudgetScreen()
}
composable(
route = Screen.CategoryDetail.route,

View File

@@ -38,12 +38,14 @@ import com.yovinchen.bookkeeping.model.MemberStat
import com.yovinchen.bookkeeping.ui.components.CategoryPieChart
import com.yovinchen.bookkeeping.ui.components.CategoryStatItem
import com.yovinchen.bookkeeping.ui.components.DateRangePicker
import com.yovinchen.bookkeeping.ui.components.DetailedAnalysisReport
import com.yovinchen.bookkeeping.ui.components.MonthlyYearlyReport
import com.yovinchen.bookkeeping.ui.components.TrendLineChart
import com.yovinchen.bookkeeping.viewmodel.AnalysisViewModel
import java.time.YearMonth
enum class ViewMode {
CATEGORY, MEMBER
CATEGORY, MEMBER, REPORT
}
@OptIn(ExperimentalMaterial3Api::class)
@@ -93,7 +95,13 @@ fun AnalysisScreen(
Button(
onClick = { showViewModeMenu = true }
) {
Text(if (currentViewMode == ViewMode.CATEGORY) "分类" else "成员")
Text(
when {
currentViewMode == ViewMode.CATEGORY -> "分类"
currentViewMode == ViewMode.MEMBER -> "成员"
else -> "报表"
}
)
Icon(Icons.Default.ArrowDropDown, "切换视图")
}
DropdownMenu(
@@ -114,6 +122,13 @@ fun AnalysisScreen(
showViewModeMenu = false
}
)
DropdownMenuItem(
text = { Text("报表") },
onClick = {
currentViewMode = ViewMode.REPORT
showViewModeMenu = false
}
)
}
}
@@ -159,41 +174,68 @@ fun AnalysisScreen(
}
}
else -> {
// 饼图视图
item {
CategoryPieChart(
categoryData = categoryStats.map { Pair(it.category, it.percentage.toFloat()) },
memberData = memberStats.map { Pair(it.member, it.percentage.toFloat()) },
currentViewMode = currentViewMode == ViewMode.MEMBER,
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
.padding(bottom = 16.dp),
onCategoryClick = { category ->
if (currentViewMode == ViewMode.CATEGORY) {
onNavigateToCategoryDetail(category, startMonth, endMonth)
} else {
onNavigateToMemberDetail(category, startMonth, endMonth, selectedAnalysisType)
if (currentViewMode == ViewMode.REPORT) {
// 报表视图
item {
MonthlyYearlyReport(
records = records,
period = if (startMonth == endMonth) "月度" else "年度",
startMonth = startMonth,
endMonth = endMonth,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
)
}
// 详细分析报表
item {
DetailedAnalysisReport(
records = records,
startMonth = startMonth,
endMonth = endMonth,
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp)
)
}
} else {
// 饼图视图
item {
CategoryPieChart(
categoryData = categoryStats.map { Pair(it.category, it.percentage.toFloat()) },
memberData = memberStats.map { Pair(it.member, it.percentage.toFloat()) },
currentViewMode = currentViewMode == ViewMode.MEMBER,
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
.padding(bottom = 16.dp),
onCategoryClick = { category ->
if (currentViewMode == ViewMode.CATEGORY) {
onNavigateToCategoryDetail(category, startMonth, endMonth)
} else {
onNavigateToMemberDetail(category, startMonth, endMonth, selectedAnalysisType)
}
}
}
)
}
)
}
// 统计列表
items(if (currentViewMode == ViewMode.CATEGORY) categoryStats else memberStats) { stat ->
val category = if (stat is CategoryStat) stat.category else null
val member = if (stat is MemberStat) stat.member else null
// 统计列表
items(if (currentViewMode == ViewMode.CATEGORY) categoryStats else memberStats) { stat ->
val category = if (stat is CategoryStat) stat.category else null
val member = if (stat is MemberStat) stat.member else null
CategoryStatItem(
stat = stat,
onClick = {
if (currentViewMode == ViewMode.CATEGORY && category != null) {
onNavigateToCategoryDetail(category, startMonth, endMonth)
} else if (currentViewMode == ViewMode.MEMBER && member != null) {
onNavigateToMemberDetail(member, startMonth, endMonth, selectedAnalysisType)
CategoryStatItem(
stat = stat,
onClick = {
if (currentViewMode == ViewMode.CATEGORY && category != null) {
onNavigateToCategoryDetail(category, startMonth, endMonth)
} else if (currentViewMode == ViewMode.MEMBER && member != null) {
onNavigateToMemberDetail(member, startMonth, endMonth, selectedAnalysisType)
}
}
}
)
)
}
}
}
}

View File

@@ -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)
}

View File

@@ -19,6 +19,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.yovinchen.bookkeeping.model.Settings
import com.yovinchen.bookkeeping.model.ThemeMode
import com.yovinchen.bookkeeping.ui.components.*
import com.yovinchen.bookkeeping.ui.dialog.*
@@ -30,6 +31,7 @@ import com.yovinchen.bookkeeping.viewmodel.*
fun SettingsScreen(
currentTheme: ThemeMode,
onThemeChange: (ThemeMode) -> Unit,
onNavigateToBudget: () -> Unit = {},
viewModel: SettingsViewModel = viewModel(),
memberViewModel: MemberViewModel = viewModel()
) {
@@ -43,6 +45,7 @@ fun SettingsScreen(
val selectedType by viewModel.selectedCategoryType.collectAsState()
val members by memberViewModel.allMembers.collectAsState(initial = emptyList())
val monthStartDay by viewModel.monthStartDay.collectAsState()
val settings by viewModel.settings.collectAsState()
val context = LocalContext.current
var showMonthStartDayDialog by remember { mutableStateOf(false) }
@@ -73,6 +76,17 @@ fun SettingsScreen(
modifier = Modifier.clickable { showBackupDialog = true }
)
HorizontalDivider()
// 预算管理设置项
ListItem(
headlineContent = { Text("预算管理") },
supportingContent = { Text("设置和管理预算") },
modifier = Modifier.clickable {
onNavigateToBudget()
}
)
HorizontalDivider()
// 主题设置项
@@ -277,6 +291,29 @@ fun SettingsScreen(
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 = {

View File

@@ -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)
}
}

View File

@@ -107,11 +107,16 @@ object FilePickerUtil {
private fun isValidFileType(fileName: String, mimeType: String?): Boolean {
val fileExtension = fileName.lowercase()
return fileExtension.endsWith(".csv") ||
fileExtension.endsWith(".csv.enc") ||
fileExtension.endsWith(".xlsx") ||
fileExtension.endsWith(".xlsx.enc") ||
fileExtension.endsWith(".xls") ||
fileExtension.endsWith(".xls.enc") ||
fileExtension.endsWith(".enc") ||
mimeType == "text/csv" ||
mimeType == "application/vnd.ms-excel" ||
mimeType == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
mimeType == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" ||
mimeType == "application/octet-stream" // 加密文件可能被识别为二进制流
}
/**

View File

@@ -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()
}
}
}

View File

@@ -14,6 +14,7 @@ import com.yovinchen.bookkeeping.model.BookkeepingRecord
import com.yovinchen.bookkeeping.model.Category
import com.yovinchen.bookkeeping.model.Settings
import com.yovinchen.bookkeeping.model.TransactionType
import com.yovinchen.bookkeeping.utils.EncryptionUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
@@ -30,6 +31,7 @@ import org.apache.poi.xssf.usermodel.XSSFWorkbook
import java.io.File
import java.io.FileReader
import java.io.FileWriter
import java.io.StringWriter
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@@ -157,15 +159,24 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
fun exportToCSV(context: Context, customDir: File? = null) {
viewModelScope.launch(Dispatchers.IO) {
try {
val currentSettings = settings.value ?: Settings()
val timestamp =
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(
Environment.DIRECTORY_DOWNLOADS
)
val file = File(downloadsDir, fileName)
CSVWriter(FileWriter(file)).use { writer ->
// 先创建CSV内容到字符串
val csvContent = StringWriter().use { stringWriter ->
val writer = CSVWriter(stringWriter)
// 写入头部
writer.writeNext(arrayOf("日期", "类型", "金额", "类别", "备注", "成员"))
@@ -189,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) {
Toast.makeText(context, "CSV导出成功: ${file.absolutePath}", Toast.LENGTH_LONG)
.show()
val message = if (shouldEncrypt) {
"CSV导出成功已加密: ${file.absolutePath}"
} else {
"CSV导出成功: ${file.absolutePath}"
}
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
}
} catch (e: Exception) {
e.printStackTrace()
@@ -207,6 +232,9 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
fun exportToExcel(context: Context) {
viewModelScope.launch(Dispatchers.IO) {
try {
val currentSettings = settings.value ?: Settings()
val shouldEncrypt = currentSettings.encryptBackup
val workbook = XSSFWorkbook()
val sheet = workbook.createSheet("账目记录")
@@ -239,18 +267,39 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
val timestamp =
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 =
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
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()
withContext(Dispatchers.Main) {
Toast.makeText(
context, "Excel导出成功: ${file.absolutePath}", Toast.LENGTH_LONG
).show()
val message = if (shouldEncrypt) {
"Excel导出成功(已加密): ${file.absolutePath}"
} else {
"Excel导出成功: ${file.absolutePath}"
}
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
}
} catch (e: Exception) {
e.printStackTrace()
@@ -265,10 +314,30 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
viewModelScope.launch(Dispatchers.IO) {
try {
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) -> {
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) -> {
restoreFromExcel(backupFile)
}
@@ -287,7 +356,11 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
} catch (e: Exception) {
e.printStackTrace()
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()
}
}
}
@@ -343,4 +416,10 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
private suspend fun findMemberIdByName(name: String): Int? {
return memberDao.getAllMembers().first().find { member -> member.name == name }?.id
}
fun updateSettings(settings: Settings) {
viewModelScope.launch {
settingsRepository.updateSettings(settings)
}
}
}