Compare commits
3 Commits
Author | SHA1 | Date | |
---|---|---|---|
f4f03ce0a4 | |||
4c1aa501e6 | |||
8bc3e987aa |
@ -32,6 +32,7 @@
|
|||||||
- [x] Material 3 设计界面
|
- [x] Material 3 设计界面
|
||||||
- [x] 深色/浅色主题切换
|
- [x] 深色/浅色主题切换
|
||||||
- [x] 主题色自定义
|
- [x] 主题色自定义
|
||||||
|
- [ ] 月度记账开始日期
|
||||||
|
|
||||||
### 1. 成员系统 (已完成 🎉)
|
### 1. 成员系统 (已完成 🎉)
|
||||||
- [x] 成员添加/编辑/删除
|
- [x] 成员添加/编辑/删除
|
||||||
|
@ -17,7 +17,7 @@ android {
|
|||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 34
|
targetSdk = 34
|
||||||
versionCode = 6
|
versionCode = 6
|
||||||
versionName = "1.4.0"
|
versionName = "1.3.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
|
@ -24,40 +24,66 @@ 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
|
||||||
@ -67,27 +93,37 @@ 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),
|
||||||
@ -95,27 +131,38 @@ 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(
|
||||||
@ -124,6 +171,9 @@ fun Greeting(name: String, modifier: Modifier = Modifier) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Greeting组件的预览函数
|
||||||
|
*/
|
||||||
@Preview(showBackground = true)
|
@Preview(showBackground = true)
|
||||||
@Composable
|
@Composable
|
||||||
fun GreetingPreview() {
|
fun GreetingPreview() {
|
||||||
@ -132,6 +182,9 @@ fun GreetingPreview() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 整个应用的预览函数
|
||||||
|
*/
|
||||||
@Preview(showBackground = true)
|
@Preview(showBackground = true)
|
||||||
@Composable
|
@Composable
|
||||||
fun BookkeepingAppPreview() {
|
fun BookkeepingAppPreview() {
|
||||||
|
@ -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) {
|
||||||
|
@ -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)
|
||||||
|
}
|
@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,7 +1,13 @@
|
|||||||
package com.yovinchen.bookkeeping.model
|
package com.yovinchen.bookkeeping.model
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分析类型枚举
|
||||||
|
* 定义记账应用中不同的数据分析视图类型
|
||||||
|
*
|
||||||
|
* 用于在数据分析模块中区分不同的分析维度和图表类型
|
||||||
|
*/
|
||||||
enum class AnalysisType {
|
enum class AnalysisType {
|
||||||
EXPENSE,
|
EXPENSE, // 支出分析,用于分析用户的支出情况
|
||||||
INCOME,
|
INCOME, // 收入分析,用于分析用户的收入情况
|
||||||
TREND
|
TREND // 趋势分析,用于分析用户收支随时间的变化趋势
|
||||||
}
|
}
|
||||||
|
@ -9,32 +9,71 @@ 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, EXPENSE
|
INCOME, // 收入
|
||||||
|
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 = [
|
||||||
@ -42,21 +81,21 @@ class Converters {
|
|||||||
entity = Member::class,
|
entity = Member::class,
|
||||||
parentColumns = ["id"],
|
parentColumns = ["id"],
|
||||||
childColumns = ["memberId"],
|
childColumns = ["memberId"],
|
||||||
onDelete = ForeignKey.SET_NULL
|
onDelete = ForeignKey.SET_NULL // 当关联的成员被删除时,将此字段设为NULL
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
indices = [
|
indices = [
|
||||||
Index(value = ["memberId"])
|
Index(value = ["memberId"]) // 在memberId上创建索引以提高查询性能
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
@TypeConverters(Converters::class)
|
@TypeConverters(Converters::class) // 应用类型转换器
|
||||||
data class BookkeepingRecord(
|
data class BookkeepingRecord(
|
||||||
@PrimaryKey(autoGenerate = true)
|
@PrimaryKey(autoGenerate = true)
|
||||||
val id: Long = 0,
|
val id: Long = 0, // 记录ID,自动生成
|
||||||
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 // 可为空,表示未指定成员
|
val memberId: Int? = null // 关联的成员ID,可为空表示未指定成员
|
||||||
)
|
)
|
||||||
|
@ -3,11 +3,18 @@ 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,
|
val id: Long = 0, // 分类ID,自动生成
|
||||||
val name: String,
|
val name: String, // 分类名称
|
||||||
val type: TransactionType,
|
val type: TransactionType, // 分类关联的交易类型(收入或支出)
|
||||||
val icon: Int? = null
|
val icon: Int? = null // 分类图标资源ID,可选,默认为null
|
||||||
)
|
)
|
||||||
|
@ -1,8 +1,14 @@
|
|||||||
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
|
val percentage: Double = 0.0 // 该分类金额占总金额的百分比(0.0-100.0)
|
||||||
)
|
)
|
||||||
|
@ -3,11 +3,18 @@ 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,
|
val id: Int = 0, // 成员ID,自动生成
|
||||||
val name: String,
|
val name: String, // 成员姓名
|
||||||
val description: String = "", // 可选的描述信息
|
val description: String = "", // 成员描述信息,可选,默认为空字符串
|
||||||
val icon: Int? = null // 新增icon字段,可为空
|
val icon: Int? = null // 成员图标资源ID,可选,默认为null
|
||||||
)
|
)
|
||||||
|
@ -2,16 +2,39 @@ 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
|
||||||
)
|
)
|
||||||
|
@ -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 // 上次备份时间
|
||||||
|
)
|
@ -2,9 +2,35 @@ 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()
|
||||||
}
|
}
|
||||||
|
@ -20,7 +20,6 @@ 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
|
||||||
|
@ -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(
|
||||||
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
@ -10,40 +10,72 @@ 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)
|
||||||
@ -51,6 +83,7 @@ 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()
|
||||||
@ -59,10 +92,18 @@ 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") ||
|
||||||
@ -73,11 +114,20 @@ 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)
|
||||||
@ -90,6 +140,13 @@ 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 ->
|
||||||
|
@ -6,77 +6,126 @@ 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_membership_24dp,
|
"奖金" to R.drawable.ic_category_gift_24dp, // 奖金类别对应礼物图标
|
||||||
"奖金" to R.drawable.ic_category_gift_24dp,
|
"投资" to R.drawable.ic_category_digital_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()
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新记录数据
|
// 更新记录数据
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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) {
|
||||||
|
Loading…
Reference in New Issue
Block a user