2 Commits

Author SHA1 Message Date
0187517099 优化动画 2025-07-23 00:05:59 +08:00
45b448ee57 优化动画 2025-07-23 00:05:55 +08:00
11 changed files with 1542 additions and 282 deletions

View File

@@ -27,6 +27,13 @@
- **Material 3**:遵循最新设计规范
- **深色模式**:支持系统主题切换
- **响应式布局**:适配不同屏幕尺寸
- **流畅动画**:优化页面切换和交互动效
### 🚀 最新优化
- **智能收起式统计栏**:主页统计信息随滑动自动收起,释放更多空间
- **优化数字显示**:支持十万级金额智能格式化(使用"万"为单位)
- **增强动画效果**:导航切换、列表加载、对话框显示均有流畅动画
- **改进布局设计**统计栏占页面20%,自适应不同屏幕尺寸
## ⭐️ 主要特性
@@ -102,11 +109,14 @@
- [x] 成员预算管理
- [ ] 预算分析报告
### 6. 体验优化 (持续进行 🔄)
### 6. 体验优化 (进行中 🚀)
- [x] 深色模式支持
- [x] 流畅页面动画
- [x] 智能收起式统计栏
- [x] 优化数字显示格式
- [ ] 手势操作优化
- [ ] 多语言支持
- [ ] 自定义主题
- [x] 自定义主题
### 7. 性能提升 (持续进行 ⚡️)
- [ ] 大数据量处理优化
@@ -144,6 +154,21 @@
## 📝 版本历史
### v1.6 (开发中)
- 用户体验优化
- 智能收起式统计栏占页面20%,随滑动自动收起)
- 优化数字显示(十万以上使用"万"为单位)
- 增强动画效果
- 导航栏切换动画(图标缩放、文字样式变化)
- 页面切换动画(方向感知的滑动效果)
- 列表项渐进式加载动画
- 对话框弹出动画AnimatedDialog组件
- 布局响应式优化
- 细节改进
- 修复RecordEditDialog参数问题
- 优化MonthlyStatistics布局间距
- 改进图标大小和间距
### v1.5 (开发中)
- 预算管理功能
- 预算数据模型设计

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

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

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

@@ -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,
@@ -83,7 +98,7 @@ sealed class Screen(
}
}
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class)
@Composable
fun MainNavigation(
currentTheme: ThemeMode,
@@ -107,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) {
@@ -134,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))
@@ -149,7 +289,35 @@ 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,

View File

@@ -32,6 +32,10 @@ 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
@@ -162,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
@@ -202,7 +215,18 @@ fun AnalysisScreen(
} else {
// 饼图视图
item {
CategoryPieChart(
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,
@@ -218,6 +242,7 @@ fun AnalysisScreen(
}
}
)
}
}
// 统计列表

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,20 +13,27 @@ 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,
@@ -50,69 +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 = { Text("设置和管理预算") },
modifier = Modifier.clickable {
onNavigateToBudget()
}
)
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(
@@ -222,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
}
}