feat: 添加趋势分析图表

- 新增趋势图组件,分别显示收入和支出折线
- 更新分析页面ViewModel,处理趋势数据
- 修改分析页面,集成趋势图显示
- 支持深色/浅色主题适配
- 优化图表布局和可读性
This commit is contained in:
yovinchen 2024-12-05 16:43:48 +08:00
parent 02375747fc
commit 5cb620b875
3 changed files with 260 additions and 43 deletions

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