From b12206dc887c1d79fc36a14dff660cbd692799e8 Mon Sep 17 00:00:00 2001 From: xfy Date: Mon, 1 Jun 2026 14:12:41 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=97=A5=E6=9C=9F=E6=A3=80=E6=9F=A5?= =?UTF-8?q?=E5=99=A8=20UI=20=E6=94=B9=E7=89=88=EF=BC=8C=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E8=BF=87=E6=9C=9F=E7=8A=B6=E6=80=81=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 生产日期改为渐变卡片展示,支持点击切换日期 - 保质期条目改为 ExpiryCard,显示过期状态标签 - 新增 ExpiryStatus 枚举(SAFE/WARNING/URGENT/EXPIRED) - 新增 ArrowRightIcon 自定义图标 - 添加中文日期格式化辅助函数 - 改进 TopAppBar 和 FAB 样式 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plus/rua/project/ui/DateCheckerScreen.kt | 517 ++++++++++++------ 1 file changed, 357 insertions(+), 160 deletions(-) diff --git a/core/src/main/kotlin/plus/rua/project/ui/DateCheckerScreen.kt b/core/src/main/kotlin/plus/rua/project/ui/DateCheckerScreen.kt index 5412f1a..7ebcaa4 100644 --- a/core/src/main/kotlin/plus/rua/project/ui/DateCheckerScreen.kt +++ b/core/src/main/kotlin/plus/rua/project/ui/DateCheckerScreen.kt @@ -2,11 +2,12 @@ package plus.rua.project.ui -import androidx.compose.foundation.Canvas import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box 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 @@ -14,6 +15,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape @@ -23,7 +25,6 @@ import androidx.compose.material3.DatePicker import androidx.compose.material3.DatePickerDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FloatingActionButton -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField @@ -33,6 +34,7 @@ import androidx.compose.material3.SwipeToDismissBoxValue import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberDatePickerState import androidx.compose.material3.rememberSwipeToDismissBoxState import androidx.compose.runtime.Composable @@ -45,11 +47,13 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.testTagsAsResourceId -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp @@ -71,11 +75,33 @@ private sealed class DatePickerTarget { data class Row(val rowId: Int) : DatePickerTarget() } +private enum class ExpiryStatus { + UNKNOWN, SAFE, WARNING, URGENT, EXPIRED +} + +@Composable +private fun ExpiryStatus.color(): Color = when (this) { + ExpiryStatus.SAFE -> Color(0xFF059669) + ExpiryStatus.WARNING -> Color(0xFFD97706) + ExpiryStatus.URGENT -> Color(0xFFEA580C) + ExpiryStatus.EXPIRED -> Color(0xFFDC2626) + ExpiryStatus.UNKNOWN -> MaterialTheme.colorScheme.outline +} + +@Composable +private fun ExpiryStatus.containerColor(): Color = when (this) { + ExpiryStatus.SAFE -> Color(0xFFD1FAE5) + ExpiryStatus.WARNING -> Color(0xFFFEF3C7) + ExpiryStatus.URGENT -> Color(0xFFFFEDD5) + ExpiryStatus.EXPIRED -> Color(0xFFFEE2E2) + ExpiryStatus.UNKNOWN -> MaterialTheme.colorScheme.surfaceVariant +} + /** * 日期检查器页面,商品过期检查工具。 * * 顶部设置生产日期,下方多行显示天数与到期日期的双向联动计算。 - * 支持滑动删除行、FAB 添加新行。 + * 支持删除行、FAB 添加新行,并显示每条保质期的过期状态。 * * @param onBack 返回回调 * @param modifier 布局修饰符 @@ -103,12 +129,22 @@ fun DateCheckerScreen(onBack: () -> Unit, modifier: Modifier = Modifier) { modifier = modifier.semantics { testTagsAsResourceId = true }, topBar = { TopAppBar( - title = { Text("日期检查器") }, + title = { + Text( + "日期检查器", + style = MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.SemiBold + ) + ) + }, navigationIcon = { IconButton(onClick = onBack) { BackArrowIcon() } - } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent + ) ) }, floatingActionButton = { @@ -119,71 +155,122 @@ fun DateCheckerScreen(onBack: () -> Unit, modifier: Modifier = Modifier) { }, modifier = Modifier.testTag("date_checker_fab"), shape = CircleShape, - containerColor = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onPrimaryContainer + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary ) { - PlusIcon(color = MaterialTheme.colorScheme.onPrimaryContainer) + PlusIcon(color = MaterialTheme.colorScheme.onPrimary) } }, + containerColor = MaterialTheme.colorScheme.surface ) { innerPadding -> Column( modifier = Modifier .fillMaxSize() .padding(innerPadding) - .padding(horizontal = 16.dp) ) { - Spacer(modifier = Modifier.height(16.dp)) - - Text( - text = "生产日期", - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - Spacer(modifier = Modifier.height(8.dp)) - - ProductionDateField( + ProductionDateCard( date = productionDate, - onDateChange = { productionDate = it }, - onShowDatePicker = { + isToday = productionDate == today, + onClick = { datePickerTarget = DatePickerTarget.Production showDatePicker = true - } + }, + modifier = Modifier.padding(horizontal = 16.dp) ) Spacer(modifier = Modifier.height(24.dp)) - HorizontalDivider() - Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "保质期列表", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = "${rows.size} 项", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Spacer(modifier = Modifier.height(12.dp)) LazyColumn( modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(12.dp) + verticalArrangement = Arrangement.spacedBy(10.dp), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 4.dp) ) { items(rows, key = { it.id }) { row -> val expiryDate = row.days?.let { productionDate.plus(DatePeriod(days = it)) } + val daysRemaining = expiryDate?.let { today.daysUntil(it) } + val status = when { + daysRemaining == null -> ExpiryStatus.UNKNOWN + daysRemaining < 0 -> ExpiryStatus.EXPIRED + daysRemaining == 0 -> ExpiryStatus.URGENT + daysRemaining <= 7 -> ExpiryStatus.URGENT + daysRemaining <= 30 -> ExpiryStatus.WARNING + else -> ExpiryStatus.SAFE + } - ExpiryRowItem( - days = row.days, - expiryDate = expiryDate, - onDaysChange = { newDays -> - rows = rows.map { - if (it.id == row.id) it.copy(days = newDays) else it + val dismissState = rememberSwipeToDismissBoxState( + confirmValueChange = { value -> + if (value == SwipeToDismissBoxValue.EndToStart) { + rows = rows.filter { it.id != row.id } + true + } else { + false } - }, - onExpiryDateChange = { newDate -> - val newDays = productionDate.daysUntil(newDate) - rows = rows.map { - if (it.id == row.id) it.copy(days = newDays) else it - } - }, - onShowDatePicker = { - datePickerTarget = DatePickerTarget.Row(row.id) - showDatePicker = true - }, - onDelete = { - rows = rows.filter { it.id != row.id } } ) + + SwipeToDismissBox( + state = dismissState, + backgroundContent = { + Box( + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(16.dp)) + .background(MaterialTheme.colorScheme.errorContainer) + .padding(horizontal = 20.dp), + contentAlignment = Alignment.CenterEnd + ) { + Text( + text = "删除", + color = MaterialTheme.colorScheme.onErrorContainer, + style = MaterialTheme.typography.labelLarge + ) + } + } + ) { + ExpiryCard( + days = row.days, + expiryDate = expiryDate, + daysRemaining = daysRemaining, + status = status, + onDaysChange = { newDays -> + rows = rows.map { + if (it.id == row.id) it.copy(days = newDays) else it + } + }, + onExpiryDateChange = { newDate -> + val newDays = productionDate.daysUntil(newDate) + rows = rows.map { + if (it.id == row.id) it.copy(days = newDays) else it + } + }, + onShowDatePicker = { + datePickerTarget = DatePickerTarget.Row(row.id) + showDatePicker = true + } + ) + } } } } @@ -237,139 +324,203 @@ fun DateCheckerScreen(onBack: () -> Unit, modifier: Modifier = Modifier) { } @Composable -private fun ProductionDateField( +private fun ProductionDateCard( date: LocalDate, - onDateChange: (LocalDate) -> Unit, - onShowDatePicker: () -> Unit, + isToday: Boolean, + onClick: () -> Unit, modifier: Modifier = Modifier ) { - var text by remember(date) { mutableStateOf(date.toString()) } - var isError by remember { mutableStateOf(false) } - - OutlinedTextField( - value = text, - onValueChange = { - text = it - isError = false - try { - onDateChange(LocalDate.parse(it)) - } catch (_: Exception) { - isError = true - } - }, - label = { Text("生产日期") }, - isError = isError, - singleLine = true, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Text, - imeAction = ImeAction.Done - ), - trailingIcon = { - IconButton( - onClick = onShowDatePicker, - modifier = Modifier.testTag("date_picker_button") - ) { - CalendarIcon(color = MaterialTheme.colorScheme.onSurfaceVariant) - } - }, - modifier = modifier.fillMaxWidth() + val gradient = Brush.linearGradient( + colors = listOf( + MaterialTheme.colorScheme.primaryContainer, + MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.7f) + ) ) + + Box( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(20.dp)) + .background(gradient) + .clickable(onClick = onClick) + .padding(20.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + Box( + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .background( + MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.12f) + ), + contentAlignment = Alignment.Center + ) { + CalendarIcon( + color = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(24.dp) + ) + } + + Column { + Text( + text = "生产日期", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f) + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = date.formatChinese(), + style = MaterialTheme.typography.headlineSmall.copy( + fontWeight = FontWeight.Bold + ), + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + if (isToday) { + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = "今天 · ${date.dayOfWeekChinese()}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.6f) + ) + } else { + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = date.dayOfWeekChinese(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.6f) + ) + } + } + } + } } @Composable -private fun ExpiryRowItem( +private fun ExpiryCard( days: Int?, expiryDate: LocalDate?, + daysRemaining: Int?, + status: ExpiryStatus, onDaysChange: (Int?) -> Unit, onExpiryDateChange: (LocalDate) -> Unit, onShowDatePicker: () -> Unit, - onDelete: () -> Unit, modifier: Modifier = Modifier ) { var daysText by remember(days) { mutableStateOf(days?.toString() ?: "") } var dateText by remember(expiryDate) { mutableStateOf(expiryDate?.toString() ?: "") } - val dismissState = rememberSwipeToDismissBoxState( - confirmValueChange = { value -> - if (value == SwipeToDismissBoxValue.EndToStart) { - onDelete() - true - } else { - false - } - } - ) - - SwipeToDismissBox( - state = dismissState, - backgroundContent = { - Box( - modifier = Modifier - .fillMaxSize() - .clip(RoundedCornerShape(12.dp)) - .background(MaterialTheme.colorScheme.errorContainer) - .padding(horizontal = 20.dp), - contentAlignment = Alignment.CenterEnd - ) { - Text( - text = "删除", - color = MaterialTheme.colorScheme.onErrorContainer, - style = MaterialTheme.typography.labelLarge - ) - } - }, + Box( modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerHigh) ) { - Row( + Column( modifier = Modifier .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .background(MaterialTheme.colorScheme.surfaceContainerHigh) - .padding(horizontal = 12.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) + .padding(16.dp) ) { - OutlinedTextField( - value = daysText, - onValueChange = { newValue -> - daysText = newValue.filter { it.isDigit() } - onDaysChange(daysText.toIntOrNull()) - }, - label = { Text("天数") }, - singleLine = true, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Number, - imeAction = ImeAction.Done - ), - modifier = Modifier.weight(1f) - ) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedTextField( + value = daysText, + onValueChange = { newValue -> + daysText = newValue.filter { it.isDigit() }.take(4) + onDaysChange(daysText.toIntOrNull()) + }, + label = { Text("天数") }, + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Done + ), + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(12.dp) + ) - OutlinedTextField( - value = dateText, - onValueChange = { newValue -> - dateText = newValue - try { - onExpiryDateChange(LocalDate.parse(newValue)) - } catch (_: Exception) { - } - }, - label = { Text("到期日期") }, - singleLine = true, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Text, - imeAction = ImeAction.Done - ), - trailingIcon = { - IconButton(onClick = onShowDatePicker) { - CalendarIcon( - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(20.dp) + Spacer(modifier = Modifier.width(8.dp)) + + ArrowRightIcon( + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + modifier = Modifier.size(20.dp) + ) + + Spacer(modifier = Modifier.width(8.dp)) + + OutlinedTextField( + value = dateText, + onValueChange = { newValue -> + dateText = newValue + try { + onExpiryDateChange(LocalDate.parse(newValue)) + } catch (_: Exception) { + } + }, + label = { Text("到期日期") }, + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Done + ), + trailingIcon = { + IconButton( + onClick = onShowDatePicker, + modifier = Modifier.size(32.dp) + ) { + CalendarIcon( + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp) + ) + } + }, + modifier = Modifier.weight(1.8f), + shape = RoundedCornerShape(12.dp) + ) + } + + if (daysRemaining != null) { + Spacer(modifier = Modifier.height(12.dp)) + + val statusText = when { + daysRemaining < 0 -> "已过期 ${-daysRemaining} 天" + daysRemaining == 0 -> "今天过期" + daysRemaining == 1 -> "明天过期" + else -> "还有 $daysRemaining 天" + } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background(status.containerColor()) + .padding(horizontal = 10.dp, vertical = 4.dp) + ) { + Text( + text = statusText, + style = MaterialTheme.typography.labelMedium, + color = status.color(), + fontWeight = FontWeight.Medium ) } - }, - modifier = Modifier.weight(1.5f) - ) + + Box( + modifier = Modifier + .size(8.dp) + .clip(CircleShape) + .background(status.color()) + ) + } + } } + } } @@ -378,7 +529,7 @@ private fun ExpiryRowItem( @Composable private fun BackArrowIcon(modifier: Modifier = Modifier) { val color = MaterialTheme.colorScheme.onSurface - Canvas(modifier = modifier.size(24.dp)) { + androidx.compose.foundation.Canvas(modifier = modifier.size(24.dp)) { val strokeWidth = 2.dp.toPx() drawLine( color = color, @@ -399,7 +550,7 @@ private fun BackArrowIcon(modifier: Modifier = Modifier) { @Composable private fun PlusIcon(color: Color, modifier: Modifier = Modifier) { - Canvas(modifier = modifier.size(24.dp)) { + androidx.compose.foundation.Canvas(modifier = modifier.size(24.dp)) { val strokeWidth = 2.dp.toPx() val cx = size.width / 2 val cy = size.height / 2 @@ -423,7 +574,7 @@ private fun PlusIcon(color: Color, modifier: Modifier = Modifier) { @Composable private fun CalendarIcon(color: Color, modifier: Modifier = Modifier) { - Canvas(modifier = modifier.size(24.dp)) { + androidx.compose.foundation.Canvas(modifier = modifier.size(24.dp)) { val strokeWidth = 1.5f.dp.toPx() val pad = 3.dp.toPx() val topY = pad + 4.dp.toPx() @@ -431,13 +582,11 @@ private fun CalendarIcon(color: Color, modifier: Modifier = Modifier) { val leftX = pad val rightX = size.width - pad - // 外框 drawLine(color, Offset(leftX, topY), Offset(rightX, topY), strokeWidth) drawLine(color, Offset(leftX, topY), Offset(leftX, bottomY), strokeWidth) drawLine(color, Offset(rightX, topY), Offset(rightX, bottomY), strokeWidth) drawLine(color, Offset(leftX, bottomY), Offset(rightX, bottomY), strokeWidth) - // 顶部挂环 val h1 = size.width * 0.3f val h2 = size.width * 0.7f drawLine(color, Offset(h1, pad), Offset(h1, topY), strokeWidth) @@ -445,6 +594,35 @@ private fun CalendarIcon(color: Color, modifier: Modifier = Modifier) { } } +@Composable +private fun ArrowRightIcon(color: Color, modifier: Modifier = Modifier) { + androidx.compose.foundation.Canvas(modifier = modifier) { + val strokeWidth = 2.dp.toPx() + val y = size.height / 2 + drawLine( + color = color, + start = Offset(0f, y), + end = Offset(size.width * 0.65f, y), + strokeWidth = strokeWidth, + cap = StrokeCap.Round + ) + drawLine( + color = color, + start = Offset(size.width * 0.4f, y - size.height * 0.3f), + end = Offset(size.width * 0.65f, y), + strokeWidth = strokeWidth, + cap = StrokeCap.Round + ) + drawLine( + color = color, + start = Offset(size.width * 0.4f, y + size.height * 0.3f), + end = Offset(size.width * 0.65f, y), + strokeWidth = strokeWidth, + cap = StrokeCap.Round + ) + } +} + // endregion // region Helpers @@ -455,4 +633,23 @@ private fun LocalDate.toEpochMillis(): Long = private fun Long.toLocalDate(): LocalDate = Instant.fromEpochMilliseconds(this).toLocalDateTime(TimeZone.UTC).date -// endregion \ No newline at end of file +@Suppress("DEPRECATION") // monthNumber/dayOfMonth 无替代 API,kotlinx-datetime 尚未提供新接口 +private fun LocalDate.formatChinese(): String = + "${year}年${monthNumber}月${dayOfMonth}日" + +@Suppress("DEPRECATION", "Unused") // monthNumber/dayOfMonth 无替代 API +private fun LocalDate.formatShortChinese(): String = + "${monthNumber}月${dayOfMonth}日" + +private fun LocalDate.dayOfWeekChinese(): String = when (dayOfWeek.ordinal) { + 0 -> "周一" + 1 -> "周二" + 2 -> "周三" + 3 -> "周四" + 4 -> "周五" + 5 -> "周六" + 6 -> "周日" + else -> "" +} + +// endregion