feat: 实现预算管理功能界面
1. 预算管理界面 - 创建 BudgetScreen 预算管理主界面 - 支持总览、分类预算、成员预算三个标签页 - 实现预算状态可视化(进度条、超支提醒) - 预算项目的启用/禁用切换 2. 预算编辑功能 - 创建 BudgetEditDialog 预算编辑对话框 - 支持设置预算类型、金额、预警阈值 - 分类预算和成员预算的选择器 - 自动设置月度周期 3. 业务逻辑 - 创建 BudgetViewModel 管理预算状态 - 实现预算的创建、更新、删除功能 - 预算状态的实时计算和更新 4. 导航集成 - 在设置页面添加预算管理入口 - 更新导航系统支持预算管理界面 - 添加预算管理路由 5. 文档更新 - 更新 README 版本历史 - 标记预算管理功能为基本完成 - 更新功能进度状态 注:界面已完成,待实现预算超支提醒和分析报告功能 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
7fc76df829
commit
e651086e6d
@ -1,7 +1,10 @@
|
|||||||
{
|
{
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"allow": [
|
||||||
"Bash(./gradlew:*)"
|
"Bash(./gradlew:*)",
|
||||||
|
"Bash(git push:*)",
|
||||||
|
"Bash(git branch:*)",
|
||||||
|
"Bash(git add:*)"
|
||||||
],
|
],
|
||||||
"deny": []
|
"deny": []
|
||||||
}
|
}
|
||||||
|
14
README.md
14
README.md
@ -69,14 +69,14 @@
|
|||||||
- [x] 定期自动备份
|
- [x] 定期自动备份
|
||||||
- [x] 备份加密功能
|
- [x] 备份加密功能
|
||||||
|
|
||||||
### 5. 预算管理 (进行中 🚀)
|
### 5. 预算管理 (基本完成 ✨)
|
||||||
- [x] 预算数据模型设计
|
- [x] 预算数据模型设计
|
||||||
- [x] 数据库架构实现
|
- [x] 数据库架构实现
|
||||||
- [ ] 预算管理界面
|
- [x] 预算管理界面
|
||||||
- [ ] 月度预算设置
|
- [x] 月度预算设置
|
||||||
- [ ] 预算超支提醒
|
- [ ] 预算超支提醒
|
||||||
- [ ] 分类预算管理
|
- [x] 分类预算管理
|
||||||
- [ ] 成员预算管理
|
- [x] 成员预算管理
|
||||||
- [ ] 预算分析报告
|
- [ ] 预算分析报告
|
||||||
|
|
||||||
### 6. 体验优化 (持续进行 🔄)
|
### 6. 体验优化 (持续进行 🔄)
|
||||||
@ -127,6 +127,10 @@
|
|||||||
- 预算数据模型设计
|
- 预算数据模型设计
|
||||||
- 支持总预算、分类预算、成员预算
|
- 支持总预算、分类预算、成员预算
|
||||||
- 数据库架构实现(升级到版本6)
|
- 数据库架构实现(升级到版本6)
|
||||||
|
- 预算管理界面设计
|
||||||
|
- 预算编辑对话框
|
||||||
|
- 预算状态可视化(进度条、超支提醒)
|
||||||
|
- 预算导航集成
|
||||||
|
|
||||||
### v1.4
|
### v1.4
|
||||||
- 数据安全功能
|
- 数据安全功能
|
||||||
|
@ -0,0 +1,223 @@
|
|||||||
|
package com.yovinchen.bookkeeping.ui.dialog
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.yovinchen.bookkeeping.model.*
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预算编辑对话框
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun BudgetEditDialog(
|
||||||
|
budget: Budget? = null,
|
||||||
|
categories: List<Category>,
|
||||||
|
members: List<Member>,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onConfirm: (Budget) -> Unit
|
||||||
|
) {
|
||||||
|
var selectedType by remember { mutableStateOf(budget?.type ?: BudgetType.TOTAL) }
|
||||||
|
var amount by remember { mutableStateOf(budget?.amount?.toString() ?: "") }
|
||||||
|
var selectedCategory by remember { mutableStateOf(budget?.categoryName) }
|
||||||
|
var selectedMemberId by remember { mutableStateOf(budget?.memberId) }
|
||||||
|
var alertThreshold by remember { mutableStateOf((budget?.alertThreshold ?: 0.8) * 100) }
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = {
|
||||||
|
Text(if (budget == null) "添加预算" else "编辑预算")
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
// 预算类型选择
|
||||||
|
Text(
|
||||||
|
text = "预算类型",
|
||||||
|
style = MaterialTheme.typography.labelMedium
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
FilterChip(
|
||||||
|
selected = selectedType == BudgetType.TOTAL,
|
||||||
|
onClick = { selectedType = BudgetType.TOTAL },
|
||||||
|
label = { Text("总预算") }
|
||||||
|
)
|
||||||
|
FilterChip(
|
||||||
|
selected = selectedType == BudgetType.CATEGORY,
|
||||||
|
onClick = { selectedType = BudgetType.CATEGORY },
|
||||||
|
label = { Text("分类预算") }
|
||||||
|
)
|
||||||
|
FilterChip(
|
||||||
|
selected = selectedType == BudgetType.MEMBER,
|
||||||
|
onClick = { selectedType = BudgetType.MEMBER },
|
||||||
|
label = { Text("成员预算") }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 金额输入
|
||||||
|
OutlinedTextField(
|
||||||
|
value = amount,
|
||||||
|
onValueChange = { amount = it.filter { char -> char.isDigit() || char == '.' } },
|
||||||
|
label = { Text("预算金额") },
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true
|
||||||
|
)
|
||||||
|
|
||||||
|
// 分类选择(仅在分类预算时显示)
|
||||||
|
if (selectedType == BudgetType.CATEGORY) {
|
||||||
|
var expanded by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
ExposedDropdownMenuBox(
|
||||||
|
expanded = expanded,
|
||||||
|
onExpandedChange = { expanded = !expanded }
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = selectedCategory ?: "",
|
||||||
|
onValueChange = {},
|
||||||
|
label = { Text("选择分类") },
|
||||||
|
readOnly = true,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.menuAnchor(),
|
||||||
|
trailingIcon = {
|
||||||
|
ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
ExposedDropdownMenu(
|
||||||
|
expanded = expanded,
|
||||||
|
onDismissRequest = { expanded = false }
|
||||||
|
) {
|
||||||
|
categories.forEach { category ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(category.name) },
|
||||||
|
onClick = {
|
||||||
|
selectedCategory = category.name
|
||||||
|
expanded = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 成员选择(仅在成员预算时显示)
|
||||||
|
if (selectedType == BudgetType.MEMBER) {
|
||||||
|
var expanded by remember { mutableStateOf(false) }
|
||||||
|
val selectedMember = members.find { it.id == selectedMemberId }
|
||||||
|
|
||||||
|
ExposedDropdownMenuBox(
|
||||||
|
expanded = expanded,
|
||||||
|
onExpandedChange = { expanded = !expanded }
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = selectedMember?.name ?: "",
|
||||||
|
onValueChange = {},
|
||||||
|
label = { Text("选择成员") },
|
||||||
|
readOnly = true,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.menuAnchor(),
|
||||||
|
trailingIcon = {
|
||||||
|
ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
ExposedDropdownMenu(
|
||||||
|
expanded = expanded,
|
||||||
|
onDismissRequest = { expanded = false }
|
||||||
|
) {
|
||||||
|
members.forEach { member ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(member.name) },
|
||||||
|
onClick = {
|
||||||
|
selectedMemberId = member.id
|
||||||
|
expanded = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 预警阈值
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = "预警阈值: ${alertThreshold.toInt()}%",
|
||||||
|
style = MaterialTheme.typography.labelMedium
|
||||||
|
)
|
||||||
|
Slider(
|
||||||
|
value = alertThreshold.toFloat(),
|
||||||
|
onValueChange = { alertThreshold = it.toDouble() },
|
||||||
|
valueRange = 50f..95f,
|
||||||
|
steps = 8,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "当使用金额达到预算的 ${alertThreshold.toInt()}% 时提醒",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
val amountValue = amount.toDoubleOrNull()
|
||||||
|
if (amountValue != null && amountValue > 0) {
|
||||||
|
val calendar = Calendar.getInstance()
|
||||||
|
val startDate = budget?.startDate ?: calendar.time
|
||||||
|
|
||||||
|
// 设置结束日期为当月最后一天
|
||||||
|
calendar.set(Calendar.DAY_OF_MONTH, calendar.getActualMaximum(Calendar.DAY_OF_MONTH))
|
||||||
|
calendar.set(Calendar.HOUR_OF_DAY, 23)
|
||||||
|
calendar.set(Calendar.MINUTE, 59)
|
||||||
|
calendar.set(Calendar.SECOND, 59)
|
||||||
|
val endDate = calendar.time
|
||||||
|
|
||||||
|
val newBudget = Budget(
|
||||||
|
id = budget?.id ?: 0,
|
||||||
|
type = selectedType,
|
||||||
|
amount = amountValue,
|
||||||
|
categoryName = if (selectedType == BudgetType.CATEGORY) selectedCategory else null,
|
||||||
|
memberId = if (selectedType == BudgetType.MEMBER) selectedMemberId else null,
|
||||||
|
startDate = startDate,
|
||||||
|
endDate = endDate,
|
||||||
|
alertThreshold = alertThreshold / 100,
|
||||||
|
isEnabled = budget?.isEnabled ?: true,
|
||||||
|
createdAt = budget?.createdAt ?: Date(),
|
||||||
|
updatedAt = Date()
|
||||||
|
)
|
||||||
|
onConfirm(newBudget)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = amount.toDoubleOrNull() != null && amount.toDouble() > 0 &&
|
||||||
|
(selectedType != BudgetType.CATEGORY || selectedCategory != null) &&
|
||||||
|
(selectedType != BudgetType.MEMBER || selectedMemberId != null)
|
||||||
|
) {
|
||||||
|
Text("确定")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text("取消")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
@ -47,6 +47,10 @@ sealed class Screen(
|
|||||||
"设置",
|
"设置",
|
||||||
iconResId = R.drawable.setting
|
iconResId = R.drawable.setting
|
||||||
)
|
)
|
||||||
|
object Budget : Screen(
|
||||||
|
"budget",
|
||||||
|
"预算管理"
|
||||||
|
)
|
||||||
object CategoryDetail : Screen(
|
object CategoryDetail : Screen(
|
||||||
"category_detail/{category}/{startMonth}/{endMonth}",
|
"category_detail/{category}/{startMonth}/{endMonth}",
|
||||||
"分类详情"
|
"分类详情"
|
||||||
@ -148,10 +152,17 @@ fun MainNavigation(
|
|||||||
composable(Screen.Settings.route) {
|
composable(Screen.Settings.route) {
|
||||||
SettingsScreen(
|
SettingsScreen(
|
||||||
currentTheme = currentTheme,
|
currentTheme = currentTheme,
|
||||||
onThemeChange = onThemeChange
|
onThemeChange = onThemeChange,
|
||||||
|
onNavigateToBudget = {
|
||||||
|
navController.navigate(Screen.Budget.route)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
composable(Screen.Budget.route) {
|
||||||
|
BudgetScreen()
|
||||||
|
}
|
||||||
|
|
||||||
composable(
|
composable(
|
||||||
route = Screen.CategoryDetail.route,
|
route = Screen.CategoryDetail.route,
|
||||||
arguments = listOf(
|
arguments = listOf(
|
||||||
|
@ -0,0 +1,411 @@
|
|||||||
|
package com.yovinchen.bookkeeping.ui.screen
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.yovinchen.bookkeeping.model.*
|
||||||
|
import com.yovinchen.bookkeeping.ui.dialog.BudgetEditDialog
|
||||||
|
import com.yovinchen.bookkeeping.viewmodel.BudgetViewModel
|
||||||
|
import com.yovinchen.bookkeeping.viewmodel.HomeViewModel
|
||||||
|
import com.yovinchen.bookkeeping.viewmodel.MemberViewModel
|
||||||
|
import java.text.NumberFormat
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预算管理界面
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun BudgetScreen(
|
||||||
|
viewModel: BudgetViewModel = viewModel(),
|
||||||
|
homeViewModel: HomeViewModel = viewModel(),
|
||||||
|
memberViewModel: MemberViewModel = viewModel()
|
||||||
|
) {
|
||||||
|
val budgetStatuses by viewModel.activeBudgetStatuses.collectAsState()
|
||||||
|
val totalBudgetStatus by viewModel.totalBudgetStatus.collectAsState()
|
||||||
|
val categoryBudgetStatuses by viewModel.categoryBudgetStatuses.collectAsState()
|
||||||
|
val memberBudgetStatuses by viewModel.memberBudgetStatuses.collectAsState()
|
||||||
|
val showBudgetDialog by viewModel.showBudgetDialog.collectAsState()
|
||||||
|
val editingBudget by viewModel.editingBudget.collectAsState()
|
||||||
|
|
||||||
|
val categories by homeViewModel.categories.collectAsState()
|
||||||
|
val members by memberViewModel.allMembers.collectAsState(initial = emptyList())
|
||||||
|
|
||||||
|
var selectedTab by remember { mutableStateOf(0) }
|
||||||
|
val tabs = listOf("总览", "分类预算", "成员预算")
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
// 页面标题
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "预算管理",
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
// 添加预算按钮
|
||||||
|
IconButton(
|
||||||
|
onClick = { viewModel.showEditBudgetDialog() }
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Add,
|
||||||
|
contentDescription = "添加预算",
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// 总预算概览卡片
|
||||||
|
totalBudgetStatus?.let { status ->
|
||||||
|
BudgetOverviewCard(status)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tab 选择器
|
||||||
|
TabRow(selectedTabIndex = selectedTab) {
|
||||||
|
tabs.forEachIndexed { index, title ->
|
||||||
|
Tab(
|
||||||
|
selected = selectedTab == index,
|
||||||
|
onClick = { selectedTab = index },
|
||||||
|
text = { Text(title) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Tab 内容
|
||||||
|
when (selectedTab) {
|
||||||
|
0 -> BudgetOverviewTab(budgetStatuses, viewModel)
|
||||||
|
1 -> CategoryBudgetTab(categoryBudgetStatuses, viewModel)
|
||||||
|
2 -> MemberBudgetTab(memberBudgetStatuses, viewModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 预算编辑对话框
|
||||||
|
if (showBudgetDialog) {
|
||||||
|
BudgetEditDialog(
|
||||||
|
budget = editingBudget,
|
||||||
|
categories = categories.filter { it.type == TransactionType.EXPENSE },
|
||||||
|
members = members,
|
||||||
|
onDismiss = { viewModel.hideBudgetDialog() },
|
||||||
|
onConfirm = { budget ->
|
||||||
|
if (editingBudget == null) {
|
||||||
|
viewModel.createBudget(
|
||||||
|
type = budget.type,
|
||||||
|
amount = budget.amount,
|
||||||
|
categoryName = budget.categoryName,
|
||||||
|
memberId = budget.memberId,
|
||||||
|
alertThreshold = budget.alertThreshold
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
viewModel.updateBudget(budget)
|
||||||
|
}
|
||||||
|
viewModel.hideBudgetDialog()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预算概览卡片
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun BudgetOverviewCard(budgetStatus: BudgetStatus) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "本月总预算",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = formatCurrency(budgetStatus.budget.amount),
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
// 进度条
|
||||||
|
LinearProgressIndicator(
|
||||||
|
progress = budgetStatus.percentage.toFloat().coerceIn(0f, 1f),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(8.dp)
|
||||||
|
.clip(RoundedCornerShape(4.dp)),
|
||||||
|
color = when {
|
||||||
|
budgetStatus.isOverBudget -> MaterialTheme.colorScheme.error
|
||||||
|
budgetStatus.isNearLimit -> MaterialTheme.colorScheme.tertiary
|
||||||
|
else -> MaterialTheme.colorScheme.primary
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = "已使用",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = formatCurrency(budgetStatus.spent),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(horizontalAlignment = Alignment.End) {
|
||||||
|
Text(
|
||||||
|
text = "剩余",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = formatCurrency(budgetStatus.remaining),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = when {
|
||||||
|
budgetStatus.isOverBudget -> MaterialTheme.colorScheme.error
|
||||||
|
budgetStatus.isNearLimit -> MaterialTheme.colorScheme.tertiary
|
||||||
|
else -> MaterialTheme.colorScheme.primary
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (budgetStatus.isOverBudget) {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = "⚠️ 已超出预算 ${formatCurrency(kotlin.math.abs(budgetStatus.remaining))}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
} else if (budgetStatus.isNearLimit) {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = "⚠️ 接近预算限制",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.tertiary,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预算总览标签页
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun BudgetOverviewTab(
|
||||||
|
budgetStatuses: List<BudgetStatus>,
|
||||||
|
viewModel: BudgetViewModel
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
items(budgetStatuses) { status ->
|
||||||
|
BudgetItem(
|
||||||
|
budgetStatus = status,
|
||||||
|
onClick = { viewModel.showEditBudgetDialog(status.budget) },
|
||||||
|
onToggleEnabled = { viewModel.toggleBudgetEnabled(status.budget) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分类预算标签页
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun CategoryBudgetTab(
|
||||||
|
categoryBudgetStatuses: List<BudgetStatus>,
|
||||||
|
viewModel: BudgetViewModel
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
items(categoryBudgetStatuses) { status ->
|
||||||
|
BudgetItem(
|
||||||
|
budgetStatus = status,
|
||||||
|
onClick = { viewModel.showEditBudgetDialog(status.budget) },
|
||||||
|
onToggleEnabled = { viewModel.toggleBudgetEnabled(status.budget) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 成员预算标签页
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun MemberBudgetTab(
|
||||||
|
memberBudgetStatuses: List<BudgetStatus>,
|
||||||
|
viewModel: BudgetViewModel
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
items(memberBudgetStatuses) { status ->
|
||||||
|
BudgetItem(
|
||||||
|
budgetStatus = status,
|
||||||
|
onClick = { viewModel.showEditBudgetDialog(status.budget) },
|
||||||
|
onToggleEnabled = { viewModel.toggleBudgetEnabled(status.budget) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预算项目组件
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun BudgetItem(
|
||||||
|
budgetStatus: BudgetStatus,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onToggleEnabled: () -> Unit
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { onClick() },
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = if (budgetStatus.budget.isEnabled) {
|
||||||
|
MaterialTheme.colorScheme.surface
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = when (budgetStatus.budget.type) {
|
||||||
|
BudgetType.TOTAL -> "总预算"
|
||||||
|
BudgetType.CATEGORY -> budgetStatus.budget.categoryName ?: "未知分类"
|
||||||
|
BudgetType.MEMBER -> "成员预算"
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "预算: ${formatCurrency(budgetStatus.budget.amount)}",
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "已用: ${formatCurrency(budgetStatus.spent)}",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = when {
|
||||||
|
budgetStatus.isOverBudget -> MaterialTheme.colorScheme.error
|
||||||
|
budgetStatus.isNearLimit -> MaterialTheme.colorScheme.tertiary
|
||||||
|
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
LinearProgressIndicator(
|
||||||
|
progress = budgetStatus.percentage.toFloat().coerceIn(0f, 1f),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(4.dp)
|
||||||
|
.clip(RoundedCornerShape(2.dp)),
|
||||||
|
color = when {
|
||||||
|
budgetStatus.isOverBudget -> MaterialTheme.colorScheme.error
|
||||||
|
budgetStatus.isNearLimit -> MaterialTheme.colorScheme.tertiary
|
||||||
|
else -> MaterialTheme.colorScheme.primary
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
IconButton(onClick = onToggleEnabled) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (budgetStatus.budget.isEnabled) {
|
||||||
|
Icons.Default.CheckCircle
|
||||||
|
} else {
|
||||||
|
Icons.Default.Cancel
|
||||||
|
},
|
||||||
|
contentDescription = if (budgetStatus.budget.isEnabled) "禁用" else "启用",
|
||||||
|
tint = if (budgetStatus.budget.isEnabled) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化货币
|
||||||
|
*/
|
||||||
|
private fun formatCurrency(amount: Double): String {
|
||||||
|
val format = NumberFormat.getCurrencyInstance(Locale.CHINA)
|
||||||
|
return format.format(amount)
|
||||||
|
}
|
@ -31,6 +31,7 @@ import com.yovinchen.bookkeeping.viewmodel.*
|
|||||||
fun SettingsScreen(
|
fun SettingsScreen(
|
||||||
currentTheme: ThemeMode,
|
currentTheme: ThemeMode,
|
||||||
onThemeChange: (ThemeMode) -> Unit,
|
onThemeChange: (ThemeMode) -> Unit,
|
||||||
|
onNavigateToBudget: () -> Unit = {},
|
||||||
viewModel: SettingsViewModel = viewModel(),
|
viewModel: SettingsViewModel = viewModel(),
|
||||||
memberViewModel: MemberViewModel = viewModel()
|
memberViewModel: MemberViewModel = viewModel()
|
||||||
) {
|
) {
|
||||||
@ -77,6 +78,17 @@ fun SettingsScreen(
|
|||||||
|
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
|
|
||||||
|
// 预算管理设置项
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text("预算管理") },
|
||||||
|
supportingContent = { Text("设置和管理预算") },
|
||||||
|
modifier = Modifier.clickable {
|
||||||
|
onNavigateToBudget()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
HorizontalDivider()
|
||||||
|
|
||||||
// 主题设置项
|
// 主题设置项
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = { Text("主题设置") },
|
headlineContent = { Text("主题设置") },
|
||||||
|
@ -0,0 +1,185 @@
|
|||||||
|
package com.yovinchen.bookkeeping.viewmodel
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.lifecycle.AndroidViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.yovinchen.bookkeeping.data.BookkeepingDatabase
|
||||||
|
import com.yovinchen.bookkeeping.data.BudgetRepository
|
||||||
|
import com.yovinchen.bookkeeping.model.*
|
||||||
|
import kotlinx.coroutines.flow.*
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.util.*
|
||||||
|
import java.util.Calendar
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预算管理 ViewModel
|
||||||
|
* 负责预算相关的业务逻辑和状态管理
|
||||||
|
*/
|
||||||
|
class BudgetViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
|
private val database = BookkeepingDatabase.getDatabase(application)
|
||||||
|
private val budgetRepository = BudgetRepository(
|
||||||
|
database.budgetDao(),
|
||||||
|
database.bookkeepingDao(),
|
||||||
|
database.memberDao()
|
||||||
|
)
|
||||||
|
|
||||||
|
// 所有预算列表
|
||||||
|
val allBudgets = budgetRepository.getAllBudgets()
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5000),
|
||||||
|
initialValue = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
// 当前活跃的预算状态
|
||||||
|
val activeBudgetStatuses = budgetRepository.getActiveBudgetStatuses()
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5000),
|
||||||
|
initialValue = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
// 总预算状态
|
||||||
|
private val _totalBudgetStatus = MutableStateFlow<BudgetStatus?>(null)
|
||||||
|
val totalBudgetStatus: StateFlow<BudgetStatus?> = _totalBudgetStatus.asStateFlow()
|
||||||
|
|
||||||
|
// 分类预算状态
|
||||||
|
val categoryBudgetStatuses = budgetRepository.getCategoryBudgetStatuses()
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5000),
|
||||||
|
initialValue = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
// 成员预算状态
|
||||||
|
val memberBudgetStatuses = budgetRepository.getMemberBudgetStatuses()
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5000),
|
||||||
|
initialValue = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
// 编辑中的预算
|
||||||
|
private val _editingBudget = MutableStateFlow<Budget?>(null)
|
||||||
|
val editingBudget: StateFlow<Budget?> = _editingBudget.asStateFlow()
|
||||||
|
|
||||||
|
// 对话框显示状态
|
||||||
|
private val _showBudgetDialog = MutableStateFlow(false)
|
||||||
|
val showBudgetDialog: StateFlow<Boolean> = _showBudgetDialog.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
// 初始化时加载总预算状态
|
||||||
|
loadTotalBudgetStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载总预算状态
|
||||||
|
*/
|
||||||
|
private fun loadTotalBudgetStatus() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_totalBudgetStatus.value = budgetRepository.getTotalBudgetStatus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建新预算
|
||||||
|
*/
|
||||||
|
fun createBudget(
|
||||||
|
type: BudgetType,
|
||||||
|
amount: Double,
|
||||||
|
categoryName: String? = null,
|
||||||
|
memberId: Int? = null,
|
||||||
|
alertThreshold: Double = 0.8
|
||||||
|
) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val calendar = Calendar.getInstance()
|
||||||
|
val startDate = calendar.time
|
||||||
|
|
||||||
|
// 设置结束日期为当月最后一天
|
||||||
|
calendar.set(Calendar.DAY_OF_MONTH, calendar.getActualMaximum(Calendar.DAY_OF_MONTH))
|
||||||
|
calendar.set(Calendar.HOUR_OF_DAY, 23)
|
||||||
|
calendar.set(Calendar.MINUTE, 59)
|
||||||
|
calendar.set(Calendar.SECOND, 59)
|
||||||
|
val endDate = calendar.time
|
||||||
|
|
||||||
|
val budget = Budget(
|
||||||
|
type = type,
|
||||||
|
amount = amount,
|
||||||
|
categoryName = categoryName,
|
||||||
|
memberId = memberId,
|
||||||
|
startDate = startDate,
|
||||||
|
endDate = endDate,
|
||||||
|
alertThreshold = alertThreshold
|
||||||
|
)
|
||||||
|
|
||||||
|
budgetRepository.createBudget(budget)
|
||||||
|
loadTotalBudgetStatus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新预算
|
||||||
|
*/
|
||||||
|
fun updateBudget(budget: Budget) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
budgetRepository.updateBudget(budget)
|
||||||
|
loadTotalBudgetStatus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除预算
|
||||||
|
*/
|
||||||
|
fun deleteBudget(budget: Budget) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
budgetRepository.deleteBudget(budget)
|
||||||
|
loadTotalBudgetStatus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换预算启用状态
|
||||||
|
*/
|
||||||
|
fun toggleBudgetEnabled(budget: Budget) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
budgetRepository.updateBudgetEnabled(budget.id, !budget.isEnabled)
|
||||||
|
loadTotalBudgetStatus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 显示预算编辑对话框
|
||||||
|
*/
|
||||||
|
fun showEditBudgetDialog(budget: Budget? = null) {
|
||||||
|
_editingBudget.value = budget
|
||||||
|
_showBudgetDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 隐藏预算编辑对话框
|
||||||
|
*/
|
||||||
|
fun hideBudgetDialog() {
|
||||||
|
_showBudgetDialog.value = false
|
||||||
|
_editingBudget.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查预算警报
|
||||||
|
*/
|
||||||
|
fun checkBudgetAlerts() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val alerts = budgetRepository.checkBudgetAlerts()
|
||||||
|
// 这里可以触发通知或其他警报机制
|
||||||
|
// 暂时先不实现,等完成通知功能后再补充
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理过期预算
|
||||||
|
*/
|
||||||
|
fun cleanupExpiredBudgets() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
budgetRepository.cleanupExpiredBudgets()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user