Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c7afbbea3 | ||
|
|
f23580de47 |
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user