diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7a95436 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.superpowers/ 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 6c9e5ba..19b9355 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 @@ -986,13 +986,14 @@ git add -A && git commit -m "feat: 新增通用 UI 组件(卡片/环形进度/ --- -### Task 4: 领域模型 + 仓库接口 + 依赖容器 +### Task 4: 领域模型 + 仓库接口 **Files:** - Create: `domain/model/HealthModels.kt`, `domain/repository/Repositories.kt` -- Create: `AppContainer.kt` -- Modify: `RingHealthApplication.kt`(持有容器) -- Create: `AppViewModelProvider.kt` + +> **范围说明(见 ledger Ruling R1 / R2)**:依赖容器 `AppContainer.kt` 与 `AppViewModelProvider.kt` +> **不在本任务创建**——前者引用 Task 6 才存在的 Mock 实现类,后者引用 Task 8 才存在的 `HomeViewModel`。 +> 若在本任务创建,Task 4 到 Task 6 之间的任何构建或单测都会因未解析引用而无法编译。 **Interfaces:** - Consumes: 无(纯 Kotlin 层) @@ -1006,7 +1007,6 @@ git add -A && git commit -m "feat: 新增通用 UI 组件(卡片/环形进度/ - `data class DeviceFunctionSetting(id, title, subtitle, enabled)` - `interface HealthDataRepository { observeTodaySummary(); observeMetricHistory(metric, range); observeWorkouts() }` - `interface RingDeviceRepository { deviceState; functionSettings; refreshDeviceState(); setFunctionEnabled(id, enabled) }` - - `AppContainer { healthDataRepository; ringDeviceRepository }`、`RingHealthApplication.container`、`AppViewModelProvider.Factory` - [ ] **Step 1: 写 `HealthModels.kt`** @@ -1123,111 +1123,18 @@ interface RingDeviceRepository { } ``` -- [ ] **Step 3: 写 `AppContainer.kt` 与更新 `RingHealthApplication.kt`** - -> 依赖 Mock 实现,需在 Task 6 完成后才能编译通过;本任务把 Mock 类的引用先写好,Step 6 的构建验证放在 Task 6 之后执行。 - -`AppContainer.kt`: - -```kotlin -package com.placeholder.ringhealth - -import com.placeholder.ringhealth.data.mock.MockHealthDataRepository -import com.placeholder.ringhealth.data.mock.MockRingDeviceRepository -import com.placeholder.ringhealth.domain.repository.HealthDataRepository -import com.placeholder.ringhealth.domain.repository.RingDeviceRepository - -/** - * 手动依赖注入容器。M2 接入 SDK 时只需在这里把 Mock 实现换成真实实现。 - */ -interface AppContainer { - val healthDataRepository: HealthDataRepository - val ringDeviceRepository: RingDeviceRepository -} - -/** 默认容器:阶段一全部使用 Mock 数据。 */ -class DefaultAppContainer : AppContainer { - override val healthDataRepository: HealthDataRepository = MockHealthDataRepository() - override val ringDeviceRepository: RingDeviceRepository = MockRingDeviceRepository() -} -``` - -`RingHealthApplication.kt`(替换 Task 1 的占位实现): - -```kotlin -package com.placeholder.ringhealth - -import android.app.Application - -/** - * 应用入口,同时作为手动依赖注入的组合根。 - */ -class RingHealthApplication : Application() { - - /** 全局依赖容器。 */ - lateinit var container: AppContainer - private set - - override fun onCreate() { - super.onCreate() - container = DefaultAppContainer() - } -} -``` - -- [ ] **Step 4: 写 `AppViewModelProvider.kt`** - -```kotlin -package com.placeholder.ringhealth - -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.viewmodel.CreationExtras -import androidx.lifecycle.viewmodel.initializer -import androidx.lifecycle.viewmodel.viewModelFactory -import com.placeholder.ringhealth.feature.device.DeviceViewModel -import com.placeholder.ringhealth.feature.home.HomeViewModel -import com.placeholder.ringhealth.feature.workout.WorkoutViewModel - -/** - * ViewModel 工厂:把容器中的仓库注入各 ViewModel。 - */ -object AppViewModelProvider { - - val Factory = viewModelFactory { - initializer { - HomeViewModel( - healthDataRepository = ringHealthApplication().container.healthDataRepository, - ringDeviceRepository = ringHealthApplication().container.ringDeviceRepository, - ) - } - initializer { - WorkoutViewModel( - healthDataRepository = ringHealthApplication().container.healthDataRepository, - ) - } - initializer { - DeviceViewModel( - ringDeviceRepository = ringHealthApplication().container.ringDeviceRepository, - ) - } - } -} - -/** 从 CreationExtras 中取出 Application 实例。 */ -fun CreationExtras.ringHealthApplication(): RingHealthApplication = - this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as RingHealthApplication -``` - -> 说明:`MetricDetailViewModel` 需要运行时参数(指标类型),使用独立的工厂,见 Task 12。 - -- [ ] **Step 5: 暂不构建(依赖 Mock 类尚未创建)** - -跳到 Task 5、Task 6,完成 Mock 后再统一验证。 - -- [ ] **Step 6: Commit** +- [ ] **Step 3: 构建验证** ```bash -git add -A && git commit -m "feat: 新增领域模型、仓库接口与依赖容器" +cd /d/cherry/repos/ring-health-app && ./gradlew assembleDebug +``` + +Expected: `BUILD SUCCESSFUL`。本任务只产出纯 Kotlin 的 `domain/` 层,不引用任何尚未创建的类。 + +- [ ] **Step 4: Commit** + +```bash +git add -A && git commit -m "feat: 新增领域模型与仓库接口" ``` --- @@ -1249,7 +1156,11 @@ git add -A && git commit -m "feat: 新增领域模型、仓库接口与依赖容 - `formatClock(timestamp: Long): String` - `formatDate(timestamp: Long): String` - `stressLevelLabel(stress: Int): String` - - `metricDisplayName(type: MetricType): String` + +> **范围说明(见 ledger Ruling R3)**:本任务**不提供** `metricDisplayName(type)`。 +> 指标中文名属于用户可见文案,Global Constraints 要求一律进 `strings.xml`; +> 把它硬编码在 Kotlin 里会与 Task 8 加入 `R.string.metric_*` 形成两份事实来源。 +> 详情页标题改用资源 id 映射,见 Task 12。 - [ ] **Step 1: 写失败的测试** @@ -1258,7 +1169,6 @@ git add -A && git commit -m "feat: 新增领域模型、仓库接口与依赖容 ```kotlin package com.placeholder.ringhealth.core.common -import com.placeholder.ringhealth.domain.model.MetricType import org.junit.Assert.assertEquals import org.junit.Test @@ -1303,17 +1213,6 @@ class FormattersTest { assertEquals("中等", stressLevelLabel(65)) assertEquals("偏高", stressLevelLabel(90)) } - - @Test - fun `metricDisplayName 返回中文指标名`() { - assertEquals("步数", metricDisplayName(MetricType.STEP)) - assertEquals("心率", metricDisplayName(MetricType.HEART_RATE)) - assertEquals("睡眠", metricDisplayName(MetricType.SLEEP)) - assertEquals("血氧", metricDisplayName(MetricType.BLOOD_OXYGEN)) - assertEquals("压力", metricDisplayName(MetricType.STRESS)) - assertEquals("体温", metricDisplayName(MetricType.TEMPERATURE)) - assertEquals("血压", metricDisplayName(MetricType.BLOOD_PRESSURE)) - } } ``` @@ -1332,7 +1231,6 @@ Expected: 编译失败或测试失败(`formatSleepDuration` 等未定义 / `Un ```kotlin package com.placeholder.ringhealth.core.common -import com.placeholder.ringhealth.domain.model.MetricType import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -1383,17 +1281,6 @@ fun stressLevelLabel(stress: Int): String = when { stress < 80 -> "中等" else -> "偏高" } - -/** 指标类型 → 中文名称。 */ -fun metricDisplayName(type: MetricType): String = when (type) { - MetricType.STEP -> "步数" - MetricType.HEART_RATE -> "心率" - MetricType.SLEEP -> "睡眠" - MetricType.BLOOD_OXYGEN -> "血氧" - MetricType.STRESS -> "压力" - MetricType.TEMPERATURE -> "体温" - MetricType.BLOOD_PRESSURE -> "血压" -} ``` - [ ] **Step 4: 运行测试,确认通过** @@ -1402,7 +1289,7 @@ fun metricDisplayName(type: MetricType): String = when (type) { cd /d/cherry/repos/ring-health-app && ./gradlew :app:testDebugUnitTest --tests "com.placeholder.ringhealth.core.common.FormattersTest" ``` -Expected: `BUILD SUCCESSFUL`,7 个测试全部通过。 +Expected: `BUILD SUCCESSFUL`,6 个测试全部通过。 - [ ] **Step 5: Commit** @@ -1416,11 +1303,14 @@ git add -A && git commit -m "feat: 新增数值格式化工具(含单元测试 **Files:** - Create: `data/mock/MockDataProvider.kt`, `MockHealthDataRepository.kt`, `MockRingDeviceRepository.kt` +- Create: `AppContainer.kt`(原属 Task 4,见 ledger Ruling R1) +- Modify: `RingHealthApplication.kt`(持有容器) - Test: `app/src/test/java/com/placeholder/ringhealth/data/mock/MockDataProviderTest.kt` **Interfaces:** - Consumes: `domain/model/*`、`domain/repository/*` - Produces: + - `interface AppContainer { healthDataRepository; ringDeviceRepository }`、`class DefaultAppContainer : AppContainer`、`RingHealthApplication.container` - `class MockDataProvider(seed: Long = DEFAULT_SEED)`,方法:`todaySummary(): HealthSummary`、`metricHistory(metric: MetricType, range: DateRange): List`、`workouts(): List`、`deviceState(): RingDeviceState`、`defaultFunctionSettings(): List` - `class MockHealthDataRepository : HealthDataRepository`(每次发射前 `delay(200)`) - `class MockRingDeviceRepository : RingDeviceRepository`(开关状态保存在内存 StateFlow) @@ -1696,18 +1586,70 @@ class MockRingDeviceRepository( } ``` -- [ ] **Step 7: 全量单元测试 + 构建验证** +- [ ] **Step 7: 写 `AppContainer.kt` 与更新 `RingHealthApplication.kt`** + +现在 Mock 实现已存在,可以安全创建容器(ledger Ruling R1)。 + +`AppContainer.kt`: + +```kotlin +package com.placeholder.ringhealth + +import com.placeholder.ringhealth.data.mock.MockHealthDataRepository +import com.placeholder.ringhealth.data.mock.MockRingDeviceRepository +import com.placeholder.ringhealth.domain.repository.HealthDataRepository +import com.placeholder.ringhealth.domain.repository.RingDeviceRepository + +/** + * 手动依赖注入容器。M2 接入 SDK 时只需在这里把 Mock 实现换成真实实现。 + */ +interface AppContainer { + val healthDataRepository: HealthDataRepository + val ringDeviceRepository: RingDeviceRepository +} + +/** 默认容器:阶段一全部使用 Mock 数据。 */ +class DefaultAppContainer : AppContainer { + override val healthDataRepository: HealthDataRepository = MockHealthDataRepository() + override val ringDeviceRepository: RingDeviceRepository = MockRingDeviceRepository() +} +``` + +`RingHealthApplication.kt`(替换 Task 1 的最小实现): + +```kotlin +package com.placeholder.ringhealth + +import android.app.Application + +/** + * 应用入口,同时作为手动依赖注入的组合根。 + */ +class RingHealthApplication : Application() { + + /** 全局依赖容器。 */ + lateinit var container: AppContainer + private set + + override fun onCreate() { + super.onCreate() + container = DefaultAppContainer() + } +} +``` + +- [ ] **Step 8: 全量单元测试 + 构建验证** ```bash cd /d/cherry/repos/ring-health-app && ./gradlew :app:testDebugUnitTest && ./gradlew assembleDebug ``` -Expected: 两个命令均 `BUILD SUCCESSFUL`(Task 4 的容器代码此时也应编译通过)。 +Expected: 两个命令均 `BUILD SUCCESSFUL`(Step 7 的容器代码也在本次编译范围内)。 -- [ ] **Step 8: Commit** +- [ ] **Step 9: Commit** ```bash -git add -A && git commit -m "feat: 新增 Mock 数据层(Provider + 两个仓库实现,含单元测试)" +git add -A && git commit -m "feat: 新增 Mock 数据层与依赖容器(含单元测试)" ``` --- @@ -2076,15 +2018,17 @@ git add -A && git commit -m "feat: 新增底部四 Tab 导航骨架与路由表" **Files:** - Create: `feature/home/HomeViewModel.kt` +- Create: `AppViewModelProvider.kt`(原属 Task 4,见 ledger Ruling R2) - Modify: `feature/home/HomeScreen.kt`(完整实现) - Test: `app/src/test/java/com/placeholder/ringhealth/feature/home/HomeViewModelTest.kt` - Modify: `res/values/strings.xml` **Interfaces:** -- Consumes: `HealthDataRepository`、`RingDeviceRepository`、`AppViewModelProvider.Factory`、Task 3 组件 +- Consumes: `HealthDataRepository`、`RingDeviceRepository`、`RingHealthApplication.container`、Task 3 组件 - Produces: - `data class HomeUiState(val loading: Boolean, val summary: HealthSummary?, val device: RingDeviceState?)` - `class HomeViewModel(healthDataRepository, ringDeviceRepository)` 暴露 `uiState: StateFlow` + - `object AppViewModelProvider { val Factory }` 与 `fun CreationExtras.ringHealthApplication(): RingHealthApplication` - `@Composable fun HomeScreen(onOpenDetail: (MetricType) -> Unit, modifier, viewModel: HomeViewModel = viewModel(factory = AppViewModelProvider.Factory))` - [ ] **Step 1: 写失败的测试** @@ -2209,27 +2153,43 @@ cd /d/cherry/repos/ring-health-app && ./gradlew :app:testDebugUnitTest --tests " Expected: `BUILD SUCCESSFUL`。 -- [ ] **Step 5: 追加首页文案到 `strings.xml`** +- [ ] **Step 5: 写 `AppViewModelProvider.kt`** -```xml - 今天也要保持活力 - %1$s · 已连接 - 未连接设备 - 电量 %1$d%% - 今日步数 - 目标 %1$s 步 - 千卡 - 公里 - 健康指标 - 未开启 - 点击了解 - 次/分 - % - °C - 小时 - 分钟 +本任务是 `AppViewModelProvider` 的第一个消费者,文件在这里建立(ledger Ruling R2)。 +Task 9 / Task 10 会各自往 `viewModelFactory` 里**追加一个 `initializer`**,文件位置与职责不变。 + +```kotlin +package com.placeholder.ringhealth + +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewmodel.CreationExtras +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import com.placeholder.ringhealth.feature.home.HomeViewModel + +/** + * ViewModel 工厂:把容器中的仓库注入各 ViewModel。 + * 每个 ViewModel 在这里注册一个 initializer;后续任务按需追加。 + */ +object AppViewModelProvider { + + val Factory = viewModelFactory { + initializer { + HomeViewModel( + healthDataRepository = ringHealthApplication().container.healthDataRepository, + ringDeviceRepository = ringHealthApplication().container.ringDeviceRepository, + ) + } + } +} + +/** 从 CreationExtras 中取出 Application 实例。 */ +fun CreationExtras.ringHealthApplication(): RingHealthApplication = + this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as RingHealthApplication ``` +> 说明:`MetricDetailViewModel` 需要运行时参数(指标类型),使用独立的工厂,见 Task 12。 + - [ ] **Step 6: 完整实现 `HomeScreen.kt`** ```kotlin @@ -2513,9 +2473,22 @@ private fun HomeContentPreview() { > 说明:`buildMetricCards` 是 `@Composable`(内部调用了 `stringResource`),而 `LazyColumn` 的 content lambda 不是 Composable 上下文,所以必须在进入 `LazyColumn` 之前取值——上面的 `val cards = ...` 就是为此。 -- [ ] **Step 7: 追加指标名文案到 `strings.xml`** +- [ ] **Step 7: 追加首页文案与指标名到 `strings.xml`** ```xml + %1$s · 已连接 + 未连接设备 + 电量 %1$d%% + 今日步数 + 目标 %1$s 步 + 千卡 + 公里 + 健康指标 + 未开启 + 次/分 + % + °C + 心率 睡眠 血氧 @@ -2525,6 +2498,8 @@ private fun HomeContentPreview() { 步数 ``` +> 这批 `metric_*` 字符串同时供 Task 12 的详情页标题使用(ledger Ruling R3),是它们的唯一来源。 + - [ ] **Step 8: 构建 + 单测 + 真机验证** ```bash @@ -2594,6 +2569,19 @@ class WorkoutViewModel( } ``` +- [ ] **Step 1b: 把 `WorkoutViewModel` 注册到 `AppViewModelProvider`** + +在 `AppViewModelProvider.kt` 的 `viewModelFactory { }` 内**追加**一个 `initializer`(ledger Ruling R2), +并在该文件顶部加 `import com.placeholder.ringhealth.feature.workout.WorkoutViewModel`: + +```kotlin + initializer { + WorkoutViewModel( + healthDataRepository = ringHealthApplication().container.healthDataRepository, + ) + } +``` + - [ ] **Step 2: 追加文案到 `strings.xml`** ```xml @@ -2894,6 +2882,19 @@ class DeviceViewModel( } ``` +- [ ] **Step 1b: 把 `DeviceViewModel` 注册到 `AppViewModelProvider`** + +在 `AppViewModelProvider.kt` 的 `viewModelFactory { }` 内**追加**一个 `initializer`(ledger Ruling R2), +并在该文件顶部加 `import com.placeholder.ringhealth.feature.device.DeviceViewModel`: + +```kotlin + initializer { + DeviceViewModel( + ringDeviceRepository = ringHealthApplication().container.ringDeviceRepository, + ) + } +``` + - [ ] **Step 2: 追加文案到 `strings.xml`** ```xml @@ -3360,12 +3361,17 @@ git add -A && git commit -m "feat: 实现我的页" - Modify: `res/values/strings.xml` **Interfaces:** -- Consumes: `HealthDataRepository`、`MetricBarChart`、`StatBar` +- Consumes: `HealthDataRepository`、`MetricBarChart`、`StatBar`、Task 8 加入的 `R.string.metric_*` - Produces: - `data class MetricDetailUiState(val loading: Boolean, val metric: MetricType, val range: DateRange, val points: List)` - `class MetricDetailViewModel(metric: MetricType, healthDataRepository)`,方法 `fun switchRange(range: DateRange)` - `@Composable fun MetricDetailScreen(metric: MetricType, onBack: () -> Unit, modifier, viewModel: MetricDetailViewModel = viewModel(factory = MetricDetailViewModel.factory(metric)))` - `MetricDetailViewModel.factory(metric: MetricType): ViewModelProvider.Factory`(伴生对象) + - `@Composable private fun metricTitleRes(type: MetricType): Int`(文件内私有) + +> **两点范围说明**: +> - 标题用 `metricTitleRes` 映射到 `R.string.metric_*`,**不再有** `metricDisplayName`(ledger Ruling R3)。 +> - 增加睡眠专属区块(`StatBar` 分期条 + 入睡/起床时间),补齐 spec §6 的 `detail/sleep` 要求(ledger Ruling R4)。 - [ ] **Step 1: 写 `MetricDetailViewModel.kt`** @@ -3460,24 +3466,28 @@ class MetricDetailViewModel( ```xml 近 7 天 近 30 天 - 平均 %1$s - 最高 %1$s - 最低 %1$s + 平均 + 最高 + 最低 趋势 睡眠分期 深睡 浅睡 清醒 - 该指标尚未开启 - 前往设备页开启对应的自动监测功能。 + 入睡时间 + 起床时间 返回 ``` +> `detail_average` / `detail_max` / `detail_min` 是**纯标签**,不带 `%1$s`: +> `StatLine` 把标签和数值分成左右两栏渲染,标签里再带占位符会原样打印出 `%1$s`。 + - [ ] **Step 3: 实现 `MetricDetailScreen.kt`(替换占位)** ```kotlin package com.placeholder.ringhealth.feature.detail +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -3486,7 +3496,9 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.CircularProgressIndicator @@ -3502,18 +3514,24 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color 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.R -import com.placeholder.ringhealth.core.common.metricDisplayName +import com.placeholder.ringhealth.core.common.formatClock +import com.placeholder.ringhealth.core.designsystem.MetricBlue import com.placeholder.ringhealth.core.designsystem.MetricGreen import com.placeholder.ringhealth.core.designsystem.TextSecondary import com.placeholder.ringhealth.core.designsystem.component.HealthCard import com.placeholder.ringhealth.core.designsystem.component.MetricBarChart import com.placeholder.ringhealth.core.designsystem.component.SectionHeader +import com.placeholder.ringhealth.core.designsystem.component.StatBar +import com.placeholder.ringhealth.core.designsystem.component.StatSegment import com.placeholder.ringhealth.domain.model.DateRange +import com.placeholder.ringhealth.domain.model.MetricPoint import com.placeholder.ringhealth.domain.model.MetricType /** @@ -3534,7 +3552,7 @@ fun MetricDetailScreen( containerColor = MaterialTheme.colorScheme.background, topBar = { TopAppBar( - title = { Text(text = metricDisplayName(metric)) }, + title = { Text(text = stringResource(metricTitleRes(metric))) }, navigationIcon = { IconButton(onClick = onBack) { Icon( @@ -3579,10 +3597,88 @@ fun MetricDetailScreen( StatLine(label = stringResource(R.string.detail_min), value = values.minOrNull() ?: 0.0) } } + if (state.metric == MetricType.SLEEP) { + item { SleepStagesCard(points = state.points) } + } } } } +/** + * 睡眠分期卡片:深睡/浅睡/清醒分段条 + 入睡/起床时间(对应 spec §6 的 `detail/sleep`)。 + */ +@Composable +private fun SleepStagesCard(points: List, modifier: Modifier = Modifier) { + // 阶段一没有真实分期数据,按固定比例演示;M3 接入真实睡眠数据后改为按实际时段计算。 + val segments = listOf( + StatSegment(weight = 0.25f, color = MetricBlue), + StatSegment(weight = 0.65f, color = MetricBlue.copy(alpha = 0.45f)), + StatSegment(weight = 0.10f, color = MetricBlue.copy(alpha = 0.2f)), + ) + HealthCard(modifier = modifier) { + SectionHeader(title = stringResource(R.string.detail_sleep_stages)) + StatBar(segments = segments) + Row( + modifier = Modifier.fillMaxWidth().padding(top = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + StageLegend(label = stringResource(R.string.detail_sleep_deep), tint = MetricBlue) + 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)) + } + val fallAsleep = points.firstOrNull()?.timestamp + val wakeUp = points.lastOrNull()?.timestamp + if (fallAsleep != null && wakeUp != null) { + Row( + modifier = Modifier.fillMaxWidth().padding(top = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = "${stringResource(R.string.detail_sleep_fall_asleep)} ${formatClock(fallAsleep)}", + style = MaterialTheme.typography.bodySmall, + color = TextSecondary, + ) + Text( + text = "${stringResource(R.string.detail_sleep_wake_up)} ${formatClock(wakeUp)}", + style = MaterialTheme.typography.bodySmall, + color = TextSecondary, + ) + } + } + } +} + +/** 分期图例:小色块 + 名称。 */ +@Composable +private fun StageLegend(label: String, tint: Color, modifier: Modifier = Modifier) { + Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(10.dp) + .clip(RoundedCornerShape(5.dp)) + .background(tint), + ) + Text( + text = label, + style = MaterialTheme.typography.bodySmall, + color = TextSecondary, + modifier = Modifier.padding(start = 6.dp), + ) + } +} + +/** 指标类型 → 标题字符串资源。文案的唯一来源是 `strings.xml`(ledger Ruling R3)。 */ +@Composable +private fun metricTitleRes(type: MetricType): Int = when (type) { + MetricType.STEP -> R.string.metric_step + MetricType.HEART_RATE -> R.string.metric_heart_rate + MetricType.SLEEP -> R.string.metric_sleep + MetricType.BLOOD_OXYGEN -> R.string.metric_blood_oxygen + MetricType.STRESS -> R.string.metric_stress + MetricType.TEMPERATURE -> R.string.metric_temperature + MetricType.BLOOD_PRESSURE -> R.string.metric_blood_pressure +} + /** 周/月切换。 */ @Composable private fun RangeSelector( @@ -3646,7 +3742,7 @@ private fun List.averageOrZero(): Double = if (isEmpty()) 0.0 else avera cd /d/cherry/repos/ring-health-app && ./gradlew assembleDebug && ./gradlew installDebug ``` -Expected: 从首页点任一指标卡进入详情页;顶部标题为指标中文名;近 7 天/近 30 天可切换,柱状图数量随之变化(7 根 / 30 根);返回键回到首页。 +Expected: 从首页点任一指标卡进入详情页;顶部标题为指标中文名;近 7 天/近 30 天可切换,柱状图数量随之变化(7 根 / 30 根);**睡眠详情额外显示分期卡片**(深睡/浅睡/清醒分段条与图例、入睡/起床时间),其他指标不显示;返回键回到首页。 - [ ] **Step 6: Commit** @@ -3832,6 +3928,7 @@ cd /d/cherry/repos/ring-health-app && ./gradlew installDebug - [ ] 首页:六个指标卡正常显示,**血压显示“未开启”** - [ ] 首页:点击任一指标卡进入详情页,标题为该指标中文名 - [ ] 详情页:近 7 天显示 7 根柱子,切到近 30 天显示 30 根柱子 +- [ ] 睡眠详情额外显示分期卡片(深睡/浅睡/清醒分段条 + 入睡/起床时间),其他指标没有这张卡 - [ ] 详情页:返回后回到首页 - [ ] 运动页:显示“开始运动”卡片 + 5 条运动记录 - [ ] 运动页:“查看全部”进入历史页,底部导航栏仍可见