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 }
|
||||
|
||||
/**
|
||||
* 相位断点:从 [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<ShiftKind>,
|
||||
val overrides: Map<LocalDate, ShiftKind> = emptyMap(),
|
||||
val phaseBreaks: List<PhaseBreak> = emptyList(),
|
||||
val rephaseFlips: List<RephaseFlip> = 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<LocalDate, Int> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<PhaseBreak> {
|
||||
private fun parseRephase(s: String?): List<RephaseFlip> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -2,14 +2,19 @@ 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
|
||||
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
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
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.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.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
|
||||
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
|
||||
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
|
||||
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.math.abs
|
||||
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.ShiftPattern
|
||||
|
||||
@ -58,7 +77,7 @@ import plus.rua.project.ShiftPattern
|
||||
* @param onPatternChange 修改后的新 pattern 回调
|
||||
* @param modifier 外部布局修饰符
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ShiftCalendarGrid(
|
||||
pattern: ShiftPattern,
|
||||
@ -66,12 +85,20 @@ 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()
|
||||
|
||||
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
|
||||
// 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 }
|
||||
}
|
||||
val density = LocalDensity.current
|
||||
|
||||
Card(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
@ -86,17 +113,18 @@ 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 = "上个月")
|
||||
}
|
||||
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
|
||||
coroutineScope.launch { pagerState.animateScrollToPage(pagerState.currentPage + 1) }
|
||||
}) {
|
||||
Icon(Icons.Filled.ChevronRight, contentDescription = "下个月")
|
||||
}
|
||||
@ -115,34 +143,131 @@ fun ShiftCalendarGrid(
|
||||
}
|
||||
}
|
||||
|
||||
(0 until 6).forEach { row ->
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
(0 until 7).forEach { col ->
|
||||
val cellIndex = row * 7 + col
|
||||
val dayNum = cellIndex - firstWeekdayOffset + 1
|
||||
if (dayNum in 1..daysInMonth) {
|
||||
val date = LocalDate(viewYear, Month(viewMonth), dayNum)
|
||||
ShiftDayCell(
|
||||
date = date,
|
||||
pattern = pattern,
|
||||
onClick = { onPatternChange(toggleOverride(pattern, date)) },
|
||||
onLongClick = { onPatternChange(toggleFlipAndRephase(pattern, date)) },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
// 用 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 {
|
||||
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 {
|
||||
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 +280,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,15 +323,22 @@ 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 }
|
||||
|
||||
// 背景色与文字色配对,保证对比度(对照 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 +367,7 @@ private fun ShiftDayCell(
|
||||
) {
|
||||
Text(
|
||||
text = badgeText,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
color = badgeTextColor,
|
||||
fontSize = 8.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
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
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
@ -13,7 +14,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 +25,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 +38,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 +47,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 +79,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,14 +92,15 @@ fun ShiftPatternScreen(onBack: () -> Unit) {
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
snackbarHost = { SnackbarHost(hostState = snackbarHostState) }
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.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)
|
||||
@ -110,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(),
|
||||
@ -145,7 +156,7 @@ fun ShiftPatternScreen(onBack: () -> Unit) {
|
||||
pattern = pattern.copy(
|
||||
cycle = cycle,
|
||||
overrides = emptyMap(),
|
||||
phaseBreaks = emptyList()
|
||||
rephaseFlips = emptyList()
|
||||
)
|
||||
},
|
||||
label = { Text(label) }
|
||||
@ -167,7 +178,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 +218,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 =
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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,13 +270,86 @@ 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)))
|
||||
}
|
||||
|
||||
// ---- 长按"翻转并重排"场景(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