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

143 KiB
Raw Blame History

智能戒指健康 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

Global Constraints

  • 仓库名:ring-health-app、ring-health-api(kebab-case,与 Gitea 上名称一致)。
  • 包名/namespace:com.placeholder.ringhealth(占位,后续按 spec §12 统一替换)。
  • SDK 版本:minSdk 26 / targetSdk 36 / compileSdk 36 / buildToolsVersion "36.1.0"(36.1.0 是本机已装版本,写死避免下载)。
  • 工具链钉死:Gradle 8.14、AGP 8.13.1、Kotlin 2.2.20(三者均为本机缓存版本)。
  • 依赖约束:阶段一不引入 Hilt、Room、Retrofit、第三方图表库。图表一律用 Compose Canvas 手绘。
  • 不硬编码文案:所有用户可见文字进 res/values/strings.xml(默认中文)。
  • 禁止行内样式散落:颜色/字号统一定义在 core/designsystem/,页面不得直接写 Color(0xFF...)。
  • 命名规范:类/Composable 用 PascalCase;函数/变量用 camelCase;常量用 UPPER_SNAKE_CASE;资源文件用 kebab-case/snake_case。
  • 注释规范:每个类与公开函数上方写中文 KDoc,说明用途(团队规范要求)。
  • 提交规范:每个 Task 结束提交一次;提交信息用中文,前缀 feat: / chore: / test: / docs:。
  • Gitea 地址:http://120.192.101.56:3000/cuiluzheng/<repo>.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 下载/复用发行包):

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:

*.iml
.gradle/
local.properties
.idea/
.DS_Store
build/
captures/
.externalNativeBuild/
.cxx/
*.apk
*.aab
*.keystore
*.jks
  • Step 3: 写 gradle/wrapper/gradle-wrapper.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
[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:

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:

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:

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
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 保持空文件(内容仅一行注释):

# 阶段一未启用混淆,规则留空。
  • Step 7: 写 local.properties(不进版本库)
sdk.dir=C\:\\Users\\10314\\AppData\\Local\\Android\\Sdk
  • Step 8: 写清单、资源与最小 Activity

app/src/main/AndroidManifest.xml:

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

    <application
        android:name=".RingHealthApplication"
        android:allowBackup="true"
        android:dataExtractionRules="@xml/data_extraction_rules"
        android:fullBackupContent="@xml/backup_rules"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/Theme.RingHealth">

        <activity
            android:name=".MainActivity"
            android:exported="true"
            android:screenOrientation="portrait"
            android:theme="@style/Theme.RingHealth">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>

</manifest>

RingHealthApplication 在 Task 4 创建。此步骤先创建占位类,见下方 Step 8b。

app/src/main/java/com/placeholder/ringhealth/RingHealthApplication.kt(Task 4 会补上容器,这里先最小化):

package com.placeholder.ringhealth

import android.app.Application

/**
 * 应用入口。同时作为手动依赖注入的组合根(M1 阶段只持有 Mock 容器)。
 */
class RingHealthApplication : Application()

app/src/main/java/com/placeholder/ringhealth/MainActivity.kt:

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 version="1.0" encoding="utf-8"?>
<resources>
    <string name="app_name">RingHealth</string>
</resources>

app/src/main/res/values/themes.xml:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <style name="Theme.RingHealth" parent="android:Theme.Material.Light.NoActionBar">
        <item name="android:statusBarColor">@android:color/transparent</item>
        <item name="android:windowLightStatusBar">true</item>
    </style>
</resources>

app/src/main/res/xml/backup_rules.xml:

<?xml version="1.0" encoding="utf-8"?>
<full-backup-content />

app/src/main/res/xml/data_extraction_rules.xml:

<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
    <cloud-backup />
    <device-transfer />
</data-extraction-rules>

app/src/main/res/drawable/ic_launcher_foreground.xml(占位矢量图标,内嵌于 Task 14 可再美化):

<vector xmlns:android="http://schemas.android.com/apk/res/android"
    android:width="108dp"
    android:height="108dp"
    android:viewportWidth="108"
    android:viewportHeight="108">
    <path
        android:fillColor="#FFFFFF"
        android:pathData="M54,30 C40,30 30,40 30,54 C30,68 40,78 54,78 C68,78 78,68 78,54 C78,40 68,30 54,30 Z M54,38 C62.8,38 70,45.2 70,54 C70,62.8 62.8,70 54,70 C45.2,70 38,62.8 38,54 C38,45.2 45.2,38 54,38 Z" />
    <path
        android:fillColor="#FFFFFF"
        android:pathData="M54,46 L60,54 L54,62 L48,54 Z" />
</vector>

app/src/main/res/values/ic_launcher_background.xml:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <color name="ic_launcher_background">#34C759</color>
</resources>

app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml 与 ic_launcher_round.xml(两文件内容相同):

<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
    <background android:drawable="@color/ic_launcher_background" />
    <foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
  • Step 9: 写 README.md
# 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 并推送
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

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
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
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 { ... } 内容替换为:

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: 构建验证
cd /d/cherry/repos/ring-health-app && ./gradlew assembleDebug

Expected: BUILD SUCCESSFUL。

  • Step 6: Commit
git add -A && git commit -m "feat: 新增设计系统(颜色/字体/浅色主题)"

Task 3: 通用 UI 组件(卡片 / 环形进度 / 指标块 / 分段条 / 柱状图)

Files:

  • Create: core/designsystem/component/HealthCard.kt, RingProgress.kt, MetricTile.kt, SectionHeader.kt, StatBar.kt, MetricBarChart.kt

Interfaces:

  • Consumes: Task 2 的颜色与字体

  • Produces(后续所有页面的构件):

    • HealthCard(modifier, onClick: (() -> Unit)? = null, content: @Composable ColumnScope.() -> Unit)
    • RingProgress(progress: Float, color: Color, trackColor: Color, modifier, ringSize: Dp = 180.dp, strokeWidth: Dp = 14.dp, content: @Composable () -> Unit = {})
    • MetricTile(icon: ImageVector, iconTint: Color, title: String, value: String, unit: String, modifier, onClick: () -> Unit)
    • SectionHeader(title: String, modifier, trailing: (@Composable () -> Unit)? = null)
    • StatBar(segments: List<StatSegment>, modifier, barHeight: Dp = 12.dp);data class StatSegment(val weight: Float, val color: Color)
    • MetricBarChart(values: List<Float>, color: Color, modifier, barHeight: Dp = 120.dp, highlightLast: Boolean = true)
  • Step 1: 写 HealthCard.kt

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
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
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
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
package com.placeholder.ringhealth.core.designsystem.component

import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp

/** 分段条的一段:权重决定宽度。 */
data class StatSegment(val weight: Float, val color: Color)

/**
 * 横向分段条,用于睡眠分期(深睡/浅睡/清醒)等占比展示。
 */
@Composable
fun StatBar(
    segments: List<StatSegment>,
    modifier: Modifier = Modifier,
    barHeight: Dp = 12.dp,
) {
    Row(
        modifier = modifier
            .fillMaxWidth()
            .height(barHeight)
            .clip(RoundedCornerShape(barHeight / 2)),
    ) {
        segments.forEach { segment ->
            androidx.compose.foundation.layout.Box(
                modifier = Modifier
                    .weight(segment.weight.coerceAtLeast(0.0001f))
                    .fillMaxWidth()
                    .height(barHeight)
                    .background(segment.color),
            )
        }
    }
}
  • Step 6: 写 MetricBarChart.kt
package com.placeholder.ringhealth.core.designsystem.component

import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp

/**
 * 极简柱状图(Compose Canvas 手绘,无第三方依赖)。
 * values 为空时不绘制;最高的一根高亮,其余半透明。
 */
@Composable
fun MetricBarChart(
    values: List<Float>,
    color: Color,
    modifier: Modifier = Modifier,
    barHeight: Dp = 120.dp,
    highlightLast: Boolean = true,
) {
    Canvas(modifier = modifier.fillMaxWidth().height(barHeight)) {
        if (values.isEmpty()) return@Canvas
        val maxValue = values.max().coerceAtLeast(1f)
        val gapRatio = 0.35f
        val slot = size.width / values.size
        val barWidth = slot * (1f - gapRatio)
        val highlightIndex = if (highlightLast) values.lastIndex else values.indexOf(maxValue)

        values.forEachIndexed { index, value ->
            val ratio = (value / maxValue).coerceIn(0f, 1f)
            val h = size.height * ratio
            val isHighlight = index == highlightIndex
            drawRoundRect(
                color = if (isHighlight) color else color.copy(alpha = 0.3f),
                topLeft = Offset(x = index * slot + (slot - barWidth) / 2f, y = size.height - h),
                size = Size(width = barWidth, height = h),
                cornerRadius = androidx.compose.ui.geometry.CornerRadius(barWidth / 2f, barWidth / 2f),
            )
        }
    }
}
  • Step 7: 构建验证
cd /d/cherry/repos/ring-health-app && ./gradlew assembleDebug

Expected: BUILD SUCCESSFUL(组件尚未被引用,仅验证编译通过)。

  • Step 8: Commit
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

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
package com.placeholder.ringhealth.domain.repository

import com.placeholder.ringhealth.domain.model.DateRange
import com.placeholder.ringhealth.domain.model.DeviceFunctionSetting
import com.placeholder.ringhealth.domain.model.HealthSummary
import com.placeholder.ringhealth.domain.model.MetricPoint
import com.placeholder.ringhealth.domain.model.MetricType
import com.placeholder.ringhealth.domain.model.RingDeviceState
import com.placeholder.ringhealth.domain.model.WorkoutRecord
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow

/**
 * 健康数据来源。M1 由 Mock 实现,M2/M3 由厂商 SDK 适配层实现,UI 无需改动。
 */
interface HealthDataRepository {
    /** 今日概览,随数据变化持续发射。 */
    fun observeTodaySummary(): Flow<HealthSummary>

    /** 指定指标在指定时间范围内的历史数据。 */
    fun observeMetricHistory(metric: MetricType, range: DateRange): Flow<List<MetricPoint>>

    /** 运动记录列表。 */
    fun observeWorkouts(): Flow<List<WorkoutRecord>>
}

/**
 * 戒指设备状态与功能开关。M2 接入蓝牙后由真实实现替换。
 */
interface RingDeviceRepository {
    /** 设备状态(连接/电量/固件)。 */
    val deviceState: StateFlow<RingDeviceState>

    /** 功能开关列表。 */
    val functionSettings: StateFlow<List<DeviceFunctionSetting>>

    /** 主动刷新设备状态。 */
    suspend fun refreshDeviceState()

    /** 切换某个功能开关(M1 仅改内存状态)。 */
    fun setFunctionEnabled(id: String, enabled: Boolean)
}
  • Step 3: 构建验证
cd /d/cherry/repos/ring-health-app && ./gradlew assembleDebug

Expected: BUILD SUCCESSFUL。本任务只产出纯 Kotlin 的 domain/ 层,不引用任何尚未创建的类。

  • Step 4: Commit
git add -A && git commit -m "feat: 新增领域模型与仓库接口"

Task 5: 数值格式化工具(TDD)

Files:

  • Create: app/src/main/java/com/placeholder/ringhealth/core/common/Formatters.kt
  • Test: app/src/test/java/com/placeholder/ringhealth/core/common/FormattersTest.kt

Interfaces:

  • Consumes: 无
  • Produces(页面与组件通用):
    • formatSteps(steps: Int): String
    • formatDistance(km: Double): String
    • formatCalories(kcal: Int): String
    • formatSleepDuration(minutes: Int): String
    • formatDuration(minutes: Int): String
    • formatClock(timestamp: Long): String
    • formatDate(timestamp: Long): String
    • stressLevelLabel(stress: Int): String

范围说明(见 ledger Ruling R3):本任务不提供 metricDisplayName(type)。 指标中文名属于用户可见文案,Global Constraints 要求一律进 strings.xml; 把它硬编码在 Kotlin 里会与 Task 8 加入 R.string.metric_* 形成两份事实来源。 详情页标题改用资源 id 映射,见 Task 12。

  • Step 1: 写失败的测试

app/src/test/java/com/placeholder/ringhealth/core/common/FormattersTest.kt:

package com.placeholder.ringhealth.core.common

import org.junit.Assert.assertEquals
import org.junit.Test

class FormattersTest {

    @Test
    fun `formatSleepDuration 处理小时与分钟`() {
        assertEquals("7小时32分", formatSleepDuration(452))
        assertEquals("45分", formatSleepDuration(45))
        assertEquals("8小时", formatSleepDuration(480))
        assertEquals("0分", formatSleepDuration(0))
    }

    @Test
    fun `formatDuration 处理小时与分钟`() {
        assertEquals("1小时05分", formatDuration(65))
        assertEquals("42分", formatDuration(42))
    }

    @Test
    fun `formatSteps 添加千分位`() {
        assertEquals("8,426", formatSteps(8426))
        assertEquals("0", formatSteps(0))
        assertEquals("1,234,567", formatSteps(1234567))
    }

    @Test
    fun `formatDistance 保留两位小数`() {
        assertEquals("6.21", formatDistance(6.212))
        assertEquals("0.00", formatDistance(0.0))
    }

    @Test
    fun `formatCalories 添加单位`() {
        assertEquals("327 千卡", formatCalories(327))
    }

    @Test
    fun `stressLevelLabel 按区间返回中文描述`() {
        assertEquals("放松", stressLevelLabel(20))
        assertEquals("正常", stressLevelLabel(45))
        assertEquals("中等", stressLevelLabel(65))
        assertEquals("偏高", stressLevelLabel(90))
    }
}
  • Step 2: 运行测试,确认失败
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:

package com.placeholder.ringhealth.core.common

import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale

/** 步数:千分位,如 8,426。 */
fun formatSteps(steps: Int): String = String.format(Locale.CHINA, "%,d", steps)

/** 距离:保留两位小数(单位 km 由 UI 单独展示)。 */
fun formatDistance(km: Double): String = String.format(Locale.CHINA, "%.2f", km)

/** 卡路里:带中文单位。 */
fun formatCalories(kcal: Int): String = "$kcal 千卡"

/** 睡眠时长:7小时32分 / 45分 / 8小时。 */
fun formatSleepDuration(minutes: Int): String {
    val hours = minutes / 60
    val mins = minutes % 60
    return when {
        hours > 0 && mins > 0 -> "${hours}小时${mins}分"
        hours > 0 -> "${hours}小时"
        else -> "${mins}分"
    }
}

/** 运动时长:1小时05分 / 42分(分钟补零)。 */
fun formatDuration(minutes: Int): String {
    val hours = minutes / 60
    val mins = minutes % 60
    return if (hours > 0) {
        String.format(Locale.CHINA, "%d小时%02d分", hours, mins)
    } else {
        "${mins}分"
    }
}

/** 时间戳 → HH:mm。 */
fun formatClock(timestamp: Long): String =
    SimpleDateFormat("HH:mm", Locale.CHINA).format(Date(timestamp))

/** 时间戳 → yyyy-MM-dd。 */
fun formatDate(timestamp: Long): String =
    SimpleDateFormat("yyyy-MM-dd", Locale.CHINA).format(Date(timestamp))

/** 压力值 → 中文等级。 */
fun stressLevelLabel(stress: Int): String = when {
    stress < 30 -> "放松"
    stress < 60 -> "正常"
    stress < 80 -> "中等"
    else -> "偏高"
}
  • Step 4: 运行测试,确认通过
cd /d/cherry/repos/ring-health-app && ./gradlew :app:testDebugUnitTest --tests "com.placeholder.ringhealth.core.common.FormattersTest"

Expected: BUILD SUCCESSFUL,6 个测试全部通过。

  • Step 5: Commit
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),方法:todaySummary(): HealthSummary、metricHistory(metric: MetricType, range: DateRange): List<MetricPoint>、workouts(): List<WorkoutRecord>、deviceState(): RingDeviceState、defaultFunctionSettings(): List<DeviceFunctionSetting>
    • class MockHealthDataRepository : HealthDataRepository(每次发射前 delay(200))
    • class MockRingDeviceRepository : RingDeviceRepository(开关状态保存在内存 StateFlow)
  • Step 1: 写失败的测试

app/src/test/java/com/placeholder/ringhealth/data/mock/MockDataProviderTest.kt:

package com.placeholder.ringhealth.data.mock

import com.placeholder.ringhealth.domain.model.DateRange
import com.placeholder.ringhealth.domain.model.MetricType
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test

class MockDataProviderTest {

    private val provider = MockDataProvider()

    @Test
    fun `今日概览返回固定演示数据`() {
        val summary = provider.todaySummary()
        assertEquals(8426, summary.steps)
        assertEquals(10000, summary.stepGoal)
        assertEquals(327, summary.calories)
        assertEquals(72, summary.heartRate)
        assertEquals(452, summary.sleepMinutes)
        // 血压未开启,应为 null
        assertEquals(null, summary.bloodPressure)
    }

    @Test
    fun `历史数据长度与范围一致`() {
        assertEquals(7, provider.metricHistory(MetricType.STEP, DateRange.WEEK).size)
        assertEquals(30, provider.metricHistory(MetricType.STEP, DateRange.MONTH).size)
    }

    @Test
    fun `历史数据可重复生成且值合理`() {
        val first = provider.metricHistory(MetricType.HEART_RATE, DateRange.WEEK)
        val second = provider.metricHistory(MetricType.HEART_RATE, DateRange.WEEK)
        assertEquals(first, second)
        assertTrue(first.all { it.value > 40.0 && it.value < 180.0 })
    }

    @Test
    fun `设备状态与开关数据合法`() {
        val device = provider.deviceState()
        assertTrue(device.batteryPercent in 0..100)
        assertEquals(true, device.connected)
        assertEquals(5, provider.defaultFunctionSettings().size)
    }

    @Test
    fun `运动记录非空且字段合理`() {
        val records = provider.workouts()
        assertTrue(records.isNotEmpty())
        assertTrue(records.all { it.durationMinutes > 0 && it.calories > 0 })
    }
}
  • Step 2: 运行测试,确认失败
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
package com.placeholder.ringhealth.data.mock

import com.placeholder.ringhealth.domain.model.DateRange
import com.placeholder.ringhealth.domain.model.DeviceFunctionSetting
import com.placeholder.ringhealth.domain.model.HealthSummary
import com.placeholder.ringhealth.domain.model.MetricPoint
import com.placeholder.ringhealth.domain.model.MetricType
import com.placeholder.ringhealth.domain.model.RingDeviceState
import com.placeholder.ringhealth.domain.model.WorkoutRecord
import kotlin.random.Random

/**
 * 演示数据生成器。所有方法都是确定性的(同参数返回同结果),
 * 保证页面展示与截图稳定。M2 接入真机后本类整体弃用。
 */
class MockDataProvider(private val seed: Long = DEFAULT_SEED) {

    /** 今日概览:固定演示值。血压为 null 以演示“未开启”占位态。 */
    fun todaySummary(): HealthSummary = HealthSummary(
        steps = 8426,
        stepGoal = 10000,
        calories = 327,
        distanceKm = 6.21,
        heartRate = 72,
        sleepMinutes = 452,
        bloodOxygen = 98,
        stress = 34,
        temperature = 36.5,
        bloodPressure = null,
    )

    /**
     * 指定指标的历史数据:WEEK 返回 7 个点,MONTH 返回 30 个点。
     * 时间戳按“每点间隔一天”倒推,值在指标合理区间内波动。
     */
    fun metricHistory(metric: MetricType, range: DateRange): List<MetricPoint> {
        val count = if (range == DateRange.WEEK) 7 else 30
        val random = Random(seed + metric.ordinal * 100L + range.ordinal)
        val (min, max) = valueRange(metric)
        val now = System.currentTimeMillis()
        val dayMillis = 24L * 60 * 60 * 1000
        return (0 until count).map { index ->
            val progress = index.toFloat() / (count - 1).coerceAtLeast(1)
            val value = min + (max - min) * progress + random.nextDouble(-1.0, 1.0) * (max - min) * 0.15
            MetricPoint(
                timestamp = now - (count - 1 - index) * dayMillis,
                value = value.coerceIn(min, max),
            )
        }
    }

    /** 运动记录:固定 5 条演示数据,倒序(最近的在最前)。 */
    fun workouts(): List<WorkoutRecord> {
        val random = Random(seed + 7)
        val now = System.currentTimeMillis()
        val dayMillis = 24L * 60 * 60 * 1000
        val types = listOf("户外跑步", "健走", "骑行", "自由训练", "户外跑步")
        return types.mapIndexed { index, type ->
            WorkoutRecord(
                id = "workout-$index",
                type = type,
                startTime = now - (index + 1) * dayMillis,
                durationMinutes = 25 + random.nextInt(0, 45),
                distanceKm = 2.0 + random.nextDouble(0.0, 5.0),
                calories = 120 + random.nextInt(0, 260),
            )
        }
    }

    /** 戒指设备状态:固定演示值。 */
    fun deviceState(): RingDeviceState = RingDeviceState(
        connected = true,
        name = "RingHealth 智能戒指",
        batteryPercent = 78,
        firmwareVersion = "1.0.5",
        charging = false,
    )

    /** 五个功能开关,对应协议中的自动监测能力。 */
    fun defaultFunctionSettings(): List<DeviceFunctionSetting> = listOf(
        DeviceFunctionSetting("heart_rate", "自动心率监测", "每 10 分钟测量一次", true),
        DeviceFunctionSetting("blood_oxygen", "自动血氧监测", "夜间持续监测", true),
        DeviceFunctionSetting("sleep", "睡眠监测", "自动识别睡眠时段", true),
        DeviceFunctionSetting("stress", "压力监测", "全天候压力评估", false),
        DeviceFunctionSetting("blood_pressure", "血压监测", "需在 App 内手动校准", false),
    )

    /** 各指标的取值区间,用于生成合理的历史数据。 */
    private fun valueRange(metric: MetricType): Pair<Double, Double> = when (metric) {
        MetricType.STEP -> 3000.0 to 12000.0
        MetricType.HEART_RATE -> 58.0 to 96.0
        MetricType.SLEEP -> 330.0 to 510.0
        MetricType.BLOOD_OXYGEN -> 95.0 to 99.0
        MetricType.STRESS -> 20.0 to 70.0
        MetricType.TEMPERATURE -> 36.1 to 36.9
        MetricType.BLOOD_PRESSURE -> 110.0 to 130.0
    }

    companion object {
        /** 默认随机种子,保证演示数据稳定。 */
        const val DEFAULT_SEED: Long = 20260910L
    }
}
  • Step 4: 运行测试,确认通过
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
package com.placeholder.ringhealth.data.mock

import com.placeholder.ringhealth.domain.model.DateRange
import com.placeholder.ringhealth.domain.model.HealthSummary
import com.placeholder.ringhealth.domain.model.MetricPoint
import com.placeholder.ringhealth.domain.model.MetricType
import com.placeholder.ringhealth.domain.model.WorkoutRecord
import com.placeholder.ringhealth.domain.repository.HealthDataRepository
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow

/**
 * Mock 健康数据仓库。模拟 200ms 网络/设备延迟,便于阶段一验证加载态。
 */
class MockHealthDataRepository(
    private val provider: MockDataProvider = MockDataProvider(),
) : HealthDataRepository {

    override fun observeTodaySummary(): Flow<HealthSummary> = flow {
        delay(MOCK_DELAY_MILLIS)
        emit(provider.todaySummary())
    }

    override fun observeMetricHistory(metric: MetricType, range: DateRange): Flow<List<MetricPoint>> = flow {
        delay(MOCK_DELAY_MILLIS)
        emit(provider.metricHistory(metric, range))
    }

    override fun observeWorkouts(): Flow<List<WorkoutRecord>> = flow {
        delay(MOCK_DELAY_MILLIS)
        emit(provider.workouts())
    }

    private companion object {
        const val MOCK_DELAY_MILLIS = 200L
    }
}
  • Step 6: 写 MockRingDeviceRepository.kt
package com.placeholder.ringhealth.data.mock

import com.placeholder.ringhealth.domain.model.DeviceFunctionSetting
import com.placeholder.ringhealth.domain.model.RingDeviceState
import com.placeholder.ringhealth.domain.repository.RingDeviceRepository
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update

/**
 * Mock 设备仓库。开关状态保存在内存中,进程重启后重置(阶段一不做持久化)。
 */
class MockRingDeviceRepository(
    private val provider: MockDataProvider = MockDataProvider(),
) : RingDeviceRepository {

    private val _deviceState = MutableStateFlow(provider.deviceState())
    override val deviceState: StateFlow<RingDeviceState> = _deviceState.asStateFlow()

    private val _functionSettings = MutableStateFlow(provider.defaultFunctionSettings())
    override val functionSettings: StateFlow<List<DeviceFunctionSetting>> = _functionSettings.asStateFlow()

    override suspend fun refreshDeviceState() {
        delay(REFRESH_DELAY_MILLIS)
        _deviceState.value = provider.deviceState()
    }

    override fun setFunctionEnabled(id: String, enabled: Boolean) {
        _functionSettings.update { settings ->
            settings.map { if (it.id == id) it.copy(enabled = enabled) else it }
        }
    }

    private companion object {
        const val REFRESH_DELAY_MILLIS = 300L
    }
}
  • Step 7: 写 AppContainer.kt 与更新 RingHealthApplication.kt

现在 Mock 实现已存在,可以安全创建容器(ledger Ruling R1)。

AppContainer.kt:

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 的最小实现):

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: 全量单元测试 + 构建验证
cd /d/cherry/repos/ring-health-app && ./gradlew :app:testDebugUnitTest && ./gradlew assembleDebug

Expected: 两个命令均 BUILD SUCCESSFUL(Step 7 的容器代码也在本次编译范围内)。

  • Step 9: Commit
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, onOpenWorkoutHistory: () -> Unit)、WorkoutScreen(onOpenHistory: () -> Unit)、DeviceScreen(onOpenSettings: () -> Unit)、ProfileScreen()
  • Step 1: 写 Routes.kt

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:

package com.placeholder.ringhealth.feature.home

import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import com.placeholder.ringhealth.domain.model.MetricType

/** 健康首页。 */
@Composable
fun HomeScreen(
    onOpenDetail: (MetricType) -> Unit,
    modifier: Modifier = Modifier,
) {
    Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
        Text("健康首页")
    }
}

feature/workout/WorkoutScreen.kt:

package com.placeholder.ringhealth.feature.workout

import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier

/** 运动页。 */
@Composable
fun WorkoutScreen(modifier: Modifier = Modifier) {
    Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
        Text("运动")
    }
}

feature/device/DeviceScreen.kt:

package com.placeholder.ringhealth.feature.device

import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier

/** 设备页。 */
@Composable
fun DeviceScreen(modifier: Modifier = Modifier) {
    Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
        Text("设备")
    }
}

feature/profile/ProfileScreen.kt:

package com.placeholder.ringhealth.feature.profile

import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier

/** 我的页。 */
@Composable
fun ProfileScreen(modifier: Modifier = Modifier) {
    Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
        Text("我的")
    }
}
  • Step 3: 写 strings.xml 的导航文案

在 res/values/strings.xml 的 <resources> 内追加:

    <string name="tab_home">健康</string>
    <string name="tab_workout">运动</string>
    <string name="tab_device">设备</string>
    <string name="tab_profile">我的</string>
  • Step 4: 写 RingHealthApp.kt
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 仅占位):

package com.placeholder.ringhealth.feature.device

import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier

/** 设备设置子页(待 Task 10 实现)。 */
@Composable
fun DeviceSettingsPlaceholder(modifier: Modifier = Modifier) {
    Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
        Text("设备设置")
    }
}

feature/workout/WorkoutHistoryScreen.kt(本 Task 仅占位):

package com.placeholder.ringhealth.feature.workout

import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier

/** 运动历史子页(待 Task 9 实现)。 */
@Composable
fun WorkoutHistoryPlaceholder(modifier: Modifier = Modifier) {
    Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
        Text("运动历史")
    }
}

feature/detail/MetricDetailScreen.kt(本 Task 仅占位):

package com.placeholder.ringhealth.feature.detail

import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import com.placeholder.ringhealth.domain.model.MetricType

/** 指标详情子页(待 Task 12 实现)。 */
@Composable
fun MetricDetailPlaceholder(metric: MetricType, modifier: Modifier = Modifier) {
    Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
        Text("详情:${metric.name}")
    }
}
  • Step 6: MainActivity 接入 RingHealthApp
setContent {
    RingHealthTheme {
        Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
            RingHealthApp()
        }
    }
}
  • Step 7: 构建 + 安装验证
cd /d/cherry/repos/ring-health-app && ./gradlew assembleDebug && ./gradlew installDebug

Expected: 构建成功并装到设备/模拟器;打开 App 可见底部四个 Tab,点击可切换、互不崩溃。

  • Step 8: Commit
git add -A && git commit -m "feat: 新增底部四 Tab 导航骨架与路由表"

Task 8: 健康首页(完整实现,含 ViewModel)

Files:

  • Create: feature/home/HomeViewModel.kt
  • Create: AppViewModelProvider.kt(原属 Task 4,见 ledger Ruling R2)
  • Modify: feature/home/HomeScreen.kt(完整实现)
  • Test: app/src/test/java/com/placeholder/ringhealth/feature/home/HomeViewModelTest.kt
  • Modify: res/values/strings.xml

Interfaces:

  • Consumes: HealthDataRepository、RingDeviceRepository、RingHealthApplication.container、Task 3 组件

  • Produces:

    • data class HomeUiState(val loading: Boolean, val summary: HealthSummary?, val device: RingDeviceState?)
    • class HomeViewModel(healthDataRepository, ringDeviceRepository) 暴露 uiState: StateFlow<HomeUiState>
    • object AppViewModelProvider { val Factory } 与 fun CreationExtras.ringHealthApplication(): RingHealthApplication
    • @Composable fun HomeScreen(onOpenDetail: (MetricType) -> Unit, modifier, viewModel: HomeViewModel = viewModel(factory = AppViewModelProvider.Factory))
  • Step 1: 写失败的测试

app/src/test/java/com/placeholder/ringhealth/feature/home/HomeViewModelTest.kt:

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: 运行测试,确认失败
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
package com.placeholder.ringhealth.feature.home

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.placeholder.ringhealth.domain.model.HealthSummary
import com.placeholder.ringhealth.domain.model.RingDeviceState
import com.placeholder.ringhealth.domain.repository.HealthDataRepository
import com.placeholder.ringhealth.domain.repository.RingDeviceRepository
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn

/**
 * 首页 UI 状态。summary/device 为 null 表示仍在加载。
 */
data class HomeUiState(
    val loading: Boolean,
    val summary: HealthSummary?,
    val device: RingDeviceState?,
)

/**
 * 健康首页 ViewModel:合并今日概览与设备状态。
 */
class HomeViewModel(
    healthDataRepository: HealthDataRepository,
    ringDeviceRepository: RingDeviceRepository,
) : ViewModel() {

    val uiState: StateFlow<HomeUiState> = combine(
        healthDataRepository.observeTodaySummary(),
        ringDeviceRepository.deviceState,
    ) { summary, device ->
        HomeUiState(loading = false, summary = summary, device = device)
    }.stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(STOP_TIMEOUT_MILLIS),
        initialValue = HomeUiState(loading = true, summary = null, device = null),
    )

    private companion object {
        const val STOP_TIMEOUT_MILLIS = 5_000L
    }
}
  • Step 4: 运行测试,确认通过
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,文件位置与职责不变。

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
package com.placeholder.ringhealth.feature.home

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.item
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bloodtype
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.MonitorHeart
import androidx.compose.material.icons.filled.Spa
import androidx.compose.material.icons.filled.Thermostat
import androidx.compose.material.icons.filled.WaterDrop
import androidx.compose.material.icons.filled.Bedtime
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.placeholder.ringhealth.AppViewModelProvider
import com.placeholder.ringhealth.R
import com.placeholder.ringhealth.core.common.formatCalories
import com.placeholder.ringhealth.core.common.formatDistance
import com.placeholder.ringhealth.core.common.formatSleepDuration
import com.placeholder.ringhealth.core.common.formatSteps
import com.placeholder.ringhealth.core.designsystem.MetricBlue
import com.placeholder.ringhealth.core.designsystem.MetricCyan
import com.placeholder.ringhealth.core.designsystem.MetricGreen
import com.placeholder.ringhealth.core.designsystem.MetricOrange
import com.placeholder.ringhealth.core.designsystem.MetricPurple
import com.placeholder.ringhealth.core.designsystem.MetricRed
import com.placeholder.ringhealth.core.designsystem.MetricYellow
import com.placeholder.ringhealth.core.designsystem.MetricValueStyle
import com.placeholder.ringhealth.core.designsystem.RingHealthTheme
import com.placeholder.ringhealth.core.designsystem.TextSecondary
import com.placeholder.ringhealth.core.designsystem.component.HealthCard
import com.placeholder.ringhealth.core.designsystem.component.MetricTile
import com.placeholder.ringhealth.core.designsystem.component.RingProgress
import com.placeholder.ringhealth.core.designsystem.component.SectionHeader
import com.placeholder.ringhealth.domain.model.HealthSummary
import com.placeholder.ringhealth.domain.model.MetricType
import com.placeholder.ringhealth.domain.model.RingDeviceState

/** 一个指标卡的数据。 */
private data class MetricCardSpec(
    val type: MetricType,
    val icon: ImageVector,
    val tint: Color,
    val title: String,
    val value: String?,
    val unit: String,
)

/** 健康首页。 */
@Composable
fun HomeScreen(
    onOpenDetail: (MetricType) -> Unit,
    modifier: Modifier = Modifier,
    viewModel: HomeViewModel = viewModel(factory = AppViewModelProvider.Factory),
) {
    val state by viewModel.uiState.collectAsStateWithLifecycle()
    HomeContent(state = state, onOpenDetail = onOpenDetail, modifier = modifier)
}

/** 无状态首页内容,便于 Preview。 */
@Composable
private fun HomeContent(
    state: HomeUiState,
    onOpenDetail: (MetricType) -> Unit,
    modifier: Modifier = Modifier,
) {
    if (state.loading || state.summary == null) {
        Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
            CircularProgressIndicator(color = MetricGreen)
        }
        return
    }

    val summary = state.summary
    // 注意:LazyColumn 的 content lambda 不是 Composable 上下文,
    // 所有需要 stringResource / Composable 调用的内容都必须在 LazyColumn 之前算好。
    val cards = buildMetricCards(summary)
    val notEnabledText = stringResource(R.string.home_not_enabled)
    LazyColumn(
        modifier = modifier.fillMaxSize(),
        contentPadding = PaddingValues(16.dp),
        verticalArrangement = Arrangement.spacedBy(12.dp),
    ) {
        item { DeviceStrip(device = state.device) }
        item { StepsCard(summary = summary, onClick = { onOpenDetail(MetricType.STEP) }) }
        item { SectionHeader(title = stringResource(R.string.home_metrics)) }
        items(cards) { card ->
            MetricTile(
                icon = card.icon,
                iconTint = card.tint,
                title = card.title,
                value = card.value ?: notEnabledText,
                unit = if (card.value == null) "" else card.unit,
                onClick = { onOpenDetail(card.type) },
            )
        }
    }
}

/** 顶部设备连接状态条。 */
@Composable
private fun DeviceStrip(device: RingDeviceState?, modifier: Modifier = Modifier) {
    HealthCard(modifier = modifier) {
        Row(verticalAlignment = Alignment.CenterVertically) {
            Icon(
                imageVector = Icons.Filled.MonitorHeart,
                contentDescription = null,
                tint = MetricGreen,
                modifier = Modifier.size(20.dp),
            )
            Column(modifier = Modifier.padding(start = 10.dp)) {
                Text(
                    text = if (device?.connected == true) {
                        stringResource(R.string.home_device_connected, device.name)
                    } else {
                        stringResource(R.string.home_device_disconnected)
                    },
                    style = MaterialTheme.typography.bodyMedium,
                )
                if (device != null) {
                    Text(
                        text = stringResource(R.string.home_battery, device.batteryPercent),
                        style = MaterialTheme.typography.bodySmall,
                        color = TextSecondary,
                    )
                }
            }
        }
    }
}

/** 今日步数大环卡片。 */
@Composable
private fun StepsCard(summary: HealthSummary, onClick: () -> Unit, modifier: Modifier = Modifier) {
    HealthCard(modifier = modifier, onClick = onClick) {
        Column(
            modifier = Modifier.fillMaxWidth(),
            horizontalAlignment = Alignment.CenterHorizontally,
        ) {
            Text(text = stringResource(R.string.home_steps_title), style = MaterialTheme.typography.titleMedium)
            Spacer(modifier = Modifier.height(12.dp))
            RingProgress(
                progress = summary.steps.toFloat() / summary.stepGoal.coerceAtLeast(1),
                color = MetricGreen,
                trackColor = MetricGreen.copy(alpha = 0.15f),
            ) {
                Column(horizontalAlignment = Alignment.CenterHorizontally) {
                    Text(text = formatSteps(summary.steps), style = MetricValueStyle)
                    Text(
                        text = stringResource(R.string.home_steps_goal, formatSteps(summary.stepGoal)),
                        style = MaterialTheme.typography.bodySmall,
                        color = TextSecondary,
                    )
                }
            }
            Spacer(modifier = Modifier.height(12.dp))
            Row(
                modifier = Modifier.fillMaxWidth(),
                horizontalArrangement = Arrangement.SpaceEvenly,
            ) {
                SummaryMetric(
                    value = formatDistance(summary.distanceKm),
                    label = stringResource(R.string.home_distance_label),
                    tint = MetricBlue,
                )
                SummaryMetric(
                    value = summary.calories.toString(),
                    label = stringResource(R.string.home_calories_label),
                    tint = MetricOrange,
                )
            }
        }
    }
}

/** 环下方的小结指标。 */
@Composable
private fun SummaryMetric(value: String, label: String, tint: Color) {
    Column(horizontalAlignment = Alignment.CenterHorizontally) {
        Text(text = value, style = MaterialTheme.typography.titleMedium, color = tint)
        Text(text = label, style = MaterialTheme.typography.bodySmall, color = TextSecondary)
    }
}

/** 组装指标卡列表;value 为 null 表示该指标未开启。 */
@Composable
private fun buildMetricCards(summary: HealthSummary): List<MetricCardSpec> = listOf(
    MetricCardSpec(
        type = MetricType.HEART_RATE,
        icon = Icons.Filled.Favorite,
        tint = MetricRed,
        title = stringResource(R.string.metric_heart_rate),
        value = summary.heartRate?.toString(),
        unit = stringResource(R.string.unit_bpm),
    ),
    MetricCardSpec(
        type = MetricType.SLEEP,
        icon = Icons.Filled.Bedtime,
        tint = MetricBlue,
        title = stringResource(R.string.metric_sleep),
        value = summary.sleepMinutes?.let { formatSleepDuration(it) },
        unit = "",
    ),
    MetricCardSpec(
        type = MetricType.BLOOD_OXYGEN,
        icon = Icons.Filled.Bloodtype,
        tint = MetricCyan,
        title = stringResource(R.string.metric_blood_oxygen),
        value = summary.bloodOxygen?.toString(),
        unit = stringResource(R.string.unit_percent),
    ),
    MetricCardSpec(
        type = MetricType.STRESS,
        icon = Icons.Filled.Spa,
        tint = MetricPurple,
        title = stringResource(R.string.metric_stress),
        value = summary.stress?.toString(),
        unit = "",
    ),
    MetricCardSpec(
        type = MetricType.TEMPERATURE,
        icon = Icons.Filled.Thermostat,
        tint = MetricOrange,
        title = stringResource(R.string.metric_temperature),
        value = summary.temperature?.toString(),
        unit = stringResource(R.string.unit_celsius),
    ),
    MetricCardSpec(
        type = MetricType.BLOOD_PRESSURE,
        icon = Icons.Filled.WaterDrop,
        tint = MetricYellow,
        title = stringResource(R.string.metric_blood_pressure),
        value = summary.bloodPressure,
        unit = "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
    <string name="home_device_connected">%1$s · 已连接</string>
    <string name="home_device_disconnected">未连接设备</string>
    <string name="home_battery">电量 %1$d%%</string>
    <string name="home_steps_title">今日步数</string>
    <string name="home_steps_goal">目标 %1$s 步</string>
    <string name="home_calories_label">千卡</string>
    <string name="home_distance_label">公里</string>
    <string name="home_metrics">健康指标</string>
    <string name="home_not_enabled">未开启</string>
    <string name="unit_bpm">次/分</string>
    <string name="unit_percent">%</string>
    <string name="unit_celsius">°C</string>

    <string name="metric_heart_rate">心率</string>
    <string name="metric_sleep">睡眠</string>
    <string name="metric_blood_oxygen">血氧</string>
    <string name="metric_stress">压力</string>
    <string name="metric_temperature">体温</string>
    <string name="metric_blood_pressure">血压</string>
    <string name="metric_step">步数</string>

这批 metric_* 字符串同时供 Task 12 的详情页标题使用(ledger Ruling R3),是它们的唯一来源。

  • Step 8: 构建 + 单测 + 真机验证
cd /d/cherry/repos/ring-health-app && ./gradlew :app:testDebugUnitTest && ./gradlew installDebug

Expected: 测试全绿;真机上首页显示设备条、步数大环、六个指标卡(血压显示“未开启”),点击卡片进入详情占位页并可返回。

  • Step 9: Commit
git add -A && git commit -m "feat: 实现健康首页(今日大环 + 指标卡片,含 ViewModel 测试)"

Task 9: 运动页 + 运动历史子页

Files:

  • Create: feature/workout/WorkoutViewModel.kt
  • Modify: feature/workout/WorkoutScreen.kt、feature/workout/WorkoutHistoryScreen.kt
  • Modify: res/values/strings.xml、core/navigation/RingHealthApp.kt(把占位替换为真实页面)

Interfaces:

  • Consumes: HealthDataRepository、WorkoutRecord

  • Produces:

    • data class WorkoutUiState(val loading: Boolean, val records: List<WorkoutRecord>)
    • class WorkoutViewModel(healthDataRepository) 暴露 uiState: StateFlow<WorkoutUiState>
    • @Composable fun WorkoutScreen(onOpenHistory: () -> Unit, modifier, viewModel)、@Composable fun WorkoutHistoryScreen(modifier, viewModel)
  • Step 1: 写 WorkoutViewModel.kt

package com.placeholder.ringhealth.feature.workout

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.placeholder.ringhealth.domain.model.WorkoutRecord
import com.placeholder.ringhealth.domain.repository.HealthDataRepository
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn

/** 运动页 UI 状态。 */
data class WorkoutUiState(
    val loading: Boolean,
    val records: List<WorkoutRecord>,
)

/** 运动页 ViewModel。 */
class WorkoutViewModel(
    healthDataRepository: HealthDataRepository,
) : ViewModel() {

    val uiState: StateFlow<WorkoutUiState> = healthDataRepository.observeWorkouts()
        .map { WorkoutUiState(loading = false, records = it) }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(STOP_TIMEOUT_MILLIS),
            initialValue = WorkoutUiState(loading = true, records = emptyList()),
        )

    private companion object {
        const val STOP_TIMEOUT_MILLIS = 5_000L
    }
}
  • Step 1b: 把 WorkoutViewModel 注册到 AppViewModelProvider

在 AppViewModelProvider.kt 的 viewModelFactory { } 内追加一个 initializer(ledger Ruling R2), 并在该文件顶部加 import com.placeholder.ringhealth.feature.workout.WorkoutViewModel:

        initializer {
            WorkoutViewModel(
                healthDataRepository = ringHealthApplication().container.healthDataRepository,
            )
        }
  • Step 2: 追加文案到 strings.xml
    <string name="workout_start">开始运动</string>
    <string name="workout_start_desc">选择运动类型,记录你的训练</string>
    <string name="workout_recent">最近运动</string>
    <string name="workout_history">全部记录</string>
    <string name="workout_empty">暂无运动记录</string>
    <string name="workout_item_summary">%1$s · %2$s 公里</string>
    <string name="workout_detail_duration">时长 %1$s</string>
    <string name="workout_detail_calories">消耗 %1$s</string>
    <string name="action_view_all">查看全部</string>
  • Step 3: 实现 WorkoutScreen.kt
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
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) 替换为:

            composable(Routes.WORKOUT) {
                WorkoutScreen(onOpenHistory = { navController.navigate(Routes.WORKOUT_HISTORY) })
            }
            composable(Routes.WORKOUT_HISTORY) {
                WorkoutHistoryScreen()
            }

并删除 WorkoutHistoryPlaceholder 的 import 与定义(文件 WorkoutHistoryScreen.kt 中不再保留占位函数)。

注意:WorkoutViewModel 在运动页与历史页各创建一次实例(导航到不同 destination 时独立作用域)。阶段一数据相同,可接受;M3 引入真实数据后如需共享,再提升作用域。

  • Step 6: 构建 + 验证
cd /d/cherry/repos/ring-health-app && ./gradlew assembleDebug && ./gradlew installDebug

Expected: 运动 Tab 显示“开始运动”卡片 + 5 条记录;“查看全部”跳转历史页。

  • Step 7: Commit
git add -A && git commit -m "feat: 实现运动页与运动历史子页"

Task 10: 设备页 + 设备设置子页

Files:

  • Create: feature/device/DeviceViewModel.kt
  • Modify: feature/device/DeviceScreen.kt、feature/device/DeviceSettingsScreen.kt
  • Modify: res/values/strings.xml、core/navigation/RingHealthApp.kt

Interfaces:

  • Consumes: RingDeviceRepository、Task 3 组件

  • Produces:

    • data class DeviceUiState(val device: RingDeviceState, val settings: List<DeviceFunctionSetting>)
    • class DeviceViewModel(ringDeviceRepository) 暴露 uiState: StateFlow<DeviceUiState>、fun setFunctionEnabled(id: String, enabled: Boolean)
    • @Composable fun DeviceScreen(onOpenSettings: () -> Unit, modifier, viewModel)、@Composable fun DeviceSettingsScreen(modifier, viewModel)
  • Step 1: 写 DeviceViewModel.kt

package com.placeholder.ringhealth.feature.device

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.placeholder.ringhealth.domain.model.DeviceFunctionSetting
import com.placeholder.ringhealth.domain.model.RingDeviceState
import com.placeholder.ringhealth.domain.repository.RingDeviceRepository
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn

/** 设备页 UI 状态。 */
data class DeviceUiState(
    val device: RingDeviceState,
    val settings: List<DeviceFunctionSetting>,
)

/** 设备页 ViewModel:设备状态 + 功能开关。 */
class DeviceViewModel(
    private val ringDeviceRepository: RingDeviceRepository,
) : ViewModel() {

    val uiState: StateFlow<DeviceUiState> = combine(
        ringDeviceRepository.deviceState,
        ringDeviceRepository.functionSettings,
    ) { device, settings ->
        DeviceUiState(device = device, settings = settings)
    }.stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(STOP_TIMEOUT_MILLIS),
        initialValue = DeviceUiState(
            device = ringDeviceRepository.deviceState.value,
            settings = ringDeviceRepository.functionSettings.value,
        ),
    )

    /** 切换功能开关(M1 仅改内存,M2 后同步到设备)。 */
    fun setFunctionEnabled(id: String, enabled: Boolean) {
        ringDeviceRepository.setFunctionEnabled(id, enabled)
    }

    private companion object {
        const val STOP_TIMEOUT_MILLIS = 5_000L
    }
}
  • Step 1b: 把 DeviceViewModel 注册到 AppViewModelProvider

在 AppViewModelProvider.kt 的 viewModelFactory { } 内追加一个 initializer(ledger Ruling R2), 并在该文件顶部加 import com.placeholder.ringhealth.feature.device.DeviceViewModel:

        initializer {
            DeviceViewModel(
                ringDeviceRepository = ringHealthApplication().container.ringDeviceRepository,
            )
        }
  • Step 2: 追加文案到 strings.xml
    <string name="device_title">我的设备</string>
    <string name="device_connected">已连接</string>
    <string name="device_disconnected">未连接</string>
    <string name="device_battery">电量 %1$d%%</string>
    <string name="device_firmware">固件版本 %1$s</string>
    <string name="device_charging">充电中</string>
    <string name="device_settings_title">功能设置</string>
    <string name="device_find_ring">查找戒指</string>
    <string name="device_find_ring_desc">让戒指震动以便寻找</string>
    <string name="device_firmware_update">固件升级</string>
    <string name="device_firmware_update_desc">检查并升级到最新固件</string>
    <string name="device_wear_detection">佩戴检测</string>
    <string name="device_wear_detection_desc">未佩戴时暂停测量</string>
    <string name="device_more">更多设置</string>
    <string name="device_switch_on">已开启</string>
    <string name="device_switch_off">已关闭</string>
  • Step 3: 实现 DeviceScreen.kt
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(替换占位)
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
    <string name="device_settings_hint">阶段一为演示开关,接入戒指后将同步到设备。</string>
  • Step 6: 在 RingHealthApp.kt 接入真实页面
            composable(Routes.DEVICE) {
                DeviceScreen(onOpenSettings = { navController.navigate(Routes.DEVICE_SETTINGS) })
            }
            composable(Routes.DEVICE_SETTINGS) {
                DeviceSettingsScreen()
            }

删除 DeviceSettingsPlaceholder 的定义与 import。

  • Step 7: 构建 + 验证
cd /d/cherry/repos/ring-health-app && ./gradlew assembleDebug && ./gradlew installDebug

Expected: 设备 Tab 显示戒指卡片(电量 78%、固件 1.0.5);“更多设置”进入后开关可切换且返回后状态保持。

  • Step 8: Commit
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

    <string name="profile_nickname">健康用户</string>
    <string name="profile_login_hint">登录后可同步数据(阶段一未开放)</string>
    <string name="profile_group_app">应用设置</string>
    <string name="profile_units">单位设置</string>
    <string name="profile_units_desc">公里 / 公斤</string>
    <string name="profile_notification">通知权限</string>
    <string name="profile_notification_desc">接收健康提醒</string>
    <string name="profile_group_about">关于</string>
    <string name="profile_check_update">检查更新</string>
    <string name="profile_check_update_desc">当前版本 0.1.0</string>
    <string name="profile_help">帮助与反馈</string>
    <string name="profile_help_desc">常见问题与意见反馈</string>
  • Step 2: 实现 ProfileScreen.kt
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: 构建 + 验证
cd /d/cherry/repos/ring-health-app && ./gradlew assembleDebug && ./gradlew installDebug

Expected: “我的”Tab 显示头像卡与两组设置入口。

  • Step 4: Commit
git add -A && git commit -m "feat: 实现我的页"

Task 12: 指标详情子页(步数 / 心率 / 睡眠 / 血氧等)

Files:

  • Create: feature/detail/MetricDetailViewModel.kt
  • Modify: feature/detail/MetricDetailScreen.kt(替换占位)
  • Modify: core/navigation/RingHealthApp.kt(接入真实详情页 + 支持返回)
  • Modify: res/values/strings.xml

Interfaces:

  • Consumes: HealthDataRepository、MetricBarChart、StatBar、Task 8 加入的 R.string.metric_*
  • Produces:
    • data class MetricDetailUiState(val loading: Boolean, val metric: MetricType, val range: DateRange, val points: List<MetricPoint>)
    • class MetricDetailViewModel(metric: MetricType, healthDataRepository),方法 fun switchRange(range: DateRange)
    • @Composable fun MetricDetailScreen(metric: MetricType, onBack: () -> Unit, modifier, viewModel: MetricDetailViewModel = viewModel(factory = MetricDetailViewModel.factory(metric)))
    • MetricDetailViewModel.factory(metric: MetricType): ViewModelProvider.Factory(伴生对象)
    • @Composable private fun metricTitleRes(type: MetricType): Int(文件内私有)

两点范围说明:

  • 标题用 metricTitleRes 映射到 R.string.metric_*,不再有 metricDisplayName(ledger Ruling R3)。
  • 增加睡眠专属区块(StatBar 分期条 + 入睡/起床时间),补齐 spec §6 的 detail/sleep 要求(ledger Ruling R4)。
  • Step 1: 写 MetricDetailViewModel.kt
package com.placeholder.ringhealth.feature.detail

import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.placeholder.ringhealth.domain.model.DateRange
import com.placeholder.ringhealth.domain.model.MetricPoint
import com.placeholder.ringhealth.domain.model.MetricType
import com.placeholder.ringhealth.domain.repository.HealthDataRepository
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update

/** 指标详情 UI 状态。 */
data class MetricDetailUiState(
    val loading: Boolean,
    val metric: MetricType,
    val range: DateRange,
    val points: List<MetricPoint>,
)

/**
 * 指标详情 ViewModel。因需要指标参数,使用独立工厂创建。
 */
@OptIn(ExperimentalCoroutinesApi::class)
class MetricDetailViewModel(
    private val metric: MetricType,
    healthDataRepository: HealthDataRepository,
) : ViewModel() {

    private val range = MutableStateFlow(DateRange.WEEK)

    val uiState: StateFlow<MetricDetailUiState> = range
        .flatMapLatest { currentRange ->
            healthDataRepository.observeMetricHistory(metric, currentRange)
                .map { points ->
                    MetricDetailUiState(
                        loading = false,
                        metric = metric,
                        range = currentRange,
                        points = points,
                    )
                }
        }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(STOP_TIMEOUT_MILLIS),
            initialValue = MetricDetailUiState(
                loading = true,
                metric = metric,
                range = DateRange.WEEK,
                points = emptyList(),
            ),
        )

    /** 切换时间范围(周/月)。 */
    fun switchRange(newRange: DateRange) {
        range.update { newRange }
    }

    companion object {
        private const val STOP_TIMEOUT_MILLIS = 5_000L

        /** 为指定指标构造 ViewModel 工厂。 */
        fun factory(metric: MetricType): ViewModelProvider.Factory = viewModelFactory {
            initializer {
                MetricDetailViewModel(
                    metric = metric,
                    healthDataRepository = ringHealthApplication().container.healthDataRepository,
                )
            }
        }
    }
}

说明:ringHealthApplication() 是 Task 4 中定义在 com.placeholder.ringhealth 包下的 CreationExtras 扩展函数,需在本文件顶部 import com.placeholder.ringhealth.ringHealthApplication。

  • Step 2: 追加文案到 strings.xml
    <string name="detail_range_week">近 7 天</string>
    <string name="detail_range_month">近 30 天</string>
    <string name="detail_average">平均</string>
    <string name="detail_max">最高</string>
    <string name="detail_min">最低</string>
    <string name="detail_trend">趋势</string>
    <string name="detail_sleep_stages">睡眠分期</string>
    <string name="detail_sleep_deep">深睡</string>
    <string name="detail_sleep_light">浅睡</string>
    <string name="detail_sleep_awake">清醒</string>
    <string name="detail_sleep_fall_asleep">入睡时间</string>
    <string name="detail_sleep_wake_up">起床时间</string>
    <string name="action_back">返回</string>

detail_average / detail_max / detail_min 是纯标签,不带 %1$s: StatLine 把标签和数值分成左右两栏渲染,标签里再带占位符会原样打印出 %1$s。

  • Step 3: 实现 MetricDetailScreen.kt(替换占位)
package com.placeholder.ringhealth.feature.detail

import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.placeholder.ringhealth.R
import com.placeholder.ringhealth.core.common.formatClock
import com.placeholder.ringhealth.core.designsystem.MetricBlue
import com.placeholder.ringhealth.core.designsystem.MetricGreen
import com.placeholder.ringhealth.core.designsystem.TextSecondary
import com.placeholder.ringhealth.core.designsystem.component.HealthCard
import com.placeholder.ringhealth.core.designsystem.component.MetricBarChart
import com.placeholder.ringhealth.core.designsystem.component.SectionHeader
import com.placeholder.ringhealth.core.designsystem.component.StatBar
import com.placeholder.ringhealth.core.designsystem.component.StatSegment
import com.placeholder.ringhealth.domain.model.DateRange
import com.placeholder.ringhealth.domain.model.MetricPoint
import com.placeholder.ringhealth.domain.model.MetricType

/**
 * 指标详情页:范围切换 + 柱状趋势图 + 统计摘要。
 */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MetricDetailScreen(
    metric: MetricType,
    onBack: () -> Unit,
    modifier: Modifier = Modifier,
    viewModel: MetricDetailViewModel = viewModel(factory = MetricDetailViewModel.factory(metric)),
) {
    val state by viewModel.uiState.collectAsStateWithLifecycle()

    Scaffold(
        modifier = modifier,
        containerColor = MaterialTheme.colorScheme.background,
        topBar = {
            TopAppBar(
                title = { Text(text = stringResource(metricTitleRes(metric))) },
                navigationIcon = {
                    IconButton(onClick = onBack) {
                        Icon(
                            imageVector = Icons.AutoMirrored.Filled.ArrowBack,
                            contentDescription = stringResource(R.string.action_back),
                        )
                    }
                },
            )
        },
    ) { innerPadding ->
        if (state.loading) {
            Box(
                modifier = Modifier.fillMaxSize().padding(innerPadding),
                contentAlignment = Alignment.Center,
            ) {
                CircularProgressIndicator(color = MetricGreen)
            }
            return@Scaffold
        }

        LazyColumn(
            modifier = Modifier.fillMaxSize().padding(innerPadding),
            contentPadding = PaddingValues(16.dp),
            verticalArrangement = Arrangement.spacedBy(12.dp),
        ) {
            item { RangeSelector(current = state.range, onSelect = viewModel::switchRange) }
            item {
                HealthCard {
                    SectionHeader(title = stringResource(R.string.detail_trend))
                    MetricBarChart(
                        values = state.points.map { it.value.toFloat() },
                        color = MetricGreen,
                    )
                }
            }
            item {
                HealthCard {
                    val values = state.points.map { it.value }
                    StatLine(label = stringResource(R.string.detail_average), value = values.averageOrZero())
                    StatLine(label = stringResource(R.string.detail_max), value = values.maxOrNull() ?: 0.0)
                    StatLine(label = stringResource(R.string.detail_min), value = values.minOrNull() ?: 0.0)
                }
            }
            if (state.metric == MetricType.SLEEP) {
                item { SleepStagesCard(points = state.points) }
            }
        }
    }
}

/**
 * 睡眠分期卡片:深睡/浅睡/清醒分段条 + 入睡/起床时间(对应 spec §6 的 `detail/sleep`)。
 */
@Composable
private fun SleepStagesCard(points: List<MetricPoint>, modifier: Modifier = Modifier) {
    // 阶段一没有真实分期数据,按固定比例演示;M3 接入真实睡眠数据后改为按实际时段计算。
    val segments = listOf(
        StatSegment(weight = 0.25f, color = MetricBlue),
        StatSegment(weight = 0.65f, color = MetricBlue.copy(alpha = 0.45f)),
        StatSegment(weight = 0.10f, color = MetricBlue.copy(alpha = 0.2f)),
    )
    HealthCard(modifier = modifier) {
        SectionHeader(title = stringResource(R.string.detail_sleep_stages))
        StatBar(segments = segments)
        Row(
            modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
            horizontalArrangement = Arrangement.SpaceBetween,
        ) {
            StageLegend(label = stringResource(R.string.detail_sleep_deep), tint = MetricBlue)
            StageLegend(label = stringResource(R.string.detail_sleep_light), tint = MetricBlue.copy(alpha = 0.45f))
            StageLegend(label = stringResource(R.string.detail_sleep_awake), tint = MetricBlue.copy(alpha = 0.2f))
        }
        val fallAsleep = points.firstOrNull()?.timestamp
        val wakeUp = points.lastOrNull()?.timestamp
        if (fallAsleep != null && wakeUp != null) {
            Row(
                modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
                horizontalArrangement = Arrangement.SpaceBetween,
            ) {
                Text(
                    text = "${stringResource(R.string.detail_sleep_fall_asleep)} ${formatClock(fallAsleep)}",
                    style = MaterialTheme.typography.bodySmall,
                    color = TextSecondary,
                )
                Text(
                    text = "${stringResource(R.string.detail_sleep_wake_up)} ${formatClock(wakeUp)}",
                    style = MaterialTheme.typography.bodySmall,
                    color = TextSecondary,
                )
            }
        }
    }
}

/** 分期图例:小色块 + 名称。 */
@Composable
private fun StageLegend(label: String, tint: Color, modifier: Modifier = Modifier) {
    Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
        Box(
            modifier = Modifier
                .size(10.dp)
                .clip(RoundedCornerShape(5.dp))
                .background(tint),
        )
        Text(
            text = label,
            style = MaterialTheme.typography.bodySmall,
            color = TextSecondary,
            modifier = Modifier.padding(start = 6.dp),
        )
    }
}

/** 指标类型 → 标题字符串资源。文案的唯一来源是 `strings.xml`(ledger Ruling R3)。 */
@Composable
private fun metricTitleRes(type: MetricType): Int = when (type) {
    MetricType.STEP -> R.string.metric_step
    MetricType.HEART_RATE -> R.string.metric_heart_rate
    MetricType.SLEEP -> R.string.metric_sleep
    MetricType.BLOOD_OXYGEN -> R.string.metric_blood_oxygen
    MetricType.STRESS -> R.string.metric_stress
    MetricType.TEMPERATURE -> R.string.metric_temperature
    MetricType.BLOOD_PRESSURE -> R.string.metric_blood_pressure
}

/** 周/月切换。 */
@Composable
private fun RangeSelector(
    current: DateRange,
    onSelect: (DateRange) -> Unit,
    modifier: Modifier = Modifier,
) {
    Row(
        modifier = modifier.fillMaxWidth(),
        horizontalArrangement = Arrangement.spacedBy(8.dp),
    ) {
        FilterChip(
            selected = current == DateRange.WEEK,
            onClick = { onSelect(DateRange.WEEK) },
            label = { Text(stringResource(R.string.detail_range_week)) },
        )
        FilterChip(
            selected = current == DateRange.MONTH,
            onClick = { onSelect(DateRange.MONTH) },
            label = { Text(stringResource(R.string.detail_range_month)) },
        )
    }
}

/** 一行统计摘要。 */
@Composable
private fun StatLine(label: String, value: Double, modifier: Modifier = Modifier) {
    Row(
        modifier = modifier.fillMaxWidth().padding(vertical = 4.dp),
        horizontalArrangement = Arrangement.SpaceBetween,
    ) {
        Text(text = label, style = MaterialTheme.typography.bodyMedium, color = TextSecondary)
        Text(text = String.format(java.util.Locale.CHINA, "%.1f", value), style = MaterialTheme.typography.bodyMedium)
    }
}

/** 空列表时返回 0.0,避免 NaN。 */
private fun List<Double>.averageOrZero(): Double = if (isEmpty()) 0.0 else average()
  • Step 4: 在 RingHealthApp.kt 接入真实详情页
            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: 构建 + 验证
cd /d/cherry/repos/ring-health-app && ./gradlew assembleDebug && ./gradlew installDebug

Expected: 从首页点任一指标卡进入详情页;顶部标题为指标中文名;近 7 天/近 30 天可切换,柱状图数量随之变化(7 根 / 30 根);睡眠详情额外显示分期卡片(深睡/浅睡/清醒分段条与图例、入睡/起床时间),其他指标不显示;返回键回到首页。

  • Step 6: Commit
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

mkdir -p /d/cherry/repos/ring-health-api/docs

repos/ring-health-api/README.md:

# ring-health-api

智能戒指健康 App 的后端 API(阶段一仅契约,未实现)。

- 当前状态:**空仓库 + 接口契约草稿**
- 后端技术栈:待 M4 前确定(Spring Boot / .NET 均可,见设计文档)
- 接口契约:见 `docs/api-contract.md`

等待 App 端数据模型稳定后(M3 结束)再启动实现,避免两端空转。
  • Step 2: 写 .gitignore
# 通用
.idea/
.vscode/
*.log
.DS_Store

# Java / Kotlin
*.class
build/
.gradle/
out/
target/

# .NET
bin/
obj/
*.user
  • Step 3: 写 docs/api-contract.md
# 接口契约草稿(未实现)

> 阶段一仅用于对齐前后端边界,字段可能随 App 数据模型调整。

## 通用约定

- 基础路径:`/api/v1`
- 认证:`Authorization: Bearer <token>`
- 时间戳:毫秒(UTC)
- 分页:`page`(从 1 开始)、`pageSize`,按时间**倒序**

## 1. 登录

`POST /api/v1/auth/login`

| 字段 | 类型 | 说明 |
|---|---|---|
| phone | string | 手机号 |
| code | string | 短信验证码 |

响应:`{ "token": "...", "userId": "..." }`

## 2. 设备绑定

`POST /api/v1/devices/bind`

| 字段 | 类型 | 说明 |
|---|---|---|
| mac | string | 戒指 MAC 地址(广播数据第 6 位起) |
| name | string | 设备名称 |
| firmwareVersion | string | 固件版本 |

## 3. 健康数据上报

`POST /api/v1/health/records`

| 字段 | 类型 | 说明 |
|---|---|---|
| type | string | `STEP` / `HEART_RATE` / `SLEEP` / `BLOOD_OXYGEN` / `STRESS` / `TEMPERATURE` / `BLOOD_PRESSURE` |
| timestamp | long | 采集时间(毫秒) |
| value | double | 数值 |

支持批量:`POST /api/v1/health/records/batch`,请求体为数组。

## 4. 健康数据查询

`GET /api/v1/health/records?type=STEP&from=<ms>&to=<ms>&page=1&pageSize=100`

响应:

```json
{
  "code": 0,
  "message": "ok",
  "data": { "total": 128, "items": [ { "timestamp": 1757000000000, "value": 8426.0 } ] }
}

5. 待定项

  • 用户资料、头像上传
  • 运动记录同步(字段见 App 端 WorkoutRecord)
  • 固件 OTA 包的托管方式(当前厂商走独立 HTTP 地址)

- [ ] **Step 4: 初始化并推送**

```bash
cd /d/cherry/repos/ring-health-api
git init -b main
git add -A
git commit -m "docs: 初始化 API 仓库骨架与接口契约草稿"
git remote add origin http://120.192.101.56:3000/cuiluzheng/ring-health-api.git
git push -u origin main

Expected: 推送成功。


Task 14: 端到端验收与文档收尾

Files:

  • Modify: repos/ring-health-app/README.md(补充截图/页面清单)
  • Modify: docs/superpowers/specs/2026-09-10-ring-health-app-design.md(如有实现偏差)
  • Create: repos/ring-health-app/docs/verification-m1.md

Interfaces:

  • Consumes: 前 13 个任务的全部产出

  • Produces: 可交付的 M1 静态 App + 验收记录。

  • Step 1: 全量单元测试

cd /d/cherry/repos/ring-health-app && ./gradlew :app:testDebugUnitTest

Expected: 全部通过(Formatters 7 项 + MockDataProvider 5 项 + HomeViewModel 1 项)。

  • Step 2: 干净构建
cd /d/cherry/repos/ring-health-app && ./gradlew clean assembleDebug

Expected: BUILD SUCCESSFUL,生成 app/build/outputs/apk/debug/app-debug.apk。

  • Step 3: 真机/模拟器人工验收(逐项打勾)
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

# M1 静态页面验收记录

- 验收日期:<填写>
- 构建版本:0.1.0 (versionCode 1)
- 测试设备:<填写机型与 Android 版本>
- 单元测试:`./gradlew :app:testDebugUnitTest` 全绿
- 构建:`./gradlew clean assembleDebug` 成功

## 人工验收结果

| 项 | 结果 | 备注 |
|---|---|---|
| 应用启动无崩溃 | | |
| 四 Tab 切换 | | |
| 首页步数大环 | | |
| 首页指标卡(含血压未开启) | | |
| 详情页周/月切换 | | |
| 运动页与历史页 | | |
| 设备页与开关 | | |
| 我的页 | | |

## 已知限制(阶段一有意为之)

- 数据全部为 Mock,不连蓝牙
- 血压恒为“未开启”占位
- 开关状态不持久化,重启即重置
- 仅浅色模式
  • Step 5: 更新 App README.md 页面清单

在 README 的“当前阶段”下补充:

## 页面清单(M1)

| 页面 | 路由 |
|---|---|
| 健康首页 | `home` |
| 运动 | `workout` |
| 运动历史 | `workout/history` |
| 设备 | `device` |
| 设备设置 | `device/settings` |
| 我的 | `profile` |
| 指标详情(步数/心率/睡眠/血氧/压力/体温/血压) | `detail/{metric}` |
  • Step 6: 提交并推送全部仓库
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 加入清单与运行时请求。