docs: 修正 Task 10 计划(补 DeviceViewModel 测试与 Preview,修正步骤编号与文案清单)

This commit is contained in:
2026-09-10 11:23:05 +08:00
parent 5c82c38ef5
commit 7b111b33f3
@@ -3117,7 +3117,92 @@ git add -A && git commit -m "feat: 实现运动页与运动历史子页"
- `class DeviceViewModel(ringDeviceRepository)` 暴露 `uiState: StateFlow<DeviceUiState>`、`fun setFunctionEnabled(id: String, enabled: Boolean)` - `class DeviceViewModel(ringDeviceRepository)` 暴露 `uiState: StateFlow<DeviceUiState>`、`fun setFunctionEnabled(id: String, enabled: Boolean)`
- `@Composable fun DeviceScreen(onOpenSettings: () -> Unit, modifier, viewModel)`、`@Composable fun DeviceSettingsScreen(modifier, viewModel)` - `@Composable fun DeviceScreen(onOpenSettings: () -> Unit, modifier, viewModel)`、`@Composable fun DeviceSettingsScreen(modifier, viewModel)`
- [ ] **Step 1: 写 `DeviceViewModel.kt`** - [ ] **Step 1: 写失败的测试**
`app/src/test/java/com/placeholder/ringhealth/feature/device/DeviceViewModelTest.kt`
(与 Task 8 / Task 9 的两个 ViewModel 测试同形;ledger Ruling R27 —— 三个 ViewModel 的覆盖度应当一致):
```kotlin
package com.placeholder.ringhealth.feature.device
import com.placeholder.ringhealth.data.mock.MockRingDeviceRepository
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.Assert.assertTrue
import org.junit.Before
import org.junit.Test
/** 设备页 ViewModel 测试。 */
@OptIn(ExperimentalCoroutinesApi::class)
class DeviceViewModelTest {
private val dispatcher = StandardTestDispatcher()
@Before
fun setUp() {
Dispatchers.setMain(dispatcher)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun `输出设备状态与五个功能开关`() = runTest(dispatcher) {
val viewModel = DeviceViewModel(MockRingDeviceRepository())
backgroundScope.launch { viewModel.uiState.collect {} }
advanceUntilIdle()
val state = viewModel.uiState.value
assertEquals(78, state.device.batteryPercent)
assertEquals("1.0.5", state.device.firmwareVersion)
assertEquals(5, state.settings.size)
}
@Test
fun `setFunctionEnabled 只改目标项且可来回切换`() = runTest(dispatcher) {
val viewModel = DeviceViewModel(MockRingDeviceRepository())
backgroundScope.launch { viewModel.uiState.collect {} }
advanceUntilIdle()
// Mock 的 5 项里只有 stress 与 blood_pressure 初始为 false,其余 3 项为 true
// (见 MockDataProvider.defaultFunctionSettings)。切换 stress 后应为 4 项开启。
val target = "stress"
assertFalse(viewModel.uiState.value.settings.first { it.id == target }.enabled)
viewModel.setFunctionEnabled(target, true)
advanceUntilIdle()
assertTrue(viewModel.uiState.value.settings.first { it.id == target }.enabled)
// 关键:其它开关不受影响。只断言目标项变了,无法发现「整表被覆盖」这类回归。
assertEquals(4, viewModel.uiState.value.settings.count { it.enabled })
viewModel.setFunctionEnabled(target, false)
advanceUntilIdle()
assertFalse(viewModel.uiState.value.settings.first { it.id == target }.enabled)
}
}
```
- [ ] **Step 1b: 运行测试,确认失败**
```bash
cd /d/cherry/repos/ring-health-app && ./gradlew :app:testDebugUnitTest --tests "com.placeholder.ringhealth.feature.device.DeviceViewModelTest"
```
Expected: `Unresolved reference: DeviceViewModel`,编译失败。
- [ ] **Step 2: 写 `DeviceViewModel.kt`**
```kotlin ```kotlin
package com.placeholder.ringhealth.feature.device package com.placeholder.ringhealth.feature.device
@@ -3168,7 +3253,7 @@ class DeviceViewModel(
} }
``` ```
- [ ] **Step 1b: 把 `DeviceViewModel` 注册到 `AppViewModelProvider`** - [ ] **Step 3: 把 `DeviceViewModel` 注册到 `AppViewModelProvider`**
在 `AppViewModelProvider.kt` 的 `viewModelFactory { }` 内**追加**一个 `initializer`(ledger Ruling R2), 在 `AppViewModelProvider.kt` 的 `viewModelFactory { }` 内**追加**一个 `initializer`(ledger Ruling R2),
并在该文件顶部加 `import com.placeholder.ringhealth.feature.device.DeviceViewModel`: 并在该文件顶部加 `import com.placeholder.ringhealth.feature.device.DeviceViewModel`:
@@ -3184,25 +3269,28 @@ class DeviceViewModel(
- [ ] **Step 4: 追加文案到 `strings.xml`** - [ ] **Step 4: 追加文案到 `strings.xml`**
```xml ```xml
<string name="device_title">我的设备</string>
<string name="device_connected">已连接</string> <string name="device_connected">已连接</string>
<string name="device_disconnected">未连接</string> <string name="device_disconnected">未连接</string>
<string name="device_battery">电量 %1$d%%</string> <string name="device_battery">电量 %1$d%%</string>
<string name="device_firmware">固件版本 %1$s</string> <string name="device_firmware">固件版本 %1$s</string>
<string name="device_charging">充电中</string>
<string name="device_settings_title">功能设置</string> <string name="device_settings_title">功能设置</string>
<string name="device_find_ring">查找戒指</string>
<string name="device_find_ring_desc">让戒指震动以便寻找</string>
<string name="device_firmware_update">固件升级</string>
<string name="device_firmware_update_desc">检查并升级到最新固件</string>
<string name="device_wear_detection">佩戴检测</string>
<string name="device_wear_detection_desc">未佩戴时暂停测量</string>
<string name="device_more">更多设置</string> <string name="device_more">更多设置</string>
<string name="device_switch_on">已开启</string> <string name="device_switch_on">已开启</string>
<string name="device_switch_off">已关闭</string> <string name="device_switch_off">已关闭</string>
``` ```
- [ ] **Step 3: 实现 `DeviceScreen.kt`** > **本任务不得新增无消费者的字符串资源(ledger Ruling R27)。** 计划早期版本的这份清单有 16 条,
> 逐条 grep 调用点后砍到 8 条。被删的 8 条是
> `device_title`(`我的设备`)、`device_charging`(`充电中`)、
> `device_find_ring` / `device_find_ring_desc`、`device_firmware_update` / `device_firmware_update_desc`、
> `device_wear_detection` / `device_wear_detection_desc` —— 全计划**零调用点**。
> 它们看起来是照着小美运动健康的设备页草拟的(查找戒指 / 固件升级 / 佩戴检测三行入口),
> 但本任务实际实现的设备页只有「戒指信息卡 + 功能开关列表 + 更多设置入口」,
> 那三行从来没有被写进任何代码。**不要把它们加回来。**
> 若 M2 真要做「查找戒指」这类入口,那时再补文案 —— 提前写好只会留下 8 条永不渲染的字符串。
> (`device_settings_hint` 不在此列,它在 Step 5 单独追加,有调用点。)
- [ ] **Step 5: 实现 `DeviceScreen.kt`**
```kotlin ```kotlin
package com.placeholder.ringhealth.feature.device package com.placeholder.ringhealth.feature.device
@@ -3219,7 +3307,6 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bluetooth import androidx.compose.material.icons.filled.Bluetooth
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Watch import androidx.compose.material.icons.filled.Watch
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -3230,16 +3317,19 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment 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.tooling.preview.Preview
import androidx.compose.ui.unit.dp 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 com.placeholder.ringhealth.core.designsystem.MetricGreen import com.placeholder.ringhealth.core.designsystem.MetricGreen
import com.placeholder.ringhealth.core.designsystem.RingHealthTheme
import com.placeholder.ringhealth.core.designsystem.TextSecondary import com.placeholder.ringhealth.core.designsystem.TextSecondary
import com.placeholder.ringhealth.core.designsystem.component.HealthCard import com.placeholder.ringhealth.core.designsystem.component.HealthCard
import com.placeholder.ringhealth.core.designsystem.component.SectionHeader import com.placeholder.ringhealth.core.designsystem.component.SectionHeader
import com.placeholder.ringhealth.domain.model.DeviceFunctionSetting import com.placeholder.ringhealth.domain.model.DeviceFunctionSetting
import com.placeholder.ringhealth.domain.model.RingDeviceState
/** 设备(戒指管理)页。 */ /** 设备(戒指管理)页。 */
@Composable @Composable
@@ -3352,9 +3442,47 @@ internal fun SettingRow(setting: DeviceFunctionSetting, modifier: Modifier = Mod
} }
} }
} }
@Preview(showBackground = true)
@Composable
private fun DeviceContentPreview() {
RingHealthTheme {
DeviceContent(
state = DeviceUiState(
device = RingDeviceState(true, "RingHealth 智能戒指", 78, "1.0.5", false),
settings = previewSettings(),
),
onOpenSettings = {},
)
}
}
/**
* Preview 用的假开关列表。
*
* `internal` 而非 `private`:`DeviceSettingsScreen.kt` 的 Preview 也要用。
* 取值与 `MockDataProvider.defaultFunctionSettings()` 保持一致,
* 免得 Preview 里看到的和跑起来看到的不是同一份数据。
*/
internal fun previewSettings(): List<DeviceFunctionSetting> = listOf(
DeviceFunctionSetting("heart_rate", "自动心率监测", "每 10 分钟测量一次", true),
DeviceFunctionSetting("blood_oxygen", "自动血氧监测", "夜间持续监测", true),
DeviceFunctionSetting("sleep", "睡眠监测", "自动识别睡眠时段", true),
DeviceFunctionSetting("stress", "压力监测", "全天候压力评估", false),
DeviceFunctionSetting("blood_pressure", "血压监测", "需在 App 内手动校准", false),
)
``` ```
- [ ] **Step 4: 实现 `DeviceSettingsScreen.kt`(替换占位)** > `previewSettings()` 是**唯一**的例外:它是 `internal` 而不是 `private`,因为两个页面的 Preview 共用。
> 它只在 Preview 与 debug 构建里被调用,不进任何生产路径。
> **Preview 是本项目唯一可用的目视检查手段(ledger Ruling R21 / R26)**:本机没有模拟器镜像也没有真机,
> `assembleDebug` 只能证明「能编译」。因此每个页面都必须拆成「公开的有状态外壳 + `private` 的无状态
> `XContent` + 挂在 `XContent` 上的 `@Preview`」——只有无状态的那层才能喂假数据渲染。
> 两个页面的 Preview 共用同一个 `internal fun previewSettings()`(定义在 `DeviceScreen.kt` 末尾,
> 见本步代码块之后):`DeviceSettingsScreen.kt` 也要用它,`private` 在文件之间传不过去。
- [ ] **Step 6: 实现 `DeviceSettingsScreen.kt`(替换占位)**
```kotlin ```kotlin
package com.placeholder.ringhealth.feature.device package com.placeholder.ringhealth.feature.device
@@ -3370,18 +3498,21 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
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.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.tooling.preview.Preview
import androidx.compose.ui.unit.dp 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 com.placeholder.ringhealth.core.designsystem.MetricGreen import com.placeholder.ringhealth.core.designsystem.MetricGreen
import com.placeholder.ringhealth.core.designsystem.RingHealthTheme
import com.placeholder.ringhealth.core.designsystem.TextSecondary import com.placeholder.ringhealth.core.designsystem.TextSecondary
import com.placeholder.ringhealth.core.designsystem.component.HealthCard import com.placeholder.ringhealth.core.designsystem.component.HealthCard
import com.placeholder.ringhealth.core.designsystem.component.SectionHeader import com.placeholder.ringhealth.core.designsystem.component.SectionHeader
@@ -3396,13 +3527,32 @@ fun DeviceSettingsScreen(
viewModel: DeviceViewModel = viewModel(factory = AppViewModelProvider.Factory), viewModel: DeviceViewModel = viewModel(factory = AppViewModelProvider.Factory),
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
DeviceSettingsContent(
settings = state.settings,
onToggle = viewModel::setFunctionEnabled,
modifier = modifier,
)
}
/**
* 无状态设备设置内容。
* 与 `DeviceContent` 同样拆出独立函数,Preview 才能喂假数据渲染(ledger Ruling R26)。
* 注意回调签名是 `(id, enabled)` 而不是 `(DeviceFunctionSetting, Boolean)`:
* 无状态层不该知道「怎么改」,只负责把「改哪一项、改成什么」报上去。
*/
@Composable
private fun DeviceSettingsContent(
settings: List<DeviceFunctionSetting>,
onToggle: (String, Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
LazyColumn( LazyColumn(
modifier = modifier.fillMaxSize(), modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp), contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
item { SectionHeader(title = stringResource(R.string.device_settings_title)) } item { SectionHeader(title = stringResource(R.string.device_settings_title)) }
items(state.settings) { setting -> items(settings) { setting ->
HealthCard { HealthCard {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -3415,8 +3565,9 @@ fun DeviceSettingsScreen(
} }
Switch( Switch(
checked = setting.enabled, checked = setting.enabled,
onCheckedChange = { checked -> viewModel.setFunctionEnabled(setting.id, checked) }, onCheckedChange = { checked -> onToggle(setting.id, checked) },
colors = androidx.compose.material3.SwitchDefaults.colors(checkedTrackColor = MetricGreen), // SwitchDefaults 走正常 import,不写字面量全限定名(与其余文件的 import 风格一致)
colors = SwitchDefaults.colors(checkedTrackColor = MetricGreen),
) )
} }
} }
@@ -3431,15 +3582,23 @@ fun DeviceSettingsScreen(
} }
} }
} }
@Preview(showBackground = true)
@Composable
private fun DeviceSettingsContentPreview() {
RingHealthTheme {
DeviceSettingsContent(settings = previewSettings(), onToggle = { _, _ -> })
}
}
``` ```
- [ ] **Step 5: 追加提示文案到 `strings.xml`** - [ ] **Step 7: 追加提示文案到 `strings.xml`**
```xml ```xml
<string name="device_settings_hint">阶段一为演示开关,接入戒指后将同步到设备。</string> <string name="device_settings_hint">阶段一为演示开关,接入戒指后将同步到设备。</string>
``` ```
- [ ] **Step 6: 在 `RingHealthApp.kt` 接入真实页面** - [ ] **Step 8: 在 `RingHealthApp.kt` 接入真实页面**
```kotlin ```kotlin
composable(Routes.DEVICE) { composable(Routes.DEVICE) {
@@ -3452,15 +3611,22 @@ fun DeviceSettingsScreen(
删除 `DeviceSettingsPlaceholder` 的定义与 import。 删除 `DeviceSettingsPlaceholder` 的定义与 import。
- [ ] **Step 7: 构建 + 验证** - [ ] **Step 9: 构建 + 单测**
```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 显示戒指卡片(电量 78%、固件 1.0.5);“更多设置”进入后开关可切换且返回后状态保持。 Expected: 两条命令均 `BUILD SUCCESSFUL`;`DeviceViewModelTest` 由 Step 1b 的 RED 转为 GREEN,
全量测试 15 项通过(Task 9 之后是 13 项,本任务 +2)。
- [ ] **Step 8: Commit** 界面上「设备 Tab 显示戒指卡片(电量 78%、固件 1.0.5)」「更多设置进入后开关可切换且返回后状态保持」
这两条**不在本步验证**(ledger Ruling R21:本机无模拟器与真机),顺延到 Task 14 Step 3 的人工验收清单。
> **不要用 `FROM-CACHE` 的绿当作证据。** Task 9 的修复轮实测到:Gradle 构建缓存命中时
> 任务会直接标记为 `FROM-CACHE` 而不真正执行。若看到 `FROM-CACHE`,加 `--rerun` 重跑一次再下结论。
- [ ] **Step 10: Commit**
```bash ```bash
git add -A && git commit -m "feat: 实现设备页与设备设置子页(可交互开关)" git add -A && git commit -m "feat: 实现设备页与设备设置子页(可交互开关)"