Files
ring-health-docs/superpowers/plans/2026-09-10-ring-health-app-m0-m1.md
T

4519 lines
170 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 智能戒指健康 App(M0+M1 静态页面)实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 交付一个可运行的 Android App 骨架:四个 Tab(健康首页/运动/设备/我的)加数据详情子页,全部页面静态可用,数据来自 Mock,UI 只依赖仓库接口,为 M2 接入厂商蓝牙 SDK 预留清晰边界。
**Architecture:** 单 `:app` Gradle 模块 + 严格分包(`core/` `domain/` `data/` `feature/`)。UI 只依赖 `domain/repository` 的接口与 `domain/model` 的模型;阶段一注入 `data/mock/` 的 Mock 实现。所有页面为纯静态/演示数据,M2 只需新增一套实现同样接口的 SDK 适配层,UI 零改动。
**Tech Stack:** Kotlin 2.2.20、Jetpack Compose(BOM 2026.09.00)、Navigation-Compose 2.10.1、Material 3、AGP 8.13.1、Gradle 8.14、JDK 17、JUnit4 + kotlinx-coroutines-test 1.10.2。
**Spec:** [docs/superpowers/specs/2026-09-10-ring-health-app-design.md](../specs/2026-09-10-ring-health-app-design.md)
## Global Constraints
- **仓库名**:`ring-health-app`、`ring-health-api`(kebab-case,与 Gitea 上名称一致)。
- **包名/namespace**:`com.placeholder.ringhealth`(占位,后续按 spec §12 统一替换)。
- **SDK 版本**:`minSdk 26` / `targetSdk 36` / `compileSdk 36` / `buildToolsVersion "36.1.0"`(36.1.0 是本机已装版本,写死避免下载)。
- **工具链钉死**:Gradle `8.14`、AGP `8.13.1`、Kotlin `2.2.20`(三者均为本机缓存版本)。
- **依赖约束**:阶段一**不引入** Hilt、Room、Retrofit、第三方图表库。图表一律用 Compose `Canvas` 手绘。
- **不硬编码文案**:所有用户可见文字进 `res/values/strings.xml`(默认中文)。
**唯一豁免**(见 ledger Ruling R13):`core/common/Formatters.kt` 中
`formatSleepDuration`(`7小时32分`)与 `formatDuration`(`1小时05分`)这两条 ——
它们是数字与单位**黏合成单一复合值**、且设计系统没有独立单位槽位可放的时长排版,
拆开会把同一段 `when` 逻辑复制进每个调用点。除此之外,格式化函数**一律不得**拼接
用户可见文字:单位与描述词要么由调用点用资源提供(如 `formatCalories` 的「千卡」),
要么以 `@StringRes` 返回资源 id(如 `stressLevelLabelRes`)。判据是
「该文字是否已在 `strings.xml` 里有第二份」与「设计系统是否已提供独立槽位」。
- **禁止行内样式散落**:颜色/字号统一定义在 `core/designsystem/`,页面不得直接写 `Color(0xFF...)`。
- **命名规范**:类/Composable 用 PascalCase;函数/变量用 camelCase;资源文件用 kebab-case/snake_case。
`const val` 编译期常量用 UPPER_SNAKE_CASE。
**例外**:`core/designsystem/` 里的设计 token(`BrandGreen`、`MetricRed`、`AppBackground`、
`MetricValueStyle` 等)一律用 PascalCase —— 它们是 Compose 惯用的公开设计 token,不是 `const val`,
且 Tasks 3–12 的组件与页面都按这些确切名字引用,改名会破坏接口契约。此例外即本约束的一部分。
- **注释规范**:每个类与公开函数上方写中文 KDoc,说明用途(团队规范要求)。
- **提交规范**:每个 Task 结束提交一次;提交信息用中文,前缀 `feat:` / `chore:` / `test:` / `docs:`。
- **Gitea 地址**:`http://120.192.101.56:3000/cuiluzheng/<repo>.git`(凭据助手已按主机配置好,可直接推送)。
- **不做 `installDebug`**:本机没有模拟器镜像、也没有连接真机,各 Task 的构建步骤**只跑 `./gradlew assembleDebug`**
(ledger Ruling R21)。运行时的界面/交互验证统一顺延到 Task 14 Step 3 的人工验收清单,交人在有设备时执行。
执行 agent 不要尝试 `installDebug`,也不要临时下载模拟器镜像。
- **每个页面必须拆成「有状态外壳 + 无状态内容」并给内容加 `@Preview`**(ledger Ruling R26):
公开的 `XScreen(modifier, viewModel)` 只负责 `collectAsStateWithLifecycle` 并委托,
真正渲染的是 `private fun XContent(state, …, modifier)`,`@Preview` 挂在后者上、传入手写的示例 state。
Task 8 的 `HomeScreen` / `HomeContent` / `HomeContentPreview` 是样板。
理由不只是惯例:本机**没有任何设备**(见上一条),IDE Preview 是 Task 14 人工验收之前
**唯一**能让人看见界面的手段;没有它,Task 9–12 四个页面到验收前都只有编译期保证。
同时这天然把「渲染」与「取数」分开,`XContent` 可以直接拿假数据测。
- **次要文字一律用 `core/designsystem/` 的 `TextSecondary`**,不要用 `MaterialTheme.colorScheme.onSurfaceVariant`
(ledger Ruling R26):主题只覆写了 `surface`/`background`/`surfaceVariant` 等槽位,
`onSurfaceVariant` 仍是 Material 默认的 `#FF49454F`,而 `TextSecondary` 是 `#FF8E8E93` ——
两者都是灰但深浅明显不同,混用会让同一屏里的次要文字深浅不一。
这是「禁止行内样式散落」的延伸:槽位不是字面量,所以编译器拦不住,只能靠这条约定。
## File Structure
```
repos/ring-health-app/
├── .gitignore
├── README.md
├── settings.gradle.kts
├── build.gradle.kts
├── gradle.properties
├── local.properties # 本机 SDK 路径,gitignore 忽略
├── gradlew / gradlew.bat
├── gradle/
│ ├── libs.versions.toml
│ └── wrapper/{gradle-wrapper.jar, gradle-wrapper.properties}
└── app/
├── build.gradle.kts
├── proguard-rules.pro
└── src/
├── main/
│ ├── AndroidManifest.xml
│ ├── java/com/placeholder/ringhealth/
│ │ ├── RingHealthApplication.kt # 组合根:构造 AppContainer
│ │ ├── AppContainer.kt # 依赖容器(手动 DI)
│ │ ├── AppViewModelProvider.kt # ViewModel 工厂
│ │ ├── MainActivity.kt # 唯一 Activity
│ │ ├── core/
│ │ │ ├── common/Formatters.kt # 数值/时长/日期格式化
│ │ │ ├── designsystem/
│ │ │ │ ├── Color.kt Type.kt Theme.kt
│ │ │ │ └── component/
│ │ │ │ ├── HealthCard.kt RingProgress.kt MetricTile.kt
│ │ │ │ ├── SectionHeader.kt StatBar.kt MetricBarChart.kt
│ │ │ └── navigation/
│ │ │ ├── Routes.kt
│ │ │ └── RingHealthApp.kt # Scaffold + 底部栏 + NavHost
│ │ ├── domain/
│ │ │ ├── model/HealthModels.kt
│ │ │ └── repository/Repositories.kt
│ │ ├── data/mock/
│ │ │ ├── MockDataProvider.kt
│ │ │ ├── MockHealthDataRepository.kt
│ │ │ └── MockRingDeviceRepository.kt
│ │ └── feature/
│ │ ├── home/{HomeScreen.kt, HomeViewModel.kt}
│ │ ├── workout/{WorkoutScreen.kt, WorkoutViewModel.kt}
│ │ ├── device/{DeviceScreen.kt, DeviceViewModel.kt, DeviceSettingsScreen.kt}
│ │ ├── profile/ProfileScreen.kt
│ │ └── detail/{MetricDetailScreen.kt, MetricDetailViewModel.kt}
│ └── res/
│ ├── values/{strings.xml, colors.xml, themes.xml}
│ ├── xml/{backup_rules.xml, data_extraction_rules.xml}
│ ├── drawable/ic_launcher_foreground.xml
│ └── mipmap-anydpi-v26/{ic_launcher.xml, ic_launcher_round.xml}
└── test/java/com/placeholder/ringhealth/
├── core/common/FormattersTest.kt
├── data/mock/MockDataProviderTest.kt
└── feature/home/HomeViewModelTest.kt
```
**职责边界:**
- `domain/` 是纯 Kotlin,**不允许** import `android.*` 或 `androidx.*`(保证可单元测试、可复用)。
- `feature/*` 只依赖 `domain/model` 与 `domain/repository`,**不允许**直接 import `data/mock/*`。
- `core/designsystem/` 不依赖任何 `feature/` 或 `data/`。
- 厂商 SDK 相关代码在 M2 才出现,将放在独立包 `com.sr.smartring`(原样拷贝)与 `data/sdk/`(我们的适配层),本计划不创建。
---
### Task 1: 仓库初始化 + Gradle 工程骨架(空壳可构建)
**Files:**
- Create: `repos/ring-health-app/.gitignore`, `README.md`, `settings.gradle.kts`, `build.gradle.kts`, `gradle.properties`, `local.properties`, `gradle/libs.versions.toml`, `gradle/wrapper/gradle-wrapper.properties`, `gradlew`, `gradlew.bat`, `gradle/wrapper/gradle-wrapper.jar`(从厂商工程拷贝)
- Create: `app/build.gradle.kts`, `app/proguard-rules.pro`
- Create: `app/src/main/AndroidManifest.xml`
- Create: `app/src/main/java/com/placeholder/ringhealth/MainActivity.kt`
- Create: `app/src/main/res/values/{strings.xml, themes.xml}`, `res/xml/{backup_rules.xml, data_extraction_rules.xml}`
- Create: `app/src/main/res/drawable/ic_launcher_foreground.xml`, `res/values/ic_launcher_background.xml`, `res/mipmap-anydpi-v26/{ic_launcher.xml, ic_launcher_round.xml}`
**Interfaces:**
- Consumes: 无(首个任务)
- Produces: 一个可通过 `./gradlew assembleDebug` 的工程骨架;`MainActivity` 承载后续 Compose 内容。
- [ ] **Step 1: 创建目录并拷贝 gradle wrapper**
本机无全局 gradle,但 Gradle 8.14 发行包已在缓存中。复用厂商工程的 wrapper 引导文件(wrapper 与 Gradle 版本无关,只负责按 `distributionUrl` 下载/复用发行包):
```bash
mkdir -p /d/cherry/repos/ring-health-app/{app/src/main/java/com/placeholder/ringhealth,app/src/main/res/values,app/src/main/res/xml,app/src/main/res/drawable,app/src/main/res/mipmap-anydpi-v26,gradle/wrapper,app/src/test/java/com/placeholder/ringhealth}
cp /d/cherry/SmartRingF01_SDK/gradlew /d/cherry/SmartRingF01_SDK/gradlew.bat /d/cherry/repos/ring-health-app/
cp /d/cherry/SmartRingF01_SDK/gradle/wrapper/gradle-wrapper.jar /d/cherry/repos/ring-health-app/gradle/wrapper/
```
- [ ] **Step 2: 写 `.gitignore`**
`repos/ring-health-app/.gitignore`:
```gitignore
*.iml
.gradle/
local.properties
.idea/
.DS_Store
build/
captures/
.externalNativeBuild/
.cxx/
*.apk
*.aab
*.keystore
*.jks
```
- [ ] **Step 3: 写 `gradle/wrapper/gradle-wrapper.properties`**
```properties
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
```
- [ ] **Step 4: 写 `gradle/libs.versions.toml`**
```toml
[versions]
agp = "8.13.1"
kotlin = "2.2.20"
coreKtx = "1.19.0"
activityCompose = "1.13.0"
lifecycle = "2.11.0"
navigationCompose = "2.10.1"
composeBom = "2026.09.00"
iconsExtended = "1.7.8"
coroutines = "1.10.2"
junit = "4.13.2"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycle" }
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended", version.ref = "iconsExtended" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
```
> 说明:`material-icons-extended` 已被官方冻结在 `1.7.8`(新版 BOM 不再收录),因此**显式钉版本、不走 BOM**。它只提供 `ImageVector` 数据,与新版 Compose UI 共存无碍。若构建仍报冲突,回退方案见 Task 14 备注。
- [ ] **Step 5: 写根 `build.gradle.kts` 与 `settings.gradle.kts`**
`repos/ring-health-app/settings.gradle.kts`:
```kotlin
pluginManagement {
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "ring-health-app"
include(":app")
```
`repos/ring-health-app/build.gradle.kts`:
```kotlin
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
}
```
`repos/ring-health-app/gradle.properties`:
```properties
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
org.gradle.parallel=true
org.gradle.caching=true
android.useAndroidX=true
android.nonTransitiveRClass=true
kotlin.code.style=official
```
- [ ] **Step 6: 写 app 模块 `app/build.gradle.kts`**
```kotlin
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
}
android {
namespace = "com.placeholder.ringhealth"
compileSdk = 36
buildToolsVersion = "36.1.0"
defaultConfig {
applicationId = "com.placeholder.ringhealth"
minSdk = 26
targetSdk = 36
versionCode = 1
versionName = "0.1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildFeatures {
compose = true
}
packaging {
resources.excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
// Kotlin 编译选项须放在 android {} 之外(AGP 8.x 起 android.kotlinOptions 已废弃)。
kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.lifecycle.viewmodel.ktx)
implementation(libs.androidx.navigation.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
implementation(libs.androidx.icons.extended)
debugImplementation(libs.androidx.ui.tooling)
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
}
```
`app/proguard-rules.pro` 保持空文件(内容仅一行注释):
```proguard
# 阶段一未启用混淆,规则留空。
```
- [ ] **Step 7: 写 `local.properties`(不进版本库)**
```properties
sdk.dir=C\:\\Users\\10314\\AppData\\Local\\Android\\Sdk
```
- [ ] **Step 8: 写清单、资源与最小 Activity**
`app/src/main/AndroidManifest.xml`:
```xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:name=".RingHealthApplication"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.RingHealth">
<activity
android:name=".MainActivity"
android:exported="true"
android:screenOrientation="portrait"
android:theme="@style/Theme.RingHealth">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
```
> `RingHealthApplication` 在 Task 4 创建。此步骤先创建占位类,见下方 Step 8b。
`app/src/main/java/com/placeholder/ringhealth/RingHealthApplication.kt`(Task 4 会补上容器,这里先最小化):
```kotlin
package com.placeholder.ringhealth
import android.app.Application
/**
* 应用入口。同时作为手动依赖注入的组合根(M1 阶段只持有 Mock 容器)。
*/
class RingHealthApplication : Application()
```
`app/src/main/java/com/placeholder/ringhealth/MainActivity.kt`:
```kotlin
package com.placeholder.ringhealth
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
/** 唯一 Activity,承载 Compose 界面。 */
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
MaterialTheme {
Surface(modifier = Modifier.fillMaxSize()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(stringResource(R.string.app_name))
}
}
}
}
}
}
```
`app/src/main/res/values/strings.xml`:
```xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">RingHealth</string>
</resources>
```
`app/src/main/res/values/themes.xml`:
```xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.RingHealth" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:windowLightStatusBar">true</item>
</style>
</resources>
```
`app/src/main/res/xml/backup_rules.xml`:
```xml
<?xml version="1.0" encoding="utf-8"?>
<full-backup-content />
```
`app/src/main/res/xml/data_extraction_rules.xml`:
```xml
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<cloud-backup />
<device-transfer />
</data-extraction-rules>
```
`app/src/main/res/drawable/ic_launcher_foreground.xml`(占位矢量图标,内嵌于 Task 14 可再美化):
```xml
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#FFFFFF"
android:pathData="M54,30 C40,30 30,40 30,54 C30,68 40,78 54,78 C68,78 78,68 78,54 C78,40 68,30 54,30 Z M54,38 C62.8,38 70,45.2 70,54 C70,62.8 62.8,70 54,70 C45.2,70 38,62.8 38,54 C38,45.2 45.2,38 54,38 Z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M54,46 L60,54 L54,62 L48,54 Z" />
</vector>
```
`app/src/main/res/values/ic_launcher_background.xml`:
```xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#34C759</color>
</resources>
```
`app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml` 与 `ic_launcher_round.xml`(两文件内容相同):
```xml
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
```
- [ ] **Step 9: 写 `README.md`**
```markdown
# ring-health-app
智能戒指健康 App(原生 Android / Kotlin + Jetpack Compose)。
- 设计文档:见 `ring-health-docs` 仓库 `superpowers/specs/`
- 当前阶段:M1 静态页面(数据来自 Mock,未接入蓝牙 SDK)
## 构建
```bash
./gradlew assembleDebug # 产物在 app/build/outputs/apk/debug/
./gradlew installDebug # 安装到已连接设备
./gradlew test # 单元测试
```
要求:JDK 17、Android SDK(platform 36 / build-tools 36.1.0)。
```
- [ ] **Step 10: 构建验证**
```bash
cd /d/cherry/repos/ring-health-app && ./gradlew assembleDebug --no-daemon
```
Expected: `BUILD SUCCESSFUL`,且 `app/build/outputs/apk/debug/app-debug.apk` 存在。
(首次构建需联网下载 AndroidX/Compose 依赖,约 1–3 分钟。)
- [ ] **Step 11: 初始化 git 并推送**
```bash
cd /d/cherry/repos/ring-health-app
git init -b main
git add -A
git commit -m "chore: 初始化 Android 工程骨架(Compose + Gradle 8.14/AGP 8.13.1)"
git remote add origin http://120.192.101.56:3000/cuiluzheng/ring-health-app.git
git push -u origin main
```
Expected: 推送成功,Gitea 上 `ring-health-app` 不再是空仓。
---
### Task 2: 设计系统(颜色 / 字体 / 主题)
**Files:**
- Create: `app/src/main/java/com/placeholder/ringhealth/core/designsystem/Color.kt`, `Type.kt`, `Theme.kt`
- Modify: `MainActivity.kt`(用 `RingHealthTheme` 包裹内容)
**Interfaces:**
- Consumes: Task 1 的工程骨架
- Produces: `RingHealthTheme(content: @Composable () -> Unit)`;指标色常量 `MetricGreen/MetricRed/MetricBlue/MetricCyan/MetricPurple/MetricOrange/MetricYellow`,供所有页面与组件引用。
- [ ] **Step 1: 写 `Color.kt`**
```kotlin
package com.placeholder.ringhealth.core.designsystem
import androidx.compose.ui.graphics.Color
/** 品牌主色(健康绿,仿小米运动健康首页大环)。 */
val BrandGreen = Color(0xFF34C759)
val BrandGreenDark = Color(0xFF248A3D)
/** 页面与卡片背景。 */
val AppBackground = Color(0xFFF5F6F8)
val CardSurface = Color(0xFFFFFFFF)
val TextPrimary = Color(0xFF1C1C1E)
val TextSecondary = Color(0xFF8E8E93)
val DividerColor = Color(0xFFEFEFF4)
/** 各健康指标的语义色,全应用统一引用。 */
val MetricGreen = Color(0xFF34C759) // 步数
val MetricRed = Color(0xFFFF3B30) // 心率
val MetricBlue = Color(0xFF3B5BFF) // 睡眠
val MetricCyan = Color(0xFF00C7BE) // 血氧
val MetricPurple = Color(0xFFAF52DE) // 压力
val MetricOrange = Color(0xFFFF9500) // 体温
val MetricYellow = Color(0xFFFFCC00) // 血压 / 提醒
```
- [ ] **Step 2: 写 `Type.kt`**
```kotlin
package com.placeholder.ringhealth.core.designsystem
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
/** 大号数值样式:首页大环与详情页主数字。 */
val MetricValueStyle = TextStyle(
fontSize = 40.sp,
fontWeight = FontWeight.Bold,
color = TextPrimary,
)
/** 中等数值样式:指标卡主数字。 */
val MetricTileValueStyle = TextStyle(
fontSize = 22.sp,
fontWeight = FontWeight.SemiBold,
color = TextPrimary,
)
/** 全局字体排版。 */
val RingHealthTypography = Typography()
```
- [ ] **Step 3: 写 `Theme.kt`**
```kotlin
package com.placeholder.ringhealth.core.designsystem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
private val RingHealthColors = lightColorScheme(
primary = BrandGreen,
onPrimary = androidx.compose.ui.graphics.Color.White,
secondary = BrandGreenDark,
background = AppBackground,
onBackground = TextPrimary,
surface = CardSurface,
onSurface = TextPrimary,
surfaceVariant = DividerColor,
)
/**
* 应用主题。阶段一仅提供浅色模式(深色留到 M5)。
*/
@Composable
fun RingHealthTheme(content: @Composable () -> Unit) {
MaterialTheme(
colorScheme = RingHealthColors,
typography = RingHealthTypography,
content = content,
)
}
```
- [ ] **Step 4: `MainActivity` 换用主题**
将 `setContent { ... }` 内容替换为:
```kotlin
setContent {
RingHealthTheme {
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(stringResource(R.string.app_name))
}
}
}
}
```
并补充 import:`com.placeholder.ringhealth.core.designsystem.RingHealthTheme`、
`androidx.compose.ui.res.stringResource`。
注意:文案必须走 `stringResource(R.string.app_name)`,不得写成 `Text("RingHealth")` 字面量 ——
全局约束「不硬编码用户可见文案」对 Compose 里的每一处 `Text(...)` 都成立。Task 1 的初版
就是因为计划里的示例代码写成了字面量而引入该缺陷,修复轮才扳正(见 ledger 裁定)。
- [ ] **Step 5: 构建验证**
```bash
cd /d/cherry/repos/ring-health-app && ./gradlew assembleDebug
```
Expected: `BUILD SUCCESSFUL`。
- [ ] **Step 6: Commit**
```bash
git add -A && git commit -m "feat: 新增设计系统(颜色/字体/浅色主题)"
```
---
### Task 3: 通用 UI 组件(卡片 / 环形进度 / 指标块 / 分段条 / 柱状图)
**Files:**
- Create: `core/designsystem/component/HealthCard.kt`, `RingProgress.kt`, `MetricTile.kt`, `SectionHeader.kt`, `StatBar.kt`, `MetricBarChart.kt`
**Interfaces:**
- Consumes: Task 2 的颜色与字体
- Produces(后续所有页面的构件):
- `HealthCard(modifier, onClick: (() -> Unit)? = null, content: @Composable ColumnScope.() -> Unit)`
- `RingProgress(progress: Float, color: Color, trackColor: Color, modifier, ringSize: Dp = 180.dp, strokeWidth: Dp = 14.dp, content: @Composable () -> Unit = {})`
- `MetricTile(icon: ImageVector, iconTint: Color, title: String, value: String, unit: String, modifier, onClick: () -> Unit)`
- `SectionHeader(title: String, modifier, trailing: (@Composable () -> Unit)? = null)`
- `StatBar(segments: List<StatSegment>, modifier, barHeight: Dp = 12.dp)`;`data class StatSegment(val weight: Float, val color: Color)`
- `MetricBarChart(values: List<Float>, color: Color, modifier, barHeight: Dp = 120.dp, highlightLast: Boolean = true)`
- [ ] **Step 1: 写 `HealthCard.kt`**
```kotlin
package com.placeholder.ringhealth.core.designsystem.component
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/**
* 统一卡片容器:白底、大圆角、轻阴影。所有页面内容块都应包在它里面。
*/
@Composable
fun HealthCard(
modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null,
content: @Composable ColumnScope.() -> Unit,
) {
val clickModifier = if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier
Card(
modifier = modifier.fillMaxWidth().then(clickModifier),
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
) {
Column(modifier = Modifier.padding(16.dp), content = content)
}
}
```
- [ ] **Step 2: 写 `RingProgress.kt`**
```kotlin
package com.placeholder.ringhealth.core.designsystem.component
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* 环形进度(首页大环与详情页复用)。progress 取 0f..1f。
* content 渲染在环中心。
*/
@Composable
fun RingProgress(
progress: Float,
color: Color,
trackColor: Color,
modifier: Modifier = Modifier,
ringSize: Dp = 180.dp,
strokeWidth: Dp = 14.dp,
content: @Composable () -> Unit = {},
) {
Box(modifier = modifier.size(ringSize), contentAlignment = Alignment.Center) {
Canvas(modifier = Modifier.size(ringSize)) {
val stroke = strokeWidth.toPx()
val inset = stroke / 2f
val arcSize = Size(this.size.width - stroke, this.size.height - stroke)
drawArc(
color = trackColor,
startAngle = 0f,
sweepAngle = 360f,
useCenter = false,
topLeft = Offset(inset, inset),
size = arcSize,
style = Stroke(width = stroke, cap = StrokeCap.Round),
)
drawArc(
color = color,
startAngle = -90f,
sweepAngle = 360f * progress.coerceIn(0f, 1f),
useCenter = false,
topLeft = Offset(inset, inset),
size = arcSize,
style = Stroke(width = stroke, cap = StrokeCap.Round),
)
}
content()
}
}
```
- [ ] **Step 3: 写 `MetricTile.kt`**
```kotlin
package com.placeholder.ringhealth.core.designsystem.component
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.placeholder.ringhealth.core.designsystem.MetricTileValueStyle
import com.placeholder.ringhealth.core.designsystem.TextSecondary
/**
* 单个健康指标小卡:图标 + 标题 + 数值 + 单位。
*/
@Composable
fun MetricTile(
icon: ImageVector,
iconTint: Color,
title: String,
value: String,
unit: String,
modifier: Modifier = Modifier,
onClick: () -> Unit = {},
) {
HealthCard(modifier = modifier, onClick = onClick) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(imageVector = icon, contentDescription = null, tint = iconTint, modifier = Modifier.size(18.dp))
Text(
text = title,
style = androidx.compose.material3.MaterialTheme.typography.bodyMedium,
color = TextSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(start = 6.dp),
)
}
Column(modifier = Modifier.padding(top = 6.dp), verticalArrangement = Arrangement.Bottom) {
Row(verticalAlignment = Alignment.Bottom) {
Text(text = value, style = MetricTileValueStyle)
Text(
text = unit,
style = androidx.compose.material3.MaterialTheme.typography.bodySmall,
color = TextSecondary,
modifier = Modifier.padding(start = 4.dp, bottom = 3.dp),
)
}
}
}
}
```
- [ ] **Step 4: 写 `SectionHeader.kt`**
```kotlin
package com.placeholder.ringhealth.core.designsystem.component
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.placeholder.ringhealth.core.designsystem.TextSecondary
/**
* 区块标题,可选右侧扩展内容(如“查看全部”)。
*/
@Composable
fun SectionHeader(
title: String,
modifier: Modifier = Modifier,
trailing: (@Composable () -> Unit)? = null,
) {
Row(
modifier = modifier.fillMaxWidth().padding(vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(text = title, style = MaterialTheme.typography.titleMedium)
trailing?.invoke()
}
}
```
- [ ] **Step 5: 写 `StatBar.kt`**
```kotlin
package com.placeholder.ringhealth.core.designsystem.component
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/** 分段条的一段:权重决定宽度。 */
data class StatSegment(val weight: Float, val color: Color)
/**
* 横向分段条,用于睡眠分期(深睡/浅睡/清醒)等占比展示。
*/
@Composable
fun StatBar(
segments: List<StatSegment>,
modifier: Modifier = Modifier,
barHeight: Dp = 12.dp,
) {
Row(
modifier = modifier
.fillMaxWidth()
.height(barHeight)
.clip(RoundedCornerShape(barHeight / 2)),
) {
segments.forEach { segment ->
androidx.compose.foundation.layout.Box(
modifier = Modifier
.weight(segment.weight.coerceAtLeast(0.0001f))
.fillMaxWidth()
.height(barHeight)
.background(segment.color),
)
}
}
}
```
- [ ] **Step 6: 写 `MetricBarChart.kt`**
```kotlin
package com.placeholder.ringhealth.core.designsystem.component
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* 极简柱状图(Compose Canvas 手绘,无第三方依赖)。
* values 为空时不绘制;最高的一根高亮,其余半透明。
*/
@Composable
fun MetricBarChart(
values: List<Float>,
color: Color,
modifier: Modifier = Modifier,
barHeight: Dp = 120.dp,
highlightLast: Boolean = true,
) {
Canvas(modifier = modifier.fillMaxWidth().height(barHeight)) {
if (values.isEmpty()) return@Canvas
val maxValue = values.max().coerceAtLeast(1f)
val gapRatio = 0.35f
val slot = size.width / values.size
val barWidth = slot * (1f - gapRatio)
val highlightIndex = if (highlightLast) values.lastIndex else values.indexOf(maxValue)
values.forEachIndexed { index, value ->
val ratio = (value / maxValue).coerceIn(0f, 1f)
val h = size.height * ratio
val isHighlight = index == highlightIndex
drawRoundRect(
color = if (isHighlight) color else color.copy(alpha = 0.3f),
topLeft = Offset(x = index * slot + (slot - barWidth) / 2f, y = size.height - h),
size = Size(width = barWidth, height = h),
cornerRadius = androidx.compose.ui.geometry.CornerRadius(barWidth / 2f, barWidth / 2f),
)
}
}
}
```
- [ ] **Step 7: 构建验证**
```bash
cd /d/cherry/repos/ring-health-app && ./gradlew assembleDebug
```
Expected: `BUILD SUCCESSFUL`(组件尚未被引用,仅验证编译通过)。
- [ ] **Step 8: Commit**
```bash
git add -A && git commit -m "feat: 新增通用 UI 组件(卡片/环形进度/指标块/分段条/柱状图)"
```
---
### Task 4: 领域模型 + 仓库接口
**Files:**
- Create: `domain/model/HealthModels.kt`, `domain/repository/Repositories.kt`
> **范围说明(见 ledger Ruling R1 / R2)**:依赖容器 `AppContainer.kt` 与 `AppViewModelProvider.kt`
> **不在本任务创建**——前者引用 Task 6 才存在的 Mock 实现类,后者引用 Task 8 才存在的 `HomeViewModel`。
> 若在本任务创建,Task 4 到 Task 6 之间的任何构建或单测都会因未解析引用而无法编译。
**Interfaces:**
- Consumes: 无(纯 Kotlin 层)
- Produces:
- `enum class MetricType { STEP, HEART_RATE, SLEEP, BLOOD_OXYGEN, STRESS, TEMPERATURE, BLOOD_PRESSURE }`
- `enum class DateRange { WEEK, MONTH }`
- `data class RingDeviceState(connected, name, batteryPercent, firmwareVersion, charging)`
- `data class HealthSummary(steps, stepGoal, calories, distanceKm, heartRate?, sleepMinutes?, bloodOxygen?, stress?, temperature?, bloodPressure?)`
- `data class MetricPoint(timestamp: Long, value: Double)`
- `data class WorkoutRecord(id, type, startTime, durationMinutes, distanceKm, calories)`
- `data class DeviceFunctionSetting(id, title, subtitle, enabled)`
- `interface HealthDataRepository { observeTodaySummary(); observeMetricHistory(metric, range); observeWorkouts() }`
- `interface RingDeviceRepository { deviceState; functionSettings; refreshDeviceState(); setFunctionEnabled(id, enabled) }`
- [ ] **Step 1: 写 `HealthModels.kt`**
```kotlin
package com.placeholder.ringhealth.domain.model
/** 健康指标类型。路由与详情页以此区分。 */
enum class MetricType {
STEP,
HEART_RATE,
SLEEP,
BLOOD_OXYGEN,
STRESS,
TEMPERATURE,
BLOOD_PRESSURE,
}
/** 历史数据的时间范围。 */
enum class DateRange { WEEK, MONTH }
/**
* 戒指设备状态。M2 接入蓝牙后由真实 SDK 填充,当前由 Mock 提供。
*/
data class RingDeviceState(
val connected: Boolean,
val name: String,
val batteryPercent: Int,
val firmwareVersion: String,
val charging: Boolean,
)
/**
* 今日健康概览。字段为 null 表示该指标未开启或暂无数据,UI 需展示“未开启”占位而非 0。
*/
data class HealthSummary(
val steps: Int,
val stepGoal: Int,
val calories: Int,
val distanceKm: Double,
val heartRate: Int?,
val sleepMinutes: Int?,
val bloodOxygen: Int?,
val stress: Int?,
val temperature: Double?,
val bloodPressure: String?,
)
/** 一条历史数据点。 */
data class MetricPoint(val timestamp: Long, val value: Double)
/** 一条运动记录。 */
data class WorkoutRecord(
val id: String,
val type: String,
val startTime: Long,
val durationMinutes: Int,
val distanceKm: Double,
val calories: Int,
)
/** 戒指功能开关项(对应协议中的自动监测开关)。 */
data class DeviceFunctionSetting(
val id: String,
val title: String,
val subtitle: String,
val enabled: Boolean,
)
```
- [ ] **Step 2: 写 `Repositories.kt`**
```kotlin
package com.placeholder.ringhealth.domain.repository
import com.placeholder.ringhealth.domain.model.DateRange
import com.placeholder.ringhealth.domain.model.DeviceFunctionSetting
import com.placeholder.ringhealth.domain.model.HealthSummary
import com.placeholder.ringhealth.domain.model.MetricPoint
import com.placeholder.ringhealth.domain.model.MetricType
import com.placeholder.ringhealth.domain.model.RingDeviceState
import com.placeholder.ringhealth.domain.model.WorkoutRecord
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
/**
* 健康数据来源。M1 由 Mock 实现,M2/M3 由厂商 SDK 适配层实现,UI 无需改动。
*/
interface HealthDataRepository {
/** 今日概览,随数据变化持续发射。 */
fun observeTodaySummary(): Flow<HealthSummary>
/** 指定指标在指定时间范围内的历史数据。 */
fun observeMetricHistory(metric: MetricType, range: DateRange): Flow<List<MetricPoint>>
/** 运动记录列表。 */
fun observeWorkouts(): Flow<List<WorkoutRecord>>
}
/**
* 戒指设备状态与功能开关。M2 接入蓝牙后由真实实现替换。
*/
interface RingDeviceRepository {
/** 设备状态(连接/电量/固件)。 */
val deviceState: StateFlow<RingDeviceState>
/** 功能开关列表。 */
val functionSettings: StateFlow<List<DeviceFunctionSetting>>
/** 主动刷新设备状态。 */
suspend fun refreshDeviceState()
/** 切换某个功能开关(M1 仅改内存状态)。 */
fun setFunctionEnabled(id: String, enabled: Boolean)
}
```
- [ ] **Step 3: 构建验证**
```bash
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: 新增领域模型与仓库接口"
```
---
### Task 5: 数值格式化工具(TDD)
**Files:**
- Create: `app/src/main/java/com/placeholder/ringhealth/core/common/Formatters.kt`
- Test: `app/src/test/java/com/placeholder/ringhealth/core/common/FormattersTest.kt`
**Interfaces:**
- Consumes: 无
- Produces(页面与组件通用):
- `formatSteps(steps: Int): String`
- `formatDistance(km: Double): String`
- `formatCalories(kcal: Int): String`
- `formatSleepDuration(minutes: Int): String`
- `formatDuration(minutes: Int): String`
- `formatClock(timestamp: Long): String`
- `formatDate(timestamp: Long): String`
- `stressLevelLabelRes(stress: Int): Int`(带 `@StringRes` 标注)
> **范围说明(见 ledger Ruling R3)**:本任务**不提供** `metricDisplayName(type)`。
> 指标中文名属于用户可见文案,Global Constraints 要求一律进 `strings.xml`;
> 把它硬编码在 Kotlin 里会与 Task 8 加入 `R.string.metric_*` 形成两份事实来源。
> 详情页标题改用资源 id 映射,见 Task 12。
>
> **范围说明(见 ledger Ruling R13)**:本任务**不得**在 Kotlin 里拼接用户可见文字,两处按此调整:
> ①`formatCalories` 只返回千分位数字,「千卡」由调用点用资源拼接 —— 硬编码会与
> `R.string.home_calories_label` 形成两份事实来源,正是 R3 排除 `metricDisplayName` 的同一条理由;
> ②压力等级**改为返回字符串资源 id**(`stressLevelLabelRes`),中文描述进 `strings.xml`,
> 与 R3 对指标名的处理保持一致。**唯一豁免**见 Global Constraints 的说明
> (`formatSleepDuration` / `formatDuration` 的「7小时32分」这类数字+单位黏合复合值)。
> 本任务同时负责新增这几条字符串资源(见 Step 3)。
- [ ] **Step 1: 写失败的测试**
`app/src/test/java/com/placeholder/ringhealth/core/common/FormattersTest.kt`:
```kotlin
package com.placeholder.ringhealth.core.common
import com.placeholder.ringhealth.R
import org.junit.Assert.assertEquals
import org.junit.Test
class FormattersTest {
@Test
fun `formatSleepDuration 处理小时与分钟`() {
assertEquals("7小时32分", formatSleepDuration(452))
assertEquals("45分", formatSleepDuration(45))
assertEquals("8小时", formatSleepDuration(480))
assertEquals("0分", formatSleepDuration(0))
}
@Test
fun `formatDuration 处理小时与分钟`() {
assertEquals("1小时05分", formatDuration(65))
assertEquals("42分", formatDuration(42))
}
@Test
fun `formatSteps 添加千分位`() {
assertEquals("8,426", formatSteps(8426))
assertEquals("0", formatSteps(0))
assertEquals("1,234,567", formatSteps(1234567))
}
@Test
fun `formatDistance 保留两位小数`() {
assertEquals("6.21", formatDistance(6.212))
assertEquals("0.00", formatDistance(0.0))
}
@Test
fun `formatCalories 只返回千分位数字`() {
assertEquals("327", formatCalories(327))
assertEquals("1,234", formatCalories(1234))
}
@Test
fun `stressLevelLabelRes 按区间映射到对应字符串资源`() {
assertEquals(R.string.stress_relaxed, stressLevelLabelRes(20))
assertEquals(R.string.stress_normal, stressLevelLabelRes(45))
assertEquals(R.string.stress_medium, stressLevelLabelRes(65))
assertEquals(R.string.stress_high, stressLevelLabelRes(90))
}
}
```
- [ ] **Step 2: 运行测试,确认失败**
```bash
cd /d/cherry/repos/ring-health-app && ./gradlew :app:testDebugUnitTest --tests "com.placeholder.ringhealth.core.common.FormattersTest"
```
Expected: 编译失败或测试失败(`formatSleepDuration` 等未定义 / `Unresolved reference`)。
- [ ] **Step 3: 写最小实现**
`app/src/main/java/com/placeholder/ringhealth/core/common/Formatters.kt`:
```kotlin
package com.placeholder.ringhealth.core.common
import androidx.annotation.StringRes
import com.placeholder.ringhealth.R
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/** 步数:千分位,如 8,426。 */
fun formatSteps(steps: Int): String = String.format(Locale.CHINA, "%,d", steps)
/** 距离:保留两位小数(单位 km 由 UI 单独展示)。 */
fun formatDistance(km: Double): String = String.format(Locale.CHINA, "%.2f", km)
/** 卡路里:只做千分位,单位「千卡」由 UI 用字符串资源拼接(ledger Ruling R13)。 */
fun formatCalories(kcal: Int): String = String.format(Locale.CHINA, "%,d", kcal)
/** 睡眠时长:7小时32分 / 45分 / 8小时。 */
fun formatSleepDuration(minutes: Int): String {
val hours = minutes / 60
val mins = minutes % 60
return when {
hours > 0 && mins > 0 -> "${hours}小时${mins}分"
hours > 0 -> "${hours}小时"
else -> "${mins}分"
}
}
/** 运动时长:1小时05分 / 42分(分钟补零)。 */
fun formatDuration(minutes: Int): String {
val hours = minutes / 60
val mins = minutes % 60
return if (hours > 0) {
String.format(Locale.CHINA, "%d小时%02d分", hours, mins)
} else {
"${mins}分"
}
}
/** 时间戳 → HH:mm。 */
fun formatClock(timestamp: Long): String =
SimpleDateFormat("HH:mm", Locale.CHINA).format(Date(timestamp))
/** 时间戳 → yyyy-MM-dd。 */
fun formatDate(timestamp: Long): String =
SimpleDateFormat("yyyy-MM-dd", Locale.CHINA).format(Date(timestamp))
/** 压力值 → 字符串资源 id。中文描述的唯一来源是 `strings.xml`(ledger Ruling R13)。 */
@StringRes
fun stressLevelLabelRes(stress: Int): Int = when {
stress < 30 -> R.string.stress_relaxed
stress < 60 -> R.string.stress_normal
stress < 80 -> R.string.stress_medium
else -> R.string.stress_high
}
```
同时把本任务新增的文案追加进 `app/src/main/res/values/strings.xml` 的 `<resources>` 内:
```xml
<string name="stress_relaxed">放松</string>
<string name="stress_normal">正常</string>
<string name="stress_medium">中等</string>
<string name="stress_high">偏高</string>
```
- [ ] **Step 4: 运行测试,确认通过**
```bash
cd /d/cherry/repos/ring-health-app && ./gradlew :app:testDebugUnitTest --tests "com.placeholder.ringhealth.core.common.FormattersTest"
```
Expected: `BUILD SUCCESSFUL`,6 个测试全部通过。
- [ ] **Step 5: Commit**
```bash
git add -A && git commit -m "feat: 新增数值格式化工具(含单元测试)"
```
---
### Task 6: Mock 数据层(TDD)
**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, now: () -> Long = System::currentTimeMillis)`,方法:`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)
> **范围说明(见 ledger Ruling R17)**:`MockDataProvider` 增加一个**可注入的时间源** `now: () -> Long`
> (默认 `System::currentTimeMillis`),`metricHistory` / `workouts` 内一律用 `now()` 而非
> `System.currentTimeMillis()`。原因:本类的 KDoc 承诺「所有方法都是确定性的(同参数返回同结果)」,
> 但直接读挂钟时 `timestamp` 会随毫秒跳动 —— brief 原先的测试 `assertEquals(first, second)`
> 因此是一条**间歇失败的 flaky 断言**(两次调用跨毫秒即挂)。注入时间源后该承诺才成立,
> 测试也才能传固定时钟、真正验证「可重复」。这是本任务唯一的接口增补。
- [ ] **Step 1: 写失败的测试**
`app/src/test/java/com/placeholder/ringhealth/data/mock/MockDataProviderTest.kt`:
```kotlin
package com.placeholder.ringhealth.data.mock
import com.placeholder.ringhealth.domain.model.DateRange
import com.placeholder.ringhealth.domain.model.MetricType
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class MockDataProviderTest {
/**
* 固定时钟。Mock 的「确定性」承诺依赖时间源固定,否则 `timestamp` 会随毫秒跳动
* (ledger Ruling R17)。
*/
private val provider = MockDataProvider(now = { FIXED_NOW })
private companion object {
/** 固定的“当前时间”,让历史数据的时间戳可复现。 */
const val FIXED_NOW = 1_760_000_000_000L
}
@Test
fun `今日概览返回固定演示数据`() {
val summary = provider.todaySummary()
assertEquals(8426, summary.steps)
assertEquals(10000, summary.stepGoal)
assertEquals(327, summary.calories)
assertEquals(72, summary.heartRate)
assertEquals(452, summary.sleepMinutes)
// 血压未开启,应为 null
assertEquals(null, summary.bloodPressure)
}
@Test
fun `历史数据长度与范围一致`() {
assertEquals(7, provider.metricHistory(MetricType.STEP, DateRange.WEEK).size)
assertEquals(30, provider.metricHistory(MetricType.STEP, DateRange.MONTH).size)
}
@Test
fun `历史数据可重复生成且值合理`() {
val first = provider.metricHistory(MetricType.HEART_RATE, DateRange.WEEK)
val second = provider.metricHistory(MetricType.HEART_RATE, DateRange.WEEK)
assertEquals(first, second)
assertTrue(first.all { it.value > 40.0 && it.value < 180.0 })
// 时间戳须严格递增(“每点间隔一天”倒推,最早的在最前)
assertTrue(first.zipWithNext().all { (a, b) -> b.timestamp > a.timestamp })
// 断言绝对时间戳,让注入的时钟真正成为被测对象:若退回 System.currentTimeMillis(),
// 这两条会立刻失败(ledger R17 / R20)。只比较 first == second 是不够的 ——
// 两次相邻调用几乎总落在同一毫秒,那时去掉时钟注入测试照样通过。
assertEquals(FIXED_NOW, first.last().timestamp)
assertEquals(FIXED_NOW - 6L * 24 * 60 * 60 * 1000, first.first().timestamp)
}
@Test
fun `设备状态与开关数据合法`() {
val device = provider.deviceState()
assertTrue(device.batteryPercent in 0..100)
assertEquals(true, device.connected)
assertEquals(5, provider.defaultFunctionSettings().size)
}
@Test
fun `运动记录非空且字段合理`() {
val records = provider.workouts()
assertTrue(records.isNotEmpty())
assertTrue(records.all { it.durationMinutes > 0 && it.calories > 0 })
}
}
```
- [ ] **Step 2: 运行测试,确认失败**
```bash
cd /d/cherry/repos/ring-health-app && ./gradlew :app:testDebugUnitTest --tests "com.placeholder.ringhealth.data.mock.MockDataProviderTest"
```
Expected: `Unresolved reference: MockDataProvider`,编译失败。
- [ ] **Step 3: 写 `MockDataProvider.kt`**
```kotlin
package com.placeholder.ringhealth.data.mock
import com.placeholder.ringhealth.domain.model.DateRange
import com.placeholder.ringhealth.domain.model.DeviceFunctionSetting
import com.placeholder.ringhealth.domain.model.HealthSummary
import com.placeholder.ringhealth.domain.model.MetricPoint
import com.placeholder.ringhealth.domain.model.MetricType
import com.placeholder.ringhealth.domain.model.RingDeviceState
import com.placeholder.ringhealth.domain.model.WorkoutRecord
import kotlin.random.Random
/**
* 演示数据生成器。所有方法都是确定性的(同参数、同时刻返回同结果),
* 保证页面展示与截图稳定。M2 接入真机后本类整体弃用。
*
* @param seed 随机种子,决定历史数据的取值序列。
* @param now 时间源,默认取系统时钟;测试注入固定值后可得到完全可复现的时间戳
* (ledger Ruling R17)。
*/
class MockDataProvider(
private val seed: Long = DEFAULT_SEED,
private val now: () -> Long = System::currentTimeMillis,
) {
/** 今日概览:固定演示值。血压为 null 以演示“未开启”占位态。 */
fun todaySummary(): HealthSummary = HealthSummary(
steps = 8426,
stepGoal = 10000,
calories = 327,
distanceKm = 6.21,
heartRate = 72,
sleepMinutes = 452,
bloodOxygen = 98,
stress = 34,
temperature = 36.5,
bloodPressure = null,
)
/**
* 指定指标的历史数据:WEEK 返回 7 个点,MONTH 返回 30 个点。
* 时间戳按“每点间隔一天”倒推,值在指标合理区间内波动。
*/
fun metricHistory(metric: MetricType, range: DateRange): List<MetricPoint> {
val count = if (range == DateRange.WEEK) 7 else 30
val random = Random(seed + metric.ordinal * 100L + range.ordinal)
val (min, max) = valueRange(metric)
val current = now()
val dayMillis = 24L * 60 * 60 * 1000
return (0 until count).map { index ->
val progress = index.toFloat() / (count - 1).coerceAtLeast(1)
val value = min + (max - min) * progress + random.nextDouble(-1.0, 1.0) * (max - min) * 0.15
MetricPoint(
timestamp = current - (count - 1 - index) * dayMillis,
value = value.coerceIn(min, max),
)
}
}
/** 运动记录:固定 5 条演示数据,倒序(最近的在最前)。 */
fun workouts(): List<WorkoutRecord> {
val random = Random(seed + 7)
val current = now()
val dayMillis = 24L * 60 * 60 * 1000
val types = listOf("户外跑步", "健走", "骑行", "自由训练", "户外跑步")
return types.mapIndexed { index, type ->
WorkoutRecord(
id = "workout-$index",
type = type,
startTime = current - (index + 1) * dayMillis,
durationMinutes = 25 + random.nextInt(0, 45),
distanceKm = 2.0 + random.nextDouble(0.0, 5.0),
calories = 120 + random.nextInt(0, 260),
)
}
}
/** 戒指设备状态:固定演示值。 */
fun deviceState(): RingDeviceState = RingDeviceState(
connected = true,
name = "RingHealth 智能戒指",
batteryPercent = 78,
firmwareVersion = "1.0.5",
charging = false,
)
/** 五个功能开关,对应协议中的自动监测能力。 */
fun defaultFunctionSettings(): List<DeviceFunctionSetting> = listOf(
DeviceFunctionSetting("heart_rate", "自动心率监测", "每 10 分钟测量一次", true),
DeviceFunctionSetting("blood_oxygen", "自动血氧监测", "夜间持续监测", true),
DeviceFunctionSetting("sleep", "睡眠监测", "自动识别睡眠时段", true),
DeviceFunctionSetting("stress", "压力监测", "全天候压力评估", false),
DeviceFunctionSetting("blood_pressure", "血压监测", "需在 App 内手动校准", false),
)
/** 各指标的取值区间,用于生成合理的历史数据。 */
private fun valueRange(metric: MetricType): Pair<Double, Double> = when (metric) {
MetricType.STEP -> 3000.0 to 12000.0
MetricType.HEART_RATE -> 58.0 to 96.0
MetricType.SLEEP -> 330.0 to 510.0
MetricType.BLOOD_OXYGEN -> 95.0 to 99.0
MetricType.STRESS -> 20.0 to 70.0
MetricType.TEMPERATURE -> 36.1 to 36.9
MetricType.BLOOD_PRESSURE -> 110.0 to 130.0
}
companion object {
/** 默认随机种子,保证演示数据稳定。 */
const val DEFAULT_SEED: Long = 20260910L
}
}
```
- [ ] **Step 4: 运行测试,确认通过**
```bash
cd /d/cherry/repos/ring-health-app && ./gradlew :app:testDebugUnitTest --tests "com.placeholder.ringhealth.data.mock.MockDataProviderTest"
```
Expected: `BUILD SUCCESSFUL`,5 个测试全部通过。
- [ ] **Step 5: 写 `MockHealthDataRepository.kt`**
```kotlin
package com.placeholder.ringhealth.data.mock
import com.placeholder.ringhealth.domain.model.DateRange
import com.placeholder.ringhealth.domain.model.HealthSummary
import com.placeholder.ringhealth.domain.model.MetricPoint
import com.placeholder.ringhealth.domain.model.MetricType
import com.placeholder.ringhealth.domain.model.WorkoutRecord
import com.placeholder.ringhealth.domain.repository.HealthDataRepository
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
/**
* Mock 健康数据仓库。模拟 200ms 网络/设备延迟,便于阶段一验证加载态。
*/
class MockHealthDataRepository(
private val provider: MockDataProvider = MockDataProvider(),
) : HealthDataRepository {
override fun observeTodaySummary(): Flow<HealthSummary> = flow {
delay(MOCK_DELAY_MILLIS)
emit(provider.todaySummary())
}
override fun observeMetricHistory(metric: MetricType, range: DateRange): Flow<List<MetricPoint>> = flow {
delay(MOCK_DELAY_MILLIS)
emit(provider.metricHistory(metric, range))
}
override fun observeWorkouts(): Flow<List<WorkoutRecord>> = flow {
delay(MOCK_DELAY_MILLIS)
emit(provider.workouts())
}
private companion object {
const val MOCK_DELAY_MILLIS = 200L
}
}
```
- [ ] **Step 6: 写 `MockRingDeviceRepository.kt`**
```kotlin
package com.placeholder.ringhealth.data.mock
import com.placeholder.ringhealth.domain.model.DeviceFunctionSetting
import com.placeholder.ringhealth.domain.model.RingDeviceState
import com.placeholder.ringhealth.domain.repository.RingDeviceRepository
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
/**
* Mock 设备仓库。开关状态保存在内存中,进程重启后重置(阶段一不做持久化)。
*
* 注意 [refreshDeviceState] 的语义:它以 **suspend 函数正常返回**表示刷新完成,
* 而**不是**靠发射新状态。`MutableStateFlow` 会抑制相等值,M1 的设备状态是常量,
* 因此刷新不会产生任何可观察的发射。调用方要展示刷新反馈,应当
* `launch { repo.refreshDeviceState(); isRefreshing = false }`,
* **不要**写成「等待下一个不同的 deviceState 发射」(那样在 M1 会永远等不到)。
*/
class MockRingDeviceRepository(
private val provider: MockDataProvider = MockDataProvider(),
) : RingDeviceRepository {
private val _deviceState = MutableStateFlow(provider.deviceState())
override val deviceState: StateFlow<RingDeviceState> = _deviceState.asStateFlow()
private val _functionSettings = MutableStateFlow(provider.defaultFunctionSettings())
override val functionSettings: StateFlow<List<DeviceFunctionSetting>> = _functionSettings.asStateFlow()
override suspend fun refreshDeviceState() {
delay(REFRESH_DELAY_MILLIS)
_deviceState.value = provider.deviceState()
}
override fun setFunctionEnabled(id: String, enabled: Boolean) {
_functionSettings.update { settings ->
settings.map { if (it.id == id) it.copy(enabled = enabled) else it }
}
}
private companion object {
const val REFRESH_DELAY_MILLIS = 300L
}
}
```
- [ ] **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`(Step 7 的容器代码也在本次编译范围内)。
- [ ] **Step 9: Commit**
```bash
git add -A && git commit -m "feat: 新增 Mock 数据层与依赖容器(含单元测试)"
```
---
### Task 7: 导航骨架(底部四 Tab + 空页面)
**Files:**
- Create: `core/navigation/Routes.kt`, `core/navigation/RingHealthApp.kt`
- Create: `feature/home/HomeScreen.kt`(占位)、`feature/workout/WorkoutScreen.kt`(占位)、`feature/device/DeviceScreen.kt`(占位)、`feature/profile/ProfileScreen.kt`(占位)
- Modify: `MainActivity.kt`
- Modify: `res/values/strings.xml`(新增 Tab 文案)
**Interfaces:**
- Consumes: Task 2/3 的主题与组件
- Produces:
- `object Routes { HOME, WORKOUT, DEVICE, PROFILE, DEVICE_SETTINGS, WORKOUT_HISTORY, DETAIL_PATTERN; fun detail(metric: MetricType): String }`
- `@Composable fun RingHealthApp()`——Scaffold + NavigationBar + NavHost
- 四个 Tab 的**占位** Composable:`HomeScreen(onOpenDetail: (MetricType) -> Unit, modifier)`、
`WorkoutScreen(modifier)`、`DeviceScreen(modifier)`、`ProfileScreen(modifier)`
(`modifier: Modifier = Modifier`,实际签名以本任务 Step 2 的代码为准)。
**注意**:`Routes.DEVICE_SETTINGS` 与 `Routes.WORKOUT_HISTORY` 在本任务**只注册、无人跳转** ——
`WorkoutScreen` 的 `onOpenHistory` 与 `DeviceScreen` 的 `onOpenSettings` 分别在 Task 9 / Task 10
才加入签名,那两个 Task 会同步更新本任务 `RingHealthApp.kt` 里的调用点。
计划早期版本在本行写过一个 `HomeScreen` 的 `onOpenWorkoutHistory` 参数,
全计划没有任何调用点(Task 8 产出的是 `HomeScreen(onOpenDetail, modifier, viewModel)`),
属笔误,已删除 —— 不要把它加回来。
- [ ] **Step 1: 写 `Routes.kt`**
```kotlin
package com.placeholder.ringhealth.core.navigation
import com.placeholder.ringhealth.domain.model.MetricType
/**
* 全局路由表。字符串集中定义,避免散落在各处。
*/
object Routes {
const val HOME = "home"
const val WORKOUT = "workout"
const val DEVICE = "device"
const val PROFILE = "profile"
const val DEVICE_SETTINGS = "device/settings"
const val WORKOUT_HISTORY = "workout/history"
/** 指标详情路由模板,参数为 MetricType.name。 */
const val DETAIL_PATTERN = "detail/{metric}"
const val DETAIL_ARG_METRIC = "metric"
/** 拼出某个指标的详情路由。 */
fun detail(metric: MetricType): String = "detail/${metric.name}"
}
```
- [ ] **Step 2: 写四个占位页面(后续 Task 替换为完整实现)**
`feature/home/HomeScreen.kt`:
```kotlin
package com.placeholder.ringhealth.feature.home
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.placeholder.ringhealth.R
import com.placeholder.ringhealth.domain.model.MetricType
/** 健康首页(占位,待 Task 8 实现)。 */
@Composable
fun HomeScreen(
onOpenDetail: (MetricType) -> Unit,
modifier: Modifier = Modifier,
) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(stringResource(R.string.tab_home))
}
}
```
`feature/workout/WorkoutScreen.kt`:
```kotlin
package com.placeholder.ringhealth.feature.workout
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.placeholder.ringhealth.R
/** 运动页(占位,待 Task 9 实现)。 */
@Composable
fun WorkoutScreen(modifier: Modifier = Modifier) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(stringResource(R.string.tab_workout))
}
}
```
`feature/device/DeviceScreen.kt`:
```kotlin
package com.placeholder.ringhealth.feature.device
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.placeholder.ringhealth.R
/** 设备页(占位,待 Task 10 实现)。 */
@Composable
fun DeviceScreen(modifier: Modifier = Modifier) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(stringResource(R.string.tab_device))
}
}
```
`feature/profile/ProfileScreen.kt`:
```kotlin
package com.placeholder.ringhealth.feature.profile
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.placeholder.ringhealth.R
/** 我的页(占位,待 Task 11 实现)。 */
@Composable
fun ProfileScreen(modifier: Modifier = Modifier) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(stringResource(R.string.tab_profile))
}
}
```
- [ ] **Step 3: 写 `strings.xml` 的导航文案**
在 `res/values/strings.xml` 的 `<resources>` 内追加:
```xml
<string name="tab_home">健康</string>
<string name="tab_workout">运动</string>
<string name="tab_device">设备</string>
<string name="tab_profile">我的</string>
<string name="placeholder_device_settings">设备设置</string>
<string name="placeholder_workout_history">运动历史</string>
<string name="placeholder_metric_detail">指标详情</string>
```
- [ ] **Step 4: 写 `RingHealthApp.kt`**
```kotlin
package com.placeholder.ringhealth.core.navigation
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.FitnessCenter
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.placeholder.ringhealth.R
import com.placeholder.ringhealth.domain.model.MetricType
import com.placeholder.ringhealth.feature.device.DeviceScreen
import com.placeholder.ringhealth.feature.device.DeviceSettingsPlaceholder
import com.placeholder.ringhealth.feature.detail.MetricDetailPlaceholder
import com.placeholder.ringhealth.feature.home.HomeScreen
import com.placeholder.ringhealth.feature.profile.ProfileScreen
import com.placeholder.ringhealth.feature.workout.WorkoutHistoryPlaceholder
import com.placeholder.ringhealth.feature.workout.WorkoutScreen
/** 底部导航项定义。 */
private data class BottomTab(
val route: String,
val labelRes: Int,
val icon: ImageVector,
)
private val BOTTOM_TABS = listOf(
BottomTab(Routes.HOME, R.string.tab_home, Icons.Filled.Home),
BottomTab(Routes.WORKOUT, R.string.tab_workout, Icons.Filled.FitnessCenter),
BottomTab(Routes.DEVICE, R.string.tab_device, Icons.Filled.Settings),
BottomTab(Routes.PROFILE, R.string.tab_profile, Icons.Filled.Person),
)
/**
* 应用主界面:Scaffold + 底部导航 + NavHost。
*/
@Composable
fun RingHealthApp(navController: NavHostController = rememberNavController()) {
val backStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = backStackEntry?.destination?.route
Scaffold(
containerColor = MaterialTheme.colorScheme.background,
bottomBar = {
NavigationBar(containerColor = MaterialTheme.colorScheme.surface) {
BOTTOM_TABS.forEach { tab ->
NavigationBarItem(
selected = currentRoute == tab.route,
onClick = { navController.navigateToTab(tab.route) },
icon = { Icon(imageVector = tab.icon, contentDescription = null) },
label = { Text(stringResource(tab.labelRes)) },
)
}
}
},
) { innerPadding ->
NavHost(
navController = navController,
startDestination = Routes.HOME,
modifier = Modifier.padding(innerPadding),
) {
composable(Routes.HOME) {
HomeScreen(onOpenDetail = { metric -> navController.navigate(Routes.detail(metric)) })
}
composable(Routes.WORKOUT) {
WorkoutScreen()
}
composable(Routes.DEVICE) {
DeviceScreen()
}
composable(Routes.PROFILE) {
ProfileScreen()
}
composable(Routes.DEVICE_SETTINGS) {
DeviceSettingsPlaceholder()
}
composable(Routes.WORKOUT_HISTORY) {
WorkoutHistoryPlaceholder()
}
composable(
route = Routes.DETAIL_PATTERN,
arguments = listOf(navArgument(Routes.DETAIL_ARG_METRIC) { type = NavType.StringType }),
) { entry ->
val raw = entry.arguments?.getString(Routes.DETAIL_ARG_METRIC).orEmpty()
val metric = MetricType.entries.firstOrNull { it.name == raw } ?: MetricType.STEP
MetricDetailPlaceholder(metric = metric)
}
}
}
}
/** 底部 Tab 切换:保留状态、避免回退栈堆积。 */
private fun NavHostController.navigateToTab(route: String) {
navigate(route) {
popUpTo(graph.findStartDestination().id) { saveState = true }
launchSingleTop = true
restoreState = true
}
}
```
- [ ] **Step 5: 创建三个占位 Composable(Task 10/12 替换)**
`feature/device/DeviceSettingsScreen.kt`(本 Task 仅占位):
```kotlin
package com.placeholder.ringhealth.feature.device
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.placeholder.ringhealth.R
/** 设备设置子页(占位,待 Task 10 实现)。 */
@Composable
fun DeviceSettingsPlaceholder(modifier: Modifier = Modifier) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(stringResource(R.string.placeholder_device_settings))
}
}
```
`feature/workout/WorkoutHistoryScreen.kt`(本 Task 仅占位):
```kotlin
package com.placeholder.ringhealth.feature.workout
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.placeholder.ringhealth.R
/** 运动历史子页(占位,待 Task 9 实现)。 */
@Composable
fun WorkoutHistoryPlaceholder(modifier: Modifier = Modifier) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(stringResource(R.string.placeholder_workout_history))
}
}
```
`feature/detail/MetricDetailScreen.kt`(本 Task 仅占位):
```kotlin
package com.placeholder.ringhealth.feature.detail
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.placeholder.ringhealth.R
import com.placeholder.ringhealth.domain.model.MetricType
/** 指标详情子页(占位,待 Task 12 实现)。 */
@Composable
fun MetricDetailPlaceholder(metric: MetricType, modifier: Modifier = Modifier) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(stringResource(R.string.placeholder_metric_detail))
}
}
```
- [ ] **Step 6: `MainActivity` 接入 `RingHealthApp`**
```kotlin
setContent {
RingHealthTheme {
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
RingHealthApp()
}
}
}
```
- [ ] **Step 7: 构建 + 安装验证**
```bash
cd /d/cherry/repos/ring-health-app && ./gradlew assembleDebug
```
Expected: 构建成功并装到设备/模拟器;打开 App 可见底部四个 Tab,点击可切换、互不崩溃。
- [ ] **Step 8: Commit**
```bash
git add -A && git commit -m "feat: 新增底部四 Tab 导航骨架与路由表"
```
---
### Task 8: 健康首页(完整实现,含 ViewModel)
**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`、`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: 写失败的测试**
`app/src/test/java/com/placeholder/ringhealth/feature/home/HomeViewModelTest.kt`:
```kotlin
package com.placeholder.ringhealth.feature.home
import com.placeholder.ringhealth.data.mock.MockHealthDataRepository
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.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class HomeViewModelTest {
private val dispatcher = StandardTestDispatcher()
@Before
fun setUp() {
Dispatchers.setMain(dispatcher)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun `加载完成后输出今日概览与设备状态`() = runTest(dispatcher) {
val viewModel = HomeViewModel(MockHealthDataRepository(), MockRingDeviceRepository())
// uiState 用 SharingStarted.WhileSubscribed 构建,必须有订阅者才会启动上游数据流;
// 否则 .value 永远是初始值(loading = true)。用 backgroundScope 起一个收集者。
backgroundScope.launch { viewModel.uiState.collect {} }
advanceUntilIdle()
val state = viewModel.uiState.value
assertFalse(state.loading)
assertEquals(8426, state.summary?.steps)
assertEquals(78, state.device?.batteryPercent)
}
}
```
- [ ] **Step 2: 运行测试,确认失败**
```bash
cd /d/cherry/repos/ring-health-app && ./gradlew :app:testDebugUnitTest --tests "com.placeholder.ringhealth.feature.home.HomeViewModelTest"
```
Expected: `Unresolved reference: HomeViewModel`,编译失败。
- [ ] **Step 3: 写 `HomeViewModel.kt`**
```kotlin
package com.placeholder.ringhealth.feature.home
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.placeholder.ringhealth.domain.model.HealthSummary
import com.placeholder.ringhealth.domain.model.RingDeviceState
import com.placeholder.ringhealth.domain.repository.HealthDataRepository
import com.placeholder.ringhealth.domain.repository.RingDeviceRepository
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
/**
* 首页 UI 状态。summary/device 为 null 表示仍在加载。
*/
data class HomeUiState(
val loading: Boolean,
val summary: HealthSummary?,
val device: RingDeviceState?,
)
/**
* 健康首页 ViewModel:合并今日概览与设备状态。
*/
class HomeViewModel(
healthDataRepository: HealthDataRepository,
ringDeviceRepository: RingDeviceRepository,
) : ViewModel() {
val uiState: StateFlow<HomeUiState> = combine(
healthDataRepository.observeTodaySummary(),
ringDeviceRepository.deviceState,
) { summary, device ->
HomeUiState(loading = false, summary = summary, device = device)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(STOP_TIMEOUT_MILLIS),
initialValue = HomeUiState(loading = true, summary = null, device = null),
)
private companion object {
const val STOP_TIMEOUT_MILLIS = 5_000L
}
}
```
- [ ] **Step 4: 运行测试,确认通过**
```bash
cd /d/cherry/repos/ring-health-app && ./gradlew :app:testDebugUnitTest --tests "com.placeholder.ringhealth.feature.home.HomeViewModelTest"
```
Expected: `BUILD SUCCESSFUL`。
- [ ] **Step 5: 写 `AppViewModelProvider.kt`**
本任务是 `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
package com.placeholder.ringhealth.feature.home
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bloodtype
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.MonitorHeart
import androidx.compose.material.icons.filled.Spa
import androidx.compose.material.icons.filled.Thermostat
import androidx.compose.material.icons.filled.WaterDrop
import androidx.compose.material.icons.filled.Bedtime
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
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.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
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.common.formatCalories
import com.placeholder.ringhealth.core.common.formatDistance
import com.placeholder.ringhealth.core.common.formatSleepDuration
import com.placeholder.ringhealth.core.common.formatSteps
import com.placeholder.ringhealth.core.designsystem.MetricBlue
import com.placeholder.ringhealth.core.designsystem.MetricCyan
import com.placeholder.ringhealth.core.designsystem.MetricGreen
import com.placeholder.ringhealth.core.designsystem.MetricOrange
import com.placeholder.ringhealth.core.designsystem.MetricPurple
import com.placeholder.ringhealth.core.designsystem.MetricRed
import com.placeholder.ringhealth.core.designsystem.MetricYellow
import com.placeholder.ringhealth.core.designsystem.MetricValueStyle
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.MetricTile
import com.placeholder.ringhealth.core.designsystem.component.RingProgress
import com.placeholder.ringhealth.core.designsystem.component.SectionHeader
import com.placeholder.ringhealth.domain.model.HealthSummary
import com.placeholder.ringhealth.domain.model.MetricType
import com.placeholder.ringhealth.domain.model.RingDeviceState
/** 一个指标卡的数据。 */
private data class MetricCardSpec(
val type: MetricType,
val icon: ImageVector,
val tint: Color,
val title: String,
val value: String?,
val unit: String,
)
/** 健康首页。 */
@Composable
fun HomeScreen(
onOpenDetail: (MetricType) -> Unit,
modifier: Modifier = Modifier,
viewModel: HomeViewModel = viewModel(factory = AppViewModelProvider.Factory),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
HomeContent(state = state, onOpenDetail = onOpenDetail, modifier = modifier)
}
/** 无状态首页内容,便于 Preview。 */
@Composable
private fun HomeContent(
state: HomeUiState,
onOpenDetail: (MetricType) -> Unit,
modifier: Modifier = Modifier,
) {
if (state.loading || state.summary == null) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = MetricGreen)
}
return
}
val summary = state.summary
// 注意:LazyColumn 的 content lambda 不是 Composable 上下文,
// 所有需要 stringResource / Composable 调用的内容都必须在 LazyColumn 之前算好。
val cards = buildMetricCards(summary)
val notEnabledText = stringResource(R.string.home_not_enabled)
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item { DeviceStrip(device = state.device) }
item { StepsCard(summary = summary, onClick = { onOpenDetail(MetricType.STEP) }) }
item { SectionHeader(title = stringResource(R.string.home_metrics)) }
items(cards) { card ->
MetricTile(
icon = card.icon,
iconTint = card.tint,
title = card.title,
value = card.value ?: notEnabledText,
unit = if (card.value == null) "" else card.unit,
onClick = { onOpenDetail(card.type) },
)
}
}
}
/** 顶部设备连接状态条。 */
@Composable
private fun DeviceStrip(device: RingDeviceState?, modifier: Modifier = Modifier) {
HealthCard(modifier = modifier) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Filled.MonitorHeart,
contentDescription = null,
tint = MetricGreen,
modifier = Modifier.size(20.dp),
)
Column(modifier = Modifier.padding(start = 10.dp)) {
Text(
text = if (device?.connected == true) {
stringResource(R.string.home_device_connected, device.name)
} else {
stringResource(R.string.home_device_disconnected)
},
style = MaterialTheme.typography.bodyMedium,
)
if (device != null) {
Text(
text = stringResource(R.string.home_battery, device.batteryPercent),
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
)
}
}
}
}
}
/** 今日步数大环卡片。 */
@Composable
private fun StepsCard(summary: HealthSummary, onClick: () -> Unit, modifier: Modifier = Modifier) {
HealthCard(modifier = modifier, onClick = onClick) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(text = stringResource(R.string.home_steps_title), style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(12.dp))
RingProgress(
progress = summary.steps.toFloat() / summary.stepGoal.coerceAtLeast(1),
color = MetricGreen,
trackColor = MetricGreen.copy(alpha = 0.15f),
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(text = formatSteps(summary.steps), style = MetricValueStyle)
Text(
text = stringResource(R.string.home_steps_goal, formatSteps(summary.stepGoal)),
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
)
}
}
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
) {
SummaryMetric(
value = formatDistance(summary.distanceKm),
label = stringResource(R.string.home_distance_label),
tint = MetricBlue,
)
SummaryMetric(
value = formatCalories(summary.calories),
label = stringResource(R.string.home_calories_label),
tint = MetricOrange,
)
}
}
}
}
/** 环下方的小结指标。 */
@Composable
private fun SummaryMetric(value: String, label: String, tint: Color) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(text = value, style = MaterialTheme.typography.titleMedium, color = tint)
Text(text = label, style = MaterialTheme.typography.bodySmall, color = TextSecondary)
}
}
/** 组装指标卡列表;value 为 null 表示该指标未开启。 */
@Composable
private fun buildMetricCards(summary: HealthSummary): List<MetricCardSpec> = listOf(
MetricCardSpec(
type = MetricType.HEART_RATE,
icon = Icons.Filled.Favorite,
tint = MetricRed,
title = stringResource(R.string.metric_heart_rate),
value = summary.heartRate?.toString(),
unit = stringResource(R.string.unit_bpm),
),
MetricCardSpec(
type = MetricType.SLEEP,
icon = Icons.Filled.Bedtime,
tint = MetricBlue,
title = stringResource(R.string.metric_sleep),
value = summary.sleepMinutes?.let { formatSleepDuration(it) },
unit = "",
),
MetricCardSpec(
type = MetricType.BLOOD_OXYGEN,
icon = Icons.Filled.Bloodtype,
tint = MetricCyan,
title = stringResource(R.string.metric_blood_oxygen),
value = summary.bloodOxygen?.toString(),
unit = stringResource(R.string.unit_percent),
),
MetricCardSpec(
type = MetricType.STRESS,
icon = Icons.Filled.Spa,
tint = MetricPurple,
title = stringResource(R.string.metric_stress),
value = summary.stress?.toString(),
unit = "",
),
MetricCardSpec(
type = MetricType.TEMPERATURE,
icon = Icons.Filled.Thermostat,
tint = MetricOrange,
title = stringResource(R.string.metric_temperature),
value = summary.temperature?.toString(),
unit = stringResource(R.string.unit_celsius),
),
MetricCardSpec(
type = MetricType.BLOOD_PRESSURE,
icon = Icons.Filled.WaterDrop,
tint = MetricYellow,
title = stringResource(R.string.metric_blood_pressure),
value = summary.bloodPressure,
unit = stringResource(R.string.unit_mmhg),
),
)
@Preview(showBackground = true)
@Composable
private fun HomeContentPreview() {
RingHealthTheme {
HomeContent(
state = HomeUiState(
loading = false,
summary = HealthSummary(8426, 10000, 327, 6.21, 72, 452, 98, 34, 36.5, null),
device = RingDeviceState(true, "RingHealth 智能戒指", 78, "1.0.5", false),
),
onOpenDetail = {},
)
}
}
```
> 说明:`buildMetricCards` 是 `@Composable`(内部调用了 `stringResource`),而 `LazyColumn` 的 content lambda 不是 Composable 上下文,所以必须在进入 `LazyColumn` 之前取值——上面的 `val cards = ...` 就是为此。
>
> **不要 import `androidx.compose.foundation.lazy.item`(ledger Ruling R24)。** 计划早期版本在这份
> import 清单里写过它,那一行**单独就能让编译失败**:`item` 是 `LazyListScope` 的**成员函数**,
> 不是顶层扩展,成员函数永远不能 import(报 `Unresolved reference: 'item'`)。
> 只有 `items(List<T>)` 是 `LazyDsl` 里的顶层扩展,才需要 import。
> `item { }` 的调用点不受影响,走成员分派即可解析。
- [ ] **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="unit_mmhg">mmHg</string>
<string name="metric_heart_rate">心率</string>
<string name="metric_sleep">睡眠</string>
<string name="metric_blood_oxygen">血氧</string>
<string name="metric_stress">压力</string>
<string name="metric_temperature">体温</string>
<string name="metric_blood_pressure">血压</string>
<string name="metric_step">步数</string>
```
> 这批 `metric_*` 字符串同时供 Task 12 的详情页标题使用(ledger Ruling R3),是它们的唯一来源。
- [ ] **Step 8: 构建 + 单测**
```bash
cd /d/cherry/repos/ring-health-app && ./gradlew :app:testDebugUnitTest && ./gradlew assembleDebug
```
Expected: 两条命令均 `BUILD SUCCESSFUL`,测试全绿(含本任务新增的 `HomeViewModelTest`)。
界面上「设备条 / 步数大环 / 六个指标卡(血压显示未开启)/ 点击进入详情」这几条**不在本步验证**
(ledger Ruling R21:本机无模拟器与真机),顺延到 Task 14 Step 3 的人工验收清单。
- [ ] **Step 9: Commit**
```bash
git add -A && git commit -m "feat: 实现健康首页(今日大环 + 指标卡片,含 ViewModel 测试)"
```
---
### Task 9: 运动页 + 运动历史子页
**Files:**
- Create: `feature/workout/WorkoutViewModel.kt`
- Modify: `feature/workout/WorkoutScreen.kt`、`feature/workout/WorkoutHistoryScreen.kt`
- Modify: `res/values/strings.xml`、`core/navigation/RingHealthApp.kt`(把占位替换为真实页面)
**Interfaces:**
- Consumes: `HealthDataRepository`、`WorkoutRecord`
- Produces:
- `data class WorkoutUiState(val loading: Boolean, val records: List<WorkoutRecord>)`
- `class WorkoutViewModel(healthDataRepository)` 暴露 `uiState: StateFlow<WorkoutUiState>`
- `@Composable fun WorkoutScreen(onOpenHistory: () -> Unit, modifier, viewModel)`、`@Composable fun WorkoutHistoryScreen(modifier, viewModel)`
- [ ] **Step 1: 写失败的测试**
`app/src/test/java/com/placeholder/ringhealth/feature/workout/WorkoutViewModelTest.kt`
(与 Task 8 的 `HomeViewModelTest` 同形;ledger Ruling R25 —— 两个 ViewModel 的覆盖度应当一致,
不该一个测一个不测):
```kotlin
package com.placeholder.ringhealth.feature.workout
import com.placeholder.ringhealth.data.mock.MockHealthDataRepository
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
@OptIn(ExperimentalCoroutinesApi::class)
class WorkoutViewModelTest {
private val dispatcher = StandardTestDispatcher()
@Before
fun setUp() {
Dispatchers.setMain(dispatcher)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun `加载完成后输出运动记录`() = runTest(dispatcher) {
val viewModel = WorkoutViewModel(MockHealthDataRepository())
// 与 HomeViewModelTest 同理:uiState 用 WhileSubscribed 构建,
// 没有订阅者时 .value 会停在 initialValue(loading = true)。
backgroundScope.launch { viewModel.uiState.collect {} }
advanceUntilIdle()
val state = viewModel.uiState.value
assertFalse(state.loading)
assertEquals(5, state.records.size)
// Mock 的运动记录是倒序生成的,最近的在最前
assertEquals("户外跑步", state.records.first().type)
}
}
```
- [ ] **Step 1b: 运行测试,确认失败**
```bash
cd /d/cherry/repos/ring-health-app && ./gradlew :app:testDebugUnitTest --tests "com.placeholder.ringhealth.feature.workout.WorkoutViewModelTest"
```
Expected: `Unresolved reference: WorkoutViewModel`,编译失败。
- [ ] **Step 2: 写 `WorkoutViewModel.kt`**
```kotlin
package com.placeholder.ringhealth.feature.workout
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.placeholder.ringhealth.domain.model.WorkoutRecord
import com.placeholder.ringhealth.domain.repository.HealthDataRepository
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
/** 运动页 UI 状态。 */
data class WorkoutUiState(
val loading: Boolean,
val records: List<WorkoutRecord>,
)
/** 运动页 ViewModel。 */
class WorkoutViewModel(
healthDataRepository: HealthDataRepository,
) : ViewModel() {
val uiState: StateFlow<WorkoutUiState> = healthDataRepository.observeWorkouts()
.map { WorkoutUiState(loading = false, records = it) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(STOP_TIMEOUT_MILLIS),
initialValue = WorkoutUiState(loading = true, records = emptyList()),
)
private companion object {
const val STOP_TIMEOUT_MILLIS = 5_000L
}
}
```
- [ ] **Step 3: 把 `WorkoutViewModel` 注册到 `AppViewModelProvider`**
在 `AppViewModelProvider.kt` 的 `viewModelFactory { }` 内**追加**一个 `initializer`(ledger Ruling R2),
并在该文件顶部加 `import com.placeholder.ringhealth.feature.workout.WorkoutViewModel`:
```kotlin
initializer {
WorkoutViewModel(
healthDataRepository = ringHealthApplication().container.healthDataRepository,
)
}
```
- [ ] **Step 4: 追加文案到 `strings.xml`**
```xml
<string name="workout_start">开始运动</string>
<string name="workout_start_desc">选择运动类型,记录你的训练</string>
<string name="workout_recent">最近运动</string>
<string name="workout_empty">暂无运动记录</string>
<string name="workout_item_summary">%1$s · %2$s 公里</string>
<string name="workout_detail_calories">消耗 %1$s 千卡</string>
<string name="action_view_all">查看全部</string>
```
> **本任务不得新增无消费者的字符串资源(ledger Ruling R25)。** 计划早期版本的这份清单里还有两条
> ——`workout_detail_duration`(`时长 %1$s`)与 `workout_history`(`全部记录`)—— 全计划 grep 下来
> **一个调用点都没有**:时长已经在 `workout_item_summary` 里作为 `%1$s` 拼进去了,
> 而本任务的运动历史页没有标题栏,用不上「全部记录」。**不要把它们加回来**:
> 现在加就是新写死资源,将来若要给历史页加标题,那时再补一条也不迟。
> 同理,`WorkoutHistoryPlaceholder` 被删后,`R.string.placeholder_workout_history` 就成了孤儿 ——
> **这个先留着不动**:`placeholder_device_settings` / `placeholder_metric_detail` 会由 Task 10 / Task 12
> 各自留下同样的孤儿,与其三个任务各删一次(且互相之间看不到对方的删除),
> 不如留到最后一次性清扫。孤立的字符串资源只占几字节,不影响构建与运行。
- [ ] **Step 5: 实现 `WorkoutScreen.kt`**
```kotlin
package com.placeholder.ringhealth.feature.workout
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
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.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.FitnessCenter
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.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.common.formatCalories
import com.placeholder.ringhealth.core.common.formatClock
import com.placeholder.ringhealth.core.common.formatDate
import com.placeholder.ringhealth.core.common.formatDistance
import com.placeholder.ringhealth.core.common.formatDuration
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.SectionHeader
import com.placeholder.ringhealth.domain.model.WorkoutRecord
/** 运动页。 */
@Composable
fun WorkoutScreen(
onOpenHistory: () -> Unit,
modifier: Modifier = Modifier,
viewModel: WorkoutViewModel = viewModel(factory = AppViewModelProvider.Factory),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
WorkoutContent(state = state, onOpenHistory = onOpenHistory, modifier = modifier)
}
/** 无状态运动页内容。 */
@Composable
private fun WorkoutContent(
state: WorkoutUiState,
onOpenHistory: () -> Unit,
modifier: Modifier = Modifier,
) {
if (state.loading) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = MetricGreen)
}
return
}
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item {
HealthCard {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Filled.FitnessCenter,
contentDescription = null,
tint = MetricGreen,
modifier = Modifier.size(28.dp),
)
Column(modifier = Modifier.padding(start = 12.dp)) {
Text(text = stringResource(R.string.workout_start), style = MaterialTheme.typography.titleMedium)
Text(
text = stringResource(R.string.workout_start_desc),
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
)
}
}
}
}
item {
SectionHeader(
title = stringResource(R.string.workout_recent),
trailing = {
TextButton(onClick = onOpenHistory) {
Text(text = stringResource(R.string.action_view_all))
}
},
)
}
items(state.records) { record ->
WorkoutRecordCard(record = record)
}
}
}
/** 单条运动记录卡片。 */
@Composable
internal fun WorkoutRecordCard(record: WorkoutRecord, modifier: Modifier = Modifier) {
HealthCard(modifier = modifier) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column {
Text(text = record.type, style = MaterialTheme.typography.titleSmall)
// 日期 + 空格 + 时间。这里**不**违反「不硬编码文案」(ledger Ruling R25):
// 两个值都由 Formatters 做本地化格式化(SimpleDateFormat + Locale.CHINA),
// 中间的连接符是一个空格,不含任何需要翻译的文字;也没有第二份资源与它重复。
Text(
text = "${formatDate(record.startTime)} ${formatClock(record.startTime)}",
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
)
}
Column(horizontalAlignment = Alignment.End) {
Text(
text = stringResource(R.string.workout_item_summary, formatDuration(record.durationMinutes), formatDistance(record.distanceKm)),
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = stringResource(R.string.workout_detail_calories, formatCalories(record.calories)),
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
)
}
}
}
}
```
- [ ] **Step 6: 实现 `WorkoutHistoryScreen.kt`**
```kotlin
package com.placeholder.ringhealth.feature.workout
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
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.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
/** 运动历史子页:展示全部运动记录。 */
@Composable
fun WorkoutHistoryScreen(
modifier: Modifier = Modifier,
viewModel: WorkoutViewModel = viewModel(factory = AppViewModelProvider.Factory),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
// 必须先判 loading 再判空(ledger Ruling R25):Mock 仓库有 200ms 延迟,
// records 在加载完成前就是 emptyList,若先判空会先闪一下「暂无运动记录」,
// 数据到了再跳成列表 —— 用户看到的是「明明有记录却说没有」。
if (state.loading) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = MetricGreen)
}
return
}
if (state.records.isEmpty()) {
Box(
modifier = modifier.fillMaxSize().padding(16.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = stringResource(R.string.workout_empty),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
return
}
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(state.records) { record ->
WorkoutRecordCard(record = record)
}
}
}
```
- [ ] **Step 7: 在 `RingHealthApp.kt` 中接入真实页面,并让子页保持所属 Tab 高亮**
将 `composable(Routes.WORKOUT)` 与 `composable(Routes.WORKOUT_HISTORY)` 替换为:
```kotlin
composable(Routes.WORKOUT) {
WorkoutScreen(onOpenHistory = { navController.navigate(Routes.WORKOUT_HISTORY) })
}
composable(Routes.WORKOUT_HISTORY) {
WorkoutHistoryScreen()
}
```
并删除 `WorkoutHistoryPlaceholder` 的 import 与定义(文件 `WorkoutHistoryScreen.kt` 中不再保留占位函数)。
**同时修正底栏的高亮归属(ledger Ruling R22)。** Task 7 的 `selected = currentRoute == tab.route`
在子页位于栈顶时会**四个 Tab 全部不高亮**:`NavDestination.route` 返回的是**路由模板**
(`"detail/{metric}"`),而不是填充后的 `"detail/STEP"`;`device/settings`、`workout/history`
又都不等于任何 Tab 路由。Material3 允许零个选中项,所以不会崩,但偏离了小米运动健康
(父 Tab 应当保持点亮)。把 Route 的比较改成「先归到父 Tab,再比」:
在 `RingHealthApp.kt` 中新增(放在 `RingHealthApp` 函数之前):
```kotlin
/**
* 把当前路由归到它所属的底部 Tab。
*
* 子页位于栈顶时,底栏应当保持父 Tab 点亮。这里必须与 `Routes.DETAIL_PATTERN`
* 比较 —— `NavDestination.route` 给出的是路由**模板**而非填充后的实际路由。
* 本 App 的路由是扁平注册的(没有嵌套导航图),`destination.hierarchy` 帮不上忙,
* 只能显式映射。
*/
private fun tabRouteFor(route: String?): String? = when (route) {
Routes.DETAIL_PATTERN -> Routes.HOME
Routes.DEVICE_SETTINGS -> Routes.DEVICE
Routes.WORKOUT_HISTORY -> Routes.WORKOUT
else -> route
}
```
再把 `RingHealthApp` 内的
```kotlin
val currentRoute = backStackEntry?.destination?.route
```
改为
```kotlin
val currentRoute = tabRouteFor(backStackEntry?.destination?.route)
```
这样 `selected = currentRoute == tab.route` 本身不用动。**三个子路由在这里一次性映射完**
(`DEVICE_SETTINGS` / `DETAIL_PATTERN` 分别由 Task 10 / Task 12 接入),
免得那两个 Task 再回来改同一个 `when`。
**顺带加固 `Routes.kt` 里的一处手工耦合(ledger Ruling R22):**
```kotlin
/** 路由参数的键名。必须声明在 DETAIL_PATTERN 之前:模板引用它。 */
const val DETAIL_ARG_METRIC = "metric"
/** 指标详情路由模板,参数为 MetricType.name。 */
const val DETAIL_PATTERN = "detail/{$DETAIL_ARG_METRIC}"
```
原来 `"detail/{metric}"` 与 `const val DETAIL_ARG_METRIC = "metric"` 是两个各自独立的字面量,
靠人手工保持一致;改了占位符名而没改常量,导航会静默走到 Task 12 的兜底分支而不报错。
`const val` 允许引用另一个 `const val`(字符串模板里只含常量即为编译期常量),
零运行时开销。**注意 `DETAIL_ARG_METRIC` 必须声明在 `DETAIL_PATTERN` 之前**,
否则常量初始化顺序会导致编译失败。
> 注意:`WorkoutViewModel` 在运动页与历史页各创建一次实例(导航到不同 destination 时独立作用域)。阶段一数据相同,可接受;M3 引入真实数据后如需共享,再提升作用域。
- [ ] **Step 8: 构建 + 单测**
```bash
cd /d/cherry/repos/ring-health-app && ./gradlew :app:testDebugUnitTest && ./gradlew assembleDebug
```
Expected: 两条命令均 `BUILD SUCCESSFUL`;`WorkoutViewModelTest` 由 Step 1b 的 RED 转为 GREEN,
全量测试 13 项通过(Task 8 之后是 12 项,本任务 +1)。
界面上「运动 Tab 显示开始运动卡片 + 5 条记录」「查看全部跳转历史页」**不在本步验证**
(ledger Ruling R21:本机无模拟器与真机),顺延到 Task 14 Step 3 的人工验收清单。
- [ ] **Step 9: Commit**
```bash
git add -A && git commit -m "feat: 实现运动页与运动历史子页"
```
---
### Task 10: 设备页 + 设备设置子页
**Files:**
- Create: `feature/device/DeviceViewModel.kt`
- Modify: `feature/device/DeviceScreen.kt`、`feature/device/DeviceSettingsScreen.kt`
- Modify: `res/values/strings.xml`、`core/navigation/RingHealthApp.kt`
**Interfaces:**
- Consumes: `RingDeviceRepository`、Task 3 组件
- Produces:
- `data class DeviceUiState(val device: RingDeviceState, val settings: List<DeviceFunctionSetting>)`
- `class DeviceViewModel(ringDeviceRepository)` 暴露 `uiState: StateFlow<DeviceUiState>`、`fun setFunctionEnabled(id: String, enabled: Boolean)`
- `@Composable fun DeviceScreen(onOpenSettings: () -> Unit, modifier, viewModel)`、`@Composable fun DeviceSettingsScreen(modifier, viewModel)`
- [ ] **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
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.placeholder.ringhealth.domain.model.DeviceFunctionSetting
import com.placeholder.ringhealth.domain.model.RingDeviceState
import com.placeholder.ringhealth.domain.repository.RingDeviceRepository
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
/** 设备页 UI 状态。 */
data class DeviceUiState(
val device: RingDeviceState,
val settings: List<DeviceFunctionSetting>,
)
/** 设备页 ViewModel:设备状态 + 功能开关。 */
class DeviceViewModel(
private val ringDeviceRepository: RingDeviceRepository,
) : ViewModel() {
val uiState: StateFlow<DeviceUiState> = combine(
ringDeviceRepository.deviceState,
ringDeviceRepository.functionSettings,
) { device, settings ->
DeviceUiState(device = device, settings = settings)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(STOP_TIMEOUT_MILLIS),
initialValue = DeviceUiState(
device = ringDeviceRepository.deviceState.value,
settings = ringDeviceRepository.functionSettings.value,
),
)
/** 切换功能开关(M1 仅改内存,M2 后同步到设备)。 */
fun setFunctionEnabled(id: String, enabled: Boolean) {
ringDeviceRepository.setFunctionEnabled(id, enabled)
}
private companion object {
const val STOP_TIMEOUT_MILLIS = 5_000L
}
}
```
- [ ] **Step 3: 把 `DeviceViewModel` 注册到 `AppViewModelProvider`**
在 `AppViewModelProvider.kt` 的 `viewModelFactory { }` 内**追加**一个 `initializer`(ledger Ruling R2),
并在该文件顶部加 `import com.placeholder.ringhealth.feature.device.DeviceViewModel`:
```kotlin
initializer {
DeviceViewModel(
ringDeviceRepository = ringHealthApplication().container.ringDeviceRepository,
)
}
```
- [ ] **Step 4: 追加文案到 `strings.xml`**
```xml
<string name="device_connected">已连接</string>
<string name="device_disconnected">未连接</string>
<string name="device_battery">电量 %1$d%%</string>
<string name="device_firmware">固件版本 %1$s</string>
<string name="device_settings_title">功能设置</string>
<string name="device_more">更多设置</string>
<string name="device_switch_on">已开启</string>
<string name="device_switch_off">已关闭</string>
```
> **本任务不得新增无消费者的字符串资源(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
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
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.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bluetooth
import androidx.compose.material.icons.filled.Watch
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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
import com.placeholder.ringhealth.domain.model.DeviceFunctionSetting
import com.placeholder.ringhealth.domain.model.RingDeviceState
/** 设备(戒指管理)页。 */
@Composable
fun DeviceScreen(
onOpenSettings: () -> Unit,
modifier: Modifier = Modifier,
viewModel: DeviceViewModel = viewModel(factory = AppViewModelProvider.Factory),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
DeviceContent(state = state, onOpenSettings = onOpenSettings, modifier = modifier)
}
/** 无状态设备页内容。 */
@Composable
private fun DeviceContent(
state: DeviceUiState,
onOpenSettings: () -> Unit,
modifier: Modifier = Modifier,
) {
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item { DeviceInfoCard(state = state) }
item {
SectionHeader(
title = stringResource(R.string.device_settings_title),
trailing = {
TextButton(onClick = onOpenSettings) {
Text(text = stringResource(R.string.device_more))
}
},
)
}
items(state.settings) { setting ->
SettingRow(setting = setting)
}
}
}
/** 戒指信息卡片。 */
@Composable
private fun DeviceInfoCard(state: DeviceUiState, modifier: Modifier = Modifier) {
HealthCard(modifier = modifier) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Filled.Watch,
contentDescription = null,
tint = MetricGreen,
modifier = Modifier.size(32.dp),
)
Column(modifier = Modifier.padding(start = 12.dp)) {
Text(text = state.device.name, style = MaterialTheme.typography.titleMedium)
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Filled.Bluetooth,
contentDescription = null,
tint = if (state.device.connected) MetricGreen else TextSecondary,
modifier = Modifier.size(14.dp),
)
Text(
text = stringResource(
if (state.device.connected) R.string.device_connected else R.string.device_disconnected,
),
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
modifier = Modifier.padding(start = 4.dp),
)
}
}
}
Row(
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.device_battery, state.device.batteryPercent),
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = stringResource(R.string.device_firmware, state.device.firmwareVersion),
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
)
}
}
}
/** 单行设置项(只读展示,开关在详情页操作)。 */
@Composable
internal fun SettingRow(setting: DeviceFunctionSetting, modifier: Modifier = Modifier) {
HealthCard(modifier = modifier) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(text = setting.title, style = MaterialTheme.typography.titleSmall)
Text(text = setting.subtitle, style = MaterialTheme.typography.bodySmall, color = TextSecondary)
}
Text(
text = stringResource(
if (setting.enabled) R.string.device_switch_on else R.string.device_switch_off,
),
style = MaterialTheme.typography.bodySmall,
color = if (setting.enabled) MetricGreen else TextSecondary,
)
}
}
}
@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),
)
```
> `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
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
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.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
/**
* 设备设置子页:可交互的功能开关列表。
* M1 只改内存状态;M2 后写入设备(对应协议 0502/0504)。
*/
@Composable
fun DeviceSettingsScreen(
modifier: Modifier = Modifier,
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<DeviceFunctionSetting>,
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(settings) { setting ->
HealthCard {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(text = setting.title, style = MaterialTheme.typography.titleSmall)
Text(text = setting.subtitle, style = MaterialTheme.typography.bodySmall, color = TextSecondary)
}
Switch(
checked = setting.enabled,
onCheckedChange = { checked -> onToggle(setting.id, checked) },
// SwitchDefaults 走正常 import,不写字面量全限定名(与其余文件的 import 风格一致)
colors = SwitchDefaults.colors(checkedTrackColor = MetricGreen),
)
}
}
}
item {
Text(
text = stringResource(R.string.device_settings_hint),
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
modifier = Modifier.padding(top = 4.dp),
)
}
}
}
@Preview(showBackground = true)
@Composable
private fun DeviceSettingsContentPreview() {
RingHealthTheme {
DeviceSettingsContent(settings = previewSettings(), onToggle = { _, _ -> })
}
}
```
- [ ] **Step 7: 追加提示文案到 `strings.xml`**
```xml
<string name="device_settings_hint">阶段一为演示开关,接入戒指后将同步到设备。</string>
```
- [ ] **Step 8: 在 `RingHealthApp.kt` 接入真实页面**
```kotlin
composable(Routes.DEVICE) {
DeviceScreen(onOpenSettings = { navController.navigate(Routes.DEVICE_SETTINGS) })
}
composable(Routes.DEVICE_SETTINGS) {
DeviceSettingsScreen()
}
```
删除 `DeviceSettingsPlaceholder` 的定义与 import。
- [ ] **Step 9: 构建 + 单测**
```bash
cd /d/cherry/repos/ring-health-app && ./gradlew :app:testDebugUnitTest && ./gradlew assembleDebug
```
Expected: 两条命令均 `BUILD SUCCESSFUL`;`DeviceViewModelTest` 由 Step 1b 的 RED 转为 GREEN,
全量测试 15 项通过(Task 9 之后是 13 项,本任务 +2)。
界面上「设备 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: 实现设备页与设备设置子页(可交互开关)"
```
---
### Task 11: 我的页
**Files:**
- Modify: `feature/profile/ProfileScreen.kt`
- Modify: `res/values/strings.xml`
**Interfaces:**
- Consumes: Task 3 组件
- Produces: `@Composable fun ProfileScreen(modifier: Modifier = Modifier)`
- [ ] **Step 1: 追加文案到 `strings.xml`**
```xml
<string name="profile_nickname">健康用户</string>
<string name="profile_login_hint">登录后可同步数据(阶段一未开放)</string>
<string name="profile_group_app">应用设置</string>
<string name="profile_units">单位设置</string>
<string name="profile_units_desc">公里 / 公斤</string>
<string name="profile_notification">通知权限</string>
<string name="profile_notification_desc">接收健康提醒</string>
<string name="profile_group_about">关于</string>
<string name="profile_check_update">检查更新</string>
<string name="profile_check_update_desc">当前版本 0.1.0</string>
<string name="profile_help">帮助与反馈</string>
<string name="profile_help_desc">常见问题与意见反馈</string>
```
- [ ] **Step 2: 实现 `ProfileScreen.kt`**
```kotlin
package com.placeholder.ringhealth.feature.profile
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
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.material.icons.Icons
import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.placeholder.ringhealth.R
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.SectionHeader
/** “我的”页。 */
@Composable
fun ProfileScreen(modifier: Modifier = Modifier) {
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item {
HealthCard {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Filled.AccountCircle,
contentDescription = null,
tint = MetricGreen,
modifier = Modifier.size(48.dp),
)
Column(modifier = Modifier.padding(start = 12.dp)) {
Text(
text = stringResource(R.string.profile_nickname),
style = MaterialTheme.typography.titleMedium,
)
Text(
text = stringResource(R.string.profile_login_hint),
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
)
}
}
}
}
item { SectionHeader(title = stringResource(R.string.profile_group_app)) }
item {
ProfileEntry(
icon = Icons.Filled.Settings,
title = stringResource(R.string.profile_units),
subtitle = stringResource(R.string.profile_units_desc),
)
}
item {
ProfileEntry(
icon = Icons.Filled.Notifications,
title = stringResource(R.string.profile_notification),
subtitle = stringResource(R.string.profile_notification_desc),
)
}
item { SectionHeader(title = stringResource(R.string.profile_group_about)) }
item {
ProfileEntry(
icon = Icons.Filled.Refresh,
title = stringResource(R.string.profile_check_update),
subtitle = stringResource(R.string.profile_check_update_desc),
)
}
item {
ProfileEntry(
icon = Icons.Filled.Info,
title = stringResource(R.string.profile_help),
subtitle = stringResource(R.string.profile_help_desc),
)
}
}
}
/** 单条设置入口。 */
@Composable
private fun ProfileEntry(
icon: ImageVector,
title: String,
subtitle: String,
modifier: Modifier = Modifier,
) {
HealthCard(modifier = modifier) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = TextSecondary,
modifier = Modifier.size(20.dp),
)
Column(modifier = Modifier.padding(start = 12.dp)) {
Text(text = title, style = MaterialTheme.typography.titleSmall)
Text(text = subtitle, style = MaterialTheme.typography.bodySmall, color = TextSecondary)
}
}
}
}
```
- [ ] **Step 3: 构建 + 验证**
```bash
cd /d/cherry/repos/ring-health-app && ./gradlew assembleDebug
```
Expected: “我的”Tab 显示头像卡与两组设置入口。
- [ ] **Step 4: Commit**
```bash
git add -A && git commit -m "feat: 实现我的页"
```
---
### Task 12: 指标详情子页(步数 / 心率 / 睡眠 / 血氧等)
**Files:**
- Create: `feature/detail/MetricDetailViewModel.kt`
- Modify: `feature/detail/MetricDetailScreen.kt`(替换占位)
- Modify: `core/navigation/RingHealthApp.kt`(接入真实详情页 + 支持返回)
- Modify: `res/values/strings.xml`
**Interfaces:**
- 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`**
```kotlin
package com.placeholder.ringhealth.feature.detail
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.placeholder.ringhealth.domain.model.DateRange
import com.placeholder.ringhealth.domain.model.MetricPoint
import com.placeholder.ringhealth.domain.model.MetricType
import com.placeholder.ringhealth.domain.repository.HealthDataRepository
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
/** 指标详情 UI 状态。 */
data class MetricDetailUiState(
val loading: Boolean,
val metric: MetricType,
val range: DateRange,
val points: List<MetricPoint>,
)
/**
* 指标详情 ViewModel。因需要指标参数,使用独立工厂创建。
*/
@OptIn(ExperimentalCoroutinesApi::class)
class MetricDetailViewModel(
private val metric: MetricType,
healthDataRepository: HealthDataRepository,
) : ViewModel() {
private val range = MutableStateFlow(DateRange.WEEK)
val uiState: StateFlow<MetricDetailUiState> = range
.flatMapLatest { currentRange ->
healthDataRepository.observeMetricHistory(metric, currentRange)
.map { points ->
MetricDetailUiState(
loading = false,
metric = metric,
range = currentRange,
points = points,
)
}
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(STOP_TIMEOUT_MILLIS),
initialValue = MetricDetailUiState(
loading = true,
metric = metric,
range = DateRange.WEEK,
points = emptyList(),
),
)
/** 切换时间范围(周/月)。 */
fun switchRange(newRange: DateRange) {
range.update { newRange }
}
companion object {
private const val STOP_TIMEOUT_MILLIS = 5_000L
/** 为指定指标构造 ViewModel 工厂。 */
fun factory(metric: MetricType): ViewModelProvider.Factory = viewModelFactory {
initializer {
MetricDetailViewModel(
metric = metric,
healthDataRepository = ringHealthApplication().container.healthDataRepository,
)
}
}
}
}
```
> 说明:`ringHealthApplication()` 是 Task 4 中定义在 `com.placeholder.ringhealth` 包下的 `CreationExtras` 扩展函数,需在本文件顶部 `import com.placeholder.ringhealth.ringHealthApplication`。
- [ ] **Step 4: 追加文案到 `strings.xml`**
```xml
<string name="detail_range_week">近 7 天</string>
<string name="detail_range_month">近 30 天</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_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
import androidx.compose.foundation.layout.PaddingValues
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
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
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.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
/**
* 指标详情页:范围切换 + 柱状趋势图 + 统计摘要。
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MetricDetailScreen(
metric: MetricType,
onBack: () -> Unit,
modifier: Modifier = Modifier,
viewModel: MetricDetailViewModel = viewModel(factory = MetricDetailViewModel.factory(metric)),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
Scaffold(
modifier = modifier,
containerColor = MaterialTheme.colorScheme.background,
topBar = {
TopAppBar(
title = { Text(text = stringResource(metricTitleRes(metric))) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.action_back),
)
}
},
)
},
) { innerPadding ->
if (state.loading) {
Box(
modifier = Modifier.fillMaxSize().padding(innerPadding),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(color = MetricGreen)
}
return@Scaffold
}
LazyColumn(
modifier = Modifier.fillMaxSize().padding(innerPadding),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item { RangeSelector(current = state.range, onSelect = viewModel::switchRange) }
item {
HealthCard {
SectionHeader(title = stringResource(R.string.detail_trend))
MetricBarChart(
values = state.points.map { it.value.toFloat() },
color = MetricGreen,
)
}
}
item {
HealthCard {
val values = state.points.map { it.value }
StatLine(label = stringResource(R.string.detail_average), value = values.averageOrZero())
StatLine(label = stringResource(R.string.detail_max), value = values.maxOrNull() ?: 0.0)
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(
current: DateRange,
onSelect: (DateRange) -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
FilterChip(
selected = current == DateRange.WEEK,
onClick = { onSelect(DateRange.WEEK) },
label = { Text(stringResource(R.string.detail_range_week)) },
)
FilterChip(
selected = current == DateRange.MONTH,
onClick = { onSelect(DateRange.MONTH) },
label = { Text(stringResource(R.string.detail_range_month)) },
)
}
}
/** 一行统计摘要。 */
@Composable
private fun StatLine(label: String, value: Double, modifier: Modifier = Modifier) {
Row(
modifier = modifier.fillMaxWidth().padding(vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(text = label, style = MaterialTheme.typography.bodyMedium, color = TextSecondary)
Text(text = String.format(java.util.Locale.CHINA, "%.1f", value), style = MaterialTheme.typography.bodyMedium)
}
}
/** 空列表时返回 0.0,避免 NaN。 */
private fun List<Double>.averageOrZero(): Double = if (isEmpty()) 0.0 else average()
```
- [ ] **Step 4: 在 `RingHealthApp.kt` 接入真实详情页**
```kotlin
composable(
route = Routes.DETAIL_PATTERN,
arguments = listOf(navArgument(Routes.DETAIL_ARG_METRIC) { type = NavType.StringType }),
) { entry ->
// 参数无法解析属于编程错误,直接抛(ledger Ruling R22):这里原先是
// `?: MetricType.STEP`,一旦路由模板与 Routes.detail() 拼串不一致,
// 页面会静默显示步数的数据而不报错 —— 那是「错数据」而不是「响亮失败」。
val raw = requireNotNull(entry.arguments?.getString(Routes.DETAIL_ARG_METRIC)) {
"详情路由缺少参数 ${Routes.DETAIL_ARG_METRIC}"
}
val metric = requireNotNull(MetricType.entries.firstOrNull { it.name == raw }) {
"无法识别的指标:$raw"
}
MetricDetailScreen(metric = metric, onBack = { navController.popBackStack() })
}
```
**同时补一条不需要设备的 JVM 测试(ledger Ruling R22)**,把「路由模板」与
`Routes.detail()` 的拼串锁在一起 —— Task 7 的审查指出这两者目前靠人手工同步,
而本 App 的路由是纯 Kotlin、不含任何 `android.*`,完全可以在 `src/test` 里断言:
`app/src/test/java/com/placeholder/ringhealth/core/navigation/RoutesTest.kt`:
```kotlin
package com.placeholder.ringhealth.core.navigation
import com.placeholder.ringhealth.domain.model.MetricType
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/** 路由表的一致性测试:模板与实际拼出的路由必须始终吻合(ledger Ruling R22)。 */
class RoutesTest {
@Test
fun `detail 拼出的路由能被详情模板匹配`() {
val expectedPrefix = Routes.DETAIL_PATTERN.substringBefore("{")
MetricType.entries.forEach { metric ->
val route = Routes.detail(metric)
assertTrue("$route 不以 $expectedPrefix 开头", route.startsWith(expectedPrefix))
assertEquals(metric.name, route.removePrefix(expectedPrefix))
}
}
@Test
fun `占位符名与参数常量一致`() {
assertTrue(Routes.DETAIL_PATTERN.contains("{${Routes.DETAIL_ARG_METRIC}}"))
}
}
```
跑 `./gradlew :app:testDebugUnitTest --tests "com.placeholder.ringhealth.core.navigation.RoutesTest"`,
Expected: `BUILD SUCCESSFUL`,2 项通过。
删除 `MetricDetailPlaceholder` 的定义与 import。
> 注意:详情页自带 `Scaffold` + `TopAppBar`,而外层 `RingHealthApp` 的 `Scaffold` 会传入 `innerPadding`(已用于 NavHost 的 `Modifier.padding`)。为避免底部导航重复遮罩,详情页在 NavHost 内会同时受外层 padding 影响——这是预期行为(底部栏保持可见)。
- [ ] **Step 5: 构建 + 验证**
```bash
cd /d/cherry/repos/ring-health-app && ./gradlew assembleDebug
```
Expected: 从首页点任一指标卡进入详情页;顶部标题为指标中文名;近 7 天/近 30 天可切换,柱状图数量随之变化(7 根 / 30 根);**睡眠详情额外显示分期卡片**(深睡/浅睡/清醒分段条与图例、入睡/起床时间),其他指标不显示;返回键回到首页。
- [ ] **Step 6: Commit**
```bash
git add -A && git commit -m "feat: 实现指标详情子页(周/月切换 + 趋势图)"
```
---
### Task 13: 后端 API 仓库骨架(M0)
**Files:**
- Create: `repos/ring-health-api/README.md`, `.gitignore`, `docs/api-contract.md`
**Interfaces:**
- Consumes: 无
- Produces: 一个含接口契约草稿的空仓库,供 M4 使用。
- [ ] **Step 1: 创建目录与 `README.md`**
```bash
mkdir -p /d/cherry/repos/ring-health-api/docs
```
`repos/ring-health-api/README.md`:
```markdown
# ring-health-api
智能戒指健康 App 的后端 API(阶段一仅契约,未实现)。
- 当前状态:**空仓库 + 接口契约草稿**
- 后端技术栈:待 M4 前确定(Spring Boot / .NET 均可,见设计文档)
- 接口契约:见 `docs/api-contract.md`
等待 App 端数据模型稳定后(M3 结束)再启动实现,避免两端空转。
```
- [ ] **Step 2: 写 `.gitignore`**
```gitignore
# 通用
.idea/
.vscode/
*.log
.DS_Store
# Java / Kotlin
*.class
build/
.gradle/
out/
target/
# .NET
bin/
obj/
*.user
```
- [ ] **Step 3: 写 `docs/api-contract.md`**
```markdown
# 接口契约草稿(未实现)
> 阶段一仅用于对齐前后端边界,字段可能随 App 数据模型调整。
## 通用约定
- 基础路径:`/api/v1`
- 认证:`Authorization: Bearer <token>`
- 时间戳:毫秒(UTC)
- 分页:`page`(从 1 开始)、`pageSize`,按时间**倒序**
## 1. 登录
`POST /api/v1/auth/login`
| 字段 | 类型 | 说明 |
|---|---|---|
| phone | string | 手机号 |
| code | string | 短信验证码 |
响应:`{ "token": "...", "userId": "..." }`
## 2. 设备绑定
`POST /api/v1/devices/bind`
| 字段 | 类型 | 说明 |
|---|---|---|
| mac | string | 戒指 MAC 地址(广播数据第 6 位起) |
| name | string | 设备名称 |
| firmwareVersion | string | 固件版本 |
## 3. 健康数据上报
`POST /api/v1/health/records`
| 字段 | 类型 | 说明 |
|---|---|---|
| type | string | `STEP` / `HEART_RATE` / `SLEEP` / `BLOOD_OXYGEN` / `STRESS` / `TEMPERATURE` / `BLOOD_PRESSURE` |
| timestamp | long | 采集时间(毫秒) |
| value | double | 数值 |
支持批量:`POST /api/v1/health/records/batch`,请求体为数组。
## 4. 健康数据查询
`GET /api/v1/health/records?type=STEP&from=<ms>&to=<ms>&page=1&pageSize=100`
响应:
```json
{
"code": 0,
"message": "ok",
"data": { "total": 128, "items": [ { "timestamp": 1757000000000, "value": 8426.0 } ] }
}
```
## 5. 待定项
- 用户资料、头像上传
- 运动记录同步(字段见 App 端 `WorkoutRecord`)
- 固件 OTA 包的托管方式(当前厂商走独立 HTTP 地址)
```
- [ ] **Step 4: 初始化并推送**
```bash
cd /d/cherry/repos/ring-health-api
git init -b main
git add -A
git commit -m "docs: 初始化 API 仓库骨架与接口契约草稿"
git remote add origin http://120.192.101.56:3000/cuiluzheng/ring-health-api.git
git push -u origin main
```
Expected: 推送成功。
---
### Task 14: 端到端验收与文档收尾
**Files:**
- Modify: `repos/ring-health-app/README.md`(补充截图/页面清单)
- Modify: `docs/superpowers/specs/2026-09-10-ring-health-app-design.md`(如有实现偏差)
- Create: `repos/ring-health-app/docs/verification-m1.md`
**Interfaces:**
- Consumes: 前 13 个任务的全部产出
- Produces: 可交付的 M1 静态 App + 验收记录。
- [ ] **Step 1: 全量单元测试**
```bash
cd /d/cherry/repos/ring-health-app && ./gradlew :app:testDebugUnitTest
```
Expected: 全部通过(Formatters 7 项 + MockDataProvider 5 项 + HomeViewModel 1 项)。
- [ ] **Step 2: 干净构建**
```bash
cd /d/cherry/repos/ring-health-app && ./gradlew clean assembleDebug
```
Expected: `BUILD SUCCESSFUL`,生成 `app/build/outputs/apk/debug/app-debug.apk`。
- [ ] **Step 3: 真机/模拟器人工验收(逐项打勾)**
> **本步在本机当前状态下无法执行(ledger Ruling R21)。** 本机没有任何模拟器镜像,也没有连接真机
> (Android SDK 只装了 `platform-tools` / `platforms;android-36` / `build-tools;36.1.0`)。
> 因此下面这份清单**要交给人在有设备时执行**,不要由 agent 尝试 `installDebug`、
> 也不要临时去下模拟器镜像(约 1–2 GB)。执行 agent 在本步的动作是:
> 把清单原样抄进验收报告,明确标注「未执行 —— 无设备」,然后继续 Step 4。
> 本条是**已知且有意的覆盖缺口**,不要在其他任何地方把它当成已通过。
在设备上逐项确认:
- [ ] 应用启动无崩溃,底部四个 Tab 显示为 健康 / 运动 / 设备 / 我的
- [ ] 四个 Tab 可自由切换,切换后状态保留、无闪烁
- [ ] **重按已高亮的父 Tab 的行为**(Task 7 用 `popUpTo(start){saveState}` + `restoreState` 实现,
推测会**恢复到该 Tab 保存的子页栈**,即停在子页而不是回到 Tab 根 —— 请实测并记录实际行为)。
若实测是「停在子页」而产品希望「回到根」,那是一个独立的小改动,记下来即可,不算本任务失败
- [ ] 每个页面的 `@Preview` 能在 Android Studio 中正常渲染(R26;这是没有设备时唯一的界面检查手段)
- [ ] 次要文字的灰度一致:同一屏内不应出现深浅两种灰(`TextSecondary` #8E8E93
与 Material 默认 `onSurfaceVariant` #49454F 混用会很明显,R26)
- [ ] 首页:设备状态条显示“RingHealth 智能戒指 · 已连接 / 电量 78%”
- [ ] 首页:步数大环显示 8,426,目标 10,000,环按比例填充
- [ ] 首页:六个指标卡正常显示,**血压显示“未开启”**
- [ ] 首页:点击任一指标卡进入详情页,标题为该指标中文名
- [ ] 详情页:近 7 天显示 7 根柱子,切到近 30 天显示 30 根柱子
- [ ] 睡眠详情额外显示分期卡片(深睡/浅睡/清醒分段条 + 入睡/起床时间),其他指标没有这张卡
- [ ] 详情页:返回后回到首页
- [ ] 运动页:显示“开始运动”卡片 + 5 条运动记录
- [ ] 运动页:“查看全部”进入历史页,底部导航栏仍可见
- [ ] 设备页:戒指卡片显示电量 78%、固件版本 1.0.5
- [ ] 设备页:“更多设置”进入后开关可切换,返回再进入状态保持
- [ ] 我的页:头像卡与两个分组入口正常显示
- [ ] 旋转屏幕/深色系统设置下不崩溃(深色样式不保证,但不得崩溃)
- [ ] **Step 4: 写验收记录 `repos/ring-health-app/docs/verification-m1.md`**
```markdown
# M1 静态页面验收记录
- 验收日期:<填写>
- 构建版本:0.1.0 (versionCode 1)
- 测试设备:<填写机型与 Android 版本>
- 单元测试:`./gradlew :app:testDebugUnitTest` 全绿
- 构建:`./gradlew clean assembleDebug` 成功
## 人工验收结果
| 项 | 结果 | 备注 |
|---|---|---|
| 应用启动无崩溃 | | |
| 四 Tab 切换 | | |
| 首页步数大环 | | |
| 首页指标卡(含血压未开启) | | |
| 详情页周/月切换 | | |
| 运动页与历史页 | | |
| 设备页与开关 | | |
| 我的页 | | |
## 已知限制(阶段一有意为之)
- 数据全部为 Mock,不连蓝牙
- 血压恒为“未开启”占位
- 开关状态不持久化,重启即重置
- 仅浅色模式
```
- [ ] **Step 5: 更新 App `README.md` 页面清单**
在 README 的“当前阶段”下补充:
```markdown
## 页面清单(M1)
| 页面 | 路由 |
|---|---|
| 健康首页 | `home` |
| 运动 | `workout` |
| 运动历史 | `workout/history` |
| 设备 | `device` |
| 设备设置 | `device/settings` |
| 我的 | `profile` |
| 指标详情(步数/心率/睡眠/血氧/压力/体温/血压) | `detail/{metric}` |
```
- [ ] **Step 6: 提交并推送全部仓库**
```bash
cd /d/cherry/repos/ring-health-app && git add -A && git commit -m "docs: 补充 M1 验收记录与页面清单" && git push
cd /d/cherry/docs && git add -A && git commit -m "docs: 补充 M1 实施计划" && git push
```
Expected: 三个仓库(app / api / docs)在 Gitea 上均为最新。
---
## 备注
**图标依赖回退方案**:若 `material-icons-extended:1.7.8` 与 Compose BOM 2026.09.00 冲突导致构建失败,按此处理:
1. 从 `libs.versions.toml` 移除 `androidx-icons-extended`,从 `app/build.gradle.kts` 移除对应依赖;
2. 新建 `core/designsystem/icon/RingIcons.kt`,用 `ImageVector.Builder` 手绘所需图标(步数/心率/睡眠/血氧/压力/体温/血压/蓝牙/戒指 共 9 个);
3. 把所有 `Icons.Filled.Xxx` 引用替换为 `RingIcons.Xxx`。
**M2 交接提示**(本计划不实施):
- 厂商通讯类原样拷贝到 `com.sr.smartring` 包(保持不改);
- 新增 `data/sdk/SdkHealthDataRepository` 与 `SdkRingDeviceRepository` 实现同名的两个接口;
- 在 `DefaultAppContainer` 中把 Mock 换成 SDK 实现——**这是 M2 唯一需要改动的既有文件**;
- BLE 权限(`BLUETOOTH_SCAN` / `BLUETOOTH_CONNECT` / 位置)在 M2 加入清单与运行时请求。