feat: 日期检查器添加数据持久化和恢复默认按钮
- 新增 DateCheckerStorage,SharedPreferences 持久化生产日期和保质期列表 - 添加 DateCheckerStorageTest 单元测试(含 InMemorySharedPreferences stub) - 页面退出后保留数据,重新打开自动恢复 - 左下角新增恢复默认 FAB,点击弹出确认弹窗后重置全部数据 - 双 FAB 样式统一(FloatingActionButton + CircleShape + primary 配色)
This commit is contained in:
parent
fa872caa59
commit
0560e81fb2
43
core/src/main/kotlin/plus/rua/project/DateCheckerStorage.kt
Normal file
43
core/src/main/kotlin/plus/rua/project/DateCheckerStorage.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
118
core/src/test/kotlin/plus/rua/project/DateCheckerStorageTest.kt
Normal file
118
core/src/test/kotlin/plus/rua/project/DateCheckerStorageTest.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user