commit
647823b66c
@ -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" }
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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(折叠方向)。
|
||||
*
|
||||
|
||||
@ -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]
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
)
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
)
|
||||
|
||||
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
)
|
||||
|
||||
244
shared/src/commonMain/kotlin/plus/rua/project/ui/YearGridView.kt
Normal file
244
shared/src/commonMain/kotlin/plus/rua/project/ui/YearGridView.kt
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user