7 Commits

Author SHA1 Message Date
0187517099 优化动画 2025-07-23 00:05:59 +08:00
45b448ee57 优化动画 2025-07-23 00:05:55 +08:00
a2489c4987 docs: 更新README文档,突出项目功能亮点
- 新增"功能亮点"部分,重点展示已完成的核心功能
  - 智能数据分析:月度/年度报表、详细分析、多维度统计
  - 预算管理系统:多层级预算、实时监控、灵活配置
  - 数据安全保障:备份加密、离线优先、隐私保护
  - 现代化设计:Material 3、深色模式、响应式布局
- 更新主要特性,标注预算管理为"基本完成"
- 更新v1.5版本历史,记录崩溃修复和功能优化
- 优化文档结构,提升可读性和专业度

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-20 00:25:07 +08:00
562617ca11 fix: 修复DetailedAnalysisReport中嵌套LazyColumn导致的崩溃
- 将DetailedAnalysisReport中的LazyColumn改为Column
- 移除所有item{}包装,直接使用普通组件布局
- 解决'Vertically scrollable component was measured with an infinity maximum height constraints'错误

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-20 00:20:59 +08:00
74cc6f36a9 feat: 优化月度/年度报表和数据分析页面
- 创建 MonthlyYearlyReport 组件,显示收支对比、盈余状况、储蓄率和日均消费
- 创建 DetailedAnalysisReport 组件,提供详细的分类统计分析
  - 支出/收入分类明细与占比
  - TOP5分类排行榜(金银铜奖牌设计)
  - 可视化进度条和百分比显示
- 在 AnalysisScreen 中新增"报表"视图模式
  - 支持分类、成员、报表三种视图切换
  - 集成月度/年度报表和详细分析报表
- 更新 README:标记月度/年度报表功能为已完成
- 更新 v1.5 版本历史,记录数据分析优化内容

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-20 00:17:49 +08:00
e651086e6d 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>
2025-07-19 23:09:38 +08:00
7fc76df829 docs: 更新 README 文件,反映最新的项目进度
- 标记月度记账开始日期功能为已完成
- 标记数据管理功能为已完成(包括备份加密)
- 更新预算管理功能状态为进行中
- 添加 v1.4 和 v1.5(开发中)版本历史
- 在主要特性中添加备份加密和自定义周期功能

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-19 22:50:05 +08:00
17 changed files with 3076 additions and 313 deletions

View File

@@ -1,7 +1,11 @@
{
"permissions": {
"allow": [
"Bash(./gradlew:*)"
"Bash(./gradlew:*)",
"Bash(git push:*)",
"Bash(git branch:*)",
"Bash(git add:*)",
"Bash(git commit:*)"
],
"deny": []
}

102
README.md
View File

@@ -6,6 +6,35 @@
本项目是一个使用 Kotlin 和 Jetpack Compose 开发的 Android 记账应用,采用 MVVM 架构,提供简洁直观的用户界面和丰富的记账功能。
## 🔥 功能亮点
### 📊 智能数据分析
- **月度/年度报表**:收支对比、储蓄率、日均消费等关键指标
- **详细分析报表**分类统计明细、TOP排行榜、可视化进度条
- **多维度统计**:支持按分类、成员、时间等多维度数据分析
### 💼 预算管理系统
- **多层级预算**:支持总预算、分类预算、成员预算
- **实时监控**:预算使用情况可视化,超支警告提醒
- **灵活配置**:可启用/禁用预算,自定义预警阈值
### 🔐 数据安全保障
- **备份加密**:使用 Android Keystore 加密导出文件
- **离线优先**:完全本地存储,无网络依赖
- **隐私保护**:极简权限,数据完全掌控
### 🎨 现代化设计
- **Material 3**:遵循最新设计规范
- **深色模式**:支持系统主题切换
- **响应式布局**:适配不同屏幕尺寸
- **流畅动画**:优化页面切换和交互动效
### 🚀 最新优化
- **智能收起式统计栏**:主页统计信息随滑动自动收起,释放更多空间
- **优化数字显示**:支持十万级金额智能格式化(使用"万"为单位)
- **增强动画效果**:导航切换、列表加载、对话框显示均有流畅动画
- **改进布局设计**统计栏占页面20%,自适应不同屏幕尺寸
## ⭐️ 主要特性
- 🔒 完全离线运行,无需网络连接
@@ -13,6 +42,10 @@
- 💰 支持收入和支出记录
- 👥 支持多人记账
- 📊 按日期和类别统计
- 🔐 备份文件加密保护
- 📅 自定义月度记账周期
- 💼 预算管理(基本完成)
- 📈 详细数据分析报表
## 🛠 技术栈
@@ -32,7 +65,7 @@
- [x] Material 3 设计界面
- [x] 深色/浅色主题切换
- [x] 主题色自定义
- [ ] 月度记账开始日期
- [x] 月度记账开始日期
### 1. 成员系统 (已完成 🎉)
- [x] 成员添加/编辑/删除
@@ -43,7 +76,7 @@
### 2. 图表分析 (已完成 🎉)
- [x] 支出/收入趋势图表
- [x] 分类占比饼图
- [ ] 月度/年度报表
- [x] 月度/年度报表
- [x] 成员消费分析
- [x] 自定义统计周期
@@ -59,26 +92,31 @@
- [x] 收入类图标 (工资、奖金、理财等)
- [x] 成员图标 (家人、朋友、同事等)
### 4. 数据管理 (进行中 🚀)
### 4. 数据管理 (已完成 🎉)
- [x] 导出 CSV/Excel 功能
- [x] 数据导入
- [x] 数据迁移工具
- [x] 定期自动备份
- [ ] 备份加密功能
- [x] 备份加密功能
### 5. 预算管理 (计划中 💡)
- [ ] 月度预算设置
### 5. 预算管理 (基本完成 ✨)
- [x] 预算数据模型设计
- [x] 数据库架构实现
- [x] 预算管理界面
- [x] 月度预算设置
- [ ] 预算超支提醒
- [ ] 分类预算管理
- [ ] 成员预算管理
- [x] 分类预算管理
- [x] 成员预算管理
- [ ] 预算分析报告
### 6. 体验优化 (持续进行 🔄)
### 6. 体验优化 (进行中 🚀)
- [x] 深色模式支持
- [x] 流畅页面动画
- [x] 智能收起式统计栏
- [x] 优化数字显示格式
- [ ] 手势操作优化
- [ ] 快速记账小组件
- [ ] 多语言支持
- [ ] 自定义主题
- [x] 自定义主题
### 7. 性能提升 (持续进行 ⚡️)
- [ ] 大数据量处理优化
@@ -116,6 +154,48 @@
## 📝 版本历史
### v1.6 (开发中)
- 用户体验优化
- 智能收起式统计栏占页面20%,随滑动自动收起)
- 优化数字显示(十万以上使用"万"为单位)
- 增强动画效果
- 导航栏切换动画(图标缩放、文字样式变化)
- 页面切换动画(方向感知的滑动效果)
- 列表项渐进式加载动画
- 对话框弹出动画AnimatedDialog组件
- 布局响应式优化
- 细节改进
- 修复RecordEditDialog参数问题
- 优化MonthlyStatistics布局间距
- 改进图标大小和间距
### v1.5 (开发中)
- 预算管理功能
- 预算数据模型设计
- 支持总预算、分类预算、成员预算
- 数据库架构实现升级到版本6
- 预算管理界面设计
- 预算编辑对话框
- 预算状态可视化(进度条、超支提醒)
- 预算导航集成
- 数据分析优化
- 月度/年度报表组件
- 详细分析报表(分类统计明细)
- 收支对比、储蓄率、日均消费分析
- TOP分类排行榜金银铜奖牌设计
- 报表视图集成到分析页面
- 修复嵌套滚动组件崩溃问题
### v1.4
- 数据安全功能
- 备份文件加密使用Android Keystore
- 支持加密CSV/Excel导出
- 自动检测和解密加密备份
- 设置页面加密开关
- 月度记账优化
- 自定义月度开始日期1-28号
- 所有统计基于自定义周期
### v1.3
- 图标美化计划
- 增加图标美化

View File

@@ -0,0 +1,111 @@
package com.yovinchen.bookkeeping.ui.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.*
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import kotlinx.coroutines.delay
@Composable
fun AnimatedDialog(
visible: Boolean,
onDismissRequest: () -> Unit,
content: @Composable () -> Unit
) {
var showDialog by remember { mutableStateOf(false) }
var showContent by remember { mutableStateOf(false) }
LaunchedEffect(visible) {
if (visible) {
showDialog = true
delay(50)
showContent = true
} else {
showContent = false
delay(300)
showDialog = false
}
}
if (showDialog) {
Dialog(
onDismissRequest = onDismissRequest,
properties = DialogProperties(
dismissOnBackPress = true,
dismissOnClickOutside = true,
usePlatformDefaultWidth = false
)
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
// 背景遮罩动画
AnimatedVisibility(
visible = showContent,
enter = fadeIn(
animationSpec = tween(
durationMillis = 200,
easing = FastOutSlowInEasing
)
),
exit = fadeOut(
animationSpec = tween(
durationMillis = 150,
easing = FastOutSlowInEasing
)
)
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.5f))
)
}
// 对话框内容动画
AnimatedVisibility(
visible = showContent,
enter = scaleIn(
initialScale = 0.8f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioLowBouncy,
stiffness = Spring.StiffnessMedium
)
) + fadeIn(
animationSpec = tween(
durationMillis = 300,
easing = FastOutSlowInEasing
)
),
exit = scaleOut(
targetScale = 0.8f,
animationSpec = tween(
durationMillis = 200,
easing = FastOutSlowInEasing
)
) + fadeOut(
animationSpec = tween(
durationMillis = 200,
easing = FastOutSlowInEasing
)
)
) {
content()
}
}
}
}
}

View File

@@ -0,0 +1,302 @@
package com.yovinchen.bookkeeping.ui.components
import androidx.compose.foundation.background
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.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.unit.dp
import androidx.compose.ui.unit.sp
import com.yovinchen.bookkeeping.model.BookkeepingRecord
import com.yovinchen.bookkeeping.model.TransactionType
import java.text.NumberFormat
import java.time.YearMonth
import java.time.format.DateTimeFormatter
import java.util.*
/**
* 详细分析报表组件
* 显示按分类统计的详细信息
*/
@Composable
fun DetailedAnalysisReport(
records: List<BookkeepingRecord>,
startMonth: YearMonth,
endMonth: YearMonth,
modifier: Modifier = Modifier
) {
val currencyFormatter = NumberFormat.getCurrencyInstance(Locale.CHINA)
// 按类型分组
val incomeRecords = records.filter { it.type == TransactionType.INCOME }
val expenseRecords = records.filter { it.type == TransactionType.EXPENSE }
// 按分类统计
val incomeByCategory = incomeRecords.groupBy { it.category }
.mapValues { it.value.sumOf { record -> record.amount } }
.toList()
.sortedByDescending { it.second }
val expenseByCategory = expenseRecords.groupBy { it.category }
.mapValues { it.value.sumOf { record -> record.amount } }
.toList()
.sortedByDescending { it.second }
// 总收入和总支出
val totalIncome = incomeRecords.sumOf { it.amount }
val totalExpense = expenseRecords.sumOf { it.amount }
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// 时间范围标题
Card(
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Text(
text = if (startMonth == endMonth) {
"统计期间:${startMonth.format(DateTimeFormatter.ofPattern("yyyy年MM月"))}"
} else {
"统计期间:${startMonth.format(DateTimeFormatter.ofPattern("yyyy年MM月"))}${endMonth.format(DateTimeFormatter.ofPattern("yyyy年MM月"))}"
},
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(16.dp),
textAlign = TextAlign.Center
)
}
// 支出分类详情
if (expenseByCategory.isNotEmpty()) {
CategoryDetailCard(
title = "支出分类明细",
categoryData = expenseByCategory,
total = totalExpense,
color = MaterialTheme.colorScheme.error,
currencyFormatter = currencyFormatter
)
}
// 收入分类详情
if (incomeByCategory.isNotEmpty()) {
CategoryDetailCard(
title = "收入分类明细",
categoryData = incomeByCategory,
total = totalIncome,
color = MaterialTheme.colorScheme.primary,
currencyFormatter = currencyFormatter
)
}
// 分类占比前5名
if (expenseByCategory.isNotEmpty()) {
TopCategoriesCard(
title = "支出TOP5",
categoryData = expenseByCategory.take(5),
total = totalExpense,
color = MaterialTheme.colorScheme.error,
currencyFormatter = currencyFormatter
)
}
}
}
/**
* 分类详情卡片
*/
@Composable
private fun CategoryDetailCard(
title: String,
categoryData: List<Pair<String, Double>>,
total: Double,
color: Color,
currencyFormatter: NumberFormat
) {
Card(
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = color,
modifier = Modifier.padding(bottom = 16.dp)
)
// 总计
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "总计",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Bold
)
Text(
text = currencyFormatter.format(total),
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Bold,
color = color
)
}
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
// 分类列表
categoryData.forEach { (category, amount) ->
val percentage = if (total > 0) (amount / total * 100) else 0.0
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = category,
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "${String.format("%.1f", percentage)}%",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Text(
text = currencyFormatter.format(amount),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium
)
}
// 进度条
LinearProgressIndicator(
progress = (percentage / 100).toFloat(),
modifier = Modifier
.fillMaxWidth()
.height(4.dp)
.clip(RoundedCornerShape(2.dp)),
color = color.copy(alpha = 0.8f),
trackColor = color.copy(alpha = 0.2f)
)
}
}
}
}
/**
* TOP分类卡片
*/
@Composable
private fun TopCategoriesCard(
title: String,
categoryData: List<Pair<String, Double>>,
total: Double,
color: Color,
currencyFormatter: NumberFormat
) {
Card(
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 12.dp)
)
categoryData.forEachIndexed { index, (category, amount) ->
val percentage = if (total > 0) (amount / total * 100) else 0.0
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
// 排名
Box(
modifier = Modifier
.size(24.dp)
.clip(RoundedCornerShape(12.dp))
.background(
when (index) {
0 -> Color(0xFFFFD700) // 金色
1 -> Color(0xFFC0C0C0) // 银色
2 -> Color(0xFFCD7F32) // 铜色
else -> MaterialTheme.colorScheme.surfaceVariant
}
),
contentAlignment = Alignment.Center
) {
Text(
text = "${index + 1}",
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = if (index < 3) Color.White else MaterialTheme.colorScheme.onSurfaceVariant
)
}
Text(
text = category,
style = MaterialTheme.typography.bodyMedium,
fontWeight = if (index == 0) FontWeight.Bold else FontWeight.Normal
)
}
Column(
horizontalAlignment = Alignment.End
) {
Text(
text = currencyFormatter.format(amount),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium
)
Text(
text = "${String.format("%.1f", percentage)}%",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
}

View File

@@ -11,6 +11,9 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.automirrored.filled.TrendingDown
import androidx.compose.material.icons.automirrored.filled.TrendingUp
import androidx.compose.material.icons.filled.AccountBalanceWallet
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@@ -19,25 +22,86 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.yovinchen.bookkeeping.model.TransactionType
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.text.font.FontWeight
import com.yovinchen.bookkeeping.ui.theme.IncomeColor
import com.yovinchen.bookkeeping.ui.theme.ExpenseColor
import com.yovinchen.bookkeeping.ui.theme.BalancePositive
import com.yovinchen.bookkeeping.ui.theme.BalanceNegative
import java.time.YearMonth
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.runtime.remember
import androidx.compose.runtime.LaunchedEffect
import kotlinx.coroutines.delay
import androidx.compose.ui.text.style.TextOverflow
import kotlin.math.abs
import java.util.Locale
// 格式化金额显示,适应不同数量级
private fun formatAmount(amount: Float): String {
val absAmount = abs(amount)
return when {
absAmount >= 100000 -> {
// 十万以上显示为万为单位
val wan = amount / 10000
"¥${String.format(Locale.getDefault(), "%.1f", wan)}"
}
absAmount >= 10000 -> {
// 万级显示一位小数
"¥${String.format(Locale.getDefault(), "%.1f", amount)}"
}
else -> {
// 千级以下显示两位小数
"¥${String.format(Locale.getDefault(), "%.2f", amount)}"
}
}
}
@OptIn(ExperimentalAnimationApi::class)
@Composable
fun MonthYearPickerDialog(
selectedMonth: YearMonth, onMonthSelected: (YearMonth) -> Unit, onDismiss: () -> Unit
) {
var currentYearMonth by remember { mutableStateOf(selectedMonth) }
var isVisible by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
isVisible = true
}
Dialog(onDismissRequest = onDismiss) {
Surface(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight(),
shape = MaterialTheme.shapes.extraLarge,
tonalElevation = 6.dp
Dialog(onDismissRequest = {
isVisible = false
onDismiss()
}) {
AnimatedVisibility(
visible = isVisible,
enter = scaleIn(
initialScale = 0.8f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioLowBouncy,
stiffness = Spring.StiffnessMedium
)
) + fadeIn(animationSpec = tween(300)),
exit = scaleOut(targetScale = 0.8f) + fadeOut(animationSpec = tween(200))
) {
Column(
modifier = Modifier.padding(16.dp)
Surface(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.animateContentSize(
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy
)
),
shape = MaterialTheme.shapes.extraLarge,
tonalElevation = 6.dp
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = "选择年月",
style = MaterialTheme.typography.titleLarge,
@@ -53,16 +117,39 @@ fun MonthYearPickerDialog(
IconButton(onClick = {
currentYearMonth = currentYearMonth.minusYears(1)
}) {
Text("<")
Icon(
Icons.AutoMirrored.Filled.KeyboardArrowLeft,
contentDescription = "上一年",
modifier = Modifier.animateContentSize()
)
}
AnimatedContent(
targetState = currentYearMonth.year,
transitionSpec = {
if (targetState > initialState) {
slideInVertically { -it } + fadeIn() togetherWith
slideOutVertically { it } + fadeOut()
} else {
slideInVertically { it } + fadeIn() togetherWith
slideOutVertically { -it } + fadeOut()
}
}
) { year ->
Text(
text = "${year}",
style = MaterialTheme.typography.titleMedium.copy(
fontWeight = FontWeight.Bold
)
)
}
Text(
text = "${currentYearMonth.year}",
style = MaterialTheme.typography.titleMedium
)
IconButton(onClick = {
currentYearMonth = currentYearMonth.plusYears(1)
}) {
Text(">")
Icon(
Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = "下一年",
modifier = Modifier.animateContentSize()
)
}
}
@@ -121,6 +208,7 @@ fun MonthYearPickerDialog(
}
}
}
}
}
}
@@ -140,17 +228,52 @@ fun MonthlyStatistics(
modifier: Modifier = Modifier
) {
var showMonthPicker by remember { mutableStateOf(false) }
val balance = totalIncome - totalExpense
// 添加动画效果
val animatedIncome by animateFloatAsState(
targetValue = totalIncome.toFloat(),
animationSpec = tween(600, easing = FastOutSlowInEasing)
)
val animatedExpense by animateFloatAsState(
targetValue = totalExpense.toFloat(),
animationSpec = tween(600, easing = FastOutSlowInEasing)
)
val animatedBalance by animateFloatAsState(
targetValue = balance.toFloat(),
animationSpec = tween(600, easing = FastOutSlowInEasing)
)
Card(
modifier = modifier
.fillMaxWidth()
.padding(16.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
.fillMaxHeight()
.padding(horizontal = 16.dp, vertical = 8.dp)
.shadow(
elevation = 8.dp,
shape = RoundedCornerShape(20.dp),
clip = false
),
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.fillMaxHeight()
.background(
brush = Brush.verticalGradient(
colors = listOf(
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f),
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.2f)
)
)
)
.padding(12.dp),
verticalArrangement = Arrangement.SpaceEvenly
) {
// 月份选择器
Row(
@@ -163,7 +286,7 @@ fun MonthlyStatistics(
}
Text(text = "${selectedMonth.year}${selectedMonth.monthValue}",
style = MaterialTheme.typography.titleLarge,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.clickable { showMonthPicker = true })
IconButton(onClick = onNextMonth) {
@@ -171,68 +294,137 @@ fun MonthlyStatistics(
}
}
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween
) {
// 支出统计
Column(modifier = Modifier
.weight(1f)
.clickable { onExpenseClick() }
.background(
if (selectedType == TransactionType.EXPENSE) MaterialTheme.colorScheme.primaryContainer
else Color.Transparent, RoundedCornerShape(8.dp)
)
.padding(8.dp)) {
Text(
text = "支出", style = MaterialTheme.typography.titleMedium
)
Text(
text = "¥${String.format("%.2f", totalExpense)}",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.error
)
Surface(
modifier = Modifier
.weight(1f)
.clickable { onExpenseClick() },
shape = RoundedCornerShape(12.dp),
color = if (selectedType == TransactionType.EXPENSE)
ExpenseColor.copy(alpha = 0.15f)
else
MaterialTheme.colorScheme.surface.copy(alpha = 0.6f)
) {
Column(
modifier = Modifier.padding(8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.TrendingDown,
contentDescription = "支出",
modifier = Modifier.size(14.dp),
tint = ExpenseColor
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "支出",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurface
)
}
Spacer(modifier = Modifier.height(2.dp))
Text(
text = formatAmount(animatedExpense),
style = MaterialTheme.typography.bodyLarge.copy(
fontWeight = FontWeight.Bold
),
color = ExpenseColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
Spacer(modifier = Modifier.width(16.dp))
Spacer(modifier = Modifier.width(8.dp))
// 收入统计
Column(modifier = Modifier
.weight(1f)
.clickable { onIncomeClick() }
.background(
if (selectedType == TransactionType.INCOME) MaterialTheme.colorScheme.primaryContainer
else Color.Transparent, RoundedCornerShape(8.dp)
)
.padding(8.dp)) {
Text(
text = "收入", style = MaterialTheme.typography.titleMedium
)
Text(
text = "¥${String.format("%.2f", totalIncome)}",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.primary
)
Surface(
modifier = Modifier
.weight(1f)
.clickable { onIncomeClick() },
shape = RoundedCornerShape(12.dp),
color = if (selectedType == TransactionType.INCOME)
IncomeColor.copy(alpha = 0.15f)
else
MaterialTheme.colorScheme.surface.copy(alpha = 0.6f)
) {
Column(
modifier = Modifier.padding(8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.TrendingUp,
contentDescription = "收入",
modifier = Modifier.size(14.dp),
tint = IncomeColor
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "收入",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurface
)
}
Spacer(modifier = Modifier.height(2.dp))
Text(
text = formatAmount(animatedIncome),
style = MaterialTheme.typography.bodyLarge.copy(
fontWeight = FontWeight.Bold
),
color = IncomeColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
Spacer(modifier = Modifier.width(16.dp))
Spacer(modifier = Modifier.width(8.dp))
// 结余统计
Column(modifier = Modifier
.weight(1f)
.clickable { onClearFilter() }
.background(
if (selectedType == TransactionType.INCOME) MaterialTheme.colorScheme.primaryContainer
else Color.Transparent, RoundedCornerShape(8.dp)
)
.padding(8.dp)) {
Text(
text = "结余", style = MaterialTheme.typography.titleMedium
)
Text(
text = "¥${String.format("%.2f", totalIncome - totalExpense)}",
style = MaterialTheme.typography.bodyLarge,
color = if (totalIncome >= totalExpense) MaterialTheme.colorScheme.tertiary
else MaterialTheme.colorScheme.error
)
Surface(
modifier = Modifier
.weight(1f)
.clickable { onClearFilter() },
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.6f)
) {
Column(
modifier = Modifier.padding(8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.AccountBalanceWallet,
contentDescription = "结余",
modifier = Modifier.size(14.dp),
tint = if (animatedBalance >= 0) BalancePositive else BalanceNegative
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "结余",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurface
)
}
Spacer(modifier = Modifier.height(2.dp))
Text(
text = formatAmount(animatedBalance),
style = MaterialTheme.typography.bodyLarge.copy(
fontWeight = FontWeight.Bold
),
color = if (animatedBalance >= 0) BalancePositive else BalanceNegative,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}

View File

@@ -0,0 +1,258 @@
package com.yovinchen.bookkeeping.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
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.unit.dp
import androidx.compose.ui.unit.sp
import com.yovinchen.bookkeeping.model.BookkeepingRecord
import com.yovinchen.bookkeeping.model.TransactionType
import java.text.NumberFormat
import java.time.LocalDateTime
import java.time.YearMonth
import java.time.format.DateTimeFormatter
import java.util.*
/**
* 月度/年度报表组件
* 显示收支对比、盈余情况等统计信息
*/
@Composable
fun MonthlyYearlyReport(
records: List<BookkeepingRecord>,
period: String, // "月度" 或 "年度"
startMonth: YearMonth,
endMonth: YearMonth,
modifier: Modifier = Modifier
) {
val totalIncome = records
.filter { it.type == TransactionType.INCOME }
.sumOf { it.amount }
val totalExpense = records
.filter { it.type == TransactionType.EXPENSE }
.sumOf { it.amount }
val balance = totalIncome - totalExpense
val savingsRate = if (totalIncome > 0) {
((totalIncome - totalExpense) / totalIncome * 100).coerceAtLeast(0.0)
} else 0.0
Card(
modifier = modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// 标题
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "$period 报表",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
Text(
text = if (startMonth == endMonth) {
startMonth.format(DateTimeFormatter.ofPattern("yyyy年MM月"))
} else {
"${startMonth.format(DateTimeFormatter.ofPattern("yyyy年MM月"))} - ${endMonth.format(DateTimeFormatter.ofPattern("yyyy年MM月"))}"
},
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
HorizontalDivider()
// 收支对比
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
// 收入
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.weight(1f)
) {
Text(
text = "总收入",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = formatCurrency(totalIncome),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
}
// 分隔线
Box(
modifier = Modifier
.width(1.dp)
.height(40.dp)
.background(MaterialTheme.colorScheme.outlineVariant)
)
// 支出
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.weight(1f)
) {
Text(
text = "总支出",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = formatCurrency(totalExpense),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.error
)
}
}
// 盈余情况
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = if (balance >= 0) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.errorContainer
}
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = if (balance >= 0) "盈余" else "亏损",
style = MaterialTheme.typography.bodyMedium,
color = if (balance >= 0) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onErrorContainer
}
)
Text(
text = formatCurrency(kotlin.math.abs(balance)),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = if (balance >= 0) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onErrorContainer
}
)
}
}
// 储蓄率
if (totalIncome > 0) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "储蓄率",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f)
)
Box(
modifier = Modifier.weight(2f)
) {
LinearProgressIndicator(
progress = (savingsRate / 100).toFloat().coerceIn(0f, 1f),
modifier = Modifier
.fillMaxWidth()
.height(8.dp)
.clip(RoundedCornerShape(4.dp)),
color = when {
savingsRate >= 30 -> MaterialTheme.colorScheme.primary
savingsRate >= 10 -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.error
}
)
}
Text(
text = "${String.format("%.1f", savingsRate)}%",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(start = 8.dp),
color = when {
savingsRate >= 30 -> MaterialTheme.colorScheme.primary
savingsRate >= 10 -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.error
}
)
}
}
// 日均消费
val dayCount = if (period == "月度") {
// 计算月度天数
java.time.temporal.ChronoUnit.DAYS.between(
startMonth.atDay(1),
endMonth.atEndOfMonth()
) + 1
} else {
// 计算年度天数
java.time.temporal.ChronoUnit.DAYS.between(
startMonth.atDay(1),
endMonth.atEndOfMonth()
) + 1
}
val dailyAverage = if (dayCount > 0) totalExpense / dayCount else 0.0
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "日均消费",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = formatCurrency(dailyAverage),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold
)
}
}
}
}
/**
* 格式化货币
*/
private fun formatCurrency(amount: Double): String {
val format = NumberFormat.getCurrencyInstance(Locale.CHINA)
return format.format(amount)
}

View File

@@ -11,11 +11,24 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.dp
import com.yovinchen.bookkeeping.model.BookkeepingRecord
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.foundation.background
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.ui.graphics.graphicsLayer
import com.yovinchen.bookkeeping.model.Member
import com.yovinchen.bookkeeping.model.TransactionType
import com.yovinchen.bookkeeping.utils.IconManager
import java.text.SimpleDateFormat
import java.util.*
import androidx.compose.ui.draw.shadow
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Surface
import androidx.compose.ui.text.font.FontWeight
import com.yovinchen.bookkeeping.ui.theme.IncomeColor
import com.yovinchen.bookkeeping.ui.theme.ExpenseColor
import java.util.Locale
@Composable
fun RecordItem(
@@ -29,29 +42,108 @@ fun RecordItem(
val timeFormat = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) }
val member = members.find { it.id == record.memberId }
val categoryIcon = IconManager.getCategoryIconVector(record.category)
// 添加滑动和缩放动画状态
var offsetX by remember { mutableStateOf(0f) }
val animatedOffset by animateFloatAsState(
targetValue = offsetX,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow
)
)
var isPressed by remember { mutableStateOf(false) }
val scale by animateFloatAsState(
targetValue = if (isPressed) 0.95f else 1f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy
)
)
Card(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp)
.padding(horizontal = 0.dp, vertical = 4.dp)
.shadow(
elevation = 2.dp,
shape = RoundedCornerShape(12.dp),
clip = false
)
.graphicsLayer {
translationX = animatedOffset
scaleX = scale
scaleY = scale
}
.pointerInput(Unit) {
detectHorizontalDragGestures(
onDragStart = { isPressed = true },
onDragEnd = {
isPressed = false
if (offsetX < -100) {
showDeleteDialog = true
}
offsetX = 0f
},
onDragCancel = {
isPressed = false
offsetX = 0f
}
) { _, dragAmount ->
offsetX = (offsetX + dragAmount).coerceIn(-200f, 0f)
}
}
.clickable(onClick = onClick),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
),
elevation = CardDefaults.cardElevation(
defaultElevation = 0.dp,
pressedElevation = 4.dp
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(
if (record.type == TransactionType.INCOME) {
IncomeColor.copy(alpha = 0.05f)
} else {
ExpenseColor.copy(alpha = 0.05f)
}
)
.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
// 左侧分类图标
if (categoryIcon != null) {
Icon(
imageVector = categoryIcon,
contentDescription = record.category,
modifier = Modifier.size(24.dp),
tint = Color.Unspecified
)
Surface(
shape = RoundedCornerShape(10.dp),
color = if (record.type == TransactionType.INCOME) {
IncomeColor.copy(alpha = 0.1f)
} else {
ExpenseColor.copy(alpha = 0.1f)
},
modifier = Modifier.size(44.dp)
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxSize()
) {
if (categoryIcon != null) {
Icon(
imageVector = categoryIcon,
contentDescription = record.category,
modifier = Modifier.size(24.dp),
tint = if (record.type == TransactionType.INCOME) {
IncomeColor
} else {
ExpenseColor
}
)
}
}
}
// 中间内容区域
@@ -81,19 +173,40 @@ fun RecordItem(
}
// 右侧金额显示
Text(
text = String.format("%.2f", record.amount),
style = MaterialTheme.typography.titleMedium,
color = if (record.type == TransactionType.EXPENSE)
MaterialTheme.colorScheme.error
else
MaterialTheme.colorScheme.primary
)
Column(
horizontalAlignment = Alignment.End
) {
Text(
text = String.format(Locale.getDefault(), "%.2f", record.amount),
style = MaterialTheme.typography.titleMedium.copy(
fontWeight = FontWeight.Bold
),
color = if (record.type == TransactionType.EXPENSE)
ExpenseColor
else
IncomeColor
)
Text(
text = if (record.type == TransactionType.EXPENSE) "支出" else "收入",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
)
}
}
}
if (showDeleteDialog) {
AlertDialog(
AnimatedVisibility(
visible = showDeleteDialog,
enter = fadeIn(animationSpec = tween(300)) + scaleIn(
initialScale = 0.8f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy
)
),
exit = fadeOut(animationSpec = tween(200)) + scaleOut(targetScale = 0.8f)
) {
AlertDialog(
onDismissRequest = { showDeleteDialog = false },
title = { Text("确认删除") },
text = { Text("确定要删除这条记录吗?") },
@@ -112,6 +225,7 @@ fun RecordItem(
Text("取消")
}
}
)
)
}
}
}

View File

@@ -7,7 +7,16 @@ import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.yovinchen.bookkeeping.ui.components.AnimatedDialog
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.ui.draw.shadow
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.OutlinedTextFieldDefaults
import com.yovinchen.bookkeeping.ui.theme.ExpenseColor
import com.yovinchen.bookkeeping.ui.theme.IncomeColor
import com.yovinchen.bookkeeping.model.Category
import com.yovinchen.bookkeeping.model.Member
import com.yovinchen.bookkeeping.model.TransactionType
@@ -53,40 +62,88 @@ fun AddRecordDialog(
?: ""
}
Dialog(onDismissRequest = onDismiss) {
AnimatedDialog(
visible = true,
onDismissRequest = onDismiss
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
.padding(16.dp)
.animateContentSize(
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMedium
)
)
.shadow(
elevation = 8.dp,
shape = RoundedCornerShape(20.dp),
clip = false
),
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.animateContentSize(
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow
)
)
) {
Text(
text = "添加记录",
style = MaterialTheme.typography.titleLarge
style = MaterialTheme.typography.headlineSmall.copy(
fontWeight = FontWeight.Bold
),
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(16.dp))
// 收入/支出选择
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
AnimatedVisibility(
visible = true,
enter = fadeIn() + expandVertically()
) {
FilterChip(
selected = selectedType == TransactionType.EXPENSE,
onClick = { selectedType = TransactionType.EXPENSE },
label = { Text("支出") }
)
FilterChip(
selected = selectedType == TransactionType.INCOME,
onClick = { selectedType = TransactionType.INCOME },
label = { Text("收入") }
)
Surface(
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f),
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(4.dp),
horizontalArrangement = Arrangement.SpaceEvenly
) {
FilterChip(
selected = selectedType == TransactionType.EXPENSE,
onClick = { selectedType = TransactionType.EXPENSE },
label = { Text("支出") },
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = ExpenseColor.copy(alpha = 0.2f),
selectedLabelColor = ExpenseColor
)
)
FilterChip(
selected = selectedType == TransactionType.INCOME,
onClick = { selectedType = TransactionType.INCOME },
label = { Text("收入") },
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = IncomeColor.copy(alpha = 0.2f),
selectedLabelColor = IncomeColor
)
)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
@@ -96,7 +153,12 @@ fun AddRecordDialog(
onValueChange = { amount = it },
label = { Text("金额") },
modifier = Modifier.fillMaxWidth(),
singleLine = true
singleLine = true,
shape = RoundedCornerShape(12.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary,
unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f)
)
)
Spacer(modifier = Modifier.height(16.dp))

View File

@@ -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("取消")
}
}
)
}

View File

@@ -21,8 +21,23 @@ import com.yovinchen.bookkeeping.model.ThemeMode
import com.yovinchen.bookkeeping.ui.screen.*
import androidx.compose.material3.*
import androidx.compose.ui.unit.dp
import androidx.compose.ui.draw.shadow
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.material3.Surface
import java.time.YearMonth
import java.time.format.DateTimeFormatter
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.AnimatedContentScope
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.runtime.remember
import androidx.compose.runtime.mutableStateOf
sealed class Screen(
val route: String,
@@ -47,6 +62,10 @@ sealed class Screen(
"设置",
iconResId = R.drawable.setting
)
object Budget : Screen(
"budget",
"预算管理"
)
object CategoryDetail : Screen(
"category_detail/{category}/{startMonth}/{endMonth}",
"分类详情"
@@ -79,7 +98,7 @@ sealed class Screen(
}
}
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class)
@Composable
fun MainNavigation(
currentTheme: ThemeMode,
@@ -103,15 +122,58 @@ fun MainNavigation(
NavigationBarItem(
icon = {
screen.icon()?.let { icon ->
Icon(
imageVector = icon,
contentDescription = screen.title,
modifier = Modifier.size(24.dp),
tint = Color.Unspecified
androidx.compose.animation.AnimatedContent(
targetState = selected,
transitionSpec = {
scaleIn(
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMedium
)
) + fadeIn() togetherWith
scaleOut(
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMedium
)
) + fadeOut()
}
) { isSelected ->
Icon(
imageVector = icon,
contentDescription = screen.title,
modifier = Modifier
.size(if (isSelected) 28.dp else 24.dp)
.animateContentSize(
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMedium
)
),
tint = Color.Unspecified
)
}
}
},
label = {
AnimatedContent(
targetState = selected,
transitionSpec = {
(fadeIn(animationSpec = tween(300)) +
expandVertically(animationSpec = tween(300))) togetherWith
(fadeOut(animationSpec = tween(200)) +
shrinkVertically(animationSpec = tween(200)))
}
) { isSelected ->
Text(
text = screen.title,
style = if (isSelected)
MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold)
else
MaterialTheme.typography.labelMedium
)
}
},
label = { Text(screen.title) },
selected = selected,
onClick = {
navController.navigate(screen.route) {
@@ -130,11 +192,93 @@ fun MainNavigation(
NavHost(
navController = navController,
startDestination = Screen.Home.route,
modifier = Modifier.padding(innerPadding)
modifier = Modifier.padding(innerPadding),
enterTransition = {
slideIntoContainer(
AnimatedContentTransitionScope.SlideDirection.Left,
animationSpec = tween(300, easing = FastOutSlowInEasing)
) + fadeIn(animationSpec = tween(300))
},
exitTransition = {
slideOutOfContainer(
AnimatedContentTransitionScope.SlideDirection.Left,
animationSpec = tween(300, easing = FastOutSlowInEasing)
) + fadeOut(animationSpec = tween(200))
},
popEnterTransition = {
slideIntoContainer(
AnimatedContentTransitionScope.SlideDirection.Right,
animationSpec = tween(300, easing = FastOutSlowInEasing)
) + fadeIn(animationSpec = tween(300))
},
popExitTransition = {
slideOutOfContainer(
AnimatedContentTransitionScope.SlideDirection.Right,
animationSpec = tween(300, easing = FastOutSlowInEasing)
) + fadeOut(animationSpec = tween(200))
}
) {
composable(Screen.Home.route) { HomeScreen() }
composable(
Screen.Home.route,
enterTransition = {
when (initialState.destination.route) {
Screen.Analysis.route -> slideIntoContainer(
AnimatedContentTransitionScope.SlideDirection.Right,
animationSpec = tween(400, easing = FastOutSlowInEasing)
) + fadeIn(animationSpec = tween(400))
Screen.Settings.route -> slideIntoContainer(
AnimatedContentTransitionScope.SlideDirection.Left,
animationSpec = tween(400, easing = FastOutSlowInEasing)
) + fadeIn(animationSpec = tween(400))
else -> fadeIn(animationSpec = tween(300))
}
},
exitTransition = {
when (targetState.destination.route) {
Screen.Analysis.route -> slideOutOfContainer(
AnimatedContentTransitionScope.SlideDirection.Left,
animationSpec = tween(400, easing = FastOutSlowInEasing)
) + fadeOut(animationSpec = tween(200))
Screen.Settings.route -> slideOutOfContainer(
AnimatedContentTransitionScope.SlideDirection.Right,
animationSpec = tween(400, easing = FastOutSlowInEasing)
) + fadeOut(animationSpec = tween(200))
else -> fadeOut(animationSpec = tween(300))
}
}
) {
HomeScreen()
}
composable(Screen.Analysis.route) {
composable(
Screen.Analysis.route,
enterTransition = {
when (initialState.destination.route) {
Screen.Home.route -> slideIntoContainer(
AnimatedContentTransitionScope.SlideDirection.Left,
animationSpec = tween(400, easing = FastOutSlowInEasing)
) + fadeIn(animationSpec = tween(400))
Screen.Settings.route -> slideIntoContainer(
AnimatedContentTransitionScope.SlideDirection.Right,
animationSpec = tween(400, easing = FastOutSlowInEasing)
) + fadeIn(animationSpec = tween(400))
else -> fadeIn(animationSpec = tween(300))
}
},
exitTransition = {
when (targetState.destination.route) {
Screen.Home.route -> slideOutOfContainer(
AnimatedContentTransitionScope.SlideDirection.Right,
animationSpec = tween(400, easing = FastOutSlowInEasing)
) + fadeOut(animationSpec = tween(200))
Screen.Settings.route -> slideOutOfContainer(
AnimatedContentTransitionScope.SlideDirection.Left,
animationSpec = tween(400, easing = FastOutSlowInEasing)
) + fadeOut(animationSpec = tween(200))
else -> fadeOut(animationSpec = tween(300))
}
}
) {
AnalysisScreen(
onNavigateToCategoryDetail = { category, startMonth, endMonth ->
navController.navigate(Screen.CategoryDetail.createRoute(category, startMonth, endMonth))
@@ -145,12 +289,47 @@ fun MainNavigation(
)
}
composable(Screen.Settings.route) {
composable(
Screen.Settings.route,
enterTransition = {
when (initialState.destination.route) {
Screen.Home.route -> slideIntoContainer(
AnimatedContentTransitionScope.SlideDirection.Right,
animationSpec = tween(400, easing = FastOutSlowInEasing)
) + fadeIn(animationSpec = tween(400))
Screen.Analysis.route -> slideIntoContainer(
AnimatedContentTransitionScope.SlideDirection.Left,
animationSpec = tween(400, easing = FastOutSlowInEasing)
) + fadeIn(animationSpec = tween(400))
else -> fadeIn(animationSpec = tween(300))
}
},
exitTransition = {
when (targetState.destination.route) {
Screen.Home.route -> slideOutOfContainer(
AnimatedContentTransitionScope.SlideDirection.Left,
animationSpec = tween(400, easing = FastOutSlowInEasing)
) + fadeOut(animationSpec = tween(200))
Screen.Analysis.route -> slideOutOfContainer(
AnimatedContentTransitionScope.SlideDirection.Right,
animationSpec = tween(400, easing = FastOutSlowInEasing)
) + fadeOut(animationSpec = tween(200))
else -> fadeOut(animationSpec = tween(300))
}
}
) {
SettingsScreen(
currentTheme = currentTheme,
onThemeChange = onThemeChange
onThemeChange = onThemeChange,
onNavigateToBudget = {
navController.navigate(Screen.Budget.route)
}
)
}
composable(Screen.Budget.route) {
BudgetScreen()
}
composable(
route = Screen.CategoryDetail.route,

View File

@@ -32,18 +32,24 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.lazy.itemsIndexed
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.DateRangePicker
import com.yovinchen.bookkeeping.ui.components.DetailedAnalysisReport
import com.yovinchen.bookkeeping.ui.components.MonthlyYearlyReport
import com.yovinchen.bookkeeping.ui.components.TrendLineChart
import com.yovinchen.bookkeeping.viewmodel.AnalysisViewModel
import java.time.YearMonth
enum class ViewMode {
CATEGORY, MEMBER
CATEGORY, MEMBER, REPORT
}
@OptIn(ExperimentalMaterial3Api::class)
@@ -93,7 +99,13 @@ fun AnalysisScreen(
Button(
onClick = { showViewModeMenu = true }
) {
Text(if (currentViewMode == ViewMode.CATEGORY) "分类" else "成员")
Text(
when {
currentViewMode == ViewMode.CATEGORY -> "分类"
currentViewMode == ViewMode.MEMBER -> "成员"
else -> "报表"
}
)
Icon(Icons.Default.ArrowDropDown, "切换视图")
}
DropdownMenu(
@@ -114,6 +126,13 @@ fun AnalysisScreen(
showViewModeMenu = false
}
)
DropdownMenuItem(
text = { Text("报表") },
onClick = {
currentViewMode = ViewMode.REPORT
showViewModeMenu = false
}
)
}
}
@@ -147,7 +166,16 @@ fun AnalysisScreen(
AnalysisType.TREND -> {
// 趋势视图
item {
if (records.isNotEmpty()) {
AnimatedVisibility(
visible = records.isNotEmpty(),
enter = fadeIn(animationSpec = tween(500)) + expandVertically(
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow
)
),
exit = fadeOut() + shrinkVertically()
) {
TrendLineChart(
records = records,
modifier = Modifier
@@ -159,41 +187,80 @@ fun AnalysisScreen(
}
}
else -> {
// 饼图视图
item {
CategoryPieChart(
categoryData = categoryStats.map { Pair(it.category, it.percentage.toFloat()) },
memberData = memberStats.map { Pair(it.member, it.percentage.toFloat()) },
currentViewMode = currentViewMode == ViewMode.MEMBER,
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
.padding(bottom = 16.dp),
onCategoryClick = { category ->
if (currentViewMode == ViewMode.CATEGORY) {
onNavigateToCategoryDetail(category, startMonth, endMonth)
} else {
onNavigateToMemberDetail(category, startMonth, endMonth, selectedAnalysisType)
if (currentViewMode == ViewMode.REPORT) {
// 报表视图
item {
MonthlyYearlyReport(
records = records,
period = if (startMonth == endMonth) "月度" else "年度",
startMonth = startMonth,
endMonth = endMonth,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
)
}
// 详细分析报表
item {
DetailedAnalysisReport(
records = records,
startMonth = startMonth,
endMonth = endMonth,
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp)
)
}
} else {
// 饼图视图
item {
AnimatedVisibility(
visible = true,
enter = fadeIn(animationSpec = tween(500)) + scaleIn(
initialScale = 0.8f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow
)
),
exit = fadeOut() + scaleOut()
) {
CategoryPieChart(
categoryData = categoryStats.map { Pair(it.category, it.percentage.toFloat()) },
memberData = memberStats.map { Pair(it.member, it.percentage.toFloat()) },
currentViewMode = currentViewMode == ViewMode.MEMBER,
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
.padding(bottom = 16.dp),
onCategoryClick = { category ->
if (currentViewMode == ViewMode.CATEGORY) {
onNavigateToCategoryDetail(category, startMonth, endMonth)
} else {
onNavigateToMemberDetail(category, startMonth, endMonth, selectedAnalysisType)
}
}
)
}
)
}
}
// 统计列表
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
// 统计列表
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 && category != null) {
onNavigateToCategoryDetail(category, startMonth, endMonth)
} else if (currentViewMode == ViewMode.MEMBER && member != null) {
onNavigateToMemberDetail(member, startMonth, endMonth, selectedAnalysisType)
CategoryStatItem(
stat = stat,
onClick = {
if (currentViewMode == ViewMode.CATEGORY && category != null) {
onNavigateToCategoryDetail(category, startMonth, endMonth)
} else if (currentViewMode == ViewMode.MEMBER && member != null) {
onNavigateToMemberDetail(member, startMonth, endMonth, selectedAnalysisType)
}
}
}
)
)
}
}
}
}

View File

@@ -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)
}

View File

@@ -1,14 +1,21 @@
package com.yovinchen.bookkeeping.ui.screen
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.foundation.background
import androidx.compose.ui.draw.shadow
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.material3.Surface
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
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.PaddingValues
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
@@ -30,6 +37,10 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.ui.Alignment
import com.yovinchen.bookkeeping.model.BookkeepingRecord
import com.yovinchen.bookkeeping.model.TransactionType
import com.yovinchen.bookkeeping.ui.components.MonthlyStatistics
@@ -39,6 +50,21 @@ import com.yovinchen.bookkeeping.ui.dialog.RecordEditDialog
import com.yovinchen.bookkeeping.viewmodel.HomeViewModel
import java.text.SimpleDateFormat
import java.util.Locale
import androidx.compose.foundation.clickable
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.ui.unit.Dp
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.windowInsetsPadding
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -55,87 +81,214 @@ fun HomeScreen(
val members by viewModel.members.collectAsState(initial = emptyList())
val totalIncome by viewModel.totalIncome.collectAsState()
val totalExpense by viewModel.totalExpense.collectAsState()
// 获取屏幕高度
val configuration = LocalConfiguration.current
val screenHeight = configuration.screenHeightDp.dp
val headerHeight = screenHeight * 0.2f // 20% 的屏幕高度
// LazyColumn 滑动状态
val listState = rememberLazyListState()
// 计算滑动偏移量
val scrollOffset by remember {
derivedStateOf {
if (listState.firstVisibleItemIndex > 0) {
1f // 完全收起
} else {
val offset = listState.firstVisibleItemScrollOffset.toFloat()
(offset / headerHeight.value).coerceIn(0f, 1f)
}
}
}
// 动画化的偏移量
val animatedScrollOffset by animateFloatAsState(
targetValue = scrollOffset,
animationSpec = tween(
durationMillis = 100,
easing = LinearEasing
)
)
Scaffold(
modifier = modifier.fillMaxSize(),
floatingActionButton = {
ExtendedFloatingActionButton(
onClick = { showAddDialog = true },
icon = { Icon(Icons.Default.Add, contentDescription = null) },
text = { Text("记一笔") }
)
AnimatedVisibility(
visible = true,
enter = scaleIn(
initialScale = 0f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow
)
),
exit = scaleOut(targetScale = 0f)
) {
ExtendedFloatingActionButton(
onClick = { showAddDialog = true },
icon = { Icon(Icons.Default.Add, contentDescription = null) },
text = { Text("记一笔") }
)
}
}
) { padding ->
Column(
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.background(MaterialTheme.colorScheme.background)
) {
// 顶部统计信息
MonthlyStatistics(
totalIncome = totalIncome,
totalExpense = totalExpense,
selectedType = null,
onIncomeClick = { viewModel.setSelectedRecordType(TransactionType.INCOME) },
onExpenseClick = { viewModel.setSelectedRecordType(TransactionType.EXPENSE) },
onClearFilter = { viewModel.setSelectedRecordType(null) },
selectedMonth = selectedMonth,
onPreviousMonth = { viewModel.moveMonth(false) },
onNextMonth = { viewModel.moveMonth(true) },
onMonthSelected = { viewModel.setSelectedMonth(it) }
)
// 记录列表
// 记录列表(背景层)
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
contentPadding = PaddingValues(
top = headerHeight + 16.dp,
start = 16.dp,
end = 16.dp,
bottom = 16.dp
),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(filteredRecords.size) { index ->
val (date, dayRecords) = filteredRecords.toList()[index]
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
itemsIndexed(
items = filteredRecords.toList(),
key = { _, (date, _) -> date }
) { index, (date, dayRecords) ->
val animationDelay = remember { (index * 30).coerceAtMost(300) }
var isVisible by remember { mutableStateOf(false) }
LaunchedEffect(key1 = date) {
delay(animationDelay.toLong())
isVisible = true
}
AnimatedVisibility(
visible = isVisible,
enter = fadeIn(
animationSpec = tween(
durationMillis = 400,
easing = FastOutSlowInEasing
)
) + slideInVertically(
initialOffsetY = { it / 4 },
animationSpec = spring(
dampingRatio = Spring.DampingRatioLowBouncy,
stiffness = Spring.StiffnessMediumLow
)
) + scaleIn(
initialScale = 0.85f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMedium
)
),
exit = fadeOut(
animationSpec = tween(200)
) + slideOutVertically(
targetOffsetY = { -it / 4 },
animationSpec = tween(200)
)
) {
Column(
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
// 日期标签
Text(
text = SimpleDateFormat(
"yyyy年MM月dd日 E",
Locale.CHINESE
).format(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 { recordIndex, record ->
RecordItem(
record = record,
onClick = { selectedRecord = record },
onDelete = { viewModel.deleteRecord(record) },
members = members
.padding(vertical = 4.dp)
.graphicsLayer {
shadowElevation = 8.dp.toPx()
}
.animateContentSize(
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMedium
)
if (recordIndex < dayRecords.size - 1) {
HorizontalDivider(
modifier = Modifier.padding(vertical = 4.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
thickness = 0.5.dp
),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
),
elevation = CardDefaults.cardElevation(
defaultElevation = 4.dp
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(
brush = Brush.verticalGradient(
colors = listOf(
MaterialTheme.colorScheme.surface,
MaterialTheme.colorScheme.surface.copy(alpha = 0.95f)
)
)
)
.padding(20.dp)
) {
// 日期标签
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Surface(
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.primaryContainer,
modifier = Modifier.padding(end = 8.dp)
) {
Text(
text = SimpleDateFormat(
"dd",
Locale.CHINESE
).format(date),
style = MaterialTheme.typography.titleLarge.copy(
fontWeight = FontWeight.Bold
),
color = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)
)
}
Column {
Text(
text = SimpleDateFormat(
"yyyy年MM月",
Locale.CHINESE
).format(date),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = SimpleDateFormat(
"EEEE",
Locale.CHINESE
).format(date),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
)
}
}
Spacer(modifier = Modifier.height(8.dp))
// 当天的记录
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
dayRecords.forEachIndexed { recordIndex, record ->
RecordItem(
record = record,
onClick = { selectedRecord = record },
onDelete = { viewModel.deleteRecord(record) },
members = members
)
if (recordIndex < dayRecords.size - 1) {
HorizontalDivider(
modifier = Modifier.padding(vertical = 4.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
thickness = 0.5.dp
)
}
}
}
}
@@ -143,6 +296,35 @@ fun HomeScreen(
}
}
}
// 顶部统计信息(悬浮层)
Box(
modifier = Modifier
.fillMaxWidth()
.height(headerHeight)
.graphicsLayer {
alpha = 1f - animatedScrollOffset * 0.3f // 淡出效果更柔和
translationY = -animatedScrollOffset * headerHeight.value * 0.5f
scaleY = 1f - animatedScrollOffset * 0.5f
transformOrigin = androidx.compose.ui.graphics.TransformOrigin(0.5f, 0f)
}
.background(MaterialTheme.colorScheme.background)
) {
if (animatedScrollOffset < 0.9f) {
MonthlyStatistics(
totalIncome = totalIncome,
totalExpense = totalExpense,
selectedType = null,
onIncomeClick = { viewModel.setSelectedRecordType(TransactionType.INCOME) },
onExpenseClick = { viewModel.setSelectedRecordType(TransactionType.EXPENSE) },
onClearFilter = { viewModel.setSelectedRecordType(null) },
selectedMonth = selectedMonth,
onPreviousMonth = { viewModel.moveMonth(false) },
onNextMonth = { viewModel.moveMonth(true) },
onMonthSelected = { viewModel.setSelectedMonth(it) }
)
}
}
}
}
@@ -172,4 +354,4 @@ fun HomeScreen(
}
)
}
}
}

View File

@@ -13,24 +13,32 @@ import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import com.yovinchen.bookkeeping.model.Settings
import androidx.compose.ui.draw.shadow
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.ui.draw.scale
import androidx.compose.ui.text.font.FontWeight
import com.yovinchen.bookkeeping.model.ThemeMode
import com.yovinchen.bookkeeping.ui.components.*
import com.yovinchen.bookkeeping.ui.dialog.*
import com.yovinchen.bookkeeping.utils.FilePickerUtil
import com.yovinchen.bookkeeping.viewmodel.*
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
currentTheme: ThemeMode,
onThemeChange: (ThemeMode) -> Unit,
onNavigateToBudget: () -> Unit = {},
viewModel: SettingsViewModel = viewModel(),
memberViewModel: MemberViewModel = viewModel()
) {
@@ -49,58 +57,350 @@ fun SettingsScreen(
var showMonthStartDayDialog by remember { mutableStateOf(false) }
Column(modifier = Modifier.fillMaxSize()) {
// 成员管理设置项
ListItem(
headlineContent = { Text("成员管理") },
supportingContent = { Text("管理账本成员") },
modifier = Modifier.clickable { showMemberDialog = true }
)
HorizontalDivider()
// 类别管理设置项
ListItem(
headlineContent = { Text("类别管理") },
supportingContent = { Text("管理收入和支出类别") },
modifier = Modifier.clickable { showCategoryDialog = true }
)
HorizontalDivider()
// 数据备份设置项
ListItem(
headlineContent = { Text("数据备份") },
supportingContent = { Text("备份和恢复数据") },
modifier = Modifier.clickable { showBackupDialog = true }
)
HorizontalDivider()
// 主题设置项
ListItem(
headlineContent = { Text("主题设置") },
supportingContent = {
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.animateContentSize(
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow
)
)
) {
// 设置页面标题
Surface(
modifier = Modifier
.fillMaxWidth()
.shadow(
elevation = 4.dp,
shape = RoundedCornerShape(bottomStart = 20.dp, bottomEnd = 20.dp),
clip = false
),
shape = RoundedCornerShape(bottomStart = 20.dp, bottomEnd = 20.dp),
color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 24.dp),
contentAlignment = Alignment.Center
) {
Text(
when (currentTheme) {
is ThemeMode.FOLLOW_SYSTEM -> "跟随系统"
is ThemeMode.LIGHT -> "浅色"
is ThemeMode.DARK -> "深色"
is ThemeMode.CUSTOM -> "自定义颜色"
text = "设置",
style = MaterialTheme.typography.headlineMedium.copy(
fontWeight = FontWeight.Bold
),
color = MaterialTheme.colorScheme.onSurface
)
}
}
Spacer(modifier = Modifier.height(16.dp))
// 成员管理设置项
AnimatedVisibility(
visible = true,
enter = fadeIn(animationSpec = tween(300)) + slideInVertically(
initialOffsetY = { -40 },
animationSpec = tween(300)
),
exit = fadeOut() + slideOutVertically()
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp)
.clickable { showMemberDialog = true },
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 2.dp
) {
ListItem(
headlineContent = {
Text(
"成员管理",
style = MaterialTheme.typography.titleMedium
)
},
supportingContent = {
Text(
"管理账本成员",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
},
leadingContent = {
Icon(
imageVector = Icons.Default.Group,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
},
trailingContent = {
Icon(
imageVector = Icons.Default.ChevronRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
)
},
modifier = Modifier.clickable { showThemeDialog = true }
)
}
}
// 类别管理设置项
AnimatedVisibility(
visible = true,
enter = fadeIn(animationSpec = tween(300, delayMillis = 50)) + slideInVertically(
initialOffsetY = { -40 },
animationSpec = tween(300, delayMillis = 50)
),
exit = fadeOut() + slideOutVertically()
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp)
.clickable { showCategoryDialog = true },
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 2.dp
) {
ListItem(
headlineContent = {
Text(
"类别管理",
style = MaterialTheme.typography.titleMedium
)
},
supportingContent = {
Text(
"管理收入和支出类别",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
},
leadingContent = {
Icon(
imageVector = Icons.Default.Category,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
},
trailingContent = {
Icon(
imageVector = Icons.Default.ChevronRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
)
}
}
// 数据备份设置项
AnimatedVisibility(
visible = true,
enter = fadeIn(animationSpec = tween(300, delayMillis = 100)) + slideInVertically(
initialOffsetY = { -40 },
animationSpec = tween(300, delayMillis = 100)
),
exit = fadeOut() + slideOutVertically()
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp)
.clickable { showBackupDialog = true },
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 2.dp
) {
ListItem(
headlineContent = {
Text(
"数据备份",
style = MaterialTheme.typography.titleMedium
)
},
supportingContent = {
Text(
"备份和恢复数据",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
},
leadingContent = {
Icon(
imageVector = Icons.Default.Backup,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
},
trailingContent = {
Icon(
imageVector = Icons.Default.ChevronRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
)
}
}
HorizontalDivider()
// 预算管理设置项
AnimatedVisibility(
visible = true,
enter = fadeIn(animationSpec = tween(300, delayMillis = 150)) + slideInVertically(
initialOffsetY = { -40 },
animationSpec = tween(300, delayMillis = 150)
),
exit = fadeOut() + slideOutVertically()
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp)
.clickable { onNavigateToBudget() },
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 2.dp
) {
ListItem(
headlineContent = {
Text(
"预算管理",
style = MaterialTheme.typography.titleMedium
)
},
supportingContent = {
Text(
"设置和管理预算",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
},
leadingContent = {
Icon(
imageVector = Icons.Default.AccountBalance,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
},
trailingContent = {
Icon(
imageVector = Icons.Default.ChevronRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
)
}
}
// 主题设置项
AnimatedVisibility(
visible = true,
enter = fadeIn(animationSpec = tween(300, delayMillis = 200)) + slideInVertically(
initialOffsetY = { -40 },
animationSpec = tween(300, delayMillis = 200)
),
exit = fadeOut() + slideOutVertically()
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp)
.clickable { showThemeDialog = true },
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 2.dp
) {
ListItem(
headlineContent = {
Text(
"主题设置",
style = MaterialTheme.typography.titleMedium
)
},
supportingContent = {
Text(
when (currentTheme) {
is ThemeMode.FOLLOW_SYSTEM -> "跟随系统"
is ThemeMode.LIGHT -> "浅色"
is ThemeMode.DARK -> "深色"
is ThemeMode.CUSTOM -> "自定义颜色"
},
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
},
leadingContent = {
Icon(
imageVector = Icons.Default.Palette,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
},
trailingContent = {
Icon(
imageVector = Icons.Default.ChevronRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
)
}
}
// 月度开始日期设置项
ListItem(
headlineContent = { Text("月度开始日期") },
supportingContent = { Text("每月从${monthStartDay}号开始计算") },
modifier = Modifier.clickable { showMonthStartDayDialog = true }
)
AnimatedVisibility(
visible = true,
enter = fadeIn(animationSpec = tween(300, delayMillis = 250)) + slideInVertically(
initialOffsetY = { -40 },
animationSpec = tween(300, delayMillis = 250)
),
exit = fadeOut() + slideOutVertically()
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp)
.clickable { showMonthStartDayDialog = true },
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 2.dp
) {
ListItem(
headlineContent = {
Text(
"月度开始日期",
style = MaterialTheme.typography.titleMedium
)
},
supportingContent = {
Text(
"每月从${monthStartDay}号开始计算",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
},
leadingContent = {
Icon(
imageVector = Icons.Default.CalendarMonth,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
},
trailingContent = {
Icon(
imageVector = Icons.Default.ChevronRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
)
}
}
if (showThemeDialog) {
AlertDialog(
@@ -210,7 +510,16 @@ fun SettingsScreen(
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxSize()
modifier = Modifier
.fillMaxSize()
.scale(
animateFloatAsState(
targetValue = if (day == monthStartDay) 1.1f else 1f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy
)
).value
)
) {
Text(
text = day.toString(),

View File

@@ -2,16 +2,59 @@ package com.yovinchen.bookkeeping.ui.theme
import androidx.compose.ui.graphics.Color
// Dark Theme Colors
val DarkPrimary = Color(0xFF9B7EE3) // 深紫色
val DarkSecondary = Color(0xFF6C5B7B) // 暗紫灰色
val DarkBackground = Color(0xFF121212) // 深黑色
val DarkSurface = Color(0xFF1E1E1E) // 深灰色
val DarkError = Color(0xFFCF6679) // 深红色
// Modern Color Palette - 现代色彩调色板
// Light Theme Colors
val LightPrimary = Color(0xFF6200EE) // 紫色
val LightSecondary = Color(0xFF8E8E93) // 浅灰
val LightBackground = Color(0xFFF5F5F5) // 浅灰白色
val LightSurface = Color(0xFFFFFFFF) // 纯白色
val LightError = Color(0xFFB00020) // 红色
// Primary Colors - 主色调
val PrimaryLight = Color(0xFF6366F1) // 优雅的蓝紫色
val PrimaryDark = Color(0xFF818CF8) // 明亮的蓝紫
val PrimaryContainer = Color(0xFFE0E7FF) // 浅蓝紫色背景
// Secondary Colors - 辅助色
val SecondaryLight = Color(0xFF10B981) // 翠绿色 - 收入
val SecondaryDark = Color(0xFF34D399) // 明亮翠绿色
// Tertiary Colors - 第三色彩
val TertiaryLight = Color(0xFFF59E0B) // 琥珀色 - 结余
val TertiaryDark = Color(0xFFFBBF24) // 明亮琥珀色
// Error Colors - 错误/支出色
val ErrorLight = Color(0xFFEF4444) // 现代红色 - 支出
val ErrorDark = Color(0xFFF87171) // 明亮红色
// Background Colors - 背景色
val BackgroundLight = Color(0xFFFAFAFA) // 非常浅的灰色
val BackgroundDark = Color(0xFF0F172A) // 深蓝灰色
val SurfaceLight = Color(0xFFFFFFFF) // 纯白色
val SurfaceDark = Color(0xFF1E293B) // 深蓝灰色
// Surface Variants - 表面变体
val SurfaceVariantLight = Color(0xFFF8FAFC) // 微灰色
val SurfaceVariantDark = Color(0xFF334155) // 中灰蓝色
// On Colors - 内容色
val OnPrimaryLight = Color(0xFFFFFFFF)
val OnPrimaryDark = Color(0xFF1E293B)
val OnSecondaryLight = Color(0xFFFFFFFF)
val OnSecondaryDark = Color(0xFF064E3B)
val OnBackgroundLight = Color(0xFF1F2937)
val OnBackgroundDark = Color(0xFFF1F5F9)
val OnSurfaceLight = Color(0xFF1F2937)
val OnSurfaceDark = Color(0xFFF1F5F9)
// Divider Colors - 分割线色
val DividerLight = Color(0xFFE5E7EB)
val DividerDark = Color(0xFF475569)
// Special Colors - 特殊色彩
val IncomeColor = Color(0xFF10B981) // 收入绿色
val ExpenseColor = Color(0xFFEF4444) // 支出红色
val BalancePositive = Color(0xFFF59E0B) // 正结余琥珀色
val BalanceNegative = Color(0xFF7C3AED) // 负结余紫色
// Card Colors - 卡片颜色
val CardLight = Color(0xFFFFFFFF)
val CardDark = Color(0xFF1E293B)
// Gradient Colors - 渐变色
val GradientStart = Color(0xFF6366F1)
val GradientEnd = Color(0xFF8B5CF6)

View File

@@ -16,31 +16,72 @@ import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
import com.yovinchen.bookkeeping.ui.theme.*
private val DarkColorScheme = darkColorScheme(
primary = DarkPrimary,
secondary = DarkSecondary,
background = DarkBackground,
surface = DarkSurface,
error = DarkError,
onPrimary = Color.White,
onSecondary = Color.White,
onBackground = Color.White,
onSurface = Color.White,
onError = Color.Black
primary = PrimaryDark,
onPrimary = OnPrimaryDark,
primaryContainer = PrimaryDark.copy(alpha = 0.12f),
onPrimaryContainer = PrimaryDark,
secondary = SecondaryDark,
onSecondary = OnSecondaryDark,
secondaryContainer = SecondaryDark.copy(alpha = 0.12f),
onSecondaryContainer = SecondaryDark,
tertiary = TertiaryDark,
onTertiary = Color.Black,
tertiaryContainer = TertiaryDark.copy(alpha = 0.12f),
onTertiaryContainer = TertiaryDark,
background = BackgroundDark,
onBackground = OnBackgroundDark,
surface = SurfaceDark,
onSurface = OnSurfaceDark,
surfaceVariant = SurfaceVariantDark,
onSurfaceVariant = OnSurfaceDark.copy(alpha = 0.7f),
error = ErrorDark,
onError = Color.Black,
errorContainer = ErrorDark.copy(alpha = 0.12f),
onErrorContainer = ErrorDark,
outline = DividerDark,
outlineVariant = DividerDark.copy(alpha = 0.5f)
)
private val LightColorScheme = lightColorScheme(
primary = LightPrimary,
secondary = LightSecondary,
background = LightBackground,
surface = LightSurface,
error = LightError,
onPrimary = Color.White,
onSecondary = Color.White,
onBackground = Color.Black,
onSurface = Color.Black,
onError = Color.White
primary = PrimaryLight,
onPrimary = OnPrimaryLight,
primaryContainer = PrimaryContainer,
onPrimaryContainer = PrimaryLight,
secondary = SecondaryLight,
onSecondary = OnSecondaryLight,
secondaryContainer = SecondaryLight.copy(alpha = 0.12f),
onSecondaryContainer = SecondaryLight,
tertiary = TertiaryLight,
onTertiary = Color.White,
tertiaryContainer = TertiaryLight.copy(alpha = 0.12f),
onTertiaryContainer = TertiaryLight,
background = BackgroundLight,
onBackground = OnBackgroundLight,
surface = SurfaceLight,
onSurface = OnSurfaceLight,
surfaceVariant = SurfaceVariantLight,
onSurfaceVariant = OnSurfaceLight.copy(alpha = 0.7f),
error = ErrorLight,
onError = Color.White,
errorContainer = ErrorLight.copy(alpha = 0.12f),
onErrorContainer = ErrorLight,
outline = DividerLight,
outlineVariant = DividerLight.copy(alpha = 0.5f)
)
@Composable
@@ -63,7 +104,7 @@ fun BookkeepingTheme(
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
window.statusBarColor = colorScheme.primary.toArgb()
window.statusBarColor = colorScheme.background.toArgb()
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
}
}

View File

@@ -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()
}
}
}