From 45b448ee572992819914fea826ebe433e2588a3b Mon Sep 17 00:00:00 2001 From: yovinchen Date: Wed, 23 Jul 2025 00:05:55 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=8A=A8=E7=94=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 29 +- .../ui/components/MonthlyStatistics.kt | 336 +++++++++++--- .../bookkeeping/ui/components/RecordItem.kt | 154 ++++++- .../bookkeeping/ui/dialog/AddRecordDialog.kt | 100 ++++- .../ui/navigation/MainNavigation.kt | 190 +++++++- .../bookkeeping/ui/screen/AnalysisScreen.kt | 29 +- .../bookkeeping/ui/screen/HomeScreen.kt | 308 ++++++++++--- .../bookkeeping/ui/screen/SettingsScreen.kt | 417 +++++++++++++++--- .../yovinchen/bookkeeping/ui/theme/Color.kt | 67 ++- .../yovinchen/bookkeeping/ui/theme/Theme.kt | 83 +++- 10 files changed, 1431 insertions(+), 282 deletions(-) diff --git a/README.md b/README.md index 622fd46..06ebc29 100644 --- a/README.md +++ b/README.md @@ -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 (开发中) - 预算管理功能 - 预算数据模型设计 diff --git a/app/src/main/java/com/yovinchen/bookkeeping/ui/components/MonthlyStatistics.kt b/app/src/main/java/com/yovinchen/bookkeeping/ui/components/MonthlyStatistics.kt index fcd1e3e..825512d 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/ui/components/MonthlyStatistics.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/ui/components/MonthlyStatistics.kt @@ -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 + ) + } } } diff --git a/app/src/main/java/com/yovinchen/bookkeeping/ui/components/RecordItem.kt b/app/src/main/java/com/yovinchen/bookkeeping/ui/components/RecordItem.kt index ec9bb5f..e0abf7b 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/ui/components/RecordItem.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/ui/components/RecordItem.kt @@ -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("取消") } } - ) + ) + } } } diff --git a/app/src/main/java/com/yovinchen/bookkeeping/ui/dialog/AddRecordDialog.kt b/app/src/main/java/com/yovinchen/bookkeeping/ui/dialog/AddRecordDialog.kt index 68dd031..44a93ec 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/ui/dialog/AddRecordDialog.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/ui/dialog/AddRecordDialog.kt @@ -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)) diff --git a/app/src/main/java/com/yovinchen/bookkeeping/ui/navigation/MainNavigation.kt b/app/src/main/java/com/yovinchen/bookkeeping/ui/navigation/MainNavigation.kt index 4c7fd9f..8801d62 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/ui/navigation/MainNavigation.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/ui/navigation/MainNavigation.kt @@ -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, diff --git a/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/AnalysisScreen.kt b/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/AnalysisScreen.kt index 06328e2..5047d4f 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/AnalysisScreen.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/AnalysisScreen.kt @@ -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( } } ) + } } // 统计列表 diff --git a/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/HomeScreen.kt b/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/HomeScreen.kt index 786675c..af9fe4c 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/HomeScreen.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/HomeScreen.kt @@ -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( } ) } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/SettingsScreen.kt b/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/SettingsScreen.kt index b5e8805..5149f24 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/SettingsScreen.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/SettingsScreen.kt @@ -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(), diff --git a/app/src/main/java/com/yovinchen/bookkeeping/ui/theme/Color.kt b/app/src/main/java/com/yovinchen/bookkeeping/ui/theme/Color.kt index dcab4be..e393d09 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/ui/theme/Color.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/ui/theme/Color.kt @@ -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) // 红色 \ No newline at end of file +// 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) \ No newline at end of file diff --git a/app/src/main/java/com/yovinchen/bookkeeping/ui/theme/Theme.kt b/app/src/main/java/com/yovinchen/bookkeeping/ui/theme/Theme.kt index f734c3b..793e2ab 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/ui/theme/Theme.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/ui/theme/Theme.kt @@ -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 } }