From 6f4d62b78fc4dc4129a7f7fa1916b361ac497cd0 Mon Sep 17 00:00:00 2001 From: xfy Date: Thu, 21 May 2026 18:16:20 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20Wave=203=20=E2=80=94=20ViewModel=20?= =?UTF-8?q?=E8=A7=A3=E8=80=A6=20Compose=20=E8=BF=90=E8=A1=8C=E6=97=B6?= =?UTF-8?q?=EF=BC=8C=E8=BF=81=E7=A7=BB=E5=88=B0=20StateFlow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .../plus/rua/project/CalendarViewModel.kt | 224 +++++++---------- .../kotlin/plus/rua/project/ui/BottomCard.kt | 42 ++-- .../plus/rua/project/ui/CalendarMonthView.kt | 97 +++++--- .../rua/project/CalendarViewModelStateTest.kt | 231 +++++++++--------- .../plus/rua/project/CalendarViewModelTest.kt | 8 +- 5 files changed, 299 insertions(+), 303 deletions(-) diff --git a/core/src/main/kotlin/plus/rua/project/CalendarViewModel.kt b/core/src/main/kotlin/plus/rua/project/CalendarViewModel.kt index f8b9a76..c5f1bfb 100644 --- a/core/src/main/kotlin/plus/rua/project/CalendarViewModel.kt +++ b/core/src/main/kotlin/plus/rua/project/CalendarViewModel.kt @@ -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 = _selectedDate.asStateFlow() - var isCollapsed by mutableStateOf(false) - private set + private val _isCollapsed = MutableStateFlow(false) + val isCollapsed: StateFlow = _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 = _collapseProgress.asStateFlow() @Suppress("DEPRECATION") // monthNumber 无替代 API,kotlinx-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 = _isYearView.asStateFlow() - private val _yearViewAnimatable = Animatable(0f) - val yearViewProgress: Float get() = _yearViewAnimatable.value + private val _yearViewProgress = MutableStateFlow(0f) + val yearViewProgress: StateFlow = _yearViewProgress.asStateFlow() - @Suppress("DEPRECATION") // monthNumber 无替代 API - var yearViewYear by mutableStateOf(today.year) - internal set + private val _yearViewYear = MutableStateFlow(today.year) + val yearViewYear: StateFlow = _yearViewYear.asStateFlow() /** * 个人轮班。与法定节假日完全独立,不受调休影响。 * MVP 默认:2026-05-15 起,2 班 2 休循环。后续接入设置页与持久化。 */ - var shiftPattern: ShiftPattern? by mutableStateOf( + private val _shiftPattern = MutableStateFlow( ShiftPattern( anchorDate = LocalDate(2026, 5, 15), cycle = listOf(ShiftKind.WORK, ShiftKind.WORK, ShiftKind.OFF, ShiftKind.OFF) ) ) + val shiftPattern: StateFlow = _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 = _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 } -} \ No newline at end of file +} diff --git a/core/src/main/kotlin/plus/rua/project/ui/BottomCard.kt b/core/src/main/kotlin/plus/rua/project/ui/BottomCard.kt index fa33798..108f9b4 100644 --- a/core/src/main/kotlin/plus/rua/project/ui/BottomCard.kt +++ b/core/src/main/kotlin/plus/rua/project/ui/BottomCard.kt @@ -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 从 0→1 对应手指移动此距离。 * 应设为折叠时日历实际高度变化量 (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 ) } diff --git a/core/src/main/kotlin/plus/rua/project/ui/CalendarMonthView.kt b/core/src/main/kotlin/plus/rua/project/ui/CalendarMonthView.kt index 7a8b828..7814aae 100644 --- a/core/src/main/kotlin/plus/rua/project/ui/CalendarMonthView.kt +++ b/core/src/main/kotlin/plus/rua/project/ui/CalendarMonthView.kt @@ -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() 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 无替代 API,kotlinx-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() diff --git a/core/src/test/kotlin/plus/rua/project/CalendarViewModelStateTest.kt b/core/src/test/kotlin/plus/rua/project/CalendarViewModelStateTest.kt index 566f305..e2b7a9b 100644 --- a/core/src/test/kotlin/plus/rua/project/CalendarViewModelStateTest.kt +++ b/core/src/test/kotlin/plus/rua/project/CalendarViewModelStateTest.kt @@ -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) + } } diff --git a/core/src/test/kotlin/plus/rua/project/CalendarViewModelTest.kt b/core/src/test/kotlin/plus/rua/project/CalendarViewModelTest.kt index 69b991c..e82e525 100644 --- a/core/src/test/kotlin/plus/rua/project/CalendarViewModelTest.kt +++ b/core/src/test/kotlin/plus/rua/project/CalendarViewModelTest.kt @@ -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 ----