feat(shift): add ShiftPatternActivity, Screen, and mini calendar grid

This commit is contained in:
xfy 2026-07-06 18:52:16 +08:00
parent d528b98cde
commit a5795b16dc
5 changed files with 469 additions and 0 deletions

View File

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

View File

@ -21,6 +21,9 @@ class MainActivity : BaseActivity() {
}, },
onNavigateToTools = { onNavigateToTools = {
startActivityWithSlide(Intent(this, ToolsActivity::class.java)) 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

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