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

Merged
yovinchen merged 1 commits from detached into develop 2025-07-14 15:19:51 +08:00
9 changed files with 408 additions and 44 deletions

View File

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

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

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.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<YearMonth> = _startMonth.asStateFlow()
@ -38,16 +39,41 @@ class AnalysisViewModel(application: Application) : AndroidViewModel(application
private val _records = MutableStateFlow<List<BookkeepingRecord>>(emptyList())
val records: StateFlow<List<BookkeepingRecord>> = _records.asStateFlow()
// 存储月度开始日期设置
private val _monthStartDay = MutableStateFlow(1)
val monthStartDay: StateFlow<Int> = _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
}
// 更新记录数据

View File

@ -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<Int> = _monthStartDay.asStateFlow()
init {
viewModelScope.launch {
settingsRepository.ensureSettingsExist()
settingsRepository.getSettings().collect { settings ->
settings?.let {
_monthStartDay.value = it.monthStartDay
}
}
}
}
private val _selectedRecordType = MutableStateFlow<TransactionType?>(null)
val selectedRecordType: StateFlow<TransactionType?> = _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

View File

@ -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<Settings?> = settingsRepository.getSettings()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = null
)
private val _isAutoBackupEnabled = MutableStateFlow(false)
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)
val selectedCategoryType: StateFlow<TransactionType> = _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) {