feat: 添加时间区间选择和数据统计改进

1. 添加 DateRangePicker 组件用于时间区间选择
2. 新增 MemberStat 模型用于成员统计
3. 重构 CategoryStatItem 以支持多类型统计数据
4. 更新 AnalysisViewModel 以支持时间区间统计
5. 改进分类和成员视图的切换逻辑
This commit is contained in:
yovinchen 2024-12-05 13:46:17 +08:00
parent 96d5fab40c
commit c92cc18dde
5 changed files with 207 additions and 135 deletions

View File

@ -0,0 +1,8 @@
package com.yovinchen.bookkeeping.model
data class MemberStat(
val member: String,
val amount: Double,
val count: Int,
val percentage: Double = 0.0
)

View File

@ -1,71 +1,76 @@
package com.yovinchen.bookkeeping.ui.components
import android.annotation.SuppressLint
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.yovinchen.bookkeeping.model.CategoryStat
import com.yovinchen.bookkeeping.model.MemberStat
import java.text.NumberFormat
import java.util.*
@SuppressLint("DefaultLocale")
@Composable
fun CategoryStatItem(
stat: CategoryStat,
onClick: () -> Unit
stat: Any,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(vertical = 8.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stat.category,
style = MaterialTheme.typography.bodyLarge
)
Text(
text = String.format("%.2f", stat.amount),
style = MaterialTheme.typography.bodyLarge
)
val name = when (stat) {
is CategoryStat -> stat.category
is MemberStat -> stat.member
else -> return
}
Spacer(modifier = Modifier.height(4.dp))
val amount = when (stat) {
is CategoryStat -> stat.amount
is MemberStat -> stat.amount
else -> return
}
val count = when (stat) {
is CategoryStat -> stat.count
is MemberStat -> stat.count
else -> return
}
val percentage = when (stat) {
is CategoryStat -> stat.percentage
is MemberStat -> stat.percentage
else -> return
}
Card(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp)
.clickable(onClick = onClick),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
LinearProgressIndicator(
progress = { stat.percentage.toFloat() / 100f },
modifier = Modifier
.weight(1f)
.height(8.dp)
.background(
MaterialTheme.colorScheme.surfaceVariant,
RoundedCornerShape(4.dp)
),
)
Spacer(modifier = Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = String.format("%.1f%%", stat.percentage),
style = MaterialTheme.typography.bodyMedium,
text = name,
style = MaterialTheme.typography.bodyLarge
)
Text(
text = "${count}笔 · ${String.format("%.1f%%", percentage)}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Text(
text = NumberFormat.getCurrencyInstance(Locale.CHINA).format(amount),
style = MaterialTheme.typography.titleMedium
)
}
}
}

View File

@ -0,0 +1,62 @@
package com.yovinchen.bookkeeping.ui.components
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import java.time.YearMonth
import java.time.format.DateTimeFormatter
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DateRangePicker(
startMonth: YearMonth,
endMonth: YearMonth,
onStartMonthSelected: (YearMonth) -> Unit,
onEndMonthSelected: (YearMonth) -> Unit,
modifier: Modifier = Modifier
) {
var showStartMonthPicker by remember { mutableStateOf(false) }
var showEndMonthPicker by remember { mutableStateOf(false) }
val formatter = DateTimeFormatter.ofPattern("yyyy年MM月")
Row(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Button(onClick = { showStartMonthPicker = true }) {
Text(startMonth.format(formatter))
}
Text("")
Button(onClick = { showEndMonthPicker = true }) {
Text(endMonth.format(formatter))
}
}
if (showStartMonthPicker) {
MonthYearPicker(
selectedMonth = startMonth,
onMonthSelected = {
onStartMonthSelected(it)
showStartMonthPicker = false
},
onDismiss = { showStartMonthPicker = false }
)
}
if (showEndMonthPicker) {
MonthYearPicker(
selectedMonth = endMonth,
onMonthSelected = {
onEndMonthSelected(it)
showEndMonthPicker = false
},
onDismiss = { showEndMonthPicker = false }
)
}
}

View File

@ -33,12 +33,13 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.yovinchen.bookkeeping.model.AnalysisType
import com.yovinchen.bookkeeping.model.CategoryStat
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.MonthYearPicker
import com.yovinchen.bookkeeping.ui.components.DateRangePicker
import com.yovinchen.bookkeeping.viewmodel.AnalysisViewModel
import java.time.YearMonth
import java.time.format.DateTimeFormatter
enum class ViewMode {
CATEGORY, MEMBER
@ -51,12 +52,12 @@ fun AnalysisScreen(
onNavigateToMemberDetail: (String, YearMonth, AnalysisType) -> Unit
) {
val viewModel: AnalysisViewModel = viewModel()
val selectedMonth by viewModel.selectedMonth.collectAsState()
val startMonth by viewModel.startMonth.collectAsState()
val endMonth by viewModel.endMonth.collectAsState()
val selectedAnalysisType by viewModel.selectedAnalysisType.collectAsState()
val categoryStats by viewModel.categoryStats.collectAsState()
val memberStats by viewModel.memberStats.collectAsState()
var showMonthPicker by remember { mutableStateOf(false) }
var showViewModeMenu by remember { mutableStateOf(false) }
var currentViewMode by rememberSaveable { mutableStateOf(ViewMode.CATEGORY) }
@ -66,18 +67,13 @@ fun AnalysisScreen(
.fillMaxSize()
.padding(padding)
) {
// 时间选择按钮行
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
Button(onClick = { showMonthPicker = true }) {
Text(selectedMonth.format(DateTimeFormatter.ofPattern("yyyy年MM月")))
}
}
// 时间区间选择
DateRangePicker(
startMonth = startMonth,
endMonth = endMonth,
onStartMonthSelected = viewModel::setStartMonth,
onEndMonthSelected = viewModel::setEndMonth
)
// 分析类型和视图模式选择行
Row(
@ -147,7 +143,7 @@ fun AnalysisScreen(
item {
CategoryPieChart(
categoryData = categoryStats.map { Pair(it.category, it.percentage.toFloat()) },
memberData = memberStats.map { Pair(it.category, it.percentage.toFloat()) },
memberData = memberStats.map { Pair(it.member, it.percentage.toFloat()) },
currentViewMode = currentViewMode == ViewMode.MEMBER,
modifier = Modifier
.fillMaxWidth()
@ -155,9 +151,9 @@ fun AnalysisScreen(
.padding(bottom = 16.dp),
onCategoryClick = { category ->
if (currentViewMode == ViewMode.CATEGORY) {
onNavigateToCategoryDetail(category, selectedMonth)
onNavigateToCategoryDetail(category, startMonth)
} else {
onNavigateToMemberDetail(category, selectedMonth, selectedAnalysisType)
onNavigateToMemberDetail(category, startMonth, selectedAnalysisType)
}
}
)
@ -166,29 +162,21 @@ fun AnalysisScreen(
// 添加统计列表项目
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) {
onNavigateToCategoryDetail(stat.category, selectedMonth)
} else {
onNavigateToMemberDetail(stat.category, selectedMonth, selectedAnalysisType)
if (currentViewMode == ViewMode.CATEGORY && category != null) {
onNavigateToCategoryDetail(category, startMonth)
} else if (currentViewMode == ViewMode.MEMBER && member != null) {
onNavigateToMemberDetail(member, startMonth, selectedAnalysisType)
}
}
)
}
}
if (showMonthPicker) {
MonthYearPicker(
selectedMonth = selectedMonth,
onMonthSelected = { month ->
viewModel.setSelectedMonth(month)
showMonthPicker = false
},
onDismiss = { showMonthPicker = false }
)
}
}
}
}

View File

@ -6,67 +6,65 @@ import androidx.lifecycle.viewModelScope
import com.yovinchen.bookkeeping.data.BookkeepingDatabase
import com.yovinchen.bookkeeping.model.AnalysisType
import com.yovinchen.bookkeeping.model.CategoryStat
import com.yovinchen.bookkeeping.model.MemberStat
import com.yovinchen.bookkeeping.model.TransactionType
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import java.time.LocalDateTime
import java.time.YearMonth
import java.time.ZoneId
import java.util.Date
import java.util.*
class AnalysisViewModel(application: Application) : AndroidViewModel(application) {
private val recordDao = BookkeepingDatabase.getDatabase(application).bookkeepingDao()
private val memberDao = BookkeepingDatabase.getDatabase(application).memberDao()
private val _selectedMonth = MutableStateFlow(YearMonth.now())
val selectedMonth = _selectedMonth.asStateFlow()
private val _startMonth = MutableStateFlow(YearMonth.now())
val startMonth: StateFlow<YearMonth> = _startMonth.asStateFlow()
private val _endMonth = MutableStateFlow(YearMonth.now())
val endMonth: StateFlow<YearMonth> = _endMonth.asStateFlow()
private val _selectedAnalysisType = MutableStateFlow(AnalysisType.EXPENSE)
val selectedAnalysisType = _selectedAnalysisType.asStateFlow()
val selectedAnalysisType: StateFlow<AnalysisType> = _selectedAnalysisType.asStateFlow()
private val members = memberDao.getAllMembers()
private val _categoryStats = MutableStateFlow<List<CategoryStat>>(emptyList())
val categoryStats: StateFlow<List<CategoryStat>> = _categoryStats.asStateFlow()
val memberStats = combine(selectedMonth, selectedAnalysisType, members) { month, type, membersList ->
private val _memberStats = MutableStateFlow<List<MemberStat>>(emptyList())
val memberStats: StateFlow<List<MemberStat>> = _memberStats.asStateFlow()
init {
viewModelScope.launch {
combine(startMonth, endMonth, selectedAnalysisType) { start, end, type ->
Triple(start, end, type)
}.collect { (start, end, type) ->
updateStats(start, end, type)
}
}
}
fun setStartMonth(month: YearMonth) {
_startMonth.value = month
}
fun setEndMonth(month: YearMonth) {
_endMonth.value = month
}
fun setAnalysisType(type: AnalysisType) {
_selectedAnalysisType.value = type
}
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())
YearMonth.from(localDateTime) == month && it.type == when(type) {
AnalysisType.EXPENSE -> TransactionType.EXPENSE
AnalysisType.INCOME -> TransactionType.INCOME
else -> null
}
}
// 按成员统计
val memberMap = monthRecords.groupBy { record ->
membersList.find { it.id == record.memberId }?.name ?: "未分配"
}
val stats = memberMap.map { (memberName, records) ->
CategoryStat(
category = memberName,
amount = records.sumOf { it.amount },
count = records.size
)
}.sortedByDescending { it.amount }
// 计算总额
val total = stats.sumOf { it.amount }
// 计算百分比
stats.map { it.copy(percentage = if (total > 0) it.amount / total * 100 else 0.0) }
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = emptyList()
)
val categoryStats = combine(selectedMonth, selectedAnalysisType) { month, type ->
val records = recordDao.getAllRecords().first()
val monthRecords = records.filter {
val recordDate = Date(it.date.time)
val localDateTime = LocalDateTime.ofInstant(recordDate.toInstant(), ZoneId.systemDefault())
YearMonth.from(localDateTime) == month && it.type == when(type) {
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
@ -75,7 +73,7 @@ class AnalysisViewModel(application: Application) : AndroidViewModel(application
// 按分类统计
val categoryMap = monthRecords.groupBy { it.category }
val stats = categoryMap.map { (category, records) ->
val categoryStats = categoryMap.map { (category, records) ->
CategoryStat(
category = category,
amount = records.sumOf { it.amount },
@ -83,22 +81,33 @@ class AnalysisViewModel(application: Application) : AndroidViewModel(application
)
}.sortedByDescending { it.amount }
// 计算总额
val total = stats.sumOf { it.amount }
// 计算分类总额和百分比
val categoryTotal = categoryStats.sumOf { it.amount }
val categoryStatsWithPercentage = categoryStats.map {
it.copy(percentage = if (categoryTotal > 0) it.amount / categoryTotal * 100 else 0.0)
}
// 计算百分比
stats.map { it.copy(percentage = if (total > 0) it.amount / total * 100 else 0.0) }
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = emptyList()
// 按成员统计
val members = memberDao.getAllMembers().first()
val memberMap = monthRecords.groupBy { record ->
members.find { it.id == record.memberId }?.name ?: "未分配"
}
val memberStats = memberMap.map { (memberName, records) ->
MemberStat(
member = memberName,
amount = records.sumOf { it.amount },
count = records.size
)
}.sortedByDescending { it.amount }
fun setSelectedMonth(month: YearMonth) {
_selectedMonth.value = month
// 计算成员总额和百分比
val memberTotal = memberStats.sumOf { it.amount }
val memberStatsWithPercentage = memberStats.map {
it.copy(percentage = if (memberTotal > 0) it.amount / memberTotal * 100 else 0.0)
}
fun setAnalysisType(type: AnalysisType) {
_selectedAnalysisType.value = type
_categoryStats.value = categoryStatsWithPercentage
_memberStats.value = memberStatsWithPercentage
}
}