feat: 日期检查器添加数据持久化和恢复默认按钮

- 新增 DateCheckerStorage,SharedPreferences 持久化生产日期和保质期列表
- 添加 DateCheckerStorageTest 单元测试(含 InMemorySharedPreferences stub)
- 页面退出后保留数据,重新打开自动恢复
- 左下角新增恢复默认 FAB,点击弹出确认弹窗后重置全部数据
- 双 FAB 样式统一(FloatingActionButton + CircleShape + primary 配色)
This commit is contained in:
xfy 2026-06-08 11:08:48 +08:00
parent fa872caa59
commit 0560e81fb2
3 changed files with 271 additions and 17 deletions

View File

@ -0,0 +1,43 @@
package plus.rua.project
import android.content.Context
import android.content.SharedPreferences
import kotlinx.datetime.LocalDate
class DateCheckerStorage(private val prefs: SharedPreferences) {
companion object {
private const val KEY_PRODUCTION_DATE = "production_date"
private const val KEY_ROWS = "rows"
private const val ROWS_SEPARATOR = ","
fun fromContext(context: Context): DateCheckerStorage {
val prefs = context.getSharedPreferences("date_checker", Context.MODE_PRIVATE)
return DateCheckerStorage(prefs)
}
}
fun save(productionDate: LocalDate, rows: List<Int?>) {
val nonNullRows = rows.filterNotNull()
prefs.edit()
.putString(KEY_PRODUCTION_DATE, productionDate.toString())
.putString(KEY_ROWS, nonNullRows.joinToString(ROWS_SEPARATOR))
.apply()
}
fun load(): Pair<LocalDate, List<Int>>? {
val dateStr = prefs.getString(KEY_PRODUCTION_DATE, null) ?: return null
val rowsStr = prefs.getString(KEY_ROWS, null) ?: return null
val date = LocalDate.parse(dateStr)
val rows = if (rowsStr.isBlank()) {
emptyList()
} else {
rowsStr.split(ROWS_SEPARATOR).map { it.toInt() }
}
return date to rows
}
fun clear() {
prefs.edit().clear().apply()
}
}

View File

@ -27,11 +27,13 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.IconButton
import androidx.compose.material3.SmallFloatingActionButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
@ -47,6 +49,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@ -58,7 +61,9 @@ 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.graphics.drawscope.Stroke
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.text.font.FontWeight
@ -78,6 +83,7 @@ import kotlinx.datetime.number
import kotlinx.datetime.plus
import kotlinx.datetime.toLocalDateTime
import kotlinx.datetime.todayIn
import plus.rua.project.DateCheckerStorage
private data class ExpiryRow(val id: Int, val days: Int? = null)
@ -120,22 +126,30 @@ private fun ExpiryStatus.containerColor(): Color = when (this) {
@Composable
fun DateCheckerScreen(onBack: () -> Unit, modifier: Modifier = Modifier) {
val today = remember { Clock.System.todayIn(TimeZone.currentSystemDefault()) }
val context = LocalContext.current.applicationContext
val storage = remember { DateCheckerStorage.fromContext(context) }
var productionDate by remember { mutableStateOf(today) }
val saved = remember { storage.load() }
val defaultRows = listOf(30, 60, 180)
var productionDate by remember { mutableStateOf(saved?.first ?: today) }
var rows by remember {
mutableStateOf(
listOf(
ExpiryRow(0, 30),
ExpiryRow(1, 60),
ExpiryRow(2, 180)
)
(saved?.second ?: defaultRows).mapIndexed { index, days ->
ExpiryRow(index, days)
}
)
}
var nextId by remember { mutableIntStateOf(3) }
var nextId by remember { mutableIntStateOf(rows.size) }
LaunchedEffect(productionDate, rows) {
storage.save(productionDate, rows.map { it.days })
}
var pendingDeleteIds by remember { mutableStateOf(setOf<Int>()) }
var showDatePicker by remember { mutableStateOf(false) }
var datePickerTarget by remember { mutableStateOf<DatePickerTarget?>(null) }
var showResetDialog by remember { mutableStateOf(false) }
val scrollState = rememberScrollState()
val scope = rememberCoroutineScope()
@ -183,20 +197,21 @@ fun DateCheckerScreen(onBack: () -> Unit, modifier: Modifier = Modifier) {
},
containerColor = MaterialTheme.colorScheme.surface
) { innerPadding ->
Column(
Box(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
) {
ProductionDateCard(
date = productionDate,
isToday = productionDate == today,
onClick = {
datePickerTarget = DatePickerTarget.Production
showDatePicker = true
},
modifier = Modifier.padding(horizontal = 16.dp)
)
Column(modifier = Modifier.fillMaxSize()) {
ProductionDateCard(
date = productionDate,
isToday = productionDate == today,
onClick = {
datePickerTarget = DatePickerTarget.Production
showDatePicker = true
},
modifier = Modifier.padding(horizontal = 16.dp)
)
Spacer(modifier = Modifier.height(24.dp))
@ -330,9 +345,47 @@ fun DateCheckerScreen(onBack: () -> Unit, modifier: Modifier = Modifier) {
}
}
}
}
FloatingActionButton(
onClick = { showResetDialog = true },
modifier = Modifier
.align(Alignment.BottomStart)
.padding(start = 16.dp, bottom = 16.dp),
shape = CircleShape,
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
) {
RefreshIcon(color = MaterialTheme.colorScheme.onPrimary)
}
}
}
if (showResetDialog) {
AlertDialog(
onDismissRequest = { showResetDialog = false },
title = { Text("恢复默认") },
text = { Text("将重置生产日期和保质期列表为默认值") },
confirmButton = {
TextButton(onClick = {
productionDate = today
rows = defaultRows.mapIndexed { index, days ->
ExpiryRow(index, days)
}
nextId = defaultRows.size
showResetDialog = false
}) {
Text("确定")
}
},
dismissButton = {
TextButton(onClick = { showResetDialog = false }) {
Text("取消")
}
}
)
}
if (showDatePicker) {
val initialMillis = when (val target = datePickerTarget) {
is DatePickerTarget.Production -> productionDate.toEpochMillis()
@ -680,6 +733,46 @@ private fun ArrowRightIcon(color: Color, modifier: Modifier = Modifier) {
}
}
@Composable
private fun RefreshIcon(color: Color, modifier: Modifier = Modifier) {
androidx.compose.foundation.Canvas(modifier = modifier.size(24.dp)) {
val strokeWidth = 2.dp.toPx()
val cx = size.width / 2
val cy = size.height / 2
val radius = size.minDimension * 0.32f
val sweepAngle = 280f
drawArc(
color = color,
startAngle = -90f + (360f - sweepAngle) / 2,
sweepAngle = sweepAngle,
useCenter = false,
topLeft = Offset(cx - radius, cy - radius),
size = androidx.compose.ui.geometry.Size(radius * 2, radius * 2),
style = Stroke(width = strokeWidth, cap = StrokeCap.Round)
)
val endAngle = Math.toRadians((-90f + (360f - sweepAngle) / 2 + sweepAngle).toDouble())
val tipLen = 4.dp.toPx()
val tipX = cx + radius * kotlin.math.cos(endAngle).toFloat()
val tipY = cy + radius * kotlin.math.sin(endAngle).toFloat()
drawLine(
color = color,
start = Offset(tipX - tipLen * 0.7f, tipY - tipLen),
end = Offset(tipX, tipY),
strokeWidth = strokeWidth,
cap = StrokeCap.Round
)
drawLine(
color = color,
start = Offset(tipX + tipLen, tipY - tipLen * 0.3f),
end = Offset(tipX, tipY),
strokeWidth = strokeWidth,
cap = StrokeCap.Round
)
}
}
// endregion
// region Helpers

View File

@ -0,0 +1,118 @@
package plus.rua.project
import android.content.SharedPreferences
import kotlinx.datetime.LocalDate
import kotlin.test.Test
import kotlin.test.assertEquals
class DateCheckerStorageTest {
private val prefs = InMemorySharedPreferences()
private val storage = DateCheckerStorage(prefs)
@Test
fun load_noSavedData_returnsDefault() {
storage.clear()
val result = storage.load()
assertEquals(null, result)
}
@Test
fun saveAndLoad_roundTrips_correctly() {
storage.clear()
val date = LocalDate(2026, 5, 15)
val rows = listOf(30, 60, 180, 365)
storage.save(date, rows)
val result = storage.load()
assertEquals(date to rows, result)
}
@Test
fun saveAndLoad_nullDaysNotPersisted() {
storage.clear()
val date = LocalDate(2026, 6, 1)
val rows = listOf(30, null, 180)
storage.save(date, rows)
val result = storage.load()
assertEquals(date to listOf(30, 180), result)
}
@Test
fun saveAndLoad_emptyRows_savesSuccessfully() {
storage.clear()
val date = LocalDate(2026, 1, 1)
storage.save(date, emptyList())
val result = storage.load()
assertEquals(date to emptyList(), result)
}
}
private class InMemorySharedPreferences : SharedPreferences {
private val data = mutableMapOf<String, Any?>()
override fun getAll(): Map<String, *> = data.toMap()
override fun getString(key: String, defValue: String?): String? =
data[key] as? String ?: defValue
override fun getStringSet(key: String, defValues: Set<String>?): Set<String>? =
data[key] as? Set<String> ?: defValues
override fun getInt(key: String, defValue: Int): Int =
data[key] as? Int ?: defValue
override fun getLong(key: String, defValue: Long): Long =
data[key] as? Long ?: defValue
override fun getFloat(key: String, defValue: Float): Float =
data[key] as? Float ?: defValue
override fun getBoolean(key: String, defValue: Boolean): Boolean =
data[key] as? Boolean ?: defValue
override fun contains(key: String): Boolean = data.containsKey(key)
override fun edit(): SharedPreferences.Editor = InMemoryEditor(data)
override fun registerOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) {}
override fun unregisterOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) {}
}
private class InMemoryEditor(private val data: MutableMap<String, Any?>) : SharedPreferences.Editor {
private val pending = mutableMapOf<String, Any?>()
private var clearPending = false
override fun putString(key: String, value: String?): SharedPreferences.Editor = apply {
pending[key] = value
}
override fun putStringSet(key: String, values: Set<String>?): SharedPreferences.Editor = apply {
pending[key] = values
}
override fun putInt(key: String, value: Int): SharedPreferences.Editor = apply { pending[key] = value }
override fun putLong(key: String, value: Long): SharedPreferences.Editor = apply { pending[key] = value }
override fun putFloat(key: String, value: Float): SharedPreferences.Editor = apply { pending[key] = value }
override fun putBoolean(key: String, value: Boolean): SharedPreferences.Editor = apply { pending[key] = value }
override fun remove(key: String): SharedPreferences.Editor = apply { pending[key] = null }
override fun clear(): SharedPreferences.Editor = apply { clearPending = true }
override fun commit(): Boolean {
apply()
return true
}
override fun apply() {
if (clearPending) {
data.clear()
clearPending = false
}
data.putAll(pending)
pending.clear()
}
}