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:
parent
774e03a928
commit
6f4d62b78f
@ -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 无替代 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<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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@ -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 无替代 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()
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 ----
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user