diff --git a/.claude/settings.local.json b/.claude/settings.local.json
new file mode 100644
index 0000000..9a7965d
--- /dev/null
+++ b/.claude/settings.local.json
@@ -0,0 +1,12 @@
+{
+ "permissions": {
+ "allow": [
+ "Bash(find:*)",
+ "Bash(ls:*)",
+ "Bash(./gradlew:*)",
+ "Bash(git checkout:*)",
+ "Bash(git add:*)"
+ ],
+ "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..7061a0d 100644
--- a/.idea/inspectionProfiles/Project_Default.xml
+++ b/.idea/inspectionProfiles/Project_Default.xml
@@ -49,6 +49,10 @@
+
+
+
+
diff --git a/.idea/misc.xml b/.idea/misc.xml
index 312151c..55660c4 100644
--- a/.idea/misc.xml
+++ b/.idea/misc.xml
@@ -1,4 +1,3 @@
-
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..470a4e0
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,135 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## 项目概述
+
+这是一个名为"轻记账"的 Android 记账应用,使用 Kotlin 和 Jetpack Compose 开发,采用 MVVM 架构模式。应用完全离线运行,注重用户隐私保护。
+
+## 常用开发命令
+
+### 构建命令
+```bash
+# 清理项目
+./gradlew clean
+
+# 构建 Debug APK
+./gradlew assembleDebug
+
+# 构建 Release APK
+./gradlew assembleRelease
+
+# 运行所有构建任务
+./gradlew build
+```
+
+### 测试命令
+```bash
+# 运行单元测试
+./gradlew test
+
+# 运行仪器测试
+./gradlew connectedAndroidTest
+
+# 运行特定模块的测试
+./gradlew :app:test
+```
+
+### 代码检查
+```bash
+# 运行 lint 检查
+./gradlew lint
+
+# 查看 lint 报告(生成在 app/build/reports/lint-results.html)
+./gradlew lintDebug
+```
+
+## 项目架构
+
+### 核心架构模式:MVVM + Repository
+- **View (UI层)**:使用 Jetpack Compose 构建的 UI 组件
+- **ViewModel**:处理业务逻辑和状态管理
+- **Model (数据层)**:Room Database + Repository 模式
+- **依赖注入**:手动依赖注入(未使用 Hilt/Dagger)
+
+### 主要技术栈
+- **UI框架**:Jetpack Compose with Material 3
+- **数据库**:Room 2.6.1
+- **异步处理**:Kotlin Coroutines + Flow
+- **导航**:Navigation Compose
+- **图表**:MPAndroidChart 3.1.0
+- **文件处理**:OpenCSV 5.7.1, Apache POI 5.2.3
+
+### 包结构说明
+```
+com.yovinchen.bookkeeping/
+├── data/ # 数据层:数据库、DAO、Repository
+├── model/ # 数据模型:实体类和数据类
+├── ui/ # UI 层
+│ ├── components/ # 可复用的 UI 组件
+│ ├── dialog/ # 对话框组件
+│ ├── screen/ # 屏幕页面(HomeScreen, AnalysisScreen等)
+│ └── theme/ # 主题配置
+├── utils/ # 工具类
+└── viewmodel/ # ViewModel 层
+```
+
+### 数据流架构
+1. **UI 层**通过 ViewModel 获取数据和发送事件
+2. **ViewModel** 通过 Repository 访问数据,使用 StateFlow 管理 UI 状态
+3. **Repository** 封装数据访问逻辑,统一数据源接口
+4. **Room Database** 提供本地数据持久化
+
+### 关键设计决策
+1. **单 Activity 架构**:整个应用只有一个 MainActivity,所有页面通过 Compose Navigation 管理
+2. **状态管理**:使用 ViewModel + StateFlow 进行状态管理,确保配置变更时的数据保持
+3. **主题系统**:支持深色/浅色模式切换和自定义主题色,通过 CompositionLocal 传递主题状态
+4. **权限最小化**:仅在需要时请求必要权限(如文件读写权限)
+
+## 开发规范
+
+### Git 分支管理
+- `master`:稳定主分支
+- `develop`:主开发分支
+- `feature/*`:功能开发分支
+- `release/*`:版本发布分支
+- `hotfix/*`:紧急修复分支
+
+### 提交信息格式
+使用约定式提交:`: `
+
+类型包括:feat, fix, docs, style, refactor, perf, test, build, ci, chore
+
+### 版本管理
+- 版本号在 `app/build.gradle.kts` 中的 `versionName` 和 `versionCode` 管理
+- APK 命名格式:`轻记账_${buildType}_v${versionName}_${date}.apk`
+
+## 关键功能实现
+
+### 记账记录管理
+- 数据模型:`BookkeepingRecord` 实体类
+- 存储:Room Database 自动管理
+- 展示:按日期分组,支持编辑和删除
+
+### 成员系统
+- 成员数据存储在独立表中
+- 记账记录通过成员 ID 列表关联
+- 支持成员消费统计和分析
+
+### 数据导入导出
+- CSV 导出:使用 OpenCSV 库
+- Excel 导出:使用 Apache POI 库
+- 文件选择:通过 SAF (Storage Access Framework)
+
+### 图表分析
+- 使用 MPAndroidChart 库
+- 通过 AndroidView 在 Compose 中集成
+- 支持饼图、折线图等多种图表类型
+
+## 注意事项
+
+1. **数据库迁移**:修改数据库结构时必须提供 Migration
+2. **Compose 预览**:使用 `@Preview` 注解时需要提供默认参数
+3. **性能优化**:大量数据时注意使用分页或懒加载
+4. **主题适配**:新增 UI 组件时确保同时支持深色和浅色主题
+5. **图标资源**:项目包含大量自定义图标,位于 `drawable` 目录
\ 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..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) {
diff --git a/img.png b/img.png
new file mode 100644
index 0000000..d757c5f
Binary files /dev/null and b/img.png differ