diff --git a/core/src/main/kotlin/plus/rua/project/ShiftPatternStorage.kt b/core/src/main/kotlin/plus/rua/project/ShiftPatternStorage.kt new file mode 100644 index 0000000..23762a1 --- /dev/null +++ b/core/src/main/kotlin/plus/rua/project/ShiftPatternStorage.kt @@ -0,0 +1,79 @@ +package plus.rua.project + +import android.content.Context +import android.content.SharedPreferences +import kotlinx.datetime.LocalDate + +/** + * 个人轮班设置持久化。照抄 [DateCheckerStorage] 模式。 + * + * 编码格式(无 JSON 依赖): + * - 锚点:ISO 日期串 "2026-07-08" + * - 周期:逗号分隔的 1/0 串 "1,1,0,0"(1=WORK,0=OFF) + * - overrides/breaks:逗号分隔的 "日期:值" 对 + * - overrides 值:1=WORK,0=OFF + * - breaks 值:cycleOffset(整数) + */ +class ShiftPatternStorage(private val prefs: SharedPreferences) { + + companion object { + private const val KEY_ANCHOR = "shift_anchor" + private const val KEY_CYCLE = "shift_cycle" + private const val KEY_OVERRIDES = "shift_overrides" + private const val KEY_BREAKS = "shift_breaks" + private const val SEP = "," + + fun fromContext(context: Context): ShiftPatternStorage = + ShiftPatternStorage( + context.getSharedPreferences("shift_pattern", Context.MODE_PRIVATE) + ) + } + + fun save(pattern: ShiftPattern) { + val cycleStr = pattern.cycle.joinToString(SEP) { if (it == ShiftKind.WORK) "1" else "0" } + val overridesStr = pattern.overrides.entries + .joinToString(SEP) { "${it.key}:${if (it.value == ShiftKind.WORK) 1 else 0}" } + val breaksStr = pattern.phaseBreaks + .joinToString(SEP) { "${it.date}:${it.cycleOffset}" } + prefs.edit() + .putString(KEY_ANCHOR, pattern.anchorDate.toString()) + .putString(KEY_CYCLE, cycleStr) + .putString(KEY_OVERRIDES, overridesStr) + .putString(KEY_BREAKS, breaksStr) + .apply() + } + + fun load(): ShiftPattern? { + val anchorStr = prefs.getString(KEY_ANCHOR, null) ?: return null + val cycleStr = prefs.getString(KEY_CYCLE, null) ?: return null + return try { + val anchor = LocalDate.parse(anchorStr) + val cycle = cycleStr.split(SEP).map { if (it.trim() == "1") ShiftKind.WORK else ShiftKind.OFF } + val overrides = parseOverrides(prefs.getString(KEY_OVERRIDES, null)) + val breaks = parseBreaks(prefs.getString(KEY_BREAKS, null)) + ShiftPattern(anchor, cycle, overrides, breaks) + } catch (e: Exception) { + null + } + } + + private fun parseOverrides(s: String?): Map { + if (s.isNullOrBlank()) return emptyMap() + return s.split(SEP).associate { pair -> + val parts = pair.split(":") + LocalDate.parse(parts[0]) to (if (parts[1].trim() == "1") ShiftKind.WORK else ShiftKind.OFF) + } + } + + private fun parseBreaks(s: String?): List { + if (s.isNullOrBlank()) return emptyList() + return s.split(SEP).map { pair -> + val parts = pair.split(":") + PhaseBreak(LocalDate.parse(parts[0]), parts[1].trim().toInt()) + } + } + + fun clear() { + prefs.edit().clear().apply() + } +} diff --git a/core/src/test/kotlin/plus/rua/project/ShiftPatternStorageTest.kt b/core/src/test/kotlin/plus/rua/project/ShiftPatternStorageTest.kt new file mode 100644 index 0000000..0da5f75 --- /dev/null +++ b/core/src/test/kotlin/plus/rua/project/ShiftPatternStorageTest.kt @@ -0,0 +1,132 @@ +package plus.rua.project + +import android.content.SharedPreferences +import kotlinx.datetime.LocalDate +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class ShiftPatternStorageTest { + + private val prefs = ShiftPatternTestPrefs() + private val storage = ShiftPatternStorage(prefs) + + @Test + fun load_noSavedData_returnsNull() { + storage.clear() + assertNull(storage.load()) + } + + @Test + fun saveAndLoad_roundTrips_basicPattern() { + storage.clear() + val pattern = ShiftPattern( + anchorDate = LocalDate(2026, 5, 15), + cycle = listOf(ShiftKind.WORK, ShiftKind.WORK, ShiftKind.OFF, ShiftKind.OFF) + ) + storage.save(pattern) + val result = storage.load() + assertEquals(pattern, result) + } + + @Test + fun saveAndLoad_roundTrips_withOverridesAndBreaks() { + storage.clear() + val pattern = ShiftPattern( + anchorDate = LocalDate(2026, 7, 8), + cycle = listOf(ShiftKind.WORK, ShiftKind.WORK, ShiftKind.OFF, ShiftKind.OFF), + overrides = mapOf( + LocalDate(2026, 7, 12) to ShiftKind.OFF, + LocalDate(2026, 7, 14) to ShiftKind.WORK + ), + phaseBreaks = listOf(PhaseBreak(LocalDate(2026, 7, 18), 0)) + ) + storage.save(pattern) + val result = storage.load() + assertEquals(pattern, result) + } + + @Test + fun saveAndLoad_emptyOverridesAndBreaks() { + storage.clear() + val pattern = ShiftPattern( + anchorDate = LocalDate(2026, 5, 15), + cycle = listOf(ShiftKind.WORK, ShiftKind.OFF), + overrides = emptyMap(), + phaseBreaks = emptyList() + ) + storage.save(pattern) + assertEquals(pattern, storage.load()) + } +} + +// 复制自 DateCheckerStorageTest(其为 private,无法共享);重命名以避免同包下 private 类同名冲突 +private class ShiftPatternTestPrefs : 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 = ShiftPatternTestPrefsEditor(data) + + override fun registerOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) {} + + override fun unregisterOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) {} +} + +private class ShiftPatternTestPrefsEditor(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() + } +}