feat: 日期检查器 UI 改版,添加过期状态显示

- 生产日期改为渐变卡片展示,支持点击切换日期
- 保质期条目改为 ExpiryCard,显示过期状态标签
- 新增 ExpiryStatus 枚举(SAFE/WARNING/URGENT/EXPIRED)
- 新增 ArrowRightIcon 自定义图标
- 添加中文日期格式化辅助函数
- 改进 TopAppBar 和 FAB 样式

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
xfy 2026-06-01 14:12:41 +08:00
parent 23909e855f
commit b12206dc88

View File

@ -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
@Suppress("DEPRECATION") // monthNumber/dayOfMonth 无替代 APIkotlinx-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