diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index eb02672..559e70d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,6 +14,7 @@ junit = "4.13.2" kotlin = "2.3.21" material3 = "1.10.0-alpha05" kotlinx-datetime = "0.8.0" +tyme4kt = "1.4.4" [libraries] kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } @@ -28,6 +29,7 @@ compose-components-resources = { module = "org.jetbrains.compose.components:comp compose-uiToolingPreview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "composeMultiplatform" } kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version = "1.11.0" } +tyme4kt = { module = "cn.6tail:tyme4kt", version.ref = "tyme4kt" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 34aaf6b..433742c 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -45,6 +45,7 @@ kotlin { implementation(libs.androidx.lifecycle.viewmodelCompose) implementation(libs.androidx.lifecycle.runtimeCompose) implementation(libs.kotlinx.datetime) + implementation(libs.tyme4kt) } commonTest.dependencies { implementation(libs.kotlin.test) diff --git a/shared/src/commonMain/kotlin/plus/rua/project/CalendarViewModel.kt b/shared/src/commonMain/kotlin/plus/rua/project/CalendarViewModel.kt index d328ac2..e02108d 100644 --- a/shared/src/commonMain/kotlin/plus/rua/project/CalendarViewModel.kt +++ b/shared/src/commonMain/kotlin/plus/rua/project/CalendarViewModel.kt @@ -1,11 +1,15 @@ package plus.rua.project import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.datetime.DatePeriod import kotlinx.datetime.LocalDate @@ -56,11 +60,42 @@ class CalendarViewModel( private val _collapseAnimatable = Animatable(0f) val collapseProgress: Float get() = _collapseAnimatable.value + private var yearViewJob: Job? = null + @Suppress("DEPRECATION") // monthNumber 无替代 API,kotlinx-datetime 尚未提供新接口 val currentMonth: Int get() = selectedDate.month.number val currentYear: Int get() = selectedDate.year + var isYearView by mutableStateOf(false) + private set + + private val _yearViewAnimatable = Animatable(0f) + val yearViewProgress: Float get() = _yearViewAnimatable.value + + @Suppress("DEPRECATION") // monthNumber 无替代 API + var yearViewYear by mutableStateOf(today.year) + internal set + + /** + * 个人轮班。与法定节假日完全独立,不受调休影响。 + * MVP 默认:2026-05-15 起,2 班 2 休循环。后续接入设置页与持久化。 + */ + var shiftPattern: ShiftPattern? by mutableStateOf( + ShiftPattern( + anchorDate = LocalDate(2026, 5, 15), + cycle = listOf(ShiftKind.WORK, ShiftKind.WORK, ShiftKind.OFF, ShiftKind.OFF) + ) + ) + + fun shiftKindAt(date: LocalDate): ShiftKind? = shiftPattern?.kindAt(date) + + /** + * 是否在右上角显示法定调休角标。默认禁用,此时右上角让位给个人排班。 + * 开启后回到旧版布局:左上角=排班,右上角=法定调休。后续接入设置页持久化。 + */ + var showLegalHoliday by mutableStateOf(false) + /** * 选中指定日期。 * @@ -70,6 +105,62 @@ class CalendarViewModel( selectedDate = date } + /** + * 切换年视图。仅在展开态可用。 + * + * 切换瞬间立即翻转 isYearView,让对应方向的目标视图立刻接管渲染, + * 当前视图被直接移除;动画只作用在目标视图的 scale/alpha 上。 + */ + fun toggleYearView() { + if (isCollapsed) return + yearViewJob?.cancel() + yearViewJob = coroutineScope.launch { + if (isYearView) { + // 年 → 月:先切换状态让月视图开始合成,再等一帧避免首帧抖动 + isYearView = false + withFrameNanos { } + _yearViewAnimatable.animateTo( + 0f, tween(400, easing = FastOutSlowInEasing) + ) + } else { + // 月 → 年:先切换状态让年视图开始合成 + yearViewYear = selectedDate.year + isYearView = true + _yearViewAnimatable.snapTo(0f) + withFrameNanos { } + _yearViewAnimatable.animateTo( + 1f, tween(400, easing = FastOutSlowInEasing) + ) + } + } + } + + /** + * 从年视图选择月份后返回月视图。 + */ + @Suppress("DEPRECATION") // monthNumber 无替代 API + fun selectMonthFromYearView(month: Int) { + val date = if (yearViewYear == today.year && today.month.number == month) today + else LocalDate(yearViewYear, month, 1) + selectedDate = date + isYearView = false + yearViewJob?.cancel() + yearViewJob = coroutineScope.launch { + withFrameNanos { } + _yearViewAnimatable.animateTo( + 0f, tween(400, easing = FastOutSlowInEasing) + ) + } + } + + fun incrementYear() { + yearViewYear++ + } + + fun decrementYear() { + yearViewYear-- + } + /** * 展开状态下拖拽折叠,delta 正值推动 progress 向 1(折叠方向)。 * diff --git a/shared/src/commonMain/kotlin/plus/rua/project/ShiftPattern.kt b/shared/src/commonMain/kotlin/plus/rua/project/ShiftPattern.kt new file mode 100644 index 0000000..d34037f --- /dev/null +++ b/shared/src/commonMain/kotlin/plus/rua/project/ShiftPattern.kt @@ -0,0 +1,33 @@ +package plus.rua.project + +import kotlinx.datetime.LocalDate +import kotlinx.datetime.daysUntil + +/** + * 个人轮班类型。仅区分上班与休息;后续可扩展早/中/晚班、休假等。 + */ +enum class ShiftKind { WORK, OFF } + +/** + * 个人轮班周期。 + * + * 与法定节假日完全独立:周期内某天是 WORK 还是 OFF,只看 + * `(date - anchorDate) mod cycle.size` 在 cycle 中的取值,不受任何节假日/调休影响。 + * + * @param anchorDate 周期基准日,对应 cycle[0] + * @param cycle 一个周期内的班次序列,例如 [WORK, WORK, OFF, OFF] 表示 "2 班 2 休" + * @param name 方案名,用于后续多套方案场景 + */ +data class ShiftPattern( + val anchorDate: LocalDate, + val cycle: List, + val name: String = "默认" +) { + fun kindAt(date: LocalDate): ShiftKind? { + if (cycle.isEmpty()) return null + val diff = anchorDate.daysUntil(date) + val size = cycle.size + val idx = ((diff % size) + size) % size + return cycle[idx] + } +} diff --git a/shared/src/commonMain/kotlin/plus/rua/project/ui/BottomCard.kt b/shared/src/commonMain/kotlin/plus/rua/project/ui/BottomCard.kt index 56a7ebf..d4b9ccf 100644 --- a/shared/src/commonMain/kotlin/plus/rua/project/ui/BottomCard.kt +++ b/shared/src/commonMain/kotlin/plus/rua/project/ui/BottomCard.kt @@ -14,7 +14,6 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp @@ -87,7 +86,7 @@ fun BottomCard( .align(Alignment.TopCenter) .padding(top = 8.dp, bottom = 8.dp) .clip(RoundedCornerShape(2.dp)) - .background(Color.Gray.copy(alpha = 0.4f)) + .background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)) .fillMaxWidth(0.15f) .height(4.dp) ) diff --git a/shared/src/commonMain/kotlin/plus/rua/project/ui/CalendarMonthPage.kt b/shared/src/commonMain/kotlin/plus/rua/project/ui/CalendarMonthPage.kt index 51da8c4..5aac5f4 100644 --- a/shared/src/commonMain/kotlin/plus/rua/project/ui/CalendarMonthPage.kt +++ b/shared/src/commonMain/kotlin/plus/rua/project/ui/CalendarMonthPage.kt @@ -1,8 +1,8 @@ package plus.rua.project.ui +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset @@ -16,17 +16,19 @@ import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex +import androidx.compose.material3.MaterialTheme import kotlinx.datetime.DatePeriod import kotlinx.datetime.LocalDate import kotlinx.datetime.minus import kotlinx.datetime.number import kotlinx.datetime.plus +import plus.rua.project.ShiftKind /** - * 月度日历网格页面,支持折叠动画。 + * 月度日历网格页面,支持两阶段折叠动画。 * - * 折叠时非选中行高度按 (1-p) 缩放,选中行保持原始高度, - * 所有行通过手动 y-offset 定位,形成向选中行收缩的视觉效果。 + * Phase 1:所有行整体上移,直到选中行到达顶部 (y=0),上方行被裁剪并淡出。 + * Phase 2:选中行固定不动,下方行整体上移并淡出。 * * @param year 年份 * @param month 月份(1-12) @@ -36,6 +38,8 @@ import kotlinx.datetime.plus * @param collapseProgress 折叠进度,0f=展开,1f=折叠 * @param rowHeightPx 从外层传入的锁定行高(像素),折叠过程中不变 * @param effectiveWeeks 当前有效行数(含翻页插值),用于计算总高度 + * @param shiftKindAt 日期 → 个人轮班类型的查询闭包 + * @param showLegalHoliday 是否显示法定调休角标。详见 [DayCell] 的同名参数。 * @param onRowHeightMeasured 首次行高测量回调,外层据此锁定行高 * @param modifier 外部布局修饰符 */ @@ -49,6 +53,8 @@ fun CalendarMonthPage( collapseProgress: Float, rowHeightPx: Int, effectiveWeeks: Float, + shiftKindAt: (LocalDate) -> ShiftKind?, + showLegalHoliday: Boolean, onRowHeightMeasured: ((Int) -> Unit)? = null, modifier: Modifier = Modifier ) { @@ -58,20 +64,33 @@ fun CalendarMonthPage( val density = LocalDensity.current val weeks = days.chunked(7) - val selectedWeekIndex = remember(weeks, selectedDate) { + val anchorIndex = remember(weeks, selectedDate) { weeks.indexOfFirst { week -> week.any { it.date == selectedDate } } } - - val hasSelectedWeek = selectedWeekIndex >= 0 + val hasAnchor = anchorIndex >= 0 val h = rowHeightPx.toFloat() - // 使用与 CalendarMonthView 一致的 effectiveWeeks 计算高度,避免滑动中高度不匹配 + // Phase 1 结束点:选中行到顶部所需的比例 + val phase1End = if (hasAnchor && anchorIndex > 0 && weeks.size > 1) { + anchorIndex.toFloat() / (weeks.size - 1) + } else 0f + + val phase1 = if (phase1End > 0f) { + (collapseProgress / phase1End).coerceIn(0f, 1f) + } else if (collapseProgress > 0f) 1f else 0f + + val phase2 = if (phase1End < 1f && collapseProgress > phase1End) { + ((collapseProgress - phase1End) / (1f - phase1End)).coerceIn(0f, 1f) + } else 0f + + val belowRowsHeight = if (hasAnchor) { + (weeks.size - 1 - anchorIndex) * h + } else 0f + val totalHeightDp = if (rowHeightPx > 0) { val totalPx = h * (1 + (effectiveWeeks - 1) * (1f - collapseProgress)) with(density) { totalPx.toDp() } - } else { - null - } + } else null Box( modifier = modifier.clipToBounds().then( @@ -80,49 +99,40 @@ fun CalendarMonthPage( ) ) { weeks.forEachIndexed { weekIndex, week -> - val isSelected = hasSelectedWeek && weekIndex == selectedWeekIndex - val isAbove = hasSelectedWeek && weekIndex < selectedWeekIndex - val isBelow = hasSelectedWeek && weekIndex > selectedWeekIndex + val isAnchor = hasAnchor && weekIndex == anchorIndex + val isAbove = hasAnchor && weekIndex < anchorIndex + val isBelow = hasAnchor && weekIndex > anchorIndex - val rowScale = when { - isAbove || isBelow -> 1f - collapseProgress - else -> 1f - } - - val rowHeightDp = if (rowHeightPx > 0 && rowScale > 0.01f) { - with(density) { (h * rowScale).toDp() } - } else if (rowHeightPx <= 0) { - null - } else { - 0.dp - } - - val yOffsetDp = if (rowHeightPx > 0 && hasSelectedWeek) { + val yOffsetDp = if (rowHeightPx > 0) { val yPx = when { - isAbove -> weekIndex * h * (1f - collapseProgress) - isSelected -> selectedWeekIndex * h * (1f - collapseProgress) - isBelow -> selectedWeekIndex * h * (1f - collapseProgress) + h + (weekIndex - selectedWeekIndex - 1) * h * (1f - collapseProgress) + !hasAnchor -> weekIndex * h - collapseProgress * weeks.size * h + isAnchor -> anchorIndex * h * (1f - phase1) + isAbove -> weekIndex * h - phase1 * anchorIndex * h + isBelow -> weekIndex * h - phase1 * anchorIndex * h - phase2 * belowRowsHeight else -> weekIndex * h } with(density) { yPx.toDp() } - } else if (rowHeightPx > 0) { - val yPx = weekIndex * h - with(density) { yPx.toDp() } - } else { - 0.dp + } else 0.dp + + val rowAlpha = when { + !hasAnchor -> (1f - collapseProgress).coerceIn(0f, 1f) + isAnchor -> 1f + isAbove -> (1f - phase1).coerceIn(0f, 1f) + isBelow -> (1f - phase2).coerceIn(0f, 1f) + else -> 1f } - val shouldShow = rowHeightDp == null || rowHeightDp > 0.dp - - val skipDayCells = (isAbove || isBelow) && rowScale < 0.1f && collapseProgress > 0.9f - - if (shouldShow) { + if (rowAlpha > 0.01f) { Row( modifier = Modifier .fillMaxWidth() - .zIndex(if (isSelected) 1f else 0f) + .zIndex(if (isAnchor) 1f else 0f) .then( - if (rowHeightDp != null) Modifier.height(rowHeightDp) + if (rowHeightPx > 0) Modifier.height(with(density) { h.toDp() }) + else Modifier + ) + .then( + if (isAnchor && phase1 >= 1f) Modifier.background(MaterialTheme.colorScheme.surface) else Modifier ) .offset(y = yOffsetDp) @@ -137,25 +147,21 @@ fun CalendarMonthPage( ) .padding(vertical = ROW_PADDING_DP.dp) .then( - if (isAbove || isBelow) Modifier.graphicsLayer { - alpha = 1f - collapseProgress - } + if (rowAlpha < 1f) Modifier.graphicsLayer { alpha = rowAlpha } else Modifier ) ) { - if (skipDayCells) { - Spacer(Modifier.weight(1f)) - } else { - week.forEach { dayData -> - DayCell( - date = dayData.date, - isCurrentMonth = dayData.isCurrentMonth, - isSelected = dayData.date == selectedDate, - isToday = dayData.date == today, - onClick = { onDateClick(dayData.date) }, - modifier = Modifier.weight(1f) - ) - } + week.forEach { dayData -> + DayCell( + date = dayData.date, + isCurrentMonth = dayData.isCurrentMonth, + isSelected = dayData.date == selectedDate, + isToday = dayData.date == today, + shiftKind = shiftKindAt(dayData.date), + showLegalHoliday = showLegalHoliday, + onClick = { onDateClick(dayData.date) }, + modifier = Modifier.weight(1f) + ) } } } @@ -185,4 +191,4 @@ private fun generateMonthDays(year: Int, month: Int): List { isCurrentMonth = date.month.number == month && date.year == year ) } -} \ No newline at end of file +} diff --git a/shared/src/commonMain/kotlin/plus/rua/project/ui/CalendarMonthView.kt b/shared/src/commonMain/kotlin/plus/rua/project/ui/CalendarMonthView.kt index c24ae65..e4ed8cb 100644 --- a/shared/src/commonMain/kotlin/plus/rua/project/ui/CalendarMonthView.kt +++ b/shared/src/commonMain/kotlin/plus/rua/project/ui/CalendarMonthView.kt @@ -7,17 +7,23 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerDefaults import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp @@ -27,15 +33,16 @@ import kotlinx.datetime.TimeZone import kotlinx.datetime.number import kotlinx.datetime.plus import kotlinx.datetime.todayIn +import kotlinx.coroutines.launch import plus.rua.project.CalendarViewModel import kotlin.math.abs import kotlin.time.Clock /** - * 日历主界面,包含月/周视图切换和折叠动画。 + * 日历主界面,包含月/周视图切换、折叠动画和年视图缩放转场。 * * 折叠时日历从月视图收缩为周视图(1行),BottomCard 同步上移填充空间。 - * 支持动态行数(4/5/6行),滑动切换月份时 BottomCard 跟手移动。 + * 点击月份标题切换年视图,以当前月为锚点缩放转场。 * * @param modifier 外部布局修饰符 */ @@ -57,10 +64,50 @@ fun CalendarMonthView( var rowHeightPx by remember { mutableIntStateOf(0) } var screenWidthPx by remember { mutableIntStateOf(0) } var screenHeightPx by remember { mutableIntStateOf(0) } + var calendarContentHeightPx by remember { mutableIntStateOf(0) } val pagerState = rememberPagerState(initialPage = START_PAGE, pageCount = { Int.MAX_VALUE }) + // 年视图分页器 + val yearPagerState = rememberPagerState( + initialPage = START_PAGE, + pageCount = { Int.MAX_VALUE } + ) + + // 进入年视图时同步 yearPagerState 到当前年 + LaunchedEffect(viewModel.isYearView) { + if (viewModel.isYearView) { + if (yearPagerState.currentPage != START_PAGE) { + yearPagerState.scrollToPage(START_PAGE) + } + } + } + + // 年视图翻页时同步 yearViewYear + LaunchedEffect(yearPagerState) { + snapshotFlow { yearPagerState.settledPage }.collect { page -> + val offset = page - START_PAGE + val targetYear = viewModel.selectedDate.year + offset + if (targetYear != viewModel.yearViewYear) { + viewModel.yearViewYear = targetYear + } + } + } + + // 折叠态 WeekPager 切月时,持续同步 CalendarPager 的 pagerState + LaunchedEffect(viewModel.selectedDate) { + @Suppress("DEPRECATION") // monthNumber 无替代 API + val targetPage = yearMonthToPage( + viewModel.selectedDate.year, viewModel.selectedDate.month.number, + today.year, today.month.number + ) + if (targetPage != pagerState.currentPage) { + pagerState.scrollToPage(targetPage) + } + } + val collapseProgress = viewModel.collapseProgress + val yearProgress = viewModel.yearViewProgress val headerHeightPx = monthHeaderHeightPx + weekdayHeaderHeightPx val rowPaddingPx = with(density) { ROW_PADDING_DP.dp.toPx() }.toInt() val cardGapPx = with(density) { @@ -71,8 +118,6 @@ fun CalendarMonthView( ).dp.toPx() }.toInt() - // 翻页时在相邻月份行数之间插值,使 BottomCard 高度平滑过渡 - // abs(fraction) > 阈值时启用插值,避免静止时的浮点抖动 val interpolatedWeeks by remember { derivedStateOf { val fraction = pagerState.currentPageOffsetFraction @@ -88,9 +133,6 @@ fun CalendarMonthView( } } - // 预估行高:DayCell aspectRatio=1,宽度 = (screenWidth - horizontalPadding) / 7 - // 加上 Row 的 vertical padding (6dp × 2) - // 用于 rowHeightPx 尚未测量时的 fallback,避免首次布局高度为 0 val estimatedRowHeightPx = if (screenWidthPx > 0) { val cellWidth = (screenWidthPx - with(density) { (HORIZONTAL_PADDING_DP * 2).dp.toPx() }) / 7 @@ -99,14 +141,8 @@ fun CalendarMonthView( } else 0 val effectiveRowHeightPx = if (rowHeightPx > 0) rowHeightPx else estimatedRowHeightPx - val effectiveWeeks = interpolatedWeeks - // 折叠时网格高度公式(与 CalendarMonthPage 一致): - // collapseProgress=0 展开时 gridH = rowH × weeks;collapseProgress=1 折叠时 gridH = rowH × 1 - // 中间态:gridH = rowH × (1 + (weeks-1) × (1-collapseProgress)) - // 直接计算而非 derivedStateOf:effectiveRowHeightPx 依赖 rowHeightPx state, - // derivedStateOf 无法追踪非 State 局部变量,rowHeightPx 从 0 变为测量值时 gridHeightPx 不会更新 val gridHeightPx = if (effectiveRowHeightPx > 0) { val rowH = effectiveRowHeightPx.toFloat() if (collapseProgress > OFFSET_FRACTION_THRESHOLD) { @@ -116,12 +152,10 @@ fun CalendarMonthView( } } else 0 - // BottomCard 高度 = 屏幕剩余空间(屏幕高度 - 日历区域高度) val calendarAreaHeightPx = headerHeightPx + gridHeightPx + rowPaddingPx + cardGapPx val cardHeightPx = if (screenHeightPx > 0 && calendarAreaHeightPx > 0) screenHeightPx - calendarAreaHeightPx else 0 - // 行高已知时约束 pager 高度防止内容溢出;否则让 pager 自由扩展以触发首次行高测量 val pagerModifier = if (rowHeightPx > 0 && gridHeightPx > 0) { Modifier .height(with(density) { gridHeightPx.toDp() }) @@ -130,6 +164,20 @@ fun CalendarMonthView( Modifier } + // 年视图锚点缩放:当前月在 4×3 网格中的归一化位置 + val anchorPivotX = ((currentMonth - 1) % 3 + 0.5f) / 3f + val anchorPivotY = ((currentMonth - 1) / 3 + 0.5f) / 4f + + // 过渡进度:0=目标视图刚出现,1=目标视图完全到位。 + // 月→年时 yearProgress 从 0→1,年→月时从 1→0,因此用 isYearView 同步翻转方向。 + val transitionProgress = if (viewModel.isYearView) yearProgress else 1f - yearProgress + val targetAlpha = transitionProgress.coerceIn(0f, 1f) + + // 月视图层缩放:从 0.3f(年网格单格大小)放大到 1f + val monthScale = lerp(0.3f, 1f, transitionProgress) + // 年视图层缩放:从 3.3f(月视图被放大到一格那么大的反向比例)缩小到 1f + val yearScale = lerp(3.3f, 1f, transitionProgress) + Box( modifier = modifier .fillMaxSize() @@ -139,77 +187,161 @@ fun CalendarMonthView( screenHeightPx = size.height } ) { - Column(modifier = Modifier.padding(horizontal = HORIZONTAL_PADDING_DP.dp)) { - MonthHeader( - year = currentYear, - month = currentMonth, - weekNumber = viewModel.getIsoWeekNumber(viewModel.selectedDate), - modifier = Modifier.onSizeChanged { size -> - monthHeaderHeightPx = size.height - } - ) - WeekdayHeader( - modifier = Modifier.fillMaxWidth().padding(bottom = ROW_PADDING_DP.dp) - .onSizeChanged { size -> - weekdayHeaderHeightPx = size.height - } - ) - // 完全折叠且无动画时切换到 WeekPager(单行高效渲染), - // 否则使用 CalendarPager(含折叠动画和下拉恢复过程) - if (viewModel.isCollapsed && viewModel.collapseProgress >= 1f) { - WeekPager( - selectedDate = viewModel.selectedDate, - today = today, - onDateClick = { date -> viewModel.selectDate(date) }, - onWeekChanged = { weekMonday -> - // 优先选中当周内的今天,否则选中该周周一 - val weekSunday = weekMonday.plus(DatePeriod(days = 6)) - val date = if (today in weekMonday..weekSunday) today else weekMonday - viewModel.selectDate(date) - }, - modifier = pagerModifier - ) + // 月视图层:仅在非年视图时渲染,年视图激活时立即移除。 + if (!viewModel.isYearView) { + val dragRangeMinPx = with(density) { DRAG_RANGE_MIN_DP.dp.toPx() } + val dragRangePx = if (effectiveRowHeightPx > 0) { + maxOf((effectiveWeeks - 1) * effectiveRowHeightPx.toFloat(), dragRangeMinPx) } else { - CalendarPager( - selectedDate = viewModel.selectedDate, - today = today, - onDateClick = { date -> viewModel.selectDate(date) }, - onMonthChanged = { year, month -> - // 优先选中当月内的今天,否则选中该月1号 - @Suppress("DEPRECATION") // monthNumber 无替代 API,kotlinx-datetime 尚未提供新接口 - val date = if (year == today.year && today.month.number == month) today - else LocalDate(year, month, 1) - viewModel.selectDate(date) - }, - collapseProgress = viewModel.collapseProgress, - rowHeightPx = rowHeightPx, - effectiveWeeks = effectiveWeeks, - onRowHeightMeasured = { h -> - if (h > 0) rowHeightPx = h - }, - pagerState = pagerState, - modifier = pagerModifier - ) + dragRangeMinPx + } + + Box( + modifier = Modifier + .fillMaxSize() + .graphicsLayer { + scaleX = monthScale + scaleY = monthScale + alpha = targetAlpha + transformOrigin = TransformOrigin(anchorPivotX, anchorPivotY) + } + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = HORIZONTAL_PADDING_DP.dp) + ) { + MonthHeader( + year = currentYear, + month = currentMonth, + weekNumber = viewModel.getIsoWeekNumber(viewModel.selectedDate), + showToday = viewModel.selectedDate != today, + onToggleYearView = { viewModel.toggleYearView() }, + onToday = { + viewModel.selectDate(today) + @Suppress("DEPRECATION") // monthNumber 无替代 API + val targetPage = yearMonthToPage( + today.year, today.month.number, + today.year, today.month.number + ) + if (targetPage != pagerState.currentPage) { + coroutineScope.launch { pagerState.animateScrollToPage(targetPage) } + } + }, + modifier = Modifier.onSizeChanged { size -> + monthHeaderHeightPx = size.height + } + ) + WeekdayHeader( + modifier = Modifier.fillMaxWidth().padding(bottom = ROW_PADDING_DP.dp) + .onSizeChanged { size -> + weekdayHeaderHeightPx = size.height + } + ) + if (viewModel.isCollapsed && viewModel.collapseProgress >= 1f) { + WeekPager( + selectedDate = viewModel.selectedDate, + today = today, + onDateClick = { date -> viewModel.selectDate(date) }, + onWeekChanged = { weekMonday -> + val weekSunday = weekMonday.plus(DatePeriod(days = 6)) + val date = when { + today in weekMonday..weekSunday -> today + weekMonday.month != weekSunday.month -> { + if (weekMonday < viewModel.selectedDate) { + @Suppress("DEPRECATION") // monthNumber 无替代 API + LocalDate(weekSunday.year, weekSunday.month.number, 1) + } else { + weekMonday + } + } + else -> weekMonday + } + viewModel.selectDate(date) + }, + shiftKindAt = { date -> viewModel.shiftKindAt(date) }, + showLegalHoliday = viewModel.showLegalHoliday, + modifier = pagerModifier + ) + } else { + CalendarPager( + selectedDate = viewModel.selectedDate, + today = today, + onDateClick = { date -> viewModel.selectDate(date) }, + onMonthChanged = { year, month -> + @Suppress("DEPRECATION") // monthNumber 无替代 API + val date = if (year == today.year && today.month.number == month) today + else LocalDate(year, month, 1) + viewModel.selectDate(date) + }, + collapseProgress = viewModel.collapseProgress, + rowHeightPx = rowHeightPx, + effectiveWeeks = effectiveWeeks, + shiftKindAt = { date -> viewModel.shiftKindAt(date) }, + showLegalHoliday = viewModel.showLegalHoliday, + onRowHeightMeasured = { h -> + if (h > 0) rowHeightPx = h + }, + pagerState = pagerState, + modifier = pagerModifier + ) + } + } + + if (cardHeightPx > 0) { + BottomCard( + viewModel = viewModel, + dragRangePx = dragRangePx, + modifier = Modifier + .fillMaxWidth() + .height(with(density) { cardHeightPx.toDp() }) + .align(Alignment.BottomCenter) + ) + } } } - // 拖拽范围 = 折叠时日历实际高度变化量 (weeks-1)×rowHeight,使手指移动与视觉变化 1:1 对应 - val dragRangeMinPx = with(density) { DRAG_RANGE_MIN_DP.dp.toPx() } - val dragRangePx = if (effectiveRowHeightPx > 0) { - maxOf((effectiveWeeks - 1) * effectiveRowHeightPx.toFloat(), dragRangeMinPx) - } else { - dragRangeMinPx - } - - if (cardHeightPx > 0) { - BottomCard( - viewModel = viewModel, - dragRangePx = dragRangePx, + // 年视图层:仅在年视图激活时渲染;HorizontalPager 支持左右滑动切年。 + if (viewModel.isYearView) { + HorizontalPager( + state = yearPagerState, + beyondViewportPageCount = 1, + flingBehavior = PagerDefaults.flingBehavior(state = yearPagerState), modifier = Modifier - .fillMaxWidth() - .height(with(density) { cardHeightPx.toDp() }) - .align(Alignment.BottomCenter) - ) + .fillMaxSize() + .graphicsLayer { + scaleX = yearScale + scaleY = yearScale + alpha = targetAlpha + transformOrigin = TransformOrigin(anchorPivotX, anchorPivotY) + } + .padding(horizontal = HORIZONTAL_PADDING_DP.dp) + ) { page -> + val pageYear = viewModel.selectedDate.year + (page - START_PAGE) + YearGridView( + year = pageYear, + selectedMonth = if (pageYear == currentYear) currentMonth else 0, + today = today, + onMonthClick = { month -> + viewModel.selectMonthFromYearView(month) + @Suppress("DEPRECATION") // monthNumber 无替代 API + val targetPage = yearMonthToPage( + viewModel.yearViewYear, month, + today.year, today.month.number + ) + if (targetPage != pagerState.currentPage) { + coroutineScope.launch { pagerState.scrollToPage(targetPage) } + } + }, + onYearChange = { newYear -> + val offset = newYear - pageYear + val targetPage = yearPagerState.currentPage + offset + if (targetPage != yearPagerState.currentPage) { + coroutineScope.launch { yearPagerState.animateScrollToPage(targetPage) } + } + } + ) + } } } } diff --git a/shared/src/commonMain/kotlin/plus/rua/project/ui/CalendarPager.kt b/shared/src/commonMain/kotlin/plus/rua/project/ui/CalendarPager.kt index 1d29e21..57d20c0 100644 --- a/shared/src/commonMain/kotlin/plus/rua/project/ui/CalendarPager.kt +++ b/shared/src/commonMain/kotlin/plus/rua/project/ui/CalendarPager.kt @@ -14,6 +14,7 @@ import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch import kotlinx.datetime.LocalDate import kotlinx.datetime.number +import plus.rua.project.ShiftKind import kotlin.math.abs /** @@ -29,6 +30,8 @@ import kotlin.math.abs * @param collapseProgress 折叠进度,0f=展开,1f=折叠 * @param rowHeightPx 锁定行高(像素) * @param effectiveWeeks 当前有效行数(含翻页插值) + * @param shiftKindAt 日期 → 个人轮班类型的查询闭包 + * @param showLegalHoliday 是否显示法定调休角标。详见 [DayCell] 的同名参数。 * @param onRowHeightMeasured 首次行高测量回调 * @param pagerState 外层共享的 PagerState,用于保持翻页状态 * @param modifier 外部布局修饰符 @@ -42,6 +45,8 @@ fun CalendarPager( collapseProgress: Float, rowHeightPx: Int, effectiveWeeks: Float, + shiftKindAt: (LocalDate) -> ShiftKind?, + showLegalHoliday: Boolean, onRowHeightMeasured: ((Int) -> Unit)? = null, pagerState: PagerState, modifier: Modifier = Modifier @@ -94,6 +99,8 @@ fun CalendarPager( collapseProgress = collapseProgress, rowHeightPx = rowHeightPx, effectiveWeeks = effectiveWeeks, + shiftKindAt = shiftKindAt, + showLegalHoliday = showLegalHoliday, onRowHeightMeasured = onRowHeightMeasured, modifier = Modifier.alpha(alpha) ) diff --git a/shared/src/commonMain/kotlin/plus/rua/project/ui/DayCell.kt b/shared/src/commonMain/kotlin/plus/rua/project/ui/DayCell.kt index 03bb0c5..b77dac0 100644 --- a/shared/src/commonMain/kotlin/plus/rua/project/ui/DayCell.kt +++ b/shared/src/commonMain/kotlin/plus/rua/project/ui/DayCell.kt @@ -5,24 +5,39 @@ import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.tween import androidx.compose.animation.core.updateTransition +import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.zIndex +import com.tyme.solar.SolarDay import kotlinx.datetime.LocalDate +import plus.rua.project.ShiftKind enum class DayCellState { NORMAL, OTHER_MONTH, TODAY, SELECTED, SELECTED_TODAY @@ -35,6 +50,10 @@ enum class DayCellState { * @param isCurrentMonth 是否属于当前显示月份 * @param isSelected 是否为选中日期 * @param isToday 是否为今天 + * @param shiftKind 个人轮班类型;null 表示不显示。与法定调休完全独立。 + * @param showLegalHoliday 是否显示法定调休角标。 + * false(默认):排班放右上角,左上角空白,不显示法定调休。 + * true:排班放左上角,法定调休放右上角(旧版布局)。 * @param onClick 点击回调 * @param modifier 外部布局修饰符 */ @@ -44,6 +63,8 @@ fun DayCell( isCurrentMonth: Boolean, isSelected: Boolean, isToday: Boolean, + shiftKind: ShiftKind?, + showLegalHoliday: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier ) { @@ -58,7 +79,7 @@ fun DayCell( val transition = updateTransition(targetState = currentState, label = "dayCell") val revealProgress by transition.animateFloat( - transitionSpec = { tween(250, easing = FastOutSlowInEasing) }, + transitionSpec = { tween(150, easing = FastOutSlowInEasing) }, label = "revealProgress" ) { state -> when (state) { @@ -68,71 +89,224 @@ fun DayCell( } val contentColor by transition.animateColor( - transitionSpec = { tween(250, easing = FastOutSlowInEasing) }, + transitionSpec = { tween(150, easing = FastOutSlowInEasing) }, label = "contentColor" ) { state -> when (state) { DayCellState.SELECTED_TODAY -> MaterialTheme.colorScheme.onPrimaryContainer - DayCellState.SELECTED -> MaterialTheme.colorScheme.onPrimary + DayCellState.SELECTED -> MaterialTheme.colorScheme.primary DayCellState.TODAY -> MaterialTheme.colorScheme.primary DayCellState.OTHER_MONTH -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) DayCellState.NORMAL -> MaterialTheme.colorScheme.onSurface } } - val selectedColor by transition.animateColor( - transitionSpec = { tween(250, easing = FastOutSlowInEasing) }, - label = "selectedColor" + // 选中今天:实心填充 primaryContainer;其他状态不填充。 + val selectedFillColor by transition.animateColor( + transitionSpec = { tween(150, easing = FastOutSlowInEasing) }, + label = "selectedFillColor" ) { state -> when (state) { DayCellState.SELECTED_TODAY -> MaterialTheme.colorScheme.primaryContainer - DayCellState.SELECTED -> MaterialTheme.colorScheme.primary else -> Color.Transparent } } - val borderAlpha by transition.animateFloat( - transitionSpec = { tween(250, easing = FastOutSlowInEasing) }, - label = "borderAlpha" + // 选中非今天:绘制描边圆,避免遮挡右上角角标。 + val selectedOutlineAlpha by transition.animateFloat( + transitionSpec = { tween(150, easing = FastOutSlowInEasing) }, + label = "selectedOutlineAlpha" ) { state -> when (state) { - DayCellState.TODAY -> 1.5f + DayCellState.SELECTED -> 1f else -> 0f } } - val todayBorderColor = MaterialTheme.colorScheme.primary + val selectedOutlineColor = MaterialTheme.colorScheme.primary + + data class DayAnnotation(val text: String, val isHighlight: Boolean) + + val holidayBadge = remember(date) { + @Suppress("DEPRECATION") // monthNumber 无替代 API + val solarDay = SolarDay.fromYmd(date.year, date.monthNumber, date.day) + solarDay.getLegalHoliday()?.let { if (it.isWork()) "班" else "休" } + } + + val annotation = remember(date) { + @Suppress("DEPRECATION") // monthNumber 无替代 API + val solarDay = SolarDay.fromYmd(date.year, date.monthNumber, date.day) + val lunarDay = solarDay.getLunarDay() + + // 农历传统节日(仅当天) + val lunarFestival = lunarDay.getFestival() + if (lunarFestival != null) { + return@remember DayAnnotation(lunarFestival.getName(), true) + } + + // 节气(当天才显示) + val termDay = solarDay.getTermDay() + if (termDay.getDayIndex() == 0) { + return@remember DayAnnotation(termDay.getSolarTerm().getName(), true) + } + + // 公历节日(仅当天) + val solarFestival = solarDay.getFestival() + if (solarFestival != null) { + return@remember DayAnnotation(solarFestival.getName(), true) + } + + // 默认:农历日期 + val name = lunarDay.getName() + val text = if (name == "初一") { + val lunarMonth = lunarDay.getLunarMonth() + "${lunarMonth.getName()}月" + } else { + name + } + DayAnnotation(text, false) + } + + val lunarColor by transition.animateColor( + transitionSpec = { tween(150, easing = FastOutSlowInEasing) }, + label = "lunarColor" + ) { state -> + if (annotation.isHighlight) { + when (state) { + DayCellState.SELECTED_TODAY -> MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.85f) + DayCellState.SELECTED -> MaterialTheme.colorScheme.error.copy(alpha = 0.7f) + DayCellState.TODAY -> MaterialTheme.colorScheme.primary + DayCellState.OTHER_MONTH -> MaterialTheme.colorScheme.error.copy(alpha = 0.35f) + DayCellState.NORMAL -> MaterialTheme.colorScheme.error.copy(alpha = 0.7f) + } + } else { + when (state) { + DayCellState.SELECTED_TODAY -> MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f) + DayCellState.SELECTED -> MaterialTheme.colorScheme.primary.copy(alpha = 0.7f) + DayCellState.TODAY -> MaterialTheme.colorScheme.primary.copy(alpha = 0.6f) + DayCellState.OTHER_MONTH -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.26f) + DayCellState.NORMAL -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f) + } + } + } + + val holidayBadgeColor = when (holidayBadge) { + "休" -> MaterialTheme.colorScheme.error + "班" -> MaterialTheme.colorScheme.primary + else -> Color.Transparent + } + val holidayBadgeAlpha = if (isCurrentMonth) 1f else 0.38f Box( - modifier = modifier - .aspectRatio(1f) - .clip(CircleShape) - .drawBehind { - if (revealProgress > 0f) { - val maxRadius = size.minDimension / 2f - drawCircle( - color = selectedColor, - radius = revealProgress * maxRadius, - center = Offset(size.width / 2f, size.height / 2f) - ) - } - if (borderAlpha > 0f) { - drawCircle( - color = todayBorderColor.copy(alpha = borderAlpha.coerceAtMost(1f)), - radius = size.minDimension / 2f, - center = Offset(size.width / 2f, size.height / 2f), - style = Stroke(width = borderAlpha.coerceAtMost(1.5f) * 1.5.dp.toPx()) - ) - } - } - .clickable(onClick = onClick), - contentAlignment = Alignment.Center + modifier = modifier.aspectRatio(1f) ) { - Text( - text = date.day.toString(), - textAlign = TextAlign.Center, - color = contentColor, - style = MaterialTheme.typography.bodyMedium - ) + Box( + modifier = Modifier + .fillMaxSize() + .semantics { + @Suppress("DEPRECATION") + contentDescription = "${date.year}年${date.monthNumber}月${date.day}日" + } + .clip(CircleShape) + .drawBehind { + val maxRadius = size.minDimension / 2f + val center = Offset(size.width / 2f, size.height / 2f) + if (revealProgress > 0f && selectedFillColor.alpha > 0f) { + drawCircle( + color = selectedFillColor, + radius = revealProgress * maxRadius, + center = center + ) + } + if (revealProgress > 0f && selectedOutlineAlpha > 0f) { + val strokePx = 1.5.dp.toPx() + drawCircle( + color = selectedOutlineColor.copy(alpha = selectedOutlineAlpha), + radius = revealProgress * maxRadius - strokePx / 2f, + center = center, + style = Stroke(width = strokePx) + ) + } + } + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onClick + ), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = date.day.toString(), + textAlign = TextAlign.Center, + color = contentColor, + style = MaterialTheme.typography.bodyMedium + ) + Text( + text = annotation.text, + textAlign = TextAlign.Center, + color = lunarColor, + fontSize = 7.sp, + maxLines = 1, + overflow = TextOverflow.Clip, + lineHeight = 9.sp + ) + } + } + if (shiftKind != null) { + val shiftAccentColor = if (shiftKind == ShiftKind.WORK) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.error + } + val shiftOnAccentColor = if (shiftKind == ShiftKind.WORK) { + MaterialTheme.colorScheme.onPrimary + } else { + MaterialTheme.colorScheme.onError + } + val shiftLabel = if (shiftKind == ShiftKind.WORK) "班" else "休" + val shiftAlpha = if (isCurrentMonth) 1f else 0.38f + // 右上角(默认)沿用法定调休视觉:surface 背景 + 彩色文字; + // 左上角(showLegalHoliday=true 时)用实心胶囊,与右上角法定调休区分。 + val shiftBgColor = + if (showLegalHoliday) shiftAccentColor else MaterialTheme.colorScheme.surface + val shiftFgColor = if (showLegalHoliday) shiftOnAccentColor else shiftAccentColor + val shiftAlignment = if (showLegalHoliday) Alignment.TopStart else Alignment.TopEnd + val shiftPadding = if (showLegalHoliday) { + Modifier.padding(top = 1.dp, start = 2.dp) + } else { + Modifier.padding(top = 1.dp, end = 2.dp) + } + Text( + text = shiftLabel, + color = shiftFgColor.copy(alpha = shiftAlpha), + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + lineHeight = 9.sp, + modifier = Modifier + .align(shiftAlignment) + .zIndex(1f) + .then(shiftPadding) + .background(shiftBgColor.copy(alpha = shiftAlpha), CircleShape) + .padding(horizontal = 2.dp) + ) + } + if (showLegalHoliday && holidayBadge != null) { + Text( + text = holidayBadge, + color = holidayBadgeColor.copy(alpha = holidayBadgeAlpha), + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + lineHeight = 9.sp, + modifier = Modifier + .align(Alignment.TopEnd) + .zIndex(1f) + .padding(top = 1.dp, end = 2.dp) + .background(MaterialTheme.colorScheme.surface, CircleShape) + .padding(horizontal = 2.dp) + ) + } } } diff --git a/shared/src/commonMain/kotlin/plus/rua/project/ui/MonthHeader.kt b/shared/src/commonMain/kotlin/plus/rua/project/ui/MonthHeader.kt index e8d3ca1..66342b6 100644 --- a/shared/src/commonMain/kotlin/plus/rua/project/ui/MonthHeader.kt +++ b/shared/src/commonMain/kotlin/plus/rua/project/ui/MonthHeader.kt @@ -7,17 +7,21 @@ import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.animation.togetherWith +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp /** * 月份标题栏,显示"年月"文字和 ISO 周号。 @@ -25,6 +29,9 @@ import androidx.compose.ui.unit.dp * @param year 年份 * @param month 月份(1-12) * @param weekNumber 当前 ISO 周号 + * @param showToday 是否显示「今天」按钮(当 selectedDate ≠ today 时) + * @param onToggleYearView 点击标题切换年视图 + * @param onToday 点击「今天」按钮跳转今天 * @param modifier 外部布局修饰符 */ @Composable @@ -32,13 +39,17 @@ fun MonthHeader( year: Int, month: Int, weekNumber: Int, + showToday: Boolean, + onToggleYearView: () -> Unit, + onToday: (() -> Unit)? = null, modifier: Modifier = Modifier ) { Row( modifier = modifier .fillMaxWidth() - .padding(vertical = 14.dp, horizontal = 12.dp), - verticalAlignment = Alignment.CenterVertically + .padding(vertical = 14.dp, horizontal = 12.dp) + .clickable(onClick = onToggleYearView), + verticalAlignment = Alignment.Bottom ) { AnimatedContent( targetState = Pair(year, month), @@ -68,12 +79,25 @@ fun MonthHeader( slideInVertically(tween(250)) { it } + fadeIn(tween(250)) togetherWith slideOutVertically(tween(250)) { -it } + fadeOut(tween(250)) } - } + }, + modifier = Modifier.padding(bottom = 2.dp) ) { week -> Text( text = "第${week}周", style = MaterialTheme.typography.bodySmall ) } + Spacer(modifier = Modifier.weight(1f)) + if (showToday && onToday != null) { + Text( + text = "今天", + color = MaterialTheme.colorScheme.primary, + fontSize = 14.sp, + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .clickable(onClick = onToday) + .padding(horizontal = 10.dp, vertical = 4.dp) + ) + } } } diff --git a/shared/src/commonMain/kotlin/plus/rua/project/ui/WeekPager.kt b/shared/src/commonMain/kotlin/plus/rua/project/ui/WeekPager.kt index 6173edb..e500203 100644 --- a/shared/src/commonMain/kotlin/plus/rua/project/ui/WeekPager.kt +++ b/shared/src/commonMain/kotlin/plus/rua/project/ui/WeekPager.kt @@ -16,7 +16,9 @@ import androidx.compose.ui.unit.dp import kotlinx.coroutines.flow.drop import kotlinx.datetime.DatePeriod import kotlinx.datetime.LocalDate +import kotlinx.datetime.daysUntil import kotlinx.datetime.plus +import plus.rua.project.ShiftKind import kotlin.math.abs /** @@ -26,6 +28,8 @@ import kotlin.math.abs * @param today 今天的日期 * @param onDateClick 日期点击回调 * @param onWeekChanged 周切换回调,滑动到新周时触发,参数为该周周一日期 + * @param shiftKindAt 日期 → 个人轮班类型的查询闭包 + * @param showLegalHoliday 是否显示法定调休角标。详见 [DayCell] 的同名参数。 * @param modifier 外部布局修饰符 */ @Composable @@ -34,6 +38,8 @@ fun WeekPager( today: LocalDate, onDateClick: (LocalDate) -> Unit, onWeekChanged: (LocalDate) -> Unit, + shiftKindAt: (LocalDate) -> ShiftKind?, + showLegalHoliday: Boolean, modifier: Modifier = Modifier ) { val initialWeekMonday = remember { selectedDate.toWeekMonday() } @@ -42,6 +48,15 @@ fun WeekPager( pageCount = { Int.MAX_VALUE } ) + // selectedDate 外部变更(如点击回到今天)时,滚动到对应周 + LaunchedEffect(selectedDate) { + val targetMonday = selectedDate.toWeekMonday() + val targetPage = START_PAGE + (initialWeekMonday.daysUntil(targetMonday) / 7) + if (pagerState.currentPage != targetPage) { + pagerState.animateScrollToPage(targetPage) + } + } + LaunchedEffect(pagerState) { snapshotFlow { pagerState.settledPage }.drop(1).collect { page -> val weekMonday = pageToWeekMonday(page, initialWeekMonday) @@ -68,9 +83,12 @@ fun WeekPager( val date = weekMonday.plus(DatePeriod(days = dayOffset)) DayCell( date = date, - isCurrentMonth = true, + isCurrentMonth = date.month == selectedDate.month + && date.year == selectedDate.year, isSelected = date == selectedDate, isToday = date == today, + shiftKind = shiftKindAt(date), + showLegalHoliday = showLegalHoliday, onClick = { onDateClick(date) }, modifier = Modifier.weight(1f) ) diff --git a/shared/src/commonMain/kotlin/plus/rua/project/ui/YearGridView.kt b/shared/src/commonMain/kotlin/plus/rua/project/ui/YearGridView.kt new file mode 100644 index 0000000..4fae310 --- /dev/null +++ b/shared/src/commonMain/kotlin/plus/rua/project/ui/YearGridView.kt @@ -0,0 +1,244 @@ +package plus.rua.project.ui + +import androidx.compose.foundation.clickable +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +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.minus +import kotlinx.datetime.number +import kotlinx.datetime.plus + +private val WEEKDAY_LABELS = listOf("一", "二", "三", "四", "五", "六", "日") + +/** + * 年度网格视图,显示 4×3 精简月历网格,支持年份切换。 + * + * 每格显示一个精简版月历(月份标题 + 星期行 + 日期数字网格), + * 选中月份高亮,点击进入该月。 + * + * @param year 显示的年份 + * @param selectedMonth 当前选中月份(1-12) + * @param today 今天的日期 + * @param onMonthClick 月份点击回调 + * @param onYearChange 年份切换回调 + * @param modifier 外部布局修饰符 + */ +@Composable +fun YearGridView( + year: Int, + selectedMonth: Int, + today: LocalDate, + onMonthClick: (Int) -> Unit, + onYearChange: (Int) -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // 年份导航行 + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "‹", + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier + .clip(CircleShape) + .clickable { onYearChange(year - 1) } + .padding(horizontal = 16.dp, vertical = 8.dp) + ) + Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.Center) { + Text( + text = "${year}年", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + } + Text( + text = "›", + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier + .clip(CircleShape) + .clickable { onYearChange(year + 1) } + .padding(horizontal = 16.dp, vertical = 8.dp) + ) + } + + // 4×3 月历网格 + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .padding(horizontal = 4.dp), + verticalArrangement = Arrangement.SpaceEvenly + ) { + (0 until 4).forEach { row -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + (0 until 3).forEach { col -> + val month = row * 3 + col + 1 + MiniMonth( + year = year, + month = month, + isSelected = month == selectedMonth, + today = today, + onClick = { onMonthClick(month) }, + modifier = Modifier.weight(1f) + ) + } + } + } + } + } +} + +/** + * 精简版月历:月份标题 + 星期行 + 日期数字网格。 + */ +@Composable +private fun MiniMonth( + year: Int, + month: Int, + isSelected: Boolean, + today: LocalDate, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val days = remember(year, month) { generateMiniMonthDays(year, month) } + val titleColor = if (isSelected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface + } + val weekdayColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f) + val dayColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + val otherMonthColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f) + val todayBgColor = MaterialTheme.colorScheme.primary + + Column( + modifier = modifier + .padding(2.dp) + .clickable(onClick = onClick) + .padding(vertical = 2.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // 月份标题 + Text( + text = "${month}月", + color = titleColor, + fontSize = 9.sp, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, + textAlign = TextAlign.Center + ) + // 星期行 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + WEEKDAY_LABELS.forEach { label -> + Text( + text = label, + color = weekdayColor, + fontSize = 6.sp, + textAlign = TextAlign.Center, + modifier = Modifier.weight(1f) + ) + } + } + // 日期网格 + days.chunked(7).forEach { week -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + week.forEach { dayData -> + val isToday = dayData.date == today && dayData.isCurrentMonth + val color = when { + !dayData.isCurrentMonth -> otherMonthColor + isToday -> MaterialTheme.colorScheme.onPrimary + else -> dayColor + } + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.weight(1f) + ) { + if (isToday) { + Box( + modifier = Modifier + .drawBehind { + drawCircle( + color = todayBgColor, + radius = size.minDimension / 2f, + center = Offset(size.width / 2f, size.height / 2f) + ) + } + .clip(CircleShape) + ) + } + Text( + text = if (dayData.isCurrentMonth) dayData.date.day.toString() else "", + color = color, + fontSize = 6.sp, + textAlign = TextAlign.Center, + lineHeight = 9.sp + ) + } + } + } + } + } +} + +private data class MiniDayData( + val date: LocalDate, + val isCurrentMonth: Boolean +) + +@Suppress("DEPRECATION") // monthNumber 无替代 API +private fun generateMiniMonthDays(year: Int, month: Int): List { + val firstOfMonth = LocalDate(year, month, 1) + val offset = firstOfMonth.dayOfWeek.ordinal + val startDate = firstOfMonth.minus(DatePeriod(days = offset)) + val nextMonth = if (month == 12) LocalDate(year + 1, 1, 1) else LocalDate(year, month + 1, 1) + val daysInMonth = nextMonth.minus(DatePeriod(days = 1)).day + val rows = ((offset + daysInMonth - 1) / 7) + 1 + val totalDays = rows * 7 + + return (0 until totalDays).map { i -> + val date = startDate.plus(DatePeriod(days = i)) + MiniDayData( + date = date, + isCurrentMonth = date.month.number == month && date.year == year + ) + } +}