Merge branch 'feature/encryption-budget' into develop
This commit is contained in:
commit
7933452ab5
8
.claude/settings.local.json
Normal file
8
.claude/settings.local.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(./gradlew:*)"
|
||||||
|
],
|
||||||
|
"deny": []
|
||||||
|
}
|
||||||
|
}
|
6
.idea/AndroidProjectSystem.xml
Normal file
6
.idea/AndroidProjectSystem.xml
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>
|
@ -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 中异步处理
|
||||||
|
- 大量数据导入时使用批量插入优化性能
|
@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
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 // 是否接近预算限制
|
||||||
|
)
|
@ -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 // 备份加密开关,默认开启
|
||||||
|
)
|
@ -4,15 +4,22 @@ 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.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.text.style.TextAlign
|
||||||
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 com.yovinchen.bookkeeping.model.Settings
|
||||||
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.*
|
||||||
@ -36,7 +43,11 @@ 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.fillMaxSize()) {
|
||||||
// 成员管理设置项
|
// 成员管理设置项
|
||||||
@ -81,6 +92,15 @@ fun SettingsScreen(
|
|||||||
},
|
},
|
||||||
modifier = Modifier.clickable { showThemeDialog = true }
|
modifier = Modifier.clickable { showThemeDialog = true }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
HorizontalDivider()
|
||||||
|
|
||||||
|
// 月度开始日期设置项
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text("月度开始日期") },
|
||||||
|
supportingContent = { Text("每月从${monthStartDay}号开始计算") },
|
||||||
|
modifier = Modifier.clickable { showMonthStartDayDialog = true }
|
||||||
|
)
|
||||||
|
|
||||||
if (showThemeDialog) {
|
if (showThemeDialog) {
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
@ -144,6 +164,76 @@ 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()
|
||||||
|
) {
|
||||||
|
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 +279,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 = {
|
||||||
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
@ -107,11 +107,16 @@ object FilePickerUtil {
|
|||||||
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" // 加密文件可能被识别为二进制流
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新记录数据
|
// 更新记录数据
|
||||||
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user