feat: shift pattern settings page

个人轮班设置页:独立 Activity + SharedPreferences 持久化 + onResume 立即生效。

数据模型支持:
- 基础周期(锚点 + 班次序列)
- 单日翻转 overrides(调班)
- 相位断点 phaseBreaks(从某天起重排周期相位)

UI:
- FAB 菜单"班次设置"入口
- 基础周期预设(1班1休/2班2休/3班3休/4班4休)+ 锚点 DatePicker
- 迷你月历(点=翻转班休,长按=设/清断点)
- 恢复默认

实现:TDD,6 任务逐个 spec + 代码质量审查。
This commit is contained in:
xfy 2026-07-07 09:36:44 +08:00
commit 14a3239b39
12 changed files with 1066 additions and 18 deletions

View File

@ -48,6 +48,10 @@
<activity
android:name=".DateCheckerActivity"
android:exported="false" />
<activity
android:name=".ShiftPatternActivity"
android:exported="false" />
</application>
</manifest>
</manifest>

View File

@ -21,6 +21,9 @@ class MainActivity : BaseActivity() {
},
onNavigateToTools = {
startActivityWithSlide(Intent(this, ToolsActivity::class.java))
},
onNavigateToShiftSettings = {
startActivityWithSlide(Intent(this, ShiftPatternActivity::class.java))
}
)
}

View File

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

View File

@ -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?>(
ShiftPattern(
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()
}
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?> = _shiftPattern.asStateFlow()
fun shiftKindAt(date: LocalDate): ShiftKind? = shiftPattern.value?.kindAt(date)
}
/**
* 是否在右上角显示法定调休角标默认禁用,此时右上角让位给个人排班

View File

@ -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<ShiftKind>,
val overrides: Map<LocalDate, ShiftKind> = emptyMap(),
val phaseBreaks: List<PhaseBreak> = 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<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
}
}

View File

@ -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<LocalDate, ShiftKind> {
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<PhaseBreak> {
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()
}
}

View File

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

View File

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

View File

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

View File

@ -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<String, Any?>()
override fun getAll(): Map<String, *> = data.toMap()
override fun getString(key: String, defValue: String?): String? =
data[key] as? String ?: defValue
override fun getStringSet(key: String, defValues: Set<String>?): Set<String>? =
data[key] as? Set<String> ?: 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<String, Any?>) : SharedPreferences.Editor {
private val pending = mutableMapOf<String, Any?>()
private var clearPending = false
override fun putString(key: String, value: String?): SharedPreferences.Editor = apply {
pending[key] = value
}
override fun putStringSet(key: String, values: Set<String>?): 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()
}
}

View File

@ -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<String, Any?>()
override fun getAll(): Map<String, *> = data.toMap()
override fun getString(key: String, defValue: String?): String? =
data[key] as? String ?: defValue
override fun getStringSet(key: String, defValues: Set<String>?): Set<String>? =
data[key] as? Set<String> ?: 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<String, Any?>) : SharedPreferences.Editor {
private val pending = mutableMapOf<String, Any?>()
private var clearPending = false
override fun putString(key: String, value: String?): SharedPreferences.Editor = apply {
pending[key] = value
}
override fun putStringSet(key: String, values: Set<String>?): 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()
}
}

View File

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