docs: Task 9 派发前修正四处(ledger R25)

- 删除两条无消费者的新字符串 workout_detail_duration / workout_history
- WorkoutHistoryScreen 先判 loading 再判空,避免闪「暂无运动记录」
- 补 WorkoutViewModelTest(TDD),Task 9 步骤重编号为 Step 1..9
- 记录日期+时间插值不违反文案约束,避免复审误报
This commit is contained in:
2026-09-10 11:11:03 +08:00
parent f23580de47
commit 5c7afbbea3
@@ -2642,7 +2642,73 @@ git add -A && git commit -m "feat: 实现健康首页(今日大环 + 指标卡
- `class WorkoutViewModel(healthDataRepository)` 暴露 `uiState: StateFlow<WorkoutUiState>` - `class WorkoutViewModel(healthDataRepository)` 暴露 `uiState: StateFlow<WorkoutUiState>`
- `@Composable fun WorkoutScreen(onOpenHistory: () -> Unit, modifier, viewModel)`、`@Composable fun WorkoutHistoryScreen(modifier, viewModel)` - `@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 ```kotlin
package com.placeholder.ringhealth.feature.workout 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), 在 `AppViewModelProvider.kt` 的 `viewModelFactory { }` 内**追加**一个 `initializer`(ledger Ruling R2),
并在该文件顶部加 `import com.placeholder.ringhealth.feature.workout.WorkoutViewModel`: 并在该文件顶部加 `import com.placeholder.ringhealth.feature.workout.WorkoutViewModel`:
@@ -2694,21 +2760,29 @@ class WorkoutViewModel(
} }
``` ```
- [ ] **Step 2: 追加文案到 `strings.xml`** - [ ] **Step 4: 追加文案到 `strings.xml`**
```xml ```xml
<string name="workout_start">开始运动</string> <string name="workout_start">开始运动</string>
<string name="workout_start_desc">选择运动类型,记录你的训练</string> <string name="workout_start_desc">选择运动类型,记录你的训练</string>
<string name="workout_recent">最近运动</string> <string name="workout_recent">最近运动</string>
<string name="workout_history">全部记录</string>
<string name="workout_empty">暂无运动记录</string> <string name="workout_empty">暂无运动记录</string>
<string name="workout_item_summary">%1$s · %2$s 公里</string> <string name="workout_item_summary">%1$s · %2$s 公里</string>
<string name="workout_detail_duration">时长 %1$s</string>
<string name="workout_detail_calories">消耗 %1$s 千卡</string> <string name="workout_detail_calories">消耗 %1$s 千卡</string>
<string name="action_view_all">查看全部</string> <string name="action_view_all">查看全部</string>
``` ```
- [ ] **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 ```kotlin
package com.placeholder.ringhealth.feature.workout package com.placeholder.ringhealth.feature.workout
@@ -2829,6 +2903,9 @@ internal fun WorkoutRecordCard(record: WorkoutRecord, modifier: Modifier = Modif
) { ) {
Column { Column {
Text(text = record.type, style = MaterialTheme.typography.titleSmall) Text(text = record.type, style = MaterialTheme.typography.titleSmall)
// 日期 + 空格 + 时间。这里**不**违反「不硬编码文案」(ledger Ruling R25):
// 两个值都由 Formatters 做本地化格式化(SimpleDateFormat + Locale.CHINA),
// 中间的连接符是一个空格,不含任何需要翻译的文字;也没有第二份资源与它重复。
Text( Text(
text = "${formatDate(record.startTime)} ${formatClock(record.startTime)}", text = "${formatDate(record.startTime)} ${formatClock(record.startTime)}",
style = MaterialTheme.typography.bodySmall, 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 ```kotlin
package com.placeholder.ringhealth.feature.workout package com.placeholder.ringhealth.feature.workout
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.placeholder.ringhealth.AppViewModelProvider import com.placeholder.ringhealth.AppViewModelProvider
import com.placeholder.ringhealth.R import com.placeholder.ringhealth.R
import androidx.compose.ui.unit.dp import com.placeholder.ringhealth.core.designsystem.MetricGreen
/** 运动历史子页:展示全部运动记录。 */ /** 运动历史子页:展示全部运动记录。 */
@Composable @Composable
@@ -2880,8 +2962,27 @@ fun WorkoutHistoryScreen(
viewModel: WorkoutViewModel = viewModel(factory = AppViewModelProvider.Factory), viewModel: WorkoutViewModel = viewModel(factory = AppViewModelProvider.Factory),
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() 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()) { 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 return
} }
LazyColumn( LazyColumn(
@@ -2896,7 +2997,7 @@ fun WorkoutHistoryScreen(
} }
``` ```
- [ ] **Step 5: 在 `RingHealthApp.kt` 中接入真实页面,并让子页保持所属 Tab 高亮** - [ ] **Step 7: 在 `RingHealthApp.kt` 中接入真实页面,并让子页保持所属 Tab 高亮**
将 `composable(Routes.WORKOUT)` 与 `composable(Routes.WORKOUT_HISTORY)` 替换为: 将 `composable(Routes.WORKOUT)` 与 `composable(Routes.WORKOUT_HISTORY)` 替换为:
@@ -2970,15 +3071,19 @@ private fun tabRouteFor(route: String?): String? = when (route) {
> 注意:`WorkoutViewModel` 在运动页与历史页各创建一次实例(导航到不同 destination 时独立作用域)。阶段一数据相同,可接受;M3 引入真实数据后如需共享,再提升作用域。 > 注意:`WorkoutViewModel` 在运动页与历史页各创建一次实例(导航到不同 destination 时独立作用域)。阶段一数据相同,可接受;M3 引入真实数据后如需共享,再提升作用域。
- [ ] **Step 6: 构建 + 验证** - [ ] **Step 8: 构建 + 单测**
```bash ```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 ```bash
git add -A && git commit -m "feat: 实现运动页与运动历史子页" git add -A && git commit -m "feat: 实现运动页与运动历史子页"
@@ -3064,7 +3169,7 @@ class DeviceViewModel(
} }
``` ```
- [ ] **Step 2: 追加文案到 `strings.xml`** - [ ] **Step 4: 追加文案到 `strings.xml`**
```xml ```xml
<string name="device_title">我的设备</string> <string name="device_title">我的设备</string>
@@ -3630,7 +3735,7 @@ class MetricDetailViewModel(
> 说明:`ringHealthApplication()` 是 Task 4 中定义在 `com.placeholder.ringhealth` 包下的 `CreationExtras` 扩展函数,需在本文件顶部 `import com.placeholder.ringhealth.ringHealthApplication`。 > 说明:`ringHealthApplication()` 是 Task 4 中定义在 `com.placeholder.ringhealth` 包下的 `CreationExtras` 扩展函数,需在本文件顶部 `import com.placeholder.ringhealth.ringHealthApplication`。
- [ ] **Step 2: 追加文案到 `strings.xml`** - [ ] **Step 4: 追加文案到 `strings.xml`**
```xml ```xml
<string name="detail_range_week">近 7 天</string> <string name="detail_range_week">近 7 天</string>