Compare commits

..
8 Commits
Author SHA1 Message Date
cuiluzhengandClaude Code 9c233e5750 docs: 补充 M1 验收记录与页面清单
Task 14 收尾(几乎不含生产代码改动):

- 清扫三条孤儿字符串:`placeholder_workout_history` / `placeholder_device_settings` /
  `placeholder_metric_detail`。grep 全 `app/src` 确认三者均无调用点后删除;
  顺带逐条核对了 `strings.xml` 其余字符串,除 manifest 引用的 `app_name` 外全部有调用点,
  未发现别的漏接线(不是顺手一起删,是先查清才决定)。
- 按 ledger R31,给 `MockRingDeviceRepository.setFunctionEnabled` 补中文 KDoc,
  写明「必须返回新列表」及其原因(`DeviceViewModel.stateIn(initialValue=…)` 在构造时
  冻结 List 引用,原地改写会让 `DeviceViewModelTest` 第一条用例退化成自我确认)。

验证:`./gradlew :app:testDebugUnitTest` 21 项全绿(FormattersTest 7 + MockDataProviderTest 5
+ HomeViewModelTest 1 + WorkoutViewModelTest 1 + DeviceViewModelTest 2 +
MetricDetailViewModelTest 3 + RoutesTest 2);`./gradlew clean assembleDebug` BUILD SUCCESSFUL,
产物 app-debug.apk(18,907,329 字节)。

新增 `docs/verification-m1.md` 记录 M1 验收。**人工验收全表标注「未执行 —— 无设备」**:
本机无模拟器镜像、无连接真机(`adb devices` 为空;`~/.android/avd` 下两个 AVD 只是指向
已删除旧 SDK 的空壳,不可启动),IDE Preview 亦无法打开。记录中含覆盖缺口声明:
M1 全部运行时行为目前只有编译期与单元测试保证,`assembleDebug` 成功不代表界面正确或不会崩溃。

README 补 M1 页面清单表,路由与 `core/navigation/Routes.kt` 逐条核对一致;
并核对 `profile_check_update_desc` 的手抄版本号 `0.1.0` 与 build.gradle.kts 的
`versionName` 一致(计划 3682 行交办项)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 12:54:40 +08:00
cuiluzhengandClaude Code 0e17502334 fix: 重写详情页 ViewModel 用例使其具备鉴别力并修正 Preview 夹具
审查指出第 3 条用例的唯一断言与 stateIn 的 initialValue 逐字相等(ledger R30 同类缺陷),
且三条用例都没有把构造函数的 metric 与 observeMetricHistory 的查询参数绑起来。
重写为「SLEEP 指标查到的是睡眠区间的数据」:点数断言杀掉「管线从不发射」,
取值区间断言(330~510,与步数 3000~12000 不相交)杀掉 metric 被写成常量的泄漏;
metric 回显断言保留但注释写明其无鉴别力。变异测试已证:把第 43 行的 metric 换成
MetricType.STEP 后该用例失败、另两条仍绿,回退后 3/3 通过。

顺带按 Minor 意见修正 previewPoints:KDoc 原文「贴近区间下限」不实,且步数夹具
7500~7572 在柱状图里渲染成齐平一排。改为带 step 形参,睡眠 330→480、步数 3200→11000,
两个 Preview 的 metric 分支覆盖不变。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 12:43:04 +08:00
cuiluzhengandClaude Code 01689b24fc feat: 实现指标详情子页(周/月切换 + 趋势图)
替换 MetricDetailPlaceholder 为真实详情页:范围切换、柱状趋势图、平均/最高/最低摘要,
睡眠指标额外渲染深睡/浅睡/清醒分期条与入睡/起床时间。
新增 MetricDetailViewModel(flatMapLatest 重新订阅数据源)与其 3 条 JVM 测试、
RoutesTest(锁住路由模板与 Routes.detail() 拼串)、Formatters.formatDecimal 与其测试。
接入 RingHealthApp 的 DETAIL_PATTERN,参数无法解析时直接抛而不是静默兜底。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 12:27:00 +08:00
cuiluzheng 88e88cf0ba feat: 实现我的页
纯静态入口列表(头像卡 + 应用设置 / 关于 两组入口),无 ViewModel、无状态。
新增 12 条 profile_* 文案资源,颜色统一走设计系统 TextSecondary。
2026-09-10 12:17:11 +08:00
cuiluzheng 7170a49664 fix: 收窄 SettingRow 可见性并强化设备 ViewModel 测试的数据管线断言 2026-09-10 11:32:55 +08:00
cuiluzheng 20fe2bce66 feat: 实现设备页与设备设置子页(可交互开关) 2026-09-10 11:27:27 +08:00
cuiluzheng 58573f4657 fix: 统一运动历史空态文字颜色并补充页面预览与顺序断言 2026-09-10 11:20:51 +08:00
cuiluzheng 031aea0fa2 feat: 实现运动页与运动历史子页 2026-09-10 11:12:28 +08:00
22 changed files with 1599 additions and 42 deletions
+15
View File
@@ -5,6 +5,21 @@
- 设计文档:见 `ring-health-docs` 仓库 `superpowers/specs/`
- 当前阶段:M1 静态页面(数据来自 Mock,未接入蓝牙 SDK)
## 页面清单(M1)
| 页面 | 路由 |
|---|---|
| 健康首页 | `home` |
| 运动 | `workout` |
| 运动历史 | `workout/history` |
| 设备 | `device` |
| 设备设置 | `device/settings` |
| 我的 | `profile` |
| 指标详情(步数/心率/睡眠/血氧/压力/体温/血压) | `detail/{metric}` |
> 页面路由的唯一定义处是 `core/navigation/Routes.kt`,上表与其保持一致。
> M1 的验收状态见 `docs/verification-m1.md`。
## 构建
```bash
@@ -4,7 +4,9 @@ import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewmodel.CreationExtras
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.placeholder.ringhealth.feature.device.DeviceViewModel
import com.placeholder.ringhealth.feature.home.HomeViewModel
import com.placeholder.ringhealth.feature.workout.WorkoutViewModel
/**
* ViewModel 工厂:把容器中的仓库注入各 ViewModel。
@@ -19,6 +21,16 @@ object AppViewModelProvider {
ringDeviceRepository = ringHealthApplication().container.ringDeviceRepository,
)
}
initializer {
WorkoutViewModel(
healthDataRepository = ringHealthApplication().container.healthDataRepository,
)
}
initializer {
DeviceViewModel(
ringDeviceRepository = ringHealthApplication().container.ringDeviceRepository,
)
}
}
}
@@ -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 {
@@ -28,11 +28,11 @@ import androidx.navigation.navArgument
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.DeviceSettingsPlaceholder
import com.placeholder.ringhealth.feature.detail.MetricDetailPlaceholder
import com.placeholder.ringhealth.feature.device.DeviceSettingsScreen
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.WorkoutHistoryPlaceholder
import com.placeholder.ringhealth.feature.workout.WorkoutHistoryScreen
import com.placeholder.ringhealth.feature.workout.WorkoutScreen
/** 底部导航项定义。 */
@@ -49,13 +49,28 @@ private val BOTTOM_TABS = listOf(
BottomTab(Routes.PROFILE, R.string.tab_profile, Icons.Filled.Person),
)
/**
* 把当前路由归到它所属的底部 Tab。
*
* 子页位于栈顶时,底栏应当保持父 Tab 点亮。这里必须与 `Routes.DETAIL_PATTERN`
* 比较 —— `NavDestination.route` 给出的是路由**模板**而非填充后的实际路由。
* 本 App 的路由是扁平注册的(没有嵌套导航图),`destination.hierarchy` 帮不上忙,
* 只能显式映射。
*/
private fun tabRouteFor(route: String?): String? = when (route) {
Routes.DETAIL_PATTERN -> Routes.HOME
Routes.DEVICE_SETTINGS -> Routes.DEVICE
Routes.WORKOUT_HISTORY -> Routes.WORKOUT
else -> route
}
/**
* 应用主界面:Scaffold + 底部导航 + NavHost。
*/
@Composable
fun RingHealthApp(navController: NavHostController = rememberNavController()) {
val backStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = backStackEntry?.destination?.route
val currentRoute = tabRouteFor(backStackEntry?.destination?.route)
Scaffold(
containerColor = MaterialTheme.colorScheme.background,
@@ -81,27 +96,34 @@ fun RingHealthApp(navController: NavHostController = rememberNavController()) {
HomeScreen(onOpenDetail = { metric -> navController.navigate(Routes.detail(metric)) })
}
composable(Routes.WORKOUT) {
WorkoutScreen()
WorkoutScreen(onOpenHistory = { navController.navigate(Routes.WORKOUT_HISTORY) })
}
composable(Routes.DEVICE) {
DeviceScreen()
DeviceScreen(onOpenSettings = { navController.navigate(Routes.DEVICE_SETTINGS) })
}
composable(Routes.PROFILE) {
ProfileScreen()
}
composable(Routes.DEVICE_SETTINGS) {
DeviceSettingsPlaceholder()
DeviceSettingsScreen()
}
composable(Routes.WORKOUT_HISTORY) {
WorkoutHistoryPlaceholder()
WorkoutHistoryScreen()
}
composable(
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() })
}
}
}
@@ -14,10 +14,12 @@ object Routes {
const val DEVICE_SETTINGS = "device/settings"
const val WORKOUT_HISTORY = "workout/history"
/** 指标详情路由模板,参数为 MetricType.name。 */
const val DETAIL_PATTERN = "detail/{metric}"
/** 路由参数的键名。必须声明在 DETAIL_PATTERN 之前:模板引用它。 */
const val DETAIL_ARG_METRIC = "metric"
/** 指标详情路由模板,参数为 MetricType.name。 */
const val DETAIL_PATTERN = "detail/{$DETAIL_ARG_METRIC}"
/** 拼出某个指标的详情路由。 */
fun detail(metric: MetricType): String = "detail/${metric.name}"
}
@@ -33,6 +33,14 @@ class MockRingDeviceRepository(
_deviceState.value = provider.deviceState()
}
/**
* 切换某个功能开关。
*
* **必须返回新列表**(`map` / `+` 等),不要原地改写同一个 `MutableList`:
* `DeviceViewModel` 的 `stateIn(initialValue = …)` 在**构造时**就把当时的 List 引用冻结了,
* 而 `DeviceViewModelTest` 第一条用例的鉴别力正依赖于此 —— 若这里改成原地改写,
* `initialValue` 会被连带改动,那条断言就退化成自我确认,测试会静默失去意义。
*/
override fun setFunctionEnabled(id: String, enabled: Boolean) {
_functionSettings.update { settings ->
settings.map { if (it.id == id) it.copy(enabled = enabled) else it }
@@ -1,19 +1,331 @@
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` 起每点递增 `step`。
*
* `base` 与 `step` 由调用方按**所在指标的取值区间**给出:`base` 取区间低位,
* `step` 取区间跨度的约 1/7,使 7 个点铺满区间的大半。这样柱状图才有可见的高低差
* (`MetricBarChart` 按 `value / maxValue` 定高,点值若都挤在一起,7 根柱子会齐平,
* 预览就看不出渲染问题),统计摘要的「平均/最高/最低」三行也不会几乎相等。
*/
private fun previewPoints(base: Double, step: 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 * step) }
}
/**
* 睡眠指标的详情页预览:走 `metric == SLEEP` 分支,带分期卡片。
* 取 SLEEP 不是随便挑的 —— 睡眠是唯一有专属区块的指标,预览它才能看见 `SleepStagesCard`。
*/
@Preview(showBackground = true)
@Composable
private fun MetricDetailContentSleepPreview() {
RingHealthTheme {
MetricDetailContent(
state = MetricDetailUiState(
loading = false,
metric = MetricType.SLEEP,
range = DateRange.WEEK,
// 睡眠时长(分钟):330→480,落在 Mock 的 330~510 区间内,跨度 150 分钟,
// 柱高从约 69% 递增到 100%,肉眼能看出梯度。
points = previewPoints(base = 330.0, step = 25.0),
),
onBack = {},
onSelectRange = {},
)
}
}
/**
* 非睡眠指标的详情页预览:走「只有趋势图 + 统计摘要」的分支。
* 两个 Preview 各覆盖一个分支,是因为只看睡眠那个无法发现「分期卡片漏判 metric、所有指标都显示」这类回归。
*/
@Preview(showBackground = true)
@Composable
private fun MetricDetailContentStepsPreview() {
RingHealthTheme {
MetricDetailContent(
state = MetricDetailUiState(
loading = false,
metric = MetricType.STEP,
range = DateRange.WEEK,
// 步数:3200→11000,落在 Mock 的 3000~12000 区间内,跨度 7800 步,
// 柱高从约 27% 递增到 100%。原来的 7500 起点会让 7 根柱子全挤在 99%~100% 高度上,
// 看起来是一条平带 —— 恰是预览本该暴露的问题。
points = previewPoints(base = 3200.0, step = 1300.0),
),
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,
)
}
}
}
}
@@ -1,18 +1,179 @@
package com.placeholder.ringhealth.feature.device
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bluetooth
import androidx.compose.material.icons.filled.Watch
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.AppViewModelProvider
import com.placeholder.ringhealth.R
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.SectionHeader
import com.placeholder.ringhealth.domain.model.DeviceFunctionSetting
import com.placeholder.ringhealth.domain.model.RingDeviceState
/** 设备页(占位,待 Task 10 实现)。 */
/** 设备(戒指管理)页。 */
@Composable
fun DeviceScreen(modifier: Modifier = Modifier) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(stringResource(R.string.tab_device))
fun DeviceScreen(
onOpenSettings: () -> Unit,
modifier: Modifier = Modifier,
viewModel: DeviceViewModel = viewModel(factory = AppViewModelProvider.Factory),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
DeviceContent(state = state, onOpenSettings = onOpenSettings, modifier = modifier)
}
/** 无状态设备页内容。 */
@Composable
private fun DeviceContent(
state: DeviceUiState,
onOpenSettings: () -> Unit,
modifier: Modifier = Modifier,
) {
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item { DeviceInfoCard(state = state) }
item {
SectionHeader(
title = stringResource(R.string.device_settings_title),
trailing = {
TextButton(onClick = onOpenSettings) {
Text(text = stringResource(R.string.device_more))
}
},
)
}
items(state.settings) { setting ->
SettingRow(setting = setting)
}
}
}
/** 戒指信息卡片。 */
@Composable
private fun DeviceInfoCard(state: DeviceUiState, modifier: Modifier = Modifier) {
HealthCard(modifier = modifier) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Filled.Watch,
contentDescription = null,
tint = MetricGreen,
modifier = Modifier.size(32.dp),
)
Column(modifier = Modifier.padding(start = 12.dp)) {
Text(text = state.device.name, style = MaterialTheme.typography.titleMedium)
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Filled.Bluetooth,
contentDescription = null,
tint = if (state.device.connected) MetricGreen else TextSecondary,
modifier = Modifier.size(14.dp),
)
Text(
text = stringResource(
if (state.device.connected) R.string.device_connected else R.string.device_disconnected,
),
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
modifier = Modifier.padding(start = 4.dp),
)
}
}
}
Row(
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.device_battery, state.device.batteryPercent),
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = stringResource(R.string.device_firmware, state.device.firmwareVersion),
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
)
}
}
}
/** 单行设置项(只读展示,开关在详情页操作)。 */
@Composable
private fun SettingRow(setting: DeviceFunctionSetting, modifier: Modifier = Modifier) {
HealthCard(modifier = modifier) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(text = setting.title, style = MaterialTheme.typography.titleSmall)
Text(text = setting.subtitle, style = MaterialTheme.typography.bodySmall, color = TextSecondary)
}
Text(
text = stringResource(
if (setting.enabled) R.string.device_switch_on else R.string.device_switch_off,
),
style = MaterialTheme.typography.bodySmall,
color = if (setting.enabled) MetricGreen else TextSecondary,
)
}
}
}
/** 预览入口:以固定示例数据渲染 [DeviceContent],便于在 IDE 中免真机查看设备页效果。 */
@Preview(showBackground = true)
@Composable
private fun DeviceContentPreview() {
RingHealthTheme {
DeviceContent(
state = DeviceUiState(
device = RingDeviceState(true, "RingHealth 智能戒指", 78, "1.0.5", false),
settings = previewSettings(),
),
onOpenSettings = {},
)
}
}
/**
* Preview 用的假开关列表。
*
* `internal` 而非 `private`:`DeviceSettingsScreen.kt` 的 Preview 也要用。
* 取值与 `MockDataProvider.defaultFunctionSettings()` 保持一致,
* 免得 Preview 里看到的和跑起来看到的不是同一份数据。
*/
internal fun previewSettings(): List<DeviceFunctionSetting> = listOf(
DeviceFunctionSetting("heart_rate", "自动心率监测", "每 10 分钟测量一次", true),
DeviceFunctionSetting("blood_oxygen", "自动血氧监测", "夜间持续监测", true),
DeviceFunctionSetting("sleep", "睡眠监测", "自动识别睡眠时段", true),
DeviceFunctionSetting("stress", "压力监测", "全天候压力评估", false),
DeviceFunctionSetting("blood_pressure", "血压监测", "需在 App 内手动校准", false),
)
@@ -1,18 +1,107 @@
package com.placeholder.ringhealth.feature.device
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.AppViewModelProvider
import com.placeholder.ringhealth.R
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.SectionHeader
import com.placeholder.ringhealth.domain.model.DeviceFunctionSetting
/** 设备设置子页(占位,待 Task 10 实现)。 */
/**
* 设备设置子页:可交互的功能开关列表。
* M1 只改内存状态;M2 后写入设备(对应协议 0502/0504)。
*/
@Composable
fun DeviceSettingsPlaceholder(modifier: Modifier = Modifier) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(stringResource(R.string.placeholder_device_settings))
fun DeviceSettingsScreen(
modifier: Modifier = Modifier,
viewModel: DeviceViewModel = viewModel(factory = AppViewModelProvider.Factory),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
DeviceSettingsContent(
settings = state.settings,
onToggle = viewModel::setFunctionEnabled,
modifier = modifier,
)
}
/**
* 无状态设备设置内容。
* 与 `DeviceContent` 同样拆出独立函数,Preview 才能喂假数据渲染(ledger Ruling R26)。
* 注意回调签名是 `(id, enabled)` 而不是 `(DeviceFunctionSetting, Boolean)`:
* 无状态层不该知道「怎么改」,只负责把「改哪一项、改成什么」报上去。
*/
@Composable
private fun DeviceSettingsContent(
settings: List<DeviceFunctionSetting>,
onToggle: (String, Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item { SectionHeader(title = stringResource(R.string.device_settings_title)) }
items(settings) { setting ->
HealthCard {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(text = setting.title, style = MaterialTheme.typography.titleSmall)
Text(text = setting.subtitle, style = MaterialTheme.typography.bodySmall, color = TextSecondary)
}
Switch(
checked = setting.enabled,
onCheckedChange = { checked -> onToggle(setting.id, checked) },
// SwitchDefaults 走正常 import,不写字面量全限定名(与其余文件的 import 风格一致)
colors = SwitchDefaults.colors(checkedTrackColor = MetricGreen),
)
}
}
}
item {
Text(
text = stringResource(R.string.device_settings_hint),
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
modifier = Modifier.padding(top = 4.dp),
)
}
}
}
/** 预览入口:以固定示例数据渲染 [DeviceSettingsContent],便于在 IDE 中免真机查看开关列表效果。 */
@Preview(showBackground = true)
@Composable
private fun DeviceSettingsContentPreview() {
RingHealthTheme {
DeviceSettingsContent(settings = previewSettings(), onToggle = { _, _ -> })
}
}
@@ -0,0 +1,46 @@
package com.placeholder.ringhealth.feature.device
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.placeholder.ringhealth.domain.model.DeviceFunctionSetting
import com.placeholder.ringhealth.domain.model.RingDeviceState
import com.placeholder.ringhealth.domain.repository.RingDeviceRepository
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
/** 设备页 UI 状态。 */
data class DeviceUiState(
val device: RingDeviceState,
val settings: List<DeviceFunctionSetting>,
)
/** 设备页 ViewModel:设备状态 + 功能开关。 */
class DeviceViewModel(
private val ringDeviceRepository: RingDeviceRepository,
) : ViewModel() {
val uiState: StateFlow<DeviceUiState> = combine(
ringDeviceRepository.deviceState,
ringDeviceRepository.functionSettings,
) { device, settings ->
DeviceUiState(device = device, settings = settings)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(STOP_TIMEOUT_MILLIS),
initialValue = DeviceUiState(
device = ringDeviceRepository.deviceState.value,
settings = ringDeviceRepository.functionSettings.value,
),
)
/** 切换功能开关(M1 仅改内存,M2 后同步到设备)。 */
fun setFunctionEnabled(id: String, enabled: Boolean) {
ringDeviceRepository.setFunctionEnabled(id, enabled)
}
private companion object {
const val STOP_TIMEOUT_MILLIS = 5_000L
}
}
@@ -1,18 +1,138 @@
package com.placeholder.ringhealth.feature.profile
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.material.icons.Icons
import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.placeholder.ringhealth.R
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.SectionHeader
/** 我的页(占位,待 Task 11 实现)。 */
/**
* “我的”页。全部为静态入口,无状态、无 ViewModel。
*
* 本页**不需要**再拆「有状态外壳 + 无状态内容」(ledger Ruling R26 的常规形状):
* 它本身就是无状态的,直接对它挂 `@Preview` 即可。
*/
@Composable
fun ProfileScreen(modifier: Modifier = Modifier) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(stringResource(R.string.tab_profile))
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item {
HealthCard {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Filled.AccountCircle,
contentDescription = null,
tint = MetricGreen,
modifier = Modifier.size(48.dp),
)
Column(modifier = Modifier.padding(start = 12.dp)) {
Text(
text = stringResource(R.string.profile_nickname),
style = MaterialTheme.typography.titleMedium,
)
Text(
text = stringResource(R.string.profile_login_hint),
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
)
}
}
}
}
item { SectionHeader(title = stringResource(R.string.profile_group_app)) }
item {
ProfileEntry(
icon = Icons.Filled.Settings,
title = stringResource(R.string.profile_units),
subtitle = stringResource(R.string.profile_units_desc),
)
}
item {
ProfileEntry(
icon = Icons.Filled.Notifications,
title = stringResource(R.string.profile_notification),
subtitle = stringResource(R.string.profile_notification_desc),
)
}
item { SectionHeader(title = stringResource(R.string.profile_group_about)) }
item {
ProfileEntry(
icon = Icons.Filled.Refresh,
title = stringResource(R.string.profile_check_update),
subtitle = stringResource(R.string.profile_check_update_desc),
)
}
item {
ProfileEntry(
icon = Icons.Filled.Info,
title = stringResource(R.string.profile_help),
subtitle = stringResource(R.string.profile_help_desc),
)
}
}
}
/** 单条设置入口。 */
@Composable
private fun ProfileEntry(
icon: ImageVector,
title: String,
subtitle: String,
modifier: Modifier = Modifier,
) {
HealthCard(modifier = modifier) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = TextSecondary,
modifier = Modifier.size(20.dp),
)
Column(modifier = Modifier.padding(start = 12.dp)) {
Text(text = title, style = MaterialTheme.typography.titleSmall)
Text(text = subtitle, style = MaterialTheme.typography.bodySmall, color = TextSecondary)
}
}
}
}
/** 预览入口:直接渲染 [ProfileScreen](本页无状态、无示例数据),便于在 IDE 中免真机查看“我的”页效果。 */
@Preview(showBackground = true)
@Composable
private fun ProfileScreenPreview() {
RingHealthTheme {
ProfileScreen()
}
}
@@ -1,18 +1,107 @@
package com.placeholder.ringhealth.feature.workout
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.AppViewModelProvider
import com.placeholder.ringhealth.R
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.domain.model.WorkoutRecord
/** 运动历史子页(占位,待 Task 9 实现)。 */
/** 运动历史子页:展示全部运动记录。 */
@Composable
fun WorkoutHistoryPlaceholder(modifier: Modifier = Modifier) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(stringResource(R.string.placeholder_workout_history))
fun WorkoutHistoryScreen(
modifier: Modifier = Modifier,
viewModel: WorkoutViewModel = viewModel(factory = AppViewModelProvider.Factory),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
WorkoutHistoryContent(state = state, modifier = modifier)
}
/** 无状态运动历史内容,便于 Preview。 */
@Composable
private fun WorkoutHistoryContent(
state: WorkoutUiState,
modifier: Modifier = Modifier,
) {
// 必须先判 loading 再判空(ledger Ruling R25):Mock 仓库有 200ms 延迟,
// records 在加载完成前就是 emptyList,若先判空会先闪一下「暂无运动记录」,
// 数据到了再跳成列表 —— 用户看到的是「明明有记录却说没有」。
if (state.loading) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = MetricGreen)
}
return
}
if (state.records.isEmpty()) {
Box(
modifier = modifier.fillMaxSize().padding(16.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = stringResource(R.string.workout_empty),
style = MaterialTheme.typography.bodyMedium,
color = TextSecondary,
)
}
return
}
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(state.records) { record ->
WorkoutRecordCard(record = record)
}
}
}
/** 预览入口:以固定示例数据渲染 [WorkoutHistoryContent],便于在 IDE 中免真机查看历史页效果。 */
@Preview(showBackground = true)
@Composable
private fun WorkoutHistoryContentPreview() {
RingHealthTheme {
WorkoutHistoryContent(
state = WorkoutUiState(
loading = false,
records = listOf(
WorkoutRecord(
id = "preview-0",
type = "户外跑步",
startTime = 1_757_000_000_000L,
durationMinutes = 42,
distanceKm = 5.20,
calories = 286,
),
WorkoutRecord(
id = "preview-1",
type = "健走",
startTime = 1_756_900_000_000L,
durationMinutes = 31,
distanceKm = 2.80,
calories = 143,
),
),
),
)
}
}
@@ -1,18 +1,175 @@
package com.placeholder.ringhealth.feature.workout
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.FitnessCenter
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.AppViewModelProvider
import com.placeholder.ringhealth.R
import com.placeholder.ringhealth.core.common.formatCalories
import com.placeholder.ringhealth.core.common.formatClock
import com.placeholder.ringhealth.core.common.formatDate
import com.placeholder.ringhealth.core.common.formatDistance
import com.placeholder.ringhealth.core.common.formatDuration
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.SectionHeader
import com.placeholder.ringhealth.domain.model.WorkoutRecord
/** 运动页(占位,待 Task 9 实现)。 */
/** 运动页。 */
@Composable
fun WorkoutScreen(modifier: Modifier = Modifier) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(stringResource(R.string.tab_workout))
fun WorkoutScreen(
onOpenHistory: () -> Unit,
modifier: Modifier = Modifier,
viewModel: WorkoutViewModel = viewModel(factory = AppViewModelProvider.Factory),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
WorkoutContent(state = state, onOpenHistory = onOpenHistory, modifier = modifier)
}
/** 无状态运动页内容。 */
@Composable
private fun WorkoutContent(
state: WorkoutUiState,
onOpenHistory: () -> Unit,
modifier: Modifier = Modifier,
) {
if (state.loading) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = MetricGreen)
}
return
}
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item {
HealthCard {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Filled.FitnessCenter,
contentDescription = null,
tint = MetricGreen,
modifier = Modifier.size(28.dp),
)
Column(modifier = Modifier.padding(start = 12.dp)) {
Text(text = stringResource(R.string.workout_start), style = MaterialTheme.typography.titleMedium)
Text(
text = stringResource(R.string.workout_start_desc),
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
)
}
}
}
}
item {
SectionHeader(
title = stringResource(R.string.workout_recent),
trailing = {
TextButton(onClick = onOpenHistory) {
Text(text = stringResource(R.string.action_view_all))
}
},
)
}
items(state.records) { record ->
WorkoutRecordCard(record = record)
}
}
}
/** 单条运动记录卡片。 */
@Composable
internal fun WorkoutRecordCard(record: WorkoutRecord, modifier: Modifier = Modifier) {
HealthCard(modifier = modifier) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column {
Text(text = record.type, style = MaterialTheme.typography.titleSmall)
// 日期 + 空格 + 时间。这里**不**违反「不硬编码文案」(ledger Ruling R25):
// 两个值都由 Formatters 做本地化格式化(SimpleDateFormat + Locale.CHINA),
// 中间的连接符是一个空格,不含任何需要翻译的文字;也没有第二份资源与它重复。
Text(
text = "${formatDate(record.startTime)} ${formatClock(record.startTime)}",
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
)
}
Column(horizontalAlignment = Alignment.End) {
Text(
text = stringResource(R.string.workout_item_summary, formatDuration(record.durationMinutes), formatDistance(record.distanceKm)),
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = stringResource(R.string.workout_detail_calories, formatCalories(record.calories)),
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
)
}
}
}
}
/** 预览入口:以固定示例数据渲染 [WorkoutContent],便于在 IDE 中免真机查看运动页效果。 */
@Preview(showBackground = true)
@Composable
private fun WorkoutContentPreview() {
RingHealthTheme {
WorkoutContent(
state = WorkoutUiState(
loading = false,
records = listOf(
WorkoutRecord(
id = "preview-0",
type = "户外跑步",
startTime = 1_757_000_000_000L,
durationMinutes = 42,
distanceKm = 5.20,
calories = 286,
),
WorkoutRecord(
id = "preview-1",
type = "健走",
startTime = 1_756_900_000_000L,
durationMinutes = 31,
distanceKm = 2.80,
calories = 143,
),
),
),
onOpenHistory = {},
)
}
}
@@ -0,0 +1,35 @@
package com.placeholder.ringhealth.feature.workout
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.placeholder.ringhealth.domain.model.WorkoutRecord
import com.placeholder.ringhealth.domain.repository.HealthDataRepository
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
/** 运动页 UI 状态。 */
data class WorkoutUiState(
val loading: Boolean,
val records: List<WorkoutRecord>,
)
/** 运动页 ViewModel。 */
class WorkoutViewModel(
healthDataRepository: HealthDataRepository,
) : ViewModel() {
/** 页面状态:仓库数据到达前为 loading,到达后为记录列表。 */
val uiState: StateFlow<WorkoutUiState> = healthDataRepository.observeWorkouts()
.map { WorkoutUiState(loading = false, records = it) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(STOP_TIMEOUT_MILLIS),
initialValue = WorkoutUiState(loading = true, records = emptyList()),
)
private companion object {
const val STOP_TIMEOUT_MILLIS = 5_000L
}
}
+45 -3
View File
@@ -11,9 +11,6 @@
<string name="tab_workout">运动</string>
<string name="tab_device">设备</string>
<string name="tab_profile">我的</string>
<string name="placeholder_device_settings">设备设置</string>
<string name="placeholder_workout_history">运动历史</string>
<string name="placeholder_metric_detail">指标详情</string>
<string name="home_device_connected">%1$s · 已连接</string>
<string name="home_device_disconnected">未连接设备</string>
@@ -36,4 +33,49 @@
<string name="metric_temperature">体温</string>
<string name="metric_blood_pressure">血压</string>
<string name="metric_step">步数</string>
<string name="workout_start">开始运动</string>
<string name="workout_start_desc">选择运动类型,记录你的训练</string>
<string name="workout_recent">最近运动</string>
<string name="workout_empty">暂无运动记录</string>
<string name="workout_item_summary">%1$s · %2$s 公里</string>
<string name="workout_detail_calories">消耗 %1$s 千卡</string>
<string name="action_view_all">查看全部</string>
<string name="device_connected">已连接</string>
<string name="device_disconnected">未连接</string>
<string name="device_battery">电量 %1$d%%</string>
<string name="device_firmware">固件版本 %1$s</string>
<string name="device_settings_title">功能设置</string>
<string name="device_more">更多设置</string>
<string name="device_switch_on">已开启</string>
<string name="device_switch_off">已关闭</string>
<string name="device_settings_hint">阶段一为演示开关,接入戒指后将同步到设备。</string>
<string name="profile_nickname">健康用户</string>
<string name="profile_login_hint">登录后可同步数据(阶段一未开放)</string>
<string name="profile_group_app">应用设置</string>
<string name="profile_units">单位设置</string>
<string name="profile_units_desc">公里 / 公斤</string>
<string name="profile_notification">通知权限</string>
<string name="profile_notification_desc">接收健康提醒</string>
<string name="profile_group_about">关于</string>
<string name="profile_check_update">检查更新</string>
<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,101 @@
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.Assert.assertTrue
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 `SLEEP 指标查到的是睡眠区间的数据`() = runTest(dispatcher) {
// 名字不叫「构造时传入的 metric 被原样带进状态」——本用例真正锁的是
// 「构造函数的 metric 被传给了仓库查询」,而不是状态字段回显(ledger R34)。
// 也不要写成「标题映射不同」:`metricTitleRes` 是 @Composable,JVM 测试里根本调不了,
// 那样命名会让人以为这里验了标题映射(其实没有,映射由 `when` 的穷尽性在编译期保证)。
val viewModel = MetricDetailViewModel(MetricType.SLEEP, MockHealthDataRepository())
backgroundScope.launch { viewModel.uiState.collect {} }
advanceUntilIdle()
val state = viewModel.uiState.value
// 承重断言一:`initialValue.points` 是 emptyList(),所以数据管线
// (observeMetricHistory / flatMapLatest / map)只要不发射,这一条必挂。
assertEquals(7, state.points.size)
// 承重断言二:上面那条只证明「有数据」,不证明「数据问对了指标」。
// `observeMetricHistory(metric, currentRange)` 里若把 metric 写成常量(如 MetricType.STEP),
// 点数仍是 7(点数只由 range 决定),只有取值区间会变 —— Mock 按指标区间生成并 coerceIn,
// 睡眠是 330~510、步数是 3000~12000,两者不相交(见 MockDataProvider.valueRange)。
// 注意 `all {}` 对空列表恒真,所以它必须与上面那条 size 断言成对出现,不能单用。
assertTrue(
"SLEEP 详情页拿到了区间外的值:${state.points.map { it.value }}",
state.points.all { it.value in 330.0..510.0 },
)
// 这一条单看**没有鉴别力**:`state.metric` 就是构造函数传进来的值,`stateIn` 的
// initialValue 里已经有它,数据管线从不发射时它照样成立(ledger R30)。保留它只是为了
// 钉住「状态里的 metric 与构造函数一致」,真正承重的是上面两条。
assertEquals(MetricType.SLEEP, state.metric)
}
}
@@ -0,0 +1,78 @@
package com.placeholder.ringhealth.feature.device
import com.placeholder.ringhealth.data.mock.MockRingDeviceRepository
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.Assert.assertTrue
import org.junit.Before
import org.junit.Test
/** 设备页 ViewModel 测试。 */
@OptIn(ExperimentalCoroutinesApi::class)
class DeviceViewModelTest {
private val dispatcher = StandardTestDispatcher()
@Before
fun setUp() {
Dispatchers.setMain(dispatcher)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun `输出设备状态与五个功能开关,且仓库变化会回流到 UI 状态`() = runTest(dispatcher) {
val repository = MockRingDeviceRepository()
val viewModel = DeviceViewModel(repository)
backgroundScope.launch { viewModel.uiState.collect {} }
advanceUntilIdle()
val state = viewModel.uiState.value
assertEquals(78, state.device.batteryPercent)
assertEquals("1.0.5", state.device.firmwareVersion)
assertEquals(5, state.settings.size)
// 上面三个值恰好与 stateIn 的 initialValue 相同(DeviceViewModel 的 initialValue
// 直接取仓库当前值),所以**单靠它们无法区分「combine 发射过」与「一直停在 initialValue」**
// (ledger Ruling R30,审查者指出)。这里绕开 ViewModel 直接改仓库,再断言状态跟着变 ——
// 只有 combine 真的在发射,这一条才可能成立。
repository.setFunctionEnabled("stress", true)
advanceUntilIdle()
assertTrue(viewModel.uiState.value.settings.first { it.id == "stress" }.enabled)
}
@Test
fun `setFunctionEnabled 只改目标项且可来回切换`() = runTest(dispatcher) {
val viewModel = DeviceViewModel(MockRingDeviceRepository())
backgroundScope.launch { viewModel.uiState.collect {} }
advanceUntilIdle()
// Mock 的 5 项里只有 stress 与 blood_pressure 初始为 false,其余 3 项为 true
// (见 MockDataProvider.defaultFunctionSettings)。切换 stress 后应为 4 项开启。
val target = "stress"
assertFalse(viewModel.uiState.value.settings.first { it.id == target }.enabled)
viewModel.setFunctionEnabled(target, true)
advanceUntilIdle()
assertTrue(viewModel.uiState.value.settings.first { it.id == target }.enabled)
// 关键:其它开关不受影响。只断言目标项变了,无法发现「整表被覆盖」这类回归。
assertEquals(4, viewModel.uiState.value.settings.count { it.enabled })
viewModel.setFunctionEnabled(target, false)
advanceUntilIdle()
assertFalse(viewModel.uiState.value.settings.first { it.id == target }.enabled)
}
}
@@ -0,0 +1,54 @@
package com.placeholder.ringhealth.feature.workout
import com.placeholder.ringhealth.data.mock.MockHealthDataRepository
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.Assert.assertTrue
import org.junit.Before
import org.junit.Test
/**
* [WorkoutViewModel] 的单元测试:验证加载完成后输出的运动记录列表。
*/
@OptIn(ExperimentalCoroutinesApi::class)
class WorkoutViewModelTest {
private val dispatcher = StandardTestDispatcher()
@Before
fun setUp() {
Dispatchers.setMain(dispatcher)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun `加载完成后输出运动记录`() = runTest(dispatcher) {
val viewModel = WorkoutViewModel(MockHealthDataRepository())
// 与 HomeViewModelTest 同理:uiState 用 WhileSubscribed 构建,
// 没有订阅者时 .value 会停在 initialValue(loading = true)。
backgroundScope.launch { viewModel.uiState.collect {} }
advanceUntilIdle()
val state = viewModel.uiState.value
assertFalse(state.loading)
assertEquals(5, state.records.size)
// 列表必须按时间倒序(最近的在最前):直接比首尾时间戳,而不是比记录类型
// —— Mock 数据的首尾类型恰好相同,用类型断言无法察觉顺序被反转。
assertTrue(state.records.first().startTime > state.records.last().startTime)
}
}
+97
View File
@@ -0,0 +1,97 @@
# M1 静态页面验收记录
## 环境
- 记录日期:2026-09-10
- 构建版本:0.1.0 (versionCode 1)
- 构建机:Windows 11,JDK 17.0.12,Android SDK `D:\Android\Sdk`(仅 `platform-tools` /
`platforms;android-36` / `build-tools;36.1.0`)
- **测试设备:无** —— 本机没有安装任何模拟器镜像,也没有连接真机
补充证据(说明「无设备」不是随口一说,而是逐项查过的):
```
$ ls D:\Android\Sdk
build-tools cmdline-tools licenses platform-tools platforms
# 没有 emulator/,也没有 system-images/
$ adb devices
List of devices attached
# 空列表,没有连接真机
```
`C:\Users\10314\.android\avd\` 下**残留着两个 AVD 定义**(`Medium_Phone_API_36.1`、
`Pixel_9_Pro`),但它们是**不可用的空壳**:二者都指向已删除的旧 SDK
`C:\Users\10314\AppData\Local\Android\Sdk`(其 `emulator\emulator.exe` 与
`system-images\android-36.1\google_apis_playstore\x86_64\` 均已不存在),且属于另一个项目
(`D:\work\xiangmu\mediq-app`)。**不要因为看到 AVD 目录就以为本机可以起模拟器。**
另注:当前 shell 的 `ANDROID_HOME` 仍指向那个已删除的旧路径,构建实际靠
`local.properties` 的 `sdk.dir=D:\Android\Sdk` 生效。
## 自动化验证(已执行)
- 单元测试:`./gradlew :app:testDebugUnitTest` —— **21 项全绿**
(`FormattersTest` 7 + `MockDataProviderTest` 5 + `HomeViewModelTest` 1 + `WorkoutViewModelTest` 1
+ `DeviceViewModelTest` 2 + `MetricDetailViewModelTest` 3 + `RoutesTest` 2)
- 构建:`./gradlew clean assembleDebug` —— `BUILD SUCCESSFUL`,
产物 `app/build/outputs/apk/debug/app-debug.apk`(18,907,329 字节)
- **未执行**:各页面的 `@Preview` 在 Android Studio 中渲染。本机没有 IDE GUI,agent 无法打开
Preview 面板,**这一项与人工验收一样是未执行的** —— 不要把它写进「已执行」段落
(旧稿把它列在上面这一节里,那等于声称验证过一件没做过的事)。
## 人工验收(未执行 —— 无设备)
下表**全部未打勾**。这份清单已原样保留,交给有设备的人执行;不要在没有实测的情况下填入结果。
| 项 | 结果 | 备注 |
|---|---|---|
| 应用启动无崩溃 | 未执行 | 无设备 |
| 四 Tab 切换 | 未执行 | 无设备 |
| 重按已高亮的父 Tab 的行为 | 未执行 | 需实测:推测会停在子页而非回到 Tab 根 |
| 每个页面的 `@Preview` 可渲染 | 未执行 | 需在 Android Studio 中逐个打开确认 |
| 次要文字灰度一致 | 未执行 | 不得出现 `TextSecondary` 与 `onSurfaceVariant` 混用 |
| 首页:设备状态条 / 步数大环 8,426 / 六个指标卡(血压“未开启”) | 未执行 | 无设备 |
| 指标详情页:周/月切换(7 根 / 30 根柱子) | 未执行 | 无设备 |
| 睡眠详情:分期卡片只在睡眠指标出现,入睡/起床读起来像「一晚」 | 未执行 | 无设备 |
| 运动页 / 运动历史页 | 未执行 | 无设备 |
| 设备页 / 设备设置开关(返回后状态保持) | 未执行 | 无设备 |
| 我的页 | 未执行 | 无设备 |
| 旋转屏幕、深色系统设置下不崩溃 | 未执行 | 无设备 |
### 需在有设备时重点确认的项(原清单细目,逐条保留)
- 应用启动无崩溃,底部四个 Tab 显示为 健康 / 运动 / 设备 / 我的
- 四个 Tab 可自由切换,切换后状态保留、无闪烁
- **重按已高亮的父 Tab 的行为**:实现为 `popUpTo(start){saveState}` + `restoreState`,
推测会**恢复到该 Tab 保存的子页栈**(停在子页而非回到 Tab 根)。请实测并记录实际行为;
若实测是「停在子页」而产品希望「回到根」,那是一个独立的小改动,记下来即可
- 首页:步数大环显示 8,426,目标 10,000,环按比例填充
- 首页:点击任一指标卡进入详情页,标题为该指标中文名
- 详情页:返回后回到首页
- 运动页:显示“开始运动”卡片 + 5 条运动记录;「查看全部」进入历史页后底部导航栏仍可见
- 设备页:戒指卡片显示电量 78%、固件版本 1.0.5
- 睡眠卡片的入睡/起床时间**读起来像「一晚」**(如 入睡 22:41 / 起床 06:41),而不是跨好几天。
阶段一按「最后一个数据点往前 8 小时」演示(R29);若显示成跨天,说明
`DEMO_SLEEP_WINDOW_MILLIS` 那段被改坏了
## 已知限制(阶段一有意为之)
- 数据全部为 Mock,不连蓝牙、不连后端
- 血压恒为“未开启”占位(`HealthSummary.bloodPressure = null`)
- 设备设置页的开关只改内存状态,重启即重置(对应 M2 的协议 0502/0504)
- 睡眠分期条与入睡/起床时间是演示值(固定比例 + 最后一个数据点往前 8 小时),无真实分期数据
- 详情页统计摘要一律保留一位小数(步数因此显示为「平均 7512.3」这类精度过剩的形式)
- 仅浅色模式
## 覆盖缺口声明
**M1 的全部运行时行为目前只有编译期与单元测试保证,没有任何一次真机/模拟器运行。**
`assembleDebug` 成功只说明代码能编译、能打包,**不说明界面正确、不说明不会崩溃**。
这是本机没有设备导致的已知缺口(ledger Ruling R21),不是「已验证通过」。
首次拿到设备时,请从上面那张人工验收表开始逐项补齐。
具体而言,**没有任何一行 M1 的 Compose 界面代码被真正渲染过**:导航图能否起来、
四个 Tab 是否显示正确文案、`MetricBarChart` 的柱子数量、`RingProgress` 的填充比例、
睡眠分期卡片是否只在睡眠指标下出现、次要文字灰度是否统一 —— 这些全部未经运行时检验。
单元测试覆盖的是 `Formatters` 的纯函数、`MockDataProvider` 的数据形状,以及七个 ViewModel
的状态流;**它们不经过 Compose 渲染管线,也不能发现布局、主题或导航层面的错误。**