优化动画
This commit is contained in:
29
README.md
29
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 (开发中)
|
||||
- 预算管理功能
|
||||
- 预算数据模型设计
|
||||
|
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -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("取消")
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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))
|
||||
|
@@ -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,
|
||||
|
@@ -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(
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 统计列表
|
||||
|
@@ -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(
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@@ -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(),
|
||||
|
@@ -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)
|
@@ -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
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user