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)
- 基础记账功能
- 收入/支出记录

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.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)
}
}
)
}
}
}
}
}

View File

@ -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<List<MemberStat>>(emptyList())
val memberStats: StateFlow<List<MemberStat>> = _memberStats.asStateFlow()
private val _records = MutableStateFlow<List<BookkeepingRecord>>(emptyList())
val records: StateFlow<List<BookkeepingRecord>> = _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<BookkeepingRecord>) {
// 按分类统计
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<BookkeepingRecord>) {
// 按成员统计
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
}
}