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,70 +1,75 @@
package com.yovinchen.bookkeeping.ui.components package com.yovinchen.bookkeeping.ui.components
import android.annotation.SuppressLint
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.*
import androidx.compose.material3.LinearProgressIndicator import androidx.compose.runtime.*
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.yovinchen.bookkeeping.model.CategoryStat import com.yovinchen.bookkeeping.model.CategoryStat
import com.yovinchen.bookkeeping.model.MemberStat
import java.text.NumberFormat
import java.util.*
@SuppressLint("DefaultLocale")
@Composable @Composable
fun CategoryStatItem( fun CategoryStatItem(
stat: CategoryStat, stat: Any,
onClick: () -> Unit onClick: () -> Unit,
modifier: Modifier = Modifier
) { ) {
Column( val name = when (stat) {
modifier = Modifier 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() .fillMaxWidth()
.clickable(onClick = onClick) .padding(horizontal = 16.dp, vertical = 4.dp)
.padding(vertical = 8.dp) .clickable(onClick = onClick),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) { ) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically 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(
text = stat.category, text = NumberFormat.getCurrencyInstance(Locale.CHINA).format(amount),
style = MaterialTheme.typography.bodyLarge style = MaterialTheme.typography.titleMedium
)
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
) )
} }
} }

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.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.yovinchen.bookkeeping.model.AnalysisType 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.CategoryPieChart
import com.yovinchen.bookkeeping.ui.components.CategoryStatItem 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 com.yovinchen.bookkeeping.viewmodel.AnalysisViewModel
import java.time.YearMonth import java.time.YearMonth
import java.time.format.DateTimeFormatter
enum class ViewMode { enum class ViewMode {
CATEGORY, MEMBER CATEGORY, MEMBER
@ -51,12 +52,12 @@ fun AnalysisScreen(
onNavigateToMemberDetail: (String, YearMonth, AnalysisType) -> Unit onNavigateToMemberDetail: (String, YearMonth, AnalysisType) -> Unit
) { ) {
val viewModel: AnalysisViewModel = viewModel() 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 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()
var showMonthPicker by remember { mutableStateOf(false) }
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) }
@ -66,18 +67,13 @@ fun AnalysisScreen(
.fillMaxSize() .fillMaxSize()
.padding(padding) .padding(padding)
) { ) {
// 时间选择按钮行 // 时间区间选择
Row( DateRangePicker(
modifier = Modifier startMonth = startMonth,
.fillMaxWidth() endMonth = endMonth,
.padding(horizontal = 16.dp, vertical = 8.dp), onStartMonthSelected = viewModel::setStartMonth,
horizontalArrangement = Arrangement.End, onEndMonthSelected = viewModel::setEndMonth
verticalAlignment = Alignment.CenterVertically )
) {
Button(onClick = { showMonthPicker = true }) {
Text(selectedMonth.format(DateTimeFormatter.ofPattern("yyyy年MM月")))
}
}
// 分析类型和视图模式选择行 // 分析类型和视图模式选择行
Row( Row(
@ -147,7 +143,7 @@ fun AnalysisScreen(
item { item {
CategoryPieChart( CategoryPieChart(
categoryData = categoryStats.map { Pair(it.category, it.percentage.toFloat()) }, 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, currentViewMode = currentViewMode == ViewMode.MEMBER,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@ -155,9 +151,9 @@ fun AnalysisScreen(
.padding(bottom = 16.dp), .padding(bottom = 16.dp),
onCategoryClick = { category -> onCategoryClick = { category ->
if (currentViewMode == ViewMode.CATEGORY) { if (currentViewMode == ViewMode.CATEGORY) {
onNavigateToCategoryDetail(category, selectedMonth) onNavigateToCategoryDetail(category, startMonth)
} else { } else {
onNavigateToMemberDetail(category, selectedMonth, selectedAnalysisType) onNavigateToMemberDetail(category, startMonth, selectedAnalysisType)
} }
} }
) )
@ -166,29 +162,21 @@ fun AnalysisScreen(
// 添加统计列表项目 // 添加统计列表项目
items(if (currentViewMode == ViewMode.CATEGORY) categoryStats else memberStats) { stat -> 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( CategoryStatItem(
stat = stat, stat = stat,
onClick = { onClick = {
if (currentViewMode == ViewMode.CATEGORY) { if (currentViewMode == ViewMode.CATEGORY && category != null) {
onNavigateToCategoryDetail(stat.category, selectedMonth) onNavigateToCategoryDetail(category, startMonth)
} else { } else if (currentViewMode == ViewMode.MEMBER && member != null) {
onNavigateToMemberDetail(stat.category, selectedMonth, selectedAnalysisType) 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.data.BookkeepingDatabase
import com.yovinchen.bookkeeping.model.AnalysisType import com.yovinchen.bookkeeping.model.AnalysisType
import com.yovinchen.bookkeeping.model.CategoryStat import com.yovinchen.bookkeeping.model.CategoryStat
import com.yovinchen.bookkeeping.model.MemberStat
import com.yovinchen.bookkeeping.model.TransactionType import com.yovinchen.bookkeeping.model.TransactionType
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.YearMonth import java.time.YearMonth
import java.time.ZoneId import java.time.ZoneId
import java.util.Date import java.util.*
class AnalysisViewModel(application: Application) : AndroidViewModel(application) { class AnalysisViewModel(application: Application) : AndroidViewModel(application) {
private val recordDao = BookkeepingDatabase.getDatabase(application).bookkeepingDao() private val recordDao = BookkeepingDatabase.getDatabase(application).bookkeepingDao()
private val memberDao = BookkeepingDatabase.getDatabase(application).memberDao() private val memberDao = BookkeepingDatabase.getDatabase(application).memberDao()
private val _selectedMonth = MutableStateFlow(YearMonth.now()) private val _startMonth = MutableStateFlow(YearMonth.now())
val selectedMonth = _selectedMonth.asStateFlow() val startMonth: StateFlow<YearMonth> = _startMonth.asStateFlow()
private val _endMonth = MutableStateFlow(YearMonth.now())
val endMonth: StateFlow<YearMonth> = _endMonth.asStateFlow()
private val _selectedAnalysisType = MutableStateFlow(AnalysisType.EXPENSE) 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 records = recordDao.getAllRecords().first() val memberStats: StateFlow<List<MemberStat>> = _memberStats.asStateFlow()
val monthRecords = records.filter {
val recordDate = Date(it.date.time) init {
val localDateTime = LocalDateTime.ofInstant(recordDate.toInstant(), ZoneId.systemDefault()) viewModelScope.launch {
YearMonth.from(localDateTime) == month && it.type == when(type) { combine(startMonth, endMonth, selectedAnalysisType) { start, end, type ->
AnalysisType.EXPENSE -> TransactionType.EXPENSE Triple(start, end, type)
AnalysisType.INCOME -> TransactionType.INCOME }.collect { (start, end, type) ->
else -> null updateStats(start, end, type)
} }
} }
}
// 按成员统计 fun setStartMonth(month: YearMonth) {
val memberMap = monthRecords.groupBy { record -> _startMonth.value = month
membersList.find { it.id == record.memberId }?.name ?: "未分配" }
}
val stats = memberMap.map { (memberName, records) -> fun setEndMonth(month: YearMonth) {
CategoryStat( _endMonth.value = month
category = memberName, }
amount = records.sumOf { it.amount },
count = records.size
)
}.sortedByDescending { it.amount }
// 计算总额 fun setAnalysisType(type: AnalysisType) {
val total = stats.sumOf { it.amount } _selectedAnalysisType.value = type
}
// 计算百分比 private suspend fun updateStats(startMonth: YearMonth, endMonth: YearMonth, type: AnalysisType) {
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 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())
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.EXPENSE -> TransactionType.EXPENSE
AnalysisType.INCOME -> TransactionType.INCOME AnalysisType.INCOME -> TransactionType.INCOME
else -> null else -> null
@ -75,7 +73,7 @@ class AnalysisViewModel(application: Application) : AndroidViewModel(application
// 按分类统计 // 按分类统计
val categoryMap = monthRecords.groupBy { it.category } val categoryMap = monthRecords.groupBy { it.category }
val stats = categoryMap.map { (category, records) -> val categoryStats = categoryMap.map { (category, records) ->
CategoryStat( CategoryStat(
category = category, category = category,
amount = records.sumOf { it.amount }, amount = records.sumOf { it.amount },
@ -83,22 +81,33 @@ class AnalysisViewModel(application: Application) : AndroidViewModel(application
) )
}.sortedByDescending { it.amount } }.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) } val members = memberDao.getAllMembers().first()
}.stateIn( val memberMap = monthRecords.groupBy { record ->
scope = viewModelScope, members.find { it.id == record.memberId }?.name ?: "未分配"
started = SharingStarted.WhileSubscribed(5000), }
initialValue = emptyList()
)
fun setSelectedMonth(month: YearMonth) { val memberStats = memberMap.map { (memberName, records) ->
_selectedMonth.value = month 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
} }
} }