diff --git a/superpowers/plans/2026-09-10-ring-health-app-m0-m1.md b/superpowers/plans/2026-09-10-ring-health-app-m0-m1.md index 39a8307..b36cdcb 100644 --- a/superpowers/plans/2026-09-10-ring-health-app-m0-m1.md +++ b/superpowers/plans/2026-09-10-ring-health-app-m0-m1.md @@ -3117,7 +3117,92 @@ git add -A && git commit -m "feat: 实现运动页与运动历史子页" - `class DeviceViewModel(ringDeviceRepository)` 暴露 `uiState: StateFlow`、`fun setFunctionEnabled(id: String, enabled: Boolean)` - `@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 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), 并在该文件顶部加 `import com.placeholder.ringhealth.feature.device.DeviceViewModel`: @@ -3184,25 +3269,28 @@ class DeviceViewModel( - [ ] **Step 4: 追加文案到 `strings.xml`** ```xml - 我的设备 已连接 未连接 电量 %1$d%% 固件版本 %1$s - 充电中 功能设置 - 查找戒指 - 让戒指震动以便寻找 - 固件升级 - 检查并升级到最新固件 - 佩戴检测 - 未佩戴时暂停测量 更多设置 已开启 已关闭 ``` -- [ ] **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 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.material.icons.Icons import androidx.compose.material.icons.filled.Bluetooth -import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Watch import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -3230,16 +3317,19 @@ 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.tooling.preview.Preview 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 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.component.HealthCard import com.placeholder.ringhealth.core.designsystem.component.SectionHeader import com.placeholder.ringhealth.domain.model.DeviceFunctionSetting +import com.placeholder.ringhealth.domain.model.RingDeviceState /** 设备(戒指管理)页。 */ @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 = 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 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.material3.MaterialTheme import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults 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.tooling.preview.Preview 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 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.component.HealthCard import com.placeholder.ringhealth.core.designsystem.component.SectionHeader @@ -3396,13 +3527,32 @@ fun DeviceSettingsScreen( viewModel: DeviceViewModel = viewModel(factory = AppViewModelProvider.Factory), ) { 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, + onToggle: (String, Boolean) -> Unit, + modifier: Modifier = Modifier, +) { LazyColumn( modifier = modifier.fillMaxSize(), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { item { SectionHeader(title = stringResource(R.string.device_settings_title)) } - items(state.settings) { setting -> + items(settings) { setting -> HealthCard { Row( modifier = Modifier.fillMaxWidth(), @@ -3415,8 +3565,9 @@ fun DeviceSettingsScreen( } Switch( checked = setting.enabled, - onCheckedChange = { checked -> viewModel.setFunctionEnabled(setting.id, checked) }, - colors = androidx.compose.material3.SwitchDefaults.colors(checkedTrackColor = MetricGreen), + onCheckedChange = { checked -> onToggle(setting.id, checked) }, + // 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 阶段一为演示开关,接入戒指后将同步到设备。 ``` -- [ ] **Step 6: 在 `RingHealthApp.kt` 接入真实页面** +- [ ] **Step 8: 在 `RingHealthApp.kt` 接入真实页面** ```kotlin composable(Routes.DEVICE) { @@ -3452,15 +3611,22 @@ fun DeviceSettingsScreen( 删除 `DeviceSettingsPlaceholder` 的定义与 import。 -- [ ] **Step 7: 构建 + 验证** +- [ ] **Step 9: 构建 + 单测** ```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 git add -A && git commit -m "feat: 实现设备页与设备设置子页(可交互开关)"