diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 02b4032..8365c24 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/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..ea35306
--- /dev/null
+++ b/core/src/main/kotlin/plus/rua/project/ui/ShiftPatternScreen.kt
@@ -0,0 +1,215 @@
+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
+ )
+
+ 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