# 智能戒指健康 App(M0+M1 静态页面)实施计划 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 交付一个可运行的 Android App 骨架:四个 Tab(健康首页/运动/设备/我的)加数据详情子页,全部页面静态可用,数据来自 Mock,UI 只依赖仓库接口,为 M2 接入厂商蓝牙 SDK 预留清晰边界。 **Architecture:** 单 `:app` Gradle 模块 + 严格分包(`core/` `domain/` `data/` `feature/`)。UI 只依赖 `domain/repository` 的接口与 `domain/model` 的模型;阶段一注入 `data/mock/` 的 Mock 实现。所有页面为纯静态/演示数据,M2 只需新增一套实现同样接口的 SDK 适配层,UI 零改动。 **Tech Stack:** Kotlin 2.2.20、Jetpack Compose(BOM 2026.09.00)、Navigation-Compose 2.10.1、Material 3、AGP 8.13.1、Gradle 8.14、JDK 17、JUnit4 + kotlinx-coroutines-test 1.10.2。 **Spec:** [docs/superpowers/specs/2026-09-10-ring-health-app-design.md](../specs/2026-09-10-ring-health-app-design.md) ## Global Constraints - **仓库名**:`ring-health-app`、`ring-health-api`(kebab-case,与 Gitea 上名称一致)。 - **包名/namespace**:`com.placeholder.ringhealth`(占位,后续按 spec §12 统一替换)。 - **SDK 版本**:`minSdk 26` / `targetSdk 36` / `compileSdk 36` / `buildToolsVersion "36.1.0"`(36.1.0 是本机已装版本,写死避免下载)。 - **工具链钉死**:Gradle `8.14`、AGP `8.13.1`、Kotlin `2.2.20`(三者均为本机缓存版本)。 - **依赖约束**:阶段一**不引入** Hilt、Room、Retrofit、第三方图表库。图表一律用 Compose `Canvas` 手绘。 - **不硬编码文案**:所有用户可见文字进 `res/values/strings.xml`(默认中文)。 **唯一豁免**(见 ledger Ruling R13):`core/common/Formatters.kt` 中 `formatSleepDuration`(`7小时32分`)与 `formatDuration`(`1小时05分`)这两条 —— 它们是数字与单位**黏合成单一复合值**、且设计系统没有独立单位槽位可放的时长排版, 拆开会把同一段 `when` 逻辑复制进每个调用点。除此之外,格式化函数**一律不得**拼接 用户可见文字:单位与描述词要么由调用点用资源提供(如 `formatCalories` 的「千卡」), 要么以 `@StringRes` 返回资源 id(如 `stressLevelLabelRes`)。判据是 「该文字是否已在 `strings.xml` 里有第二份」与「设计系统是否已提供独立槽位」。 - **禁止行内样式散落**:颜色/字号统一定义在 `core/designsystem/`,页面不得直接写 `Color(0xFF...)`。 - **命名规范**:类/Composable 用 PascalCase;函数/变量用 camelCase;资源文件用 kebab-case/snake_case。 `const val` 编译期常量用 UPPER_SNAKE_CASE。 **例外**:`core/designsystem/` 里的设计 token(`BrandGreen`、`MetricRed`、`AppBackground`、 `MetricValueStyle` 等)一律用 PascalCase —— 它们是 Compose 惯用的公开设计 token,不是 `const val`, 且 Tasks 3–12 的组件与页面都按这些确切名字引用,改名会破坏接口契约。此例外即本约束的一部分。 - **注释规范**:每个类与公开函数上方写中文 KDoc,说明用途(团队规范要求)。 - **提交规范**:每个 Task 结束提交一次;提交信息用中文,前缀 `feat:` / `chore:` / `test:` / `docs:`。 - **Gitea 地址**:`http://120.192.101.56:3000/cuiluzheng/.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 import androidx.compose.ui.res.stringResource /** 唯一 Activity,承载 Compose 界面。 */ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() setContent { MaterialTheme { Surface(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text(stringResource(R.string.app_name)) } } } } } } ``` `app/src/main/res/values/strings.xml`: ```xml 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(stringResource(R.string.app_name)) } } } } ``` 并补充 import:`com.placeholder.ringhealth.core.designsystem.RingHealthTheme`、 `androidx.compose.ui.res.stringResource`。 注意:文案必须走 `stringResource(R.string.app_name)`,不得写成 `Text("RingHealth")` 字面量 —— 全局约束「不硬编码用户可见文案」对 Compose 里的每一处 `Text(...)` 都成立。Task 1 的初版 就是因为计划里的示例代码写成了字面量而引入该缺陷,修复轮才扳正(见 ledger 裁定)。 - [ ] **Step 5: 构建验证** ```bash cd /d/cherry/repos/ring-health-app && ./gradlew assembleDebug ``` Expected: `BUILD SUCCESSFUL`。 - [ ] **Step 6: Commit** ```bash git add -A && git commit -m "feat: 新增设计系统(颜色/字体/浅色主题)" ``` --- ### Task 3: 通用 UI 组件(卡片 / 环形进度 / 指标块 / 分段条 / 柱状图) **Files:** - Create: `core/designsystem/component/HealthCard.kt`, `RingProgress.kt`, `MetricTile.kt`, `SectionHeader.kt`, `StatBar.kt`, `MetricBarChart.kt` **Interfaces:** - Consumes: Task 2 的颜色与字体 - Produces(后续所有页面的构件): - `HealthCard(modifier, onClick: (() -> Unit)? = null, content: @Composable ColumnScope.() -> Unit)` - `RingProgress(progress: Float, color: Color, trackColor: Color, modifier, ringSize: Dp = 180.dp, strokeWidth: Dp = 14.dp, content: @Composable () -> Unit = {})` - `MetricTile(icon: ImageVector, iconTint: Color, title: String, value: String, unit: String, modifier, onClick: () -> Unit)` - `SectionHeader(title: String, modifier, trailing: (@Composable () -> Unit)? = null)` - `StatBar(segments: List, 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` > **范围说明(见 ledger Ruling R1 / R2)**:依赖容器 `AppContainer.kt` 与 `AppViewModelProvider.kt` > **不在本任务创建**——前者引用 Task 6 才存在的 Mock 实现类,后者引用 Task 8 才存在的 `HomeViewModel`。 > 若在本任务创建,Task 4 到 Task 6 之间的任何构建或单测都会因未解析引用而无法编译。 **Interfaces:** - Consumes: 无(纯 Kotlin 层) - Produces: - `enum class MetricType { STEP, HEART_RATE, SLEEP, BLOOD_OXYGEN, STRESS, TEMPERATURE, BLOOD_PRESSURE }` - `enum class DateRange { WEEK, MONTH }` - `data class RingDeviceState(connected, name, batteryPercent, firmwareVersion, charging)` - `data class HealthSummary(steps, stepGoal, calories, distanceKm, heartRate?, sleepMinutes?, bloodOxygen?, stress?, temperature?, bloodPressure?)` - `data class MetricPoint(timestamp: Long, value: Double)` - `data class WorkoutRecord(id, type, startTime, durationMinutes, distanceKm, calories)` - `data class DeviceFunctionSetting(id, title, subtitle, enabled)` - `interface HealthDataRepository { observeTodaySummary(); observeMetricHistory(metric, range); observeWorkouts() }` - `interface RingDeviceRepository { deviceState; functionSettings; refreshDeviceState(); setFunctionEnabled(id, enabled) }` - [ ] **Step 1: 写 `HealthModels.kt`** ```kotlin package com.placeholder.ringhealth.domain.model /** 健康指标类型。路由与详情页以此区分。 */ enum class MetricType { STEP, HEART_RATE, SLEEP, BLOOD_OXYGEN, STRESS, TEMPERATURE, BLOOD_PRESSURE, } /** 历史数据的时间范围。 */ enum class DateRange { WEEK, MONTH } /** * 戒指设备状态。M2 接入蓝牙后由真实 SDK 填充,当前由 Mock 提供。 */ data class RingDeviceState( val connected: Boolean, val name: String, val batteryPercent: Int, val firmwareVersion: String, val charging: Boolean, ) /** * 今日健康概览。字段为 null 表示该指标未开启或暂无数据,UI 需展示“未开启”占位而非 0。 */ data class HealthSummary( val steps: Int, val stepGoal: Int, val calories: Int, val distanceKm: Double, val heartRate: Int?, val sleepMinutes: Int?, val bloodOxygen: Int?, val stress: Int?, val temperature: Double?, val bloodPressure: String?, ) /** 一条历史数据点。 */ data class MetricPoint(val timestamp: Long, val value: Double) /** 一条运动记录。 */ data class WorkoutRecord( val id: String, val type: String, val startTime: Long, val durationMinutes: Int, val distanceKm: Double, val calories: Int, ) /** 戒指功能开关项(对应协议中的自动监测开关)。 */ data class DeviceFunctionSetting( val id: String, val title: String, val subtitle: String, val enabled: Boolean, ) ``` - [ ] **Step 2: 写 `Repositories.kt`** ```kotlin package com.placeholder.ringhealth.domain.repository import com.placeholder.ringhealth.domain.model.DateRange import com.placeholder.ringhealth.domain.model.DeviceFunctionSetting import com.placeholder.ringhealth.domain.model.HealthSummary import com.placeholder.ringhealth.domain.model.MetricPoint import com.placeholder.ringhealth.domain.model.MetricType import com.placeholder.ringhealth.domain.model.RingDeviceState import com.placeholder.ringhealth.domain.model.WorkoutRecord import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow /** * 健康数据来源。M1 由 Mock 实现,M2/M3 由厂商 SDK 适配层实现,UI 无需改动。 */ interface HealthDataRepository { /** 今日概览,随数据变化持续发射。 */ fun observeTodaySummary(): Flow /** 指定指标在指定时间范围内的历史数据。 */ 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: 构建验证** ```bash cd /d/cherry/repos/ring-health-app && ./gradlew assembleDebug ``` Expected: `BUILD SUCCESSFUL`。本任务只产出纯 Kotlin 的 `domain/` 层,不引用任何尚未创建的类。 - [ ] **Step 4: Commit** ```bash git add -A && git commit -m "feat: 新增领域模型与仓库接口" ``` --- ### Task 5: 数值格式化工具(TDD) **Files:** - Create: `app/src/main/java/com/placeholder/ringhealth/core/common/Formatters.kt` - Test: `app/src/test/java/com/placeholder/ringhealth/core/common/FormattersTest.kt` **Interfaces:** - Consumes: 无 - Produces(页面与组件通用): - `formatSteps(steps: Int): String` - `formatDistance(km: Double): String` - `formatCalories(kcal: Int): String` - `formatSleepDuration(minutes: Int): String` - `formatDuration(minutes: Int): String` - `formatClock(timestamp: Long): String` - `formatDate(timestamp: Long): String` - `stressLevelLabelRes(stress: Int): Int`(带 `@StringRes` 标注) > **范围说明(见 ledger Ruling R3)**:本任务**不提供** `metricDisplayName(type)`。 > 指标中文名属于用户可见文案,Global Constraints 要求一律进 `strings.xml`; > 把它硬编码在 Kotlin 里会与 Task 8 加入 `R.string.metric_*` 形成两份事实来源。 > 详情页标题改用资源 id 映射,见 Task 12。 > > **范围说明(见 ledger Ruling R13)**:本任务**不得**在 Kotlin 里拼接用户可见文字,两处按此调整: > ①`formatCalories` 只返回千分位数字,「千卡」由调用点用资源拼接 —— 硬编码会与 > `R.string.home_calories_label` 形成两份事实来源,正是 R3 排除 `metricDisplayName` 的同一条理由; > ②压力等级**改为返回字符串资源 id**(`stressLevelLabelRes`),中文描述进 `strings.xml`, > 与 R3 对指标名的处理保持一致。**唯一豁免**见 Global Constraints 的说明 > (`formatSleepDuration` / `formatDuration` 的「7小时32分」这类数字+单位黏合复合值)。 > 本任务同时负责新增这几条字符串资源(见 Step 3)。 - [ ] **Step 1: 写失败的测试** `app/src/test/java/com/placeholder/ringhealth/core/common/FormattersTest.kt`: ```kotlin package com.placeholder.ringhealth.core.common import com.placeholder.ringhealth.R import org.junit.Assert.assertEquals import org.junit.Test class FormattersTest { @Test fun `formatSleepDuration 处理小时与分钟`() { assertEquals("7小时32分", formatSleepDuration(452)) assertEquals("45分", formatSleepDuration(45)) assertEquals("8小时", formatSleepDuration(480)) assertEquals("0分", formatSleepDuration(0)) } @Test fun `formatDuration 处理小时与分钟`() { assertEquals("1小时05分", formatDuration(65)) assertEquals("42分", formatDuration(42)) } @Test fun `formatSteps 添加千分位`() { assertEquals("8,426", formatSteps(8426)) assertEquals("0", formatSteps(0)) assertEquals("1,234,567", formatSteps(1234567)) } @Test fun `formatDistance 保留两位小数`() { assertEquals("6.21", formatDistance(6.212)) assertEquals("0.00", formatDistance(0.0)) } @Test fun `formatCalories 只返回千分位数字`() { assertEquals("327", formatCalories(327)) assertEquals("1,234", formatCalories(1234)) } @Test fun `stressLevelLabelRes 按区间映射到对应字符串资源`() { assertEquals(R.string.stress_relaxed, stressLevelLabelRes(20)) assertEquals(R.string.stress_normal, stressLevelLabelRes(45)) assertEquals(R.string.stress_medium, stressLevelLabelRes(65)) assertEquals(R.string.stress_high, stressLevelLabelRes(90)) } } ``` - [ ] **Step 2: 运行测试,确认失败** ```bash cd /d/cherry/repos/ring-health-app && ./gradlew :app:testDebugUnitTest --tests "com.placeholder.ringhealth.core.common.FormattersTest" ``` Expected: 编译失败或测试失败(`formatSleepDuration` 等未定义 / `Unresolved reference`)。 - [ ] **Step 3: 写最小实现** `app/src/main/java/com/placeholder/ringhealth/core/common/Formatters.kt`: ```kotlin package com.placeholder.ringhealth.core.common import androidx.annotation.StringRes import com.placeholder.ringhealth.R import java.text.SimpleDateFormat import java.util.Date import java.util.Locale /** 步数:千分位,如 8,426。 */ fun formatSteps(steps: Int): String = String.format(Locale.CHINA, "%,d", steps) /** 距离:保留两位小数(单位 km 由 UI 单独展示)。 */ fun formatDistance(km: Double): String = String.format(Locale.CHINA, "%.2f", km) /** 卡路里:只做千分位,单位「千卡」由 UI 用字符串资源拼接(ledger Ruling R13)。 */ fun formatCalories(kcal: Int): String = String.format(Locale.CHINA, "%,d", kcal) /** 睡眠时长:7小时32分 / 45分 / 8小时。 */ fun formatSleepDuration(minutes: Int): String { val hours = minutes / 60 val mins = minutes % 60 return when { hours > 0 && mins > 0 -> "${hours}小时${mins}分" hours > 0 -> "${hours}小时" else -> "${mins}分" } } /** 运动时长:1小时05分 / 42分(分钟补零)。 */ fun formatDuration(minutes: Int): String { val hours = minutes / 60 val mins = minutes % 60 return if (hours > 0) { String.format(Locale.CHINA, "%d小时%02d分", hours, mins) } else { "${mins}分" } } /** 时间戳 → HH:mm。 */ fun formatClock(timestamp: Long): String = SimpleDateFormat("HH:mm", Locale.CHINA).format(Date(timestamp)) /** 时间戳 → yyyy-MM-dd。 */ fun formatDate(timestamp: Long): String = SimpleDateFormat("yyyy-MM-dd", Locale.CHINA).format(Date(timestamp)) /** 压力值 → 字符串资源 id。中文描述的唯一来源是 `strings.xml`(ledger Ruling R13)。 */ @StringRes fun stressLevelLabelRes(stress: Int): Int = when { stress < 30 -> R.string.stress_relaxed stress < 60 -> R.string.stress_normal stress < 80 -> R.string.stress_medium else -> R.string.stress_high } ``` 同时把本任务新增的文案追加进 `app/src/main/res/values/strings.xml` 的 `` 内: ```xml 放松 正常 中等 偏高 ``` - [ ] **Step 4: 运行测试,确认通过** ```bash cd /d/cherry/repos/ring-health-app && ./gradlew :app:testDebugUnitTest --tests "com.placeholder.ringhealth.core.common.FormattersTest" ``` Expected: `BUILD SUCCESSFUL`,6 个测试全部通过。 - [ ] **Step 5: Commit** ```bash git add -A && git commit -m "feat: 新增数值格式化工具(含单元测试)" ``` --- ### Task 6: Mock 数据层(TDD) **Files:** - Create: `data/mock/MockDataProvider.kt`, `MockHealthDataRepository.kt`, `MockRingDeviceRepository.kt` - Create: `AppContainer.kt`(原属 Task 4,见 ledger Ruling R1) - Modify: `RingHealthApplication.kt`(持有容器) - Test: `app/src/test/java/com/placeholder/ringhealth/data/mock/MockDataProviderTest.kt` **Interfaces:** - Consumes: `domain/model/*`、`domain/repository/*` - Produces: - `interface AppContainer { healthDataRepository; ringDeviceRepository }`、`class DefaultAppContainer : AppContainer`、`RingHealthApplication.container` - `class MockDataProvider(seed: Long = DEFAULT_SEED, now: () -> Long = System::currentTimeMillis)`,方法:`todaySummary(): HealthSummary`、`metricHistory(metric: MetricType, range: DateRange): List`、`workouts(): List`、`deviceState(): RingDeviceState`、`defaultFunctionSettings(): List` - `class MockHealthDataRepository : HealthDataRepository`(每次发射前 `delay(200)`) - `class MockRingDeviceRepository : RingDeviceRepository`(开关状态保存在内存 StateFlow) > **范围说明(见 ledger Ruling R17)**:`MockDataProvider` 增加一个**可注入的时间源** `now: () -> Long` > (默认 `System::currentTimeMillis`),`metricHistory` / `workouts` 内一律用 `now()` 而非 > `System.currentTimeMillis()`。原因:本类的 KDoc 承诺「所有方法都是确定性的(同参数返回同结果)」, > 但直接读挂钟时 `timestamp` 会随毫秒跳动 —— brief 原先的测试 `assertEquals(first, second)` > 因此是一条**间歇失败的 flaky 断言**(两次调用跨毫秒即挂)。注入时间源后该承诺才成立, > 测试也才能传固定时钟、真正验证「可重复」。这是本任务唯一的接口增补。 - [ ] **Step 1: 写失败的测试** `app/src/test/java/com/placeholder/ringhealth/data/mock/MockDataProviderTest.kt`: ```kotlin package com.placeholder.ringhealth.data.mock import com.placeholder.ringhealth.domain.model.DateRange import com.placeholder.ringhealth.domain.model.MetricType import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test class MockDataProviderTest { /** * 固定时钟。Mock 的「确定性」承诺依赖时间源固定,否则 `timestamp` 会随毫秒跳动 * (ledger Ruling R17)。 */ private val provider = MockDataProvider(now = { FIXED_NOW }) private companion object { /** 固定的“当前时间”,让历史数据的时间戳可复现。 */ const val FIXED_NOW = 1_760_000_000_000L } @Test fun `今日概览返回固定演示数据`() { val summary = provider.todaySummary() assertEquals(8426, summary.steps) assertEquals(10000, summary.stepGoal) assertEquals(327, summary.calories) assertEquals(72, summary.heartRate) assertEquals(452, summary.sleepMinutes) // 血压未开启,应为 null assertEquals(null, summary.bloodPressure) } @Test fun `历史数据长度与范围一致`() { assertEquals(7, provider.metricHistory(MetricType.STEP, DateRange.WEEK).size) assertEquals(30, provider.metricHistory(MetricType.STEP, DateRange.MONTH).size) } @Test fun `历史数据可重复生成且值合理`() { val first = provider.metricHistory(MetricType.HEART_RATE, DateRange.WEEK) val second = provider.metricHistory(MetricType.HEART_RATE, DateRange.WEEK) assertEquals(first, second) assertTrue(first.all { it.value > 40.0 && it.value < 180.0 }) // 时间戳须严格递增(“每点间隔一天”倒推,最早的在最前) assertTrue(first.zipWithNext().all { (a, b) -> b.timestamp > a.timestamp }) // 断言绝对时间戳,让注入的时钟真正成为被测对象:若退回 System.currentTimeMillis(), // 这两条会立刻失败(ledger R17 / R20)。只比较 first == second 是不够的 —— // 两次相邻调用几乎总落在同一毫秒,那时去掉时钟注入测试照样通过。 assertEquals(FIXED_NOW, first.last().timestamp) assertEquals(FIXED_NOW - 6L * 24 * 60 * 60 * 1000, first.first().timestamp) } @Test fun `设备状态与开关数据合法`() { val device = provider.deviceState() assertTrue(device.batteryPercent in 0..100) assertEquals(true, device.connected) assertEquals(5, provider.defaultFunctionSettings().size) } @Test fun `运动记录非空且字段合理`() { val records = provider.workouts() assertTrue(records.isNotEmpty()) assertTrue(records.all { it.durationMinutes > 0 && it.calories > 0 }) } } ``` - [ ] **Step 2: 运行测试,确认失败** ```bash cd /d/cherry/repos/ring-health-app && ./gradlew :app:testDebugUnitTest --tests "com.placeholder.ringhealth.data.mock.MockDataProviderTest" ``` Expected: `Unresolved reference: MockDataProvider`,编译失败。 - [ ] **Step 3: 写 `MockDataProvider.kt`** ```kotlin package com.placeholder.ringhealth.data.mock import com.placeholder.ringhealth.domain.model.DateRange import com.placeholder.ringhealth.domain.model.DeviceFunctionSetting import com.placeholder.ringhealth.domain.model.HealthSummary import com.placeholder.ringhealth.domain.model.MetricPoint import com.placeholder.ringhealth.domain.model.MetricType import com.placeholder.ringhealth.domain.model.RingDeviceState import com.placeholder.ringhealth.domain.model.WorkoutRecord import kotlin.random.Random /** * 演示数据生成器。所有方法都是确定性的(同参数、同时刻返回同结果), * 保证页面展示与截图稳定。M2 接入真机后本类整体弃用。 * * @param seed 随机种子,决定历史数据的取值序列。 * @param now 时间源,默认取系统时钟;测试注入固定值后可得到完全可复现的时间戳 * (ledger Ruling R17)。 */ class MockDataProvider( private val seed: Long = DEFAULT_SEED, private val now: () -> Long = System::currentTimeMillis, ) { /** 今日概览:固定演示值。血压为 null 以演示“未开启”占位态。 */ fun todaySummary(): HealthSummary = HealthSummary( steps = 8426, stepGoal = 10000, calories = 327, distanceKm = 6.21, heartRate = 72, sleepMinutes = 452, bloodOxygen = 98, stress = 34, temperature = 36.5, bloodPressure = null, ) /** * 指定指标的历史数据:WEEK 返回 7 个点,MONTH 返回 30 个点。 * 时间戳按“每点间隔一天”倒推,值在指标合理区间内波动。 */ fun metricHistory(metric: MetricType, range: DateRange): List { val count = if (range == DateRange.WEEK) 7 else 30 val random = Random(seed + metric.ordinal * 100L + range.ordinal) val (min, max) = valueRange(metric) val current = now() val dayMillis = 24L * 60 * 60 * 1000 return (0 until count).map { index -> val progress = index.toFloat() / (count - 1).coerceAtLeast(1) val value = min + (max - min) * progress + random.nextDouble(-1.0, 1.0) * (max - min) * 0.15 MetricPoint( timestamp = current - (count - 1 - index) * dayMillis, value = value.coerceIn(min, max), ) } } /** 运动记录:固定 5 条演示数据,倒序(最近的在最前)。 */ fun workouts(): List { val random = Random(seed + 7) val current = now() val dayMillis = 24L * 60 * 60 * 1000 val types = listOf("户外跑步", "健走", "骑行", "自由训练", "户外跑步") return types.mapIndexed { index, type -> WorkoutRecord( id = "workout-$index", type = type, startTime = current - (index + 1) * dayMillis, durationMinutes = 25 + random.nextInt(0, 45), distanceKm = 2.0 + random.nextDouble(0.0, 5.0), calories = 120 + random.nextInt(0, 260), ) } } /** 戒指设备状态:固定演示值。 */ fun deviceState(): RingDeviceState = RingDeviceState( connected = true, name = "RingHealth 智能戒指", batteryPercent = 78, firmwareVersion = "1.0.5", charging = false, ) /** 五个功能开关,对应协议中的自动监测能力。 */ fun defaultFunctionSettings(): List = 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 设备仓库。开关状态保存在内存中,进程重启后重置(阶段一不做持久化)。 * * 注意 [refreshDeviceState] 的语义:它以 **suspend 函数正常返回**表示刷新完成, * 而**不是**靠发射新状态。`MutableStateFlow` 会抑制相等值,M1 的设备状态是常量, * 因此刷新不会产生任何可观察的发射。调用方要展示刷新反馈,应当 * `launch { repo.refreshDeviceState(); isRefreshing = false }`, * **不要**写成「等待下一个不同的 deviceState 发射」(那样在 M1 会永远等不到)。 */ class MockRingDeviceRepository( private val provider: MockDataProvider = MockDataProvider(), ) : RingDeviceRepository { private val _deviceState = MutableStateFlow(provider.deviceState()) override val deviceState: StateFlow = _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: 写 `AppContainer.kt` 与更新 `RingHealthApplication.kt`** 现在 Mock 实现已存在,可以安全创建容器(ledger Ruling R1)。 `AppContainer.kt`: ```kotlin package com.placeholder.ringhealth import com.placeholder.ringhealth.data.mock.MockHealthDataRepository import com.placeholder.ringhealth.data.mock.MockRingDeviceRepository import com.placeholder.ringhealth.domain.repository.HealthDataRepository import com.placeholder.ringhealth.domain.repository.RingDeviceRepository /** * 手动依赖注入容器。M2 接入 SDK 时只需在这里把 Mock 实现换成真实实现。 */ interface AppContainer { val healthDataRepository: HealthDataRepository val ringDeviceRepository: RingDeviceRepository } /** 默认容器:阶段一全部使用 Mock 数据。 */ class DefaultAppContainer : AppContainer { override val healthDataRepository: HealthDataRepository = MockHealthDataRepository() override val ringDeviceRepository: RingDeviceRepository = MockRingDeviceRepository() } ``` `RingHealthApplication.kt`(替换 Task 1 的最小实现): ```kotlin package com.placeholder.ringhealth import android.app.Application /** * 应用入口,同时作为手动依赖注入的组合根。 */ class RingHealthApplication : Application() { /** 全局依赖容器。 */ lateinit var container: AppContainer private set override fun onCreate() { super.onCreate() container = DefaultAppContainer() } } ``` - [ ] **Step 8: 全量单元测试 + 构建验证** ```bash cd /d/cherry/repos/ring-health-app && ./gradlew :app:testDebugUnitTest && ./gradlew assembleDebug ``` Expected: 两个命令均 `BUILD SUCCESSFUL`(Step 7 的容器代码也在本次编译范围内)。 - [ ] **Step 9: Commit** ```bash git add -A && git commit -m "feat: 新增 Mock 数据层与依赖容器(含单元测试)" ``` --- ### Task 7: 导航骨架(底部四 Tab + 空页面) **Files:** - Create: `core/navigation/Routes.kt`, `core/navigation/RingHealthApp.kt` - Create: `feature/home/HomeScreen.kt`(占位)、`feature/workout/WorkoutScreen.kt`(占位)、`feature/device/DeviceScreen.kt`(占位)、`feature/profile/ProfileScreen.kt`(占位) - Modify: `MainActivity.kt` - Modify: `res/values/strings.xml`(新增 Tab 文案) **Interfaces:** - Consumes: Task 2/3 的主题与组件 - Produces: - `object Routes { HOME, WORKOUT, DEVICE, PROFILE, DEVICE_SETTINGS, WORKOUT_HISTORY, DETAIL_PATTERN; fun detail(metric: MetricType): String }` - `@Composable fun RingHealthApp()`——Scaffold + NavigationBar + NavHost - 四个 Tab 的**占位** Composable:`HomeScreen(onOpenDetail: (MetricType) -> Unit, modifier)`、 `WorkoutScreen(modifier)`、`DeviceScreen(modifier)`、`ProfileScreen(modifier)` (`modifier: Modifier = Modifier`,实际签名以本任务 Step 2 的代码为准)。 **注意**:`Routes.DEVICE_SETTINGS` 与 `Routes.WORKOUT_HISTORY` 在本任务**只注册、无人跳转** —— `WorkoutScreen` 的 `onOpenHistory` 与 `DeviceScreen` 的 `onOpenSettings` 分别在 Task 9 / Task 10 才加入签名,那两个 Task 会同步更新本任务 `RingHealthApp.kt` 里的调用点。 计划早期版本在本行写过一个 `HomeScreen` 的 `onOpenWorkoutHistory` 参数, 全计划没有任何调用点(Task 8 产出的是 `HomeScreen(onOpenDetail, modifier, viewModel)`), 属笔误,已删除 —— 不要把它加回来。 - [ ] **Step 1: 写 `Routes.kt`** ```kotlin package com.placeholder.ringhealth.core.navigation import com.placeholder.ringhealth.domain.model.MetricType /** * 全局路由表。字符串集中定义,避免散落在各处。 */ object Routes { const val HOME = "home" const val WORKOUT = "workout" const val DEVICE = "device" const val PROFILE = "profile" const val DEVICE_SETTINGS = "device/settings" const val WORKOUT_HISTORY = "workout/history" /** 指标详情路由模板,参数为 MetricType.name。 */ const val DETAIL_PATTERN = "detail/{metric}" const val DETAIL_ARG_METRIC = "metric" /** 拼出某个指标的详情路由。 */ fun detail(metric: MetricType): String = "detail/${metric.name}" } ``` - [ ] **Step 2: 写四个占位页面(后续 Task 替换为完整实现)** `feature/home/HomeScreen.kt`: ```kotlin package com.placeholder.ringhealth.feature.home import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import com.placeholder.ringhealth.R import com.placeholder.ringhealth.domain.model.MetricType /** 健康首页(占位,待 Task 8 实现)。 */ @Composable fun HomeScreen( onOpenDetail: (MetricType) -> Unit, modifier: Modifier = Modifier, ) { Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text(stringResource(R.string.tab_home)) } } ``` `feature/workout/WorkoutScreen.kt`: ```kotlin package com.placeholder.ringhealth.feature.workout import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import com.placeholder.ringhealth.R /** 运动页(占位,待 Task 9 实现)。 */ @Composable fun WorkoutScreen(modifier: Modifier = Modifier) { Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text(stringResource(R.string.tab_workout)) } } ``` `feature/device/DeviceScreen.kt`: ```kotlin package com.placeholder.ringhealth.feature.device import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import com.placeholder.ringhealth.R /** 设备页(占位,待 Task 10 实现)。 */ @Composable fun DeviceScreen(modifier: Modifier = Modifier) { Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text(stringResource(R.string.tab_device)) } } ``` `feature/profile/ProfileScreen.kt`: ```kotlin package com.placeholder.ringhealth.feature.profile import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import com.placeholder.ringhealth.R /** 我的页(占位,待 Task 11 实现)。 */ @Composable fun ProfileScreen(modifier: Modifier = Modifier) { Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text(stringResource(R.string.tab_profile)) } } ``` - [ ] **Step 3: 写 `strings.xml` 的导航文案** 在 `res/values/strings.xml` 的 `` 内追加: ```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 import androidx.compose.ui.res.stringResource import com.placeholder.ringhealth.R /** 设备设置子页(占位,待 Task 10 实现)。 */ @Composable fun DeviceSettingsPlaceholder(modifier: Modifier = Modifier) { Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text(stringResource(R.string.placeholder_device_settings)) } } ``` `feature/workout/WorkoutHistoryScreen.kt`(本 Task 仅占位): ```kotlin package com.placeholder.ringhealth.feature.workout import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import com.placeholder.ringhealth.R /** 运动历史子页(占位,待 Task 9 实现)。 */ @Composable fun WorkoutHistoryPlaceholder(modifier: Modifier = Modifier) { Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text(stringResource(R.string.placeholder_workout_history)) } } ``` `feature/detail/MetricDetailScreen.kt`(本 Task 仅占位): ```kotlin package com.placeholder.ringhealth.feature.detail import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import com.placeholder.ringhealth.R import com.placeholder.ringhealth.domain.model.MetricType /** 指标详情子页(占位,待 Task 12 实现)。 */ @Composable fun MetricDetailPlaceholder(metric: MetricType, modifier: Modifier = Modifier) { Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text(stringResource(R.string.placeholder_metric_detail)) } } ``` - [ ] **Step 6: `MainActivity` 接入 `RingHealthApp`** ```kotlin setContent { RingHealthTheme { Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { RingHealthApp() } } } ``` - [ ] **Step 7: 构建 + 安装验证** ```bash cd /d/cherry/repos/ring-health-app && ./gradlew assembleDebug && ./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` - Create: `AppViewModelProvider.kt`(原属 Task 4,见 ledger Ruling R2) - Modify: `feature/home/HomeScreen.kt`(完整实现) - Test: `app/src/test/java/com/placeholder/ringhealth/feature/home/HomeViewModelTest.kt` - Modify: `res/values/strings.xml` **Interfaces:** - Consumes: `HealthDataRepository`、`RingDeviceRepository`、`RingHealthApplication.container`、Task 3 组件 - Produces: - `data class HomeUiState(val loading: Boolean, val summary: HealthSummary?, val device: RingDeviceState?)` - `class HomeViewModel(healthDataRepository, ringDeviceRepository)` 暴露 `uiState: StateFlow` - `object AppViewModelProvider { val Factory }` 与 `fun CreationExtras.ringHealthApplication(): RingHealthApplication` - `@Composable fun HomeScreen(onOpenDetail: (MetricType) -> Unit, modifier, viewModel: HomeViewModel = viewModel(factory = AppViewModelProvider.Factory))` - [ ] **Step 1: 写失败的测试** `app/src/test/java/com/placeholder/ringhealth/feature/home/HomeViewModelTest.kt`: ```kotlin package com.placeholder.ringhealth.feature.home import com.placeholder.ringhealth.data.mock.MockHealthDataRepository import com.placeholder.ringhealth.data.mock.MockRingDeviceRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.launch import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Before import org.junit.Test @OptIn(ExperimentalCoroutinesApi::class) class HomeViewModelTest { private val dispatcher = StandardTestDispatcher() @Before fun setUp() { Dispatchers.setMain(dispatcher) } @After fun tearDown() { Dispatchers.resetMain() } @Test fun `加载完成后输出今日概览与设备状态`() = runTest(dispatcher) { val viewModel = HomeViewModel(MockHealthDataRepository(), MockRingDeviceRepository()) // uiState 用 SharingStarted.WhileSubscribed 构建,必须有订阅者才会启动上游数据流; // 否则 .value 永远是初始值(loading = true)。用 backgroundScope 起一个收集者。 backgroundScope.launch { viewModel.uiState.collect {} } advanceUntilIdle() val state = viewModel.uiState.value assertFalse(state.loading) assertEquals(8426, state.summary?.steps) assertEquals(78, state.device?.batteryPercent) } } ``` - [ ] **Step 2: 运行测试,确认失败** ```bash cd /d/cherry/repos/ring-health-app && ./gradlew :app:testDebugUnitTest --tests "com.placeholder.ringhealth.feature.home.HomeViewModelTest" ``` Expected: `Unresolved reference: HomeViewModel`,编译失败。 - [ ] **Step 3: 写 `HomeViewModel.kt`** ```kotlin package com.placeholder.ringhealth.feature.home import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.placeholder.ringhealth.domain.model.HealthSummary import com.placeholder.ringhealth.domain.model.RingDeviceState import com.placeholder.ringhealth.domain.repository.HealthDataRepository import com.placeholder.ringhealth.domain.repository.RingDeviceRepository import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn /** * 首页 UI 状态。summary/device 为 null 表示仍在加载。 */ data class HomeUiState( val loading: Boolean, val summary: HealthSummary?, val device: RingDeviceState?, ) /** * 健康首页 ViewModel:合并今日概览与设备状态。 */ class HomeViewModel( healthDataRepository: HealthDataRepository, ringDeviceRepository: RingDeviceRepository, ) : ViewModel() { val uiState: StateFlow = combine( healthDataRepository.observeTodaySummary(), ringDeviceRepository.deviceState, ) { summary, device -> HomeUiState(loading = false, summary = summary, device = device) }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(STOP_TIMEOUT_MILLIS), initialValue = HomeUiState(loading = true, summary = null, device = null), ) private companion object { const val STOP_TIMEOUT_MILLIS = 5_000L } } ``` - [ ] **Step 4: 运行测试,确认通过** ```bash cd /d/cherry/repos/ring-health-app && ./gradlew :app:testDebugUnitTest --tests "com.placeholder.ringhealth.feature.home.HomeViewModelTest" ``` Expected: `BUILD SUCCESSFUL`。 - [ ] **Step 5: 写 `AppViewModelProvider.kt`** 本任务是 `AppViewModelProvider` 的第一个消费者,文件在这里建立(ledger Ruling R2)。 Task 9 / Task 10 会各自往 `viewModelFactory` 里**追加一个 `initializer`**,文件位置与职责不变。 ```kotlin package com.placeholder.ringhealth import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewmodel.CreationExtras import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory import com.placeholder.ringhealth.feature.home.HomeViewModel /** * ViewModel 工厂:把容器中的仓库注入各 ViewModel。 * 每个 ViewModel 在这里注册一个 initializer;后续任务按需追加。 */ object AppViewModelProvider { val Factory = viewModelFactory { initializer { HomeViewModel( healthDataRepository = ringHealthApplication().container.healthDataRepository, ringDeviceRepository = ringHealthApplication().container.ringDeviceRepository, ) } } } /** 从 CreationExtras 中取出 Application 实例。 */ fun CreationExtras.ringHealthApplication(): RingHealthApplication = this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as RingHealthApplication ``` > 说明:`MetricDetailViewModel` 需要运行时参数(指标类型),使用独立的工厂,见 Task 12。 - [ ] **Step 6: 完整实现 `HomeScreen.kt`** ```kotlin package com.placeholder.ringhealth.feature.home import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.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 = formatCalories(summary.calories), label = stringResource(R.string.home_calories_label), tint = MetricOrange, ) } } } } /** 环下方的小结指标。 */ @Composable private fun SummaryMetric(value: String, label: String, tint: Color) { Column(horizontalAlignment = Alignment.CenterHorizontally) { Text(text = value, style = MaterialTheme.typography.titleMedium, color = tint) Text(text = label, style = MaterialTheme.typography.bodySmall, color = TextSecondary) } } /** 组装指标卡列表;value 为 null 表示该指标未开启。 */ @Composable private fun buildMetricCards(summary: HealthSummary): List = 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 %1$s · 已连接 未连接设备 电量 %1$d%% 今日步数 目标 %1$s 步 千卡 公里 健康指标 未开启 次/分 % °C 心率 睡眠 血氧 压力 体温 血压 步数 ``` > 这批 `metric_*` 字符串同时供 Task 12 的详情页标题使用(ledger Ruling R3),是它们的唯一来源。 - [ ] **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 1b: 把 `WorkoutViewModel` 注册到 `AppViewModelProvider`** 在 `AppViewModelProvider.kt` 的 `viewModelFactory { }` 内**追加**一个 `initializer`(ledger Ruling R2), 并在该文件顶部加 `import com.placeholder.ringhealth.feature.workout.WorkoutViewModel`: ```kotlin initializer { WorkoutViewModel( healthDataRepository = ringHealthApplication().container.healthDataRepository, ) } ``` - [ ] **Step 2: 追加文案到 `strings.xml`** ```xml 开始运动 选择运动类型,记录你的训练 最近运动 全部记录 暂无运动记录 %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 1b: 把 `DeviceViewModel` 注册到 `AppViewModelProvider`** 在 `AppViewModelProvider.kt` 的 `viewModelFactory { }` 内**追加**一个 `initializer`(ledger Ruling R2), 并在该文件顶部加 `import com.placeholder.ringhealth.feature.device.DeviceViewModel`: ```kotlin initializer { DeviceViewModel( ringDeviceRepository = ringHealthApplication().container.ringDeviceRepository, ) } ``` - [ ] **Step 2: 追加文案到 `strings.xml`** ```xml 我的设备 已连接 未连接 电量 %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`、Task 8 加入的 `R.string.metric_*` - Produces: - `data class MetricDetailUiState(val loading: Boolean, val metric: MetricType, val range: DateRange, val points: List)` - `class MetricDetailViewModel(metric: MetricType, healthDataRepository)`,方法 `fun switchRange(range: DateRange)` - `@Composable fun MetricDetailScreen(metric: MetricType, onBack: () -> Unit, modifier, viewModel: MetricDetailViewModel = viewModel(factory = MetricDetailViewModel.factory(metric)))` - `MetricDetailViewModel.factory(metric: MetricType): ViewModelProvider.Factory`(伴生对象) - `@Composable private fun metricTitleRes(type: MetricType): Int`(文件内私有) > **两点范围说明**: > - 标题用 `metricTitleRes` 映射到 `R.string.metric_*`,**不再有** `metricDisplayName`(ledger Ruling R3)。 > - 增加睡眠专属区块(`StatBar` 分期条 + 入睡/起床时间),补齐 spec §6 的 `detail/sleep` 要求(ledger Ruling R4)。 - [ ] **Step 1: 写 `MetricDetailViewModel.kt`** ```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 天 平均 最高 最低 趋势 睡眠分期 深睡 浅睡 清醒 入睡时间 起床时间 返回 ``` > `detail_average` / `detail_max` / `detail_min` 是**纯标签**,不带 `%1$s`: > `StatLine` 把标签和数值分成左右两栏渲染,标签里再带占位符会原样打印出 `%1$s`。 - [ ] **Step 3: 实现 `MetricDetailScreen.kt`(替换占位)** ```kotlin package com.placeholder.ringhealth.feature.detail import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.placeholder.ringhealth.R import com.placeholder.ringhealth.core.common.formatClock import com.placeholder.ringhealth.core.designsystem.MetricBlue import com.placeholder.ringhealth.core.designsystem.MetricGreen import com.placeholder.ringhealth.core.designsystem.TextSecondary import com.placeholder.ringhealth.core.designsystem.component.HealthCard import com.placeholder.ringhealth.core.designsystem.component.MetricBarChart import com.placeholder.ringhealth.core.designsystem.component.SectionHeader import com.placeholder.ringhealth.core.designsystem.component.StatBar import com.placeholder.ringhealth.core.designsystem.component.StatSegment import com.placeholder.ringhealth.domain.model.DateRange import com.placeholder.ringhealth.domain.model.MetricPoint import com.placeholder.ringhealth.domain.model.MetricType /** * 指标详情页:范围切换 + 柱状趋势图 + 统计摘要。 */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun MetricDetailScreen( metric: MetricType, onBack: () -> Unit, modifier: Modifier = Modifier, viewModel: MetricDetailViewModel = viewModel(factory = MetricDetailViewModel.factory(metric)), ) { val state by viewModel.uiState.collectAsStateWithLifecycle() Scaffold( modifier = modifier, containerColor = MaterialTheme.colorScheme.background, topBar = { TopAppBar( title = { Text(text = stringResource(metricTitleRes(metric))) }, navigationIcon = { IconButton(onClick = onBack) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back), ) } }, ) }, ) { innerPadding -> if (state.loading) { Box( modifier = Modifier.fillMaxSize().padding(innerPadding), contentAlignment = Alignment.Center, ) { CircularProgressIndicator(color = MetricGreen) } return@Scaffold } LazyColumn( modifier = Modifier.fillMaxSize().padding(innerPadding), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { item { RangeSelector(current = state.range, onSelect = viewModel::switchRange) } item { HealthCard { SectionHeader(title = stringResource(R.string.detail_trend)) MetricBarChart( values = state.points.map { it.value.toFloat() }, color = MetricGreen, ) } } item { HealthCard { val values = state.points.map { it.value } StatLine(label = stringResource(R.string.detail_average), value = values.averageOrZero()) StatLine(label = stringResource(R.string.detail_max), value = values.maxOrNull() ?: 0.0) StatLine(label = stringResource(R.string.detail_min), value = values.minOrNull() ?: 0.0) } } if (state.metric == MetricType.SLEEP) { item { SleepStagesCard(points = state.points) } } } } } /** * 睡眠分期卡片:深睡/浅睡/清醒分段条 + 入睡/起床时间(对应 spec §6 的 `detail/sleep`)。 */ @Composable private fun SleepStagesCard(points: List, modifier: Modifier = Modifier) { // 阶段一没有真实分期数据,按固定比例演示;M3 接入真实睡眠数据后改为按实际时段计算。 val segments = listOf( StatSegment(weight = 0.25f, color = MetricBlue), StatSegment(weight = 0.65f, color = MetricBlue.copy(alpha = 0.45f)), StatSegment(weight = 0.10f, color = MetricBlue.copy(alpha = 0.2f)), ) HealthCard(modifier = modifier) { SectionHeader(title = stringResource(R.string.detail_sleep_stages)) StatBar(segments = segments) Row( modifier = Modifier.fillMaxWidth().padding(top = 12.dp), horizontalArrangement = Arrangement.SpaceBetween, ) { StageLegend(label = stringResource(R.string.detail_sleep_deep), tint = MetricBlue) StageLegend(label = stringResource(R.string.detail_sleep_light), tint = MetricBlue.copy(alpha = 0.45f)) StageLegend(label = stringResource(R.string.detail_sleep_awake), tint = MetricBlue.copy(alpha = 0.2f)) } val fallAsleep = points.firstOrNull()?.timestamp val wakeUp = points.lastOrNull()?.timestamp if (fallAsleep != null && wakeUp != null) { Row( modifier = Modifier.fillMaxWidth().padding(top = 12.dp), horizontalArrangement = Arrangement.SpaceBetween, ) { Text( text = "${stringResource(R.string.detail_sleep_fall_asleep)} ${formatClock(fallAsleep)}", style = MaterialTheme.typography.bodySmall, color = TextSecondary, ) Text( text = "${stringResource(R.string.detail_sleep_wake_up)} ${formatClock(wakeUp)}", style = MaterialTheme.typography.bodySmall, color = TextSecondary, ) } } } } /** 分期图例:小色块 + 名称。 */ @Composable private fun StageLegend(label: String, tint: Color, modifier: Modifier = Modifier) { Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { Box( modifier = Modifier .size(10.dp) .clip(RoundedCornerShape(5.dp)) .background(tint), ) Text( text = label, style = MaterialTheme.typography.bodySmall, color = TextSecondary, modifier = Modifier.padding(start = 6.dp), ) } } /** 指标类型 → 标题字符串资源。文案的唯一来源是 `strings.xml`(ledger Ruling R3)。 */ @Composable private fun metricTitleRes(type: MetricType): Int = when (type) { MetricType.STEP -> R.string.metric_step MetricType.HEART_RATE -> R.string.metric_heart_rate MetricType.SLEEP -> R.string.metric_sleep MetricType.BLOOD_OXYGEN -> R.string.metric_blood_oxygen MetricType.STRESS -> R.string.metric_stress MetricType.TEMPERATURE -> R.string.metric_temperature MetricType.BLOOD_PRESSURE -> R.string.metric_blood_pressure } /** 周/月切换。 */ @Composable private fun RangeSelector( current: DateRange, onSelect: (DateRange) -> Unit, modifier: Modifier = Modifier, ) { Row( modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), ) { FilterChip( selected = current == DateRange.WEEK, onClick = { onSelect(DateRange.WEEK) }, label = { Text(stringResource(R.string.detail_range_week)) }, ) FilterChip( selected = current == DateRange.MONTH, onClick = { onSelect(DateRange.MONTH) }, label = { Text(stringResource(R.string.detail_range_month)) }, ) } } /** 一行统计摘要。 */ @Composable private fun StatLine(label: String, value: Double, modifier: Modifier = Modifier) { Row( modifier = modifier.fillMaxWidth().padding(vertical = 4.dp), horizontalArrangement = Arrangement.SpaceBetween, ) { Text(text = label, style = MaterialTheme.typography.bodyMedium, color = TextSecondary) Text(text = String.format(java.util.Locale.CHINA, "%.1f", value), style = MaterialTheme.typography.bodyMedium) } } /** 空列表时返回 0.0,避免 NaN。 */ private fun List.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 加入清单与运行时请求。