From 0560e81fb2191bc122c3eaf240805d30e909300d Mon Sep 17 00:00:00 2001 From: xfy Date: Mon, 8 Jun 2026 11:08:48 +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=E6=B7=BB=E5=8A=A0=E6=95=B0=E6=8D=AE=E6=8C=81=E4=B9=85?= =?UTF-8?q?=E5=8C=96=E5=92=8C=E6=81=A2=E5=A4=8D=E9=BB=98=E8=AE=A4=E6=8C=89?= =?UTF-8?q?=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 DateCheckerStorage,SharedPreferences 持久化生产日期和保质期列表 - 添加 DateCheckerStorageTest 单元测试(含 InMemorySharedPreferences stub) - 页面退出后保留数据,重新打开自动恢复 - 左下角新增恢复默认 FAB,点击弹出确认弹窗后重置全部数据 - 双 FAB 样式统一(FloatingActionButton + CircleShape + primary 配色) --- .../plus/rua/project/DateCheckerStorage.kt | 43 ++++++ .../plus/rua/project/ui/DateCheckerScreen.kt | 127 +++++++++++++++--- .../rua/project/DateCheckerStorageTest.kt | 118 ++++++++++++++++ 3 files changed, 271 insertions(+), 17 deletions(-) create mode 100644 core/src/main/kotlin/plus/rua/project/DateCheckerStorage.kt create mode 100644 core/src/test/kotlin/plus/rua/project/DateCheckerStorageTest.kt diff --git a/core/src/main/kotlin/plus/rua/project/DateCheckerStorage.kt b/core/src/main/kotlin/plus/rua/project/DateCheckerStorage.kt new file mode 100644 index 0000000..f04e2fb --- /dev/null +++ b/core/src/main/kotlin/plus/rua/project/DateCheckerStorage.kt @@ -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) { + val nonNullRows = rows.filterNotNull() + prefs.edit() + .putString(KEY_PRODUCTION_DATE, productionDate.toString()) + .putString(KEY_ROWS, nonNullRows.joinToString(ROWS_SEPARATOR)) + .apply() + } + + fun load(): Pair>? { + 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() + } +} 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 4dfa7ae..f18ad03 100644 --- a/core/src/main/kotlin/plus/rua/project/ui/DateCheckerScreen.kt +++ b/core/src/main/kotlin/plus/rua/project/ui/DateCheckerScreen.kt @@ -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()) } var showDatePicker by remember { mutableStateOf(false) } var datePickerTarget by remember { mutableStateOf(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 diff --git a/core/src/test/kotlin/plus/rua/project/DateCheckerStorageTest.kt b/core/src/test/kotlin/plus/rua/project/DateCheckerStorageTest.kt new file mode 100644 index 0000000..e49597b --- /dev/null +++ b/core/src/test/kotlin/plus/rua/project/DateCheckerStorageTest.kt @@ -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() + + override fun getAll(): Map = data.toMap() + + override fun getString(key: String, defValue: String?): String? = + data[key] as? String ?: defValue + + override fun getStringSet(key: String, defValues: Set?): Set? = + data[key] as? Set ?: 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) : SharedPreferences.Editor { + + private val pending = mutableMapOf() + private var clearPending = false + + override fun putString(key: String, value: String?): SharedPreferences.Editor = apply { + pending[key] = value + } + + override fun putStringSet(key: String, values: Set?): 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() + } +}