docs: Task 9 派发前修正四处(ledger R25)
- 删除两条无消费者的新字符串 workout_detail_duration / workout_history - WorkoutHistoryScreen 先判 loading 再判空,避免闪「暂无运动记录」 - 补 WorkoutViewModelTest(TDD),Task 9 步骤重编号为 Step 1..9 - 记录日期+时间插值不违反文案约束,避免复审误报
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user