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 =
|
fun formatDate(timestamp: Long): String =
|
||||||
SimpleDateFormat("yyyy-MM-dd", Locale.CHINA).format(Date(timestamp))
|
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)。 */
|
/** 压力值 → 字符串资源 id。中文描述的唯一来源是 `strings.xml`(ledger Ruling R13)。 */
|
||||||
@StringRes
|
@StringRes
|
||||||
fun stressLevelLabelRes(stress: Int): Int = when {
|
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.domain.model.MetricType
|
||||||
import com.placeholder.ringhealth.feature.device.DeviceScreen
|
import com.placeholder.ringhealth.feature.device.DeviceScreen
|
||||||
import com.placeholder.ringhealth.feature.device.DeviceSettingsScreen
|
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.home.HomeScreen
|
||||||
import com.placeholder.ringhealth.feature.profile.ProfileScreen
|
import com.placeholder.ringhealth.feature.profile.ProfileScreen
|
||||||
import com.placeholder.ringhealth.feature.workout.WorkoutHistoryScreen
|
import com.placeholder.ringhealth.feature.workout.WorkoutHistoryScreen
|
||||||
@@ -114,9 +114,16 @@ fun RingHealthApp(navController: NavHostController = rememberNavController()) {
|
|||||||
route = Routes.DETAIL_PATTERN,
|
route = Routes.DETAIL_PATTERN,
|
||||||
arguments = listOf(navArgument(Routes.DETAIL_ARG_METRIC) { type = NavType.StringType }),
|
arguments = listOf(navArgument(Routes.DETAIL_ARG_METRIC) { type = NavType.StringType }),
|
||||||
) { entry ->
|
) { entry ->
|
||||||
val raw = entry.arguments?.getString(Routes.DETAIL_ARG_METRIC).orEmpty()
|
// 参数无法解析属于编程错误,直接抛(ledger Ruling R22):这里原先是
|
||||||
val metric = MetricType.entries.firstOrNull { it.name == raw } ?: MetricType.STEP
|
// `?: MetricType.STEP`,一旦路由模板与 Routes.detail() 拼串不一致,
|
||||||
MetricDetailPlaceholder(metric = metric)
|
// 页面会静默显示步数的数据而不报错 —— 那是「错数据」而不是「响亮失败」。
|
||||||
|
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
|
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.Box
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
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.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.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.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
|
import com.placeholder.ringhealth.domain.model.MetricType
|
||||||
|
|
||||||
/** 指标详情子页(占位,待 Task 12 实现)。 */
|
/**
|
||||||
|
* 指标详情页(有状态外壳)。
|
||||||
|
*
|
||||||
|
* 本页自带 `Scaffold` + `TopAppBar`(外层 `RingHealthApp` 也有一个 `Scaffold`,
|
||||||
|
* 它传入的 `innerPadding` 已用于 NavHost 的 `Modifier.padding`,底部栏因此保持可见)。
|
||||||
|
* 页面内容全部在 `MetricDetailContent` 里,本函数只负责取状态与传回调(ledger Ruling R26)。
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun MetricDetailPlaceholder(metric: MetricType, modifier: Modifier = Modifier) {
|
fun MetricDetailScreen(
|
||||||
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
metric: MetricType,
|
||||||
Text(stringResource(R.string.placeholder_metric_detail))
|
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_check_update_desc">当前版本 0.1.0</string>
|
||||||
<string name="profile_help">帮助与反馈</string>
|
<string name="profile_help">帮助与反馈</string>
|
||||||
<string name="profile_help_desc">常见问题与意见反馈</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>
|
</resources>
|
||||||
|
|||||||
@@ -39,6 +39,13 @@ class FormattersTest {
|
|||||||
assertEquals("1,234", formatCalories(1234))
|
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
|
@Test
|
||||||
fun `stressLevelLabelRes 按区间映射到对应字符串资源`() {
|
fun `stressLevelLabelRes 按区间映射到对应字符串资源`() {
|
||||||
assertEquals(R.string.stress_relaxed, stressLevelLabelRes(20))
|
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