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.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DatePicker import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.SmallFloatingActionButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
@ -47,6 +49,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.key import androidx.compose.runtime.key
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope 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.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@ -78,6 +83,7 @@ import kotlinx.datetime.number
import kotlinx.datetime.plus import kotlinx.datetime.plus
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import kotlinx.datetime.todayIn import kotlinx.datetime.todayIn
import plus.rua.project.DateCheckerStorage
private data class ExpiryRow(val id: Int, val days: Int? = null) private data class ExpiryRow(val id: Int, val days: Int? = null)
@ -120,22 +126,30 @@ private fun ExpiryStatus.containerColor(): Color = when (this) {
@Composable @Composable
fun DateCheckerScreen(onBack: () -> Unit, modifier: Modifier = Modifier) { fun DateCheckerScreen(onBack: () -> Unit, modifier: Modifier = Modifier) {
val today = remember { Clock.System.todayIn(TimeZone.currentSystemDefault()) } 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 { var rows by remember {
mutableStateOf( mutableStateOf(
listOf( (saved?.second ?: defaultRows).mapIndexed { index, days ->
ExpiryRow(0, 30), ExpiryRow(index, days)
ExpiryRow(1, 60), }
ExpiryRow(2, 180)
)
) )
} }
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 pendingDeleteIds by remember { mutableStateOf(setOf<Int>()) }
var showDatePicker by remember { mutableStateOf(false) } var showDatePicker by remember { mutableStateOf(false) }
var datePickerTarget by remember { mutableStateOf<DatePickerTarget?>(null) } var datePickerTarget by remember { mutableStateOf<DatePickerTarget?>(null) }
var showResetDialog by remember { mutableStateOf(false) }
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@ -183,20 +197,21 @@ fun DateCheckerScreen(onBack: () -> Unit, modifier: Modifier = Modifier) {
}, },
containerColor = MaterialTheme.colorScheme.surface containerColor = MaterialTheme.colorScheme.surface
) { innerPadding -> ) { innerPadding ->
Column( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(innerPadding) .padding(innerPadding)
) { ) {
ProductionDateCard( Column(modifier = Modifier.fillMaxSize()) {
date = productionDate, ProductionDateCard(
isToday = productionDate == today, date = productionDate,
onClick = { isToday = productionDate == today,
datePickerTarget = DatePickerTarget.Production onClick = {
showDatePicker = true datePickerTarget = DatePickerTarget.Production
}, showDatePicker = true
modifier = Modifier.padding(horizontal = 16.dp) },
) modifier = Modifier.padding(horizontal = 16.dp)
)
Spacer(modifier = Modifier.height(24.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) { if (showDatePicker) {
val initialMillis = when (val target = datePickerTarget) { val initialMillis = when (val target = datePickerTarget) {
is DatePickerTarget.Production -> productionDate.toEpochMillis() 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 // endregion
// region Helpers // 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()
}
}