feat: 成员分析与详情功能实现
1. 新增成员详情页面,按天分组显示记录 2. 优化分析页面,支持分类/成员视图切换 3. 使用 rememberSaveable 保持视图模式状态 4. 改进 UI 布局和交互体验
This commit is contained in:
parent
76d0286883
commit
380fdd5589
@ -38,6 +38,17 @@ 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>>
|
||||||
|
|
||||||
@Insert
|
@Insert
|
||||||
suspend fun insertRecord(record: BookkeepingRecord): Long
|
suspend fun insertRecord(record: BookkeepingRecord): Long
|
||||||
|
|
||||||
|
@ -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"
|
||||||
)
|
)
|
||||||
|
@ -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)
|
||||||
|
@ -6,6 +6,7 @@ import androidx.compose.material.icons.filled.Home
|
|||||||
import androidx.compose.material.icons.filled.List
|
import androidx.compose.material.icons.filled.List
|
||||||
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.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
|
||||||
@ -24,28 +25,33 @@ 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 icon: ImageVector? = null,
|
||||||
val label: String
|
val label: String? = null
|
||||||
) {
|
) {
|
||||||
data object Home : Screen("home", Icons.Default.Home, "主页")
|
data object Home : Screen("home", Icons.Outlined.Home, "首页")
|
||||||
data object Analysis : Screen("analysis", Icons.Outlined.Analytics, "分析")
|
data object Analysis : Screen("analysis", Icons.Outlined.Analytics, "分析")
|
||||||
data object Settings : Screen("settings", Icons.Default.Settings, "设置")
|
data object Settings : Screen("settings", Icons.Default.Settings, "设置")
|
||||||
data object CategoryDetail : Screen(
|
data object CategoryDetail : Screen(
|
||||||
"category_detail/{category}/{yearMonth}",
|
"category/{category}/{yearMonth}",
|
||||||
Icons.Default.List,
|
Icons.Default.List,
|
||||||
"分类详情"
|
"分类详情"
|
||||||
) {
|
) {
|
||||||
fun createRoute(category: String, yearMonth: String) =
|
fun createRoute(category: String, yearMonth: YearMonth): String =
|
||||||
"category_detail/$category/$yearMonth"
|
"category/$category/${yearMonth.format(DateTimeFormatter.ofPattern("yyyy-MM"))}"
|
||||||
|
}
|
||||||
|
data object MemberDetail : Screen(
|
||||||
|
"member/{memberName}/{yearMonth}",
|
||||||
|
Icons.Default.List,
|
||||||
|
"成员详情"
|
||||||
|
) {
|
||||||
|
fun createRoute(memberName: String, yearMonth: YearMonth): String =
|
||||||
|
"member/$memberName/${yearMonth.format(DateTimeFormatter.ofPattern("yyyy-MM"))}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -69,8 +75,8 @@ fun MainNavigation(
|
|||||||
Screen.Settings
|
Screen.Settings
|
||||||
).forEach { screen ->
|
).forEach { screen ->
|
||||||
NavigationBarItem(
|
NavigationBarItem(
|
||||||
icon = { Icon(screen.icon, contentDescription = screen.label) },
|
icon = { Icon(screen.icon!!, contentDescription = screen.label) },
|
||||||
label = { Text(screen.label) },
|
label = { Text(screen.label!!) },
|
||||||
selected = currentRoute == screen.route,
|
selected = currentRoute == screen.route,
|
||||||
onClick = {
|
onClick = {
|
||||||
navController.navigate(screen.route) {
|
navController.navigate(screen.route) {
|
||||||
@ -92,14 +98,18 @@ 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 ->
|
||||||
|
navController.navigate(Screen.MemberDetail.createRoute(memberName, yearMonth))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
composable(Screen.Settings.route) {
|
composable(Screen.Settings.route) {
|
||||||
SettingsScreen(
|
SettingsScreen(
|
||||||
currentTheme = currentTheme,
|
currentTheme = currentTheme,
|
||||||
@ -115,15 +125,31 @@ 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, DateTimeFormatter.ofPattern("yyyy-MM"))
|
||||||
)
|
|
||||||
CategoryDetailScreen(
|
CategoryDetailScreen(
|
||||||
category = category,
|
category = category,
|
||||||
month = yearMonth,
|
month = yearMonth,
|
||||||
onBack = { navController.popBackStack() }
|
onBack = { navController.popBackStack() }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
composable(
|
||||||
|
route = Screen.MemberDetail.route,
|
||||||
|
arguments = listOf(
|
||||||
|
navArgument("memberName") { type = NavType.StringType },
|
||||||
|
navArgument("yearMonth") { type = NavType.StringType }
|
||||||
|
)
|
||||||
|
) { backStackEntry ->
|
||||||
|
val memberName = backStackEntry.arguments?.getString("memberName") ?: return@composable
|
||||||
|
val yearMonthStr = backStackEntry.arguments?.getString("yearMonth") ?: return@composable
|
||||||
|
val yearMonth = YearMonth.parse(yearMonthStr, DateTimeFormatter.ofPattern("yyyy-MM"))
|
||||||
|
MemberDetailScreen(
|
||||||
|
memberName = memberName,
|
||||||
|
yearMonth = yearMonth,
|
||||||
|
onNavigateBack = { navController.popBackStack() }
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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 ->
|
||||||
|
if (currentViewMode == ViewMode.CATEGORY) {
|
||||||
onNavigateToCategoryDetail(category, selectedMonth)
|
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 = {
|
onMonthSelected = { month ->
|
||||||
viewModel.setSelectedMonth(it)
|
viewModel.setSelectedMonth(month)
|
||||||
showMonthPicker = false
|
showMonthPicker = false
|
||||||
},
|
},
|
||||||
onDismiss = { showMonthPicker = false }
|
onDismiss = { showMonthPicker = false }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,149 @@
|
|||||||
|
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.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.model.BookkeepingRecord
|
||||||
|
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,
|
||||||
|
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, yearMonth) {
|
||||||
|
viewModel.loadMemberRecords(memberName, yearMonth)
|
||||||
|
}
|
||||||
|
|
||||||
|
val groupedRecords = remember(records) {
|
||||||
|
records.groupBy { record ->
|
||||||
|
SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(record.date)
|
||||||
|
}.toSortedMap(reverseOrder())
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
Text("$memberName - ${yearMonth.format(DateTimeFormatter.ofPattern("yyyy年MM月"))}")
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onNavigateBack) {
|
||||||
|
Icon(Icons.Default.ArrowBack, "返回")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { padding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding)
|
||||||
|
) {
|
||||||
|
// 总金额显示
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "总支出",
|
||||||
|
style = MaterialTheme.typography.titleMedium
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = NumberFormat.getCurrencyInstance(Locale.CHINA)
|
||||||
|
.format(totalAmount),
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按日期分组的记录列表
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
groupedRecords.forEach { (date, dayRecords) ->
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
text = date,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
modifier = Modifier.padding(vertical = 8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
items(dayRecords.sortedByDescending { it.date }) { record ->
|
||||||
|
RecordItem(record = record)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun RecordItem(record: BookkeepingRecord) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = record.category,
|
||||||
|
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 = if (record.amount < 0) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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
|
||||||
|
@ -0,0 +1,34 @@
|
|||||||
|
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.*
|
||||||
|
import java.time.YearMonth
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
|
class MemberDetailViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
|
private val recordDao = BookkeepingDatabase.getDatabase(application).bookkeepingDao()
|
||||||
|
|
||||||
|
private val _memberRecords = MutableStateFlow<List<BookkeepingRecord>>(emptyList())
|
||||||
|
val memberRecords: StateFlow<List<BookkeepingRecord>> = _memberRecords.asStateFlow()
|
||||||
|
|
||||||
|
val totalAmount: StateFlow<Double> = _memberRecords
|
||||||
|
.map { records -> records.sumOf { it.amount } }
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5000),
|
||||||
|
initialValue = 0.0
|
||||||
|
)
|
||||||
|
|
||||||
|
fun loadMemberRecords(memberName: String, yearMonth: YearMonth) {
|
||||||
|
recordDao.getRecordsByMemberAndMonth(
|
||||||
|
memberName,
|
||||||
|
yearMonth.format(DateTimeFormatter.ofPattern("yyyy-MM"))
|
||||||
|
).onEach { records ->
|
||||||
|
_memberRecords.value = records
|
||||||
|
}.launchIn(viewModelScope)
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user