feat: 实现月度记账开始日期功能

- 添加 Settings 实体和 DAO 来持久化存储设置
- 创建 SettingsRepository 管理设置数据
- 添加数据库迁移从版本 4 到版本 5
- 在设置界面添加月度开始日期选择器(1-28号)
- 创建 DateUtils 工具类处理基于月度开始日期的日期计算
- 更新 HomeViewModel 和 AnalysisViewModel 使用月度开始日期进行统计
- 修复日期选择器中数字显示不完整的问题
- 创建 CLAUDE.md 文件记录项目开发指南

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
yovinchen 2025-07-14 15:08:12 +08:00
parent 4c1aa501e6
commit a86898011d
15 changed files with 565 additions and 45 deletions

View File

@ -0,0 +1,12 @@
{
"permissions": {
"allow": [
"Bash(find:*)",
"Bash(ls:*)",
"Bash(./gradlew:*)",
"Bash(git checkout:*)",
"Bash(git add:*)"
],
"deny": []
}
}

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AndroidProjectSystem">
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
</component>
</project>

View File

@ -49,6 +49,10 @@
<option name="composableFile" value="true" /> <option name="composableFile" value="true" />
<option name="previewFile" value="true" /> <option name="previewFile" value="true" />
</inspection_tool> </inspection_tool>
<inspection_tool class="PreviewParameterProviderOnFirstParameter" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true"> <inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" /> <option name="composableFile" value="true" />
<option name="previewFile" value="true" /> <option name="previewFile" value="true" />

View File

@ -1,4 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="EntryPointsManager"> <component name="EntryPointsManager">
<list size="1"> <list size="1">

135
CLAUDE.md Normal file
View File

@ -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/*`:紧急修复分支
### 提交信息格式
使用约定式提交:`<type>: <description>`
类型包括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` 目录

View File

@ -13,14 +13,15 @@ import com.yovinchen.bookkeeping.model.BookkeepingRecord
import com.yovinchen.bookkeeping.model.Category import com.yovinchen.bookkeeping.model.Category
import com.yovinchen.bookkeeping.model.Converters import com.yovinchen.bookkeeping.model.Converters
import com.yovinchen.bookkeeping.model.Member import com.yovinchen.bookkeeping.model.Member
import com.yovinchen.bookkeeping.model.Settings
import com.yovinchen.bookkeeping.model.TransactionType import com.yovinchen.bookkeeping.model.TransactionType
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@Database( @Database(
entities = [BookkeepingRecord::class, Category::class, Member::class], entities = [BookkeepingRecord::class, Category::class, Member::class, Settings::class],
version = 4, version = 5,
exportSchema = false exportSchema = false
) )
@TypeConverters(Converters::class) @TypeConverters(Converters::class)
@ -28,6 +29,7 @@ abstract class BookkeepingDatabase : RoomDatabase() {
abstract fun bookkeepingDao(): BookkeepingDao abstract fun bookkeepingDao(): BookkeepingDao
abstract fun categoryDao(): CategoryDao abstract fun categoryDao(): CategoryDao
abstract fun memberDao(): MemberDao abstract fun memberDao(): MemberDao
abstract fun settingsDao(): SettingsDao
companion object { companion object {
private const val TAG = "BookkeepingDatabase" 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 @Volatile
private var INSTANCE: BookkeepingDatabase? = null private var INSTANCE: BookkeepingDatabase? = null
@ -134,7 +158,7 @@ abstract class BookkeepingDatabase : RoomDatabase() {
BookkeepingDatabase::class.java, BookkeepingDatabase::class.java,
"bookkeeping_database" "bookkeeping_database"
) )
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4) .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5)
.addCallback(object : Callback() { .addCallback(object : Callback() {
override fun onCreate(db: SupportSQLiteDatabase) { override fun onCreate(db: SupportSQLiteDatabase) {
super.onCreate(db) super.onCreate(db)
@ -143,6 +167,11 @@ abstract class BookkeepingDatabase : RoomDatabase() {
try { try {
val database = getDatabase(context) val database = getDatabase(context)
// 初始化默认设置
database.settingsDao().apply {
updateSettings(Settings())
}
// 初始化默认成员 // 初始化默认成员
database.memberDao().apply { database.memberDao().apply {
if (getMemberCount() == 0) { if (getMemberCount() == 0) {

View File

@ -0,0 +1,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<Settings?>
@Query("SELECT * FROM settings WHERE id = 1")
suspend fun getSettingsOnce(): Settings?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun updateSettings(settings: Settings)
@Query("UPDATE settings SET monthStartDay = :day WHERE id = 1")
suspend fun updateMonthStartDay(day: Int)
@Query("UPDATE settings SET themeMode = :mode WHERE id = 1")
suspend fun updateThemeMode(mode: String)
@Query("UPDATE settings SET autoBackupEnabled = :enabled WHERE id = 1")
suspend fun updateAutoBackupEnabled(enabled: Boolean)
@Query("UPDATE settings SET autoBackupInterval = :interval WHERE id = 1")
suspend fun updateAutoBackupInterval(interval: Int)
@Query("UPDATE settings SET lastBackupTime = :time WHERE id = 1")
suspend fun updateLastBackupTime(time: Long)
}

View File

@ -0,0 +1,45 @@
package com.yovinchen.bookkeeping.data
import com.yovinchen.bookkeeping.model.Settings
import kotlinx.coroutines.flow.Flow
class SettingsRepository(private val settingsDao: SettingsDao) {
fun getSettings(): Flow<Settings?> = settingsDao.getSettings()
suspend fun getSettingsOnce(): Settings {
return settingsDao.getSettingsOnce() ?: Settings()
}
suspend fun updateSettings(settings: Settings) {
settingsDao.updateSettings(settings)
}
suspend fun updateMonthStartDay(day: Int) {
// 确保日期在有效范围内 (1-28)
val validDay = day.coerceIn(1, 28)
settingsDao.updateMonthStartDay(validDay)
}
suspend fun updateThemeMode(mode: String) {
settingsDao.updateThemeMode(mode)
}
suspend fun updateAutoBackupEnabled(enabled: Boolean) {
settingsDao.updateAutoBackupEnabled(enabled)
}
suspend fun updateAutoBackupInterval(interval: Int) {
settingsDao.updateAutoBackupInterval(interval)
}
suspend fun updateLastBackupTime(time: Long) {
settingsDao.updateLastBackupTime(time)
}
suspend fun ensureSettingsExist() {
if (settingsDao.getSettingsOnce() == null) {
settingsDao.updateSettings(Settings())
}
}
}

View File

@ -0,0 +1,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 // 上次备份时间
)

View File

@ -4,13 +4,19 @@ import android.content.Context
import android.widget.Toast import android.widget.Toast
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.yovinchen.bookkeeping.model.ThemeMode import com.yovinchen.bookkeeping.model.ThemeMode
@ -36,8 +42,11 @@ fun SettingsScreen(
val categories by viewModel.categories.collectAsState() val categories by viewModel.categories.collectAsState()
val selectedType by viewModel.selectedCategoryType.collectAsState() val selectedType by viewModel.selectedCategoryType.collectAsState()
val members by memberViewModel.allMembers.collectAsState(initial = emptyList()) val members by memberViewModel.allMembers.collectAsState(initial = emptyList())
val monthStartDay by viewModel.monthStartDay.collectAsState()
val context = LocalContext.current val context = LocalContext.current
var showMonthStartDayDialog by remember { mutableStateOf(false) }
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
// 成员管理设置项 // 成员管理设置项
ListItem( ListItem(
@ -82,6 +91,15 @@ fun SettingsScreen(
modifier = Modifier.clickable { showThemeDialog = true } modifier = Modifier.clickable { showThemeDialog = true }
) )
HorizontalDivider()
// 月度开始日期设置项
ListItem(
headlineContent = { Text("月度开始日期") },
supportingContent = { Text("每月从${monthStartDay}号开始计算") },
modifier = Modifier.clickable { showMonthStartDayDialog = true }
)
if (showThemeDialog) { if (showThemeDialog) {
AlertDialog( AlertDialog(
onDismissRequest = { showThemeDialog = false }, onDismissRequest = { showThemeDialog = false },
@ -145,6 +163,76 @@ fun SettingsScreen(
) )
} }
// 月度开始日期对话框
if (showMonthStartDayDialog) {
AlertDialog(
onDismissRequest = { showMonthStartDayDialog = false },
title = { Text("选择月度开始日期") },
text = {
Column {
Text("选择每月记账的开始日期1-28号")
Spacer(modifier = Modifier.height(16.dp))
// 日期选择器
val days = (1..28).toList()
LazyVerticalGrid(
columns = GridCells.Fixed(7),
modifier = Modifier.fillMaxWidth().height(280.dp),
contentPadding = PaddingValues(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
items(days) { day ->
Surface(
onClick = {
viewModel.setMonthStartDay(day)
showMonthStartDayDialog = false
},
shape = RoundedCornerShape(8.dp),
color = if (day == monthStartDay) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surface
},
border = BorderStroke(
width = 1.dp,
color = if (day == monthStartDay) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.outline
}
),
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxSize()
) {
Text(
text = day.toString(),
style = MaterialTheme.typography.bodyMedium,
color = if (day == monthStartDay) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onSurface
}
)
}
}
}
}
}
},
confirmButton = {
TextButton(onClick = { showMonthStartDayDialog = false }) {
Text("取消")
}
}
)
}
// 备份对话框 // 备份对话框
if (showBackupDialog) { if (showBackupDialog) {
AlertDialog( AlertDialog(

View File

@ -0,0 +1,85 @@
package com.yovinchen.bookkeeping.utils
import java.time.LocalDate
import java.time.YearMonth
import java.time.ZoneId
import java.util.Date
object DateUtils {
/**
* 根据月度开始日期计算给定日期所属的记账月份
* @param date 要判断的日期
* @param monthStartDay 月度开始日期1-28
* @return 该日期所属的记账月份
*/
fun getAccountingMonth(date: Date, monthStartDay: Int): YearMonth {
val localDate = date.toInstant().atZone(ZoneId.systemDefault()).toLocalDate()
return getAccountingMonth(localDate, monthStartDay)
}
/**
* 根据月度开始日期计算给定日期所属的记账月份
* @param date 要判断的日期
* @param monthStartDay 月度开始日期1-28
* @return 该日期所属的记账月份
*/
fun getAccountingMonth(date: LocalDate, monthStartDay: Int): YearMonth {
val dayOfMonth = date.dayOfMonth
return if (dayOfMonth >= monthStartDay) {
// 当前日期大于等于开始日期,属于当前月
YearMonth.from(date)
} else {
// 当前日期小于开始日期,属于上个月
YearMonth.from(date.minusMonths(1))
}
}
/**
* 获取记账月份的开始日期
* @param yearMonth 记账月份
* @param monthStartDay 月度开始日期1-28
* @return 该记账月份的开始日期
*/
fun getMonthStartDate(yearMonth: YearMonth, monthStartDay: Int): LocalDate {
return yearMonth.atDay(monthStartDay)
}
/**
* 获取记账月份的结束日期
* @param yearMonth 记账月份
* @param monthStartDay 月度开始日期1-28
* @return 该记账月份的结束日期
*/
fun getMonthEndDate(yearMonth: YearMonth, monthStartDay: Int): LocalDate {
val nextMonth = yearMonth.plusMonths(1)
return nextMonth.atDay(monthStartDay).minusDays(1)
}
/**
* 检查日期是否在指定的记账月份内
* @param date 要检查的日期
* @param yearMonth 记账月份
* @param monthStartDay 月度开始日期1-28
* @return 是否在该记账月份内
*/
fun isInAccountingMonth(date: Date, yearMonth: YearMonth, monthStartDay: Int): Boolean {
val localDate = date.toInstant().atZone(ZoneId.systemDefault()).toLocalDate()
return isInAccountingMonth(localDate, yearMonth, monthStartDay)
}
/**
* 检查日期是否在指定的记账月份内
* @param date 要检查的日期
* @param yearMonth 记账月份
* @param monthStartDay 月度开始日期1-28
* @return 是否在该记账月份内
*/
fun isInAccountingMonth(date: LocalDate, yearMonth: YearMonth, monthStartDay: Int): Boolean {
val startDate = getMonthStartDate(yearMonth, monthStartDay)
val endDate = getMonthEndDate(yearMonth, monthStartDay)
return date >= startDate && date <= endDate
}
}

View File

@ -4,21 +4,22 @@ import android.app.Application
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.yovinchen.bookkeeping.data.BookkeepingDatabase import com.yovinchen.bookkeeping.data.BookkeepingDatabase
import com.yovinchen.bookkeeping.data.SettingsRepository
import com.yovinchen.bookkeeping.model.AnalysisType import com.yovinchen.bookkeeping.model.AnalysisType
import com.yovinchen.bookkeeping.model.BookkeepingRecord import com.yovinchen.bookkeeping.model.BookkeepingRecord
import com.yovinchen.bookkeeping.model.CategoryStat import com.yovinchen.bookkeeping.model.CategoryStat
import com.yovinchen.bookkeeping.model.MemberStat import com.yovinchen.bookkeeping.model.MemberStat
import com.yovinchen.bookkeeping.model.TransactionType import com.yovinchen.bookkeeping.model.TransactionType
import com.yovinchen.bookkeeping.utils.DateUtils
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.time.LocalDateTime
import java.time.YearMonth import java.time.YearMonth
import java.time.ZoneId
import java.util.* import java.util.*
class AnalysisViewModel(application: Application) : AndroidViewModel(application) { class AnalysisViewModel(application: Application) : AndroidViewModel(application) {
private val recordDao = BookkeepingDatabase.getDatabase(application).bookkeepingDao() private val recordDao = BookkeepingDatabase.getDatabase(application).bookkeepingDao()
private val memberDao = BookkeepingDatabase.getDatabase(application).memberDao() private val memberDao = BookkeepingDatabase.getDatabase(application).memberDao()
private val settingsRepository = SettingsRepository(BookkeepingDatabase.getDatabase(application).settingsDao())
private val _startMonth = MutableStateFlow(YearMonth.now()) private val _startMonth = MutableStateFlow(YearMonth.now())
val startMonth: StateFlow<YearMonth> = _startMonth.asStateFlow() val startMonth: StateFlow<YearMonth> = _startMonth.asStateFlow()
@ -38,16 +39,41 @@ class AnalysisViewModel(application: Application) : AndroidViewModel(application
private val _records = MutableStateFlow<List<BookkeepingRecord>>(emptyList()) private val _records = MutableStateFlow<List<BookkeepingRecord>>(emptyList())
val records: StateFlow<List<BookkeepingRecord>> = _records.asStateFlow() val records: StateFlow<List<BookkeepingRecord>> = _records.asStateFlow()
// 存储月度开始日期设置
private val _monthStartDay = MutableStateFlow(1)
val monthStartDay: StateFlow<Int> = _monthStartDay.asStateFlow()
init { init {
// 订阅设置变化,获取月度开始日期
viewModelScope.launch { viewModelScope.launch {
combine(startMonth, endMonth, selectedAnalysisType) { start, end, type -> settingsRepository.getSettings().collect { settings ->
Triple(start, end, type) _monthStartDay.value = settings?.monthStartDay ?: 1
}.collect { (start, end, type) -> }
updateStats(start, end, type) }
// 当月度开始日期、起始月份、结束月份或分析类型变化时,更新统计数据
viewModelScope.launch {
combine(
startMonth,
endMonth,
selectedAnalysisType,
monthStartDay
) { start, end, type, startDay ->
UpdateParams(start, end, type, startDay)
}.collect { params ->
updateStats(params.start, params.end, params.type, params.startDay)
} }
} }
} }
// 用于传递更新参数的数据类
private data class UpdateParams(
val start: YearMonth,
val end: YearMonth,
val type: AnalysisType,
val startDay: Int
)
fun setStartMonth(month: YearMonth) { fun setStartMonth(month: YearMonth) {
_startMonth.value = month _startMonth.value = month
} }
@ -60,16 +86,16 @@ class AnalysisViewModel(application: Application) : AndroidViewModel(application
_selectedAnalysisType.value = type _selectedAnalysisType.value = type
} }
private suspend fun updateStats(startMonth: YearMonth, endMonth: YearMonth, type: AnalysisType) { private suspend fun updateStats(startMonth: YearMonth, endMonth: YearMonth, type: AnalysisType, monthStartDay: Int) {
val records = recordDao.getAllRecords().first() val records = recordDao.getAllRecords().first()
// 过滤日期范围内的记录 // 使用 DateUtils 过滤日期范围内的记录
val monthRecords = records.filter { val monthRecords = records.filter { record ->
val recordDate = Date(it.date.time) val recordDate = Date(record.date.time)
val localDateTime = LocalDateTime.ofInstant(recordDate.toInstant(), ZoneId.systemDefault()) val accountingMonth = DateUtils.getAccountingMonth(recordDate, monthStartDay)
val yearMonth = YearMonth.from(localDateTime)
yearMonth.isAfter(startMonth.minusMonths(1)) && // 检查记账月份是否在选定的范围内
yearMonth.isBefore(endMonth.plusMonths(1)) accountingMonth >= startMonth && accountingMonth <= endMonth
} }
// 更新记录数据 // 更新记录数据

View File

@ -4,10 +4,12 @@ import android.app.Application
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.yovinchen.bookkeeping.data.BookkeepingDatabase import com.yovinchen.bookkeeping.data.BookkeepingDatabase
import com.yovinchen.bookkeeping.data.SettingsRepository
import com.yovinchen.bookkeeping.model.BookkeepingRecord import com.yovinchen.bookkeeping.model.BookkeepingRecord
import com.yovinchen.bookkeeping.model.Category import com.yovinchen.bookkeeping.model.Category
import com.yovinchen.bookkeeping.model.Member import com.yovinchen.bookkeeping.model.Member
import com.yovinchen.bookkeeping.model.TransactionType import com.yovinchen.bookkeeping.model.TransactionType
import com.yovinchen.bookkeeping.utils.DateUtils
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -18,9 +20,26 @@ import java.util.*
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
class HomeViewModel(application: Application) : AndroidViewModel(application) { class HomeViewModel(application: Application) : AndroidViewModel(application) {
private val bookkeepingDao = BookkeepingDatabase.getDatabase(application).bookkeepingDao() private val database = BookkeepingDatabase.getDatabase(application)
private val memberDao = BookkeepingDatabase.getDatabase(application).memberDao() private val bookkeepingDao = database.bookkeepingDao()
private val categoryDao = BookkeepingDatabase.getDatabase(application).categoryDao() private val memberDao = database.memberDao()
private val categoryDao = database.categoryDao()
private val settingsRepository = SettingsRepository(database.settingsDao())
// 设置相关
private val _monthStartDay = MutableStateFlow(1)
val monthStartDay: StateFlow<Int> = _monthStartDay.asStateFlow()
init {
viewModelScope.launch {
settingsRepository.ensureSettingsExist()
settingsRepository.getSettings().collect { settings ->
settings?.let {
_monthStartDay.value = it.monthStartDay
}
}
}
}
private val _selectedRecordType = MutableStateFlow<TransactionType?>(null) private val _selectedRecordType = MutableStateFlow<TransactionType?>(null)
val selectedRecordType: StateFlow<TransactionType?> = _selectedRecordType.asStateFlow() val selectedRecordType: StateFlow<TransactionType?> = _selectedRecordType.asStateFlow()
@ -56,17 +75,13 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
allRecords, allRecords,
_selectedRecordType, _selectedRecordType,
_selectedMonth, _selectedMonth,
_selectedMember _selectedMember,
) { records, selectedType, selectedMonth, selectedMember -> _monthStartDay
) { records, selectedType, selectedMonth, selectedMember, monthStartDay ->
records records
.filter { record -> .filter { record ->
val recordDate = record.date.toInstant()
.atZone(ZoneId.systemDefault())
.toLocalDate()
val recordYearMonth = YearMonth.from(recordDate)
val typeMatches = selectedType?.let { record.type == it } ?: true val typeMatches = selectedType?.let { record.type == it } ?: true
val monthMatches = recordYearMonth == selectedMonth val monthMatches = DateUtils.isInAccountingMonth(record.date, selectedMonth, monthStartDay)
val memberMatches = selectedMember?.let { record.memberId == it.id } ?: true val memberMatches = selectedMember?.let { record.memberId == it.id } ?: true
monthMatches && memberMatches && typeMatches monthMatches && memberMatches && typeMatches
@ -90,16 +105,12 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
val totalIncome = combine( val totalIncome = combine(
allRecords, allRecords,
_selectedMonth, _selectedMonth,
_selectedMember _selectedMember,
) { records, selectedMonth, selectedMember -> _monthStartDay
) { records, selectedMonth, selectedMember, monthStartDay ->
records records
.filter { record -> .filter { record ->
val recordDate = record.date.toInstant() val monthMatches = DateUtils.isInAccountingMonth(record.date, selectedMonth, monthStartDay)
.atZone(ZoneId.systemDefault())
.toLocalDate()
val recordYearMonth = YearMonth.from(recordDate)
val monthMatches = recordYearMonth == selectedMonth
val memberMatches = selectedMember?.let { record.memberId == it.id } ?: true val memberMatches = selectedMember?.let { record.memberId == it.id } ?: true
val typeMatches = record.type == TransactionType.INCOME val typeMatches = record.type == TransactionType.INCOME
@ -115,16 +126,12 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
val totalExpense = combine( val totalExpense = combine(
allRecords, allRecords,
_selectedMonth, _selectedMonth,
_selectedMember _selectedMember,
) { records, selectedMonth, selectedMember -> _monthStartDay
) { records, selectedMonth, selectedMember, monthStartDay ->
records records
.filter { record -> .filter { record ->
val recordDate = record.date.toInstant() val monthMatches = DateUtils.isInAccountingMonth(record.date, selectedMonth, monthStartDay)
.atZone(ZoneId.systemDefault())
.toLocalDate()
val recordYearMonth = YearMonth.from(recordDate)
val monthMatches = recordYearMonth == selectedMonth
val memberMatches = selectedMember?.let { record.memberId == it.id } ?: true val memberMatches = selectedMember?.let { record.memberId == it.id } ?: true
val typeMatches = record.type == TransactionType.EXPENSE val typeMatches = record.type == TransactionType.EXPENSE

View File

@ -9,8 +9,10 @@ import androidx.lifecycle.viewModelScope
import com.opencsv.CSVReader import com.opencsv.CSVReader
import com.opencsv.CSVWriter import com.opencsv.CSVWriter
import com.yovinchen.bookkeeping.data.BookkeepingDatabase import com.yovinchen.bookkeeping.data.BookkeepingDatabase
import com.yovinchen.bookkeeping.data.SettingsRepository
import com.yovinchen.bookkeeping.model.BookkeepingRecord import com.yovinchen.bookkeeping.model.BookkeepingRecord
import com.yovinchen.bookkeeping.model.Category import com.yovinchen.bookkeeping.model.Category
import com.yovinchen.bookkeeping.model.Settings
import com.yovinchen.bookkeeping.model.TransactionType import com.yovinchen.bookkeeping.model.TransactionType
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -38,9 +40,37 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
private val database = BookkeepingDatabase.getDatabase(application) private val database = BookkeepingDatabase.getDatabase(application)
private val dao = database.bookkeepingDao() private val dao = database.bookkeepingDao()
private val memberDao = database.memberDao() private val memberDao = database.memberDao()
private val settingsRepository = SettingsRepository(database.settingsDao())
// 设置相关的状态
val settings: StateFlow<Settings?> = settingsRepository.getSettings()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = null
)
private val _isAutoBackupEnabled = MutableStateFlow(false) private val _isAutoBackupEnabled = MutableStateFlow(false)
val isAutoBackupEnabled: StateFlow<Boolean> = _isAutoBackupEnabled.asStateFlow() val isAutoBackupEnabled: StateFlow<Boolean> = _isAutoBackupEnabled.asStateFlow()
private val _monthStartDay = MutableStateFlow(1)
val monthStartDay: StateFlow<Int> = _monthStartDay.asStateFlow()
init {
viewModelScope.launch {
// 确保设置存在
settingsRepository.ensureSettingsExist()
// 监听设置变化
settings.collect { settings ->
settings?.let {
_isAutoBackupEnabled.value = it.autoBackupEnabled
_monthStartDay.value = it.monthStartDay
}
}
}
}
private val _selectedCategoryType = MutableStateFlow(TransactionType.EXPENSE) private val _selectedCategoryType = MutableStateFlow(TransactionType.EXPENSE)
val selectedCategoryType: StateFlow<TransactionType> = _selectedCategoryType.asStateFlow() val selectedCategoryType: StateFlow<TransactionType> = _selectedCategoryType.asStateFlow()
@ -85,12 +115,20 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
fun setAutoBackup(enabled: Boolean) { fun setAutoBackup(enabled: Boolean) {
viewModelScope.launch { viewModelScope.launch {
_isAutoBackupEnabled.value = enabled _isAutoBackupEnabled.value = enabled
settingsRepository.updateAutoBackupEnabled(enabled)
if (enabled) { if (enabled) {
schedulePeriodicBackup() schedulePeriodicBackup()
} }
} }
} }
fun setMonthStartDay(day: Int) {
viewModelScope.launch {
_monthStartDay.value = day
settingsRepository.updateMonthStartDay(day)
}
}
private fun schedulePeriodicBackup() { private fun schedulePeriodicBackup() {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
while (isAutoBackupEnabled.value) { while (isAutoBackupEnabled.value) {

BIN
img.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB