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