# 智能戒指健康 App 设计文档(阶段一) - 日期:2026-09-10 - 状态:待评审 - 产品代号(占位):`ring-health` - 相关仓库:`ring-health-app`(Android App)、`ring-health-api`(后端 API) --- ## 1. 背景与目标 我们要为自有的智能戒指/手环做一款手机 App,用于后续与设备联调、并最终面向用户。 交互参照**小米运动健康**(Mi Fitness),但**按我们戒指的真实能力裁剪**。 **阶段一目标(本文档范围)**:用原生 Android + Jetpack Compose 搭出完整可运行的 **静态页面骨架**,页面齐全、导航顺畅、真机可跑;所有数据来自本地 Mock。 **不在阶段一范围**:真实蓝牙连接、协议解析、本地数据库、账号登录、云端同步。 这些在 M2 及以后的里程碑中,通过预留的接口边界接入。 ## 2. 现状:厂商 SDK 分析(已探明) 手上的 `SmartRingF01_SDK` **不是 `.aar` 库,而是厂商「鑫域隆 SmartRingF01/F02」的完整 Demo App**。 | 项 | 现状 | |---|---| | 工程名 / 包名 | `SmartRingDCLT_SDK` / `com.sr.smartring` | | 可复用核心 | `BluetoothLeClass`(BLE 扫描/连接/GATT 收发,**仅依赖 Android 框架,零第三方依赖**) | | 协议收发 | 写在厂商 `MainActivity` 的静态方法:`sendBleData` / `recvBleData` / `recvBleWriteData`,帧格式 `DC 0001 LT`(下行)、`DC 0002 …`(上报) | | 辅助类 | `HEROUtils`(OTA 下载 + CRC32)、`Utils`、`BluetoothApplication`、`LeDeviceListAdapter` | | 技术栈 | Java + Android **Support Library(非 AndroidX)** + EventBus 3 + Gradle 6.7.1 / AGP 4.2.2(很旧) | | 协议文档 | `鑫域隆智能戒指协议.txt`(GBK 编码,含全部指令码) | **戒指能力(来自协议文档,决定我们能做哪些页面)**: 计步、心率、血氧、睡眠、血压、体温、压力、运动检测、查找戒指、电量、佩戴/充电状态、OTA 升级、自动监测开关配置、时间同步。 **复用策略**:SDK 内容**不修改**。M2 接 BLE 时,把 `BluetoothLeClass` 等纯通讯类**原样拷贝**进 App 的 `vendor/` 包(保持 `com.sr.smartring` 包名与代码原样),我们只**新写一层适配器**去调用它。 UI 永远不直接依赖厂商代码。 ## 3. 仓库与目录结构 遵循团队 `docs/` + `repos/` 惯例;仓库名 kebab-case,移动端 `-app`、后端 `-api`。 ``` d:\cherry ├── SmartRingF01_SDK/ # 厂商原始工程(只读参考,不纳入任何 git 仓库) ├── docs/ # 项目文档根(独立 git 仓库) │ └── superpowers/specs/ # 设计/技术文档 └── repos/ ├── ring-health-app/ # Android App(Kotlin + Compose) ← 阶段一主战场 └── ring-health-api/ # 后端 API ← 阶段一仅脚手架 + 接口契约 ``` - `ring-health-app`:阶段一全部实现在此。 - `ring-health-api`:阶段一**只放空骨架(README + gitignore)与 `docs/api-contract.md` 契约草稿**, 不写业务接口。后端技术栈(Spring Boot / .NET)待 M4 真正做云同步时再定。 ## 4. App 技术选型 | 项 | 选择 | 理由 | |---|---|---| | 语言 | Kotlin | Compose 唯一舒适选择 | | UI | Jetpack Compose + Material 3 | 写卡片/图表类仿小米页面效率最高 | | 导航 | Navigation-Compose,单 Activity | 多 Tab + 深层子页标准做法 | | 架构 | MVVM + `StateFlow`(`UiState`) | 官方推荐,易测试 | | 异步 | Coroutines + Flow | — | | 图表 | **Compose Canvas 手绘**(今日大环、柱状/折线占位) | 阶段一不引第三方图表库(YAGNI) | | DI | 手动组合根(`Application` 中构造仓库) | 阶段一无需 Hilt,后续可平滑替换 | | 构建 | **Gradle 8.14 + AGP 8.13.1** + JDK 17 + `libs.versions.toml` | 本机已缓存 Gradle 8.14,免联网下载。~~AGP 8.11.x~~ 已按 R5/R6 改为 **8.13.1**(见下方注记) | | SDK | `minSdk 26` / `targetSdk 36` / `compileSdk 36`,build-tools **36.1.0** | 对齐厂商 minSdk(26);~~35~~ 已按 R5 改为 **36**,与本机已装 platform/build-tools 一致 | > **注记(2026-09-10,M1 实施后回填):** 上表两行原写 AGP 8.11.x / compileSdk 35 / build-tools 35.0.0, > 与最终交付的 `app/build.gradle.kts` 不符 —— **代码是对的,是本文档当时没跟着更新。** > 实际工具链:AGP **8.13.1**、compileSdk/targetSdk **36**、buildToolsVersion **36.1.0**、minSdk 26、 > Kotlin 2.2.20。下调理由见 Ruling R5(工具链以本机已装版本为准)与 R6(越界依赖下调至工具链可接受的最新版本), > 完整证据链写在 `gradle/libs.versions.toml` 顶部的注释里。 > 另外 `compose-bom` 用的是 **2026.06.01**(compose 1.11.4),不是 2026.09.00 —— 那组版本要 > compileSdk 37 + AGP 9.1.0,与本工具链互斥。 **阶段一依赖清单(刻意精简)**:`androidx.core:core-ktx`、`androidx.activity:activity-compose`、 `androidx.lifecycle:lifecycle-runtime-compose` / `lifecycle-viewmodel-compose`、 `androidx.navigation:navigation-compose`、`androidx.compose:compose-bom`(ui / material3 / material-icons-extended / ui-tooling-preview)、`kotlinx-coroutines`。 **不引** Hilt、Room、Retrofit、第三方图表库。 ## 5. App 架构分层(单 `:app` 模块 + 严格分包) ``` com.placeholder.ringhealth ├── RingHealthApplication.kt # 组合根:手动构造 Repository,注入 ViewModel ├── MainActivity.kt # 唯一 Activity,承载 Compose NavHost ├── core/ │ ├── designsystem/ # Theme/Color/Type/Shape + 通用组件 │ │ └── component/ # HealthCard、RingProgress、MetricTile、SectionHeader… │ ├── common/ # 格式化与扩展(步数、时长、心率区间) │ └── navigation/ # Route 常量与 NavGraph ├── domain/ │ ├── model/ # 纯 Kotlin 数据模型(无 Android 依赖) │ └── repository/ # 仓库接口(UI 只依赖这里) ├── data/ │ └── mock/ # Mock 实现 + 造数器(阶段一) │ # M2 将新增 data/sdk/ 作为厂商 SDK 适配层 ├── feature/ # 每个 Tab / 子页一个包:Screen + ViewModel + UiState │ ├── home/ workout/ device/ profile/ detail/ └── vendor/ # 预留:M2 原样存放厂商 com.sr.smartring 通讯类(不改一行) ``` **核心边界原则**:UI(`feature/*`)**只依赖 `domain/repository` 的接口**与 `domain/model`。 阶段一注入 Mock 实现;M2 新增 SDK 适配实现,接口不变则 UI 零改动。 ### 5.1 关键接口(阶段一定稿) ```kotlin // domain/repository interface RingDeviceRepository { val deviceState: StateFlow // 连接态/电量/型号/固件版本 val functionSettings: StateFlow> // 功能开关(见 §6.3) suspend fun refreshDeviceState() fun setFunctionEnabled(id: String, enabled: Boolean) } interface HealthDataRepository { fun observeTodaySummary(): Flow fun observeMetricHistory(metric: MetricType, range: DateRange): Flow> fun observeSleepSessions(range: DateRange): Flow> // 见 §6.2 fun observeWorkouts(): Flow> } // domain/model(节选) // switchId 为 null 表示「常开」(计步、体温),见 §6.3 enum class MetricType(val switchId: String?) { STEP(null), HEART_RATE("heart_rate"), SLEEP("sleep"), BLOOD_OXYGEN("blood_oxygen"), STRESS("stress"), TEMPERATURE(null), BLOOD_PRESSURE("blood_pressure"), } data class RingDeviceState( val connected: Boolean, val name: String, val batteryPercent: Int, val firmwareVersion: String, val charging: Boolean, ) 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?, // 血压未开启时为 null ) data class MetricPoint(val timestamp: Long, val value: Double) /** 一晚睡眠。睡眠占的是一段区间,MetricPoint 装不下,所以单独建模(§6.2)。 */ data class SleepSession( val wakeDate: Long, val sleepStart: Long, val sleepEnd: Long, val deepMinutes: Int, val lightMinutes: Int, val awakeMinutes: Int, ) ``` 约定:`ViewModel` 用 `stateIn` 把 Flow 转成 `StateFlow`;`null` 表示"该指标未开启/无数据", UI 统一走"未开启占位态",不显示 0。 ## 6. 页面地图与路由 底部四 Tab(仿小米运动健康),并按戒指能力裁剪: | Tab | 页面内容 | 路由 | |---|---|---| | **① 健康·首页** | 顶部问候 + 设备连接状态条;今日大环(步数/卡路里/距离);指标卡片网格:**心率 / 睡眠 / 血氧 / 压力 / 体温**(血压显示"未开启/需校准"占位) | `home` | | **② 运动** | "开始运动"入口(跑步/骑行/自由训练占位)+ 运动历史记录列表(静态) | `workout` | | **③ 设备** | 戒指卡片(名称/电量/连接态/固件)+ 设置列表:健康自动监测开关(心率/血氧/血压/压力/睡眠,对应协议 0502 开关位)~~、查找戒指(050B)、智能提醒/闹钟占位、**固件升级占位**(OTA 17xx)、佩戴检测~~ | `device` | | **④ 我的** | 头像/昵称(登录占位)、App 设置(单位/通知)、通知权限引导、关于/检查更新、帮助与反馈 | `profile` | **详情子页**(全部静态 + Canvas 图表占位): | 路由 | 内容 | |---|---| | `detail/step` | 今日步数详情:大环 + 7 天/月切换柱状图 + 说明文案 | | `detail/heart_rate` | 心率详情:当日折线/区间统计(静息/运动)+ 历史区间 | | `detail/sleep` | 睡眠详情:深睡/浅睡/清醒分段条 + 入睡/起床时间 + **睡眠时段图**(§6.2) | | `detail/blood_oxygen` | 血氧详情:趋势折线 + 正常范围说明 | | `device/settings` | 戒指功能开关详情(静态开关,M2 后写向设备) | | `workout/history` | 运动历史列表 | > **M1 实际交付与上表的差异(2026-09-10 回填,全分支终审发现):** > 上面 `detail/*` 的**四条路由实际合并成了一条**:`detail/{metric}`(`Routes.DETAIL_PATTERN`, > 参数为 `MetricType.name`),七个指标共用同一个模板 —— 柱状趋势图 + 周/月切换 + 平均/最高/最低。 > **仅睡眠保留了专属区块**(`SleepStagesCard`:深睡/浅睡/清醒分段条 + 入睡/起床时间)。 > **未实现的是:** 步数的大环、心率的折线/静息-运动区间、血氧的正常范围说明文案。 > **同日晚已关闭这条差异**:七个页面各自独立成文件并各自专属化,见 §6.1;睡眠另加时段图,见 §6.2。 > 收敛本身是有意的(避免七套几乎相同的页面代码),但当时没留下裁定记录 —— 这不妥, > 此处补记,供后续决定是否回归为专属设计。**这是 M1 已知的、有意的范围收窄,不是漏做。** > > **该悬案已关闭(2026-09-10 晚,见下方「详情页专属化」,§6.1)。** 结论是**回归专属设计**, > 但拆法与上表原来的设想不同:不是「一页一条路由」,而是共用一条路由、七个页面各自实现内容。 > > **另一处当时没被记录的差异:首页顶部的「问候 + 日期」在 M1 根本没做。** > 上表白纸黑字写了「顶部问候」,交付的首页却是从设备状态条直接开始的 —— > 而且这条差异在终审时也没被发现,是后来做视觉改版时才补上的(`greetingResFor` / > `weekdayResFor`,按 5/12/18/23 点四档给「早上好 / 下午好 / 晚上好 / 夜深了」)。 > 记在这里是因为**它暴露了验收方式的问题**:M1 的验收逐条核对了「页面在不在、能不能点」, > 却没核对「页面上该有的东西在不在」—— 纯静态的文案块最容易这样漏掉。 **明确不做**(小米有但我们戒指不支持):跑步课程、商城、社区、多设备接入、体重秤/体脂秤。 ### 6.1 详情页专属化(2026-09-10 晚,关闭上面的悬案) **动因。** M1 收敛成一套模板后,验收时的原话是「**每个模块点开都差不多,只有颜色不一样, 能不能有特点一点**」。这不是观感偏好 —— 七个页面共用「一张柱状图 + 平均/最高/最低」, 等于七个页面都只回答了同一个问题(「这几天怎么变的」),而**每个指标真正要回答的问题并不相同**。 **方案:一页一个文件,共用路由。** ```text feature/detail/ ├── MetricDetailScreen.kt # 骨架:TopAppBar + 周/月切换 + 空态 + 按 metric 分派 ├── MetricDetailShared.kt # 只放「每页都用且不因指标而变」的东西(成色、数值口径、轴刻度) ├── MetricStats.kt # 统计口径纯函数(+ 单测) └── pages/ # 七个指标各一个文件,每个是一页的全部内容 ├── StepDetail.kt HeartRateDetail.kt SleepDetail.kt BloodOxygenDetail.kt ├── StressDetail.kt TemperatureDetail.kt BloodPressureDetail.kt ``` 每个 `pages/*.kt` 导出 `internal fun LazyListScope.xxxDetail(state)`,自己决定往列表里放几张卡。 骨架里的 `when (metric)` 是唯一知道「有哪些页面」的地方。**改一页不必碰其他六页。** **七个页面各自的「主张」**(这是本次改版的实质,不是换皮): | 页面 | 这个指标真正要回答的问题 | 头图 | 趋势图 | |---|---|---|---| | 步数 | 今天走够了没有 | 进度环 + 完成度 % | 柱状图 + **目标线**(不是均值线) | | 心率 | 这个数算不算正常 | 大字 + **区间条**(40–120 轴,正常带 60–100 高亮) | 柱状图 + **正常区间带** | | 睡眠 | 昨晚这一觉睡得怎么样 / 我每天都几点睡 | 时长 + **分期条 + 各期时长** + 入睡/起床时刻 | **睡眠时段图**(每晚一行,横轴是钟点) | | 血氧 | 有没有掉到线下 | 大字 + 达标/偏低判定 | **阈值带**(点阵 + 95% 阈值虚线,线下标红) | | 压力 | 这几天是紧还是松 | 大字 + **等级名** | 柱状图**按等级着色** + 四色图例 | | 体温 | 比自己的基线高了还是低了 | 大字 + **较基线 ±x.x°C** | **偏离条**(基线居中,上下偏离的棒棒糖) | | 血压 | ——(阶段一该功能未开启) | 空态说明 | 不画图 | 为此新增三个 Canvas 图元(`core/designsystem/component/`):`MetricRangeBar`(区间条)、 `MetricThresholdStrip`(阈值带)、`MetricDeviationBar`(偏离条);`MetricBarChart` 增两个参数 `valueRange`(取景窗口)与 `bandRange`(正常区间带),`average` 泛化成 `referenceLine`。 **顺带修掉的两个真实缺陷**(都是截图时看出来的,不是预先想到的): 1. **窄区间指标的柱子全一样高。** `MetricBarChart` 原来按 `value / maxValue` 定高, 血氧(95–99 → 比值 0.96–1.0)与体温(36.1–36.9 → 0.978–1.0)的七根柱子因此齐平, 整张图没有任何信息量 —— 这不是「不好看」,是数据被抹平了。加 `valueRange` 取景窗口解决。 (代码里原本有一条预览注释已经指出过同类问题,但修复只应用到了预览数据,没到真实范围。) 2. **头图的「当前值」与趋势图最后一根柱子对不上。** 概览的 `todaySummary()` 是固定演示值、 历史点由另一套随机序列生成,于是出现「头图写心率 72、图上今天却是 96」这种自相矛盾, 而且两个数字看起来都很确定。改为 `metricHistory` 的**末点值取自 `todaySummary()` (`HealthSummary.valueOf(metric)`)**,并加了单测钉住。 **血压页为什么不画图。** 血压是演示数据里唯一 `bloodPressure = null`(未开启)的指标, 首页那张卡据此渲染成「未开启」。若详情页画出一张完整趋势图,两页自相矛盾。 注意 `metricHistory(BLOOD_PRESSURE)` 仍会返回 110–130 的演示点 —— 本页刻意不读它。 M3 接入真实血压数据后,本页整体改为双值(收缩压/舒张压)详情页, `HealthSummary.bloodPressure` 也从字符串改为结构化的两个数值。 **这张卡必须能点开(同日晚修正)。** 第一版为了堵上面那个矛盾,顺手让详情页按 「有没有数值」决定挂不挂 `clickable` —— 血压没有数值,于是不可点击。这堵过头了: 「未开启 + 怎么开启」的说明页因此**一个入口都没有,成了死代码**,用户也无从知道 该怎么把功能打开。矛盾的根源是详情页画了图,不是卡片可点击,所以改为**由本页不画图来解决**, 卡片恢复可点击;`enabled = false` 仍保留(数值位显示的是「未开启」三个字,不该用大号粗体, 看着比真实数值还重)。同理,本页不显示周/月切换 —— 那两个 chip 点了不会有任何东西跟着变。 ### 6.2 睡眠时段图(2026-09-10 晚,追加) **动因。** 睡眠页当时只有「最高/最低时长」两个数,用户的原话是 「**只有最高时间最低时间,但我不知道一天内具体什么时候睡的觉**」。 柱状图只画「睡了多久」,七根柱子一样高的时候看不出作息是不是一天天往后拖 —— 而「几点睡」恰恰是睡眠这个指标最直接的问题。 **方案:`SleepSession` 单独建模,不复用 `MetricPoint`。** ```kotlin data class SleepSession( val wakeDate: Long, val sleepStart: Long, val sleepEnd: Long, val deepMinutes: Int, val lightMinutes: Int, val awakeMinutes: Int, ) ``` `MetricPoint` 只装得下一个 `Double`,而一晚睡眠占的是**一段区间**。硬塞进去的话 「入睡时刻」无处可放,只能像改版前那样拿「起床时刻往前推 [时长]」凑一个 —— 那份凑出来的时刻与时长永远自洽,因此永远看不出是凑的,但它不对。 `HealthDataRepository` 增加 `observeSleepSessions(range)`,与 `observeMetricHistory` 同口径 (WEEK 七晚 / MONTH 三十晚)。 **只有一个数据源。** `todaySummary().sleepMinutes` 取自最后一个会话, `metricHistory(SLEEP)` 也由各会话折算成点(`value = sleepMinutes`)。 此前睡眠页上「头图 7 小时 32 分」和「图上另一根长度」是可以并存的 —— 同一屏里最显眼的两块内容各说各的。分期条的权重同样取自会话 (深睡 96 / 浅睡 356 / 清醒 22,合计即卧床时长),不再是写死的 25% / 65% / 10%。 **`SleepWindowChart`**(`core/designsystem/component/`):每晚一行, 一根圆角条从入睡横到醒来,横轴是钟点,左侧行标签(近 7 天用星期、近 30 天用日期)。 三个决定值得记下来: 1. **横轴锚在 20:00。** 一晚从 23:43 到 07:37,用「当天第几分钟」表示会得到 1423 → 457 这样一对反过来的数,画出来入睡点跑到起床点右边。`minutesSinceSleepAnchor` 以 20:00 为界重新起算,两个时刻都落在 0–960 内。**不能用 `timestamp % 86_400_000`** —— 那是 UTC 的余数,在东八区会把整张图平移八小时,而平移后每根条看着都还挺合理。 2. **轴范围由数据算,不写死。** 写死「20:00–12:00」的话,某晚熬到中午才睡, 那根条会被右端裁掉一截,看起来像睡得短。取整到最近的刻度线(2/3/4/6 小时, 取第一个能让刻度不超过 8 个的),刻度因此均匀铺满整条轴。 3. **只用一个颜色,不画分期。** 手头只有「深睡共 96 分钟」这样的累计时长, 没有「第 47 分钟进入深睡」这种分期序列。按比例铺成色块画的是**一段并不存在的分期位置**, 比不画更容易让人当真。M3 接入真实分期序列后再谈分期着色。 **刻度文字画在 Canvas 里(`TextMeasurer`),没有另起一行等宽 `Text`。** 等宽文本框的中心落在 `(i + 0.5) / n`,刻度线落在 `i / (n - 1)`,中间几档会差出小半个格。 量一次宽度再居中,文字和网格线用的是同一个 `xOf`。首尾两档贴边 —— 居中的话各有一半落在画布外。 **睡眠页不再画柱状图。** 柱子高度是时长,时段图的条长同样是时长,两张图讲同一件事。 「目标 8 小时」原本是柱状图那条参考线的附注,改挂到头图右侧 —— 目标本身仍有用, 不该跟着那张图一起消失。 **遗留:** 分期时长(深睡/浅睡/清醒)本身仍是 Mock 按比例生成的演示值, 但**生成的位置从渲染层挪到了数据层**(`MockDataProvider.sleepSessions`), M3 接入真实分期时只需换掉数据源,UI 与统计口径一行不用改。 `HealthSummary.valueOf` 对 `BLOOD_PRESSURE` 返回 null,同样等结构化的血压字段。 ### 6.3 功能开关与健康数据的联动(2026-09-10 晚,修 bug) **现象。** 用户在「设备 - 功能设置」里打开「血压监测」,首页那张卡纹丝不动。 **根因不是漏了一个刷新,是两套系统。** `DefaultAppContainer` 里 `ringDeviceRepository` 与 `healthDataRepository` 是两个互不引用的字段, 健康数据由写死的常量生成,压根不认识开关。「开关 id 是 `blood_pressure`、 枚举是 `BLOOD_PRESSURE`」这个对应关系只存在于**字符串长得像**这个巧合里, 没有一行代码声明过它们是同一件事。同类症状还有一处:压力开关默认关着, 而 `todaySummary()` 一直给出 `stress = 34`。 **修法:让开关成为首页数据的唯一真相来源。** - `MetricType` 显式带上 `switchId: String?`,**null 表示常开**(计步与体温由戒指持续采集, 没有「关掉」这个选项)。`enabledMetrics()` 由它算出「当前开着哪些指标」, 常开指标在这里兜底 —— 若按「找不到对应开关就算关」处理,一个空开关列表会让步数和体温 凭空变成「未开启」,而戒指一直在数着步。 - `MockHealthDataRepository` 订阅 `functionSettings` 这个 `StateFlow`, `todaySummary` / `metricHistory` / `sleepSessions` 全部改为**接受已开启指标集**。 `todaySummary(enabledMetrics)` **刻意不给默认值** —— 默认「全都开着」会让漏传参数的调用方 看起来一切正常,而那正是这次要修的病。 - 关掉的指标在概览里是 `null`,首页那张卡显示「未开启」。 **顺带纠正的两处。** - **详情页对未开启的指标不画图。** 此前未开启的指标只是首页显示「未开启」, 点进详情页照样画出七天的随机数据 —— 一段并不存在的监测记录,比空图更有误导性。 现在整页走共用的 `MetricDisabledCard`(图标与颜色取自 `MetricVisuals`, 与首页那张卡完全一致,用户才知道自己没点错),周/月切换一并隐藏 —— 两个点了不会有任何东西跟着变的 chip 比不显示更让人困惑。 - **血压卡片恢复可点击。** 第一版为了堵「首页说未开启、详情页画图」这个矛盾, 顺手让详情页按「有没有数值」决定挂不挂 `clickable`,于是血压页**一个入口都没有,成了死代码**。 矛盾的根源是详情页画了图,不是卡片可点击,所以改由「本页不画图」来解决。 **未修的(已知,本次不动):** 「设备」页与「更多设置」页渲染的是同一份 `functionSettings` (同一批开关出现在两个地方,两个页面各自维护一套列表 UI);运动页的「最近运动」与 「运动历史」内容完全相同;「我的」页四个入口均为死链接。三项都已报告,未在本轮处理。 ## 7. 设计系统与视觉 > **方向变更(2026-09-10,M1 真机验收后)。** 本节原始表述为「仿小米运动健康:浅色为主, > 白底卡片 + 大圆角 + 轻阴影」。该方向在验收时被否,原话是「**我不想像小米我想更好看, > 我觉得现在的页面不好看**」。**「像小米」从此不再是目标,也不再是验收标准**; > 目标改为「用同一套视觉语言,做得比小米干净」。下面 §7.1–§7.4 是改版后的实际口径, > 其中每一条都对应当时截图上肉眼可见的一个问题 —— 原表述没能防住它们。 ### 7.1 视觉口径(改版后) - **两层底色,不是一层。** 页面底 `#EEF1F6`(浅灰蓝),卡片 `#FFFFFF`,分隔线 `#EFEFF4`。 原表述只说了「白底卡片」,没说页面底是什么 —— 实现时页面底取了近白的 `#F5F6F8`, 与卡片几乎同色,卡片边界只剩一层几乎看不见的阴影在撑,整屏糊成一片白。 **底色必须与卡片拉开可见差距,卡片才立得起来。** - **卡片用真阴影,不是 M3 `Card` 默认的 tonal elevation。** 后者的 elevation 走的是 「给表面叠一层主色调」而不是投影,在浅灰底上表现成一团脏灰。现改为 `Surface` + `Modifier.shadow(5dp, 20dp 圆角, ambient/spot 双色)`。 **彩色阴影只在 API 28+ 生效,minSdk 26 会退化成黑色 —— 可接受,不为此提高 minSdk。** - **颜色是有含义的,不能当装饰用。** 每个健康指标一个专属色(§7.2); 非指标的次要读数(距离、千卡)一律深色数值 + 灰单位,**不上色** —— 否则用户会以为「蓝色」代表某种指标身份,点上去却什么也没有。 - **大数字用等宽数字(`fontFeatureSettings = "tnum"`)**,否则数值刷新时字宽跳动、整行抖动。 ### 7.2 指标色板 步数=绿、心率=红、睡眠=蓝、血氧=青、压力=紫、体温=橙、血压=黄(`core/designsystem/Color.kt` 统一定义)。 **小字号绿字单列一色** `BrandGreenText #12894A`:品牌绿 `#34C759` 在 12sp 文字上对白底 只有约 **2.0:1** 对比度,远低于 WCAG AA 要求的 4.5:1 —— 小字用它读不清,故另立一色专供文字。 > **踩过的坑(M1 验收时被当成正常现象记录了下来):** Material 3 的 `secondaryContainer` > **同时**是「底部导航选中态药丸底」和「FilterChip 选中底」两处的取色来源。 > 主题里不显式覆盖它,就会落到 M3 默认的淡紫 `#E8DEF8`,于是出现 > 「**淡紫底 + 绿色文字**」的撞色。现已在 `Theme.kt` 中显式指向浅绿 `NavIndicator`。 ### 7.3 组件清单(`core/designsystem/component/`) `HealthCard`(白卡 + 真阴影)、`RingProgress`(Canvas 环形进度)、 `MetricTile`(2 列网格用的紧凑指标卡:色底图标徽章 + 数值 + 单位)、 `IconBadge`(圆形色底图标,首页指标卡与「我的」页共用)、`SectionHeader`、 `StatBar`(睡眠分段条)、`MetricBarChart`(Canvas 柱状图)、`SubPageTopAppBar`(子页顶栏)。 详情页专属化(§6.1)新增四个,都是「某个指标要回答的问题」缺一个图形语言时补的: | 组件 | 图形语言 | 用在 | |---|---|---| | `MetricRangeBar` | 横向轴 + 高亮的正常区间 + 一个带白环的当前值圆点 | 心率 | | `MetricThresholdStrip` | 点阵 + 阈值虚线,越线的点加警示色光晕 | 血氧 | | `MetricDeviationBar` | 基线居中的棒棒糖图,向上/向下分别着色 | 体温 | | `ChartAxisLabels` | 不是图元,是三个图共用的 X 轴标签行 | 全部 | > `ChartAxisLabels` 的存在理由:X 轴标签要和柱子**逐列对齐**,所以「标签数 == 柱数时按 > `weight` 等分、否则 `SpaceBetween`」这段判断每个图都得写一遍。抽出来后三个图只调一次, > 也保证了三张图的日期对位规则不会各走各的。 睡眠时段图(§6.2)另加一个: | 组件 | 图形语言 | 用在 | |---|---|---| | `SleepWindowChart` | 每晚一行,圆角条从入睡横到醒来,横轴是钟点(锚在 20:00,跨零点) | 睡眠 | > 它**不复用 `MetricBarChart`**:后者的横轴是「第几天」,槽位等分、每根柱居中, > 而时段图的横轴是**连续的钟点**,一根条的位置本身就是数据。两者只有配色可以共用。 > 唯一的例外是刻度文字 —— 时段图的刻度画在 Canvas 内(`TextMeasurer` 量宽后居中), > 因为它必须和竖向网格线共用同一个坐标换算,见 §6.2。 **四条组件级约定,都是改版时踩出来的:** - **子页顶栏底色取 `background`,不取 `surface`。** M3 `TopAppBar` 的默认容器色是 `surface`(本应用里是纯白),而页面底是浅灰 —— 两者不同色时,顶栏会在浅灰页面上 糊出一条白色横带,同时状态栏那一条仍是页底色,顶部于是出现「**灰、白、灰**」三层。 三个子页统一改用 `SubPageTopAppBar`。 - **`MetricBarChart` 必须画基线、均值参考线与 X 轴刻度。** 改版前它只有一个 `SectionHeader` 加一排浮空的胶囊柱:**没有基线**(柱子悬空)、**没有刻度** (看不出哪根是哪天)、**没有参照**(读不出「今天比平时高还是低」)。三者补齐后这张图 才开始承载信息。参考线用**中性灰**而非柱色 —— 跟着柱色走会被当成「第 8 根柱子」。 - **窄区间指标必须给 `valueRange`,否则柱子会被抹平。** 柱子按 `value / max` 定高, 血氧(95–99)与体温(36.1–36.9)的七根柱子因此几乎齐平,**整张图没有信息量**。 这不是审美问题,是数据被压没了。`valueRange` 把取景窗口收窄到数据的实际活动范围; 但**睡眠与步数不能收**——「睡了 3 小时」相对「睡了 8 小时」确实只有 3/8 高, 这个比例本身有含义,所以它们的下界仍是 0。 - **统计数值的小数位按指标分别定。** 此前一律走 `"%.1f"`,心率显示成「78.0」、 步数显示成「8426.0」—— 尾随的 `.0` 是**假精度**,看着像仪器精度比实际高一个数量级。 现在只有体温保留一位(36.5 与 36 的差别有实际意义),其余取整并补上单位。 ### 7.4 其他 - 深色模式:阶段一**仅保证浅色**可用,深色留到 M5。 - 所有中文字符串进 `strings.xml`;**禁止硬编码文案**。多语言排在 M5(见下方注记 3)。 - 网格布局:首页六张指标卡用 `LazyVerticalGrid` 两列 —— 原先单列时每张卡被拉到整屏宽 而高只有 180px(宽高比约 6:1),横向大片留白、纵向被撑开,六个数字要滚三屏才看得完。 > **M1 实施状态注记(2026-09-10,全分支终审后回填)—— 与上文不符的实际交付:** > > 1. **设备页删减了四项:** 查找戒指(050B)、智能提醒/闹钟占位、固件升级占位(OTA 17xx)、佩戴检测, > M1 **未实现**(见 Ruling R27,并已同步删除对应的 8 条字符串资源)。理由是阶段一没有真实协议可对接, > 摆一排点不动的入口不如不摆。**这些能力仍属于产品范围,将随后续里程碑回归**;本文档上表已用删除线标注。 > 2. **详情页由「四个专属设计」收敛为一个模板** —— 详见**上方「详情子页」表下的注记**。 > 3. **多语言**:`strings.xml` 目前**只有中文**,没有 `values-en/`。本文档 > 「附英文」与 M5 把多语言排期两处自相矛盾;以 M5 为准,M1 不做。 > 4. **详情页仍缺三项已规划的内容**(§6 注记已列):步数的大环、心率的折线/静息-运动区间、 > 血氧的**正常范围说明**。本次改版给柱状图补了刻度与参考线,但「这个值算不算正常」 > 仍需按指标给出参考区间才能回答 —— 那属于领域数据(应来自 `domain/`,不该写死在 > UI 层),留到 M3 与真实数据一起做。 ## 8. Mock 数据策略 - `data/mock/MockDataProvider.kt` 提供**确定性**演示数据(固定随机种子),保证每次运行截图一致、便于展示。 - Mock 仓库模拟轻微异步延迟(~200ms),让加载态/占位骨架在阶段一就能跑通。 - 开关类设置的状态用内存态(`StateFlow`)保存,重启重置;**阶段一不引入持久化**。 ## 9. API 仓库(阶段一) `ring-health-api` 阶段一只包含: ``` ring-health-api/ ├── README.md # 说明:后端栈待定,M4 落地 ├── .gitignore └── docs/api-contract.md # 接口契约草稿(登录、设备绑定、健康数据同步的字段雏形) ``` 契约草稿仅为对齐未来前后端边界,**不实现**。后端技术栈(Spring Boot / .NET)在 M4 前单独定。 ## 10. 里程碑与验收 | 阶段 | 内容 | 验收标准 | |---|---|---| | **M0 基建** | 建 `docs/`、`repos/` 目录;初始化两个 git 仓库;gitignore/README | 两仓库结构就绪,符合命名规范 | | **M1 静态 App(阶段一)** | Compose 工程骨架、主题、四 Tab 导航、本文档第 6 节全部页面、Mock 仓库 | 真机/模拟器可跑;四 Tab 与全部子页可切换;无崩溃;`assembleDebug` 通过 | | **M2 SDK 接入** | `vendor/` 原样放厂商通讯类;新增 `data/sdk/` 适配层实现既有接口;BLE 权限、扫描、连接 | 真机扫描到戒指、连接成功、读到电量/固件 | | **M3 数据打通** | 协议解析(帧 `DC…LT`)、Mock→真数据、ROOM 本地存储、真实图表 | 首页与详情显示真机数据 | | **M4 云端/账号** | 定后端栈、实现登录与同步接口、App 接入 | 接口联调通过 | | **M5 发布** | 签名、图标、深色模式、多语言、内测包 | 可分发 APK | **本次实施范围 = M0 + M1。** ## 11. 风险与决策记录 | 风险/决策 | 说明与对策 | |---|---| | 厂商 SDK 老旧(Support Library / EventBus)与 Compose 工程共存 | `BluetoothLeClass` 零第三方依赖,风险低;M2 隔离在 `vendor/` 包内,仅当必需才考虑 jetifier | | 占位命名需全局替换 | 替换清单见 §12;集中一处记录,替换时按清单执行 | | BLE 权限(Android 12+) | M2 统一处理 `BLUETOOTH_SCAN/CONNECT` + 位置权限与引导 UI,阶段一不涉及 | | 手绘 Canvas 图表工作量 | 阶段一只做环形 + 简单柱/折线,避免过度实现;复杂图表留 M3 | | 后端栈未定 | 有意延迟到 M4,避免两仓库空转 | | 本机构建环境版本 | 已探明:JDK 17、Android SDK(platform 34/35/36)、build-tools **仅 35.0.0 / 36.1.0**、Gradle 缓存 8.12/8.14、无全局 gradle。故工具链**钉死在已安装版本**,避免构建时联网拉取缺失组件 | | 视觉方向定为「**不仿小米**」 | 2026-09-10 M1 真机验收后由需求方否决原「仿小米运动健康」方向。**「像小米」不再是目标,也不是验收标准**。改版后的实际口径见 §7,视觉审查一律以 §7 为准 | **已确认决策**:① 前后端分离,两个仓库;② Jetpack Compose;③ 阶段一单 `:app` 分层模块; ④ 四个 Tab 与全部详情子页都做;⑤ 后端阶段一只留契约;⑥ 占位命名后续统一替换; ⑦ 视觉方向**不仿小米**(见上表,改版口径见 §7)。 ## 12. 占位命名与替换清单 待正式产品名/域名确定后,需统一替换: | 位置 | 占位值 | |---|---| | 仓库名 | `ring-health-app`、`ring-health-api`(含 git 服务器上名称) | | `applicationId` / `namespace` | `com.placeholder.ringhealth` | | 包路径 | `com/placeholder/ringhealth` 目录树 | | `app_name`(strings.xml) | `RingHealth`(占位) | | 文档与 README 中的产品代号 | `ring-health` | 替换为一次性全局操作,不涉及逻辑改动;执行时按上表逐项核对。