Compare commits

...

3 Commits

Author SHA1 Message Date
e577744ed9 Merge branch 'feature/chart' into develop 2024-12-05 16:51:08 +08:00
c8ebe27082 docs: 更新README文档
- 添加v1.1.0版本成员管理系统的详细说明
- 添加v1.2.0-v1.2.4图表分析系统的功能说明
- 完善版本历史文档 README.md
2024-12-05 16:49:16 +08:00
5cb620b875 feat: 添加趋势分析图表
- 新增趋势图组件,分别显示收入和支出折线
- 更新分析页面ViewModel,处理趋势数据
- 修改分析页面,集成趋势图显示
- 支持深色/浅色主题适配
- 优化图表布局和可读性
2024-12-05 16:43:48 +08:00
4 changed files with 293 additions and 43 deletions

View File

@ -116,6 +116,39 @@
- 数据库性能优化 - 数据库性能优化
- 状态管理重构 - 状态管理重构
### v1.1.0
#### 成员管理系统
- 新增成员管理功能
- 支持添加、编辑和删除成员信息
- 为每笔记录分配对应的成员
- 成员列表显示和管理
- 记录关联
- 在记账时可选择相关成员
- 支持按成员筛选和查看记录
- 数据统计
- 成员消费统计和分析
- 成员支出占比展示
### v1.2.0 - v1.2.4
#### 图表分析系统
- 分类数据可视化
- 支出/收入分类饼图展示
- 分类占比详细统计
- 分类数据交互和筛选
- 成员数据可视化
- 成员消费饼图展示
- 成员支出占比统计
- 成员数据交互和筛选
- 趋势分析
- 日收支趋势折线图
- 收入支出双线对比
- 支持深色/浅色主题
- 图表交互和缩放
- 数据筛选
- 支持按日期范围筛选
- 支持按收入/支出类型筛选
- 支持按成员/分类筛选
### v1.0.0 (2024-01-05) ### v1.0.0 (2024-01-05)
- 基础记账功能 - 基础记账功能
- 收入/支出记录 - 收入/支出记录

View File

@ -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<BookkeepingRecord>,
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()
}
)
}

View File

@ -38,6 +38,7 @@ import com.yovinchen.bookkeeping.model.MemberStat
import com.yovinchen.bookkeeping.ui.components.CategoryPieChart import com.yovinchen.bookkeeping.ui.components.CategoryPieChart
import com.yovinchen.bookkeeping.ui.components.CategoryStatItem import com.yovinchen.bookkeeping.ui.components.CategoryStatItem
import com.yovinchen.bookkeeping.ui.components.DateRangePicker import com.yovinchen.bookkeeping.ui.components.DateRangePicker
import com.yovinchen.bookkeeping.ui.components.TrendLineChart
import com.yovinchen.bookkeeping.viewmodel.AnalysisViewModel import com.yovinchen.bookkeeping.viewmodel.AnalysisViewModel
import java.time.YearMonth import java.time.YearMonth
@ -58,6 +59,7 @@ fun AnalysisScreen(
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() val memberStats by viewModel.memberStats.collectAsState()
val records by viewModel.records.collectAsState()
var showViewModeMenu by remember { mutableStateOf(false) } var showViewModeMenu by remember { mutableStateOf(false) }
var currentViewMode by rememberSaveable { mutableStateOf(ViewMode.CATEGORY) } var currentViewMode by rememberSaveable { mutableStateOf(ViewMode.CATEGORY) }
@ -141,43 +143,59 @@ fun AnalysisScreen(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp) contentPadding = PaddingValues(16.dp)
) { ) {
// 添加饼图作为第一个项目 when (selectedAnalysisType) {
if (selectedAnalysisType != AnalysisType.TREND) { AnalysisType.TREND -> {
item { // 趋势视图
CategoryPieChart( item {
categoryData = categoryStats.map { Pair(it.category, it.percentage.toFloat()) }, if (records.isNotEmpty()) {
memberData = memberStats.map { Pair(it.member, it.percentage.toFloat()) }, TrendLineChart(
currentViewMode = currentViewMode == ViewMode.MEMBER, records = records,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(200.dp) .height(300.dp)
.padding(bottom = 16.dp), .padding(vertical = 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)
} }
} }
) }
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)
}
}
)
}
}
} }
} }
} }

View File

@ -5,6 +5,7 @@ import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.yovinchen.bookkeeping.data.BookkeepingDatabase import com.yovinchen.bookkeeping.data.BookkeepingDatabase
import com.yovinchen.bookkeeping.model.AnalysisType import com.yovinchen.bookkeeping.model.AnalysisType
import com.yovinchen.bookkeeping.model.BookkeepingRecord
import com.yovinchen.bookkeeping.model.CategoryStat import com.yovinchen.bookkeeping.model.CategoryStat
import com.yovinchen.bookkeeping.model.MemberStat import com.yovinchen.bookkeeping.model.MemberStat
import com.yovinchen.bookkeeping.model.TransactionType import com.yovinchen.bookkeeping.model.TransactionType
@ -34,6 +35,9 @@ class AnalysisViewModel(application: Application) : AndroidViewModel(application
private val _memberStats = MutableStateFlow<List<MemberStat>>(emptyList()) private val _memberStats = MutableStateFlow<List<MemberStat>>(emptyList())
val memberStats: StateFlow<List<MemberStat>> = _memberStats.asStateFlow() val memberStats: StateFlow<List<MemberStat>> = _memberStats.asStateFlow()
private val _records = MutableStateFlow<List<BookkeepingRecord>>(emptyList())
val records: StateFlow<List<BookkeepingRecord>> = _records.asStateFlow()
init { init {
viewModelScope.launch { viewModelScope.launch {
combine(startMonth, endMonth, selectedAnalysisType) { start, end, type -> 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) { private suspend fun updateStats(startMonth: YearMonth, endMonth: YearMonth, type: AnalysisType) {
val records = recordDao.getAllRecords().first() val records = recordDao.getAllRecords().first()
// 过滤日期范围内的记录
val monthRecords = records.filter { val monthRecords = records.filter {
val recordDate = Date(it.date.time) val recordDate = Date(it.date.time)
val localDateTime = LocalDateTime.ofInstant(recordDate.toInstant(), ZoneId.systemDefault()) val localDateTime = LocalDateTime.ofInstant(recordDate.toInstant(), ZoneId.systemDefault())
val yearMonth = YearMonth.from(localDateTime) val yearMonth = YearMonth.from(localDateTime)
yearMonth.isAfter(startMonth.minusMonths(1)) && yearMonth.isAfter(startMonth.minusMonths(1)) &&
yearMonth.isBefore(endMonth.plusMonths(1)) && yearMonth.isBefore(endMonth.plusMonths(1))
it.type == when(type) { }
AnalysisType.EXPENSE -> TransactionType.EXPENSE
AnalysisType.INCOME -> TransactionType.INCOME // 更新记录数据
else -> null _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<BookkeepingRecord>) {
// 按分类统计 // 按分类统计
val categoryMap = monthRecords.groupBy { it.category } val categoryMap = records.groupBy { it.category }
val categoryStats = categoryMap.map { (category, records) -> val categoryStats = categoryMap.map { (category, records) ->
CategoryStat( CategoryStat(
category = category, 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) it.copy(percentage = if (categoryTotal > 0) it.amount / categoryTotal * 100 else 0.0)
} }
_categoryStats.value = categoryStatsWithPercentage
}
private suspend fun updateMemberStats(records: List<BookkeepingRecord>) {
// 按成员统计 // 按成员统计
val members = memberDao.getAllMembers().first() val members = memberDao.getAllMembers().first()
val memberMap = monthRecords.groupBy { record -> val memberMap = records.groupBy { record ->
members.find { it.id == record.memberId }?.name ?: "未分配" 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) it.copy(percentage = if (memberTotal > 0) it.amount / memberTotal * 100 else 0.0)
} }
_categoryStats.value = categoryStatsWithPercentage
_memberStats.value = memberStatsWithPercentage _memberStats.value = memberStatsWithPercentage
} }
} }