diff --git a/.claude/settings.local.json b/.claude/settings.local.json
new file mode 100644
index 0000000..f50d242
--- /dev/null
+++ b/.claude/settings.local.json
@@ -0,0 +1,8 @@
+{
+ "permissions": {
+ "allow": [
+ "Bash(./gradlew:*)"
+ ],
+ "deny": []
+ }
+}
\ No newline at end of file
diff --git a/.idea/AndroidProjectSystem.xml b/.idea/AndroidProjectSystem.xml
new file mode 100644
index 0000000..4a53bee
--- /dev/null
+++ b/.idea/AndroidProjectSystem.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
index cde3e19..763e424 100644
--- a/.idea/inspectionProfiles/Project_Default.xml
+++ b/.idea/inspectionProfiles/Project_Default.xml
@@ -49,6 +49,9 @@
+
+
+
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..f4d2304
--- /dev/null
+++ b/CLAUDE.md
@@ -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/*: 紧急修复
+
+### 提交规范
+使用约定式提交: `: `
+- feat: 新功能
+- fix: 修复 bug
+- docs: 文档更新
+- style: 代码格式
+- refactor: 代码重构
+- perf: 性能优化
+- test: 测试相关
+- build: 构建相关
+
+### 重要功能模块
+
+#### 月度记账开始日期 (Settings 中的 monthStartDay)
+- 支持自定义每月记账的开始日期 (1-31)
+- 影响月度统计和分析的日期范围计算
+- 默认值为 1 (每月 1 日开始)
+
+#### 加密功能 (EncryptionUtils)
+- 预留的备份加密功能接口
+- 使用 AES 加密算法
+- 目前尚未集成到备份功能中
+
+#### 默认数据初始化
+- 首次启动时自动创建默认分类
+- 包含常用的收入和支出分类
+- 可通过 insertDefaultCategories() 查看默认分类列表
+
+### 性能考虑
+- 使用 Room 的 Flow 实现数据的响应式更新
+- 图表数据计算在 ViewModel 中异步处理
+- 大量数据导入时使用批量插入优化性能
\ No newline at end of file
diff --git a/app/src/main/java/com/yovinchen/bookkeeping/data/BookkeepingDatabase.kt b/app/src/main/java/com/yovinchen/bookkeeping/data/BookkeepingDatabase.kt
index 3fd3ccc..67093cb 100644
--- a/app/src/main/java/com/yovinchen/bookkeeping/data/BookkeepingDatabase.kt
+++ b/app/src/main/java/com/yovinchen/bookkeeping/data/BookkeepingDatabase.kt
@@ -10,17 +10,19 @@ import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import com.yovinchen.bookkeeping.R
import com.yovinchen.bookkeeping.model.BookkeepingRecord
+import com.yovinchen.bookkeeping.model.Budget
import com.yovinchen.bookkeeping.model.Category
import com.yovinchen.bookkeeping.model.Converters
import com.yovinchen.bookkeeping.model.Member
+import com.yovinchen.bookkeeping.model.Settings
import com.yovinchen.bookkeeping.model.TransactionType
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Database(
- entities = [BookkeepingRecord::class, Category::class, Member::class],
- version = 4,
+ entities = [BookkeepingRecord::class, Category::class, Member::class, Settings::class, Budget::class],
+ version = 6,
exportSchema = false
)
@TypeConverters(Converters::class)
@@ -28,6 +30,8 @@ abstract class BookkeepingDatabase : RoomDatabase() {
abstract fun bookkeepingDao(): BookkeepingDao
abstract fun categoryDao(): CategoryDao
abstract fun memberDao(): MemberDao
+ abstract fun settingsDao(): SettingsDao
+ abstract fun budgetDao(): BudgetDao
companion object {
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
private var INSTANCE: BookkeepingDatabase? = null
@@ -134,7 +184,7 @@ abstract class BookkeepingDatabase : RoomDatabase() {
BookkeepingDatabase::class.java,
"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() {
override fun onCreate(db: SupportSQLiteDatabase) {
super.onCreate(db)
@@ -143,6 +193,11 @@ abstract class BookkeepingDatabase : RoomDatabase() {
try {
val database = getDatabase(context)
+ // 初始化默认设置
+ database.settingsDao().apply {
+ updateSettings(Settings())
+ }
+
// 初始化默认成员
database.memberDao().apply {
if (getMemberCount() == 0) {
diff --git a/app/src/main/java/com/yovinchen/bookkeeping/data/BudgetDao.kt b/app/src/main/java/com/yovinchen/bookkeeping/data/BudgetDao.kt
new file mode 100644
index 0000000..6e80750
--- /dev/null
+++ b/app/src/main/java/com/yovinchen/bookkeeping/data/BudgetDao.kt
@@ -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>
+
+ /**
+ * 获取所有预算(包括禁用的)
+ */
+ @Query("SELECT * FROM budgets ORDER BY type, amount DESC")
+ fun getAllBudgets(): Flow>
+
+ /**
+ * 根据类型获取预算
+ */
+ @Query("SELECT * FROM budgets WHERE type = :type AND isEnabled = 1")
+ fun getBudgetsByType(type: BudgetType): Flow>
+
+ /**
+ * 获取总预算
+ */
+ @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>
+
+ /**
+ * 更新预算的启用状态
+ */
+ @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>
+
+ /**
+ * 获取指定日期范围内的成员预算
+ */
+ @Query("SELECT * FROM budgets WHERE type = 'MEMBER' AND isEnabled = 1 AND :date BETWEEN startDate AND endDate")
+ fun getMemberBudgetsForDate(date: Date): Flow>
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/yovinchen/bookkeeping/data/BudgetRepository.kt b/app/src/main/java/com/yovinchen/bookkeeping/data/BudgetRepository.kt
new file mode 100644
index 0000000..8918790
--- /dev/null
+++ b/app/src/main/java/com/yovinchen/bookkeeping/data/BudgetRepository.kt
@@ -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> {
+ return budgetDao.getAllBudgets()
+ }
+
+ /**
+ * 获取所有启用的预算
+ */
+ fun getAllEnabledBudgets(): Flow> {
+ return budgetDao.getAllEnabledBudgets()
+ }
+
+ /**
+ * 获取当前有效的预算状态
+ */
+ fun getActiveBudgetStatuses(date: Date = Date()): Flow> {
+ 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,
+ members: List,
+ 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> {
+ 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> {
+ 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 {
+ 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())
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/yovinchen/bookkeeping/data/Converters.kt b/app/src/main/java/com/yovinchen/bookkeeping/data/Converters.kt
index b23692a..a9b249b 100644
--- a/app/src/main/java/com/yovinchen/bookkeeping/data/Converters.kt
+++ b/app/src/main/java/com/yovinchen/bookkeeping/data/Converters.kt
@@ -1,6 +1,7 @@
package com.yovinchen.bookkeeping.data
import androidx.room.TypeConverter
+import com.yovinchen.bookkeeping.model.BudgetType
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.util.*
@@ -29,4 +30,14 @@ class Converters {
fun toDate(timestamp: String?): Date? {
return timestamp?.let { Date(it.toLong()) }
}
+
+ @TypeConverter
+ fun fromBudgetType(budgetType: BudgetType?): String? {
+ return budgetType?.name
+ }
+
+ @TypeConverter
+ fun toBudgetType(budgetType: String?): BudgetType? {
+ return budgetType?.let { BudgetType.valueOf(it) }
+ }
}
diff --git a/app/src/main/java/com/yovinchen/bookkeeping/data/SettingsDao.kt b/app/src/main/java/com/yovinchen/bookkeeping/data/SettingsDao.kt
new file mode 100644
index 0000000..0517085
--- /dev/null
+++ b/app/src/main/java/com/yovinchen/bookkeeping/data/SettingsDao.kt
@@ -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
+
+ @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)
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/yovinchen/bookkeeping/data/SettingsRepository.kt b/app/src/main/java/com/yovinchen/bookkeeping/data/SettingsRepository.kt
new file mode 100644
index 0000000..481bec6
--- /dev/null
+++ b/app/src/main/java/com/yovinchen/bookkeeping/data/SettingsRepository.kt
@@ -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 = 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())
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/yovinchen/bookkeeping/model/Budget.kt b/app/src/main/java/com/yovinchen/bookkeeping/model/Budget.kt
new file mode 100644
index 0000000..7616bce
--- /dev/null
+++ b/app/src/main/java/com/yovinchen/bookkeeping/model/Budget.kt
@@ -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 // 是否接近预算限制
+)
\ No newline at end of file
diff --git a/app/src/main/java/com/yovinchen/bookkeeping/model/Settings.kt b/app/src/main/java/com/yovinchen/bookkeeping/model/Settings.kt
new file mode 100644
index 0000000..daf153b
--- /dev/null
+++ b/app/src/main/java/com/yovinchen/bookkeeping/model/Settings.kt
@@ -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 // 备份加密开关,默认开启
+)
\ No newline at end of file
diff --git a/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/SettingsScreen.kt b/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/SettingsScreen.kt
index a015d78..ea59a9f 100644
--- a/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/SettingsScreen.kt
+++ b/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/SettingsScreen.kt
@@ -4,15 +4,22 @@ import android.content.Context
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.appcompat.app.AppCompatActivity
+import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.grid.GridCells
+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.foundation.lazy.grid.items
+import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
+import com.yovinchen.bookkeeping.model.Settings
import com.yovinchen.bookkeeping.model.ThemeMode
import com.yovinchen.bookkeeping.ui.components.*
import com.yovinchen.bookkeeping.ui.dialog.*
@@ -36,7 +43,11 @@ fun SettingsScreen(
val categories by viewModel.categories.collectAsState()
val selectedType by viewModel.selectedCategoryType.collectAsState()
val members by memberViewModel.allMembers.collectAsState(initial = emptyList())
+ val monthStartDay by viewModel.monthStartDay.collectAsState()
+ val settings by viewModel.settings.collectAsState()
val context = LocalContext.current
+
+ var showMonthStartDayDialog by remember { mutableStateOf(false) }
Column(modifier = Modifier.fillMaxSize()) {
// 成员管理设置项
@@ -81,6 +92,15 @@ fun SettingsScreen(
},
modifier = Modifier.clickable { showThemeDialog = true }
)
+
+ HorizontalDivider()
+
+ // 月度开始日期设置项
+ ListItem(
+ headlineContent = { Text("月度开始日期") },
+ supportingContent = { Text("每月从${monthStartDay}号开始计算") },
+ modifier = Modifier.clickable { showMonthStartDayDialog = true }
+ )
if (showThemeDialog) {
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) {
@@ -189,6 +279,29 @@ fun SettingsScreen(
style = MaterialTheme.typography.bodySmall
)
}
+
+ HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
+
+ // 备份加密开关
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text("备份加密", modifier = Modifier.weight(1f))
+ Switch(
+ checked = settings?.encryptBackup ?: true,
+ onCheckedChange = { enabled ->
+ viewModel.updateSettings(
+ settings?.copy(encryptBackup = enabled) ?: Settings(encryptBackup = enabled)
+ )
+ }
+ )
+ }
+ Text(
+ "开启后,导出的备份文件将被加密保护",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
}
},
confirmButton = {
diff --git a/app/src/main/java/com/yovinchen/bookkeeping/utils/DateUtils.kt b/app/src/main/java/com/yovinchen/bookkeeping/utils/DateUtils.kt
new file mode 100644
index 0000000..a525ad6
--- /dev/null
+++ b/app/src/main/java/com/yovinchen/bookkeeping/utils/DateUtils.kt
@@ -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
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/yovinchen/bookkeeping/utils/EncryptionUtils.kt b/app/src/main/java/com/yovinchen/bookkeeping/utils/EncryptionUtils.kt
new file mode 100644
index 0000000..faf399e
--- /dev/null
+++ b/app/src/main/java/com/yovinchen/bookkeeping/utils/EncryptionUtils.kt
@@ -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)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/yovinchen/bookkeeping/utils/FilePickerUtil.kt b/app/src/main/java/com/yovinchen/bookkeeping/utils/FilePickerUtil.kt
index 59493e6..4d03ce2 100644
--- a/app/src/main/java/com/yovinchen/bookkeeping/utils/FilePickerUtil.kt
+++ b/app/src/main/java/com/yovinchen/bookkeeping/utils/FilePickerUtil.kt
@@ -107,11 +107,16 @@ object FilePickerUtil {
private fun isValidFileType(fileName: String, mimeType: String?): Boolean {
val fileExtension = fileName.lowercase()
return fileExtension.endsWith(".csv") ||
+ fileExtension.endsWith(".csv.enc") ||
fileExtension.endsWith(".xlsx") ||
+ fileExtension.endsWith(".xlsx.enc") ||
fileExtension.endsWith(".xls") ||
+ fileExtension.endsWith(".xls.enc") ||
+ fileExtension.endsWith(".enc") ||
mimeType == "text/csv" ||
mimeType == "application/vnd.ms-excel" ||
- mimeType == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
+ mimeType == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" ||
+ mimeType == "application/octet-stream" // 加密文件可能被识别为二进制流
}
/**
diff --git a/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/AnalysisViewModel.kt b/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/AnalysisViewModel.kt
index eaba8e9..e035ced 100644
--- a/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/AnalysisViewModel.kt
+++ b/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/AnalysisViewModel.kt
@@ -4,21 +4,22 @@ import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.yovinchen.bookkeeping.data.BookkeepingDatabase
+import com.yovinchen.bookkeeping.data.SettingsRepository
import com.yovinchen.bookkeeping.model.AnalysisType
import com.yovinchen.bookkeeping.model.BookkeepingRecord
import com.yovinchen.bookkeeping.model.CategoryStat
import com.yovinchen.bookkeeping.model.MemberStat
import com.yovinchen.bookkeeping.model.TransactionType
+import com.yovinchen.bookkeeping.utils.DateUtils
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
-import java.time.LocalDateTime
import java.time.YearMonth
-import java.time.ZoneId
import java.util.*
class AnalysisViewModel(application: Application) : AndroidViewModel(application) {
private val recordDao = BookkeepingDatabase.getDatabase(application).bookkeepingDao()
private val memberDao = BookkeepingDatabase.getDatabase(application).memberDao()
+ private val settingsRepository = SettingsRepository(BookkeepingDatabase.getDatabase(application).settingsDao())
private val _startMonth = MutableStateFlow(YearMonth.now())
val startMonth: StateFlow = _startMonth.asStateFlow()
@@ -38,16 +39,41 @@ class AnalysisViewModel(application: Application) : AndroidViewModel(application
private val _records = MutableStateFlow>(emptyList())
val records: StateFlow> = _records.asStateFlow()
+ // 存储月度开始日期设置
+ private val _monthStartDay = MutableStateFlow(1)
+ val monthStartDay: StateFlow = _monthStartDay.asStateFlow()
+
init {
+ // 订阅设置变化,获取月度开始日期
viewModelScope.launch {
- combine(startMonth, endMonth, selectedAnalysisType) { start, end, type ->
- Triple(start, end, type)
- }.collect { (start, end, type) ->
- updateStats(start, end, type)
+ settingsRepository.getSettings().collect { settings ->
+ _monthStartDay.value = settings?.monthStartDay ?: 1
+ }
+ }
+
+ // 当月度开始日期、起始月份、结束月份或分析类型变化时,更新统计数据
+ 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) {
_startMonth.value = month
}
@@ -60,16 +86,16 @@ class AnalysisViewModel(application: Application) : AndroidViewModel(application
_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 monthRecords = records.filter {
- val recordDate = Date(it.date.time)
- val localDateTime = LocalDateTime.ofInstant(recordDate.toInstant(), ZoneId.systemDefault())
- val yearMonth = YearMonth.from(localDateTime)
- yearMonth.isAfter(startMonth.minusMonths(1)) &&
- yearMonth.isBefore(endMonth.plusMonths(1))
+ // 使用 DateUtils 过滤日期范围内的记录
+ val monthRecords = records.filter { record ->
+ val recordDate = Date(record.date.time)
+ val accountingMonth = DateUtils.getAccountingMonth(recordDate, monthStartDay)
+
+ // 检查记账月份是否在选定的范围内
+ accountingMonth >= startMonth && accountingMonth <= endMonth
}
// 更新记录数据
diff --git a/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/HomeViewModel.kt b/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/HomeViewModel.kt
index daebbf3..53e85bb 100644
--- a/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/HomeViewModel.kt
+++ b/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/HomeViewModel.kt
@@ -4,10 +4,12 @@ import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.yovinchen.bookkeeping.data.BookkeepingDatabase
+import com.yovinchen.bookkeeping.data.SettingsRepository
import com.yovinchen.bookkeeping.model.BookkeepingRecord
import com.yovinchen.bookkeeping.model.Category
import com.yovinchen.bookkeeping.model.Member
import com.yovinchen.bookkeeping.model.TransactionType
+import com.yovinchen.bookkeeping.utils.DateUtils
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
@@ -18,9 +20,26 @@ import java.util.*
@OptIn(ExperimentalCoroutinesApi::class)
class HomeViewModel(application: Application) : AndroidViewModel(application) {
- private val bookkeepingDao = BookkeepingDatabase.getDatabase(application).bookkeepingDao()
- private val memberDao = BookkeepingDatabase.getDatabase(application).memberDao()
- private val categoryDao = BookkeepingDatabase.getDatabase(application).categoryDao()
+ private val database = BookkeepingDatabase.getDatabase(application)
+ private val bookkeepingDao = database.bookkeepingDao()
+ private val memberDao = database.memberDao()
+ private val categoryDao = database.categoryDao()
+ private val settingsRepository = SettingsRepository(database.settingsDao())
+
+ // 设置相关
+ private val _monthStartDay = MutableStateFlow(1)
+ val monthStartDay: StateFlow = _monthStartDay.asStateFlow()
+
+ init {
+ viewModelScope.launch {
+ settingsRepository.ensureSettingsExist()
+ settingsRepository.getSettings().collect { settings ->
+ settings?.let {
+ _monthStartDay.value = it.monthStartDay
+ }
+ }
+ }
+ }
private val _selectedRecordType = MutableStateFlow(null)
val selectedRecordType: StateFlow = _selectedRecordType.asStateFlow()
@@ -56,17 +75,13 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
allRecords,
_selectedRecordType,
_selectedMonth,
- _selectedMember
- ) { records, selectedType, selectedMonth, selectedMember ->
+ _selectedMember,
+ _monthStartDay
+ ) { records, selectedType, selectedMonth, selectedMember, monthStartDay ->
records
.filter { record ->
- val recordDate = record.date.toInstant()
- .atZone(ZoneId.systemDefault())
- .toLocalDate()
- val recordYearMonth = YearMonth.from(recordDate)
-
val typeMatches = selectedType?.let { record.type == it } ?: true
- val monthMatches = recordYearMonth == selectedMonth
+ val monthMatches = DateUtils.isInAccountingMonth(record.date, selectedMonth, monthStartDay)
val memberMatches = selectedMember?.let { record.memberId == it.id } ?: true
monthMatches && memberMatches && typeMatches
@@ -90,16 +105,12 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
val totalIncome = combine(
allRecords,
_selectedMonth,
- _selectedMember
- ) { records, selectedMonth, selectedMember ->
+ _selectedMember,
+ _monthStartDay
+ ) { records, selectedMonth, selectedMember, monthStartDay ->
records
.filter { record ->
- val recordDate = record.date.toInstant()
- .atZone(ZoneId.systemDefault())
- .toLocalDate()
- val recordYearMonth = YearMonth.from(recordDate)
-
- val monthMatches = recordYearMonth == selectedMonth
+ val monthMatches = DateUtils.isInAccountingMonth(record.date, selectedMonth, monthStartDay)
val memberMatches = selectedMember?.let { record.memberId == it.id } ?: true
val typeMatches = record.type == TransactionType.INCOME
@@ -115,16 +126,12 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
val totalExpense = combine(
allRecords,
_selectedMonth,
- _selectedMember
- ) { records, selectedMonth, selectedMember ->
+ _selectedMember,
+ _monthStartDay
+ ) { records, selectedMonth, selectedMember, monthStartDay ->
records
.filter { record ->
- val recordDate = record.date.toInstant()
- .atZone(ZoneId.systemDefault())
- .toLocalDate()
- val recordYearMonth = YearMonth.from(recordDate)
-
- val monthMatches = recordYearMonth == selectedMonth
+ val monthMatches = DateUtils.isInAccountingMonth(record.date, selectedMonth, monthStartDay)
val memberMatches = selectedMember?.let { record.memberId == it.id } ?: true
val typeMatches = record.type == TransactionType.EXPENSE
diff --git a/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/SettingsViewModel.kt b/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/SettingsViewModel.kt
index 840a7c4..f9709b3 100644
--- a/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/SettingsViewModel.kt
+++ b/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/SettingsViewModel.kt
@@ -9,9 +9,12 @@ import androidx.lifecycle.viewModelScope
import com.opencsv.CSVReader
import com.opencsv.CSVWriter
import com.yovinchen.bookkeeping.data.BookkeepingDatabase
+import com.yovinchen.bookkeeping.data.SettingsRepository
import com.yovinchen.bookkeeping.model.BookkeepingRecord
import com.yovinchen.bookkeeping.model.Category
+import com.yovinchen.bookkeeping.model.Settings
import com.yovinchen.bookkeeping.model.TransactionType
+import com.yovinchen.bookkeeping.utils.EncryptionUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
@@ -28,6 +31,7 @@ import org.apache.poi.xssf.usermodel.XSSFWorkbook
import java.io.File
import java.io.FileReader
import java.io.FileWriter
+import java.io.StringWriter
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@@ -38,8 +42,36 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
private val database = BookkeepingDatabase.getDatabase(application)
private val dao = database.bookkeepingDao()
private val memberDao = database.memberDao()
+ private val settingsRepository = SettingsRepository(database.settingsDao())
+
+ // 设置相关的状态
+ val settings: StateFlow = settingsRepository.getSettings()
+ .stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.WhileSubscribed(5000),
+ initialValue = null
+ )
+
private val _isAutoBackupEnabled = MutableStateFlow(false)
val isAutoBackupEnabled: StateFlow = _isAutoBackupEnabled.asStateFlow()
+
+ private val _monthStartDay = MutableStateFlow(1)
+ val monthStartDay: StateFlow = _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)
val selectedCategoryType: StateFlow = _selectedCategoryType.asStateFlow()
@@ -85,11 +117,19 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
fun setAutoBackup(enabled: Boolean) {
viewModelScope.launch {
_isAutoBackupEnabled.value = enabled
+ settingsRepository.updateAutoBackupEnabled(enabled)
if (enabled) {
schedulePeriodicBackup()
}
}
}
+
+ fun setMonthStartDay(day: Int) {
+ viewModelScope.launch {
+ _monthStartDay.value = day
+ settingsRepository.updateMonthStartDay(day)
+ }
+ }
private fun schedulePeriodicBackup() {
viewModelScope.launch(Dispatchers.IO) {
@@ -119,15 +159,24 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
fun exportToCSV(context: Context, customDir: File? = null) {
viewModelScope.launch(Dispatchers.IO) {
try {
+ val currentSettings = settings.value ?: Settings()
val timestamp =
SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
- val fileName = "bookkeeping_backup_$timestamp.csv"
+ val shouldEncrypt = currentSettings.encryptBackup
+ val fileName = if (shouldEncrypt) {
+ "bookkeeping_backup_$timestamp.csv.enc"
+ } else {
+ "bookkeeping_backup_$timestamp.csv"
+ }
val downloadsDir = customDir ?: Environment.getExternalStoragePublicDirectory(
Environment.DIRECTORY_DOWNLOADS
)
val file = File(downloadsDir, fileName)
- CSVWriter(FileWriter(file)).use { writer ->
+ // 先创建CSV内容到字符串
+ val csvContent = StringWriter().use { stringWriter ->
+ val writer = CSVWriter(stringWriter)
+
// 写入头部
writer.writeNext(arrayOf("日期", "类型", "金额", "类别", "备注", "成员"))
@@ -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) {
- Toast.makeText(context, "CSV导出成功: ${file.absolutePath}", Toast.LENGTH_LONG)
- .show()
+ val message = if (shouldEncrypt) {
+ "CSV导出成功(已加密): ${file.absolutePath}"
+ } else {
+ "CSV导出成功: ${file.absolutePath}"
+ }
+ Toast.makeText(context, message, Toast.LENGTH_LONG).show()
}
} catch (e: Exception) {
e.printStackTrace()
@@ -169,6 +232,9 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
fun exportToExcel(context: Context) {
viewModelScope.launch(Dispatchers.IO) {
try {
+ val currentSettings = settings.value ?: Settings()
+ val shouldEncrypt = currentSettings.encryptBackup
+
val workbook = XSSFWorkbook()
val sheet = workbook.createSheet("账目记录")
@@ -201,18 +267,39 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
val timestamp =
SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
- val fileName = "bookkeeping_backup_$timestamp.xlsx"
+ val fileName = if (shouldEncrypt) {
+ "bookkeeping_backup_$timestamp.xlsx.enc"
+ } else {
+ "bookkeeping_backup_$timestamp.xlsx"
+ }
val downloadsDir =
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
val file = File(downloadsDir, fileName)
- workbook.write(file.outputStream())
+ if (shouldEncrypt) {
+ // 将 workbook 写入字节数组
+ val byteArrayOutputStream = java.io.ByteArrayOutputStream()
+ workbook.write(byteArrayOutputStream)
+ val excelBytes = byteArrayOutputStream.toByteArray()
+
+ // 加密字节数组
+ val encryptedBytes = EncryptionUtils.encryptBytes(excelBytes)
+
+ // 写入加密文件
+ file.writeBytes(encryptedBytes)
+ } else {
+ workbook.write(file.outputStream())
+ }
+
workbook.close()
withContext(Dispatchers.Main) {
- Toast.makeText(
- context, "Excel导出成功: ${file.absolutePath}", Toast.LENGTH_LONG
- ).show()
+ val message = if (shouldEncrypt) {
+ "Excel导出成功(已加密): ${file.absolutePath}"
+ } else {
+ "Excel导出成功: ${file.absolutePath}"
+ }
+ Toast.makeText(context, message, Toast.LENGTH_LONG).show()
}
} catch (e: Exception) {
e.printStackTrace()
@@ -227,10 +314,30 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
viewModelScope.launch(Dispatchers.IO) {
try {
when {
+ backupFile.name.endsWith(".csv.enc", ignoreCase = true) -> {
+ // 解密CSV文件
+ val encryptedContent = backupFile.readText()
+ val decryptedContent = EncryptionUtils.decrypt(encryptedContent)
+ val tempFile = File(context.cacheDir, "temp_decrypted.csv")
+ tempFile.writeText(decryptedContent)
+ restoreFromCSV(tempFile)
+ tempFile.delete()
+ }
+
backupFile.name.endsWith(".csv", ignoreCase = true) -> {
restoreFromCSV(backupFile)
}
+ backupFile.name.endsWith(".xlsx.enc", ignoreCase = true) -> {
+ // 解密Excel文件
+ val encryptedBytes = backupFile.readBytes()
+ val decryptedBytes = EncryptionUtils.decryptBytes(encryptedBytes)
+ val tempFile = File(context.cacheDir, "temp_decrypted.xlsx")
+ tempFile.writeBytes(decryptedBytes)
+ restoreFromExcel(tempFile)
+ tempFile.delete()
+ }
+
backupFile.name.endsWith(".xlsx", ignoreCase = true) -> {
restoreFromExcel(backupFile)
}
@@ -249,7 +356,11 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
} catch (e: Exception) {
e.printStackTrace()
withContext(Dispatchers.Main) {
- Toast.makeText(context, "数据恢复失败: ${e.message}", Toast.LENGTH_LONG).show()
+ val errorMessage = when {
+ e.message?.contains("decrypt") == true -> "解密失败,请确认文件未损坏"
+ else -> "数据恢复失败: ${e.message}"
+ }
+ Toast.makeText(context, errorMessage, Toast.LENGTH_LONG).show()
}
}
}
@@ -305,4 +416,10 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
private suspend fun findMemberIdByName(name: String): Int? {
return memberDao.getAllMembers().first().find { member -> member.name == name }?.id
}
+
+ fun updateSettings(settings: Settings) {
+ viewModelScope.launch {
+ settingsRepository.updateSettings(settings)
+ }
+ }
}