From 5cb620b8756278ecbddb52bb66f588c4027ebaf0 Mon Sep 17 00:00:00 2001 From: yovinchen Date: Thu, 5 Dec 2024 16:43:48 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E8=B6=8B?= =?UTF-8?q?=E5=8A=BF=E5=88=86=E6=9E=90=E5=9B=BE=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增趋势图组件,分别显示收入和支出折线 - 更新分析页面ViewModel,处理趋势数据 - 修改分析页面,集成趋势图显示 - 支持深色/浅色主题适配 - 优化图表布局和可读性 --- .../ui/components/TrendLineChart.kt | 173 ++++++++++++++++++ .../bookkeeping/ui/screen/AnalysisScreen.kt | 88 +++++---- .../viewmodel/AnalysisViewModel.kt | 42 ++++- 3 files changed, 260 insertions(+), 43 deletions(-) create mode 100644 app/src/main/java/com/yovinchen/bookkeeping/ui/components/TrendLineChart.kt diff --git a/app/src/main/java/com/yovinchen/bookkeeping/ui/components/TrendLineChart.kt b/app/src/main/java/com/yovinchen/bookkeeping/ui/components/TrendLineChart.kt new file mode 100644 index 0000000..aaab22f --- /dev/null +++ b/app/src/main/java/com/yovinchen/bookkeeping/ui/components/TrendLineChart.kt @@ -0,0 +1,173 @@ +package com.yovinchen.bookkeeping.ui.components + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.* +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.* +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.LineChart +import com.github.mikephil.charting.components.XAxis +import com.github.mikephil.charting.data.Entry +import com.github.mikephil.charting.data.LineData +import com.github.mikephil.charting.data.LineDataSet +import com.github.mikephil.charting.formatter.ValueFormatter +import com.yovinchen.bookkeeping.model.BookkeepingRecord +import com.yovinchen.bookkeeping.model.TransactionType +import java.text.SimpleDateFormat +import java.util.* + +@Composable +fun TrendLineChart( + records: List, + modifier: Modifier = Modifier +) { + val isDarkTheme = isSystemInDarkTheme() + var textColor = if (isDarkTheme) { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.87f).toArgb() + } else { + MaterialTheme.colorScheme.onSurface.toArgb() + } + + var gridColor = if (isDarkTheme) { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f).toArgb() + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f).toArgb() + } + + val incomeColor = MaterialTheme.colorScheme.primary.toArgb() + val expenseColor = MaterialTheme.colorScheme.error.toArgb() + + AndroidView( + modifier = modifier + .fillMaxWidth() + .height(300.dp), + factory = { context -> + LineChart(context).apply { + description.isEnabled = false + + // 基本设置 + setDrawGridBackground(false) + setDrawBorders(false) + + // X轴设置 + xAxis.apply { + position = XAxis.XAxisPosition.BOTTOM + this.textColor = textColor + this.gridColor = gridColor + setDrawGridLines(true) + setDrawAxisLine(true) + labelRotationAngle = -45f + textSize = 12f + yOffset = 10f + } + + // Y轴设置 + axisLeft.apply { + this.textColor = textColor + this.gridColor = gridColor + setDrawGridLines(true) + setDrawAxisLine(true) + textSize = 12f + valueFormatter = object : ValueFormatter() { + override fun getFormattedValue(value: Float): String { + return String.format("%.0f", value) + } + } + } + axisRight.isEnabled = false + + // 图例设置 + legend.apply { + this.textColor = textColor + this.textSize = 12f + isEnabled = true + yOffset = 10f + } + + // 交互设置 + setTouchEnabled(true) + isDragEnabled = true + setScaleEnabled(true) + + // 边距设置 + setExtraOffsets(8f, 16f, 8f, 24f) + } + }, + update = { chart -> + // 按日期分组计算收入和支出 + val dailyData = records + .groupBy { record -> + SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(record.date) + } + .mapValues { (_, dayRecords) -> + val income = dayRecords + .filter { it.type == TransactionType.INCOME } + .sumOf { it.amount } + .toFloat() + val expense = dayRecords + .filter { it.type == TransactionType.EXPENSE } + .sumOf { it.amount } + .toFloat() + Pair(income, expense) + } + .toList() + .sortedBy { it.first } + + // 创建收入数据点 + val incomeEntries = dailyData.mapIndexed { index, (_, amounts) -> + Entry(index.toFloat(), amounts.first) + } + + // 创建支出数据点 + val expenseEntries = dailyData.mapIndexed { index, (_, amounts) -> + Entry(index.toFloat(), amounts.second) + } + + // 创建收入数据集 + val incomeDataSet = LineDataSet(incomeEntries, "收入").apply { + color = incomeColor + lineWidth = 2.5f + setDrawCircles(true) + circleRadius = 4f + setCircleColor(incomeColor) + valueTextColor = textColor + valueTextSize = 12f + setDrawFilled(true) + fillColor = incomeColor + fillAlpha = if (isDarkTheme) 40 else 50 + } + + // 创建支出数据集 + val expenseDataSet = LineDataSet(expenseEntries, "支出").apply { + color = expenseColor + lineWidth = 2.5f + setDrawCircles(true) + circleRadius = 4f + setCircleColor(expenseColor) + valueTextColor = textColor + valueTextSize = 12f + setDrawFilled(true) + fillColor = expenseColor + fillAlpha = if (isDarkTheme) 40 else 50 + } + + // 设置X轴标签 + chart.xAxis.valueFormatter = object : ValueFormatter() { + override fun getFormattedValue(value: Float): String { + return try { + dailyData[value.toInt()].first.substring(5) // 只显示MM-dd + } catch (e: Exception) { + "" + } + } + } + + // 更新图表数据 + chart.data = LineData(incomeDataSet, expenseDataSet) + chart.invalidate() + } + ) +} diff --git a/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/AnalysisScreen.kt b/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/AnalysisScreen.kt index ebfb993..1077147 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/AnalysisScreen.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/AnalysisScreen.kt @@ -38,6 +38,7 @@ import com.yovinchen.bookkeeping.model.MemberStat import com.yovinchen.bookkeeping.ui.components.CategoryPieChart import com.yovinchen.bookkeeping.ui.components.CategoryStatItem import com.yovinchen.bookkeeping.ui.components.DateRangePicker +import com.yovinchen.bookkeeping.ui.components.TrendLineChart import com.yovinchen.bookkeeping.viewmodel.AnalysisViewModel import java.time.YearMonth @@ -58,6 +59,7 @@ fun AnalysisScreen( val selectedAnalysisType by viewModel.selectedAnalysisType.collectAsState() val categoryStats by viewModel.categoryStats.collectAsState() val memberStats by viewModel.memberStats.collectAsState() + val records by viewModel.records.collectAsState() var showViewModeMenu by remember { mutableStateOf(false) } var currentViewMode by rememberSaveable { mutableStateOf(ViewMode.CATEGORY) } @@ -141,43 +143,59 @@ fun AnalysisScreen( modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(16.dp) ) { - // 添加饼图作为第一个项目 - if (selectedAnalysisType != AnalysisType.TREND) { - item { - CategoryPieChart( - categoryData = categoryStats.map { Pair(it.category, it.percentage.toFloat()) }, - memberData = memberStats.map { Pair(it.member, 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, startMonth, endMonth) - } else { - onNavigateToMemberDetail(category, startMonth, endMonth, selectedAnalysisType) - } - } - ) - } - } - - // 添加统计列表项目 - items(if (currentViewMode == ViewMode.CATEGORY) categoryStats else memberStats) { stat -> - val category = if (stat is CategoryStat) stat.category else null - val member = if (stat is MemberStat) stat.member else null - - CategoryStatItem( - stat = stat, - onClick = { - if (currentViewMode == ViewMode.CATEGORY && category != null) { - onNavigateToCategoryDetail(category, startMonth, endMonth) - } else if (currentViewMode == ViewMode.MEMBER && member != null) { - onNavigateToMemberDetail(member, startMonth, endMonth, selectedAnalysisType) + when (selectedAnalysisType) { + AnalysisType.TREND -> { + // 趋势视图 + item { + if (records.isNotEmpty()) { + TrendLineChart( + records = records, + modifier = Modifier + .fillMaxWidth() + .height(300.dp) + .padding(vertical = 16.dp) + ) } } - ) + } + else -> { + // 饼图视图 + item { + CategoryPieChart( + categoryData = categoryStats.map { Pair(it.category, it.percentage.toFloat()) }, + memberData = memberStats.map { Pair(it.member, 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, startMonth, endMonth) + } else { + onNavigateToMemberDetail(category, startMonth, endMonth, selectedAnalysisType) + } + } + ) + } + + // 统计列表 + items(if (currentViewMode == ViewMode.CATEGORY) categoryStats else memberStats) { stat -> + val category = if (stat is CategoryStat) stat.category else null + val member = if (stat is MemberStat) stat.member else null + + CategoryStatItem( + stat = stat, + onClick = { + if (currentViewMode == ViewMode.CATEGORY && category != null) { + onNavigateToCategoryDetail(category, startMonth, endMonth) + } else if (currentViewMode == ViewMode.MEMBER && member != null) { + onNavigateToMemberDetail(member, startMonth, endMonth, selectedAnalysisType) + } + } + ) + } + } } } } diff --git a/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/AnalysisViewModel.kt b/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/AnalysisViewModel.kt index 19bb68e..eaba8e9 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/AnalysisViewModel.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/AnalysisViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.yovinchen.bookkeeping.data.BookkeepingDatabase import com.yovinchen.bookkeeping.model.AnalysisType +import com.yovinchen.bookkeeping.model.BookkeepingRecord import com.yovinchen.bookkeeping.model.CategoryStat import com.yovinchen.bookkeeping.model.MemberStat import com.yovinchen.bookkeeping.model.TransactionType @@ -34,6 +35,9 @@ class AnalysisViewModel(application: Application) : AndroidViewModel(application private val _memberStats = MutableStateFlow>(emptyList()) val memberStats: StateFlow> = _memberStats.asStateFlow() + private val _records = MutableStateFlow>(emptyList()) + val records: StateFlow> = _records.asStateFlow() + init { viewModelScope.launch { combine(startMonth, endMonth, selectedAnalysisType) { start, end, type -> @@ -58,21 +62,40 @@ class AnalysisViewModel(application: Application) : AndroidViewModel(application private suspend fun updateStats(startMonth: YearMonth, endMonth: YearMonth, type: AnalysisType) { val records = recordDao.getAllRecords().first() + + // 过滤日期范围内的记录 val monthRecords = records.filter { val recordDate = Date(it.date.time) val localDateTime = LocalDateTime.ofInstant(recordDate.toInstant(), ZoneId.systemDefault()) val yearMonth = YearMonth.from(localDateTime) yearMonth.isAfter(startMonth.minusMonths(1)) && - yearMonth.isBefore(endMonth.plusMonths(1)) && - it.type == when(type) { - AnalysisType.EXPENSE -> TransactionType.EXPENSE - AnalysisType.INCOME -> TransactionType.INCOME - else -> null + yearMonth.isBefore(endMonth.plusMonths(1)) + } + + // 更新记录数据 + _records.value = monthRecords + + // 根据分析类型过滤记录 + val filteredRecords = if (type == AnalysisType.TREND) { + monthRecords + } else { + monthRecords.filter { + it.type == when(type) { + AnalysisType.EXPENSE -> TransactionType.EXPENSE + AnalysisType.INCOME -> TransactionType.INCOME + else -> return@filter true + } } } + // 更新统计数据 + updateCategoryStats(filteredRecords) + updateMemberStats(filteredRecords) + } + + private suspend fun updateCategoryStats(records: List) { // 按分类统计 - val categoryMap = monthRecords.groupBy { it.category } + val categoryMap = records.groupBy { it.category } val categoryStats = categoryMap.map { (category, records) -> CategoryStat( category = category, @@ -87,9 +110,13 @@ class AnalysisViewModel(application: Application) : AndroidViewModel(application it.copy(percentage = if (categoryTotal > 0) it.amount / categoryTotal * 100 else 0.0) } + _categoryStats.value = categoryStatsWithPercentage + } + + private suspend fun updateMemberStats(records: List) { // 按成员统计 val members = memberDao.getAllMembers().first() - val memberMap = monthRecords.groupBy { record -> + val memberMap = records.groupBy { record -> members.find { it.id == record.memberId }?.name ?: "未分配" } @@ -107,7 +134,6 @@ class AnalysisViewModel(application: Application) : AndroidViewModel(application it.copy(percentage = if (memberTotal > 0) it.amount / memberTotal * 100 else 0.0) } - _categoryStats.value = categoryStatsWithPercentage _memberStats.value = memberStatsWithPercentage } } From c8ebe270820e26fd379fdd329c531fa10b2f83cf Mon Sep 17 00:00:00 2001 From: yovinchen Date: Thu, 5 Dec 2024 16:49:16 +0800 Subject: [PATCH 2/2] =?UTF-8?q?docs:=20=E6=9B=B4=E6=96=B0README=E6=96=87?= =?UTF-8?q?=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加v1.1.0版本成员管理系统的详细说明 - 添加v1.2.0-v1.2.4图表分析系统的功能说明 - 完善版本历史文档 README.md --- README.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/README.md b/README.md index a2e93d1..5c8dd46 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,39 @@ - 数据库性能优化 - 状态管理重构 +### v1.1.0 +#### 成员管理系统 +- 新增成员管理功能 + - 支持添加、编辑和删除成员信息 + - 为每笔记录分配对应的成员 + - 成员列表显示和管理 +- 记录关联 + - 在记账时可选择相关成员 + - 支持按成员筛选和查看记录 +- 数据统计 + - 成员消费统计和分析 + - 成员支出占比展示 + +### v1.2.0 - v1.2.4 +#### 图表分析系统 +- 分类数据可视化 + - 支出/收入分类饼图展示 + - 分类占比详细统计 + - 分类数据交互和筛选 +- 成员数据可视化 + - 成员消费饼图展示 + - 成员支出占比统计 + - 成员数据交互和筛选 +- 趋势分析 + - 日收支趋势折线图 + - 收入支出双线对比 + - 支持深色/浅色主题 + - 图表交互和缩放 +- 数据筛选 + - 支持按日期范围筛选 + - 支持按收入/支出类型筛选 + - 支持按成员/分类筛选 + ### v1.0.0 (2024-01-05) - 基础记账功能 - 收入/支出记录