feat: 实现指标详情子页(周/月切换 + 趋势图)
替换 MetricDetailPlaceholder 为真实详情页:范围切换、柱状趋势图、平均/最高/最低摘要, 睡眠指标额外渲染深睡/浅睡/清醒分期条与入睡/起床时间。 新增 MetricDetailViewModel(flatMapLatest 重新订阅数据源)与其 3 条 JVM 测试、 RoutesTest(锁住路由模板与 Routes.detail() 拼串)、Formatters.formatDecimal 与其测试。 接入 RingHealthApp 的 DETAIL_PATTERN,参数无法解析时直接抛而不是静默兜底。 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -45,6 +45,9 @@ fun formatClock(timestamp: Long): String =
|
||||
fun formatDate(timestamp: Long): String =
|
||||
SimpleDateFormat("yyyy-MM-dd", Locale.CHINA).format(Date(timestamp))
|
||||
|
||||
/** 数值 → 保留一位小数。详情页的统计摘要用(ledger Ruling R29)。 */
|
||||
fun formatDecimal(value: Double): String = String.format(Locale.CHINA, "%.1f", value)
|
||||
|
||||
/** 压力值 → 字符串资源 id。中文描述的唯一来源是 `strings.xml`(ledger Ruling R13)。 */
|
||||
@StringRes
|
||||
fun stressLevelLabelRes(stress: Int): Int = when {
|
||||
|
||||
@@ -29,7 +29,7 @@ import com.placeholder.ringhealth.R
|
||||
import com.placeholder.ringhealth.domain.model.MetricType
|
||||
import com.placeholder.ringhealth.feature.device.DeviceScreen
|
||||
import com.placeholder.ringhealth.feature.device.DeviceSettingsScreen
|
||||
import com.placeholder.ringhealth.feature.detail.MetricDetailPlaceholder
|
||||
import com.placeholder.ringhealth.feature.detail.MetricDetailScreen
|
||||
import com.placeholder.ringhealth.feature.home.HomeScreen
|
||||
import com.placeholder.ringhealth.feature.profile.ProfileScreen
|
||||
import com.placeholder.ringhealth.feature.workout.WorkoutHistoryScreen
|
||||
@@ -114,9 +114,16 @@ fun RingHealthApp(navController: NavHostController = rememberNavController()) {
|
||||
route = Routes.DETAIL_PATTERN,
|
||||
arguments = listOf(navArgument(Routes.DETAIL_ARG_METRIC) { type = NavType.StringType }),
|
||||
) { entry ->
|
||||
val raw = entry.arguments?.getString(Routes.DETAIL_ARG_METRIC).orEmpty()
|
||||
val metric = MetricType.entries.firstOrNull { it.name == raw } ?: MetricType.STEP
|
||||
MetricDetailPlaceholder(metric = metric)
|
||||
// 参数无法解析属于编程错误,直接抛(ledger Ruling R22):这里原先是
|
||||
// `?: MetricType.STEP`,一旦路由模板与 Routes.detail() 拼串不一致,
|
||||
// 页面会静默显示步数的数据而不报错 —— 那是「错数据」而不是「响亮失败」。
|
||||
val raw = requireNotNull(entry.arguments?.getString(Routes.DETAIL_ARG_METRIC)) {
|
||||
"详情路由缺少参数 ${Routes.DETAIL_ARG_METRIC}"
|
||||
}
|
||||
val metric = requireNotNull(MetricType.entries.firstOrNull { it.name == raw }) {
|
||||
"无法识别的指标:$raw"
|
||||
}
|
||||
MetricDetailScreen(metric = metric, onBack = { navController.popBackStack() })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,322 @@
|
||||
package com.placeholder.ringhealth.feature.detail
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.placeholder.ringhealth.R
|
||||
import com.placeholder.ringhealth.core.common.formatClock
|
||||
import com.placeholder.ringhealth.core.common.formatDecimal
|
||||
import com.placeholder.ringhealth.core.designsystem.MetricBlue
|
||||
import com.placeholder.ringhealth.core.designsystem.MetricGreen
|
||||
import com.placeholder.ringhealth.core.designsystem.RingHealthTheme
|
||||
import com.placeholder.ringhealth.core.designsystem.TextSecondary
|
||||
import com.placeholder.ringhealth.core.designsystem.component.HealthCard
|
||||
import com.placeholder.ringhealth.core.designsystem.component.MetricBarChart
|
||||
import com.placeholder.ringhealth.core.designsystem.component.SectionHeader
|
||||
import com.placeholder.ringhealth.core.designsystem.component.StatBar
|
||||
import com.placeholder.ringhealth.core.designsystem.component.StatSegment
|
||||
import com.placeholder.ringhealth.domain.model.DateRange
|
||||
import com.placeholder.ringhealth.domain.model.MetricPoint
|
||||
import com.placeholder.ringhealth.domain.model.MetricType
|
||||
|
||||
/** 指标详情子页(占位,待 Task 12 实现)。 */
|
||||
/**
|
||||
* 指标详情页(有状态外壳)。
|
||||
*
|
||||
* 本页自带 `Scaffold` + `TopAppBar`(外层 `RingHealthApp` 也有一个 `Scaffold`,
|
||||
* 它传入的 `innerPadding` 已用于 NavHost 的 `Modifier.padding`,底部栏因此保持可见)。
|
||||
* 页面内容全部在 `MetricDetailContent` 里,本函数只负责取状态与传回调(ledger Ruling R26)。
|
||||
*/
|
||||
@Composable
|
||||
fun MetricDetailPlaceholder(metric: MetricType, modifier: Modifier = Modifier) {
|
||||
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(stringResource(R.string.placeholder_metric_detail))
|
||||
fun MetricDetailScreen(
|
||||
metric: MetricType,
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: MetricDetailViewModel = viewModel(factory = MetricDetailViewModel.factory(metric)),
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
MetricDetailContent(
|
||||
state = state,
|
||||
onBack = onBack,
|
||||
onSelectRange = viewModel::switchRange,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 无状态指标详情内容:范围切换 + 柱状趋势图 + 统计摘要(+ 睡眠分期)。
|
||||
*
|
||||
* 标题取自 `state.metric` 而不是外面传进来的 `metric` —— 无状态层只有 `state` 一个数据来源,
|
||||
* 免得出现「标题写心率、数据画睡眠」这种两者不一致的可能。
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun MetricDetailContent(
|
||||
state: MetricDetailUiState,
|
||||
onBack: () -> Unit,
|
||||
onSelectRange: (DateRange) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(text = stringResource(metricTitleRes(state.metric))) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.action_back),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
if (state.loading) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().padding(innerPadding),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator(color = MetricGreen)
|
||||
}
|
||||
return@Scaffold
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().padding(innerPadding),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
item { RangeSelector(current = state.range, onSelect = onSelectRange) }
|
||||
item {
|
||||
HealthCard {
|
||||
SectionHeader(title = stringResource(R.string.detail_trend))
|
||||
MetricBarChart(
|
||||
values = state.points.map { it.value.toFloat() },
|
||||
color = MetricGreen,
|
||||
)
|
||||
}
|
||||
}
|
||||
item {
|
||||
HealthCard {
|
||||
val values = state.points.map { it.value }
|
||||
StatLine(label = stringResource(R.string.detail_average), value = values.averageOrZero())
|
||||
StatLine(label = stringResource(R.string.detail_max), value = values.maxOrNull() ?: 0.0)
|
||||
StatLine(label = stringResource(R.string.detail_min), value = values.minOrNull() ?: 0.0)
|
||||
}
|
||||
}
|
||||
if (state.metric == MetricType.SLEEP) {
|
||||
item { SleepStagesCard(points = state.points) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 睡眠分期卡片:深睡/浅睡/清醒分段条 + 入睡/起床时间(对应 spec §6 的 `detail/sleep`)。
|
||||
*/
|
||||
@Composable
|
||||
private fun SleepStagesCard(points: List<MetricPoint>, modifier: Modifier = Modifier) {
|
||||
// 阶段一没有真实分期数据,按固定比例演示;M3 接入真实睡眠数据后改为按实际时段计算。
|
||||
val segments = listOf(
|
||||
StatSegment(weight = 0.25f, color = MetricBlue),
|
||||
StatSegment(weight = 0.65f, color = MetricBlue.copy(alpha = 0.45f)),
|
||||
StatSegment(weight = 0.10f, color = MetricBlue.copy(alpha = 0.2f)),
|
||||
)
|
||||
HealthCard(modifier = modifier) {
|
||||
SectionHeader(title = stringResource(R.string.detail_sleep_stages))
|
||||
StatBar(segments = segments)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
StageLegend(label = stringResource(R.string.detail_sleep_deep), tint = MetricBlue)
|
||||
StageLegend(label = stringResource(R.string.detail_sleep_light), tint = MetricBlue.copy(alpha = 0.45f))
|
||||
StageLegend(label = stringResource(R.string.detail_sleep_awake), tint = MetricBlue.copy(alpha = 0.2f))
|
||||
}
|
||||
// 入睡/起床演示值(ledger Ruling R29):Mock 的睡眠历史点是「每天一个」,首点是 7 天前、
|
||||
// 末点是现在。直接拿 first/last 当入睡/起床会显示成「睡了 7 天」,在验收时看着像 bug。
|
||||
// 这里改成以最后一个数据点为起床时刻、往前推 8 小时作为入睡时刻 —— 一张卡片讲「一晚」。
|
||||
// M3 接入真实睡眠数据后改为读取实际分期时段。
|
||||
val wakeUp = points.lastOrNull()?.timestamp
|
||||
if (wakeUp != null) {
|
||||
// 只有拿到起床时刻才渲染这一行;入睡时刻由它推出,所以判空只需判 wakeUp
|
||||
// (brief 原文写的是 `fallAsleep != null && wakeUp != null`,Kotlin 会因其恒真而告警,
|
||||
// 语义与此处完全等价)。
|
||||
val fallAsleep = wakeUp - DEMO_SLEEP_WINDOW_MILLIS
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = "${stringResource(R.string.detail_sleep_fall_asleep)} ${formatClock(fallAsleep)}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = TextSecondary,
|
||||
)
|
||||
Text(
|
||||
text = "${stringResource(R.string.detail_sleep_wake_up)} ${formatClock(wakeUp)}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = TextSecondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 阶段一演示用的「一晚」时长:8 小时。M3 接入真实睡眠数据后删除。 */
|
||||
private const val DEMO_SLEEP_WINDOW_MILLIS = 8L * 60 * 60 * 1000
|
||||
|
||||
/** 分期图例:小色块 + 名称。 */
|
||||
@Composable
|
||||
private fun StageLegend(label: String, tint: Color, modifier: Modifier = Modifier) {
|
||||
Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(10.dp)
|
||||
.clip(RoundedCornerShape(5.dp))
|
||||
.background(tint),
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = TextSecondary,
|
||||
modifier = Modifier.padding(start = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** 指标类型 → 标题字符串资源。文案的唯一来源是 `strings.xml`(ledger Ruling R3)。 */
|
||||
@Composable
|
||||
private fun metricTitleRes(type: MetricType): Int = when (type) {
|
||||
MetricType.STEP -> R.string.metric_step
|
||||
MetricType.HEART_RATE -> R.string.metric_heart_rate
|
||||
MetricType.SLEEP -> R.string.metric_sleep
|
||||
MetricType.BLOOD_OXYGEN -> R.string.metric_blood_oxygen
|
||||
MetricType.STRESS -> R.string.metric_stress
|
||||
MetricType.TEMPERATURE -> R.string.metric_temperature
|
||||
MetricType.BLOOD_PRESSURE -> R.string.metric_blood_pressure
|
||||
}
|
||||
|
||||
/** 周/月切换。 */
|
||||
@Composable
|
||||
private fun RangeSelector(
|
||||
current: DateRange,
|
||||
onSelect: (DateRange) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
FilterChip(
|
||||
selected = current == DateRange.WEEK,
|
||||
onClick = { onSelect(DateRange.WEEK) },
|
||||
label = { Text(stringResource(R.string.detail_range_week)) },
|
||||
)
|
||||
FilterChip(
|
||||
selected = current == DateRange.MONTH,
|
||||
onClick = { onSelect(DateRange.MONTH) },
|
||||
label = { Text(stringResource(R.string.detail_range_month)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** 一行统计摘要。 */
|
||||
@Composable
|
||||
private fun StatLine(label: String, value: Double, modifier: Modifier = Modifier) {
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(text = label, style = MaterialTheme.typography.bodyMedium, color = TextSecondary)
|
||||
// 数值格式化一律走 core/common/Formatters.kt(Step 1c),页面里不写 String.format:
|
||||
// 那是格式串副本,改口径时要满仓库找。
|
||||
Text(text = formatDecimal(value), style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
|
||||
/** 空列表时返回 0.0,避免 NaN。 */
|
||||
private fun List<Double>.averageOrZero(): Double = if (isEmpty()) 0.0 else average()
|
||||
|
||||
/**
|
||||
* Preview 用的样例数据:7 个点、每天一个、值从 `base` 起小幅递增。
|
||||
* 取值贴近区间下限,好让柱状图有高低差、统计摘要的三行数字不相等(都相等就看不出渲染问题)。
|
||||
*/
|
||||
private fun previewPoints(base: Double): List<MetricPoint> {
|
||||
val dayMillis = 24L * 60 * 60 * 1000
|
||||
val start = 1_700_000_000_000L
|
||||
return List(7) { index -> MetricPoint(timestamp = start + index * dayMillis, value = base + index * 12) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 睡眠指标的详情页预览:走 `metric == SLEEP` 分支,带分期卡片。
|
||||
* 取 SLEEP 不是随便挑的 —— 睡眠是唯一有专属区块的指标,预览它才能看见 `SleepStagesCard`。
|
||||
*/
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun MetricDetailContentSleepPreview() {
|
||||
RingHealthTheme {
|
||||
MetricDetailContent(
|
||||
state = MetricDetailUiState(
|
||||
loading = false,
|
||||
metric = MetricType.SLEEP,
|
||||
range = DateRange.WEEK,
|
||||
points = previewPoints(base = 420.0), // 睡眠时长(分钟),与 Mock 的 330~510 同量级
|
||||
),
|
||||
onBack = {},
|
||||
onSelectRange = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 非睡眠指标的详情页预览:走「只有趋势图 + 统计摘要」的分支。
|
||||
* 两个 Preview 各覆盖一个分支,是因为只看睡眠那个无法发现「分期卡片漏判 metric、所有指标都显示」这类回归。
|
||||
*/
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun MetricDetailContentStepsPreview() {
|
||||
RingHealthTheme {
|
||||
MetricDetailContent(
|
||||
state = MetricDetailUiState(
|
||||
loading = false,
|
||||
metric = MetricType.STEP,
|
||||
range = DateRange.WEEK,
|
||||
points = previewPoints(base = 7500.0), // 步数,与 Mock 的 3000~12000 同量级
|
||||
),
|
||||
onBack = {},
|
||||
onSelectRange = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package com.placeholder.ringhealth.feature.detail
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import com.placeholder.ringhealth.domain.model.DateRange
|
||||
import com.placeholder.ringhealth.domain.model.MetricPoint
|
||||
import com.placeholder.ringhealth.domain.model.MetricType
|
||||
import com.placeholder.ringhealth.domain.repository.HealthDataRepository
|
||||
import com.placeholder.ringhealth.ringHealthApplication
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
||||
/** 指标详情 UI 状态。 */
|
||||
data class MetricDetailUiState(
|
||||
val loading: Boolean,
|
||||
val metric: MetricType,
|
||||
val range: DateRange,
|
||||
val points: List<MetricPoint>,
|
||||
)
|
||||
|
||||
/**
|
||||
* 指标详情 ViewModel。因需要指标参数,使用独立工厂创建。
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class MetricDetailViewModel(
|
||||
private val metric: MetricType,
|
||||
healthDataRepository: HealthDataRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val range = MutableStateFlow(DateRange.WEEK)
|
||||
|
||||
val uiState: StateFlow<MetricDetailUiState> = range
|
||||
.flatMapLatest { currentRange ->
|
||||
healthDataRepository.observeMetricHistory(metric, currentRange)
|
||||
.map { points ->
|
||||
MetricDetailUiState(
|
||||
loading = false,
|
||||
metric = metric,
|
||||
range = currentRange,
|
||||
points = points,
|
||||
)
|
||||
}
|
||||
}
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(STOP_TIMEOUT_MILLIS),
|
||||
initialValue = MetricDetailUiState(
|
||||
loading = true,
|
||||
metric = metric,
|
||||
range = DateRange.WEEK,
|
||||
points = emptyList(),
|
||||
),
|
||||
)
|
||||
|
||||
/** 切换时间范围(周/月)。 */
|
||||
fun switchRange(newRange: DateRange) {
|
||||
range.update { newRange }
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val STOP_TIMEOUT_MILLIS = 5_000L
|
||||
|
||||
/** 为指定指标构造 ViewModel 工厂。 */
|
||||
fun factory(metric: MetricType): ViewModelProvider.Factory = viewModelFactory {
|
||||
initializer {
|
||||
MetricDetailViewModel(
|
||||
metric = metric,
|
||||
healthDataRepository = ringHealthApplication().container.healthDataRepository,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -67,4 +67,18 @@
|
||||
<string name="profile_check_update_desc">当前版本 0.1.0</string>
|
||||
<string name="profile_help">帮助与反馈</string>
|
||||
<string name="profile_help_desc">常见问题与意见反馈</string>
|
||||
|
||||
<string name="detail_range_week">近 7 天</string>
|
||||
<string name="detail_range_month">近 30 天</string>
|
||||
<string name="detail_average">平均</string>
|
||||
<string name="detail_max">最高</string>
|
||||
<string name="detail_min">最低</string>
|
||||
<string name="detail_trend">趋势</string>
|
||||
<string name="detail_sleep_stages">睡眠分期</string>
|
||||
<string name="detail_sleep_deep">深睡</string>
|
||||
<string name="detail_sleep_light">浅睡</string>
|
||||
<string name="detail_sleep_awake">清醒</string>
|
||||
<string name="detail_sleep_fall_asleep">入睡时间</string>
|
||||
<string name="detail_sleep_wake_up">起床时间</string>
|
||||
<string name="action_back">返回</string>
|
||||
</resources>
|
||||
|
||||
@@ -39,6 +39,13 @@ class FormattersTest {
|
||||
assertEquals("1,234", formatCalories(1234))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `formatDecimal 保留一位小数`() {
|
||||
assertEquals("72.0", formatDecimal(72.0))
|
||||
assertEquals("98.6", formatDecimal(98.56))
|
||||
assertEquals("0.0", formatDecimal(0.0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stressLevelLabelRes 按区间映射到对应字符串资源`() {
|
||||
assertEquals(R.string.stress_relaxed, stressLevelLabelRes(20))
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.placeholder.ringhealth.core.navigation
|
||||
|
||||
import com.placeholder.ringhealth.domain.model.MetricType
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/** 路由表的一致性测试:模板与实际拼出的路由必须始终吻合(ledger Ruling R22)。 */
|
||||
class RoutesTest {
|
||||
|
||||
@Test
|
||||
fun `detail 拼出的路由能被详情模板匹配`() {
|
||||
val expectedPrefix = Routes.DETAIL_PATTERN.substringBefore("{")
|
||||
MetricType.entries.forEach { metric ->
|
||||
val route = Routes.detail(metric)
|
||||
assertTrue("$route 不以 $expectedPrefix 开头", route.startsWith(expectedPrefix))
|
||||
assertEquals(metric.name, route.removePrefix(expectedPrefix))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `占位符名与参数常量一致`() {
|
||||
assertTrue(Routes.DETAIL_PATTERN.contains("{${Routes.DETAIL_ARG_METRIC}}"))
|
||||
}
|
||||
}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
package com.placeholder.ringhealth.feature.detail
|
||||
|
||||
import com.placeholder.ringhealth.data.mock.MockHealthDataRepository
|
||||
import com.placeholder.ringhealth.domain.model.DateRange
|
||||
import com.placeholder.ringhealth.domain.model.MetricType
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
/** 指标详情 ViewModel 测试。 */
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class MetricDetailViewModelTest {
|
||||
|
||||
private val dispatcher = StandardTestDispatcher()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(dispatcher)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `默认输出近 7 天的数据`() = runTest(dispatcher) {
|
||||
val viewModel = MetricDetailViewModel(MetricType.HEART_RATE, MockHealthDataRepository())
|
||||
backgroundScope.launch { viewModel.uiState.collect {} }
|
||||
|
||||
advanceUntilIdle()
|
||||
|
||||
val state = viewModel.uiState.value
|
||||
assertFalse(state.loading)
|
||||
assertEquals(MetricType.HEART_RATE, state.metric)
|
||||
assertEquals(DateRange.WEEK, state.range)
|
||||
assertEquals(7, state.points.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `switchRange 切到月度后变成 30 个点`() = runTest(dispatcher) {
|
||||
val viewModel = MetricDetailViewModel(MetricType.HEART_RATE, MockHealthDataRepository())
|
||||
backgroundScope.launch { viewModel.uiState.collect {} }
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.switchRange(DateRange.MONTH)
|
||||
advanceUntilIdle()
|
||||
|
||||
val state = viewModel.uiState.value
|
||||
// 三个断言缺一不可:只断言点数,无法发现「上游没重新订阅、只是把旧数据换了标签」;
|
||||
// 只断言 range,无法发现 flatMapLatest 没生效。
|
||||
assertEquals(DateRange.MONTH, state.range)
|
||||
assertEquals(30, state.points.size)
|
||||
assertEquals(MetricType.HEART_RATE, state.metric)
|
||||
|
||||
viewModel.switchRange(DateRange.WEEK)
|
||||
advanceUntilIdle()
|
||||
assertEquals(7, viewModel.uiState.value.points.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `构造时传入的 metric 被原样带进状态`() = runTest(dispatcher) {
|
||||
// 名字就叫它断言的事,不要写成「标题映射不同」——`metricTitleRes` 是 @Composable,
|
||||
// JVM 测试里根本调不了,那样命名会让人以为这里验了标题映射(其实没有,
|
||||
// 映射本身由 `when` 的穷尽性在编译期保证)。本用例只锁「metric 被原样带进状态」。
|
||||
val viewModel = MetricDetailViewModel(MetricType.SLEEP, MockHealthDataRepository())
|
||||
backgroundScope.launch { viewModel.uiState.collect {} }
|
||||
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(MetricType.SLEEP, viewModel.uiState.value.metric)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user