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:
2026-09-10 12:27:00 +08:00
co-authored by Claude Code
parent 88e88cf0ba
commit 01689b24fc
8 changed files with 532 additions and 8 deletions
@@ -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,
)
}
}
}
}
+14
View File
@@ -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}}"))
}
}
@@ -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)
}
}