From d8014b5154b9a862ec7b8a090954cbbfb49df24c Mon Sep 17 00:00:00 2001 From: xfy Date: Thu, 9 Jul 2026 16:25:23 +0800 Subject: [PATCH 1/8] test(shift): add safety-net tests for long-press rephase scenarios MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 锁定重构前行为:单次长按重排、连续两次长按、撤销。 作为 RephaseFlip 重构的回归基线。 --- ...ss_ace6e49c-6557-47c4-9e42-2b0bbfd83e00.md | 137 ++++++++++++++++++ .../plus/rua/project/ShiftPatternTest.kt | 84 +++++++++++ 2 files changed, 221 insertions(+) create mode 100644 .zcode/plans/plan-sess_ace6e49c-6557-47c4-9e42-2b0bbfd83e00.md diff --git a/.zcode/plans/plan-sess_ace6e49c-6557-47c4-9e42-2b0bbfd83e00.md b/.zcode/plans/plan-sess_ace6e49c-6557-47c4-9e42-2b0bbfd83e00.md new file mode 100644 index 0000000..83b52dd --- /dev/null +++ b/.zcode/plans/plan-sess_ace6e49c-6557-47c4-9e42-2b0bbfd83e00.md @@ -0,0 +1,137 @@ +## 目标 + +修复班次设置页的 6 个问题: +1. 长按撤销误删巧合重合的独立断点 +2. 长按产生的 override 与 phaseBreak 解耦,单独点翻转留幽灵重排 +3. 连续长按产生断点链,撤销老断点不级联 +4. `kindAt` 每格每次重组重算(无 remember 缓存) +5. "恢复默认"无撤销,立即落盘 +6. 角标颜色硬编码 `onPrimary`、固定 6 行 + +根因:问题 1-3 都是"长按产生的 override + phaseBreak 是两个独立数据,但语义上是原子操作"。彻底解法是重构数据模型。 + +## 总体方案 + +**引入 `RephaseFlip` 原子结构**,把"翻转某天 + 从次日起重排"绑定成一个不可分割的数据单元。`overrides`(纯单日翻转)和 `phaseBreaks` 两字段保留,但长按操作改为产出 `RephaseFlip` 而非拆成两个独立字段。 + +### 新数据模型 + +```kotlin +// 新增:原子化的"翻转并重排"操作记录 +data class RephaseFlip( + val date: LocalDate, // 被翻转的天 + val flippedTo: ShiftKind, // 翻转后的值 + val rephaseFrom: LocalDate // 重排起点(= date + 1) +) + +data class ShiftPattern( + val anchorDate: LocalDate, + val cycle: List, + val overrides: Map = emptyMap(), // 保留:纯单日翻转(点) + val rephaseFlips: List = emptyList(), // 新增:翻转并重排(长按) + val name: String = "默认" +) { + fun kindAt(date: LocalDate): ShiftKind? { + if (cycle.isEmpty()) return null + // 1. 纯单日翻转优先 + overrides[date]?.let { return it } + // 2. rephaseFlips 中被翻转的当天 + rephaseFlips.find { it.date == date }?.let { return it.flippedTo } + // 3. 找活跃锚点:rephaseFlips 的 rephaseFrom <= 当天 的最大那个;无则基础锚点 + val (anchor, offset) = activeAnchor(date) + ... + } +} +``` + +**关键变化**: +- `phaseBreaks` 字段**删除**,替换为 `rephaseFlips` +- `activeAnchor` 改为从 `rephaseFlips` 取 `rephaseFrom` 作锚点 +- kindAt 优先级:overrides → rephaseFlip 当天 → 活跃锚点 cycle + +**撤销语义变干净**:长按 7/10 产生 `RephaseFlip(7/10, OFF, 7/11)`。再次长按 7/10 → 按 `date == 7/10` 精确匹配整个原子记录删除,**不会误删**别的(解决问题 1)。单独点 7/10 翻转时,如果存在 `rephaseFlip.date == 7/10`,提示或阻止(解决问题 2)。 + +### 存储格式 + +App 未正式发布(git 历史显示个人项目,SharedPreferences 刚引入)。**不做向后兼容迁移**: +- 存储格式改:`KEY_BREAKS` → `KEY_REPHASE`,编码 `日期:flippedTo:rephaseFrom`(`1/0:ISO日期`) +- 旧数据(含 `KEY_BREAKS` 的)在 `load()` 时解析 `KEY_REPHASE` 失败返回 null → 回退 `DEFAULT_PATTERN`(load 已有 try/catch) +- 用户唯一数据是默认 2班2休,丢失无感知 + +### 测试策略(先建安全网) + +**Task 0 优先**:在改模型前,先用端到端测试锁定当前所有行为(kindAt 的全部输出),确保重构后这些断言仍然成立。新增针对长按场景(翻转+重排、撤销、连续长按)的测试,先在旧模型上跑过(记录当前行为),再重构后验证不回归。 + +## 实施步骤(7 个 Task,TDD) + +### Task 0: 建立测试安全网(不改产品代码) + +在改任何产品代码前,补充测试覆盖当前行为,作为重构的回归基线: +- `ShiftPatternTest`:补充"长按翻转+重排"端到端断言(用 override+phaseBreak 组合模拟当前长按产出,断言后续序列)。包括:单次长按、连续两次长按、撤销场景。 +- `ShiftPatternStorageTest`:补充往返测试覆盖 phaseBreak。 +- 这些测试在旧模型上**必须先通过**,记录为"重构前行为"。 + +### Task 1: 重构 ShiftPattern 数据模型(RephaseFlip) + +- 新增 `RephaseFlip` data class +- `ShiftPattern` 删除 `phaseBreaks`,加 `rephaseFlips: List` +- 重写 `kindAt`:overrides → rephaseFlip 当天 → 活跃锚点(从 rephaseFlips.rephaseFrom 取) +- 重写 `activeAnchor`:从 `rephaseFlips` 取 `rephaseFrom <= date` 的最大 +- 删除 `PhaseBreak` 类 +- **所有 Task 0 测试必须通过**(行为不变) +- 补充新测试:rephaseFlip 撤销精确匹配、多个 rephaseFlip 共存 + +### Task 2: 重构 ShiftPatternStorage 存储格式 + +- `KEY_BREAKS` → `KEY_REPHASE` +- save/load 改为序列化 `rephaseFlips`(`日期:flippedTo:rephaseFrom`) +- `parseRephase` 替代 `parseBreaks` +- 往返测试更新 + +### Task 3: 重写 ShiftCalendarGrid 交互逻辑 + +- `toggleFlipAndRephase`:产出 `RephaseFlip(date, flippedTo, date+1)`,撤销按 `date` 精确匹配 +- `togglePhaseBreak` 删除 +- `toggleOverride` 加保护:若该天存在 rephaseFlip,移除整个 rephaseFlip(防止幽灵重排,解决问题 2) +- 角标 `isRephaseStart` 改为检测 `rephaseFlips.any { it.rephaseFrom == date }` +- ShiftDayCell 加 `remember(pattern, date) { pattern.kindAt(date) }` 缓存(解决问题 4) + +### Task 4: 修复角标颜色(问题 6) + +- 角标文字色按类别:班=`onPrimary`、休=`onError`、起=`onTertiary` +- 对齐 DayCell 风格:9sp、TopEnd、CircleShape 胶囊背景 + +### Task 5: 修复固定行数(问题 7) + +- `ShiftCalendarGrid` 改用 `getMonthGridInfo(viewYear, viewMonth).rows` 动态算行数 +- 删除硬编码 `(0 until 6)`,改为 `(0 until rows)` + +### Task 6: "恢复默认"加撤销(问题 5) + +- 引入 `SnackbarHost`(项目首次使用,需在 Scaffold 加 `snackbarHost` 参数) +- 点恢复默认 → 存旧 pattern 到临时变量 → 显示 Snackbar "已恢复默认,撤销" 5 秒 +- 点撤销 → 恢复旧 pattern +- 不再用 AlertDialog 确认(Snackbar 撤销比确认对话框更现代,且防误操作) + +### Task 7: 全量验证 + +- `./gradlew :app:assembleDebug :core:testDebugUnitTest` 通过 +- `./gradlew spotlessApply` +- 手动验证清单(模拟器):长按翻转重排、再次长按撤销、连续长按、恢复默认撤销 + +## 改动文件清单 + +| 文件 | Task | 改动 | +|------|------|------| +| `core/.../ShiftPattern.kt` | 1 | 删 PhaseBreak,加 RephaseFlip,重写 kindAt | +| `core/.../ShiftPatternStorage.kt` | 2 | 存储格式改 | +| `core/.../ui/ShiftCalendarGrid.kt` | 3,4,5 | 交互逻辑+角标颜色+动态行数 | +| `core/.../ui/ShiftPatternScreen.kt` | 6 | Snackbar 撤销 | +| `core/test/.../ShiftPatternTest.kt` | 0,1 | 安全网+重构后验证 | +| `core/test/.../ShiftPatternStorageTest.kt` | 0,2 | 往返测试更新 | + +## 风险 + +- **kindAt 行为变化**:`rephaseFlips` 的 `rephaseFrom` 与旧 `phaseBreaks.date` 在"长按场景"下语义等价(都是次日),但"纯 phaseBreak"(offset!=0)不再支持。当前 UI 不产生 offset!=0 的断点(长按总是 offset=0),所以无实际影响。Task 0 测试会验证。 +- **存储不兼容**:旧 `KEY_BREAKS` 数据被忽略,用户重置为默认。已确认可接受(个人项目,数据可重建)。 +- **Snackbar 首次引入**:需确认 Material3 Scaffold 的 snackbarHost 用法正确,不破坏现有布局。 \ No newline at end of file diff --git a/core/src/test/kotlin/plus/rua/project/ShiftPatternTest.kt b/core/src/test/kotlin/plus/rua/project/ShiftPatternTest.kt index 9ebb401..765fa53 100644 --- a/core/src/test/kotlin/plus/rua/project/ShiftPatternTest.kt +++ b/core/src/test/kotlin/plus/rua/project/ShiftPatternTest.kt @@ -288,4 +288,88 @@ class ShiftPatternTest { assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 7, 20))) assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 7, 21))) } + + // ---- 长按"翻转并重排"场景(重构安全网)---- + // 以下测试模拟当前 UI 长按产出:override[date] + PhaseBreak(date+1, 0) + // 重构为 RephaseFlip 后,这些场景的 kindAt 输出必须保持一致。 + + /** + * 单次长按:锚点 7/10,2班2休。长按 7/10 把班翻转为休,7/11 起重排。 + * 期望:7/10=休,7/11-12=班,7/13-14=休(后续按 cycle 顺延) + */ + @Test + fun longPress_singleFlip_dateFlippedAndRephased() { + // 锚点 7/10,7/10=班(基础) + val pattern = ShiftPattern( + anchorDate = LocalDate(2026, 7, 10), + cycle = listOf(ShiftKind.WORK, ShiftKind.WORK, ShiftKind.OFF, ShiftKind.OFF), + overrides = mapOf(LocalDate(2026, 7, 10) to ShiftKind.OFF), + phaseBreaks = listOf(PhaseBreak(LocalDate(2026, 7, 11), 0)) + ) + // 7/10 翻转为休 + assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 7, 10))) + // 7/11 起重排:cycle[0,1]=班班 + assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 7, 11))) + assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 7, 12))) + // cycle[2,3]=休休 + assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 7, 13))) + assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 7, 14))) + // 继续循环:7/15=cycle[0]=班 + assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 7, 15))) + } + + /** + * 连续两次长按:7/10 一次、7/14 一次。两个重排点共存。 + * 期望:7/11-13 受第一个断点支配,7/14 起受第二个断点支配 + */ + @Test + fun longPress_twoFlips_bothRephasesApplied() { + val pattern = ShiftPattern( + anchorDate = LocalDate(2026, 7, 10), + cycle = listOf(ShiftKind.WORK, ShiftKind.WORK, ShiftKind.OFF, ShiftKind.OFF), + overrides = mapOf( + LocalDate(2026, 7, 10) to ShiftKind.OFF, + LocalDate(2026, 7, 14) to ShiftKind.OFF + ), + phaseBreaks = listOf( + PhaseBreak(LocalDate(2026, 7, 11), 0), + PhaseBreak(LocalDate(2026, 7, 15), 0) + ) + ) + // 7/10 翻转为休 + assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 7, 10))) + // 7/11-13 受第一个断点:班班休 + assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 7, 11))) + assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 7, 12))) + assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 7, 13))) + // 7/14 翻转为休 + assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 7, 14))) + // 7/15 起受第二个断点:班班休休 + assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 7, 15))) + assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 7, 16))) + assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 7, 17))) + assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 7, 18))) + } + + /** + * 撤销长按:移除 7/10 的 override 和关联的 7/11 断点后,回到基础周期。 + */ + @Test + fun longPress_undo_returnsToBaseCycle() { + val before = ShiftPattern( + anchorDate = LocalDate(2026, 7, 10), + cycle = listOf(ShiftKind.WORK, ShiftKind.WORK, ShiftKind.OFF, ShiftKind.OFF), + overrides = mapOf(LocalDate(2026, 7, 10) to ShiftKind.OFF), + phaseBreaks = listOf(PhaseBreak(LocalDate(2026, 7, 11), 0)) + ) + // 撤销:移除 override 和断点 + val after = before.copy( + overrides = before.overrides - LocalDate(2026, 7, 10), + phaseBreaks = emptyList() + ) + // 回到基础:7/10=班(锚点),7/12=休 + assertEquals(ShiftKind.WORK, after.kindAt(LocalDate(2026, 7, 10))) + assertEquals(ShiftKind.WORK, after.kindAt(LocalDate(2026, 7, 11))) + assertEquals(ShiftKind.OFF, after.kindAt(LocalDate(2026, 7, 12))) + } } From f8df108bf329d2d3df36afb2c5ec5ca96ab61df0 Mon Sep 17 00:00:00 2001 From: xfy Date: Thu, 9 Jul 2026 16:33:29 +0800 Subject: [PATCH 2/8] refactor(shift): replace phaseBreaks with atomic RephaseFlip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 数据模型重构:把"翻转+重排"从两个独立字段(override+phaseBreak) 改为单一原子结构 RephaseFlip(date, flippedTo, rephaseFrom)。 - 删除 PhaseBreak / phaseBreaks / cycleOffset(UI 未使用 offset!=0) - kindAt:overrides → rephaseFlip 当天 → 活跃锚点(从 rephaseFrom 取) - Storage:KEY_BREAKS → KEY_REPHASE,编码 翻转日:值:重排起点 - ShiftCalendarGrid:toggleFlipAndRephase 产出原子记录,撤销按 date 精确匹配 - toggleOverride 加保护:rephaseFlip 翻转日被点时整体移除,防幽灵重排 解决问题:撤销误删独立断点、单独点翻转留幽灵、连续长按断点链不级联。 --- .../kotlin/plus/rua/project/ShiftPattern.kt | 50 +++++--- .../plus/rua/project/ShiftPatternStorage.kt | 32 +++-- .../plus/rua/project/ui/ShiftCalendarGrid.kt | 44 +++---- .../plus/rua/project/ui/ShiftPatternScreen.kt | 2 +- .../rua/project/ShiftPatternStorageTest.kt | 4 +- .../plus/rua/project/ShiftPatternTest.kt | 116 ++++++++---------- 6 files changed, 123 insertions(+), 125 deletions(-) diff --git a/core/src/main/kotlin/plus/rua/project/ShiftPattern.kt b/core/src/main/kotlin/plus/rua/project/ShiftPattern.kt index 37a8c61..13bda2e 100644 --- a/core/src/main/kotlin/plus/rua/project/ShiftPattern.kt +++ b/core/src/main/kotlin/plus/rua/project/ShiftPattern.kt @@ -9,55 +9,67 @@ import kotlinx.datetime.daysUntil enum class ShiftKind { WORK, OFF } /** - * 相位断点:从 [date] 起重排周期相位,[cycleOffset] 指定当天对应 cycle 的第几位。 + * "翻转并重排"的原子记录:翻转 [date] 当天为 [flippedTo], + * 并从 [rephaseFrom] 起重排周期相位(后续按 cycle 重新顺延)。 * - * @param date 断点生效日(含当天) - * @param cycleOffset 该天对应的 cycle 索引(0 = cycle[0]) + * date 与 rephaseFrom 通常相邻(rephaseFrom = date 的次日), + * 作为单一操作的两面绑定存储,撤销时整体删除,不会留下孤立数据。 + * + * @param date 被翻转的天 + * @param flippedTo 翻转后的班次 + * @param rephaseFrom 重排起点(含当天),该天起按 cycle[0] 重新循环 */ -data class PhaseBreak( +data class RephaseFlip( val date: LocalDate, - val cycleOffset: Int + val flippedTo: ShiftKind, + val rephaseFrom: LocalDate ) /** * 个人轮班周期。 * * 与法定节假日完全独立。某天的班/休由以下顺序决定: - * 1. 若 [overrides] 命中该天,取 override 值(单日翻转); - * 2. 否则取该天"活跃锚点"(最近的 phaseBreak 或基础 anchorDate)起算的 cycle 索引。 + * 1. 若 [overrides] 命中该天,取 override 值(单日翻转,仅当天); + * 2. 否则若 [rephaseFlips] 中有该天作为翻转日的记录,取 [RephaseFlip.flippedTo]; + * 3. 否则取该天"活跃锚点"(最近的 rephaseFlips.rephaseFrom 或基础 anchorDate) + * 起算的 cycle 索引。 * * @param anchorDate 基础周期基准日,对应 cycle[0] * @param cycle 一个周期内的班次序列,例如 [WORK, WORK, OFF, OFF] 表示 "2 班 2 休" - * @param overrides 单日翻转映射(调班),key 为日期 - * @param phaseBreaks 相位断点列表,从某天起重排周期相位 + * @param overrides 单日翻转映射(仅当天,不影响后续),key 为日期 + * @param rephaseFlips 翻转并重排记录列表;每条翻转某天并从次日起重排周期 * @param name 方案名 */ data class ShiftPattern( val anchorDate: LocalDate, val cycle: List, val overrides: Map = emptyMap(), - val phaseBreaks: List = emptyList(), + val rephaseFlips: List = emptyList(), val name: String = "默认" ) { /** - * 返回 [date] 当天的班次。优先级:overrides → 活跃锚点的 cycle 索引。 + * 返回 [date] 当天的班次。优先级:overrides → rephaseFlip 当天 → 活跃锚点的 cycle 索引。 * cycle 为空时返回 null。 */ fun kindAt(date: LocalDate): ShiftKind? { if (cycle.isEmpty()) return null - // 1. 单日翻转优先 + // 1. 单日翻转优先(仅当天) overrides[date]?.let { return it } - // 2. 找活跃锚点:phaseBreaks 中 date <= 当天 的最大那个;无则用基础锚点 - val (anchor, offset) = activeAnchor(date) + // 2. rephaseFlips 中被翻转的当天 + rephaseFlips.find { it.date == date }?.let { return it.flippedTo } + // 3. 找活跃锚点:rephaseFlips 中 rephaseFrom <= 当天 的最大那个;无则用基础锚点 + val anchor = activeAnchor(date) val diff = anchor.daysUntil(date) val size = cycle.size - val idx = (((diff + offset) % size) + size) % size + val idx = ((diff % 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 + private fun activeAnchor(date: LocalDate): LocalDate { + return rephaseFlips + .filter { it.rephaseFrom <= date } + .maxByOrNull { it.rephaseFrom } + ?.rephaseFrom + ?: anchorDate } } diff --git a/core/src/main/kotlin/plus/rua/project/ShiftPatternStorage.kt b/core/src/main/kotlin/plus/rua/project/ShiftPatternStorage.kt index 600e9bc..29b49be 100644 --- a/core/src/main/kotlin/plus/rua/project/ShiftPatternStorage.kt +++ b/core/src/main/kotlin/plus/rua/project/ShiftPatternStorage.kt @@ -10,9 +10,8 @@ import kotlinx.datetime.LocalDate * 编码格式(无 JSON 依赖): * - 锚点:ISO 日期串 "2026-07-08" * - 周期:逗号分隔的 1/0 串 "1,1,0,0"(1=WORK,0=OFF) - * - overrides/breaks:逗号分隔的 "日期:值" 对 - * - overrides 值:1=WORK,0=OFF - * - breaks 值:cycleOffset(整数) + * - overrides:逗号分隔的 "日期:值" 对,值 1=WORK,0=OFF + * - rephaseFlips:逗号分隔的 "翻转日:值:重排起点" 三元组,值 1=WORK,0=OFF */ class ShiftPatternStorage(private val prefs: SharedPreferences) { @@ -20,7 +19,7 @@ class ShiftPatternStorage(private val prefs: SharedPreferences) { 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 KEY_REPHASE = "shift_rephase" private const val KEY_NAME = "shift_name" private const val SEP = "," @@ -34,13 +33,15 @@ class ShiftPatternStorage(private val prefs: SharedPreferences) { 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}" } + val rephaseStr = pattern.rephaseFlips + .joinToString(SEP) { + "${it.date}:${if (it.flippedTo == ShiftKind.WORK) 1 else 0}:${it.rephaseFrom}" + } prefs.edit() .putString(KEY_ANCHOR, pattern.anchorDate.toString()) .putString(KEY_CYCLE, cycleStr) .putString(KEY_OVERRIDES, overridesStr) - .putString(KEY_BREAKS, breaksStr) + .putString(KEY_REPHASE, rephaseStr) .putString(KEY_NAME, pattern.name) .apply() } @@ -56,8 +57,8 @@ class ShiftPatternStorage(private val prefs: SharedPreferences) { 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, name = prefs.getString(KEY_NAME, null) ?: "默认") + val rephaseFlips = parseRephase(prefs.getString(KEY_REPHASE, null)) + ShiftPattern(anchor, cycle, overrides, rephaseFlips, name = prefs.getString(KEY_NAME, null) ?: "默认") } catch (e: Exception) { null } @@ -71,11 +72,16 @@ class ShiftPatternStorage(private val prefs: SharedPreferences) { } } - private fun parseBreaks(s: String?): List { + private fun parseRephase(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()) + return s.split(SEP).map { triple -> + // 格式:翻转日:值:重排起点(ISO 日期不含冒号,split 安全) + val colonIdx = triple.indexOf(':') + val lastColon = triple.lastIndexOf(':') + val date = LocalDate.parse(triple.substring(0, colonIdx)) + val flippedTo = if (triple.substring(colonIdx + 1, lastColon).trim() == "1") ShiftKind.WORK else ShiftKind.OFF + val rephaseFrom = LocalDate.parse(triple.substring(lastColon + 1)) + RephaseFlip(date, flippedTo, rephaseFrom) } } diff --git a/core/src/main/kotlin/plus/rua/project/ui/ShiftCalendarGrid.kt b/core/src/main/kotlin/plus/rua/project/ui/ShiftCalendarGrid.kt index 286f7a5..b5ad972 100644 --- a/core/src/main/kotlin/plus/rua/project/ui/ShiftCalendarGrid.kt +++ b/core/src/main/kotlin/plus/rua/project/ui/ShiftCalendarGrid.kt @@ -43,7 +43,7 @@ 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.RephaseFlip import plus.rua.project.ShiftKind import plus.rua.project.ShiftPattern @@ -140,9 +140,17 @@ fun ShiftCalendarGrid( } /** - * 翻转某天的班/休 override。翻转后若与基础周期值一致,则移除 override。 + * 翻转某天的班/休(单日 override)。翻转后若与基础周期值一致,则移除 override。 + * + * 若该天存在 rephaseFlip(长按产生的翻转并重排),则移除整个 rephaseFlip, + * 避免留下孤立的"幽灵重排"(只清翻转但保留后续相位重排)。 */ private fun toggleOverride(pattern: ShiftPattern, date: LocalDate): ShiftPattern { + val existingFlip = pattern.rephaseFlips.find { it.date == date } + if (existingFlip != null) { + // 该天是 rephaseFlip 的翻转日:整体移除,避免幽灵重排 + return pattern.copy(rephaseFlips = pattern.rephaseFlips - existingFlip) + } 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) @@ -155,35 +163,27 @@ private fun toggleOverride(pattern: ShiftPattern, date: LocalDate): ShiftPattern /** * 翻转某天的班/休,并从次日起重排周期(后续按 cycle 重新顺延)。 * - * 实现:翻转该天 override + 在 [date] 次日插入 PhaseBreak(offset=0)。 + * 产出原子记录 [RephaseFlip]:翻转该天 + 从次日([rephaseFrom])起重排。 * 次日成为新的 cycle[0],后续自动按周期顺延。 * - * 撤销:再次长按同一天,若存在由该天派生的断点(次日、offset=0),则移除 override 与该断点。 + * 撤销:再次长按同一天,按 [RephaseFlip.date] 精确匹配并整体移除(不会误删其他记录)。 * * @param date 被翻转并作为重排起点的日期 */ private fun toggleFlipAndRephase(pattern: ShiftPattern, date: LocalDate): ShiftPattern { - val nextDay = date.plus(DatePeriod(days = 1)) - val derivedBreak = pattern.phaseBreaks.find { it.date == nextDay && it.cycleOffset == 0 } - - if (derivedBreak != null) { - // 撤销:移除该天的 override 与关联断点 - return pattern.copy( - overrides = pattern.overrides - date, - phaseBreaks = pattern.phaseBreaks - derivedBreak - ) + val existing = pattern.rephaseFlips.find { it.date == date } + if (existing != null) { + // 撤销:整体移除该 rephaseFlip(原子删除) + return pattern.copy(rephaseFlips = pattern.rephaseFlips - existing) } - // 翻转该天 + 次日插入断点重排 + // 翻转该天 + 次日重排 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) + val rephaseFrom = date.plus(DatePeriod(days = 1)) return pattern.copy( - overrides = newOverrides, - phaseBreaks = pattern.phaseBreaks + PhaseBreak(nextDay, 0) + overrides = pattern.overrides - date, // 该天改由 rephaseFlip 管辖,移除可能的旧 override + rephaseFlips = pattern.rephaseFlips + RephaseFlip(date, newVal, rephaseFrom) ) } @@ -206,8 +206,8 @@ private fun ShiftDayCell( modifier: Modifier = Modifier ) { val kind = pattern.kindAt(date) - // 断点在"翻转日的次日",表示该天为重排起点 - val isRephaseStart = pattern.phaseBreaks.any { it.date == date && it.cycleOffset == 0 } + // rephaseFlip 的 rephaseFrom = 当天,表示该天为重排起点 + val isRephaseStart = pattern.rephaseFlips.any { it.rephaseFrom == date } val badgeColor = when { isRephaseStart -> MaterialTheme.colorScheme.tertiary 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 8898101..9139b36 100644 --- a/core/src/main/kotlin/plus/rua/project/ui/ShiftPatternScreen.kt +++ b/core/src/main/kotlin/plus/rua/project/ui/ShiftPatternScreen.kt @@ -145,7 +145,7 @@ fun ShiftPatternScreen(onBack: () -> Unit) { pattern = pattern.copy( cycle = cycle, overrides = emptyMap(), - phaseBreaks = emptyList() + rephaseFlips = emptyList() ) }, label = { Text(label) } diff --git a/core/src/test/kotlin/plus/rua/project/ShiftPatternStorageTest.kt b/core/src/test/kotlin/plus/rua/project/ShiftPatternStorageTest.kt index 238188a..3bd3cce 100644 --- a/core/src/test/kotlin/plus/rua/project/ShiftPatternStorageTest.kt +++ b/core/src/test/kotlin/plus/rua/project/ShiftPatternStorageTest.kt @@ -39,7 +39,7 @@ class ShiftPatternStorageTest { LocalDate(2026, 7, 12) to ShiftKind.OFF, LocalDate(2026, 7, 14) to ShiftKind.WORK ), - phaseBreaks = listOf(PhaseBreak(LocalDate(2026, 7, 18), 0)) + rephaseFlips = listOf(RephaseFlip(LocalDate(2026, 7, 17), ShiftKind.OFF, LocalDate(2026, 7, 18))) ) storage.save(pattern) val result = storage.load() @@ -53,7 +53,7 @@ class ShiftPatternStorageTest { anchorDate = LocalDate(2026, 5, 15), cycle = listOf(ShiftKind.WORK, ShiftKind.OFF), overrides = emptyMap(), - phaseBreaks = emptyList() + rephaseFlips = emptyList() ) storage.save(pattern) assertEquals(pattern, storage.load()) diff --git a/core/src/test/kotlin/plus/rua/project/ShiftPatternTest.kt b/core/src/test/kotlin/plus/rua/project/ShiftPatternTest.kt index 765fa53..cdb59ad 100644 --- a/core/src/test/kotlin/plus/rua/project/ShiftPatternTest.kt +++ b/core/src/test/kotlin/plus/rua/project/ShiftPatternTest.kt @@ -199,75 +199,66 @@ class ShiftPatternTest { assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 5, 17))) } - // ---- kindAt: 相位断点 phaseBreak ---- + // ---- kindAt: 翻转并重排 rephaseFlip ---- @Test - fun kindAt_phaseBreak_restartsCycleFromBreakDate() { - // 断点设在 5/19,从这天起重新 cycle[0] + fun kindAt_rephaseFlip_restartsCycleFromRephaseDate() { + // 5/18 翻转为 OFF,5/19 起重排(rephaseFrom=5/19) val pattern = twoOnTwoOff.copy( - phaseBreaks = listOf(PhaseBreak(LocalDate(2026, 5, 19), 0)) + rephaseFlips = listOf(RephaseFlip(LocalDate(2026, 5, 18), ShiftKind.OFF, LocalDate(2026, 5, 19))) ) - // 5/19 = cycle[0] = WORK + // 5/18 = 翻转为 OFF + assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 5, 18))) + // 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))) } @Test - fun kindAt_phaseBreakWithOffset_shiftsPhase() { - // 断点 5/19,cycleOffset=2:5/19 对应 cycle[2]=OFF(而非 cycle[0]=WORK) + fun kindAt_multipleRephaseFlips_usesLatestApplicable() { + // 两个 rephaseFlip:5/18→5/19 重排,5/24→5/25 重排 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) + rephaseFlips = listOf( + RephaseFlip(LocalDate(2026, 5, 18), ShiftKind.OFF, LocalDate(2026, 5, 19)), + RephaseFlip(LocalDate(2026, 5, 24), ShiftKind.OFF, LocalDate(2026, 5, 25)) ) ) - // 5/19-5/24 受第一个断点支配(offset 0) + // 5/19-5/23 受第一个重排支配(5/19 起锚) // 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 + // 5/23 = cycle[(4)%4] = cycle[0] = WORK + assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 5, 23))) + // 5/24 = 第二个 rephaseFlip 的翻转日,翻转为 OFF + assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 5, 24))) + // 5/25 起受第二个断点支配(5/25 起锚) + // 5/25 = cycle[0] = 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))) + // 5/26 = cycle[1] = WORK + assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 5, 26))) + // 5/27 = cycle[2] = OFF + assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 5, 27))) } - // ---- kindAt: override + phaseBreak 组合(用户原始例子)---- + // ---- kindAt: override + rephaseFlip 组合(用户原始例子)---- @Test - fun kindAt_userExample_combinedOverridesAndPhaseBreak() { + fun kindAt_userExample_combinedOverridesAndRephaseFlip() { // 基础锚点 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 // 班→休 + 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/17 翻转为 OFF,7/18 起重排 + rephaseFlips = listOf(RephaseFlip(LocalDate(2026, 7, 17), ShiftKind.OFF, LocalDate(2026, 7, 18))) ) // 7/10,11 = 基础周期自动算 (idx 2,3) = OFF,OFF assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 7, 10))) @@ -279,19 +270,18 @@ class ShiftPatternTest { // 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 + // 7/16 = override OFF assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 7, 16))) + // 7/17 = rephaseFlip 翻转为 OFF assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 7, 17))) - // 7/18 起 phaseBreak 重排:cycle[0,1,2,3] = WORK,WORK,OFF,OFF + // 7/18 起 rephase 重排: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))) } - // ---- 长按"翻转并重排"场景(重构安全网)---- - // 以下测试模拟当前 UI 长按产出:override[date] + PhaseBreak(date+1, 0) - // 重构为 RephaseFlip 后,这些场景的 kindAt 输出必须保持一致。 + // ---- 长按"翻转并重排"场景(RephaseFlip 原子操作)---- /** * 单次长按:锚点 7/10,2班2休。长按 7/10 把班翻转为休,7/11 起重排。 @@ -299,12 +289,10 @@ class ShiftPatternTest { */ @Test fun longPress_singleFlip_dateFlippedAndRephased() { - // 锚点 7/10,7/10=班(基础) val pattern = ShiftPattern( anchorDate = LocalDate(2026, 7, 10), cycle = listOf(ShiftKind.WORK, ShiftKind.WORK, ShiftKind.OFF, ShiftKind.OFF), - overrides = mapOf(LocalDate(2026, 7, 10) to ShiftKind.OFF), - phaseBreaks = listOf(PhaseBreak(LocalDate(2026, 7, 11), 0)) + rephaseFlips = listOf(RephaseFlip(LocalDate(2026, 7, 10), ShiftKind.OFF, LocalDate(2026, 7, 11))) ) // 7/10 翻转为休 assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 7, 10))) @@ -319,32 +307,28 @@ class ShiftPatternTest { } /** - * 连续两次长按:7/10 一次、7/14 一次。两个重排点共存。 - * 期望:7/11-13 受第一个断点支配,7/14 起受第二个断点支配 + * 连续两次长按:7/10 一次、7/14 一次。两个 rephaseFlip 共存。 + * 期望:7/11-13 受第一个重排支配,7/15 起受第二个重排支配 */ @Test fun longPress_twoFlips_bothRephasesApplied() { val pattern = ShiftPattern( anchorDate = LocalDate(2026, 7, 10), cycle = listOf(ShiftKind.WORK, ShiftKind.WORK, ShiftKind.OFF, ShiftKind.OFF), - overrides = mapOf( - LocalDate(2026, 7, 10) to ShiftKind.OFF, - LocalDate(2026, 7, 14) to ShiftKind.OFF - ), - phaseBreaks = listOf( - PhaseBreak(LocalDate(2026, 7, 11), 0), - PhaseBreak(LocalDate(2026, 7, 15), 0) + rephaseFlips = listOf( + RephaseFlip(LocalDate(2026, 7, 10), ShiftKind.OFF, LocalDate(2026, 7, 11)), + RephaseFlip(LocalDate(2026, 7, 14), ShiftKind.OFF, LocalDate(2026, 7, 15)) ) ) // 7/10 翻转为休 assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 7, 10))) - // 7/11-13 受第一个断点:班班休 + // 7/11-13 受第一个重排(7/11 起):班班休 assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 7, 11))) assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 7, 12))) assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 7, 13))) // 7/14 翻转为休 assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 7, 14))) - // 7/15 起受第二个断点:班班休休 + // 7/15 起受第二个重排:班班休休 assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 7, 15))) assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 7, 16))) assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 7, 17))) @@ -352,21 +336,17 @@ class ShiftPatternTest { } /** - * 撤销长按:移除 7/10 的 override 和关联的 7/11 断点后,回到基础周期。 + * 撤销长按:移除整个 RephaseFlip 后,回到基础周期。 */ @Test fun longPress_undo_returnsToBaseCycle() { val before = ShiftPattern( anchorDate = LocalDate(2026, 7, 10), cycle = listOf(ShiftKind.WORK, ShiftKind.WORK, ShiftKind.OFF, ShiftKind.OFF), - overrides = mapOf(LocalDate(2026, 7, 10) to ShiftKind.OFF), - phaseBreaks = listOf(PhaseBreak(LocalDate(2026, 7, 11), 0)) - ) - // 撤销:移除 override 和断点 - val after = before.copy( - overrides = before.overrides - LocalDate(2026, 7, 10), - phaseBreaks = emptyList() + rephaseFlips = listOf(RephaseFlip(LocalDate(2026, 7, 10), ShiftKind.OFF, LocalDate(2026, 7, 11))) ) + // 撤销:移除整个 rephaseFlip(原子删除,不会留孤立断点) + val after = before.copy(rephaseFlips = emptyList()) // 回到基础:7/10=班(锚点),7/12=休 assertEquals(ShiftKind.WORK, after.kindAt(LocalDate(2026, 7, 10))) assertEquals(ShiftKind.WORK, after.kindAt(LocalDate(2026, 7, 11))) From 4b098442c40f6156649fc71cc88d59788b9cd5ca Mon Sep 17 00:00:00 2001 From: xfy Date: Thu, 9 Jul 2026 16:36:36 +0800 Subject: [PATCH 3/8] fix(shift): badge color contrast and dynamic calendar rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 角标文字色按背景类别配对:班=onPrimary,休=onError,起=onTertiary - 迷你月历复用 getMonthGridInfo 动态算行数(4/5/6),不再固定 6 行 --- .../plus/rua/project/ui/ShiftCalendarGrid.kt | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/core/src/main/kotlin/plus/rua/project/ui/ShiftCalendarGrid.kt b/core/src/main/kotlin/plus/rua/project/ui/ShiftCalendarGrid.kt index b5ad972..c24a99f 100644 --- a/core/src/main/kotlin/plus/rua/project/ui/ShiftCalendarGrid.kt +++ b/core/src/main/kotlin/plus/rua/project/ui/ShiftCalendarGrid.kt @@ -69,9 +69,10 @@ fun ShiftCalendarGrid( 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 + // 复用主日历的网格计算(周一为首日,动态行数 4/5/6) + val gridInfo = remember(viewYear, viewMonth) { getMonthGridInfo(viewYear, viewMonth) } + val firstWeekdayOffset = gridInfo.offset + val rows = gridInfo.rows Card( modifier = modifier.fillMaxWidth(), @@ -115,12 +116,12 @@ fun ShiftCalendarGrid( } } - (0 until 6).forEach { row -> + (0 until rows).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) { + if (dayNum in 1..gridInfo.daysInMonth) { val date = LocalDate(viewYear, Month(viewMonth), dayNum) ShiftDayCell( date = date, @@ -209,12 +210,19 @@ private fun ShiftDayCell( // rephaseFlip 的 rephaseFrom = 当天,表示该天为重排起点 val isRephaseStart = pattern.rephaseFlips.any { it.rephaseFrom == date } + // 背景色与文字色配对,保证对比度(对照 DayCell 的配色风格) val badgeColor = when { isRephaseStart -> MaterialTheme.colorScheme.tertiary kind == ShiftKind.WORK -> MaterialTheme.colorScheme.primary kind == ShiftKind.OFF -> MaterialTheme.colorScheme.error else -> Color.Transparent } + val badgeTextColor = when { + isRephaseStart -> MaterialTheme.colorScheme.onTertiary + kind == ShiftKind.WORK -> MaterialTheme.colorScheme.onPrimary + kind == ShiftKind.OFF -> MaterialTheme.colorScheme.onError + else -> Color.Transparent + } val badgeText = when { isRephaseStart -> "起" kind == ShiftKind.WORK -> "班" @@ -243,7 +251,7 @@ private fun ShiftDayCell( ) { Text( text = badgeText, - color = MaterialTheme.colorScheme.onPrimary, + color = badgeTextColor, fontSize = 8.sp, fontWeight = FontWeight.Bold, lineHeight = 8.sp From c68944bd55b9c5e031ef649fe5e73dc2273e567c Mon Sep 17 00:00:00 2001 From: xfy Date: Thu, 9 Jul 2026 16:38:57 +0800 Subject: [PATCH 4/8] feat(shift): undoable reset-to-default via Snackbar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 恢复默认改为 Snackbar 撤销模式: - 点恢复默认立即生效 + 显示 5 秒"撤销"Snackbar - 点撤销恢复之前的 pattern - 删除 AlertDialog 确认框(更现代,防误操作) - 首次在项目引入 SnackbarHost --- .../plus/rua/project/ui/ShiftPatternScreen.kt | 45 ++++++++++--------- 1 file changed, 24 insertions(+), 21 deletions(-) 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 9139b36..df54587 100644 --- a/core/src/main/kotlin/plus/rua/project/ui/ShiftPatternScreen.kt +++ b/core/src/main/kotlin/plus/rua/project/ui/ShiftPatternScreen.kt @@ -13,7 +13,6 @@ 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 @@ -25,6 +24,10 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar @@ -34,6 +37,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -42,6 +46,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone +import kotlinx.coroutines.launch import kotlinx.datetime.atStartOfDayIn import kotlinx.datetime.toLocalDateTime import plus.rua.project.CalendarViewModel @@ -73,7 +78,8 @@ fun ShiftPatternScreen(onBack: () -> Unit) { LaunchedEffect(pattern) { storage.save(pattern) } var showAnchorPicker by remember { mutableStateOf(false) } - var showResetDialog by remember { mutableStateOf(false) } + val snackbarHostState = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() Scaffold( topBar = { @@ -85,7 +91,8 @@ fun ShiftPatternScreen(onBack: () -> Unit) { } } ) - } + }, + snackbarHost = { SnackbarHost(hostState = snackbarHostState) } ) { padding -> Column( modifier = Modifier @@ -167,7 +174,20 @@ fun ShiftPatternScreen(onBack: () -> Unit) { ) OutlinedButton( - onClick = { showResetDialog = true }, + onClick = { + val previous = pattern + pattern = CalendarViewModel.DEFAULT_PATTERN + scope.launch { + val result = snackbarHostState.showSnackbar( + message = "已恢复默认设置", + actionLabel = "撤销", + duration = SnackbarDuration.Short + ) + if (result == SnackbarResult.ActionPerformed) { + pattern = previous + } + } + }, modifier = Modifier.fillMaxWidth() ) { Text("恢复默认") @@ -194,23 +214,6 @@ fun ShiftPatternScreen(onBack: () -> Unit) { 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 = From 62ed8bd09a02d2c4b75e77ec7c61ae07b48e5985 Mon Sep 17 00:00:00 2001 From: xfy Date: Thu, 9 Jul 2026 16:46:38 +0800 Subject: [PATCH 5/8] fix(shift): align anchor date with cycle row and trim top padding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 顶部内边距过高:Scaffold padding 已含 TopAppBar 间距, Column 去掉多余 vertical=8.dp,避免叠加 - 锚点日期右侧不齐:TextButton 自带内边距导致右边缘内缩, 改为 Text+clickable 与"周期"行纯 Text 结构一致,右侧对齐 --- .../kotlin/plus/rua/project/ui/ShiftPatternScreen.kt | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) 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 df54587..4ba05cb 100644 --- a/core/src/main/kotlin/plus/rua/project/ui/ShiftPatternScreen.kt +++ b/core/src/main/kotlin/plus/rua/project/ui/ShiftPatternScreen.kt @@ -1,5 +1,6 @@ package plus.rua.project.ui +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi @@ -99,7 +100,7 @@ fun ShiftPatternScreen(onBack: () -> Unit) { .fillMaxSize() .padding(padding) .verticalScroll(rememberScrollState()) - .padding(horizontal = 16.dp, vertical = 8.dp), + .padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp) ) { Text("基础周期", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) @@ -117,9 +118,12 @@ fun ShiftPatternScreen(onBack: () -> Unit) { verticalAlignment = Alignment.CenterVertically ) { Text("锚点日期", style = MaterialTheme.typography.bodyMedium) - TextButton(onClick = { showAnchorPicker = true }) { - Text(pattern.anchorDate.toString()) - } + Text( + text = pattern.anchorDate.toString(), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.clickable { showAnchorPicker = true } + ) } Row( modifier = Modifier.fillMaxWidth(), From 1cf950817e2ac07cd22b97c1fe7adc6fc9e331db Mon Sep 17 00:00:00 2001 From: xfy Date: Thu, 9 Jul 2026 16:55:22 +0800 Subject: [PATCH 6/8] feat(shift): tap year-month title to pick date in mini calendar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 点击迷你月历顶部"年 月"标题弹出 DatePicker, 选定日期后跳转到对应月份,比连点箭头快。 --- .../plus/rua/project/ui/ShiftCalendarGrid.kt | 46 ++++++++++++++++++- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/core/src/main/kotlin/plus/rua/project/ui/ShiftCalendarGrid.kt b/core/src/main/kotlin/plus/rua/project/ui/ShiftCalendarGrid.kt index c24a99f..65b3c20 100644 --- a/core/src/main/kotlin/plus/rua/project/ui/ShiftCalendarGrid.kt +++ b/core/src/main/kotlin/plus/rua/project/ui/ShiftCalendarGrid.kt @@ -2,6 +2,7 @@ package plus.rua.project.ui import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -17,10 +18,15 @@ 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.DatePicker +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberDatePickerState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -37,12 +43,15 @@ import kotlinx.datetime.DatePeriod import kotlinx.datetime.LocalDate import kotlinx.datetime.Month import kotlinx.datetime.TimeZone +import kotlinx.datetime.atStartOfDayIn import kotlinx.datetime.isoDayNumber import kotlinx.datetime.minus import kotlinx.datetime.number import kotlinx.datetime.plus +import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.todayIn import kotlin.time.Clock +import kotlin.time.Instant import plus.rua.project.RephaseFlip import plus.rua.project.ShiftKind import plus.rua.project.ShiftPattern @@ -58,7 +67,7 @@ import plus.rua.project.ShiftPattern * @param onPatternChange 修改后的新 pattern 回调 * @param modifier 外部布局修饰符 */ -@OptIn(ExperimentalFoundationApi::class) +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @Composable fun ShiftCalendarGrid( pattern: ShiftPattern, @@ -68,6 +77,7 @@ fun ShiftCalendarGrid( val today = remember { Clock.System.todayIn(TimeZone.currentSystemDefault()) } var viewYear by remember { mutableStateOf(today.year) } var viewMonth by remember { mutableStateOf(today.month.number) } + var showMonthPicker by remember { mutableStateOf(false) } // 复用主日历的网格计算(周一为首日,动态行数 4/5/6) val gridInfo = remember(viewYear, viewMonth) { getMonthGridInfo(viewYear, viewMonth) } @@ -94,7 +104,8 @@ fun ShiftCalendarGrid( Text( text = "${viewYear}年 ${viewMonth}月", style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold + fontWeight = FontWeight.SemiBold, + modifier = Modifier.clickable { showMonthPicker = true } ) IconButton(onClick = { if (viewMonth == 12) { viewMonth = 1; viewYear += 1 } else viewMonth += 1 @@ -138,6 +149,31 @@ fun ShiftCalendarGrid( } } } + + if (showMonthPicker) { + val initialDate = LocalDate(viewYear, Month(viewMonth), 1) + val datePickerState = rememberDatePickerState( + initialSelectedDateMillis = initialDate.toEpochMillis() + ) + DatePickerDialog( + onDismissRequest = { showMonthPicker = false }, + confirmButton = { + TextButton(onClick = { + datePickerState.selectedDateMillis?.let { millis -> + val picked = millis.toLocalDate() + viewYear = picked.year + viewMonth = picked.month.number + } + showMonthPicker = false + }) { Text("确定") } + }, + dismissButton = { + TextButton(onClick = { showMonthPicker = false }) { Text("取消") } + } + ) { + DatePicker(state = datePickerState) + } + } } /** @@ -260,3 +296,9 @@ private fun ShiftDayCell( } } } + +private fun LocalDate.toEpochMillis(): Long = + this.atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds() + +private fun Long.toLocalDate(): LocalDate = + Instant.fromEpochMilliseconds(this).toLocalDateTime(TimeZone.UTC).date From 7b1290dd66fb7925857124446fa7a69f88670c37 Mon Sep 17 00:00:00 2001 From: xfy Date: Thu, 9 Jul 2026 17:07:38 +0800 Subject: [PATCH 7/8] feat(shift): swipeable mini calendar via HorizontalPager MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 迷你月历接入 HorizontalPager,支持左右滑动翻月: - 复用主日历 START_PAGE/pageToYearMonth/yearMonthToPage 工具 - viewYear/viewMonth 由 pagerState.currentPage 派生 - 箭头按钮改为 animateScrollToPage - DatePicker 选月后 animateScrollToPage 跳转 - 网格提取为 MonthGrid 单页组件 --- .../plus/rua/project/ui/ShiftCalendarGrid.kt | 107 +++++++++++++----- 1 file changed, 78 insertions(+), 29 deletions(-) diff --git a/core/src/main/kotlin/plus/rua/project/ui/ShiftCalendarGrid.kt b/core/src/main/kotlin/plus/rua/project/ui/ShiftCalendarGrid.kt index 65b3c20..5751382 100644 --- a/core/src/main/kotlin/plus/rua/project/ui/ShiftCalendarGrid.kt +++ b/core/src/main/kotlin/plus/rua/project/ui/ShiftCalendarGrid.kt @@ -11,6 +11,8 @@ 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.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -28,9 +30,11 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.rememberDatePickerState import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -39,6 +43,7 @@ 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.coroutines.launch import kotlinx.datetime.DatePeriod import kotlinx.datetime.LocalDate import kotlinx.datetime.Month @@ -75,14 +80,19 @@ fun ShiftCalendarGrid( 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 initialYear = remember { today.year } + val initialMonth = remember { today.month.number } var showMonthPicker by remember { mutableStateOf(false) } + val coroutineScope = rememberCoroutineScope() - // 复用主日历的网格计算(周一为首日,动态行数 4/5/6) - val gridInfo = remember(viewYear, viewMonth) { getMonthGridInfo(viewYear, viewMonth) } - val firstWeekdayOffset = gridInfo.offset - val rows = gridInfo.rows + // Pager 居中无限页,与主日历一致;viewYear/viewMonth 由当前页派生 + val pagerState = rememberPagerState(initialPage = START_PAGE, pageCount = { Int.MAX_VALUE }) + val viewYear by remember { + derivedStateOf { pageToYearMonth(pagerState.currentPage, initialYear, initialMonth).first } + } + val viewMonth by remember { + derivedStateOf { pageToYearMonth(pagerState.currentPage, initialYear, initialMonth).second } + } Card( modifier = modifier.fillMaxWidth(), @@ -97,7 +107,7 @@ fun ShiftCalendarGrid( verticalAlignment = Alignment.CenterVertically ) { IconButton(onClick = { - if (viewMonth == 1) { viewMonth = 12; viewYear -= 1 } else viewMonth -= 1 + coroutineScope.launch { pagerState.animateScrollToPage(pagerState.currentPage - 1) } }) { Icon(Icons.Filled.ChevronLeft, contentDescription = "上个月") } @@ -108,7 +118,7 @@ fun ShiftCalendarGrid( modifier = Modifier.clickable { showMonthPicker = true } ) IconButton(onClick = { - if (viewMonth == 12) { viewMonth = 1; viewYear += 1 } else viewMonth += 1 + coroutineScope.launch { pagerState.animateScrollToPage(pagerState.currentPage + 1) } }) { Icon(Icons.Filled.ChevronRight, contentDescription = "下个月") } @@ -127,25 +137,20 @@ fun ShiftCalendarGrid( } } - (0 until rows).forEach { row -> - Row(Modifier.fillMaxWidth()) { - (0 until 7).forEach { col -> - val cellIndex = row * 7 + col - val dayNum = cellIndex - firstWeekdayOffset + 1 - if (dayNum in 1..gridInfo.daysInMonth) { - val date = LocalDate(viewYear, Month(viewMonth), dayNum) - ShiftDayCell( - date = date, - pattern = pattern, - onClick = { onPatternChange(toggleOverride(pattern, date)) }, - onLongClick = { onPatternChange(toggleFlipAndRephase(pattern, date)) }, - modifier = Modifier.weight(1f) - ) - } else { - Box(Modifier.weight(1f).aspectRatio(1f)) - } - } - } + // 滑动翻页区域:每页一个月的网格 + HorizontalPager( + state = pagerState, + beyondViewportPageCount = 0, + modifier = Modifier.fillMaxWidth() + ) { page -> + val (year, month) = pageToYearMonth(page, initialYear, initialMonth) + MonthGrid( + year = year, + month = month, + pattern = pattern, + onPatternChange = onPatternChange, + modifier = Modifier.fillMaxWidth() + ) } } } @@ -161,8 +166,10 @@ fun ShiftCalendarGrid( TextButton(onClick = { datePickerState.selectedDateMillis?.let { millis -> val picked = millis.toLocalDate() - viewYear = picked.year - viewMonth = picked.month.number + val targetPage = yearMonthToPage( + picked.year, picked.month.number, initialYear, initialMonth + ) + coroutineScope.launch { pagerState.animateScrollToPage(targetPage) } } showMonthPicker = false }) { Text("确定") } @@ -176,6 +183,48 @@ fun ShiftCalendarGrid( } } +/** + * 迷你月历的单页网格。复用主日历 [getMonthGridInfo] 计算行数与偏移。 + * + * @param year 年 + * @param month 月(1-12) + * @param pattern 当前轮班配置(只读) + * @param onPatternChange 修改后的新 pattern 回调 + * @param modifier 外部布局修饰符 + */ +@Composable +private fun MonthGrid( + year: Int, + month: Int, + pattern: ShiftPattern, + onPatternChange: (ShiftPattern) -> Unit, + modifier: Modifier = Modifier +) { + val gridInfo = remember(year, month) { getMonthGridInfo(year, month) } + Column(modifier) { + (0 until gridInfo.rows).forEach { row -> + Row(Modifier.fillMaxWidth()) { + (0 until 7).forEach { col -> + val cellIndex = row * 7 + col + val dayNum = cellIndex - gridInfo.offset + 1 + if (dayNum in 1..gridInfo.daysInMonth) { + val date = LocalDate(year, Month(month), dayNum) + ShiftDayCell( + date = date, + pattern = pattern, + onClick = { onPatternChange(toggleOverride(pattern, date)) }, + onLongClick = { onPatternChange(toggleFlipAndRephase(pattern, date)) }, + modifier = Modifier.weight(1f) + ) + } else { + Box(Modifier.weight(1f).aspectRatio(1f)) + } + } + } + } + } +} + /** * 翻转某天的班/休(单日 override)。翻转后若与基础周期值一致,则移除 override。 * From 2a3089bf479ab59e5d772dae77afc25ca901d72b Mon Sep 17 00:00:00 2001 From: xfy Date: Thu, 9 Jul 2026 17:14:54 +0800 Subject: [PATCH 8/8] feat(shift): smooth height interpolation for mini calendar swipe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 滑动翻月时高度平滑过渡,不再跳变: - 复用主日历的 interpolatedWeeks 行数插值逻辑 (currentPageOffsetFraction 在当前页与目标页行数间 lerp) - 行高由 BoxWithConstraints 宽度推算(格宽=宽/7,aspectRatio(1f) 使行高=格宽) - HorizontalPager 套 Modifier.height(px).clipToBounds(),跟随手指实时变高 - 迷你月历简化版:无折叠动画,只保留展开态 gridHeightPx = rowH * weeks --- .../plus/rua/project/ui/ShiftCalendarGrid.kt | 59 ++++++++++++++----- 1 file changed, 45 insertions(+), 14 deletions(-) diff --git a/core/src/main/kotlin/plus/rua/project/ui/ShiftCalendarGrid.kt b/core/src/main/kotlin/plus/rua/project/ui/ShiftCalendarGrid.kt index 5751382..361b0b4 100644 --- a/core/src/main/kotlin/plus/rua/project/ui/ShiftCalendarGrid.kt +++ b/core/src/main/kotlin/plus/rua/project/ui/ShiftCalendarGrid.kt @@ -6,10 +6,12 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints 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.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState @@ -38,6 +40,8 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -55,6 +59,7 @@ import kotlinx.datetime.number import kotlinx.datetime.plus import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.todayIn +import kotlin.math.abs import kotlin.time.Clock import kotlin.time.Instant import plus.rua.project.RephaseFlip @@ -93,6 +98,7 @@ fun ShiftCalendarGrid( val viewMonth by remember { derivedStateOf { pageToYearMonth(pagerState.currentPage, initialYear, initialMonth).second } } + val density = LocalDensity.current Card( modifier = modifier.fillMaxWidth(), @@ -137,20 +143,45 @@ fun ShiftCalendarGrid( } } - // 滑动翻页区域:每页一个月的网格 - HorizontalPager( - state = pagerState, - beyondViewportPageCount = 0, - modifier = Modifier.fillMaxWidth() - ) { page -> - val (year, month) = pageToYearMonth(page, initialYear, initialMonth) - MonthGrid( - year = year, - month = month, - pattern = pattern, - onPatternChange = onPatternChange, - modifier = Modifier.fillMaxWidth() - ) + // 用 BoxWithConstraints 拿宽度,推算行高(格宽=宽/7,aspectRatio(1f) 使行高=格宽) + BoxWithConstraints(Modifier.fillMaxWidth()) { + val cellWidthPx = constraints.maxWidth / 7 + val rowHeightPx = cellWidthPx + + // 行数插值:滑动时在当前页与目标页行数间线性过渡,避免高度跳变 + val interpolatedWeeks by remember { + derivedStateOf { + val fraction = pagerState.currentPageOffsetFraction + if (abs(fraction) > OFFSET_FRACTION_THRESHOLD) { + val cp = pagerState.currentPage + val baseWeeks = calculateWeeksCountForPage(cp, today) + val targetPage = cp + if (fraction > 0) 1 else -1 + val targetWeeks = calculateWeeksCountForPage(targetPage, today) + lerp(baseWeeks.toFloat(), targetWeeks.toFloat(), abs(fraction)) + } else { + calculateWeeksCountForPage(pagerState.currentPage, today).toFloat() + } + } + } + val gridHeightPx = (rowHeightPx * interpolatedWeeks).toInt() + + HorizontalPager( + state = pagerState, + beyondViewportPageCount = 0, + modifier = Modifier + .fillMaxWidth() + .height(with(density) { gridHeightPx.toDp() }) + .clipToBounds() + ) { page -> + val (year, month) = pageToYearMonth(page, initialYear, initialMonth) + MonthGrid( + year = year, + month = month, + pattern = pattern, + onPatternChange = onPatternChange, + modifier = Modifier.fillMaxWidth() + ) + } } } }