feat: 添加类别详情页面
1. 新增类别详情相关组件和视图模型 2. 优化饼图显示效果 3. 完善导航系统 4. 改进数据查询接口
This commit is contained in:
parent
c3f108ab57
commit
8339d3d5da
@ -27,6 +27,17 @@ interface BookkeepingDao {
|
|||||||
@Query("SELECT SUM(amount) FROM bookkeeping_records WHERE type = :type AND (memberId = :memberId OR memberId IS NULL)")
|
@Query("SELECT SUM(amount) FROM bookkeeping_records WHERE type = :type AND (memberId = :memberId OR memberId IS NULL)")
|
||||||
fun getTotalAmountByType(type: TransactionType, memberId: Int? = null): Flow<Double?>
|
fun getTotalAmountByType(type: TransactionType, memberId: Int? = null): Flow<Double?>
|
||||||
|
|
||||||
|
@Query("""
|
||||||
|
SELECT * FROM bookkeeping_records
|
||||||
|
WHERE category = :category
|
||||||
|
AND strftime('%Y-%m', datetime(date/1000, 'unixepoch')) = :yearMonth
|
||||||
|
ORDER BY date DESC
|
||||||
|
""")
|
||||||
|
fun getRecordsByCategoryAndMonth(
|
||||||
|
category: String,
|
||||||
|
yearMonth: String
|
||||||
|
): Flow<List<BookkeepingRecord>>
|
||||||
|
|
||||||
@Insert
|
@Insert
|
||||||
suspend fun insertRecord(record: BookkeepingRecord): Long
|
suspend fun insertRecord(record: BookkeepingRecord): Long
|
||||||
|
|
||||||
|
@ -0,0 +1,7 @@
|
|||||||
|
package com.yovinchen.bookkeeping.model
|
||||||
|
|
||||||
|
enum class AnalysisType {
|
||||||
|
EXPENSE,
|
||||||
|
INCOME,
|
||||||
|
TREND
|
||||||
|
}
|
@ -0,0 +1,8 @@
|
|||||||
|
package com.yovinchen.bookkeeping.model
|
||||||
|
|
||||||
|
data class CategoryStat(
|
||||||
|
val category: String,
|
||||||
|
val amount: Double,
|
||||||
|
val count: Int = 0,
|
||||||
|
val percentage: Double = 0.0
|
||||||
|
)
|
@ -44,8 +44,8 @@ fun CategoryPieChart(
|
|||||||
setHoleColor(AndroidColor.TRANSPARENT)
|
setHoleColor(AndroidColor.TRANSPARENT)
|
||||||
setTransparentCircleRadius(45f)
|
setTransparentCircleRadius(45f)
|
||||||
|
|
||||||
// 设置标签文字颜色为白色(因为标签在彩色扇形上)
|
// 设置标签文字颜色
|
||||||
setEntryLabelColor(AndroidColor.WHITE)
|
setEntryLabelColor(textColor)
|
||||||
setEntryLabelTextSize(12f)
|
setEntryLabelTextSize(12f)
|
||||||
|
|
||||||
// 设置中心文字颜色跟随主题
|
// 设置中心文字颜色跟随主题
|
||||||
@ -61,7 +61,7 @@ fun CategoryPieChart(
|
|||||||
colors = ColorTemplate.MATERIAL_COLORS.toList()
|
colors = ColorTemplate.MATERIAL_COLORS.toList()
|
||||||
valueTextSize = 14f
|
valueTextSize = 14f
|
||||||
valueFormatter = PercentFormatter(chart)
|
valueFormatter = PercentFormatter(chart)
|
||||||
valueTextColor = AndroidColor.WHITE // 扇形上的数值文字保持白色
|
valueTextColor = textColor
|
||||||
setDrawValues(true)
|
setDrawValues(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -0,0 +1,71 @@
|
|||||||
|
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.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.yovinchen.bookkeeping.model.CategoryStat
|
||||||
|
|
||||||
|
@SuppressLint("DefaultLocale")
|
||||||
|
@Composable
|
||||||
|
fun CategoryStatItem(
|
||||||
|
stat: CategoryStat,
|
||||||
|
onClick: () -> Unit
|
||||||
|
) {
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -22,6 +22,7 @@ fun RecordItem(
|
|||||||
members: List<Member> = emptyList()
|
members: List<Member> = emptyList()
|
||||||
) {
|
) {
|
||||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||||
|
// val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) }
|
||||||
val timeFormat = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) }
|
val timeFormat = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) }
|
||||||
val member = members.find { it.id == record.memberId }
|
val member = members.find { it.id == record.memberId }
|
||||||
|
|
||||||
@ -48,14 +49,18 @@ fun RecordItem(
|
|||||||
style = MaterialTheme.typography.bodyLarge
|
style = MaterialTheme.typography.bodyLarge
|
||||||
)
|
)
|
||||||
|
|
||||||
// 第二行:时间 | 成员 | 详情
|
// 第二行:日期和时间 | 成员 | 详情
|
||||||
Text(
|
Text(
|
||||||
text = buildString {
|
text = buildString {
|
||||||
|
// append(dateFormat.format(record.date))
|
||||||
|
// append(" ")
|
||||||
append(timeFormat.format(record.date))
|
append(timeFormat.format(record.date))
|
||||||
if (member != null && member.name != "自己") {
|
// if (member != null && member.name != "自己") {
|
||||||
append(" | ")
|
append(" | ")
|
||||||
|
if (member != null) {
|
||||||
append(member.name)
|
append(member.name)
|
||||||
}
|
}
|
||||||
|
// }
|
||||||
if (record.description.isNotEmpty()) {
|
if (record.description.isNotEmpty()) {
|
||||||
append(" | ")
|
append(" | ")
|
||||||
append(record.description)
|
append(record.description)
|
||||||
|
@ -3,6 +3,7 @@ package com.yovinchen.bookkeeping.ui.navigation
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Home
|
import androidx.compose.material.icons.filled.Home
|
||||||
|
import androidx.compose.material.icons.filled.List
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
import androidx.compose.material.icons.outlined.Analytics
|
import androidx.compose.material.icons.outlined.Analytics
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
@ -16,14 +17,19 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.navigation.NavGraph.Companion.findStartDestination
|
import androidx.navigation.NavGraph.Companion.findStartDestination
|
||||||
|
import androidx.navigation.NavType
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
|
import androidx.navigation.navArgument
|
||||||
import com.yovinchen.bookkeeping.model.ThemeMode
|
import com.yovinchen.bookkeeping.model.ThemeMode
|
||||||
import com.yovinchen.bookkeeping.ui.screen.AnalysisScreen
|
import com.yovinchen.bookkeeping.ui.screen.AnalysisScreen
|
||||||
|
import com.yovinchen.bookkeeping.ui.screen.CategoryDetailScreen
|
||||||
import com.yovinchen.bookkeeping.ui.screen.HomeScreen
|
import com.yovinchen.bookkeeping.ui.screen.HomeScreen
|
||||||
import com.yovinchen.bookkeeping.ui.screen.SettingsScreen
|
import com.yovinchen.bookkeeping.ui.screen.SettingsScreen
|
||||||
|
import java.time.YearMonth
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
sealed class Screen(
|
sealed class Screen(
|
||||||
val route: String,
|
val route: String,
|
||||||
@ -33,6 +39,14 @@ sealed class Screen(
|
|||||||
data object Home : Screen("home", Icons.Default.Home, "主页")
|
data object Home : Screen("home", Icons.Default.Home, "主页")
|
||||||
data object Analysis : Screen("analysis", Icons.Outlined.Analytics, "分析")
|
data object Analysis : Screen("analysis", Icons.Outlined.Analytics, "分析")
|
||||||
data object Settings : Screen("settings", Icons.Default.Settings, "设置")
|
data object Settings : Screen("settings", Icons.Default.Settings, "设置")
|
||||||
|
data object CategoryDetail : Screen(
|
||||||
|
"category_detail/{category}/{yearMonth}",
|
||||||
|
Icons.Default.List,
|
||||||
|
"分类详情"
|
||||||
|
) {
|
||||||
|
fun createRoute(category: String, yearMonth: String) =
|
||||||
|
"category_detail/$category/$yearMonth"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@ -78,13 +92,38 @@ fun MainNavigation(
|
|||||||
modifier = Modifier.padding(innerPadding)
|
modifier = Modifier.padding(innerPadding)
|
||||||
) {
|
) {
|
||||||
composable(Screen.Home.route) { HomeScreen() }
|
composable(Screen.Home.route) { HomeScreen() }
|
||||||
composable(Screen.Analysis.route) { AnalysisScreen() }
|
composable(Screen.Analysis.route) {
|
||||||
|
AnalysisScreen(
|
||||||
|
onNavigateToCategoryDetail = { category, month ->
|
||||||
|
val monthStr = month.format(DateTimeFormatter.ofPattern("yyyy-MM"))
|
||||||
|
navController.navigate(Screen.CategoryDetail.createRoute(category, monthStr))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
composable(Screen.Settings.route) {
|
composable(Screen.Settings.route) {
|
||||||
SettingsScreen(
|
SettingsScreen(
|
||||||
currentTheme = currentTheme,
|
currentTheme = currentTheme,
|
||||||
onThemeChange = onThemeChange
|
onThemeChange = onThemeChange
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
composable(
|
||||||
|
route = Screen.CategoryDetail.route,
|
||||||
|
arguments = listOf(
|
||||||
|
navArgument("category") { type = NavType.StringType },
|
||||||
|
navArgument("yearMonth") { type = NavType.StringType }
|
||||||
|
)
|
||||||
|
) { backStackEntry ->
|
||||||
|
val category = backStackEntry.arguments?.getString("category") ?: return@composable
|
||||||
|
val yearMonth = YearMonth.parse(
|
||||||
|
backStackEntry.arguments?.getString("yearMonth") ?: return@composable
|
||||||
|
)
|
||||||
|
CategoryDetailScreen(
|
||||||
|
category = category,
|
||||||
|
month = yearMonth,
|
||||||
|
onBack = { navController.popBackStack() }
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,229 +1,110 @@
|
|||||||
package com.yovinchen.bookkeeping.ui.screen
|
package com.yovinchen.bookkeeping.ui.screen
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
|
|
||||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
|
||||||
import androidx.compose.material3.FilterChip
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.platform.LocalContext
|
||||||
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.ui.components.CategoryPieChart
|
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.MonthYearPicker
|
||||||
import com.yovinchen.bookkeeping.viewmodel.AnalysisType
|
|
||||||
import com.yovinchen.bookkeeping.viewmodel.AnalysisViewModel
|
import com.yovinchen.bookkeeping.viewmodel.AnalysisViewModel
|
||||||
import com.yovinchen.bookkeeping.viewmodel.CategoryStat
|
import java.time.YearMonth
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun AnalysisScreen(
|
fun AnalysisScreen(
|
||||||
modifier: Modifier = Modifier,
|
onNavigateToCategoryDetail: (String, YearMonth) -> Unit
|
||||||
viewModel: AnalysisViewModel = viewModel()
|
|
||||||
) {
|
) {
|
||||||
|
val viewModel: AnalysisViewModel = viewModel()
|
||||||
val selectedMonth by viewModel.selectedMonth.collectAsState()
|
val selectedMonth by viewModel.selectedMonth.collectAsState()
|
||||||
val selectedType by viewModel.selectedAnalysisType.collectAsState()
|
val selectedAnalysisType by viewModel.selectedAnalysisType.collectAsState()
|
||||||
val categoryStats by viewModel.categoryStats.collectAsState()
|
val categoryStats by viewModel.categoryStats.collectAsState()
|
||||||
|
|
||||||
var showMonthPicker by remember { mutableStateOf(false) }
|
var showMonthPicker by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
LazyColumn(
|
Scaffold { padding ->
|
||||||
modifier = modifier
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(16.dp)
|
.padding(padding)
|
||||||
) {
|
) {
|
||||||
// 月份选择器
|
// 月份选择器和类型切换
|
||||||
item {
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
IconButton(onClick = {
|
// 月份选择按钮
|
||||||
viewModel.setSelectedMonth(selectedMonth.minusMonths(1))
|
Button(onClick = { showMonthPicker = true }) {
|
||||||
}) {
|
Text(selectedMonth.format(DateTimeFormatter.ofPattern("yyyy年MM月")))
|
||||||
Icon(Icons.AutoMirrored.Filled.KeyboardArrowLeft, "上个月")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Text(
|
// 类型切换
|
||||||
text = "${selectedMonth.year}年${selectedMonth.monthValue}月",
|
Row {
|
||||||
style = MaterialTheme.typography.titleLarge,
|
AnalysisType.values().forEach { type ->
|
||||||
modifier = Modifier.clickable { showMonthPicker = true }
|
|
||||||
)
|
|
||||||
|
|
||||||
IconButton(onClick = {
|
|
||||||
viewModel.setSelectedMonth(selectedMonth.plusMonths(1))
|
|
||||||
}) {
|
|
||||||
Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, "下个月")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
|
|
||||||
// 分析类型选择
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceEvenly
|
|
||||||
) {
|
|
||||||
AnalysisType.entries.forEach { type ->
|
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = selectedType == type,
|
selected = selectedAnalysisType == type,
|
||||||
onClick = { viewModel.setAnalysisType(type) },
|
onClick = { viewModel.setAnalysisType(type) },
|
||||||
label = {
|
label = {
|
||||||
Text(
|
Text(
|
||||||
when (type) {
|
when (type) {
|
||||||
AnalysisType.EXPENSE -> "支出分析"
|
AnalysisType.EXPENSE -> "支出"
|
||||||
AnalysisType.INCOME -> "收入分析"
|
AnalysisType.INCOME -> "收入"
|
||||||
AnalysisType.TREND -> "收支趋势"
|
AnalysisType.TREND -> "趋势"
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
},
|
||||||
|
modifier = Modifier.padding(horizontal = 4.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
|
|
||||||
// 统计内容
|
|
||||||
when (selectedType) {
|
|
||||||
AnalysisType.EXPENSE, AnalysisType.INCOME -> {
|
|
||||||
Text(
|
|
||||||
text = if (selectedType == AnalysisType.EXPENSE) "" else "",
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
modifier = Modifier.padding(vertical = 8.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
if (categoryStats.isNotEmpty()) {
|
|
||||||
val pieChartData = categoryStats.map { stat ->
|
|
||||||
stat.category to stat.percentage.toFloat()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 饼图
|
||||||
|
if (selectedAnalysisType != AnalysisType.TREND) {
|
||||||
CategoryPieChart(
|
CategoryPieChart(
|
||||||
categoryData = pieChartData,
|
categoryData = categoryStats.map { Pair(it.category, it.percentage.toFloat()) },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(300.dp)
|
.height(200.dp)
|
||||||
|
.padding(16.dp)
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = "分类明细",
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
modifier = Modifier.padding(vertical = 8.dp)
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Text(
|
|
||||||
text = "暂无数据",
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(32.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
AnalysisType.TREND -> {
|
|
||||||
Text(
|
|
||||||
text = "收支趋势分析(开发中)",
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
modifier = Modifier.padding(vertical = 8.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 分类统计列表
|
// 分类统计列表
|
||||||
if (selectedType != AnalysisType.TREND && categoryStats.isNotEmpty()) {
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(16.dp)
|
||||||
|
) {
|
||||||
items(categoryStats) { stat ->
|
items(categoryStats) { stat ->
|
||||||
CategoryStatItem(stat)
|
CategoryStatItem(
|
||||||
|
stat = stat,
|
||||||
|
onClick = { onNavigateToCategoryDetail(stat.category, selectedMonth) }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 月份选择器对话框
|
||||||
if (showMonthPicker) {
|
if (showMonthPicker) {
|
||||||
MonthYearPicker(
|
MonthYearPicker(
|
||||||
selectedMonth = selectedMonth,
|
selectedMonth = selectedMonth,
|
||||||
onMonthSelected = { month ->
|
onMonthSelected = {
|
||||||
viewModel.setSelectedMonth(month)
|
viewModel.setSelectedMonth(it)
|
||||||
showMonthPicker = false
|
showMonthPicker = false
|
||||||
},
|
},
|
||||||
onDismiss = { showMonthPicker = false }
|
onDismiss = { showMonthPicker = false }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressLint("DefaultLocale")
|
|
||||||
@Composable
|
|
||||||
fun CategoryStatItem(stat: CategoryStat) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,147 @@
|
|||||||
|
package com.yovinchen.bookkeeping.ui.screen
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.yovinchen.bookkeeping.data.BookkeepingDatabase
|
||||||
|
import com.yovinchen.bookkeeping.model.BookkeepingRecord
|
||||||
|
import com.yovinchen.bookkeeping.ui.components.RecordItem
|
||||||
|
import com.yovinchen.bookkeeping.viewmodel.CategoryDetailViewModel
|
||||||
|
import com.yovinchen.bookkeeping.viewmodel.CategoryDetailViewModelFactory
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.time.YearMonth
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun CategoryDetailScreen(
|
||||||
|
category: String,
|
||||||
|
month: YearMonth,
|
||||||
|
onBack: () -> Unit
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val database = remember { BookkeepingDatabase.getDatabase(context) }
|
||||||
|
val viewModel: CategoryDetailViewModel = viewModel(
|
||||||
|
factory = CategoryDetailViewModelFactory(database, category, month)
|
||||||
|
)
|
||||||
|
|
||||||
|
val records by viewModel.records.collectAsState()
|
||||||
|
val total by viewModel.total.collectAsState()
|
||||||
|
val members by viewModel.members.collectAsState()
|
||||||
|
val groupedRecords = remember(records) {
|
||||||
|
records.groupBy { record ->
|
||||||
|
SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(record.date)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("$category - ${month.format(DateTimeFormatter.ofPattern("yyyy年MM月"))}") },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(Icons.Default.ArrowBack, contentDescription = "返回")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { padding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding)
|
||||||
|
) {
|
||||||
|
// 总金额显示
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "总金额",
|
||||||
|
style = MaterialTheme.typography.titleMedium
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = String.format("%.2f", total),
|
||||||
|
style = MaterialTheme.typography.titleLarge
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录列表
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
groupedRecords.forEach { (date, dayRecords) ->
|
||||||
|
item {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
// 日期标签
|
||||||
|
Text(
|
||||||
|
text = SimpleDateFormat(
|
||||||
|
"yyyy年MM月dd日 E",
|
||||||
|
Locale.CHINESE
|
||||||
|
).format(dayRecords.first().date),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// 当天的记录
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
dayRecords.forEachIndexed { index, record ->
|
||||||
|
RecordItem(
|
||||||
|
record = record,
|
||||||
|
onClick = {},
|
||||||
|
members = members
|
||||||
|
)
|
||||||
|
|
||||||
|
if (index < dayRecords.size - 1) {
|
||||||
|
HorizontalDivider(
|
||||||
|
modifier = Modifier.padding(vertical = 4.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
thickness = 0.5.dp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -4,6 +4,8 @@ import android.app.Application
|
|||||||
import androidx.lifecycle.AndroidViewModel
|
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.CategoryStat
|
||||||
import com.yovinchen.bookkeeping.model.TransactionType
|
import com.yovinchen.bookkeeping.model.TransactionType
|
||||||
import kotlinx.coroutines.flow.*
|
import kotlinx.coroutines.flow.*
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
@ -59,14 +61,3 @@ class AnalysisViewModel(application: Application) : AndroidViewModel(application
|
|||||||
_selectedAnalysisType.value = type
|
_selectedAnalysisType.value = type
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class AnalysisType {
|
|
||||||
EXPENSE, INCOME, TREND
|
|
||||||
}
|
|
||||||
|
|
||||||
data class CategoryStat(
|
|
||||||
val category: String,
|
|
||||||
val amount: Double,
|
|
||||||
val count: Int,
|
|
||||||
val percentage: Double = 0.0
|
|
||||||
)
|
|
||||||
|
@ -0,0 +1,52 @@
|
|||||||
|
package com.yovinchen.bookkeeping.viewmodel
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.yovinchen.bookkeeping.data.BookkeepingDatabase
|
||||||
|
import com.yovinchen.bookkeeping.model.BookkeepingRecord
|
||||||
|
import com.yovinchen.bookkeeping.model.Member
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.time.YearMonth
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
|
class CategoryDetailViewModel(
|
||||||
|
private val database: BookkeepingDatabase,
|
||||||
|
private val category: String,
|
||||||
|
private val month: YearMonth
|
||||||
|
) : ViewModel() {
|
||||||
|
private val _records = MutableStateFlow<List<BookkeepingRecord>>(emptyList())
|
||||||
|
val records: StateFlow<List<BookkeepingRecord>> = _records
|
||||||
|
|
||||||
|
private val _total = MutableStateFlow(0.0)
|
||||||
|
val total: StateFlow<Double> = _total
|
||||||
|
|
||||||
|
private val _members = MutableStateFlow<List<Member>>(emptyList())
|
||||||
|
val members: StateFlow<List<Member>> = _members
|
||||||
|
|
||||||
|
init {
|
||||||
|
loadRecords()
|
||||||
|
loadMembers()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadRecords() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val monthStr = month.format(DateTimeFormatter.ofPattern("yyyy-MM"))
|
||||||
|
database.bookkeepingDao().getRecordsByCategoryAndMonth(category, monthStr)
|
||||||
|
.collect { records ->
|
||||||
|
_records.value = records
|
||||||
|
_total.value = records.sumOf { it.amount }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadMembers() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
database.memberDao().getAllMembers().collect { members ->
|
||||||
|
_members.value = members
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,20 @@
|
|||||||
|
package com.yovinchen.bookkeeping.viewmodel
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import com.yovinchen.bookkeeping.data.BookkeepingDatabase
|
||||||
|
import java.time.YearMonth
|
||||||
|
|
||||||
|
class CategoryDetailViewModelFactory(
|
||||||
|
private val database: BookkeepingDatabase,
|
||||||
|
private val category: String,
|
||||||
|
private val month: YearMonth
|
||||||
|
) : ViewModelProvider.Factory {
|
||||||
|
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||||
|
if (modelClass.isAssignableFrom(CategoryDetailViewModel::class.java)) {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
return CategoryDetailViewModel(database, category, month) as T
|
||||||
|
}
|
||||||
|
throw IllegalArgumentException("Unknown ViewModel class")
|
||||||
|
}
|
||||||
|
}
|
@ -21,3 +21,5 @@ kotlin.code.style=official
|
|||||||
# resources declared in the library itself and none from the library's dependencies,
|
# resources declared in the library itself and none from the library's dependencies,
|
||||||
# thereby reducing the size of the R class for that library
|
# thereby reducing the size of the R class for that library
|
||||||
android.nonTransitiveRClass=true
|
android.nonTransitiveRClass=true
|
||||||
|
# Kotlin
|
||||||
|
org.gradle.java.home=/Library/Java/JavaVirtualMachines/jdk-17.jdk/Contents/Home
|
Loading…
Reference in New Issue
Block a user