Compare commits

..

2 Commits

Author SHA1 Message Date
0d40a8abb0 "Merge changes and finalize updates" 2024-12-17 14:42:58 +08:00
9382e7adde update: 升级版本1.4 2024-12-17 14:13:45 +08:00
22 changed files with 120 additions and 757 deletions

View File

@ -32,7 +32,6 @@
- [x] Material 3 设计界面 - [x] Material 3 设计界面
- [x] 深色/浅色主题切换 - [x] 深色/浅色主题切换
- [x] 主题色自定义 - [x] 主题色自定义
- [ ] 月度记账开始日期
### 1. 成员系统 (已完成 🎉) ### 1. 成员系统 (已完成 🎉)
- [x] 成员添加/编辑/删除 - [x] 成员添加/编辑/删除

View File

@ -17,7 +17,7 @@ android {
minSdk = 26 minSdk = 26
targetSdk = 34 targetSdk = 34
versionCode = 6 versionCode = 6
versionName = "1.3.0" versionName = "1.4.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { vectorDrawables {

View File

@ -24,66 +24,40 @@ import com.yovinchen.bookkeeping.ui.navigation.MainNavigation
import com.yovinchen.bookkeeping.ui.theme.BookkeepingTheme import com.yovinchen.bookkeeping.ui.theme.BookkeepingTheme
import com.yovinchen.bookkeeping.utils.FilePickerUtil import com.yovinchen.bookkeeping.utils.FilePickerUtil
/**
* 全局文件选择器启动器
* 用于在整个应用程序中共享同一个文件选择器实例
*/
private var filePickerLauncher: ActivityResultLauncher<Array<String>>? = null private var filePickerLauncher: ActivityResultLauncher<Array<String>>? = null
/**
* 获取预先注册的文件选择器启动器的扩展函数
*
* @return 预先注册的文件选择器启动器
* @throws IllegalStateException 如果文件选择器未初始化
*/
fun ComponentActivity.getPreregisteredFilePickerLauncher(): ActivityResultLauncher<Array<String>> { fun ComponentActivity.getPreregisteredFilePickerLauncher(): ActivityResultLauncher<Array<String>> {
return filePickerLauncher ?: throw IllegalStateException("FilePickerLauncher not initialized") return filePickerLauncher ?: throw IllegalStateException("FilePickerLauncher not initialized")
} }
/**
* 应用程序的主活动
* 负责初始化应用界面和必要的系统组件
*/
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
// 设置系统窗口装饰,确保内容能够扩展到系统栏区域
WindowCompat.setDecorFitsSystemWindows(window, false) WindowCompat.setDecorFitsSystemWindows(window, false)
// 预注册文件选择器,用于处理文件选择操作 // 预注册文件选择器
filePickerLauncher = registerForActivityResult( filePickerLauncher = registerForActivityResult(
ActivityResultContracts.OpenDocument() ActivityResultContracts.OpenDocument()
) { uri: Uri? -> ) { uri: Uri? ->
// 当用户选择文件后,调用工具类处理文件选择结果
FilePickerUtil.handleFileSelection(this, uri) FilePickerUtil.handleFileSelection(this, uri)
} }
// 设置应用的主Compose内容
setContent { setContent {
BookkeepingApp() BookkeepingApp()
} }
} }
} }
/**
* 系统状态栏和导航栏颜色设置
* 根据当前主题模式设置系统UI元素的颜色和外观
*
* @param isDarkTheme 是否为暗色主题
*/
@Composable @Composable
private fun SystemBarColor(isDarkTheme: Boolean) { private fun SystemBarColor(isDarkTheme: Boolean) {
val view = LocalView.current val view = LocalView.current
if (!view.isInEditMode) { if (!view.isInEditMode) {
// 获取当前主题的表面颜色用于系统栏
val surfaceColor = MaterialTheme.colorScheme.surface.toArgb() val surfaceColor = MaterialTheme.colorScheme.surface.toArgb()
val currentWindow = (view.context as? Activity)?.window val currentWindow = (view.context as? Activity)?.window
SideEffect { SideEffect {
currentWindow?.let { window -> currentWindow?.let { window ->
// 设置状态栏和导航栏颜色
window.statusBarColor = surfaceColor window.statusBarColor = surfaceColor
window.navigationBarColor = surfaceColor window.navigationBarColor = surfaceColor
// 设置系统栏图标的亮暗模式,以确保在不同背景下的可见性
WindowCompat.getInsetsController(window, view).apply { WindowCompat.getInsetsController(window, view).apply {
isAppearanceLightStatusBars = !isDarkTheme isAppearanceLightStatusBars = !isDarkTheme
isAppearanceLightNavigationBars = !isDarkTheme isAppearanceLightNavigationBars = !isDarkTheme
@ -93,37 +67,27 @@ private fun SystemBarColor(isDarkTheme: Boolean) {
} }
} }
/**
* 记账应用的主Compose函数
* 处理主题设置并启动主导航组件
*/
@Composable @Composable
fun BookkeepingApp() { fun BookkeepingApp() {
// 跟踪当前应用的主题模式状态
var themeMode by remember { mutableStateOf<ThemeMode>(ThemeMode.FOLLOW_SYSTEM) } var themeMode by remember { mutableStateOf<ThemeMode>(ThemeMode.FOLLOW_SYSTEM) }
// 根据主题模式确定是否使用暗色主题
val isDarkTheme = when (themeMode) { val isDarkTheme = when (themeMode) {
is ThemeMode.FOLLOW_SYSTEM -> isSystemInDarkTheme() // 跟随系统设置 is ThemeMode.FOLLOW_SYSTEM -> isSystemInDarkTheme()
is ThemeMode.LIGHT -> false // 强制使用亮色主题 is ThemeMode.LIGHT -> false
is ThemeMode.DARK -> true // 强制使用暗色主题 is ThemeMode.DARK -> true
is ThemeMode.CUSTOM -> isSystemInDarkTheme() // 自定义主题下的基础亮暗模式仍跟随系统 is ThemeMode.CUSTOM -> isSystemInDarkTheme()
} }
// 处理自定义主题颜色方案
val customColorScheme = when (themeMode) { val customColorScheme = when (themeMode) {
is ThemeMode.CUSTOM -> { is ThemeMode.CUSTOM -> {
// 从主题模式中提取自定义主色
val primaryColor = (themeMode as ThemeMode.CUSTOM).primaryColor val primaryColor = (themeMode as ThemeMode.CUSTOM).primaryColor
if (isDarkTheme) { if (isDarkTheme) {
// 暗色模式下的自定义颜色方案
MaterialTheme.colorScheme.copy( MaterialTheme.colorScheme.copy(
primary = primaryColor, primary = primaryColor,
secondary = primaryColor.copy(alpha = 0.7f), secondary = primaryColor.copy(alpha = 0.7f),
tertiary = primaryColor.copy(alpha = 0.5f) tertiary = primaryColor.copy(alpha = 0.5f)
) )
} else { } else {
// 亮色模式下的自定义颜色方案
MaterialTheme.colorScheme.copy( MaterialTheme.colorScheme.copy(
primary = primaryColor, primary = primaryColor,
secondary = primaryColor.copy(alpha = 0.7f), secondary = primaryColor.copy(alpha = 0.7f),
@ -131,38 +95,27 @@ fun BookkeepingApp() {
) )
} }
} }
else -> null // 非自定义主题模式使用默认颜色方案 else -> null
} }
// 应用主题到整个应用内容
BookkeepingTheme( BookkeepingTheme(
darkTheme = isDarkTheme, darkTheme = isDarkTheme,
customColorScheme = customColorScheme customColorScheme = customColorScheme
) { ) {
// 设置系统状态栏和导航栏颜色
SystemBarColor(isDarkTheme) SystemBarColor(isDarkTheme)
// 创建填充整个屏幕的基础Surface
Surface( Surface(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.surface color = MaterialTheme.colorScheme.surface
) { ) {
// 启动主导航组件,并传递主题相关参数
MainNavigation( MainNavigation(
currentTheme = themeMode, currentTheme = themeMode,
onThemeChange = { themeMode = it } // 允许导航组件中的屏幕更改主题 onThemeChange = { themeMode = it }
) )
} }
} }
} }
/**
* 示例问候函数
* 仅用于开发预览和测试目的
*
* @param name 要显示的名称
* @param modifier 应用于Text组件的修饰符
*/
@Composable @Composable
fun Greeting(name: String, modifier: Modifier = Modifier) { fun Greeting(name: String, modifier: Modifier = Modifier) {
Text( Text(
@ -171,9 +124,6 @@ fun Greeting(name: String, modifier: Modifier = Modifier) {
) )
} }
/**
* Greeting组件的预览函数
*/
@Preview(showBackground = true) @Preview(showBackground = true)
@Composable @Composable
fun GreetingPreview() { fun GreetingPreview() {
@ -182,9 +132,6 @@ fun GreetingPreview() {
} }
} }
/**
* 整个应用的预览函数
*/
@Preview(showBackground = true) @Preview(showBackground = true)
@Composable @Composable
fun BookkeepingAppPreview() { fun BookkeepingAppPreview() {

View File

@ -13,15 +13,14 @@ 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, Settings::class], entities = [BookkeepingRecord::class, Category::class, Member::class],
version = 5, version = 4,
exportSchema = false exportSchema = false
) )
@TypeConverters(Converters::class) @TypeConverters(Converters::class)
@ -29,7 +28,6 @@ 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"
@ -126,28 +124,6 @@ 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
@ -158,7 +134,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, MIGRATION_4_5) .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4)
.addCallback(object : Callback() { .addCallback(object : Callback() {
override fun onCreate(db: SupportSQLiteDatabase) { override fun onCreate(db: SupportSQLiteDatabase) {
super.onCreate(db) super.onCreate(db)
@ -167,11 +143,6 @@ 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

@ -1,32 +0,0 @@
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

@ -1,45 +0,0 @@
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

@ -1,13 +1,7 @@
package com.yovinchen.bookkeeping.model package com.yovinchen.bookkeeping.model
/**
* 分析类型枚举
* 定义记账应用中不同的数据分析视图类型
*
* 用于在数据分析模块中区分不同的分析维度和图表类型
*/
enum class AnalysisType { enum class AnalysisType {
EXPENSE, // 支出分析,用于分析用户的支出情况 EXPENSE,
INCOME, // 收入分析,用于分析用户的收入情况 INCOME,
TREND // 趋势分析,用于分析用户收支随时间的变化趋势 TREND
} }

View File

@ -9,71 +9,32 @@ import androidx.room.TypeConverters
import com.yovinchen.bookkeeping.model.Member import com.yovinchen.bookkeeping.model.Member
import java.util.Date import java.util.Date
/**
* 交易类型枚举
* 定义记账记录的交易类型
*/
enum class TransactionType { enum class TransactionType {
INCOME, // 收入 INCOME, EXPENSE
EXPENSE // 支出
} }
/**
* Room数据库类型转换器
* 用于在数据库中存储和检索复杂类型
*/
class Converters { class Converters {
/**
* 将时间戳转换为Date对象
*
* @param value 时间戳毫秒
* @return 对应的Date对象如果输入为null则返回null
*/
@TypeConverter @TypeConverter
fun fromTimestamp(value: Long?): Date? { fun fromTimestamp(value: Long?): Date? {
return value?.let { Date(it) } return value?.let { Date(it) }
} }
/**
* 将Date对象转换为时间戳
*
* @param date Date对象
* @return 对应的时间戳毫秒如果输入为null则返回null
*/
@TypeConverter @TypeConverter
fun dateToTimestamp(date: Date?): Long? { fun dateToTimestamp(date: Date?): Long? {
return date?.time return date?.time
} }
/**
* 将字符串转换为TransactionType枚举
*
* @param value 交易类型的字符串表示
* @return 对应的TransactionType枚举值
*/
@TypeConverter @TypeConverter
fun fromTransactionType(value: String): TransactionType { fun fromTransactionType(value: String): TransactionType {
return enumValueOf<TransactionType>(value) return enumValueOf<TransactionType>(value)
} }
/**
* 将TransactionType枚举转换为字符串
*
* @param type TransactionType枚举值
* @return 对应的字符串表示
*/
@TypeConverter @TypeConverter
fun transactionTypeToString(type: TransactionType): String { fun transactionTypeToString(type: TransactionType): String {
return type.name return type.name
} }
} }
/**
* 记账记录实体类
* 用于在Room数据库中存储用户的收支记录
*
* 该实体与Member实体存在外键关系表示每条记录可以关联到一个家庭成员
*/
@Entity( @Entity(
tableName = "bookkeeping_records", tableName = "bookkeeping_records",
foreignKeys = [ foreignKeys = [
@ -81,21 +42,21 @@ class Converters {
entity = Member::class, entity = Member::class,
parentColumns = ["id"], parentColumns = ["id"],
childColumns = ["memberId"], childColumns = ["memberId"],
onDelete = ForeignKey.SET_NULL // 当关联的成员被删除时将此字段设为NULL onDelete = ForeignKey.SET_NULL
) )
], ],
indices = [ indices = [
Index(value = ["memberId"]) // 在memberId上创建索引以提高查询性能 Index(value = ["memberId"])
] ]
) )
@TypeConverters(Converters::class) // 应用类型转换器 @TypeConverters(Converters::class)
data class BookkeepingRecord( data class BookkeepingRecord(
@PrimaryKey(autoGenerate = true) @PrimaryKey(autoGenerate = true)
val id: Long = 0, // 记录ID自动生成 val id: Long = 0,
val amount: Double, // 金额 val amount: Double,
val type: TransactionType, // 交易类型(收入或支出) val type: TransactionType,
val category: String, // 分类 val category: String,
val description: String, // 描述 val description: String,
val date: Date, // 日期 val date: Date,
val memberId: Int? = null // 关联的成员ID可为空表示未指定成员 val memberId: Int? = null // 可为空表示未指定成员
) )

View File

@ -3,18 +3,11 @@ package com.yovinchen.bookkeeping.model
import androidx.room.Entity import androidx.room.Entity
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
/**
* 交易分类实体类
* 用于在Room数据库中存储收支分类信息
*
* 在记账应用中每条记账记录都属于某个分类
* "餐饮""交通""工资"便于用户对支出和收入进行分类统计
*/
@Entity(tableName = "categories") @Entity(tableName = "categories")
data class Category( data class Category(
@PrimaryKey(autoGenerate = true) @PrimaryKey(autoGenerate = true)
val id: Long = 0, // 分类ID自动生成 val id: Long = 0,
val name: String, // 分类名称 val name: String,
val type: TransactionType, // 分类关联的交易类型(收入或支出) val type: TransactionType,
val icon: Int? = null // 分类图标资源ID可选默认为null val icon: Int? = null
) )

View File

@ -1,14 +1,8 @@
package com.yovinchen.bookkeeping.model package com.yovinchen.bookkeeping.model
/**
* 分类统计数据类
* 用于表示某个分类的统计信息通常用于数据分析和图表展示
*
* 该类不是数据库实体而是从数据库查询结果中聚合生成的统计数据
*/
data class CategoryStat( data class CategoryStat(
val category: String, // 分类名称 val category: String,
val amount: Double, // 该分类的总金额 val amount: Double,
val count: Int = 0, // 该分类下的记录数量 val count: Int = 0,
val percentage: Double = 0.0 // 该分类金额占总金额的百分比0.0-100.0 val percentage: Double = 0.0
) )

View File

@ -3,18 +3,11 @@ package com.yovinchen.bookkeeping.model
import androidx.room.Entity import androidx.room.Entity
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
/**
* 家庭成员实体类
* 用于在Room数据库中存储家庭成员信息
*
* 在记账应用中每条记账记录可以关联到特定的家庭成员
* 以便追踪不同成员的收支情况
*/
@Entity(tableName = "members") @Entity(tableName = "members")
data class Member( data class Member(
@PrimaryKey(autoGenerate = true) @PrimaryKey(autoGenerate = true)
val id: Int = 0, // 成员ID自动生成 val id: Int = 0,
val name: String, // 成员姓名 val name: String,
val description: String = "", // 成员描述信息,可选,默认为空字符串 val description: String = "", // 可选的描述信息
val icon: Int? = null // 成员图标资源ID可选默认为null val icon: Int? = null // 新增icon字段可为空
) )

View File

@ -2,39 +2,16 @@ package com.yovinchen.bookkeeping.model
import androidx.room.ColumnInfo import androidx.room.ColumnInfo
/**
* 家庭成员统计数据类
* 用于表示某个成员的统计信息通常用于数据分析和图表展示
*
* 该类不是数据库实体而是通过数据库查询直接映射的结果类
* 表示按成员分组的聚合数据
*/
data class MemberStat( data class MemberStat(
/**
* 成员名称
* 映射数据库查询结果中的member列
*/
@ColumnInfo(name = "member") @ColumnInfo(name = "member")
val member: String, val member: String,
/**
* 该成员的总金额
* 映射数据库查询结果中的amount列
*/
@ColumnInfo(name = "amount") @ColumnInfo(name = "amount")
val amount: Double, val amount: Double,
/**
* 该成员下的记录数量
* 映射数据库查询结果中的count列
*/
@ColumnInfo(name = "count") @ColumnInfo(name = "count")
val count: Int, val count: Int,
/**
* 该成员金额占总金额的百分比0.0-100.0
* 映射数据库查询结果中的percentage列
*/
@ColumnInfo(name = "percentage") @ColumnInfo(name = "percentage")
val percentage: Double = 0.0 val percentage: Double = 0.0
) )

View File

@ -1,14 +0,0 @@
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

@ -2,35 +2,9 @@ package com.yovinchen.bookkeeping.model
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
/**
* 主题模式密封类
* 用于表示应用程序的不同主题设置选项
* 通过密封类实现限制可能的主题模式类型
*/
sealed class ThemeMode { sealed class ThemeMode {
/**
* 跟随系统主题模式
* 应用将根据设备系统的暗色/亮色主题设置自动调整
*/
object FOLLOW_SYSTEM : ThemeMode() object FOLLOW_SYSTEM : ThemeMode()
/**
* 固定亮色主题模式
* 无论设备系统设置如何应用将始终使用亮色主题
*/
object LIGHT : ThemeMode() object LIGHT : ThemeMode()
/**
* 固定暗色主题模式
* 无论设备系统设置如何应用将始终使用暗色主题
*/
object DARK : ThemeMode() object DARK : ThemeMode()
/**
* 自定义主题模式
* 允许用户选择自定义的主题颜色
*
* @property primaryColor 用户选择的主要颜色将影响应用的主色调
*/
data class CUSTOM(val primaryColor: Color) : ThemeMode() data class CUSTOM(val primaryColor: Color) : ThemeMode()
} }

View File

@ -20,6 +20,7 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState

View File

@ -4,19 +4,13 @@ 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
@ -42,11 +36,8 @@ 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(
@ -91,15 +82,6 @@ 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 },
@ -163,76 +145,6 @@ 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

@ -1,85 +0,0 @@
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

@ -10,72 +10,40 @@ import com.yovinchen.bookkeeping.getPreregisteredFilePickerLauncher
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
/**
* 文件选择器工具类
* 用于处理文件选择权限获取和文件处理的工具类
*
* 主要功能
* 1. 启动系统文件选择器
* 2. 处理选择结果
* 3. 将选择的文件复制到应用缓存目录
* 4. 文件类型验证
*/
object FilePickerUtil { object FilePickerUtil {
/**
* 当前活跃的文件选择回调
* 用于在文件选择完成后调用
*/
private var currentCallback: ((File) -> Unit)? = null private var currentCallback: ((File) -> Unit)? = null
/**
* 启动文件选择器
*
* @param activity 当前活动用于启动文件选择器
* @param onFileSelected 文件选择完成后的回调函数参数为选中的文件
*/
fun startFilePicker(activity: ComponentActivity, onFileSelected: (File) -> Unit) { fun startFilePicker(activity: ComponentActivity, onFileSelected: (File) -> Unit) {
currentCallback = onFileSelected currentCallback = onFileSelected
try { try {
// 设置可选择的文件类型限制为CSV和Excel文件
val mimeTypes = arrayOf( val mimeTypes = arrayOf(
"text/csv", "text/csv",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.ms-excel" "application/vnd.ms-excel"
) )
// 使用预注册的文件选择器启动文件选择流程
activity.getPreregisteredFilePickerLauncher().launch(mimeTypes) activity.getPreregisteredFilePickerLauncher().launch(mimeTypes)
} catch (e: Exception) { } catch (e: Exception) {
// 文件选择器启动失败时显示错误提示
Toast.makeText(activity, "无法启动文件选择器:${e.message}", Toast.LENGTH_SHORT).show() Toast.makeText(activity, "无法启动文件选择器:${e.message}", Toast.LENGTH_SHORT).show()
currentCallback = null currentCallback = null
} }
} }
/**
* 处理文件选择结果
*
* @param context 上下文对象用于访问ContentResolver
* @param uri 选中文件的URI如果用户取消选择则为null
*/
fun handleFileSelection(context: Context, uri: Uri?) { fun handleFileSelection(context: Context, uri: Uri?) {
if (uri == null) { if (uri == null) {
// 用户未选择文件时显示提示
Toast.makeText(context, "未选择文件", Toast.LENGTH_SHORT).show() Toast.makeText(context, "未选择文件", Toast.LENGTH_SHORT).show()
currentCallback = null currentCallback = null
return return
} }
try { try {
// 获取文件MIME类型
val mimeType = context.contentResolver.getType(uri) val mimeType = context.contentResolver.getType(uri)
// 验证文件类型是否合法
if (!isValidFileType(uri.toString(), mimeType)) { if (!isValidFileType(uri.toString(), mimeType)) {
Toast.makeText(context, "请选择CSV或Excel文件", Toast.LENGTH_SHORT).show() Toast.makeText(context, "请选择CSV或Excel文件", Toast.LENGTH_SHORT).show()
return return
} }
// 获取持久性权限,确保应用在重启后仍能访问该文件 // 获取持久性权限
val takeFlags: Int = Intent.FLAG_GRANT_READ_URI_PERMISSION or val takeFlags: Int = Intent.FLAG_GRANT_READ_URI_PERMISSION or
Intent.FLAG_GRANT_WRITE_URI_PERMISSION Intent.FLAG_GRANT_WRITE_URI_PERMISSION
context.contentResolver.takePersistableUriPermission(uri, takeFlags) context.contentResolver.takePersistableUriPermission(uri, takeFlags)
@ -83,7 +51,6 @@ object FilePickerUtil {
// 将选中的文件复制到应用私有目录 // 将选中的文件复制到应用私有目录
val tempFile = copyUriToTempFile(context, uri) val tempFile = copyUriToTempFile(context, uri)
if (tempFile != null) { if (tempFile != null) {
// 调用回调函数,传递临时文件
currentCallback?.invoke(tempFile) currentCallback?.invoke(tempFile)
} else { } else {
Toast.makeText(context, "文件处理失败,请重试", Toast.LENGTH_SHORT).show() Toast.makeText(context, "文件处理失败,请重试", Toast.LENGTH_SHORT).show()
@ -92,18 +59,10 @@ object FilePickerUtil {
e.printStackTrace() e.printStackTrace()
Toast.makeText(context, "文件处理出错:${e.message}", Toast.LENGTH_SHORT).show() Toast.makeText(context, "文件处理出错:${e.message}", Toast.LENGTH_SHORT).show()
} finally { } finally {
// 清除回调引用,避免内存泄漏
currentCallback = null currentCallback = null
} }
} }
/**
* 验证文件类型是否合法
*
* @param fileName 文件名用于检查文件扩展名
* @param mimeType 文件MIME类型
* @return 如果文件类型合法则返回true否则返回false
*/
private fun isValidFileType(fileName: String, mimeType: String?): Boolean { private fun isValidFileType(fileName: String, mimeType: String?): Boolean {
val fileExtension = fileName.lowercase() val fileExtension = fileName.lowercase()
return fileExtension.endsWith(".csv") || return fileExtension.endsWith(".csv") ||
@ -114,20 +73,11 @@ object FilePickerUtil {
mimeType == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" mimeType == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
} }
/**
* 将URI指向的文件复制到应用缓存目录
*
* @param context 上下文对象用于访问ContentResolver和缓存目录
* @param uri 要复制的文件URI
* @return 复制后的临时文件如果复制失败则返回null
*/
private fun copyUriToTempFile(context: Context, uri: Uri): File? { private fun copyUriToTempFile(context: Context, uri: Uri): File? {
return try { return try {
// 获取文件名,如果无法获取则使用时间戳作为文件名
val fileName = getFileName(context, uri) ?: "temp_backup_${System.currentTimeMillis()}" val fileName = getFileName(context, uri) ?: "temp_backup_${System.currentTimeMillis()}"
val tempFile = File(context.cacheDir, fileName) val tempFile = File(context.cacheDir, fileName)
// 从URI读取内容并写入临时文件
context.contentResolver.openInputStream(uri)?.use { inputStream -> context.contentResolver.openInputStream(uri)?.use { inputStream ->
FileOutputStream(tempFile).use { outputStream -> FileOutputStream(tempFile).use { outputStream ->
inputStream.copyTo(outputStream) inputStream.copyTo(outputStream)
@ -140,13 +90,6 @@ object FilePickerUtil {
} }
} }
/**
* 从URI中获取文件名
*
* @param context 上下文对象用于访问ContentResolver
* @param uri 文件URI
* @return 文件名如果无法获取则返回null
*/
private fun getFileName(context: Context, uri: Uri): String? { private fun getFileName(context: Context, uri: Uri): String? {
var fileName: String? = null var fileName: String? = null
context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->

View File

@ -6,126 +6,77 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource import androidx.compose.ui.res.vectorResource
import com.yovinchen.bookkeeping.R import com.yovinchen.bookkeeping.R
/**
* 图标管理器
* 集中管理应用中使用的各类图标资源
*
* 主要功能
* 1. 管理分类图标和成员图标的映射关系
* 2. 提供根据名称获取对应图标的方法
* 3. 提供获取所有可用图标的方法
*/
object IconManager { object IconManager {
/** // 类别图标映射
* 类别图标映射
* 将分类名称映射到对应的图标资源ID
*/
private val categoryIcons = mapOf( private val categoryIcons = mapOf(
"餐饮" to R.drawable.ic_category_food_24dp, // 餐饮类别对应食物图标 "餐饮" to R.drawable.ic_category_food_24dp,
"交通" to R.drawable.ic_category_taxi_24dp, // 交通类别对应出租车图标 "交通" to R.drawable.ic_category_taxi_24dp,
"购物" to R.drawable.ic_category_supermarket_24dp, // 购物类别对应超市图标 "购物" to R.drawable.ic_category_supermarket_24dp,
"娱乐" to R.drawable.ic_category_bar_24dp, // 娱乐类别对应酒吧图标 "娱乐" to R.drawable.ic_category_bar_24dp,
"居住" to R.drawable.ic_category_hotel_24dp, // 居住类别对应酒店图标 "居住" to R.drawable.ic_category_hotel_24dp,
"医疗" to R.drawable.ic_category_medicine_24dp, // 医疗类别对应药品图标 "医疗" to R.drawable.ic_category_medicine_24dp,
"教育" to R.drawable.ic_category_training_24dp, // 教育类别对应培训图标 "教育" to R.drawable.ic_category_training_24dp,
"宠物" to R.drawable.ic_category_pet_24dp, // 宠物类别对应宠物图标 "宠物" to R.drawable.ic_category_pet_24dp,
"鲜花" to R.drawable.ic_category_flower_24dp, // 鲜花类别对应花图标 "鲜花" to R.drawable.ic_category_flower_24dp,
"外卖" to R.drawable.ic_category_delivery_24dp, // 外卖类别对应外卖图标 "外卖" to R.drawable.ic_category_delivery_24dp,
"数码" to R.drawable.ic_category_digital_24dp, // 数码类别对应数码产品图标 "数码" to R.drawable.ic_category_digital_24dp,
"化妆品" to R.drawable.ic_category_cosmetics_24dp, // 化妆品类别对应化妆品图标 "化妆品" to R.drawable.ic_category_cosmetics_24dp,
"水果" to R.drawable.ic_category_fruit_24dp, // 水果类别对应水果图标 "水果" to R.drawable.ic_category_fruit_24dp,
"零食" to R.drawable.ic_category_snack_24dp, // 零食类别对应零食图标 "零食" to R.drawable.ic_category_snack_24dp,
"蔬菜" to R.drawable.ic_category_vegetable_24dp, // 蔬菜类别对应蔬菜图标 "蔬菜" to R.drawable.ic_category_vegetable_24dp,
"工资" to R.drawable.ic_category_membership_24dp, // 工资类别对应会员图标 "工资" to R.drawable.ic_category_membership_24dp,
"礼物" to R.drawable.ic_category_gift_24dp, // 礼物类别对应礼物图标 "礼物" to R.drawable.ic_category_gift_24dp,
"其他" to R.drawable.ic_category_more_24dp, // 其他类别对应更多图标 "其他" to R.drawable.ic_category_more_24dp,
"会员" to R.drawable.ic_category_membership_24dp, // 会员类别对应会员图标 "工资" to R.drawable.ic_category_membership_24dp,
"奖金" to R.drawable.ic_category_gift_24dp, // 奖金类别对应礼物图标 "会员" to R.drawable.ic_category_membership_24dp,
"投资" to R.drawable.ic_category_digital_24dp // 投资类别对应数码图标 "奖金" to R.drawable.ic_category_gift_24dp,
"投资" to R.drawable.ic_category_digital_24dp,
"其他" to R.drawable.ic_category_more_24dp
) )
/** // 成员图标映射
* 成员图标映射
* 将成员角色名称映射到对应的图标资源ID
*/
private val memberIcons = mapOf( private val memberIcons = mapOf(
"自己" to R.drawable.ic_member_boy_24dp, // 自己对应男孩图标 "自己" to R.drawable.ic_member_boy_24dp,
"老婆" to R.drawable.ic_member_bride_24dp, // 老婆对应新娘图标 "老婆" to R.drawable.ic_member_bride_24dp,
"老公" to R.drawable.ic_member_groom_24dp, // 老公对应新郎图标 "老公" to R.drawable.ic_member_groom_24dp,
"家庭" to R.drawable.ic_member_family_24dp, // 家庭对应家庭图标 "家庭" to R.drawable.ic_member_family_24dp,
"儿子" to R.drawable.ic_member_baby_boy_24dp, // 儿子对应男婴图标 "儿子" to R.drawable.ic_member_baby_boy_24dp,
"女儿" to R.drawable.ic_member_baby_girl_24dp, // 女儿对应女婴图标 "女儿" to R.drawable.ic_member_baby_girl_24dp,
"爸爸" to R.drawable.ic_member_father_24dp, // 爸爸对应父亲图标 "爸爸" to R.drawable.ic_member_father_24dp,
"妈妈" to R.drawable.ic_member_mother_24dp, // 妈妈对应母亲图标 "妈妈" to R.drawable.ic_member_mother_24dp,
"爷爷" to R.drawable.ic_member_grandfather_24dp, // 爷爷对应祖父图标 "爷爷" to R.drawable.ic_member_grandfather_24dp,
"奶奶" to R.drawable.ic_member_grandmother_24dp, // 奶奶对应祖母图标 "奶奶" to R.drawable.ic_member_grandmother_24dp,
"男生" to R.drawable.ic_member_boy_24dp, // 男生对应男孩图标 "男生" to R.drawable.ic_member_boy_24dp,
"女生" to R.drawable.ic_member_girl_24dp, // 女生对应女孩图标 "女生" to R.drawable.ic_member_girl_24dp,
"外公" to R.drawable.ic_member_grandfather_24dp, // 外公对应祖父图标 "外公" to R.drawable.ic_member_grandfather_24dp,
"外婆" to R.drawable.ic_member_grandmother_24dp, // 外婆对应祖母图标 "外婆" to R.drawable.ic_member_grandmother_24dp,
"其他" to R.drawable.ic_member_girl_24dp // 其他成员使用女孩图标作为默认值 "其他" to R.drawable.ic_member_girl_24dp
) )
/**
* 获取分类对应的图标向量
* 用于在Compose UI中直接使用
*
* @param name 分类名称
* @return 对应的图标向量如果未找到则返回null
*/
@Composable @Composable
fun getCategoryIconVector(name: String): ImageVector? { fun getCategoryIconVector(name: String): ImageVector? {
return categoryIcons[name]?.let { ImageVector.vectorResource(id = it) } return categoryIcons[name]?.let { ImageVector.vectorResource(id = it) }
} }
/**
* 获取成员对应的图标向量
* 用于在Compose UI中直接使用
*
* @param name 成员名称
* @return 对应的图标向量如果未找到则返回null
*/
@Composable @Composable
fun getMemberIconVector(name: String): ImageVector? { fun getMemberIconVector(name: String): ImageVector? {
return memberIcons[name]?.let { ImageVector.vectorResource(id = it) } return memberIcons[name]?.let { ImageVector.vectorResource(id = it) }
} }
/**
* 获取分类对应的图标资源ID
*
* @param name 分类名称
* @return 对应的图标资源ID如果未找到则返回null
*/
@DrawableRes @DrawableRes
fun getCategoryIcon(name: String): Int? { fun getCategoryIcon(name: String): Int? {
return categoryIcons[name] return categoryIcons[name]
} }
/**
* 获取成员对应的图标资源ID
*
* @param name 成员名称
* @return 对应的图标资源ID如果未找到则返回null
*/
@DrawableRes @DrawableRes
fun getMemberIcon(name: String): Int? { fun getMemberIcon(name: String): Int? {
return memberIcons[name] return memberIcons[name]
} }
/**
* 获取所有可用的分类图标资源ID列表
*
* @return 所有分类图标的资源ID列表
*/
fun getAllCategoryIcons(): List<Int> { fun getAllCategoryIcons(): List<Int> {
return categoryIcons.values.toList() return categoryIcons.values.toList()
} }
/**
* 获取所有可用的成员图标资源ID列表
*
* @return 所有成员图标的资源ID列表
*/
fun getAllMemberIcons(): List<Int> { fun getAllMemberIcons(): List<Int> {
return memberIcons.values.toList() return memberIcons.values.toList()
} }

View File

@ -4,22 +4,21 @@ 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()
@ -39,41 +38,16 @@ 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 {
settingsRepository.getSettings().collect { settings -> combine(startMonth, endMonth, selectedAnalysisType) { start, end, type ->
_monthStartDay.value = settings?.monthStartDay ?: 1 Triple(start, end, type)
} }.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
} }
@ -86,16 +60,16 @@ class AnalysisViewModel(application: Application) : AndroidViewModel(application
_selectedAnalysisType.value = type _selectedAnalysisType.value = type
} }
private suspend fun updateStats(startMonth: YearMonth, endMonth: YearMonth, type: AnalysisType, monthStartDay: Int) { private suspend fun updateStats(startMonth: YearMonth, endMonth: YearMonth, type: AnalysisType) {
val records = recordDao.getAllRecords().first() val records = recordDao.getAllRecords().first()
// 使用 DateUtils 过滤日期范围内的记录 // 过滤日期范围内的记录
val monthRecords = records.filter { record -> val monthRecords = records.filter {
val recordDate = Date(record.date.time) val recordDate = Date(it.date.time)
val accountingMonth = DateUtils.getAccountingMonth(recordDate, monthStartDay) val localDateTime = LocalDateTime.ofInstant(recordDate.toInstant(), ZoneId.systemDefault())
val yearMonth = YearMonth.from(localDateTime)
// 检查记账月份是否在选定的范围内 yearMonth.isAfter(startMonth.minusMonths(1)) &&
accountingMonth >= startMonth && accountingMonth <= endMonth yearMonth.isBefore(endMonth.plusMonths(1))
} }
// 更新记录数据 // 更新记录数据

View File

@ -4,12 +4,10 @@ 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
@ -20,26 +18,9 @@ import java.util.*
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
class HomeViewModel(application: Application) : AndroidViewModel(application) { class HomeViewModel(application: Application) : AndroidViewModel(application) {
private val database = BookkeepingDatabase.getDatabase(application) private val bookkeepingDao = BookkeepingDatabase.getDatabase(application).bookkeepingDao()
private val bookkeepingDao = database.bookkeepingDao() private val memberDao = BookkeepingDatabase.getDatabase(application).memberDao()
private val memberDao = database.memberDao() private val categoryDao = BookkeepingDatabase.getDatabase(application).categoryDao()
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()
@ -75,13 +56,17 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
allRecords, allRecords,
_selectedRecordType, _selectedRecordType,
_selectedMonth, _selectedMonth,
_selectedMember, _selectedMember
_monthStartDay ) { records, selectedType, selectedMonth, selectedMember ->
) { 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 = DateUtils.isInAccountingMonth(record.date, selectedMonth, monthStartDay) val monthMatches = recordYearMonth == selectedMonth
val memberMatches = selectedMember?.let { record.memberId == it.id } ?: true val memberMatches = selectedMember?.let { record.memberId == it.id } ?: true
monthMatches && memberMatches && typeMatches monthMatches && memberMatches && typeMatches
@ -105,12 +90,16 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
val totalIncome = combine( val totalIncome = combine(
allRecords, allRecords,
_selectedMonth, _selectedMonth,
_selectedMember, _selectedMember
_monthStartDay ) { records, selectedMonth, selectedMember ->
) { records, selectedMonth, selectedMember, monthStartDay ->
records records
.filter { record -> .filter { record ->
val monthMatches = DateUtils.isInAccountingMonth(record.date, selectedMonth, monthStartDay) val recordDate = record.date.toInstant()
.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
@ -126,12 +115,16 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
val totalExpense = combine( val totalExpense = combine(
allRecords, allRecords,
_selectedMonth, _selectedMonth,
_selectedMember, _selectedMember
_monthStartDay ) { records, selectedMonth, selectedMember ->
) { records, selectedMonth, selectedMember, monthStartDay ->
records records
.filter { record -> .filter { record ->
val monthMatches = DateUtils.isInAccountingMonth(record.date, selectedMonth, monthStartDay) val recordDate = record.date.toInstant()
.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,10 +9,8 @@ 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
@ -40,37 +38,9 @@ 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()
@ -115,20 +85,12 @@ 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) {