feat: 日期检查器页面实现商品过期检查功能

- 顶部生产日期选择器(TextField + DatePickerDialog)
- 默认三行(30/60/180天),天数与到期日期双向联动
- 滑动删除行
- 底部右侧 FAB 添加新行
- 日期格式 ISO YYYY-MM-DD

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
xfy 2026-05-25 10:58:17 +08:00
parent 93c02810bf
commit 57eae41c65

View File

@ -1,76 +1,451 @@
@file:OptIn(ExperimentalMaterial3Api::class)
package plus.rua.project.ui
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
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
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SwipeToDismissBox
import androidx.compose.material3.SwipeToDismissBoxValue
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.rememberDatePickerState
import androidx.compose.material3.rememberSwipeToDismissBoxState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import kotlin.time.Clock
import kotlinx.datetime.DatePeriod
import kotlin.time.Instant
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.daysUntil
import kotlinx.datetime.plus
import kotlinx.datetime.toLocalDateTime
import kotlinx.datetime.todayIn
private data class ExpiryRow(val id: Int, val days: Int? = null)
private sealed class DatePickerTarget {
data object Production : DatePickerTarget()
data class Row(val rowId: Int) : DatePickerTarget()
}
/**
* 日期检查器页面
* 日期检查器页面商品过期检查工具
*
* 顶部设置生产日期下方多行显示天数与到期日期的双向联动计算
* 支持滑动删除行FAB 添加新行
*
* @param onBack 返回回调
* @param modifier 布局修饰符
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DateCheckerScreen(
onBack: () -> Unit,
modifier: Modifier = Modifier
) {
fun DateCheckerScreen(onBack: () -> Unit, modifier: Modifier = Modifier) {
val today = remember { Clock.System.todayIn(TimeZone.currentSystemDefault()) }
var productionDate by remember { mutableStateOf(today) }
var rows by remember {
mutableStateOf(
listOf(
ExpiryRow(0, 30),
ExpiryRow(1, 60),
ExpiryRow(2, 180)
)
)
}
var nextId by remember { mutableIntStateOf(3) }
var showDatePicker by remember { mutableStateOf(false) }
var datePickerTarget by remember { mutableStateOf<DatePickerTarget?>(null) }
Scaffold(
topBar = {
TopAppBar(
title = { Text("日期检查器") },
navigationIcon = {
IconButton(onClick = onBack) {
val arrowColor = MaterialTheme.colorScheme.onSurface
Canvas(modifier = Modifier.size(24.dp)) {
val strokeWidth = 2.dp.toPx()
drawLine(
color = arrowColor,
start = Offset(size.width * 0.75f, size.height * 0.15f),
end = Offset(size.width * 0.25f, size.height * 0.5f),
strokeWidth = strokeWidth,
cap = StrokeCap.Round
)
drawLine(
color = arrowColor,
start = Offset(size.width * 0.25f, size.height * 0.5f),
end = Offset(size.width * 0.75f, size.height * 0.85f),
strokeWidth = strokeWidth,
cap = StrokeCap.Round
)
}
BackArrowIcon()
}
}
)
},
floatingActionButton = {
FloatingActionButton(
onClick = {
rows = rows + ExpiryRow(nextId, null)
nextId++
},
shape = CircleShape,
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
) {
PlusIcon(color = MaterialTheme.colorScheme.onPrimaryContainer)
}
},
modifier = modifier
) { innerPadding ->
Box(
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
contentAlignment = Alignment.Center
.padding(innerPadding)
.padding(horizontal = 16.dp)
) {
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "日期检查器",
style = MaterialTheme.typography.bodyLarge,
text = "生产日期",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
ProductionDateField(
date = productionDate,
onDateChange = { productionDate = it },
onShowDatePicker = {
datePickerTarget = DatePickerTarget.Production
showDatePicker = true
}
)
Spacer(modifier = Modifier.height(24.dp))
HorizontalDivider()
Spacer(modifier = Modifier.height(16.dp))
LazyColumn(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(rows, key = { it.id }) { row ->
val expiryDate = row.days?.let { productionDate.plus(DatePeriod(days = it)) }
ExpiryRowItem(
days = row.days,
expiryDate = expiryDate,
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
},
onDelete = {
rows = rows.filter { it.id != row.id }
}
)
}
}
}
}
if (showDatePicker) {
val initialMillis = when (val target = datePickerTarget) {
is DatePickerTarget.Production -> productionDate.toEpochMillis()
is DatePickerTarget.Row -> {
val row = rows.find { it.id == target.rowId }
row?.days?.let {
productionDate.plus(DatePeriod(days = it)).toEpochMillis()
} ?: productionDate.toEpochMillis()
}
null -> productionDate.toEpochMillis()
}
val datePickerState = rememberDatePickerState(initialSelectedDateMillis = initialMillis)
DatePickerDialog(
onDismissRequest = { showDatePicker = false },
confirmButton = {
TextButton(onClick = {
datePickerState.selectedDateMillis?.let { millis ->
val selected = millis.toLocalDate()
when (val target = datePickerTarget) {
is DatePickerTarget.Production -> productionDate = selected
is DatePickerTarget.Row -> {
val newDays = productionDate.daysUntil(selected)
rows = rows.map {
if (it.id == target.rowId) it.copy(days = newDays) else it
}
}
null -> {}
}
}
showDatePicker = false
}) {
Text("确定")
}
},
dismissButton = {
TextButton(onClick = { showDatePicker = false }) {
Text("取消")
}
}
) {
DatePicker(state = datePickerState)
}
}
}
@Composable
private fun ProductionDateField(
date: LocalDate,
onDateChange: (LocalDate) -> Unit,
onShowDatePicker: () -> 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) {
CalendarIcon(color = MaterialTheme.colorScheme.onSurfaceVariant)
}
},
modifier = modifier.fillMaxWidth()
)
}
@Composable
private fun ExpiryRowItem(
days: Int?,
expiryDate: LocalDate?,
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
)
}
},
modifier = modifier
) {
Row(
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)
) {
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)
)
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)
)
}
},
modifier = Modifier.weight(1.5f)
)
}
}
}
// region Icons
@Composable
private fun BackArrowIcon(modifier: Modifier = Modifier) {
val color = MaterialTheme.colorScheme.onSurface
Canvas(modifier = modifier.size(24.dp)) {
val strokeWidth = 2.dp.toPx()
drawLine(
color = color,
start = Offset(size.width * 0.75f, size.height * 0.15f),
end = Offset(size.width * 0.25f, size.height * 0.5f),
strokeWidth = strokeWidth,
cap = StrokeCap.Round
)
drawLine(
color = color,
start = Offset(size.width * 0.25f, size.height * 0.5f),
end = Offset(size.width * 0.75f, size.height * 0.85f),
strokeWidth = strokeWidth,
cap = StrokeCap.Round
)
}
}
@Composable
private fun PlusIcon(color: Color, modifier: Modifier = Modifier) {
Canvas(modifier = modifier.size(24.dp)) {
val strokeWidth = 2.dp.toPx()
val cx = size.width / 2
val cy = size.height / 2
val half = size.minDimension * 0.35f
drawLine(
color = color,
start = Offset(cx, cy - half),
end = Offset(cx, cy + half),
strokeWidth = strokeWidth,
cap = StrokeCap.Round
)
drawLine(
color = color,
start = Offset(cx - half, cy),
end = Offset(cx + half, cy),
strokeWidth = strokeWidth,
cap = StrokeCap.Round
)
}
}
@Composable
private fun CalendarIcon(color: Color, modifier: Modifier = Modifier) {
Canvas(modifier = modifier.size(24.dp)) {
val strokeWidth = 1.5f.dp.toPx()
val pad = 3.dp.toPx()
val topY = pad + 4.dp.toPx()
val bottomY = size.height - pad
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)
drawLine(color, Offset(h2, pad), Offset(h2, topY), strokeWidth)
}
}
// endregion
// region Helpers
private fun LocalDate.toEpochMillis(): Long =
this.atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds()
private fun Long.toLocalDate(): LocalDate =
Instant.fromEpochMilliseconds(this).toLocalDateTime(TimeZone.UTC).date
// endregion