yayacal/docs/superpowers/specs/2026-07-06-shift-pattern-settings-design.md
xfy 5afac5dd04 docs: add shift pattern settings design spec
个人轮班设置页设计:独立 Activity + SharedPreferences + onResume 重读。
数据模型支持单日 override + 相位断点重排,完整覆盖调班场景。
2026-07-06 16:43:03 +08:00

14 KiB
Raw Permalink Blame History

个人轮班设置页设计

背景

当前 ShiftPatternCalendarViewModel 中硬编码(锚点 2026-05-15,周期 [WORK, WORK, OFF, OFF]),用户无法修改,重启后回到默认值(注释明确标注"MVP 默认,后续接入设置页与持久化")。

本设计新增一个独立设置页,让用户能:

  • 编辑基础周期(锚点日期 + 班次序列);
  • 对单日做调班(翻转某天班/休);
  • 设置相位断点(从某天起重排周期相位);
  • 持久化到 SharedPreferences设置返回后主界面立即生效。

目标

  • 用户能从主界面 FAB 菜单进入"班次设置"页。
  • 基础周期通过预设方案选择1班1休 / 2班2休 / 3班3休 / 4班4休
  • 锚点日期通过 DatePicker 修改。
  • 迷你月历上:点某天 = 翻转班/休(单日 override长按某天 = 设/清相位断点
  • 设置页改动通过 LaunchedEffect 自动存盘,返回主界面后 onResume 触发 VM 重读,立即刷新日历。
  • 单元测试覆盖核心算法(kindAt 含 overrides/phaseBreaks、持久化往返、VM 重读。

需求语义(用户原始例子)

基础周期 [WORK, WORK, OFF, OFF] 锚点 7/8。期望 7/107/21 序列:

日期 10 11 12 13 14 15 16 17 18 19 20 21
目标

实现方式:

  • 7/10、7/11、7/13基础周期自动算出无需调班。
  • 7/12、7/14、7/15、7/16、7/17单日 override 翻转。
  • 7/18 起:相位断点重排(PhaseBreak(date=7/18, cycleOffset=0)18 当作 cycle[0] 重新循环,自动得到 1821 = 班班休休。

结论:单靠 override 或单靠 phaseBreak 都无法表达此例,必须两者结合

方案选择

采用独立 Activity + SharedPreferences + onResume 重读。理由:

  • 与项目现有架构一致(DateCheckerActivity + DateCheckerStorage 是现成的"设置页 + 持久化"完整范例,无 DI、无 Navigation、无 DataStore
  • 改动隔离,不污染主界面 Composable。
  • onResume 重读实现简单、体验顺滑(设置返回立即生效),无需引入 Flow 持久化或 ContentObserver。

备选方案(未采用):

  • 底部弹窗 Dialog与多 Activity 架构不一致,弹窗内 UI 拥挤。
  • 全屏覆盖层:增加主 Composable 复杂度,违背"改动隔离"原则。
  • 实时同步Flow + OnSharedPreferenceChangeListener过度设计设置页返回一次重读已足够。

详细设计

数据层

文件:core/src/main/kotlin/plus/rua/project/ShiftPattern.kt

新增 PhaseBreak 数据类,并扩展 ShiftPattern

data class PhaseBreak(
    val date: LocalDate,      // 从这天起重排
    val cycleOffset: Int      // 这天对应 cycle 的第几位0 = cycle[0]
)

data class ShiftPattern(
    val anchorDate: LocalDate,
    val cycle: List<ShiftKind>,
    val overrides: Map<LocalDate, ShiftKind> = emptyMap(),   // 单日翻转
    val phaseBreaks: List<PhaseBreak> = emptyList(),         // 相位重排
    val name: String = "默认"
) {
    fun kindAt(date: LocalDate): ShiftKind? {
        if (cycle.isEmpty()) return null
        overrides[date]?.let { return it }                   // 1. override 优先
        val (anchor, offset) = activeAnchor(date)             // 2. 找活跃锚点
        val diff = anchor.daysUntil(date)
        val size = cycle.size
        val idx = (((diff + offset) % 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
    }
}

activeAnchor 语义:在 date 当天或之前(<=)的 phaseBreaks 中取日期最大的那个;若无则回退到基础 anchorDateoffset=0kindAt 先查 override未命中再按活跃锚点推算。

注:cycleOffset 保留为可配置字段(当前 UI 仅写入 0为未来"断点从周期中段开始"留扩展点YAGNI 之下不暴露到 UI。

持久化

新文件:core/src/main/kotlin/plus/rua/project/ShiftPatternStorage.kt

照抄 DateCheckerStorage 模式SharedPreferences + fromContext 工厂 + save/load/clear不引入新依赖。

class ShiftPatternStorage(private val prefs: SharedPreferences) {
    companion object {
        private const val KEY_ANCHOR = "shift_anchor"        // "2026-07-08"ISO
        private const val KEY_CYCLE = "shift_cycle"          // "1,1,0,0"1=WORK 0=OFF
        private const val KEY_OVERRIDES = "shift_overrides"  // "2026-07-12:0,2026-07-14:1"
        private const val KEY_BREAKS = "shift_breaks"        // "2026-07-18:0"
        private const val SEPARATOR_PAIR = ","

        fun fromContext(context: Context): ShiftPatternStorage =
            ShiftPatternStorage(
                context.getSharedPreferences("shift_pattern", Context.MODE_PRIVATE)
            )
    }

    fun save(pattern: ShiftPattern)
    fun load(): ShiftPattern?
    fun clear()
}

编码格式(无 JSON 依赖,与 DateCheckerStorage 风格一致):

  • 锚点:LocalDate.toString()"2026-07-08"
  • 周期:"1,1,0,0"(逗号分隔;1=WORK 0=OFF)。
  • overrides/breaks"日期:值,日期:值"(逗号分隔的 key:value 对overrides 的值 0/1breaks 的值是 cycleOffset

load() 在锚点或周期缺失/解析失败时返回 null,由 VM 回退到 DEFAULT_PATTERN

ViewModel 接入

文件:core/src/main/kotlin/plus/rua/project/CalendarViewModel.kt

  1. 构造参数新增 shiftStorage: ShiftPatternStorage? = null(可空,便于测试注入与保持默认构造可用)。
  2. _shiftPattern 初始值改为 loadShiftPattern()
  3. 新增 refreshShiftPattern():从 storage 重读后写入 _shiftPattern.value
  4. 新增 companion object DEFAULT_PATTERN(迁移原硬编码值:锚点 2026-05-15,周期 [WORK,WORK,OFF,OFF])。
class CalendarViewModel(
    private val clock: Clock = Clock.System,
    private val shiftStorage: ShiftPatternStorage? = null
) : ViewModel() {

    private val _shiftPattern = MutableStateFlow(loadShiftPattern())
    val shiftPattern: StateFlow<ShiftPattern?> = _shiftPattern.asStateFlow()

    private fun loadShiftPattern(): ShiftPattern =
        shiftStorage?.load() ?: DEFAULT_PATTERN   // storage 为空或未存 → 用默认(非空)

    fun refreshShiftPattern() {
        _shiftPattern.value = loadShiftPattern()
    }

    companion object {
        val DEFAULT_PATTERN = ShiftPattern(
            anchorDate = LocalDate(2026, 5, 15),
            cycle = listOf(ShiftKind.WORK, ShiftKind.WORK, ShiftKind.OFF, ShiftKind.OFF)
        )
    }
}

shiftKindAt(date) 逻辑不变(内部 shiftPattern.value?.kindAt(date) 已自动支持 overrides/phaseBreaks

VM 创建方式Factory

文件:core/src/main/kotlin/plus/rua/project/ui/CalendarMonthView.kt

viewModel<CalendarViewModel>() 改为带 factory 的写法:

val context = LocalContext.current.applicationContext
val viewModel: CalendarViewModel = viewModel(
    factory = viewModelFactory {
        initializer {
            CalendarViewModel(
                clock = Clock.System,
                shiftStorage = ShiftPatternStorage.fromContext(context)
            )
        }
    }
)

androidx.lifecycle.viewmodel.viewModelFactory / initializer 已随 lifecycle-viewmodel-compose2.11.0)提供,无需加依赖。

onResume 重读(立即生效)

文件:core/src/main/kotlin/plus/rua/project/ui/CalendarMonthView.kt

在 Composable 中监听生命周期,设置页返回时刷新:

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

设置页改完 → 用户返回 → 主 Activity onResume → VM 重读 storage → shiftPattern flow 发新值 → 日历自动重组刷新。

设置页 UI

新文件:core/src/main/kotlin/plus/rua/project/ui/ShiftPatternScreen.kt

照抄 DateCheckerScreen 的 storage 创建 + 状态管理 + 自动存盘模式:

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

页面结构(三段式):

Section 1 — 基础周期

  • 锚点日期 Card点击弹 DatePickerDialog(复用 DateCheckerScreenrememberDatePickerState + toLocalDate 工具)。
  • 预设周期 FlowRowFilterChip 列表1班1休 / 2班2休 / 3班3休 / 4班4休当前选中高亮。点击 → pattern.copy(cycle = preset, overrides = emptyMap(), phaseBreaks = emptyList())(换周期清空调班,避免错位)。

Section 2 — 迷你月历(核心交互)

新文件:core/src/main/kotlin/plus/rua/project/ui/ShiftCalendarGrid.kt

  • 月份切换 < / > 头部。
  • 每个日期格 Box.aspectRatio(1f).combinedClickable(onClick = ::toggleOverride, onLongClick = ::togglePhaseBreak)
  • 右上角角标显示当天状态:
    • 断点当天 → 琥珀色 tertiary + "断"标。
    • 班 → primary + "班"标。
    • 休 → error + "休"标。
  • 月历下方提示文案:"点 = 翻转班/休,长按 = 设/清断点"。
  • 图例:颜色含义说明。

交互逻辑:

  • toggleOverride(date):算出该天不含 override 时的基础值 base;当前值翻转得到 newValnewVal == base 则从 overrides 移除该 date否则加入 date to newVal
  • togglePhaseBreak(date):已有则移除,否则加入 PhaseBreak(date, 0)
  • combinedClickable 已在 Compose Foundation 提供,无需新依赖。

Section 3 — 恢复默认

  • 底部 OutlinedButton("恢复默认")AlertDialog 确认(复用 DateCheckerScreen 的对话框样式)→ 重置为 CalendarViewModel.DEFAULT_PATTERN

入口FAB 菜单项

文件:core/src/main/kotlin/plus/rua/project/ui/CalendarMonthView.kt

在"显示调休"菜单项之后新增:

MenuItem(text = "班次设置", selected = false, onClick = {
    isMenuExpanded = false
    onNavigateToShiftSettings()
})

CalendarMonthView 签名新增 onNavigateToShiftSettings: () -> Unit = {}

Activity 与导航

新文件:app/src/main/kotlin/plus/rua/project/ShiftPatternActivity.kt(照抄 DateCheckerActivity20 行薄壳):

class ShiftPatternActivity : BaseActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            YaYaTheme {
                ShiftPatternScreen(onBack = { finishWithSlideBack() })
            }
        }
    }
}

文件改动:

  • app/src/main/kotlin/plus/rua/project/MainActivity.ktCalendarMonthView(...) 调用处加 onNavigateToShiftSettings = { startActivityWithSlide(Intent(this, ShiftPatternActivity::class.java)) }
  • app/src/main/AndroidManifest.xml:注册 <activity android:name=".ShiftPatternActivity" android:exported="false" />

颜色方案(与主日历 DayCell 一致)

元素 颜色
MaterialTheme.colorScheme.primary
MaterialTheme.colorScheme.error
断点标记 MaterialTheme.colorScheme.tertiary

测试

测试文件 覆盖内容
core/src/test/.../ShiftPatternTest.kt(改) kindAt 含 overrides / phaseBreaks / 两者结合;用户原始例子全序列断言
core/src/test/.../ShiftPatternStorageTest.kt(新) save→load 往返、空值返回 null、格式解析、复用 InMemorySharedPreferences
core/src/test/.../CalendarViewModelTest.kt(改) refreshShiftPattern() 从 mock storage 重读storage 为 null 时回退 DEFAULT_PATTERN

改动文件

  • core/src/main/kotlin/plus/rua/project/ShiftPattern.kt(改:加 PhaseBreakoverridesphaseBreaks、新 kindAt
  • core/src/main/kotlin/plus/rua/project/ShiftPatternStorage.kt(新)
  • core/src/main/kotlin/plus/rua/project/ui/ShiftPatternScreen.kt(新)
  • core/src/main/kotlin/plus/rua/project/ui/ShiftCalendarGrid.kt(新)
  • core/src/main/kotlin/plus/rua/project/CalendarViewModel.kt(改:注入 storage、refreshShiftPatternDEFAULT_PATTERN
  • core/src/main/kotlin/plus/rua/project/ui/CalendarMonthView.ktVM factory、onResume 重读、菜单项、新回调参数)
  • app/src/main/kotlin/plus/rua/project/ShiftPatternActivity.kt(新)
  • app/src/main/kotlin/plus/rua/project/MainActivity.kt(改:接导航回调)
  • app/src/main/AndroidManifest.xml(改:注册 Activity
  • core/src/test/kotlin/plus/rua/project/ShiftPatternTest.kt(改)
  • core/src/test/kotlin/plus/rua/project/ShiftPatternStorageTest.kt(新)
  • core/src/test/kotlin/plus/rua/project/CalendarViewModelTest.kt(改)

风险与注意事项

  • 换预设周期时必须清空 overrides 和 phaseBreaks,否则旧调班数据与新周期错位。copy(cycle = preset, overrides = emptyMap(), phaseBreaks = emptyList()) 已处理。
  • VM Factory 改动:原 viewModel<CalendarViewModel>() 默认构造改为 factory 注入,需确认现有测试(CalendarViewModelTest 通过 FixedClock 直接构造 VM不受影响——构造参数 shiftStorage 可空且有默认值,直接构造仍可用。
  • 迷你月历性能:单月 42 格,kindAt 是 O(phaseBreaks) 查找phaseBreaks 数量预期极小(个位数),无需优化。
  • combinedClickable 长按冲突:需确认与系统长按菜单无冲突;combinedClickable 默认不触发文本选择,无额外风险。
  • 存储格式向后兼容:未来若改格式,load() 应在解析失败时返回 null 回退默认值,避免崩溃。