feat: 添加时间区间选择和数据统计改进
1. 添加 DateRangePicker 组件用于时间区间选择 2. 新增 MemberStat 模型用于成员统计 3. 重构 CategoryStatItem 以支持多类型统计数据 4. 更新 AnalysisViewModel 以支持时间区间统计 5. 改进分类和成员视图的切换逻辑
This commit is contained in:
parent
96d5fab40c
commit
c92cc18dde
@ -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
|
||||
)
|
@ -1,70 +1,75 @@
|
||||
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
|
||||
val name = when (stat) {
|
||||
is CategoryStat -> stat.category
|
||||
is MemberStat -> stat.member
|
||||
else -> return
|
||||
}
|
||||
|
||||
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()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(vertical = 8.dp)
|
||||
.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
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = name,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
Text(
|
||||
text = "${count}笔 · ${String.format("%.1f%%", percentage)}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = stat.category,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
Text(
|
||||
text = String.format("%.2f", stat.amount),
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
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))
|
||||
|
||||
Text(
|
||||
text = String.format("%.1f%%", stat.percentage),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
text = NumberFormat.getCurrencyInstance(Locale.CHINA).format(amount),
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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 ->
|
||||
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
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 按成员统计
|
||||
val memberMap = monthRecords.groupBy { record ->
|
||||
membersList.find { it.id == record.memberId }?.name ?: "未分配"
|
||||
}
|
||||
fun setStartMonth(month: YearMonth) {
|
||||
_startMonth.value = month
|
||||
}
|
||||
|
||||
val stats = memberMap.map { (memberName, records) ->
|
||||
CategoryStat(
|
||||
category = memberName,
|
||||
amount = records.sumOf { it.amount },
|
||||
count = records.size
|
||||
)
|
||||
}.sortedByDescending { it.amount }
|
||||
fun setEndMonth(month: YearMonth) {
|
||||
_endMonth.value = month
|
||||
}
|
||||
|
||||
// 计算总额
|
||||
val total = stats.sumOf { it.amount }
|
||||
fun setAnalysisType(type: AnalysisType) {
|
||||
_selectedAnalysisType.value = type
|
||||
}
|
||||
|
||||
// 计算百分比
|
||||
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 ->
|
||||
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) {
|
||||
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 ?: "未分配"
|
||||
}
|
||||
|
||||
fun setSelectedMonth(month: YearMonth) {
|
||||
_selectedMonth.value = month
|
||||
}
|
||||
val memberStats = memberMap.map { (memberName, records) ->
|
||||
MemberStat(
|
||||
member = memberName,
|
||||
amount = records.sumOf { it.amount },
|
||||
count = records.size
|
||||
)
|
||||
}.sortedByDescending { it.amount }
|
||||
|
||||
fun setAnalysisType(type: AnalysisType) {
|
||||
_selectedAnalysisType.value = type
|
||||
// 计算成员总额和百分比
|
||||
val memberTotal = memberStats.sumOf { it.amount }
|
||||
val memberStatsWithPercentage = memberStats.map {
|
||||
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