Compare commits

...

2 Commits

Author SHA1 Message Date
94fc7b2a7e feat: 优化记账分析功能
- 重构导航系统,支持更细粒度的页面跳转
- 增强数据访问层,添加新的查询方法
- 优化界面布局和交互体验
- 添加成员分布分析功能
- 改进日期和金额的显示方式
2024-11-28 17:38:54 +08:00
380fdd5589 feat: 成员分析与详情功能实现
1. 新增成员详情页面,按天分组显示记录
2. 优化分析页面,支持分类/成员视图切换
3. 使用 rememberSaveable 保持视图模式状态
4. 改进 UI 布局和交互体验
2024-11-28 16:14:49 +08:00
12 changed files with 616 additions and 186 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
@ -38,6 +39,48 @@ interface BookkeepingDao {
yearMonth: String yearMonth: String
): Flow<List<BookkeepingRecord>> ): 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 @Insert
suspend fun insertRecord(record: BookkeepingRecord): Long suspend fun insertRecord(record: BookkeepingRecord): Long
@ -64,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

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

View File

@ -2,16 +2,14 @@ package com.yovinchen.bookkeeping.ui.components
import android.graphics.Color as AndroidColor import android.graphics.Color as AndroidColor
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.height
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import com.github.mikephil.charting.charts.PieChart import com.github.mikephil.charting.charts.PieChart
import com.github.mikephil.charting.components.Legend
import com.github.mikephil.charting.data.Entry import com.github.mikephil.charting.data.Entry
import com.github.mikephil.charting.data.PieData import com.github.mikephil.charting.data.PieData
import com.github.mikephil.charting.data.PieDataSet import com.github.mikephil.charting.data.PieDataSet
@ -24,11 +22,13 @@ import com.github.mikephil.charting.utils.ColorTemplate
@Composable @Composable
fun CategoryPieChart( fun CategoryPieChart(
categoryData: List<Pair<String, Float>>, categoryData: List<Pair<String, Float>>,
memberData: List<Pair<String, Float>>,
currentViewMode: Boolean = false, // false 为分类视图true 为成员视图
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onCategoryClick: (String) -> Unit = {} onCategoryClick: (String) -> Unit = {}
) { ) {
isSystemInDarkTheme()
val textColor = MaterialTheme.colorScheme.onSurface.toArgb() val textColor = MaterialTheme.colorScheme.onSurface.toArgb()
val data = if (currentViewMode) memberData else categoryData
AndroidView( AndroidView(
modifier = modifier modifier = modifier
@ -39,23 +39,15 @@ fun CategoryPieChart(
description.isEnabled = false description.isEnabled = false
setUsePercentValues(true) setUsePercentValues(true)
setDrawEntryLabels(true) setDrawEntryLabels(true)
// 禁用图例显示
legend.isEnabled = false legend.isEnabled = false
isDrawHoleEnabled = true isDrawHoleEnabled = true
holeRadius = 40f holeRadius = 40f
setHoleColor(AndroidColor.TRANSPARENT) setHoleColor(AndroidColor.TRANSPARENT)
setTransparentCircleRadius(45f) setTransparentCircleRadius(45f)
// 设置标签文字颜色
setEntryLabelColor(textColor) setEntryLabelColor(textColor)
setEntryLabelTextSize(12f) setEntryLabelTextSize(12f)
// 设置中心文字颜色跟随主题
setCenterTextColor(textColor) setCenterTextColor(textColor)
// 添加点击事件监听器
setOnChartValueSelectedListener(object : OnChartValueSelectedListener { setOnChartValueSelectedListener(object : OnChartValueSelectedListener {
override fun onValueSelected(e: Entry?, h: Highlight?) { override fun onValueSelected(e: Entry?, h: Highlight?) {
e?.let { e?.let {
@ -65,18 +57,16 @@ fun CategoryPieChart(
} }
} }
override fun onNothingSelected() { override fun onNothingSelected() {}
// 不需要处理
}
}) })
} }
}, },
update = { chart -> update = { chart ->
val entries = categoryData.map { (category, amount) -> val entries = data.map { (label, amount) ->
PieEntry(amount, category) PieEntry(amount, label)
} }
val dataSet = PieDataSet(entries, "").apply { // 将标题设为空字符串 val dataSet = PieDataSet(entries, "").apply {
colors = ColorTemplate.MATERIAL_COLORS.toList() colors = ColorTemplate.MATERIAL_COLORS.toList()
valueTextSize = 14f valueTextSize = 14f
valueFormatter = PercentFormatter(chart) valueFormatter = PercentFormatter(chart)

View File

@ -2,10 +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.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
@ -24,28 +23,31 @@ import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument import androidx.navigation.navArgument
import com.yovinchen.bookkeeping.model.ThemeMode import com.yovinchen.bookkeeping.model.ThemeMode
import com.yovinchen.bookkeeping.ui.screen.AnalysisScreen import com.yovinchen.bookkeeping.ui.screen.*
import com.yovinchen.bookkeeping.ui.screen.CategoryDetailScreen
import com.yovinchen.bookkeeping.ui.screen.HomeScreen
import com.yovinchen.bookkeeping.ui.screen.SettingsScreen
import java.time.YearMonth import java.time.YearMonth
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
sealed class Screen( sealed class Screen(
val route: String, val route: String,
val icon: ImageVector, val title: String,
val label: String val icon: ImageVector? = null
) { ) {
data object Home : Screen("home", Icons.Default.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_detail/{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: String) = object MemberDetail : Screen("member_detail/{memberName}/{category}/{yearMonth}", "成员详情") {
"category_detail/$category/$yearMonth" fun createRoute(memberName: String, category: String, yearMonth: YearMonth): String {
return "member_detail/$memberName/$category/${yearMonth.format(DateTimeFormatter.ofPattern("yyyy-MM"))}"
}
}
companion object {
fun bottomNavigationItems() = listOf(Home, Analysis, Settings)
} }
} }
@ -63,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) {
@ -92,14 +90,20 @@ fun MainNavigation(
modifier = Modifier.padding(innerPadding) modifier = Modifier.padding(innerPadding)
) { ) {
composable(Screen.Home.route) { HomeScreen() } composable(Screen.Home.route) { HomeScreen() }
composable(Screen.Analysis.route) { composable(Screen.Analysis.route) {
AnalysisScreen( AnalysisScreen(
onNavigateToCategoryDetail = { category, month -> onNavigateToCategoryDetail = { category, yearMonth ->
val monthStr = month.format(DateTimeFormatter.ofPattern("yyyy-MM")) navController.navigate(Screen.CategoryDetail.createRoute(category, yearMonth))
navController.navigate(Screen.CategoryDetail.createRoute(category, monthStr)) },
onNavigateToMemberDetail = { memberName, yearMonth ->
// 在这里我们暂时使用一个默认分类,你需要根据实际情况修改这里的逻辑
val defaultCategory = "默认"
navController.navigate(Screen.MemberDetail.createRoute(memberName, defaultCategory, yearMonth))
} }
) )
} }
composable(Screen.Settings.route) { composable(Screen.Settings.route) {
SettingsScreen( SettingsScreen(
currentTheme = currentTheme, currentTheme = currentTheme,
@ -115,13 +119,37 @@ fun MainNavigation(
) )
) { backStackEntry -> ) { backStackEntry ->
val category = backStackEntry.arguments?.getString("category") ?: return@composable val category = backStackEntry.arguments?.getString("category") ?: return@composable
val yearMonth = YearMonth.parse( val yearMonthStr = backStackEntry.arguments?.getString("yearMonth") ?: return@composable
backStackEntry.arguments?.getString("yearMonth") ?: return@composable 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))
}
)
}
composable(
route = Screen.MemberDetail.route,
arguments = listOf(
navArgument("memberName") { type = NavType.StringType },
navArgument("category") { type = NavType.StringType },
navArgument("yearMonth") { type = NavType.StringType }
)
) { 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)
MemberDetailScreen(
memberName = memberName,
category = category,
yearMonth = yearMonth,
onNavigateBack = { navController.popBackStack() }
) )
} }
} }

View File

@ -3,8 +3,11 @@ 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.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
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
@ -18,17 +21,25 @@ import com.yovinchen.bookkeeping.viewmodel.AnalysisViewModel
import java.time.YearMonth import java.time.YearMonth
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
enum class ViewMode {
CATEGORY, MEMBER
}
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun AnalysisScreen( fun AnalysisScreen(
onNavigateToCategoryDetail: (String, YearMonth) -> Unit onNavigateToCategoryDetail: (String, YearMonth) -> Unit,
onNavigateToMemberDetail: (String, YearMonth) -> Unit
) { ) {
val viewModel: AnalysisViewModel = viewModel() val viewModel: AnalysisViewModel = viewModel()
val selectedMonth by viewModel.selectedMonth.collectAsState() val selectedMonth by viewModel.selectedMonth.collectAsState()
val selectedAnalysisType by viewModel.selectedAnalysisType.collectAsState() val selectedAnalysisType by viewModel.selectedAnalysisType.collectAsState()
val categoryStats by viewModel.categoryStats.collectAsState() val categoryStats by viewModel.categoryStats.collectAsState()
val memberStats by viewModel.memberStats.collectAsState()
var showMonthPicker by remember { mutableStateOf(false) } var showMonthPicker by remember { mutableStateOf(false) }
var showViewModeMenu by remember { mutableStateOf(false) }
var currentViewMode by rememberSaveable { mutableStateOf(ViewMode.CATEGORY) }
Scaffold { padding -> Scaffold { padding ->
Column( Column(
@ -36,17 +47,54 @@ fun AnalysisScreen(
.fillMaxSize() .fillMaxSize()
.padding(padding) .padding(padding)
) { ) {
// 月份选择器和类型切换 // 时间选择按钮行
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(16.dp), .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, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
// 月份选择按钮 // 分类/成员切换下拉菜单
Button(onClick = { showMonthPicker = true }) { Box {
Text(selectedMonth.format(DateTimeFormatter.ofPattern("yyyy年MM月"))) 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
}
)
}
} }
// 类型切换 // 类型切换
@ -80,37 +128,48 @@ fun AnalysisScreen(
item { item {
CategoryPieChart( CategoryPieChart(
categoryData = categoryStats.map { Pair(it.category, it.percentage.toFloat()) }, categoryData = categoryStats.map { Pair(it.category, it.percentage.toFloat()) },
memberData = memberStats.map { Pair(it.category, it.percentage.toFloat()) },
currentViewMode = currentViewMode == ViewMode.MEMBER,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(200.dp) .height(200.dp)
.padding(bottom = 16.dp), .padding(bottom = 16.dp),
onCategoryClick = { category -> onCategoryClick = { category ->
onNavigateToCategoryDetail(category, selectedMonth) if (currentViewMode == ViewMode.CATEGORY) {
onNavigateToCategoryDetail(category, selectedMonth)
} else {
onNavigateToMemberDetail(category, selectedMonth)
}
} }
) )
} }
} }
// 添加分类统计列表项目 // 添加统计列表项目
items(categoryStats) { stat -> items(if (currentViewMode == ViewMode.CATEGORY) categoryStats else memberStats) { stat ->
CategoryStatItem( CategoryStatItem(
stat = stat, stat = stat,
onClick = { onNavigateToCategoryDetail(stat.category, selectedMonth) } onClick = {
if (currentViewMode == ViewMode.CATEGORY) {
onNavigateToCategoryDetail(stat.category, selectedMonth)
} else {
onNavigateToMemberDetail(stat.category, selectedMonth)
}
}
) )
} }
} }
}
// 月份选择器对话框 if (showMonthPicker) {
if (showMonthPicker) { MonthYearPicker(
MonthYearPicker( selectedMonth = selectedMonth,
selectedMonth = selectedMonth, onMonthSelected = { month ->
onMonthSelected = { viewModel.setSelectedMonth(month)
viewModel.setSelectedMonth(it) showMonthPicker = false
showMonthPicker = false },
}, onDismiss = { showMonthPicker = false }
onDismiss = { showMonthPicker = false } )
) }
} }
} }
} }

View File

@ -4,138 +4,131 @@ 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
) { ) {
// 总金额显示 item {
Card( Text(
modifier = Modifier text = NumberFormat.getCurrencyInstance(Locale.CHINA).format(total),
.fillMaxWidth() style = MaterialTheme.typography.headlineMedium,
.padding(horizontal = 16.dp, vertical = 8.dp) modifier = Modifier.padding(16.dp)
) { )
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "总金额",
style = MaterialTheme.typography.titleMedium
)
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
groupedRecords.forEach { (date, dayRecords) -> .fillMaxWidth()
item { .padding(16.dp),
Card( 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) ->
item {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(vertical = 4.dp), .padding(16.dp)
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) { ) {
Column( // 日期标题和总金额
Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(16.dp) .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()) {
) { HorizontalDivider(
dayRecords.forEachIndexed { index, record -> modifier = Modifier.padding(vertical = 8.dp),
RecordItem( color = MaterialTheme.colorScheme.outlineVariant
record = record, )
onClick = {},
members = members
)
if (index < dayRecords.size - 1) {
HorizontalDivider(
modifier = Modifier.padding(vertical = 4.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
thickness = 0.5.dp
)
}
}
} }
} }
} }
@ -145,3 +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

@ -0,0 +1,162 @@
package com.yovinchen.bookkeeping.ui.screen
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.*
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.ui.components.RecordItem
import com.yovinchen.bookkeeping.viewmodel.MemberDetailViewModel
import java.text.NumberFormat
import java.text.SimpleDateFormat
import java.time.YearMonth
import java.time.format.DateTimeFormatter
import java.util.*
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MemberDetailScreen(
memberName: String,
category: String,
yearMonth: YearMonth,
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) {
viewModel.loadMemberRecords(memberName, category, yearMonth)
}
val groupedRecords = remember(records) {
records.groupBy { record ->
SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(record.date)
}.toSortedMap(reverseOrder())
}
Scaffold(
topBar = {
TopAppBar(
title = {
Text("$category - $memberName")
},
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.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 = "当前分类总支出",
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

@ -11,9 +11,11 @@ import kotlinx.coroutines.flow.*
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.YearMonth import java.time.YearMonth
import java.time.ZoneId import java.time.ZoneId
import java.util.Date
class AnalysisViewModel(application: Application) : AndroidViewModel(application) { class AnalysisViewModel(application: Application) : AndroidViewModel(application) {
private val recordDao = BookkeepingDatabase.getDatabase(application).bookkeepingDao() private val recordDao = BookkeepingDatabase.getDatabase(application).bookkeepingDao()
private val memberDao = BookkeepingDatabase.getDatabase(application).memberDao()
private val _selectedMonth = MutableStateFlow(YearMonth.now()) private val _selectedMonth = MutableStateFlow(YearMonth.now())
val selectedMonth = _selectedMonth.asStateFlow() val selectedMonth = _selectedMonth.asStateFlow()
@ -21,11 +23,50 @@ class AnalysisViewModel(application: Application) : AndroidViewModel(application
private val _selectedAnalysisType = MutableStateFlow(AnalysisType.EXPENSE) private val _selectedAnalysisType = MutableStateFlow(AnalysisType.EXPENSE)
val selectedAnalysisType = _selectedAnalysisType.asStateFlow() 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 categoryStats = combine(selectedMonth, selectedAnalysisType) { month, type ->
val records = recordDao.getAllRecords().first() val records = recordDao.getAllRecords().first()
val monthRecords = records.filter { val monthRecords = records.filter {
val recordDate = LocalDateTime.ofInstant(it.date.toInstant(), ZoneId.systemDefault()) val recordDate = Date(it.date.time)
YearMonth.from(recordDate) == month && it.type == when(type) { val localDateTime = LocalDateTime.ofInstant(recordDate.toInstant(), ZoneId.systemDefault())
YearMonth.from(localDateTime) == month && it.type == when(type) {
AnalysisType.EXPENSE -> TransactionType.EXPENSE AnalysisType.EXPENSE -> TransactionType.EXPENSE
AnalysisType.INCOME -> TransactionType.INCOME AnalysisType.INCOME -> TransactionType.INCOME
else -> null else -> null

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(
private fun loadRecords() { DateTimeFormatter.ofPattern("yyyy-MM")
viewModelScope.launch { .parse(yearMonthStr)
val monthStr = month.format(DateTimeFormatter.ofPattern("yyyy-MM")) )
database.bookkeepingDao().getRecordsByCategoryAndMonth(category, monthStr) YearMonth.from(record.date.toInstant().atZone(java.time.ZoneId.systemDefault()).toLocalDateTime()) == recordMonth
.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)
recordDao.getMemberStatsByCategory(category, yearMonthStr)
.onEach { stats ->
_memberStats.value = stats
}
.launchIn(viewModelScope)
} }
} }

View File

@ -0,0 +1,47 @@
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 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) {
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 records = recordDao.getRecordsByMemberAndCategory(
memberName = memberName,
category = category,
startDate = startDate,
endDate = endDate
)
_memberRecords.value = records
_totalAmount.value = records.sumOf { it.amount }
}
}
}