feat: refactor shift pattern with atomic rephase and swipeable calendar
班次设置页重构与体验增强: 数据模型重构: - phaseBreaks + override 两独立字段 → 原子 RephaseFlip(date, flippedTo, rephaseFrom) - 解决幽灵重排/撤销误删/断点链不级联三大问题 体验改进: - 角标颜色按类别配对(onPrimary/onError/onTertiary) - 迷你月历动态行数(复用 getMonthGridInfo) - 恢复默认改 Snackbar 撤销(首次引入 SnackbarHost) - 锚点日期与周期行右侧对齐修复 - 点击年月标题弹 DatePicker 选月份跳转 - HorizontalPager 滑动翻月 + 高度插值平滑过渡
This commit is contained in:
commit
afea8bf109
137
.zcode/plans/plan-sess_ace6e49c-6557-47c4-9e42-2b0bbfd83e00.md
Normal file
137
.zcode/plans/plan-sess_ace6e49c-6557-47c4-9e42-2b0bbfd83e00.md
Normal file
@ -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<ShiftKind>,
|
||||||
|
val overrides: Map<LocalDate, ShiftKind> = emptyMap(), // 保留:纯单日翻转(点)
|
||||||
|
val rephaseFlips: List<RephaseFlip> = 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<RephaseFlip>`
|
||||||
|
- 重写 `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 用法正确,不破坏现有布局。
|
||||||
@ -9,55 +9,67 @@ import kotlinx.datetime.daysUntil
|
|||||||
enum class ShiftKind { WORK, OFF }
|
enum class ShiftKind { WORK, OFF }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 相位断点:从 [date] 起重排周期相位,[cycleOffset] 指定当天对应 cycle 的第几位。
|
* "翻转并重排"的原子记录:翻转 [date] 当天为 [flippedTo],
|
||||||
|
* 并从 [rephaseFrom] 起重排周期相位(后续按 cycle 重新顺延)。
|
||||||
*
|
*
|
||||||
* @param date 断点生效日(含当天)
|
* date 与 rephaseFrom 通常相邻(rephaseFrom = date 的次日),
|
||||||
* @param cycleOffset 该天对应的 cycle 索引(0 = cycle[0])
|
* 作为单一操作的两面绑定存储,撤销时整体删除,不会留下孤立数据。
|
||||||
|
*
|
||||||
|
* @param date 被翻转的天
|
||||||
|
* @param flippedTo 翻转后的班次
|
||||||
|
* @param rephaseFrom 重排起点(含当天),该天起按 cycle[0] 重新循环
|
||||||
*/
|
*/
|
||||||
data class PhaseBreak(
|
data class RephaseFlip(
|
||||||
val date: LocalDate,
|
val date: LocalDate,
|
||||||
val cycleOffset: Int
|
val flippedTo: ShiftKind,
|
||||||
|
val rephaseFrom: LocalDate
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 个人轮班周期。
|
* 个人轮班周期。
|
||||||
*
|
*
|
||||||
* 与法定节假日完全独立。某天的班/休由以下顺序决定:
|
* 与法定节假日完全独立。某天的班/休由以下顺序决定:
|
||||||
* 1. 若 [overrides] 命中该天,取 override 值(单日翻转);
|
* 1. 若 [overrides] 命中该天,取 override 值(单日翻转,仅当天);
|
||||||
* 2. 否则取该天"活跃锚点"(最近的 phaseBreak 或基础 anchorDate)起算的 cycle 索引。
|
* 2. 否则若 [rephaseFlips] 中有该天作为翻转日的记录,取 [RephaseFlip.flippedTo];
|
||||||
|
* 3. 否则取该天"活跃锚点"(最近的 rephaseFlips.rephaseFrom 或基础 anchorDate)
|
||||||
|
* 起算的 cycle 索引。
|
||||||
*
|
*
|
||||||
* @param anchorDate 基础周期基准日,对应 cycle[0]
|
* @param anchorDate 基础周期基准日,对应 cycle[0]
|
||||||
* @param cycle 一个周期内的班次序列,例如 [WORK, WORK, OFF, OFF] 表示 "2 班 2 休"
|
* @param cycle 一个周期内的班次序列,例如 [WORK, WORK, OFF, OFF] 表示 "2 班 2 休"
|
||||||
* @param overrides 单日翻转映射(调班),key 为日期
|
* @param overrides 单日翻转映射(仅当天,不影响后续),key 为日期
|
||||||
* @param phaseBreaks 相位断点列表,从某天起重排周期相位
|
* @param rephaseFlips 翻转并重排记录列表;每条翻转某天并从次日起重排周期
|
||||||
* @param name 方案名
|
* @param name 方案名
|
||||||
*/
|
*/
|
||||||
data class ShiftPattern(
|
data class ShiftPattern(
|
||||||
val anchorDate: LocalDate,
|
val anchorDate: LocalDate,
|
||||||
val cycle: List<ShiftKind>,
|
val cycle: List<ShiftKind>,
|
||||||
val overrides: Map<LocalDate, ShiftKind> = emptyMap(),
|
val overrides: Map<LocalDate, ShiftKind> = emptyMap(),
|
||||||
val phaseBreaks: List<PhaseBreak> = emptyList(),
|
val rephaseFlips: List<RephaseFlip> = emptyList(),
|
||||||
val name: String = "默认"
|
val name: String = "默认"
|
||||||
) {
|
) {
|
||||||
/**
|
/**
|
||||||
* 返回 [date] 当天的班次。优先级:overrides → 活跃锚点的 cycle 索引。
|
* 返回 [date] 当天的班次。优先级:overrides → rephaseFlip 当天 → 活跃锚点的 cycle 索引。
|
||||||
* cycle 为空时返回 null。
|
* cycle 为空时返回 null。
|
||||||
*/
|
*/
|
||||||
fun kindAt(date: LocalDate): ShiftKind? {
|
fun kindAt(date: LocalDate): ShiftKind? {
|
||||||
if (cycle.isEmpty()) return null
|
if (cycle.isEmpty()) return null
|
||||||
// 1. 单日翻转优先
|
// 1. 单日翻转优先(仅当天)
|
||||||
overrides[date]?.let { return it }
|
overrides[date]?.let { return it }
|
||||||
// 2. 找活跃锚点:phaseBreaks 中 date <= 当天 的最大那个;无则用基础锚点
|
// 2. rephaseFlips 中被翻转的当天
|
||||||
val (anchor, offset) = activeAnchor(date)
|
rephaseFlips.find { it.date == date }?.let { return it.flippedTo }
|
||||||
|
// 3. 找活跃锚点:rephaseFlips 中 rephaseFrom <= 当天 的最大那个;无则用基础锚点
|
||||||
|
val anchor = activeAnchor(date)
|
||||||
val diff = anchor.daysUntil(date)
|
val diff = anchor.daysUntil(date)
|
||||||
val size = cycle.size
|
val size = cycle.size
|
||||||
val idx = (((diff + offset) % size) + size) % size
|
val idx = ((diff % size) + size) % size
|
||||||
return cycle[idx]
|
return cycle[idx]
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun activeAnchor(date: LocalDate): Pair<LocalDate, Int> {
|
private fun activeAnchor(date: LocalDate): LocalDate {
|
||||||
val applicable = phaseBreaks.filter { it.date <= date }.maxByOrNull { it.date }
|
return rephaseFlips
|
||||||
return if (applicable != null) applicable.date to applicable.cycleOffset
|
.filter { it.rephaseFrom <= date }
|
||||||
else anchorDate to 0
|
.maxByOrNull { it.rephaseFrom }
|
||||||
|
?.rephaseFrom
|
||||||
|
?: anchorDate
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,9 +10,8 @@ import kotlinx.datetime.LocalDate
|
|||||||
* 编码格式(无 JSON 依赖):
|
* 编码格式(无 JSON 依赖):
|
||||||
* - 锚点:ISO 日期串 "2026-07-08"
|
* - 锚点:ISO 日期串 "2026-07-08"
|
||||||
* - 周期:逗号分隔的 1/0 串 "1,1,0,0"(1=WORK,0=OFF)
|
* - 周期:逗号分隔的 1/0 串 "1,1,0,0"(1=WORK,0=OFF)
|
||||||
* - overrides/breaks:逗号分隔的 "日期:值" 对
|
* - overrides:逗号分隔的 "日期:值" 对,值 1=WORK,0=OFF
|
||||||
* - overrides 值:1=WORK,0=OFF
|
* - rephaseFlips:逗号分隔的 "翻转日:值:重排起点" 三元组,值 1=WORK,0=OFF
|
||||||
* - breaks 值:cycleOffset(整数)
|
|
||||||
*/
|
*/
|
||||||
class ShiftPatternStorage(private val prefs: SharedPreferences) {
|
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_ANCHOR = "shift_anchor"
|
||||||
private const val KEY_CYCLE = "shift_cycle"
|
private const val KEY_CYCLE = "shift_cycle"
|
||||||
private const val KEY_OVERRIDES = "shift_overrides"
|
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 KEY_NAME = "shift_name"
|
||||||
private const val SEP = ","
|
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 cycleStr = pattern.cycle.joinToString(SEP) { if (it == ShiftKind.WORK) "1" else "0" }
|
||||||
val overridesStr = pattern.overrides.entries
|
val overridesStr = pattern.overrides.entries
|
||||||
.joinToString(SEP) { "${it.key}:${if (it.value == ShiftKind.WORK) 1 else 0}" }
|
.joinToString(SEP) { "${it.key}:${if (it.value == ShiftKind.WORK) 1 else 0}" }
|
||||||
val breaksStr = pattern.phaseBreaks
|
val rephaseStr = pattern.rephaseFlips
|
||||||
.joinToString(SEP) { "${it.date}:${it.cycleOffset}" }
|
.joinToString(SEP) {
|
||||||
|
"${it.date}:${if (it.flippedTo == ShiftKind.WORK) 1 else 0}:${it.rephaseFrom}"
|
||||||
|
}
|
||||||
prefs.edit()
|
prefs.edit()
|
||||||
.putString(KEY_ANCHOR, pattern.anchorDate.toString())
|
.putString(KEY_ANCHOR, pattern.anchorDate.toString())
|
||||||
.putString(KEY_CYCLE, cycleStr)
|
.putString(KEY_CYCLE, cycleStr)
|
||||||
.putString(KEY_OVERRIDES, overridesStr)
|
.putString(KEY_OVERRIDES, overridesStr)
|
||||||
.putString(KEY_BREAKS, breaksStr)
|
.putString(KEY_REPHASE, rephaseStr)
|
||||||
.putString(KEY_NAME, pattern.name)
|
.putString(KEY_NAME, pattern.name)
|
||||||
.apply()
|
.apply()
|
||||||
}
|
}
|
||||||
@ -56,8 +57,8 @@ class ShiftPatternStorage(private val prefs: SharedPreferences) {
|
|||||||
cycleStr.split(SEP).map { if (it.trim() == "1") ShiftKind.WORK else ShiftKind.OFF }
|
cycleStr.split(SEP).map { if (it.trim() == "1") ShiftKind.WORK else ShiftKind.OFF }
|
||||||
}
|
}
|
||||||
val overrides = parseOverrides(prefs.getString(KEY_OVERRIDES, null))
|
val overrides = parseOverrides(prefs.getString(KEY_OVERRIDES, null))
|
||||||
val breaks = parseBreaks(prefs.getString(KEY_BREAKS, null))
|
val rephaseFlips = parseRephase(prefs.getString(KEY_REPHASE, null))
|
||||||
ShiftPattern(anchor, cycle, overrides, breaks, name = prefs.getString(KEY_NAME, null) ?: "默认")
|
ShiftPattern(anchor, cycle, overrides, rephaseFlips, name = prefs.getString(KEY_NAME, null) ?: "默认")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
@ -71,11 +72,16 @@ class ShiftPatternStorage(private val prefs: SharedPreferences) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parseBreaks(s: String?): List<PhaseBreak> {
|
private fun parseRephase(s: String?): List<RephaseFlip> {
|
||||||
if (s.isNullOrBlank()) return emptyList()
|
if (s.isNullOrBlank()) return emptyList()
|
||||||
return s.split(SEP).map { pair ->
|
return s.split(SEP).map { triple ->
|
||||||
val parts = pair.split(":")
|
// 格式:翻转日:值:重排起点(ISO 日期不含冒号,split 安全)
|
||||||
PhaseBreak(LocalDate.parse(parts[0]), parts[1].trim().toInt())
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,14 +2,19 @@ package plus.rua.project.ui
|
|||||||
|
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.combinedClickable
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.aspectRatio
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
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.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
@ -17,33 +22,47 @@ import androidx.compose.material.icons.filled.ChevronLeft
|
|||||||
import androidx.compose.material.icons.filled.ChevronRight
|
import androidx.compose.material.icons.filled.ChevronRight
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
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.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.rememberDatePickerState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.graphics.Color
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.datetime.DatePeriod
|
import kotlinx.datetime.DatePeriod
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlinx.datetime.Month
|
import kotlinx.datetime.Month
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.atStartOfDayIn
|
||||||
import kotlinx.datetime.isoDayNumber
|
import kotlinx.datetime.isoDayNumber
|
||||||
import kotlinx.datetime.minus
|
import kotlinx.datetime.minus
|
||||||
import kotlinx.datetime.number
|
import kotlinx.datetime.number
|
||||||
import kotlinx.datetime.plus
|
import kotlinx.datetime.plus
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
import kotlinx.datetime.todayIn
|
import kotlinx.datetime.todayIn
|
||||||
|
import kotlin.math.abs
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
import plus.rua.project.PhaseBreak
|
import kotlin.time.Instant
|
||||||
|
import plus.rua.project.RephaseFlip
|
||||||
import plus.rua.project.ShiftKind
|
import plus.rua.project.ShiftKind
|
||||||
import plus.rua.project.ShiftPattern
|
import plus.rua.project.ShiftPattern
|
||||||
|
|
||||||
@ -58,7 +77,7 @@ import plus.rua.project.ShiftPattern
|
|||||||
* @param onPatternChange 修改后的新 pattern 回调
|
* @param onPatternChange 修改后的新 pattern 回调
|
||||||
* @param modifier 外部布局修饰符
|
* @param modifier 外部布局修饰符
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ShiftCalendarGrid(
|
fun ShiftCalendarGrid(
|
||||||
pattern: ShiftPattern,
|
pattern: ShiftPattern,
|
||||||
@ -66,12 +85,20 @@ fun ShiftCalendarGrid(
|
|||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val today = remember { Clock.System.todayIn(TimeZone.currentSystemDefault()) }
|
val today = remember { Clock.System.todayIn(TimeZone.currentSystemDefault()) }
|
||||||
var viewYear by remember { mutableStateOf(today.year) }
|
val initialYear = remember { today.year }
|
||||||
var viewMonth by remember { mutableStateOf(today.month.number) }
|
val initialMonth = remember { today.month.number }
|
||||||
|
var showMonthPicker by remember { mutableStateOf(false) }
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
val firstOfMonth = LocalDate(viewYear, Month(viewMonth), 1)
|
// Pager 居中无限页,与主日历一致;viewYear/viewMonth 由当前页派生
|
||||||
val daysInMonth = firstOfMonth.plus(DatePeriod(months = 1)).minus(DatePeriod(days = 1)).day
|
val pagerState = rememberPagerState(initialPage = START_PAGE, pageCount = { Int.MAX_VALUE })
|
||||||
val firstWeekdayOffset = firstOfMonth.dayOfWeek.isoDayNumber - 1
|
val viewYear by remember {
|
||||||
|
derivedStateOf { pageToYearMonth(pagerState.currentPage, initialYear, initialMonth).first }
|
||||||
|
}
|
||||||
|
val viewMonth by remember {
|
||||||
|
derivedStateOf { pageToYearMonth(pagerState.currentPage, initialYear, initialMonth).second }
|
||||||
|
}
|
||||||
|
val density = LocalDensity.current
|
||||||
|
|
||||||
Card(
|
Card(
|
||||||
modifier = modifier.fillMaxWidth(),
|
modifier = modifier.fillMaxWidth(),
|
||||||
@ -86,17 +113,18 @@ fun ShiftCalendarGrid(
|
|||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
IconButton(onClick = {
|
IconButton(onClick = {
|
||||||
if (viewMonth == 1) { viewMonth = 12; viewYear -= 1 } else viewMonth -= 1
|
coroutineScope.launch { pagerState.animateScrollToPage(pagerState.currentPage - 1) }
|
||||||
}) {
|
}) {
|
||||||
Icon(Icons.Filled.ChevronLeft, contentDescription = "上个月")
|
Icon(Icons.Filled.ChevronLeft, contentDescription = "上个月")
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
text = "${viewYear}年 ${viewMonth}月",
|
text = "${viewYear}年 ${viewMonth}月",
|
||||||
style = MaterialTheme.typography.titleSmall,
|
style = MaterialTheme.typography.titleSmall,
|
||||||
fontWeight = FontWeight.SemiBold
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
modifier = Modifier.clickable { showMonthPicker = true }
|
||||||
)
|
)
|
||||||
IconButton(onClick = {
|
IconButton(onClick = {
|
||||||
if (viewMonth == 12) { viewMonth = 1; viewYear += 1 } else viewMonth += 1
|
coroutineScope.launch { pagerState.animateScrollToPage(pagerState.currentPage + 1) }
|
||||||
}) {
|
}) {
|
||||||
Icon(Icons.Filled.ChevronRight, contentDescription = "下个月")
|
Icon(Icons.Filled.ChevronRight, contentDescription = "下个月")
|
||||||
}
|
}
|
||||||
@ -115,34 +143,131 @@ fun ShiftCalendarGrid(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
(0 until 6).forEach { row ->
|
// 用 BoxWithConstraints 拿宽度,推算行高(格宽=宽/7,aspectRatio(1f) 使行高=格宽)
|
||||||
Row(Modifier.fillMaxWidth()) {
|
BoxWithConstraints(Modifier.fillMaxWidth()) {
|
||||||
(0 until 7).forEach { col ->
|
val cellWidthPx = constraints.maxWidth / 7
|
||||||
val cellIndex = row * 7 + col
|
val rowHeightPx = cellWidthPx
|
||||||
val dayNum = cellIndex - firstWeekdayOffset + 1
|
|
||||||
if (dayNum in 1..daysInMonth) {
|
// 行数插值:滑动时在当前页与目标页行数间线性过渡,避免高度跳变
|
||||||
val date = LocalDate(viewYear, Month(viewMonth), dayNum)
|
val interpolatedWeeks by remember {
|
||||||
ShiftDayCell(
|
derivedStateOf {
|
||||||
date = date,
|
val fraction = pagerState.currentPageOffsetFraction
|
||||||
pattern = pattern,
|
if (abs(fraction) > OFFSET_FRACTION_THRESHOLD) {
|
||||||
onClick = { onPatternChange(toggleOverride(pattern, date)) },
|
val cp = pagerState.currentPage
|
||||||
onLongClick = { onPatternChange(toggleFlipAndRephase(pattern, date)) },
|
val baseWeeks = calculateWeeksCountForPage(cp, today)
|
||||||
modifier = Modifier.weight(1f)
|
val targetPage = cp + if (fraction > 0) 1 else -1
|
||||||
)
|
val targetWeeks = calculateWeeksCountForPage(targetPage, today)
|
||||||
|
lerp(baseWeeks.toFloat(), targetWeeks.toFloat(), abs(fraction))
|
||||||
} else {
|
} else {
|
||||||
Box(Modifier.weight(1f).aspectRatio(1f))
|
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()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
val targetPage = yearMonthToPage(
|
||||||
|
picked.year, picked.month.number, initialYear, initialMonth
|
||||||
|
)
|
||||||
|
coroutineScope.launch { pagerState.animateScrollToPage(targetPage) }
|
||||||
|
}
|
||||||
|
showMonthPicker = false
|
||||||
|
}) { Text("确定") }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { showMonthPicker = false }) { Text("取消") }
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
DatePicker(state = datePickerState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 迷你月历的单页网格。复用主日历 [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。
|
* 翻转某天的班/休(单日 override)。翻转后若与基础周期值一致,则移除 override。
|
||||||
|
*
|
||||||
|
* 若该天存在 rephaseFlip(长按产生的翻转并重排),则移除整个 rephaseFlip,
|
||||||
|
* 避免留下孤立的"幽灵重排"(只清翻转但保留后续相位重排)。
|
||||||
*/
|
*/
|
||||||
private fun toggleOverride(pattern: ShiftPattern, date: LocalDate): ShiftPattern {
|
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 current = pattern.kindAt(date) ?: return pattern
|
||||||
val newVal = if (current == ShiftKind.WORK) ShiftKind.OFF else ShiftKind.WORK
|
val newVal = if (current == ShiftKind.WORK) ShiftKind.OFF else ShiftKind.WORK
|
||||||
val tempPattern = pattern.copy(overrides = pattern.overrides - date)
|
val tempPattern = pattern.copy(overrides = pattern.overrides - date)
|
||||||
@ -155,35 +280,27 @@ private fun toggleOverride(pattern: ShiftPattern, date: LocalDate): ShiftPattern
|
|||||||
/**
|
/**
|
||||||
* 翻转某天的班/休,并从次日起重排周期(后续按 cycle 重新顺延)。
|
* 翻转某天的班/休,并从次日起重排周期(后续按 cycle 重新顺延)。
|
||||||
*
|
*
|
||||||
* 实现:翻转该天 override + 在 [date] 次日插入 PhaseBreak(offset=0)。
|
* 产出原子记录 [RephaseFlip]:翻转该天 + 从次日([rephaseFrom])起重排。
|
||||||
* 次日成为新的 cycle[0],后续自动按周期顺延。
|
* 次日成为新的 cycle[0],后续自动按周期顺延。
|
||||||
*
|
*
|
||||||
* 撤销:再次长按同一天,若存在由该天派生的断点(次日、offset=0),则移除 override 与该断点。
|
* 撤销:再次长按同一天,按 [RephaseFlip.date] 精确匹配并整体移除(不会误删其他记录)。
|
||||||
*
|
*
|
||||||
* @param date 被翻转并作为重排起点的日期
|
* @param date 被翻转并作为重排起点的日期
|
||||||
*/
|
*/
|
||||||
private fun toggleFlipAndRephase(pattern: ShiftPattern, date: LocalDate): ShiftPattern {
|
private fun toggleFlipAndRephase(pattern: ShiftPattern, date: LocalDate): ShiftPattern {
|
||||||
val nextDay = date.plus(DatePeriod(days = 1))
|
val existing = pattern.rephaseFlips.find { it.date == date }
|
||||||
val derivedBreak = pattern.phaseBreaks.find { it.date == nextDay && it.cycleOffset == 0 }
|
if (existing != null) {
|
||||||
|
// 撤销:整体移除该 rephaseFlip(原子删除)
|
||||||
if (derivedBreak != null) {
|
return pattern.copy(rephaseFlips = pattern.rephaseFlips - existing)
|
||||||
// 撤销:移除该天的 override 与关联断点
|
|
||||||
return pattern.copy(
|
|
||||||
overrides = pattern.overrides - date,
|
|
||||||
phaseBreaks = pattern.phaseBreaks - derivedBreak
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 翻转该天 + 次日插入断点重排
|
// 翻转该天 + 次日重排
|
||||||
val current = pattern.kindAt(date) ?: return pattern
|
val current = pattern.kindAt(date) ?: return pattern
|
||||||
val newVal = if (current == ShiftKind.WORK) ShiftKind.OFF else ShiftKind.WORK
|
val newVal = if (current == ShiftKind.WORK) ShiftKind.OFF else ShiftKind.WORK
|
||||||
val tempPattern = pattern.copy(overrides = pattern.overrides - date)
|
val rephaseFrom = date.plus(DatePeriod(days = 1))
|
||||||
val base = tempPattern.kindAt(date)
|
|
||||||
val newOverrides = if (newVal == base) pattern.overrides - date
|
|
||||||
else pattern.overrides + (date to newVal)
|
|
||||||
return pattern.copy(
|
return pattern.copy(
|
||||||
overrides = newOverrides,
|
overrides = pattern.overrides - date, // 该天改由 rephaseFlip 管辖,移除可能的旧 override
|
||||||
phaseBreaks = pattern.phaseBreaks + PhaseBreak(nextDay, 0)
|
rephaseFlips = pattern.rephaseFlips + RephaseFlip(date, newVal, rephaseFrom)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -206,15 +323,22 @@ private fun ShiftDayCell(
|
|||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val kind = pattern.kindAt(date)
|
val kind = pattern.kindAt(date)
|
||||||
// 断点在"翻转日的次日",表示该天为重排起点
|
// rephaseFlip 的 rephaseFrom = 当天,表示该天为重排起点
|
||||||
val isRephaseStart = pattern.phaseBreaks.any { it.date == date && it.cycleOffset == 0 }
|
val isRephaseStart = pattern.rephaseFlips.any { it.rephaseFrom == date }
|
||||||
|
|
||||||
|
// 背景色与文字色配对,保证对比度(对照 DayCell 的配色风格)
|
||||||
val badgeColor = when {
|
val badgeColor = when {
|
||||||
isRephaseStart -> MaterialTheme.colorScheme.tertiary
|
isRephaseStart -> MaterialTheme.colorScheme.tertiary
|
||||||
kind == ShiftKind.WORK -> MaterialTheme.colorScheme.primary
|
kind == ShiftKind.WORK -> MaterialTheme.colorScheme.primary
|
||||||
kind == ShiftKind.OFF -> MaterialTheme.colorScheme.error
|
kind == ShiftKind.OFF -> MaterialTheme.colorScheme.error
|
||||||
else -> Color.Transparent
|
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 {
|
val badgeText = when {
|
||||||
isRephaseStart -> "起"
|
isRephaseStart -> "起"
|
||||||
kind == ShiftKind.WORK -> "班"
|
kind == ShiftKind.WORK -> "班"
|
||||||
@ -243,7 +367,7 @@ private fun ShiftDayCell(
|
|||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = badgeText,
|
text = badgeText,
|
||||||
color = MaterialTheme.colorScheme.onPrimary,
|
color = badgeTextColor,
|
||||||
fontSize = 8.sp,
|
fontSize = 8.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
lineHeight = 8.sp
|
lineHeight = 8.sp
|
||||||
@ -252,3 +376,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
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
package plus.rua.project.ui
|
package plus.rua.project.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
@ -13,7 +14,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.ChevronLeft
|
import androidx.compose.material.icons.filled.ChevronLeft
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.DatePicker
|
import androidx.compose.material3.DatePicker
|
||||||
@ -25,6 +25,10 @@ import androidx.compose.material3.IconButton
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.Scaffold
|
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.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
@ -34,6 +38,7 @@ import androidx.compose.runtime.LaunchedEffect
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@ -42,6 +47,7 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.datetime.atStartOfDayIn
|
import kotlinx.datetime.atStartOfDayIn
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
import plus.rua.project.CalendarViewModel
|
import plus.rua.project.CalendarViewModel
|
||||||
@ -73,7 +79,8 @@ fun ShiftPatternScreen(onBack: () -> Unit) {
|
|||||||
LaunchedEffect(pattern) { storage.save(pattern) }
|
LaunchedEffect(pattern) { storage.save(pattern) }
|
||||||
|
|
||||||
var showAnchorPicker by remember { mutableStateOf(false) }
|
var showAnchorPicker by remember { mutableStateOf(false) }
|
||||||
var showResetDialog by remember { mutableStateOf(false) }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
@ -85,14 +92,15 @@ fun ShiftPatternScreen(onBack: () -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
},
|
||||||
|
snackbarHost = { SnackbarHost(hostState = snackbarHostState) }
|
||||||
) { padding ->
|
) { padding ->
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(padding)
|
.padding(padding)
|
||||||
.verticalScroll(rememberScrollState())
|
.verticalScroll(rememberScrollState())
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
.padding(horizontal = 16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
) {
|
) {
|
||||||
Text("基础周期", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
|
Text("基础周期", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
|
||||||
@ -110,9 +118,12 @@ fun ShiftPatternScreen(onBack: () -> Unit) {
|
|||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Text("锚点日期", style = MaterialTheme.typography.bodyMedium)
|
Text("锚点日期", style = MaterialTheme.typography.bodyMedium)
|
||||||
TextButton(onClick = { showAnchorPicker = true }) {
|
Text(
|
||||||
Text(pattern.anchorDate.toString())
|
text = pattern.anchorDate.toString(),
|
||||||
}
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.clickable { showAnchorPicker = true }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
@ -145,7 +156,7 @@ fun ShiftPatternScreen(onBack: () -> Unit) {
|
|||||||
pattern = pattern.copy(
|
pattern = pattern.copy(
|
||||||
cycle = cycle,
|
cycle = cycle,
|
||||||
overrides = emptyMap(),
|
overrides = emptyMap(),
|
||||||
phaseBreaks = emptyList()
|
rephaseFlips = emptyList()
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
label = { Text(label) }
|
label = { Text(label) }
|
||||||
@ -167,7 +178,20 @@ fun ShiftPatternScreen(onBack: () -> Unit) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
OutlinedButton(
|
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()
|
modifier = Modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
Text("恢复默认")
|
Text("恢复默认")
|
||||||
@ -194,23 +218,6 @@ fun ShiftPatternScreen(onBack: () -> Unit) {
|
|||||||
DatePicker(state = state)
|
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 =
|
private fun LocalDate.toEpochMillis(): Long =
|
||||||
|
|||||||
@ -39,7 +39,7 @@ class ShiftPatternStorageTest {
|
|||||||
LocalDate(2026, 7, 12) to ShiftKind.OFF,
|
LocalDate(2026, 7, 12) to ShiftKind.OFF,
|
||||||
LocalDate(2026, 7, 14) to ShiftKind.WORK
|
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)
|
storage.save(pattern)
|
||||||
val result = storage.load()
|
val result = storage.load()
|
||||||
@ -53,7 +53,7 @@ class ShiftPatternStorageTest {
|
|||||||
anchorDate = LocalDate(2026, 5, 15),
|
anchorDate = LocalDate(2026, 5, 15),
|
||||||
cycle = listOf(ShiftKind.WORK, ShiftKind.OFF),
|
cycle = listOf(ShiftKind.WORK, ShiftKind.OFF),
|
||||||
overrides = emptyMap(),
|
overrides = emptyMap(),
|
||||||
phaseBreaks = emptyList()
|
rephaseFlips = emptyList()
|
||||||
)
|
)
|
||||||
storage.save(pattern)
|
storage.save(pattern)
|
||||||
assertEquals(pattern, storage.load())
|
assertEquals(pattern, storage.load())
|
||||||
|
|||||||
@ -199,75 +199,66 @@ class ShiftPatternTest {
|
|||||||
assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 5, 17)))
|
assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 5, 17)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- kindAt: 相位断点 phaseBreak ----
|
// ---- kindAt: 翻转并重排 rephaseFlip ----
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun kindAt_phaseBreak_restartsCycleFromBreakDate() {
|
fun kindAt_rephaseFlip_restartsCycleFromRephaseDate() {
|
||||||
// 断点设在 5/19,从这天起重新 cycle[0]
|
// 5/18 翻转为 OFF,5/19 起重排(rephaseFrom=5/19)
|
||||||
val pattern = twoOnTwoOff.copy(
|
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)))
|
assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 5, 19)))
|
||||||
// 5/20 = cycle[1] = WORK
|
// 5/20 = cycle[1] = WORK
|
||||||
assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 5, 20)))
|
assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 5, 20)))
|
||||||
// 5/21 = cycle[2] = OFF
|
// 5/21 = cycle[2] = OFF
|
||||||
assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 5, 21)))
|
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
|
@Test
|
||||||
fun kindAt_phaseBreakWithOffset_shiftsPhase() {
|
fun kindAt_multipleRephaseFlips_usesLatestApplicable() {
|
||||||
// 断点 5/19,cycleOffset=2:5/19 对应 cycle[2]=OFF(而非 cycle[0]=WORK)
|
// 两个 rephaseFlip:5/18→5/19 重排,5/24→5/25 重排
|
||||||
val pattern = twoOnTwoOff.copy(
|
val pattern = twoOnTwoOff.copy(
|
||||||
phaseBreaks = listOf(PhaseBreak(LocalDate(2026, 5, 19), 2))
|
rephaseFlips = listOf(
|
||||||
)
|
RephaseFlip(LocalDate(2026, 5, 18), ShiftKind.OFF, LocalDate(2026, 5, 19)),
|
||||||
// 5/19 = cycle[(0+2)%4] = cycle[2] = OFF
|
RephaseFlip(LocalDate(2026, 5, 24), ShiftKind.OFF, LocalDate(2026, 5, 25))
|
||||||
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-5/23 受第一个重排支配(5/19 起锚)
|
||||||
// 5/19 = cycle[0] = WORK
|
// 5/19 = cycle[0] = WORK
|
||||||
assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 5, 19)))
|
assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 5, 19)))
|
||||||
// 5/24 = cycle[(5)%4] = cycle[1] = WORK
|
// 5/23 = cycle[(4)%4] = cycle[0] = WORK
|
||||||
assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 5, 24)))
|
assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 5, 23)))
|
||||||
// 5/25 起受第二个断点支配(offset 1)
|
// 5/24 = 第二个 rephaseFlip 的翻转日,翻转为 OFF
|
||||||
// 5/25 = cycle[(0+1)%4] = cycle[1] = WORK
|
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)))
|
assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 5, 25)))
|
||||||
// 5/26 = cycle[(1+1)%4] = cycle[2] = OFF
|
// 5/26 = cycle[1] = WORK
|
||||||
assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 5, 26)))
|
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
|
@Test
|
||||||
fun kindAt_userExample_combinedOverridesAndPhaseBreak() {
|
fun kindAt_userExample_combinedOverridesAndRephaseFlip() {
|
||||||
// 基础锚点 7/8,周期 [WORK,WORK,OFF,OFF]
|
// 基础锚点 7/8,周期 [WORK,WORK,OFF,OFF]
|
||||||
val pattern = ShiftPattern(
|
val pattern = ShiftPattern(
|
||||||
anchorDate = LocalDate(2026, 7, 8),
|
anchorDate = LocalDate(2026, 7, 8),
|
||||||
cycle = listOf(ShiftKind.WORK, ShiftKind.WORK, ShiftKind.OFF, ShiftKind.OFF),
|
cycle = listOf(ShiftKind.WORK, ShiftKind.WORK, ShiftKind.OFF, ShiftKind.OFF),
|
||||||
overrides = mapOf(
|
overrides = mapOf(
|
||||||
LocalDate(2026, 7, 12) to ShiftKind.OFF, // 班→休
|
LocalDate(2026, 7, 12) to ShiftKind.OFF, // 班→休(单日)
|
||||||
LocalDate(2026, 7, 14) to ShiftKind.WORK, // 休→班
|
LocalDate(2026, 7, 14) to ShiftKind.WORK, // 休→班(单日)
|
||||||
LocalDate(2026, 7, 15) to ShiftKind.WORK, // 休→班
|
LocalDate(2026, 7, 15) to ShiftKind.WORK, // 休→班(单日)
|
||||||
LocalDate(2026, 7, 16) to ShiftKind.OFF, // 班→休
|
LocalDate(2026, 7, 16) to ShiftKind.OFF, // 班→休(单日)
|
||||||
LocalDate(2026, 7, 17) 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
|
// 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, 10)))
|
||||||
@ -279,13 +270,86 @@ class ShiftPatternTest {
|
|||||||
// 7/14,15 = override WORK
|
// 7/14,15 = override WORK
|
||||||
assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 7, 14)))
|
assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 7, 14)))
|
||||||
assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 7, 15)))
|
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)))
|
assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 7, 16)))
|
||||||
|
// 7/17 = rephaseFlip 翻转为 OFF
|
||||||
assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 7, 17)))
|
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, 18)))
|
||||||
assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 7, 19)))
|
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, 20)))
|
||||||
assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 7, 21)))
|
assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 7, 21)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- 长按"翻转并重排"场景(RephaseFlip 原子操作)----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单次长按:锚点 7/10,2班2休。长按 7/10 把班翻转为休,7/11 起重排。
|
||||||
|
* 期望:7/10=休,7/11-12=班,7/13-14=休(后续按 cycle 顺延)
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun longPress_singleFlip_dateFlippedAndRephased() {
|
||||||
|
val pattern = ShiftPattern(
|
||||||
|
anchorDate = LocalDate(2026, 7, 10),
|
||||||
|
cycle = listOf(ShiftKind.WORK, ShiftKind.WORK, ShiftKind.OFF, ShiftKind.OFF),
|
||||||
|
rephaseFlips = listOf(RephaseFlip(LocalDate(2026, 7, 10), ShiftKind.OFF, LocalDate(2026, 7, 11)))
|
||||||
|
)
|
||||||
|
// 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 一次。两个 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),
|
||||||
|
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 起):班班休
|
||||||
|
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)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 撤销长按:移除整个 RephaseFlip 后,回到基础周期。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun longPress_undo_returnsToBaseCycle() {
|
||||||
|
val before = ShiftPattern(
|
||||||
|
anchorDate = LocalDate(2026, 7, 10),
|
||||||
|
cycle = listOf(ShiftKind.WORK, ShiftKind.WORK, ShiftKind.OFF, ShiftKind.OFF),
|
||||||
|
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)))
|
||||||
|
assertEquals(ShiftKind.OFF, after.kindAt(LocalDate(2026, 7, 12)))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user