275 lines
9.5 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlinx.datetime.DatePeriod
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.daysUntil
import kotlinx.datetime.minus
import kotlinx.datetime.number
import kotlinx.datetime.plus
import kotlinx.datetime.todayIn
import plus.rua.project.ui.COLLAPSE_THRESHOLD
import plus.rua.project.ui.FLING_VELOCITY_THRESHOLD_DP
import kotlin.time.Clock
/**
* 日历日期数据,用于网格单元格渲染。
*
* @param date 日期
* @param isCurrentMonth 是否属于当前显示月份
* @param isToday 是否为今天
* @param isSelected 是否为选中日期
*/
data class CalendarDay(
val date: LocalDate,
val isCurrentMonth: Boolean,
val isToday: Boolean,
val isSelected: Boolean
)
/**
* 日历状态管理,持有选中日期、折叠状态和 ISO 周号计算逻辑。
*
* @param coroutineScope 协程作用域,用于驱动折叠动画
* @param clock 时钟源,默认系统时钟;测试时可注入固定时钟
*/
class CalendarViewModel(
private val coroutineScope: CoroutineScope,
private val clock: Clock = Clock.System
) {
private val today: LocalDate = clock.todayIn(TimeZone.currentSystemDefault())
var selectedDate by mutableStateOf(today)
private set
var isCollapsed by mutableStateOf(false)
private set
// collapseProgress: 0f=展开(月视图), 1f=折叠(周视图)
private val _collapseAnimatable = Animatable(0f)
val collapseProgress: Float get() = _collapseAnimatable.value
@Suppress("DEPRECATION") // monthNumber 无替代 APIkotlinx-datetime 尚未提供新接口
val currentMonth: Int get() = selectedDate.month.number
val currentYear: Int get() = selectedDate.year
var isYearView by mutableStateOf(false)
private set
private val _yearViewAnimatable = Animatable(0f)
val yearViewProgress: Float get() = _yearViewAnimatable.value
@Suppress("DEPRECATION") // monthNumber 无替代 API
var yearViewYear by mutableStateOf(today.year)
private set
/**
* 选中指定日期。
*
* @param date 目标日期
*/
fun selectDate(date: LocalDate) {
selectedDate = date
}
/**
* 切换年视图。仅在展开态可用。
*/
fun toggleYearView() {
if (isCollapsed) return
coroutineScope.launch {
if (isYearView) {
_yearViewAnimatable.animateTo(
0f, tween(400, easing = FastOutSlowInEasing)
)
isYearView = false
} else {
yearViewYear = selectedDate.year
isYearView = true
_yearViewAnimatable.snapTo(0f)
_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
coroutineScope.launch {
_yearViewAnimatable.animateTo(
0f, tween(400, easing = FastOutSlowInEasing)
)
isYearView = false
}
}
fun incrementYear() {
yearViewYear++
}
fun decrementYear() {
yearViewYear--
}
/**
* 展开状态下拖拽折叠delta 正值推动 progress 向 1折叠方向
*
* @param delta 拖拽增量,已归一化到 [0,1] 区间
*/
fun onDrag(delta: Float) {
coroutineScope.launch {
val new = (_collapseAnimatable.value + delta).coerceIn(0f, 1f)
_collapseAnimatable.snapTo(new)
}
}
/**
* 展开状态拖拽结束,根据进度和速度决定折叠或回弹。
*
* 拖拽超过阈值时自动折叠到周视图,否则回弹到月视图。
*
* @param velocityDpPerSec 松手时的 fling 速度 (dp/s),正值=上滑(折叠方向),负值=下滑(展开方向)
*/
fun onDragEnd(velocityDpPerSec: Float = 0f) {
coroutineScope.launch {
val progress = _collapseAnimatable.value
val shouldCollapse = when {
velocityDpPerSec > FLING_VELOCITY_THRESHOLD_DP -> true // 快速上滑→折叠
velocityDpPerSec < -FLING_VELOCITY_THRESHOLD_DP -> false // 快速下滑→展开
else -> progress > COLLAPSE_THRESHOLD // 慢速按 progress 判断
}
if (shouldCollapse) {
_collapseAnimatable.animateTo(
targetValue = 1f,
animationSpec = spring(dampingRatio = 0.8f, stiffness = 400f)
)
isCollapsed = true
} else {
_collapseAnimatable.animateTo(
targetValue = 0f,
animationSpec = spring(dampingRatio = 0.8f, stiffness = 400f)
)
}
}
}
/**
* 折叠状态下下拉恢复delta 为负值(向下拖)推动 progress 向 0。
*
* @param delta 拖拽增量,已归一化到 [0,1] 区间
*/
fun onExpandDrag(delta: Float) {
coroutineScope.launch {
val new = (_collapseAnimatable.value + delta).coerceIn(0f, 1f)
_collapseAnimatable.snapTo(new)
}
}
/**
* 折叠状态拖拽结束,根据进度和速度决定展开或回弹。
*
* 下拉超过阈值时自动展开到月视图,否则回弹到周视图。
*
* @param velocityDpPerSec 松手时的 fling 速度 (dp/s),正值=上滑,负值=下滑
*/
fun onExpandDragEnd(velocityDpPerSec: Float = 0f) {
coroutineScope.launch {
val progress = _collapseAnimatable.value
val shouldExpand = when {
velocityDpPerSec < -FLING_VELOCITY_THRESHOLD_DP -> true // 快速下滑→展开
velocityDpPerSec > FLING_VELOCITY_THRESHOLD_DP -> false // 快速上滑→保持折叠
else -> progress < 1f - COLLAPSE_THRESHOLD // 慢速按 progress 判断
}
if (shouldExpand) {
_collapseAnimatable.animateTo(
targetValue = 0f,
animationSpec = spring(dampingRatio = 0.8f, stiffness = 400f)
)
isCollapsed = false
} else {
_collapseAnimatable.animateTo(
targetValue = 1f,
animationSpec = spring(dampingRatio = 0.8f, stiffness = 400f)
)
}
}
}
/**
* 计算给定日期的 ISO 8601 周号。
*
* @param date 目标日期
* @return ISO 周号1-53
*/
fun getIsoWeekNumber(date: LocalDate): Int {
val jan4 = LocalDate(date.year, 1, 4)
val jan4DayOfWeek = jan4.dayOfWeek.ordinal
val week1Monday = jan4.minus(DatePeriod(days = jan4DayOfWeek))
val diff = week1Monday.daysUntil(date)
val weekNumber = diff / 7 + 1
return if (weekNumber < 1) {
getIsoWeekNumber(LocalDate(date.year - 1, 12, 28))
} else if (weekNumber > getIsoWeeksInYear(date.year)) {
1
} else {
weekNumber
}
}
private fun getIsoWeeksInYear(year: Int): Int {
val dec28 = LocalDate(year, 12, 28)
val jan4 = LocalDate(year, 1, 4)
val jan4DayOfWeek = jan4.dayOfWeek.ordinal
val week1Monday = jan4.minus(DatePeriod(days = jan4DayOfWeek))
val diff = week1Monday.daysUntil(dec28)
return diff / 7 + 1
}
/**
* 计算给定年月的日历网格数据,包含跨月填充至完整行。
*
* 网格行数按实际需要计算4/5/6行每行7格首行从该月1号所在周的周一开始。
*
* @param year 年份
* @param month 月份1-12
* @return 日历网格列表,每项包含日期、是否当月、是否今天、是否选中
*/
@Suppress("DEPRECATION") // monthNumber 无替代 APIkotlinx-datetime 尚未提供新接口
fun getMonthDays(year: Int, month: Int): List<CalendarDay> {
val firstOfMonth = LocalDate(year, month, 1)
val dayOfWeekOffset = firstOfMonth.dayOfWeek.ordinal
val startDate = firstOfMonth.minus(DatePeriod(days = dayOfWeekOffset))
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 = ((dayOfWeekOffset + daysInMonth - 1) / 7) + 1
val totalDays = rows * 7
return (0 until totalDays).map { i ->
val date = startDate.plus(DatePeriod(days = i))
CalendarDay(
date = date,
isCurrentMonth = date.month.number == month && date.year == year,
isToday = date == today,
isSelected = date == selectedDate
)
}
}
}