From f4f03ce0a4067680b32b1fdf3a3d5a165b26fed2 Mon Sep 17 00:00:00 2001 From: yovinchen Date: Mon, 14 Jul 2025 15:17:47 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E6=9C=88?= =?UTF-8?q?=E5=BA=A6=E8=AE=B0=E8=B4=A6=E5=BC=80=E5=A7=8B=E6=97=A5=E6=9C=9F?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 Settings 实体和 DAO 来持久化存储设置 - 创建 SettingsRepository 管理设置数据 - 添加数据库迁移从版本 4 到版本 5 - 在设置界面添加月度开始日期选择器(1-28号) - 创建 DateUtils 工具类处理基于月度开始日期的日期计算 - 更新 HomeViewModel 和 AnalysisViewModel 使用月度开始日期进行统计 - 修复日期选择器中数字显示不完整的问题 --- .../bookkeeping/data/BookkeepingDatabase.kt | 35 +++++++- .../yovinchen/bookkeeping/data/SettingsDao.kt | 32 +++++++ .../bookkeeping/data/SettingsRepository.kt | 45 ++++++++++ .../yovinchen/bookkeeping/model/Settings.kt | 14 +++ .../bookkeeping/ui/screen/SettingsScreen.kt | 88 +++++++++++++++++++ .../yovinchen/bookkeeping/utils/DateUtils.kt | 85 ++++++++++++++++++ .../viewmodel/AnalysisViewModel.kt | 54 +++++++++--- .../bookkeeping/viewmodel/HomeViewModel.kt | 61 +++++++------ .../viewmodel/SettingsViewModel.kt | 38 ++++++++ 9 files changed, 408 insertions(+), 44 deletions(-) create mode 100644 app/src/main/java/com/yovinchen/bookkeeping/data/SettingsDao.kt create mode 100644 app/src/main/java/com/yovinchen/bookkeeping/data/SettingsRepository.kt create mode 100644 app/src/main/java/com/yovinchen/bookkeeping/model/Settings.kt create mode 100644 app/src/main/java/com/yovinchen/bookkeeping/utils/DateUtils.kt 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..bd5105c 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/data/BookkeepingDatabase.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/data/BookkeepingDatabase.kt @@ -13,14 +13,15 @@ import com.yovinchen.bookkeeping.model.BookkeepingRecord 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], + version = 5, exportSchema = false ) @TypeConverters(Converters::class) @@ -28,6 +29,7 @@ abstract class BookkeepingDatabase : RoomDatabase() { abstract fun bookkeepingDao(): BookkeepingDao abstract fun categoryDao(): CategoryDao abstract fun memberDao(): MemberDao + abstract fun settingsDao(): SettingsDao companion object { private const val TAG = "BookkeepingDatabase" @@ -124,6 +126,28 @@ 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) + """) + } + } + @Volatile private var INSTANCE: BookkeepingDatabase? = null @@ -134,7 +158,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) .addCallback(object : Callback() { override fun onCreate(db: SupportSQLiteDatabase) { super.onCreate(db) @@ -143,6 +167,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/SettingsDao.kt b/app/src/main/java/com/yovinchen/bookkeeping/data/SettingsDao.kt new file mode 100644 index 0000000..42edcca --- /dev/null +++ b/app/src/main/java/com/yovinchen/bookkeeping/data/SettingsDao.kt @@ -0,0 +1,32 @@ +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) +} \ 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/Settings.kt b/app/src/main/java/com/yovinchen/bookkeeping/model/Settings.kt new file mode 100644 index 0000000..5041e19 --- /dev/null +++ b/app/src/main/java/com/yovinchen/bookkeeping/model/Settings.kt @@ -0,0 +1,14 @@ +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 // 上次备份时间 +) \ 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..054ecf4 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,13 +4,19 @@ 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.ThemeMode @@ -36,7 +42,10 @@ 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 context = LocalContext.current + + var showMonthStartDayDialog by remember { mutableStateOf(false) } Column(modifier = Modifier.fillMaxSize()) { // 成员管理设置项 @@ -81,6 +90,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 +162,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) { 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/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..4fcfa8a 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/SettingsViewModel.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/SettingsViewModel.kt @@ -9,8 +9,10 @@ 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 kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -38,8 +40,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 +115,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) { From bdf01f6bbe89082f743992f7beab4709a42e2aaa Mon Sep 17 00:00:00 2001 From: yovinchen Date: Sat, 19 Jul 2025 22:19:43 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E6=9C=88?= =?UTF-8?q?=E5=BA=A6=E8=AE=B0=E8=B4=A6=E5=BC=80=E5=A7=8B=E6=97=A5=E6=9C=9F?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 Settings 实体和 DAO 来持久化存储设置 - 创建 SettingsRepository 管理设置数据 - 添加数据库迁移从版本 4 到版本 5 - 在设置界面添加月度开始日期选择器(1-28号) - 创建 DateUtils 工具类处理基于月度开始日期的日期计算 - 更新 HomeViewModel 和 AnalysisViewModel 使用月度开始日期进行统计 - 修复日期选择器中数字显示不完整的问题 --- .../bookkeeping/data/BookkeepingDatabase.kt | 32 ++++- .../yovinchen/bookkeeping/data/Converters.kt | 11 ++ .../yovinchen/bookkeeping/data/SettingsDao.kt | 3 + .../yovinchen/bookkeeping/model/Settings.kt | 3 +- .../bookkeeping/ui/screen/SettingsScreen.kt | 25 ++++ .../bookkeeping/utils/EncryptionUtils.kt | 136 ++++++++++++++++++ .../bookkeeping/utils/FilePickerUtil.kt | 7 +- .../viewmodel/SettingsViewModel.kt | 99 +++++++++++-- 8 files changed, 301 insertions(+), 15 deletions(-) create mode 100644 app/src/main/java/com/yovinchen/bookkeeping/utils/EncryptionUtils.kt 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 bd5105c..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,6 +10,7 @@ import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase import com.yovinchen.bookkeeping.R import com.yovinchen.bookkeeping.model.BookkeepingRecord +import com.yovinchen.bookkeeping.model.Budget import com.yovinchen.bookkeeping.model.Category import com.yovinchen.bookkeeping.model.Converters import com.yovinchen.bookkeeping.model.Member @@ -20,8 +21,8 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @Database( - entities = [BookkeepingRecord::class, Category::class, Member::class, Settings::class], - version = 5, + entities = [BookkeepingRecord::class, Category::class, Member::class, Settings::class, Budget::class], + version = 6, exportSchema = false ) @TypeConverters(Converters::class) @@ -30,6 +31,7 @@ abstract class BookkeepingDatabase : RoomDatabase() { abstract fun categoryDao(): CategoryDao abstract fun memberDao(): MemberDao abstract fun settingsDao(): SettingsDao + abstract fun budgetDao(): BudgetDao companion object { private const val TAG = "BookkeepingDatabase" @@ -147,6 +149,30 @@ abstract class BookkeepingDatabase : RoomDatabase() { """) } } + + private val MIGRATION_5_6 = object : Migration(5, 6) { + override fun migrate(db: SupportSQLiteDatabase) { + // 创建预算表 + db.execSQL(""" + CREATE TABLE IF NOT EXISTS budgets ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + type TEXT NOT NULL, + categoryName TEXT, + memberId INTEGER, + amount REAL NOT NULL, + startDate INTEGER NOT NULL, + endDate INTEGER NOT NULL, + isEnabled INTEGER NOT NULL DEFAULT 1, + alertThreshold REAL NOT NULL DEFAULT 0.8, + createdAt INTEGER NOT NULL, + updatedAt INTEGER NOT NULL + ) + """) + + // 在 settings 表中添加 encryptBackup 列 + db.execSQL("ALTER TABLE settings ADD COLUMN encryptBackup INTEGER NOT NULL DEFAULT 1") + } + } @Volatile private var INSTANCE: BookkeepingDatabase? = null @@ -158,7 +184,7 @@ abstract class BookkeepingDatabase : RoomDatabase() { BookkeepingDatabase::class.java, "bookkeeping_database" ) - .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5) + .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6) .addCallback(object : Callback() { override fun onCreate(db: SupportSQLiteDatabase) { super.onCreate(db) 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 index 42edcca..0517085 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/data/SettingsDao.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/data/SettingsDao.kt @@ -29,4 +29,7 @@ interface SettingsDao { @Query("UPDATE settings SET lastBackupTime = :time WHERE id = 1") suspend fun updateLastBackupTime(time: Long) + + @Query("UPDATE settings SET encryptBackup = :encrypt WHERE id = 1") + suspend fun updateEncryptBackup(encrypt: Boolean) } \ 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 index 5041e19..daf153b 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/model/Settings.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/model/Settings.kt @@ -10,5 +10,6 @@ data class Settings( val themeMode: String = "FOLLOW_SYSTEM", // 主题模式:FOLLOW_SYSTEM, LIGHT, DARK val autoBackupEnabled: Boolean = false, // 自动备份开关 val autoBackupInterval: Int = 7, // 自动备份间隔(天) - val lastBackupTime: Long = 0L // 上次备份时间 + val lastBackupTime: Long = 0L, // 上次备份时间 + val encryptBackup: Boolean = true // 备份加密开关,默认开启 ) \ 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 054ecf4..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 @@ -19,6 +19,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel +import com.yovinchen.bookkeeping.model.Settings import com.yovinchen.bookkeeping.model.ThemeMode import com.yovinchen.bookkeeping.ui.components.* import com.yovinchen.bookkeeping.ui.dialog.* @@ -43,6 +44,7 @@ fun SettingsScreen( val selectedType by viewModel.selectedCategoryType.collectAsState() val members by memberViewModel.allMembers.collectAsState(initial = emptyList()) val monthStartDay by viewModel.monthStartDay.collectAsState() + val settings by viewModel.settings.collectAsState() val context = LocalContext.current var showMonthStartDayDialog by remember { mutableStateOf(false) } @@ -277,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/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/SettingsViewModel.kt b/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/SettingsViewModel.kt index 4fcfa8a..f9709b3 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/SettingsViewModel.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/SettingsViewModel.kt @@ -14,6 +14,7 @@ import com.yovinchen.bookkeeping.model.BookkeepingRecord import com.yovinchen.bookkeeping.model.Category import com.yovinchen.bookkeeping.model.Settings import com.yovinchen.bookkeeping.model.TransactionType +import com.yovinchen.bookkeeping.utils.EncryptionUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay @@ -30,6 +31,7 @@ import org.apache.poi.xssf.usermodel.XSSFWorkbook import java.io.File import java.io.FileReader import java.io.FileWriter +import java.io.StringWriter import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -157,15 +159,24 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application fun exportToCSV(context: Context, customDir: File? = null) { viewModelScope.launch(Dispatchers.IO) { try { + val currentSettings = settings.value ?: Settings() val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date()) - val fileName = "bookkeeping_backup_$timestamp.csv" + val shouldEncrypt = currentSettings.encryptBackup + val fileName = if (shouldEncrypt) { + "bookkeeping_backup_$timestamp.csv.enc" + } else { + "bookkeeping_backup_$timestamp.csv" + } val downloadsDir = customDir ?: Environment.getExternalStoragePublicDirectory( Environment.DIRECTORY_DOWNLOADS ) val file = File(downloadsDir, fileName) - CSVWriter(FileWriter(file)).use { writer -> + // 先创建CSV内容到字符串 + val csvContent = StringWriter().use { stringWriter -> + val writer = CSVWriter(stringWriter) + // 写入头部 writer.writeNext(arrayOf("日期", "类型", "金额", "类别", "备注", "成员")) @@ -189,11 +200,25 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application ) ) } + writer.close() + stringWriter.toString() + } + + // 根据设置决定是否加密 + if (shouldEncrypt) { + val encryptedContent = EncryptionUtils.encrypt(csvContent) + file.writeText(encryptedContent) + } else { + file.writeText(csvContent) } withContext(Dispatchers.Main) { - Toast.makeText(context, "CSV导出成功: ${file.absolutePath}", Toast.LENGTH_LONG) - .show() + val message = if (shouldEncrypt) { + "CSV导出成功(已加密): ${file.absolutePath}" + } else { + "CSV导出成功: ${file.absolutePath}" + } + Toast.makeText(context, message, Toast.LENGTH_LONG).show() } } catch (e: Exception) { e.printStackTrace() @@ -207,6 +232,9 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application fun exportToExcel(context: Context) { viewModelScope.launch(Dispatchers.IO) { try { + val currentSettings = settings.value ?: Settings() + val shouldEncrypt = currentSettings.encryptBackup + val workbook = XSSFWorkbook() val sheet = workbook.createSheet("账目记录") @@ -239,18 +267,39 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date()) - val fileName = "bookkeeping_backup_$timestamp.xlsx" + val fileName = if (shouldEncrypt) { + "bookkeeping_backup_$timestamp.xlsx.enc" + } else { + "bookkeeping_backup_$timestamp.xlsx" + } val downloadsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) val file = File(downloadsDir, fileName) - workbook.write(file.outputStream()) + if (shouldEncrypt) { + // 将 workbook 写入字节数组 + val byteArrayOutputStream = java.io.ByteArrayOutputStream() + workbook.write(byteArrayOutputStream) + val excelBytes = byteArrayOutputStream.toByteArray() + + // 加密字节数组 + val encryptedBytes = EncryptionUtils.encryptBytes(excelBytes) + + // 写入加密文件 + file.writeBytes(encryptedBytes) + } else { + workbook.write(file.outputStream()) + } + workbook.close() withContext(Dispatchers.Main) { - Toast.makeText( - context, "Excel导出成功: ${file.absolutePath}", Toast.LENGTH_LONG - ).show() + val message = if (shouldEncrypt) { + "Excel导出成功(已加密): ${file.absolutePath}" + } else { + "Excel导出成功: ${file.absolutePath}" + } + Toast.makeText(context, message, Toast.LENGTH_LONG).show() } } catch (e: Exception) { e.printStackTrace() @@ -265,10 +314,30 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application viewModelScope.launch(Dispatchers.IO) { try { when { + backupFile.name.endsWith(".csv.enc", ignoreCase = true) -> { + // 解密CSV文件 + val encryptedContent = backupFile.readText() + val decryptedContent = EncryptionUtils.decrypt(encryptedContent) + val tempFile = File(context.cacheDir, "temp_decrypted.csv") + tempFile.writeText(decryptedContent) + restoreFromCSV(tempFile) + tempFile.delete() + } + backupFile.name.endsWith(".csv", ignoreCase = true) -> { restoreFromCSV(backupFile) } + backupFile.name.endsWith(".xlsx.enc", ignoreCase = true) -> { + // 解密Excel文件 + val encryptedBytes = backupFile.readBytes() + val decryptedBytes = EncryptionUtils.decryptBytes(encryptedBytes) + val tempFile = File(context.cacheDir, "temp_decrypted.xlsx") + tempFile.writeBytes(decryptedBytes) + restoreFromExcel(tempFile) + tempFile.delete() + } + backupFile.name.endsWith(".xlsx", ignoreCase = true) -> { restoreFromExcel(backupFile) } @@ -287,7 +356,11 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application } catch (e: Exception) { e.printStackTrace() withContext(Dispatchers.Main) { - Toast.makeText(context, "数据恢复失败: ${e.message}", Toast.LENGTH_LONG).show() + val errorMessage = when { + e.message?.contains("decrypt") == true -> "解密失败,请确认文件未损坏" + else -> "数据恢复失败: ${e.message}" + } + Toast.makeText(context, errorMessage, Toast.LENGTH_LONG).show() } } } @@ -343,4 +416,10 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application private suspend fun findMemberIdByName(name: String): Int? { return memberDao.getAllMembers().first().find { member -> member.name == name }?.id } + + fun updateSettings(settings: Settings) { + viewModelScope.launch { + settingsRepository.updateSettings(settings) + } + } } From 316176bf6a4f6574173beb7749d397ff37c5a62a Mon Sep 17 00:00:00 2001 From: yovinchen Date: Sat, 19 Jul 2025 22:19:50 +0800 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E6=9C=88?= =?UTF-8?q?=E5=BA=A6=E8=AE=B0=E8=B4=A6=E5=BC=80=E5=A7=8B=E6=97=A5=E6=9C=9F?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 Settings 实体和 DAO 来持久化存储设置 - 创建 SettingsRepository 管理设置数据 - 添加数据库迁移从版本 4 到版本 5 - 在设置界面添加月度开始日期选择器(1-28号) - 创建 DateUtils 工具类处理基于月度开始日期的日期计算 - 更新 HomeViewModel 和 AnalysisViewModel 使用月度开始日期进行统计 - 修复日期选择器中数字显示不完整的问题 --- .../yovinchen/bookkeeping/data/BudgetDao.kt | 105 +++++++++ .../bookkeeping/data/BudgetRepository.kt | 215 ++++++++++++++++++ .../com/yovinchen/bookkeeping/model/Budget.kt | 86 +++++++ 3 files changed, 406 insertions(+) create mode 100644 app/src/main/java/com/yovinchen/bookkeeping/data/BudgetDao.kt create mode 100644 app/src/main/java/com/yovinchen/bookkeeping/data/BudgetRepository.kt create mode 100644 app/src/main/java/com/yovinchen/bookkeeping/model/Budget.kt 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/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 From 026df119335891290e29a6caa1583e21ab7b335e Mon Sep 17 00:00:00 2001 From: yovinchen Date: Sat, 19 Jul 2025 22:26:17 +0800 Subject: [PATCH 4/4] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E5=A4=87?= =?UTF-8?q?=E4=BB=BD=E5=8A=A0=E5=AF=86=E5=8A=9F=E8=83=BD=E5=92=8C=E9=A2=84?= =?UTF-8?q?=E7=AE=97=E7=AE=A1=E7=90=86=E5=9F=BA=E7=A1=80=E6=9E=B6=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 备份加密功能 - 添加 EncryptionUtils 使用 Android Keystore 安全存储密钥 - 修改导出功能支持 CSV 和 Excel 文件加密 - 实现加密文件的自动解密导入 - 在设置页面添加备份加密开关 2. 预算管理基础架构 - 创建 Budget 数据模型,支持总预算、分类预算和成员预算 - 创建 BudgetDao 提供数据库操作接口 - 创建 BudgetRepository 实现预算业务逻辑 - 更新数据库版本至 v6 并添加迁移 3. 其他改进 - 创建 CLAUDE.md 文件提供项目指导 - 修复编译错误和类型安全问题 - 更新 FilePickerUtil 支持加密文件格式 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .claude/settings.local.json | 8 ++ .idea/AndroidProjectSystem.xml | 6 + .idea/inspectionProfiles/Project_Default.xml | 3 + CLAUDE.md | 117 +++++++++++++++++++ 4 files changed, 134 insertions(+) create mode 100644 .claude/settings.local.json create mode 100644 .idea/AndroidProjectSystem.xml create mode 100644 CLAUDE.md 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 @@