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.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,11 +197,12 @@ 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)
|
||||||
) {
|
) {
|
||||||
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
ProductionDateCard(
|
ProductionDateCard(
|
||||||
date = productionDate,
|
date = productionDate,
|
||||||
isToday = productionDate == today,
|
isToday = productionDate == today,
|
||||||
@ -331,6 +346,44 @@ 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) {
|
||||||
@ -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
|
||||||
|
|||||||
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