feat(shift): add ShiftPatternActivity, Screen, and mini calendar grid
This commit is contained in:
parent
d528b98cde
commit
a5795b16dc
@ -48,6 +48,10 @@
|
||||
<activity
|
||||
android:name=".DateCheckerActivity"
|
||||
android:exported="false" />
|
||||
|
||||
<activity
|
||||
android:name=".ShiftPatternActivity"
|
||||
android:exported="false" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@ -21,6 +21,9 @@ class MainActivity : BaseActivity() {
|
||||
},
|
||||
onNavigateToTools = {
|
||||
startActivityWithSlide(Intent(this, ToolsActivity::class.java))
|
||||
},
|
||||
onNavigateToShiftSettings = {
|
||||
startActivityWithSlide(Intent(this, ShiftPatternActivity::class.java))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
18
app/src/main/kotlin/plus/rua/project/ShiftPatternActivity.kt
Normal file
18
app/src/main/kotlin/plus/rua/project/ShiftPatternActivity.kt
Normal 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() })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
229
core/src/main/kotlin/plus/rua/project/ui/ShiftCalendarGrid.kt
Normal file
229
core/src/main/kotlin/plus/rua/project/ui/ShiftCalendarGrid.kt
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
215
core/src/main/kotlin/plus/rua/project/ui/ShiftPatternScreen.kt
Normal file
215
core/src/main/kotlin/plus/rua/project/ui/ShiftPatternScreen.kt
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user