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))) + } }