chore: merge feature/member into master
This commit is contained in:
commit
d0bd40421a
@ -4,6 +4,14 @@
|
||||
<selectionStates>
|
||||
<SelectionState runConfigName="app">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
<DropdownSelection timestamp="2024-11-27T02:15:16.043756Z">
|
||||
<Target type="DEFAULT_BOOT">
|
||||
<handle>
|
||||
<DeviceId pluginId="LocalEmulator" identifier="path=/Users/yovinchen/.android/avd/Pixel_7a_API_34.avd" />
|
||||
</handle>
|
||||
</Target>
|
||||
</DropdownSelection>
|
||||
<DialogSelection />
|
||||
</SelectionState>
|
||||
</selectionStates>
|
||||
</component>
|
||||
|
@ -1,5 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="GradleMigrationSettings" migrationVersion="1" />
|
||||
<component name="GradleSettings">
|
||||
<option name="linkedExternalProjectsSettings">
|
||||
<GradleProjectSettings>
|
||||
|
6
.idea/studiobot.xml
Normal file
6
.idea/studiobot.xml
Normal file
@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="StudioBotProjectSettings">
|
||||
<option name="shareContext" value="OptedIn" />
|
||||
</component>
|
||||
</project>
|
128
README.md
128
README.md
@ -1,103 +1,59 @@
|
||||
# Bookkeeping App
|
||||
# 轻记账 (Lightweight Bookkeeping)
|
||||
|
||||
一个基于 Jetpack Compose 开发的现代化记账应用。
|
||||
一个轻量级的个人记账应用,专注于隐私和离线使用。
|
||||
|
||||
## 项目概述
|
||||
## 🌟 特点
|
||||
|
||||
本项目是一个使用 Kotlin 和 Jetpack Compose 开发的 Android 记账应用,采用 MVVM 架构,提供简洁直观的用户界面和丰富的记账功能。
|
||||
- 🔒 完全离线运行,无需网络连接
|
||||
- 📱 极简权限要求,仅使用必要的系统权限
|
||||
- 💰 支持收入和支出记录
|
||||
- 👥 支持多人记账
|
||||
- 📊 按日期和类别统计
|
||||
- 🎨 Material You 设计风格
|
||||
|
||||
## 主要特性
|
||||
## 🛠 技术栈
|
||||
|
||||
- 💰 收入/支出记录管理
|
||||
- 👥 成员管理系统
|
||||
- 📊 分类管理系统
|
||||
- 📅 自定义日期选择器
|
||||
- 📈 月度统计视图
|
||||
- 🎨 Material 3 设计风格
|
||||
- 语言:Kotlin
|
||||
- UI框架:Jetpack Compose
|
||||
- 数据库:Room
|
||||
- 架构:MVVM
|
||||
|
||||
## 技术栈
|
||||
## 📱 功能
|
||||
|
||||
- 开发语言:Kotlin
|
||||
- UI 框架:Jetpack Compose
|
||||
- 架构模式:MVVM
|
||||
- 数据存储:Room Database
|
||||
- 依赖注入:Hilt
|
||||
- 异步处理:Kotlin Coroutines
|
||||
### 记账管理
|
||||
- 收入和支出记录
|
||||
- 自定义分类管理
|
||||
- 日期和时间选择
|
||||
- 备注说明
|
||||
|
||||
## 开发计划
|
||||
### 成员管理
|
||||
- 多人记账支持
|
||||
- 成员关联记录
|
||||
- 按成员筛选统计
|
||||
|
||||
### 0. 基础功能 (已完成)
|
||||
- [x] 收入/支出记录管理
|
||||
- [x] 分类管理系统
|
||||
- [x] 默认分类
|
||||
- [x] 自定义分类
|
||||
- [x] 分类编辑/删除
|
||||
- [x] 自定义日期选择器
|
||||
- [x] Material 3 设计界面
|
||||
- [x] 深色/浅色主题切换
|
||||
- [x] 主题色自定义
|
||||
### 数据统计
|
||||
- 月度收支统计
|
||||
- 分类统计
|
||||
- 每日收支明细
|
||||
|
||||
### 1. 成员管理功能 (feature/member)
|
||||
- [ ] 成员添加/编辑/删除
|
||||
- [ ] 记账时选择相关成员
|
||||
- [ ] 成员消费统计
|
||||
- [ ] 成员间账单分摊
|
||||
## 🔒 隐私保护
|
||||
|
||||
### 2. 数据统计与可视化 (feature/statistics)
|
||||
- [ ] 支出/收入趋势图表
|
||||
- [ ] 分类占比饼图
|
||||
- [ ] 月度/年度报表
|
||||
- 完全离线运行,数据存储在本地
|
||||
- 无需任何网络权限
|
||||
- 最小化系统权限要求
|
||||
|
||||
### 3. 数据导出与备份 (feature/backup)
|
||||
- [ ] 导出 CSV/Excel 功能
|
||||
- [ ] 云端备份支持
|
||||
- [ ] 数据迁移工具
|
||||
## 📝 系统要求
|
||||
|
||||
### 4. 预算管理 (feature/budget)
|
||||
- [ ] 月度预算设置
|
||||
- [ ] 预算超支提醒
|
||||
- [ ] 分类预算管理
|
||||
- Android 5.0 (API 21) 或更高版本
|
||||
- 存储权限(用于数据备份,可选)
|
||||
|
||||
### 5. 用户体验优化 (feature/ux-enhancement)
|
||||
- [x] 深色模式支持
|
||||
- [ ] 手势操作优化
|
||||
- [ ] 快速记账小组件
|
||||
- [ ] 多语言支持
|
||||
## 🔜 未来计划
|
||||
|
||||
### 6. 性能优化 (feature/performance)
|
||||
- [ ] 大数据量处理优化
|
||||
- [ ] 启动速度优化
|
||||
- [ ] 内存使用优化
|
||||
- [ ] 数据导出和备份
|
||||
- [ ] 预算管理
|
||||
- [ ] 更多统计图表
|
||||
- [ ] 自定义主题
|
||||
|
||||
## 分支管理
|
||||
## 📄 许可证
|
||||
|
||||
- `master`: 稳定主分支
|
||||
- `develop`: 主开发分支
|
||||
- `feature/*`: 功能开发分支
|
||||
- `release/*`: 版本发布分支
|
||||
|
||||
## 版本历史
|
||||
|
||||
### v1.0.0
|
||||
- ✨ 基础记账功能
|
||||
- 收入/支出记录
|
||||
- 金额、日期、分类、备注管理
|
||||
- 🎨 Material 3 设计界面
|
||||
- 深色/浅色主题切换
|
||||
- 主题色自定义
|
||||
- 📊 分类管理
|
||||
- 默认分类预设
|
||||
- 自定义分类支持
|
||||
- 分类编辑与删除
|
||||
- 📅 月度统计
|
||||
- 月度收支总览
|
||||
- 月份快速切换
|
||||
- 🗓️ 自定义日期选择器
|
||||
|
||||
## 贡献指南
|
||||
|
||||
欢迎提交 Issue 和 Pull Request 来帮助改进项目。
|
||||
|
||||
## 许可证
|
||||
|
||||
本项目采用 MIT 许可证。
|
||||
[MIT License](LICENSE)
|
||||
|
@ -12,50 +12,42 @@ interface BookkeepingDao {
|
||||
@Query("SELECT * FROM bookkeeping_records ORDER BY date DESC")
|
||||
fun getAllRecords(): Flow<List<BookkeepingRecord>>
|
||||
|
||||
@Insert
|
||||
suspend fun insertRecord(record: BookkeepingRecord)
|
||||
@Query("SELECT * FROM bookkeeping_records WHERE memberId = :memberId OR memberId IS NULL ORDER BY date DESC")
|
||||
fun getRecordsByMember(memberId: Int): Flow<List<BookkeepingRecord>>
|
||||
|
||||
@Delete
|
||||
suspend fun deleteRecord(record: BookkeepingRecord)
|
||||
@Query("SELECT * FROM bookkeeping_records WHERE date BETWEEN :startDate AND :endDate ORDER BY date DESC")
|
||||
fun getRecordsByDateRange(startDate: Date, endDate: Date): Flow<List<BookkeepingRecord>>
|
||||
|
||||
@Query("SELECT * FROM bookkeeping_records WHERE (memberId = :memberId OR memberId IS NULL) AND date BETWEEN :startDate AND :endDate ORDER BY date DESC")
|
||||
fun getRecordsByMemberAndDateRange(memberId: Int, startDate: Date, endDate: Date): Flow<List<BookkeepingRecord>>
|
||||
|
||||
@Query("SELECT * FROM bookkeeping_records WHERE type = :type ORDER BY date DESC")
|
||||
fun getRecordsByType(type: TransactionType): Flow<List<BookkeepingRecord>>
|
||||
|
||||
@Query("SELECT SUM(amount) FROM bookkeeping_records WHERE type = :type AND (memberId = :memberId OR memberId IS NULL)")
|
||||
fun getTotalAmountByType(type: TransactionType, memberId: Int? = null): Flow<Double?>
|
||||
|
||||
@Insert
|
||||
suspend fun insertRecord(record: BookkeepingRecord): Long
|
||||
|
||||
@Update
|
||||
suspend fun updateRecord(record: BookkeepingRecord)
|
||||
|
||||
@Query("SELECT * FROM bookkeeping_records WHERE type = 'INCOME'")
|
||||
fun getAllIncome(): Flow<List<BookkeepingRecord>>
|
||||
@Delete
|
||||
suspend fun deleteRecord(record: BookkeepingRecord)
|
||||
|
||||
@Query("SELECT * FROM bookkeeping_records WHERE type = 'EXPENSE'")
|
||||
fun getAllExpense(): Flow<List<BookkeepingRecord>>
|
||||
|
||||
// 按日期查询
|
||||
@Query("SELECT * FROM bookkeeping_records WHERE date >= :startOfDay AND date < :endOfDay ORDER BY date DESC")
|
||||
fun getRecordsByDate(startOfDay: Date, endOfDay: Date): Flow<List<BookkeepingRecord>>
|
||||
|
||||
// 按日期范围查询
|
||||
@Query("SELECT * FROM bookkeeping_records WHERE date BETWEEN :startDate AND :endDate ORDER BY date DESC")
|
||||
fun getRecordsByDateRange(startDate: Date, endDate: Date): Flow<List<BookkeepingRecord>>
|
||||
|
||||
// 按类别查询
|
||||
@Query("SELECT * FROM bookkeeping_records WHERE category = :category ORDER BY date DESC")
|
||||
fun getRecordsByCategory(category: String): Flow<List<BookkeepingRecord>>
|
||||
|
||||
// 按类型查询
|
||||
@Query("SELECT * FROM bookkeeping_records WHERE type = :type ORDER BY date DESC")
|
||||
fun getRecordsByType(type: TransactionType): Flow<List<BookkeepingRecord>>
|
||||
|
||||
// Category related queries
|
||||
@Query("SELECT * FROM categories WHERE type = :type ORDER BY name ASC")
|
||||
fun getCategoriesByType(type: TransactionType): Flow<List<Category>>
|
||||
|
||||
@Insert
|
||||
suspend fun insertCategory(category: Category)
|
||||
|
||||
@Delete
|
||||
suspend fun deleteCategory(category: Category)
|
||||
suspend fun insertCategory(category: Category): Long
|
||||
|
||||
@Update
|
||||
suspend fun updateCategory(category: Category)
|
||||
|
||||
@Delete
|
||||
suspend fun deleteCategory(category: Category)
|
||||
|
||||
@Query("SELECT EXISTS(SELECT 1 FROM bookkeeping_records WHERE category = :categoryName LIMIT 1)")
|
||||
suspend fun isCategoryInUse(categoryName: String): Boolean
|
||||
|
||||
|
@ -11,159 +11,164 @@ import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import com.yovinchen.bookkeeping.model.BookkeepingRecord
|
||||
import com.yovinchen.bookkeeping.model.Category
|
||||
import com.yovinchen.bookkeeping.model.Converters
|
||||
import com.yovinchen.bookkeeping.model.Member
|
||||
import com.yovinchen.bookkeeping.model.TransactionType
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Database(entities = [BookkeepingRecord::class, Category::class], version = 2, exportSchema = false)
|
||||
@Database(
|
||||
entities = [BookkeepingRecord::class, Category::class, Member::class],
|
||||
version = 3,
|
||||
exportSchema = false
|
||||
)
|
||||
@TypeConverters(Converters::class)
|
||||
abstract class BookkeepingDatabase : RoomDatabase() {
|
||||
abstract fun bookkeepingDao(): BookkeepingDao
|
||||
abstract fun categoryDao(): CategoryDao
|
||||
abstract fun memberDao(): MemberDao
|
||||
|
||||
companion object {
|
||||
private const val TAG = "BookkeepingDatabase"
|
||||
|
||||
@Volatile
|
||||
private var Instance: BookkeepingDatabase? = null
|
||||
|
||||
private val MIGRATION_1_2 = object : Migration(1, 2) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
try {
|
||||
Log.d(TAG, "Starting migration from version 1 to 2")
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
// 创建成员表
|
||||
database.execSQL("""
|
||||
CREATE TABLE IF NOT EXISTS members (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT ''
|
||||
)
|
||||
""")
|
||||
|
||||
// 检查表是否存在
|
||||
val cursor = db.query("SELECT name FROM sqlite_master WHERE type='table' AND name='categories'")
|
||||
val tableExists = cursor.moveToFirst()
|
||||
cursor.close()
|
||||
// 插入默认成员
|
||||
database.execSQL("""
|
||||
INSERT INTO members (name, description)
|
||||
VALUES ('自己', '默认成员')
|
||||
""")
|
||||
|
||||
if (tableExists) {
|
||||
// 如果表存在,执行迁移
|
||||
Log.d(TAG, "Categories table exists, performing migration")
|
||||
db.execSQL("ALTER TABLE categories RENAME TO categories_old")
|
||||
// 修改记账记录表,添加成员ID字段
|
||||
database.execSQL("""
|
||||
ALTER TABLE bookkeeping_records
|
||||
ADD COLUMN memberId INTEGER DEFAULT NULL
|
||||
REFERENCES members(id) ON DELETE SET NULL
|
||||
""")
|
||||
|
||||
db.execSQL("""
|
||||
CREATE TABLE categories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
|
||||
db.execSQL("""
|
||||
INSERT INTO categories (name, type)
|
||||
SELECT name, type FROM categories_old
|
||||
""")
|
||||
|
||||
db.execSQL("DROP TABLE categories_old")
|
||||
} else {
|
||||
// 如果表不存在,直接创建新表
|
||||
Log.d(TAG, "Categories table does not exist, creating new table")
|
||||
db.execSQL("""
|
||||
CREATE TABLE IF NOT EXISTS categories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
}
|
||||
|
||||
// 确保 bookkeeping_records 表存在
|
||||
db.execSQL("""
|
||||
CREATE TABLE IF NOT EXISTS bookkeeping_records (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
amount REAL NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
date INTEGER NOT NULL
|
||||
)
|
||||
""")
|
||||
|
||||
Log.d(TAG, "Migration completed successfully")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error during migration", e)
|
||||
throw e
|
||||
}
|
||||
// 更新现有记录,将其关联到默认成员
|
||||
database.execSQL("""
|
||||
UPDATE bookkeeping_records
|
||||
SET memberId = (SELECT id FROM members WHERE name = '我自己')
|
||||
""")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun populateDefaultCategories(dao: BookkeepingDao) {
|
||||
try {
|
||||
Log.d(TAG, "Starting to populate default categories")
|
||||
// 支出类别
|
||||
listOf(
|
||||
"餐饮",
|
||||
"交通",
|
||||
"购物",
|
||||
"娱乐",
|
||||
"医疗",
|
||||
"住房",
|
||||
"其他支出"
|
||||
).forEach { name ->
|
||||
try {
|
||||
dao.insertCategory(Category(name = name, type = TransactionType.EXPENSE))
|
||||
Log.d(TAG, "Added expense category: $name")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error adding expense category: $name", e)
|
||||
}
|
||||
}
|
||||
private val MIGRATION_2_3 = object : Migration(2, 3) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
// 重新创建记账记录表
|
||||
database.execSQL("""
|
||||
CREATE TABLE IF NOT EXISTS bookkeeping_records_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
amount REAL NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
date INTEGER NOT NULL,
|
||||
memberId INTEGER,
|
||||
FOREIGN KEY(memberId) REFERENCES members(id) ON DELETE SET NULL
|
||||
)
|
||||
""")
|
||||
|
||||
// 收入类别
|
||||
listOf(
|
||||
"工资",
|
||||
"奖金",
|
||||
"投资",
|
||||
"其他收入"
|
||||
).forEach { name ->
|
||||
try {
|
||||
dao.insertCategory(Category(name = name, type = TransactionType.INCOME))
|
||||
Log.d(TAG, "Added income category: $name")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error adding income category: $name", e)
|
||||
}
|
||||
}
|
||||
Log.d(TAG, "Finished populating default categories")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error during category population", e)
|
||||
// 复制数据
|
||||
database.execSQL("""
|
||||
INSERT INTO bookkeeping_records_new (id, amount, type, category, description, date, memberId)
|
||||
SELECT id, amount, type, category, description, date, memberId FROM bookkeeping_records
|
||||
""")
|
||||
|
||||
// 删除旧表
|
||||
database.execSQL("DROP TABLE bookkeeping_records")
|
||||
|
||||
// 重命名新表
|
||||
database.execSQL("ALTER TABLE bookkeeping_records_new RENAME TO bookkeeping_records")
|
||||
|
||||
// 重新创建分类表
|
||||
database.execSQL("""
|
||||
CREATE TABLE IF NOT EXISTS categories_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
|
||||
// 复制分类数据
|
||||
database.execSQL("""
|
||||
INSERT INTO categories_new (id, name, type)
|
||||
SELECT id, name, type FROM categories
|
||||
""")
|
||||
|
||||
// 删除旧表
|
||||
database.execSQL("DROP TABLE categories")
|
||||
|
||||
// 重命名新表
|
||||
database.execSQL("ALTER TABLE categories_new RENAME TO categories")
|
||||
}
|
||||
}
|
||||
|
||||
@Volatile
|
||||
private var INSTANCE: BookkeepingDatabase? = null
|
||||
|
||||
fun getDatabase(context: Context): BookkeepingDatabase {
|
||||
return Instance ?: synchronized(this) {
|
||||
try {
|
||||
Log.d(TAG, "Creating new database instance")
|
||||
val instance = Room.databaseBuilder(
|
||||
context.applicationContext,
|
||||
BookkeepingDatabase::class.java,
|
||||
"bookkeeping_database"
|
||||
)
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
val instance = Room.databaseBuilder(
|
||||
context.applicationContext,
|
||||
BookkeepingDatabase::class.java,
|
||||
"bookkeeping_database"
|
||||
)
|
||||
.addMigrations(MIGRATION_1_2, MIGRATION_2_3)
|
||||
.addCallback(object : Callback() {
|
||||
override fun onCreate(db: SupportSQLiteDatabase) {
|
||||
super.onCreate(db)
|
||||
Log.d(TAG, "Database created, initializing default categories")
|
||||
Log.d(TAG, "Database created, initializing default data")
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
Instance?.let { database ->
|
||||
populateDefaultCategories(database.bookkeepingDao())
|
||||
val database = getDatabase(context)
|
||||
|
||||
// 初始化默认成员
|
||||
database.memberDao().apply {
|
||||
if (getMemberCount() == 0) {
|
||||
insertMember(Member(name = "自己", description = "默认成员"))
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化默认分类
|
||||
database.categoryDao().apply {
|
||||
// 支出分类
|
||||
insertCategory(Category(name = "餐饮", type = TransactionType.EXPENSE))
|
||||
insertCategory(Category(name = "交通", type = TransactionType.EXPENSE))
|
||||
insertCategory(Category(name = "购物", type = TransactionType.EXPENSE))
|
||||
insertCategory(Category(name = "娱乐", type = TransactionType.EXPENSE))
|
||||
insertCategory(Category(name = "居住", type = TransactionType.EXPENSE))
|
||||
insertCategory(Category(name = "医疗", type = TransactionType.EXPENSE))
|
||||
insertCategory(Category(name = "教育", type = TransactionType.EXPENSE))
|
||||
insertCategory(Category(name = "其他支出", type = TransactionType.EXPENSE))
|
||||
|
||||
// 收入分类
|
||||
insertCategory(Category(name = "工资", type = TransactionType.INCOME))
|
||||
insertCategory(Category(name = "奖金", type = TransactionType.INCOME))
|
||||
insertCategory(Category(name = "投资", type = TransactionType.INCOME))
|
||||
insertCategory(Category(name = "其他收入", type = TransactionType.INCOME))
|
||||
}
|
||||
|
||||
Log.d(TAG, "Default data initialized successfully")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error in onCreate callback", e)
|
||||
Log.e(TAG, "Error initializing default data", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.addMigrations(MIGRATION_1_2)
|
||||
.fallbackToDestructiveMigration() // 如果迁移失败,允许重建数据库
|
||||
.build()
|
||||
|
||||
Instance = instance
|
||||
Log.d(TAG, "Database instance created successfully")
|
||||
instance
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error creating database", e)
|
||||
throw e
|
||||
}
|
||||
INSTANCE = instance
|
||||
instance
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,30 @@
|
||||
package com.yovinchen.bookkeeping.data
|
||||
|
||||
import androidx.room.*
|
||||
import com.yovinchen.bookkeeping.model.Category
|
||||
import com.yovinchen.bookkeeping.model.TransactionType
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface CategoryDao {
|
||||
@Query("SELECT * FROM categories WHERE type = :type ORDER BY name ASC")
|
||||
fun getCategoriesByType(type: TransactionType): Flow<List<Category>>
|
||||
|
||||
@Query("SELECT * FROM categories ORDER BY type ASC, name ASC")
|
||||
fun getAllCategories(): Flow<List<Category>>
|
||||
|
||||
@Insert
|
||||
suspend fun insertCategory(category: Category): Long
|
||||
|
||||
@Update
|
||||
suspend fun updateCategory(category: Category)
|
||||
|
||||
@Delete
|
||||
suspend fun deleteCategory(category: Category)
|
||||
|
||||
@Query("SELECT EXISTS(SELECT 1 FROM bookkeeping_records WHERE category = :categoryName LIMIT 1)")
|
||||
suspend fun isCategoryInUse(categoryName: String): Boolean
|
||||
|
||||
@Query("SELECT COUNT(*) FROM categories WHERE type = :type")
|
||||
suspend fun getCategoryCountByType(type: TransactionType): Int
|
||||
}
|
@ -0,0 +1,26 @@
|
||||
package com.yovinchen.bookkeeping.data
|
||||
|
||||
import androidx.room.*
|
||||
import com.yovinchen.bookkeeping.model.Member
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface MemberDao {
|
||||
@Query("SELECT * FROM members ORDER BY name ASC")
|
||||
fun getAllMembers(): Flow<List<Member>>
|
||||
|
||||
@Query("SELECT * FROM members WHERE id = :memberId")
|
||||
suspend fun getMemberById(memberId: Int): Member?
|
||||
|
||||
@Insert
|
||||
suspend fun insertMember(member: Member): Long
|
||||
|
||||
@Update
|
||||
suspend fun updateMember(member: Member)
|
||||
|
||||
@Delete
|
||||
suspend fun deleteMember(member: Member)
|
||||
|
||||
@Query("SELECT COUNT(*) FROM members")
|
||||
suspend fun getMemberCount(): Int
|
||||
}
|
@ -1,9 +1,11 @@
|
||||
package com.yovinchen.bookkeeping.model
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.PrimaryKey
|
||||
import androidx.room.TypeConverter
|
||||
import androidx.room.TypeConverters
|
||||
import com.yovinchen.bookkeeping.model.Member
|
||||
import java.util.Date
|
||||
|
||||
enum class TransactionType {
|
||||
@ -32,7 +34,17 @@ class Converters {
|
||||
}
|
||||
}
|
||||
|
||||
@Entity(tableName = "bookkeeping_records")
|
||||
@Entity(
|
||||
tableName = "bookkeeping_records",
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = Member::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["memberId"],
|
||||
onDelete = ForeignKey.SET_NULL
|
||||
)
|
||||
]
|
||||
)
|
||||
@TypeConverters(Converters::class)
|
||||
data class BookkeepingRecord(
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
@ -41,5 +53,6 @@ data class BookkeepingRecord(
|
||||
val type: TransactionType,
|
||||
val category: String,
|
||||
val description: String,
|
||||
val date: Date
|
||||
val date: Date,
|
||||
val memberId: Int? = null // 可为空,表示未指定成员
|
||||
)
|
||||
|
@ -0,0 +1,250 @@
|
||||
package com.yovinchen.bookkeeping.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import com.yovinchen.bookkeeping.model.TransactionType
|
||||
import java.time.YearMonth
|
||||
|
||||
@Composable
|
||||
fun MonthYearPickerDialog(
|
||||
selectedMonth: YearMonth,
|
||||
onMonthSelected: (YearMonth) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
var currentYearMonth by remember { mutableStateOf(selectedMonth) }
|
||||
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.wrapContentHeight(),
|
||||
shape = MaterialTheme.shapes.extraLarge,
|
||||
tonalElevation = 6.dp
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "选择年月",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier.padding(bottom = 16.dp)
|
||||
)
|
||||
|
||||
// 年份选择
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(onClick = {
|
||||
currentYearMonth = currentYearMonth.minusYears(1)
|
||||
}) {
|
||||
Text("<")
|
||||
}
|
||||
Text(
|
||||
text = "${currentYearMonth.year}年",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
IconButton(onClick = {
|
||||
currentYearMonth = currentYearMonth.plusYears(1)
|
||||
}) {
|
||||
Text(">")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// 月份网格
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(3),
|
||||
modifier = Modifier.height(200.dp)
|
||||
) {
|
||||
items(12) { index ->
|
||||
val month = index + 1
|
||||
val isSelected = month == currentYearMonth.monthValue
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.padding(4.dp)
|
||||
.aspectRatio(1.5f)
|
||||
.clickable {
|
||||
currentYearMonth = YearMonth.of(currentYearMonth.year, month)
|
||||
},
|
||||
shape = MaterialTheme.shapes.small,
|
||||
color = if (isSelected) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.surface
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
Text(
|
||||
text = "${month}月",
|
||||
color = if (isSelected) MaterialTheme.colorScheme.onPrimary
|
||||
else MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 按钮行
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 16.dp),
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("取消")
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Button(onClick = {
|
||||
onMonthSelected(currentYearMonth)
|
||||
onDismiss()
|
||||
}) {
|
||||
Text("确定")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MonthlyStatistics(
|
||||
totalIncome: Double,
|
||||
totalExpense: Double,
|
||||
onIncomeClick: () -> Unit,
|
||||
onExpenseClick: () -> Unit,
|
||||
selectedType: TransactionType?,
|
||||
onClearFilter: () -> Unit,
|
||||
selectedMonth: YearMonth,
|
||||
onPreviousMonth: () -> Unit,
|
||||
onNextMonth: () -> Unit,
|
||||
onMonthSelected: (YearMonth) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var showMonthPicker by remember { mutableStateOf(false) }
|
||||
|
||||
Card(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
// 月份选择器
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(onClick = onPreviousMonth) {
|
||||
Icon(Icons.AutoMirrored.Filled.KeyboardArrowLeft, "上个月")
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "${selectedMonth.year}年${selectedMonth.monthValue}月",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier.clickable { showMonthPicker = true }
|
||||
)
|
||||
|
||||
IconButton(onClick = onNextMonth) {
|
||||
Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, "下个月")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
// 收入统计
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clickable { onIncomeClick() }
|
||||
.background(
|
||||
if (selectedType == TransactionType.INCOME) MaterialTheme.colorScheme.primaryContainer
|
||||
else Color.Transparent,
|
||||
RoundedCornerShape(8.dp)
|
||||
)
|
||||
.padding(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "收入",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
Text(
|
||||
text = "¥${String.format("%.2f", totalIncome)}",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
|
||||
// 支出统计
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clickable { onExpenseClick() }
|
||||
.background(
|
||||
if (selectedType == TransactionType.EXPENSE) MaterialTheme.colorScheme.primaryContainer
|
||||
else Color.Transparent,
|
||||
RoundedCornerShape(8.dp)
|
||||
)
|
||||
.padding(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "支出",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
Text(
|
||||
text = "¥${String.format("%.2f", totalExpense)}",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedType != null) {
|
||||
TextButton(
|
||||
onClick = onClearFilter,
|
||||
modifier = Modifier.align(Alignment.End)
|
||||
) {
|
||||
Text("清除筛选")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showMonthPicker) {
|
||||
MonthYearPickerDialog(
|
||||
selectedMonth = selectedMonth,
|
||||
onMonthSelected = onMonthSelected,
|
||||
onDismiss = { showMonthPicker = false }
|
||||
)
|
||||
}
|
||||
}
|
@ -0,0 +1,103 @@
|
||||
package com.yovinchen.bookkeeping.ui.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.yovinchen.bookkeeping.model.BookkeepingRecord
|
||||
import com.yovinchen.bookkeeping.model.Member
|
||||
import com.yovinchen.bookkeeping.model.TransactionType
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
@Composable
|
||||
fun RecordItem(
|
||||
record: BookkeepingRecord,
|
||||
onClick: () -> Unit = {},
|
||||
onDelete: () -> Unit = {},
|
||||
modifier: Modifier = Modifier,
|
||||
members: List<Member> = emptyList()
|
||||
) {
|
||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||
val timeFormat = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) }
|
||||
val member = members.find { it.id == record.memberId }
|
||||
|
||||
Card(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||
.clickable(onClick = onClick),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
// 第一行:分类
|
||||
Text(
|
||||
text = record.category,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
|
||||
// 第二行:时间 | 成员 | 详情
|
||||
Text(
|
||||
text = buildString {
|
||||
append(timeFormat.format(record.date))
|
||||
if (member != null && member.name != "自己") {
|
||||
append(" | ")
|
||||
append(member.name)
|
||||
}
|
||||
if (record.description.isNotEmpty()) {
|
||||
append(" | ")
|
||||
append(record.description)
|
||||
}
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
// 金额显示
|
||||
Text(
|
||||
text = String.format("%.2f", record.amount),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = if (record.type == TransactionType.EXPENSE)
|
||||
MaterialTheme.colorScheme.error
|
||||
else
|
||||
MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (showDeleteDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showDeleteDialog = false },
|
||||
title = { Text("确认删除") },
|
||||
text = { Text("确定要删除这条记录吗?") },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
onDelete()
|
||||
showDeleteDialog = false
|
||||
}
|
||||
) {
|
||||
Text("删除")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showDeleteDialog = false }) {
|
||||
Text("取消")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
@ -1,36 +1,57 @@
|
||||
@file:OptIn(ExperimentalMaterial3Api::class)
|
||||
|
||||
package com.yovinchen.bookkeeping.ui.dialog
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import com.yovinchen.bookkeeping.model.Category
|
||||
import com.yovinchen.bookkeeping.model.Member
|
||||
import com.yovinchen.bookkeeping.model.TransactionType
|
||||
import com.yovinchen.bookkeeping.ui.components.DateTimePicker
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
import java.util.Date
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AddRecordDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (TransactionType, Double, String, String) -> Unit,
|
||||
categories: List<Category>,
|
||||
selectedType: TransactionType,
|
||||
onTypeChange: (TransactionType) -> Unit,
|
||||
selectedDateTime: LocalDateTime,
|
||||
onDateTimeSelected: (LocalDateTime) -> Unit
|
||||
members: List<Member>,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (amount: Double, category: String, description: String, date: Date, type: TransactionType, memberId: Int?) -> Unit
|
||||
) {
|
||||
var amount by remember { mutableStateOf("") }
|
||||
var selectedCategory by remember { mutableStateOf<Category?>(null) }
|
||||
var description by remember { mutableStateOf("") }
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
var memberExpanded by remember { mutableStateOf(false) }
|
||||
var description by remember { mutableStateOf("") }
|
||||
var selectedType by remember { mutableStateOf(TransactionType.EXPENSE) }
|
||||
|
||||
// 根据当前选择的类型过滤类别
|
||||
val filteredCategories = categories.filter { it.type == selectedType }
|
||||
// 找到默认成员("自己")
|
||||
val defaultMember = remember(members) {
|
||||
members.find { it.name == "自己" }
|
||||
}
|
||||
var currentSelectedMember by remember(defaultMember) {
|
||||
mutableStateOf(defaultMember)
|
||||
}
|
||||
|
||||
// 设置默认分类为"餐饮"
|
||||
var selectedCategory by remember {
|
||||
mutableStateOf(categories.find { it.type == selectedType && it.name == "餐饮" }?.name ?: categories.firstOrNull { it.type == selectedType }?.name ?: "")
|
||||
}
|
||||
|
||||
var selectedDateTime by remember {
|
||||
mutableStateOf(LocalDateTime.now())
|
||||
}
|
||||
|
||||
// 当类型改变时更新分类
|
||||
LaunchedEffect(selectedType) {
|
||||
selectedCategory = categories.find { it.type == selectedType && it.name == "餐饮" }?.name
|
||||
?: categories.firstOrNull { it.type == selectedType }?.name
|
||||
?: ""
|
||||
}
|
||||
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
Card(
|
||||
@ -51,74 +72,59 @@ fun AddRecordDialog(
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// 类型选择
|
||||
// 收入/支出选择
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
FilterChip(
|
||||
selected = selectedType == TransactionType.EXPENSE,
|
||||
onClick = {
|
||||
onTypeChange(TransactionType.EXPENSE)
|
||||
selectedCategory = null
|
||||
},
|
||||
onClick = { selectedType = TransactionType.EXPENSE },
|
||||
label = { Text("支出") }
|
||||
)
|
||||
FilterChip(
|
||||
selected = selectedType == TransactionType.INCOME,
|
||||
onClick = {
|
||||
onTypeChange(TransactionType.INCOME)
|
||||
selectedCategory = null
|
||||
},
|
||||
onClick = { selectedType = TransactionType.INCOME },
|
||||
label = { Text("收入") }
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// 日期时间选择
|
||||
DateTimePicker(
|
||||
selectedDateTime = selectedDateTime,
|
||||
onDateTimeSelected = onDateTimeSelected,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// 金额输入
|
||||
OutlinedTextField(
|
||||
value = amount,
|
||||
onValueChange = { amount = it },
|
||||
label = { Text("金额") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// 类别选择
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
onExpandedChange = { expanded = it }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = selectedCategory?.name ?: "",
|
||||
value = selectedCategory,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("类别") },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor()
|
||||
)
|
||||
|
||||
ExposedDropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false }
|
||||
) {
|
||||
filteredCategories.forEach { category ->
|
||||
categories.filter { it.type == selectedType }.forEach { category ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(category.name) },
|
||||
onClick = {
|
||||
selectedCategory = category
|
||||
selectedCategory = category.name
|
||||
expanded = false
|
||||
}
|
||||
)
|
||||
@ -126,19 +132,59 @@ fun AddRecordDialog(
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// 描述输入
|
||||
OutlinedTextField(
|
||||
value = description,
|
||||
onValueChange = { description = it },
|
||||
label = { Text("描述") },
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = memberExpanded,
|
||||
onExpandedChange = { memberExpanded = it }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = currentSelectedMember?.name ?: "选择成员",
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("成员") },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = memberExpanded) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor()
|
||||
)
|
||||
|
||||
ExposedDropdownMenu(
|
||||
expanded = memberExpanded,
|
||||
onDismissRequest = { memberExpanded = false }
|
||||
) {
|
||||
members.forEach { member ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(member.name) },
|
||||
onClick = {
|
||||
currentSelectedMember = member
|
||||
memberExpanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
DateTimePicker(
|
||||
selectedDateTime = selectedDateTime,
|
||||
onDateTimeSelected = { selectedDateTime = it },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// 按钮
|
||||
OutlinedTextField(
|
||||
value = description,
|
||||
onValueChange = { description = it },
|
||||
label = { Text("备注") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End
|
||||
@ -149,13 +195,21 @@ fun AddRecordDialog(
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
val amountValue = amount.toDoubleOrNull() ?: 0.0
|
||||
selectedCategory?.let { category ->
|
||||
onConfirm(selectedType, amountValue, category.name, description)
|
||||
onDismiss()
|
||||
val amountValue = amount.toDoubleOrNull()
|
||||
if (amountValue != null) {
|
||||
onConfirm(
|
||||
amountValue,
|
||||
selectedCategory,
|
||||
description,
|
||||
Date.from(
|
||||
selectedDateTime.atZone(ZoneId.systemDefault()).toInstant()
|
||||
),
|
||||
selectedType,
|
||||
currentSelectedMember?.id
|
||||
)
|
||||
}
|
||||
},
|
||||
enabled = amount.isNotEmpty() && selectedCategory != null
|
||||
enabled = amount.isNotEmpty() && selectedCategory.isNotEmpty()
|
||||
) {
|
||||
Text("确定")
|
||||
}
|
||||
|
@ -8,25 +8,33 @@ import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.yovinchen.bookkeeping.model.BookkeepingRecord
|
||||
import com.yovinchen.bookkeeping.model.Category
|
||||
import com.yovinchen.bookkeeping.model.Member
|
||||
import com.yovinchen.bookkeeping.ui.components.DateTimePicker
|
||||
import com.yovinchen.bookkeeping.viewmodel.HomeViewModel
|
||||
import java.time.Instant
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
import java.util.Date
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun RecordEditDialog(
|
||||
record: BookkeepingRecord,
|
||||
categories: List<Category>,
|
||||
members: List<Member>,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (BookkeepingRecord) -> Unit
|
||||
onConfirm: (BookkeepingRecord) -> Unit,
|
||||
viewModel: HomeViewModel = viewModel()
|
||||
) {
|
||||
var amount by remember { mutableStateOf(record.amount.toString()) }
|
||||
var selectedCategory by remember { mutableStateOf(record.category) }
|
||||
var description by remember { mutableStateOf(record.description) }
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
var memberExpanded by remember { mutableStateOf(false) }
|
||||
var currentSelectedMember by remember { mutableStateOf<Member?>(null) }
|
||||
var selectedDateTime by remember {
|
||||
mutableStateOf(
|
||||
LocalDateTime.ofInstant(
|
||||
@ -36,6 +44,16 @@ fun RecordEditDialog(
|
||||
)
|
||||
}
|
||||
|
||||
// 加载原关联成员
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
LaunchedEffect(record.memberId) {
|
||||
if (record.memberId != null) {
|
||||
coroutineScope.launch {
|
||||
currentSelectedMember = viewModel.getMemberById(record.memberId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
@ -55,24 +73,16 @@ fun RecordEditDialog(
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// 日期时间选择
|
||||
DateTimePicker(
|
||||
selectedDateTime = selectedDateTime,
|
||||
onDateTimeSelected = { selectedDateTime = it },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// 金额输入
|
||||
OutlinedTextField(
|
||||
value = amount,
|
||||
onValueChange = { amount = it },
|
||||
label = { Text("金额") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// 类别选择
|
||||
ExposedDropdownMenuBox(
|
||||
@ -84,10 +94,12 @@ fun RecordEditDialog(
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("类别") },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor()
|
||||
)
|
||||
|
||||
ExposedDropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false }
|
||||
@ -104,19 +116,72 @@ fun RecordEditDialog(
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// 描述输入
|
||||
OutlinedTextField(
|
||||
value = description,
|
||||
onValueChange = { description = it },
|
||||
label = { Text("描述") },
|
||||
// 成员选择
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = memberExpanded,
|
||||
onExpandedChange = { memberExpanded = it }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = currentSelectedMember?.name ?: "选择成员",
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("成员") },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = memberExpanded) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor()
|
||||
)
|
||||
|
||||
ExposedDropdownMenu(
|
||||
expanded = memberExpanded,
|
||||
onDismissRequest = { memberExpanded = false }
|
||||
) {
|
||||
// 添加一个"清除选择"选项
|
||||
DropdownMenuItem(
|
||||
text = { Text("清除选择") },
|
||||
onClick = {
|
||||
currentSelectedMember = null
|
||||
memberExpanded = false
|
||||
}
|
||||
)
|
||||
|
||||
members.forEach { member ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(member.name) },
|
||||
onClick = {
|
||||
currentSelectedMember = member
|
||||
memberExpanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// 日期时间选择
|
||||
DateTimePicker(
|
||||
selectedDateTime = selectedDateTime,
|
||||
onDateTimeSelected = { selectedDateTime = it },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// 按钮
|
||||
// 备注输入
|
||||
OutlinedTextField(
|
||||
value = description,
|
||||
onValueChange = { description = it },
|
||||
label = { Text("备注") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// 按钮行
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End
|
||||
@ -127,15 +192,22 @@ fun RecordEditDialog(
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
val updatedRecord = record.copy(
|
||||
amount = amount.toDoubleOrNull() ?: record.amount,
|
||||
category = selectedCategory,
|
||||
description = description,
|
||||
date = Date.from(selectedDateTime.atZone(ZoneId.systemDefault()).toInstant())
|
||||
)
|
||||
onConfirm(updatedRecord)
|
||||
onDismiss()
|
||||
}
|
||||
val amountValue = amount.toDoubleOrNull()
|
||||
if (amountValue != null) {
|
||||
onConfirm(
|
||||
record.copy(
|
||||
amount = amountValue,
|
||||
category = selectedCategory,
|
||||
description = description,
|
||||
date = Date.from(
|
||||
selectedDateTime.atZone(ZoneId.systemDefault()).toInstant()
|
||||
),
|
||||
memberId = currentSelectedMember?.id
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
enabled = amount.isNotEmpty()
|
||||
) {
|
||||
Text("确定")
|
||||
}
|
||||
|
@ -1,60 +1,73 @@
|
||||
package com.yovinchen.bookkeeping.ui.screen
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.yovinchen.bookkeeping.model.BookkeepingRecord
|
||||
import com.yovinchen.bookkeeping.model.TransactionType
|
||||
import com.yovinchen.bookkeeping.ui.components.MonthlyStatistics
|
||||
import com.yovinchen.bookkeeping.ui.components.RecordItem
|
||||
import com.yovinchen.bookkeeping.ui.dialog.AddRecordDialog
|
||||
import com.yovinchen.bookkeeping.ui.dialog.RecordEditDialog
|
||||
import com.yovinchen.bookkeeping.viewmodel.HomeViewModel
|
||||
import java.time.YearMonth
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import java.util.Locale
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun HomeScreen(
|
||||
modifier: Modifier = Modifier, viewModel: HomeViewModel = viewModel()
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: HomeViewModel = viewModel()
|
||||
) {
|
||||
val filteredRecords by viewModel.filteredRecords.collectAsState()
|
||||
val totalIncome by viewModel.totalIncome.collectAsState()
|
||||
val totalExpense by viewModel.totalExpense.collectAsState()
|
||||
val categories by viewModel.categories.collectAsState()
|
||||
val selectedRecordType by viewModel.selectedRecordType.collectAsState()
|
||||
val selectedMonth by viewModel.selectedMonth.collectAsState()
|
||||
|
||||
var showAddDialog by remember { mutableStateOf(false) }
|
||||
var selectedRecord by remember { mutableStateOf<BookkeepingRecord?>(null) }
|
||||
|
||||
Scaffold(modifier = modifier.fillMaxSize(), floatingActionButton = {
|
||||
FloatingActionButton(onClick = { showAddDialog = true }) {
|
||||
Icon(Icons.Default.Add, contentDescription = "添加记录")
|
||||
val selectedMonth by viewModel.selectedMonth.collectAsState()
|
||||
val filteredRecords by viewModel.filteredRecords.collectAsState()
|
||||
val categories by viewModel.categories.collectAsState(initial = emptyList())
|
||||
val members by viewModel.members.collectAsState(initial = emptyList())
|
||||
val selectedMember by viewModel.selectedMember.collectAsState()
|
||||
val totalIncome by viewModel.totalIncome.collectAsState()
|
||||
val totalExpense by viewModel.totalExpense.collectAsState()
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
floatingActionButton = {
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = { showAddDialog = true },
|
||||
icon = { Icon(Icons.Default.Add, contentDescription = null) },
|
||||
text = { Text("记一笔") }
|
||||
)
|
||||
}
|
||||
}, floatingActionButtonPosition = FabPosition.End, topBar = {
|
||||
TopAppBar(title = { Text("记账本") })
|
||||
}) { padding ->
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
@ -62,408 +75,103 @@ fun HomeScreen(
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
) {
|
||||
// 顶部统计信息
|
||||
MonthlyStatistics(totalIncome = totalIncome,
|
||||
MonthlyStatistics(
|
||||
totalIncome = totalIncome,
|
||||
totalExpense = totalExpense,
|
||||
selectedType = null,
|
||||
onIncomeClick = { viewModel.setSelectedRecordType(TransactionType.INCOME) },
|
||||
onExpenseClick = { viewModel.setSelectedRecordType(TransactionType.EXPENSE) },
|
||||
selectedType = selectedRecordType,
|
||||
onClearFilter = { viewModel.setSelectedRecordType(null) },
|
||||
selectedMonth = selectedMonth,
|
||||
onPreviousMonth = { viewModel.setSelectedMonth(selectedMonth.minusMonths(1)) },
|
||||
onNextMonth = { viewModel.setSelectedMonth(selectedMonth.plusMonths(1)) },
|
||||
onMonthSelected = { viewModel.setSelectedMonth(it) })
|
||||
onPreviousMonth = { viewModel.moveMonth(false) },
|
||||
onNextMonth = { viewModel.moveMonth(true) },
|
||||
onMonthSelected = { viewModel.setSelectedMonth(it) }
|
||||
)
|
||||
|
||||
// 记录列表
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
filteredRecords.forEach { (date, records) ->
|
||||
item {
|
||||
Surface(
|
||||
items(filteredRecords.size) { index ->
|
||||
val (date, dayRecords) = filteredRecords.toList()[index]
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.7f),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
tonalElevation = 2.dp
|
||||
.padding(16.dp)
|
||||
) {
|
||||
// 日期标签
|
||||
Text(
|
||||
text = SimpleDateFormat(
|
||||
"yyyy年MM月dd日 E",
|
||||
Locale.CHINESE
|
||||
).format(date),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// 当天的记录
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
// 日期标签
|
||||
Text(
|
||||
text = SimpleDateFormat(
|
||||
"yyyy年MM月dd日 E", Locale.CHINESE
|
||||
).format(date),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// 当天的记录
|
||||
records.forEachIndexed { index, record ->
|
||||
RecordItem(record = record,
|
||||
dayRecords.forEachIndexed { recordIndex, record ->
|
||||
RecordItem(
|
||||
record = record,
|
||||
onClick = { selectedRecord = record },
|
||||
onDelete = { viewModel.deleteRecord(record) })
|
||||
onDelete = { viewModel.deleteRecord(record) },
|
||||
members = members
|
||||
)
|
||||
|
||||
if (index < records.size - 1) {
|
||||
if (recordIndex < dayRecords.size - 1) {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
modifier = Modifier.padding(vertical = 4.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
thickness = 0.5.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// 当天统计
|
||||
HorizontalDivider(
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
thickness = 0.5.dp
|
||||
)
|
||||
|
||||
val dayIncome = records.filter { it.type == TransactionType.INCOME }
|
||||
.sumOf { it.amount }
|
||||
val dayExpense =
|
||||
records.filter { it.type == TransactionType.EXPENSE }
|
||||
.sumOf { it.amount }
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 12.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = "收入: ¥%.2f".format(dayIncome),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
text = "支出: ¥%.2f".format(dayExpense),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 添加记录对话框
|
||||
if (showAddDialog) {
|
||||
val selectedDateTime by viewModel.selectedDateTime.collectAsState()
|
||||
val selectedCategoryType by viewModel.selectedCategoryType.collectAsState()
|
||||
AddRecordDialog(onDismiss = {
|
||||
// 添加记录对话框
|
||||
if (showAddDialog) {
|
||||
AddRecordDialog(
|
||||
categories = categories,
|
||||
members = members,
|
||||
onDismiss = { showAddDialog = false },
|
||||
onConfirm = { amount, category, description, date, type, memberId ->
|
||||
viewModel.addRecord(amount, category, description, date, type, memberId)
|
||||
showAddDialog = false
|
||||
viewModel.resetSelectedDateTime()
|
||||
},
|
||||
onConfirm = { type, amount, category, description ->
|
||||
viewModel.addRecord(type, amount, category, description)
|
||||
showAddDialog = false
|
||||
},
|
||||
categories = categories,
|
||||
selectedType = selectedCategoryType,
|
||||
onTypeChange = viewModel::setSelectedCategoryType,
|
||||
selectedDateTime = selectedDateTime,
|
||||
onDateTimeSelected = viewModel::setSelectedDateTime
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// 编辑记录对话框
|
||||
selectedRecord?.let { record ->
|
||||
RecordEditDialog(record = record,
|
||||
categories = categories,
|
||||
onDismiss = { selectedRecord = null },
|
||||
onConfirm = { updatedRecord ->
|
||||
viewModel.updateRecord(updatedRecord)
|
||||
selectedRecord = null
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MonthYearPickerDialog(
|
||||
selectedMonth: YearMonth, onMonthSelected: (YearMonth) -> Unit, onDismiss: () -> Unit
|
||||
) {
|
||||
var currentYearMonth by remember { mutableStateOf(selectedMonth) }
|
||||
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.wrapContentHeight(),
|
||||
shape = MaterialTheme.shapes.extraLarge,
|
||||
tonalElevation = 6.dp
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "选择年月",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier.padding(bottom = 16.dp)
|
||||
)
|
||||
|
||||
// 年份选择
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(onClick = {
|
||||
currentYearMonth = currentYearMonth.minusYears(1)
|
||||
}) {
|
||||
Text("<")
|
||||
}
|
||||
Text(
|
||||
text = "${currentYearMonth.year}年",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
IconButton(onClick = {
|
||||
currentYearMonth = currentYearMonth.plusYears(1)
|
||||
}) {
|
||||
Text(">")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// 月份网格
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(3), modifier = Modifier.height(200.dp)
|
||||
) {
|
||||
items(12) { index ->
|
||||
val month = index + 1
|
||||
val isSelected = month == currentYearMonth.monthValue
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.padding(4.dp)
|
||||
.aspectRatio(1.5f)
|
||||
.clickable {
|
||||
currentYearMonth = YearMonth.of(currentYearMonth.year, month)
|
||||
},
|
||||
shape = MaterialTheme.shapes.small,
|
||||
color = if (isSelected) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.surface
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
Text(
|
||||
text = "${month}月",
|
||||
color = if (isSelected) MaterialTheme.colorScheme.onPrimary
|
||||
else MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 按钮行
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 16.dp),
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("取消")
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Button(onClick = {
|
||||
onMonthSelected(currentYearMonth)
|
||||
onDismiss()
|
||||
}) {
|
||||
Text("确定")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MonthlyStatistics(
|
||||
totalIncome: Double,
|
||||
totalExpense: Double,
|
||||
onIncomeClick: () -> Unit,
|
||||
onExpenseClick: () -> Unit,
|
||||
selectedType: TransactionType?,
|
||||
onClearFilter: () -> Unit,
|
||||
selectedMonth: YearMonth,
|
||||
onPreviousMonth: () -> Unit,
|
||||
onNextMonth: () -> Unit,
|
||||
onMonthSelected: (YearMonth) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var showMonthPicker by remember { mutableStateOf(false) }
|
||||
|
||||
Card(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
// 月份选择器
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(onClick = onPreviousMonth) {
|
||||
Icon(Icons.AutoMirrored.Filled.KeyboardArrowLeft, "上个月")
|
||||
}
|
||||
|
||||
Text(text = "${selectedMonth.year}年${selectedMonth.monthValue}月",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier.clickable { showMonthPicker = true })
|
||||
|
||||
IconButton(onClick = onNextMonth) {
|
||||
Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, "下个月")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
// 收入统计
|
||||
Column(modifier = Modifier
|
||||
.weight(1f)
|
||||
.clickable { onIncomeClick() }
|
||||
.background(
|
||||
if (selectedType == TransactionType.INCOME) MaterialTheme.colorScheme.primaryContainer
|
||||
else Color.Transparent, RoundedCornerShape(8.dp)
|
||||
)
|
||||
.padding(8.dp)) {
|
||||
Text(
|
||||
text = "收入", style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
Text(
|
||||
text = "¥${String.format("%.2f", totalIncome)}",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
|
||||
// 支出统计
|
||||
Column(modifier = Modifier
|
||||
.weight(1f)
|
||||
.clickable { onExpenseClick() }
|
||||
.background(
|
||||
if (selectedType == TransactionType.EXPENSE) MaterialTheme.colorScheme.primaryContainer
|
||||
else Color.Transparent, RoundedCornerShape(8.dp)
|
||||
)
|
||||
.padding(8.dp)) {
|
||||
Text(
|
||||
text = "支出", style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
Text(
|
||||
text = "¥${String.format("%.2f", totalExpense)}",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedType != null) {
|
||||
TextButton(
|
||||
onClick = onClearFilter, modifier = Modifier.align(Alignment.End)
|
||||
) {
|
||||
Text("清除筛选")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showMonthPicker) {
|
||||
MonthYearPickerDialog(selectedMonth = selectedMonth,
|
||||
onMonthSelected = onMonthSelected,
|
||||
onDismiss = { showMonthPicker = false })
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RecordItem(
|
||||
record: BookkeepingRecord,
|
||||
onClick: () -> Unit = {},
|
||||
onDelete: () -> Unit = {},
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Card(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = record.category, style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
if (record.description.isNotEmpty()) {
|
||||
Text(
|
||||
text = record.description,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = SimpleDateFormat(
|
||||
"yyyy-MM-dd HH:mm", Locale.getDefault()
|
||||
).format(record.date),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = if (record.type == TransactionType.EXPENSE) "-" else "+",
|
||||
color = if (record.type == TransactionType.EXPENSE) MaterialTheme.colorScheme.error
|
||||
else MaterialTheme.colorScheme.primary,
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
Text(
|
||||
text = String.format("%.2f", record.amount),
|
||||
color = if (record.type == TransactionType.EXPENSE) MaterialTheme.colorScheme.error
|
||||
else MaterialTheme.colorScheme.primary,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(end = 8.dp)
|
||||
)
|
||||
IconButton(onClick = onDelete) {
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = "删除",
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 编辑记录对话框
|
||||
selectedRecord?.let { record ->
|
||||
RecordEditDialog(
|
||||
record = record,
|
||||
categories = categories,
|
||||
members = members,
|
||||
onDismiss = { selectedRecord = null },
|
||||
onConfirm = { updatedRecord ->
|
||||
viewModel.updateRecord(updatedRecord)
|
||||
selectedRecord = null
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -28,6 +28,8 @@ import com.yovinchen.bookkeeping.model.ThemeMode
|
||||
import com.yovinchen.bookkeeping.ui.components.ColorPicker
|
||||
import com.yovinchen.bookkeeping.ui.components.predefinedColors
|
||||
import com.yovinchen.bookkeeping.ui.dialog.CategoryManagementDialog
|
||||
import com.yovinchen.bookkeeping.ui.dialog.MemberManagementDialog
|
||||
import com.yovinchen.bookkeeping.viewmodel.MemberViewModel
|
||||
import com.yovinchen.bookkeeping.viewmodel.SettingsViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@ -35,15 +37,27 @@ import com.yovinchen.bookkeeping.viewmodel.SettingsViewModel
|
||||
fun SettingsScreen(
|
||||
currentTheme: ThemeMode,
|
||||
onThemeChange: (ThemeMode) -> Unit,
|
||||
viewModel: SettingsViewModel = viewModel()
|
||||
viewModel: SettingsViewModel = viewModel(),
|
||||
memberViewModel: MemberViewModel = viewModel()
|
||||
) {
|
||||
var showThemeDialog by remember { mutableStateOf(false) }
|
||||
var showCategoryDialog by remember { mutableStateOf(false) }
|
||||
var showMemberDialog by remember { mutableStateOf(false) }
|
||||
|
||||
val categories by viewModel.categories.collectAsState()
|
||||
val selectedType by viewModel.selectedCategoryType.collectAsState()
|
||||
val members by memberViewModel.allMembers.collectAsState(initial = emptyList())
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// 成员管理设置项
|
||||
ListItem(
|
||||
headlineContent = { Text("成员管理") },
|
||||
supportingContent = { Text("管理账本成员") },
|
||||
modifier = Modifier.clickable { showMemberDialog = true }
|
||||
)
|
||||
|
||||
Divider()
|
||||
|
||||
// 类别管理设置项
|
||||
ListItem(
|
||||
headlineContent = { Text("类别管理") },
|
||||
@ -145,6 +159,19 @@ fun SettingsScreen(
|
||||
onTypeChange = viewModel::setSelectedCategoryType
|
||||
)
|
||||
}
|
||||
|
||||
// 成员管理对话框
|
||||
if (showMemberDialog) {
|
||||
MemberManagementDialog(
|
||||
onDismiss = { showMemberDialog = false },
|
||||
members = members,
|
||||
onAddMember = memberViewModel::addMember,
|
||||
onDeleteMember = memberViewModel::deleteMember,
|
||||
onUpdateMember = { member, name, description ->
|
||||
memberViewModel.updateMember(member.copy(name = name, description = description))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
@ -7,6 +7,7 @@ import androidx.lifecycle.viewModelScope
|
||||
import com.yovinchen.bookkeeping.data.BookkeepingDatabase
|
||||
import com.yovinchen.bookkeeping.model.BookkeepingRecord
|
||||
import com.yovinchen.bookkeeping.model.Category
|
||||
import com.yovinchen.bookkeeping.model.Member
|
||||
import com.yovinchen.bookkeeping.model.TransactionType
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.*
|
||||
@ -14,37 +15,39 @@ import kotlinx.coroutines.launch
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
import java.time.YearMonth
|
||||
import java.util.Date
|
||||
import java.util.Calendar
|
||||
import java.util.*
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class HomeViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private val TAG = "HomeViewModel"
|
||||
private val dao = BookkeepingDatabase.getDatabase(application).bookkeepingDao()
|
||||
private val bookkeepingDao = BookkeepingDatabase.getDatabase(application).bookkeepingDao()
|
||||
private val memberDao = BookkeepingDatabase.getDatabase(application).memberDao()
|
||||
private val categoryDao = BookkeepingDatabase.getDatabase(application).categoryDao()
|
||||
|
||||
private val _selectedRecordType = MutableStateFlow<TransactionType?>(null)
|
||||
val selectedRecordType: StateFlow<TransactionType?> = _selectedRecordType.asStateFlow()
|
||||
|
||||
private val _selectedDateTime = MutableStateFlow(LocalDateTime.now())
|
||||
val selectedDateTime: StateFlow<LocalDateTime> = _selectedDateTime.asStateFlow()
|
||||
|
||||
private val _selectedCategoryType = MutableStateFlow(TransactionType.EXPENSE)
|
||||
val selectedCategoryType: StateFlow<TransactionType> = _selectedCategoryType.asStateFlow()
|
||||
|
||||
private val _selectedMonth = MutableStateFlow(YearMonth.now())
|
||||
val selectedMonth: StateFlow<YearMonth> = _selectedMonth.asStateFlow()
|
||||
|
||||
private val records = dao.getAllRecords()
|
||||
private val _selectedMember = MutableStateFlow<Member?>(null)
|
||||
val selectedMember: StateFlow<Member?> = _selectedMember.asStateFlow()
|
||||
|
||||
val members = memberDao.getAllMembers()
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5000),
|
||||
initialValue = emptyList()
|
||||
)
|
||||
|
||||
val categories: StateFlow<List<Category>> = _selectedCategoryType
|
||||
.flatMapLatest { type ->
|
||||
dao.getCategoriesByType(type)
|
||||
}
|
||||
val categories = categoryDao.getAllCategories()
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5000),
|
||||
initialValue = emptyList()
|
||||
)
|
||||
|
||||
private val allRecords = bookkeepingDao.getAllRecords()
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5000),
|
||||
@ -52,10 +55,11 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
|
||||
)
|
||||
|
||||
val filteredRecords = combine(
|
||||
records,
|
||||
allRecords,
|
||||
_selectedRecordType,
|
||||
_selectedMonth
|
||||
) { records, selectedType, selectedMonth ->
|
||||
_selectedMonth,
|
||||
_selectedMember
|
||||
) { records, selectedType, selectedMonth, selectedMember ->
|
||||
records
|
||||
.filter { record ->
|
||||
val recordDate = record.date.toInstant()
|
||||
@ -65,13 +69,14 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
val typeMatches = selectedType?.let { record.type == it } ?: true
|
||||
val monthMatches = recordYearMonth == selectedMonth
|
||||
val memberMatches = selectedMember?.let { record.memberId == it.id } ?: true
|
||||
|
||||
typeMatches && monthMatches
|
||||
monthMatches && memberMatches && typeMatches
|
||||
}
|
||||
.sortedByDescending { it.date }
|
||||
.groupBy { record ->
|
||||
val calendar = Calendar.getInstance().apply { time = record.date }
|
||||
calendar.apply {
|
||||
Calendar.getInstance().apply {
|
||||
time = record.date
|
||||
set(Calendar.HOUR_OF_DAY, 0)
|
||||
set(Calendar.MINUTE, 0)
|
||||
set(Calendar.SECOND, 0)
|
||||
@ -79,15 +84,16 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}.time
|
||||
}
|
||||
}.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.WhileSubscribed(5000),
|
||||
emptyMap()
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5000),
|
||||
initialValue = emptyMap()
|
||||
)
|
||||
|
||||
val totalIncome = combine(
|
||||
records,
|
||||
_selectedMonth
|
||||
) { records, selectedMonth ->
|
||||
allRecords,
|
||||
_selectedMonth,
|
||||
_selectedMember
|
||||
) { records, selectedMonth, selectedMember ->
|
||||
records
|
||||
.filter { record ->
|
||||
val recordDate = record.date.toInstant()
|
||||
@ -95,19 +101,24 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
|
||||
.toLocalDate()
|
||||
val recordYearMonth = YearMonth.from(recordDate)
|
||||
|
||||
record.type == TransactionType.INCOME && recordYearMonth == selectedMonth
|
||||
val monthMatches = recordYearMonth == selectedMonth
|
||||
val memberMatches = selectedMember?.let { record.memberId == it.id } ?: true
|
||||
val typeMatches = record.type == TransactionType.INCOME
|
||||
|
||||
monthMatches && memberMatches && typeMatches
|
||||
}
|
||||
.sumOf { it.amount }
|
||||
}.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.WhileSubscribed(5000),
|
||||
0.0
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5000),
|
||||
initialValue = 0.0
|
||||
)
|
||||
|
||||
val totalExpense = combine(
|
||||
records,
|
||||
_selectedMonth
|
||||
) { records, selectedMonth ->
|
||||
allRecords,
|
||||
_selectedMonth,
|
||||
_selectedMember
|
||||
) { records, selectedMonth, selectedMember ->
|
||||
records
|
||||
.filter { record ->
|
||||
val recordDate = record.date.toInstant()
|
||||
@ -115,111 +126,73 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
|
||||
.toLocalDate()
|
||||
val recordYearMonth = YearMonth.from(recordDate)
|
||||
|
||||
record.type == TransactionType.EXPENSE && recordYearMonth == selectedMonth
|
||||
val monthMatches = recordYearMonth == selectedMonth
|
||||
val memberMatches = selectedMember?.let { record.memberId == it.id } ?: true
|
||||
val typeMatches = record.type == TransactionType.EXPENSE
|
||||
|
||||
monthMatches && memberMatches && typeMatches
|
||||
}
|
||||
.sumOf { it.amount }
|
||||
}.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.WhileSubscribed(5000),
|
||||
0.0
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5000),
|
||||
initialValue = 0.0
|
||||
)
|
||||
|
||||
private fun updateTotals() {
|
||||
// 移除未使用的参数
|
||||
}
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
records.collect {
|
||||
updateTotals()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addRecord(type: TransactionType, amount: Double, category: String, description: String) {
|
||||
viewModelScope.launch {
|
||||
val record = BookkeepingRecord(
|
||||
amount = amount,
|
||||
type = type,
|
||||
category = category,
|
||||
description = description,
|
||||
date = Date.from(_selectedDateTime.value.atZone(ZoneId.systemDefault()).toInstant())
|
||||
)
|
||||
dao.insertRecord(record)
|
||||
resetSelectedDateTime()
|
||||
}
|
||||
}
|
||||
|
||||
fun setSelectedDateTime(dateTime: LocalDateTime) {
|
||||
_selectedDateTime.value = dateTime
|
||||
}
|
||||
|
||||
fun setSelectedRecordType(type: TransactionType?) {
|
||||
_selectedRecordType.value = type
|
||||
}
|
||||
|
||||
fun setSelectedCategoryType(type: TransactionType) {
|
||||
_selectedCategoryType.value = type
|
||||
}
|
||||
|
||||
fun setSelectedMonth(yearMonth: YearMonth) {
|
||||
_selectedMonth.value = yearMonth
|
||||
}
|
||||
|
||||
fun setSelectedMember(member: Member?) {
|
||||
_selectedMember.value = member
|
||||
}
|
||||
|
||||
fun moveMonth(forward: Boolean) {
|
||||
val current = _selectedMonth.value
|
||||
_selectedMonth.value = if (forward) {
|
||||
current.plusMonths(1)
|
||||
_selectedMonth.value.plusMonths(1)
|
||||
} else {
|
||||
current.minusMonths(1)
|
||||
_selectedMonth.value.minusMonths(1)
|
||||
}
|
||||
}
|
||||
|
||||
fun resetSelectedDateTime() {
|
||||
_selectedDateTime.value = LocalDateTime.now()
|
||||
suspend fun getMemberById(memberId: Int): Member? {
|
||||
return memberDao.getMemberById(memberId)
|
||||
}
|
||||
|
||||
fun addRecord(
|
||||
amount: Double,
|
||||
category: String,
|
||||
description: String,
|
||||
date: Date,
|
||||
type: TransactionType,
|
||||
memberId: Int?
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
val record = BookkeepingRecord(
|
||||
type = type,
|
||||
amount = amount,
|
||||
category = category,
|
||||
description = description,
|
||||
date = date,
|
||||
memberId = memberId
|
||||
)
|
||||
bookkeepingDao.insertRecord(record)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateRecord(record: BookkeepingRecord) {
|
||||
viewModelScope.launch {
|
||||
dao.updateRecord(record)
|
||||
bookkeepingDao.updateRecord(record)
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteRecord(record: BookkeepingRecord) {
|
||||
viewModelScope.launch {
|
||||
dao.deleteRecord(record)
|
||||
bookkeepingDao.deleteRecord(record)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取指定日期的记录
|
||||
fun getRecordsByDate(date: LocalDateTime): Flow<List<BookkeepingRecord>> {
|
||||
val calendar = Calendar.getInstance().apply {
|
||||
time = Date.from(date.atZone(ZoneId.systemDefault()).toInstant())
|
||||
set(Calendar.HOUR_OF_DAY, 0)
|
||||
set(Calendar.MINUTE, 0)
|
||||
set(Calendar.SECOND, 0)
|
||||
set(Calendar.MILLISECOND, 0)
|
||||
}
|
||||
val startOfDay = calendar.time
|
||||
calendar.add(Calendar.DAY_OF_MONTH, 1)
|
||||
val endOfDay = calendar.time
|
||||
return dao.getRecordsByDateRange(startOfDay, endOfDay)
|
||||
}
|
||||
|
||||
// 获取指定日期范围的记录
|
||||
fun getRecordsByDateRange(startDate: LocalDateTime, endDate: LocalDateTime): Flow<List<BookkeepingRecord>> {
|
||||
val start = Date.from(startDate.atZone(ZoneId.systemDefault()).toInstant())
|
||||
val end = Date.from(endDate.atZone(ZoneId.systemDefault()).toInstant())
|
||||
return dao.getRecordsByDateRange(start, end)
|
||||
}
|
||||
|
||||
// 获取指定类型的记录
|
||||
fun getRecordsByType(type: TransactionType): Flow<List<BookkeepingRecord>> {
|
||||
return dao.getRecordsByType(type)
|
||||
fun setSelectedRecordType(type: TransactionType?) {
|
||||
_selectedRecordType.value = type
|
||||
}
|
||||
}
|
||||
|
||||
data class UiState(
|
||||
val isAddingRecord: Boolean = false,
|
||||
val isManagingCategories: Boolean = false
|
||||
)
|
||||
|
@ -0,0 +1,38 @@
|
||||
package com.yovinchen.bookkeeping.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.yovinchen.bookkeeping.data.BookkeepingDatabase
|
||||
import com.yovinchen.bookkeeping.model.Member
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class MemberViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private val memberDao = BookkeepingDatabase.getDatabase(application).memberDao()
|
||||
|
||||
val allMembers: Flow<List<Member>> = memberDao.getAllMembers()
|
||||
|
||||
fun addMember(name: String, description: String = "") {
|
||||
viewModelScope.launch {
|
||||
val member = Member(name = name, description = description)
|
||||
memberDao.insertMember(member)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateMember(member: Member) {
|
||||
viewModelScope.launch {
|
||||
memberDao.updateMember(member)
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteMember(member: Member) {
|
||||
viewModelScope.launch {
|
||||
memberDao.deleteMember(member)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getMemberCount(): Int {
|
||||
return memberDao.getMemberCount()
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user