Compare commits

..

No commits in common. "94fc7b2a7e1a1bf9b4c52473a633eb951b04e1ac" and "76d028688322624c4ce4dafafcd54fd7f138d53d" have entirely different histories.

12 changed files with 184 additions and 614 deletions

View File

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

View File

@ -3,7 +3,6 @@ package com.yovinchen.bookkeeping.data
import androidx.room.*
import com.yovinchen.bookkeeping.model.BookkeepingRecord
import com.yovinchen.bookkeeping.model.Category
import com.yovinchen.bookkeeping.model.CategoryStat
import com.yovinchen.bookkeeping.model.TransactionType
import kotlinx.coroutines.flow.Flow
import java.util.Date
@ -39,48 +38,6 @@ interface BookkeepingDao {
yearMonth: String
): Flow<List<BookkeepingRecord>>
@Query("""
SELECT * FROM bookkeeping_records
WHERE memberId IN (SELECT id FROM members WHERE name = :memberName)
AND strftime('%Y-%m', datetime(date/1000, 'unixepoch')) = :yearMonth
ORDER BY date DESC
""")
fun getRecordsByMemberAndMonth(
memberName: String,
yearMonth: String
): Flow<List<BookkeepingRecord>>
@Query("""
SELECT m.name as category,
SUM(r.amount) as amount,
COUNT(*) as count,
(SUM(r.amount) * 100.0 / (
SELECT SUM(amount)
FROM bookkeeping_records
WHERE category = :category
AND strftime('%Y-%m', datetime(date/1000, 'unixepoch')) = :yearMonth
)) as percentage
FROM bookkeeping_records r
JOIN members m ON r.memberId = m.id
WHERE r.category = :category
AND strftime('%Y-%m', datetime(r.date/1000, 'unixepoch')) = :yearMonth
GROUP BY m.name
ORDER BY amount DESC
""")
fun getMemberStatsByCategory(
category: String,
yearMonth: String
): Flow<List<CategoryStat>>
@Query("""
SELECT * FROM bookkeeping_records
WHERE category = :category
ORDER BY date DESC
""")
fun getRecordsByCategory(
category: String
): Flow<List<BookkeepingRecord>>
@Insert
suspend fun insertRecord(record: BookkeepingRecord): Long
@ -107,18 +64,4 @@ interface BookkeepingDao {
@Query("UPDATE bookkeeping_records SET category = :newName WHERE category = :oldName")
suspend fun updateRecordCategories(oldName: String, newName: String)
@Query("""
SELECT * FROM bookkeeping_records
WHERE memberId IN (SELECT id FROM members WHERE name = :memberName)
AND 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,7 +3,6 @@ package com.yovinchen.bookkeeping.data
import androidx.room.TypeConverter
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.util.*
class Converters {
private val formatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME
@ -19,14 +18,4 @@ class Converters {
fun dateToTimestamp(date: LocalDateTime?): String? {
return date?.format(formatter)
}
@TypeConverter
fun fromDate(value: Date?): String? {
return value?.time?.toString()
}
@TypeConverter
fun toDate(timestamp: String?): Date? {
return timestamp?.let { Date(it.toLong()) }
}
}

View File

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

View File

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

View File

@ -2,9 +2,10 @@ package com.yovinchen.bookkeeping.ui.navigation
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.List
import androidx.compose.material.icons.filled.Analytics
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.List
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.outlined.Analytics
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar
@ -23,31 +24,28 @@ import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.yovinchen.bookkeeping.model.ThemeMode
import com.yovinchen.bookkeeping.ui.screen.*
import com.yovinchen.bookkeeping.ui.screen.AnalysisScreen
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.format.DateTimeFormatter
sealed class Screen(
val route: String,
val title: String,
val icon: ImageVector? = null
val icon: ImageVector,
val label: String
) {
object Home : Screen("home", "记账", Icons.AutoMirrored.Filled.List)
object Analysis : Screen("analysis", "分析", Icons.Default.Analytics)
object Settings : Screen("settings", "设置", Icons.Default.Settings)
object CategoryDetail : Screen("category_detail/{category}/{yearMonth}", "分类详情") {
fun createRoute(category: String, yearMonth: YearMonth): String {
return "category_detail/$category/${yearMonth.format(DateTimeFormatter.ofPattern("yyyy-MM"))}"
}
}
object MemberDetail : Screen("member_detail/{memberName}/{category}/{yearMonth}", "成员详情") {
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)
data object Home : Screen("home", Icons.Default.Home, "主页")
data object Analysis : Screen("analysis", Icons.Outlined.Analytics, "分析")
data object Settings : Screen("settings", Icons.Default.Settings, "设置")
data object CategoryDetail : Screen(
"category_detail/{category}/{yearMonth}",
Icons.Default.List,
"分类详情"
) {
fun createRoute(category: String, yearMonth: String) =
"category_detail/$category/$yearMonth"
}
}
@ -65,10 +63,14 @@ fun MainNavigation(
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
Screen.bottomNavigationItems().forEach { screen ->
listOf(
Screen.Home,
Screen.Analysis,
Screen.Settings
).forEach { screen ->
NavigationBarItem(
icon = { Icon(screen.icon!!, contentDescription = screen.title) },
label = { Text(screen.title) },
icon = { Icon(screen.icon, contentDescription = screen.label) },
label = { Text(screen.label) },
selected = currentRoute == screen.route,
onClick = {
navController.navigate(screen.route) {
@ -90,20 +92,14 @@ fun MainNavigation(
modifier = Modifier.padding(innerPadding)
) {
composable(Screen.Home.route) { HomeScreen() }
composable(Screen.Analysis.route) {
AnalysisScreen(
onNavigateToCategoryDetail = { category, yearMonth ->
navController.navigate(Screen.CategoryDetail.createRoute(category, yearMonth))
},
onNavigateToMemberDetail = { memberName, yearMonth ->
// 在这里我们暂时使用一个默认分类,你需要根据实际情况修改这里的逻辑
val defaultCategory = "默认"
navController.navigate(Screen.MemberDetail.createRoute(memberName, defaultCategory, yearMonth))
onNavigateToCategoryDetail = { category, month ->
val monthStr = month.format(DateTimeFormatter.ofPattern("yyyy-MM"))
navController.navigate(Screen.CategoryDetail.createRoute(category, monthStr))
}
)
}
composable(Screen.Settings.route) {
SettingsScreen(
currentTheme = currentTheme,
@ -119,37 +115,13 @@ fun MainNavigation(
)
) { backStackEntry ->
val category = backStackEntry.arguments?.getString("category") ?: return@composable
val yearMonthStr = backStackEntry.arguments?.getString("yearMonth") ?: return@composable
val yearMonth = YearMonth.parse(yearMonthStr)
val yearMonth = YearMonth.parse(
backStackEntry.arguments?.getString("yearMonth") ?: return@composable
)
CategoryDetailScreen(
category = category,
yearMonth = yearMonth,
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() }
month = yearMonth,
onBack = { navController.popBackStack() }
)
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,47 +0,0 @@
package com.yovinchen.bookkeeping.viewmodel
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.yovinchen.bookkeeping.data.BookkeepingDatabase
import com.yovinchen.bookkeeping.model.BookkeepingRecord
import 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 }
}
}
}