From 5c7afbbea33f46ecd8a5ecdd2ecbaee88f2ecdf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B4=94=E9=B2=81=E6=AD=A3?= <13780741225@163.com> Date: Thu, 10 Sep 2026 11:11:03 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20Task=209=20=E6=B4=BE=E5=8F=91=E5=89=8D?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E5=9B=9B=E5=A4=84=EF=BC=88ledger=20R25?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除两条无消费者的新字符串 workout_detail_duration / workout_history - WorkoutHistoryScreen 先判 loading 再判空,避免闪「暂无运动记录」 - 补 WorkoutViewModelTest(TDD),Task 9 步骤重编号为 Step 1..9 - 记录日期+时间插值不违反文案约束,避免复审误报 --- .../plans/2026-09-10-ring-health-app-m0-m1.md | 137 ++++++++++++++++-- 1 file changed, 121 insertions(+), 16 deletions(-) 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 3a2e02a..34175f9 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 @@ -2642,7 +2642,73 @@ git add -A && git commit -m "feat: 实现健康首页(今日大环 + 指标卡 - `class WorkoutViewModel(healthDataRepository)` 暴露 `uiState: StateFlow` - `@Composable fun WorkoutScreen(onOpenHistory: () -> Unit, modifier, viewModel)`、`@Composable fun WorkoutHistoryScreen(modifier, viewModel)` -- [ ] **Step 1: 写 `WorkoutViewModel.kt`** +- [ ] **Step 1: 写失败的测试** + +`app/src/test/java/com/placeholder/ringhealth/feature/workout/WorkoutViewModelTest.kt` +(与 Task 8 的 `HomeViewModelTest` 同形;ledger Ruling R25 —— 两个 ViewModel 的覆盖度应当一致, +不该一个测一个不测): + +```kotlin +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.Before +import org.junit.Test + +@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 的运动记录是倒序生成的,最近的在最前 + assertEquals("户外跑步", state.records.first().type) + } +} +``` + +- [ ] **Step 1b: 运行测试,确认失败** + +```bash +cd /d/cherry/repos/ring-health-app && ./gradlew :app:testDebugUnitTest --tests "com.placeholder.ringhealth.feature.workout.WorkoutViewModelTest" +``` + +Expected: `Unresolved reference: WorkoutViewModel`,编译失败。 + +- [ ] **Step 2: 写 `WorkoutViewModel.kt`** ```kotlin package com.placeholder.ringhealth.feature.workout @@ -2681,7 +2747,7 @@ class WorkoutViewModel( } ``` -- [ ] **Step 1b: 把 `WorkoutViewModel` 注册到 `AppViewModelProvider`** +- [ ] **Step 3: 把 `WorkoutViewModel` 注册到 `AppViewModelProvider`** 在 `AppViewModelProvider.kt` 的 `viewModelFactory { }` 内**追加**一个 `initializer`(ledger Ruling R2), 并在该文件顶部加 `import com.placeholder.ringhealth.feature.workout.WorkoutViewModel`: @@ -2694,21 +2760,29 @@ class WorkoutViewModel( } ``` -- [ ] **Step 2: 追加文案到 `strings.xml`** +- [ ] **Step 4: 追加文案到 `strings.xml`** ```xml 开始运动 选择运动类型,记录你的训练 最近运动 - 全部记录 暂无运动记录 %1$s · %2$s 公里 - 时长 %1$s 消耗 %1$s 千卡 查看全部 ``` -- [ ] **Step 3: 实现 `WorkoutScreen.kt`** +> **本任务不得新增无消费者的字符串资源(ledger Ruling R25)。** 计划早期版本的这份清单里还有两条 +> ——`workout_detail_duration`(`时长 %1$s`)与 `workout_history`(`全部记录`)—— 全计划 grep 下来 +> **一个调用点都没有**:时长已经在 `workout_item_summary` 里作为 `%1$s` 拼进去了, +> 而本任务的运动历史页没有标题栏,用不上「全部记录」。**不要把它们加回来**: +> 现在加就是新写死资源,将来若要给历史页加标题,那时再补一条也不迟。 +> 同理,`WorkoutHistoryPlaceholder` 被删后,`R.string.placeholder_workout_history` 就成了孤儿 —— +> **这个先留着不动**:`placeholder_device_settings` / `placeholder_metric_detail` 会由 Task 10 / Task 12 +> 各自留下同样的孤儿,与其三个任务各删一次(且互相之间看不到对方的删除), +> 不如留到最后一次性清扫。孤立的字符串资源只占几字节,不影响构建与运行。 + +- [ ] **Step 5: 实现 `WorkoutScreen.kt`** ```kotlin package com.placeholder.ringhealth.feature.workout @@ -2829,6 +2903,9 @@ internal fun WorkoutRecordCard(record: WorkoutRecord, modifier: Modifier = Modif ) { 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, @@ -2851,27 +2928,32 @@ internal fun WorkoutRecordCard(record: WorkoutRecord, modifier: Modifier = Modif } ``` -- [ ] **Step 4: 实现 `WorkoutHistoryScreen.kt`** +- [ ] **Step 6: 实现 `WorkoutHistoryScreen.kt`** ```kotlin 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.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.placeholder.ringhealth.AppViewModelProvider import com.placeholder.ringhealth.R -import androidx.compose.ui.unit.dp +import com.placeholder.ringhealth.core.designsystem.MetricGreen /** 运动历史子页:展示全部运动记录。 */ @Composable @@ -2880,8 +2962,27 @@ fun WorkoutHistoryScreen( viewModel: WorkoutViewModel = viewModel(factory = AppViewModelProvider.Factory), ) { val state by viewModel.uiState.collectAsStateWithLifecycle() + + // 必须先判 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()) { - Text(text = stringResource(R.string.workout_empty), style = MaterialTheme.typography.bodyMedium) + Box( + modifier = modifier.fillMaxSize().padding(16.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(R.string.workout_empty), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } return } LazyColumn( @@ -2896,7 +2997,7 @@ fun WorkoutHistoryScreen( } ``` -- [ ] **Step 5: 在 `RingHealthApp.kt` 中接入真实页面,并让子页保持所属 Tab 高亮** +- [ ] **Step 7: 在 `RingHealthApp.kt` 中接入真实页面,并让子页保持所属 Tab 高亮** 将 `composable(Routes.WORKOUT)` 与 `composable(Routes.WORKOUT_HISTORY)` 替换为: @@ -2970,15 +3071,19 @@ private fun tabRouteFor(route: String?): String? = when (route) { > 注意:`WorkoutViewModel` 在运动页与历史页各创建一次实例(导航到不同 destination 时独立作用域)。阶段一数据相同,可接受;M3 引入真实数据后如需共享,再提升作用域。 -- [ ] **Step 6: 构建 + 验证** +- [ ] **Step 8: 构建 + 单测** ```bash -cd /d/cherry/repos/ring-health-app && ./gradlew assembleDebug +cd /d/cherry/repos/ring-health-app && ./gradlew :app:testDebugUnitTest && ./gradlew assembleDebug ``` -Expected: 运动 Tab 显示“开始运动”卡片 + 5 条记录;“查看全部”跳转历史页。 +Expected: 两条命令均 `BUILD SUCCESSFUL`;`WorkoutViewModelTest` 由 Step 1b 的 RED 转为 GREEN, +全量测试 13 项通过(Task 8 之后是 12 项,本任务 +1)。 -- [ ] **Step 7: Commit** +界面上「运动 Tab 显示开始运动卡片 + 5 条记录」「查看全部跳转历史页」**不在本步验证** +(ledger Ruling R21:本机无模拟器与真机),顺延到 Task 14 Step 3 的人工验收清单。 + +- [ ] **Step 9: Commit** ```bash git add -A && git commit -m "feat: 实现运动页与运动历史子页" @@ -3064,7 +3169,7 @@ class DeviceViewModel( } ``` -- [ ] **Step 2: 追加文案到 `strings.xml`** +- [ ] **Step 4: 追加文案到 `strings.xml`** ```xml 我的设备 @@ -3630,7 +3735,7 @@ class MetricDetailViewModel( > 说明:`ringHealthApplication()` 是 Task 4 中定义在 `com.placeholder.ringhealth` 包下的 `CreationExtras` 扩展函数,需在本文件顶部 `import com.placeholder.ringhealth.ringHealthApplication`。 -- [ ] **Step 2: 追加文案到 `strings.xml`** +- [ ] **Step 4: 追加文案到 `strings.xml`** ```xml 近 7 天