feat: 添加趋势分析图表
- 新增趋势图组件,分别显示收入和支出折线 - 更新分析页面ViewModel,处理趋势数据 - 修改分析页面,集成趋势图显示 - 支持深色/浅色主题适配 - 优化图表布局和可读性
This commit is contained in:
parent
02375747fc
commit
5cb620b875
@ -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()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user