Merge branch 'feature/encryption-budget' into develop

This commit is contained in:
yovinchen 2025-07-19 22:31:57 +08:00
commit 7933452ab5
19 changed files with 1245 additions and 55 deletions

View File

@ -0,0 +1,8 @@
{
"permissions": {
"allow": [
"Bash(./gradlew:*)"
],
"deny": []
}
}

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="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
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

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

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

View File

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

View File

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

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

@ -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 // 备份加密开关,默认开启
)

View File

@ -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 = {

View File

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

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 { 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" // 加密文件可能被识别为二进制流
} }
/** /**

View File

@ -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
} }
// 更新记录数据 // 更新记录数据

View File

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

View File

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