docs: 补 Task 12 的 ViewModel 测试与 Preview、修正 Task 14 测试数与孤儿字符串清扫
This commit is contained in:
@@ -3158,8 +3158,9 @@ class DeviceViewModelTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `输出设备状态与五个功能开关`() = runTest(dispatcher) {
|
fun `输出设备状态与五个功能开关,且仓库变化会回流到 UI 状态`() = runTest(dispatcher) {
|
||||||
val viewModel = DeviceViewModel(MockRingDeviceRepository())
|
val repository = MockRingDeviceRepository()
|
||||||
|
val viewModel = DeviceViewModel(repository)
|
||||||
backgroundScope.launch { viewModel.uiState.collect {} }
|
backgroundScope.launch { viewModel.uiState.collect {} }
|
||||||
|
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
@@ -3168,6 +3169,14 @@ class DeviceViewModelTest {
|
|||||||
assertEquals(78, state.device.batteryPercent)
|
assertEquals(78, state.device.batteryPercent)
|
||||||
assertEquals("1.0.5", state.device.firmwareVersion)
|
assertEquals("1.0.5", state.device.firmwareVersion)
|
||||||
assertEquals(5, state.settings.size)
|
assertEquals(5, state.settings.size)
|
||||||
|
|
||||||
|
// 上面三个值恰好与 stateIn 的 initialValue 相同(DeviceViewModel 的 initialValue
|
||||||
|
// 直接取仓库当前值),所以**单靠它们无法区分「combine 发射过」与「一直停在 initialValue」**
|
||||||
|
// (ledger Ruling R30,审查者指出)。这里绕开 ViewModel 直接改仓库,再断言状态跟着变 ——
|
||||||
|
// 只有 combine 真的在发射,这一条才可能成立。
|
||||||
|
repository.setFunctionEnabled("stress", true)
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertTrue(viewModel.uiState.value.settings.first { it.id == "stress" }.enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -3421,7 +3430,7 @@ private fun DeviceInfoCard(state: DeviceUiState, modifier: Modifier = Modifier)
|
|||||||
|
|
||||||
/** 单行设置项(只读展示,开关在详情页操作)。 */
|
/** 单行设置项(只读展示,开关在详情页操作)。 */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun SettingRow(setting: DeviceFunctionSetting, modifier: Modifier = Modifier) {
|
private fun SettingRow(setting: DeviceFunctionSetting, modifier: Modifier = Modifier) {
|
||||||
HealthCard(modifier = modifier) {
|
HealthCard(modifier = modifier) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
@@ -3473,8 +3482,10 @@ internal fun previewSettings(): List<DeviceFunctionSetting> = listOf(
|
|||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
> `previewSettings()` 是**唯一**的例外:它是 `internal` 而不是 `private`,因为两个页面的 Preview 共用。
|
> `previewSettings()` 是**唯一**被放宽到 `internal` 的函数(两个文件的 Preview 共用,`private` 传不过去),
|
||||||
> 它只在 Preview 与 debug 构建里被调用,不进任何生产路径。
|
> 文件里其余一切都保持 `private`。它只在 Preview 与 debug 构建里被调用,不进任何生产路径。
|
||||||
|
> —— 审查者指出 `SettingRow` 原本也写成了 `internal`,但它的消费者只有同文件的 `DeviceContent`,
|
||||||
|
> 属于无谓地放大暴露面,已改回 `private`(ledger Ruling R30)。
|
||||||
|
|
||||||
> **Preview 是本项目唯一可用的目视检查手段(ledger Ruling R21 / R26)**:本机没有模拟器镜像也没有真机,
|
> **Preview 是本项目唯一可用的目视检查手段(ledger Ruling R21 / R26)**:本机没有模拟器镜像也没有真机,
|
||||||
> `assembleDebug` 只能证明「能编译」。因此每个页面都必须拆成「公开的有状态外壳 + `private` 的无状态
|
> `assembleDebug` 只能证明「能编译」。因此每个页面都必须拆成「公开的有状态外壳 + `private` 的无状态
|
||||||
@@ -3516,6 +3527,7 @@ 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
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 设备设置子页:可交互的功能开关列表。
|
* 设备设置子页:可交互的功能开关列表。
|
||||||
@@ -3835,24 +3847,160 @@ git add -A && git commit -m "feat: 实现我的页"
|
|||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Create: `feature/detail/MetricDetailViewModel.kt`
|
- Create: `feature/detail/MetricDetailViewModel.kt`
|
||||||
|
- Create: `app/src/test/java/com/placeholder/ringhealth/feature/detail/MetricDetailViewModelTest.kt`
|
||||||
|
- Create: `app/src/test/java/com/placeholder/ringhealth/core/navigation/RoutesTest.kt`
|
||||||
- Modify: `feature/detail/MetricDetailScreen.kt`(替换占位)
|
- Modify: `feature/detail/MetricDetailScreen.kt`(替换占位)
|
||||||
- Modify: `core/navigation/RingHealthApp.kt`(接入真实详情页 + 支持返回)
|
- Modify: `core/navigation/RingHealthApp.kt`(接入真实详情页 + 支持返回)
|
||||||
|
- Modify: `core/common/Formatters.kt`(+1 个 `formatDecimal`)
|
||||||
|
- Modify: `app/src/test/java/com/placeholder/ringhealth/core/common/FormattersTest.kt`(+1 条用例)
|
||||||
- Modify: `res/values/strings.xml`
|
- Modify: `res/values/strings.xml`
|
||||||
|
|
||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
- Consumes: `HealthDataRepository`、`MetricBarChart`、`StatBar`、Task 8 加入的 `R.string.metric_*`
|
- Consumes: `HealthDataRepository`、`MetricBarChart`、`StatBar`、`StatSegment`、Task 8 加入的 `R.string.metric_*`
|
||||||
- Produces:
|
- Produces:
|
||||||
- `data class MetricDetailUiState(val loading: Boolean, val metric: MetricType, val range: DateRange, val points: List<MetricPoint>)`
|
- `data class MetricDetailUiState(val loading: Boolean, val metric: MetricType, val range: DateRange, val points: List<MetricPoint>)`
|
||||||
- `class MetricDetailViewModel(metric: MetricType, healthDataRepository)`,方法 `fun switchRange(range: DateRange)`
|
- `class MetricDetailViewModel(metric: MetricType, healthDataRepository)`,方法 `fun switchRange(range: DateRange)`
|
||||||
- `@Composable fun MetricDetailScreen(metric: MetricType, onBack: () -> Unit, modifier, viewModel: MetricDetailViewModel = viewModel(factory = MetricDetailViewModel.factory(metric)))`
|
- `@Composable fun MetricDetailScreen(metric: MetricType, onBack: () -> Unit, modifier, viewModel: MetricDetailViewModel = viewModel(factory = MetricDetailViewModel.factory(metric)))`
|
||||||
- `MetricDetailViewModel.factory(metric: MetricType): ViewModelProvider.Factory`(伴生对象)
|
- `MetricDetailViewModel.factory(metric: MetricType): ViewModelProvider.Factory`(伴生对象)
|
||||||
|
- `fun formatDecimal(value: Double): String`(`core/common/Formatters.kt`)
|
||||||
- `@Composable private fun metricTitleRes(type: MetricType): Int`(文件内私有)
|
- `@Composable private fun metricTitleRes(type: MetricType): Int`(文件内私有)
|
||||||
|
|
||||||
> **两点范围说明**:
|
> **范围说明**:
|
||||||
> - 标题用 `metricTitleRes` 映射到 `R.string.metric_*`,**不再有** `metricDisplayName`(ledger Ruling R3)。
|
> - 标题用 `metricTitleRes` 映射到 `R.string.metric_*`,**不再有** `metricDisplayName`(ledger Ruling R3)。
|
||||||
> - 增加睡眠专属区块(`StatBar` 分期条 + 入睡/起床时间),补齐 spec §6 的 `detail/sleep` 要求(ledger Ruling R4)。
|
> - 增加睡眠专属区块(`StatBar` 分期条 + 入睡/起床时间),补齐 spec §6 的 `detail/sleep` 要求(ledger Ruling R4)。
|
||||||
|
> - 本任务同时补齐 Task 12 缺的 ViewModel 测试、两个 `@Preview`、以及 `formatDecimal` 的集中化(ledger Ruling R29)。
|
||||||
|
|
||||||
- [ ] **Step 1: 写 `MetricDetailViewModel.kt`**
|
- [ ] **Step 1: 写失败的测试**
|
||||||
|
|
||||||
|
`app/src/test/java/com/placeholder/ringhealth/feature/detail/MetricDetailViewModelTest.kt`
|
||||||
|
(与 Task 8/9/10 的三个 ViewModel 测试同形;ledger Ruling R29 —— 四个 ViewModel 的覆盖度应当一致。
|
||||||
|
本任务这个 ViewModel 的逻辑最重:`range` 变化要经 `flatMapLatest` 换掉上游流,
|
||||||
|
是全 App 唯一一处会**重新订阅**数据源的地方,不测说不过去):
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
package com.placeholder.ringhealth.feature.detail
|
||||||
|
|
||||||
|
import com.placeholder.ringhealth.data.mock.MockHealthDataRepository
|
||||||
|
import com.placeholder.ringhealth.domain.model.DateRange
|
||||||
|
import com.placeholder.ringhealth.domain.model.MetricType
|
||||||
|
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
|
||||||
|
|
||||||
|
/** 指标详情 ViewModel 测试。 */
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class MetricDetailViewModelTest {
|
||||||
|
|
||||||
|
private val dispatcher = StandardTestDispatcher()
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
Dispatchers.setMain(dispatcher)
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
Dispatchers.resetMain()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `默认输出近 7 天的数据`() = runTest(dispatcher) {
|
||||||
|
val viewModel = MetricDetailViewModel(MetricType.HEART_RATE, MockHealthDataRepository())
|
||||||
|
backgroundScope.launch { viewModel.uiState.collect {} }
|
||||||
|
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
val state = viewModel.uiState.value
|
||||||
|
assertFalse(state.loading)
|
||||||
|
assertEquals(MetricType.HEART_RATE, state.metric)
|
||||||
|
assertEquals(DateRange.WEEK, state.range)
|
||||||
|
assertEquals(7, state.points.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `switchRange 切到月度后变成 30 个点`() = runTest(dispatcher) {
|
||||||
|
val viewModel = MetricDetailViewModel(MetricType.HEART_RATE, MockHealthDataRepository())
|
||||||
|
backgroundScope.launch { viewModel.uiState.collect {} }
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
viewModel.switchRange(DateRange.MONTH)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
val state = viewModel.uiState.value
|
||||||
|
// 三个断言缺一不可:只断言点数,无法发现「上游没重新订阅、只是把旧数据换了标签」;
|
||||||
|
// 只断言 range,无法发现 flatMapLatest 没生效。
|
||||||
|
assertEquals(DateRange.MONTH, state.range)
|
||||||
|
assertEquals(30, state.points.size)
|
||||||
|
assertEquals(MetricType.HEART_RATE, state.metric)
|
||||||
|
|
||||||
|
viewModel.switchRange(DateRange.WEEK)
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertEquals(7, viewModel.uiState.value.points.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `不同指标的标题映射不同`() = runTest(dispatcher) {
|
||||||
|
// metricTitleRes 是 @Composable,JVM 测试里调不了;这里只锁「metric 被原样带进状态」,
|
||||||
|
// 保证详情页拿到的 metric 与导航参数一致(映射本身由编译器穷尽 when 保证)。
|
||||||
|
val viewModel = MetricDetailViewModel(MetricType.SLEEP, MockHealthDataRepository())
|
||||||
|
backgroundScope.launch { viewModel.uiState.collect {} }
|
||||||
|
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertEquals(MetricType.SLEEP, viewModel.uiState.value.metric)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 1b: 运行测试,确认失败**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /d/cherry/repos/ring-health-app && ./gradlew :app:testDebugUnitTest --tests "com.placeholder.ringhealth.feature.detail.MetricDetailViewModelTest"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `Unresolved reference: MetricDetailViewModel`,编译失败。
|
||||||
|
|
||||||
|
- [ ] **Step 1c: 给 `Formatters.kt` 补一个 `formatDecimal`**
|
||||||
|
|
||||||
|
`StatLine` 要显示「平均 / 最高 / 最低」,需要保留一位小数。**不要在页面里直接写
|
||||||
|
`String.format(...)`**:全计划 grep 下来,那样写的话会是**整个 App 唯一**一处绕过
|
||||||
|
`core/common/Formatters.kt` 的数值格式化 —— 而该模块存在的全部理由就是「数值格式化集中在一处」
|
||||||
|
(Task 5)。补一个函数进去,并给它配一条测试:
|
||||||
|
|
||||||
|
在 `core/common/Formatters.kt` 末尾追加(文件顶部已有 `import java.util.Locale`):
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
/** 数值 → 保留一位小数。详情页的统计摘要用(ledger Ruling R29)。 */
|
||||||
|
fun formatDecimal(value: Double): String = String.format(Locale.CHINA, "%.1f", value)
|
||||||
|
```
|
||||||
|
|
||||||
|
在 `app/src/test/java/com/placeholder/ringhealth/core/common/FormattersTest.kt` 追加一条用例
|
||||||
|
(沿用该文件已有的测试类与 import):
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Test
|
||||||
|
fun `formatDecimal 保留一位小数`() {
|
||||||
|
assertEquals("72.0", formatDecimal(72.0))
|
||||||
|
assertEquals("98.6", formatDecimal(98.56))
|
||||||
|
assertEquals("0.0", formatDecimal(0.0))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> 口径说明:**所有指标一律一位小数**,步数因此会显示成「平均 7512.3 步」——
|
||||||
|
> 对连续量(心率/血氧/体温/压力/睡眠时长)这是对的,对步数只是精度过剩,不算错。
|
||||||
|
> 按指标分别定精度属于 M3 的展示打磨,阶段一不做;已记入 ledger 的 parked observations。
|
||||||
|
|
||||||
|
- [ ] **Step 2: 写 `MetricDetailViewModel.kt`**
|
||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
package com.placeholder.ringhealth.feature.detail
|
package com.placeholder.ringhealth.feature.detail
|
||||||
@@ -3940,7 +4088,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 4: 追加文案到 `strings.xml`**
|
- [ ] **Step 3: 追加文案到 `strings.xml`**
|
||||||
|
|
||||||
```xml
|
```xml
|
||||||
<string name="detail_range_week">近 7 天</string>
|
<string name="detail_range_week">近 7 天</string>
|
||||||
@@ -3961,7 +4109,7 @@ class MetricDetailViewModel(
|
|||||||
> `detail_average` / `detail_max` / `detail_min` 是**纯标签**,不带 `%1$s`:
|
> `detail_average` / `detail_max` / `detail_min` 是**纯标签**,不带 `%1$s`:
|
||||||
> `StatLine` 把标签和数值分成左右两栏渲染,标签里再带占位符会原样打印出 `%1$s`。
|
> `StatLine` 把标签和数值分成左右两栏渲染,标签里再带占位符会原样打印出 `%1$s`。
|
||||||
|
|
||||||
- [ ] **Step 3: 实现 `MetricDetailScreen.kt`(替换占位)**
|
- [ ] **Step 4: 实现 `MetricDetailScreen.kt`(替换占位)**
|
||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
package com.placeholder.ringhealth.feature.detail
|
package com.placeholder.ringhealth.feature.detail
|
||||||
@@ -3969,7 +4117,6 @@ package com.placeholder.ringhealth.feature.detail
|
|||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
@@ -3996,13 +4143,16 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
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.R
|
import com.placeholder.ringhealth.R
|
||||||
import com.placeholder.ringhealth.core.common.formatClock
|
import com.placeholder.ringhealth.core.common.formatClock
|
||||||
|
import com.placeholder.ringhealth.core.common.formatDecimal
|
||||||
import com.placeholder.ringhealth.core.designsystem.MetricBlue
|
import com.placeholder.ringhealth.core.designsystem.MetricBlue
|
||||||
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.MetricBarChart
|
import com.placeholder.ringhealth.core.designsystem.component.MetricBarChart
|
||||||
@@ -4014,9 +4164,12 @@ import com.placeholder.ringhealth.domain.model.MetricPoint
|
|||||||
import com.placeholder.ringhealth.domain.model.MetricType
|
import com.placeholder.ringhealth.domain.model.MetricType
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 指标详情页:范围切换 + 柱状趋势图 + 统计摘要。
|
* 指标详情页(有状态外壳)。
|
||||||
|
*
|
||||||
|
* 本页自带 `Scaffold` + `TopAppBar`(外层 `RingHealthApp` 也有一个 `Scaffold`,
|
||||||
|
* 它传入的 `innerPadding` 已用于 NavHost 的 `Modifier.padding`,底部栏因此保持可见)。
|
||||||
|
* 页面内容全部在 `MetricDetailContent` 里,本函数只负责取状态与传回调(ledger Ruling R26)。
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MetricDetailScreen(
|
fun MetricDetailScreen(
|
||||||
metric: MetricType,
|
metric: MetricType,
|
||||||
@@ -4025,13 +4178,34 @@ fun MetricDetailScreen(
|
|||||||
viewModel: MetricDetailViewModel = viewModel(factory = MetricDetailViewModel.factory(metric)),
|
viewModel: MetricDetailViewModel = viewModel(factory = MetricDetailViewModel.factory(metric)),
|
||||||
) {
|
) {
|
||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
MetricDetailContent(
|
||||||
|
state = state,
|
||||||
|
onBack = onBack,
|
||||||
|
onSelectRange = viewModel::switchRange,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 无状态指标详情内容:范围切换 + 柱状趋势图 + 统计摘要(+ 睡眠分期)。
|
||||||
|
*
|
||||||
|
* 标题取自 `state.metric` 而不是外面传进来的 `metric` —— 无状态层只有 `state` 一个数据来源,
|
||||||
|
* 免得出现「标题写心率、数据画睡眠」这种两者不一致的可能。
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun MetricDetailContent(
|
||||||
|
state: MetricDetailUiState,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onSelectRange: (DateRange) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
containerColor = MaterialTheme.colorScheme.background,
|
containerColor = MaterialTheme.colorScheme.background,
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text(text = stringResource(metricTitleRes(metric))) },
|
title = { Text(text = stringResource(metricTitleRes(state.metric))) },
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = onBack) {
|
IconButton(onClick = onBack) {
|
||||||
Icon(
|
Icon(
|
||||||
@@ -4058,7 +4232,7 @@ fun MetricDetailScreen(
|
|||||||
contentPadding = PaddingValues(16.dp),
|
contentPadding = PaddingValues(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
item { RangeSelector(current = state.range, onSelect = viewModel::switchRange) }
|
item { RangeSelector(current = state.range, onSelect = onSelectRange) }
|
||||||
item {
|
item {
|
||||||
HealthCard {
|
HealthCard {
|
||||||
SectionHeader(title = stringResource(R.string.detail_trend))
|
SectionHeader(title = stringResource(R.string.detail_trend))
|
||||||
@@ -4105,8 +4279,12 @@ private fun SleepStagesCard(points: List<MetricPoint>, modifier: Modifier = Modi
|
|||||||
StageLegend(label = stringResource(R.string.detail_sleep_light), tint = MetricBlue.copy(alpha = 0.45f))
|
StageLegend(label = stringResource(R.string.detail_sleep_light), tint = MetricBlue.copy(alpha = 0.45f))
|
||||||
StageLegend(label = stringResource(R.string.detail_sleep_awake), tint = MetricBlue.copy(alpha = 0.2f))
|
StageLegend(label = stringResource(R.string.detail_sleep_awake), tint = MetricBlue.copy(alpha = 0.2f))
|
||||||
}
|
}
|
||||||
val fallAsleep = points.firstOrNull()?.timestamp
|
// 入睡/起床演示值(ledger Ruling R29):Mock 的睡眠历史点是「每天一个」,首点是 7 天前、
|
||||||
|
// 末点是现在。直接拿 first/last 当入睡/起床会显示成「睡了 7 天」,在验收时看着像 bug。
|
||||||
|
// 这里改成以最后一个数据点为起床时刻、往前推 8 小时作为入睡时刻 —— 一张卡片讲「一晚」。
|
||||||
|
// M3 接入真实睡眠数据后改为读取实际分期时段。
|
||||||
val wakeUp = points.lastOrNull()?.timestamp
|
val wakeUp = points.lastOrNull()?.timestamp
|
||||||
|
val fallAsleep = wakeUp?.minus(DEMO_SLEEP_WINDOW_MILLIS)
|
||||||
if (fallAsleep != null && wakeUp != null) {
|
if (fallAsleep != null && wakeUp != null) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
|
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
|
||||||
@@ -4127,6 +4305,9 @@ private fun SleepStagesCard(points: List<MetricPoint>, modifier: Modifier = Modi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 阶段一演示用的「一晚」时长:8 小时。M3 接入真实睡眠数据后删除。 */
|
||||||
|
private const val DEMO_SLEEP_WINDOW_MILLIS = 8L * 60 * 60 * 1000
|
||||||
|
|
||||||
/** 分期图例:小色块 + 名称。 */
|
/** 分期图例:小色块 + 名称。 */
|
||||||
@Composable
|
@Composable
|
||||||
private fun StageLegend(label: String, tint: Color, modifier: Modifier = Modifier) {
|
private fun StageLegend(label: String, tint: Color, modifier: Modifier = Modifier) {
|
||||||
@@ -4190,15 +4371,66 @@ private fun StatLine(label: String, value: Double, modifier: Modifier = Modifier
|
|||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
) {
|
) {
|
||||||
Text(text = label, style = MaterialTheme.typography.bodyMedium, color = TextSecondary)
|
Text(text = label, style = MaterialTheme.typography.bodyMedium, color = TextSecondary)
|
||||||
Text(text = String.format(java.util.Locale.CHINA, "%.1f", value), style = MaterialTheme.typography.bodyMedium)
|
// 数值格式化一律走 core/common/Formatters.kt(Step 1c),页面里不写 String.format:
|
||||||
|
// 那是格式串副本,改口径时要满仓库找。
|
||||||
|
Text(text = formatDecimal(value), style = MaterialTheme.typography.bodyMedium)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 空列表时返回 0.0,避免 NaN。 */
|
/** 空列表时返回 0.0,避免 NaN。 */
|
||||||
private fun List<Double>.averageOrZero(): Double = if (isEmpty()) 0.0 else average()
|
private fun List<Double>.averageOrZero(): Double = if (isEmpty()) 0.0 else average()
|
||||||
|
|
||||||
|
/** 睡眠指标的样例数据,供下面两个 Preview 共用。 */
|
||||||
|
private fun previewSleepPoints(): List<MetricPoint> {
|
||||||
|
val dayMillis = 24L * 60 * 60 * 1000
|
||||||
|
val base = 1_700_000_000_000L
|
||||||
|
return List(7) { index -> MetricPoint(timestamp = base + index * dayMillis, value = 420.0 + index * 12) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 睡眠指标的详情页预览:走 `metric == SLEEP` 分支,带分期卡片。
|
||||||
|
* 取 SLEEP 不是随便挑的 —— 睡眠是唯一有专属区块的指标,预览它才能看见 `SleepStagesCard`。
|
||||||
|
*/
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
private fun MetricDetailContentSleepPreview() {
|
||||||
|
RingHealthTheme {
|
||||||
|
MetricDetailContent(
|
||||||
|
state = MetricDetailUiState(
|
||||||
|
loading = false,
|
||||||
|
metric = MetricType.SLEEP,
|
||||||
|
range = DateRange.WEEK,
|
||||||
|
points = previewSleepPoints(),
|
||||||
|
),
|
||||||
|
onBack = {},
|
||||||
|
onSelectRange = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 非睡眠指标的详情页预览:走「只有趋势图 + 统计摘要」的分支。
|
||||||
|
* 两个 Preview 各覆盖一个分支,是因为只看睡眠那个无法发现「分期卡片漏判 metric、所有指标都显示」这类回归。
|
||||||
|
*/
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
private fun MetricDetailContentStepsPreview() {
|
||||||
|
RingHealthTheme {
|
||||||
|
MetricDetailContent(
|
||||||
|
state = MetricDetailUiState(
|
||||||
|
loading = false,
|
||||||
|
metric = MetricType.STEP,
|
||||||
|
range = DateRange.WEEK,
|
||||||
|
points = previewSleepPoints(),
|
||||||
|
),
|
||||||
|
onBack = {},
|
||||||
|
onSelectRange = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 4: 在 `RingHealthApp.kt` 接入真实详情页**
|
- [ ] **Step 5: 在 `RingHealthApp.kt` 接入真实详情页**
|
||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
composable(
|
composable(
|
||||||
@@ -4259,15 +4491,23 @@ Expected: `BUILD SUCCESSFUL`,2 项通过。
|
|||||||
|
|
||||||
> 注意:详情页自带 `Scaffold` + `TopAppBar`,而外层 `RingHealthApp` 的 `Scaffold` 会传入 `innerPadding`(已用于 NavHost 的 `Modifier.padding`)。为避免底部导航重复遮罩,详情页在 NavHost 内会同时受外层 padding 影响——这是预期行为(底部栏保持可见)。
|
> 注意:详情页自带 `Scaffold` + `TopAppBar`,而外层 `RingHealthApp` 的 `Scaffold` 会传入 `innerPadding`(已用于 NavHost 的 `Modifier.padding`)。为避免底部导航重复遮罩,详情页在 NavHost 内会同时受外层 padding 影响——这是预期行为(底部栏保持可见)。
|
||||||
|
|
||||||
- [ ] **Step 5: 构建 + 验证**
|
- [ ] **Step 6: 构建 + 单测**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /d/cherry/repos/ring-health-app && ./gradlew assembleDebug
|
cd /d/cherry/repos/ring-health-app && ./gradlew :app:testDebugUnitTest && ./gradlew assembleDebug
|
||||||
```
|
```
|
||||||
|
|
||||||
Expected: 从首页点任一指标卡进入详情页;顶部标题为指标中文名;近 7 天/近 30 天可切换,柱状图数量随之变化(7 根 / 30 根);**睡眠详情额外显示分期卡片**(深睡/浅睡/清醒分段条与图例、入睡/起床时间),其他指标不显示;返回键回到首页。
|
Expected: 两条命令均 `BUILD SUCCESSFUL`;全量测试 **21 项**通过
|
||||||
|
(Task 10 之后是 15 项,本任务 +3 `MetricDetailViewModelTest` +2 `RoutesTest` +1 `FormattersTest.formatDecimal`)。
|
||||||
|
|
||||||
- [ ] **Step 6: Commit**
|
界面上「从首页点任一指标卡进入详情页、标题为指标中文名、近 7 天/近 30 天可切换(柱状图 7 根 / 30 根)、
|
||||||
|
睡眠详情额外显示分期卡片而其他指标不显示、返回键回到首页」这几条**不在本步验证**
|
||||||
|
(ledger Ruling R21:本机无模拟器与真机),顺延到 Task 14 Step 3 的人工验收清单。
|
||||||
|
本步能确认的是两个 `@Preview`(睡眠分支 / 步数分支)能在 Android Studio 中渲染 —— 这是本机唯一能看见界面的手段。
|
||||||
|
|
||||||
|
> **不要用 `FROM-CACHE` 的绿当作证据。** 缓存命中时任务会标 `FROM-CACHE` 而不真正执行,看到就加 `--rerun`。
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add -A && git commit -m "feat: 实现指标详情子页(周/月切换 + 趋势图)"
|
git add -A && git commit -m "feat: 实现指标详情子页(周/月切换 + 趋势图)"
|
||||||
@@ -4420,15 +4660,38 @@ Expected: 推送成功。
|
|||||||
- Consumes: 前 13 个任务的全部产出
|
- Consumes: 前 13 个任务的全部产出
|
||||||
- Produces: 可交付的 M1 静态 App + 验收记录。
|
- Produces: 可交付的 M1 静态 App + 验收记录。
|
||||||
|
|
||||||
- [ ] **Step 1: 全量单元测试**
|
- [ ] **Step 1: 清扫孤儿字符串资源(R25/R27/R30 的收尾)**
|
||||||
|
|
||||||
|
Task 7 建了三个占位 Composable,Task 9/10/12 各自删掉一个,于是 `strings.xml` 里留下三条
|
||||||
|
**没有任何调用点**的字符串。R25 当时就定了处置方式:**不在各自任务里删,留到最后一次性清扫**
|
||||||
|
(那时三个任务互相看不见对方的删除)。现在就是「最后」。先 grep 确认真的没人用,再删:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /d/cherry/repos/ring-health-app
|
||||||
|
for k in placeholder_workout_history placeholder_device_settings placeholder_metric_detail; do
|
||||||
|
echo "--- $k"; grep -rn "R.string.$k" app/src || echo " (无调用点)"
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
三条都应当打印 `(无调用点)`。**若任何一条仍有调用点,就不要删它**,并在验收报告里说明原因。
|
||||||
|
确认无调用点后,从 `res/values/strings.xml` 里删掉这三行。
|
||||||
|
|
||||||
|
> 只有这三条。`strings.xml` 里其余字符串**每一条都必须有调用点** —— 若 grep 出别的孤儿,
|
||||||
|
> 那是某个任务漏了接线,**先查清再决定**,不要顺手一起删(可能是某个页面本该显示却没显示的文案)。
|
||||||
|
|
||||||
|
- [ ] **Step 2: 全量单元测试**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /d/cherry/repos/ring-health-app && ./gradlew :app:testDebugUnitTest
|
cd /d/cherry/repos/ring-health-app && ./gradlew :app:testDebugUnitTest
|
||||||
```
|
```
|
||||||
|
|
||||||
Expected: 全部通过(Formatters 7 项 + MockDataProvider 5 项 + HomeViewModel 1 项)。
|
Expected: 全部通过,共 **21 项**:`FormattersTest` 7 + `MockDataProviderTest` 5 + `HomeViewModelTest` 1
|
||||||
|
+ `WorkoutViewModelTest` 1 + `DeviceViewModelTest` 2 + `MetricDetailViewModelTest` 3 + `RoutesTest` 2。
|
||||||
|
(若实际数字与此不符,先查清是多了还是少了 —— 数字对不上通常意味着某个任务的测试没进来。
|
||||||
|
本行原先写的是「Formatters 7 + MockDataProvider 5 + HomeViewModel 1 = 13」,两处都错:
|
||||||
|
当时 `FormattersTest` 实际只有 6 项,而 6+5+1=12 才是 Task 8 结束时的真实总数,ledger Ruling R29 已更正。)
|
||||||
|
|
||||||
- [ ] **Step 2: 干净构建**
|
- [ ] **Step 3: 干净构建**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /d/cherry/repos/ring-health-app && ./gradlew clean assembleDebug
|
cd /d/cherry/repos/ring-health-app && ./gradlew clean assembleDebug
|
||||||
@@ -4436,7 +4699,7 @@ cd /d/cherry/repos/ring-health-app && ./gradlew clean assembleDebug
|
|||||||
|
|
||||||
Expected: `BUILD SUCCESSFUL`,生成 `app/build/outputs/apk/debug/app-debug.apk`。
|
Expected: `BUILD SUCCESSFUL`,生成 `app/build/outputs/apk/debug/app-debug.apk`。
|
||||||
|
|
||||||
- [ ] **Step 3: 真机/模拟器人工验收(逐项打勾)**
|
- [ ] **Step 4: 真机/模拟器人工验收(逐项打勾)**
|
||||||
|
|
||||||
> **本步在本机当前状态下无法执行(ledger Ruling R21)。** 本机没有任何模拟器镜像,也没有连接真机
|
> **本步在本机当前状态下无法执行(ledger Ruling R21)。** 本机没有任何模拟器镜像,也没有连接真机
|
||||||
> (Android SDK 只装了 `platform-tools` / `platforms;android-36` / `build-tools;36.1.0`)。
|
> (Android SDK 只装了 `platform-tools` / `platforms;android-36` / `build-tools;36.1.0`)。
|
||||||
@@ -4461,6 +4724,9 @@ Expected: `BUILD SUCCESSFUL`,生成 `app/build/outputs/apk/debug/app-debug.apk
|
|||||||
- [ ] 首页:点击任一指标卡进入详情页,标题为该指标中文名
|
- [ ] 首页:点击任一指标卡进入详情页,标题为该指标中文名
|
||||||
- [ ] 详情页:近 7 天显示 7 根柱子,切到近 30 天显示 30 根柱子
|
- [ ] 详情页:近 7 天显示 7 根柱子,切到近 30 天显示 30 根柱子
|
||||||
- [ ] 睡眠详情额外显示分期卡片(深睡/浅睡/清醒分段条 + 入睡/起床时间),其他指标没有这张卡
|
- [ ] 睡眠详情额外显示分期卡片(深睡/浅睡/清醒分段条 + 入睡/起床时间),其他指标没有这张卡
|
||||||
|
- [ ] 睡眠卡片的入睡/起床时间**读起来像「一晚」**(比如 入睡 22:41 / 起床 06:41),
|
||||||
|
而不是跨了好几天的两个日期 —— 阶段一没有真实分期数据,这里按「最后一个数据点往前 8 小时」
|
||||||
|
演示(R29)。若显示成跨天,说明 `DEMO_SLEEP_WINDOW_MILLIS` 那段被改坏了
|
||||||
- [ ] 详情页:返回后回到首页
|
- [ ] 详情页:返回后回到首页
|
||||||
- [ ] 运动页:显示“开始运动”卡片 + 5 条运动记录
|
- [ ] 运动页:显示“开始运动”卡片 + 5 条运动记录
|
||||||
- [ ] 运动页:“查看全部”进入历史页,底部导航栏仍可见
|
- [ ] 运动页:“查看全部”进入历史页,底部导航栏仍可见
|
||||||
@@ -4469,7 +4735,7 @@ Expected: `BUILD SUCCESSFUL`,生成 `app/build/outputs/apk/debug/app-debug.apk
|
|||||||
- [ ] 我的页:头像卡与两个分组入口正常显示
|
- [ ] 我的页:头像卡与两个分组入口正常显示
|
||||||
- [ ] 旋转屏幕/深色系统设置下不崩溃(深色样式不保证,但不得崩溃)
|
- [ ] 旋转屏幕/深色系统设置下不崩溃(深色样式不保证,但不得崩溃)
|
||||||
|
|
||||||
- [ ] **Step 4: 写验收记录 `repos/ring-health-app/docs/verification-m1.md`**
|
- [ ] **Step 5: 写验收记录 `repos/ring-health-app/docs/verification-m1.md`**
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
# M1 静态页面验收记录
|
# M1 静态页面验收记录
|
||||||
@@ -4501,7 +4767,7 @@ Expected: `BUILD SUCCESSFUL`,生成 `app/build/outputs/apk/debug/app-debug.apk
|
|||||||
- 仅浅色模式
|
- 仅浅色模式
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 5: 更新 App `README.md` 页面清单**
|
- [ ] **Step 6: 更新 App `README.md` 页面清单**
|
||||||
|
|
||||||
在 README 的“当前阶段”下补充:
|
在 README 的“当前阶段”下补充:
|
||||||
|
|
||||||
@@ -4519,7 +4785,7 @@ Expected: `BUILD SUCCESSFUL`,生成 `app/build/outputs/apk/debug/app-debug.apk
|
|||||||
| 指标详情(步数/心率/睡眠/血氧/压力/体温/血压) | `detail/{metric}` |
|
| 指标详情(步数/心率/睡眠/血氧/压力/体温/血压) | `detail/{metric}` |
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 6: 提交并推送全部仓库**
|
- [ ] **Step 7: 提交并推送全部仓库**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /d/cherry/repos/ring-health-app && git add -A && git commit -m "docs: 补充 M1 验收记录与页面清单" && git push
|
cd /d/cherry/repos/ring-health-app && git add -A && git commit -m "docs: 补充 M1 验收记录与页面清单" && git push
|
||||||
|
|||||||
Reference in New Issue
Block a user