docs: 按 pre-flight 冲突扫描修订 M0+M1 计划(R1-R4)
This commit is contained in:
@@ -0,0 +1 @@
|
||||
.superpowers/
|
||||
@@ -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<MetricPoint>`、`workouts(): List<WorkoutRecord>`、`deviceState(): RingDeviceState`、`defaultFunctionSettings(): List<DeviceFunctionSetting>`
|
||||
- `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<HomeUiState>`
|
||||
- `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
|
||||
<string name="home_greeting">今天也要保持活力</string>
|
||||
<string name="home_device_connected">%1$s · 已连接</string>
|
||||
<string name="home_device_disconnected">未连接设备</string>
|
||||
<string name="home_battery">电量 %1$d%%</string>
|
||||
<string name="home_steps_title">今日步数</string>
|
||||
<string name="home_steps_goal">目标 %1$s 步</string>
|
||||
<string name="home_calories_label">千卡</string>
|
||||
<string name="home_distance_label">公里</string>
|
||||
<string name="home_metrics">健康指标</string>
|
||||
<string name="home_not_enabled">未开启</string>
|
||||
<string name="home_tap_to_enable">点击了解</string>
|
||||
<string name="unit_bpm">次/分</string>
|
||||
<string name="unit_percent">%</string>
|
||||
<string name="unit_celsius">°C</string>
|
||||
<string name="unit_hour">小时</string>
|
||||
<string name="unit_minute">分钟</string>
|
||||
本任务是 `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
|
||||
<string name="home_device_connected">%1$s · 已连接</string>
|
||||
<string name="home_device_disconnected">未连接设备</string>
|
||||
<string name="home_battery">电量 %1$d%%</string>
|
||||
<string name="home_steps_title">今日步数</string>
|
||||
<string name="home_steps_goal">目标 %1$s 步</string>
|
||||
<string name="home_calories_label">千卡</string>
|
||||
<string name="home_distance_label">公里</string>
|
||||
<string name="home_metrics">健康指标</string>
|
||||
<string name="home_not_enabled">未开启</string>
|
||||
<string name="unit_bpm">次/分</string>
|
||||
<string name="unit_percent">%</string>
|
||||
<string name="unit_celsius">°C</string>
|
||||
|
||||
<string name="metric_heart_rate">心率</string>
|
||||
<string name="metric_sleep">睡眠</string>
|
||||
<string name="metric_blood_oxygen">血氧</string>
|
||||
@@ -2525,6 +2498,8 @@ private fun HomeContentPreview() {
|
||||
<string name="metric_step">步数</string>
|
||||
```
|
||||
|
||||
> 这批 `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<MetricPoint>)`
|
||||
- `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
|
||||
<string name="detail_range_week">近 7 天</string>
|
||||
<string name="detail_range_month">近 30 天</string>
|
||||
<string name="detail_average">平均 %1$s</string>
|
||||
<string name="detail_max">最高 %1$s</string>
|
||||
<string name="detail_min">最低 %1$s</string>
|
||||
<string name="detail_average">平均</string>
|
||||
<string name="detail_max">最高</string>
|
||||
<string name="detail_min">最低</string>
|
||||
<string name="detail_trend">趋势</string>
|
||||
<string name="detail_sleep_stages">睡眠分期</string>
|
||||
<string name="detail_sleep_deep">深睡</string>
|
||||
<string name="detail_sleep_light">浅睡</string>
|
||||
<string name="detail_sleep_awake">清醒</string>
|
||||
<string name="detail_not_enabled_title">该指标尚未开启</string>
|
||||
<string name="detail_not_enabled_desc">前往设备页开启对应的自动监测功能。</string>
|
||||
<string name="detail_sleep_fall_asleep">入睡时间</string>
|
||||
<string name="detail_sleep_wake_up">起床时间</string>
|
||||
<string name="action_back">返回</string>
|
||||
```
|
||||
|
||||
> `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<MetricPoint>, 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<Double>.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 条运动记录
|
||||
- [ ] 运动页:“查看全部”进入历史页,底部导航栏仍可见
|
||||
|
||||
Reference in New Issue
Block a user