From a5795b16dcb0a01d05f1f2fdb87b79131a581dc8 Mon Sep 17 00:00:00 2001 From: xfy Date: Mon, 6 Jul 2026 18:52:16 +0800 Subject: [PATCH] feat(shift): add ShiftPatternActivity, Screen, and mini calendar grid --- app/src/main/AndroidManifest.xml | 4 + .../kotlin/plus/rua/project/MainActivity.kt | 3 + .../plus/rua/project/ShiftPatternActivity.kt | 18 ++ .../plus/rua/project/ui/ShiftCalendarGrid.kt | 229 ++++++++++++++++++ .../plus/rua/project/ui/ShiftPatternScreen.kt | 215 ++++++++++++++++ 5 files changed, 469 insertions(+) create mode 100644 app/src/main/kotlin/plus/rua/project/ShiftPatternActivity.kt create mode 100644 core/src/main/kotlin/plus/rua/project/ui/ShiftCalendarGrid.kt create mode 100644 core/src/main/kotlin/plus/rua/project/ui/ShiftPatternScreen.kt 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