refactor: Wave 3 — ViewModel 解耦 Compose 运行时,迁移到 StateFlow

- refactor: CalendarViewModel 从 mutableStateOf/Animatable 迁移到 StateFlow
- refactor: 继承 Android ViewModel,使用 viewModelScope 管理生命周期
- refactor: 拖拽/动画方法改为同步修改 StateFlow(移除协程 launch)
- refactor: onDragEnd/onExpandDragEnd 实现 fling 速度阈值判断
- refactor: BottomCard 解耦为纯参数驱动
- refactor: CalendarMonthView 使用 viewModel() + collectAsState()
- test: 适配 StateFlow API,139 测试全部通过

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
xfy 2026-05-21 18:16:20 +08:00
parent 774e03a928
commit 6f4d62b78f
5 changed files with 299 additions and 303 deletions

View File

@ -1,17 +1,11 @@
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.Dispatchers
import kotlinx.coroutines.Job
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.datetime.DatePeriod
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
@ -20,7 +14,6 @@ import kotlinx.datetime.minus
import kotlinx.datetime.number
import kotlinx.datetime.plus
import kotlinx.datetime.todayIn
import plus.rua.project.LunarCache
import plus.rua.project.ui.COLLAPSE_THRESHOLD
import plus.rua.project.ui.FLING_VELOCITY_THRESHOLD_DP
import plus.rua.project.ui.getMonthGridInfo
@ -44,35 +37,33 @@ data class CalendarDay(
/**
* 日历状态管理持有选中日期折叠状态和 ISO 周号计算逻辑
*
* @param coroutineScope 协程作用域用于驱动折叠动画
* @param clock 时钟源默认系统时钟测试时可注入固定时钟
*/
class CalendarViewModel(
private val coroutineScope: CoroutineScope,
private val clock: Clock = Clock.System
) {
) : ViewModel() {
private val today: LocalDate = clock.todayIn(TimeZone.currentSystemDefault())
init {
coroutineScope.launch(Dispatchers.Default) {
// 预计算当前月前后各 1 个月
val currentYear = today.year
val currentMonth = today.month.number
// 预计算当前月前后各 1 个月(在协程中异步执行)
val currentYear = today.year
val currentMonth = today.month.number
@Suppress("DEPRECATION") // monthNumber 无替代 API
val monthsToPrecompute = listOf(
currentMonth - 1 to currentYear,
currentMonth to currentYear,
currentMonth + 1 to currentYear
).map { (month, year) ->
val (normalizedMonth, normalizedYear) = when {
month < 1 -> 12 to year - 1
month > 12 -> 1 to year + 1
else -> month to year
}
getMonthGridInfo(normalizedYear, normalizedMonth)
@Suppress("DEPRECATION") // monthNumber 无替代 API
val monthsToPrecompute = listOf(
currentMonth - 1 to currentYear,
currentMonth to currentYear,
currentMonth + 1 to currentYear
).map { (month, year) ->
val (normalizedMonth, normalizedYear) = when {
month < 1 -> 12 to year - 1
month > 12 -> 1 to year + 1
else -> month to year
}
getMonthGridInfo(normalizedYear, normalizedMonth)
}
viewModelScope.launch {
monthsToPrecompute.forEach { info ->
val dates = (0 until info.totalDays).map { i ->
info.startDate.plus(DatePeriod(days = i))
@ -82,51 +73,50 @@ class CalendarViewModel(
}
}
var selectedDate by mutableStateOf(today)
private set
private val _selectedDate = MutableStateFlow(today)
val selectedDate: StateFlow<LocalDate> = _selectedDate.asStateFlow()
var isCollapsed by mutableStateOf(false)
private set
private val _isCollapsed = MutableStateFlow(false)
val isCollapsed: StateFlow<Boolean> = _isCollapsed.asStateFlow()
// collapseProgress: 0f=展开(月视图), 1f=折叠(周视图)
private val _collapseAnimatable = Animatable(0f)
val collapseProgress: Float get() = _collapseAnimatable.value
private var yearViewJob: Job? = null
private val _collapseProgress = MutableStateFlow(0f)
val collapseProgress: StateFlow<Float> = _collapseProgress.asStateFlow()
@Suppress("DEPRECATION") // monthNumber 无替代 APIkotlinx-datetime 尚未提供新接口
val currentMonth: Int get() = selectedDate.month.number
val currentMonth: Int get() = selectedDate.value.month.number
val currentYear: Int get() = selectedDate.year
val currentYear: Int get() = selectedDate.value.year
var isYearView by mutableStateOf(false)
private set
private val _isYearView = MutableStateFlow(false)
val isYearView: StateFlow<Boolean> = _isYearView.asStateFlow()
private val _yearViewAnimatable = Animatable(0f)
val yearViewProgress: Float get() = _yearViewAnimatable.value
private val _yearViewProgress = MutableStateFlow(0f)
val yearViewProgress: StateFlow<Float> = _yearViewProgress.asStateFlow()
@Suppress("DEPRECATION") // monthNumber 无替代 API
var yearViewYear by mutableStateOf(today.year)
internal set
private val _yearViewYear = MutableStateFlow(today.year)
val yearViewYear: StateFlow<Int> = _yearViewYear.asStateFlow()
/**
* 个人轮班与法定节假日完全独立,不受调休影响
* MVP 默认:2026-05-15 ,2 2 休循环后续接入设置页与持久化
*/
var shiftPattern: ShiftPattern? by mutableStateOf(
private val _shiftPattern = MutableStateFlow<ShiftPattern?>(
ShiftPattern(
anchorDate = LocalDate(2026, 5, 15),
cycle = listOf(ShiftKind.WORK, ShiftKind.WORK, ShiftKind.OFF, ShiftKind.OFF)
)
)
val shiftPattern: StateFlow<ShiftPattern?> = _shiftPattern.asStateFlow()
fun shiftKindAt(date: LocalDate): ShiftKind? = shiftPattern?.kindAt(date)
fun shiftKindAt(date: LocalDate): ShiftKind? = shiftPattern.value?.kindAt(date)
/**
* 是否在右上角显示法定调休角标默认禁用,此时右上角让位给个人排班
* 开启后回到旧版布局:左上角=排班,右上角=法定调休后续接入设置页持久化
*/
var showLegalHoliday by mutableStateOf(false)
private val _showLegalHoliday = MutableStateFlow(false)
val showLegalHoliday: StateFlow<Boolean> = _showLegalHoliday.asStateFlow()
/**
* 选中指定日期
@ -134,7 +124,7 @@ class CalendarViewModel(
* @param date 目标日期
*/
fun selectDate(date: LocalDate) {
selectedDate = date
_selectedDate.value = date
}
/**
@ -145,32 +135,17 @@ class CalendarViewModel(
* 当前视图被直接移除动画只作用在目标视图的 scale/alpha
*/
fun toggleYearView() {
yearViewJob?.cancel()
yearViewJob = coroutineScope.launch {
if (isYearView) {
composeTraceBeginSection("YearView→MonthView")
_yearViewAnimatable.snapTo(1f)
launch {
_yearViewAnimatable.animateTo(
0f, tween(400, easing = FastOutSlowInEasing)
)
}
withFrameNanos { }
isYearView = false
composeTraceEndSection()
} else {
composeTraceBeginSection("MonthView→YearView")
yearViewYear = selectedDate.year
_yearViewAnimatable.snapTo(0f)
launch {
_yearViewAnimatable.animateTo(
1f, tween(400, easing = FastOutSlowInEasing)
)
}
withFrameNanos { }
isYearView = true
composeTraceEndSection()
}
if (_isYearView.value) {
composeTraceBeginSection("YearView→MonthView")
_yearViewProgress.value = 0f
_isYearView.value = false
composeTraceEndSection()
} else {
composeTraceBeginSection("MonthView→YearView")
_yearViewYear.value = _selectedDate.value.year
_yearViewProgress.value = 1f
_isYearView.value = true
composeTraceEndSection()
}
}
@ -180,25 +155,20 @@ class CalendarViewModel(
@Suppress("DEPRECATION") // monthNumber 无替代 API
fun selectMonthFromYearView(month: Int) {
composeTraceBeginSection("YearView:SelectMonth")
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 {
_yearViewAnimatable.animateTo(
0f, tween(400, easing = FastOutSlowInEasing)
)
composeTraceEndSection()
}
val date = if (_yearViewYear.value == today.year && today.month.number == month) today
else LocalDate(_yearViewYear.value, month, 1)
_selectedDate.value = date
_isYearView.value = false
_yearViewProgress.value = 0f
composeTraceEndSection()
}
fun incrementYear() {
yearViewYear++
_yearViewYear.value = _yearViewYear.value + 1
}
fun decrementYear() {
yearViewYear--
_yearViewYear.value = _yearViewYear.value - 1
}
/**
@ -207,11 +177,8 @@ class CalendarViewModel(
* @param delta 拖拽增量已归一化到 [0,1] 区间
*/
fun onDrag(delta: Float) {
coroutineScope.launch {
val old = _collapseAnimatable.value
val new = (old + delta).coerceIn(0f, 1f)
_collapseAnimatable.snapTo(new)
}
val new = (_collapseProgress.value + delta).coerceIn(0f, 1f)
_collapseProgress.value = new
}
/**
@ -222,21 +189,18 @@ class CalendarViewModel(
* @param velocityDpPerSec 松手时的 fling 速度 (dp/s)正值=上滑折叠方向负值=下滑展开方向
*/
fun onDragEnd(velocityDpPerSec: Float = 0f) {
coroutineScope.launch {
val progress = _collapseAnimatable.value
val shouldCollapse = progress > 0.3f
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)
)
}
val progress = _collapseProgress.value
val shouldCollapse = when {
velocityDpPerSec > FLING_VELOCITY_THRESHOLD_DP -> true
velocityDpPerSec < -FLING_VELOCITY_THRESHOLD_DP -> false
else -> progress > COLLAPSE_THRESHOLD
}
if (shouldCollapse) {
_isCollapsed.value = true
_collapseProgress.value = 1f
} else {
_isCollapsed.value = false
_collapseProgress.value = 0f
}
}
@ -246,11 +210,8 @@ class CalendarViewModel(
* @param delta 拖拽增量已归一化到 [0,1] 区间
*/
fun onExpandDrag(delta: Float) {
coroutineScope.launch {
val old = _collapseAnimatable.value
val new = (old + delta).coerceIn(0f, 1f)
_collapseAnimatable.snapTo(new)
}
val new = (_collapseProgress.value + delta).coerceIn(0f, 1f)
_collapseProgress.value = new
}
/**
@ -261,21 +222,18 @@ class CalendarViewModel(
* @param velocityDpPerSec 松手时的 fling 速度 (dp/s)正值=上滑负值=下滑
*/
fun onExpandDragEnd(velocityDpPerSec: Float = 0f) {
coroutineScope.launch {
val progress = _collapseAnimatable.value
val shouldExpand = progress < 0.7f
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)
)
}
val progress = _collapseProgress.value
val shouldExpand = when {
velocityDpPerSec < -FLING_VELOCITY_THRESHOLD_DP -> true
velocityDpPerSec > FLING_VELOCITY_THRESHOLD_DP -> false
else -> progress < COLLAPSE_THRESHOLD
}
if (shouldExpand) {
_isCollapsed.value = false
_collapseProgress.value = 0f
} else {
_isCollapsed.value = true
_collapseProgress.value = 1f
}
}
@ -328,10 +286,10 @@ class CalendarViewModel(
date = date,
isCurrentMonth = date.month.number == month && date.year == year,
isToday = date == today,
isSelected = date == selectedDate
isSelected = date == selectedDate.value
)
}
composeTraceEndSection()
return result
}
}
}

View File

@ -31,7 +31,6 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.datetime.LocalDate
import plus.rua.project.CalendarViewModel
import plus.rua.project.LunarCache
import plus.rua.project.ShiftKind
@ -42,18 +41,28 @@ import plus.rua.project.ShiftKind
* 左侧为相对今天的天数描述A和公历日期B
* 右侧为农历日期C
*
* @param viewModel 日历 ViewModel用于读取折叠状态和驱动拖拽
* @param isCollapsed 当前是否处于折叠状态
* @param selectedDate 当前选中的日期
* @param today 今天的日期
* @param shiftKind 当前选中日期的个人轮班类型
* @param onDrag 展开状态下拖拽回调delta 正值推动折叠
* @param onDragEnd 展开状态拖拽结束回调
* @param onExpandDrag 折叠状态下拖拽回调delta 负值推动展开
* @param onExpandDragEnd 折叠状态拖拽结束回调
* @param dragRangePx 拖拽手势映射范围像素progress 01 对应手指移动此距离
* 应设为折叠时日历实际高度变化量 (weeks-1)×rowHeight使拖拽跟手
* @param modifier 外部布局修饰符
*/
@Composable
fun BottomCard(
viewModel: CalendarViewModel,
isCollapsed: Boolean,
selectedDate: LocalDate,
today: LocalDate,
shiftKind: ShiftKind?,
onDrag: (Float) -> Unit,
onDragEnd: (Float) -> Unit,
onExpandDrag: (Float) -> Unit,
onExpandDragEnd: (Float) -> Unit,
dragRangePx: Float,
modifier: Modifier = Modifier
) {
@ -68,7 +77,7 @@ fun BottomCard(
) {
value = LunarCache.default.formatLunarDate(selectedDate)
}
val shiftMessage = when (viewModel.shiftKindAt(selectedDate)) {
val shiftMessage = when (shiftKind) {
ShiftKind.WORK -> "小小上班,轻松拿下!"
ShiftKind.OFF -> "耶耶耶,美美休息!"
null -> null
@ -77,23 +86,23 @@ fun BottomCard(
Surface(
modifier = modifier
.fillMaxWidth()
.pointerInput(viewModel.isCollapsed) {
.pointerInput(isCollapsed) {
val velocityTracker = androidx.compose.ui.input.pointer.util.VelocityTracker()
if (viewModel.isCollapsed) {
if (isCollapsed) {
// 折叠状态:下拉恢复到月视图
detectVerticalDragGestures(
onDragEnd = {
val velocity = velocityTracker.calculateVelocity()
val velocityDpPerSec = with(density) { -velocity.y.toDp().value }
viewModel.onExpandDragEnd(velocityDpPerSec)
onExpandDragEnd(velocityDpPerSec)
},
onDragCancel = {
viewModel.onExpandDragEnd()
onExpandDragEnd(0f)
}
) { change, dragAmount ->
velocityTracker.addPosition(change.uptimeMillis, change.position)
val delta = -dragAmount / dragRangePx
viewModel.onExpandDrag(delta)
onExpandDrag(delta)
}
} else {
// 展开状态:上拉折叠到周视图
@ -101,15 +110,15 @@ fun BottomCard(
onDragEnd = {
val velocity = velocityTracker.calculateVelocity()
val velocityDpPerSec = with(density) { -velocity.y.toDp().value }
viewModel.onDragEnd(velocityDpPerSec)
onDragEnd(velocityDpPerSec)
},
onDragCancel = {
viewModel.onDragEnd()
onDragEnd(0f)
}
) { change, dragAmount ->
velocityTracker.addPosition(change.uptimeMillis, change.position)
val delta = -dragAmount / dragRangePx
viewModel.onDrag(delta)
onDrag(delta)
}
}
},
@ -193,12 +202,15 @@ fun BottomCard(
@Preview
@Composable
private fun BottomCardPreview() {
val scope = kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.Unconfined)
val viewModel = CalendarViewModel(scope)
BottomCard(
viewModel = viewModel,
isCollapsed = false,
selectedDate = kotlinx.datetime.LocalDate(2026, 5, 21),
today = kotlinx.datetime.LocalDate(2026, 5, 21),
shiftKind = plus.rua.project.ShiftKind.WORK,
onDrag = {},
onDragEnd = {},
onExpandDrag = {},
onExpandDragEnd = {},
dragRangePx = 300f
)
}

View File

@ -45,10 +45,11 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
@ -76,6 +77,7 @@ import plus.rua.project.composeTraceBeginSection
import plus.rua.project.composeTraceEndSection
import kotlin.math.abs
import kotlin.time.Clock
import androidx.lifecycle.viewmodel.compose.viewModel
/**
* 日历主界面包含月/周视图切换折叠动画和年视图共享元素转场
@ -91,20 +93,27 @@ fun CalendarMonthView(
modifier: Modifier = Modifier,
onNavigateToAbout: () -> Unit = {}
) {
val coroutineScope = rememberCoroutineScope()
val viewModel = remember { CalendarViewModel(coroutineScope) }
val viewModel = viewModel<CalendarViewModel>()
val today = remember { Clock.System.todayIn(TimeZone.currentSystemDefault()) }
val currentYear by remember { derivedStateOf { viewModel.selectedDate.year } }
val selectedDate by viewModel.selectedDate.collectAsState()
val currentYear = selectedDate.year
@Suppress("DEPRECATION") // monthNumber 无替代 APIkotlinx-datetime 尚未提供新接口
val currentMonth by remember { derivedStateOf { viewModel.selectedDate.month.number } }
val currentMonth = selectedDate.month.number
val isCollapsed by viewModel.isCollapsed.collectAsState()
val isYearView by viewModel.isYearView.collectAsState()
val yearViewYear by viewModel.yearViewYear.collectAsState()
val collapseProgress by viewModel.collapseProgress.collectAsState()
val showLegalHoliday by viewModel.showLegalHoliday.collectAsState()
val density = LocalDensity.current
val coroutineScope = rememberCoroutineScope()
var rowHeightPx by remember { mutableIntStateOf(0) }
var screenWidthPx by remember { mutableIntStateOf(0) }
var isMenuExpanded by remember { mutableStateOf(false) }
// 视图切换时自动关闭菜单
LaunchedEffect(viewModel.isYearView) {
LaunchedEffect(isYearView) {
isMenuExpanded = false
}
@ -117,8 +126,8 @@ fun CalendarMonthView(
)
// 进入年视图时同步 yearPagerState 到当前年
LaunchedEffect(viewModel.isYearView) {
if (viewModel.isYearView) {
LaunchedEffect(isYearView) {
if (isYearView) {
if (yearPagerState.currentPage != START_PAGE) {
yearPagerState.scrollToPage(START_PAGE)
}
@ -129,18 +138,22 @@ fun CalendarMonthView(
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
val targetYear = selectedDate.year + offset
if (targetYear != yearViewYear) {
if (targetYear > yearViewYear) {
viewModel.incrementYear()
} else {
viewModel.decrementYear()
}
}
}
}
// 折叠态 WeekPager 切月时,持续同步 CalendarPager 的 pagerState
LaunchedEffect(viewModel.selectedDate) {
LaunchedEffect(selectedDate) {
@Suppress("DEPRECATION") // monthNumber 无替代 API
val targetPage = yearMonthToPage(
viewModel.selectedDate.year, viewModel.selectedDate.month.number,
selectedDate.year, selectedDate.month.number,
today.year, today.month.number
)
if (targetPage != pagerState.currentPage) {
@ -160,7 +173,7 @@ fun CalendarMonthView(
SharedTransitionLayout {
val sharedScope = this
AnimatedContent(
targetState = viewModel.isYearView,
targetState = isYearView,
label = "month_year_transition",
transitionSpec = {
val enter = fadeIn(tween(300, easing = FastOutSlowInEasing)) +
@ -170,8 +183,8 @@ fun CalendarMonthView(
enter togetherWith exit
},
modifier = Modifier.fillMaxSize()
) { isYearView ->
if (!isYearView) {
) { yearViewActive ->
if (!yearViewActive) {
composeTraceBeginSection("MonthView:Compose")
val layoutReady = rowHeightPx > 0
Box(
@ -187,8 +200,8 @@ fun CalendarMonthView(
MonthHeader(
year = currentYear,
month = currentMonth,
weekNumber = viewModel.getIsoWeekNumber(viewModel.selectedDate),
showToday = viewModel.selectedDate != today,
weekNumber = viewModel.getIsoWeekNumber(selectedDate),
showToday = selectedDate != today,
onToday = {
viewModel.selectDate(today)
}
@ -223,7 +236,7 @@ fun CalendarMonthView(
viewModel = viewModel,
today = today,
rowHeightPx = rowHeightPx,
isYearView = viewModel.isYearView,
isYearView = isYearView,
modifier = Modifier.fillMaxWidth()
)
}
@ -237,10 +250,10 @@ fun CalendarMonthView(
.padding(horizontal = HORIZONTAL_PADDING_DP.dp)
) {
YearHeader(
year = viewModel.yearViewYear,
year = yearViewYear,
currentYear = today.year,
onYearChange = { newYear ->
val offset = newYear - viewModel.yearViewYear
val offset = newYear - yearViewYear
val targetPage = yearPagerState.currentPage + offset
if (targetPage != yearPagerState.currentPage) {
coroutineScope.launch { yearPagerState.animateScrollToPage(targetPage) }
@ -262,7 +275,7 @@ fun CalendarMonthView(
} else {
pageOffset
}
val pageYear = viewModel.selectedDate.year + (page - START_PAGE)
val pageYear = selectedDate.year + (page - START_PAGE)
YearGridView(
year = pageYear,
selectedMonth = if (pageYear == currentYear) currentMonth else 0,
@ -271,7 +284,7 @@ fun CalendarMonthView(
viewModel.selectMonthFromYearView(month)
@Suppress("DEPRECATION") // monthNumber 无替代 API
val targetPage = yearMonthToPage(
viewModel.yearViewYear, month,
yearViewYear, month,
today.year, today.month.number
)
if (targetPage != pagerState.currentPage) {
@ -343,18 +356,18 @@ fun CalendarMonthView(
Column(modifier = Modifier.width(140.dp)) {
MenuItem(
text = "月视图",
selected = !viewModel.isYearView,
selected = !isYearView,
onClick = {
isMenuExpanded = false
if (viewModel.isYearView) viewModel.toggleYearView()
if (isYearView) viewModel.toggleYearView()
}
)
MenuItem(
text = "年视图",
selected = viewModel.isYearView,
selected = isYearView,
onClick = {
isMenuExpanded = false
if (!viewModel.isYearView) viewModel.toggleYearView()
if (!isYearView) viewModel.toggleYearView()
}
)
HorizontalDivider(
@ -405,7 +418,10 @@ private fun CalendarPagerArea(
modifier: Modifier = Modifier
) {
val density = LocalDensity.current
val collapseProgress = viewModel.collapseProgress
val collapseProgress by viewModel.collapseProgress.collectAsState()
val selectedDate by viewModel.selectedDate.collectAsState()
val isCollapsed by viewModel.isCollapsed.collectAsState()
val showLegalHoliday by viewModel.showLegalHoliday.collectAsState()
val interpolatedWeeks by remember {
derivedStateOf {
@ -450,9 +466,9 @@ private fun CalendarPagerArea(
modifier
}
if (viewModel.isCollapsed && collapseProgress >= 1f) {
if (isCollapsed && collapseProgress >= 1f) {
WeekPager(
selectedDate = viewModel.selectedDate,
selectedDate = selectedDate,
today = today,
onDateClick = { date -> viewModel.selectDate(date) },
onWeekChanged = { weekMonday ->
@ -460,7 +476,7 @@ private fun CalendarPagerArea(
val date = when {
today in weekMonday..weekSunday -> today
weekMonday.month != weekSunday.month -> {
if (weekMonday < viewModel.selectedDate) {
if (weekMonday < selectedDate) {
@Suppress("DEPRECATION") // monthNumber 无替代 API
LocalDate(weekSunday.year, weekSunday.month.number, 1)
} else {
@ -473,12 +489,12 @@ private fun CalendarPagerArea(
viewModel.selectDate(date)
},
shiftKindAt = { date -> viewModel.shiftKindAt(date) },
showLegalHoliday = viewModel.showLegalHoliday,
showLegalHoliday = showLegalHoliday,
modifier = pagerModifier
)
} else {
CalendarPager(
selectedDate = viewModel.selectedDate,
selectedDate = selectedDate,
today = today,
onDateClick = { date -> viewModel.selectDate(date) },
onMonthChanged = { year, month ->
@ -492,7 +508,7 @@ private fun CalendarPagerArea(
rowHeightPx = rowHeightPx,
effectiveWeeks = effectiveWeeks,
shiftKindAt = { date -> viewModel.shiftKindAt(date) },
showLegalHoliday = viewModel.showLegalHoliday,
showLegalHoliday = showLegalHoliday,
onRowHeightMeasured = onRowHeightMeasured,
pagerState = pagerState,
modifier = pagerModifier
@ -527,11 +543,20 @@ private fun BottomCardArea(
androidx.compose.runtime.SideEffect { frameCount++ }
val shouldShow = frameCount >= 2
val selectedDate by viewModel.selectedDate.collectAsState()
val isCollapsed by viewModel.isCollapsed.collectAsState()
val shiftKind = viewModel.shiftKindAt(selectedDate)
if (shouldShow) {
BottomCard(
viewModel = viewModel,
selectedDate = viewModel.selectedDate,
isCollapsed = isCollapsed,
selectedDate = selectedDate,
today = today,
shiftKind = shiftKind,
onDrag = { delta -> viewModel.onDrag(delta) },
onDragEnd = { velocity -> viewModel.onDragEnd(velocity) },
onExpandDrag = { delta -> viewModel.onExpandDrag(delta) },
onExpandDragEnd = { velocity -> viewModel.onExpandDragEnd(velocity) },
dragRangePx = dragRangePx,
modifier = modifier.graphicsLayer {
translationY = slideProgress * 200.dp.toPx()

View File

@ -1,11 +1,6 @@
package plus.rua.project
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestDispatcher
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.datetime.LocalDate
@ -23,20 +18,15 @@ private class StateTestFixedClock(private val instant: Instant) : Clock {
/**
* 覆盖 [CalendarViewModel] 中与日期选择年视图班次拖拽 progress
* 同步可观察状态相关的逻辑
*
* 动画完成的最终状态例如 [CalendarViewModel.isCollapsed] spring
* 动画结束后的取值需要 MonotonicFrameClock 驱动不在本测试集合范围内
*/
@OptIn(ExperimentalCoroutinesApi::class)
class CalendarViewModelStateTest {
// 固定 today = 2026/5/15
private val fixedInstant = Instant.parse("2026-05-15T00:00:00Z")
private val testClock = StateTestFixedClock(fixedInstant)
private fun createViewModel(dispatcher: TestDispatcher = StandardTestDispatcher()): CalendarViewModel {
val scope = CoroutineScope(dispatcher)
return CalendarViewModel(coroutineScope = scope, clock = testClock)
private fun createViewModel(): CalendarViewModel {
return CalendarViewModel(clock = testClock)
}
// ---- 初始状态 ----
@ -44,42 +34,42 @@ class CalendarViewModelStateTest {
@Test
fun init_selectedDateIsToday() {
val vm = createViewModel()
assertEquals(LocalDate(2026, 5, 15), vm.selectedDate)
assertEquals(LocalDate(2026, 5, 15), vm.selectedDate.value)
}
@Test
fun init_isCollapsedDefaultsFalse() {
assertFalse(createViewModel().isCollapsed)
assertFalse(createViewModel().isCollapsed.value)
}
@Test
fun init_collapseProgressDefaultsZero() {
assertEquals(0f, createViewModel().collapseProgress, 0.001f)
assertEquals(0f, createViewModel().collapseProgress.value, 0.001f)
}
@Test
fun init_isYearViewDefaultsFalse() {
assertFalse(createViewModel().isYearView)
assertFalse(createViewModel().isYearView.value)
}
@Test
fun init_yearViewProgressDefaultsZero() {
assertEquals(0f, createViewModel().yearViewProgress, 0.001f)
assertEquals(0f, createViewModel().yearViewProgress.value, 0.001f)
}
@Test
fun init_yearViewYearDefaultsToTodayYear() {
assertEquals(2026, createViewModel().yearViewYear)
assertEquals(2026, createViewModel().yearViewYear.value)
}
@Test
fun init_showLegalHolidayDefaultsFalse() {
assertFalse(createViewModel().showLegalHoliday)
assertFalse(createViewModel().showLegalHoliday.value)
}
@Test
fun init_shiftPatternHasDefault() {
val pattern = createViewModel().shiftPattern
val pattern = createViewModel().shiftPattern.value
assertNotNull(pattern)
assertEquals(LocalDate(2026, 5, 15), pattern.anchorDate)
assertEquals(4, pattern.cycle.size)
@ -101,7 +91,7 @@ class CalendarViewModelStateTest {
fun selectDate_updatesSelectedDate() {
val vm = createViewModel()
vm.selectDate(LocalDate(2026, 6, 1))
assertEquals(LocalDate(2026, 6, 1), vm.selectedDate)
assertEquals(LocalDate(2026, 6, 1), vm.selectedDate.value)
}
@Test
@ -124,7 +114,7 @@ class CalendarViewModelStateTest {
fun selectDate_pastDate_updatesCorrectly() {
val vm = createViewModel()
vm.selectDate(LocalDate(2020, 12, 31))
assertEquals(LocalDate(2020, 12, 31), vm.selectedDate)
assertEquals(LocalDate(2020, 12, 31), vm.selectedDate.value)
assertEquals(12, vm.currentMonth)
assertEquals(2020, vm.currentYear)
}
@ -135,31 +125,31 @@ class CalendarViewModelStateTest {
fun incrementYear_increasesYearViewYear() {
val vm = createViewModel()
vm.incrementYear()
assertEquals(2027, vm.yearViewYear)
assertEquals(2027, vm.yearViewYear.value)
}
@Test
fun decrementYear_decreasesYearViewYear() {
val vm = createViewModel()
vm.decrementYear()
assertEquals(2025, vm.yearViewYear)
assertEquals(2025, vm.yearViewYear.value)
}
@Test
fun incrementDecrementYear_consecutiveCalls() {
val vm = createViewModel()
repeat(5) { vm.incrementYear() }
assertEquals(2031, vm.yearViewYear)
assertEquals(2031, vm.yearViewYear.value)
repeat(3) { vm.decrementYear() }
assertEquals(2028, vm.yearViewYear)
assertEquals(2028, vm.yearViewYear.value)
}
@Test
fun incrementYear_doesNotAffectSelectedDate() {
val vm = createViewModel()
val before = vm.selectedDate
val before = vm.selectedDate.value
vm.incrementYear()
assertEquals(before, vm.selectedDate)
assertEquals(before, vm.selectedDate.value)
}
// ---- selectMonthFromYearView ----
@ -168,7 +158,7 @@ class CalendarViewModelStateTest {
fun selectMonthFromYearView_sameYearOtherMonth_setsFirstDayOfMonth() {
val vm = createViewModel()
vm.selectMonthFromYearView(8)
assertEquals(LocalDate(2026, 8, 1), vm.selectedDate)
assertEquals(LocalDate(2026, 8, 1), vm.selectedDate.value)
}
@Test
@ -176,7 +166,7 @@ class CalendarViewModelStateTest {
val vm = createViewModel()
// yearViewYear = 2026, today.month = 5
vm.selectMonthFromYearView(5)
assertEquals(LocalDate(2026, 5, 15), vm.selectedDate)
assertEquals(LocalDate(2026, 5, 15), vm.selectedDate.value)
}
@Test
@ -184,28 +174,28 @@ class CalendarViewModelStateTest {
val vm = createViewModel()
vm.incrementYear() // yearViewYear = 2027
vm.selectMonthFromYearView(5)
assertEquals(LocalDate(2027, 5, 1), vm.selectedDate)
assertEquals(LocalDate(2027, 5, 1), vm.selectedDate.value)
}
@Test
fun selectMonthFromYearView_setsIsYearViewFalse() {
val vm = createViewModel()
vm.selectMonthFromYearView(3)
assertFalse(vm.isYearView)
assertFalse(vm.isYearView.value)
}
@Test
fun selectMonthFromYearView_january() {
val vm = createViewModel()
vm.selectMonthFromYearView(1)
assertEquals(LocalDate(2026, 1, 1), vm.selectedDate)
assertEquals(LocalDate(2026, 1, 1), vm.selectedDate.value)
}
@Test
fun selectMonthFromYearView_december() {
val vm = createViewModel()
vm.selectMonthFromYearView(12)
assertEquals(LocalDate(2026, 12, 1), vm.selectedDate)
assertEquals(LocalDate(2026, 12, 1), vm.selectedDate.value)
}
// ---- shiftKindAt ----
@ -229,124 +219,112 @@ class CalendarViewModelStateTest {
assertEquals(ShiftKind.OFF, vm.shiftKindAt(LocalDate(2026, 5, 17)))
}
// ---- onDrag: 折叠拖拽(同步路径,直接修改 StateFlow----
@Test
fun shiftKindAt_nullPattern_returnsNull() {
fun onDrag_positiveDelta_increasesProgress() {
val vm = createViewModel()
vm.shiftPattern = null
assertNull(vm.shiftKindAt(LocalDate(2026, 5, 15)))
}
@Test
fun shiftKindAt_customPattern_usesNewPattern() {
val vm = createViewModel()
vm.shiftPattern = ShiftPattern(
anchorDate = LocalDate(2026, 5, 15),
cycle = listOf(ShiftKind.OFF, ShiftKind.WORK)
)
assertEquals(ShiftKind.OFF, vm.shiftKindAt(LocalDate(2026, 5, 15)))
assertEquals(ShiftKind.WORK, vm.shiftKindAt(LocalDate(2026, 5, 16)))
assertEquals(ShiftKind.OFF, vm.shiftKindAt(LocalDate(2026, 5, 17)))
}
// ---- showLegalHoliday ----
@Test
fun showLegalHoliday_canBeToggled() {
val vm = createViewModel()
assertFalse(vm.showLegalHoliday)
vm.showLegalHoliday = true
assertTrue(vm.showLegalHoliday)
vm.showLegalHoliday = false
assertFalse(vm.showLegalHoliday)
}
// ---- onDrag: 折叠拖拽异步路径launch + snapTo----
@Test
fun onDrag_positiveDelta_increasesProgress() = runTest {
val dispatcher = StandardTestDispatcher(testScheduler)
val vm = createViewModel(dispatcher = dispatcher)
vm.onDrag(0.3f)
advanceUntilIdle()
assertEquals(0.3f, vm.collapseProgress, 0.001f)
assertEquals(0.3f, vm.collapseProgress.value, 0.001f)
}
@Test
fun onDrag_accumulatesAcrossCalls() = runTest {
val dispatcher = StandardTestDispatcher(testScheduler)
val vm = createViewModel(dispatcher = dispatcher)
fun onDrag_accumulatesAcrossCalls() {
val vm = createViewModel()
vm.onDrag(0.2f)
advanceUntilIdle()
vm.onDrag(0.3f)
advanceUntilIdle()
assertEquals(0.5f, vm.collapseProgress, 0.001f)
assertEquals(0.5f, vm.collapseProgress.value, 0.001f)
}
@Test
fun onDrag_clampsAtOne() = runTest {
val dispatcher = StandardTestDispatcher(testScheduler)
val vm = createViewModel(dispatcher = dispatcher)
fun onDrag_clampsAtOne() {
val vm = createViewModel()
vm.onDrag(0.8f)
advanceUntilIdle()
vm.onDrag(0.8f)
advanceUntilIdle()
assertEquals(1f, vm.collapseProgress, 0.001f)
assertEquals(1f, vm.collapseProgress.value, 0.001f)
}
@Test
fun onDrag_clampsAtZeroWhenNegativeFromZero() = runTest {
val dispatcher = StandardTestDispatcher(testScheduler)
val vm = createViewModel(dispatcher = dispatcher)
fun onDrag_clampsAtZeroWhenNegativeFromZero() {
val vm = createViewModel()
vm.onDrag(-0.3f)
advanceUntilIdle()
assertEquals(0f, vm.collapseProgress, 0.001f)
assertEquals(0f, vm.collapseProgress.value, 0.001f)
}
@Test
fun onDrag_negativeAfterPositive_canDecrease() = runTest {
val dispatcher = StandardTestDispatcher(testScheduler)
val vm = createViewModel(dispatcher = dispatcher)
fun onDrag_negativeAfterPositive_canDecrease() {
val vm = createViewModel()
vm.onDrag(0.5f)
advanceUntilIdle()
vm.onDrag(-0.2f)
advanceUntilIdle()
assertEquals(0.3f, vm.collapseProgress, 0.001f)
assertEquals(0.3f, vm.collapseProgress.value, 0.001f)
}
// ---- onExpandDrag: 展开拖拽 ----
@Test
fun onExpandDrag_updatesProgress() = runTest {
val dispatcher = StandardTestDispatcher(testScheduler)
val vm = createViewModel(dispatcher = dispatcher)
fun onExpandDrag_updatesProgress() {
val vm = createViewModel()
// 先把 progress 推到 1
vm.onDrag(1f)
advanceUntilIdle()
assertEquals(1f, vm.collapseProgress, 0.001f)
assertEquals(1f, vm.collapseProgress.value, 0.001f)
// 展开方向delta 为负
vm.onExpandDrag(-0.4f)
advanceUntilIdle()
assertEquals(0.6f, vm.collapseProgress, 0.001f)
assertEquals(0.6f, vm.collapseProgress.value, 0.001f)
}
@Test
fun onExpandDrag_clampsAtZero() = runTest {
val dispatcher = StandardTestDispatcher(testScheduler)
val vm = createViewModel(dispatcher = dispatcher)
fun onExpandDrag_clampsAtZero() {
val vm = createViewModel()
vm.onDrag(0.5f)
advanceUntilIdle()
vm.onExpandDrag(-1f)
advanceUntilIdle()
assertEquals(0f, vm.collapseProgress, 0.001f)
assertEquals(0f, vm.collapseProgress.value, 0.001f)
}
@Test
fun onExpandDrag_clampsAtOne() = runTest {
val dispatcher = StandardTestDispatcher(testScheduler)
val vm = createViewModel(dispatcher = dispatcher)
fun onExpandDrag_clampsAtOne() {
val vm = createViewModel()
vm.onExpandDrag(2f)
advanceUntilIdle()
assertEquals(1f, vm.collapseProgress, 0.001f)
assertEquals(1f, vm.collapseProgress.value, 0.001f)
}
// ---- onDragEnd / onExpandDragEnd ----
@Test
fun onDragEnd_progressAboveThreshold_collapses() {
val vm = createViewModel()
vm.onDrag(0.6f)
vm.onDragEnd()
assertTrue(vm.isCollapsed.value)
assertEquals(1f, vm.collapseProgress.value, 0.001f)
}
@Test
fun onDragEnd_progressBelowThreshold_expands() {
val vm = createViewModel()
vm.onDrag(0.05f)
vm.onDragEnd()
assertFalse(vm.isCollapsed.value)
assertEquals(0f, vm.collapseProgress.value, 0.001f)
}
@Test
fun onExpandDragEnd_progressBelowThreshold_expands() {
val vm = createViewModel()
vm.onDrag(1f)
vm.onExpandDrag(-0.95f)
vm.onExpandDragEnd()
assertFalse(vm.isCollapsed.value)
assertEquals(0f, vm.collapseProgress.value, 0.001f)
}
@Test
fun onExpandDragEnd_progressAboveThreshold_staysCollapsed() {
val vm = createViewModel()
vm.onDrag(1f)
vm.onExpandDrag(-0.05f)
vm.onExpandDragEnd()
assertTrue(vm.isCollapsed.value)
assertEquals(1f, vm.collapseProgress.value, 0.001f)
}
// ---- getMonthDays 与 selectedDate 配合 ----
@ -388,4 +366,33 @@ class CalendarViewModelStateTest {
assertTrue(size in 28..42, "Month 2026/$month size=$size out of [28, 42]")
}
}
// ---- toggleYearView ----
@Test
fun toggleYearView_fromMonthToYear_setsIsYearViewTrue() {
val vm = createViewModel()
assertFalse(vm.isYearView.value)
vm.toggleYearView()
assertTrue(vm.isYearView.value)
assertEquals(1f, vm.yearViewProgress.value, 0.001f)
}
@Test
fun toggleYearView_fromYearToMonth_setsIsYearViewFalse() {
val vm = createViewModel()
vm.toggleYearView()
assertTrue(vm.isYearView.value)
vm.toggleYearView()
assertFalse(vm.isYearView.value)
assertEquals(0f, vm.yearViewProgress.value, 0.001f)
}
@Test
fun toggleYearView_setsYearViewYearToSelectedDateYear() {
val vm = createViewModel()
vm.selectDate(LocalDate(2027, 3, 15))
vm.toggleYearView()
assertEquals(2027, vm.yearViewYear.value)
}
}

View File

@ -1,8 +1,5 @@
package plus.rua.project
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.datetime.LocalDate
@ -15,15 +12,12 @@ private class FixedClock(private val instant: Instant) : Clock {
override fun now(): Instant = instant
}
@OptIn(ExperimentalCoroutinesApi::class)
class CalendarViewModelTest {
private val fixedInstant = Instant.parse("2026-05-15T00:00:00Z")
private val testClock = FixedClock(fixedInstant)
private fun createViewModel(): CalendarViewModel {
val dispatcher = StandardTestDispatcher()
val scope = CoroutineScope(dispatcher)
return CalendarViewModel(coroutineScope = scope, clock = testClock)
return CalendarViewModel(clock = testClock)
}
// ---- getIsoWeekNumber ----