feat: 优化记账分析功能

- 重构导航系统,支持更细粒度的页面跳转
- 增强数据访问层,添加新的查询方法
- 优化界面布局和交互体验
- 添加成员分布分析功能
- 改进日期和金额的显示方式
This commit is contained in:
yovinchen 2024-11-28 17:38:54 +08:00
parent 380fdd5589
commit 94fc7b2a7e
8 changed files with 351 additions and 232 deletions

View File

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

View File

@ -3,6 +3,7 @@ package com.yovinchen.bookkeeping.data
import androidx.room.* import androidx.room.*
import com.yovinchen.bookkeeping.model.BookkeepingRecord import com.yovinchen.bookkeeping.model.BookkeepingRecord
import com.yovinchen.bookkeeping.model.Category import com.yovinchen.bookkeeping.model.Category
import com.yovinchen.bookkeeping.model.CategoryStat
import com.yovinchen.bookkeeping.model.TransactionType import com.yovinchen.bookkeeping.model.TransactionType
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import java.util.Date import java.util.Date
@ -49,6 +50,37 @@ interface BookkeepingDao {
yearMonth: String yearMonth: String
): Flow<List<BookkeepingRecord>> ): 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 @Insert
suspend fun insertRecord(record: BookkeepingRecord): Long suspend fun insertRecord(record: BookkeepingRecord): Long
@ -75,4 +107,18 @@ interface BookkeepingDao {
@Query("UPDATE bookkeeping_records SET category = :newName WHERE category = :oldName") @Query("UPDATE bookkeeping_records SET category = :newName WHERE category = :oldName")
suspend fun updateRecordCategories(oldName: String, newName: String) suspend fun updateRecordCategories(oldName: String, newName: String)
@Query("""
SELECT * FROM bookkeeping_records
WHERE memberId IN (SELECT id FROM members WHERE name = :memberName)
AND category = :category
AND date BETWEEN :startDate AND :endDate
ORDER BY date DESC
""")
suspend fun getRecordsByMemberAndCategory(
memberName: String,
category: String,
startDate: Date,
endDate: Date
): List<BookkeepingRecord>
} }

View File

@ -3,6 +3,7 @@ package com.yovinchen.bookkeeping.data
import androidx.room.TypeConverter import androidx.room.TypeConverter
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.util.*
class Converters { class Converters {
private val formatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME private val formatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME
@ -18,4 +19,14 @@ class Converters {
fun dateToTimestamp(date: LocalDateTime?): String? { fun dateToTimestamp(date: LocalDateTime?): String? {
return date?.format(formatter) 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

@ -2,11 +2,9 @@ package com.yovinchen.bookkeeping.ui.navigation
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.automirrored.filled.List
import androidx.compose.material.icons.filled.List import androidx.compose.material.icons.filled.Analytics
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.outlined.Analytics
import androidx.compose.material.icons.outlined.Home
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBar
@ -31,27 +29,25 @@ import java.time.format.DateTimeFormatter
sealed class Screen( sealed class Screen(
val route: String, val route: String,
val icon: ImageVector? = null, val title: String,
val label: String? = null val icon: ImageVector? = null
) { ) {
data object Home : Screen("home", Icons.Outlined.Home, "首页") object Home : Screen("home", "记账", Icons.AutoMirrored.Filled.List)
data object Analysis : Screen("analysis", Icons.Outlined.Analytics, "分析") object Analysis : Screen("analysis", "分析", Icons.Default.Analytics)
data object Settings : Screen("settings", Icons.Default.Settings, "设置") object Settings : Screen("settings", "设置", Icons.Default.Settings)
data object CategoryDetail : Screen( object CategoryDetail : Screen("category_detail/{category}/{yearMonth}", "分类详情") {
"category/{category}/{yearMonth}", fun createRoute(category: String, yearMonth: YearMonth): String {
Icons.Default.List, return "category_detail/$category/${yearMonth.format(DateTimeFormatter.ofPattern("yyyy-MM"))}"
"分类详情"
) {
fun createRoute(category: String, yearMonth: YearMonth): String =
"category/$category/${yearMonth.format(DateTimeFormatter.ofPattern("yyyy-MM"))}"
} }
data object MemberDetail : Screen( }
"member/{memberName}/{yearMonth}", object MemberDetail : Screen("member_detail/{memberName}/{category}/{yearMonth}", "成员详情") {
Icons.Default.List, fun createRoute(memberName: String, category: String, yearMonth: YearMonth): String {
"成员详情" return "member_detail/$memberName/$category/${yearMonth.format(DateTimeFormatter.ofPattern("yyyy-MM"))}"
) { }
fun createRoute(memberName: String, yearMonth: YearMonth): String = }
"member/$memberName/${yearMonth.format(DateTimeFormatter.ofPattern("yyyy-MM"))}"
companion object {
fun bottomNavigationItems() = listOf(Home, Analysis, Settings)
} }
} }
@ -69,14 +65,10 @@ fun MainNavigation(
val navBackStackEntry by navController.currentBackStackEntryAsState() val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route val currentRoute = navBackStackEntry?.destination?.route
listOf( Screen.bottomNavigationItems().forEach { screen ->
Screen.Home,
Screen.Analysis,
Screen.Settings
).forEach { screen ->
NavigationBarItem( NavigationBarItem(
icon = { Icon(screen.icon!!, contentDescription = screen.label) }, icon = { Icon(screen.icon!!, contentDescription = screen.title) },
label = { Text(screen.label!!) }, label = { Text(screen.title) },
selected = currentRoute == screen.route, selected = currentRoute == screen.route,
onClick = { onClick = {
navController.navigate(screen.route) { navController.navigate(screen.route) {
@ -105,7 +97,9 @@ fun MainNavigation(
navController.navigate(Screen.CategoryDetail.createRoute(category, yearMonth)) navController.navigate(Screen.CategoryDetail.createRoute(category, yearMonth))
}, },
onNavigateToMemberDetail = { memberName, yearMonth -> onNavigateToMemberDetail = { memberName, yearMonth ->
navController.navigate(Screen.MemberDetail.createRoute(memberName, yearMonth)) // 在这里我们暂时使用一个默认分类,你需要根据实际情况修改这里的逻辑
val defaultCategory = "默认"
navController.navigate(Screen.MemberDetail.createRoute(memberName, defaultCategory, yearMonth))
} }
) )
} }
@ -126,11 +120,15 @@ fun MainNavigation(
) { backStackEntry -> ) { backStackEntry ->
val category = backStackEntry.arguments?.getString("category") ?: return@composable val category = backStackEntry.arguments?.getString("category") ?: return@composable
val yearMonthStr = backStackEntry.arguments?.getString("yearMonth") ?: return@composable val yearMonthStr = backStackEntry.arguments?.getString("yearMonth") ?: return@composable
val yearMonth = YearMonth.parse(yearMonthStr, DateTimeFormatter.ofPattern("yyyy-MM")) val yearMonth = YearMonth.parse(yearMonthStr)
CategoryDetailScreen( CategoryDetailScreen(
category = category, category = category,
month = yearMonth, yearMonth = yearMonth,
onBack = { navController.popBackStack() } onNavigateBack = { navController.popBackStack() },
onNavigateToMemberDetail = { memberName ->
navController.navigate(Screen.MemberDetail.createRoute(memberName, category, yearMonth))
}
) )
} }
@ -138,14 +136,18 @@ fun MainNavigation(
route = Screen.MemberDetail.route, route = Screen.MemberDetail.route,
arguments = listOf( arguments = listOf(
navArgument("memberName") { type = NavType.StringType }, navArgument("memberName") { type = NavType.StringType },
navArgument("category") { type = NavType.StringType },
navArgument("yearMonth") { type = NavType.StringType } navArgument("yearMonth") { type = NavType.StringType }
) )
) { backStackEntry -> ) { backStackEntry ->
val memberName = backStackEntry.arguments?.getString("memberName") ?: return@composable 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 yearMonthStr = backStackEntry.arguments?.getString("yearMonth") ?: return@composable
val yearMonth = YearMonth.parse(yearMonthStr, DateTimeFormatter.ofPattern("yyyy-MM")) val yearMonth = YearMonth.parse(yearMonthStr)
MemberDetailScreen( MemberDetailScreen(
memberName = memberName, memberName = memberName,
category = category,
yearMonth = yearMonth, yearMonth = yearMonth,
onNavigateBack = { navController.popBackStack() } onNavigateBack = { navController.popBackStack() }
) )

View File

@ -4,135 +4,130 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.yovinchen.bookkeeping.data.BookkeepingDatabase import com.yovinchen.bookkeeping.data.BookkeepingDatabase
import com.yovinchen.bookkeeping.model.BookkeepingRecord import com.yovinchen.bookkeeping.model.BookkeepingRecord
import com.yovinchen.bookkeeping.ui.components.CategoryPieChart
import com.yovinchen.bookkeeping.ui.components.RecordItem import com.yovinchen.bookkeeping.ui.components.RecordItem
import com.yovinchen.bookkeeping.viewmodel.CategoryDetailViewModel import com.yovinchen.bookkeeping.viewmodel.CategoryDetailViewModel
import com.yovinchen.bookkeeping.viewmodel.CategoryDetailViewModelFactory import com.yovinchen.bookkeeping.viewmodel.CategoryDetailViewModelFactory
import java.text.NumberFormat
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.time.YearMonth import java.time.YearMonth
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.util.Locale import java.util.*
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun CategoryDetailScreen( fun CategoryDetailScreen(
category: String, category: String,
month: YearMonth, yearMonth: YearMonth,
onBack: () -> Unit onNavigateBack: () -> Unit,
onNavigateToMemberDetail: (String) -> Unit,
modifier: Modifier = Modifier
) { ) {
val context = LocalContext.current val context = LocalContext.current
val database = remember { BookkeepingDatabase.getDatabase(context) } val database = remember { BookkeepingDatabase.getDatabase(context) }
val viewModel: CategoryDetailViewModel = viewModel( val viewModel: CategoryDetailViewModel = viewModel(
factory = CategoryDetailViewModelFactory(database, category, month) factory = CategoryDetailViewModelFactory(database, category, yearMonth)
) )
val records by viewModel.records.collectAsState() val records by viewModel.records.collectAsState()
val memberStats by viewModel.memberStats.collectAsState()
val total by viewModel.total.collectAsState() val total by viewModel.total.collectAsState()
val members by viewModel.members.collectAsState()
val groupedRecords = remember(records) {
records.groupBy { record ->
SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(record.date)
}
}
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text("$category - ${month.format(DateTimeFormatter.ofPattern("yyyy年MM月"))}") }, title = { Text(category) },
navigationIcon = { navigationIcon = {
IconButton(onClick = onBack) { IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "返回") Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回")
} }
} }
) )
} }
) { padding -> ) { padding ->
Column( LazyColumn(
modifier = Modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
.padding(padding) .padding(padding),
) { horizontalAlignment = Alignment.CenterHorizontally
// 总金额显示
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) { ) {
item {
Text( Text(
text = "总金额", text = NumberFormat.getCurrencyInstance(Locale.CHINA).format(total),
style = MaterialTheme.typography.titleMedium style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(16.dp)
) )
Text(
text = String.format("%.2f", total),
style = MaterialTheme.typography.titleLarge
)
}
} }
// 记录列表 item {
LazyColumn( CategoryPieChart(
modifier = Modifier.fillMaxSize(), categoryData = memberStats.map { Pair(it.category, it.percentage.toFloat()) },
contentPadding = PaddingValues(16.dp), memberData = emptyList(),
verticalArrangement = Arrangement.spacedBy(8.dp) currentViewMode = false,
) { modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
onCategoryClick = { memberName -> onNavigateToMemberDetail(memberName) }
)
}
// 按日期分组记录
val groupedRecords = records.groupBy { record ->
SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(record.date)
}.toSortedMap(compareByDescending { it })
groupedRecords.forEach { (date, dayRecords) -> groupedRecords.forEach { (date, dayRecords) ->
item { item {
Card( Card(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(vertical = 4.dp), .padding(horizontal = 16.dp, vertical = 8.dp)
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(16.dp) .padding(16.dp)
) { ) {
// 日期标签 // 日期标题和总金额
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text( Text(
text = SimpleDateFormat( text = date,
"yyyy年MM月dd日 E",
Locale.CHINESE
).format(dayRecords.first().date),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant 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)) Spacer(modifier = Modifier.height(8.dp))
// 当天的记录 // 当天的记录列表
Column( dayRecords.forEach { record ->
modifier = Modifier.fillMaxWidth(), RecordItem(record = record)
verticalArrangement = Arrangement.spacedBy(4.dp) if (record != dayRecords.last()) {
) {
dayRecords.forEachIndexed { index, record ->
RecordItem(
record = record,
onClick = {},
members = members
)
if (index < dayRecords.size - 1) {
HorizontalDivider( HorizontalDivider(
modifier = Modifier.padding(vertical = 4.dp), modifier = Modifier.padding(vertical = 8.dp),
color = MaterialTheme.colorScheme.surfaceVariant, color = MaterialTheme.colorScheme.outlineVariant
thickness = 0.5.dp
) )
} }
} }
@ -143,5 +138,43 @@ fun CategoryDetailScreen(
} }
} }
} }
@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

@ -2,7 +2,6 @@ package com.yovinchen.bookkeeping.ui.screen
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.* import androidx.compose.material3.*
@ -12,7 +11,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.yovinchen.bookkeeping.model.BookkeepingRecord import com.yovinchen.bookkeeping.data.Record
import com.yovinchen.bookkeeping.ui.components.RecordItem
import com.yovinchen.bookkeeping.viewmodel.MemberDetailViewModel import com.yovinchen.bookkeeping.viewmodel.MemberDetailViewModel
import java.text.NumberFormat import java.text.NumberFormat
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
@ -24,6 +24,7 @@ import java.util.*
@Composable @Composable
fun MemberDetailScreen( fun MemberDetailScreen(
memberName: String, memberName: String,
category: String,
yearMonth: YearMonth, yearMonth: YearMonth,
onNavigateBack: () -> Unit, onNavigateBack: () -> Unit,
viewModel: MemberDetailViewModel = viewModel() viewModel: MemberDetailViewModel = viewModel()
@ -31,8 +32,8 @@ fun MemberDetailScreen(
val records by viewModel.memberRecords.collectAsState(initial = emptyList()) val records by viewModel.memberRecords.collectAsState(initial = emptyList())
val totalAmount by viewModel.totalAmount.collectAsState(initial = 0.0) val totalAmount by viewModel.totalAmount.collectAsState(initial = 0.0)
LaunchedEffect(memberName, yearMonth) { LaunchedEffect(memberName, category, yearMonth) {
viewModel.loadMemberRecords(memberName, yearMonth) viewModel.loadMemberRecords(memberName, category, yearMonth)
} }
val groupedRecords = remember(records) { val groupedRecords = remember(records) {
@ -45,7 +46,7 @@ fun MemberDetailScreen(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { title = {
Text("$memberName - ${yearMonth.format(DateTimeFormatter.ofPattern("yyyy年MM月"))}") Text("$category - $memberName")
}, },
navigationIcon = { navigationIcon = {
IconButton(onClick = onNavigateBack) { IconButton(onClick = onNavigateBack) {
@ -55,25 +56,29 @@ fun MemberDetailScreen(
) )
} }
) { padding -> ) { padding ->
Column( LazyColumn(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(padding) .padding(padding)
) { ) {
// 总金额显示 // 第一层:总金额卡片
item {
Card( Card(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(16.dp) .padding(16.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
.padding(16.dp) .padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Text( Text(
text = "总支出", text = "当前分类总支出",
style = MaterialTheme.typography.titleMedium style = MaterialTheme.typography.titleMedium
) )
Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = NumberFormat.getCurrencyInstance(Locale.CHINA) text = NumberFormat.getCurrencyInstance(Locale.CHINA)
.format(totalAmount), .format(totalAmount),
@ -82,22 +87,38 @@ fun MemberDetailScreen(
) )
} }
} }
}
// 按日期分组的记录列表 // 第二层:按日期分组的记录列表
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
groupedRecords.forEach { (date, dayRecords) -> groupedRecords.forEach { (date, dayRecords) ->
item { 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(
text = date, text = date,
style = MaterialTheme.typography.titleMedium
)
Text(
text = NumberFormat.getCurrencyInstance(Locale.CHINA)
.format(dayRecords.sumOf { it.amount }),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(vertical = 8.dp) color = MaterialTheme.colorScheme.primary
) )
} }
items(dayRecords.sortedByDescending { it.date }) { record -> dayRecords.forEach { record ->
RecordItem(record = record) RecordItem(record = record)
} }
} }
@ -105,45 +126,37 @@ fun MemberDetailScreen(
} }
} }
} }
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun RecordItem(record: BookkeepingRecord) { private fun RecordItem(record: Record) {
Card(
modifier = Modifier.fillMaxWidth()
) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(16.dp), .padding(vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Column( Column {
modifier = Modifier.weight(1f)
) {
Text(
text = record.category,
style = MaterialTheme.typography.titleMedium
)
if (record.description.isNotBlank()) { if (record.description.isNotBlank()) {
Text( Text(
text = record.description, text = record.description,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium
color = MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
Text( Text(
text = SimpleDateFormat("HH:mm", Locale.getDefault()).format(record.date), text = SimpleDateFormat("HH:mm", Locale.getDefault())
.format(record.dateTime),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
Text( Text(
text = NumberFormat.getCurrencyInstance(Locale.CHINA).format(record.amount), text = NumberFormat.getCurrencyInstance(Locale.CHINA)
style = MaterialTheme.typography.titleMedium, .format(record.amount),
color = if (record.amount < 0) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary
) )
} }
} }
}

View File

@ -1,14 +1,12 @@
package com.yovinchen.bookkeeping.viewmodel package com.yovinchen.bookkeeping.viewmodel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.yovinchen.bookkeeping.data.BookkeepingDatabase import com.yovinchen.bookkeeping.data.BookkeepingDatabase
import com.yovinchen.bookkeeping.model.BookkeepingRecord import com.yovinchen.bookkeeping.model.BookkeepingRecord
import com.yovinchen.bookkeeping.model.Member import com.yovinchen.bookkeeping.model.CategoryStat
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.*
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import java.time.YearMonth import java.time.YearMonth
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
@ -17,36 +15,40 @@ class CategoryDetailViewModel(
private val category: String, private val category: String,
private val month: YearMonth private val month: YearMonth
) : ViewModel() { ) : ViewModel() {
private val recordDao = database.bookkeepingDao()
private val yearMonthStr = month.format(DateTimeFormatter.ofPattern("yyyy-MM"))
private val _records = MutableStateFlow<List<BookkeepingRecord>>(emptyList()) private val _records = MutableStateFlow<List<BookkeepingRecord>>(emptyList())
val records: StateFlow<List<BookkeepingRecord>> = _records val records: StateFlow<List<BookkeepingRecord>> = _records.asStateFlow()
private val _total = MutableStateFlow(0.0) private val _memberStats = MutableStateFlow<List<CategoryStat>>(emptyList())
val total: StateFlow<Double> = _total val memberStats: StateFlow<List<CategoryStat>> = _memberStats.asStateFlow()
private val _members = MutableStateFlow<List<Member>>(emptyList()) val total: StateFlow<Double> = records
val members: StateFlow<List<Member>> = _members .map { records -> records.sumOf { it.amount } }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = 0.0
)
init { init {
loadRecords() recordDao.getRecordsByCategory(category)
loadMembers() .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)
private fun loadRecords() { recordDao.getMemberStatsByCategory(category, yearMonthStr)
viewModelScope.launch { .onEach { stats ->
val monthStr = month.format(DateTimeFormatter.ofPattern("yyyy-MM")) _memberStats.value = stats
database.bookkeepingDao().getRecordsByCategoryAndMonth(category, monthStr) }
.collect { records -> .launchIn(viewModelScope)
_records.value = records
_total.value = records.sumOf { it.amount }
}
}
}
private fun loadMembers() {
viewModelScope.launch {
database.memberDao().getAllMembers().collect { members ->
_members.value = members
}
}
} }
} }

View File

@ -5,30 +5,43 @@ import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.yovinchen.bookkeeping.data.BookkeepingDatabase import com.yovinchen.bookkeeping.data.BookkeepingDatabase
import com.yovinchen.bookkeeping.model.BookkeepingRecord import com.yovinchen.bookkeeping.model.BookkeepingRecord
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import java.time.YearMonth import java.time.YearMonth
import java.time.format.DateTimeFormatter import java.time.ZoneId
import java.util.Date
class MemberDetailViewModel(application: Application) : AndroidViewModel(application) { class MemberDetailViewModel(application: Application) : AndroidViewModel(application) {
private val recordDao = BookkeepingDatabase.getDatabase(application).bookkeepingDao() private val database = BookkeepingDatabase.getDatabase(application)
private val recordDao = database.bookkeepingDao()
private val _memberRecords = MutableStateFlow<List<BookkeepingRecord>>(emptyList()) private val _memberRecords = MutableStateFlow<List<BookkeepingRecord>>(emptyList())
val memberRecords: StateFlow<List<BookkeepingRecord>> = _memberRecords.asStateFlow() val memberRecords: StateFlow<List<BookkeepingRecord>> = _memberRecords
val totalAmount: StateFlow<Double> = _memberRecords private val _totalAmount = MutableStateFlow(0.0)
.map { records -> records.sumOf { it.amount } } val totalAmount: StateFlow<Double> = _totalAmount
.stateIn(
scope = viewModelScope, fun loadMemberRecords(memberName: String, category: String, yearMonth: YearMonth) {
started = SharingStarted.WhileSubscribed(5000), viewModelScope.launch {
initialValue = 0.0 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 records = recordDao.getRecordsByMemberAndCategory(
memberName = memberName,
category = category,
startDate = startDate,
endDate = endDate
) )
fun loadMemberRecords(memberName: String, yearMonth: YearMonth) {
recordDao.getRecordsByMemberAndMonth(
memberName,
yearMonth.format(DateTimeFormatter.ofPattern("yyyy-MM"))
).onEach { records ->
_memberRecords.value = records _memberRecords.value = records
}.launchIn(viewModelScope) _totalAmount.value = records.sumOf { it.amount }
}
} }
} }