feat: 优化记账分析功能
- 重构导航系统,支持更细粒度的页面跳转 - 增强数据访问层,添加新的查询方法 - 优化界面布局和交互体验 - 添加成员分布分析功能 - 改进日期和金额的显示方式
This commit is contained in:
parent
380fdd5589
commit
94fc7b2a7e
@ -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">
|
||||||
|
@ -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>
|
||||||
}
|
}
|
||||||
|
@ -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()) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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() }
|
||||||
)
|
)
|
||||||
|
@ -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
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -142,6 +137,44 @@ 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
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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,68 +87,76 @@ 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -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 ->
|
|
||||||
_records.value = records
|
|
||||||
_total.value = records.sumOf { it.amount }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun loadMembers() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
database.memberDao().getAllMembers().collect { members ->
|
|
||||||
_members.value = members
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
.launchIn(viewModelScope)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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 }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user