From a1bc2322f36bb61a87e22e3e1d9f9f27dd85bf7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B4=94=E9=B2=81=E6=AD=A3?= <13780741225@163.com> Date: Thu, 10 Sep 2026 09:01:41 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E6=96=B0=E5=A2=9E=20M0+M1=20=E5=AE=9E?= =?UTF-8?q?=E6=96=BD=E8=AE=A1=E5=88=92=EF=BC=8814=20=E4=B8=AA=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=EF=BC=8C=E5=90=AB=20TDD=20=E6=AD=A5=E9=AA=A4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-09-10-ring-health-app-m0-m1.md | 3916 +++++++++++++++++ 1 file changed, 3916 insertions(+) create mode 100644 superpowers/plans/2026-09-10-ring-health-app-m0-m1.md diff --git a/superpowers/plans/2026-09-10-ring-health-app-m0-m1.md b/superpowers/plans/2026-09-10-ring-health-app-m0-m1.md new file mode 100644 index 0000000..6c9e5ba --- /dev/null +++ b/superpowers/plans/2026-09-10-ring-health-app-m0-m1.md @@ -0,0 +1,3916 @@ +# 智能戒指健康 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`(默认中文)。 +- **禁止行内样式散落**:颜色/字号统一定义在 `core/designsystem/`,页面不得直接写 `Color(0xFF...)`。 +- **命名规范**:类/Composable 用 PascalCase;函数/变量用 camelCase;常量用 UPPER_SNAKE_CASE;资源文件用 kebab-case/snake_case。 +- **注释规范**:每个类与公开函数上方写中文 KDoc,说明用途(团队规范要求)。 +- **提交规范**:每个 Task 结束提交一次;提交信息用中文,前缀 `feat:` / `chore:` / `test:` / `docs:`。 +- **Gitea 地址**:`http://120.192.101.56:3000/cuiluzheng/.git`(凭据助手已按主机配置好,可直接推送)。 + +## 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 + + + + + + + + + + + + + + +``` + +> `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 + +/** 唯一 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("RingHealth") + } + } + } + } + } +} +``` + +`app/src/main/res/values/strings.xml`: + +```xml + + + RingHealth + +``` + +`app/src/main/res/values/themes.xml`: + +```xml + + + + +``` + +`app/src/main/res/xml/backup_rules.xml`: + +```xml + + +``` + +`app/src/main/res/xml/data_extraction_rules.xml`: + +```xml + + + + + +``` + +`app/src/main/res/drawable/ic_launcher_foreground.xml`(占位矢量图标,内嵌于 Task 14 可再美化): + +```xml + + + + +``` + +`app/src/main/res/values/ic_launcher_background.xml`: + +```xml + + + #34C759 + +``` + +`app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml` 与 `ic_launcher_round.xml`(两文件内容相同): + +```xml + + + + + +``` + +- [ ] **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("RingHealth") + } + } + } +} +``` + +并补充 import:`com.placeholder.ringhealth.core.designsystem.RingHealthTheme`。 + +- [ ] **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, modifier, barHeight: Dp = 12.dp)`;`data class StatSegment(val weight: Float, val color: Color)` + - `MetricBarChart(values: List, 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, + 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, + 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` +- Create: `AppContainer.kt` +- Modify: `RingHealthApplication.kt`(持有容器) +- Create: `AppViewModelProvider.kt` + +**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) }` + - `AppContainer { healthDataRepository; ringDeviceRepository }`、`RingHealthApplication.container`、`AppViewModelProvider.Factory` + +- [ ] **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 + + /** 指定指标在指定时间范围内的历史数据。 */ + fun observeMetricHistory(metric: MetricType, range: DateRange): Flow> + + /** 运动记录列表。 */ + fun observeWorkouts(): Flow> +} + +/** + * 戒指设备状态与功能开关。M2 接入蓝牙后由真实实现替换。 + */ +interface RingDeviceRepository { + /** 设备状态(连接/电量/固件)。 */ + val deviceState: StateFlow + + /** 功能开关列表。 */ + val functionSettings: StateFlow> + + /** 主动刷新设备状态。 */ + suspend fun refreshDeviceState() + + /** 切换某个功能开关(M1 仅改内存状态)。 */ + fun setFunctionEnabled(id: String, enabled: Boolean) +} +``` + +- [ ] **Step 3: 写 `AppContainer.kt` 与更新 `RingHealthApplication.kt`** + +> 依赖 Mock 实现,需在 Task 6 完成后才能编译通过;本任务把 Mock 类的引用先写好,Step 6 的构建验证放在 Task 6 之后执行。 + +`AppContainer.kt`: + +```kotlin +package com.placeholder.ringhealth + +import com.placeholder.ringhealth.data.mock.MockHealthDataRepository +import com.placeholder.ringhealth.data.mock.MockRingDeviceRepository +import com.placeholder.ringhealth.domain.repository.HealthDataRepository +import com.placeholder.ringhealth.domain.repository.RingDeviceRepository + +/** + * 手动依赖注入容器。M2 接入 SDK 时只需在这里把 Mock 实现换成真实实现。 + */ +interface AppContainer { + val healthDataRepository: HealthDataRepository + val ringDeviceRepository: RingDeviceRepository +} + +/** 默认容器:阶段一全部使用 Mock 数据。 */ +class DefaultAppContainer : AppContainer { + override val healthDataRepository: HealthDataRepository = MockHealthDataRepository() + override val ringDeviceRepository: RingDeviceRepository = MockRingDeviceRepository() +} +``` + +`RingHealthApplication.kt`(替换 Task 1 的占位实现): + +```kotlin +package com.placeholder.ringhealth + +import android.app.Application + +/** + * 应用入口,同时作为手动依赖注入的组合根。 + */ +class RingHealthApplication : Application() { + + /** 全局依赖容器。 */ + lateinit var container: AppContainer + private set + + override fun onCreate() { + super.onCreate() + container = DefaultAppContainer() + } +} +``` + +- [ ] **Step 4: 写 `AppViewModelProvider.kt`** + +```kotlin +package com.placeholder.ringhealth + +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewmodel.CreationExtras +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import com.placeholder.ringhealth.feature.device.DeviceViewModel +import com.placeholder.ringhealth.feature.home.HomeViewModel +import com.placeholder.ringhealth.feature.workout.WorkoutViewModel + +/** + * ViewModel 工厂:把容器中的仓库注入各 ViewModel。 + */ +object AppViewModelProvider { + + val Factory = viewModelFactory { + initializer { + HomeViewModel( + healthDataRepository = ringHealthApplication().container.healthDataRepository, + ringDeviceRepository = ringHealthApplication().container.ringDeviceRepository, + ) + } + initializer { + WorkoutViewModel( + healthDataRepository = ringHealthApplication().container.healthDataRepository, + ) + } + initializer { + DeviceViewModel( + ringDeviceRepository = ringHealthApplication().container.ringDeviceRepository, + ) + } + } +} + +/** 从 CreationExtras 中取出 Application 实例。 */ +fun CreationExtras.ringHealthApplication(): RingHealthApplication = + this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as RingHealthApplication +``` + +> 说明:`MetricDetailViewModel` 需要运行时参数(指标类型),使用独立的工厂,见 Task 12。 + +- [ ] **Step 5: 暂不构建(依赖 Mock 类尚未创建)** + +跳到 Task 5、Task 6,完成 Mock 后再统一验证。 + +- [ ] **Step 6: Commit** + +```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` + - `stressLevelLabel(stress: Int): String` + - `metricDisplayName(type: MetricType): String` + +- [ ] **Step 1: 写失败的测试** + +`app/src/test/java/com/placeholder/ringhealth/core/common/FormattersTest.kt`: + +```kotlin +package com.placeholder.ringhealth.core.common + +import com.placeholder.ringhealth.domain.model.MetricType +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)) + } + + @Test + fun `stressLevelLabel 按区间返回中文描述`() { + assertEquals("放松", stressLevelLabel(20)) + assertEquals("正常", stressLevelLabel(45)) + assertEquals("中等", stressLevelLabel(65)) + assertEquals("偏高", stressLevelLabel(90)) + } + + @Test + fun `metricDisplayName 返回中文指标名`() { + assertEquals("步数", metricDisplayName(MetricType.STEP)) + assertEquals("心率", metricDisplayName(MetricType.HEART_RATE)) + assertEquals("睡眠", metricDisplayName(MetricType.SLEEP)) + assertEquals("血氧", metricDisplayName(MetricType.BLOOD_OXYGEN)) + assertEquals("压力", metricDisplayName(MetricType.STRESS)) + assertEquals("体温", metricDisplayName(MetricType.TEMPERATURE)) + assertEquals("血压", metricDisplayName(MetricType.BLOOD_PRESSURE)) + } +} +``` + +- [ ] **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 com.placeholder.ringhealth.domain.model.MetricType +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) + +/** 卡路里:带中文单位。 */ +fun formatCalories(kcal: Int): String = "$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)) + +/** 压力值 → 中文等级。 */ +fun stressLevelLabel(stress: Int): String = when { + stress < 30 -> "放松" + stress < 60 -> "正常" + stress < 80 -> "中等" + else -> "偏高" +} + +/** 指标类型 → 中文名称。 */ +fun metricDisplayName(type: MetricType): String = when (type) { + MetricType.STEP -> "步数" + MetricType.HEART_RATE -> "心率" + MetricType.SLEEP -> "睡眠" + MetricType.BLOOD_OXYGEN -> "血氧" + MetricType.STRESS -> "压力" + MetricType.TEMPERATURE -> "体温" + MetricType.BLOOD_PRESSURE -> "血压" +} +``` + +- [ ] **Step 4: 运行测试,确认通过** + +```bash +cd /d/cherry/repos/ring-health-app && ./gradlew :app:testDebugUnitTest --tests "com.placeholder.ringhealth.core.common.FormattersTest" +``` + +Expected: `BUILD SUCCESSFUL`,7 个测试全部通过。 + +- [ ] **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` +- Test: `app/src/test/java/com/placeholder/ringhealth/data/mock/MockDataProviderTest.kt` + +**Interfaces:** +- Consumes: `domain/model/*`、`domain/repository/*` +- Produces: + - `class MockDataProvider(seed: Long = DEFAULT_SEED)`,方法:`todaySummary(): HealthSummary`、`metricHistory(metric: MetricType, range: DateRange): List`、`workouts(): List`、`deviceState(): RingDeviceState`、`defaultFunctionSettings(): List` + - `class MockHealthDataRepository : HealthDataRepository`(每次发射前 `delay(200)`) + - `class MockRingDeviceRepository : RingDeviceRepository`(开关状态保存在内存 StateFlow) + +- [ ] **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 { + + private val provider = MockDataProvider() + + @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 }) + } + + @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 接入真机后本类整体弃用。 + */ +class MockDataProvider(private val seed: Long = DEFAULT_SEED) { + + /** 今日概览:固定演示值。血压为 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 { + val count = if (range == DateRange.WEEK) 7 else 30 + val random = Random(seed + metric.ordinal * 100L + range.ordinal) + val (min, max) = valueRange(metric) + val now = System.currentTimeMillis() + 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 = now - (count - 1 - index) * dayMillis, + value = value.coerceIn(min, max), + ) + } + } + + /** 运动记录:固定 5 条演示数据,倒序(最近的在最前)。 */ + fun workouts(): List { + val random = Random(seed + 7) + val now = System.currentTimeMillis() + val dayMillis = 24L * 60 * 60 * 1000 + val types = listOf("户外跑步", "健走", "骑行", "自由训练", "户外跑步") + return types.mapIndexed { index, type -> + WorkoutRecord( + id = "workout-$index", + type = type, + startTime = now - (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 = 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 = 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 = flow { + delay(MOCK_DELAY_MILLIS) + emit(provider.todaySummary()) + } + + override fun observeMetricHistory(metric: MetricType, range: DateRange): Flow> = flow { + delay(MOCK_DELAY_MILLIS) + emit(provider.metricHistory(metric, range)) + } + + override fun observeWorkouts(): Flow> = 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 设备仓库。开关状态保存在内存中,进程重启后重置(阶段一不做持久化)。 + */ +class MockRingDeviceRepository( + private val provider: MockDataProvider = MockDataProvider(), +) : RingDeviceRepository { + + private val _deviceState = MutableStateFlow(provider.deviceState()) + override val deviceState: StateFlow = _deviceState.asStateFlow() + + private val _functionSettings = MutableStateFlow(provider.defaultFunctionSettings()) + override val functionSettings: StateFlow> = _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: 全量单元测试 + 构建验证** + +```bash +cd /d/cherry/repos/ring-health-app && ./gradlew :app:testDebugUnitTest && ./gradlew assembleDebug +``` + +Expected: 两个命令均 `BUILD SUCCESSFUL`(Task 4 的容器代码此时也应编译通过)。 + +- [ ] **Step 8: Commit** + +```bash +git add -A && git commit -m "feat: 新增 Mock 数据层(Provider + 两个仓库实现,含单元测试)" +``` + +--- + +### 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, onOpenWorkoutHistory: () -> Unit)`、`WorkoutScreen(onOpenHistory: () -> Unit)`、`DeviceScreen(onOpenSettings: () -> Unit)`、`ProfileScreen()` + +- [ ] **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 com.placeholder.ringhealth.domain.model.MetricType + +/** 健康首页。 */ +@Composable +fun HomeScreen( + onOpenDetail: (MetricType) -> Unit, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("健康首页") + } +} +``` + +`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 + +/** 运动页。 */ +@Composable +fun WorkoutScreen(modifier: Modifier = Modifier) { + Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("运动") + } +} +``` + +`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 + +/** 设备页。 */ +@Composable +fun DeviceScreen(modifier: Modifier = Modifier) { + Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("设备") + } +} +``` + +`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 + +/** 我的页。 */ +@Composable +fun ProfileScreen(modifier: Modifier = Modifier) { + Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("我的") + } +} +``` + +- [ ] **Step 3: 写 `strings.xml` 的导航文案** + +在 `res/values/strings.xml` 的 `` 内追加: + +```xml + 健康 + 运动 + 设备 + 我的 +``` + +- [ ] **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 + +/** 设备设置子页(待 Task 10 实现)。 */ +@Composable +fun DeviceSettingsPlaceholder(modifier: Modifier = Modifier) { + Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("设备设置") + } +} +``` + +`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 + +/** 运动历史子页(待 Task 9 实现)。 */ +@Composable +fun WorkoutHistoryPlaceholder(modifier: Modifier = Modifier) { + Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("运动历史") + } +} +``` + +`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 com.placeholder.ringhealth.domain.model.MetricType + +/** 指标详情子页(待 Task 12 实现)。 */ +@Composable +fun MetricDetailPlaceholder(metric: MetricType, modifier: Modifier = Modifier) { + Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("详情:${metric.name}") + } +} +``` + +- [ ] **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 && ./gradlew installDebug +``` + +Expected: 构建成功并装到设备/模拟器;打开 App 可见底部四个 Tab,点击可切换、互不崩溃。 + +- [ ] **Step 8: Commit** + +```bash +git add -A && git commit -m "feat: 新增底部四 Tab 导航骨架与路由表" +``` + +--- + +### Task 8: 健康首页(完整实现,含 ViewModel) + +**Files:** +- Create: `feature/home/HomeViewModel.kt` +- Modify: `feature/home/HomeScreen.kt`(完整实现) +- Test: `app/src/test/java/com/placeholder/ringhealth/feature/home/HomeViewModelTest.kt` +- Modify: `res/values/strings.xml` + +**Interfaces:** +- Consumes: `HealthDataRepository`、`RingDeviceRepository`、`AppViewModelProvider.Factory`、Task 3 组件 +- Produces: + - `data class HomeUiState(val loading: Boolean, val summary: HealthSummary?, val device: RingDeviceState?)` + - `class HomeViewModel(healthDataRepository, ringDeviceRepository)` 暴露 `uiState: StateFlow` + - `@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 = 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: 追加首页文案到 `strings.xml`** + +```xml + 今天也要保持活力 + %1$s · 已连接 + 未连接设备 + 电量 %1$d%% + 今日步数 + 目标 %1$s 步 + 千卡 + 公里 + 健康指标 + 未开启 + 点击了解 + 次/分 + % + °C + 小时 + 分钟 +``` + +- [ ] **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.item +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 = summary.calories.toString(), + 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 = 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 = "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 = ...` 就是为此。 + +- [ ] **Step 7: 追加指标名文案到 `strings.xml`** + +```xml + 心率 + 睡眠 + 血氧 + 压力 + 体温 + 血压 + 步数 +``` + +- [ ] **Step 8: 构建 + 单测 + 真机验证** + +```bash +cd /d/cherry/repos/ring-health-app && ./gradlew :app:testDebugUnitTest && ./gradlew installDebug +``` + +Expected: 测试全绿;真机上首页显示设备条、步数大环、六个指标卡(血压显示“未开启”),点击卡片进入详情占位页并可返回。 + +- [ ] **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)` + - `class WorkoutViewModel(healthDataRepository)` 暴露 `uiState: StateFlow` + - `@Composable fun WorkoutScreen(onOpenHistory: () -> Unit, modifier, viewModel)`、`@Composable fun WorkoutHistoryScreen(modifier, viewModel)` + +- [ ] **Step 1: 写 `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, +) + +/** 运动页 ViewModel。 */ +class WorkoutViewModel( + healthDataRepository: HealthDataRepository, +) : ViewModel() { + + val uiState: StateFlow = 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 2: 追加文案到 `strings.xml`** + +```xml + 开始运动 + 选择运动类型,记录你的训练 + 最近运动 + 全部记录 + 暂无运动记录 + %1$s · %2$s 公里 + 时长 %1$s + 消耗 %1$s + 查看全部 +``` + +- [ ] **Step 3: 实现 `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) + 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 4: 实现 `WorkoutHistoryScreen.kt`** + +```kotlin +package com.placeholder.ringhealth.feature.workout + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.placeholder.ringhealth.AppViewModelProvider +import com.placeholder.ringhealth.R +import androidx.compose.ui.unit.dp + +/** 运动历史子页:展示全部运动记录。 */ +@Composable +fun WorkoutHistoryScreen( + modifier: Modifier = Modifier, + viewModel: WorkoutViewModel = viewModel(factory = AppViewModelProvider.Factory), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + if (state.records.isEmpty()) { + Text(text = stringResource(R.string.workout_empty), style = MaterialTheme.typography.bodyMedium) + return + } + LazyColumn( + modifier = modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(state.records) { record -> + WorkoutRecordCard(record = record) + } + } +} +``` + +- [ ] **Step 5: 在 `RingHealthApp.kt` 中接入真实页面** + +将 `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` 中不再保留占位函数)。 + +> 注意:`WorkoutViewModel` 在运动页与历史页各创建一次实例(导航到不同 destination 时独立作用域)。阶段一数据相同,可接受;M3 引入真实数据后如需共享,再提升作用域。 + +- [ ] **Step 6: 构建 + 验证** + +```bash +cd /d/cherry/repos/ring-health-app && ./gradlew assembleDebug && ./gradlew installDebug +``` + +Expected: 运动 Tab 显示“开始运动”卡片 + 5 条记录;“查看全部”跳转历史页。 + +- [ ] **Step 7: 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)` + - `class DeviceViewModel(ringDeviceRepository)` 暴露 `uiState: StateFlow`、`fun setFunctionEnabled(id: String, enabled: Boolean)` + - `@Composable fun DeviceScreen(onOpenSettings: () -> Unit, modifier, viewModel)`、`@Composable fun DeviceSettingsScreen(modifier, viewModel)` + +- [ ] **Step 1: 写 `DeviceViewModel.kt`** + +```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, +) + +/** 设备页 ViewModel:设备状态 + 功能开关。 */ +class DeviceViewModel( + private val ringDeviceRepository: RingDeviceRepository, +) : ViewModel() { + + val uiState: StateFlow = 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 2: 追加文案到 `strings.xml`** + +```xml + 我的设备 + 已连接 + 未连接 + 电量 %1$d%% + 固件版本 %1$s + 充电中 + 功能设置 + 查找戒指 + 让戒指震动以便寻找 + 固件升级 + 检查并升级到最新固件 + 佩戴检测 + 未佩戴时暂停测量 + 更多设置 + 已开启 + 已关闭 +``` + +- [ ] **Step 3: 实现 `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.Settings +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.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.TextSecondary +import com.placeholder.ringhealth.core.designsystem.component.HealthCard +import com.placeholder.ringhealth.core.designsystem.component.SectionHeader +import com.placeholder.ringhealth.domain.model.DeviceFunctionSetting + +/** 设备(戒指管理)页。 */ +@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, + ) + } + } +} +``` + +- [ ] **Step 4: 实现 `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.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 +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() + LazyColumn( + modifier = modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item { SectionHeader(title = stringResource(R.string.device_settings_title)) } + items(state.settings) { setting -> + 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 -> viewModel.setFunctionEnabled(setting.id, checked) }, + colors = androidx.compose.material3.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), + ) + } + } +} +``` + +- [ ] **Step 5: 追加提示文案到 `strings.xml`** + +```xml + 阶段一为演示开关,接入戒指后将同步到设备。 +``` + +- [ ] **Step 6: 在 `RingHealthApp.kt` 接入真实页面** + +```kotlin + composable(Routes.DEVICE) { + DeviceScreen(onOpenSettings = { navController.navigate(Routes.DEVICE_SETTINGS) }) + } + composable(Routes.DEVICE_SETTINGS) { + DeviceSettingsScreen() + } +``` + +删除 `DeviceSettingsPlaceholder` 的定义与 import。 + +- [ ] **Step 7: 构建 + 验证** + +```bash +cd /d/cherry/repos/ring-health-app && ./gradlew assembleDebug && ./gradlew installDebug +``` + +Expected: 设备 Tab 显示戒指卡片(电量 78%、固件 1.0.5);“更多设置”进入后开关可切换且返回后状态保持。 + +- [ ] **Step 8: 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 + 健康用户 + 登录后可同步数据(阶段一未开放) + 应用设置 + 单位设置 + 公里 / 公斤 + 通知权限 + 接收健康提醒 + 关于 + 检查更新 + 当前版本 0.1.0 + 帮助与反馈 + 常见问题与意见反馈 +``` + +- [ ] **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 && ./gradlew installDebug +``` + +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` +- Produces: + - `data class MetricDetailUiState(val loading: Boolean, val metric: MetricType, val range: DateRange, val points: List)` + - `class MetricDetailViewModel(metric: MetricType, healthDataRepository)`,方法 `fun switchRange(range: DateRange)` + - `@Composable fun MetricDetailScreen(metric: MetricType, onBack: () -> Unit, modifier, viewModel: MetricDetailViewModel = viewModel(factory = MetricDetailViewModel.factory(metric)))` + - `MetricDetailViewModel.factory(metric: MetricType): ViewModelProvider.Factory`(伴生对象) + +- [ ] **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, +) + +/** + * 指标详情 ViewModel。因需要指标参数,使用独立工厂创建。 + */ +@OptIn(ExperimentalCoroutinesApi::class) +class MetricDetailViewModel( + private val metric: MetricType, + healthDataRepository: HealthDataRepository, +) : ViewModel() { + + private val range = MutableStateFlow(DateRange.WEEK) + + val uiState: StateFlow = 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 2: 追加文案到 `strings.xml`** + +```xml + 近 7 天 + 近 30 天 + 平均 %1$s + 最高 %1$s + 最低 %1$s + 趋势 + 睡眠分期 + 深睡 + 浅睡 + 清醒 + 该指标尚未开启 + 前往设备页开启对应的自动监测功能。 + 返回 +``` + +- [ ] **Step 3: 实现 `MetricDetailScreen.kt`(替换占位)** + +```kotlin +package com.placeholder.ringhealth.feature.detail + +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.lazy.LazyColumn +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.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.placeholder.ringhealth.R +import com.placeholder.ringhealth.core.common.metricDisplayName +import com.placeholder.ringhealth.core.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.domain.model.DateRange +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 = metricDisplayName(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) + } + } + } + } +} + +/** 周/月切换。 */ +@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.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 -> + val raw = entry.arguments?.getString(Routes.DETAIL_ARG_METRIC).orEmpty() + val metric = MetricType.entries.firstOrNull { it.name == raw } ?: MetricType.STEP + MetricDetailScreen(metric = metric, onBack = { navController.popBackStack() }) + } +``` + +删除 `MetricDetailPlaceholder` 的定义与 import。 + +> 注意:详情页自带 `Scaffold` + `TopAppBar`,而外层 `RingHealthApp` 的 `Scaffold` 会传入 `innerPadding`(已用于 NavHost 的 `Modifier.padding`)。为避免底部导航重复遮罩,详情页在 NavHost 内会同时受外层 padding 影响——这是预期行为(底部栏保持可见)。 + +- [ ] **Step 5: 构建 + 验证** + +```bash +cd /d/cherry/repos/ring-health-app && ./gradlew assembleDebug && ./gradlew installDebug +``` + +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 ` +- 时间戳:毫秒(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=&to=&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: 真机/模拟器人工验收(逐项打勾)** + +```bash +cd /d/cherry/repos/ring-health-app && ./gradlew installDebug +``` + +在设备上逐项确认: + +- [ ] 应用启动无崩溃,底部四个 Tab 显示为 健康 / 运动 / 设备 / 我的 +- [ ] 四个 Tab 可自由切换,切换后状态保留、无闪烁 +- [ ] 首页:设备状态条显示“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 加入清单与运行时请求。