diff --git a/superpowers/plans/2026-09-10-ring-health-app-m0-m1.md b/superpowers/plans/2026-09-10-ring-health-app-m0-m1.md index 418cc88..ccc1f2b 100644 --- a/superpowers/plans/2026-09-10-ring-health-app-m0-m1.md +++ b/superpowers/plans/2026-09-10-ring-health-app-m0-m1.md @@ -3158,8 +3158,9 @@ class DeviceViewModelTest { } @Test - fun `输出设备状态与五个功能开关`() = runTest(dispatcher) { - val viewModel = DeviceViewModel(MockRingDeviceRepository()) + fun `输出设备状态与五个功能开关,且仓库变化会回流到 UI 状态`() = runTest(dispatcher) { + val repository = MockRingDeviceRepository() + val viewModel = DeviceViewModel(repository) backgroundScope.launch { viewModel.uiState.collect {} } advanceUntilIdle() @@ -3168,6 +3169,14 @@ class DeviceViewModelTest { 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 @@ -3421,7 +3430,7 @@ private fun DeviceInfoCard(state: DeviceUiState, modifier: Modifier = Modifier) /** 单行设置项(只读展示,开关在详情页操作)。 */ @Composable -internal fun SettingRow(setting: DeviceFunctionSetting, modifier: Modifier = Modifier) { +private fun SettingRow(setting: DeviceFunctionSetting, modifier: Modifier = Modifier) { HealthCard(modifier = modifier) { Row( modifier = Modifier.fillMaxWidth(), @@ -3473,8 +3482,10 @@ internal fun previewSettings(): List = listOf( ) ``` -> `previewSettings()` 是**唯一**的例外:它是 `internal` 而不是 `private`,因为两个页面的 Preview 共用。 -> 它只在 Preview 与 debug 构建里被调用,不进任何生产路径。 +> `previewSettings()` 是**唯一**被放宽到 `internal` 的函数(两个文件的 Preview 共用,`private` 传不过去), +> 文件里其余一切都保持 `private`。它只在 Preview 与 debug 构建里被调用,不进任何生产路径。 +> —— 审查者指出 `SettingRow` 原本也写成了 `internal`,但它的消费者只有同文件的 `DeviceContent`, +> 属于无谓地放大暴露面,已改回 `private`(ledger Ruling R30)。 > **Preview 是本项目唯一可用的目视检查手段(ledger Ruling R21 / R26)**:本机没有模拟器镜像也没有真机, > `assembleDebug` 只能证明「能编译」。因此每个页面都必须拆成「公开的有状态外壳 + `private` 的无状态 @@ -3516,6 +3527,7 @@ 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 /** * 设备设置子页:可交互的功能开关列表。 @@ -3835,24 +3847,160 @@ git add -A && git commit -m "feat: 实现我的页" **Files:** - Create: `feature/detail/MetricDetailViewModel.kt` +- Create: `app/src/test/java/com/placeholder/ringhealth/feature/detail/MetricDetailViewModelTest.kt` +- Create: `app/src/test/java/com/placeholder/ringhealth/core/navigation/RoutesTest.kt` - Modify: `feature/detail/MetricDetailScreen.kt`(替换占位) - Modify: `core/navigation/RingHealthApp.kt`(接入真实详情页 + 支持返回) +- Modify: `core/common/Formatters.kt`(+1 个 `formatDecimal`) +- Modify: `app/src/test/java/com/placeholder/ringhealth/core/common/FormattersTest.kt`(+1 条用例) - Modify: `res/values/strings.xml` **Interfaces:** -- Consumes: `HealthDataRepository`、`MetricBarChart`、`StatBar`、Task 8 加入的 `R.string.metric_*` +- Consumes: `HealthDataRepository`、`MetricBarChart`、`StatBar`、`StatSegment`、Task 8 加入的 `R.string.metric_*` - Produces: - `data class MetricDetailUiState(val loading: Boolean, val metric: MetricType, val range: DateRange, val points: List)` - `class MetricDetailViewModel(metric: MetricType, healthDataRepository)`,方法 `fun switchRange(range: DateRange)` - `@Composable fun MetricDetailScreen(metric: MetricType, onBack: () -> Unit, modifier, viewModel: MetricDetailViewModel = viewModel(factory = MetricDetailViewModel.factory(metric)))` - `MetricDetailViewModel.factory(metric: MetricType): ViewModelProvider.Factory`(伴生对象) + - `fun formatDecimal(value: Double): String`(`core/common/Formatters.kt`) - `@Composable private fun metricTitleRes(type: MetricType): Int`(文件内私有) -> **两点范围说明**: +> **范围说明**: > - 标题用 `metricTitleRes` 映射到 `R.string.metric_*`,**不再有** `metricDisplayName`(ledger Ruling R3)。 > - 增加睡眠专属区块(`StatBar` 分期条 + 入睡/起床时间),补齐 spec §6 的 `detail/sleep` 要求(ledger Ruling R4)。 +> - 本任务同时补齐 Task 12 缺的 ViewModel 测试、两个 `@Preview`、以及 `formatDecimal` 的集中化(ledger Ruling R29)。 -- [ ] **Step 1: 写 `MetricDetailViewModel.kt`** +- [ ] **Step 1: 写失败的测试** + +`app/src/test/java/com/placeholder/ringhealth/feature/detail/MetricDetailViewModelTest.kt` +(与 Task 8/9/10 的三个 ViewModel 测试同形;ledger Ruling R29 —— 四个 ViewModel 的覆盖度应当一致。 +本任务这个 ViewModel 的逻辑最重:`range` 变化要经 `flatMapLatest` 换掉上游流, +是全 App 唯一一处会**重新订阅**数据源的地方,不测说不过去): + +```kotlin +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 `不同指标的标题映射不同`() = runTest(dispatcher) { + // metricTitleRes 是 @Composable,JVM 测试里调不了;这里只锁「metric 被原样带进状态」, + // 保证详情页拿到的 metric 与导航参数一致(映射本身由编译器穷尽 when 保证)。 + val viewModel = MetricDetailViewModel(MetricType.SLEEP, MockHealthDataRepository()) + backgroundScope.launch { viewModel.uiState.collect {} } + + advanceUntilIdle() + + assertEquals(MetricType.SLEEP, viewModel.uiState.value.metric) + } +} +``` + +- [ ] **Step 1b: 运行测试,确认失败** + +```bash +cd /d/cherry/repos/ring-health-app && ./gradlew :app:testDebugUnitTest --tests "com.placeholder.ringhealth.feature.detail.MetricDetailViewModelTest" +``` + +Expected: `Unresolved reference: MetricDetailViewModel`,编译失败。 + +- [ ] **Step 1c: 给 `Formatters.kt` 补一个 `formatDecimal`** + +`StatLine` 要显示「平均 / 最高 / 最低」,需要保留一位小数。**不要在页面里直接写 +`String.format(...)`**:全计划 grep 下来,那样写的话会是**整个 App 唯一**一处绕过 +`core/common/Formatters.kt` 的数值格式化 —— 而该模块存在的全部理由就是「数值格式化集中在一处」 +(Task 5)。补一个函数进去,并给它配一条测试: + +在 `core/common/Formatters.kt` 末尾追加(文件顶部已有 `import java.util.Locale`): + +```kotlin +/** 数值 → 保留一位小数。详情页的统计摘要用(ledger Ruling R29)。 */ +fun formatDecimal(value: Double): String = String.format(Locale.CHINA, "%.1f", value) +``` + +在 `app/src/test/java/com/placeholder/ringhealth/core/common/FormattersTest.kt` 追加一条用例 +(沿用该文件已有的测试类与 import): + +```kotlin + @Test + fun `formatDecimal 保留一位小数`() { + assertEquals("72.0", formatDecimal(72.0)) + assertEquals("98.6", formatDecimal(98.56)) + assertEquals("0.0", formatDecimal(0.0)) + } +``` + +> 口径说明:**所有指标一律一位小数**,步数因此会显示成「平均 7512.3 步」—— +> 对连续量(心率/血氧/体温/压力/睡眠时长)这是对的,对步数只是精度过剩,不算错。 +> 按指标分别定精度属于 M3 的展示打磨,阶段一不做;已记入 ledger 的 parked observations。 + +- [ ] **Step 2: 写 `MetricDetailViewModel.kt`** ```kotlin package com.placeholder.ringhealth.feature.detail @@ -3940,7 +4088,7 @@ class MetricDetailViewModel( > 说明:`ringHealthApplication()` 是 Task 4 中定义在 `com.placeholder.ringhealth` 包下的 `CreationExtras` 扩展函数,需在本文件顶部 `import com.placeholder.ringhealth.ringHealthApplication`。 -- [ ] **Step 4: 追加文案到 `strings.xml`** +- [ ] **Step 3: 追加文案到 `strings.xml`** ```xml 近 7 天 @@ -3961,7 +4109,7 @@ class MetricDetailViewModel( > `detail_average` / `detail_max` / `detail_min` 是**纯标签**,不带 `%1$s`: > `StatLine` 把标签和数值分成左右两栏渲染,标签里再带占位符会原样打印出 `%1$s`。 -- [ ] **Step 3: 实现 `MetricDetailScreen.kt`(替换占位)** +- [ ] **Step 4: 实现 `MetricDetailScreen.kt`(替换占位)** ```kotlin package com.placeholder.ringhealth.feature.detail @@ -3969,7 +4117,6 @@ 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.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize @@ -3996,13 +4143,16 @@ 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 @@ -4014,9 +4164,12 @@ import com.placeholder.ringhealth.domain.model.MetricPoint import com.placeholder.ringhealth.domain.model.MetricType /** - * 指标详情页:范围切换 + 柱状趋势图 + 统计摘要。 + * 指标详情页(有状态外壳)。 + * + * 本页自带 `Scaffold` + `TopAppBar`(外层 `RingHealthApp` 也有一个 `Scaffold`, + * 它传入的 `innerPadding` 已用于 NavHost 的 `Modifier.padding`,底部栏因此保持可见)。 + * 页面内容全部在 `MetricDetailContent` 里,本函数只负责取状态与传回调(ledger Ruling R26)。 */ -@OptIn(ExperimentalMaterial3Api::class) @Composable fun MetricDetailScreen( metric: MetricType, @@ -4025,13 +4178,34 @@ fun MetricDetailScreen( 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(metric))) }, + title = { Text(text = stringResource(metricTitleRes(state.metric))) }, navigationIcon = { IconButton(onClick = onBack) { Icon( @@ -4058,7 +4232,7 @@ fun MetricDetailScreen( contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { - item { RangeSelector(current = state.range, onSelect = viewModel::switchRange) } + item { RangeSelector(current = state.range, onSelect = onSelectRange) } item { HealthCard { SectionHeader(title = stringResource(R.string.detail_trend)) @@ -4105,8 +4279,12 @@ private fun SleepStagesCard(points: List, modifier: Modifier = Modi 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)) } - val fallAsleep = points.firstOrNull()?.timestamp + // 入睡/起床演示值(ledger Ruling R29):Mock 的睡眠历史点是「每天一个」,首点是 7 天前、 + // 末点是现在。直接拿 first/last 当入睡/起床会显示成「睡了 7 天」,在验收时看着像 bug。 + // 这里改成以最后一个数据点为起床时刻、往前推 8 小时作为入睡时刻 —— 一张卡片讲「一晚」。 + // M3 接入真实睡眠数据后改为读取实际分期时段。 val wakeUp = points.lastOrNull()?.timestamp + val fallAsleep = wakeUp?.minus(DEMO_SLEEP_WINDOW_MILLIS) if (fallAsleep != null && wakeUp != null) { Row( modifier = Modifier.fillMaxWidth().padding(top = 12.dp), @@ -4127,6 +4305,9 @@ private fun SleepStagesCard(points: List, modifier: Modifier = Modi } } +/** 阶段一演示用的「一晚」时长:8 小时。M3 接入真实睡眠数据后删除。 */ +private const val DEMO_SLEEP_WINDOW_MILLIS = 8L * 60 * 60 * 1000 + /** 分期图例:小色块 + 名称。 */ @Composable private fun StageLegend(label: String, tint: Color, modifier: Modifier = Modifier) { @@ -4190,15 +4371,66 @@ private fun StatLine(label: String, value: Double, modifier: Modifier = Modifier horizontalArrangement = Arrangement.SpaceBetween, ) { Text(text = label, style = MaterialTheme.typography.bodyMedium, color = TextSecondary) - Text(text = String.format(java.util.Locale.CHINA, "%.1f", value), style = MaterialTheme.typography.bodyMedium) + // 数值格式化一律走 core/common/Formatters.kt(Step 1c),页面里不写 String.format: + // 那是格式串副本,改口径时要满仓库找。 + Text(text = formatDecimal(value), style = MaterialTheme.typography.bodyMedium) } } /** 空列表时返回 0.0,避免 NaN。 */ private fun List.averageOrZero(): Double = if (isEmpty()) 0.0 else average() + +/** 睡眠指标的样例数据,供下面两个 Preview 共用。 */ +private fun previewSleepPoints(): List { + val dayMillis = 24L * 60 * 60 * 1000 + val base = 1_700_000_000_000L + return List(7) { index -> MetricPoint(timestamp = base + index * dayMillis, value = 420.0 + 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 = previewSleepPoints(), + ), + onBack = {}, + onSelectRange = {}, + ) + } +} + +/** + * 非睡眠指标的详情页预览:走「只有趋势图 + 统计摘要」的分支。 + * 两个 Preview 各覆盖一个分支,是因为只看睡眠那个无法发现「分期卡片漏判 metric、所有指标都显示」这类回归。 + */ +@Preview(showBackground = true) +@Composable +private fun MetricDetailContentStepsPreview() { + RingHealthTheme { + MetricDetailContent( + state = MetricDetailUiState( + loading = false, + metric = MetricType.STEP, + range = DateRange.WEEK, + points = previewSleepPoints(), + ), + onBack = {}, + onSelectRange = {}, + ) + } +} ``` -- [ ] **Step 4: 在 `RingHealthApp.kt` 接入真实详情页** +- [ ] **Step 5: 在 `RingHealthApp.kt` 接入真实详情页** ```kotlin composable( @@ -4259,15 +4491,23 @@ Expected: `BUILD SUCCESSFUL`,2 项通过。 > 注意:详情页自带 `Scaffold` + `TopAppBar`,而外层 `RingHealthApp` 的 `Scaffold` 会传入 `innerPadding`(已用于 NavHost 的 `Modifier.padding`)。为避免底部导航重复遮罩,详情页在 NavHost 内会同时受外层 padding 影响——这是预期行为(底部栏保持可见)。 -- [ ] **Step 5: 构建 + 验证** +- [ ] **Step 6: 构建 + 单测** ```bash -cd /d/cherry/repos/ring-health-app && ./gradlew assembleDebug +cd /d/cherry/repos/ring-health-app && ./gradlew :app:testDebugUnitTest && ./gradlew assembleDebug ``` -Expected: 从首页点任一指标卡进入详情页;顶部标题为指标中文名;近 7 天/近 30 天可切换,柱状图数量随之变化(7 根 / 30 根);**睡眠详情额外显示分期卡片**(深睡/浅睡/清醒分段条与图例、入睡/起床时间),其他指标不显示;返回键回到首页。 +Expected: 两条命令均 `BUILD SUCCESSFUL`;全量测试 **21 项**通过 +(Task 10 之后是 15 项,本任务 +3 `MetricDetailViewModelTest` +2 `RoutesTest` +1 `FormattersTest.formatDecimal`)。 -- [ ] **Step 6: Commit** +界面上「从首页点任一指标卡进入详情页、标题为指标中文名、近 7 天/近 30 天可切换(柱状图 7 根 / 30 根)、 +睡眠详情额外显示分期卡片而其他指标不显示、返回键回到首页」这几条**不在本步验证** +(ledger Ruling R21:本机无模拟器与真机),顺延到 Task 14 Step 3 的人工验收清单。 +本步能确认的是两个 `@Preview`(睡眠分支 / 步数分支)能在 Android Studio 中渲染 —— 这是本机唯一能看见界面的手段。 + +> **不要用 `FROM-CACHE` 的绿当作证据。** 缓存命中时任务会标 `FROM-CACHE` 而不真正执行,看到就加 `--rerun`。 + +- [ ] **Step 7: Commit** ```bash git add -A && git commit -m "feat: 实现指标详情子页(周/月切换 + 趋势图)" @@ -4420,15 +4660,38 @@ Expected: 推送成功。 - Consumes: 前 13 个任务的全部产出 - Produces: 可交付的 M1 静态 App + 验收记录。 -- [ ] **Step 1: 全量单元测试** +- [ ] **Step 1: 清扫孤儿字符串资源(R25/R27/R30 的收尾)** + +Task 7 建了三个占位 Composable,Task 9/10/12 各自删掉一个,于是 `strings.xml` 里留下三条 +**没有任何调用点**的字符串。R25 当时就定了处置方式:**不在各自任务里删,留到最后一次性清扫** +(那时三个任务互相看不见对方的删除)。现在就是「最后」。先 grep 确认真的没人用,再删: + +```bash +cd /d/cherry/repos/ring-health-app +for k in placeholder_workout_history placeholder_device_settings placeholder_metric_detail; do + echo "--- $k"; grep -rn "R.string.$k" app/src || echo " (无调用点)" +done +``` + +三条都应当打印 `(无调用点)`。**若任何一条仍有调用点,就不要删它**,并在验收报告里说明原因。 +确认无调用点后,从 `res/values/strings.xml` 里删掉这三行。 + +> 只有这三条。`strings.xml` 里其余字符串**每一条都必须有调用点** —— 若 grep 出别的孤儿, +> 那是某个任务漏了接线,**先查清再决定**,不要顺手一起删(可能是某个页面本该显示却没显示的文案)。 + +- [ ] **Step 2: 全量单元测试** ```bash cd /d/cherry/repos/ring-health-app && ./gradlew :app:testDebugUnitTest ``` -Expected: 全部通过(Formatters 7 项 + MockDataProvider 5 项 + HomeViewModel 1 项)。 +Expected: 全部通过,共 **21 项**:`FormattersTest` 7 + `MockDataProviderTest` 5 + `HomeViewModelTest` 1 ++ `WorkoutViewModelTest` 1 + `DeviceViewModelTest` 2 + `MetricDetailViewModelTest` 3 + `RoutesTest` 2。 +(若实际数字与此不符,先查清是多了还是少了 —— 数字对不上通常意味着某个任务的测试没进来。 +本行原先写的是「Formatters 7 + MockDataProvider 5 + HomeViewModel 1 = 13」,两处都错: +当时 `FormattersTest` 实际只有 6 项,而 6+5+1=12 才是 Task 8 结束时的真实总数,ledger Ruling R29 已更正。) -- [ ] **Step 2: 干净构建** +- [ ] **Step 3: 干净构建** ```bash cd /d/cherry/repos/ring-health-app && ./gradlew clean assembleDebug @@ -4436,7 +4699,7 @@ cd /d/cherry/repos/ring-health-app && ./gradlew clean assembleDebug Expected: `BUILD SUCCESSFUL`,生成 `app/build/outputs/apk/debug/app-debug.apk`。 -- [ ] **Step 3: 真机/模拟器人工验收(逐项打勾)** +- [ ] **Step 4: 真机/模拟器人工验收(逐项打勾)** > **本步在本机当前状态下无法执行(ledger Ruling R21)。** 本机没有任何模拟器镜像,也没有连接真机 > (Android SDK 只装了 `platform-tools` / `platforms;android-36` / `build-tools;36.1.0`)。 @@ -4461,6 +4724,9 @@ Expected: `BUILD SUCCESSFUL`,生成 `app/build/outputs/apk/debug/app-debug.apk - [ ] 首页:点击任一指标卡进入详情页,标题为该指标中文名 - [ ] 详情页:近 7 天显示 7 根柱子,切到近 30 天显示 30 根柱子 - [ ] 睡眠详情额外显示分期卡片(深睡/浅睡/清醒分段条 + 入睡/起床时间),其他指标没有这张卡 +- [ ] 睡眠卡片的入睡/起床时间**读起来像「一晚」**(比如 入睡 22:41 / 起床 06:41), + 而不是跨了好几天的两个日期 —— 阶段一没有真实分期数据,这里按「最后一个数据点往前 8 小时」 + 演示(R29)。若显示成跨天,说明 `DEMO_SLEEP_WINDOW_MILLIS` 那段被改坏了 - [ ] 详情页:返回后回到首页 - [ ] 运动页:显示“开始运动”卡片 + 5 条运动记录 - [ ] 运动页:“查看全部”进入历史页,底部导航栏仍可见 @@ -4469,7 +4735,7 @@ Expected: `BUILD SUCCESSFUL`,生成 `app/build/outputs/apk/debug/app-debug.apk - [ ] 我的页:头像卡与两个分组入口正常显示 - [ ] 旋转屏幕/深色系统设置下不崩溃(深色样式不保证,但不得崩溃) -- [ ] **Step 4: 写验收记录 `repos/ring-health-app/docs/verification-m1.md`** +- [ ] **Step 5: 写验收记录 `repos/ring-health-app/docs/verification-m1.md`** ```markdown # M1 静态页面验收记录 @@ -4501,7 +4767,7 @@ Expected: `BUILD SUCCESSFUL`,生成 `app/build/outputs/apk/debug/app-debug.apk - 仅浅色模式 ``` -- [ ] **Step 5: 更新 App `README.md` 页面清单** +- [ ] **Step 6: 更新 App `README.md` 页面清单** 在 README 的“当前阶段”下补充: @@ -4519,7 +4785,7 @@ Expected: `BUILD SUCCESSFUL`,生成 `app/build/outputs/apk/debug/app-debug.apk | 指标详情(步数/心率/睡眠/血氧/压力/体温/血压) | `detail/{metric}` | ``` -- [ ] **Step 6: 提交并推送全部仓库** +- [ ] **Step 7: 提交并推送全部仓库** ```bash cd /d/cherry/repos/ring-health-app && git add -A && git commit -m "docs: 补充 M1 验收记录与页面清单" && git push