commit
647823b66c
@ -14,6 +14,7 @@ junit = "4.13.2"
|
|||||||
kotlin = "2.3.21"
|
kotlin = "2.3.21"
|
||||||
material3 = "1.10.0-alpha05"
|
material3 = "1.10.0-alpha05"
|
||||||
kotlinx-datetime = "0.8.0"
|
kotlinx-datetime = "0.8.0"
|
||||||
|
tyme4kt = "1.4.4"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
|
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" }
|
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-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" }
|
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version = "1.11.0" }
|
||||||
|
tyme4kt = { module = "cn.6tail:tyme4kt", version.ref = "tyme4kt" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
androidApplication = { id = "com.android.application", version.ref = "agp" }
|
androidApplication = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
|||||||
@ -45,6 +45,7 @@ kotlin {
|
|||||||
implementation(libs.androidx.lifecycle.viewmodelCompose)
|
implementation(libs.androidx.lifecycle.viewmodelCompose)
|
||||||
implementation(libs.androidx.lifecycle.runtimeCompose)
|
implementation(libs.androidx.lifecycle.runtimeCompose)
|
||||||
implementation(libs.kotlinx.datetime)
|
implementation(libs.kotlinx.datetime)
|
||||||
|
implementation(libs.tyme4kt)
|
||||||
}
|
}
|
||||||
commonTest.dependencies {
|
commonTest.dependencies {
|
||||||
implementation(libs.kotlin.test)
|
implementation(libs.kotlin.test)
|
||||||
|
|||||||
@ -1,11 +1,15 @@
|
|||||||
package plus.rua.project
|
package plus.rua.project
|
||||||
|
|
||||||
import androidx.compose.animation.core.Animatable
|
import androidx.compose.animation.core.Animatable
|
||||||
|
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||||
import androidx.compose.animation.core.spring
|
import androidx.compose.animation.core.spring
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.withFrameNanos
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.datetime.DatePeriod
|
import kotlinx.datetime.DatePeriod
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
@ -56,11 +60,42 @@ class CalendarViewModel(
|
|||||||
private val _collapseAnimatable = Animatable(0f)
|
private val _collapseAnimatable = Animatable(0f)
|
||||||
val collapseProgress: Float get() = _collapseAnimatable.value
|
val collapseProgress: Float get() = _collapseAnimatable.value
|
||||||
|
|
||||||
|
private var yearViewJob: Job? = null
|
||||||
|
|
||||||
@Suppress("DEPRECATION") // monthNumber 无替代 API,kotlinx-datetime 尚未提供新接口
|
@Suppress("DEPRECATION") // monthNumber 无替代 API,kotlinx-datetime 尚未提供新接口
|
||||||
val currentMonth: Int get() = selectedDate.month.number
|
val currentMonth: Int get() = selectedDate.month.number
|
||||||
|
|
||||||
val currentYear: Int get() = selectedDate.year
|
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
|
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(折叠方向)。
|
* 展开状态下拖拽折叠,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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@ -87,7 +86,7 @@ fun BottomCard(
|
|||||||
.align(Alignment.TopCenter)
|
.align(Alignment.TopCenter)
|
||||||
.padding(top = 8.dp, bottom = 8.dp)
|
.padding(top = 8.dp, bottom = 8.dp)
|
||||||
.clip(RoundedCornerShape(2.dp))
|
.clip(RoundedCornerShape(2.dp))
|
||||||
.background(Color.Gray.copy(alpha = 0.4f))
|
.background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f))
|
||||||
.fillMaxWidth(0.15f)
|
.fillMaxWidth(0.15f)
|
||||||
.height(4.dp)
|
.height(4.dp)
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
package plus.rua.project.ui
|
package plus.rua.project.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.offset
|
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.platform.LocalDensity
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.zIndex
|
import androidx.compose.ui.zIndex
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
import kotlinx.datetime.DatePeriod
|
import kotlinx.datetime.DatePeriod
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlinx.datetime.minus
|
import kotlinx.datetime.minus
|
||||||
import kotlinx.datetime.number
|
import kotlinx.datetime.number
|
||||||
import kotlinx.datetime.plus
|
import kotlinx.datetime.plus
|
||||||
|
import plus.rua.project.ShiftKind
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 月度日历网格页面,支持折叠动画。
|
* 月度日历网格页面,支持两阶段折叠动画。
|
||||||
*
|
*
|
||||||
* 折叠时非选中行高度按 (1-p) 缩放,选中行保持原始高度,
|
* Phase 1:所有行整体上移,直到选中行到达顶部 (y=0),上方行被裁剪并淡出。
|
||||||
* 所有行通过手动 y-offset 定位,形成向选中行收缩的视觉效果。
|
* Phase 2:选中行固定不动,下方行整体上移并淡出。
|
||||||
*
|
*
|
||||||
* @param year 年份
|
* @param year 年份
|
||||||
* @param month 月份(1-12)
|
* @param month 月份(1-12)
|
||||||
@ -36,6 +38,8 @@ import kotlinx.datetime.plus
|
|||||||
* @param collapseProgress 折叠进度,0f=展开,1f=折叠
|
* @param collapseProgress 折叠进度,0f=展开,1f=折叠
|
||||||
* @param rowHeightPx 从外层传入的锁定行高(像素),折叠过程中不变
|
* @param rowHeightPx 从外层传入的锁定行高(像素),折叠过程中不变
|
||||||
* @param effectiveWeeks 当前有效行数(含翻页插值),用于计算总高度
|
* @param effectiveWeeks 当前有效行数(含翻页插值),用于计算总高度
|
||||||
|
* @param shiftKindAt 日期 → 个人轮班类型的查询闭包
|
||||||
|
* @param showLegalHoliday 是否显示法定调休角标。详见 [DayCell] 的同名参数。
|
||||||
* @param onRowHeightMeasured 首次行高测量回调,外层据此锁定行高
|
* @param onRowHeightMeasured 首次行高测量回调,外层据此锁定行高
|
||||||
* @param modifier 外部布局修饰符
|
* @param modifier 外部布局修饰符
|
||||||
*/
|
*/
|
||||||
@ -49,6 +53,8 @@ fun CalendarMonthPage(
|
|||||||
collapseProgress: Float,
|
collapseProgress: Float,
|
||||||
rowHeightPx: Int,
|
rowHeightPx: Int,
|
||||||
effectiveWeeks: Float,
|
effectiveWeeks: Float,
|
||||||
|
shiftKindAt: (LocalDate) -> ShiftKind?,
|
||||||
|
showLegalHoliday: Boolean,
|
||||||
onRowHeightMeasured: ((Int) -> Unit)? = null,
|
onRowHeightMeasured: ((Int) -> Unit)? = null,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
@ -58,20 +64,33 @@ fun CalendarMonthPage(
|
|||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
|
|
||||||
val weeks = days.chunked(7)
|
val weeks = days.chunked(7)
|
||||||
val selectedWeekIndex = remember(weeks, selectedDate) {
|
val anchorIndex = remember(weeks, selectedDate) {
|
||||||
weeks.indexOfFirst { week -> week.any { it.date == selectedDate } }
|
weeks.indexOfFirst { week -> week.any { it.date == selectedDate } }
|
||||||
}
|
}
|
||||||
|
val hasAnchor = anchorIndex >= 0
|
||||||
val hasSelectedWeek = selectedWeekIndex >= 0
|
|
||||||
val h = rowHeightPx.toFloat()
|
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 totalHeightDp = if (rowHeightPx > 0) {
|
||||||
val totalPx = h * (1 + (effectiveWeeks - 1) * (1f - collapseProgress))
|
val totalPx = h * (1 + (effectiveWeeks - 1) * (1f - collapseProgress))
|
||||||
with(density) { totalPx.toDp() }
|
with(density) { totalPx.toDp() }
|
||||||
} else {
|
} else null
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier.clipToBounds().then(
|
modifier = modifier.clipToBounds().then(
|
||||||
@ -80,49 +99,40 @@ fun CalendarMonthPage(
|
|||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
weeks.forEachIndexed { weekIndex, week ->
|
weeks.forEachIndexed { weekIndex, week ->
|
||||||
val isSelected = hasSelectedWeek && weekIndex == selectedWeekIndex
|
val isAnchor = hasAnchor && weekIndex == anchorIndex
|
||||||
val isAbove = hasSelectedWeek && weekIndex < selectedWeekIndex
|
val isAbove = hasAnchor && weekIndex < anchorIndex
|
||||||
val isBelow = hasSelectedWeek && weekIndex > selectedWeekIndex
|
val isBelow = hasAnchor && weekIndex > anchorIndex
|
||||||
|
|
||||||
val rowScale = when {
|
val yOffsetDp = if (rowHeightPx > 0) {
|
||||||
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 yPx = when {
|
val yPx = when {
|
||||||
isAbove -> weekIndex * h * (1f - collapseProgress)
|
!hasAnchor -> weekIndex * h - collapseProgress * weeks.size * h
|
||||||
isSelected -> selectedWeekIndex * h * (1f - collapseProgress)
|
isAnchor -> anchorIndex * h * (1f - phase1)
|
||||||
isBelow -> selectedWeekIndex * h * (1f - collapseProgress) + h + (weekIndex - selectedWeekIndex - 1) * h * (1f - collapseProgress)
|
isAbove -> weekIndex * h - phase1 * anchorIndex * h
|
||||||
|
isBelow -> weekIndex * h - phase1 * anchorIndex * h - phase2 * belowRowsHeight
|
||||||
else -> weekIndex * h
|
else -> weekIndex * h
|
||||||
}
|
}
|
||||||
with(density) { yPx.toDp() }
|
with(density) { yPx.toDp() }
|
||||||
} else if (rowHeightPx > 0) {
|
} else 0.dp
|
||||||
val yPx = weekIndex * h
|
|
||||||
with(density) { yPx.toDp() }
|
val rowAlpha = when {
|
||||||
} else {
|
!hasAnchor -> (1f - collapseProgress).coerceIn(0f, 1f)
|
||||||
0.dp
|
isAnchor -> 1f
|
||||||
|
isAbove -> (1f - phase1).coerceIn(0f, 1f)
|
||||||
|
isBelow -> (1f - phase2).coerceIn(0f, 1f)
|
||||||
|
else -> 1f
|
||||||
}
|
}
|
||||||
|
|
||||||
val shouldShow = rowHeightDp == null || rowHeightDp > 0.dp
|
if (rowAlpha > 0.01f) {
|
||||||
|
|
||||||
val skipDayCells = (isAbove || isBelow) && rowScale < 0.1f && collapseProgress > 0.9f
|
|
||||||
|
|
||||||
if (shouldShow) {
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.zIndex(if (isSelected) 1f else 0f)
|
.zIndex(if (isAnchor) 1f else 0f)
|
||||||
.then(
|
.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
|
else Modifier
|
||||||
)
|
)
|
||||||
.offset(y = yOffsetDp)
|
.offset(y = yOffsetDp)
|
||||||
@ -137,21 +147,18 @@ fun CalendarMonthPage(
|
|||||||
)
|
)
|
||||||
.padding(vertical = ROW_PADDING_DP.dp)
|
.padding(vertical = ROW_PADDING_DP.dp)
|
||||||
.then(
|
.then(
|
||||||
if (isAbove || isBelow) Modifier.graphicsLayer {
|
if (rowAlpha < 1f) Modifier.graphicsLayer { alpha = rowAlpha }
|
||||||
alpha = 1f - collapseProgress
|
|
||||||
}
|
|
||||||
else Modifier
|
else Modifier
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
if (skipDayCells) {
|
|
||||||
Spacer(Modifier.weight(1f))
|
|
||||||
} else {
|
|
||||||
week.forEach { dayData ->
|
week.forEach { dayData ->
|
||||||
DayCell(
|
DayCell(
|
||||||
date = dayData.date,
|
date = dayData.date,
|
||||||
isCurrentMonth = dayData.isCurrentMonth,
|
isCurrentMonth = dayData.isCurrentMonth,
|
||||||
isSelected = dayData.date == selectedDate,
|
isSelected = dayData.date == selectedDate,
|
||||||
isToday = dayData.date == today,
|
isToday = dayData.date == today,
|
||||||
|
shiftKind = shiftKindAt(dayData.date),
|
||||||
|
showLegalHoliday = showLegalHoliday,
|
||||||
onClick = { onDateClick(dayData.date) },
|
onClick = { onDateClick(dayData.date) },
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
@ -161,7 +168,6 @@ fun CalendarMonthPage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private data class DayData(
|
private data class DayData(
|
||||||
val date: LocalDate,
|
val date: LocalDate,
|
||||||
|
|||||||
@ -7,17 +7,23 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.statusBarsPadding
|
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.foundation.pager.rememberPagerState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clipToBounds
|
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.layout.onSizeChanged
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@ -27,15 +33,16 @@ import kotlinx.datetime.TimeZone
|
|||||||
import kotlinx.datetime.number
|
import kotlinx.datetime.number
|
||||||
import kotlinx.datetime.plus
|
import kotlinx.datetime.plus
|
||||||
import kotlinx.datetime.todayIn
|
import kotlinx.datetime.todayIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import plus.rua.project.CalendarViewModel
|
import plus.rua.project.CalendarViewModel
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 日历主界面,包含月/周视图切换和折叠动画。
|
* 日历主界面,包含月/周视图切换、折叠动画和年视图缩放转场。
|
||||||
*
|
*
|
||||||
* 折叠时日历从月视图收缩为周视图(1行),BottomCard 同步上移填充空间。
|
* 折叠时日历从月视图收缩为周视图(1行),BottomCard 同步上移填充空间。
|
||||||
* 支持动态行数(4/5/6行),滑动切换月份时 BottomCard 跟手移动。
|
* 点击月份标题切换年视图,以当前月为锚点缩放转场。
|
||||||
*
|
*
|
||||||
* @param modifier 外部布局修饰符
|
* @param modifier 外部布局修饰符
|
||||||
*/
|
*/
|
||||||
@ -57,10 +64,50 @@ fun CalendarMonthView(
|
|||||||
var rowHeightPx by remember { mutableIntStateOf(0) }
|
var rowHeightPx by remember { mutableIntStateOf(0) }
|
||||||
var screenWidthPx by remember { mutableIntStateOf(0) }
|
var screenWidthPx by remember { mutableIntStateOf(0) }
|
||||||
var screenHeightPx 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 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 collapseProgress = viewModel.collapseProgress
|
||||||
|
val yearProgress = viewModel.yearViewProgress
|
||||||
val headerHeightPx = monthHeaderHeightPx + weekdayHeaderHeightPx
|
val headerHeightPx = monthHeaderHeightPx + weekdayHeaderHeightPx
|
||||||
val rowPaddingPx = with(density) { ROW_PADDING_DP.dp.toPx() }.toInt()
|
val rowPaddingPx = with(density) { ROW_PADDING_DP.dp.toPx() }.toInt()
|
||||||
val cardGapPx = with(density) {
|
val cardGapPx = with(density) {
|
||||||
@ -71,8 +118,6 @@ fun CalendarMonthView(
|
|||||||
).dp.toPx()
|
).dp.toPx()
|
||||||
}.toInt()
|
}.toInt()
|
||||||
|
|
||||||
// 翻页时在相邻月份行数之间插值,使 BottomCard 高度平滑过渡
|
|
||||||
// abs(fraction) > 阈值时启用插值,避免静止时的浮点抖动
|
|
||||||
val interpolatedWeeks by remember {
|
val interpolatedWeeks by remember {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
val fraction = pagerState.currentPageOffsetFraction
|
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 estimatedRowHeightPx = if (screenWidthPx > 0) {
|
||||||
val cellWidth =
|
val cellWidth =
|
||||||
(screenWidthPx - with(density) { (HORIZONTAL_PADDING_DP * 2).dp.toPx() }) / 7
|
(screenWidthPx - with(density) { (HORIZONTAL_PADDING_DP * 2).dp.toPx() }) / 7
|
||||||
@ -99,14 +141,8 @@ fun CalendarMonthView(
|
|||||||
} else 0
|
} else 0
|
||||||
|
|
||||||
val effectiveRowHeightPx = if (rowHeightPx > 0) rowHeightPx else estimatedRowHeightPx
|
val effectiveRowHeightPx = if (rowHeightPx > 0) rowHeightPx else estimatedRowHeightPx
|
||||||
|
|
||||||
val effectiveWeeks = interpolatedWeeks
|
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 gridHeightPx = if (effectiveRowHeightPx > 0) {
|
||||||
val rowH = effectiveRowHeightPx.toFloat()
|
val rowH = effectiveRowHeightPx.toFloat()
|
||||||
if (collapseProgress > OFFSET_FRACTION_THRESHOLD) {
|
if (collapseProgress > OFFSET_FRACTION_THRESHOLD) {
|
||||||
@ -116,12 +152,10 @@ fun CalendarMonthView(
|
|||||||
}
|
}
|
||||||
} else 0
|
} else 0
|
||||||
|
|
||||||
// BottomCard 高度 = 屏幕剩余空间(屏幕高度 - 日历区域高度)
|
|
||||||
val calendarAreaHeightPx = headerHeightPx + gridHeightPx + rowPaddingPx + cardGapPx
|
val calendarAreaHeightPx = headerHeightPx + gridHeightPx + rowPaddingPx + cardGapPx
|
||||||
val cardHeightPx =
|
val cardHeightPx =
|
||||||
if (screenHeightPx > 0 && calendarAreaHeightPx > 0) screenHeightPx - calendarAreaHeightPx else 0
|
if (screenHeightPx > 0 && calendarAreaHeightPx > 0) screenHeightPx - calendarAreaHeightPx else 0
|
||||||
|
|
||||||
// 行高已知时约束 pager 高度防止内容溢出;否则让 pager 自由扩展以触发首次行高测量
|
|
||||||
val pagerModifier = if (rowHeightPx > 0 && gridHeightPx > 0) {
|
val pagerModifier = if (rowHeightPx > 0 && gridHeightPx > 0) {
|
||||||
Modifier
|
Modifier
|
||||||
.height(with(density) { gridHeightPx.toDp() })
|
.height(with(density) { gridHeightPx.toDp() })
|
||||||
@ -130,6 +164,20 @@ fun CalendarMonthView(
|
|||||||
Modifier
|
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(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@ -139,11 +187,47 @@ fun CalendarMonthView(
|
|||||||
screenHeightPx = size.height
|
screenHeightPx = size.height
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.padding(horizontal = HORIZONTAL_PADDING_DP.dp)) {
|
// 月视图层:仅在非年视图时渲染,年视图激活时立即移除。
|
||||||
|
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 {
|
||||||
|
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(
|
MonthHeader(
|
||||||
year = currentYear,
|
year = currentYear,
|
||||||
month = currentMonth,
|
month = currentMonth,
|
||||||
weekNumber = viewModel.getIsoWeekNumber(viewModel.selectedDate),
|
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 ->
|
modifier = Modifier.onSizeChanged { size ->
|
||||||
monthHeaderHeightPx = size.height
|
monthHeaderHeightPx = size.height
|
||||||
}
|
}
|
||||||
@ -154,19 +238,29 @@ fun CalendarMonthView(
|
|||||||
weekdayHeaderHeightPx = size.height
|
weekdayHeaderHeightPx = size.height
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
// 完全折叠且无动画时切换到 WeekPager(单行高效渲染),
|
|
||||||
// 否则使用 CalendarPager(含折叠动画和下拉恢复过程)
|
|
||||||
if (viewModel.isCollapsed && viewModel.collapseProgress >= 1f) {
|
if (viewModel.isCollapsed && viewModel.collapseProgress >= 1f) {
|
||||||
WeekPager(
|
WeekPager(
|
||||||
selectedDate = viewModel.selectedDate,
|
selectedDate = viewModel.selectedDate,
|
||||||
today = today,
|
today = today,
|
||||||
onDateClick = { date -> viewModel.selectDate(date) },
|
onDateClick = { date -> viewModel.selectDate(date) },
|
||||||
onWeekChanged = { weekMonday ->
|
onWeekChanged = { weekMonday ->
|
||||||
// 优先选中当周内的今天,否则选中该周周一
|
|
||||||
val weekSunday = weekMonday.plus(DatePeriod(days = 6))
|
val weekSunday = weekMonday.plus(DatePeriod(days = 6))
|
||||||
val date = if (today in weekMonday..weekSunday) today else weekMonday
|
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)
|
viewModel.selectDate(date)
|
||||||
},
|
},
|
||||||
|
shiftKindAt = { date -> viewModel.shiftKindAt(date) },
|
||||||
|
showLegalHoliday = viewModel.showLegalHoliday,
|
||||||
modifier = pagerModifier
|
modifier = pagerModifier
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@ -175,8 +269,7 @@ fun CalendarMonthView(
|
|||||||
today = today,
|
today = today,
|
||||||
onDateClick = { date -> viewModel.selectDate(date) },
|
onDateClick = { date -> viewModel.selectDate(date) },
|
||||||
onMonthChanged = { year, month ->
|
onMonthChanged = { year, month ->
|
||||||
// 优先选中当月内的今天,否则选中该月1号
|
@Suppress("DEPRECATION") // monthNumber 无替代 API
|
||||||
@Suppress("DEPRECATION") // monthNumber 无替代 API,kotlinx-datetime 尚未提供新接口
|
|
||||||
val date = if (year == today.year && today.month.number == month) today
|
val date = if (year == today.year && today.month.number == month) today
|
||||||
else LocalDate(year, month, 1)
|
else LocalDate(year, month, 1)
|
||||||
viewModel.selectDate(date)
|
viewModel.selectDate(date)
|
||||||
@ -184,6 +277,8 @@ fun CalendarMonthView(
|
|||||||
collapseProgress = viewModel.collapseProgress,
|
collapseProgress = viewModel.collapseProgress,
|
||||||
rowHeightPx = rowHeightPx,
|
rowHeightPx = rowHeightPx,
|
||||||
effectiveWeeks = effectiveWeeks,
|
effectiveWeeks = effectiveWeeks,
|
||||||
|
shiftKindAt = { date -> viewModel.shiftKindAt(date) },
|
||||||
|
showLegalHoliday = viewModel.showLegalHoliday,
|
||||||
onRowHeightMeasured = { h ->
|
onRowHeightMeasured = { h ->
|
||||||
if (h > 0) rowHeightPx = h
|
if (h > 0) rowHeightPx = h
|
||||||
},
|
},
|
||||||
@ -193,14 +288,6 @@ fun CalendarMonthView(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 拖拽范围 = 折叠时日历实际高度变化量 (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) {
|
if (cardHeightPx > 0) {
|
||||||
BottomCard(
|
BottomCard(
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
@ -213,3 +300,48 @@ fun CalendarMonthView(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 年视图层:仅在年视图激活时渲染;HorizontalPager 支持左右滑动切年。
|
||||||
|
if (viewModel.isYearView) {
|
||||||
|
HorizontalPager(
|
||||||
|
state = yearPagerState,
|
||||||
|
beyondViewportPageCount = 1,
|
||||||
|
flingBehavior = PagerDefaults.flingBehavior(state = yearPagerState),
|
||||||
|
modifier = Modifier
|
||||||
|
.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.coroutines.launch
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlinx.datetime.number
|
import kotlinx.datetime.number
|
||||||
|
import plus.rua.project.ShiftKind
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -29,6 +30,8 @@ import kotlin.math.abs
|
|||||||
* @param collapseProgress 折叠进度,0f=展开,1f=折叠
|
* @param collapseProgress 折叠进度,0f=展开,1f=折叠
|
||||||
* @param rowHeightPx 锁定行高(像素)
|
* @param rowHeightPx 锁定行高(像素)
|
||||||
* @param effectiveWeeks 当前有效行数(含翻页插值)
|
* @param effectiveWeeks 当前有效行数(含翻页插值)
|
||||||
|
* @param shiftKindAt 日期 → 个人轮班类型的查询闭包
|
||||||
|
* @param showLegalHoliday 是否显示法定调休角标。详见 [DayCell] 的同名参数。
|
||||||
* @param onRowHeightMeasured 首次行高测量回调
|
* @param onRowHeightMeasured 首次行高测量回调
|
||||||
* @param pagerState 外层共享的 PagerState,用于保持翻页状态
|
* @param pagerState 外层共享的 PagerState,用于保持翻页状态
|
||||||
* @param modifier 外部布局修饰符
|
* @param modifier 外部布局修饰符
|
||||||
@ -42,6 +45,8 @@ fun CalendarPager(
|
|||||||
collapseProgress: Float,
|
collapseProgress: Float,
|
||||||
rowHeightPx: Int,
|
rowHeightPx: Int,
|
||||||
effectiveWeeks: Float,
|
effectiveWeeks: Float,
|
||||||
|
shiftKindAt: (LocalDate) -> ShiftKind?,
|
||||||
|
showLegalHoliday: Boolean,
|
||||||
onRowHeightMeasured: ((Int) -> Unit)? = null,
|
onRowHeightMeasured: ((Int) -> Unit)? = null,
|
||||||
pagerState: PagerState,
|
pagerState: PagerState,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
@ -94,6 +99,8 @@ fun CalendarPager(
|
|||||||
collapseProgress = collapseProgress,
|
collapseProgress = collapseProgress,
|
||||||
rowHeightPx = rowHeightPx,
|
rowHeightPx = rowHeightPx,
|
||||||
effectiveWeeks = effectiveWeeks,
|
effectiveWeeks = effectiveWeeks,
|
||||||
|
shiftKindAt = shiftKindAt,
|
||||||
|
showLegalHoliday = showLegalHoliday,
|
||||||
onRowHeightMeasured = onRowHeightMeasured,
|
onRowHeightMeasured = onRowHeightMeasured,
|
||||||
modifier = Modifier.alpha(alpha)
|
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.animateFloat
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.animation.core.updateTransition
|
import androidx.compose.animation.core.updateTransition
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.aspectRatio
|
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.foundation.shape.CircleShape
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.draw.drawBehind
|
import androidx.compose.ui.draw.drawBehind
|
||||||
import androidx.compose.ui.geometry.Offset
|
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.Color
|
||||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
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.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
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 kotlinx.datetime.LocalDate
|
||||||
|
import plus.rua.project.ShiftKind
|
||||||
|
|
||||||
enum class DayCellState {
|
enum class DayCellState {
|
||||||
NORMAL, OTHER_MONTH, TODAY, SELECTED, SELECTED_TODAY
|
NORMAL, OTHER_MONTH, TODAY, SELECTED, SELECTED_TODAY
|
||||||
@ -35,6 +50,10 @@ enum class DayCellState {
|
|||||||
* @param isCurrentMonth 是否属于当前显示月份
|
* @param isCurrentMonth 是否属于当前显示月份
|
||||||
* @param isSelected 是否为选中日期
|
* @param isSelected 是否为选中日期
|
||||||
* @param isToday 是否为今天
|
* @param isToday 是否为今天
|
||||||
|
* @param shiftKind 个人轮班类型;null 表示不显示。与法定调休完全独立。
|
||||||
|
* @param showLegalHoliday 是否显示法定调休角标。
|
||||||
|
* false(默认):排班放右上角,左上角空白,不显示法定调休。
|
||||||
|
* true:排班放左上角,法定调休放右上角(旧版布局)。
|
||||||
* @param onClick 点击回调
|
* @param onClick 点击回调
|
||||||
* @param modifier 外部布局修饰符
|
* @param modifier 外部布局修饰符
|
||||||
*/
|
*/
|
||||||
@ -44,6 +63,8 @@ fun DayCell(
|
|||||||
isCurrentMonth: Boolean,
|
isCurrentMonth: Boolean,
|
||||||
isSelected: Boolean,
|
isSelected: Boolean,
|
||||||
isToday: Boolean,
|
isToday: Boolean,
|
||||||
|
shiftKind: ShiftKind?,
|
||||||
|
showLegalHoliday: Boolean,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
@ -58,7 +79,7 @@ fun DayCell(
|
|||||||
val transition = updateTransition(targetState = currentState, label = "dayCell")
|
val transition = updateTransition(targetState = currentState, label = "dayCell")
|
||||||
|
|
||||||
val revealProgress by transition.animateFloat(
|
val revealProgress by transition.animateFloat(
|
||||||
transitionSpec = { tween(250, easing = FastOutSlowInEasing) },
|
transitionSpec = { tween(150, easing = FastOutSlowInEasing) },
|
||||||
label = "revealProgress"
|
label = "revealProgress"
|
||||||
) { state ->
|
) { state ->
|
||||||
when (state) {
|
when (state) {
|
||||||
@ -68,65 +89,154 @@ fun DayCell(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val contentColor by transition.animateColor(
|
val contentColor by transition.animateColor(
|
||||||
transitionSpec = { tween(250, easing = FastOutSlowInEasing) },
|
transitionSpec = { tween(150, easing = FastOutSlowInEasing) },
|
||||||
label = "contentColor"
|
label = "contentColor"
|
||||||
) { state ->
|
) { state ->
|
||||||
when (state) {
|
when (state) {
|
||||||
DayCellState.SELECTED_TODAY -> MaterialTheme.colorScheme.onPrimaryContainer
|
DayCellState.SELECTED_TODAY -> MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
DayCellState.SELECTED -> MaterialTheme.colorScheme.onPrimary
|
DayCellState.SELECTED -> MaterialTheme.colorScheme.primary
|
||||||
DayCellState.TODAY -> MaterialTheme.colorScheme.primary
|
DayCellState.TODAY -> MaterialTheme.colorScheme.primary
|
||||||
DayCellState.OTHER_MONTH -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
DayCellState.OTHER_MONTH -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
||||||
DayCellState.NORMAL -> MaterialTheme.colorScheme.onSurface
|
DayCellState.NORMAL -> MaterialTheme.colorScheme.onSurface
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val selectedColor by transition.animateColor(
|
// 选中今天:实心填充 primaryContainer;其他状态不填充。
|
||||||
transitionSpec = { tween(250, easing = FastOutSlowInEasing) },
|
val selectedFillColor by transition.animateColor(
|
||||||
label = "selectedColor"
|
transitionSpec = { tween(150, easing = FastOutSlowInEasing) },
|
||||||
|
label = "selectedFillColor"
|
||||||
) { state ->
|
) { state ->
|
||||||
when (state) {
|
when (state) {
|
||||||
DayCellState.SELECTED_TODAY -> MaterialTheme.colorScheme.primaryContainer
|
DayCellState.SELECTED_TODAY -> MaterialTheme.colorScheme.primaryContainer
|
||||||
DayCellState.SELECTED -> MaterialTheme.colorScheme.primary
|
|
||||||
else -> Color.Transparent
|
else -> Color.Transparent
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val borderAlpha by transition.animateFloat(
|
// 选中非今天:绘制描边圆,避免遮挡右上角角标。
|
||||||
transitionSpec = { tween(250, easing = FastOutSlowInEasing) },
|
val selectedOutlineAlpha by transition.animateFloat(
|
||||||
label = "borderAlpha"
|
transitionSpec = { tween(150, easing = FastOutSlowInEasing) },
|
||||||
|
label = "selectedOutlineAlpha"
|
||||||
) { state ->
|
) { state ->
|
||||||
when (state) {
|
when (state) {
|
||||||
DayCellState.TODAY -> 1.5f
|
DayCellState.SELECTED -> 1f
|
||||||
else -> 0f
|
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(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier.aspectRatio(1f)
|
||||||
.aspectRatio(1f)
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.semantics {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
contentDescription = "${date.year}年${date.monthNumber}月${date.day}日"
|
||||||
|
}
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.drawBehind {
|
.drawBehind {
|
||||||
if (revealProgress > 0f) {
|
|
||||||
val maxRadius = size.minDimension / 2f
|
val maxRadius = size.minDimension / 2f
|
||||||
|
val center = Offset(size.width / 2f, size.height / 2f)
|
||||||
|
if (revealProgress > 0f && selectedFillColor.alpha > 0f) {
|
||||||
drawCircle(
|
drawCircle(
|
||||||
color = selectedColor,
|
color = selectedFillColor,
|
||||||
radius = revealProgress * maxRadius,
|
radius = revealProgress * maxRadius,
|
||||||
center = Offset(size.width / 2f, size.height / 2f)
|
center = center
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (borderAlpha > 0f) {
|
if (revealProgress > 0f && selectedOutlineAlpha > 0f) {
|
||||||
|
val strokePx = 1.5.dp.toPx()
|
||||||
drawCircle(
|
drawCircle(
|
||||||
color = todayBorderColor.copy(alpha = borderAlpha.coerceAtMost(1f)),
|
color = selectedOutlineColor.copy(alpha = selectedOutlineAlpha),
|
||||||
radius = size.minDimension / 2f,
|
radius = revealProgress * maxRadius - strokePx / 2f,
|
||||||
center = Offset(size.width / 2f, size.height / 2f),
|
center = center,
|
||||||
style = Stroke(width = borderAlpha.coerceAtMost(1.5f) * 1.5.dp.toPx())
|
style = Stroke(width = strokePx)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.clickable(onClick = onClick),
|
.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null,
|
||||||
|
onClick = onClick
|
||||||
|
),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = date.day.toString(),
|
text = date.day.toString(),
|
||||||
@ -134,5 +244,69 @@ fun DayCell(
|
|||||||
color = contentColor,
|
color = contentColor,
|
||||||
style = MaterialTheme.typography.bodyMedium
|
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.slideInVertically
|
||||||
import androidx.compose.animation.slideOutVertically
|
import androidx.compose.animation.slideOutVertically
|
||||||
import androidx.compose.animation.togetherWith
|
import androidx.compose.animation.togetherWith
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 月份标题栏,显示"年月"文字和 ISO 周号。
|
* 月份标题栏,显示"年月"文字和 ISO 周号。
|
||||||
@ -25,6 +29,9 @@ import androidx.compose.ui.unit.dp
|
|||||||
* @param year 年份
|
* @param year 年份
|
||||||
* @param month 月份(1-12)
|
* @param month 月份(1-12)
|
||||||
* @param weekNumber 当前 ISO 周号
|
* @param weekNumber 当前 ISO 周号
|
||||||
|
* @param showToday 是否显示「今天」按钮(当 selectedDate ≠ today 时)
|
||||||
|
* @param onToggleYearView 点击标题切换年视图
|
||||||
|
* @param onToday 点击「今天」按钮跳转今天
|
||||||
* @param modifier 外部布局修饰符
|
* @param modifier 外部布局修饰符
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
@ -32,13 +39,17 @@ fun MonthHeader(
|
|||||||
year: Int,
|
year: Int,
|
||||||
month: Int,
|
month: Int,
|
||||||
weekNumber: Int,
|
weekNumber: Int,
|
||||||
|
showToday: Boolean,
|
||||||
|
onToggleYearView: () -> Unit,
|
||||||
|
onToday: (() -> Unit)? = null,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(vertical = 14.dp, horizontal = 12.dp),
|
.padding(vertical = 14.dp, horizontal = 12.dp)
|
||||||
verticalAlignment = Alignment.CenterVertically
|
.clickable(onClick = onToggleYearView),
|
||||||
|
verticalAlignment = Alignment.Bottom
|
||||||
) {
|
) {
|
||||||
AnimatedContent(
|
AnimatedContent(
|
||||||
targetState = Pair(year, month),
|
targetState = Pair(year, month),
|
||||||
@ -68,12 +79,25 @@ fun MonthHeader(
|
|||||||
slideInVertically(tween(250)) { it } + fadeIn(tween(250)) togetherWith
|
slideInVertically(tween(250)) { it } + fadeIn(tween(250)) togetherWith
|
||||||
slideOutVertically(tween(250)) { -it } + fadeOut(tween(250))
|
slideOutVertically(tween(250)) { -it } + fadeOut(tween(250))
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
modifier = Modifier.padding(bottom = 2.dp)
|
||||||
) { week ->
|
) { week ->
|
||||||
Text(
|
Text(
|
||||||
text = "第${week}周",
|
text = "第${week}周",
|
||||||
style = MaterialTheme.typography.bodySmall
|
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.coroutines.flow.drop
|
||||||
import kotlinx.datetime.DatePeriod
|
import kotlinx.datetime.DatePeriod
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.daysUntil
|
||||||
import kotlinx.datetime.plus
|
import kotlinx.datetime.plus
|
||||||
|
import plus.rua.project.ShiftKind
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -26,6 +28,8 @@ import kotlin.math.abs
|
|||||||
* @param today 今天的日期
|
* @param today 今天的日期
|
||||||
* @param onDateClick 日期点击回调
|
* @param onDateClick 日期点击回调
|
||||||
* @param onWeekChanged 周切换回调,滑动到新周时触发,参数为该周周一日期
|
* @param onWeekChanged 周切换回调,滑动到新周时触发,参数为该周周一日期
|
||||||
|
* @param shiftKindAt 日期 → 个人轮班类型的查询闭包
|
||||||
|
* @param showLegalHoliday 是否显示法定调休角标。详见 [DayCell] 的同名参数。
|
||||||
* @param modifier 外部布局修饰符
|
* @param modifier 外部布局修饰符
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
@ -34,6 +38,8 @@ fun WeekPager(
|
|||||||
today: LocalDate,
|
today: LocalDate,
|
||||||
onDateClick: (LocalDate) -> Unit,
|
onDateClick: (LocalDate) -> Unit,
|
||||||
onWeekChanged: (LocalDate) -> Unit,
|
onWeekChanged: (LocalDate) -> Unit,
|
||||||
|
shiftKindAt: (LocalDate) -> ShiftKind?,
|
||||||
|
showLegalHoliday: Boolean,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val initialWeekMonday = remember { selectedDate.toWeekMonday() }
|
val initialWeekMonday = remember { selectedDate.toWeekMonday() }
|
||||||
@ -42,6 +48,15 @@ fun WeekPager(
|
|||||||
pageCount = { Int.MAX_VALUE }
|
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) {
|
LaunchedEffect(pagerState) {
|
||||||
snapshotFlow { pagerState.settledPage }.drop(1).collect { page ->
|
snapshotFlow { pagerState.settledPage }.drop(1).collect { page ->
|
||||||
val weekMonday = pageToWeekMonday(page, initialWeekMonday)
|
val weekMonday = pageToWeekMonday(page, initialWeekMonday)
|
||||||
@ -68,9 +83,12 @@ fun WeekPager(
|
|||||||
val date = weekMonday.plus(DatePeriod(days = dayOffset))
|
val date = weekMonday.plus(DatePeriod(days = dayOffset))
|
||||||
DayCell(
|
DayCell(
|
||||||
date = date,
|
date = date,
|
||||||
isCurrentMonth = true,
|
isCurrentMonth = date.month == selectedDate.month
|
||||||
|
&& date.year == selectedDate.year,
|
||||||
isSelected = date == selectedDate,
|
isSelected = date == selectedDate,
|
||||||
isToday = date == today,
|
isToday = date == today,
|
||||||
|
shiftKind = shiftKindAt(date),
|
||||||
|
showLegalHoliday = showLegalHoliday,
|
||||||
onClick = { onDateClick(date) },
|
onClick = { onDateClick(date) },
|
||||||
modifier = Modifier.weight(1f)
|
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