Compare commits

..
15 Commits
Author SHA1 Message Date
cuiluzheng 6af1078ca5 fix: 首页三件套字号 30sp 改 26sp,label 含单位避免窄格里换行
第一版 value 行 + 单位同行 + 30sp 字号在窄格里换行成两截:「8,426」一行 + 「步」一行,「327」+「千」+「卡」也各换一行,「47」+「分钟」同样问题 —— 整张图被切成 6 段,视觉上不像三件套、像六个数。

改法:label 含单位(卡路里(千卡)、活动(分钟)),value 行只摆数字,字号降到 26sp —— 三件套格子约 110dp 宽,26sp + 5 位数字 100,000 装得下,再留余量给目标那行小字。

KDoc 注释一并更新,标注 M1 验证阶段肉眼发现的坑。
2026-09-11 09:52:09 +08:00
cuiluzheng 841f9f1542 feat: 四个 Tab 收口 — 首页三件套 / 运动类型网格 / 设备空态 / 我的四个真子页
PROGRESS.md §3.2 报告的三个结构性问题(设备页 + 更多设置重复、运动页两个入口同数据、「我的」四个死链接)本批一并收口。用户原话:「现在就是要 APP 页面完整成熟,后期再做后端接口,我的任务只是前端」。

首页(HomeScreen)
- HealthSummary 加 activeMinutes: Int(M3 接 SDK 后由真实活动汇总接口填入)
- MockDataProvider 演示值 47 分钟
- 删 StepsCard(步数大环 + 距离/千卡 横排),换为 TodaySummaryCard:横排 3 列「步数 / 卡路里 / 活动时长」
- 三列等宽,无主导项,步数下挂目标;步数一格整格可点进详情
- 三件套单格内 30sp + tnum,宽字符数字不会跳
- 三列之间 1dp 浅灰竖线(不用 core 的 DividerColor,那个是卡片间分割线)

运动页(WorkoutScreen)
- 加 SportType 枚举(户外跑步 / 健走 / 户外骑行 / 室内跑步 / 跳绳 / 自由训练)
- 删 StartWorkoutCard,换为 SportTypeGrid:横排 6 个运动类型入口,可滑
- 每格:80dp 圆角方块 + 浅 MetricGreen 底 + 深 MetricGreen 图标 + 类型名下方
- 点任意类型仍弹 Snackbar「将在接入戒指后开放」(一致行为)
- 图标背景用 MetricGreen.copy(alpha=0.12f) 而不是小米那种饱和度极高的大色块 —— spec §7.1「颜色是有含义的不能当装饰用」

设备页(DeviceScreen)
- DeviceContent 加分支:!state.device.connected 时显示 DeviceEmptyState
- 空态:圆形 NFC 图标 + 「未连接戒指」+ 「连接戒指后即可查看电量、固件与监测设置」+ 「扫一扫」+ 「按型号添加」两按钮
- 已连态完全保留
- 加 Preview @Preview(name = "未连") 验证空态
- Mock 始终 connected,所以生产路径仍走已连;M2 接 SDK 后未连时自动落空态

我的(ProfileScreen + 4 个新子页)
- 4 个死链接(单位设置 / 通知权限 / 检查更新 / 帮助与反馈)全部挂上 onClick
- 加 4 条路由:PROFILE_SETTINGS / PROFILE_NOTIFICATIONS / PROFILE_ABOUT / PROFILE_HELP
- 新增 4 个子页:
  - SettingsScreen:单位卡(距离/体重,只读)+ 通知卡(健康/设备/营销三个 Switch,演示值)
  - NotificationsScreen:权限状态徽章 + 引导文案 + 「请求权限」+ 「前往系统设置」按钮
  - AboutScreen:App 版本 / 固件版本 / 设备型号 + 「检查更新」按钮(弹 Snackbar「已是最新版本」)
  - HelpScreen:4 条 FAQ 折叠卡(整卡可点切换展开)+ 「联系我们」邮箱卡
- tabRouteFor 把 4 条新路由归到 PROFILE Tab,子页栈顶时底栏仍高亮「我的」

校验
- ./gradlew :app:testDebugUnitTest → 44 个用例全绿
- ./gradlew :app:assembleDebug → APK 出包成功
- 装机截屏:PROGRESS.md 提到的 ringtest AVD 已不存在,现存的 Medium_Phone AVD 配错系统镜像路径(指向 android-36.1 但本机只装了 android-36),按 ledger Ruling R21 跳过运行时验证,交人工

未做(已记录的边界)
- 通知开关不持久化:阶段一切换只走 remember,离页即丢;M2 接 SDK 时换 StateFlow
- 「请求权限」不真发系统弹窗:阶段一按下后只切演示状态;M2 接 SDK 时用 NotificationManagerCompat.requestPermissions
- 「前往系统设置」不真跳:同上,阶段一只占位

——共 14 文件,+10 修改 / +4 新建。
2026-09-11 08:55:38 +08:00
cuiluzheng fba26fc372 feat: 睡眠详情页支持按分期染色(演示仅昨晚带分期序列)
- SleepSession 增加可选 stages: List<SleepSegment>?;为 null 走原行为
- SleepSegment(stage, startOffsetMinute, endOffsetMinute) —— 相对 sleepStart 的分钟偏移
- MockDataProvider.demoLastNight() 填入真实分期序列,深睡/浅睡/清醒各期时长与累计值严格对齐
- SleepWindowChart 新增 StageTints(deep, light, awake, track);stages 非空时画轨道 + 子矩形
- AWAKE 跳出 MetricBlue 色族用暖粉 Color(0xFFE8B4A8),中途醒显眼;track 用极浅蓝作 rail
- 头图分期条 / 图例自动跟着 AWAKE_TINT 走,两处口径一致
- 仅昨晚一条带 stages,其他 6 晚单色原样保留 ——
  这是为了演示视觉效果,不是生产数据形态;M2 接入真机后逐步补全其他夜晚
2026-09-10 17:25:48 +08:00
cuiluzheng 514e5ec9e2 feat: 详情页专属化 + 功能开关联动 + 睡眠时段图
三块改动同批落地,互相咬合(详情页未开启分支依赖开关联动,睡眠页依赖前两者),
拆开提交会得到编译不过的中间态,故合并为一次。

详情页专属化(spec §6.1)
- feature/detail/pages/ 七个指标各一个文件,各自决定放几张卡;
  MetricDetailScreen 只剩页头 + 周/月切换 + 按 metric 分派
- 新增 MetricRangeBar(心率区间条)、MetricThresholdStrip(血氧阈值带)、
  MetricDeviationBar(体温偏离条)、ChartAxisLabels(三图共用的 X 轴标签行)
- MetricBarChart 增 valueRange / bandRange,均值参考线泛化为 referenceLine
- 修:窄区间指标(血氧 95–99、体温 36.1–36.9)柱子被抹平成一样高,整张图无信息量
- 修:头图当前值与趋势图末点各生成各的,改为末点取自 todaySummary

功能开关联动(spec §6.3)
- MetricType 显式带 switchId,null 表示常开(计步、体温)
- MockHealthDataRepository 订阅 functionSettings,概览/历史/睡眠全部由开关算出
- 未开启的指标在概览里为 null;详情页整页走共用的 MetricDisabledCard 且不画图
- 修:血压卡因「未开启」不可点击,说明页成为死代码,用户无从知道怎么开启
- 修:压力开关默认关着而 todaySummary 一直给出 stress = 34

睡眠时段图(spec §6.2)
- 新增 SleepSession:MetricPoint 只装得下一个 Double,装不下「几点到几点」
- 新增 SleepWindowChart:每晚一行,圆角条从入睡横到醒来;横轴锚在 20:00 跨零点
  (不能用 timestamp % 86400000,那是 UTC 余数,东八区会整体平移八小时),
  轴范围由数据算出并取整到刻度线;只用一个颜色,不假装知道分期位置
- 睡眠页撤掉柱状图(条长即时长,与时段图重复),改画时段图 + 分期时长图例
- 今日睡眠时长、睡眠历史点、分期条全部由 SleepSession 派生,不再各生成各的

验证:44 个单测通过;装机核对近 7 天 / 近 30 天 / 关闭睡眠开关三种状态
2026-09-10 16:49:17 +08:00
cuiluzheng e5f935625e docs: 补录模拟器实测结果,关闭 M1 运行时验收缺口(含 consumeWindowInsets 像素级验证) 2026-09-10 14:14:18 +08:00
cuiluzheng 77de7808cb docs: 补充终审修复轮的验证状态与 M2 交接接缝 2026-09-10 13:37:15 +08:00
cuiluzhengandClaude Code 5fe0d4a420 fix: 修正终审发现的详情页配色、空态与血压卡可点击等问题
- 详情页柱状图改用指标语义色(新增 MetricType→色板映射),此前恒定绿色
- 详情页无数据时渲染空态文案,不再画全 0 柱状图与三行 0.0
- 首页「未开启」指标卡不再可点击,与详情页内容保持一致
- 压力卡单位位显示等级文案(如「34 正常」),接上原先无人调用的 stressLevelLabelRes
- NavHost 补 consumeWindowInsets,避免详情页 TopAppBar 重复叠加状态栏内边距
- README 注明 installDebug 需要设备/模拟器

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 13:18:51 +08:00
cuiluzhengandClaude Code 9c233e5750 docs: 补充 M1 验收记录与页面清单
Task 14 收尾(几乎不含生产代码改动):

- 清扫三条孤儿字符串:`placeholder_workout_history` / `placeholder_device_settings` /
  `placeholder_metric_detail`。grep 全 `app/src` 确认三者均无调用点后删除;
  顺带逐条核对了 `strings.xml` 其余字符串,除 manifest 引用的 `app_name` 外全部有调用点,
  未发现别的漏接线(不是顺手一起删,是先查清才决定)。
- 按 ledger R31,给 `MockRingDeviceRepository.setFunctionEnabled` 补中文 KDoc,
  写明「必须返回新列表」及其原因(`DeviceViewModel.stateIn(initialValue=…)` 在构造时
  冻结 List 引用,原地改写会让 `DeviceViewModelTest` 第一条用例退化成自我确认)。

验证:`./gradlew :app:testDebugUnitTest` 21 项全绿(FormattersTest 7 + MockDataProviderTest 5
+ HomeViewModelTest 1 + WorkoutViewModelTest 1 + DeviceViewModelTest 2 +
MetricDetailViewModelTest 3 + RoutesTest 2);`./gradlew clean assembleDebug` BUILD SUCCESSFUL,
产物 app-debug.apk(18,907,329 字节)。

新增 `docs/verification-m1.md` 记录 M1 验收。**人工验收全表标注「未执行 —— 无设备」**:
本机无模拟器镜像、无连接真机(`adb devices` 为空;`~/.android/avd` 下两个 AVD 只是指向
已删除旧 SDK 的空壳,不可启动),IDE Preview 亦无法打开。记录中含覆盖缺口声明:
M1 全部运行时行为目前只有编译期与单元测试保证,`assembleDebug` 成功不代表界面正确或不会崩溃。

README 补 M1 页面清单表,路由与 `core/navigation/Routes.kt` 逐条核对一致;
并核对 `profile_check_update_desc` 的手抄版本号 `0.1.0` 与 build.gradle.kts 的
`versionName` 一致(计划 3682 行交办项)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 12:54:40 +08:00
cuiluzhengandClaude Code 0e17502334 fix: 重写详情页 ViewModel 用例使其具备鉴别力并修正 Preview 夹具
审查指出第 3 条用例的唯一断言与 stateIn 的 initialValue 逐字相等(ledger R30 同类缺陷),
且三条用例都没有把构造函数的 metric 与 observeMetricHistory 的查询参数绑起来。
重写为「SLEEP 指标查到的是睡眠区间的数据」:点数断言杀掉「管线从不发射」,
取值区间断言(330~510,与步数 3000~12000 不相交)杀掉 metric 被写成常量的泄漏;
metric 回显断言保留但注释写明其无鉴别力。变异测试已证:把第 43 行的 metric 换成
MetricType.STEP 后该用例失败、另两条仍绿,回退后 3/3 通过。

顺带按 Minor 意见修正 previewPoints:KDoc 原文「贴近区间下限」不实,且步数夹具
7500~7572 在柱状图里渲染成齐平一排。改为带 step 形参,睡眠 330→480、步数 3200→11000,
两个 Preview 的 metric 分支覆盖不变。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 12:43:04 +08:00
cuiluzhengandClaude Code 01689b24fc feat: 实现指标详情子页(周/月切换 + 趋势图)
替换 MetricDetailPlaceholder 为真实详情页:范围切换、柱状趋势图、平均/最高/最低摘要,
睡眠指标额外渲染深睡/浅睡/清醒分期条与入睡/起床时间。
新增 MetricDetailViewModel(flatMapLatest 重新订阅数据源)与其 3 条 JVM 测试、
RoutesTest(锁住路由模板与 Routes.detail() 拼串)、Formatters.formatDecimal 与其测试。
接入 RingHealthApp 的 DETAIL_PATTERN,参数无法解析时直接抛而不是静默兜底。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 12:27:00 +08:00
cuiluzheng 88e88cf0ba feat: 实现我的页
纯静态入口列表(头像卡 + 应用设置 / 关于 两组入口),无 ViewModel、无状态。
新增 12 条 profile_* 文案资源,颜色统一走设计系统 TextSecondary。
2026-09-10 12:17:11 +08:00
cuiluzheng 7170a49664 fix: 收窄 SettingRow 可见性并强化设备 ViewModel 测试的数据管线断言 2026-09-10 11:32:55 +08:00
cuiluzheng 20fe2bce66 feat: 实现设备页与设备设置子页(可交互开关) 2026-09-10 11:27:27 +08:00
cuiluzheng 58573f4657 fix: 统一运动历史空态文字颜色并补充页面预览与顺序断言 2026-09-10 11:20:51 +08:00
cuiluzheng 031aea0fa2 feat: 实现运动页与运动历史子页 2026-09-10 11:12:28 +08:00
62 changed files with 6723 additions and 246 deletions
+19 -1
View File
@@ -5,12 +5,30 @@
- 设计文档:见 `ring-health-docs` 仓库 `superpowers/specs/` - 设计文档:见 `ring-health-docs` 仓库 `superpowers/specs/`
- 当前阶段:M1 静态页面(数据来自 Mock,未接入蓝牙 SDK) - 当前阶段:M1 静态页面(数据来自 Mock,未接入蓝牙 SDK)
## 页面清单(M1)
| 页面 | 路由 |
|---|---|
| 健康首页 | `home` |
| 运动 | `workout` |
| 运动历史 | `workout/history` |
| 设备 | `device` |
| 设备设置 | `device/settings` |
| 我的 | `profile` |
| 指标详情(步数/心率/睡眠/血氧/压力/体温/血压) | `detail/{metric}` |
> 页面路由的唯一定义处是 `core/navigation/Routes.kt`,上表与其保持一致。
> M1 的验收状态见 `docs/verification-m1.md`。
## 构建 ## 构建
```bash ```bash
./gradlew assembleDebug # 产物在 app/build/outputs/apk/debug/ ./gradlew assembleDebug # 产物在 app/build/outputs/apk/debug/
./gradlew installDebug # 安装到已连接设备 ./gradlew installDebug # 安装到已连接设备(需接好设备或已启动模拟器)
./gradlew test # 单元测试 ./gradlew test # 单元测试
``` ```
> `installDebug` 需要一台已连接的设备或正在运行的模拟器。M1 使用的机器两者都没有,
> 因此本机实际可执行的是 `./gradlew assembleDebug` 与 `./gradlew :app:testDebugUnitTest`。
要求:JDK 17、Android SDK(platform 36 / build-tools 36.1.0)。 要求:JDK 17、Android SDK(platform 36 / build-tools 36.1.0)。
@@ -13,8 +13,18 @@ interface AppContainer {
val ringDeviceRepository: RingDeviceRepository val ringDeviceRepository: RingDeviceRepository
} }
/** 默认容器:阶段一全部使用 Mock 数据。 */ /**
* 默认容器:阶段一全部使用 Mock 数据。
*
* 注意 [healthDataRepository] **依赖** [ringDeviceRepository] 的开关流,不是两个平行字段 ——
* 之前正是各建各的,导致功能开关与健康数据互不相干(详见 [MockHealthDataRepository])。
* 声明里保留具体类型,是为了把开关流传下去而不必向下转型。
*/
class DefaultAppContainer : AppContainer { class DefaultAppContainer : AppContainer {
override val healthDataRepository: HealthDataRepository = MockHealthDataRepository() private val mockRingDeviceRepository = MockRingDeviceRepository()
override val ringDeviceRepository: RingDeviceRepository = MockRingDeviceRepository()
override val ringDeviceRepository: RingDeviceRepository = mockRingDeviceRepository
override val healthDataRepository: HealthDataRepository =
MockHealthDataRepository(functionSettings = mockRingDeviceRepository.functionSettings)
} }
@@ -4,7 +4,9 @@ import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewmodel.CreationExtras import androidx.lifecycle.viewmodel.CreationExtras
import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory import androidx.lifecycle.viewmodel.viewModelFactory
import com.placeholder.ringhealth.feature.device.DeviceViewModel
import com.placeholder.ringhealth.feature.home.HomeViewModel import com.placeholder.ringhealth.feature.home.HomeViewModel
import com.placeholder.ringhealth.feature.workout.WorkoutViewModel
/** /**
* ViewModel 工厂:把容器中的仓库注入各 ViewModel。 * ViewModel 工厂:把容器中的仓库注入各 ViewModel。
@@ -19,6 +21,16 @@ object AppViewModelProvider {
ringDeviceRepository = ringHealthApplication().container.ringDeviceRepository, ringDeviceRepository = ringHealthApplication().container.ringDeviceRepository,
) )
} }
initializer {
WorkoutViewModel(
healthDataRepository = ringHealthApplication().container.healthDataRepository,
)
}
initializer {
DeviceViewModel(
ringDeviceRepository = ringHealthApplication().container.ringDeviceRepository,
)
}
} }
} }
@@ -2,7 +2,10 @@ package com.placeholder.ringhealth.core.common
import androidx.annotation.StringRes import androidx.annotation.StringRes
import com.placeholder.ringhealth.R import com.placeholder.ringhealth.R
import com.placeholder.ringhealth.domain.model.StressLevel
import com.placeholder.ringhealth.domain.model.stressLevelOf
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
@@ -15,6 +18,9 @@ fun formatDistance(km: Double): String = String.format(Locale.CHINA, "%.2f", km)
/** 卡路里:只做千分位,单位「千卡」由 UI 用字符串资源拼接(ledger Ruling R13)。 */ /** 卡路里:只做千分位,单位「千卡」由 UI 用字符串资源拼接(ledger Ruling R13)。 */
fun formatCalories(kcal: Int): String = String.format(Locale.CHINA, "%,d", kcal) fun formatCalories(kcal: Int): String = String.format(Locale.CHINA, "%,d", kcal)
/** 活动累计分钟数:只取整,单位「分钟」由 UI 用字符串资源拼接。 */
fun formatActiveMinutes(minutes: Int): String = minutes.toString()
/** 睡眠时长:7小时32分 / 45分 / 8小时。 */ /** 睡眠时长:7小时32分 / 45分 / 8小时。 */
fun formatSleepDuration(minutes: Int): String { fun formatSleepDuration(minutes: Int): String {
val hours = minutes / 60 val hours = minutes / 60
@@ -45,11 +51,119 @@ fun formatClock(timestamp: Long): String =
fun formatDate(timestamp: Long): String = fun formatDate(timestamp: Long): String =
SimpleDateFormat("yyyy-MM-dd", Locale.CHINA).format(Date(timestamp)) SimpleDateFormat("yyyy-MM-dd", Locale.CHINA).format(Date(timestamp))
/** 压力值 → 字符串资源 id。中文描述的唯一来源是 `strings.xml`(ledger Ruling R13)。 */ /** 时间戳 → 「9月10日」形式(首页顶部日期行左半部分;星期由 [weekdayResFor] 单独给)。 */
fun formatMonthDay(timestamp: Long): String =
SimpleDateFormat("M月d日", Locale.CHINA).format(Date(timestamp))
/**
* 时间戳所在星期 → 字符串资源 id(「周四」这类简写)。
*
* 星期名属于**用户可见文案**,必须走 `strings.xml`(ledger Ruling R3),
* 所以这里只做 Calendar → 资源 id 的映射,不返回中文串本身。
* 注意 `Calendar.DAY_OF_WEEK` 是「周日 = 1」起算,与直觉的「周一 = 1」差一天。
*/
@StringRes @StringRes
fun stressLevelLabelRes(stress: Int): Int = when { fun weekdayResFor(timestamp: Long): Int = when (calendarField(timestamp, Calendar.DAY_OF_WEEK)) {
stress < 30 -> R.string.stress_relaxed Calendar.MONDAY -> R.string.weekday_monday
stress < 60 -> R.string.stress_normal Calendar.TUESDAY -> R.string.weekday_tuesday
stress < 80 -> R.string.stress_medium Calendar.WEDNESDAY -> R.string.weekday_wednesday
else -> R.string.stress_high Calendar.THURSDAY -> R.string.weekday_thursday
Calendar.FRIDAY -> R.string.weekday_friday
Calendar.SATURDAY -> R.string.weekday_saturday
else -> R.string.weekday_sunday
} }
/**
* 时间戳所在时段 → 问候语字符串资源 id。
*
* 按时段取文案而不是拼字符串,文案本身仍全在 `strings.xml` 里(Ruling R3)。
* 分档:[GREETING_NIGHT_START]–[GREETING_MORNING_START] 为「夜深了」,
* 其余依次为早上/下午/晚上。
*/
@StringRes
fun greetingResFor(timestamp: Long): Int {
val hour = calendarField(timestamp, Calendar.HOUR_OF_DAY)
return when {
hour < GREETING_MORNING_START -> R.string.home_greeting_night
hour < GREETING_AFTERNOON_START -> R.string.home_greeting_morning
hour < GREETING_EVENING_START -> R.string.home_greeting_afternoon
hour < GREETING_NIGHT_START -> R.string.home_greeting_evening
else -> R.string.home_greeting_night
}
}
/** 一天 1440 分钟。[minutesOfDay] 与 [formatMinuteOfDay] 的取值范围。 */
private const val MINUTES_PER_DAY = 24 * 60
/** 5 点起算「早上好」。 */
private const val GREETING_MORNING_START = 5
/** 12 点起算「下午好」。 */
private const val GREETING_AFTERNOON_START = 12
/** 18 点起算「晚上好」。 */
private const val GREETING_EVENING_START = 18
/** 23 点起算「夜深了」。 */
private const val GREETING_NIGHT_START = 23
/** 取时间戳的某个日历字段。抽出来是为了 [weekdayResFor] / [greetingResFor] 共用一份 Calendar 构造。 */
private fun calendarField(timestamp: Long, field: Int): Int =
Calendar.getInstance().apply { timeInMillis = timestamp }.get(field)
/**
* 时间戳 → 当天 0 点起的分钟数(0–1439),按**设备本地时区**算。
*
* 睡眠时段图的横轴是「几点」而不是「哪天」,需要这个纯时间量。
* 不能用 `timestamp % 86_400_000 / 60_000` —— 那是 UTC 的余数,
* 在东八区会把 23:43 算成 15:43,整张图会平移八小时,
* 而且平移后每个条看起来都还挺合理,不会有人发现。
*/
fun minutesOfDay(timestamp: Long): Int =
calendarField(timestamp, Calendar.HOUR_OF_DAY) * 60 + calendarField(timestamp, Calendar.MINUTE)
/** 当天第几分钟(0–1439)→「HH:mm」。上一条的逆运算,给时段图的横轴刻度用。 */
fun formatMinuteOfDay(minuteOfDay: Int): String {
val normalized = ((minuteOfDay % MINUTES_PER_DAY) + MINUTES_PER_DAY) % MINUTES_PER_DAY
return String.format(Locale.CHINA, "%02d:%02d", normalized / 60, normalized % 60)
}
/**
* 数值 → 保留 [decimals] 位小数。详情页的统计摘要用(ledger Ruling R29)。
*
* 小数位由调用方按指标给:体温 36.5 保留一位才有意义,而心率 78.0、步数 8426.0
* 这种尾随的 `.0` 只会让人以为精度很高,实际是假精度。
*/
fun formatDecimal(value: Double, decimals: Int = 1): String =
String.format(Locale.CHINA, "%.${decimals}f", value)
/**
* 数值 → 带正负号、保留 [decimals] 位小数,如 `+0.4` / `-0.3`。
*
* 体温页的「较基线」与「偏离量」用它:正负号是它一半的信息,
* 走 [formatDecimal] 的话 +0.4 会显示成「0.4」,看着像没有偏离。
* 零统一带正号(`+0.0`)—— `-0.0` 在浮点里存在,但它不是「低了 0.0」的意思。
*/
fun formatSignedDecimal(value: Double, decimals: Int = 1): String {
val text = String.format(Locale.CHINA, "%.${decimals}f", kotlin.math.abs(value))
return if (value < 0) "-$text" else "+$text"
}
/** 时间戳 → 「9/10」形式。详情页柱状图的 X 轴刻度用(月度 30 根柱时日期要尽量窄)。 */
fun formatMonthDayNumeric(timestamp: Long): String =
SimpleDateFormat("M/d", Locale.CHINA).format(Date(timestamp))
/**
* 压力等级 → 字符串资源 id。中文描述的唯一来源是 `strings.xml`(ledger Ruling R13)。
*
* 分档阈值不在这里 —— 它属于领域判断,在 `domain/model/MetricReference.kt` 的
* [stressLevelOf] 里,本函数只负责「等级 → 文案」这一段映射。
*/
@StringRes
fun stressLevelLabelRes(level: StressLevel): Int = when (level) {
StressLevel.RELAXED -> R.string.stress_relaxed
StressLevel.NORMAL -> R.string.stress_normal
StressLevel.MEDIUM -> R.string.stress_medium
StressLevel.HIGH -> R.string.stress_high
}
/** [stressLevelLabelRes] 的便捷重载:直接吃压力值,省得调用方自己转一次等级。 */
@StringRes
fun stressLevelLabelRes(stress: Int): Int = stressLevelLabelRes(stressLevelOf(stress.toDouble()))
@@ -0,0 +1,29 @@
package com.placeholder.ringhealth.core.common
/**
* 睡眠时段的横轴锚点:**晚上 20:00**。
*
* 一晚睡眠横跨零点(23:43 入睡、07:37 起床),横轴不能直接用「当天第几分钟」——
* 那样 23:43 是 1423、07:37 是 457,画出来入睡点跑到了起床点右边,整条时段是反的。
* 以 20:00 为界重新起算,两个时刻都落在 0–960 这一段里,先后顺序就对了。
*/
const val SLEEP_ANCHOR_MINUTE_OF_DAY = 20 * 60
/** 一天 1440 分钟;[SLEEP_ANCHOR_MINUTE_OF_DAY] 加上它就是把时间推过零点。 */
private const val MINUTES_PER_DAY = 24 * 60
/**
* 时间戳 → 「距最近一次 20:00 过了多少分钟」,跨过零点继续累加(0–1439)。
*
* 20:00–24:00 之间是 0–239,次日 00:00–20:00 之间是 240–1439。
* 睡眠时段图与睡眠统计都靠它把「几点睡、几点起」变成可以比较、可以求平均的连续量 ——
* 直接对 `HH:mm` 求平均会把 23:00 和 01:00 平均成 12:00,正好是睡得最香的时候。
*/
fun minutesSinceSleepAnchor(timestamp: Long): Int {
val offset = minutesOfDay(timestamp) - SLEEP_ANCHOR_MINUTE_OF_DAY
return if (offset >= 0) offset else offset + MINUTES_PER_DAY
}
/** [minutesSinceSleepAnchor] 的逆运算:偏移量 → 「23:43」这样的时刻文案。 */
fun formatSleepAnchorOffset(offset: Int): String =
formatMinuteOfDay(SLEEP_ANCHOR_MINUTE_OF_DAY + offset)
@@ -2,17 +2,42 @@ package com.placeholder.ringhealth.core.designsystem
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
/** 品牌主色(健康绿,仿小米运动健康首页大环)。 */ /** 品牌主色(健康绿)。首页大环、主按钮与选中态都用它。 */
val BrandGreen = Color(0xFF34C759) val BrandGreen = Color(0xFF34C759)
val BrandGreenDark = Color(0xFF248A3D) val BrandGreenDark = Color(0xFF248A3D)
/**
* 小字号绿:用于文字与图标。
*
* 与 [BrandGreen] 的区别是**对比度**而不是色相 —— `#34C759` 在 12sp 的文字上
* 对白底只有约 2.0:1,远低于 WCAG AA 要求的 4.5:1,读起来发虚。文字一律用这个更深的值。
*/
val BrandGreenText = Color(0xFF12894A)
/** 页面与卡片背景。 */ /** 页面与卡片背景。 */
val AppBackground = Color(0xFFF5F6F8) val AppBackground = Color(0xFFEEF1F6)
val CardSurface = Color(0xFFFFFFFF) val CardSurface = Color(0xFFFFFFFF)
val TextPrimary = Color(0xFF1C1C1E) val TextPrimary = Color(0xFF14161C)
val TextSecondary = Color(0xFF8E8E93) val TextSecondary = Color(0xFF8A8F98)
val DividerColor = Color(0xFFEFEFF4) val DividerColor = Color(0xFFEFEFF4)
/**
* 卡片阴影。
*
* 不用纯黑:纯黑阴影叠在冷灰背景上会发脏、发绿。这两个值是从背景色相推出来的深蓝灰,
* 低透明度(6% / 8%),叠出来是「卡片浮起来」而不是「卡片镶了黑边」。
*
* 注意:`Modifier.shadow` 的 ambientColor / spotColor 仅在 **Android 9(API 28)及以上**生效,
* 本应用 minSdk 26,在 26/27 上会退化成系统默认黑阴影 —— 属于可接受的降级,不影响布局。
*/
val CardShadowAmbient = Color(0x101B2A4A)
val CardShadowSpot = Color(0x141B2A4A)
/** 底部导航选中态:浅绿药丸底 + 深绿图标文字。 */
val NavIndicator = Color(0xFFDCF3E4)
val NavSelected = BrandGreenText
val NavUnselected = Color(0xFF9AA0A6)
/** 各健康指标的语义色,全应用统一引用。 */ /** 各健康指标的语义色,全应用统一引用。 */
val MetricGreen = Color(0xFF34C759) // 步数 val MetricGreen = Color(0xFF34C759) // 步数
val MetricRed = Color(0xFFFF3B30) // 心率 val MetricRed = Color(0xFFFF3B30) // 心率
@@ -0,0 +1,49 @@
package com.placeholder.ringhealth.core.designsystem
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.DirectionsWalk
import androidx.compose.material.icons.filled.Bedtime
import androidx.compose.material.icons.filled.Bloodtype
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.Spa
import androidx.compose.material.icons.filled.Thermostat
import androidx.compose.material.icons.filled.WaterDrop
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import com.placeholder.ringhealth.domain.model.MetricType
/**
* 指标的视觉身份:图标 + 语义色。
*
* **一个指标只有一处定义。** 此前这份对应关系抄了两份 —— 一份写在首页各指标卡的字面量里,
* 一份是详情页里一个从没被调用过的 `metricTint`。两份都在时不会报错,只会在改了一处之后
* 让同一个指标在首页和详情页显示成两种颜色,而且看不出是哪边错了。
*
* 放在 `core/designsystem` 而不是某个 `feature/` 下,是为了让首页和七个详情页都能直接用,
* 不必让 `feature/home` 去 import `feature/detail`。色板的唯一来源仍是设计文档 §7.2 与
* `Color.kt`,本文件只做映射,不新造颜色。
*/
/** 指标类型 → 图标。 */
fun metricIcon(type: MetricType): ImageVector = when (type) {
// 步数在首页不占指标卡(它有一张跨两列的进度环大卡),但详情页的「未开启」说明卡
// 需要它,映射因此保持完整 —— 七个指标一个不缺。
MetricType.STEP -> Icons.AutoMirrored.Filled.DirectionsWalk
MetricType.HEART_RATE -> Icons.Filled.Favorite
MetricType.SLEEP -> Icons.Filled.Bedtime
MetricType.BLOOD_OXYGEN -> Icons.Filled.Bloodtype
MetricType.STRESS -> Icons.Filled.Spa
MetricType.TEMPERATURE -> Icons.Filled.Thermostat
MetricType.BLOOD_PRESSURE -> Icons.Filled.WaterDrop
}
/** 指标类型 → 语义色(步数=绿、心率=红、睡眠=蓝、血氧=青、压力=紫、体温=橙、血压=黄)。 */
fun metricTint(type: MetricType): Color = when (type) {
MetricType.STEP -> MetricGreen
MetricType.HEART_RATE -> MetricRed
MetricType.SLEEP -> MetricBlue
MetricType.BLOOD_OXYGEN -> MetricCyan
MetricType.STRESS -> MetricPurple
MetricType.TEMPERATURE -> MetricOrange
MetricType.BLOOD_PRESSURE -> MetricYellow
}
@@ -3,16 +3,22 @@ package com.placeholder.ringhealth.core.designsystem
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.lightColorScheme import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
private val RingHealthColors = lightColorScheme( private val RingHealthColors = lightColorScheme(
primary = BrandGreen, primary = BrandGreen,
onPrimary = androidx.compose.ui.graphics.Color.White, onPrimary = Color.White,
secondary = BrandGreenDark, secondary = BrandGreenDark,
background = AppBackground, background = AppBackground,
onBackground = TextPrimary, onBackground = TextPrimary,
surface = CardSurface, surface = CardSurface,
onSurface = TextPrimary, onSurface = TextPrimary,
surfaceVariant = DividerColor, surfaceVariant = DividerColor,
// secondaryContainer 必须显式覆盖:它在 Material 3 里同时是
// 「底部导航选中态药丸底」和「FilterChip 选中底」的取色来源。不写的话会落到 M3 默认的
// 淡紫 #E8DEF8 —— 于是出现「淡紫底 + 绿色文字」的撞色(M1 验收时被当成正常记录了下来)。
secondaryContainer = NavIndicator,
onSecondaryContainer = NavSelected,
) )
/** /**
@@ -5,17 +5,35 @@ import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
/** 大号数值样式:首页大环与详情页主数字。 */ /**
* 大号数值样式:首页大环与详情页主数字。
*
* `fontFeatureSettings = "tnum"` 打开等宽数字 —— 数值变化时每个字形宽度一致,
* 数字不会左右跳动。字体不支持时是空操作,无副作用。
* 负字距(-1sp)让多位数字排得更紧,是让大号数字「显贵」的关键一步。
*/
val MetricValueStyle = TextStyle( val MetricValueStyle = TextStyle(
fontSize = 40.sp, fontSize = 44.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
letterSpacing = (-1).sp,
fontFeatureSettings = "tnum",
color = TextPrimary, color = TextPrimary,
) )
/** 中等数值样式:指标卡主数字。 */ /** 中等数值样式:指标卡主数字。同样开等宽数字,避免网格里各卡数字宽度参差。 */
val MetricTileValueStyle = TextStyle( val MetricTileValueStyle = TextStyle(
fontSize = 22.sp, fontSize = 22.sp,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.Bold,
letterSpacing = (-0.3).sp,
fontFeatureSettings = "tnum",
color = TextPrimary,
)
/** 首页问候语:一屏里字号最大的文字,承担「这是哪一页」的身份作用。 */
val GreetingStyle = TextStyle(
fontSize = 26.sp,
fontWeight = FontWeight.Bold,
letterSpacing = (-0.4).sp,
color = TextPrimary, color = TextPrimary,
) )
@@ -0,0 +1,58 @@
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.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import com.placeholder.ringhealth.core.designsystem.TextSecondary
/**
* 图表 X 轴刻度行。柱状图、阈值带、偏离条三张图共用,免得同一段对齐逻辑抄三份。
*
* 两种排布由 [labels] 与 [slotCount] 的关系决定:
* - 数量相等(周视图 7 根柱标 7 个星期)时按槽位等分,刻度正对着它那根柱子;
* - 数量更少(月视图 30 根柱只标 7 个日期)时两端对齐铺开 —— 此时槽宽只有约 10dp,
* 若也按槽位等分,每个文字会被压进 10dp 宽的格子里截断成一两个字。
*
* @param labels 刻度文字,从左到右。
* @param slotCount 数据槽位数量(柱数 / 点数)。
* @param modifier 外部修饰符。
*/
@Composable
internal fun ChartAxisLabels(
labels: List<String>,
slotCount: Int,
modifier: Modifier = Modifier,
) {
if (labels.isEmpty()) return
if (slotCount > 0 && labels.size == slotCount) {
Row(modifier = modifier.fillMaxWidth()) {
labels.forEach { label -> AxisLabel(text = label, modifier = Modifier.weight(1f)) }
}
} else {
Row(modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
labels.forEach { label -> AxisLabel(text = label) }
}
}
}
/**
* 单个刻度文字。居中、单行、不换行 ——
* 换行会把刻度行撑成两行高,柱子与刻度的对齐就散了。
*/
@Composable
private fun AxisLabel(text: String, modifier: Modifier = Modifier) {
Text(
text = text,
modifier = modifier,
style = MaterialTheme.typography.labelSmall,
color = TextSecondary,
textAlign = TextAlign.Center,
maxLines = 1,
softWrap = false,
)
}
@@ -3,40 +3,73 @@ package com.placeholder.ringhealth.core.designsystem.component
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.placeholder.ringhealth.core.designsystem.CardShadowAmbient
import com.placeholder.ringhealth.core.designsystem.CardShadowSpot
/** 卡片圆角。全应用统一,改这里就是改所有卡片。 */
val HealthCardShape = RoundedCornerShape(20.dp)
/** 卡片默认阴影高度。 */
private val CardElevation = 5.dp
/** /**
* 统一卡片容器:白底、大圆角、轻阴影。所有页面内容块都应包在它里面。 * 统一卡片容器:白底、大圆角、柔和阴影。所有页面内容块都应包在它里面。
*
* **为什么不用 `Card` + `CardDefaults.cardElevation`:**
* M3 的 `Card` 在 1~2dp 时的阴影几乎看不见,屏幕上就是一排纯白方块贴在灰底上,
* 没有纵深,整屏会「糊」成一片。这里改用 `Modifier.shadow` 并传入**带色相的深蓝灰**
* (见 [CardShadowAmbient] / [CardShadowSpot])而不是纯黑 —— 纯黑阴影叠在冷灰背景上会发脏。
* 同时 `tonalElevation = 0.dp` 关掉 M3 的 surface tint,保证卡片在任何高度都是纯白。
* *
* 颜色取自主题的 surface(由 [com.placeholder.ringhealth.core.designsystem.RingHealthTheme] * 颜色取自主题的 surface(由 [com.placeholder.ringhealth.core.designsystem.RingHealthTheme]
* 映射为设计系统的 CardSurface),因此升级深色模式时无需改动本文件。 * 映射为设计系统的 CardSurface),因此升级深色模式时无需改动本文件。
* *
* @param modifier 外部修饰符,会在 fillMaxWidth 之前生效。 * @param modifier 外部修饰符,会在 fillMaxWidth 之前生效。
* @param onClick 非空时整卡可点击(如水/体重等可跳转的卡片);为空则纯展示。 * @param onClick 非空时整卡可点击(如可跳转的卡片);为空则纯展示。
* @param content 卡片内容,作用域为 ColumnScope,默认内边距 16dp。 * @param contentPadding 卡片内边距,默认 16dp;网格里的紧凑卡片可调小。
* @param content 卡片内容,作用域为 ColumnScope。
*/ */
@Composable @Composable
fun HealthCard( fun HealthCard(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null, onClick: (() -> Unit)? = null,
contentPadding: PaddingValues = PaddingValues(16.dp),
content: @Composable ColumnScope.() -> Unit, content: @Composable ColumnScope.() -> Unit,
) { ) {
// 仅当传入点击回调时才挂 clickable,避免给纯展示卡片带来无谓的水波纹与无障碍焦点。 // 仅当传入点击回调时才挂 clickable,避免给纯展示卡片带来无谓的水波纹与无障碍焦点。
val clickModifier = if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier val clickModifier = if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier
Card( Surface(
modifier = modifier.fillMaxWidth().then(clickModifier), modifier = modifier
shape = RoundedCornerShape(20.dp), .fillMaxWidth()
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), // clip = false 让阴影画在卡片轮廓之外;卡片自身的形状由下面的 shape 参数裁剪。
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), .shadow(
elevation = CardElevation,
shape = HealthCardShape,
clip = false,
ambientColor = CardShadowAmbient,
spotColor = CardShadowSpot,
)
.then(clickModifier),
shape = HealthCardShape,
color = MaterialTheme.colorScheme.surface,
// 关掉 colorSurface 的 tonal 叠加,否则不同高度的卡片会呈现不同深浅的白。
tonalElevation = 0.dp,
shadowElevation = 0.dp,
) { ) {
Column(modifier = Modifier.padding(16.dp), content = content) Column(modifier = Modifier.padding(contentPadding), content = content)
} }
} }
/** 供需要显式指定阴影的场景复用(如自定义容器)。 */
internal val HealthCardElevation: Dp = CardElevation
@@ -0,0 +1,57 @@
package com.placeholder.ringhealth.core.designsystem.component
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
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.graphics.vector.ImageVector
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/** 徽章底色相对图标色的不透明度:淡到足以让图标本身仍是主体,又足以看出那是一块色底。 */
private const val BADGE_ALPHA = 0.14f
/**
* 圆形色底图标徽章:一层同色淡底 + 居中图标。
*
* 首页指标卡与「我的」页入口共用它。抽出来的直接原因是**两处原本不一致** ——
* 首页用色底圆形徽章、我的页用裸灰图标,同一套 App 里两种图标处理方式。
* 更实际的原因是:图标是纯装饰,给它配一块淡色底之后,一行「图标 + 文字」的入口
* 立刻有了视觉重心,而不必把图标本身调大调粗去抢注意力。
*
* @param icon 图标。
* @param tint 图标色,也是底色来源(底色取它的 [BADGE_ALPHA] 透明度)。
* @param modifier 外部修饰符。
* @param badgeSize 圆形底的直径。
* @param iconSize 图标本身的大小,需明显小于 [badgeSize] 才不会顶满圆。
*/
@Composable
fun IconBadge(
icon: ImageVector,
tint: Color,
modifier: Modifier = Modifier,
badgeSize: Dp = 30.dp,
iconSize: Dp = 17.dp,
) {
Box(
modifier = modifier
.size(badgeSize)
.clip(CircleShape)
.background(tint.copy(alpha = BADGE_ALPHA)),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = icon,
// 徽章是纯装饰,语义由旁边的标题文字承担,避免无障碍重复朗读。
contentDescription = null,
tint = tint,
modifier = Modifier.size(iconSize),
)
}
}
@@ -1,6 +1,8 @@
package com.placeholder.ringhealth.core.designsystem.component package com.placeholder.ringhealth.core.designsystem.component
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -9,53 +11,149 @@ import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.placeholder.ringhealth.core.designsystem.DividerColor
import com.placeholder.ringhealth.core.designsystem.TextSecondary
/** 柱状图基线相对灰线的粗细,1dp 在 420dpi 下是可见但不抢戏的一根细线。 */
private val BASELINE_WIDTH = 1.dp
/** 参考线的虚线节拍:6dp 实线 + 5dp 空白。 */
private val REFERENCE_DASH = floatArrayOf(6f, 5f)
/** /**
* 极简柱状图(Compose Canvas 手绘,无第三方依赖)。 * 柱状图(Compose Canvas 手绘,无第三方依赖)。
* values 为空时不绘制;被高亮的一根用纯色,其余半透明。 *
* 由三部分叠成:**基线** + **参考虚线** + **胶囊柱**,下方另有一行 X 轴刻度文字。
*
* 早先只有一排浮空的柱子 —— 没有基线,柱子像悬在半空;没有刻度,看不出哪根是哪天;
* 没有参照,也读不出「今天比平时高还是低」。三个都补上之后这张图才开始承载信息。
* *
* 几何说明:画布横向按 values.size 等分得到每个槽位 slot,柱宽取槽位的 65%, * 几何说明:画布横向按 values.size 等分得到每个槽位 slot,柱宽取槽位的 65%,
* 余下 35% 作为间隔,柱子因此在槽内水平居中;柱高 = 画布高 × (value / maxValue), * 余下 35% 作为间隔,柱子因此在槽内水平居中;柱高 = 画布高 ×
* 底边贴合画布底边(y = 画布高 - 柱高),圆角半径取柱宽一半,形成胶囊柱。 * (value - 窗口下限) / (窗口上限 - 窗口下限),底边贴合基线(y = 画布高),
* maxValue 下限为 1f,避免「全为 0」时出现 0 除。 * 圆角半径取柱宽一半,形成胶囊柱。窗口见 [valueRange]。
* *
* @param values 各柱数值(须为非负),按时间顺序从左到右。 * @param values 各柱数值(须为非负),按时间顺序从左到右。
* @param color 柱子颜色;高亮柱用纯色,其余用其 30% 透明度。 * @param color 柱子颜色;高亮柱用纯色,其余用其 30% 透明度。
* @param modifier 外部修饰符。 * @param modifier 外部修饰符。
* @param barHeight 画布高度,即最高柱的高度。 * @param barHeight 画布高度,即最高柱的高度。
* @param highlightLast 为 true 时高亮最后一根(如「今天」),否则高亮数值最大的一根。 * @param highlightLast 为 true 时高亮最后一根(如「今天」),否则高亮数值最大的一根。
* @param referenceLine 参考线的位置(与 [values] 同单位),如均值或目标值;为 null 则不画。
* @param axisLabels X 轴刻度文字。数量与 [values] 一致时按槽位一一对齐;
* 数量更少时(月度 30 根柱只标 5 个刻度)改为两端对齐铺开。
* @param barColorOf 按数值决定单根柱子的颜色;返回 null 表示用 [color](高亮柱纯色、其余 30%)。
* 压力页用它按等级给柱子着色。
* @param valueRange 取景窗口(纵轴范围)。**窄区间指标必须传它** ——
* 见下方 KDoc 的说明,不传会让血氧/体温这类指标的柱子全部一样高。
* @param bandRange 正常区间带(与 [values] 同单位),在柱子后面铺一层极淡的指标色。
* 心率页用它标出 60–100 的正常静息区间 —— 柱子高过或低于这条带子,一眼就知道出没出界。
*/ */
@Composable @Composable
fun MetricBarChart( fun MetricBarChart(
values: List<Float>, values: List<Float>,
color: Color, color: Color,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
barHeight: Dp = 120.dp, barHeight: Dp = 170.dp,
highlightLast: Boolean = true, highlightLast: Boolean = true,
referenceLine: Float? = null,
axisLabels: List<String> = emptyList(),
barColorOf: ((Float) -> Color?)? = null,
valueRange: ClosedFloatingPointRange<Float>? = null,
bandRange: ClosedFloatingPointRange<Float>? = null,
) { ) {
Canvas(modifier = modifier.fillMaxWidth().height(barHeight)) { Column(modifier = modifier.fillMaxWidth()) {
Canvas(modifier = Modifier.fillMaxWidth().height(barHeight)) {
if (values.isEmpty()) return@Canvas if (values.isEmpty()) return@Canvas
val maxValue = values.max().coerceAtLeast(1f) // 纵轴:默认从 0 到最大值,给了 valueRange 就按窗口取。
// 窄区间指标(血氧 95–99、体温 36.1–36.9)不传窗口时,所有柱子都落在满高的
// 96%~100% 之间,七根柱子一样高,整张图读不出任何东西 —— 这不是「观感差」,是数据被抹平了。
val low = valueRange?.start ?: 0f
val high = valueRange?.endInclusive ?: values.max().coerceAtLeast(1f)
// 窗口退化成一条线时直接不画:否则下面的 (value - low) / span 会除零得到 NaN。
val span = high - low
if (span <= 0f) return@Canvas
val gapRatio = 0.35f val gapRatio = 0.35f
val slot = size.width / values.size val slot = size.width / values.size
val barWidth = slot * (1f - gapRatio) val barWidth = slot * (1f - gapRatio)
// 注意:maxValue 已被 coerceAtLeast(1f) 抬高,当所有值都小于 1 时 // 注意:不能拿 `values.indexOf(maxValue)` 找高亮柱 —— 无窗口时 high 被
// values.indexOf(maxValue) 会返回 -1 导致没有任何柱子被高亮, // coerceAtLeast(1f) 抬高过,当所有值都小于 1 时 indexOf 返回 -1,没有柱子会被高亮。
// 因此这里改为直接取最大值的下标。
val highlightIndex = if (highlightLast) values.lastIndex else values.indices.maxByOrNull { values[it] } ?: 0 val highlightIndex = if (highlightLast) values.lastIndex else values.indices.maxByOrNull { values[it] } ?: 0
val baselineWidthPx = BASELINE_WIDTH.toPx()
// 基线先画,后画的柱子会压住它两端,视觉上柱子是「立在线上的」。
// y 取 height - 半线宽,否则 1dp 的线会有一半落在画布外被裁掉。
drawLine(
color = DividerColor,
start = Offset(x = 0f, y = size.height - baselineWidthPx / 2f),
end = Offset(x = size.width, y = size.height - baselineWidthPx / 2f),
strokeWidth = baselineWidthPx,
)
/** 值 → 画布纵坐标。窗口换算只写这一处,柱子/参考线/区间带共用。 */
fun yOf(v: Float): Float =
size.height - size.height * ((v - low) / span).coerceIn(0f, 1f)
// 正常区间带最先画,柱子压在上面。两层表达:极淡的填充 + 上下各一条边界线。
// 只有填充时,一整块横贯全宽的颜色块视觉重量会盖过柱子 —— 心率页试过 10% 的填充,
// 结果是「一堵粉墙,柱子贴在墙上」,看不出柱子是穿进带子里还是没够到。
// 加上两条边界线之后,带子的范围有了明确的边,「有没有出界」才读得出来。
if (bandRange != null) {
val top = yOf(bandRange.endInclusive)
val bottom = yOf(bandRange.start)
if (bottom > top) {
drawRect(
color = color.copy(alpha = 0.06f),
topLeft = Offset(0f, top),
size = Size(size.width, bottom - top),
)
listOf(top, bottom).forEach { y ->
drawLine(
color = color.copy(alpha = 0.35f),
start = Offset(0f, y),
end = Offset(size.width, y),
strokeWidth = BASELINE_WIDTH.toPx(),
)
}
}
}
// 参考线画在柱子下方:它是一条读数参照,不该盖住任何一根柱子。
// 用中性灰而不是柱色:参考线不是数据,跟着柱色走会被当成「第 8 根柱子」。
if (referenceLine != null) {
val y = yOf(referenceLine)
drawLine(
color = REFERENCE_LINE_COLOR,
start = Offset(x = 0f, y = y),
end = Offset(x = size.width, y = y),
strokeWidth = baselineWidthPx,
pathEffect = PathEffect.dashPathEffect(REFERENCE_DASH),
)
}
values.forEachIndexed { index, value -> values.forEachIndexed { index, value ->
val ratio = (value / maxValue).coerceIn(0f, 1f) val h = size.height - yOf(value)
val h = size.height * ratio
val isHighlight = index == highlightIndex val isHighlight = index == highlightIndex
// 调用方指定了按值取色就优先用它;返回 null 表示「这根交回默认规则」。
val barColor = barColorOf?.invoke(value)
?: if (isHighlight) color else color.copy(alpha = 0.3f)
drawRoundRect( drawRoundRect(
color = if (isHighlight) color else color.copy(alpha = 0.3f), color = barColor,
topLeft = Offset(x = index * slot + (slot - barWidth) / 2f, y = size.height - h), topLeft = Offset(x = index * slot + (slot - barWidth) / 2f, y = size.height - h),
size = Size(width = barWidth, height = h), size = Size(width = barWidth, height = h),
cornerRadius = CornerRadius(barWidth / 2f, barWidth / 2f), cornerRadius = CornerRadius(barWidth / 2f, barWidth / 2f),
) )
} }
} }
if (axisLabels.isEmpty()) return@Column
Spacer(modifier = Modifier.height(6.dp))
ChartAxisLabels(labels = axisLabels, slotCount = values.size)
}
} }
/** 参考虚线的颜色:中性灰,与任何指标色都不撞,一眼能分得出它是参照而非数据。 */
private val REFERENCE_LINE_COLOR = TextSecondary.copy(alpha = 0.65f)
@@ -0,0 +1,108 @@
package com.placeholder.ringhealth.core.designsystem.component
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
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.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.placeholder.ringhealth.core.designsystem.TextSecondary
/** 画布高度。偏离条是纵向的,比柱状图矮一点就够 —— 半幅只有 ±1℃。 */
private val DEVIATION_HEIGHT = 132.dp
/** 每根「棒棒糖」的杆粗。 */
private val STEM_WIDTH = 2.dp
/** 杆顶圆点的直径。 */
private val DOT_SIZE = 9.dp
/** 基线的虚线节拍,与柱状图参考线一致。 */
private val BASELINE_DASH = floatArrayOf(6f, 5f)
/**
* 偏离条:一条基线 + 每天一根从基线出发的「棒棒糖」,向上偏高、向下偏低。
*
* 体温页的头图。体温的特殊之处在于**绝对值没有意义** —— 36.5 和 36.8 都属于正常,
* 真正要读的是「比自己的基线高还是低」。柱状图从 y=0 起画,36.1–36.9 的差别会被压成
* 一条平带(这正是改造前体温页的样子);即使加了取景窗口,柱子仍然是从底部往上长,
* 视觉上仍在暗示「越高越好」,而体温不是这样。
*
* 把基线画在正中间、让值向两侧偏离,极性才读得出来:偏上一片暖色、偏下一片冷色。
* 上方用 [aboveColor]、下方用 [belowColor] —— 这是一套**页内发散配色**,
* 与「一个指标一个专属色」的规则不冲突:它在同一张图里表达方向,不是另一个指标的身份。
*
* @param values 各天的值,按时间顺序从左到右。
* @param baseline 基线值(体温取 36.5),画在画布垂直中央。
* @param span 半幅,即基线到画布上下边缘代表多少(体温取 1.0℃)。
* @param aboveColor 高于基线的颜色。
* @param belowColor 低于基线的颜色。
* @param modifier 外部修饰符。
* @param axisLabels X 轴刻度文字。
*/
@Composable
fun MetricDeviationBar(
values: List<Double>,
baseline: Double,
span: Double,
aboveColor: Color,
belowColor: Color,
modifier: Modifier = Modifier,
axisLabels: List<String> = emptyList(),
) {
Column(modifier = modifier.fillMaxWidth()) {
Canvas(modifier = Modifier.fillMaxWidth().height(DEVIATION_HEIGHT)) {
if (values.isEmpty()) return@Canvas
if (span <= 0.0) return@Canvas
val radius = DOT_SIZE.toPx() / 2f
// 可用半高:留出一个圆点的半径,偏离到半幅的点才不会被画布边缘裁掉。
val halfHeight = size.height / 2f - radius
if (halfHeight <= 0f) return@Canvas
val centerY = size.height / 2f
val slot = size.width / values.size
// 基线:体温的参照就是它,必须画得比数据更「静」—— 中性灰 + 虚线。
drawLine(
color = TextSecondary.copy(alpha = 0.55f),
start = Offset(0f, centerY),
end = Offset(size.width, centerY),
strokeWidth = 1.dp.toPx(),
pathEffect = PathEffect.dashPathEffect(BASELINE_DASH),
)
values.forEachIndexed { index, value ->
val cx = index * slot + slot / 2f
val deviation = value - baseline
// 超出半幅的值夹到边缘,而不是丢弃 —— 丢弃会让「那天没有数据」和
// 「那天偏得离谱」看起来一模一样。
val ratio = (deviation / span).coerceIn(-1.0, 1.0).toFloat()
val y = centerY - halfHeight * ratio
val tint = if (deviation >= 0.0) aboveColor else belowColor
// 杆从基线长到点,点画在杆顶。比单纯一个点更能读出「从基线偏出去多少」。
drawLine(
color = tint.copy(alpha = 0.45f),
start = Offset(cx, centerY),
end = Offset(cx, y),
strokeWidth = STEM_WIDTH.toPx(),
cap = StrokeCap.Round,
)
drawCircle(color = tint, radius = radius, center = Offset(cx, y))
}
}
if (axisLabels.isEmpty()) return@Column
Spacer(modifier = Modifier.height(6.dp))
ChartAxisLabels(labels = axisLabels, slotCount = values.size)
}
}
@@ -0,0 +1,91 @@
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.CornerRadius
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 com.placeholder.ringhealth.core.designsystem.CardSurface
import com.placeholder.ringhealth.core.designsystem.DividerColor
/** 轨道的粗细。10dp 在 420dpi 下是一条能看清、又不至于喧宾夺主的带子。 */
private val TRACK_HEIGHT = 10.dp
/** 当前值圆点的直径。比轨道粗一截,让人一眼找到「我在哪」。 */
private val MARKER_SIZE = 18.dp
/** 圆点外的白圈宽度。 */
private val MARKER_RING = 3.dp
/**
* 区间条:一根横轴 + 一段高亮区间 + 一个当前值圆点。
*
* 心率页的头图。它回答的是「这个数落在哪儿」,而柱状图回答的是「这几天怎么变的」——
* 前者是单值判读,后者是趋势。心率的单值判读基准是「正常静息 60–100」,
* 一根光秃秃的数字「72」看不出这件事,落在区间条上就一目了然。
*
* 几何说明:横轴按 [axis] 线性映射到画布宽度,但左右各内缩一个圆点半径 ——
* 否则贴边的值(比如 40 或 120)圆点会有一半落在画布外被裁掉。
*
* @param value 当前值,须与 [axis]、[normal] 同单位。
* @param axis 横轴范围,通常比 [normal] 左右各留余量(见 `MetricReference.HEART_RATE_AXIS`)。
* @param normal 正常区间,在轨道上高亮出来。
* @param color 指标色,用于高亮段与圆点。
* @param modifier 外部修饰符。
*/
@Composable
fun MetricRangeBar(
value: Double,
axis: ClosedFloatingPointRange<Double>,
normal: ClosedFloatingPointRange<Double>,
color: Color,
modifier: Modifier = Modifier,
) {
Canvas(modifier = modifier.fillMaxWidth().height(MARKER_SIZE)) {
val span = (axis.endInclusive - axis.start).toFloat()
// 轴退化成一条线时直接不画,否则下面除法会得到 NaN。
if (span <= 0f) return@Canvas
val radius = MARKER_SIZE.toPx() / 2f
// 可用宽度左右各让出一个半径,圆点因此永远不会被画布边缘切掉。
val usable = size.width - radius * 2f
if (usable <= 0f) return@Canvas
/** 值 → 画布横坐标。 */
fun xOf(v: Double): Float =
radius + usable * ((v - axis.start).toFloat() / span).coerceIn(0f, 1f)
val centerY = size.height / 2f
val trackH = TRACK_HEIGHT.toPx()
val trackRadius = trackH / 2f
// 轨道:整条横轴的底
drawRoundRect(
color = DividerColor,
topLeft = Offset(radius, centerY - trackRadius),
size = Size(usable, trackH),
cornerRadius = CornerRadius(trackRadius, trackRadius),
)
// 正常区间高亮。宽度下限取一个轨道高,免得区间极窄时退化成看不见的一条。
val left = xOf(normal.start)
val width = (xOf(normal.endInclusive) - left).coerceAtLeast(trackH)
drawRoundRect(
color = color.copy(alpha = 0.30f),
topLeft = Offset(left, centerY - trackRadius),
size = Size(width, trackH),
cornerRadius = CornerRadius(trackRadius, trackRadius),
)
// 当前值圆点:先铺一圈与卡片同色的底,再叠实心点。
// 少了这圈底,点落在高亮段上时两个同色系会糊成一片,看不出点的边界。
val center = Offset(xOf(value), centerY)
drawCircle(color = CardSurface, radius = radius, center = center)
drawCircle(color = color, radius = radius - MARKER_RING.toPx(), center = center)
}
}
@@ -0,0 +1,113 @@
package com.placeholder.ringhealth.core.designsystem.component
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
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.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.placeholder.ringhealth.core.designsystem.TextSecondary
/** 阈值带的高度。比柱状图矮 —— 它只承载一条窄区间,撑高只会留出空白。 */
private val STRIP_HEIGHT = 108.dp
/** 每天那个点的直径。 */
private val DOT_SIZE = 11.dp
/** 阈值虚线的节拍,与柱状图的参考线保持一致(6dp 实线 + 5dp 空白)。 */
private val THRESHOLD_DASH = floatArrayOf(6f, 5f)
/**
* 阈值带:一条窄区间 + 一条阈值虚线 + 每天一个点。
*
* 血氧页的头图。它和柱状图的区别是**回答的问题不同**:柱状图比的是「谁高谁矮」,
* 而血氧要判的是「有没有掉到线下」—— 一条 95% 的线画在那里,点在线下方就是偏低。
* 用柱状图表达这件事会很别扭:柱高按 0–100 画全是满格,按取景窗口画又读不出绝对位置。
*
* 点用圆点而不是细柱,是为了在 30 天的月视图里依然能分辨 —— 细柱互相挨着会连成一片色块。
*
* @param values 各天的值,按时间顺序从左到右。
* @param axis 纵轴范围(注意这是**纵向**映射),见 `MetricReference.BLOOD_OXYGEN_FLOOR`。
* @param threshold 阈值线的位置,低于它的点用 [warningColor] 画。
* @param color 达标点的颜色,通常是血氧的指标色。
* @param warningColor 低于阈值时的警示色。
* @param modifier 外部修饰符。
* @param axisLabels X 轴刻度文字,数量与 [values] 一致时按槽位对齐,否则两端铺开。
*/
@Composable
fun MetricThresholdStrip(
values: List<Double>,
axis: ClosedFloatingPointRange<Double>,
threshold: Double,
color: Color,
warningColor: Color,
modifier: Modifier = Modifier,
axisLabels: List<String> = emptyList(),
) {
Column(modifier = modifier.fillMaxWidth()) {
Canvas(modifier = Modifier.fillMaxWidth().height(STRIP_HEIGHT)) {
if (values.isEmpty()) return@Canvas
val span = (axis.endInclusive - axis.start).toFloat()
// 轴退化成一条线时直接不画,否则下面除法会得到 NaN。
if (span <= 0f) return@Canvas
val radius = DOT_SIZE.toPx() / 2f
// 纵向可用高度上下各让出一个半径,贴轴的点才不会被裁掉。
val usableY = size.height - radius * 2f
if (usableY <= 0f) return@Canvas
val slot = size.width / values.size
/** 值 → 画布纵坐标(值越大越靠上)。 */
fun yOf(v: Double): Float =
radius + usableY * (1f - ((v - axis.start).toFloat() / span).coerceIn(0f, 1f))
// 达标区:阈值以上铺一层极淡的指标色,扫一眼就知道「上面这块是安全的」。
val thresholdY = yOf(threshold)
drawRoundRect(
color = color.copy(alpha = 0.10f),
topLeft = Offset(0f, 0f),
size = Size(size.width, thresholdY.coerceAtLeast(0f)),
cornerRadius = CornerRadius(6.dp.toPx(), 6.dp.toPx()),
)
// 阈值线用中性灰:它是判读基准,不是数据,跟着指标色走会被当成「第 N+1 个点」。
drawLine(
color = TextSecondary.copy(alpha = 0.65f),
start = Offset(0f, thresholdY),
end = Offset(size.width, thresholdY),
strokeWidth = 1.dp.toPx(),
pathEffect = PathEffect.dashPathEffect(THRESHOLD_DASH),
)
values.forEachIndexed { index, value ->
val cx = index * slot + slot / 2f
val below = value < threshold
// 偏低的点额外套一圈警示色的淡晕,在月视图里连成一串时尤其醒目。
if (below) {
drawCircle(
color = warningColor.copy(alpha = 0.25f),
radius = radius * 2.1f,
center = Offset(cx, yOf(value)),
)
}
drawCircle(
color = if (below) warningColor else color,
radius = radius,
center = Offset(cx, yOf(value)),
)
}
}
if (axisLabels.isEmpty()) return@Column
Spacer(modifier = Modifier.height(6.dp))
ChartAxisLabels(labels = axisLabels, slotCount = values.size)
}
}
@@ -2,10 +2,11 @@ package com.placeholder.ringhealth.core.designsystem.component
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -19,18 +20,25 @@ import com.placeholder.ringhealth.core.designsystem.MetricTileValueStyle
import com.placeholder.ringhealth.core.designsystem.TextSecondary import com.placeholder.ringhealth.core.designsystem.TextSecondary
/** /**
* 单个健康指标小卡:图标 + 标题 + 数值 + 单位。 * 单个健康指标小卡:色底图标徽章 + 标题 + 数值 + 单位。
*
* 为**两列网格**设计(首页 2×3),因此刻意做得紧凑:卡片内边距 14dp、图标 17dp。
* 图标外面套一层同色浅底的圆形徽章,是为了让网格里六张卡各自带一点颜色 —— 纯白卡片 +
* 灰色小图标排成网格会显得很素,色彩只能靠数字本身,而数字是黑白的。
* *
* 文案全部由调用方传入(标题/数值/单位),组件内不出现任何用户可见字面量。 * 文案全部由调用方传入(标题/数值/单位),组件内不出现任何用户可见字面量。
* 图标为纯装饰(contentDescription = null),语义由 title 承担,避免无障碍重复朗读。 * 图标为纯装饰(contentDescription = null),语义由 title 承担,避免无障碍重复朗读。
* *
* @param icon 指标图标,如 Icons.Filled.DirectionsWalk。 * @param icon 指标图标,如 Icons.Filled.Favorite。
* @param iconTint 图标着色,通常取设计系统中该指标的语义色。 * @param iconTint 图标着色,通常取设计系统中该指标的语义色。
* @param title 指标名(由调用方从字符串资源取得)。 * @param title 指标名(由调用方从字符串资源取得)。
* @param value 主数值文本。 * @param value 主数值文本。
* @param unit 单位文本,如「步」「次/分」。 * @param unit 单位文本,如「次/分」,可为空串。
* @param modifier 外部修饰符。 * @param modifier 外部修饰符。
* @param onClick 点击回调,默认空实现(配合 HealthCard 的整卡可点击)。 * @param enabled 为 false 时数值降级为普通灰字(用于「未开启」这类占位),
* 避免「未开启」三个字用大号粗体排出来,看着比真实数值还重。
* @param onClick 点击回调;为 null 表示卡片不可点击 —— 不挂 clickable,因此既没有水波纹
* 也不会触发导航,用于「未开启」这类没有数值的指标卡(有值的卡才可跳转详情)。
*/ */
@Composable @Composable
fun MetricTile( fun MetricTile(
@@ -40,23 +48,37 @@ fun MetricTile(
value: String, value: String,
unit: String, unit: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onClick: () -> Unit = {}, enabled: Boolean = true,
onClick: (() -> Unit)? = null,
) { ) {
HealthCard(modifier = modifier, onClick = onClick) { HealthCard(
modifier = modifier,
onClick = onClick,
contentPadding = PaddingValues(14.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Icon(imageVector = icon, contentDescription = null, tint = iconTint, modifier = Modifier.size(18.dp)) IconBadge(icon = icon, tint = iconTint)
Text( Text(
text = title, text = title,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = TextSecondary, color = TextSecondary,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(start = 6.dp), modifier = Modifier.padding(start = 8.dp),
) )
} }
Column(modifier = Modifier.padding(top = 6.dp), verticalArrangement = Arrangement.Bottom) { Spacer(modifier = Modifier.height(10.dp))
Column(verticalArrangement = Arrangement.Bottom) {
Row(verticalAlignment = Alignment.Bottom) { Row(verticalAlignment = Alignment.Bottom) {
if (enabled) {
Text(text = value, style = MetricTileValueStyle) Text(text = value, style = MetricTileValueStyle)
} else {
Text(
text = value,
style = MaterialTheme.typography.bodyMedium,
color = TextSecondary,
)
}
Text( Text(
text = unit, text = unit,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
@@ -0,0 +1,272 @@
package com.placeholder.ringhealth.core.designsystem.component
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.placeholder.ringhealth.core.common.formatSleepAnchorOffset
import com.placeholder.ringhealth.core.common.minutesSinceSleepAnchor
import com.placeholder.ringhealth.core.designsystem.DividerColor
import com.placeholder.ringhealth.core.designsystem.MetricBlue
import com.placeholder.ringhealth.core.designsystem.TextSecondary
import com.placeholder.ringhealth.domain.model.SleepSession
import com.placeholder.ringhealth.domain.model.SleepSegment
import com.placeholder.ringhealth.domain.model.SleepStage
/** 每行日期标签的宽度,够放下「周三」两个字。 */
private val ROW_LABEL_WIDTH = 40.dp
/** 刻度文字所占的高度(文字本身 + 与图表之间的空隙)。 */
private val AXIS_LABEL_BAND = 20.dp
/** 刻度文字与图表下沿的间距。 */
private val AXIS_LABEL_GAP = 6.dp
/** 竖向网格线的粗细。比柱图的基线还细一档:它是背景参照,不该和时段条抢注意力。 */
private val GRID_WIDTH = 1.dp
/**
* 时段图圆角条的默认「底色」(分期模式下的轨道)。极浅蓝,与 MetricBlue 同色系,
* 让深睡/浅睡子矩形自然浮起来;AWAKE 用另一族暖色,更显眼。给 [StageTints.track] 用作兜底值。
*/
internal val DefaultStageTrackTint: Color = MetricBlue.copy(alpha = 0.10f)
/** 一分钟对应的毫秒数。本地常量,不引用 `MockDataProvider` 那份以免引入循环依赖。 */
private const val MILLIS_PER_MINUTE = 60_000L
/** 分期 → 着色。单色模式下没有 `stageTints`,仍用 [color];分期模式按 stage 取色。 */
private fun colorForStage(stage: SleepStage, tints: StageTints?, fallback: Color): Color = when (stage) {
SleepStage.DEEP -> tints?.deep ?: fallback
SleepStage.LIGHT -> tints?.light ?: fallback
SleepStage.AWAKE -> tints?.awake ?: fallback
}
/**
* 时段图内单条睡眠条的分期配色。**为 null 表示该图不画分期**,所有夜晚都按单色画。
*
* 三色通常取自同一色系的两个深浅(DEEP / LIGHT)+ 一个跳出该色系的 AWAKE 暖色 ——
* 「中途醒」是用户最该被告知的信息之一,让它和浅睡同色系会让它和「睡得浅」混淆;
* 暖色与睡眠的冷色调形成对比,眼睛会先抓到这里。颜色同时驱动头图分期条与时段图。
*
* `track` 是时段图内圆角条的底色(rail),子矩形落在这条底色之上 —— 与 AWAKE 区分开来。
* 不传则用 [DefaultStageTrackTint]。
*/
data class StageTints(
val deep: Color,
val light: Color,
val awake: Color,
val track: Color = DefaultStageTrackTint,
)
/**
* 睡眠时段图:**每晚一行**,一根圆角条从入睡时刻横到醒来时刻,横轴是钟点。
*
* 它回答的是柱状图回答不了的那个问题:「我这一周都是几点睡的」。
* 柱状图只画「睡了多久」,七根一样高的柱子看不出作息是不是在往后拖;
* 时段条的长短同样是时长,但**左端的位置**是入睡时刻,连起来看就是一条作息曲线。
*
* 横轴以晚上 20:00 为锚点跨过零点:一晚从 23:43 到 07:37,用「当天第几分钟」表示会得到
* 1423 → 457 这样一对反过来的数,画出来入睡点跑到起床点右边。换算见 `minutesSinceSleepAnchor`。
*
* **分期染色**:[SleepSession.stages] 非空的那几晚,条会被拆成多个染色子矩形;
* 传 [stageTints] 启用这一支。不传则所有夜晚走单色原行为 —— 多数 Mock 数据没有分期序列,
* 这是默认状态;有这层数据的夜晚再单独着色。M2 接入真机后会逐步补全序列,
* 把每一晚都变成可分期着色的版本。
*
* @param sessions 睡眠时段,按时间正序。
* @param color 单色模式下的时段条颜色;分期模式下充当未指定分期的兜底色。
* @param rowLabel 每行左侧的标签(如「周三」)。由调用方从 [SleepSession.wakeDate] 取,
* 组件本身不做资源查找;标成 `@Composable` 是因为调用方多半要 `stringResource`。
* @param stageTints 为非 null 时启用分期染色;为 null 时全部夜晚走单色。
* @param modifier 外部修饰符。
* @param rowHeight 时段条的高度。
* @param rowGap 行距。
*/
@Composable
fun SleepWindowChart(
sessions: List<SleepSession>,
color: Color,
rowLabel: @Composable (SleepSession) -> String,
stageTints: StageTints? = null,
modifier: Modifier = Modifier,
rowHeight: Dp = 16.dp,
rowGap: Dp = 10.dp,
) {
if (sessions.isEmpty()) return
// 横轴范围由这批数据自己决定:写死「20:00–12:00」的话,某晚熬到中午才睡,
// 那根条会被右端裁掉一截,看起来像睡得短,而它其实只是被画短了。
val axis = remember(sessions) { SleepAxis.of(sessions) }
val measurer = rememberTextMeasurer()
val tickStyle: TextStyle = MaterialTheme.typography.labelSmall.copy(color = TextSecondary)
val labelStyle = MaterialTheme.typography.labelSmall
// 行高 + 行距的展开只写一次,左侧标签列与画布共用,两边的行才对得齐。
val pitch = rowHeight + rowGap
val chartHeight = pitch * sessions.size - rowGap
Row(modifier = modifier.fillMaxWidth()) {
Column(modifier = Modifier.width(ROW_LABEL_WIDTH)) {
sessions.forEach { session ->
Box(
modifier = Modifier.height(rowHeight),
contentAlignment = Alignment.CenterStart,
) {
Text(
text = rowLabel(session),
style = labelStyle,
color = TextSecondary,
maxLines = 1,
)
}
Spacer(modifier = Modifier.height(rowGap))
}
}
Canvas(modifier = Modifier.weight(1f).height(chartHeight + AXIS_LABEL_BAND)) {
val chartPx = chartHeight.toPx()
val rowHeightPx = rowHeight.toPx()
val rowPitchPx = pitch.toPx()
/** 横轴偏移量(距锚点 20:00 的分钟数)→ 画布横坐标。 */
fun xOf(minute: Int): Float =
size.width * ((minute - axis.lo).toFloat() / axis.span.toFloat())
// 竖向网格线先画,时段条压在它上面。整张图共用一张画布而不是每晚一个,
// 网格线才能从上到下连成一条,而不是每晚断成一截。
axis.ticks.forEach { tick ->
val x = xOf(tick)
drawLine(
color = DividerColor,
start = Offset(x = x, y = 0f),
end = Offset(x = x, y = chartPx),
strokeWidth = GRID_WIDTH.toPx(),
)
}
sessions.forEachIndexed { index, session ->
val top = index * rowPitchPx
// 轴的上下界由这批数据算出,所以 coerce 在正常情况下不生效;
// 留着是防止将来有人把 axis 换成固定窗口后,某根条悄悄画到画布外面。
val start = xOf(minutesSinceSleepAnchor(session.sleepStart)).coerceIn(0f, size.width)
val end = xOf(minutesSinceSleepAnchor(session.sleepEnd)).coerceIn(0f, size.width)
if (end <= start) return@forEachIndexed
val stages = session.stages
if (stages.isNullOrEmpty() || stageTints == null) {
// 单色原行为:所有夜晚(默认)或没拿到分期数据的夜晚。
drawRoundRect(
color = color,
topLeft = Offset(x = start, y = top),
size = Size(width = end - start, height = rowHeightPx),
cornerRadius = CornerRadius(rowHeightPx / 2f, rowHeightPx / 2f),
)
return@forEachIndexed
}
// 分期模式:先画整条 track 浅色圆角条作底色(保住两端圆角 + 给未覆盖区域兜底),
// 再把每个分期子矩形用方角 `drawRect` 叠在上面 —— 子矩形落在圆角范围内,
// 视觉上左右两端仍是 pill 形。AWAKE 子矩形此时是独立暖色,从 track 上明显浮出,
// 不再是上一版的「看起来像渲染瑕疵的窄缝」。
val baseTint = stageTints.track
drawRoundRect(
color = baseTint,
topLeft = Offset(x = start, y = top),
size = Size(width = end - start, height = rowHeightPx),
cornerRadius = CornerRadius(rowHeightPx / 2f, rowHeightPx / 2f),
)
stages.forEach { segment ->
val segStartTs = session.sleepStart + segment.startOffsetMinute * MILLIS_PER_MINUTE
val segEndTs = session.sleepStart + segment.endOffsetMinute * MILLIS_PER_MINUTE
val segStart = xOf(minutesSinceSleepAnchor(segStartTs)).coerceIn(0f, size.width)
val segEnd = xOf(minutesSinceSleepAnchor(segEndTs)).coerceIn(0f, size.width)
if (segEnd <= segStart) return@forEach
drawRect(
color = colorForStage(segment.stage, stageTints, color),
topLeft = Offset(x = segStart, y = top),
size = Size(width = segEnd - segStart, height = rowHeightPx),
)
}
}
// 刻度文字画在画布内,而不是另起一行等宽 Text —— 等宽的文本框中心落在
// (i + 0.5) / n,刻度线落在 i / (n - 1),中间几档会差出小半个格。
// 在画布里量一次宽度再居中,刻度线和文字是同一个 xOf,对得严丝合缝。
val labelTop = chartPx + AXIS_LABEL_GAP.toPx()
axis.ticks.forEachIndexed { index, tick ->
val layout = measurer.measure(formatSleepAnchorOffset(tick), tickStyle)
val x = when (index) {
// 首尾两档贴边:居中的话各有一半落在画布外,会被裁掉半个字。
0 -> 0f
axis.ticks.last -> size.width - layout.size.width
else -> xOf(tick) - layout.size.width / 2f
}
drawText(textLayoutResult = layout, topLeft = Offset(x = x, y = labelTop))
}
}
}
}
/**
* 横轴窗口:`lo`–`hi` 是距锚点 20:00 的分钟数,[step] 是刻度间隔。
*
* 三者满足 `(hi - lo) % step == 0`,刻度因此均匀铺满整条轴 ——
* 首尾两档正好落在画布两端,中间每档等距,读起来不需要心算。
*/
private data class SleepAxis(val lo: Int, val hi: Int, val step: Int) {
val span: Int get() = hi - lo
/** 各刻度相对锚点的分钟数,从 [lo] 到 [hi] 递增。 */
val ticks: IntProgression get() = lo..hi step step
companion object {
/** 刻度间隔候选,从密到疏:2 小时、3 小时、4 小时、6 小时。 */
private val STEP_CANDIDATES = listOf(120, 180, 240, 360)
/** 刻度数量的上限。再多就该换更疏的间隔,否则刻度文字会挤成一团。 */
private const val MAX_TICKS = 8
/**
* 按数据选窗口:起点向下取整、终点向上取整到最近的刻度线。
*
* 取整是均匀刻度的前提 —— 直接拿最早入睡时刻当左端,第一个刻度就不在整点上,
* 后面每一档都跟着错位。
*/
fun of(sessions: List<SleepSession>): SleepAxis {
val rawLo = sessions.minOf { minutesSinceSleepAnchor(it.sleepStart) }
val rawHi = sessions.maxOf { minutesSinceSleepAnchor(it.sleepEnd) }
val step = STEP_CANDIDATES.firstOrNull { (rawHi - rawLo) / it + 3 <= MAX_TICKS }
?: STEP_CANDIDATES.last()
val lo = floorTo(rawLo, step)
// 所有夜晚长度都短于一个间隔时 hi 会等于 lo,轴退成一条线,除零得到 NaN。
// 至少留一格。
val hi = ceilTo(rawHi, step).let { if (it <= lo) lo + step else it }
return SleepAxis(lo = lo, hi = hi, step = step)
}
/** 向下取整到 [step] 的整数倍([value] 为负时也要正确,即 -60、180 → -180)。 */
private fun floorTo(value: Int, step: Int): Int = value - ((value % step) + step) % step
/** 向上取整到 [step] 的整数倍。 */
private fun ceilTo(value: Int, step: Int): Int = value + ((step - value % step) % step)
}
}
@@ -0,0 +1,52 @@
package com.placeholder.ringhealth.core.designsystem.component
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.placeholder.ringhealth.R
/**
* 子页顶栏:返回箭头 + 标题。
*
* 抽成组件而不是让三个子页各写一遍,是因为**底色这件事必须三个页面同时正确**:
* 顶栏默认容器色是 `surface`(本应用里是纯白),而页面底色是浅灰 `background` ——
* 两者不同色时,AppBar 会在浅灰页面上糊出一条白色横带,与下方内容形成硬分界线,
* 且状态栏那一条仍是页面底色,于是顶部出现「灰、白、灰」三层。
* 统一取 `background` 后顶栏与页面融为一体,页面只剩「浅灰底 + 白卡片」两层,干净得多。
*
* @param title 标题文案,由调用方从字符串资源取得(不在此处硬编码)。
* @param onBack 返回回调。
* @param modifier 外部修饰符。
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SubPageTopAppBar(
title: String,
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
TopAppBar(
modifier = modifier,
title = { Text(text = title) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.action_back),
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background,
),
)
}
@@ -1,5 +1,6 @@
package com.placeholder.ringhealth.core.navigation package com.placeholder.ringhealth.core.navigation
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.FitnessCenter import androidx.compose.material.icons.filled.FitnessCenter
@@ -10,6 +11,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -26,13 +28,20 @@ import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument import androidx.navigation.navArgument
import com.placeholder.ringhealth.R import com.placeholder.ringhealth.R
import com.placeholder.ringhealth.core.designsystem.NavIndicator
import com.placeholder.ringhealth.core.designsystem.NavSelected
import com.placeholder.ringhealth.core.designsystem.NavUnselected
import com.placeholder.ringhealth.domain.model.MetricType import com.placeholder.ringhealth.domain.model.MetricType
import com.placeholder.ringhealth.feature.device.DeviceScreen import com.placeholder.ringhealth.feature.device.DeviceScreen
import com.placeholder.ringhealth.feature.device.DeviceSettingsPlaceholder import com.placeholder.ringhealth.feature.device.DeviceSettingsScreen
import com.placeholder.ringhealth.feature.detail.MetricDetailPlaceholder import com.placeholder.ringhealth.feature.detail.MetricDetailScreen
import com.placeholder.ringhealth.feature.home.HomeScreen import com.placeholder.ringhealth.feature.home.HomeScreen
import com.placeholder.ringhealth.feature.profile.AboutScreen
import com.placeholder.ringhealth.feature.profile.HelpScreen
import com.placeholder.ringhealth.feature.profile.NotificationsScreen
import com.placeholder.ringhealth.feature.profile.ProfileScreen import com.placeholder.ringhealth.feature.profile.ProfileScreen
import com.placeholder.ringhealth.feature.workout.WorkoutHistoryPlaceholder import com.placeholder.ringhealth.feature.profile.SettingsScreen
import com.placeholder.ringhealth.feature.workout.WorkoutHistoryScreen
import com.placeholder.ringhealth.feature.workout.WorkoutScreen import com.placeholder.ringhealth.feature.workout.WorkoutScreen
/** 底部导航项定义。 */ /** 底部导航项定义。 */
@@ -49,13 +58,36 @@ private val BOTTOM_TABS = listOf(
BottomTab(Routes.PROFILE, R.string.tab_profile, Icons.Filled.Person), BottomTab(Routes.PROFILE, R.string.tab_profile, Icons.Filled.Person),
) )
/**
* 把当前路由归到它所属的底部 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
// 2026-09-10 晚:四个「我的」子页加进来,子页栈顶时底栏仍高亮「我的」。
// 之前一批 PROFILE_* 路由不存在,对应的「未开启」死链是 PROGRESS.md §3.2 报告的
// 已知结构性问题;本批一并收口。
Routes.PROFILE_SETTINGS,
Routes.PROFILE_NOTIFICATIONS,
Routes.PROFILE_ABOUT,
Routes.PROFILE_HELP,
-> Routes.PROFILE
else -> route
}
/** /**
* 应用主界面:Scaffold + 底部导航 + NavHost。 * 应用主界面:Scaffold + 底部导航 + NavHost。
*/ */
@Composable @Composable
fun RingHealthApp(navController: NavHostController = rememberNavController()) { fun RingHealthApp(navController: NavHostController = rememberNavController()) {
val backStackEntry by navController.currentBackStackEntryAsState() val backStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = backStackEntry?.destination?.route val currentRoute = tabRouteFor(backStackEntry?.destination?.route)
Scaffold( Scaffold(
containerColor = MaterialTheme.colorScheme.background, containerColor = MaterialTheme.colorScheme.background,
@@ -67,6 +99,16 @@ fun RingHealthApp(navController: NavHostController = rememberNavController()) {
onClick = { navController.navigateToTab(tab.route) }, onClick = { navController.navigateToTab(tab.route) },
icon = { Icon(imageVector = tab.icon, contentDescription = null) }, icon = { Icon(imageVector = tab.icon, contentDescription = null) },
label = { Text(stringResource(tab.labelRes)) }, label = { Text(stringResource(tab.labelRes)) },
// 颜色显式指定,不吃主题默认值。M3 的默认选中底色来自
// secondaryContainer,本应用虽已在 Theme 里把它改成浅绿,但底栏是全应用
// 最显眼的一处选中态,写明确一点,避免将来有人改主题时无意改到这里。
colors = NavigationBarItemDefaults.colors(
selectedIconColor = NavSelected,
selectedTextColor = NavSelected,
indicatorColor = NavIndicator,
unselectedIconColor = NavUnselected,
unselectedTextColor = NavUnselected,
),
) )
} }
} }
@@ -75,33 +117,65 @@ fun RingHealthApp(navController: NavHostController = rememberNavController()) {
NavHost( NavHost(
navController = navController, navController = navController,
startDestination = Routes.HOME, startDestination = Routes.HOME,
modifier = Modifier.padding(innerPadding), // consumeWindowInsets 必须跟着 padding 一起写:外层 Scaffold 没有 topBar,
// 它的 innerPadding.top 就是状态栏高度,若只 padding 不消费,这个 inset 会继续
// 往下传,详情页内层 Scaffold 的 TopAppBar 会再加一次状态栏内边距 ——
// AppBar 被推低约两个状态栏,上方露出一条背景色带,与四个 Tab 页明显不同。
// 消费后内层拿到的是已扣减的 inset,两个方向都安全(若外层早已消费,这里是 no-op)。
modifier = Modifier.padding(innerPadding).consumeWindowInsets(innerPadding),
) { ) {
composable(Routes.HOME) { composable(Routes.HOME) {
HomeScreen(onOpenDetail = { metric -> navController.navigate(Routes.detail(metric)) }) HomeScreen(onOpenDetail = { metric -> navController.navigate(Routes.detail(metric)) })
} }
composable(Routes.WORKOUT) { composable(Routes.WORKOUT) {
WorkoutScreen() WorkoutScreen(onOpenHistory = { navController.navigate(Routes.WORKOUT_HISTORY) })
} }
composable(Routes.DEVICE) { composable(Routes.DEVICE) {
DeviceScreen() DeviceScreen(onOpenSettings = { navController.navigate(Routes.DEVICE_SETTINGS) })
} }
composable(Routes.PROFILE) { composable(Routes.PROFILE) {
ProfileScreen() ProfileScreen(
onOpenSettings = { navController.navigate(Routes.PROFILE_SETTINGS) },
onOpenNotifications = { navController.navigate(Routes.PROFILE_NOTIFICATIONS) },
onOpenAbout = { navController.navigate(Routes.PROFILE_ABOUT) },
onOpenHelp = { navController.navigate(Routes.PROFILE_HELP) },
)
} }
// 「我的」四个子页:每个都带自己的 TopAppBar 与返回箭头,与详情页一致。
composable(Routes.PROFILE_SETTINGS) {
SettingsScreen(onBack = { navController.popBackStack() })
}
composable(Routes.PROFILE_NOTIFICATIONS) {
NotificationsScreen(onBack = { navController.popBackStack() })
}
composable(Routes.PROFILE_ABOUT) {
AboutScreen(onBack = { navController.popBackStack() })
}
composable(Routes.PROFILE_HELP) {
HelpScreen(onBack = { navController.popBackStack() })
}
// 两个子页都带自己的 TopAppBar 与返回箭头(原先没有,与详情页不一致 ——
// 详情页有 ←,这两页只能靠系统返回手势或点底部 Tab 离开)。
composable(Routes.DEVICE_SETTINGS) { composable(Routes.DEVICE_SETTINGS) {
DeviceSettingsPlaceholder() DeviceSettingsScreen(onBack = { navController.popBackStack() })
} }
composable(Routes.WORKOUT_HISTORY) { composable(Routes.WORKOUT_HISTORY) {
WorkoutHistoryPlaceholder() WorkoutHistoryScreen(onBack = { navController.popBackStack() })
} }
composable( composable(
route = Routes.DETAIL_PATTERN, route = Routes.DETAIL_PATTERN,
arguments = listOf(navArgument(Routes.DETAIL_ARG_METRIC) { type = NavType.StringType }), arguments = listOf(navArgument(Routes.DETAIL_ARG_METRIC) { type = NavType.StringType }),
) { entry -> ) { entry ->
val raw = entry.arguments?.getString(Routes.DETAIL_ARG_METRIC).orEmpty() // 参数无法解析属于编程错误,直接抛(ledger Ruling R22):这里原先是
val metric = MetricType.entries.firstOrNull { it.name == raw } ?: MetricType.STEP // `?: MetricType.STEP`,一旦路由模板与 Routes.detail() 拼串不一致,
MetricDetailPlaceholder(metric = metric) // 页面会静默显示步数的数据而不报错 —— 那是「错数据」而不是「响亮失败」。
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() })
} }
} }
} }
@@ -14,10 +14,22 @@ object Routes {
const val DEVICE_SETTINGS = "device/settings" const val DEVICE_SETTINGS = "device/settings"
const val WORKOUT_HISTORY = "workout/history" const val WORKOUT_HISTORY = "workout/history"
/** 指标详情路由模板,参数为 MetricType.name。 */ // 「我的」子页:四张真页面替代了阶段一首批的四个死链接
const val DETAIL_PATTERN = "detail/{metric}" // (PROGRESS.md §3.2 中报告的「未开启」占位状态)。
// 路由名按语义独立、不再嵌在 `profile/` 下 —— 子页与 Tab 用同一字符串前缀
// 容易让 `tabRouteFor` 误把它们归到「我的」Tab(实际上它们就是「我的」的内容,
// 这里归到「我的」才对,所以单独加分支,见 `RingHealthApp.tabRouteFor`)。
const val PROFILE_SETTINGS = "profile_settings"
const val PROFILE_NOTIFICATIONS = "profile_notifications"
const val PROFILE_ABOUT = "profile_about"
const val PROFILE_HELP = "profile_help"
/** 路由参数的键名。必须声明在 DETAIL_PATTERN 之前:模板引用它。 */
const val DETAIL_ARG_METRIC = "metric" const val DETAIL_ARG_METRIC = "metric"
/** 指标详情路由模板,参数为 MetricType.name。 */
const val DETAIL_PATTERN = "detail/{$DETAIL_ARG_METRIC}"
/** 拼出某个指标的详情路由。 */ /** 拼出某个指标的详情路由。 */
fun detail(metric: MetricType): String = "detail/${metric.name}" fun detail(metric: MetricType): String = "detail/${metric.name}"
} }
@@ -6,7 +6,13 @@ import com.placeholder.ringhealth.domain.model.HealthSummary
import com.placeholder.ringhealth.domain.model.MetricPoint import com.placeholder.ringhealth.domain.model.MetricPoint
import com.placeholder.ringhealth.domain.model.MetricType import com.placeholder.ringhealth.domain.model.MetricType
import com.placeholder.ringhealth.domain.model.RingDeviceState import com.placeholder.ringhealth.domain.model.RingDeviceState
import com.placeholder.ringhealth.domain.model.SleepSegment
import com.placeholder.ringhealth.domain.model.SleepSession
import com.placeholder.ringhealth.domain.model.SleepStage
import com.placeholder.ringhealth.domain.model.WorkoutRecord import com.placeholder.ringhealth.domain.model.WorkoutRecord
import com.placeholder.ringhealth.domain.model.valueOf
import java.util.Calendar
import kotlin.math.roundToInt
import kotlin.random.Random import kotlin.random.Random
/** /**
@@ -22,31 +28,68 @@ class MockDataProvider(
private val now: () -> Long = System::currentTimeMillis, private val now: () -> Long = System::currentTimeMillis,
) { ) {
/** 今日概览:固定演示值。血压为 null 以演示“未开启”占位态。 */ /**
fun todaySummary(): HealthSummary = HealthSummary( * 今日概览:固定演示值,但**未开启的指标一律为 null**。
*
* @param enabledMetrics 当前开启的指标集合,来自功能开关(见 [DeviceFunctionSetting])。
* 关掉的指标在这里被置空,首页那张卡才会显示「未开启」——
* 否则用户在功能设置页翻了开关,首页纹丝不动,开关就是个摆设。
* **刻意不给默认值**:默认「全都开着」会让漏传参数的调用方看起来一切正常,
* 而那正是这次要修的病。
*
* 计步与体温没有开关([MetricType.switchId] 为 null),恒定给值。
*/
fun todaySummary(enabledMetrics: Set<MetricType>): HealthSummary =
HealthSummary(
steps = 8426, steps = 8426,
stepGoal = 10000, stepGoal = 10000,
calories = 327, calories = 327,
distanceKm = 6.21, distanceKm = 6.21,
heartRate = 72, heartRate = if (MetricType.HEART_RATE in enabledMetrics) 72 else null,
sleepMinutes = 452, // 睡眠时长取自昨晚那条 [SleepSession],不另写一个常量:
bloodOxygen = 98, // 头图写 452 分钟、时段图上昨晚那根条却是另一个长度,是同一屏里最刺眼的一种自相矛盾。
stress = 34, sleepMinutes = if (MetricType.SLEEP in enabledMetrics) demoLastNight().sleepMinutes else null,
temperature = 36.5, bloodOxygen = if (MetricType.BLOOD_OXYGEN in enabledMetrics) 98 else null,
bloodPressure = null, stress = if (MetricType.STRESS in enabledMetrics) 34 else null,
temperature = if (MetricType.TEMPERATURE in enabledMetrics) 36.5 else null,
// 血压在概览里是「收缩压/舒张压」字符串。开着给一个演示值,关着为 null ——
// 首页那张卡据此在「118/76」和「未开启」之间切换。
bloodPressure = if (MetricType.BLOOD_PRESSURE in enabledMetrics) DEMO_BLOOD_PRESSURE else null,
// 今日活动累计分钟数:与 steps/calories 同性质,**不属于任何一个 MetricType**,
// 因此不参与 enabledMetrics 的过滤 —— 它没有开关页可关,给 null 会变成「未开启」
// 跟步数一样荒谬。值是演示用,M3 接 SDK 后由真实活动汇总接口填入。
activeMinutes = DEMO_ACTIVE_MINUTES,
) )
/** /**
* 指定指标的历史数据:WEEK 返回 7 个点,MONTH 返回 30 个点。 * 指定指标的历史数据:WEEK 返回 7 个点,MONTH 返回 30 个点。
* 时间戳按“每点间隔一天”倒推,值在指标合理区间内波动。 * 时间戳按“每点间隔一天”倒推,值在指标合理区间内波动。
*
* **最后一个点(时间戳 = 现在,即“今天”)的值取自 [todaySummary]**,不由随机序列生成。
* 详情页的头图显示概览里的当前值、趋势图的最后一根柱子显示今天的值,两者本该相同;
* 各生成各的会让页面出现「头图写心率 72、图上今天却是 96」这种矛盾,
* 而且两个数字看起来都很确定,用户无从判断该信哪个。
*
* **睡眠是例外:它的点由 [sleepSessions] 折算而来**,不走下面那套随机序列。
* 同一个指标有两套数据源,就会出现「头图写 7 小时 32 分、时段图上却是一根 6 小时的条」——
* 而这正是睡眠页最显眼的两块内容。
*/ */
fun metricHistory(metric: MetricType, range: DateRange): List<MetricPoint> { fun metricHistory(
metric: MetricType,
range: DateRange,
enabledMetrics: Set<MetricType>,
): List<MetricPoint> {
if (metric == MetricType.SLEEP) {
return sleepSessions(range).map {
MetricPoint(timestamp = it.wakeDate, value = it.sleepMinutes.toDouble())
}
}
val count = if (range == DateRange.WEEK) 7 else 30 val count = if (range == DateRange.WEEK) 7 else 30
val random = Random(seed + metric.ordinal * 100L + range.ordinal) val random = Random(seed + metric.ordinal * 100L + range.ordinal)
val (min, max) = valueRange(metric) val (min, max) = valueRange(metric)
val current = now() val current = now()
val dayMillis = 24L * 60 * 60 * 1000 val dayMillis = 24L * 60 * 60 * 1000
return (0 until count).map { index -> val points = (0 until count).map { index ->
val progress = index.toFloat() / (count - 1).coerceAtLeast(1) val progress = index.toFloat() / (count - 1).coerceAtLeast(1)
val value = min + (max - min) * progress + random.nextDouble(-1.0, 1.0) * (max - min) * 0.15 val value = min + (max - min) * progress + random.nextDouble(-1.0, 1.0) * (max - min) * 0.15
MetricPoint( MetricPoint(
@@ -54,8 +97,93 @@ class MockDataProvider(
value = value.coerceIn(min, max), value = value.coerceIn(min, max),
) )
} }
// 指标未开启(概览里为 null)时保留随机序列:没有「今日值」可对齐。
// 这些点不会显示 —— 未开启的指标,详情页整页走「未开启」分支,不画图(见 MetricDetailScreen)。
val todayValue = todaySummary(enabledMetrics).valueOf(metric) ?: return points
return points.dropLast(1) + MetricPoint(timestamp = current, value = todayValue)
} }
/**
* 睡眠时段:WEEK 返回最近 7 晚,MONTH 返回最近 30 晚,按时间正序(最后一晚是昨晚)。
*
* 与其他指标不同,这里的**时刻本身就是数据**,所以不能用「现在往前减 N 天」那种做法 ——
* 那会让每一晚的入睡时刻都等于「此刻减若干小时」,于是七根条在时段图上排成整齐的一列,
* 看不出作息差异,而「作息差在哪」正是这张图存在的理由。这里按日历把每晚上落到
* 早上 6–9 点之间的某个时刻,入睡时刻再由卧床时长倒推。
*
* 昨晚(daysAgo = 0)用固定值 [demoLastNight],与 [todaySummary] 给出的睡眠时长对齐。
*/
fun sleepSessions(range: DateRange): List<SleepSession> {
val count = if (range == DateRange.WEEK) 7 else 30
return (count - 1 downTo 0).map { daysAgo ->
if (daysAgo == 0) {
demoLastNight()
} else {
// 每晚一个独立种子:结果与生成顺序无关,多取几晚也不会让已有那几晚变样。
randomSession(Random(seed + SLEEP_SEED_OFFSET + daysAgo), daysAgo)
}
}
}
/**
* 昨晚(今天凌晨那次睡眠)。入睡 23:43、醒来 07:37,睡着 7 小时 32 分。
*
* 它是一个**写死的演示值**,和步数 8426、心率 72 同一性质;随机生成的那几晚负责
* 「作息有波动」的观感,昨晚这一条负责让第一屏的数字好看且固定。
* 时长(452 分钟)就是 `demoLastNight().sleepMinutes`,见 [todaySummary]。
*
* 此外**只有这一晚带 [SleepSession.stages]** —— 24 小时采样里 6 晚没有这层数据,
* 时段图按单色画;昨晚给了它,时段图把这一行拆成多个染色子矩形,让用户能直观看到
* 「夜里深睡 / 浅睡 / 中途醒是怎么交替的」。各段时长加起来正好等于上面的累计三件套,
* 否则头图分期条与时段图条内分期的颜色块会对不上。
*/
private fun demoLastNight(): SleepSession {
val wake = localTimeAt(daysAgo = 0, hour = DEMO_WAKE_HOUR, minute = DEMO_WAKE_MINUTE)
return SleepSession(
wakeDate = wake,
sleepStart = wake - DEMO_IN_BED_MINUTES * MILLIS_PER_MINUTE,
sleepEnd = wake,
deepMinutes = DEMO_DEEP_MINUTES,
lightMinutes = DEMO_LIGHT_MINUTES,
awakeMinutes = DEMO_AWAKE_MINUTES,
stages = DEMO_LAST_NIGHT_STAGES,
)
}
/** 随机构造一晚:睡着 350–500 分钟,夜里清醒 0–40 分钟,其中深睡占 18%–28%。 */
private fun randomSession(random: Random, daysAgo: Int): SleepSession {
val asleep = random.nextInt(350, 501)
val awake = random.nextInt(0, 41)
// 入睡时刻倒推:先定醒来时刻,再往前挪「卧床时长」。
// 醒来时刻在 6–9 点之间随机,作息差异才看得出来。
val wake = localTimeAt(daysAgo, hour = random.nextInt(6, 10), minute = random.nextInt(0, 60))
val deep = (asleep * random.nextDouble(DEEP_RATIO_MIN, DEEP_RATIO_MAX)).roundToInt()
return SleepSession(
wakeDate = wake,
sleepStart = wake - (asleep + awake) * MILLIS_PER_MINUTE,
sleepEnd = wake,
deepMinutes = deep,
lightMinutes = asleep - deep,
awakeMinutes = awake,
)
}
/**
* 时间戳:今天往前 [daysAgo] 天的 [hour]:[minute](本地时区)。
*
* 必须走 Calendar 而不是 `now() - daysAgo * 86400_000` —— 后者只会平移,改不了「几点」,
* 生成的每个时刻都带着当前这一秒的分钟数。跨夏令时的那种一天也不是 86400 秒。
*/
private fun localTimeAt(daysAgo: Int, hour: Int, minute: Int): Long =
Calendar.getInstance().apply {
timeInMillis = now()
add(Calendar.DAY_OF_YEAR, -daysAgo)
set(Calendar.HOUR_OF_DAY, hour)
set(Calendar.MINUTE, minute)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}.timeInMillis
/** 运动记录:固定 5 条演示数据,倒序(最近的在最前)。 */ /** 运动记录:固定 5 条演示数据,倒序(最近的在最前)。 */
fun workouts(): List<WorkoutRecord> { fun workouts(): List<WorkoutRecord> {
val random = Random(seed + 7) val random = Random(seed + 7)
@@ -83,13 +211,40 @@ class MockDataProvider(
charging = false, charging = false,
) )
/** 五个功能开关,对应协议中的自动监测能力。 */ /**
* 五个功能开关,对应协议中的自动监测能力。
*
* id 一律取 [MetricType.switchId],不在这里手写字符串 —— 手写就等于把「开关 ↔ 指标」
* 这个对应关系又抄了一份,抄错一份不会报错,只会让某个开关悄悄失效。
*
* 默认值:血压关着,用来演示「未开启」占位态;其余开着。
* 压力此前默认是关的,但 [todaySummary] 里 `stress = 34` 一直有值 ——
* 那正是「开关与数据两套系统」留下的痕迹,现在两者由同一个开关决定,默认值必须自洽。
*/
fun defaultFunctionSettings(): List<DeviceFunctionSetting> = listOf( fun defaultFunctionSettings(): List<DeviceFunctionSetting> = listOf(
DeviceFunctionSetting("heart_rate", "自动心率监测", "每 10 分钟测量一次", true), setting(MetricType.HEART_RATE, "自动心率监测", "每 10 分钟测量一次", enabled = true),
DeviceFunctionSetting("blood_oxygen", "自动血氧监测", "夜间持续监测", true), setting(MetricType.BLOOD_OXYGEN, "自动血氧监测", "夜间持续监测", enabled = true),
DeviceFunctionSetting("sleep", "睡眠监测", "自动识别睡眠时段", true), setting(MetricType.SLEEP, "睡眠监测", "自动识别睡眠时段", enabled = true),
DeviceFunctionSetting("stress", "压力监测", "全天候压力评估", false), setting(MetricType.STRESS, "压力监测", "全天候压力评估", enabled = true),
DeviceFunctionSetting("blood_pressure", "血压监测", "需在 App 内手动校准", false), setting(MetricType.BLOOD_PRESSURE, "血压监测", "需在 App 内手动校准", enabled = false),
)
/**
* 构造一个开关项,id 从指标上取。
*
* 常开指标([MetricType.switchId] 为 null)不该出现在开关列表里,
* 传进来会立刻抛异常而不是生成一个 id 为 "null" 的幽灵开关。
*/
private fun setting(
metric: MetricType,
title: String,
subtitle: String,
enabled: Boolean,
): DeviceFunctionSetting = DeviceFunctionSetting(
id = requireNotNull(metric.switchId) { "$metric 是常开指标,不该出现在功能开关列表里" },
title = title,
subtitle = subtitle,
enabled = enabled,
) )
/** 各指标的取值区间,用于生成合理的历史数据。 */ /** 各指标的取值区间,用于生成合理的历史数据。 */
@@ -106,5 +261,74 @@ class MockDataProvider(
companion object { companion object {
/** 默认随机种子,保证演示数据稳定。 */ /** 默认随机种子,保证演示数据稳定。 */
const val DEFAULT_SEED: Long = 20260910L const val DEFAULT_SEED: Long = 20260910L
/** 血压开着时的演示读数(概览里是「收缩压/舒张压」形式)。 */
const val DEMO_BLOOD_PRESSURE: String = "118/76"
/**
* 今日活动累计分钟数演示值。
*
* 与 [DEMO_BLOOD_PRESSURE] 同一性质:固定写死,让第一屏有数字;M3 接 SDK 后整体替换。
* 47 分钟是「轻办公 + 通勤走路 + 一点通勤骑」的合理区间,比 0 像真人,比 120 不会让用户
* 误以为自己达标。
*/
const val DEMO_ACTIVE_MINUTES: Int = 47
/** 睡眠时段的随机种子偏移,避开 [metricHistory] 用的 `ordinal * 100`。 */
private const val SLEEP_SEED_OFFSET = 31L
private const val MILLIS_PER_MINUTE = 60_000L
/** 深睡占比区间。真实设备大约在这个范围,太低会让「深睡 3 分钟」这种数出现在演示里。 */
private const val DEEP_RATIO_MIN = 0.18
private const val DEEP_RATIO_MAX = 0.28
/** 昨晚的醒来时刻:07:37。 */
private const val DEMO_WAKE_HOUR = 7
private const val DEMO_WAKE_MINUTE = 37
/** 昨晚分期:深睡 96 + 浅睡 356 = 睡着 452 分钟,另有 22 分钟夜里清醒。 */
private const val DEMO_DEEP_MINUTES = 96
private const val DEMO_LIGHT_MINUTES = 356
private const val DEMO_AWAKE_MINUTES = 22
/** 昨晚卧床时长 = 睡着 452 + 清醒 22,由 07:37 倒推得入睡时刻 23:43。 */
private const val DEMO_IN_BED_MINUTES = DEMO_DEEP_MINUTES + DEMO_LIGHT_MINUTES + DEMO_AWAKE_MINUTES
/**
* 昨晚的分期序列(相对 `sleepStart` 的分钟偏移)。
*
* 三段时间加起来严格等于 [DEMO_DEEP_MINUTES] / [DEMO_LIGHT_MINUTES] / [DEMO_AWAKE_MINUTES]:
* 深睡 48 + 35 + 13 = 96
* 浅睡 30 + 60 + 60 + 44 + 57 + 105 = 356
* 清醒 5 + 5 + 7 + 5 = 22
* 否则头图分期条与时段图条内分期的色块会对不上。形态上模拟「入睡后两轮深睡 + 一段长浅睡
* (含 REM)+ 中途两段短暂清醒」的常见曲线。
*/
private val DEMO_LAST_NIGHT_STAGES: List<SleepSegment> = listOf(
// 入睡后短暂清醒 → 浅睡入睡
SleepSegment(SleepStage.AWAKE, 0, 5),
SleepSegment(SleepStage.LIGHT, 5, 35),
// 第一轮深睡
SleepSegment(SleepStage.DEEP, 35, 83),
// 第一轮长浅睡
SleepSegment(SleepStage.LIGHT, 83, 143),
// 第二轮深睡(比第一轮短)
SleepSegment(SleepStage.DEEP, 143, 178),
// 第二轮浅睡
SleepSegment(SleepStage.LIGHT, 178, 238),
// 第一次中途醒(5 分钟)
SleepSegment(SleepStage.AWAKE, 238, 243),
SleepSegment(SleepStage.LIGHT, 243, 287),
// 第三轮深睡(很短)
SleepSegment(SleepStage.DEEP, 287, 300),
SleepSegment(SleepStage.LIGHT, 300, 357),
// 第二次中途醒(7 分钟)
SleepSegment(SleepStage.AWAKE, 357, 364),
// 临近醒来的长浅睡(含 REM 密集期)
SleepSegment(SleepStage.LIGHT, 364, 469),
// 最终清醒
SleepSegment(SleepStage.AWAKE, 469, 474),
)
} }
} }
@@ -1,30 +1,57 @@
package com.placeholder.ringhealth.data.mock package com.placeholder.ringhealth.data.mock
import com.placeholder.ringhealth.domain.model.DateRange 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.HealthSummary
import com.placeholder.ringhealth.domain.model.MetricPoint import com.placeholder.ringhealth.domain.model.MetricPoint
import com.placeholder.ringhealth.domain.model.MetricType import com.placeholder.ringhealth.domain.model.MetricType
import com.placeholder.ringhealth.domain.model.SleepSession
import com.placeholder.ringhealth.domain.model.WorkoutRecord import com.placeholder.ringhealth.domain.model.WorkoutRecord
import com.placeholder.ringhealth.domain.model.enabledMetrics
import com.placeholder.ringhealth.domain.repository.HealthDataRepository import com.placeholder.ringhealth.domain.repository.HealthDataRepository
import com.placeholder.ringhealth.domain.repository.RingDeviceRepository
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
/** /**
* Mock 健康数据仓库。模拟 200ms 网络/设备延迟,便于阶段一验证加载态。 * Mock 健康数据仓库。模拟 200ms 网络/设备延迟,便于阶段一验证加载态。
*
* **它订阅功能开关。** 这是「开关能影响首页」的关键:原先健康数据与开关状态是
* `AppContainer` 里两个互不引用的字段,用户打开「血压监测」只翻动了开关自己那一行,
* 这边照样发射写死的概览,首页因而纹丝不动。现在数据由开关算出来,
* 开关一变就重发。
*
* @param functionSettings 功能开关流,来自 [RingDeviceRepository]。
* 注意依赖的是 `StateFlow`(会立刻给出当前值)而不是一个一次性读取 ——
* 否则开关变化传不过来,又会退回原样。
*/ */
class MockHealthDataRepository( class MockHealthDataRepository(
private val functionSettings: StateFlow<List<DeviceFunctionSetting>>,
private val provider: MockDataProvider = MockDataProvider(), private val provider: MockDataProvider = MockDataProvider(),
) : HealthDataRepository { ) : HealthDataRepository {
// flow { delay(...) } 先给出加载态,再 emitAll 接上开关流:初值延迟 200ms 到达
// (便于验证 loading),此后每次翻开关都立刻重发。
override fun observeTodaySummary(): Flow<HealthSummary> = flow { override fun observeTodaySummary(): Flow<HealthSummary> = flow {
delay(MOCK_DELAY_MILLIS) delay(MOCK_DELAY_MILLIS)
emit(provider.todaySummary()) emitAll(functionSettings.map { provider.todaySummary(it.enabledMetrics()) })
} }
override fun observeMetricHistory(metric: MetricType, range: DateRange): Flow<List<MetricPoint>> = flow { override fun observeMetricHistory(metric: MetricType, range: DateRange): Flow<List<MetricPoint>> = flow {
delay(MOCK_DELAY_MILLIS) delay(MOCK_DELAY_MILLIS)
emit(provider.metricHistory(metric, range)) emitAll(functionSettings.map { provider.metricHistory(metric, range, it.enabledMetrics()) })
}
// 与 observeMetricHistory 同一口径:**照常发数据,判开关是页面的事**(见 MetricDetailScreen
// 的 enabled 分支)。不在这里按开关置空,是因为长度/取值是随 range 与种子固定的,
// 在这里分叉会让「关了再打开」拿到一段与之前不同的序列,而 Mock 承诺的是可复现。
override fun observeSleepSessions(range: DateRange): Flow<List<SleepSession>> = flow {
delay(MOCK_DELAY_MILLIS)
emitAll(functionSettings.map { provider.sleepSessions(range) })
} }
override fun observeWorkouts(): Flow<List<WorkoutRecord>> = flow { override fun observeWorkouts(): Flow<List<WorkoutRecord>> = flow {
@@ -33,6 +33,14 @@ class MockRingDeviceRepository(
_deviceState.value = provider.deviceState() _deviceState.value = provider.deviceState()
} }
/**
* 切换某个功能开关。
*
* **必须返回新列表**(`map` / `+` 等),不要原地改写同一个 `MutableList`:
* `DeviceViewModel` 的 `stateIn(initialValue = …)` 在**构造时**就把当时的 List 引用冻结了,
* 而 `DeviceViewModelTest` 第一条用例的鉴别力正依赖于此 —— 若这里改成原地改写,
* `initialValue` 会被连带改动,那条断言就退化成自我确认,测试会静默失去意义。
*/
override fun setFunctionEnabled(id: String, enabled: Boolean) { override fun setFunctionEnabled(id: String, enabled: Boolean) {
_functionSettings.update { settings -> _functionSettings.update { settings ->
settings.map { if (it.id == id) it.copy(enabled = enabled) else it } settings.map { if (it.id == id) it.copy(enabled = enabled) else it }
@@ -1,14 +1,26 @@
package com.placeholder.ringhealth.domain.model package com.placeholder.ringhealth.domain.model
/** 健康指标类型。路由与详情页以此区分。 */ /**
enum class MetricType { * 健康指标类型。路由与详情页以此区分。
STEP, *
HEART_RATE, * @param switchId 该指标对应 [DeviceFunctionSetting.id] 里的哪个开关;**为 null 表示常开** ——
SLEEP, * 计步与体温由戒指持续采集,没有「关掉」这个选项。
BLOOD_OXYGEN, *
STRESS, * 这个对应关系必须显式写出来,不能靠两边字符串长得像来对齐。原先它只存在于巧合里
TEMPERATURE, * (开关 id 是 `"blood_pressure"`、枚举是 `BLOOD_PRESSURE`,撞得上),没有一行代码声明过
BLOOD_PRESSURE, * 它们是同一件事 —— 于是开关状态和健康数据成了两个互不相干的系统:用户在功能设置页打开
* 「血压监测」,首页那张卡照样写「未开启」,因为首页读的数据根本不认识开关。
*/
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"),
} }
/** 历史数据的时间范围。 */ /** 历史数据的时间范围。 */
@@ -27,6 +39,10 @@ data class RingDeviceState(
/** /**
* 今日健康概览。字段为 null 表示该指标未开启或暂无数据,UI 需展示“未开启”占位而非 0。 * 今日健康概览。字段为 null 表示该指标未开启或暂无数据,UI 需展示“未开启”占位而非 0。
*
* @param activeMinutes 今日活动累计分钟数(高强度活动 + 中等强度活动合并)。
* 与 [steps] / [calories] 同属「每日总量」型数据,**不属于任何一个 [MetricType]**
* (首页三件套横排里展示,M3 接 SDK 后由真实活动汇总接口填入)。
*/ */
data class HealthSummary( data class HealthSummary(
val steps: Int, val steps: Int,
@@ -39,11 +55,98 @@ data class HealthSummary(
val stress: Int?, val stress: Int?,
val temperature: Double?, val temperature: Double?,
val bloodPressure: String?, val bloodPressure: String?,
val activeMinutes: Int = 0,
) )
/**
* 取某个指标在今日概览里的值;该指标未开启或暂无数据时返回 null。
*
* 存在的理由是**让「当前值」与「历史点」用同一把尺子**:详情页的头图取概览里的当前值,
* 趋势图的最后一个点代表今天,两者说的本该是同一件事。数据源若各给各的,
* 页面就会出现「头图写心率 72、图上今天却是 96」这种自相矛盾 ——
* 用户没法判断该信哪个,而两个数字看起来都很确定。
*/
fun HealthSummary.valueOf(metric: MetricType): Double? = when (metric) {
MetricType.STEP -> steps.toDouble()
MetricType.HEART_RATE -> heartRate?.toDouble()
MetricType.SLEEP -> sleepMinutes?.toDouble()
MetricType.BLOOD_OXYGEN -> bloodOxygen?.toDouble()
MetricType.STRESS -> stress?.toDouble()
MetricType.TEMPERATURE -> temperature
// 血压在概览里是「收缩压/舒张压」形式的字符串,取不出单一数值。
// M3 把 bloodPressure 改成结构化的两个数值后再补上这一支。
MetricType.BLOOD_PRESSURE -> null
}
/** 一条历史数据点。 */ /** 一条历史数据点。 */
data class MetricPoint(val timestamp: Long, val value: Double) data class MetricPoint(val timestamp: Long, val value: Double)
/**
* 一段睡眠分期:起点到终点(相对于 [SleepSession.sleepStart] 的分钟数)+ 哪一期。
*
* 时段图渲染时按这一份序列把一根条拆成多个染色子矩形。没有这层序列时只能画
* 「累计深睡 96 分钟」这样的比例块 —— 那是一段并不存在的分期位置,比不画更容易让人当真
* (见 `SleepWindowChart` 的 KDoc)。有这份序列才能画「23:43–23:48 清醒、23:48–00:18 浅睡」这样的真顺序。
*
* @param stage 这一段是哪个分期。
* @param startOffsetMinute 起点相对 [SleepSession.sleepStart] 的分钟数。
* @param endOffsetMinute 终点相对 [SleepSession.sleepStart] 的分钟数。必须大于 [startOffsetMinute]。
*/
data class SleepSegment(
val stage: SleepStage,
val startOffsetMinute: Int,
val endOffsetMinute: Int,
)
/**
* 一晚睡眠。
*
* 睡眠与其他指标不一样:它在一天里占的是**一段区间**,不是一个数。
* [MetricPoint] 只装得下一个 `Double`,于是「睡了 7 小时 32 分」之外什么都说不了 ——
* 而「从几点睡到几点」恰恰是作息规律的关键,也是用户真正会问的问题
* (「我不知道一天内具体什么时候睡的觉」)。所以睡眠单独有这个类型。
*
* @param wakeDate 醒来的时刻;这条记录算在哪一天,以它为准。
* @param sleepStart 入睡时刻。
* @param sleepEnd 醒来时刻。
* @param deepMinutes 深睡时长。
* @param lightMinutes 浅睡时长。
* @param awakeMinutes 夜里清醒的累计时长(中途醒来)。
* @param stages 分期序列(相对 [sleepStart] 的分钟偏移)。**为 null 表示该晚不画分期染色**,
* 时段图按单色画这根条 —— 大多数 Mock 数据没有这层序列,M2 接入真机后会逐步补上。
*/
data class SleepSession(
val wakeDate: Long,
val sleepStart: Long,
val sleepEnd: Long,
val deepMinutes: Int,
val lightMinutes: Int,
val awakeMinutes: Int,
val stages: List<SleepSegment>? = null,
) {
/**
* 实际睡着的时长 = 深睡 + 浅睡,**不含夜里清醒**。
* 首页那张卡和详情页头图显示的都是这个数。
*/
val sleepMinutes: Int get() = deepMinutes + lightMinutes
/** 卧床时长 = 睡着 + 夜里清醒,等于 [sleepStart] 到 [sleepEnd] 的跨度。 */
val inBedMinutes: Int get() = sleepMinutes + awakeMinutes
}
/** 睡眠分期。头图的分期条与图例按它取色取文案。 */
enum class SleepStage { DEEP, LIGHT, AWAKE }
/**
* 某一期的时长(分钟)。注意各期时长**不表示先后顺序** ——
* 真实的分期是交替出现的,而这里只有累计时长。
*/
fun SleepSession.minutesOf(stage: SleepStage): Int = when (stage) {
SleepStage.DEEP -> deepMinutes
SleepStage.LIGHT -> lightMinutes
SleepStage.AWAKE -> awakeMinutes
}
/** 一条运动记录。 */ /** 一条运动记录。 */
data class WorkoutRecord( data class WorkoutRecord(
val id: String, val id: String,
@@ -61,3 +164,29 @@ data class DeviceFunctionSetting(
val subtitle: String, val subtitle: String,
val enabled: Boolean, val enabled: Boolean,
) )
/**
* 从开关列表算出「当前开着哪些指标」。
*
* 常开指标([MetricType.switchId] 为 null)永远算作开启 —— 它们不在开关列表里,
* 若按「找不到对应开关就算关」处理,计步和体温会凭空变成「未开启」。
*
* 开关 id 找不到对应指标时被忽略:那说明协议里有我们还没接入的功能,
* 不该让整个首页崩掉。
*/
fun List<DeviceFunctionSetting>.enabledMetrics(): Set<MetricType> =
MetricType.entries.filterTo(mutableSetOf()) { metric ->
val switchId = metric.switchId ?: return@filterTo true
any { it.id == switchId && it.enabled }
}
/**
* 该指标当前是否开启。
*
* 判据是「今日概览里有没有这个指标的值」—— 概览的字段由功能开关生成
* (见 `MockHealthDataRepository`),所以「概览里取不到值」与「开关是关的」是同一件事。
*
* 血压要单独判:它的值在概览里是「收缩压/舒张压」字符串,[valueOf] 按设计取不出单一数值。
*/
fun HealthSummary.isEnabled(metric: MetricType): Boolean =
if (metric == MetricType.BLOOD_PRESSURE) bloodPressure != null else valueOf(metric) != null
@@ -0,0 +1,63 @@
package com.placeholder.ringhealth.domain.model
/**
* 指标参考区间与判读基线。
*
* 这些是**领域数据**,不是展示细节 —— 「这个值算不算正常」的回答依据不该写死在 UI 层,
* 否则将来接真实数据、按用户画像下发区间时,得去七个页面里把常量一个个抠出来。
*
* 取值为通用成人临床参考值,**仅供演示,不构成医疗建议**。
* M3 接入真实数据后改为由数据层按用户画像提供。
*/
object MetricReference {
/** 成人静息心率正常区间(次/分)。 */
val HEART_RATE_NORMAL = 60.0..100.0
/**
* 心率区间条的横轴范围(次/分)。
*
* 比 [HEART_RATE_NORMAL] 左右各留出 20 的余量:若横轴恰好等于正常区间,
* 落在 60 或 100 附近的点会紧贴条的边缘,看不出它到底是「刚好在界内」还是「已经出界」。
*/
val HEART_RATE_AXIS = 40.0..120.0
/** 血氧饱和度下限(%)。低于此值的日子需要被单独标出并计数。 */
const val BLOOD_OXYGEN_MIN = 95.0
/**
* 血氧阈值带的下沿(%)。
*
* 比 [BLOOD_OXYGEN_MIN] 低的点仍要能画在带子上(否则低于阈值的点会集体贴边,
* 看不出低了多少),所以下沿再往下留 5。
*/
const val BLOOD_OXYGEN_FLOOR = 90.0
/** 体温基线(℃)。偏离条以它为中线。 */
const val TEMPERATURE_BASELINE = 36.5
/**
* 体温偏离条的半幅(℃),即纵轴范围是 基线 ± 本值。
*
* 取 1.0 而不是更大:演示数据的体温落在 36.1–36.9(偏离 ±0.4),
* 半幅 1.0 时点会落在中线到边缘的 40% 处,偏离看得出来;半幅 1.5 就被压到中间一小团了。
*/
const val TEMPERATURE_SPAN = 1.0
}
/** 压力等级。 */
enum class StressLevel { RELAXED, NORMAL, MEDIUM, HIGH }
/**
* 压力值 → 等级。
*
* 抽成独立的纯函数,是为了让「按等级给柱子着色」「统计放松天数」和「显示等级文案」
* 三处用的是**同一份**分档逻辑 —— 此前文案分档藏在 `Formatters.stressLevelLabelRes` 里,
* 一旦别处再写一份阈值,两边迟早对不上。
*/
fun stressLevelOf(stress: Double): StressLevel = when {
stress < 30.0 -> StressLevel.RELAXED
stress < 60.0 -> StressLevel.NORMAL
stress < 80.0 -> StressLevel.MEDIUM
else -> StressLevel.HIGH
}
@@ -6,6 +6,7 @@ import com.placeholder.ringhealth.domain.model.HealthSummary
import com.placeholder.ringhealth.domain.model.MetricPoint import com.placeholder.ringhealth.domain.model.MetricPoint
import com.placeholder.ringhealth.domain.model.MetricType import com.placeholder.ringhealth.domain.model.MetricType
import com.placeholder.ringhealth.domain.model.RingDeviceState import com.placeholder.ringhealth.domain.model.RingDeviceState
import com.placeholder.ringhealth.domain.model.SleepSession
import com.placeholder.ringhealth.domain.model.WorkoutRecord import com.placeholder.ringhealth.domain.model.WorkoutRecord
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -20,6 +21,16 @@ interface HealthDataRepository {
/** 指定指标在指定时间范围内的历史数据。 */ /** 指定指标在指定时间范围内的历史数据。 */
fun observeMetricHistory(metric: MetricType, range: DateRange): Flow<List<MetricPoint>> fun observeMetricHistory(metric: MetricType, range: DateRange): Flow<List<MetricPoint>>
/**
* 睡眠时段:每晚的入睡/醒来时刻与各期时长,按时间正序。
*
* 睡眠无法复用 [observeMetricHistory] —— [MetricPoint] 只装得下一个 `Double`,
* 而「几点睡到几点」是这一段区间的全部意义(见 [SleepSession])。
*
* @param range 与 [observeMetricHistory] 同一套口径:WEEK 七晚,MONTH 三十晚。
*/
fun observeSleepSessions(range: DateRange): Flow<List<SleepSession>>
/** 运动记录列表。 */ /** 运动记录列表。 */
fun observeWorkouts(): Flow<List<WorkoutRecord>> fun observeWorkouts(): Flow<List<WorkoutRecord>>
} }
@@ -1,19 +1,350 @@
package com.placeholder.ringhealth.feature.detail package com.placeholder.ringhealth.feature.detail
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize 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.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource 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.R 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.SubPageTopAppBar
import com.placeholder.ringhealth.data.mock.MockDataProvider
import com.placeholder.ringhealth.domain.model.DateRange
import com.placeholder.ringhealth.domain.model.HealthSummary
import com.placeholder.ringhealth.domain.model.MetricType import com.placeholder.ringhealth.domain.model.MetricType
import com.placeholder.ringhealth.domain.model.SleepSession
import com.placeholder.ringhealth.feature.detail.pages.bloodOxygenDetail
import com.placeholder.ringhealth.feature.detail.pages.bloodPressureDetail
import com.placeholder.ringhealth.feature.detail.pages.heartRateDetail
import com.placeholder.ringhealth.feature.detail.pages.sleepDetail
import com.placeholder.ringhealth.feature.detail.pages.stepDetail
import com.placeholder.ringhealth.feature.detail.pages.stressDetail
import com.placeholder.ringhealth.feature.detail.pages.temperatureDetail
/** 指标详情子页(占位,待 Task 12 实现)。 */ /**
* 指标详情页(有状态外壳)。
*
* 本页自带 `Scaffold` + `TopAppBar`(外层 `RingHealthApp` 也有一个 `Scaffold`,
* 它传入的 `innerPadding` 已用于 NavHost 的 `Modifier.padding`,底部栏因此保持可见)。
* 页面内容全部在 `MetricDetailContent` 里,本函数只负责取状态与传回调(ledger Ruling R26)。
*/
@Composable @Composable
fun MetricDetailPlaceholder(metric: MetricType, modifier: Modifier = Modifier) { fun MetricDetailScreen(
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { metric: MetricType,
Text(stringResource(R.string.placeholder_metric_detail)) onBack: () -> Unit,
modifier: Modifier = Modifier,
viewModel: MetricDetailViewModel = viewModel(factory = MetricDetailViewModel.factory(metric)),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
MetricDetailContent(
state = state,
onBack = onBack,
onSelectRange = viewModel::switchRange,
modifier = modifier,
)
}
/**
* 指标详情内容:**一具骨架**。
*
* 这里只剩三件事:页头(返回 + 标题)、范围切换、以及把内容分派给对应指标。
* **七个指标的页面内容一行都不在本文件里** —— 它们各自待在 `feature/detail/pages/` 下自己的
* 文件里(`StepDetail.kt` / `HeartRateDetail.kt` / …)。
*
* 之所以这么拆:改造前七个指标共用「范围切换 + 一张柱状图 + 平均/最高/最低」这同一套模板,
* 页面之间只有柱子颜色不同 —— 打开哪个都像同一个页面。视觉上的雷同,根子是代码上的共用。
* 拆开之后,每个指标「要突出什么」写在它自己的文件里,改一页不必碰其他六页。
*
* 标题取自 `state.metric` 而不是外面传进来的 `metric` —— 无状态层只有 `state` 一个数据来源,
* 免得出现「标题写心率、数据画睡眠」这种两者不一致的可能。
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun MetricDetailContent(
state: MetricDetailUiState,
onBack: () -> Unit,
onSelectRange: (DateRange) -> Unit,
modifier: Modifier = Modifier,
) {
Scaffold(
modifier = modifier,
containerColor = MaterialTheme.colorScheme.background,
topBar = {
SubPageTopAppBar(
title = stringResource(metricTitleRes(state.metric)),
onBack = onBack,
)
},
) { 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),
) {
// 范围切换常驻,空数据时反而更要留着:切到另一个范围是用户看到「暂无数据」后的
// 第一反应,chips 也是判断「哪个范围是空的」的唯一线索。它属于页头,不属于数据区。
//
// 但「未开启」时不给:那一页没有任何数据,切换周/月不会有东西跟着变,
// 留两个能点但点了没反应的 chip 比不显示更让人困惑。
if (state.enabled) {
item { RangeSelector(current = state.range, onSelect = onSelectRange) }
}
// 未开启的指标整页走说明卡,不画图。
// 判在 points 之前:Mock 对未开启的指标照常生成随机序列(见 MockDataProvider),
// 那些点画的是一段并不存在的监测数据,比空图更有误导性。
if (!state.enabled) {
item { MetricDisabledCard(metric = state.metric) }
return@LazyColumn
}
// 只有内容区被替换为空态。判空必须在 loading 之后:Mock 有约 200ms 延迟,
// 加载期间 points 也是空的,先判空会在数据到达前闪一下空态(loading 分支已提前 return)。
if (state.points.isEmpty()) {
item {
Box(
modifier = Modifier.fillMaxWidth().padding(vertical = 48.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = stringResource(R.string.detail_empty),
style = MaterialTheme.typography.bodyMedium,
color = TextSecondary,
)
}
}
return@LazyColumn
}
// 每个指标页自己决定往列表里放几张卡、放什么 —— 见各页文件的 KDoc。
when (state.metric) {
MetricType.STEP -> stepDetail(state)
MetricType.HEART_RATE -> heartRateDetail(state)
MetricType.SLEEP -> sleepDetail(state)
MetricType.BLOOD_OXYGEN -> bloodOxygenDetail(state)
MetricType.STRESS -> stressDetail(state)
MetricType.TEMPERATURE -> temperatureDetail(state)
MetricType.BLOOD_PRESSURE -> bloodPressureDetail(state)
}
}
}
}
/** 周/月切换。 */
@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)) },
)
}
}
/** Preview 用的今日概览。 */
private val PreviewSummary = HealthSummary(
steps = 8426,
stepGoal = 10000,
calories = 327,
distanceKm = 6.21,
heartRate = 72,
sleepMinutes = 452,
bloodOxygen = 98,
stress = 34,
temperature = 36.5,
bloodPressure = null,
)
/**
* 构造一份预览状态;各指标页的 Preview 共用它,避免七个预览各写一遍 HealthSummary。
*
* @param enabled 该指标是否已开启。默认 true 对应 [PreviewSummary] 里给全了的那些值;
* 血压是未开启的,它的预览显式传 false。
* @param sleepSessions 只有睡眠预览会传。睡眠时段图要的是「几点到几点」,
* 而入睡时刻是**本地时钟上的位置**,随手编一个时间戳会让整张图平移几个钟头,
* 所以这里直接向 [MockDataProvider] 要一份演示数据,不另写一套日历换算。
*/
private fun previewState(
metric: MetricType,
base: Double,
step: Double,
enabled: Boolean = true,
sleepSessions: List<SleepSession> = emptyList(),
): MetricDetailUiState =
MetricDetailUiState(
loading = false,
metric = metric,
range = DateRange.WEEK,
points = previewPoints(base = base, step = step),
summary = PreviewSummary,
enabled = enabled,
sleepSessions = sleepSessions,
)
/**
* 七个指标的预览。
*
* 一个 Preview 一个指标,是因为它们现在真的是七个不同的页面 ——
* 改造前「预览步数页」看不出别的页有没有坏,现在也看不出,但至少每个页面都有一张自己
* 能对照的图。数值区间按各指标的真实量程给(见 [previewPoints] 的说明)。
*/
@Preview(showBackground = true, heightDp = 1000)
@Composable
private fun StepDetailPreview() {
RingHealthTheme {
MetricDetailContent(
// 步数:3200→11000,落在 Mock 的 3000~12000 区间内,七根柱子高度递增且都在目标线附近。
state = previewState(MetricType.STEP, base = 3200.0, step = 1300.0),
onBack = {},
onSelectRange = {},
)
}
}
@Preview(showBackground = true, heightDp = 1000)
@Composable
private fun HeartRateDetailPreview() {
RingHealthTheme {
// 心率:58→96,正好跨过正常带的下沿(60)并逼近上沿(100),区间带与柱子的关系看得出来。
MetricDetailContent(
state = previewState(MetricType.HEART_RATE, base = 58.0, step = 6.3),
onBack = {},
onSelectRange = {},
)
}
}
@Preview(showBackground = true, heightDp = 1000)
@Composable
private fun SleepDetailPreview() {
RingHealthTheme {
// 睡眠时长(分钟):330→480,跨过 8 小时目标线。
MetricDetailContent(
state = previewState(
metric = MetricType.SLEEP,
base = 330.0,
step = 25.0,
sleepSessions = MockDataProvider().sleepSessions(DateRange.WEEK),
),
onBack = {},
onSelectRange = {},
)
}
}
@Preview(showBackground = true, heightDp = 1000)
@Composable
private fun BloodOxygenDetailPreview() {
RingHealthTheme {
// 血氧:94.0→97.2 —— 起点在线下、终点在线上,阈值带两侧都有点,才能验出警示色有没有生效。
MetricDetailContent(
state = previewState(MetricType.BLOOD_OXYGEN, base = 94.0, step = 0.53),
onBack = {},
onSelectRange = {},
)
}
}
@Preview(showBackground = true, heightDp = 1000)
@Composable
private fun StressDetailPreview() {
RingHealthTheme {
// 压力:18→66 —— 放松(<30)、正常(30–60)、中等(60–80) 三档都会出现,四色图例能对上号。
MetricDetailContent(
state = previewState(MetricType.STRESS, base = 18.0, step = 8.0),
onBack = {},
onSelectRange = {},
)
}
}
@Preview(showBackground = true, heightDp = 1000)
@Composable
private fun TemperatureDetailPreview() {
RingHealthTheme {
// 体温:36.1→36.7 —— 全部低于 36.5 基线之上一点点,两侧偏离都出现。
MetricDetailContent(
state = previewState(MetricType.TEMPERATURE, base = 36.1, step = 0.1),
onBack = {},
onSelectRange = {},
)
}
}
@Preview(showBackground = true, heightDp = 1000)
@Composable
private fun BloodPressureDetailPreview() {
RingHealthTheme {
MetricDetailContent(
// 血压在演示数据里默认关着,所以这一页预览的是「未开启」说明卡。
state = previewState(
metric = MetricType.BLOOD_PRESSURE,
base = 110.0,
step = 3.3,
enabled = false,
),
onBack = {},
onSelectRange = {},
)
}
}
/** 血压开着时的样子:一对读数 + 「还没开始生成记录」的说明。 */
@Preview(showBackground = true, heightDp = 1000)
@Composable
private fun BloodPressureEnabledPreview() {
RingHealthTheme {
MetricDetailContent(
state = previewState(
metric = MetricType.BLOOD_PRESSURE,
base = 110.0,
step = 3.3,
).copy(summary = PreviewSummary.copy(bloodPressure = "118/76")),
onBack = {},
onSelectRange = {},
)
} }
} }
@@ -0,0 +1,254 @@
package com.placeholder.ringhealth.feature.detail
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.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.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.placeholder.ringhealth.R
import com.placeholder.ringhealth.core.common.formatDecimal
import com.placeholder.ringhealth.core.common.formatMonthDayNumeric
import com.placeholder.ringhealth.core.common.formatSleepDuration
import com.placeholder.ringhealth.core.common.formatSteps
import com.placeholder.ringhealth.core.common.weekdayResFor
import com.placeholder.ringhealth.core.designsystem.TextPrimary
import com.placeholder.ringhealth.core.designsystem.TextSecondary
import com.placeholder.ringhealth.core.designsystem.component.HealthCard
import com.placeholder.ringhealth.core.designsystem.metricIcon
import com.placeholder.ringhealth.core.designsystem.metricTint
import com.placeholder.ringhealth.domain.model.MetricPoint
import com.placeholder.ringhealth.domain.model.MetricType
import kotlin.math.roundToInt
/**
* 七个指标详情页共用的小件与口径。
*
* 放在这里的东西有两个共同点:**每个页面都要用**,且**不因指标而变**。
* 只要一个东西是「心率页独有的」,它就该待在自己的页面文件里 ——
* 那正是这次改造要恢复的东西:此前七页共用一整套模板,于是七页长得一模一样。
*/
/** 指标类型 → 标题字符串资源。文案的唯一来源是 `strings.xml`(ledger Ruling R3)。 */
@Composable
internal 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
}
/**
* hero 区的大字读数:数值 + 可选单位,纵向排列在主标签之下。
*
* 单位用更小更浅的字 —— 「72」与「次/分」一样重的话,一眼看不出哪个是要读的数。
*/
@Composable
internal fun HeroReading(
value: String,
unit: String,
modifier: Modifier = Modifier,
unitRes: Int? = null,
) {
Row(modifier = modifier, verticalAlignment = Alignment.Bottom) {
Text(text = value, style = MaterialTheme.typography.displaySmall, color = TextPrimary)
val unitText = unitRes?.let { stringResource(it) } ?: unit
if (unitText.isNotEmpty()) {
Text(
text = unitText,
style = MaterialTheme.typography.bodyMedium,
color = TextSecondary,
modifier = Modifier.padding(start = 4.dp, bottom = 6.dp),
)
}
}
}
/**
* 一列统计摘要:标签在上、数值在下。
*
* 三项横排而不是三行竖排:三行会把这张卡撑到 150dp 高去放三个数字,
* 横排后高度减半,且三者并列时更容易互相比较。
*
* @param valueColor 数值的强调色;null 表示用正文色。只有「这个数字本身是坏消息」时才染
* (如血氧的偏低天数),全列染色会让三列失去主次。
*/
@Composable
internal fun StatColumn(
label: String,
value: String,
unit: String,
modifier: Modifier = Modifier,
valueColor: Color? = null,
) {
Column(modifier = modifier) {
Text(text = label, style = MaterialTheme.typography.bodySmall, color = TextSecondary)
Spacer(modifier = Modifier.height(6.dp))
Row(verticalAlignment = Alignment.Bottom) {
Text(
text = value,
style = MaterialTheme.typography.titleLarge,
color = valueColor ?: TextPrimary,
)
if (unit.isNotEmpty()) {
Text(
text = unit,
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
modifier = Modifier.padding(start = 3.dp, bottom = 3.dp),
)
}
}
}
}
/** 统计摘要的三个槽位,横向铺满一行。 */
@Composable
internal fun StatRow(
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
content()
}
}
/**
* 未开启指标的说明卡:图标 + 「XX监测未开启」+ 一句怎么办。
*
* 七个指标共用一张。哪一项关着就显示哪一项的图标和名字 —— 用户是从首页那张写着
* 「未开启」的卡点进来的,这里的图标和颜色必须和那张卡一模一样,他才知道自己没点错。
*
* 文案拆成「标题」和「怎么办」两句:[titleRes] 只报状态,[R.string.detail_disabled_hint]
* 统一说明去设备页开启。此前只有血压一页有这段说明,而用户一旦关掉别的指标,
* 详情页仍会画出一整张图 —— 那段并不存在的监测数据比空白更误导。
*/
@Composable
internal fun MetricDisabledCard(metric: MetricType, modifier: Modifier = Modifier) {
HealthCard(modifier = modifier) {
Column(
modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
imageVector = metricIcon(metric),
contentDescription = null,
tint = metricTint(metric),
modifier = Modifier.size(40.dp),
)
Spacer(modifier = Modifier.height(14.dp))
Text(
text = stringResource(
R.string.detail_disabled_title,
stringResource(metricTitleRes(metric)),
),
style = MaterialTheme.typography.titleMedium,
)
Spacer(modifier = Modifier.height(8.dp))
// 说明文案居中对齐、左右留白:它是一段话而不是一行标签,铺满整行会很难读。
Text(
text = stringResource(R.string.detail_disabled_hint),
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 16.dp),
)
}
}
}
/** 详情页统计摘要的显示口径:数值保留几位小数、单位是什么。 */
internal data class StatFormat(val decimals: Int, val unit: String)
/**
* 指标 → 统计摘要的显示口径。
*
* 小数位按指标分别定:此前一律走 `"%.1f"`,于是心率显示成「78.0」、步数显示成「8426.0」——
* 尾随的 `.0` 是假精度,看着像仪器读数精度比实际高一个数量级。
*/
@Composable
internal fun statFormatFor(type: MetricType): StatFormat = when (type) {
// 体温保留一位:36.5 与 36 之间差的 0.5℃ 是有意义的差别,取整会把它抹掉。
MetricType.TEMPERATURE -> StatFormat(decimals = 1, unit = stringResource(R.string.unit_celsius))
MetricType.HEART_RATE -> StatFormat(decimals = 0, unit = stringResource(R.string.unit_bpm))
MetricType.BLOOD_OXYGEN -> StatFormat(decimals = 0, unit = stringResource(R.string.unit_percent))
MetricType.BLOOD_PRESSURE -> StatFormat(decimals = 0, unit = stringResource(R.string.unit_mmhg))
MetricType.STEP -> StatFormat(decimals = 0, unit = stringResource(R.string.unit_steps))
// 压力是 0–100 的评分,本身没有单位;睡眠的数值在 formatStatValue 里换成时长文本,也不需要单位。
MetricType.STRESS, MetricType.SLEEP -> StatFormat(decimals = 0, unit = "")
}
/**
* 统计数值 → 展示文本。
*
* 数值格式化一律走 `core/common/Formatters.kt`,页面里不写 String.format:
* 那是格式串副本,改口径时要满仓库找。
*
* 睡眠是个例外:它的数据点单位是「分钟」,但「平均 451 分钟」远不如「平均 7小时31分」好读,
* 所以这里换成与首页同一套时长格式,而不是走 [formatDecimal]。
*/
internal fun formatStatValue(value: Double, type: MetricType, format: StatFormat): String =
when (type) {
// 睡眠的数值单位是「分钟」,但「平均 451 分钟」远不如「平均 7小时31分」好读。
MetricType.SLEEP -> formatSleepDuration(value.roundToInt())
// 步数要走千分位:首页的步数是「8,426」,详情页写成「8426」会像是两套口径。
MetricType.STEP -> formatSteps(value.roundToInt())
else -> formatDecimal(value, format.decimals)
}
/** 柱状图 X 轴的刻度数量上限。 */
private const val MAX_AXIS_TICKS = 7
/**
* 生成图表的 X 轴刻度。
*
* 点数不多(周视图 7 个点)时每根柱子都标,刻度与柱子一一对齐;
* 点数多(月视图 30 个点)时等距取 [MAX_AXIS_TICKS] 个 —— 30 个「9/10」在约 350dp 宽里
* 每个只分得到 11dp,必然被截成一两个字,不如少标几个能看清的。
*
* 周视图用星期简写(走 `strings.xml`,Ruling R3);月视图用 `M/d` 纯数字格式 ——
* 它不含任何需要翻译的文字,与 R25 对「日期 + 空格 + 时间」的判断一致。
*/
@Composable
internal fun chartAxisLabels(points: List<MetricPoint>): List<String> {
if (points.isEmpty()) return emptyList()
if (points.size <= MAX_AXIS_TICKS) {
return points.map { stringResource(weekdayResFor(it.timestamp)) }
}
val step = (points.size - 1).toFloat() / (MAX_AXIS_TICKS - 1)
return (0 until MAX_AXIS_TICKS).map { tick ->
val index = (tick * step).roundToInt().coerceIn(0, points.lastIndex)
formatMonthDayNumeric(points[index].timestamp)
}
}
/**
* Preview 用的样例数据:7 个点、每天一个,取值从 `base` 起每点递增 `step`。
*
* `base` 与 `step` 由调用方按**所在指标的取值区间**给出:`base` 取区间低位,
* `step` 取区间跨度的约 1/7,使 7 个点铺满区间的大半。这样图才有可见的高低差,
* 统计摘要的三列也不会几乎相等 —— 若点值都挤在一起,七根柱子会齐平,预览就看不出渲染问题。
*/
internal fun previewPoints(base: Double, step: Double): List<MetricPoint> {
val dayMillis = 24L * 60 * 60 * 1000
val start = 1_700_000_000_000L
return List(7) { index -> MetricPoint(timestamp = start + index * dayMillis, value = base + index * step) }
}
@@ -0,0 +1,129 @@
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.HealthSummary
import com.placeholder.ringhealth.domain.model.MetricPoint
import com.placeholder.ringhealth.domain.model.MetricType
import com.placeholder.ringhealth.domain.model.SleepSession
import com.placeholder.ringhealth.domain.model.isEnabled
import com.placeholder.ringhealth.domain.repository.HealthDataRepository
import com.placeholder.ringhealth.ringHealthApplication
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
/**
* 指标详情 UI 状态。
*
* [summary] 是今日概览,各指标的 hero 卡从它取「当前值」(今日步数、当前心率……)。
* 历史点 [points] 只回答「这几天怎么变的」,取不出「现在是多少」——
* 之前 hero 区缺的就是这个值,页面第一屏只有一个标题和一张图。
*
* [enabled] 是该指标的功能开关是否打开。关掉的指标整页走「未开启」分支、不画图:
* 它的历史点即便有(Mock 会照常生成随机序列),画的也是不存在的监测数据。
*
* [sleepSessions] 只有睡眠页会拿到内容,其余指标恒为空列表(见 [MetricDetailViewModel.sleepSessionsFor])。
* 睡眠时段图要的是「几点到几点」,[points] 里装不下,只能另走一路。
*/
data class MetricDetailUiState(
val loading: Boolean,
val metric: MetricType,
val range: DateRange,
val points: List<MetricPoint>,
val summary: HealthSummary?,
val enabled: Boolean,
val sleepSessions: List<SleepSession>,
)
/**
* 指标详情 ViewModel。因需要指标参数,使用独立工厂创建。
*/
@OptIn(ExperimentalCoroutinesApi::class)
class MetricDetailViewModel(
private val metric: MetricType,
private val healthDataRepository: HealthDataRepository,
) : ViewModel() {
private val range = MutableStateFlow(DateRange.WEEK)
/** 今日概览只需要订阅一次,与时间范围无关,所以放在 flatMapLatest 外面。 */
private val summary = healthDataRepository.observeTodaySummary()
val uiState: StateFlow<MetricDetailUiState> = range
.flatMapLatest { currentRange ->
combine(
healthDataRepository.observeMetricHistory(metric, currentRange),
summary,
sleepSessionsFor(currentRange),
) { points, today, sessions ->
MetricDetailUiState(
loading = false,
metric = metric,
range = currentRange,
points = points,
summary = today,
// 概览里有没有这个指标的值,就是它开没开 —— 概览本身由开关生成。
enabled = today.isEnabled(metric),
sleepSessions = sessions,
)
}
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(STOP_TIMEOUT_MILLIS),
initialValue = MetricDetailUiState(
loading = true,
metric = metric,
range = DateRange.WEEK,
points = emptyList(),
summary = null,
enabled = true,
sleepSessions = emptyList(),
),
)
/**
* 睡眠时段只在睡眠页取;其余六个指标拿一个恒空的流。
*
* 不写成「顺手也查一遍、反正用不上」:那会让每个指标页都多订阅一路睡眠数据,
* M2 换成真实实现后就是六次无谓的设备查询,而且多出来的那路数据将来一定会有人用,
* 用着用着就分不清哪个页面依赖什么了。
*/
private fun sleepSessionsFor(currentRange: DateRange): Flow<List<SleepSession>> =
if (metric == MetricType.SLEEP) {
healthDataRepository.observeSleepSessions(currentRange)
} else {
flowOf(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,
)
}
}
}
}
@@ -0,0 +1,79 @@
package com.placeholder.ringhealth.feature.detail
import com.placeholder.ringhealth.core.common.minutesSinceSleepAnchor
import com.placeholder.ringhealth.domain.model.SleepSession
import kotlin.math.roundToInt
/**
* 一组数据点的统计摘要。
*
* 抽成独立类型而不是散着传四个 Double,是为了让「空数据」有一个明确的表示 ——
* 见 [metricStatsOf]。
*/
data class MetricStats(
val average: Double,
val max: Double,
val min: Double,
val count: Int,
)
/**
* 求统计摘要;**列表为空时返回 null**。
*
* 此前这里返回 0.0,页面上于是出现「平均 0.0 / 最高 0.0 / 最低 0.0」三行 ——
* 看着像三条真实读数,其实是「没有数据」。空数据要么不渲染,要么渲染成「暂无数据」,
* 不能渲染成 0:0 次/分的心率是个能吓死人的假数。
*/
fun metricStatsOf(values: List<Double>): MetricStats? {
if (values.isEmpty()) return null
return MetricStats(
average = values.average(),
max = values.max(),
min = values.min(),
count = values.size,
)
}
/**
* 睡眠统计。入睡/醒来给的是**距锚点 20:00 的分钟数**(见 `minutesSinceSleepAnchor`),
* 不是钟点本身 —— 直接对「23:43」「00:31」这样的时刻求平均会得到 12:07,正好是睡得最沉的时候。
* 显示时用 `formatSleepAnchorOffset` 换回钟点。
*
* @param averageBedtimeMinute 平均入睡时刻(锚点偏移量)。
* @param averageWakeMinute 平均醒来时刻(锚点偏移量)。
* @param averageSleepMinutes 平均实际睡眠时长,不含夜里清醒。
* @param count 统计了几晚。
*/
data class SleepStats(
val averageBedtimeMinute: Int,
val averageWakeMinute: Int,
val averageSleepMinutes: Int,
val count: Int,
)
/**
* 求睡眠统计;**列表为空时返回 null**,理由同 [metricStatsOf]。
*
* 前提:这几晚的作息相差不到半圈(12 小时)。演示数据里入睡都在 21:00–04:00 之间,成立;
* 若将来出现「白天补觉」这类记录,锚点归一化会把 14:00 当成前一天的深夜,
* 均值就会被拽偏,那时该改用中位数。
*/
fun sleepStatsOf(sessions: List<SleepSession>): SleepStats? {
if (sessions.isEmpty()) return null
fun averageOf(values: List<Int>): Int = values.average().roundToInt()
return SleepStats(
averageBedtimeMinute = averageOf(sessions.map { minutesSinceSleepAnchor(it.sleepStart) }),
averageWakeMinute = averageOf(sessions.map { minutesSinceSleepAnchor(it.sleepEnd) }),
averageSleepMinutes = averageOf(sessions.map { it.sleepMinutes }),
count = sessions.size,
)
}
/** 落在 [range] 内的天数。心率页的「正常范围天数」用它。 */
fun List<Double>.countWithin(range: ClosedFloatingPointRange<Double>): Int = count { it in range }
/** 低于 [threshold] 的天数。血氧页的「偏低天数」用它。 */
fun List<Double>.countBelow(threshold: Double): Int = count { it < threshold }
/** 不低于 [threshold] 的天数。步数页的「达标天数」用它。 */
fun List<Double>.countAtLeast(threshold: Double): Int = count { it >= threshold }
@@ -0,0 +1,145 @@
package com.placeholder.ringhealth.feature.detail.pages
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.placeholder.ringhealth.R
import com.placeholder.ringhealth.core.common.formatDecimal
import com.placeholder.ringhealth.core.designsystem.MetricCyan
import com.placeholder.ringhealth.core.designsystem.MetricRed
import com.placeholder.ringhealth.core.designsystem.TextSecondary
import com.placeholder.ringhealth.core.designsystem.component.HealthCard
import com.placeholder.ringhealth.core.designsystem.component.MetricThresholdStrip
import com.placeholder.ringhealth.core.designsystem.component.SectionHeader
import com.placeholder.ringhealth.domain.model.MetricReference
import com.placeholder.ringhealth.feature.detail.HeroReading
import com.placeholder.ringhealth.feature.detail.MetricDetailUiState
import com.placeholder.ringhealth.feature.detail.StatColumn
import com.placeholder.ringhealth.feature.detail.StatRow
import com.placeholder.ringhealth.feature.detail.chartAxisLabels
import com.placeholder.ringhealth.feature.detail.countBelow
import com.placeholder.ringhealth.feature.detail.formatStatValue
import com.placeholder.ringhealth.feature.detail.metricStatsOf
import com.placeholder.ringhealth.feature.detail.statFormatFor
/**
* 血氧详情页。
*
* 血氧的特点是**只有一个下限**:高了没有意义(人体血氧到 100% 就到顶了),
* 低了才是问题。所以这一页全篇围绕「95% 这条线」组织 ——
* 头图给出「达标 / 偏低」的判定,趋势图画成阈值带(点在线的哪一侧就是结论),
* 统计里第三列是「偏低几天」而不是「最高多少」。
*/
internal fun LazyListScope.bloodOxygenDetail(state: MetricDetailUiState) {
item { BloodOxygenHeroCard(state) }
item { BloodOxygenChartCard(state) }
item { BloodOxygenStatsCard(state) }
}
/** 头图:当前血氧 + 达标判定 + 达标线说明。 */
@Composable
private fun BloodOxygenHeroCard(state: MetricDetailUiState, modifier: Modifier = Modifier) {
val current = state.summary?.bloodOxygen ?: return
val below = current < MetricReference.BLOOD_OXYGEN_MIN
HealthCard(modifier = modifier) {
Text(
text = stringResource(R.string.metric_blood_oxygen),
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
)
Spacer(modifier = Modifier.height(2.dp))
HeroReading(value = current.toString(), unit = "", unitRes = R.string.unit_percent)
Spacer(modifier = Modifier.height(10.dp))
// 判定用文字 + 颜色双重表达:单靠颜色的话,色觉障碍用户读不出「达标」还是「偏低」。
Text(
text = stringResource(
if (below) R.string.detail_status_low else R.string.detail_status_normal,
),
style = MaterialTheme.typography.titleMedium,
color = if (below) MetricRed else MetricCyan,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.detail_spo2_threshold, formatDecimal(MetricReference.BLOOD_OXYGEN_MIN, 0)),
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
)
}
}
/** 趋势图:阈值带。 */
@Composable
private fun BloodOxygenChartCard(state: MetricDetailUiState, modifier: Modifier = Modifier) {
HealthCard(modifier = modifier) {
SectionHeader(
title = stringResource(R.string.detail_chart_daily, stringResource(R.string.metric_blood_oxygen)),
trailing = {
Text(
text = stringResource(
R.string.detail_spo2_threshold,
formatDecimal(MetricReference.BLOOD_OXYGEN_MIN, 0),
),
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
)
},
)
MetricThresholdStrip(
values = state.points.map { it.value },
// 纵轴下沿取 FLOOR(90)而不是阈值(95):低于阈值的点若被夹在轴上,
// 会集体贴着底边,看不出「低了 1%」还是「低了 5%」。
axis = MetricReference.BLOOD_OXYGEN_FLOOR..MetricReference.BLOOD_OXYGEN_MIN.plus(
SPO2_AXIS_HEADROOM,
),
threshold = MetricReference.BLOOD_OXYGEN_MIN,
color = MetricCyan,
warningColor = MetricRed,
axisLabels = chartAxisLabels(state.points),
)
}
}
/**
* 纵轴上沿比阈值高出的部分。
*
* 血氧正常值 95–100,最高就是 100(血氧仪的上限)。上沿取 100 而不是「数据最大值」——
* 固定上沿才能让「点在线的上方多远处」在不同周之间可比。
*/
private const val SPO2_AXIS_HEADROOM = 5.0
/** 统计卡:平均 / 最低 / 偏低天数。 */
@Composable
private fun BloodOxygenStatsCard(state: MetricDetailUiState, modifier: Modifier = Modifier) {
val values = state.points.map { it.value }
val stats = metricStatsOf(values) ?: return
val format = statFormatFor(state.metric)
val lowDays = values.countBelow(MetricReference.BLOOD_OXYGEN_MIN)
HealthCard(modifier = modifier) {
StatRow {
StatColumn(
label = stringResource(R.string.detail_average),
value = formatStatValue(stats.average, state.metric, format),
unit = format.unit,
)
StatColumn(
label = stringResource(R.string.detail_min),
value = formatStatValue(stats.min, state.metric, format),
unit = format.unit,
)
// 第三列给「偏低天数」,并把数字染成警示色 —— 只在真的出现偏低时才染色,
// 「偏低 0 天」染红会让人以为出了事。
StatColumn(
label = stringResource(R.string.detail_status_low),
value = stringResource(R.string.detail_days_count, lowDays),
unit = "",
valueColor = if (lowDays > 0) MetricRed else null,
)
}
}
}
@@ -0,0 +1,98 @@
package com.placeholder.ringhealth.feature.detail.pages
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.placeholder.ringhealth.R
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.feature.detail.MetricDetailUiState
import com.placeholder.ringhealth.feature.detail.StatColumn
import com.placeholder.ringhealth.feature.detail.StatRow
/**
* 血压详情页:**已开启态**。
*
* 未开启的那一态不在这里 —— 它由 `MetricDetailScreen` 统一的「未开启」分支负责
* (`MetricDisabledCard`),因为「关掉的指标不画图」是七个页面共同的事,不该由某一页各自实现。
* 本页因此可以专注于一件事:功能开着的时候显示什么。
*
* **它不画趋势图,这是有意的。** 血压与其他六个指标不同:一次测量是「收缩压/舒张压」两个数,
* 而当前的历史数据模型 `MetricPoint(timestamp, value: Double)` 只装得下一个数,
* `metricHistory(BLOOD_PRESSURE)` 返回的那串 110–130 是 Mock 随手生成的单值序列,
* 既不是收缩压也不是舒张压,画出来是一张无意义的图。M3 接入真实血压数据后,
* 这一页改成双值趋势图,`HealthSummary.bloodPressure` 也从字符串改为结构化的两个数值。
*/
internal fun LazyListScope.bloodPressureDetail(state: MetricDetailUiState) {
item { BloodPressureHeroCard(state) }
item { BloodPressurePendingCard() }
}
/**
* 头图:一对读数。
*
* 收缩压与舒张压并排,而不是像其他指标那样给一个大字 —— 「118/76」本身就是一对数,
* 拆成两栏才看得出哪个是哪个;挤成一行大字,用户只会看到一个比别的指标长很多的数字。
*/
@Composable
private fun BloodPressureHeroCard(state: MetricDetailUiState, modifier: Modifier = Modifier) {
// 概览里是「收缩压/舒张压」字符串(如 "118/76")。M1 的 Mock 保证格式固定,
// 取不到两段时整卡不显示,而不是硬凑一个「--/--」。
val reading = state.summary?.bloodPressure ?: return
val parts = reading.split("/")
if (parts.size != 2) return
HealthCard(modifier = modifier) {
Text(
text = stringResource(R.string.detail_bp_latest),
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
)
Spacer(modifier = Modifier.height(2.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(24.dp),
) {
StatColumn(
label = stringResource(R.string.detail_bp_systolic),
value = parts[0].trim(),
unit = stringResource(R.string.unit_mmhg),
)
StatColumn(
label = stringResource(R.string.detail_bp_diastolic),
value = parts[1].trim(),
unit = stringResource(R.string.unit_mmhg),
)
}
}
}
/**
* 说明卡:功能开着,但还没有可看的历史。
*
* 这一句必须留着。功能刚打开时用户最想知道的是「然后呢」——
* 血压与其余指标不同,它需要先在设备页完成手动校准才会开始生成记录
* (同一句话在设备页那个开关的副标题里也写着)。省掉它,这一页就只剩一对孤零零的读数,
* 用户会以为历史数据加载失败了。
*/
@Composable
private fun BloodPressurePendingCard(modifier: Modifier = Modifier) {
HealthCard(modifier = modifier) {
SectionHeader(title = stringResource(R.string.detail_bp_history))
Text(
text = stringResource(R.string.detail_bp_pending),
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
)
}
}
@@ -0,0 +1,147 @@
package com.placeholder.ringhealth.feature.detail.pages
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.placeholder.ringhealth.R
import com.placeholder.ringhealth.core.common.formatDecimal
import com.placeholder.ringhealth.core.designsystem.MetricRed
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.MetricRangeBar
import com.placeholder.ringhealth.core.designsystem.component.SectionHeader
import com.placeholder.ringhealth.domain.model.MetricReference
import com.placeholder.ringhealth.feature.detail.HeroReading
import com.placeholder.ringhealth.feature.detail.MetricDetailUiState
import com.placeholder.ringhealth.feature.detail.StatColumn
import com.placeholder.ringhealth.feature.detail.StatRow
import com.placeholder.ringhealth.feature.detail.chartAxisLabels
import com.placeholder.ringhealth.feature.detail.countWithin
import com.placeholder.ringhealth.feature.detail.formatStatValue
import com.placeholder.ringhealth.feature.detail.metricStatsOf
import com.placeholder.ringhealth.feature.detail.statFormatFor
/**
* 心率详情页。
*
* 心率的特点是**有正常区间**:「72」这个数字本身说明不了什么,落在 60–100 之内才算正常静息。
* 所以头图的核心不是数字而是那根**区间条** —— 数字只回答「是多少」,区间条回答「算不算正常」。
* 趋势图同样在柱子后面铺一层正常带,柱子冒出去就是那天出了问题。
*/
internal fun LazyListScope.heartRateDetail(state: MetricDetailUiState) {
item { HeartRateHeroCard(state) }
item { HeartRateChartCard(state) }
item { HeartRateStatsCard(state) }
}
/** 头图:当前心率 + 区间条 + 状态文案。 */
@Composable
private fun HeartRateHeroCard(state: MetricDetailUiState, modifier: Modifier = Modifier) {
val current = state.summary?.heartRate ?: return
HealthCard(modifier = modifier) {
Text(
text = stringResource(R.string.metric_heart_rate),
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
)
Spacer(modifier = Modifier.height(2.dp))
HeroReading(
value = current.toString(),
unit = "",
unitRes = R.string.unit_bpm,
)
Spacer(modifier = Modifier.height(14.dp))
MetricRangeBar(
value = current.toDouble(),
axis = MetricReference.HEART_RATE_AXIS,
normal = MetricReference.HEART_RATE_NORMAL,
color = MetricRed,
)
Spacer(modifier = Modifier.height(10.dp))
Text(
text = stringResource(
R.string.detail_hr_normal_band,
formatDecimal(MetricReference.HEART_RATE_NORMAL.start, 0),
formatDecimal(MetricReference.HEART_RATE_NORMAL.endInclusive, 0),
),
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
)
}
}
/**
* 趋势图:柱子 + 正常区间带。
*
* 纵向窗口固定取 [MetricReference.HEART_RATE_AXIS](40–120)而不是按数据自适应:
* 正常带的位置要**每次都一样**,否则同一份 60–100 的带子在不同周里画在不同高度,
* 「柱子冒出去了没有」就失去了跨页面的可比性。
*/
@Composable
private fun HeartRateChartCard(state: MetricDetailUiState, modifier: Modifier = Modifier) {
HealthCard(modifier = modifier) {
SectionHeader(
title = stringResource(R.string.detail_chart_daily, stringResource(R.string.metric_heart_rate)),
trailing = {
// 参考带的含义必须在图边上写清:一条淡色带子不标注,读者只会当成背景色。
Text(
text = stringResource(
R.string.detail_hr_normal_band,
formatDecimal(MetricReference.HEART_RATE_NORMAL.start, 0),
formatDecimal(MetricReference.HEART_RATE_NORMAL.endInclusive, 0),
),
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
)
},
)
MetricBarChart(
values = state.points.map { it.value.toFloat() },
color = MetricRed,
valueRange = MetricReference.HEART_RATE_AXIS.start.toFloat()..
MetricReference.HEART_RATE_AXIS.endInclusive.toFloat(),
bandRange = MetricReference.HEART_RATE_NORMAL.start.toFloat()..
MetricReference.HEART_RATE_NORMAL.endInclusive.toFloat(),
axisLabels = chartAxisLabels(state.points),
)
}
}
/** 统计卡:平均 / 最高 / 正常范围天数。 */
@Composable
private fun HeartRateStatsCard(state: MetricDetailUiState, modifier: Modifier = Modifier) {
val values = state.points.map { it.value }
val stats = metricStatsOf(values) ?: return
val format = statFormatFor(state.metric)
HealthCard(modifier = modifier) {
StatRow {
StatColumn(
label = stringResource(R.string.detail_average),
value = formatStatValue(stats.average, state.metric, format),
unit = format.unit,
)
StatColumn(
label = stringResource(R.string.detail_max),
value = formatStatValue(stats.max, state.metric, format),
unit = format.unit,
)
// 第三列不给「最低」而给「正常范围天数」:心率低到 55 对运动员是好事,
// 单看一个最低值会被误读成异常,而「7 天里正常 6 天」没有这个歧义。
StatColumn(
label = stringResource(R.string.detail_hr_normal_days),
value = stringResource(
R.string.detail_days_count,
values.countWithin(MetricReference.HEART_RATE_NORMAL),
),
unit = "",
)
}
}
}
@@ -0,0 +1,256 @@
package com.placeholder.ringhealth.feature.detail.pages
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.LazyListScope
import androidx.compose.foundation.shape.RoundedCornerShape
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.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.placeholder.ringhealth.R
import com.placeholder.ringhealth.core.common.formatClock
import com.placeholder.ringhealth.core.common.formatDecimal
import com.placeholder.ringhealth.core.common.formatMonthDayNumeric
import com.placeholder.ringhealth.core.common.formatSleepAnchorOffset
import com.placeholder.ringhealth.core.common.formatSleepDuration
import com.placeholder.ringhealth.core.common.weekdayResFor
import com.placeholder.ringhealth.core.designsystem.MetricBlue
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.core.designsystem.component.SleepWindowChart
import com.placeholder.ringhealth.core.designsystem.component.StageTints
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.SleepSession
import com.placeholder.ringhealth.feature.detail.HeroReading
import com.placeholder.ringhealth.feature.detail.MetricDetailUiState
import com.placeholder.ringhealth.feature.detail.StatColumn
import com.placeholder.ringhealth.feature.detail.StatRow
import com.placeholder.ringhealth.feature.detail.sleepStatsOf
/**
* 睡眠详情页。
*
* 睡眠在一天里占的是**一段区间**,所以这一页的头图不是光秃秃一个数字,而是
* 「睡了多久 + 从几点到几点 + 睡得沉不沉」;下面那张时段图把最近几晚的区间并排摆开,
* 横轴是钟点 —— 用户真正会问的「我每天都几点睡」只有这张图回答得了。
*
* 与之相对,通用的柱状图在这一页**不出现**:柱子高度就是时长,而时段图的条长同样是时长,
* 两张图讲同一件事,只会让人多看一遍。
*/
internal fun LazyListScope.sleepDetail(state: MetricDetailUiState) {
item { SleepHeroCard(state) }
item { SleepWindowCard(state) }
item { SleepStatsCard(state) }
}
/** 演示用的目标睡眠时长:8 小时,与设计文档 §7 的口径一致。 */
private const val SLEEP_TARGET_MINUTES = 480
/**
* 分期配色:DEEP/LIGHT 走 MetricBlue 的两档深浅(深浅即「睡得沉不沉」),
* **AWAKE 故意跳出蓝色族**,用一枚暖灰偏粉 —— 睡眠是蓝色调,「醒了」要明显是另一件事。
* 「中途醒」是用户最该被告知的信息之一,不能让它和「睡着了但浅」混淆:
* 在时段图里它会落成一段独立暖色子矩形,在头图分期条里它会落成独立暖色段。
*/
private val DEEP_TINT = MetricBlue
private val LIGHT_TINT = MetricBlue.copy(alpha = 0.45f)
private val AWAKE_TINT = Color(0xFFE8B4A8)
/** 头图:昨晚时长 + 入睡/起床时刻 + 分期条与分期时长。 */
@Composable
private fun SleepHeroCard(state: MetricDetailUiState, modifier: Modifier = Modifier) {
// 昨晚 = 最后一个会话。取不到就整卡不画,而不是退回 summary 里那个数字 ——
// 那样会出现「头图写着 7 小时 32 分、下面时段图里没有这一根」的两截对不上。
val session = state.sleepSessions.lastOrNull() ?: return
HealthCard(modifier = modifier) {
Text(
text = stringResource(R.string.detail_sleep_last_night),
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
)
Spacer(modifier = Modifier.height(2.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Bottom,
) {
HeroReading(value = formatSleepDuration(session.sleepMinutes), unit = "")
// 目标值留在头图这一行。原来它是柱状图上那条 8 小时参考线的附注,
// 而柱状图已被时段图取代 —— 目标本身仍然有用,不该跟着那张图一起消失。
Text(
text = stringResource(
R.string.detail_sleep_target,
formatSleepDuration(SLEEP_TARGET_MINUTES),
),
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
modifier = Modifier.padding(bottom = 10.dp),
)
}
Spacer(modifier = Modifier.height(6.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = "${stringResource(R.string.detail_sleep_fall_asleep)} ${formatClock(session.sleepStart)}",
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
)
Text(
text = "${stringResource(R.string.detail_sleep_wake_up)} ${formatClock(session.sleepEnd)}",
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
)
}
Spacer(modifier = Modifier.height(16.dp))
// 分期条的权重取**卧床时长**而不是睡着时长:清醒的那一段也要占位置,
// 否则「睡了 7 小时 32 分」的条里藏着一截其实醒着的时间,比例看着对不上。
StatBar(segments = session.stageSegments())
Spacer(modifier = Modifier.height(10.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
StageLegend(
label = stringResource(R.string.detail_sleep_deep),
minutes = session.deepMinutes,
tint = DEEP_TINT,
)
StageLegend(
label = stringResource(R.string.detail_sleep_light),
minutes = session.lightMinutes,
tint = LIGHT_TINT,
)
StageLegend(
label = stringResource(R.string.detail_sleep_awake),
minutes = session.awakeMinutes,
tint = AWAKE_TINT,
)
}
}
}
/** 三个分段的权重 = 各期时长 / 卧床时长。卧床时长为 0 只可能是坏数据,兜一个极小正数。 */
private fun SleepSession.stageSegments(): List<StatSegment> {
val total = inBedMinutes.coerceAtLeast(1).toFloat()
return listOf(
StatSegment(weight = deepMinutes / total, color = DEEP_TINT),
StatSegment(weight = lightMinutes / total, color = LIGHT_TINT),
StatSegment(weight = awakeMinutes / total, color = AWAKE_TINT),
)
}
/** 分期图例:小色块 + 名称 + 时长。「深睡 1小时36分」比光一个「深睡」多告诉用户一件事。 */
@Composable
private fun StageLegend(label: String, minutes: Int, 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 ${formatSleepDuration(minutes)}",
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
modifier = Modifier.padding(start = 6.dp),
)
}
}
/**
* 时段图:每晚一行,一根条从入睡横到起床,横轴是钟点。
*
* 行标签按范围换口径:近 7 天用星期(「周三」),近 30 天用日期(「9/10」)——
* 三十行里「周三」会出现四次,反而认不出是哪一天。
*/
@Composable
private fun SleepWindowCard(state: MetricDetailUiState, modifier: Modifier = Modifier) {
if (state.sleepSessions.isEmpty()) return
val stats = sleepStatsOf(state.sleepSessions)
HealthCard(modifier = modifier) {
SectionHeader(
title = stringResource(R.string.detail_sleep_window),
trailing = stats?.let {
{
Text(
text = stringResource(
R.string.detail_sleep_window_average,
formatSleepAnchorOffset(it.averageBedtimeMinute),
),
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
)
}
},
)
SleepWindowChart(
sessions = state.sleepSessions,
color = MetricBlue,
// 启用分期染色:少数带 [SleepSession.stages] 的夜晚会把条拆成多色子矩形,
// 与头图分期条共用同一套色系(深浅 = 睡得沉不沉)。其他夜晚无 `stages` 字段,
// 组件会自己退回单色模式 —— 调用方不需要关心「哪些晚有分期」。
stageTints = StageTints(
deep = DEEP_TINT,
light = LIGHT_TINT,
awake = AWAKE_TINT,
),
rowLabel = { session ->
if (state.range == DateRange.WEEK) {
stringResource(weekdayResFor(session.wakeDate))
} else {
formatMonthDayNumeric(session.wakeDate)
}
},
)
}
}
/**
* 统计卡:平均入睡 / 平均醒来 / 平均时长。
*
* 前两项给的是**钟点**而不是时长 —— 「这几天平均几点睡」和「平均睡多久」是两个不同的问题,
* 而原来的最长/最短只回答后者,且回答得没什么用(最长那晚是几号?页面上没有)。
*/
@Composable
private fun SleepStatsCard(state: MetricDetailUiState, modifier: Modifier = Modifier) {
val stats = sleepStatsOf(state.sleepSessions) ?: return
HealthCard(modifier = modifier) {
StatRow {
StatColumn(
label = stringResource(R.string.detail_sleep_avg_fall_asleep),
value = formatSleepAnchorOffset(stats.averageBedtimeMinute),
unit = "",
)
StatColumn(
label = stringResource(R.string.detail_sleep_avg_wake_up),
value = formatSleepAnchorOffset(stats.averageWakeMinute),
unit = "",
)
StatColumn(
label = stringResource(R.string.detail_sleep_avg_duration),
// 用「7.5 小时」而不是「7小时32分」:三列并排,后者的宽度会把前两列的钟点挤扁。
value = formatDecimal(stats.averageSleepMinutes / 60.0, decimals = 1),
unit = stringResource(R.string.unit_hours),
)
}
}
}
@@ -0,0 +1,197 @@
package com.placeholder.ringhealth.feature.detail.pages
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.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material3.HorizontalDivider
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.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
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.formatSteps
import com.placeholder.ringhealth.core.designsystem.DividerColor
import com.placeholder.ringhealth.core.designsystem.MetricGreen
import com.placeholder.ringhealth.core.designsystem.TextPrimary
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.RingProgress
import com.placeholder.ringhealth.core.designsystem.component.SectionHeader
import com.placeholder.ringhealth.feature.detail.MetricDetailUiState
import com.placeholder.ringhealth.feature.detail.StatColumn
import com.placeholder.ringhealth.feature.detail.StatRow
import com.placeholder.ringhealth.feature.detail.chartAxisLabels
import com.placeholder.ringhealth.feature.detail.countAtLeast
import com.placeholder.ringhealth.feature.detail.formatStatValue
import com.placeholder.ringhealth.feature.detail.metricStatsOf
import com.placeholder.ringhealth.feature.detail.statFormatFor
import kotlin.math.roundToInt
/**
* 步数详情页。
*
* 步数的特点是**有目标**:它不是一个「多少算正常」的体征,而是一个「今天走够了没有」的任务。
* 所以头图把「完成度」摆在最显眼处(环 + 百分比),趋势图的参考线画的是目标线而不是均值线 ——
* 均值只是「我平时走多少」,目标线才回答「今天够不够」。
*/
internal fun LazyListScope.stepDetail(state: MetricDetailUiState) {
item { StepsHeroCard(state) }
item { StepsChartCard(state) }
item { StepsStatsCard(state) }
}
/** 头图:进度环 + 完成度百分比 + 目标,右侧附距离与千卡。 */
@Composable
private fun StepsHeroCard(state: MetricDetailUiState, modifier: Modifier = Modifier) {
val summary = state.summary ?: return
val goal = summary.stepGoal.coerceAtLeast(1)
// 完成度可以超过 100%(走爆了是好事),但环只画到满;百分比照实显示。
val progress = summary.steps.toFloat() / goal
HealthCard(modifier = modifier, contentPadding = PaddingValues(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
RingProgress(
progress = progress,
color = MetricGreen,
trackColor = MetricGreen.copy(alpha = 0.15f),
ringSize = HERO_RING_SIZE,
strokeWidth = 10.dp,
) {
Text(text = formatSteps(summary.steps), style = HeroStepsStyle)
}
Column(modifier = Modifier.padding(start = 18.dp)) {
Text(
text = stringResource(R.string.detail_steps_progress),
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
)
Spacer(modifier = Modifier.height(2.dp))
Row(verticalAlignment = Alignment.Bottom) {
Text(
text = (progress * 100).roundToInt().toString(),
style = MaterialTheme.typography.displaySmall,
color = MetricGreen,
)
Text(
text = stringResource(R.string.unit_percent),
style = MaterialTheme.typography.bodyMedium,
color = TextSecondary,
modifier = Modifier.padding(start = 2.dp, bottom = 6.dp),
)
}
Spacer(modifier = Modifier.height(8.dp))
HorizontalDivider(color = DividerColor)
Spacer(modifier = Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
HeroFact(value = formatDistance(summary.distanceKm), label = stringResource(R.string.home_distance_label))
HeroFact(value = formatCalories(summary.calories), label = stringResource(R.string.home_calories_label))
}
}
}
}
}
/** hero 里的一个小事实:数值在上、标签在下。 */
@Composable
private fun HeroFact(value: String, label: String, modifier: Modifier = Modifier) {
Column(modifier = modifier) {
Text(text = value, style = MaterialTheme.typography.titleMedium, color = TextPrimary)
Text(text = label, style = MaterialTheme.typography.bodySmall, color = TextSecondary)
}
}
/**
* 趋势图:柱子 + 目标线。
*
* 纵向窗口取「0 到 目标与历史最高中的较大者再上浮 15%」——
* 不能让参考线把柱子压扁:若窗口只按历史最高取,而目标恰好高于历史所有点,
* 目标线会贴到画布顶端,看着像页面的边框而不是一条线。
*/
@Composable
private fun StepsChartCard(state: MetricDetailUiState, modifier: Modifier = Modifier) {
val goal = (state.summary?.stepGoal ?: 0).toFloat()
val values = state.points.map { it.value.toFloat() }
HealthCard(modifier = modifier) {
SectionHeader(
title = stringResource(R.string.detail_chart_daily, stringResource(R.string.metric_step)),
trailing = {
if (goal > 0f) {
Text(
text = stringResource(R.string.detail_goal, formatSteps(goal.toInt())),
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
)
}
},
)
MetricBarChart(
values = values,
color = MetricGreen,
valueRange = 0f..(maxOf(values.maxOrNull() ?: 0f, goal) * 1.15f).coerceAtLeast(1f),
referenceLine = goal.takeIf { it > 0f },
axisLabels = chartAxisLabels(state.points),
)
}
}
/** 统计卡:日均 / 最高 / 达标天数。 */
@Composable
private fun StepsStatsCard(state: MetricDetailUiState, modifier: Modifier = Modifier) {
val values = state.points.map { it.value }
val stats = metricStatsOf(values) ?: return
val goal = (state.summary?.stepGoal ?: 0).toDouble()
val format = statFormatFor(state.metric)
HealthCard(modifier = modifier) {
StatRow {
StatColumn(
label = stringResource(R.string.detail_steps_average),
value = formatStatValue(stats.average, state.metric, format),
unit = format.unit,
)
StatColumn(
label = stringResource(R.string.detail_max),
value = formatStatValue(stats.max, state.metric, format),
unit = format.unit,
)
// 步数页的第三列不给「最低」,给「达标天数」—— 最低步数(比如卧床那天 300 步)
// 既不好看也没指导意义,而「7 天里达标 4 天」正是这张页面要回答的问题。
if (goal > 0.0) {
StatColumn(
label = stringResource(R.string.detail_steps_reached_label),
value = stringResource(R.string.detail_days_count, values.countAtLeast(goal)),
unit = "",
)
} else {
StatColumn(
label = stringResource(R.string.detail_min),
value = formatStatValue(stats.min, state.metric, format),
unit = format.unit,
)
}
}
}
}
/** 环内步数字号。比首页那个(28sp)小一号 —— 这里环也更小。 */
private val HeroStepsStyle = TextStyle(
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
letterSpacing = (-1).sp,
color = TextPrimary,
)
/** 头图圆环外径。比首页的小:详情页第一屏还要留位置给下面的图。 */
private val HERO_RING_SIZE = 108.dp
@@ -0,0 +1,178 @@
package com.placeholder.ringhealth.feature.detail.pages
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.LazyListScope
import androidx.compose.foundation.shape.RoundedCornerShape
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.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.placeholder.ringhealth.R
import com.placeholder.ringhealth.core.common.stressLevelLabelRes
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.MetricRed
import com.placeholder.ringhealth.core.designsystem.TextSecondary
import com.placeholder.ringhealth.core.designsystem.component.HealthCard
import com.placeholder.ringhealth.core.designsystem.component.MetricBarChart
import com.placeholder.ringhealth.core.designsystem.component.SectionHeader
import com.placeholder.ringhealth.domain.model.StressLevel
import com.placeholder.ringhealth.domain.model.stressLevelOf
import com.placeholder.ringhealth.feature.detail.HeroReading
import com.placeholder.ringhealth.feature.detail.MetricDetailUiState
import com.placeholder.ringhealth.feature.detail.StatColumn
import com.placeholder.ringhealth.feature.detail.StatRow
import com.placeholder.ringhealth.feature.detail.chartAxisLabels
import com.placeholder.ringhealth.feature.detail.formatStatValue
import com.placeholder.ringhealth.feature.detail.metricStatsOf
import com.placeholder.ringhealth.feature.detail.statFormatFor
/**
* 压力详情页。
*
* 压力的特点是**数值本身没有量纲意义**:31 和 62 的差别不是「差了 31 个单位」,
* 而是「从放松变成了中等」。所以这一页处处按**等级**而不是按数值组织 ——
* 头图给等级名而不是光给数字,趋势图每根柱子按它那天的等级着色,
* 统计里第三列是「放松几天」。同一张图上出现四种颜色,这在别的页面是要避免的,
* 在压力页恰恰是重点。
*/
internal fun LazyListScope.stressDetail(state: MetricDetailUiState) {
item { StressHeroCard(state) }
item { StressChartCard(state) }
item { StressStatsCard(state) }
}
/** 压力等级 → 颜色。等级与颜色的对应只此一份,图例与柱子共用。 */
private fun stressLevelColor(level: StressLevel): Color = when (level) {
StressLevel.RELAXED -> MetricGreen
StressLevel.NORMAL -> MetricCyan
StressLevel.MEDIUM -> MetricOrange
StressLevel.HIGH -> MetricRed
}
/** 头图:当前压力值 + 等级名(染色)。 */
@Composable
private fun StressHeroCard(state: MetricDetailUiState, modifier: Modifier = Modifier) {
val current = state.summary?.stress ?: return
val level = stressLevelOf(current.toDouble())
HealthCard(modifier = modifier) {
Text(
text = stringResource(R.string.detail_stress_current),
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
)
Spacer(modifier = Modifier.height(2.dp))
HeroReading(value = current.toString(), unit = "")
Spacer(modifier = Modifier.height(10.dp))
Text(
text = stringResource(stressLevelLabelRes(level)),
style = MaterialTheme.typography.titleMedium,
color = stressLevelColor(level),
)
}
}
/** 趋势图:按等级着色的柱子 + 四色图例。 */
@Composable
private fun StressChartCard(state: MetricDetailUiState, modifier: Modifier = Modifier) {
HealthCard(modifier = modifier) {
SectionHeader(
title = stringResource(R.string.detail_chart_daily, stringResource(R.string.metric_stress)),
)
MetricBarChart(
values = state.points.map { it.value.toFloat() },
// 基础色取紫色(压力指标的语义色),但 barColorOf 会覆盖其中绝大多数柱子 ——
// 它只在返回值列表为空时兜底,那时才轮到这条分支。
color = stressLevelColor(StressLevel.HIGH),
valueRange = 0f..STRESS_AXIS_MAX,
barColorOf = { value -> stressLevelColor(stressLevelOf(value.toDouble())) },
axisLabels = chartAxisLabels(state.points),
)
Spacer(modifier = Modifier.height(10.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
// 四个等级全列出来,而不是只列图上出现过的 —— 图例的作用是给出「颜色 → 等级」的
// 对照表,缺一档就会让人以为少的那档不存在。
StressLevel.entries.forEach { level ->
LevelLegend(
label = stringResource(stressLevelLabelRes(level)),
tint = stressLevelColor(level),
)
}
}
}
}
/**
* 压力纵轴上限。
*
* 固定 100 而不是按数据自适应:压力的分档阈值(30/60/80)是绝对的,
* 若轴随数据缩放,同样高度的柱子在不同周里代表不同等级,图例就成了摆设。
*/
private const val STRESS_AXIS_MAX = 100f
/** 等级图例:小色块 + 名称。 */
@Composable
private fun LevelLegend(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),
)
}
}
/** 统计卡:平均 / 最高 / 放松天数。 */
@Composable
private fun StressStatsCard(state: MetricDetailUiState, modifier: Modifier = Modifier) {
val values = state.points.map { it.value }
val stats = metricStatsOf(values) ?: return
val format = statFormatFor(state.metric)
val relaxedDays = values.count { stressLevelOf(it) == StressLevel.RELAXED }
HealthCard(modifier = modifier) {
StatRow {
StatColumn(
label = stringResource(R.string.detail_average),
value = formatStatValue(stats.average, state.metric, format),
unit = format.unit,
)
StatColumn(
label = stringResource(R.string.detail_max),
value = formatStatValue(stats.max, state.metric, format),
unit = format.unit,
)
// 第三列给「放松天数」:压力的「最低值」没有解读价值(睡着时人人都低),
// 「这几天有几天是放松的」才是可用的结论。
StatColumn(
label = stringResource(R.string.detail_stress_relaxed_days),
value = stringResource(R.string.detail_days_count, relaxedDays),
unit = "",
valueColor = if (relaxedDays > 0) MetricGreen else null,
)
}
}
}
@@ -0,0 +1,133 @@
package com.placeholder.ringhealth.feature.detail.pages
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.placeholder.ringhealth.R
import com.placeholder.ringhealth.core.common.formatDecimal
import com.placeholder.ringhealth.core.common.formatSignedDecimal
import com.placeholder.ringhealth.core.designsystem.MetricBlue
import com.placeholder.ringhealth.core.designsystem.MetricOrange
import com.placeholder.ringhealth.core.designsystem.TextSecondary
import com.placeholder.ringhealth.core.designsystem.component.HealthCard
import com.placeholder.ringhealth.core.designsystem.component.MetricDeviationBar
import com.placeholder.ringhealth.core.designsystem.component.SectionHeader
import com.placeholder.ringhealth.domain.model.MetricReference
import com.placeholder.ringhealth.feature.detail.HeroReading
import com.placeholder.ringhealth.feature.detail.MetricDetailUiState
import com.placeholder.ringhealth.feature.detail.StatColumn
import com.placeholder.ringhealth.feature.detail.StatRow
import com.placeholder.ringhealth.feature.detail.chartAxisLabels
import com.placeholder.ringhealth.feature.detail.formatStatValue
import com.placeholder.ringhealth.feature.detail.metricStatsOf
import com.placeholder.ringhealth.feature.detail.statFormatFor
/**
* 体温详情页。
*
* 体温的特点是**绝对值没有意义**:36.5 与 36.8 都属于正常,要读的是「比自己的基线高还是低」。
* 所以这一页的中心是一条基线而不是零点 —— 头图给「较基线 +0.0°C」,
* 趋势图画成从基线向两侧偏离的棒棒糖(偏高一片暖色、偏低一片冷色),
* 统计里后两列给的是「最高偏离 / 最低偏离」而不是「最高 / 最低体温」。
*/
internal fun LazyListScope.temperatureDetail(state: MetricDetailUiState) {
item { TemperatureHeroCard(state) }
item { TemperatureChartCard(state) }
item { TemperatureStatsCard(state) }
}
/** 头图:当前体温 + 较基线的偏离 + 基线说明。 */
@Composable
private fun TemperatureHeroCard(state: MetricDetailUiState, modifier: Modifier = Modifier) {
val current = state.summary?.temperature ?: return
val deviation = current - MetricReference.TEMPERATURE_BASELINE
HealthCard(modifier = modifier) {
Text(
text = stringResource(R.string.metric_temperature),
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
)
Spacer(modifier = Modifier.height(2.dp))
HeroReading(value = formatDecimal(current, 1), unit = "", unitRes = R.string.unit_celsius)
Spacer(modifier = Modifier.height(10.dp))
// 偏离量染成与趋势图一致的方向色(偏高橙、偏低蓝),两处说的是同一件事。
Text(
text = stringResource(R.string.detail_temp_above, formatSignedDecimal(deviation, 1)),
style = MaterialTheme.typography.titleMedium,
color = if (deviation >= 0.0) MetricOrange else MetricBlue,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(
R.string.detail_temp_baseline,
formatDecimal(MetricReference.TEMPERATURE_BASELINE, 1),
),
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
)
}
}
/** 趋势图:以基线为中线的偏离条。 */
@Composable
private fun TemperatureChartCard(state: MetricDetailUiState, modifier: Modifier = Modifier) {
HealthCard(modifier = modifier) {
SectionHeader(
title = stringResource(R.string.detail_chart_daily, stringResource(R.string.metric_temperature)),
trailing = {
Text(
text = stringResource(
R.string.detail_temp_baseline,
formatDecimal(MetricReference.TEMPERATURE_BASELINE, 1),
),
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
)
},
)
MetricDeviationBar(
values = state.points.map { it.value },
baseline = MetricReference.TEMPERATURE_BASELINE,
span = MetricReference.TEMPERATURE_SPAN,
aboveColor = MetricOrange,
belowColor = MetricBlue,
axisLabels = chartAxisLabels(state.points),
)
}
}
/** 统计卡:平均体温 / 最高偏离 / 最低偏离。 */
@Composable
private fun TemperatureStatsCard(state: MetricDetailUiState, modifier: Modifier = Modifier) {
val values = state.points.map { it.value }
val stats = metricStatsOf(values) ?: return
val format = statFormatFor(state.metric)
val unit = format.unit
HealthCard(modifier = modifier) {
StatRow {
// 第一列是绝对值(36.5°C),后两列是相对基线的偏离量(+0.4°C / -0.3°C)。
// 混着放看着不齐,但正是这一页要讲的事:绝对值 + 它相对基线偏了多少。
StatColumn(
label = stringResource(R.string.detail_average),
value = formatStatValue(stats.average, state.metric, format),
unit = unit,
)
StatColumn(
label = stringResource(R.string.detail_temp_highest),
value = formatSignedDecimal(stats.max - MetricReference.TEMPERATURE_BASELINE, 1),
unit = unit,
)
StatColumn(
label = stringResource(R.string.detail_temp_lowest),
value = formatSignedDecimal(stats.min - MetricReference.TEMPERATURE_BASELINE, 1),
unit = unit,
)
}
}
}
@@ -1,18 +1,292 @@
package com.placeholder.ringhealth.feature.device package com.placeholder.ringhealth.feature.device
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box 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.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.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bluetooth
import androidx.compose.material.icons.filled.Nfc
import androidx.compose.material.icons.filled.QrCodeScanner
import androidx.compose.material.icons.filled.Watch
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.res.stringResource
import androidx.compose.ui.text.style.TextAlign
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.R
import com.placeholder.ringhealth.core.designsystem.AppBackground
import com.placeholder.ringhealth.core.designsystem.MetricGreen
import com.placeholder.ringhealth.core.designsystem.RingHealthTheme
import com.placeholder.ringhealth.core.designsystem.TextPrimary
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
/** 设备页(占位,待 Task 10 实现)。 */ /** 设备(戒指管理)页。 */
@Composable @Composable
fun DeviceScreen(modifier: Modifier = Modifier) { fun DeviceScreen(
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { onOpenSettings: () -> Unit,
Text(stringResource(R.string.tab_device)) modifier: Modifier = Modifier,
viewModel: DeviceViewModel = viewModel(factory = AppViewModelProvider.Factory),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
DeviceContent(state = state, onOpenSettings = onOpenSettings, modifier = modifier)
}
/**
* 无状态设备页内容。
*
* **空态 vs 已连态分两条分支。** 阶段一 Mock 始终 `connected = true`,空态走 Preview 验证;
* M2 接 SDK 后真实未连时会自动落到这里 —— 戒指扫描/绑定是用户买戒指后的第一件事,
* 不能让人看到一张「已连但没数据」的卡(那是 bug)。
*/
@Composable
private fun DeviceContent(
state: DeviceUiState,
onOpenSettings: () -> Unit,
modifier: Modifier = Modifier,
) {
if (!state.device.connected) {
DeviceEmptyState(modifier = modifier.fillMaxSize())
return
}
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 DeviceEmptyState(modifier: Modifier = Modifier) {
Column(
modifier = modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(modifier = Modifier.height(80.dp))
Box(
modifier = Modifier
.size(140.dp)
.clip(CircleShape)
.background(MetricGreen.copy(alpha = 0.10f)),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Filled.Nfc,
contentDescription = null,
tint = MetricGreen,
modifier = Modifier.size(64.dp),
)
}
Spacer(modifier = Modifier.height(20.dp))
Text(
text = stringResource(R.string.device_empty_title),
style = MaterialTheme.typography.titleLarge,
color = TextPrimary,
)
Spacer(modifier = Modifier.height(6.dp))
Text(
text = stringResource(R.string.device_empty_subtitle),
style = MaterialTheme.typography.bodyMedium,
color = TextSecondary,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 32.dp),
)
Spacer(modifier = Modifier.height(28.dp))
HealthCard(contentPadding = PaddingValues(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Button(
onClick = { /* 阶段一未开放:扫一扫入口在 M2 接 BLE 后实接 */ },
modifier = Modifier.weight(1f),
colors = ButtonDefaults.buttonColors(containerColor = MetricGreen),
) {
Icon(
imageVector = Icons.Filled.QrCodeScanner,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.width(8.dp))
Text(text = stringResource(R.string.device_empty_scan))
}
OutlinedButton(
onClick = { /* 阶段一未开放 */ },
modifier = Modifier.weight(1f),
) {
Text(text = stringResource(R.string.device_empty_pair_by_model))
}
}
}
}
}
/** 戒指信息卡片。 */
@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
private 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, name = "已连")
@Composable
private fun DeviceContentPreview() {
RingHealthTheme {
DeviceContent(
state = DeviceUiState(
device = RingDeviceState(true, "RingHealth 智能戒指", 78, "1.0.5", false),
settings = previewSettings(),
),
onOpenSettings = {},
)
}
}
/** 预览入口:未连空态。 */
@Preview(showBackground = true, name = "未连")
@Composable
private fun DeviceEmptyStatePreview() {
RingHealthTheme {
DeviceContent(
state = DeviceUiState(
device = RingDeviceState(false, "", 0, "", false),
settings = emptyList(),
),
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),
)
@@ -1,18 +1,125 @@
package com.placeholder.ringhealth.feature.device package com.placeholder.ringhealth.feature.device
import androidx.compose.foundation.layout.Box 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.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.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource 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.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.SubPageTopAppBar
import com.placeholder.ringhealth.domain.model.DeviceFunctionSetting
/** 设备设置子页(占位,待 Task 10 实现)。 */ /**
* 设备设置子页:可交互的功能开关列表。
* M1 只改内存状态;M2 后写入设备(对应协议 0502/0504)。
*/
@Composable @Composable
fun DeviceSettingsPlaceholder(modifier: Modifier = Modifier) { fun DeviceSettingsScreen(
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { onBack: () -> Unit,
Text(stringResource(R.string.placeholder_device_settings)) modifier: Modifier = Modifier,
viewModel: DeviceViewModel = viewModel(factory = AppViewModelProvider.Factory),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
DeviceSettingsContent(
settings = state.settings,
onToggle = viewModel::setFunctionEnabled,
onBack = onBack,
modifier = modifier,
)
}
/**
* 无状态设备设置内容。
* 与 `DeviceContent` 同样拆出独立函数,Preview 才能喂假数据渲染(ledger Ruling R26)。
* 注意回调签名是 `(id, enabled)` 而不是 `(DeviceFunctionSetting, Boolean)`:
* 无状态层不该知道「怎么改」,只负责把「改哪一项、改成什么」报上去。
*
* 顶栏的标题取代了原先列表里的「功能设置」区块标题 —— 同一个标题在一屏里出现两次是多余的。
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun DeviceSettingsContent(
settings: List<DeviceFunctionSetting>,
onToggle: (String, Boolean) -> Unit,
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
Scaffold(
modifier = modifier,
containerColor = MaterialTheme.colorScheme.background,
topBar = {
SubPageTopAppBar(
title = stringResource(R.string.device_settings_title),
onBack = onBack,
)
},
) { innerPadding ->
LazyColumn(
modifier = Modifier.fillMaxSize().padding(innerPadding),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
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),
)
}
}
}
}
/** 预览入口:以固定示例数据渲染 [DeviceSettingsContent],便于在 IDE 中免真机查看开关列表效果。 */
@Preview(showBackground = true)
@Composable
private fun DeviceSettingsContentPreview() {
RingHealthTheme {
DeviceSettingsContent(settings = previewSettings(), onToggle = { _, _ -> }, onBack = {})
} }
} }
@@ -0,0 +1,46 @@
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
}
}
@@ -1,75 +1,84 @@
package com.placeholder.ringhealth.feature.home package com.placeholder.ringhealth.feature.home
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material.icons.Icons 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.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.CircularProgressIndicator
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.placeholder.ringhealth.AppViewModelProvider import com.placeholder.ringhealth.AppViewModelProvider
import com.placeholder.ringhealth.R import com.placeholder.ringhealth.R
import com.placeholder.ringhealth.core.common.formatActiveMinutes
import com.placeholder.ringhealth.core.common.formatCalories import com.placeholder.ringhealth.core.common.formatCalories
import com.placeholder.ringhealth.core.common.formatDistance import com.placeholder.ringhealth.core.common.formatMonthDay
import com.placeholder.ringhealth.core.common.formatSleepDuration import com.placeholder.ringhealth.core.common.formatSleepDuration
import com.placeholder.ringhealth.core.common.formatSteps import com.placeholder.ringhealth.core.common.formatSteps
import com.placeholder.ringhealth.core.designsystem.MetricBlue import com.placeholder.ringhealth.core.common.greetingResFor
import com.placeholder.ringhealth.core.designsystem.MetricCyan import com.placeholder.ringhealth.core.common.stressLevelLabelRes
import com.placeholder.ringhealth.core.common.weekdayResFor
import com.placeholder.ringhealth.core.designsystem.GreetingStyle
import com.placeholder.ringhealth.core.designsystem.MetricGreen 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.RingHealthTheme
import com.placeholder.ringhealth.core.designsystem.TextPrimary
import com.placeholder.ringhealth.core.designsystem.TextSecondary import com.placeholder.ringhealth.core.designsystem.TextSecondary
import com.placeholder.ringhealth.core.designsystem.component.HealthCard import com.placeholder.ringhealth.core.designsystem.component.HealthCard
import com.placeholder.ringhealth.core.designsystem.component.MetricTile 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.core.designsystem.component.SectionHeader
import com.placeholder.ringhealth.core.designsystem.metricIcon
import com.placeholder.ringhealth.core.designsystem.metricTint
import com.placeholder.ringhealth.domain.model.HealthSummary import com.placeholder.ringhealth.domain.model.HealthSummary
import com.placeholder.ringhealth.domain.model.MetricType import com.placeholder.ringhealth.domain.model.MetricType
import com.placeholder.ringhealth.domain.model.RingDeviceState import com.placeholder.ringhealth.domain.model.RingDeviceState
/** 一个指标卡的数据。 */ /**
* 一个指标卡的数据。
*
* 只有文案和数值在这里 —— 图标与配色是指标的固有属性,取自 [metricIcon] / [metricTint],
* 不随卡片走。否则同一份映射会被复制到每个用到它的地方。
*/
private data class MetricCardSpec( private data class MetricCardSpec(
val type: MetricType, val type: MetricType,
val icon: ImageVector,
val tint: Color,
val title: String, val title: String,
val value: String?, val value: String?,
val unit: String, val unit: String,
) )
/** 首页网格列数。 */
private const val HOME_GRID_COLUMNS = 2
/** 健康首页。 */ /** 健康首页。 */
@Composable @Composable
fun HomeScreen( fun HomeScreen(
@@ -78,14 +87,30 @@ fun HomeScreen(
viewModel: HomeViewModel = viewModel(factory = AppViewModelProvider.Factory), viewModel: HomeViewModel = viewModel(factory = AppViewModelProvider.Factory),
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
HomeContent(state = state, onOpenDetail = onOpenDetail, modifier = modifier) HomeContent(
state = state,
onOpenDetail = onOpenDetail,
// 进页面时取一次当前时间,供问候语与日期行使用。
// 取一次而不是起一个每秒跳动的时钟:问候语在「早上好→下午好」之间切换没有实时性要求,
// 而一个常驻协程会在页面不可见时也持续重组。remember 保证配置变更外的重组不重取。
nowMillis = remember { System.currentTimeMillis() },
modifier = modifier,
)
} }
/** 无状态首页内容,便于 Preview。 */ /**
* 无状态首页内容,便于 Preview。
*
* 布局用 `LazyVerticalGrid` 两列:整宽内容(问候、设备条、步数大环、区块标题)用
* `GridItemSpan(maxLineSpan)` 跨满整行,六个指标卡各占一格,排成 2×3。
* 之前是 `LazyColumn` 单列 —— 每张指标卡被拉到整屏宽(约 1100px)而高只有 180px,
* 宽高比 6:1,横向大片留白、纵向却被撑开,六个数字要滚三屏才看得完。
*/
@Composable @Composable
private fun HomeContent( private fun HomeContent(
state: HomeUiState, state: HomeUiState,
onOpenDetail: (MetricType) -> Unit, onOpenDetail: (MetricType) -> Unit,
nowMillis: Long,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
if (state.loading || state.summary == null) { if (state.loading || state.summary == null) {
@@ -96,35 +121,80 @@ private fun HomeContent(
} }
val summary = state.summary val summary = state.summary
// 注意:LazyColumn 的 content lambda 不是 Composable 上下文, // 注意:LazyVerticalGrid 的 content lambda 不是 Composable 上下文,
// 所有需要 stringResource / Composable 调用的内容都必须在 LazyColumn 之前算好。 // 所有需要 stringResource / Composable 调用的内容都必须在网格之前算好。
val cards = buildMetricCards(summary) val cards = buildMetricCards(summary)
val notEnabledText = stringResource(R.string.home_not_enabled) val notEnabledText = stringResource(R.string.home_not_enabled)
LazyColumn( LazyVerticalGrid(
columns = GridCells.Fixed(HOME_GRID_COLUMNS),
modifier = modifier.fillMaxSize(), modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp), contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 4.dp, bottom = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
item { DeviceStrip(device = state.device) } // 跨满整行的元素一律显式传 GridItemSpan(maxLineSpan),而不是写死 2 ——
item { StepsCard(summary = summary, onClick = { onOpenDetail(MetricType.STEP) }) } // 将来改列数时这些元素自动跟随,不会漏改。
item { SectionHeader(title = stringResource(R.string.home_metrics)) } item(span = { GridItemSpan(maxLineSpan) }) { GreetingHeader(nowMillis = nowMillis) }
items(cards) { card -> item(span = { GridItemSpan(maxLineSpan) }) { DeviceStrip(device = state.device) }
item(span = { GridItemSpan(maxLineSpan) }) {
TodaySummaryCard(summary = summary, onOpenSteps = { onOpenDetail(MetricType.STEP) })
}
item(span = { GridItemSpan(maxLineSpan) }) {
SectionHeader(title = stringResource(R.string.home_metrics))
}
items(cards, key = { it.type.name }) { card ->
// 未开启的指标卡(血压)**同样可点开** —— 点进去是「未开启 + 怎么开启」的说明页。
//
// 这里曾经反过来:没有数值就不挂 clickable(`card.value?.let { ... }`),用来堵
// 「首页写未开启、点进去却是一张完整趋势图」这个自相矛盾。堵法过头了 ——
// 那张说明页因此一个入口都没有,成了死代码,用户也无从知道该怎么把功能打开。
// 矛盾的根源是详情页画了图,不是卡片可点击,所以改由血压页不画图来解决
// (见 BloodPressureDetail.kt)。
//
// 但 enabled 仍按有无数值来定:未开启时数值位显示的是「未开启」三个字,
// 用大号粗体排出来会比真实数值还重。
val enabled = card.value != null
MetricTile( MetricTile(
icon = card.icon, icon = metricIcon(card.type),
iconTint = card.tint, iconTint = metricTint(card.type),
title = card.title, title = card.title,
value = card.value ?: notEnabledText, value = card.value ?: notEnabledText,
unit = if (card.value == null) "" else card.unit, // 显示「未开启」时它不是一个数值,也就没有单位可跟。
unit = if (enabled) card.unit else "",
enabled = enabled,
onClick = { onOpenDetail(card.type) }, onClick = { onOpenDetail(card.type) },
) )
} }
} }
} }
/**
* 顶部问候 + 日期。
*
* 这是设计文档 §6 要求、但 M1 漏做的一块(当时首页直接从设备状态条开始)——
* 补上它既对齐了文档,也给页面一个「身份」:此前进入首页第一眼是一张卡片怼上来,
* 看不出这是哪一页。
*/
@Composable
private fun GreetingHeader(nowMillis: Long, modifier: Modifier = Modifier) {
Column(modifier = modifier.padding(top = 8.dp, bottom = 2.dp)) {
Text(
text = stringResource(greetingResFor(nowMillis)),
style = GreetingStyle,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "${formatMonthDay(nowMillis)} ${stringResource(weekdayResFor(nowMillis))}",
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
)
}
}
/** 顶部设备连接状态条。 */ /** 顶部设备连接状态条。 */
@Composable @Composable
private fun DeviceStrip(device: RingDeviceState?, modifier: Modifier = Modifier) { private fun DeviceStrip(device: RingDeviceState?, modifier: Modifier = Modifier) {
HealthCard(modifier = modifier) { HealthCard(modifier = modifier, contentPadding = PaddingValues(horizontal = 16.dp, vertical = 14.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Icon( Icon(
imageVector = Icons.Filled.MonitorHeart, imageVector = Icons.Filled.MonitorHeart,
@@ -153,114 +223,167 @@ private fun DeviceStrip(device: RingDeviceState?, modifier: Modifier = Modifier)
} }
} }
/** 今日步数大环卡片。 */ /**
* 今日数据卡片:三件套横排(步数 / 卡路里 / 活动时长)。
*
* 早先是「步数大环 + 距离/千卡」两件套 —— 大环占近 1/3 屏,环外信息只有两个换算结果,
* 留白多、信息密度低。换成三件套后:
* - 三列等宽,无主导项 —— 步数不再被环放大成「今天的全部重点」
* - 步数下挂目标("目标 10,000"),目标本身仍是用户对照自己的尺子
* - 步数一格整格可点进详情;卡路里/活动时长一格整格也点了能进(暂未挂路由,行为同环外区域)
*
* **第一版的「value + 单位同行」写法装不下**:30sp 字号下「8,426 步」「327 千卡」「47 分钟」
* 三件套单元格里都会换行 —— 视觉上「8,426」「步」分两行变成「数值」「单位」两截,反而像丢了内容。
* 改为「label 表达单位 + value 纯数字」:label 写成「步数 / 卡路里(千卡)/ 活动(分钟)」,
* 把单位塞进 label,value 行只摆数字。
*/
@Composable @Composable
private fun StepsCard(summary: HealthSummary, onClick: () -> Unit, modifier: Modifier = Modifier) { private fun TodaySummaryCard(summary: HealthSummary, onOpenSteps: () -> Unit, modifier: Modifier = Modifier) {
HealthCard(modifier = modifier, onClick = onClick) { HealthCard(modifier = modifier, contentPadding = PaddingValues(horizontal = 12.dp, vertical = 16.dp)) {
Row(modifier = Modifier.fillMaxWidth()) {
SummaryCell(
modifier = Modifier.weight(1f),
label = stringResource(R.string.home_summary_steps_label),
value = formatSteps(summary.steps),
subline = stringResource(R.string.home_summary_steps_goal, formatSteps(summary.stepGoal)),
onClick = onOpenSteps,
)
SummaryDivider()
SummaryCell(
modifier = Modifier.weight(1f),
label = stringResource(R.string.home_summary_calories_label),
value = formatCalories(summary.calories),
subline = null,
onClick = null,
)
SummaryDivider()
SummaryCell(
modifier = Modifier.weight(1f),
label = stringResource(R.string.home_summary_active_label),
value = formatActiveMinutes(summary.activeMinutes),
subline = null,
onClick = null,
)
}
}
}
/**
* 三件套行中两列之间的细分隔线。
*
* 一根 1dp 宽的浅色竖线,把三件套视觉上连成一张表而不是三张独立卡。
* 颜色用本页自定义的浅灰([HomeSummaryDivider]),不放 `core/designsystem/DividerColor`
* —— 那个值是卡片之间的整宽分割线(`#EFEFF4`),放在卡片内部会太显眼。
*/
@Composable
private fun RowScope.SummaryDivider() {
androidx.compose.foundation.layout.Box(
modifier = Modifier
.width(1.dp)
.padding(vertical = 6.dp)
.background(HomeSummaryDivider),
)
}
/** 三件套行内分隔线专用色,比卡片间分割线略深一档但仍属「几乎看不见」。 */
private val HomeSummaryDivider = androidx.compose.ui.graphics.Color(0xFFE2E5EB)
/**
* 三件套的单格:上标签(含单位)、下大数字、再下一行小字(步数那一格用,挂目标)。
*
* `onClick` 为 null 时整格不响应点击(卡路里/活动时长暂未挂详情路由);
* 不为 null 时整格可点(步数一格进步数详情)。
*/
@Composable
private fun SummaryCell(
label: String,
value: String,
subline: String?,
onClick: (() -> Unit)?,
modifier: Modifier = Modifier,
) {
val clickableModifier = if (onClick != null) modifier.clickable(onClick = onClick) else modifier
Column( Column(
modifier = Modifier.fillMaxWidth(), modifier = clickableModifier.padding(horizontal = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally, 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) Text(text = label, style = MaterialTheme.typography.bodySmall, color = TextSecondary)
Spacer(modifier = Modifier.height(6.dp))
Text(text = value, style = TodaySummaryValueStyle)
if (subline != null) {
Spacer(modifier = Modifier.height(2.dp))
Text(text = subline, style = MaterialTheme.typography.bodySmall, color = TextSecondary)
}
} }
} }
/** 组装指标卡列表;value 为 null 表示该指标未开启。 */ /** 三件套单格内的数值字号:26sp + tnum;30sp 在三等分格里换行(首版踩过这个坑)。 */
private val TodaySummaryValueStyle = TextStyle(
fontSize = 26.sp,
fontWeight = FontWeight.Bold,
letterSpacing = (-1).sp,
color = TextPrimary,
fontFeatureSettings = "tnum",
)
/**
* 组装指标卡列表;value 为 null 表示该指标未开启。
*
* 图标与配色一律取自 [metricIcon] / [metricTint],不在这里写字面量 ——
* 卡片和详情页必须显示成同一个指标的样子,抄两份就会有一份先过期。
*
* 值为 null 的原因是**功能开关关着**(概览的字段由开关生成,见 `MockHealthDataRepository`),
* 所以这里读到的 null 与功能设置页那个开关是同一个事实的两个说法。
*/
@Composable @Composable
private fun buildMetricCards(summary: HealthSummary): List<MetricCardSpec> = listOf( private fun buildMetricCards(summary: HealthSummary): List<MetricCardSpec> = listOf(
MetricCardSpec( MetricCardSpec(
type = MetricType.HEART_RATE, type = MetricType.HEART_RATE,
icon = Icons.Filled.Favorite,
tint = MetricRed,
title = stringResource(R.string.metric_heart_rate), title = stringResource(R.string.metric_heart_rate),
value = summary.heartRate?.toString(), value = summary.heartRate?.toString(),
unit = stringResource(R.string.unit_bpm), unit = stringResource(R.string.unit_bpm),
), ),
MetricCardSpec( MetricCardSpec(
type = MetricType.SLEEP, type = MetricType.SLEEP,
icon = Icons.Filled.Bedtime,
tint = MetricBlue,
title = stringResource(R.string.metric_sleep), title = stringResource(R.string.metric_sleep),
value = summary.sleepMinutes?.let { formatSleepDuration(it) }, value = summary.sleepMinutes?.let { formatSleepDuration(it) },
unit = "", unit = "",
), ),
MetricCardSpec( MetricCardSpec(
type = MetricType.BLOOD_OXYGEN, type = MetricType.BLOOD_OXYGEN,
icon = Icons.Filled.Bloodtype,
tint = MetricCyan,
title = stringResource(R.string.metric_blood_oxygen), title = stringResource(R.string.metric_blood_oxygen),
value = summary.bloodOxygen?.toString(), value = summary.bloodOxygen?.toString(),
unit = stringResource(R.string.unit_percent), unit = stringResource(R.string.unit_percent),
), ),
MetricCardSpec( MetricCardSpec(
type = MetricType.STRESS, type = MetricType.STRESS,
icon = Icons.Filled.Spa,
tint = MetricPurple,
title = stringResource(R.string.metric_stress), title = stringResource(R.string.metric_stress),
value = summary.stress?.toString(), value = summary.stress?.toString(),
unit = "", // 单位位改放压力等级文案(数值落在哪个区间就是放松/正常/中等/偏高),
// 于是卡片读作「34 正常」而不是一个孤零零的数字。无值时留空(上面的 value 分支
// 会整卡退化成「未开启」,此时单位本来也会被清空)。
unit = summary.stress?.let { stringResource(stressLevelLabelRes(it)) } ?: "",
), ),
MetricCardSpec( MetricCardSpec(
type = MetricType.TEMPERATURE, type = MetricType.TEMPERATURE,
icon = Icons.Filled.Thermostat,
tint = MetricOrange,
title = stringResource(R.string.metric_temperature), title = stringResource(R.string.metric_temperature),
value = summary.temperature?.toString(), value = summary.temperature?.toString(),
unit = stringResource(R.string.unit_celsius), unit = stringResource(R.string.unit_celsius),
), ),
MetricCardSpec( MetricCardSpec(
type = MetricType.BLOOD_PRESSURE, type = MetricType.BLOOD_PRESSURE,
icon = Icons.Filled.WaterDrop,
tint = MetricYellow,
title = stringResource(R.string.metric_blood_pressure), title = stringResource(R.string.metric_blood_pressure),
value = summary.bloodPressure, value = summary.bloodPressure,
unit = stringResource(R.string.unit_mmhg), unit = stringResource(R.string.unit_mmhg),
), ),
) )
/** 预览入口:以固定示例数据渲染 [HomeContent],便于在 IDE 中免真机查看首页效果。 */ /**
@Preview(showBackground = true) * 预览入口:以固定示例数据渲染 [HomeContent],便于在 IDE 中免真机查看首页效果。
*
* `nowMillis` 写死成固定时刻,预览里的问候语与日期才不会随运行时间变化。
*/
@Preview(showBackground = true, heightDp = 900)
@Composable @Composable
private fun HomeContentPreview() { private fun HomeContentPreview() {
RingHealthTheme { RingHealthTheme {
@@ -271,6 +394,9 @@ private fun HomeContentPreview() {
device = RingDeviceState(true, "RingHealth 智能戒指", 78, "1.0.5", false), device = RingDeviceState(true, "RingHealth 智能戒指", 78, "1.0.5", false),
), ),
onOpenDetail = {}, onOpenDetail = {},
// 固定为 2026-09-10 08:26(周四)—— 取固定值是为了让预览里的问候语「早上好」
// 与日期行不随运行时间变化,否则同一份 Preview 在不同时段渲染出不同文案。
nowMillis = 1_789_000_000_000L,
) )
} }
} }
@@ -0,0 +1,161 @@
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.lazy.LazyColumn
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
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 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
import com.placeholder.ringhealth.core.designsystem.component.SubPageTopAppBar
/**
* 「关于」子页:版本信息 + 检查更新。
*
* 把 spec §6「④ 我的」里的 `关于/检查更新` 合并到一页 ——「关于」页就该告诉用户
* 当前是什么版本、检查更新是关于页的标准动作之一,没必要再单开。
*
* 阶段一版本号是写死的 `0.1.0`(与 spec §12 占位命名阶段一致),
* 设备型号与固件版本从 [com.placeholder.ringhealth.data.mock.MockDataProvider.deviceState] 取演示值。
*
* 「检查更新」按钮按下后弹 Snackbar(这里用 `remember` 改一段说明文字代替真弹窗)
* —— 阶段一没有后端,「已是最新版本」是唯一合理回答。
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AboutScreen(
onBack: () -> Unit,
deviceModel: String = DEMO_DEVICE_MODEL,
firmwareVersion: String = DEMO_FIRMWARE_VERSION,
appVersion: String = DEMO_APP_VERSION,
modifier: Modifier = Modifier,
) {
Scaffold(
modifier = modifier,
containerColor = MaterialTheme.colorScheme.background,
topBar = {
SubPageTopAppBar(
title = stringResource(R.string.profile_about_title),
onBack = onBack,
)
},
) { innerPadding ->
AboutContent(
modifier = Modifier.fillMaxSize().padding(innerPadding),
deviceModel = deviceModel,
firmwareVersion = firmwareVersion,
appVersion = appVersion,
)
}
}
/** 演示用的版本号常量。真实版本号应在 build.gradle.kts 里取 BuildConfig.VERSION_NAME。 */
private const val DEMO_APP_VERSION = "0.1.0"
private const val DEMO_DEVICE_MODEL = "RingHealth 智能戒指"
private const val DEMO_FIRMWARE_VERSION = "1.0.5"
@Composable
private fun AboutContent(
deviceModel: String,
firmwareVersion: String,
appVersion: String,
modifier: Modifier = Modifier,
) {
// 「检查更新」按过的提示:阶段一没有真后端,按下后给一段静态文案。
// 走完全限定名 + 取 `.value` 而不是 `by remember`,避免在不同包引用 setValue 操作符。
val checkedState = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(false) }
val checked = checkedState.value
LazyColumn(
modifier = modifier,
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item { SectionHeader(title = stringResource(R.string.profile_about_section_app)) }
item {
HealthCard {
Column {
InfoRow(
label = stringResource(R.string.profile_about_app_version_label),
value = appVersion,
)
InfoRow(
label = stringResource(R.string.profile_about_firmware_version_label),
value = firmwareVersion,
)
InfoRow(
label = stringResource(R.string.profile_about_device_model_label),
value = deviceModel,
)
}
}
}
item { SectionHeader(title = stringResource(R.string.profile_about_section_actions)) }
item {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Button(
onClick = { checkedState.value = true },
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(containerColor = MetricGreen),
) {
Text(text = stringResource(R.string.profile_about_check_update))
}
if (checked) {
Text(
text = stringResource(R.string.profile_about_check_update_done),
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
)
Text(
text = stringResource(R.string.profile_about_check_update_current, appVersion),
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
)
}
}
}
}
}
@Composable
private fun InfoRow(label: String, value: String, modifier: Modifier = Modifier) {
Row(
modifier = modifier.fillMaxWidth().padding(vertical = 6.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(text = label, style = MaterialTheme.typography.bodyMedium)
Text(text = value, style = MaterialTheme.typography.bodyMedium, color = TextSecondary)
}
}
@Preview(showBackground = true)
@Composable
private fun AboutContentPreview() {
RingHealthTheme {
AboutContent(
deviceModel = DEMO_DEVICE_MODEL,
firmwareVersion = DEMO_FIRMWARE_VERSION,
appVersion = DEMO_APP_VERSION,
)
}
}
@@ -0,0 +1,175 @@
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.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.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Email
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
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
import com.placeholder.ringhealth.core.designsystem.component.SubPageTopAppBar
/**
* 「帮助与反馈」子页。
*
* 内容分两块:FAQ 折叠列表 + 「联系我们」入口卡。
* 阶段一 FAQ 是写死的 4 条,覆盖「连接 / 同步 / 单位 / 反馈」四个常见问题;
* M2 接 SDK 后接入真实 FAQ 后端。
*
* 折叠交互:每条 FAQ 是一个 `Card`,点击整张卡切换展开/收起 —— 整卡可点击比只在标题
* 加箭头更符合拇指操作区域。
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HelpScreen(onBack: () -> Unit, modifier: Modifier = Modifier) {
Scaffold(
modifier = modifier,
containerColor = MaterialTheme.colorScheme.background,
topBar = {
SubPageTopAppBar(
title = stringResource(R.string.profile_help_title),
onBack = onBack,
)
},
) { innerPadding ->
HelpContent(modifier = Modifier.fillMaxSize().padding(innerPadding))
}
}
/** FAQ 一条。题目与答案作为参数传入,便于复用与测试。 */
private data class FaqItem(val questionRes: Int, val answerRes: Int)
/** 阶段一 FAQ 列表 —— 按用户最可能先问的顺序排:连接 → 同步 → 设置 → 反馈。 */
private val FaqCatalog: List<FaqItem> = listOf(
FaqItem(R.string.profile_help_faq_1_q, R.string.profile_help_faq_1_a),
FaqItem(R.string.profile_help_faq_2_q, R.string.profile_help_faq_2_a),
FaqItem(R.string.profile_help_faq_3_q, R.string.profile_help_faq_3_a),
FaqItem(R.string.profile_help_faq_4_q, R.string.profile_help_faq_4_a),
)
@Composable
private fun HelpContent(modifier: Modifier = Modifier) {
LazyColumn(
modifier = modifier,
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item { SectionHeader(title = stringResource(R.string.profile_help_section_faq)) }
items(FaqCatalog.size) { index ->
FaqCard(item = FaqCatalog[index])
}
item { Spacer(modifier = Modifier.height(4.dp)) }
item { SectionHeader(title = stringResource(R.string.profile_help_section_contact)) }
item { ContactCard() }
}
}
/**
* 单条 FAQ 卡。
*
* 展开状态用 `remember { mutableStateOf(false) }` 保留:默认收起,进入页面只看问题列表,
* 点开才看答案 —— 减少一屏的信息密度。整张卡可点,比把点击区限制在标题上更顺手。
*/
@Composable
private fun FaqCard(item: FaqItem, modifier: Modifier = Modifier) {
var expanded by remember { mutableStateOf(false) }
HealthCard(modifier = modifier.fillMaxWidth(), onClick = { expanded = !expanded }) {
Column {
Text(
text = stringResource(item.questionRes),
style = MaterialTheme.typography.titleSmall,
)
if (expanded) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(item.answerRes),
style = MaterialTheme.typography.bodyMedium,
color = TextSecondary,
)
}
}
}
}
/**
* 「联系我们」卡:邮箱 + 图标。
*
* 阶段一没有真点开的动作 —— 邮件地址是写死的演示值。M2 接后端时换成 `mailto:` Intent。
* 卡片上不放按钮,把整张卡做成「邮箱 + Email 图标 + 右箭头」的结构,按下后跳邮件客户端。
*/
@Composable
private fun ContactCard(modifier: Modifier = Modifier) {
HealthCard(modifier = modifier) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Filled.Email,
contentDescription = null,
tint = MetricGreen,
modifier = Modifier
.size(36.dp)
.clip(CircleShape)
.padding(6.dp),
)
Spacer(modifier = Modifier.size(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.profile_help_contact),
style = MaterialTheme.typography.titleSmall,
)
Text(
text = stringResource(R.string.profile_help_contact_email),
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
)
}
}
}
}
/** LazyListScope.items(count) — 阶段一直接用 lambda,不引入 item DSL。 */
private fun androidx.compose.foundation.lazy.LazyListScope.items(
count: Int,
itemContent: @Composable (index: Int) -> Unit,
) {
items(count = count, itemContent = { index -> itemContent(index) })
}
@Preview(showBackground = true)
@Composable
private fun HelpContentPreview() {
RingHealthTheme {
HelpContent()
}
}
@@ -0,0 +1,152 @@
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.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.material.icons.Icons
import androidx.compose.material.icons.filled.NotificationsActive
import androidx.compose.material.icons.filled.NotificationsOff
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
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 androidx.compose.ui.text.style.TextAlign
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.TextPrimary
import com.placeholder.ringhealth.core.designsystem.TextSecondary
import com.placeholder.ringhealth.core.designsystem.component.HealthCard
import com.placeholder.ringhealth.core.designsystem.component.SubPageTopAppBar
/**
* 「通知权限」引导子页。
*
* 阶段一只做 **静态引导**:告诉用户当前权限状态、关闭权限会丢什么,
* 并提供「请求权限」与「前往系统设置」两个按钮 —— 后者会真跳到系统设置页
* (`Settings.ACTION_APP_NOTIFICATION_SETTINGS` 是个标准 Intent),
* 前者阶段一不真发起系统权限弹窗(M2 接 SDK 时再做)。
*
* 权限状态用 `remember { mutableStateOf(false) }` 当演示值,「未授权」是默认演示态 —
* — 引导用户从这里开始走流程。
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NotificationsScreen(onBack: () -> Unit, modifier: Modifier = Modifier) {
Scaffold(
modifier = modifier,
containerColor = MaterialTheme.colorScheme.background,
topBar = {
SubPageTopAppBar(
title = stringResource(R.string.profile_notifications_title),
onBack = onBack,
)
},
) { innerPadding ->
NotificationsContent(modifier = Modifier.fillMaxSize().padding(innerPadding))
}
}
@Composable
private fun NotificationsContent(modifier: Modifier = Modifier) {
// 演示用状态:阶段一始终未授权,引导用户走完整流程。
// 走完全限定名 + 取 `.value` 而不是 `var by remember`,避免 setValue 操作符跨包引用。
val grantedState = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(false) }
val granted = grantedState.value
LazyColumn(
modifier = modifier,
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item { PermissionStatusCard(granted = granted) }
item { PermissionHintCard() }
item {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Button(
onClick = { grantedState.value = true },
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(containerColor = MetricGreen),
) {
Text(text = stringResource(R.string.profile_notifications_request))
}
OutlinedButton(
onClick = { /* 阶段一未实接:M2 时用 Settings.ACTION_APP_NOTIFICATION_SETTINGS 跳系统设置 */ },
modifier = Modifier.fillMaxWidth(),
) {
Text(text = stringResource(R.string.profile_notifications_open_settings))
}
}
}
}
}
/** 顶部权限状态展示:圆形徽章 + 当前状态文字。 */
@Composable
private fun PermissionStatusCard(granted: Boolean, modifier: Modifier = Modifier) {
HealthCard(modifier = modifier) {
Column(
modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
imageVector = if (granted) Icons.Filled.NotificationsActive else Icons.Filled.NotificationsOff,
contentDescription = null,
tint = if (granted) MetricGreen else TextSecondary,
modifier = Modifier.size(56.dp),
)
Spacer(modifier = Modifier.height(10.dp))
Text(
text = stringResource(
R.string.profile_notifications_status,
stringResource(
if (granted) R.string.profile_notifications_status_granted
else R.string.profile_notifications_status_denied,
),
),
style = MaterialTheme.typography.titleMedium,
color = TextPrimary,
)
}
}
}
/** 解释关掉通知会丢什么的说明卡。 */
@Composable
private fun PermissionHintCard(modifier: Modifier = Modifier) {
HealthCard(modifier = modifier) {
Text(
text = stringResource(R.string.profile_notifications_hint),
style = MaterialTheme.typography.bodyMedium,
color = TextSecondary,
textAlign = TextAlign.Start,
)
}
}
/** 56dp 图标是「权限状态」这个语义节点该有的视觉重量 —— 比下面的开关按钮重,比顶栏轻。 */
@Preview(showBackground = true)
@Composable
private fun NotificationsContentPreview() {
RingHealthTheme {
NotificationsContent()
}
}
@@ -1,18 +1,174 @@
package com.placeholder.ringhealth.feature.profile package com.placeholder.ringhealth.feature.profile
import androidx.compose.foundation.layout.Box 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.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.automirrored.filled.KeyboardArrowRight
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.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.placeholder.ringhealth.R import com.placeholder.ringhealth.R
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.RingHealthTheme
import com.placeholder.ringhealth.core.designsystem.TextSecondary
import com.placeholder.ringhealth.core.designsystem.component.HealthCard
import com.placeholder.ringhealth.core.designsystem.component.IconBadge
import com.placeholder.ringhealth.core.designsystem.component.SectionHeader
/** 我的页(占位,待 Task 11 实现)。 */ /**
* “我的”页。
*
* 2026-09-10 晚收口:四个入口(单位设置 / 通知权限 / 检查更新 / 帮助与反馈)
* 全部挂上 `onClick`,导航到 [SettingsScreen] / [NotificationsScreen] / [AboutScreen] / [HelpScreen],
* 告别阶段一首批的死链接(PROGRESS.md §3.2)。
*
* 本页**不需要**再拆「有状态外壳 + 无状态内容」(ledger Ruling R26 的常规形状):
* 它本身就是无状态的,直接对它挂 `@Preview` 即可。
*/
@Composable @Composable
fun ProfileScreen(modifier: Modifier = Modifier) { fun ProfileScreen(
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { onOpenSettings: () -> Unit = {},
Text(stringResource(R.string.tab_profile)) onOpenNotifications: () -> Unit = {},
onOpenAbout: () -> Unit = {},
onOpenHelp: () -> Unit = {},
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,
tint = MetricBlue,
title = stringResource(R.string.profile_units),
subtitle = stringResource(R.string.profile_units_desc),
onClick = onOpenSettings,
)
}
item {
ProfileEntry(
icon = Icons.Filled.Notifications,
tint = MetricOrange,
title = stringResource(R.string.profile_notification),
subtitle = stringResource(R.string.profile_notification_desc),
onClick = onOpenNotifications,
)
}
item { SectionHeader(title = stringResource(R.string.profile_group_about)) }
item {
ProfileEntry(
icon = Icons.Filled.Refresh,
tint = MetricPurple,
title = stringResource(R.string.profile_check_update),
subtitle = stringResource(R.string.profile_check_update_desc),
onClick = onOpenAbout,
)
}
item {
ProfileEntry(
icon = Icons.Filled.Info,
tint = MetricCyan,
title = stringResource(R.string.profile_help),
subtitle = stringResource(R.string.profile_help_desc),
onClick = onOpenHelp,
)
}
}
}
/**
* 单条设置入口。
*
* 图标改用 [IconBadge] 的色底圆徽章,与首页指标卡同一套处理 —— 此前是裸的灰色小图标,
* 在全屏白卡上几乎是隐形的;四张卡的图标各配一色之后,一行行扫下来能靠颜色定位。
*
* 末尾的右箭头表示「本条可进」;`onClick` 为空 lambda 时卡片整张不响应点击(**保留
* 默认值兼容旧调用方**,将来如有需要可以让单条退化为纯展示)。
*/
@Composable
private fun ProfileEntry(
icon: ImageVector,
tint: Color,
title: String,
subtitle: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
HealthCard(modifier = modifier, onClick = onClick) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
IconBadge(icon = icon, tint = tint)
Column(modifier = Modifier.weight(1f).padding(start = 12.dp)) {
Text(text = title, style = MaterialTheme.typography.titleSmall)
Text(text = subtitle, style = MaterialTheme.typography.bodySmall, color = TextSecondary)
}
Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
tint = TextSecondary,
modifier = Modifier.size(20.dp),
)
}
}
}
/** 预览入口:直接渲染 [ProfileScreen](本页无状态、无示例数据),便于在 IDE 中免真机查看“我的”页效果。 */
@Preview(showBackground = true)
@Composable
private fun ProfileScreenPreview() {
RingHealthTheme {
ProfileScreen()
} }
} }
@@ -0,0 +1,180 @@
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.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.lazy.LazyColumn
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
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 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.core.designsystem.component.SubPageTopAppBar
/**
* 「应用设置」子页。
*
* 把阶段一首批两个死链接(单位设置 + 通知权限)合并到一个页面,
* 按 `单位` / `通知` 两个 [SectionHeader] 分组。开关是 **演示用** —— 切了不持久化、
* 也不影响任何真实行为;M2 接 SDK 后通知开关才会真正写到设置接口。
*
* 为什么不拆成两个子页:单位只有两项只读,不值得单独一页;通知三个开关合在一张卡更直观。
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(onBack: () -> Unit, modifier: Modifier = Modifier) {
Scaffold(
modifier = modifier,
containerColor = MaterialTheme.colorScheme.background,
topBar = {
SubPageTopAppBar(
title = stringResource(R.string.profile_settings_title),
onBack = onBack,
)
},
) { innerPadding ->
SettingsContent(modifier = Modifier.fillMaxSize().padding(innerPadding))
}
}
/** 内容拆出独立函数便于 Preview。 */
@Composable
private fun SettingsContent(modifier: Modifier = Modifier) {
LazyColumn(
modifier = modifier,
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item { SectionHeader(title = stringResource(R.string.profile_settings_section_units)) }
item { UnitsCard() }
item { Spacer(modifier = Modifier.height(4.dp)) }
item { SectionHeader(title = stringResource(R.string.profile_settings_section_notifications)) }
item { NotificationsCard() }
}
}
/**
* 单位卡:只读展示当前选中的距离/体重单位。
*
* 阶段一是「静态展示」,没有切换交互 —— 让用户看到单位配置在这里,将来要做切换
* 时再补 `onClick` 与路由。M2 接 SDK 后会改成可点的下拉。
*/
@Composable
private fun UnitsCard(modifier: Modifier = Modifier) {
HealthCard(modifier = modifier) {
Column {
SettingRow(
title = stringResource(R.string.profile_settings_units_distance_label),
value = stringResource(R.string.profile_settings_units_distance_value),
)
Spacer(modifier = Modifier.height(12.dp))
SettingRow(
title = stringResource(R.string.profile_settings_units_weight_label),
value = stringResource(R.string.profile_settings_units_weight_value),
)
}
}
}
/**
* 通知卡:三个开关,按 `MockDataProvider.defaultFunctionSettings()` 的思路给演示值。
*
* 三个开关里**默认营销通知关着**、其余默认开着 —— 与「健康/设备提醒应当默认开,
* 营销通知应当默认关」的产品惯例一致;M2 接 SDK 后会真正写到系统通知渠道设置。
*/
@Composable
private fun NotificationsCard(modifier: Modifier = Modifier) {
HealthCard(modifier = modifier) {
Column {
ToggleRow(
title = stringResource(R.string.profile_settings_notifications_health),
subtitle = stringResource(R.string.profile_settings_notifications_health_desc),
initial = true,
)
Spacer(modifier = Modifier.height(12.dp))
ToggleRow(
title = stringResource(R.string.profile_settings_notifications_device),
subtitle = stringResource(R.string.profile_settings_notifications_device_desc),
initial = true,
)
Spacer(modifier = Modifier.height(12.dp))
ToggleRow(
title = stringResource(R.string.profile_settings_notifications_marketing),
subtitle = stringResource(R.string.profile_settings_notifications_marketing_desc),
initial = false,
)
}
}
}
@Composable
private fun SettingRow(title: String, value: String, modifier: Modifier = Modifier) {
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(text = title, style = MaterialTheme.typography.bodyMedium)
Text(text = value, style = MaterialTheme.typography.bodyMedium, color = TextSecondary)
}
}
/**
* 单行开关。阶段一切换不持久化 —— 这里用 `remember` 保留每行自己的状态,
* 离页即丢。视觉一致即可。
*/
@Composable
private fun ToggleRow(
title: String,
subtitle: String,
initial: Boolean,
modifier: Modifier = Modifier,
) {
// `var checked by remember { ... }` 需要 `getValue` / `setValue` 两个 delegate 操作符,
// 跨包引用时 Compose 不会自动导,所以这里走完全限定名 —— `var` 的左值其实只需要 setValue。
val mutableState = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(initial) }
val checked = mutableState.value
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f).padding(end = 16.dp)) {
Text(text = title, style = MaterialTheme.typography.bodyMedium)
Text(text = subtitle, style = MaterialTheme.typography.bodySmall, color = TextSecondary)
}
Switch(
checked = checked,
onCheckedChange = { mutableState.value = it },
colors = SwitchDefaults.colors(checkedTrackColor = MetricGreen),
)
}
}
/** 预览入口:直接渲染 [SettingsContent],便于在 IDE 中免真机查看应用设置页效果。 */
@Preview(showBackground = true)
@Composable
private fun SettingsContentPreview() {
RingHealthTheme {
SettingsContent()
}
}
@@ -1,18 +1,135 @@
package com.placeholder.ringhealth.feature.workout package com.placeholder.ringhealth.feature.workout
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize 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.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource 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.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.SubPageTopAppBar
import com.placeholder.ringhealth.domain.model.WorkoutRecord
/** 运动历史子页(占位,待 Task 9 实现)。 */ /** 运动历史子页:展示全部运动记录。 */
@Composable @Composable
fun WorkoutHistoryPlaceholder(modifier: Modifier = Modifier) { fun WorkoutHistoryScreen(
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { onBack: () -> Unit,
Text(stringResource(R.string.placeholder_workout_history)) modifier: Modifier = Modifier,
viewModel: WorkoutViewModel = viewModel(factory = AppViewModelProvider.Factory),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
WorkoutHistoryContent(state = state, onBack = onBack, modifier = modifier)
}
/**
* 无状态运动历史内容,便于 Preview。
*
* 本页自带 `Scaffold` + `TopAppBar`(外层 `RingHealthApp` 的 `innerPadding` 已在 NavHost 上
* 消费过,这里再补一次 `padding(innerPadding)` 是正常的层级内边距,不会叠加状态栏 ——
* 与 `MetricDetailScreen` 同一套写法)。补顶栏是为了和详情页一致:此前本页没有返回箭头,
* 只能靠系统返回手势或点底部 Tab 离开。
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun WorkoutHistoryContent(
state: WorkoutUiState,
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
Scaffold(
modifier = modifier,
containerColor = MaterialTheme.colorScheme.background,
topBar = {
SubPageTopAppBar(
title = stringResource(R.string.workout_history_title),
onBack = onBack,
)
},
) { innerPadding ->
// 必须先判 loading 再判空(ledger Ruling R25):Mock 仓库有 200ms 延迟,
// records 在加载完成前就是 emptyList,若先判空会先闪一下「暂无运动记录」,
// 数据到了再跳成列表 —— 用户看到的是「明明有记录却说没有」。
if (state.loading) {
Box(
modifier = Modifier.fillMaxSize().padding(innerPadding),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(color = MetricGreen)
}
return@Scaffold
}
if (state.records.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize().padding(innerPadding).padding(16.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = stringResource(R.string.workout_empty),
style = MaterialTheme.typography.bodyMedium,
color = TextSecondary,
)
}
return@Scaffold
}
LazyColumn(
modifier = Modifier.fillMaxSize().padding(innerPadding),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(state.records) { record ->
WorkoutRecordCard(record = record)
}
}
}
}
/** 预览入口:以固定示例数据渲染 [WorkoutHistoryContent],便于在 IDE 中免真机查看历史页效果。 */
@Preview(showBackground = true)
@Composable
private fun WorkoutHistoryContentPreview() {
RingHealthTheme {
WorkoutHistoryContent(
state = WorkoutUiState(
loading = false,
records = listOf(
WorkoutRecord(
id = "preview-0",
type = "户外跑步",
startTime = 1_757_000_000_000L,
durationMinutes = 42,
distanceKm = 5.20,
calories = 286,
),
WorkoutRecord(
id = "preview-1",
type = "健走",
startTime = 1_756_900_000_000L,
durationMinutes = 31,
distanceKm = 2.80,
calories = 143,
),
),
),
onBack = {},
)
} }
} }
@@ -1,18 +1,291 @@
package com.placeholder.ringhealth.feature.workout package com.placeholder.ringhealth.feature.workout
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box 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.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.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.DirectionsBike
import androidx.compose.material.icons.automirrored.filled.DirectionsRun
import androidx.compose.material.icons.automirrored.filled.DirectionsWalk
import androidx.compose.material.icons.filled.FitnessCenter
import androidx.compose.material.icons.filled.SportsBaseball
import androidx.compose.material.icons.filled.SportsHandball
import androidx.compose.material.icons.filled.SportsMartialArts
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
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.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.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.WorkoutRecord
/** 运动页(占位,待 Task 9 实现)。 */ /**
* 运动类型。
*
* 戒指协议覆盖的运动模式列表([SDK 协议文档](SmartRingF01_SDK))。
* 阶段一尚未接入真实运动检测,**全部仅作占位** —— 点击任何一项都弹 Snackbar
* 「将在接入戒指后开放」,不会真的启动运动。
*
* 排序按使用频率从高到低:跑步(最常)→ 自由训练(兜底)。
* 改这里记得同步 [SportCatalog] 的顺序 —— 列表按它枚举。
*/
internal enum class SportType(val labelRes: Int, val icon: ImageVector) {
OUTDOOR_RUN(R.string.workout_sport_running_outdoor, Icons.AutoMirrored.Filled.DirectionsRun),
WALK(R.string.workout_sport_walking, Icons.AutoMirrored.Filled.DirectionsWalk),
OUTDOOR_CYCLE(R.string.workout_sport_cycling_outdoor, Icons.AutoMirrored.Filled.DirectionsBike),
INDOOR_RUN(R.string.workout_sport_running_indoor, Icons.Filled.SportsBaseball),
JUMP_ROPE(R.string.workout_sport_jump_rope, Icons.Filled.SportsHandball),
FREE_TRAINING(R.string.workout_sport_free_training, Icons.Filled.SportsMartialArts),
}
/** 运动类型网格的渲染顺序。 */
private val SportCatalog: List<SportType> = SportType.entries
/** 运动页。 */
@Composable @Composable
fun WorkoutScreen(modifier: Modifier = Modifier) { 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)
}
/**
* 无状态运动页内容。
*
* 顶部是 [SportTypeGrid](横排可滑的 6 个运动类型入口),
* 下方是「最近运动」记录列表。每个运动类型都跟原 [StartWorkoutCard] 一样
* 弹一条 Snackbar 说明该能力尚未开放 —— 弹提示比 `enabled = false` 的半透明按钮好,
* 前者明确告诉用户「为什么现在没有」。
*/
@Composable
private fun WorkoutContent(
state: WorkoutUiState,
onOpenHistory: () -> Unit,
modifier: Modifier = Modifier,
) {
if (state.loading) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(stringResource(R.string.tab_workout)) CircularProgressIndicator(color = MetricGreen)
}
return
}
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
// 在 Composable 上下文里先取好文案:onClick 是普通 lambda,里面不能调 stringResource。
val startUnavailableMessage = stringResource(R.string.workout_start_unavailable)
Box(modifier = modifier.fillMaxSize()) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item {
SportTypeGrid(
onSportClick = {
scope.launch { snackbarHostState.showSnackbar(startUnavailableMessage) }
},
)
}
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)
}
}
SnackbarHost(
hostState = snackbarHostState,
modifier = Modifier.align(Alignment.BottomCenter),
)
}
}
/**
* 运动类型入口网格。
*
* 横排 6 个类型入口,可滑。
*
* 视觉选择:每格都是「72dp 圆角方块 + MetricGreen 浅色底 + MetricGreen 深色图标 + 类型名下方」
* —— 与 `HealthCard` 的白底区分开(这些是「操作入口」而不是「数据卡片」)。
* 图标背景用 `MetricGreen.copy(alpha = 0.12f)` 而不是小米那种饱和度极高的大色块:
* 设计文档 §7.1 说「颜色是有含义的,不能当装饰用」,所以运动类型不带色彩信号,
* 一律走品牌主色的浅色底,与品牌色图标配对传达「这里是行动入口」。
*/
@Composable
private fun SportTypeGrid(onSportClick: () -> Unit, modifier: Modifier = Modifier) {
HealthCard(modifier = modifier, contentPadding = PaddingValues(vertical = 16.dp, horizontal = 8.dp)) {
LazyRow(
modifier = Modifier.fillMaxWidth(),
contentPadding = PaddingValues(horizontal = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
items(SportCatalog, key = { it.name }) { sport ->
SportTile(
label = stringResource(sport.labelRes),
icon = sport.icon,
onClick = onSportClick,
)
}
}
}
}
/** 单个运动类型入口。 */
@Composable
private fun SportTile(label: String, icon: ImageVector, onClick: () -> Unit, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.padding(vertical = 4.dp)
.clip(RoundedCornerShape(12.dp))
.background(MetricGreen.copy(alpha = 0.10f))
.clickable(onClick = onClick)
.padding(horizontal = 12.dp, vertical = 14.dp)
.width(80.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box(
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.background(MetricGreen),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(26.dp),
)
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
)
}
}
/** 单条运动记录卡片。 */
@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,
)
}
}
}
}
/** 预览入口:以固定示例数据渲染 [WorkoutContent],便于在 IDE 中免真机查看运动页效果。 */
@Preview(showBackground = true)
@Composable
private fun WorkoutContentPreview() {
RingHealthTheme {
WorkoutContent(
state = WorkoutUiState(
loading = false,
records = listOf(
WorkoutRecord(
id = "preview-0",
type = "户外跑步",
startTime = 1_757_000_000_000L,
durationMinutes = 42,
distanceKm = 5.20,
calories = 286,
),
WorkoutRecord(
id = "preview-1",
type = "健走",
startTime = 1_756_900_000_000L,
durationMinutes = 31,
distanceKm = 2.80,
calories = 143,
),
),
),
onOpenHistory = {},
)
} }
} }
@@ -0,0 +1,35 @@
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() {
/** 页面状态:仓库数据到达前为 loading,到达后为记录列表。 */
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
}
}
+199 -3
View File
@@ -11,9 +11,24 @@
<string name="tab_workout">运动</string> <string name="tab_workout">运动</string>
<string name="tab_device">设备</string> <string name="tab_device">设备</string>
<string name="tab_profile">我的</string> <string name="tab_profile">我的</string>
<string name="placeholder_device_settings">设备设置</string>
<string name="placeholder_workout_history">运动历史</string> <!--
<string name="placeholder_metric_detail">指标详情</string> 首页顶部问候。按当前时段四选一,由 HomeScreen 依时间片决定用哪条。
分四档而不是三档:凌晨(0:00–5:00)说「早上好」不符合直觉,单列一条「夜深了」。
-->
<string name="home_greeting_morning">早上好</string>
<string name="home_greeting_afternoon">下午好</string>
<string name="home_greeting_evening">晚上好</string>
<string name="home_greeting_night">夜深了</string>
<!-- 星期简写。日期行形如「9月10日 周四」,日期本身走 Formatters 的格式化,星期走这里。 -->
<string name="weekday_monday">周一</string>
<string name="weekday_tuesday">周二</string>
<string name="weekday_wednesday">周三</string>
<string name="weekday_thursday">周四</string>
<string name="weekday_friday">周五</string>
<string name="weekday_saturday">周六</string>
<string name="weekday_sunday">周日</string>
<string name="home_device_connected">%1$s · 已连接</string> <string name="home_device_connected">%1$s · 已连接</string>
<string name="home_device_disconnected">未连接设备</string> <string name="home_device_disconnected">未连接设备</string>
@@ -28,6 +43,8 @@
<string name="unit_percent">%</string> <string name="unit_percent">%</string>
<string name="unit_celsius">°C</string> <string name="unit_celsius">°C</string>
<string name="unit_mmhg">mmHg</string> <string name="unit_mmhg">mmHg</string>
<!-- 详情页统计摘要的单位位用。步数此前没有单位资源,摘要里只能显示一个光秃秃的 8,426。 -->
<string name="unit_steps">步</string>
<string name="metric_heart_rate">心率</string> <string name="metric_heart_rate">心率</string>
<string name="metric_sleep">睡眠</string> <string name="metric_sleep">睡眠</string>
@@ -36,4 +53,183 @@
<string name="metric_temperature">体温</string> <string name="metric_temperature">体温</string>
<string name="metric_blood_pressure">血压</string> <string name="metric_blood_pressure">血压</string>
<string name="metric_step">步数</string> <string name="metric_step">步数</string>
<string name="workout_start">开始运动</string>
<string name="workout_start_desc">选择运动类型,记录你的训练</string>
<string name="workout_recent">最近运动</string>
<string name="workout_history_title">运动历史</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="workout_start_unavailable">运动记录将在接入戒指后开放</string>
<string name="action_view_all">查看全部</string>
<string name="device_connected">已连接</string>
<string name="device_disconnected">未连接</string>
<string name="device_battery">电量 %1$d%%</string>
<string name="device_firmware">固件版本 %1$s</string>
<string name="device_settings_title">功能设置</string>
<string name="device_more">更多设置</string>
<string name="device_switch_on">已开启</string>
<string name="device_switch_off">已关闭</string>
<string name="device_settings_hint">阶段一为演示开关,接入戒指后将同步到设备。</string>
<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>
<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_empty">暂无数据</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="detail_chart_daily">每日%1$s</string>
<string name="detail_goal">目标 %1$s</string>
<string name="detail_days_count">%1$d 天</string>
<string name="detail_status_normal">正常</string>
<string name="detail_status_high">偏高</string>
<string name="detail_status_low">偏低</string>
<!-- 步数 -->
<string name="detail_steps_today">今日步数</string>
<string name="detail_steps_progress">已完成</string>
<string name="detail_steps_reached_label">达标天数</string>
<string name="detail_steps_average">日均</string>
<!-- 心率 -->
<string name="detail_hr_normal_band">正常 %1$s–%2$s 次/分</string>
<string name="detail_hr_normal_days">正常范围</string>
<!-- 睡眠 -->
<string name="detail_sleep_last_night">昨晚睡眠</string>
<string name="detail_sleep_target">目标 %1$s</string>
<!-- 睡眠时段图:横轴是钟点,一根条从入睡横到起床。柱状图只画「睡了多久」,
看不出作息是不是一天天往后拖,这张图才回答得了那个问题。 -->
<string name="detail_sleep_window">睡眠时段</string>
<string name="detail_sleep_window_average">平均 %1$s 入睡</string>
<string name="detail_sleep_avg_fall_asleep">平均入睡</string>
<string name="detail_sleep_avg_wake_up">平均醒来</string>
<string name="detail_sleep_avg_duration">平均时长</string>
<string name="unit_hours">小时</string>
<!-- 血氧 -->
<string name="detail_spo2_threshold">达标线 %1$s%%</string>
<string name="detail_spo2_low_days">偏低 %1$d 天</string>
<!-- 压力 -->
<string name="detail_stress_current">当前压力</string>
<string name="detail_stress_relaxed_days">放松天数</string>
<string name="detail_stress_legend">等级分布</string>
<!-- 体温 -->
<string name="detail_temp_baseline">基线 %1$s°C</string>
<string name="detail_temp_above">较基线 %1$s°C</string>
<string name="detail_temp_highest">最高偏离</string>
<string name="detail_temp_lowest">最低偏离</string>
<!-- 血压:演示数据里该功能未开启,页面给空态而不是画图 -->
<!-- 未开启说明卡:七个指标共用,%1$s 是指标名(如「血压」) -->
<string name="detail_disabled_title">%1$s监测未开启</string>
<string name="detail_disabled_hint">可在「设备 - 功能设置」中开启该监测项,开启后才会生成记录。</string>
<!-- 血压页(已开启态) -->
<string name="detail_bp_latest">最近一次测量</string>
<string name="detail_bp_systolic">收缩压</string>
<string name="detail_bp_diastolic">舒张压</string>
<string name="detail_bp_history">测量记录</string>
<string name="detail_bp_pending">血压需先在设备页完成手动校准,之后才会生成测量记录。</string>
<string name="action_back">返回</string>
<!-- ====== 2026-09-10 晚追加:四个 Tab 收口(首页三件套 / 运动类型网格 / 设备空态 / 我的子页) ====== -->
<!-- 首页:三件套横排(替代原「步数大环 + 距离/千卡」结构)。 -->
<string name="home_today_summary">今日数据</string>
<string name="home_summary_steps_label">步数</string>
<string name="home_summary_steps_goal">目标 %1$s</string>
<string name="home_summary_calories_label">卡路里(千卡)</string>
<string name="home_summary_active_label">活动(分钟)</string>
<!-- 运动:六类运动类型网格。 -->
<string name="workout_sport_running_outdoor">户外跑步</string>
<string name="workout_sport_walking">健走</string>
<string name="workout_sport_cycling_outdoor">户外骑行</string>
<string name="workout_sport_running_indoor">室内跑步</string>
<string name="workout_sport_jump_rope">跳绳</string>
<string name="workout_sport_free_training">自由训练</string>
<!-- 设备:未连接空态。 -->
<string name="device_empty_title">未连接戒指</string>
<string name="device_empty_subtitle">连接戒指后即可查看电量、固件与监测设置</string>
<string name="device_empty_scan">扫一扫</string>
<string name="device_empty_pair_by_model">按型号添加</string>
<!-- 我的 - 应用设置。 -->
<string name="profile_settings_title">应用设置</string>
<string name="profile_settings_section_units">单位</string>
<string name="profile_settings_units_distance_label">距离</string>
<string name="profile_settings_units_distance_value">公里</string>
<string name="profile_settings_units_weight_label">体重</string>
<string name="profile_settings_units_weight_value">公斤</string>
<string name="profile_settings_section_notifications">通知</string>
<string name="profile_settings_notifications_health">健康提醒</string>
<string name="profile_settings_notifications_health_desc">达标 / 异常时推送</string>
<string name="profile_settings_notifications_device">设备提醒</string>
<string name="profile_settings_notifications_device_desc">低电量 / 断开连接</string>
<string name="profile_settings_notifications_marketing">营销通知</string>
<string name="profile_settings_notifications_marketing_desc">活动与产品信息</string>
<!-- 我的 - 通知权限引导。 -->
<string name="profile_notifications_title">通知权限</string>
<string name="profile_notifications_status">当前状态:%1$s</string>
<string name="profile_notifications_status_granted">已授权</string>
<string name="profile_notifications_status_denied">未授权</string>
<string name="profile_notifications_hint">通知权限关闭时,健康异常、设备状态等推送将无法送达。开启后本 App 会在系统状态栏推送提醒。</string>
<string name="profile_notifications_request">请求权限</string>
<string name="profile_notifications_open_settings">前往系统设置</string>
<!-- 我的 - 关于 / 检查更新。 -->
<string name="profile_about_title">关于</string>
<string name="profile_about_section_app">App</string>
<string name="profile_about_app_version_label">App 版本</string>
<string name="profile_about_firmware_version_label">固件版本</string>
<string name="profile_about_device_model_label">设备型号</string>
<string name="profile_about_section_actions">操作</string>
<string name="profile_about_check_update">检查更新</string>
<string name="profile_about_check_update_done">已是最新版本</string>
<string name="profile_about_check_update_current">当前版本 %1$s</string>
<!-- 我的 - 帮助与反馈。 -->
<string name="profile_help_section_faq">常见问题</string>
<string name="profile_help_faq_1_q">戒指怎么连?</string>
<string name="profile_help_faq_1_a">在「设备」页点击「添加设备」,扫一扫或按型号添加。</string>
<string name="profile_help_faq_2_q">数据多久同步一次?</string>
<string name="profile_help_faq_2_a">戒指开启自动监测后,每 10 分钟同步一次到 App。</string>
<string name="profile_help_faq_3_q">如何切换单位?</string>
<string name="profile_help_faq_3_a">「我的 - 应用设置 - 单位」中切换距离与体重。</string>
<string name="profile_help_faq_4_q">如何反馈问题?</string>
<string name="profile_help_faq_4_a">可通过邮箱 support@ringhealth.example 联系我们。</string>
<string name="profile_help_section_contact">联系我们</string>
<string name="profile_help_contact_email">support@ringhealth.example</string>
<string name="profile_help_title">帮助与反馈</string>
<string name="profile_help_contact">联系我们</string>
</resources> </resources>
@@ -39,6 +39,13 @@ class FormattersTest {
assertEquals("1,234", formatCalories(1234)) assertEquals("1,234", formatCalories(1234))
} }
@Test
fun `formatDecimal 保留一位小数`() {
assertEquals("72.0", formatDecimal(72.0))
assertEquals("98.6", formatDecimal(98.56))
assertEquals("0.0", formatDecimal(0.0))
}
@Test @Test
fun `stressLevelLabelRes 按区间映射到对应字符串资源`() { fun `stressLevelLabelRes 按区间映射到对应字符串资源`() {
assertEquals(R.string.stress_relaxed, stressLevelLabelRes(20)) assertEquals(R.string.stress_relaxed, stressLevelLabelRes(20))
@@ -0,0 +1,56 @@
package com.placeholder.ringhealth.core.common
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.util.Calendar
/**
* 睡眠时段图的横轴换算。
*
* 这里每一处都对着一个**看起来仍然合理**的错误:时区错八小时、跨零点算反、
* 负数偏移没归一化 —— 出了任何一种,图还是那张图,只是每根条的位置都悄悄偏了,
* 而偏过的位置照样像一份正常的睡眠记录。所以这几条断言必须钉在具体数值上。
*/
class SleepTimeTest {
@Test
fun `入睡时刻按锚点归一化后仍排在醒来之前`() {
// 时段图成立的前提:23:43 那根条的左端必须在 07:37 的左端**左边**。
// 用「当天第几分钟」直接比会得到 1423 > 457,整根条反过来画。
val bedtime = localTime(daysAgo = 1, hour = 23, minute = 43)
val wakeUp = localTime(daysAgo = 0, hour = 7, minute = 37)
assertTrue(minutesSinceSleepAnchor(bedtime) < minutesSinceSleepAnchor(wakeUp))
// 23:43 距 20:00 是 3 小时 43 分;07:37 跨过零点后是 11 小时 37 分。
assertEquals(223, minutesSinceSleepAnchor(bedtime))
assertEquals(697, minutesSinceSleepAnchor(wakeUp))
}
@Test
fun `锚点偏移量换回钟点 是上一条的逆运算`() {
assertEquals("23:43", formatSleepAnchorOffset(223))
assertEquals("07:37", formatSleepAnchorOffset(697))
// 早于 20:00 的时刻会得到负偏移,仍要换回正确的钟点而不是绕到另一天。
assertEquals("17:00", formatSleepAnchorOffset(-180))
}
@Test
fun `minutesOfDay 按本地时区取 而不是拿时间戳取余`() {
// `timestamp % 86_400_000` 是 UTC 的余数,在东八区会把 23:43 算成 15:43 ——
// 整张时段图平移八小时,而平移后每根条看着都还挺合理。
assertEquals(23 * 60 + 43, minutesOfDay(localTime(daysAgo = 0, hour = 23, minute = 43)))
assertEquals("00:00", formatMinuteOfDay(0))
assertEquals("23:59", formatMinuteOfDay(23 * 60 + 59))
}
/** 本地时间戳:今天往前 [daysAgo] 天的 [hour]:[minute]。 */
private fun localTime(daysAgo: Int, hour: Int, minute: Int): Long =
Calendar.getInstance().apply {
set(Calendar.HOUR_OF_DAY, hour)
set(Calendar.MINUTE, minute)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
add(Calendar.DAY_OF_YEAR, -daysAgo)
}.timeInMillis
}
@@ -0,0 +1,25 @@
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}}"))
}
}
@@ -1,8 +1,14 @@
package com.placeholder.ringhealth.data.mock package com.placeholder.ringhealth.data.mock
import com.placeholder.ringhealth.core.common.formatClock
import com.placeholder.ringhealth.core.common.minutesOfDay
import com.placeholder.ringhealth.domain.model.DateRange import com.placeholder.ringhealth.domain.model.DateRange
import com.placeholder.ringhealth.domain.model.DeviceFunctionSetting
import com.placeholder.ringhealth.domain.model.MetricType import com.placeholder.ringhealth.domain.model.MetricType
import com.placeholder.ringhealth.domain.model.enabledMetrics
import com.placeholder.ringhealth.domain.model.valueOf
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
@@ -19,28 +25,79 @@ class MockDataProviderTest {
const val FIXED_NOW = 1_760_000_000_000L const val FIXED_NOW = 1_760_000_000_000L
} }
/** 默认演示状态下的开关:血压关着,其余开着(见 [MockDataProvider.defaultFunctionSettings])。 */
private val defaultEnabled: Set<MetricType> =
provider.defaultFunctionSettings().enabledMetrics()
@Test @Test
fun `今日概览返回固定演示数据`() { fun `今日概览返回固定演示数据`() {
val summary = provider.todaySummary() val summary = provider.todaySummary(defaultEnabled)
assertEquals(8426, summary.steps) assertEquals(8426, summary.steps)
assertEquals(10000, summary.stepGoal) assertEquals(10000, summary.stepGoal)
assertEquals(327, summary.calories) assertEquals(327, summary.calories)
assertEquals(72, summary.heartRate) assertEquals(72, summary.heartRate)
assertEquals(452, summary.sleepMinutes) assertEquals(452, summary.sleepMinutes)
// 血压未开启,应为 null // 血压默认未开启,应为 null
assertEquals(null, summary.bloodPressure) assertEquals(null, summary.bloodPressure)
} }
@Test
fun `关掉的指标在概览里没有值`() {
// 这是「功能开关能影响首页」的根:概览的字段必须由开关算出来。
// 若这里退回写死的常量,首页就会在开关关着时照样显示数字,用户翻开关毫无反应。
val summary = provider.todaySummary(enabledMetrics = emptySet())
assertEquals(null, summary.heartRate)
assertEquals(null, summary.sleepMinutes)
assertEquals(null, summary.bloodOxygen)
assertEquals(null, summary.stress)
assertEquals(null, summary.bloodPressure)
}
@Test
fun `常开指标不在开关列表里也算开启`() {
// 计步与体温没有开关(MetricType.switchId 为 null),所以开关列表里找不到它们。
// 兜底必须发生在「开关列表 → 已开启指标集」这一步:若按「找不到对应开关就算关」处理,
// 一个空列表会让步数和体温凭空变成「未开启」—— 而戒指一直在数着步。
val enabled = emptyList<DeviceFunctionSetting>().enabledMetrics()
assertTrue(MetricType.STEP in enabled)
assertTrue(MetricType.TEMPERATURE in enabled)
// 反向:真的有关卡的指标,列表为空时就不该算开启。
assertFalse(MetricType.HEART_RATE in enabled)
val summary = provider.todaySummary(enabledMetrics = enabled)
assertEquals(8426, summary.steps)
assertEquals(36.5, summary.temperature)
}
@Test
fun `开关 id 与指标枚举一一对应`() {
// 双向核对:开关列表里的每个 id 都能找到归属,每个有开关的指标也都真的在列表里。
// 少了任何一个方向,对应关系就只是「字符串长得像」——那正是这次修掉的病根。
val settingIds = provider.defaultFunctionSettings().map { it.id }.toSet()
val declaredIds = MetricType.entries.mapNotNull { it.switchId }.toSet()
assertEquals(declaredIds, settingIds)
}
@Test
fun `血压开关打开后概览给出读数`() {
val summary = provider.todaySummary(defaultEnabled + MetricType.BLOOD_PRESSURE)
assertEquals(MockDataProvider.DEMO_BLOOD_PRESSURE, summary.bloodPressure)
}
@Test @Test
fun `历史数据长度与范围一致`() { fun `历史数据长度与范围一致`() {
assertEquals(7, provider.metricHistory(MetricType.STEP, DateRange.WEEK).size) assertEquals(7, provider.metricHistory(MetricType.STEP, DateRange.WEEK, defaultEnabled).size)
assertEquals(30, provider.metricHistory(MetricType.STEP, DateRange.MONTH).size) assertEquals(30, provider.metricHistory(MetricType.STEP, DateRange.MONTH, defaultEnabled).size)
} }
@Test @Test
fun `历史数据可重复生成且值合理`() { fun `历史数据可重复生成且值合理`() {
val first = provider.metricHistory(MetricType.HEART_RATE, DateRange.WEEK) val first = provider.metricHistory(MetricType.HEART_RATE, DateRange.WEEK, defaultEnabled)
val second = provider.metricHistory(MetricType.HEART_RATE, DateRange.WEEK) val second = provider.metricHistory(MetricType.HEART_RATE, DateRange.WEEK, defaultEnabled)
assertEquals(first, second) assertEquals(first, second)
assertTrue(first.all { it.value > 40.0 && it.value < 180.0 }) assertTrue(first.all { it.value > 40.0 && it.value < 180.0 })
// 时间戳须严格递增(“每点间隔一天”倒推,最早的在最前) // 时间戳须严格递增(“每点间隔一天”倒推,最早的在最前)
@@ -52,6 +109,80 @@ class MockDataProviderTest {
assertEquals(FIXED_NOW - 6L * 24 * 60 * 60 * 1000, first.first().timestamp) assertEquals(FIXED_NOW - 6L * 24 * 60 * 60 * 1000, first.first().timestamp)
} }
@Test
fun `历史数据的最后一个点与今日概览一致`() {
// 详情页的头图显示概览的当前值、趋势图的最后一根柱子显示今天的值。两者若各生成各的,
// 页面会出现「头图写心率 72、图上今天却是 96」这种自相矛盾(ledger R35)。
val summary = provider.todaySummary(defaultEnabled)
MetricType.entries.forEach { metric ->
val todayValue = summary.valueOf(metric) ?: return@forEach
val last = provider.metricHistory(metric, DateRange.WEEK, defaultEnabled).last()
assertEquals("$metric 的今日值与历史末点对不上", todayValue, last.value, 0.0001)
if (metric == MetricType.SLEEP) {
// 睡眠的末点是**今晨醒来的那一刻**,不是「此刻」——
// 这段睡眠属于今天,但它发生在今天早上。硬套 FIXED_NOW 会逼着
// 时段图把每一晚都画成「现在结束」,整张图会糊成一列。
assertEquals(provider.sleepSessions(DateRange.WEEK).last().wakeDate, last.timestamp)
} else {
assertEquals(FIXED_NOW, last.timestamp)
}
}
}
@Test
fun `睡眠时段的时长与两端时刻自洽`() {
// 时段图上那根条的左右端由 sleepStart/sleepEnd 决定,头图上的大数字由分期时长相加得到。
// 两者若各算各的,同一张卡上就会出现「睡了 7 小时 32 分」和一根长度不符的条。
provider.sleepSessions(DateRange.WEEK).forEach { session ->
assertEquals(
"卧床时长与起止时刻对不上:$session",
session.inBedMinutes * 60_000L,
session.sleepEnd - session.sleepStart,
)
assertEquals(
session.inBedMinutes,
session.deepMinutes + session.lightMinutes + session.awakeMinutes,
)
}
}
@Test
fun `昨晚是固定演示值 且与今日概览一致`() {
// 昨晚这一条和步数 8426、心率 72 同一性质:写死的演示值,负责让第一屏的数字好看且固定。
val last = provider.sleepSessions(DateRange.WEEK).last()
assertEquals(452, last.sleepMinutes)
assertEquals(96, last.deepMinutes)
assertEquals(452, provider.todaySummary(defaultEnabled).sleepMinutes)
}
@Test
fun `睡眠时段按时间正序 且醒来时刻落在清晨`() {
val sessions = provider.sleepSessions(DateRange.WEEK)
assertEquals(7, sessions.size)
assertTrue(sessions.zipWithNext().all { (a, b) -> b.wakeDate > a.wakeDate })
// 除昨晚外都是随机生成的。醒来时刻必须落在 6–9 点:
// 前几晚若也贴着「此刻」生成,七根条会排成整齐的一列,看不出作息差异 ——
// 而「作息差在哪」正是这张图存在的理由。
sessions.dropLast(1).forEach {
assertTrue(
"醒来时刻落在清晨之外:${formatClock(it.sleepEnd)}",
minutesOfDay(it.sleepEnd) in 6 * 60..9 * 60 + 59,
)
}
}
@Test
fun `睡眠历史点由时段折算 不另生成一串随机数`() {
// 同一个指标有两套数据源,就会出现「头图 7 小时 32 分、时段图上却是另一根长度」——
// 而这恰好是睡眠页最显眼的两块内容。
val sessions = provider.sleepSessions(DateRange.WEEK)
val points = provider.metricHistory(MetricType.SLEEP, DateRange.WEEK, defaultEnabled)
assertEquals(sessions.map { it.wakeDate }, points.map { it.timestamp })
assertEquals(sessions.map { it.sleepMinutes.toDouble() }, points.map { it.value })
assertEquals(30, provider.sleepSessions(DateRange.MONTH).size)
}
@Test @Test
fun `设备状态与开关数据合法`() { fun `设备状态与开关数据合法`() {
val device = provider.deviceState() val device = provider.deviceState()
@@ -0,0 +1,22 @@
package com.placeholder.ringhealth.data.mock
/**
* 造一对**已接线**的 Mock 仓库:健康数据的开关状态来自同一个设备仓库。
*
* 测试里必须这样接,不能各 `new` 一个。各 new 一个正是生产代码修掉的那个 bug ——
* 开关状态与健康数据分属两个互不引用的实例,用户翻了开关首页纹丝不动。
* 测试若照抄旧写法,就会在一套与线上不同的接线方式上做断言:
* 断言全绿,线上照样是坏的。
*/
/** 设备仓库,功能开关为 [MockDataProvider.defaultFunctionSettings];[enable] 用于覆盖个别开关。 */
fun mockDeviceRepository(enable: Map<String, Boolean> = emptyMap()): MockRingDeviceRepository {
val repository = MockRingDeviceRepository()
enable.forEach { (id, on) -> repository.setFunctionEnabled(id, on) }
return repository
}
/** 健康数据仓库,开关状态取自 [device] —— 必须传同一个实例,理由见文件头。 */
fun mockHealthRepository(
device: MockRingDeviceRepository = mockDeviceRepository(),
): MockHealthDataRepository = MockHealthDataRepository(functionSettings = device.functionSettings)
@@ -0,0 +1,155 @@
package com.placeholder.ringhealth.feature.detail
import com.placeholder.ringhealth.data.mock.mockDeviceRepository
import com.placeholder.ringhealth.data.mock.mockHealthRepository
import com.placeholder.ringhealth.domain.model.DateRange
import com.placeholder.ringhealth.domain.model.MetricType
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 MetricDetailViewModelTest {
private val dispatcher = StandardTestDispatcher()
@Before
fun setUp() {
Dispatchers.setMain(dispatcher)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun `默认输出近 7 天的数据`() = runTest(dispatcher) {
val viewModel = MetricDetailViewModel(MetricType.HEART_RATE, mockHealthRepository())
backgroundScope.launch { viewModel.uiState.collect {} }
advanceUntilIdle()
val state = viewModel.uiState.value
assertFalse(state.loading)
assertEquals(MetricType.HEART_RATE, state.metric)
assertEquals(DateRange.WEEK, state.range)
assertEquals(7, state.points.size)
// 今日概览必须一并到达:各页 hero 卡的「当前值」取自 summary,
// combine 若漏了这一路,页面第一屏会只剩图、没有读数(而 points 仍然正常,不易察觉)。
assertEquals(72, state.summary?.heartRate)
}
@Test
fun `关掉的指标 enabled 为 false`() = runTest(dispatcher) {
// 详情页靠这个标志决定画图还是显示「未开启」说明卡。它取错了,页面就会一边
// 说功能没开、一边画出七天的数据 —— 一段根本不存在的监测记录。
val device = mockDeviceRepository(enable = mapOf("heart_rate" to false))
val viewModel = MetricDetailViewModel(MetricType.HEART_RATE, mockHealthRepository(device))
backgroundScope.launch { viewModel.uiState.collect {} }
advanceUntilIdle()
val state = viewModel.uiState.value
assertFalse(state.enabled)
assertEquals(null, state.summary?.heartRate)
}
@Test
fun `开关变化会传到已订阅的详情页`() = runTest(dispatcher) {
// 承重的是「切换之后状态跟着变」,所以必须在订阅建立之后再翻开关。
// 只断言初始值等于某个常量是没有鉴别力的:那样即使数据流根本不订阅开关,
// 只要初始值碰巧对,测试照样绿。
val device = mockDeviceRepository(enable = mapOf("heart_rate" to false))
val viewModel = MetricDetailViewModel(MetricType.HEART_RATE, mockHealthRepository(device))
backgroundScope.launch { viewModel.uiState.collect {} }
advanceUntilIdle()
assertFalse(viewModel.uiState.value.enabled)
device.setFunctionEnabled("heart_rate", true)
advanceUntilIdle()
val state = viewModel.uiState.value
assertTrue(state.enabled)
assertEquals(72, state.summary?.heartRate)
}
@Test
fun `switchRange 切到月度后变成 30 个点`() = runTest(dispatcher) {
val viewModel = MetricDetailViewModel(MetricType.HEART_RATE, mockHealthRepository())
backgroundScope.launch { viewModel.uiState.collect {} }
advanceUntilIdle()
viewModel.switchRange(DateRange.MONTH)
advanceUntilIdle()
val state = viewModel.uiState.value
// 三个断言缺一不可:只断言点数,无法发现「上游没重新订阅、只是把旧数据换了标签」;
// 只断言 range,无法发现 flatMapLatest 没生效。
assertEquals(DateRange.MONTH, state.range)
assertEquals(30, state.points.size)
assertEquals(MetricType.HEART_RATE, state.metric)
viewModel.switchRange(DateRange.WEEK)
advanceUntilIdle()
assertEquals(7, viewModel.uiState.value.points.size)
}
@Test
fun `只有睡眠页拿得到时段数据`() = runTest(dispatcher) {
// 时段图要的是「几点到几点」,points 里装不下,只能另走一路。
// 两个断言必须成对:单看「心率页的时段为空」,initialValue 本来就是空列表,
// 数据流从不发射时它照样成立 —— 加上「同一时刻睡眠页拿得到」才说明这是分流而不是没接上。
val sleep = MetricDetailViewModel(MetricType.SLEEP, mockHealthRepository())
backgroundScope.launch { sleep.uiState.collect {} }
advanceUntilIdle()
assertEquals(7, sleep.uiState.value.sleepSessions.size)
val heartRate = MetricDetailViewModel(MetricType.HEART_RATE, mockHealthRepository())
backgroundScope.launch { heartRate.uiState.collect {} }
advanceUntilIdle()
assertTrue(heartRate.uiState.value.sleepSessions.isEmpty())
}
@Test
fun `SLEEP 指标查到的是睡眠区间的数据`() = runTest(dispatcher) {
// 名字不叫「构造时传入的 metric 被原样带进状态」——本用例真正锁的是
// 「构造函数的 metric 被传给了仓库查询」,而不是状态字段回显(ledger R34)。
// 也不要写成「标题映射不同」:`metricTitleRes` 是 @Composable,JVM 测试里根本调不了,
// 那样命名会让人以为这里验了标题映射(其实没有,映射由 `when` 的穷尽性在编译期保证)。
val viewModel = MetricDetailViewModel(MetricType.SLEEP, mockHealthRepository())
backgroundScope.launch { viewModel.uiState.collect {} }
advanceUntilIdle()
val state = viewModel.uiState.value
// 承重断言一:`initialValue.points` 是 emptyList(),所以数据管线
// (observeMetricHistory / flatMapLatest / map)只要不发射,这一条必挂。
assertEquals(7, state.points.size)
// 承重断言二:上面那条只证明「有数据」,不证明「数据问对了指标」。
// `observeMetricHistory(metric, currentRange)` 里若把 metric 写成常量(如 MetricType.STEP),
// 点数仍是 7(点数只由 range 决定),只有取值区间会变 —— Mock 按指标区间生成并 coerceIn,
// 睡眠是 330~510、步数是 3000~12000,两者不相交(见 MockDataProvider.valueRange)。
// 注意 `all {}` 对空列表恒真,所以它必须与上面那条 size 断言成对出现,不能单用。
assertTrue(
"SLEEP 详情页拿到了区间外的值:${state.points.map { it.value }}",
state.points.all { it.value in 330.0..510.0 },
)
// 这一条单看**没有鉴别力**:`state.metric` 就是构造函数传进来的值,`stateIn` 的
// initialValue 里已经有它,数据管线从不发射时它照样成立(ledger R30)。保留它只是为了
// 钉住「状态里的 metric 与构造函数一致」,真正承重的是上面两条。
assertEquals(MetricType.SLEEP, state.metric)
}
}
@@ -0,0 +1,111 @@
package com.placeholder.ringhealth.feature.detail
import com.placeholder.ringhealth.core.common.formatSleepAnchorOffset
import com.placeholder.ringhealth.domain.model.SleepSession
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import java.util.Calendar
class MetricStatsTest {
@Test
fun `平均入睡不会把 23 点与 1 点平均成中午`() {
// 直接对「23:00」和「01:00」这两个钟点求平均会得到 12:00 —— 正好是睡得最沉的时候。
// 求平均必须在「距锚点 20:00 的分钟数」这个连续量上做:180 和 300,均值 240,即 00:00。
val stats = sleepStatsOf(
listOf(
session(bedHour = 23, bedMinute = 0, wakeHour = 7, wakeMinute = 0),
session(bedHour = 1, bedMinute = 0, wakeHour = 6, wakeMinute = 0),
),
)!!
assertEquals(240, stats.averageBedtimeMinute)
assertEquals("00:00", formatSleepAnchorOffset(stats.averageBedtimeMinute))
assertEquals(2, stats.count)
}
@Test
fun `sleepStatsOf 空列表返回 null 而不是全 0`() {
// 同 metricStatsOf:0 点入睡、0 分钟时长是能吓死人的假读数,不能用它表示「没有数据」。
assertNull(sleepStatsOf(emptyList()))
}
@Test
fun `平均时长不含夜里清醒`() {
// 「睡了多久」与「躺了多久」是两个数。头图上那个大数字是睡着的时长,
// 统计卡里的平均必须同一口径,否则同一页两处各说各的。
val stats = sleepStatsOf(listOf(session(bedHour = 23, bedMinute = 0, wakeHour = 7, wakeMinute = 0)))!!
// 卧床 480 分钟(深睡 96 + 浅睡 360 + 清醒 24),睡着的是 456。
assertEquals(456, stats.averageSleepMinutes)
assertTrue("平均时长把夜里清醒也算进去了", stats.averageSleepMinutes < 480)
}
/**
* 造一晚。时长由两端时刻算出(卧床 = 醒来 - 入睡),再按 20% 深睡、5% 清醒、其余浅睡拆分 ——
* 三者必须加起来等于卧床时长,否则这里就是在拿一份自相矛盾的数据做断言。
*/
private fun session(bedHour: Int, bedMinute: Int, wakeHour: Int, wakeMinute: Int): SleepSession {
val start = localTime(daysAgo = 1, hour = bedHour, minute = bedMinute)
val end = localTime(daysAgo = 0, hour = wakeHour, minute = wakeMinute)
val inBed = ((end - start) / MILLIS_PER_MINUTE).toInt()
val deep = inBed / 5
val awake = inBed / 20
return SleepSession(
wakeDate = end,
sleepStart = start,
sleepEnd = end,
deepMinutes = deep,
lightMinutes = inBed - deep - awake,
awakeMinutes = awake,
)
}
private companion object {
const val MILLIS_PER_MINUTE = 60_000L
}
private fun localTime(daysAgo: Int, hour: Int, minute: Int): Long =
Calendar.getInstance().apply {
set(Calendar.HOUR_OF_DAY, hour)
set(Calendar.MINUTE, minute)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
add(Calendar.DAY_OF_YEAR, -daysAgo)
}.timeInMillis
@Test
fun `metricStatsOf 求平均最高最低与天数`() {
val stats = metricStatsOf(listOf(60.0, 80.0, 100.0))
assertEquals(80.0, stats!!.average, 0.0001)
assertEquals(100.0, stats.max, 0.0001)
assertEquals(60.0, stats.min, 0.0001)
assertEquals(3, stats.count)
}
@Test
fun `metricStatsOf 空列表返回 null 而不是全 0`() {
// 空数据返回 0.0 会在页面上渲染成「平均 0.0 次/分」这种假读数。
assertNull(metricStatsOf(emptyList()))
}
@Test
fun `countWithin 只数落在区间内的点 边界算在内`() {
val values = listOf(40.0, 60.0, 75.0, 100.0, 110.0)
assertEquals(3, values.countWithin(60.0..100.0))
}
@Test
fun `countBelow 严格小于阈值`() {
val values = listOf(94.0, 95.0, 96.0)
assertEquals(1, values.countBelow(95.0))
}
@Test
fun `countAtLeast 达到阈值即算达标`() {
val values = listOf(9999.0, 10000.0, 10001.0)
assertEquals(2, values.countAtLeast(10000.0))
}
}
@@ -0,0 +1,82 @@
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 `输出设备状态与五个功能开关,且仓库变化会回流到 UI 状态`() = runTest(dispatcher) {
val repository = MockRingDeviceRepository()
val viewModel = DeviceViewModel(repository)
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)
// 上面三个值恰好与 stateIn 的 initialValue 相同(DeviceViewModel 的 initialValue
// 直接取仓库当前值),所以**单靠它们无法区分「combine 发射过」与「一直停在 initialValue」**
// (ledger Ruling R30,审查者指出)。这里绕开 ViewModel 直接改仓库,再断言状态跟着变 ——
// 只有 combine 真的在发射,这一条才可能成立。
// 关掉一个**初始为 true** 的开关,断言它跟着变 false。方向很重要:
// 若这里改成「把初始为 false 的打开」,即使 combine 一次都没发射过,
// 只要 initialValue 里那项碰巧是 true,断言照样通过(ledger Ruling R30)。
repository.setFunctionEnabled("stress", false)
advanceUntilIdle()
assertFalse(viewModel.uiState.value.settings.first { it.id == "stress" }.enabled)
}
@Test
fun `setFunctionEnabled 只改目标项且可来回切换`() = runTest(dispatcher) {
val viewModel = DeviceViewModel(MockRingDeviceRepository())
backgroundScope.launch { viewModel.uiState.collect {} }
advanceUntilIdle()
// Mock 的 5 项里只有 blood_pressure 初始为 false,其余 4 项为 true
// (见 MockDataProvider.defaultFunctionSettings)。打开它后应变成 5 项全开。
val target = "blood_pressure"
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(5, viewModel.uiState.value.settings.count { it.enabled })
viewModel.setFunctionEnabled(target, false)
advanceUntilIdle()
assertFalse(viewModel.uiState.value.settings.first { it.id == target }.enabled)
assertEquals(4, viewModel.uiState.value.settings.count { it.enabled })
}
}
@@ -1,7 +1,7 @@
package com.placeholder.ringhealth.feature.home package com.placeholder.ringhealth.feature.home
import com.placeholder.ringhealth.data.mock.MockHealthDataRepository import com.placeholder.ringhealth.data.mock.mockDeviceRepository
import com.placeholder.ringhealth.data.mock.MockRingDeviceRepository import com.placeholder.ringhealth.data.mock.mockHealthRepository
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -36,7 +36,9 @@ class HomeViewModelTest {
@Test @Test
fun `加载完成后输出今日概览与设备状态`() = runTest(dispatcher) { fun `加载完成后输出今日概览与设备状态`() = runTest(dispatcher) {
val viewModel = HomeViewModel(MockHealthDataRepository(), MockRingDeviceRepository()) // 两个仓库必须共用同一个开关实例,理由见 TestRepositories.kt 文件头。
val device = mockDeviceRepository()
val viewModel = HomeViewModel(mockHealthRepository(device), device)
// uiState 用 SharingStarted.WhileSubscribed 构建,必须有订阅者才会启动上游数据流; // uiState 用 SharingStarted.WhileSubscribed 构建,必须有订阅者才会启动上游数据流;
// 否则 .value 永远是初始值(loading = true)。用 backgroundScope 起一个收集者。 // 否则 .value 永远是初始值(loading = true)。用 backgroundScope 起一个收集者。
@@ -0,0 +1,54 @@
package com.placeholder.ringhealth.feature.workout
import com.placeholder.ringhealth.data.mock.mockHealthRepository
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
/**
* [WorkoutViewModel] 的单元测试:验证加载完成后输出的运动记录列表。
*/
@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(mockHealthRepository())
// 与 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 数据的首尾类型恰好相同,用类型断言无法察觉顺序被反转。
assertTrue(state.records.first().startTime > state.records.last().startTime)
}
}
+154
View File
@@ -0,0 +1,154 @@
# M1 静态页面验收记录
## 环境
- 记录日期:2026-09-10
- 构建版本:0.1.0 (versionCode 1)
- 构建机:Windows 11,JDK 17.0.12,Android SDK `D:\Android\Sdk`
- **验收设备:Android 模拟器**(非真机)
- AVD `ringtest`,外形 `pixel_7`,镜像 `system-images;android-36;google_apis;x86_64`
- 1080×2400 @ 420dpi;系统栏 inset:状态栏 136 px、导航栏 63 px
> **本次与上一次记录的差别:** 上一版这份文件里,人工验收**整张表都是「未执行 —— 无设备」**。
> 2026-09-10 当天下载并装好了模拟器(见文末「模拟器是怎么装上的」),因此**下面这张表已经真的跑过了**。
> 但仍有一处必须说清楚:**跑的是模拟器,不是真机**。模拟器能证明界面渲染正确、导航不崩,
> 但证明不了真机上的性能、厂商 ROM 差异、以及 M2 之后才涉及的蓝牙权限行为。
## 自动化验证(已执行)
- 单元测试:`./gradlew :app:testDebugUnitTest` —— **21 项全绿**
(`FormattersTest` 7 + `MockDataProviderTest` 5 + `HomeViewModelTest` 1 + `WorkoutViewModelTest` 1
+ `DeviceViewModelTest` 2 + `MetricDetailViewModelTest` 3 + `RoutesTest` 2)
- 构建:`./gradlew :app:assembleDebug` —— `BUILD SUCCESSFUL`,
产物 `app/build/outputs/apk/debug/app-debug.apk`(18,907,725 字节)
- 安装并启动:`adb install -r` + `am start` —— 成功,进程存活,`logcat -b crash` 全程为空
- **未执行**:各页面的 `@Preview` 在 Android Studio 中渲染。本机没有 IDE GUI 自动化手段,
这一项仍是未执行的 —— 不过它原本的作用(没有设备时唯一的界面检查手段)已被下面的模拟器实测取代
## 人工验收(模拟器实测,逐项已执行)
| 项 | 结果 | 证据 |
|---|---|---|
| 应用启动无崩溃 | ✅ | 进程 pid 存活,`mCurrentFocus` 为 `MainActivity`,crash buffer 空 |
| 四个 Tab 显示为 健康 / 运动 / 设备 / 我的 | ✅ | 四个 Tab 文案与图标正确,选中态为淡紫底 + 绿色文字 |
| 四个 Tab 可自由切换 | ✅ | 逐个点过,无闪烁、无崩溃 |
| 首页:设备状态条 | ✅ | 「RingHealth 智能戒指 · 已连接 / 电量 78%」 |
| 首页:步数大环 8,426 / 目标 10,000 | ✅ | 环按 84% 填充,另有「6.21 公里 / 327 千卡」 |
| 首页:六个指标卡,血压显示「未开启」 | ✅ | 心率 72 次/分、睡眠 7小时32分、血氧 98%、压力 34 正常、体温 36.5 °C、血压 未开启 |
| 首页:点指标卡进详情,标题为该指标中文名 | ✅ | 点「睡眠」→ 标题「睡眠」;点「心率」→ 标题「心率」 |
| 详情页:近 7 天 7 根柱 / 近 30 天 30 根柱 | ✅ | 切换后柱子数量与统计值都变了(心率:78.0/95.1/58.0 → 77.0/96.0/59.8) |
| 睡眠详情额外显示分期卡片,其他指标没有 | ✅ | 睡眠有「睡眠分期」卡(深睡/浅睡/清醒 + 入睡/起床),心率页没有该卡 |
| 睡眠入睡/起床时间读起来像「一晚」 | ✅ | 入睡 22:08 / 起床 06:08,正好相隔 8 小时(`DEMO_SLEEP_WINDOW_MILLIS` 生效) |
| 详情页:返回后回到首页 | ✅ | `keyevent 4` 后回到健康首页 |
| 运动页:开始运动 + 5 条记录 | ✅ | 5 条:户外跑步/健走/骑行/自由训练/户外跑步 |
| 运动页「查看全部」进历史页,底部导航栏仍可见 | ✅ | 历史页 5 条记录,底部四 Tab 正常显示 |
| 设备页:戒指卡片 电量 78% / 固件 1.0.5 | ✅ | 另显示「已连接」与蓝牙图标 |
| 设备页「更多设置」开关可切换,返回后状态保持 | ✅ | 打开「压力监测」→ 返回设备页 → 再进入,开关仍为开 |
| 我的页 | ✅ | 头像卡与分组入口正常 |
| 全程无崩溃 | ✅ | `logcat -b crash` 自始至终为空 |
### 补充实测:终审修复轮的六项改动
这六项原先只有代码阅读作为依据,现在逐项在模拟器上确认:
| 改动 | 实测结果 |
|---|---|
| 详情页按指标取色 | ✅ 睡眠=蓝、心率=红,与首页各卡的 tint 一致 |
| 血压卡不可点击 | ✅ 点血压卡后与点击前**逐像素一致**,未发生跳转 |
| 压力卡 `unit` 接线 | ✅ 卡片显示「34 **正常**」,即 `stressLevelLabelRes` 真的接上了 |
| 周/月 chips 常驻 | ✅ 两个范围的详情页都始终显示 chips |
| `consumeWindowInsets` 顶栏内边距 | ✅ **量了像素**:状态栏 136 px,顶栏白底 136→303 px,即**正好 168 px = 64 dp**(Material 3 `TopAppBar` 标准高度),标题文字中心在顶栏内 86 px ≈ 32 dp,**正中**。若 insets 被叠加,顶栏会变成 88 dp 且标题偏下 —— 实测不是 |
| README 注明 `installDebug` 需设备 | ✅ 文档项 |
> `consumeWindowInsets` 这一项此前被反复标注「推理得出、未经设备验证」,**现在验证过了**。
> 顺带确认:状态栏上方那条灰带就是状态栏本身(136 px),不是多余留白。
## 本轮新发现的观察(非缺陷,记录备查)
1. **设备设置页与运动历史页没有 `TopAppBar`,也就没有返回箭头** —— 标题(「功能设置」)是普通
标题文字。返回只能靠系统返回手势,或点底部「设备」Tab。功能上可用,但与详情页(有 `←`)
不一致。**是否补一个顶栏,留给 M2 一起定。**
2. **运动记录的日期随运行时间变化** —— 两次截图里同一批记录显示 06:08 与 06:10。
因为 Mock 的时间戳是相对「此刻」生成的。这**削弱了设计文档 §8「每次运行截图一致」的说法**:
数据形状是确定性的,但日期不是。若将来要做截图对比测试,需要连时间源一起固定。
3. **趋势图把最后一根柱子画成深色**(其余为其 30% 透明度)—— 这是**有意的**,
`MetricBarChart(highlightLast = true)` 即「高亮今天」,KDoc 里写明了。
记录在此只是为了说明:实测看到的「只有最后一根是深色」不是渲染错误。
## 已知限制(阶段一有意为之)
- 数据全部为 Mock,不连蓝牙、不连后端
- 血压恒为「未开启」占位(`HealthSummary.bloodPressure = null`)
- 设备设置页的开关只改内存状态,**重启即重置**(对应 M2 的协议 0502/0504);
本次只验证了跨页面保持,没有验证跨进程重启
- 睡眠分期条与入睡/起床时间是演示值(固定比例 + 最后一个数据点往前 8 小时),无真实分期数据
- 详情页统计摘要一律保留一位小数(步数因此显示为「平均 7512.3」这类精度过剩的形式)
- 仅浅色模式
- 血压卡**不可点击**(`value == null` 时 `onClick` 传 `null`)
- 详情页的**周/月切换常驻**,空数据时只把切换条下方的图表与统计换成一行空态文案
### 仍然没有验证到的
- **详情页的空数据态**:当前 Mock 恒返回 7 或 30 个点,该分支不可达,**本次没有、也无法触达**。
它仍然是只有代码推理保证的防御性代码。
- **旋转屏幕 / 深色系统设置下不崩溃**:本次未测。
- **`@Preview` 渲染**:未测(见上)。
- **真机**:未测。模拟器不能替代真机验收。
### 与设计文档 §6 的偏差(有意收窄,非遗漏)
- **设备页少了四项**:查找戒指(050B)、智能提醒/闹钟占位、固件升级占位(OTA 17xx)、佩戴检测。
阶段一没有真实协议可对接,摆一排点不动的入口不如不摆(ledger R27)。**这些能力仍属产品范围,
将于后续里程碑回归**;设计文档 §6 的设备页一行已用删除线标注。**本次实测确认设备页确实没有这四项。**
- **详情页由「四个专属设计」收敛为一个模板**:四条路由合并为 `detail/{metric}`,七个指标共用
同一模板。**仅睡眠保留了专属区块**。未实现的是:步数的大环、心率的折线/静息-运动区间、
血氧的正常范围说明文案。详见设计文档 §6 下的注记。
### M2 接入时的已知接缝(交接用,本阶段不修)
`RingDeviceRepository` 目前**不足以承载 M2 的设备控制**:
- `refreshDeviceState()` **零调用者** —— 接口定义了,UI 从不触发。M2 需要接上「下拉刷新」或
进入设备页时刷新,否则真机断连后界面不会更新。
- `setFunctionEnabled` 是**非 `suspend`** 的 —— Mock 只改内存态所以无所谓,但 M2 写向 BLE
必然是挂起操作,接口签名届时需要改,连带 `DeviceViewModel` 的调用点。
- 预计 M2 的改动面:`domain/repository/Repositories.kt`、`data/mock/*`、`di/AppContainer.kt`、
`AndroidManifest.xml`(BLE 权限)、`app/build.gradle.kts` + `gradle/libs.versions.toml`
(厂商 Java 依赖,**很可能需要 Jetifier**)、`feature/device/DeviceScreen.kt`。
完整清单见实施计划的「M2 交接提示」一节。
## 覆盖缺口声明(已大幅收窄,但仍非零)
M1 的运行时行为**已在 Android 模拟器上完整走查过一遍**(上表逐项打勾),不再是「只有编译期保证」。
但仍有三个缺口,**不要把它们读成「已验证」**:
1. **跑的是模拟器不是真机** —— 真机上的厂商 ROM、性能、字体缩放、刘海/挖孔适配均未检验。
2. **空数据态与旋转/深色模式未触达**(见上)。
3. **`@Preview` 未渲染** —— 不过它原本的替代作用已被模拟器实测覆盖。
首次拿到真机时,请至少重跑上表中的「启动无崩溃」「四 Tab 切换」「详情页顶栏内边距」三项。
## 附:模拟器是怎么装上的(含一个踩过的坑)
本机原先 `D:\Android\Sdk` 下没有 `emulator/` 也没有 `system-images/`,本次新装:
```bash
# 1) 装包(注意坑:见下)
/d/Android/Sdk/cmdline-tools/latest/bin/sdkmanager.bat \
--sdk_root=D:/Android/Sdk "emulator" "system-images;android-36;google_apis;x86_64"
# 2) 建 AVD(avdmanager 不认 --sdk_root,只认环境变量)
export ANDROID_SDK_ROOT='D:\Android\Sdk' ANDROID_AVD_HOME='D:\Android\avd'
echo no | /d/Android/Sdk/cmdline-tools/latest/bin/avdmanager.bat create avd \
-n ringtest -k "system-images;android-36;google_apis;x86_64" -d pixel_7
# 3) 启动
/d/Android/Sdk/emulator/emulator.exe -avd ringtest
```
**坑:`--sdk_root` 一定要写正斜杠。** 写成 `D:\Android\Sdk` 时,Git Bash 会把反斜杠吃掉,
变成 Windows 的**盘符相对路径** `D:AndroidSdk` —— 这不是绝对路径,而是「D 盘当前目录下的
AndroidSdk」。结果 sdkmanager 把 5.3 GB 的包装进了 `cmdline-tools\latest\bin\AndroidSdk\`。
同盘移动是瞬时的,已搬回 `D:\Android\Sdk\`。**在 Git Bash 里给 Windows 工具传路径,一律用正斜杠。**
另外 `ANDROID_AVD_HOME` 已设为用户级变量 `D:\Android\avd`(AVD 镜像有几个 G,C 盘只剩 29 G)。