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:
xfy 2026-07-09 17:16:45 +08:00
commit afea8bf109
7 changed files with 506 additions and 150 deletions

View 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 用法正确,不破坏现有布局。

View File

@ -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
}
}

View File

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

View File

@ -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

View File

@ -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 =

View File

@ -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())

View File

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