Merge pull request #1 from xunrua/main

重构日历折叠动画并修复折叠态下的多个交互问题
This commit is contained in:
Sonetto 2026-05-17 09:02:52 +08:00 committed by GitHub
commit 647823b66c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 919 additions and 188 deletions

View File

@ -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" }

View File

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

View File

@ -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 无替代 APIkotlinx-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折叠方向
*

View File

@ -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<ShiftKind>,
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]
}
}

View File

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

View File

@ -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<DayData> {
isCurrentMonth = date.month.number == month && date.year == year
)
}
}
}

View File

@ -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
/**
* 日历主界面包含月/周视图切换和折叠动画
* 日历主界面包含月/周视图切换折叠动画和年视图缩放转场
*
* 折叠时日历从月视图收缩为周视图1BottomCard 同步上移填充空间
* 支持动态行数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 × weekscollapseProgress=1 折叠时 gridH = rowH × 1
// 中间态gridH = rowH × (1 + (weeks-1) × (1-collapseProgress))
// 直接计算而非 derivedStateOfeffectiveRowHeightPx 依赖 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 无替代 APIkotlinx-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) }
}
}
)
}
}
}
}

View File

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

View File

@ -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)
)
}
}
}

View File

@ -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)
)
}
}
}

View File

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

View File

@ -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<MiniDayData> {
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
)
}
}