3 Commits

Author SHA1 Message Date
1ab75f4701 Merge branch 'develop' into feature/member
修改
2024-11-27 16:18:26 +08:00
3ad8cf9184 Revert "修复警告"
This reverts commit 1147bc47d7.
2024-11-27 16:07:37 +08:00
1147bc47d7 修复警告 2024-11-27 16:07:10 +08:00
25 changed files with 68 additions and 1371 deletions

1
.idea/misc.xml generated
View File

@@ -1,3 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">

View File

@@ -25,7 +25,7 @@
## 🗺 开发路线图
### 0. 基础记账 (已完成 ✨)
### 1. 基础记账 (已完成 ✨)
- [x] 收入/支出记录管理
- [x] 分类管理系统
- [x] 自定义日期选择器
@@ -33,40 +33,41 @@
- [x] 深色/浅色主题切换
- [x] 主题色自定义
### 1. 成员系统 (已完成 🎉)
### 2. 成员系统 (已完成 🎉)
- [x] 成员添加/编辑/删除
- [x] 记账时选择相关成员
- [x] 主页账单修改相关成员
- [x] 成员消费统计
### 2. 图表分析 (进行中 🚀)
### 3. 数据分析 (进行中 🚀)
- [ ] 支出/收入趋势图表
- [ ] 分类占比饼图
- [ ] 月度/年度报表
- [ ] 成员消费分析
- [ ] 自定义统计周期
### 3. 数据管理 (计划中 📝)
### 4. 数据管理 (计划中 📝)
- [ ] 导出 CSV/Excel 功能
- [ ] 云端备份支持
- [ ] 数据迁移工具
- [ ] 定期自动备份
- [ ] 备份加密功能
### 4. 预算管理 (计划中 💡)
### 5. 预算管理 (计划中 💡)
- [ ] 月度预算设置
- [ ] 预算超支提醒
- [ ] 分类预算管理
- [ ] 成员预算管理
- [ ] 预算分析报告
### 5. 体验优化 (持续进行 🔄)
### 6. 体验优化 (持续进行 🔄)
- [x] 深色模式支持
- [ ] 手势操作优化
- [ ] 快速记账小组件
- [ ] 多语言支持
- [ ] 自定义主题
### 6. 性能提升 (持续进行 ⚡️)
### 7. 性能提升 (持续进行 ⚡️)
- [ ] 大数据量处理优化
- [ ] 启动速度优化
- [ ] 内存使用优化

View File

@@ -16,8 +16,8 @@ android {
applicationId = "com.yovinchen.bookkeeping"
minSdk = 26
targetSdk = 34
versionCode = 4
versionName = "1.2.2"
versionCode = 1
versionName = "1.0.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -89,7 +89,6 @@ dependencies {
implementation(libs.androidx.room.common)
implementation(libs.androidx.navigation.common.ktx)
implementation(libs.androidx.navigation.compose)
implementation(libs.vision.internal.vkp)
// Room
val roomVersion = "2.6.1"
@@ -97,9 +96,6 @@ dependencies {
implementation("androidx.room:room-ktx:$roomVersion")
ksp("androidx.room:room-compiler:$roomVersion")
// 图表库
implementation("com.github.PhilJay:MPAndroidChart:v3.1.0")
implementation("androidx.compose.material:material-icons-extended:1.4.3")
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")

View File

@@ -3,7 +3,6 @@ package com.yovinchen.bookkeeping.data
import androidx.room.*
import com.yovinchen.bookkeeping.model.BookkeepingRecord
import com.yovinchen.bookkeeping.model.Category
import com.yovinchen.bookkeeping.model.CategoryStat
import com.yovinchen.bookkeeping.model.TransactionType
import kotlinx.coroutines.flow.Flow
import java.util.Date
@@ -28,59 +27,6 @@ interface BookkeepingDao {
@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?>
@Query("""
SELECT * FROM bookkeeping_records
WHERE category = :category
AND strftime('%Y-%m', datetime(date/1000, 'unixepoch')) = :yearMonth
ORDER BY date DESC
""")
fun getRecordsByCategoryAndMonth(
category: String,
yearMonth: String
): Flow<List<BookkeepingRecord>>
@Query("""
SELECT * FROM bookkeeping_records
WHERE memberId IN (SELECT id FROM members WHERE name = :memberName)
AND strftime('%Y-%m', datetime(date/1000, 'unixepoch')) = :yearMonth
ORDER BY date DESC
""")
fun getRecordsByMemberAndMonth(
memberName: String,
yearMonth: String
): Flow<List<BookkeepingRecord>>
@Query("""
SELECT m.name as category,
SUM(r.amount) as amount,
COUNT(*) as count,
(SUM(r.amount) * 100.0 / (
SELECT SUM(amount)
FROM bookkeeping_records
WHERE category = :category
AND strftime('%Y-%m', datetime(date/1000, 'unixepoch')) = :yearMonth
)) as percentage
FROM bookkeeping_records r
JOIN members m ON r.memberId = m.id
WHERE r.category = :category
AND strftime('%Y-%m', datetime(r.date/1000, 'unixepoch')) = :yearMonth
GROUP BY m.name
ORDER BY amount DESC
""")
fun getMemberStatsByCategory(
category: String,
yearMonth: String
): Flow<List<CategoryStat>>
@Query("""
SELECT * FROM bookkeeping_records
WHERE category = :category
ORDER BY date DESC
""")
fun getRecordsByCategory(
category: String
): Flow<List<BookkeepingRecord>>
@Insert
suspend fun insertRecord(record: BookkeepingRecord): Long
@@ -107,50 +53,4 @@ interface BookkeepingDao {
@Query("UPDATE bookkeeping_records SET category = :newName WHERE category = :oldName")
suspend fun updateRecordCategories(oldName: String, newName: String)
@Query("""
SELECT * FROM bookkeeping_records
WHERE memberId IN (SELECT id FROM members WHERE name = :memberName)
AND date BETWEEN :startDate AND :endDate
AND (
:transactionType IS NULL
OR type = (
CASE :transactionType
WHEN 'INCOME' THEN 'INCOME'
WHEN 'EXPENSE' THEN 'EXPENSE'
END
)
)
ORDER BY date DESC
""")
suspend fun getRecordsByMember(
memberName: String,
startDate: Date,
endDate: Date,
transactionType: TransactionType?
): List<BookkeepingRecord>
@Query("""
SELECT * FROM bookkeeping_records
WHERE memberId IN (SELECT id FROM members WHERE name = :memberName)
AND category = :category
AND date BETWEEN :startDate AND :endDate
AND (
:transactionType IS NULL
OR type = (
CASE :transactionType
WHEN 'INCOME' THEN 'INCOME'
WHEN 'EXPENSE' THEN 'EXPENSE'
END
)
)
ORDER BY date DESC
""")
suspend fun getRecordsByMemberAndCategory(
memberName: String,
category: String,
startDate: Date,
endDate: Date,
transactionType: TransactionType?
): List<BookkeepingRecord>
}

View File

@@ -32,9 +32,9 @@ abstract class BookkeepingDatabase : RoomDatabase() {
private const val TAG = "BookkeepingDatabase"
private val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
override fun migrate(database: SupportSQLiteDatabase) {
// 创建成员表
db.execSQL("""
database.execSQL("""
CREATE TABLE IF NOT EXISTS members (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
name TEXT NOT NULL,
@@ -43,20 +43,20 @@ abstract class BookkeepingDatabase : RoomDatabase() {
""")
// 插入默认成员
db.execSQL("""
database.execSQL("""
INSERT INTO members (name, description)
VALUES ('自己', '默认成员')
""")
// 修改记账记录表添加成员ID字段
db.execSQL("""
database.execSQL("""
ALTER TABLE bookkeeping_records
ADD COLUMN memberId INTEGER DEFAULT NULL
REFERENCES members(id) ON DELETE SET NULL
""")
// 更新现有记录,将其关联到默认成员
db.execSQL("""
database.execSQL("""
UPDATE bookkeeping_records
SET memberId = (SELECT id FROM members WHERE name = '我自己')
""")
@@ -64,9 +64,9 @@ abstract class BookkeepingDatabase : RoomDatabase() {
}
private val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
override fun migrate(database: SupportSQLiteDatabase) {
// 重新创建记账记录表
db.execSQL("""
database.execSQL("""
CREATE TABLE IF NOT EXISTS bookkeeping_records_new (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
amount REAL NOT NULL,
@@ -80,19 +80,19 @@ abstract class BookkeepingDatabase : RoomDatabase() {
""")
// 复制数据
db.execSQL("""
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
""")
// 删除旧表
db.execSQL("DROP TABLE bookkeeping_records")
database.execSQL("DROP TABLE bookkeeping_records")
// 重命名新表
db.execSQL("ALTER TABLE bookkeeping_records_new RENAME TO bookkeeping_records")
database.execSQL("ALTER TABLE bookkeeping_records_new RENAME TO bookkeeping_records")
// 重新创建分类表
db.execSQL("""
database.execSQL("""
CREATE TABLE IF NOT EXISTS categories_new (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
name TEXT NOT NULL,
@@ -101,16 +101,16 @@ abstract class BookkeepingDatabase : RoomDatabase() {
""")
// 复制分类数据
db.execSQL("""
database.execSQL("""
INSERT INTO categories_new (id, name, type)
SELECT id, name, type FROM categories
""")
// 删除旧表
db.execSQL("DROP TABLE categories")
database.execSQL("DROP TABLE categories")
// 重命名新表
db.execSQL("ALTER TABLE categories_new RENAME TO categories")
database.execSQL("ALTER TABLE categories_new RENAME TO categories")
}
}

View File

@@ -3,7 +3,6 @@ package com.yovinchen.bookkeeping.data
import androidx.room.TypeConverter
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.util.*
class Converters {
private val formatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME
@@ -19,14 +18,4 @@ class Converters {
fun dateToTimestamp(date: LocalDateTime?): String? {
return date?.format(formatter)
}
@TypeConverter
fun fromDate(value: Date?): String? {
return value?.time?.toString()
}
@TypeConverter
fun toDate(timestamp: String?): Date? {
return timestamp?.let { Date(it.toLong()) }
}
}

View File

@@ -12,6 +12,5 @@ data class Record(
val category: String,
val description: String,
val dateTime: LocalDateTime = LocalDateTime.now(),
val isExpense: Boolean = true,
val member: String = "Default"
val isExpense: Boolean = true
)

View File

@@ -1,7 +0,0 @@
package com.yovinchen.bookkeeping.model
enum class AnalysisType {
EXPENSE,
INCOME,
TREND
}

View File

@@ -2,7 +2,6 @@ package com.yovinchen.bookkeeping.model
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
import androidx.room.TypeConverter
import androidx.room.TypeConverters
@@ -44,9 +43,6 @@ class Converters {
childColumns = ["memberId"],
onDelete = ForeignKey.SET_NULL
)
],
indices = [
Index(value = ["memberId"])
]
)
@TypeConverters(Converters::class)

View File

@@ -1,8 +0,0 @@
package com.yovinchen.bookkeeping.model
data class CategoryStat(
val category: String,
val amount: Double,
val count: Int = 0,
val percentage: Double = 0.0
)

View File

@@ -1,82 +0,0 @@
package com.yovinchen.bookkeeping.ui.components
import android.graphics.Color as AndroidColor
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.*
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import com.github.mikephil.charting.charts.PieChart
import com.github.mikephil.charting.data.Entry
import com.github.mikephil.charting.data.PieData
import com.github.mikephil.charting.data.PieDataSet
import com.github.mikephil.charting.data.PieEntry
import com.github.mikephil.charting.formatter.PercentFormatter
import com.github.mikephil.charting.highlight.Highlight
import com.github.mikephil.charting.listener.OnChartValueSelectedListener
import com.github.mikephil.charting.utils.ColorTemplate
@Composable
fun CategoryPieChart(
categoryData: List<Pair<String, Float>>,
memberData: List<Pair<String, Float>>,
currentViewMode: Boolean = false, // false 为分类视图true 为成员视图
modifier: Modifier = Modifier,
onCategoryClick: (String) -> Unit = {}
) {
val textColor = MaterialTheme.colorScheme.onSurface.toArgb()
val data = if (currentViewMode) memberData else categoryData
AndroidView(
modifier = modifier
.fillMaxWidth()
.height(300.dp),
factory = { context ->
PieChart(context).apply {
description.isEnabled = false
setUsePercentValues(true)
setDrawEntryLabels(true)
legend.isEnabled = false
isDrawHoleEnabled = true
holeRadius = 40f
setHoleColor(AndroidColor.TRANSPARENT)
setTransparentCircleRadius(45f)
setEntryLabelColor(textColor)
setEntryLabelTextSize(12f)
setCenterTextColor(textColor)
setOnChartValueSelectedListener(object : OnChartValueSelectedListener {
override fun onValueSelected(e: Entry?, h: Highlight?) {
e?.let {
if (it is PieEntry) {
onCategoryClick(it.label ?: return)
}
}
}
override fun onNothingSelected() {}
})
}
},
update = { chart ->
val entries = data.map { (label, amount) ->
PieEntry(amount, label)
}
val dataSet = PieDataSet(entries, "").apply {
colors = ColorTemplate.MATERIAL_COLORS.toList()
valueTextSize = 14f
valueFormatter = PercentFormatter(chart)
valueTextColor = textColor
setDrawValues(true)
}
val pieData = PieData(dataSet)
chart.data = pieData
chart.invalidate()
}
)
}

View File

@@ -1,71 +0,0 @@
package com.yovinchen.bookkeeping.ui.components
import android.annotation.SuppressLint
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.yovinchen.bookkeeping.model.CategoryStat
@SuppressLint("DefaultLocale")
@Composable
fun CategoryStatItem(
stat: CategoryStat,
onClick: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(vertical = 8.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stat.category,
style = MaterialTheme.typography.bodyLarge
)
Text(
text = String.format("%.2f", stat.amount),
style = MaterialTheme.typography.bodyLarge
)
}
Spacer(modifier = Modifier.height(4.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
LinearProgressIndicator(
progress = { stat.percentage.toFloat() / 100f },
modifier = Modifier
.weight(1f)
.height(8.dp)
.background(
MaterialTheme.colorScheme.surfaceVariant,
RoundedCornerShape(4.dp)
),
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = String.format("%.1f%%", stat.percentage),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}

View File

@@ -1,88 +0,0 @@
package com.yovinchen.bookkeeping.ui.components
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 java.time.YearMonth
@Composable
fun MonthYearPicker(
selectedMonth: YearMonth,
onMonthSelected: (YearMonth) -> Unit,
onDismiss: () -> Unit
) {
var year by remember { mutableStateOf(selectedMonth.year) }
var month by remember { mutableStateOf(selectedMonth.monthValue) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("选择月份") },
text = {
Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// 年份选择
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text("年份:")
OutlinedButton(
onClick = { year-- }
) {
Text("-")
}
Text(year.toString())
OutlinedButton(
onClick = { year++ }
) {
Text("+")
}
}
Spacer(modifier = Modifier.height(16.dp))
// 月份选择
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text("月份:")
OutlinedButton(
onClick = {
if (month > 1) month--
}
) {
Text("-")
}
Text(month.toString())
OutlinedButton(
onClick = {
if (month < 12) month++
}
) {
Text("+")
}
}
}
},
confirmButton = {
TextButton(
onClick = {
onMonthSelected(YearMonth.of(year, month))
}
) {
Text("确定")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("取消")
}
}
)
}

View File

@@ -22,7 +22,6 @@ fun RecordItem(
members: List<Member> = emptyList()
) {
var showDeleteDialog by remember { mutableStateOf(false) }
// val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) }
val timeFormat = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) }
val member = members.find { it.id == record.memberId }
@@ -49,18 +48,14 @@ fun RecordItem(
style = MaterialTheme.typography.bodyLarge
)
// 第二行:日期和时间 | 成员 | 详情
// 第二行:时间 | 成员 | 详情
Text(
text = buildString {
// append(dateFormat.format(record.date))
// append(" ")
append(timeFormat.format(record.date))
// if (member != null && member.name != "自己") {
if (member != null && member.name != "自己") {
append(" | ")
if (member != null) {
append(member.name)
}
// }
if (record.description.isNotEmpty()) {
append(" | ")
append(record.description)

View File

@@ -2,54 +2,40 @@ package com.yovinchen.bookkeeping.ui.navigation
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.List
import androidx.compose.material.icons.filled.Analytics
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.yovinchen.bookkeeping.model.AnalysisType
import com.yovinchen.bookkeeping.model.ThemeMode
import com.yovinchen.bookkeeping.ui.screen.*
import java.time.YearMonth
import java.time.format.DateTimeFormatter
import com.yovinchen.bookkeeping.ui.screen.HomeScreen
import com.yovinchen.bookkeeping.ui.screen.SettingsScreen
sealed class Screen(
val route: String,
val title: String,
val icon: ImageVector? = null
) {
object Home : Screen("home", "记账", Icons.AutoMirrored.Filled.List)
object Analysis : Screen("analysis", "分析", Icons.Default.Analytics)
object Settings : Screen("settings", "设置", Icons.Default.Settings)
object CategoryDetail : Screen("category_detail/{category}/{yearMonth}", "分类详情") {
fun createRoute(category: String, yearMonth: YearMonth): String {
return "category_detail/$category/${yearMonth.format(DateTimeFormatter.ofPattern("yyyy-MM"))}"
}
}
object MemberDetail : Screen("member_detail/{memberName}/{category}/{yearMonth}?type={type}", "成员详情") {
fun createRoute(memberName: String, category: String, yearMonth: YearMonth, type: AnalysisType): String {
return "member_detail/$memberName/$category/${yearMonth.format(DateTimeFormatter.ofPattern("yyyy-MM"))}?type=${type.name}"
}
}
companion object {
fun bottomNavigationItems() = listOf(Home, Analysis, Settings)
}
sealed class Screen(val route: String, val icon: @Composable () -> Unit, val label: String) {
object Home : Screen(
route = "home",
icon = { Icon(Icons.Default.Home, contentDescription = "主页") },
label = "主页"
)
object Settings : Screen(
route = "settings",
icon = { Icon(Icons.Default.Settings, contentDescription = "设置") },
label = "设置"
)
}
@OptIn(ExperimentalMaterial3Api::class)
@@ -59,18 +45,22 @@ fun MainNavigation(
onThemeChange: (ThemeMode) -> Unit
) {
val navController = rememberNavController()
val items = listOf(Screen.Home, Screen.Settings)
Scaffold(
bottomBar = {
NavigationBar {
NavigationBar(
containerColor = MaterialTheme.colorScheme.surface,
contentColor = MaterialTheme.colorScheme.onSurface,
) {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
val currentDestination = navBackStackEntry?.destination
Screen.bottomNavigationItems().forEach { screen ->
items.forEach { screen ->
NavigationBarItem(
icon = { Icon(screen.icon!!, contentDescription = screen.title) },
label = { Text(screen.title) },
selected = currentRoute == screen.route,
icon = screen.icon,
label = { Text(screen.label) },
selected = currentDestination?.hierarchy?.any { it.route == screen.route } == true,
onClick = {
navController.navigate(screen.route) {
popUpTo(navController.graph.findStartDestination().id) {
@@ -79,90 +69,33 @@ fun MainNavigation(
launchSingleTop = true
restoreState = true
}
}
},
colors = NavigationBarItemDefaults.colors(
selectedIconColor = MaterialTheme.colorScheme.primary,
selectedTextColor = MaterialTheme.colorScheme.primary,
unselectedIconColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
unselectedTextColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
indicatorColor = MaterialTheme.colorScheme.surfaceVariant
)
)
}
}
}
) { innerPadding ->
) { paddingValues ->
NavHost(
navController = navController,
startDestination = Screen.Home.route,
modifier = Modifier.padding(innerPadding)
modifier = Modifier.padding(paddingValues)
) {
composable(Screen.Home.route) { HomeScreen() }
composable(Screen.Analysis.route) {
AnalysisScreen(
onNavigateToCategoryDetail = { category, yearMonth ->
navController.navigate(Screen.CategoryDetail.createRoute(category, yearMonth))
},
onNavigateToMemberDetail = { memberName, yearMonth, analysisType ->
navController.navigate(Screen.MemberDetail.createRoute(memberName, "", yearMonth, analysisType))
}
)
composable(Screen.Home.route) {
HomeScreen()
}
composable(Screen.Settings.route) {
SettingsScreen(
currentTheme = currentTheme,
onThemeChange = onThemeChange
)
}
composable(
route = Screen.CategoryDetail.route,
arguments = listOf(
navArgument("category") { type = NavType.StringType },
navArgument("yearMonth") { type = NavType.StringType }
)
) { backStackEntry ->
val category = backStackEntry.arguments?.getString("category") ?: return@composable
val yearMonthStr = backStackEntry.arguments?.getString("yearMonth") ?: return@composable
val yearMonth = YearMonth.parse(yearMonthStr)
CategoryDetailScreen(
category = category,
yearMonth = yearMonth,
onNavigateBack = { navController.popBackStack() },
onNavigateToMemberDetail = { memberName ->
navController.navigate(Screen.MemberDetail.createRoute(memberName, category, yearMonth, AnalysisType.EXPENSE))
}
)
}
composable(
route = Screen.MemberDetail.route,
arguments = listOf(
navArgument("memberName") { type = NavType.StringType },
navArgument("category") { type = NavType.StringType },
navArgument("yearMonth") { type = NavType.StringType },
navArgument("type") {
type = NavType.StringType
defaultValue = AnalysisType.EXPENSE.name
}
)
) { backStackEntry ->
val memberName = backStackEntry.arguments?.getString("memberName") ?: return@composable
val category = backStackEntry.arguments?.getString("category") ?: return@composable
val yearMonthStr = backStackEntry.arguments?.getString("yearMonth") ?: return@composable
val yearMonth = YearMonth.parse(yearMonthStr)
val type = backStackEntry.arguments?.getString("type")?.let {
try {
AnalysisType.valueOf(it)
} catch (e: IllegalArgumentException) {
AnalysisType.EXPENSE
}
} ?: AnalysisType.EXPENSE
MemberDetailScreen(
memberName = memberName,
yearMonth = yearMonth,
category = category,
analysisType = type,
onNavigateBack = { navController.popBackStack() }
)
}
}
}
}

View File

@@ -1,175 +0,0 @@
package com.yovinchen.bookkeeping.ui.screen
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.yovinchen.bookkeeping.model.AnalysisType
import com.yovinchen.bookkeeping.ui.components.CategoryPieChart
import com.yovinchen.bookkeeping.ui.components.CategoryStatItem
import com.yovinchen.bookkeeping.ui.components.MonthYearPicker
import com.yovinchen.bookkeeping.viewmodel.AnalysisViewModel
import java.time.YearMonth
import java.time.format.DateTimeFormatter
enum class ViewMode {
CATEGORY, MEMBER
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AnalysisScreen(
onNavigateToCategoryDetail: (String, YearMonth) -> Unit,
onNavigateToMemberDetail: (String, YearMonth, AnalysisType) -> Unit
) {
val viewModel: AnalysisViewModel = viewModel()
val selectedMonth by viewModel.selectedMonth.collectAsState()
val selectedAnalysisType by viewModel.selectedAnalysisType.collectAsState()
val categoryStats by viewModel.categoryStats.collectAsState()
val memberStats by viewModel.memberStats.collectAsState()
var showMonthPicker by remember { mutableStateOf(false) }
var showViewModeMenu by remember { mutableStateOf(false) }
var currentViewMode by rememberSaveable { mutableStateOf(ViewMode.CATEGORY) }
Scaffold { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
// 时间选择按钮行
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
Button(onClick = { showMonthPicker = true }) {
Text(selectedMonth.format(DateTimeFormatter.ofPattern("yyyy年MM月")))
}
}
// 分析类型和视图模式选择行
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
// 分类/成员切换下拉菜单
Box {
Button(
onClick = { showViewModeMenu = true }
) {
Text(if (currentViewMode == ViewMode.CATEGORY) "分类" else "成员")
Icon(Icons.Default.ArrowDropDown, "切换视图")
}
DropdownMenu(
expanded = showViewModeMenu,
onDismissRequest = { showViewModeMenu = false }
) {
DropdownMenuItem(
text = { Text("分类") },
onClick = {
currentViewMode = ViewMode.CATEGORY
showViewModeMenu = false
}
)
DropdownMenuItem(
text = { Text("成员") },
onClick = {
currentViewMode = ViewMode.MEMBER
showViewModeMenu = false
}
)
}
}
// 类型切换
Row {
AnalysisType.values().forEach { type ->
FilterChip(
selected = selectedAnalysisType == type,
onClick = { viewModel.setAnalysisType(type) },
label = {
Text(
when (type) {
AnalysisType.EXPENSE -> "支出"
AnalysisType.INCOME -> "收入"
AnalysisType.TREND -> "趋势"
}
)
},
modifier = Modifier.padding(horizontal = 4.dp)
)
}
}
}
// 使用LazyColumn包含饼图和列表
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp)
) {
// 添加饼图作为第一个项目
if (selectedAnalysisType != AnalysisType.TREND) {
item {
CategoryPieChart(
categoryData = categoryStats.map { Pair(it.category, it.percentage.toFloat()) },
memberData = memberStats.map { Pair(it.category, it.percentage.toFloat()) },
currentViewMode = currentViewMode == ViewMode.MEMBER,
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
.padding(bottom = 16.dp),
onCategoryClick = { category ->
if (currentViewMode == ViewMode.CATEGORY) {
onNavigateToCategoryDetail(category, selectedMonth)
} else {
onNavigateToMemberDetail(category, selectedMonth, selectedAnalysisType)
}
}
)
}
}
// 添加统计列表项目
items(if (currentViewMode == ViewMode.CATEGORY) categoryStats else memberStats) { stat ->
CategoryStatItem(
stat = stat,
onClick = {
if (currentViewMode == ViewMode.CATEGORY) {
onNavigateToCategoryDetail(stat.category, selectedMonth)
} else {
onNavigateToMemberDetail(stat.category, selectedMonth, selectedAnalysisType)
}
}
)
}
}
if (showMonthPicker) {
MonthYearPicker(
selectedMonth = selectedMonth,
onMonthSelected = { month ->
viewModel.setSelectedMonth(month)
showMonthPicker = false
},
onDismiss = { showMonthPicker = false }
)
}
}
}
}

View File

@@ -1,249 +0,0 @@
package com.yovinchen.bookkeeping.ui.screen
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.yovinchen.bookkeeping.data.BookkeepingDatabase
import com.yovinchen.bookkeeping.model.BookkeepingRecord
import com.yovinchen.bookkeeping.model.TransactionType
import com.yovinchen.bookkeeping.ui.components.CategoryPieChart
import com.yovinchen.bookkeeping.ui.components.RecordItem
import com.yovinchen.bookkeeping.viewmodel.CategoryDetailViewModel
import com.yovinchen.bookkeeping.viewmodel.CategoryDetailViewModelFactory
import java.text.NumberFormat
import java.text.SimpleDateFormat
import java.time.YearMonth
import java.util.Locale
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CategoryDetailScreen(
category: String,
yearMonth: YearMonth,
onNavigateBack: () -> Unit,
onNavigateToMemberDetail: (String) -> Unit,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val database = remember { BookkeepingDatabase.getDatabase(context) }
val viewModel: CategoryDetailViewModel = viewModel(
factory = CategoryDetailViewModelFactory(database, category, yearMonth)
)
val records by viewModel.records.collectAsState()
val memberStats by viewModel.memberStats.collectAsState()
val total by viewModel.total.collectAsState()
Scaffold(
topBar = {
TopAppBar(
title = { Text(category) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回")
}
}
)
}
) { padding ->
LazyColumn(
modifier = modifier
.fillMaxSize()
.padding(padding),
horizontalAlignment = Alignment.CenterHorizontally
) {
// 第一部分:总支出
item {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Column(
modifier = Modifier
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = if (records.isNotEmpty() && records.first().type == TransactionType.INCOME) "总收入" else "总支出",
style = MaterialTheme.typography.titleMedium
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = NumberFormat.getCurrencyInstance(Locale.CHINA).format(total),
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
}
}
}
// 第二部分:扇形图
item {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Column(
modifier = Modifier
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "成员分布",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(bottom = 16.dp)
)
CategoryPieChart(
categoryData = memberStats.map { Pair(it.category, it.percentage.toFloat()) },
memberData = emptyList(),
currentViewMode = false,
modifier = Modifier
.fillMaxWidth()
.height(200.dp),
onCategoryClick = { memberName ->
if (records.isNotEmpty() && records.first().type == TransactionType.EXPENSE) {
onNavigateToMemberDetail(memberName)
}
}
)
}
}
}
// 第三部分:详细信息
item {
Text(
text = "详细记录",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(16.dp)
)
}
// 按日期分组的记录列表
val groupedRecords = records.groupBy { record ->
SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(record.date)
}.toSortedMap(compareByDescending { it })
groupedRecords.forEach { (date, dayRecords) ->
item {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
// 日期标题和总金额
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = date,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Text(
text = NumberFormat.getCurrencyInstance(Locale.CHINA)
.format(dayRecords.sumOf { it.amount }),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.error
)
}
Spacer(modifier = Modifier.height(8.dp))
// 当天的记录列表
dayRecords.forEach { record ->
RecordItem(record = record)
if (record != dayRecords.last()) {
HorizontalDivider(
modifier = Modifier.padding(vertical = 8.dp),
color = MaterialTheme.colorScheme.outlineVariant
)
}
}
}
}
}
}
}
}
}
@Composable
private fun RecordItem(
record: BookkeepingRecord,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = record.memberId.toString(), // 暂时显示 memberId后续可以通过 MemberDao 获取成员名称
style = MaterialTheme.typography.titleMedium
)
if (record.description.isNotBlank()) {
Text(
text = record.description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Text(
text = SimpleDateFormat("HH:mm", Locale.getDefault()).format(record.date),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Text(
text = NumberFormat.getCurrencyInstance(Locale.CHINA).format(record.amount),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.error
)
}
}

View File

@@ -1,184 +0,0 @@
package com.yovinchen.bookkeeping.ui.screen
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.yovinchen.bookkeeping.data.Record
import com.yovinchen.bookkeeping.model.TransactionType
import com.yovinchen.bookkeeping.model.AnalysisType
import com.yovinchen.bookkeeping.ui.components.RecordItem
import com.yovinchen.bookkeeping.viewmodel.MemberDetailViewModel
import java.text.NumberFormat
import java.text.SimpleDateFormat
import java.time.YearMonth
import java.util.Locale
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MemberDetailScreen(
memberName: String,
yearMonth: YearMonth,
category: String = "",
analysisType: AnalysisType = AnalysisType.EXPENSE,
onNavigateBack: () -> Unit,
viewModel: MemberDetailViewModel = viewModel()
) {
val records by viewModel.memberRecords.collectAsState(initial = emptyList())
val totalAmount by viewModel.totalAmount.collectAsState(initial = 0.0)
LaunchedEffect(memberName, category, yearMonth, analysisType) {
viewModel.loadMemberRecords(memberName, category, yearMonth, analysisType)
}
val groupedRecords = remember(records) {
records.groupBy { record ->
SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(record.date)
}.toSortedMap(reverseOrder())
}
Scaffold(
topBar = {
TopAppBar(
title = {
Text(memberName)
},
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "返回")
}
}
)
}
) { padding ->
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
// 第一层:总金额卡片
item {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Column(
modifier = Modifier
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = if (records.isNotEmpty() && records.first().type == TransactionType.INCOME) "总收入" else "总支出",
style = MaterialTheme.typography.titleMedium
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = NumberFormat.getCurrencyInstance(Locale.CHINA)
.format(totalAmount),
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
}
}
}
// 第二层:按日期分组的记录列表
groupedRecords.forEach { (date, dayRecords) ->
item {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = date,
style = MaterialTheme.typography.titleMedium
)
Text(
text = NumberFormat.getCurrencyInstance(Locale.CHINA)
.format(dayRecords.sumOf { it.amount }),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)
}
dayRecords.forEach { record ->
RecordItem(record = record)
}
}
}
}
}
}
}
}
@Composable
private fun RecordItem(record: Record) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
if (record.description.isNotBlank()) {
Text(
text = record.description,
style = MaterialTheme.typography.bodyMedium
)
}
Text(
text = SimpleDateFormat("HH:mm", Locale.getDefault())
.format(record.dateTime),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Text(
text = NumberFormat.getCurrencyInstance(Locale.CHINA)
.format(record.amount),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary
)
}
}

View File

@@ -1,104 +0,0 @@
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.AnalysisType
import com.yovinchen.bookkeeping.model.CategoryStat
import com.yovinchen.bookkeeping.model.TransactionType
import kotlinx.coroutines.flow.*
import java.time.LocalDateTime
import java.time.YearMonth
import java.time.ZoneId
import java.util.Date
class AnalysisViewModel(application: Application) : AndroidViewModel(application) {
private val recordDao = BookkeepingDatabase.getDatabase(application).bookkeepingDao()
private val memberDao = BookkeepingDatabase.getDatabase(application).memberDao()
private val _selectedMonth = MutableStateFlow(YearMonth.now())
val selectedMonth = _selectedMonth.asStateFlow()
private val _selectedAnalysisType = MutableStateFlow(AnalysisType.EXPENSE)
val selectedAnalysisType = _selectedAnalysisType.asStateFlow()
private val members = memberDao.getAllMembers()
val memberStats = combine(selectedMonth, selectedAnalysisType, members) { month, type, membersList ->
val records = recordDao.getAllRecords().first()
val monthRecords = records.filter {
val recordDate = Date(it.date.time)
val localDateTime = LocalDateTime.ofInstant(recordDate.toInstant(), ZoneId.systemDefault())
YearMonth.from(localDateTime) == month && it.type == when(type) {
AnalysisType.EXPENSE -> TransactionType.EXPENSE
AnalysisType.INCOME -> TransactionType.INCOME
else -> null
}
}
// 按成员统计
val memberMap = monthRecords.groupBy { record ->
membersList.find { it.id == record.memberId }?.name ?: "未分配"
}
val stats = memberMap.map { (memberName, records) ->
CategoryStat(
category = memberName,
amount = records.sumOf { it.amount },
count = records.size
)
}.sortedByDescending { it.amount }
// 计算总额
val total = stats.sumOf { it.amount }
// 计算百分比
stats.map { it.copy(percentage = if (total > 0) it.amount / total * 100 else 0.0) }
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = emptyList()
)
val categoryStats = combine(selectedMonth, selectedAnalysisType) { month, type ->
val records = recordDao.getAllRecords().first()
val monthRecords = records.filter {
val recordDate = Date(it.date.time)
val localDateTime = LocalDateTime.ofInstant(recordDate.toInstant(), ZoneId.systemDefault())
YearMonth.from(localDateTime) == month && it.type == when(type) {
AnalysisType.EXPENSE -> TransactionType.EXPENSE
AnalysisType.INCOME -> TransactionType.INCOME
else -> null
}
}
// 按分类统计
val categoryMap = monthRecords.groupBy { it.category }
val stats = categoryMap.map { (category, records) ->
CategoryStat(
category = category,
amount = records.sumOf { it.amount },
count = records.size
)
}.sortedByDescending { it.amount }
// 计算总额
val total = stats.sumOf { it.amount }
// 计算百分比
stats.map { it.copy(percentage = if (total > 0) it.amount / total * 100 else 0.0) }
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = emptyList()
)
fun setSelectedMonth(month: YearMonth) {
_selectedMonth.value = month
}
fun setAnalysisType(type: AnalysisType) {
_selectedAnalysisType.value = type
}
}

View File

@@ -1,54 +0,0 @@
package com.yovinchen.bookkeeping.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.yovinchen.bookkeeping.data.BookkeepingDatabase
import com.yovinchen.bookkeeping.model.BookkeepingRecord
import com.yovinchen.bookkeeping.model.CategoryStat
import kotlinx.coroutines.flow.*
import java.time.YearMonth
import java.time.format.DateTimeFormatter
class CategoryDetailViewModel(
private val database: BookkeepingDatabase,
private val category: String,
private val month: YearMonth
) : ViewModel() {
private val recordDao = database.bookkeepingDao()
private val yearMonthStr = month.format(DateTimeFormatter.ofPattern("yyyy-MM"))
private val _records = MutableStateFlow<List<BookkeepingRecord>>(emptyList())
val records: StateFlow<List<BookkeepingRecord>> = _records.asStateFlow()
private val _memberStats = MutableStateFlow<List<CategoryStat>>(emptyList())
val memberStats: StateFlow<List<CategoryStat>> = _memberStats.asStateFlow()
val total: StateFlow<Double> = records
.map { records -> records.sumOf { it.amount } }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = 0.0
)
init {
recordDao.getRecordsByCategory(category)
.onEach { records ->
_records.value = records.filter { record ->
val recordMonth = YearMonth.from(
DateTimeFormatter.ofPattern("yyyy-MM")
.parse(yearMonthStr)
)
YearMonth.from(record.date.toInstant().atZone(java.time.ZoneId.systemDefault()).toLocalDateTime()) == recordMonth
}
}
.launchIn(viewModelScope)
recordDao.getMemberStatsByCategory(category, yearMonthStr)
.onEach { stats ->
_memberStats.value = stats
}
.launchIn(viewModelScope)
}
}

View File

@@ -1,20 +0,0 @@
package com.yovinchen.bookkeeping.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.yovinchen.bookkeeping.data.BookkeepingDatabase
import java.time.YearMonth
class CategoryDetailViewModelFactory(
private val database: BookkeepingDatabase,
private val category: String,
private val month: YearMonth
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(CategoryDetailViewModel::class.java)) {
@Suppress("UNCHECKED_CAST")
return CategoryDetailViewModel(database, category, month) as T
}
throw IllegalArgumentException("Unknown ViewModel class")
}
}

View File

@@ -1,65 +0,0 @@
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.BookkeepingRecord
import com.yovinchen.bookkeeping.model.AnalysisType
import com.yovinchen.bookkeeping.model.TransactionType
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import java.time.YearMonth
import java.time.ZoneId
import java.util.Date
class MemberDetailViewModel(application: Application) : AndroidViewModel(application) {
private val database = BookkeepingDatabase.getDatabase(application)
private val recordDao = database.bookkeepingDao()
private val _memberRecords = MutableStateFlow<List<BookkeepingRecord>>(emptyList())
val memberRecords: StateFlow<List<BookkeepingRecord>> = _memberRecords
private val _totalAmount = MutableStateFlow(0.0)
val totalAmount: StateFlow<Double> = _totalAmount
fun loadMemberRecords(memberName: String, category: String, yearMonth: YearMonth, analysisType: AnalysisType) {
viewModelScope.launch {
val startDate = yearMonth.atDay(1).atStartOfDay()
.atZone(ZoneId.systemDefault())
.toInstant()
.let { Date.from(it) }
val endDate = yearMonth.atEndOfMonth().atTime(23, 59, 59)
.atZone(ZoneId.systemDefault())
.toInstant()
.let { Date.from(it) }
val transactionType = when (analysisType) {
AnalysisType.INCOME -> TransactionType.INCOME
AnalysisType.EXPENSE -> TransactionType.EXPENSE
else -> null
}
val records = if (category.isEmpty()) {
recordDao.getRecordsByMember(
memberName = memberName,
startDate = startDate,
endDate = endDate,
transactionType = transactionType
)
} else {
recordDao.getRecordsByMemberAndCategory(
memberName = memberName,
category = category,
startDate = startDate,
endDate = endDate,
transactionType = transactionType
)
}
_memberRecords.value = records
_totalAmount.value = records.sumOf { it.amount }
}
}
}

View File

@@ -20,6 +20,4 @@ kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true
# Kotlin
org.gradle.java.home=/Library/Java/JavaVirtualMachines/jdk-17.jdk/Contents/Home
android.nonTransitiveRClass=true

View File

@@ -11,7 +11,6 @@ composeBom = "2024.04.01"
roomCommon = "2.6.1"
navigationCommonKtx = "2.8.4"
navigationCompose = "2.8.4"
visionInternalVkp = "18.2.3"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -31,7 +30,6 @@ androidx-material3 = { group = "androidx.compose.material3", name = "material3"
androidx-room-common = { group = "androidx.room", name = "room-common", version.ref = "roomCommon" }
androidx-navigation-common-ktx = { group = "androidx.navigation", name = "navigation-common-ktx", version.ref = "navigationCommonKtx" }
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
vision-internal-vkp = { group = "com.google.mlkit", name = "vision-internal-vkp", version.ref = "visionInternalVkp" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }

View File

@@ -9,7 +9,6 @@ pluginManagement {
}
mavenCentral()
gradlePluginPortal()
maven { url = uri("https://jitpack.io") }
}
}
dependencyResolutionManagement {
@@ -17,7 +16,6 @@ dependencyResolutionManagement {
repositories {
google()
mavenCentral()
maven { url = uri("https://jitpack.io") }
}
}