diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 02b4032..e238289 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -48,6 +48,10 @@
+
+
-
\ No newline at end of file
+
diff --git a/app/src/main/kotlin/plus/rua/project/MainActivity.kt b/app/src/main/kotlin/plus/rua/project/MainActivity.kt
index f9a24a4..7760b68 100644
--- a/app/src/main/kotlin/plus/rua/project/MainActivity.kt
+++ b/app/src/main/kotlin/plus/rua/project/MainActivity.kt
@@ -21,6 +21,9 @@ class MainActivity : BaseActivity() {
},
onNavigateToTools = {
startActivityWithSlide(Intent(this, ToolsActivity::class.java))
+ },
+ onNavigateToShiftSettings = {
+ startActivityWithSlide(Intent(this, ShiftPatternActivity::class.java))
}
)
}
diff --git a/app/src/main/kotlin/plus/rua/project/ShiftPatternActivity.kt b/app/src/main/kotlin/plus/rua/project/ShiftPatternActivity.kt
new file mode 100644
index 0000000..18ddb51
--- /dev/null
+++ b/app/src/main/kotlin/plus/rua/project/ShiftPatternActivity.kt
@@ -0,0 +1,18 @@
+package plus.rua.project
+
+import android.os.Bundle
+import androidx.activity.compose.setContent
+import plus.rua.project.ui.ShiftPatternScreen
+import plus.rua.project.ui.theme.YaYaTheme
+
+class ShiftPatternActivity : BaseActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ setContent {
+ YaYaTheme {
+ ShiftPatternScreen(onBack = { finishWithSlideBack() })
+ }
+ }
+ }
+}
diff --git a/core/src/main/kotlin/plus/rua/project/CalendarViewModel.kt b/core/src/main/kotlin/plus/rua/project/CalendarViewModel.kt
index 00d0473..5256691 100644
--- a/core/src/main/kotlin/plus/rua/project/CalendarViewModel.kt
+++ b/core/src/main/kotlin/plus/rua/project/CalendarViewModel.kt
@@ -59,7 +59,8 @@ data class CalendarUiState(
* @param clock 时钟源,默认系统时钟;测试时可注入固定时钟
*/
class CalendarViewModel(
- private val clock: Clock = Clock.System
+ private val clock: Clock = Clock.System,
+ private val shiftStorage: ShiftPatternStorage? = null
) : ViewModel() {
private val today: LocalDate = clock.todayIn(TimeZone.currentSystemDefault())
@@ -112,17 +113,30 @@ class CalendarViewModel(
/**
* 个人轮班。与法定节假日完全独立,不受调休影响。
- * MVP 默认:2026-05-15 起,2 班 2 休循环。后续接入设置页与持久化。
+ * 从 [shiftStorage] 加载;storage 为空或未存时回退到 [DEFAULT_PATTERN]。
*/
- private val _shiftPattern = MutableStateFlow(
- ShiftPattern(
+ private val _shiftPattern = MutableStateFlow(loadShiftPattern())
+ val shiftPattern: StateFlow = _shiftPattern.asStateFlow()
+
+ private fun loadShiftPattern(): ShiftPattern =
+ shiftStorage?.load() ?: DEFAULT_PATTERN
+
+ /**
+ * 设置页返回后调用,从 storage 重新加载,立即生效。
+ */
+ fun refreshShiftPattern() {
+ _shiftPattern.value = loadShiftPattern()
+ }
+
+ fun shiftKindAt(date: LocalDate): ShiftKind? = shiftPattern.value?.kindAt(date)
+
+ companion object {
+ /** 默认轮班:2026-05-15 起,2 班 2 休循环。 */
+ val DEFAULT_PATTERN = ShiftPattern(
anchorDate = LocalDate(2026, 5, 15),
cycle = listOf(ShiftKind.WORK, ShiftKind.WORK, ShiftKind.OFF, ShiftKind.OFF)
)
- )
- val shiftPattern: StateFlow = _shiftPattern.asStateFlow()
-
- fun shiftKindAt(date: LocalDate): ShiftKind? = shiftPattern.value?.kindAt(date)
+ }
/**
* 是否在右上角显示法定调休角标。默认禁用,此时右上角让位给个人排班。
diff --git a/core/src/main/kotlin/plus/rua/project/ShiftPattern.kt b/core/src/main/kotlin/plus/rua/project/ShiftPattern.kt
index d34037f..37a8c61 100644
--- a/core/src/main/kotlin/plus/rua/project/ShiftPattern.kt
+++ b/core/src/main/kotlin/plus/rua/project/ShiftPattern.kt
@@ -8,26 +8,56 @@ import kotlinx.datetime.daysUntil
*/
enum class ShiftKind { WORK, OFF }
+/**
+ * 相位断点:从 [date] 起重排周期相位,[cycleOffset] 指定当天对应 cycle 的第几位。
+ *
+ * @param date 断点生效日(含当天)
+ * @param cycleOffset 该天对应的 cycle 索引(0 = cycle[0])
+ */
+data class PhaseBreak(
+ val date: LocalDate,
+ val cycleOffset: Int
+)
+
/**
* 个人轮班周期。
*
- * 与法定节假日完全独立:周期内某天是 WORK 还是 OFF,只看
- * `(date - anchorDate) mod cycle.size` 在 cycle 中的取值,不受任何节假日/调休影响。
+ * 与法定节假日完全独立。某天的班/休由以下顺序决定:
+ * 1. 若 [overrides] 命中该天,取 override 值(单日翻转);
+ * 2. 否则取该天"活跃锚点"(最近的 phaseBreak 或基础 anchorDate)起算的 cycle 索引。
*
- * @param anchorDate 周期基准日,对应 cycle[0]
+ * @param anchorDate 基础周期基准日,对应 cycle[0]
* @param cycle 一个周期内的班次序列,例如 [WORK, WORK, OFF, OFF] 表示 "2 班 2 休"
- * @param name 方案名,用于后续多套方案场景
+ * @param overrides 单日翻转映射(调班),key 为日期
+ * @param phaseBreaks 相位断点列表,从某天起重排周期相位
+ * @param name 方案名
*/
data class ShiftPattern(
val anchorDate: LocalDate,
val cycle: List,
+ val overrides: Map = emptyMap(),
+ val phaseBreaks: List = emptyList(),
val name: String = "默认"
) {
+ /**
+ * 返回 [date] 当天的班次。优先级:overrides → 活跃锚点的 cycle 索引。
+ * cycle 为空时返回 null。
+ */
fun kindAt(date: LocalDate): ShiftKind? {
if (cycle.isEmpty()) return null
- val diff = anchorDate.daysUntil(date)
+ // 1. 单日翻转优先
+ overrides[date]?.let { return it }
+ // 2. 找活跃锚点:phaseBreaks 中 date <= 当天 的最大那个;无则用基础锚点
+ val (anchor, offset) = activeAnchor(date)
+ val diff = anchor.daysUntil(date)
val size = cycle.size
- val idx = ((diff % size) + size) % size
+ val idx = (((diff + offset) % size) + size) % size
return cycle[idx]
}
+
+ private fun activeAnchor(date: LocalDate): Pair {
+ val applicable = phaseBreaks.filter { it.date <= date }.maxByOrNull { it.date }
+ return if (applicable != null) applicable.date to applicable.cycleOffset
+ else anchorDate to 0
+ }
}
diff --git a/core/src/main/kotlin/plus/rua/project/ShiftPatternStorage.kt b/core/src/main/kotlin/plus/rua/project/ShiftPatternStorage.kt
new file mode 100644
index 0000000..600e9bc
--- /dev/null
+++ b/core/src/main/kotlin/plus/rua/project/ShiftPatternStorage.kt
@@ -0,0 +1,85 @@
+package plus.rua.project
+
+import android.content.Context
+import android.content.SharedPreferences
+import kotlinx.datetime.LocalDate
+
+/**
+ * 个人轮班设置持久化。照抄 [DateCheckerStorage] 模式。
+ *
+ * 编码格式(无 JSON 依赖):
+ * - 锚点:ISO 日期串 "2026-07-08"
+ * - 周期:逗号分隔的 1/0 串 "1,1,0,0"(1=WORK,0=OFF)
+ * - overrides/breaks:逗号分隔的 "日期:值" 对
+ * - overrides 值:1=WORK,0=OFF
+ * - breaks 值:cycleOffset(整数)
+ */
+class ShiftPatternStorage(private val prefs: SharedPreferences) {
+
+ companion object {
+ 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_NAME = "shift_name"
+ private const val SEP = ","
+
+ fun fromContext(context: Context): ShiftPatternStorage =
+ ShiftPatternStorage(
+ context.getSharedPreferences("shift_pattern", Context.MODE_PRIVATE)
+ )
+ }
+
+ fun save(pattern: ShiftPattern) {
+ 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}" }
+ prefs.edit()
+ .putString(KEY_ANCHOR, pattern.anchorDate.toString())
+ .putString(KEY_CYCLE, cycleStr)
+ .putString(KEY_OVERRIDES, overridesStr)
+ .putString(KEY_BREAKS, breaksStr)
+ .putString(KEY_NAME, pattern.name)
+ .apply()
+ }
+
+ fun load(): ShiftPattern? {
+ val anchorStr = prefs.getString(KEY_ANCHOR, null) ?: return null
+ val cycleStr = prefs.getString(KEY_CYCLE, null) ?: return null
+ return try {
+ val anchor = LocalDate.parse(anchorStr)
+ val cycle = if (cycleStr.isBlank()) {
+ emptyList()
+ } else {
+ 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) ?: "默认")
+ } catch (e: Exception) {
+ null
+ }
+ }
+
+ private fun parseOverrides(s: String?): Map {
+ if (s.isNullOrBlank()) return emptyMap()
+ return s.split(SEP).associate { pair ->
+ val parts = pair.split(":")
+ LocalDate.parse(parts[0]) to (if (parts[1].trim() == "1") ShiftKind.WORK else ShiftKind.OFF)
+ }
+ }
+
+ private fun parseBreaks(s: String?): List {
+ if (s.isNullOrBlank()) return emptyList()
+ return s.split(SEP).map { pair ->
+ val parts = pair.split(":")
+ PhaseBreak(LocalDate.parse(parts[0]), parts[1].trim().toInt())
+ }
+ }
+
+ fun clear() {
+ prefs.edit().clear().apply()
+ }
+}
diff --git a/core/src/main/kotlin/plus/rua/project/ui/CalendarMonthView.kt b/core/src/main/kotlin/plus/rua/project/ui/CalendarMonthView.kt
index 8e458e4..f894432 100644
--- a/core/src/main/kotlin/plus/rua/project/ui/CalendarMonthView.kt
+++ b/core/src/main/kotlin/plus/rua/project/ui/CalendarMonthView.kt
@@ -93,7 +93,15 @@ import plus.rua.project.composeTraceBeginSection
import plus.rua.project.composeTraceEndSection
import kotlin.math.abs
import kotlin.time.Clock
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.ui.platform.LocalContext
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleEventObserver
+import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.viewmodel.compose.viewModel
+import androidx.lifecycle.viewmodel.initializer
+import androidx.lifecycle.viewmodel.viewModelFactory
+import plus.rua.project.ShiftPatternStorage
/**
* 日历主界面,包含月/周视图切换、折叠动画和年视图转场。
@@ -107,12 +115,34 @@ import androidx.lifecycle.viewmodel.compose.viewModel
fun CalendarMonthView(
modifier: Modifier = Modifier,
onNavigateToAbout: () -> Unit = {},
- onNavigateToTools: () -> Unit = {}
+ onNavigateToTools: () -> Unit = {},
+ onNavigateToShiftSettings: () -> Unit = {}
) {
- val viewModel = viewModel()
+ val context = LocalContext.current.applicationContext
+ val viewModel: CalendarViewModel = viewModel(
+ factory = viewModelFactory {
+ initializer {
+ CalendarViewModel(
+ clock = Clock.System,
+ shiftStorage = ShiftPatternStorage.fromContext(context)
+ )
+ }
+ }
+ )
+
+ // 设置页返回后 onResume 重读 storage,立即刷新班次
+ val lifecycleOwner = LocalLifecycleOwner.current
+ DisposableEffect(lifecycleOwner) {
+ val observer = LifecycleEventObserver { _, event ->
+ if (event == Lifecycle.Event.ON_RESUME) viewModel.refreshShiftPattern()
+ }
+ lifecycleOwner.lifecycle.addObserver(observer)
+ onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
+ }
val today = remember { Clock.System.todayIn(TimeZone.currentSystemDefault()) }
val uiState by viewModel.uiState.collectAsState()
+ val shiftPattern by viewModel.shiftPattern.collectAsState()
val selectedDate = uiState.selectedDate
val currentYear = selectedDate.year
val currentMonth = selectedDate.month.number
@@ -241,7 +271,7 @@ fun CalendarMonthView(
viewModel.selectDate(date)
}
}
- val shiftKindAt = remember(viewModel) {
+ val shiftKindAt = remember(viewModel, shiftPattern) {
{ date: LocalDate -> viewModel.shiftKindAt(date) }
}
val onRowHeightMeasured = remember {
@@ -427,6 +457,14 @@ fun CalendarMonthView(
viewModel.toggleShowLegalHoliday()
}
)
+ MenuItem(
+ text = "班次设置",
+ selected = false,
+ onClick = {
+ isMenuExpanded = false
+ onNavigateToShiftSettings()
+ }
+ )
HorizontalDivider(
thickness = 1.dp,
color = MaterialTheme.colorScheme.outlineVariant,
@@ -589,6 +627,7 @@ private fun BottomCardArea(
val shouldShow = hasLoaded
val uiState by viewModel.uiState.collectAsState()
+ val shiftPattern by viewModel.shiftPattern.collectAsState()
val shiftKind = viewModel.shiftKindAt(uiState.selectedDate)
if (shouldShow) {
diff --git a/core/src/main/kotlin/plus/rua/project/ui/ShiftCalendarGrid.kt b/core/src/main/kotlin/plus/rua/project/ui/ShiftCalendarGrid.kt
new file mode 100644
index 0000000..7d0a658
--- /dev/null
+++ b/core/src/main/kotlin/plus/rua/project/ui/ShiftCalendarGrid.kt
@@ -0,0 +1,229 @@
+package plus.rua.project.ui
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.background
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+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.padding
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+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.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+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.datetime.DatePeriod
+import kotlinx.datetime.LocalDate
+import kotlinx.datetime.Month
+import kotlinx.datetime.TimeZone
+import kotlinx.datetime.isoDayNumber
+import kotlinx.datetime.minus
+import kotlinx.datetime.number
+import kotlinx.datetime.plus
+import kotlinx.datetime.todayIn
+import kotlin.time.Clock
+import plus.rua.project.PhaseBreak
+import plus.rua.project.ShiftKind
+import plus.rua.project.ShiftPattern
+
+/**
+ * 班次设置页用的迷你月历。点某天翻转班/休,长按设/清相位断点。
+ *
+ * 月份可前后翻页,每格显示日期数字与班次角标(班/休/断)。
+ * 点击翻转该天的班/休 override;长按切换相位断点。
+ *
+ * @param pattern 当前轮班配置(只读),由 [onPatternChange] 修改
+ * @param onPatternChange 修改后的新 pattern 回调
+ * @param modifier 外部布局修饰符
+ */
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+fun ShiftCalendarGrid(
+ pattern: ShiftPattern,
+ onPatternChange: (ShiftPattern) -> Unit,
+ 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 firstOfMonth = LocalDate(viewYear, Month(viewMonth), 1)
+ val daysInMonth = firstOfMonth.plus(DatePeriod(months = 1)).minus(DatePeriod(days = 1)).day
+ val firstWeekdayOffset = firstOfMonth.dayOfWeek.isoDayNumber - 1
+
+ Card(
+ modifier = modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(12.dp),
+ elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow)
+ ) {
+ Column(Modifier.padding(8.dp)) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ IconButton(onClick = {
+ if (viewMonth == 1) { viewMonth = 12; viewYear -= 1 } else viewMonth -= 1
+ }) {
+ Icon(Icons.Filled.ChevronLeft, contentDescription = "上个月")
+ }
+ Text(
+ text = "${viewYear}年 ${viewMonth}月",
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = FontWeight.SemiBold
+ )
+ IconButton(onClick = {
+ if (viewMonth == 12) { viewMonth = 1; viewYear += 1 } else viewMonth += 1
+ }) {
+ Icon(Icons.Filled.ChevronRight, contentDescription = "下个月")
+ }
+ }
+
+ val weekTitles = listOf("一", "二", "三", "四", "五", "六", "日")
+ Row(Modifier.fillMaxWidth()) {
+ weekTitles.forEach { w ->
+ Text(
+ text = w,
+ modifier = Modifier.weight(1f),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center
+ )
+ }
+ }
+
+ (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(togglePhaseBreak(pattern, date)) },
+ modifier = Modifier.weight(1f)
+ )
+ } else {
+ Box(Modifier.weight(1f).aspectRatio(1f))
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+/**
+ * 翻转某天的班/休 override。翻转后若与基础周期值一致,则移除 override。
+ */
+private fun toggleOverride(pattern: ShiftPattern, date: LocalDate): ShiftPattern {
+ 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)
+ return pattern.copy(overrides = newOverrides)
+}
+
+/**
+ * 切换某天的相位断点:已有则清除,没有则新增(cycleOffset=0)。
+ */
+private fun togglePhaseBreak(pattern: ShiftPattern, date: LocalDate): ShiftPattern {
+ val existing = pattern.phaseBreaks.find { it.date == date }
+ return if (existing != null) {
+ pattern.copy(phaseBreaks = pattern.phaseBreaks - existing)
+ } else {
+ pattern.copy(phaseBreaks = pattern.phaseBreaks + PhaseBreak(date, 0))
+ }
+}
+
+/**
+ * 迷你月历的单日格子。显示日期数字与班次角标。
+ *
+ * @param date 该格对应的日期
+ * @param pattern 当前轮班配置(只读)
+ * @param onClick 点击回调(翻转班/休)
+ * @param onLongClick 长按回调(设/清相位断点)
+ * @param modifier 外部布局修饰符
+ */
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+private fun ShiftDayCell(
+ date: LocalDate,
+ pattern: ShiftPattern,
+ onClick: () -> Unit,
+ onLongClick: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ val kind = pattern.kindAt(date)
+ val isBreak = pattern.phaseBreaks.any { it.date == date }
+
+ val badgeColor = when {
+ isBreak -> MaterialTheme.colorScheme.tertiary
+ kind == ShiftKind.WORK -> MaterialTheme.colorScheme.primary
+ kind == ShiftKind.OFF -> MaterialTheme.colorScheme.error
+ else -> Color.Transparent
+ }
+ val badgeText = when {
+ isBreak -> "断"
+ kind == ShiftKind.WORK -> "班"
+ kind == ShiftKind.OFF -> "休"
+ else -> ""
+ }
+
+ Box(
+ modifier = modifier
+ .aspectRatio(1f)
+ .combinedClickable(onClick = onClick, onLongClick = onLongClick),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = date.day.toString(),
+ fontSize = 13.sp,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ if (badgeText.isNotEmpty()) {
+ Box(
+ modifier = Modifier
+ .align(Alignment.TopEnd)
+ .padding(top = 2.dp, end = 2.dp)
+ .background(badgeColor, CircleShape)
+ .padding(horizontal = 3.dp, vertical = 1.dp)
+ ) {
+ Text(
+ text = badgeText,
+ color = MaterialTheme.colorScheme.onPrimary,
+ fontSize = 8.sp,
+ fontWeight = FontWeight.Bold,
+ lineHeight = 8.sp
+ )
+ }
+ }
+ }
+}
diff --git a/core/src/main/kotlin/plus/rua/project/ui/ShiftPatternScreen.kt b/core/src/main/kotlin/plus/rua/project/ui/ShiftPatternScreen.kt
new file mode 100644
index 0000000..3fb57c0
--- /dev/null
+++ b/core/src/main/kotlin/plus/rua/project/ui/ShiftPatternScreen.kt
@@ -0,0 +1,220 @@
+package plus.rua.project.ui
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+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
+import androidx.compose.material3.DatePickerDialog
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FilterChip
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.rememberDatePickerState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import kotlinx.datetime.LocalDate
+import kotlinx.datetime.TimeZone
+import kotlinx.datetime.atStartOfDayIn
+import kotlinx.datetime.toLocalDateTime
+import plus.rua.project.CalendarViewModel
+import plus.rua.project.ShiftKind
+import plus.rua.project.ShiftPattern
+import plus.rua.project.ShiftPatternStorage
+import kotlin.time.Instant
+
+/**
+ * 班次设置页。照抄 DateCheckerScreen 的 storage 创建 + 自动存盘模式。
+ *
+ * 用户在页面内修改 → [LaunchedEffect] 自动存 storage → 返回主界面后
+ * CalendarViewModel.onResume 重读 → 日历立即刷新。
+ *
+ * 页面结构三段:
+ * 1. 基础周期:锚点日期、当前周期展示;
+ * 2. 预设方案:1班1休 / 2班2休 / 3班3休 / 4班4休 快捷切换;
+ * 3. 调班设置:迷你月历(点=翻转班/休,长按=设/清相位断点)+ 恢复默认。
+ *
+ * @param onBack 返回回调(由 Activity 触发 finishWithSlideBack)
+ */
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
+@Composable
+fun ShiftPatternScreen(onBack: () -> Unit) {
+ val context = LocalContext.current.applicationContext
+ val storage = remember { ShiftPatternStorage.fromContext(context) }
+ val saved = remember { storage.load() }
+ var pattern by remember { mutableStateOf(saved ?: CalendarViewModel.DEFAULT_PATTERN) }
+ LaunchedEffect(pattern) { storage.save(pattern) }
+
+ var showAnchorPicker by remember { mutableStateOf(false) }
+ var showResetDialog by remember { mutableStateOf(false) }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text("班次设置") },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(Icons.Filled.ChevronLeft, contentDescription = "返回")
+ }
+ }
+ )
+ }
+ ) { padding ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding)
+ .verticalScroll(rememberScrollState())
+ .padding(horizontal = 16.dp, vertical = 8.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Text("基础周期", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
+
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(12.dp),
+ elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow)
+ ) {
+ Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text("锚点日期", style = MaterialTheme.typography.bodyMedium)
+ TextButton(onClick = { showAnchorPicker = true }) {
+ Text(pattern.anchorDate.toString())
+ }
+ }
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text("周期", style = MaterialTheme.typography.bodyMedium)
+ Text(
+ pattern.cycle.joinToString(" ") { if (it == ShiftKind.WORK) "班" else "休" },
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.primary,
+ fontWeight = FontWeight.SemiBold
+ )
+ }
+ }
+ }
+
+ Text("预设方案", style = MaterialTheme.typography.labelLarge)
+ val presets = listOf(
+ "1班1休" to listOf(ShiftKind.WORK, ShiftKind.OFF),
+ "2班2休" to listOf(ShiftKind.WORK, ShiftKind.WORK, ShiftKind.OFF, ShiftKind.OFF),
+ "3班3休" to listOf(ShiftKind.WORK, ShiftKind.WORK, ShiftKind.WORK, ShiftKind.OFF, ShiftKind.OFF, ShiftKind.OFF),
+ "4班4休" to listOf(ShiftKind.WORK, ShiftKind.WORK, ShiftKind.WORK, ShiftKind.WORK, ShiftKind.OFF, ShiftKind.OFF, ShiftKind.OFF, ShiftKind.OFF)
+ )
+ FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ presets.forEach { (label, cycle) ->
+ FilterChip(
+ selected = pattern.cycle == cycle,
+ onClick = {
+ pattern = pattern.copy(
+ cycle = cycle,
+ overrides = emptyMap(),
+ phaseBreaks = emptyList()
+ )
+ },
+ label = { Text(label) }
+ )
+ }
+ }
+
+ Text("调班设置", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
+ ShiftCalendarGrid(pattern = pattern, onPatternChange = { pattern = it })
+ Text(
+ text = "点某天 = 翻转班/休,长按某天 = 设/清相位断点",
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Text(
+ text = "图例:班(蓝)/休(红)/断点(琥珀)",
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ OutlinedButton(
+ onClick = { showResetDialog = true },
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text("恢复默认")
+ }
+ }
+ }
+
+ if (showAnchorPicker) {
+ val state = rememberDatePickerState(
+ initialSelectedDateMillis = pattern.anchorDate.toEpochMillis()
+ )
+ DatePickerDialog(
+ onDismissRequest = { showAnchorPicker = false },
+ confirmButton = {
+ TextButton(onClick = {
+ state.selectedDateMillis?.let { pattern = pattern.copy(anchorDate = it.toLocalDate()) }
+ showAnchorPicker = false
+ }) { Text("确定") }
+ },
+ dismissButton = {
+ TextButton(onClick = { showAnchorPicker = false }) { Text("取消") }
+ }
+ ) {
+ 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 =
+ this.atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds()
+
+private fun Long.toLocalDate(): LocalDate =
+ Instant.fromEpochMilliseconds(this).toLocalDateTime(TimeZone.UTC).date
diff --git a/core/src/test/kotlin/plus/rua/project/CalendarViewModelTest.kt b/core/src/test/kotlin/plus/rua/project/CalendarViewModelTest.kt
index 065b97c..3b46641 100644
--- a/core/src/test/kotlin/plus/rua/project/CalendarViewModelTest.kt
+++ b/core/src/test/kotlin/plus/rua/project/CalendarViewModelTest.kt
@@ -1,9 +1,11 @@
package plus.rua.project
+import android.content.SharedPreferences
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.datetime.LocalDate
import kotlinx.datetime.number
+import plus.rua.project.ShiftKind
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
@@ -127,4 +129,126 @@ class CalendarViewModelTest {
val selectedCell = days.first { it.isSelected }
assertEquals(15, selectedCell.date.day)
}
+
+ // ---- shiftPattern: 默认值与 refresh ----
+
+ @Test
+ fun shiftPattern_noStorage_returnsDefault() {
+ val vm = createViewModel() // 不传 storage
+ assertEquals(ShiftKind.WORK, vm.shiftKindAt(LocalDate(2026, 5, 15)))
+ assertEquals(ShiftKind.OFF, vm.shiftKindAt(LocalDate(2026, 5, 17)))
+ }
+
+ @Test
+ fun refreshShiftPattern_reloadsFromStorage() {
+ val prefs = CalendarVmTestPrefs()
+ val storage = ShiftPatternStorage(prefs)
+ // storage 里存一个 1班1休,锚点 2026-01-01
+ storage.save(
+ ShiftPattern(
+ anchorDate = LocalDate(2026, 1, 1),
+ cycle = listOf(ShiftKind.WORK, ShiftKind.OFF)
+ )
+ )
+ val vm = CalendarViewModel(clock = testClock, shiftStorage = storage)
+ // 初始即从 storage 读
+ assertEquals(ShiftKind.WORK, vm.shiftKindAt(LocalDate(2026, 1, 1)))
+ assertEquals(ShiftKind.OFF, vm.shiftKindAt(LocalDate(2026, 1, 2)))
+ }
+
+ @Test
+ fun refreshShiftPattern_reloadsAfterStorageChange() {
+ val prefs = CalendarVmTestPrefs()
+ val storage = ShiftPatternStorage(prefs)
+ // 初始:2 班 2 休(默认),构造 VM(不传 storage → 用 DEFAULT_PATTERN)
+ val vm = CalendarViewModel(clock = testClock, shiftStorage = storage)
+ // 2026-05-15 = WORK(默认 2班2休 锚点)
+ assertEquals(ShiftKind.WORK, vm.shiftKindAt(LocalDate(2026, 5, 15)))
+ // 改 storage 为 1班1休,锚点 2026-01-01
+ storage.save(
+ ShiftPattern(
+ anchorDate = LocalDate(2026, 1, 1),
+ cycle = listOf(ShiftKind.WORK, ShiftKind.OFF)
+ )
+ )
+ // refresh 前:VM 还持有旧 pattern
+ assertEquals(ShiftKind.WORK, vm.shiftKindAt(LocalDate(2026, 5, 15)))
+ // 调用 refresh
+ vm.refreshShiftPattern()
+ // refresh 后:VM 已从 storage 重读
+ // 2026-05-15 距 2026-01-01 = 134 天,134 % 2 = 0 → cycle[0] = WORK
+ assertEquals(ShiftKind.WORK, vm.shiftKindAt(LocalDate(2026, 5, 15)))
+ // 2026-01-02 距锚点 1 天,1 % 2 = 1 → cycle[1] = OFF
+ assertEquals(ShiftKind.OFF, vm.shiftKindAt(LocalDate(2026, 1, 2)))
+ }
+}
+
+private class CalendarVmTestPrefs : SharedPreferences {
+
+ private val data = mutableMapOf()
+
+ override fun getAll(): Map = data.toMap()
+
+ override fun getString(key: String, defValue: String?): String? =
+ data[key] as? String ?: defValue
+
+ override fun getStringSet(key: String, defValues: Set?): Set? =
+ data[key] as? Set ?: defValues
+
+ override fun getInt(key: String, defValue: Int): Int =
+ data[key] as? Int ?: defValue
+
+ override fun getLong(key: String, defValue: Long): Long =
+ data[key] as? Long ?: defValue
+
+ override fun getFloat(key: String, defValue: Float): Float =
+ data[key] as? Float ?: defValue
+
+ override fun getBoolean(key: String, defValue: Boolean): Boolean =
+ data[key] as? Boolean ?: defValue
+
+ override fun contains(key: String): Boolean = data.containsKey(key)
+
+ override fun edit(): SharedPreferences.Editor = CalendarVmTestPrefsEditor(data)
+
+ override fun registerOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) {}
+
+ override fun unregisterOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) {}
+}
+
+private class CalendarVmTestPrefsEditor(private val data: MutableMap) : SharedPreferences.Editor {
+
+ private val pending = mutableMapOf()
+ private var clearPending = false
+
+ override fun putString(key: String, value: String?): SharedPreferences.Editor = apply {
+ pending[key] = value
+ }
+
+ override fun putStringSet(key: String, values: Set?): SharedPreferences.Editor = apply {
+ pending[key] = values
+ }
+
+ override fun putInt(key: String, value: Int): SharedPreferences.Editor = apply { pending[key] = value }
+ override fun putLong(key: String, value: Long): SharedPreferences.Editor = apply { pending[key] = value }
+ override fun putFloat(key: String, value: Float): SharedPreferences.Editor = apply { pending[key] = value }
+ override fun putBoolean(key: String, value: Boolean): SharedPreferences.Editor = apply { pending[key] = value }
+
+ override fun remove(key: String): SharedPreferences.Editor = apply { pending[key] = null }
+
+ override fun clear(): SharedPreferences.Editor = apply { clearPending = true }
+
+ override fun commit(): Boolean {
+ apply()
+ return true
+ }
+
+ override fun apply() {
+ if (clearPending) {
+ data.clear()
+ clearPending = false
+ }
+ data.putAll(pending)
+ pending.clear()
+ }
}
diff --git a/core/src/test/kotlin/plus/rua/project/ShiftPatternStorageTest.kt b/core/src/test/kotlin/plus/rua/project/ShiftPatternStorageTest.kt
new file mode 100644
index 0000000..238188a
--- /dev/null
+++ b/core/src/test/kotlin/plus/rua/project/ShiftPatternStorageTest.kt
@@ -0,0 +1,170 @@
+package plus.rua.project
+
+import android.content.SharedPreferences
+import kotlinx.datetime.LocalDate
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+
+class ShiftPatternStorageTest {
+
+ private val prefs = ShiftPatternTestPrefs()
+ private val storage = ShiftPatternStorage(prefs)
+
+ @Test
+ fun load_noSavedData_returnsNull() {
+ storage.clear()
+ assertNull(storage.load())
+ }
+
+ @Test
+ fun saveAndLoad_roundTrips_basicPattern() {
+ storage.clear()
+ val pattern = ShiftPattern(
+ anchorDate = LocalDate(2026, 5, 15),
+ cycle = listOf(ShiftKind.WORK, ShiftKind.WORK, ShiftKind.OFF, ShiftKind.OFF)
+ )
+ storage.save(pattern)
+ val result = storage.load()
+ assertEquals(pattern, result)
+ }
+
+ @Test
+ fun saveAndLoad_roundTrips_withOverridesAndBreaks() {
+ storage.clear()
+ 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
+ ),
+ phaseBreaks = listOf(PhaseBreak(LocalDate(2026, 7, 18), 0))
+ )
+ storage.save(pattern)
+ val result = storage.load()
+ assertEquals(pattern, result)
+ }
+
+ @Test
+ fun saveAndLoad_emptyOverridesAndBreaks() {
+ storage.clear()
+ val pattern = ShiftPattern(
+ anchorDate = LocalDate(2026, 5, 15),
+ cycle = listOf(ShiftKind.WORK, ShiftKind.OFF),
+ overrides = emptyMap(),
+ phaseBreaks = emptyList()
+ )
+ storage.save(pattern)
+ assertEquals(pattern, storage.load())
+ }
+
+ @Test
+ fun load_corruptOverrides_returnsNull() {
+ storage.clear()
+ // 通过底层 prefs 注入损坏的 overrides 值
+ prefs.edit()
+ .putString("shift_anchor", "2026-05-15")
+ .putString("shift_cycle", "1,1,0,0")
+ .putString("shift_overrides", "not-a-date:1")
+ .apply()
+ assertNull(storage.load())
+ }
+
+ @Test
+ fun saveAndLoad_customName_preserved() {
+ storage.clear()
+ val pattern = ShiftPattern(
+ anchorDate = LocalDate(2026, 5, 15),
+ cycle = listOf(ShiftKind.WORK, ShiftKind.OFF),
+ name = "我的方案"
+ )
+ storage.save(pattern)
+ val loaded = storage.load()
+ assertEquals(pattern, loaded)
+ assertEquals("我的方案", loaded?.name)
+ }
+
+ @Test
+ fun saveAndLoad_emptyCycle_roundTrips() {
+ storage.clear()
+ val pattern = ShiftPattern(
+ anchorDate = LocalDate(2026, 5, 15),
+ cycle = emptyList()
+ )
+ storage.save(pattern)
+ val loaded = storage.load()
+ assertEquals(pattern, loaded)
+ }
+}
+
+// 复制自 DateCheckerStorageTest(其为 private,无法共享);重命名以避免同包下 private 类同名冲突
+private class ShiftPatternTestPrefs : SharedPreferences {
+
+ private val data = mutableMapOf()
+
+ override fun getAll(): Map = data.toMap()
+
+ override fun getString(key: String, defValue: String?): String? =
+ data[key] as? String ?: defValue
+
+ override fun getStringSet(key: String, defValues: Set?): Set? =
+ data[key] as? Set ?: defValues
+
+ override fun getInt(key: String, defValue: Int): Int =
+ data[key] as? Int ?: defValue
+
+ override fun getLong(key: String, defValue: Long): Long =
+ data[key] as? Long ?: defValue
+
+ override fun getFloat(key: String, defValue: Float): Float =
+ data[key] as? Float ?: defValue
+
+ override fun getBoolean(key: String, defValue: Boolean): Boolean =
+ data[key] as? Boolean ?: defValue
+
+ override fun contains(key: String): Boolean = data.containsKey(key)
+
+ override fun edit(): SharedPreferences.Editor = ShiftPatternTestPrefsEditor(data)
+
+ override fun registerOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) {}
+
+ override fun unregisterOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) {}
+}
+
+private class ShiftPatternTestPrefsEditor(private val data: MutableMap) : SharedPreferences.Editor {
+
+ private val pending = mutableMapOf()
+ private var clearPending = false
+
+ override fun putString(key: String, value: String?): SharedPreferences.Editor = apply {
+ pending[key] = value
+ }
+
+ override fun putStringSet(key: String, values: Set?): SharedPreferences.Editor = apply {
+ pending[key] = values
+ }
+
+ override fun putInt(key: String, value: Int): SharedPreferences.Editor = apply { pending[key] = value }
+ override fun putLong(key: String, value: Long): SharedPreferences.Editor = apply { pending[key] = value }
+ override fun putFloat(key: String, value: Float): SharedPreferences.Editor = apply { pending[key] = value }
+ override fun putBoolean(key: String, value: Boolean): SharedPreferences.Editor = apply { pending[key] = value }
+
+ override fun remove(key: String): SharedPreferences.Editor = apply { pending[key] = null }
+
+ override fun clear(): SharedPreferences.Editor = apply { clearPending = true }
+
+ override fun commit(): Boolean {
+ apply()
+ return true
+ }
+
+ override fun apply() {
+ if (clearPending) {
+ data.clear()
+ clearPending = false
+ }
+ data.putAll(pending)
+ pending.clear()
+ }
+}
diff --git a/core/src/test/kotlin/plus/rua/project/ShiftPatternTest.kt b/core/src/test/kotlin/plus/rua/project/ShiftPatternTest.kt
index 1958796..9ebb401 100644
--- a/core/src/test/kotlin/plus/rua/project/ShiftPatternTest.kt
+++ b/core/src/test/kotlin/plus/rua/project/ShiftPatternTest.kt
@@ -176,4 +176,116 @@ class ShiftPatternTest {
assertEquals(a, b)
assertEquals(a.hashCode(), b.hashCode())
}
+
+ // ---- kindAt: 单日 override ----
+
+ @Test
+ fun kindAt_overrideOnBaseDay_flipsToOff() {
+ // 基础周期下 5/15 = WORK(锚点),override 为 OFF
+ val pattern = twoOnTwoOff.copy(
+ overrides = mapOf(LocalDate(2026, 5, 15) to ShiftKind.OFF)
+ )
+ assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 5, 15)))
+ // 隔天不受影响
+ assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 5, 16)))
+ }
+
+ @Test
+ fun kindAt_overrideOnOffDay_flipsToWork() {
+ // 基础周期下 5/17 = OFF,override 为 WORK
+ val pattern = twoOnTwoOff.copy(
+ overrides = mapOf(LocalDate(2026, 5, 17) to ShiftKind.WORK)
+ )
+ assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 5, 17)))
+ }
+
+ // ---- kindAt: 相位断点 phaseBreak ----
+
+ @Test
+ fun kindAt_phaseBreak_restartsCycleFromBreakDate() {
+ // 断点设在 5/19,从这天起重新 cycle[0]
+ val pattern = twoOnTwoOff.copy(
+ phaseBreaks = listOf(PhaseBreak(LocalDate(2026, 5, 19), 0))
+ )
+ // 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)
+ 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)
+ )
+ )
+ // 5/19-5/24 受第一个断点支配(offset 0)
+ // 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
+ 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)))
+ }
+
+ // ---- kindAt: override + phaseBreak 组合(用户原始例子)----
+
+ @Test
+ fun kindAt_userExample_combinedOverridesAndPhaseBreak() {
+ // 基础锚点 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 // 班→休
+ ),
+ phaseBreaks = listOf(PhaseBreak(LocalDate(2026, 7, 18), 0))
+ )
+ // 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, 11)))
+ // 7/12 = override OFF
+ assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 7, 12)))
+ // 7/13 = 基础周期 (idx 5%4=1) = WORK
+ assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 7, 13)))
+ // 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
+ assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 7, 16)))
+ assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 7, 17)))
+ // 7/18 起 phaseBreak 重排: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)))
+ }
}