Compare commits

..
2 Commits
Author SHA1 Message Date
cuiluzheng 5c7afbbea3 docs: Task 9 派发前修正四处(ledger R25)
- 删除两条无消费者的新字符串 workout_detail_duration / workout_history
- WorkoutHistoryScreen 先判 loading 再判空,避免闪「暂无运动记录」
- 补 WorkoutViewModelTest(TDD),Task 9 步骤重编号为 Step 1..9
- 记录日期+时间插值不违反文案约束,避免复审误报
2026-09-10 11:11:03 +08:00
cuiluzheng f23580de47 docs: 删除不可编译的 lazy.item import(ledger R24)
item 是 LazyListScope 的成员函数而非顶层扩展,无法 import;
该行单独即可导致 Unresolved reference。仅 items(List<T>) 需要 import。
由 Task 8 实现者 javap 反编译产物核实后推翻我的核验结论。
2026-09-10 11:04:13 +08:00
@@ -2310,7 +2310,6 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.item
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bloodtype
@@ -2575,6 +2574,12 @@ private fun HomeContentPreview() {
```
> 说明:`buildMetricCards` 是 `@Composable`(内部调用了 `stringResource`),而 `LazyColumn` 的 content lambda 不是 Composable 上下文,所以必须在进入 `LazyColumn` 之前取值——上面的 `val cards = ...` 就是为此。
>
> **不要 import `androidx.compose.foundation.lazy.item`(ledger Ruling R24)。** 计划早期版本在这份
> import 清单里写过它,那一行**单独就能让编译失败**:`item` 是 `LazyListScope` 的**成员函数**,
> 不是顶层扩展,成员函数永远不能 import(报 `Unresolved reference: 'item'`)。
> 只有 `items(List<T>)` 是 `LazyDsl` 里的顶层扩展,才需要 import。
> `item { }` 的调用点不受影响,走成员分派即可解析。
- [ ] **Step 7: 追加首页文案与指标名到 `strings.xml`**
@@ -2637,7 +2642,73 @@ git add -A && git commit -m "feat: 实现健康首页(今日大环 + 指标卡
- `class WorkoutViewModel(healthDataRepository)` 暴露 `uiState: StateFlow<WorkoutUiState>`
- `@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
@@ -2676,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`:
@@ -2689,21 +2760,29 @@ class WorkoutViewModel(
}
```
- [ ] **Step 2: 追加文案到 `strings.xml`**
- [ ] **Step 4: 追加文案到 `strings.xml`**
```xml
<string name="workout_start">开始运动</string>
<string name="workout_start_desc">选择运动类型,记录你的训练</string>
<string name="workout_recent">最近运动</string>
<string name="workout_history">全部记录</string>
<string name="workout_empty">暂无运动记录</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="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
package com.placeholder.ringhealth.feature.workout
@@ -2824,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,
@@ -2846,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
@@ -2875,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(
@@ -2891,7 +2997,7 @@ fun WorkoutHistoryScreen(
}
```
- [ ] **Step 5: 在 `RingHealthApp.kt` 中接入真实页面,并让子页保持所属 Tab 高亮**
- [ ] **Step 7: 在 `RingHealthApp.kt` 中接入真实页面,并让子页保持所属 Tab 高亮**
将 `composable(Routes.WORKOUT)` 与 `composable(Routes.WORKOUT_HISTORY)` 替换为:
@@ -2965,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: 实现运动页与运动历史子页"
@@ -3059,7 +3169,7 @@ class DeviceViewModel(
}
```
- [ ] **Step 2: 追加文案到 `strings.xml`**
- [ ] **Step 4: 追加文案到 `strings.xml`**
```xml
<string name="device_title">我的设备</string>
@@ -3625,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
<string name="detail_range_week">近 7 天</string>