From 778d5dc8be4c05c80ccac1671a9e802f1337fb3a Mon Sep 17 00:00:00 2001 From: xfy Date: Mon, 6 Jul 2026 18:14:24 +0800 Subject: [PATCH 01/10] feat(shift): support overrides and phase breaks in ShiftPattern --- .../kotlin/plus/rua/project/ShiftPattern.kt | 38 +++++++-- .../plus/rua/project/ShiftPatternTest.kt | 77 +++++++++++++++++++ 2 files changed, 109 insertions(+), 6 deletions(-) diff --git a/core/src/main/kotlin/plus/rua/project/ShiftPattern.kt b/core/src/main/kotlin/plus/rua/project/ShiftPattern.kt index d34037f..7cacda9 100644 --- a/core/src/main/kotlin/plus/rua/project/ShiftPattern.kt +++ b/core/src/main/kotlin/plus/rua/project/ShiftPattern.kt @@ -8,26 +8,52 @@ import kotlinx.datetime.daysUntil */ enum class ShiftKind { WORK, OFF } +/** + * 相位断点:从 [date] 起重排周期相位,[cycleOffset] 指定当天对应 cycle 的第几位。 + * + * @param date 断点生效日(含当天) + * @param cycleOffset 该天对应的 cycle 索引(0 = cycle[0]) + */ +data class PhaseBreak( + val date: LocalDate, + val cycleOffset: Int +) + /** * 个人轮班周期。 * - * 与法定节假日完全独立:周期内某天是 WORK 还是 OFF,只看 - * `(date - anchorDate) mod cycle.size` 在 cycle 中的取值,不受任何节假日/调休影响。 + * 与法定节假日完全独立。某天的班/休由以下顺序决定: + * 1. 若 [overrides] 命中该天,取 override 值(单日翻转); + * 2. 否则取该天"活跃锚点"(最近的 phaseBreak 或基础 anchorDate)起算的 cycle 索引。 * - * @param anchorDate 周期基准日,对应 cycle[0] + * @param anchorDate 基础周期基准日,对应 cycle[0] * @param cycle 一个周期内的班次序列,例如 [WORK, WORK, OFF, OFF] 表示 "2 班 2 休" - * @param name 方案名,用于后续多套方案场景 + * @param overrides 单日翻转映射(调班),key 为日期 + * @param phaseBreaks 相位断点列表,从某天起重排周期相位 + * @param name 方案名 */ data class ShiftPattern( val anchorDate: LocalDate, val cycle: List, + val overrides: Map = emptyMap(), + val phaseBreaks: List = emptyList(), val name: String = "默认" ) { fun kindAt(date: LocalDate): ShiftKind? { if (cycle.isEmpty()) return null - val diff = anchorDate.daysUntil(date) + // 1. 单日翻转优先 + overrides[date]?.let { return it } + // 2. 找活跃锚点:phaseBreaks 中 date <= 当天 的最大那个;无则用基础锚点 + val (anchor, offset) = activeAnchor(date) + val diff = anchor.daysUntil(date) val size = cycle.size - val idx = ((diff % size) + size) % size + val idx = (((diff + offset) % size) + size) % size return cycle[idx] } + + private fun activeAnchor(date: LocalDate): Pair { + val applicable = phaseBreaks.filter { it.date <= date }.maxByOrNull { it.date } + return if (applicable != null) applicable.date to applicable.cycleOffset + else anchorDate to 0 + } } diff --git a/core/src/test/kotlin/plus/rua/project/ShiftPatternTest.kt b/core/src/test/kotlin/plus/rua/project/ShiftPatternTest.kt index 1958796..8f60227 100644 --- a/core/src/test/kotlin/plus/rua/project/ShiftPatternTest.kt +++ b/core/src/test/kotlin/plus/rua/project/ShiftPatternTest.kt @@ -176,4 +176,81 @@ class ShiftPatternTest { assertEquals(a, b) assertEquals(a.hashCode(), b.hashCode()) } + + // ---- kindAt: 单日 override ---- + + @Test + fun kindAt_overrideOnBaseDay_flipsToOff() { + // 基础周期下 5/15 = WORK(锚点),override 为 OFF + val pattern = twoOnTwoOff.copy( + overrides = mapOf(LocalDate(2026, 5, 15) to ShiftKind.OFF) + ) + assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 5, 15))) + // 隔天不受影响 + assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 5, 16))) + } + + @Test + fun kindAt_overrideOnOffDay_flipsToWork() { + // 基础周期下 5/17 = OFF,override 为 WORK + val pattern = twoOnTwoOff.copy( + overrides = mapOf(LocalDate(2026, 5, 17) to ShiftKind.WORK) + ) + assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 5, 17))) + } + + // ---- kindAt: 相位断点 phaseBreak ---- + + @Test + fun kindAt_phaseBreak_restartsCycleFromBreakDate() { + // 断点设在 5/19,从这天起重新 cycle[0] + val pattern = twoOnTwoOff.copy( + phaseBreaks = listOf(PhaseBreak(LocalDate(2026, 5, 19), 0)) + ) + // 5/19 = cycle[0] = WORK + assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 5, 19))) + // 5/20 = cycle[1] = WORK + assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 5, 20))) + // 5/21 = cycle[2] = OFF + assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 5, 21))) + // 断点之前保持原相位:5/18 = (18-15)%4=3 = OFF + assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 5, 18))) + } + + // ---- kindAt: override + phaseBreak 组合(用户原始例子)---- + + @Test + fun kindAt_userExample_combinedOverridesAndPhaseBreak() { + // 基础锚点 7/8,周期 [WORK,WORK,OFF,OFF] + 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, // 休→班 + LocalDate(2026, 7, 15) to ShiftKind.WORK, // 休→班 + LocalDate(2026, 7, 16) to ShiftKind.OFF, // 班→休 + LocalDate(2026, 7, 17) to ShiftKind.OFF // 班→休 + ), + phaseBreaks = listOf(PhaseBreak(LocalDate(2026, 7, 18), 0)) + ) + // 7/10,11 = 基础周期自动算 (idx 2,3) = OFF,OFF + assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 7, 10))) + assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 7, 11))) + // 7/12 = override OFF + assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 7, 12))) + // 7/13 = 基础周期 (idx 5%4=1) = WORK + assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 7, 13))) + // 7/14,15 = override WORK + assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 7, 14))) + assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 7, 15))) + // 7/16,17 = override OFF + assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 7, 16))) + assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 7, 17))) + // 7/18 起 phaseBreak 重排:cycle[0,1,2,3] = WORK,WORK,OFF,OFF + assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 7, 18))) + assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 7, 19))) + assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 7, 20))) + assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 7, 21))) + } } From eca6c610586ab5281af2ad44e7034741a6180363 Mon Sep 17 00:00:00 2001 From: xfy Date: Mon, 6 Jul 2026 18:20:48 +0800 Subject: [PATCH 02/10] test(shift): cover non-zero cycleOffset and multiple phase breaks; add kindAt KDoc --- .../kotlin/plus/rua/project/ShiftPattern.kt | 4 +++ .../plus/rua/project/ShiftPatternTest.kt | 35 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/core/src/main/kotlin/plus/rua/project/ShiftPattern.kt b/core/src/main/kotlin/plus/rua/project/ShiftPattern.kt index 7cacda9..37a8c61 100644 --- a/core/src/main/kotlin/plus/rua/project/ShiftPattern.kt +++ b/core/src/main/kotlin/plus/rua/project/ShiftPattern.kt @@ -39,6 +39,10 @@ data class ShiftPattern( val phaseBreaks: List = emptyList(), val name: String = "默认" ) { + /** + * 返回 [date] 当天的班次。优先级:overrides → 活跃锚点的 cycle 索引。 + * cycle 为空时返回 null。 + */ fun kindAt(date: LocalDate): ShiftKind? { if (cycle.isEmpty()) return null // 1. 单日翻转优先 diff --git a/core/src/test/kotlin/plus/rua/project/ShiftPatternTest.kt b/core/src/test/kotlin/plus/rua/project/ShiftPatternTest.kt index 8f60227..9ebb401 100644 --- a/core/src/test/kotlin/plus/rua/project/ShiftPatternTest.kt +++ b/core/src/test/kotlin/plus/rua/project/ShiftPatternTest.kt @@ -217,6 +217,41 @@ class ShiftPatternTest { assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 5, 18))) } + @Test + fun kindAt_phaseBreakWithOffset_shiftsPhase() { + // 断点 5/19,cycleOffset=2:5/19 对应 cycle[2]=OFF(而非 cycle[0]=WORK) + val pattern = twoOnTwoOff.copy( + phaseBreaks = listOf(PhaseBreak(LocalDate(2026, 5, 19), 2)) + ) + // 5/19 = cycle[(0+2)%4] = cycle[2] = OFF + assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 5, 19))) + // 5/20 = cycle[(1+2)%4] = cycle[3] = OFF + assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 5, 20))) + // 5/21 = cycle[(2+2)%4] = cycle[0] = WORK + assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 5, 21))) + } + + @Test + fun kindAt_multiplePhaseBreaks_usesLatestApplicable() { + // 两个断点:5/19(offset 0) 和 5/25(offset 1) + val pattern = twoOnTwoOff.copy( + phaseBreaks = listOf( + PhaseBreak(LocalDate(2026, 5, 19), 0), + PhaseBreak(LocalDate(2026, 5, 25), 1) + ) + ) + // 5/19-5/24 受第一个断点支配(offset 0) + // 5/19 = cycle[0] = WORK + assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 5, 19))) + // 5/24 = cycle[(5)%4] = cycle[1] = WORK + assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 5, 24))) + // 5/25 起受第二个断点支配(offset 1) + // 5/25 = cycle[(0+1)%4] = cycle[1] = WORK + assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 5, 25))) + // 5/26 = cycle[(1+1)%4] = cycle[2] = OFF + assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 5, 26))) + } + // ---- kindAt: override + phaseBreak 组合(用户原始例子)---- @Test From e4834a52c9631b4a2c571c29fc4c0cd378ed8d14 Mon Sep 17 00:00:00 2001 From: xfy Date: Mon, 6 Jul 2026 18:24:28 +0800 Subject: [PATCH 03/10] feat(shift): add ShiftPatternStorage for persistence --- .../plus/rua/project/ShiftPatternStorage.kt | 79 +++++++++++ .../rua/project/ShiftPatternStorageTest.kt | 132 ++++++++++++++++++ 2 files changed, 211 insertions(+) create mode 100644 core/src/main/kotlin/plus/rua/project/ShiftPatternStorage.kt create mode 100644 core/src/test/kotlin/plus/rua/project/ShiftPatternStorageTest.kt 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() + } +} From 1f1db17384316cb85d2a473882ef88186be6a6ce Mon Sep 17 00:00:00 2001 From: xfy Date: Mon, 6 Jul 2026 18:30:40 +0800 Subject: [PATCH 04/10] fix(shift): persist name field, guard empty cycle, test corrupt-data path --- .../plus/rua/project/ShiftPatternStorage.kt | 10 ++++- .../rua/project/ShiftPatternStorageTest.kt | 38 +++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/core/src/main/kotlin/plus/rua/project/ShiftPatternStorage.kt b/core/src/main/kotlin/plus/rua/project/ShiftPatternStorage.kt index 23762a1..600e9bc 100644 --- a/core/src/main/kotlin/plus/rua/project/ShiftPatternStorage.kt +++ b/core/src/main/kotlin/plus/rua/project/ShiftPatternStorage.kt @@ -21,6 +21,7 @@ class ShiftPatternStorage(private val prefs: SharedPreferences) { private const val KEY_CYCLE = "shift_cycle" private const val KEY_OVERRIDES = "shift_overrides" private const val KEY_BREAKS = "shift_breaks" + private const val KEY_NAME = "shift_name" private const val SEP = "," fun fromContext(context: Context): ShiftPatternStorage = @@ -40,6 +41,7 @@ class ShiftPatternStorage(private val prefs: SharedPreferences) { .putString(KEY_CYCLE, cycleStr) .putString(KEY_OVERRIDES, overridesStr) .putString(KEY_BREAKS, breaksStr) + .putString(KEY_NAME, pattern.name) .apply() } @@ -48,10 +50,14 @@ class ShiftPatternStorage(private val prefs: SharedPreferences) { 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 cycle = if (cycleStr.isBlank()) { + emptyList() + } else { + 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) + ShiftPattern(anchor, cycle, overrides, breaks, name = prefs.getString(KEY_NAME, null) ?: "默认") } catch (e: Exception) { null } diff --git a/core/src/test/kotlin/plus/rua/project/ShiftPatternStorageTest.kt b/core/src/test/kotlin/plus/rua/project/ShiftPatternStorageTest.kt index 0da5f75..238188a 100644 --- a/core/src/test/kotlin/plus/rua/project/ShiftPatternStorageTest.kt +++ b/core/src/test/kotlin/plus/rua/project/ShiftPatternStorageTest.kt @@ -58,6 +58,44 @@ class ShiftPatternStorageTest { storage.save(pattern) assertEquals(pattern, storage.load()) } + + @Test + fun load_corruptOverrides_returnsNull() { + storage.clear() + // 通过底层 prefs 注入损坏的 overrides 值 + prefs.edit() + .putString("shift_anchor", "2026-05-15") + .putString("shift_cycle", "1,1,0,0") + .putString("shift_overrides", "not-a-date:1") + .apply() + assertNull(storage.load()) + } + + @Test + fun saveAndLoad_customName_preserved() { + storage.clear() + val pattern = ShiftPattern( + anchorDate = LocalDate(2026, 5, 15), + cycle = listOf(ShiftKind.WORK, ShiftKind.OFF), + name = "我的方案" + ) + storage.save(pattern) + val loaded = storage.load() + assertEquals(pattern, loaded) + assertEquals("我的方案", loaded?.name) + } + + @Test + fun saveAndLoad_emptyCycle_roundTrips() { + storage.clear() + val pattern = ShiftPattern( + anchorDate = LocalDate(2026, 5, 15), + cycle = emptyList() + ) + storage.save(pattern) + val loaded = storage.load() + assertEquals(pattern, loaded) + } } // 复制自 DateCheckerStorageTest(其为 private,无法共享);重命名以避免同包下 private 类同名冲突 From ef55ffcf6452d8a654bfc5d9ecc92b0cf809e164 Mon Sep 17 00:00:00 2001 From: xfy Date: Mon, 6 Jul 2026 18:34:12 +0800 Subject: [PATCH 05/10] feat(shift): inject ShiftPatternStorage into CalendarViewModel --- .../plus/rua/project/CalendarViewModel.kt | 30 ++++-- .../plus/rua/project/CalendarViewModelTest.kt | 98 +++++++++++++++++++ 2 files changed, 120 insertions(+), 8 deletions(-) diff --git a/core/src/main/kotlin/plus/rua/project/CalendarViewModel.kt b/core/src/main/kotlin/plus/rua/project/CalendarViewModel.kt index 00d0473..5256691 100644 --- a/core/src/main/kotlin/plus/rua/project/CalendarViewModel.kt +++ b/core/src/main/kotlin/plus/rua/project/CalendarViewModel.kt @@ -59,7 +59,8 @@ data class CalendarUiState( * @param clock 时钟源,默认系统时钟;测试时可注入固定时钟 */ class CalendarViewModel( - private val clock: Clock = Clock.System + private val clock: Clock = Clock.System, + private val shiftStorage: ShiftPatternStorage? = null ) : ViewModel() { private val today: LocalDate = clock.todayIn(TimeZone.currentSystemDefault()) @@ -112,17 +113,30 @@ class CalendarViewModel( /** * 个人轮班。与法定节假日完全独立,不受调休影响。 - * MVP 默认:2026-05-15 起,2 班 2 休循环。后续接入设置页与持久化。 + * 从 [shiftStorage] 加载;storage 为空或未存时回退到 [DEFAULT_PATTERN]。 */ - private val _shiftPattern = MutableStateFlow( - ShiftPattern( + private val _shiftPattern = MutableStateFlow(loadShiftPattern()) + val shiftPattern: StateFlow = _shiftPattern.asStateFlow() + + private fun loadShiftPattern(): ShiftPattern = + shiftStorage?.load() ?: DEFAULT_PATTERN + + /** + * 设置页返回后调用,从 storage 重新加载,立即生效。 + */ + fun refreshShiftPattern() { + _shiftPattern.value = loadShiftPattern() + } + + fun shiftKindAt(date: LocalDate): ShiftKind? = shiftPattern.value?.kindAt(date) + + companion object { + /** 默认轮班:2026-05-15 起,2 班 2 休循环。 */ + val DEFAULT_PATTERN = ShiftPattern( anchorDate = LocalDate(2026, 5, 15), cycle = listOf(ShiftKind.WORK, ShiftKind.WORK, ShiftKind.OFF, ShiftKind.OFF) ) - ) - val shiftPattern: StateFlow = _shiftPattern.asStateFlow() - - fun shiftKindAt(date: LocalDate): ShiftKind? = shiftPattern.value?.kindAt(date) + } /** * 是否在右上角显示法定调休角标。默认禁用,此时右上角让位给个人排班。 diff --git a/core/src/test/kotlin/plus/rua/project/CalendarViewModelTest.kt b/core/src/test/kotlin/plus/rua/project/CalendarViewModelTest.kt index 065b97c..e8dd021 100644 --- a/core/src/test/kotlin/plus/rua/project/CalendarViewModelTest.kt +++ b/core/src/test/kotlin/plus/rua/project/CalendarViewModelTest.kt @@ -1,9 +1,11 @@ package plus.rua.project +import android.content.SharedPreferences import kotlinx.datetime.Clock import kotlinx.datetime.Instant import kotlinx.datetime.LocalDate import kotlinx.datetime.number +import plus.rua.project.ShiftKind import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -127,4 +129,100 @@ class CalendarViewModelTest { val selectedCell = days.first { it.isSelected } assertEquals(15, selectedCell.date.day) } + + // ---- shiftPattern: 默认值与 refresh ---- + + @Test + fun shiftPattern_noStorage_returnsDefault() { + val vm = createViewModel() // 不传 storage + assertEquals(ShiftKind.WORK, vm.shiftKindAt(LocalDate(2026, 5, 15))) + assertEquals(ShiftKind.OFF, vm.shiftKindAt(LocalDate(2026, 5, 17))) + } + + @Test + fun refreshShiftPattern_reloadsFromStorage() { + val prefs = CalendarVmTestPrefs() + val storage = ShiftPatternStorage(prefs) + // storage 里存一个 1班1休,锚点 2026-01-01 + storage.save( + ShiftPattern( + anchorDate = LocalDate(2026, 1, 1), + cycle = listOf(ShiftKind.WORK, ShiftKind.OFF) + ) + ) + val vm = CalendarViewModel(clock = testClock, shiftStorage = storage) + // 初始即从 storage 读 + assertEquals(ShiftKind.WORK, vm.shiftKindAt(LocalDate(2026, 1, 1))) + assertEquals(ShiftKind.OFF, vm.shiftKindAt(LocalDate(2026, 1, 2))) + } +} + +private class CalendarVmTestPrefs : 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 = CalendarVmTestPrefsEditor(data) + + override fun registerOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) {} + + override fun unregisterOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) {} +} + +private class CalendarVmTestPrefsEditor(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() + } } From e08243d0fa92cdaa29bbc9405bc2bca956b63307 Mon Sep 17 00:00:00 2001 From: xfy Date: Mon, 6 Jul 2026 18:37:47 +0800 Subject: [PATCH 06/10] test(shift): cover refreshShiftPattern reload after storage change --- .../plus/rua/project/CalendarViewModelTest.kt | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/core/src/test/kotlin/plus/rua/project/CalendarViewModelTest.kt b/core/src/test/kotlin/plus/rua/project/CalendarViewModelTest.kt index e8dd021..3b46641 100644 --- a/core/src/test/kotlin/plus/rua/project/CalendarViewModelTest.kt +++ b/core/src/test/kotlin/plus/rua/project/CalendarViewModelTest.kt @@ -155,6 +155,32 @@ class CalendarViewModelTest { assertEquals(ShiftKind.WORK, vm.shiftKindAt(LocalDate(2026, 1, 1))) assertEquals(ShiftKind.OFF, vm.shiftKindAt(LocalDate(2026, 1, 2))) } + + @Test + fun refreshShiftPattern_reloadsAfterStorageChange() { + val prefs = CalendarVmTestPrefs() + val storage = ShiftPatternStorage(prefs) + // 初始:2 班 2 休(默认),构造 VM(不传 storage → 用 DEFAULT_PATTERN) + val vm = CalendarViewModel(clock = testClock, shiftStorage = storage) + // 2026-05-15 = WORK(默认 2班2休 锚点) + assertEquals(ShiftKind.WORK, vm.shiftKindAt(LocalDate(2026, 5, 15))) + // 改 storage 为 1班1休,锚点 2026-01-01 + storage.save( + ShiftPattern( + anchorDate = LocalDate(2026, 1, 1), + cycle = listOf(ShiftKind.WORK, ShiftKind.OFF) + ) + ) + // refresh 前:VM 还持有旧 pattern + assertEquals(ShiftKind.WORK, vm.shiftKindAt(LocalDate(2026, 5, 15))) + // 调用 refresh + vm.refreshShiftPattern() + // refresh 后:VM 已从 storage 重读 + // 2026-05-15 距 2026-01-01 = 134 天,134 % 2 = 0 → cycle[0] = WORK + assertEquals(ShiftKind.WORK, vm.shiftKindAt(LocalDate(2026, 5, 15))) + // 2026-01-02 距锚点 1 天,1 % 2 = 1 → cycle[1] = OFF + assertEquals(ShiftKind.OFF, vm.shiftKindAt(LocalDate(2026, 1, 2))) + } } private class CalendarVmTestPrefs : SharedPreferences { From d528b98cdeb8a8c999ea04e8a2758a5a9e6bceea Mon Sep 17 00:00:00 2001 From: xfy Date: Mon, 6 Jul 2026 18:41:07 +0800 Subject: [PATCH 07/10] feat(shift): wire VM factory, onResume reload, settings menu entry --- .../plus/rua/project/ui/CalendarMonthView.kt | 41 ++++++++++++++++++- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/core/src/main/kotlin/plus/rua/project/ui/CalendarMonthView.kt b/core/src/main/kotlin/plus/rua/project/ui/CalendarMonthView.kt index 8e458e4..1d3a9a2 100644 --- a/core/src/main/kotlin/plus/rua/project/ui/CalendarMonthView.kt +++ b/core/src/main/kotlin/plus/rua/project/ui/CalendarMonthView.kt @@ -93,7 +93,15 @@ import plus.rua.project.composeTraceBeginSection import plus.rua.project.composeTraceEndSection import kotlin.math.abs import kotlin.time.Clock +import androidx.compose.runtime.DisposableEffect +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import plus.rua.project.ShiftPatternStorage /** * 日历主界面,包含月/周视图切换、折叠动画和年视图转场。 @@ -107,9 +115,30 @@ import androidx.lifecycle.viewmodel.compose.viewModel fun CalendarMonthView( modifier: Modifier = Modifier, onNavigateToAbout: () -> Unit = {}, - onNavigateToTools: () -> Unit = {} + onNavigateToTools: () -> Unit = {}, + onNavigateToShiftSettings: () -> Unit = {} ) { - val viewModel = viewModel() + val context = LocalContext.current.applicationContext + val viewModel: CalendarViewModel = viewModel( + factory = viewModelFactory { + initializer { + CalendarViewModel( + clock = Clock.System, + shiftStorage = ShiftPatternStorage.fromContext(context) + ) + } + } + ) + + // 设置页返回后 onResume 重读 storage,立即刷新班次 + val lifecycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) viewModel.refreshShiftPattern() + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } val today = remember { Clock.System.todayIn(TimeZone.currentSystemDefault()) } val uiState by viewModel.uiState.collectAsState() @@ -427,6 +456,14 @@ fun CalendarMonthView( viewModel.toggleShowLegalHoliday() } ) + MenuItem( + text = "班次设置", + selected = false, + onClick = { + isMenuExpanded = false + onNavigateToShiftSettings() + } + ) HorizontalDivider( thickness = 1.dp, color = MaterialTheme.colorScheme.outlineVariant, From a5795b16dcb0a01d05f1f2fdb87b79131a581dc8 Mon Sep 17 00:00:00 2001 From: xfy Date: Mon, 6 Jul 2026 18:52:16 +0800 Subject: [PATCH 08/10] feat(shift): add ShiftPatternActivity, Screen, and mini calendar grid --- app/src/main/AndroidManifest.xml | 4 + .../kotlin/plus/rua/project/MainActivity.kt | 3 + .../plus/rua/project/ShiftPatternActivity.kt | 18 ++ .../plus/rua/project/ui/ShiftCalendarGrid.kt | 229 ++++++++++++++++++ .../plus/rua/project/ui/ShiftPatternScreen.kt | 215 ++++++++++++++++ 5 files changed, 469 insertions(+) create mode 100644 app/src/main/kotlin/plus/rua/project/ShiftPatternActivity.kt create mode 100644 core/src/main/kotlin/plus/rua/project/ui/ShiftCalendarGrid.kt create mode 100644 core/src/main/kotlin/plus/rua/project/ui/ShiftPatternScreen.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 02b4032..8365c24 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -48,6 +48,10 @@ + + \ No newline at end of file diff --git a/app/src/main/kotlin/plus/rua/project/MainActivity.kt b/app/src/main/kotlin/plus/rua/project/MainActivity.kt index f9a24a4..7760b68 100644 --- a/app/src/main/kotlin/plus/rua/project/MainActivity.kt +++ b/app/src/main/kotlin/plus/rua/project/MainActivity.kt @@ -21,6 +21,9 @@ class MainActivity : BaseActivity() { }, onNavigateToTools = { startActivityWithSlide(Intent(this, ToolsActivity::class.java)) + }, + onNavigateToShiftSettings = { + startActivityWithSlide(Intent(this, ShiftPatternActivity::class.java)) } ) } diff --git a/app/src/main/kotlin/plus/rua/project/ShiftPatternActivity.kt b/app/src/main/kotlin/plus/rua/project/ShiftPatternActivity.kt new file mode 100644 index 0000000..18ddb51 --- /dev/null +++ b/app/src/main/kotlin/plus/rua/project/ShiftPatternActivity.kt @@ -0,0 +1,18 @@ +package plus.rua.project + +import android.os.Bundle +import androidx.activity.compose.setContent +import plus.rua.project.ui.ShiftPatternScreen +import plus.rua.project.ui.theme.YaYaTheme + +class ShiftPatternActivity : BaseActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + YaYaTheme { + ShiftPatternScreen(onBack = { finishWithSlideBack() }) + } + } + } +} diff --git a/core/src/main/kotlin/plus/rua/project/ui/ShiftCalendarGrid.kt b/core/src/main/kotlin/plus/rua/project/ui/ShiftCalendarGrid.kt new file mode 100644 index 0000000..7d0a658 --- /dev/null +++ b/core/src/main/kotlin/plus/rua/project/ui/ShiftCalendarGrid.kt @@ -0,0 +1,229 @@ +package plus.rua.project.ui + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ChevronLeft +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.datetime.DatePeriod +import kotlinx.datetime.LocalDate +import kotlinx.datetime.Month +import kotlinx.datetime.TimeZone +import kotlinx.datetime.isoDayNumber +import kotlinx.datetime.minus +import kotlinx.datetime.number +import kotlinx.datetime.plus +import kotlinx.datetime.todayIn +import kotlin.time.Clock +import plus.rua.project.PhaseBreak +import plus.rua.project.ShiftKind +import plus.rua.project.ShiftPattern + +/** + * 班次设置页用的迷你月历。点某天翻转班/休,长按设/清相位断点。 + * + * 月份可前后翻页,每格显示日期数字与班次角标(班/休/断)。 + * 点击翻转该天的班/休 override;长按切换相位断点。 + * + * @param pattern 当前轮班配置(只读),由 [onPatternChange] 修改 + * @param onPatternChange 修改后的新 pattern 回调 + * @param modifier 外部布局修饰符 + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun ShiftCalendarGrid( + pattern: ShiftPattern, + onPatternChange: (ShiftPattern) -> Unit, + modifier: Modifier = Modifier +) { + val today = remember { Clock.System.todayIn(TimeZone.currentSystemDefault()) } + var viewYear by remember { mutableStateOf(today.year) } + var viewMonth by remember { mutableStateOf(today.month.number) } + + val firstOfMonth = LocalDate(viewYear, Month(viewMonth), 1) + val daysInMonth = firstOfMonth.plus(DatePeriod(months = 1)).minus(DatePeriod(days = 1)).day + val firstWeekdayOffset = firstOfMonth.dayOfWeek.isoDayNumber - 1 + + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow) + ) { + Column(Modifier.padding(8.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + IconButton(onClick = { + if (viewMonth == 1) { viewMonth = 12; viewYear -= 1 } else viewMonth -= 1 + }) { + Icon(Icons.Filled.ChevronLeft, contentDescription = "上个月") + } + Text( + text = "${viewYear}年 ${viewMonth}月", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold + ) + IconButton(onClick = { + if (viewMonth == 12) { viewMonth = 1; viewYear += 1 } else viewMonth += 1 + }) { + Icon(Icons.Filled.ChevronRight, contentDescription = "下个月") + } + } + + val weekTitles = listOf("一", "二", "三", "四", "五", "六", "日") + Row(Modifier.fillMaxWidth()) { + weekTitles.forEach { w -> + Text( + text = w, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + } + } + + (0 until 6).forEach { row -> + Row(Modifier.fillMaxWidth()) { + (0 until 7).forEach { col -> + val cellIndex = row * 7 + col + val dayNum = cellIndex - firstWeekdayOffset + 1 + if (dayNum in 1..daysInMonth) { + val date = LocalDate(viewYear, Month(viewMonth), dayNum) + ShiftDayCell( + date = date, + pattern = pattern, + onClick = { onPatternChange(toggleOverride(pattern, date)) }, + onLongClick = { onPatternChange(togglePhaseBreak(pattern, date)) }, + modifier = Modifier.weight(1f) + ) + } else { + Box(Modifier.weight(1f).aspectRatio(1f)) + } + } + } + } + } + } +} + +/** + * 翻转某天的班/休 override。翻转后若与基础周期值一致,则移除 override。 + */ +private fun toggleOverride(pattern: ShiftPattern, date: LocalDate): ShiftPattern { + val current = pattern.kindAt(date) ?: return pattern + val newVal = if (current == ShiftKind.WORK) ShiftKind.OFF else ShiftKind.WORK + val tempPattern = pattern.copy(overrides = pattern.overrides - date) + val base = tempPattern.kindAt(date) + val newOverrides = if (newVal == base) pattern.overrides - date + else pattern.overrides + (date to newVal) + return pattern.copy(overrides = newOverrides) +} + +/** + * 切换某天的相位断点:已有则清除,没有则新增(cycleOffset=0)。 + */ +private fun togglePhaseBreak(pattern: ShiftPattern, date: LocalDate): ShiftPattern { + val existing = pattern.phaseBreaks.find { it.date == date } + return if (existing != null) { + pattern.copy(phaseBreaks = pattern.phaseBreaks - existing) + } else { + pattern.copy(phaseBreaks = pattern.phaseBreaks + PhaseBreak(date, 0)) + } +} + +/** + * 迷你月历的单日格子。显示日期数字与班次角标。 + * + * @param date 该格对应的日期 + * @param pattern 当前轮班配置(只读) + * @param onClick 点击回调(翻转班/休) + * @param onLongClick 长按回调(设/清相位断点) + * @param modifier 外部布局修饰符 + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun ShiftDayCell( + date: LocalDate, + pattern: ShiftPattern, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier +) { + val kind = pattern.kindAt(date) + val isBreak = pattern.phaseBreaks.any { it.date == date } + + val badgeColor = when { + isBreak -> MaterialTheme.colorScheme.tertiary + kind == ShiftKind.WORK -> MaterialTheme.colorScheme.primary + kind == ShiftKind.OFF -> MaterialTheme.colorScheme.error + else -> Color.Transparent + } + val badgeText = when { + isBreak -> "断" + kind == ShiftKind.WORK -> "班" + kind == ShiftKind.OFF -> "休" + else -> "" + } + + Box( + modifier = modifier + .aspectRatio(1f) + .combinedClickable(onClick = onClick, onLongClick = onLongClick), + contentAlignment = Alignment.Center + ) { + Text( + text = date.day.toString(), + fontSize = 13.sp, + color = MaterialTheme.colorScheme.onSurface + ) + if (badgeText.isNotEmpty()) { + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(top = 2.dp, end = 2.dp) + .background(badgeColor, CircleShape) + .padding(horizontal = 3.dp, vertical = 1.dp) + ) { + Text( + text = badgeText, + color = MaterialTheme.colorScheme.onPrimary, + fontSize = 8.sp, + fontWeight = FontWeight.Bold, + lineHeight = 8.sp + ) + } + } + } +} diff --git a/core/src/main/kotlin/plus/rua/project/ui/ShiftPatternScreen.kt b/core/src/main/kotlin/plus/rua/project/ui/ShiftPatternScreen.kt new file mode 100644 index 0000000..ea35306 --- /dev/null +++ b/core/src/main/kotlin/plus/rua/project/ui/ShiftPatternScreen.kt @@ -0,0 +1,215 @@ +package plus.rua.project.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ChevronLeft +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atStartOfDayIn +import kotlinx.datetime.toLocalDateTime +import plus.rua.project.CalendarViewModel +import plus.rua.project.ShiftKind +import plus.rua.project.ShiftPattern +import plus.rua.project.ShiftPatternStorage +import kotlin.time.Instant + +/** + * 班次设置页。照抄 DateCheckerScreen 的 storage 创建 + 自动存盘模式。 + * + * 用户在页面内修改 → [LaunchedEffect] 自动存 storage → 返回主界面后 + * CalendarViewModel.onResume 重读 → 日历立即刷新。 + * + * 页面结构三段: + * 1. 基础周期:锚点日期、当前周期展示; + * 2. 预设方案:1班1休 / 2班2休 / 3班3休 / 4班4休 快捷切换; + * 3. 调班设置:迷你月历(点=翻转班/休,长按=设/清相位断点)+ 恢复默认。 + * + * @param onBack 返回回调(由 Activity 触发 finishWithSlideBack) + */ +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +fun ShiftPatternScreen(onBack: () -> Unit) { + val context = LocalContext.current.applicationContext + val storage = remember { ShiftPatternStorage.fromContext(context) } + val saved = remember { storage.load() } + var pattern by remember { mutableStateOf(saved ?: CalendarViewModel.DEFAULT_PATTERN) } + LaunchedEffect(pattern) { storage.save(pattern) } + + var showAnchorPicker by remember { mutableStateOf(false) } + var showResetDialog by remember { mutableStateOf(false) } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("班次设置") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.Filled.ChevronLeft, contentDescription = "返回") + } + } + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text("基础周期", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) + + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow) + ) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text("锚点日期", style = MaterialTheme.typography.bodyMedium) + TextButton(onClick = { showAnchorPicker = true }) { + Text(pattern.anchorDate.toString()) + } + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text("周期", style = MaterialTheme.typography.bodyMedium) + Text( + pattern.cycle.joinToString(" ") { if (it == ShiftKind.WORK) "班" else "休" }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.SemiBold + ) + } + } + } + + Text("预设方案", style = MaterialTheme.typography.labelLarge) + val presets = listOf( + "1班1休" to listOf(ShiftKind.WORK, ShiftKind.OFF), + "2班2休" to listOf(ShiftKind.WORK, ShiftKind.WORK, ShiftKind.OFF, ShiftKind.OFF), + "3班3休" to listOf(ShiftKind.WORK, ShiftKind.WORK, ShiftKind.WORK, ShiftKind.OFF, ShiftKind.OFF, ShiftKind.OFF), + "4班4休" to listOf(ShiftKind.WORK, ShiftKind.WORK, ShiftKind.WORK, ShiftKind.WORK, ShiftKind.OFF, ShiftKind.OFF, ShiftKind.OFF, ShiftKind.OFF) + ) + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + presets.forEach { (label, cycle) -> + FilterChip( + selected = pattern.cycle == cycle, + onClick = { + pattern = pattern.copy( + cycle = cycle, + overrides = emptyMap(), + phaseBreaks = emptyList() + ) + }, + label = { Text(label) } + ) + } + } + + Text("调班设置", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) + ShiftCalendarGrid(pattern = pattern, onPatternChange = { pattern = it }) + Text( + text = "点某天 = 翻转班/休,长按某天 = 设/清相位断点", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + OutlinedButton( + onClick = { showResetDialog = true }, + modifier = Modifier.fillMaxWidth() + ) { + Text("恢复默认") + } + } + } + + if (showAnchorPicker) { + val state = rememberDatePickerState( + initialSelectedDateMillis = pattern.anchorDate.toEpochMillis() + ) + DatePickerDialog( + onDismissRequest = { showAnchorPicker = false }, + confirmButton = { + TextButton(onClick = { + state.selectedDateMillis?.let { pattern = pattern.copy(anchorDate = it.toLocalDate()) } + showAnchorPicker = false + }) { Text("确定") } + }, + dismissButton = { + TextButton(onClick = { showAnchorPicker = false }) { Text("取消") } + } + ) { + DatePicker(state = state) + } + } + + if (showResetDialog) { + AlertDialog( + onDismissRequest = { showResetDialog = false }, + title = { Text("恢复默认") }, + text = { Text("将清空所有调班与断点设置,恢复为 2 班 2 休。确认?") }, + confirmButton = { + TextButton(onClick = { + pattern = CalendarViewModel.DEFAULT_PATTERN + showResetDialog = false + }) { Text("确定") } + }, + dismissButton = { + TextButton(onClick = { showResetDialog = false }) { Text("取消") } + } + ) + } +} + +private fun LocalDate.toEpochMillis(): Long = + this.atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds() + +private fun Long.toLocalDate(): LocalDate = + Instant.fromEpochMilliseconds(this).toLocalDateTime(TimeZone.UTC).date From 32d66f0a42e9406550e53117451846e6956590dd Mon Sep 17 00:00:00 2001 From: xfy Date: Mon, 6 Jul 2026 19:09:26 +0800 Subject: [PATCH 09/10] fix(shift): subscribe shiftPattern to trigger recomposition on refresh --- core/src/main/kotlin/plus/rua/project/ui/CalendarMonthView.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/core/src/main/kotlin/plus/rua/project/ui/CalendarMonthView.kt b/core/src/main/kotlin/plus/rua/project/ui/CalendarMonthView.kt index 1d3a9a2..f894432 100644 --- a/core/src/main/kotlin/plus/rua/project/ui/CalendarMonthView.kt +++ b/core/src/main/kotlin/plus/rua/project/ui/CalendarMonthView.kt @@ -142,6 +142,7 @@ fun CalendarMonthView( val today = remember { Clock.System.todayIn(TimeZone.currentSystemDefault()) } val uiState by viewModel.uiState.collectAsState() + val shiftPattern by viewModel.shiftPattern.collectAsState() val selectedDate = uiState.selectedDate val currentYear = selectedDate.year val currentMonth = selectedDate.month.number @@ -270,7 +271,7 @@ fun CalendarMonthView( viewModel.selectDate(date) } } - val shiftKindAt = remember(viewModel) { + val shiftKindAt = remember(viewModel, shiftPattern) { { date: LocalDate -> viewModel.shiftKindAt(date) } } val onRowHeightMeasured = remember { @@ -626,6 +627,7 @@ private fun BottomCardArea( val shouldShow = hasLoaded val uiState by viewModel.uiState.collectAsState() + val shiftPattern by viewModel.shiftPattern.collectAsState() val shiftKind = viewModel.shiftKindAt(uiState.selectedDate) if (shouldShow) { From 7f080f2d9028abb64e542daba0b78bb30d1c8dfc Mon Sep 17 00:00:00 2001 From: xfy Date: Mon, 6 Jul 2026 19:10:27 +0800 Subject: [PATCH 10/10] fix(shift): add color legend and restore manifest trailing newline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add legend text (班/休/断点 color meanings) below mini calendar - Restore trailing newline in AndroidManifest.xml --- app/src/main/AndroidManifest.xml | 2 +- .../main/kotlin/plus/rua/project/ui/ShiftPatternScreen.kt | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8365c24..e238289 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -54,4 +54,4 @@ android:exported="false" /> - \ No newline at end of file + diff --git a/core/src/main/kotlin/plus/rua/project/ui/ShiftPatternScreen.kt b/core/src/main/kotlin/plus/rua/project/ui/ShiftPatternScreen.kt index ea35306..3fb57c0 100644 --- a/core/src/main/kotlin/plus/rua/project/ui/ShiftPatternScreen.kt +++ b/core/src/main/kotlin/plus/rua/project/ui/ShiftPatternScreen.kt @@ -160,6 +160,11 @@ fun ShiftPatternScreen(onBack: () -> Unit) { style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) + Text( + text = "图例:班(蓝)/休(红)/断点(琥珀)", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) OutlinedButton( onClick = { showResetDialog = true },