Compare commits
3 Commits
02375747fc
...
e577744ed9
Author | SHA1 | Date | |
---|---|---|---|
e577744ed9 | |||
c8ebe27082 | |||
5cb620b875 |
33
README.md
33
README.md
@ -116,6 +116,39 @@
|
||||
- 数据库性能优化
|
||||
- 状态管理重构
|
||||
|
||||
### v1.1.0
|
||||
#### 成员管理系统
|
||||
- 新增成员管理功能
|
||||
- 支持添加、编辑和删除成员信息
|
||||
- 为每笔记录分配对应的成员
|
||||
- 成员列表显示和管理
|
||||
- 记录关联
|
||||
- 在记账时可选择相关成员
|
||||
- 支持按成员筛选和查看记录
|
||||
- 数据统计
|
||||
- 成员消费统计和分析
|
||||
- 成员支出占比展示
|
||||
|
||||
### v1.2.0 - v1.2.4
|
||||
#### 图表分析系统
|
||||
- 分类数据可视化
|
||||
- 支出/收入分类饼图展示
|
||||
- 分类占比详细统计
|
||||
- 分类数据交互和筛选
|
||||
- 成员数据可视化
|
||||
- 成员消费饼图展示
|
||||
- 成员支出占比统计
|
||||
- 成员数据交互和筛选
|
||||
- 趋势分析
|
||||
- 日收支趋势折线图
|
||||
- 收入支出双线对比
|
||||
- 支持深色/浅色主题
|
||||
- 图表交互和缩放
|
||||
- 数据筛选
|
||||
- 支持按日期范围筛选
|
||||
- 支持按收入/支出类型筛选
|
||||
- 支持按成员/分类筛选
|
||||
|
||||
### v1.0.0 (2024-01-05)
|
||||
- 基础记账功能
|
||||
- 收入/支出记录
|
||||
|
@ -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.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)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user