Compare commits
20
Commits
bf373a2a5a
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6af1078ca5 | ||
|
|
841f9f1542 | ||
|
|
fba26fc372 | ||
|
|
514e5ec9e2 | ||
|
|
e5f935625e | ||
|
|
77de7808cb | ||
|
|
5fe0d4a420 | ||
|
|
9c233e5750 | ||
|
|
0e17502334 | ||
|
|
01689b24fc | ||
|
|
88e88cf0ba | ||
|
|
7170a49664 | ||
|
|
20fe2bce66 | ||
|
|
58573f4657 | ||
|
|
031aea0fa2 | ||
|
|
4805b027e0 | ||
|
|
7eeb9f8e26 | ||
|
|
6d3cb1b418 | ||
|
|
2fba18bc7e | ||
|
|
fda104041a |
@@ -5,12 +5,30 @@
|
||||
- 设计文档:见 `ring-health-docs` 仓库 `superpowers/specs/`
|
||||
- 当前阶段:M1 静态页面(数据来自 Mock,未接入蓝牙 SDK)
|
||||
|
||||
## 页面清单(M1)
|
||||
|
||||
| 页面 | 路由 |
|
||||
|---|---|
|
||||
| 健康首页 | `home` |
|
||||
| 运动 | `workout` |
|
||||
| 运动历史 | `workout/history` |
|
||||
| 设备 | `device` |
|
||||
| 设备设置 | `device/settings` |
|
||||
| 我的 | `profile` |
|
||||
| 指标详情(步数/心率/睡眠/血氧/压力/体温/血压) | `detail/{metric}` |
|
||||
|
||||
> 页面路由的唯一定义处是 `core/navigation/Routes.kt`,上表与其保持一致。
|
||||
> M1 的验收状态见 `docs/verification-m1.md`。
|
||||
|
||||
## 构建
|
||||
|
||||
```bash
|
||||
./gradlew assembleDebug # 产物在 app/build/outputs/apk/debug/
|
||||
./gradlew installDebug # 安装到已连接设备
|
||||
./gradlew installDebug # 安装到已连接设备(需接好设备或已启动模拟器)
|
||||
./gradlew test # 单元测试
|
||||
```
|
||||
|
||||
> `installDebug` 需要一台已连接的设备或正在运行的模拟器。M1 使用的机器两者都没有,
|
||||
> 因此本机实际可执行的是 `./gradlew assembleDebug` 与 `./gradlew :app:testDebugUnitTest`。
|
||||
|
||||
要求:JDK 17、Android SDK(platform 36 / build-tools 36.1.0)。
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.placeholder.ringhealth
|
||||
|
||||
import com.placeholder.ringhealth.data.mock.MockHealthDataRepository
|
||||
import com.placeholder.ringhealth.data.mock.MockRingDeviceRepository
|
||||
import com.placeholder.ringhealth.domain.repository.HealthDataRepository
|
||||
import com.placeholder.ringhealth.domain.repository.RingDeviceRepository
|
||||
|
||||
/**
|
||||
* 手动依赖注入容器。M2 接入 SDK 时只需在这里把 Mock 实现换成真实实现。
|
||||
*/
|
||||
interface AppContainer {
|
||||
val healthDataRepository: HealthDataRepository
|
||||
val ringDeviceRepository: RingDeviceRepository
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认容器:阶段一全部使用 Mock 数据。
|
||||
*
|
||||
* 注意 [healthDataRepository] **依赖** [ringDeviceRepository] 的开关流,不是两个平行字段 ——
|
||||
* 之前正是各建各的,导致功能开关与健康数据互不相干(详见 [MockHealthDataRepository])。
|
||||
* 声明里保留具体类型,是为了把开关流传下去而不必向下转型。
|
||||
*/
|
||||
class DefaultAppContainer : AppContainer {
|
||||
private val mockRingDeviceRepository = MockRingDeviceRepository()
|
||||
|
||||
override val ringDeviceRepository: RingDeviceRepository = mockRingDeviceRepository
|
||||
|
||||
override val healthDataRepository: HealthDataRepository =
|
||||
MockHealthDataRepository(functionSettings = mockRingDeviceRepository.functionSettings)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.placeholder.ringhealth
|
||||
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewmodel.CreationExtras
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import com.placeholder.ringhealth.feature.device.DeviceViewModel
|
||||
import com.placeholder.ringhealth.feature.home.HomeViewModel
|
||||
import com.placeholder.ringhealth.feature.workout.WorkoutViewModel
|
||||
|
||||
/**
|
||||
* ViewModel 工厂:把容器中的仓库注入各 ViewModel。
|
||||
* 每个 ViewModel 在这里注册一个 initializer;后续任务按需追加。
|
||||
*/
|
||||
object AppViewModelProvider {
|
||||
|
||||
val Factory = viewModelFactory {
|
||||
initializer {
|
||||
HomeViewModel(
|
||||
healthDataRepository = ringHealthApplication().container.healthDataRepository,
|
||||
ringDeviceRepository = ringHealthApplication().container.ringDeviceRepository,
|
||||
)
|
||||
}
|
||||
initializer {
|
||||
WorkoutViewModel(
|
||||
healthDataRepository = ringHealthApplication().container.healthDataRepository,
|
||||
)
|
||||
}
|
||||
initializer {
|
||||
DeviceViewModel(
|
||||
ringDeviceRepository = ringHealthApplication().container.ringDeviceRepository,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 从 CreationExtras 中取出 Application 实例。 */
|
||||
fun CreationExtras.ringHealthApplication(): RingHealthApplication =
|
||||
this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as RingHealthApplication
|
||||
@@ -4,15 +4,12 @@ import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.placeholder.ringhealth.core.designsystem.RingHealthTheme
|
||||
import com.placeholder.ringhealth.core.navigation.RingHealthApp
|
||||
|
||||
/** 唯一 Activity,承载 Compose 界面。 */
|
||||
class MainActivity : ComponentActivity() {
|
||||
@@ -25,10 +22,8 @@ class MainActivity : ComponentActivity() {
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
// 文案取自 res/values/strings.xml,不在 Kotlin 里硬编码用户可见文字
|
||||
Text(stringResource(R.string.app_name))
|
||||
}
|
||||
// 界面骨架(底部导航 + 路由)在 core/navigation/RingHealthApp.kt
|
||||
RingHealthApp()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,16 @@ package com.placeholder.ringhealth
|
||||
import android.app.Application
|
||||
|
||||
/**
|
||||
* 应用入口。同时作为手动依赖注入的组合根(M1 阶段只持有 Mock 容器)。
|
||||
* 应用入口,同时作为手动依赖注入的组合根。
|
||||
*/
|
||||
class RingHealthApplication : Application()
|
||||
class RingHealthApplication : Application() {
|
||||
|
||||
/** 全局依赖容器。 */
|
||||
lateinit var container: AppContainer
|
||||
private set
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
container = DefaultAppContainer()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,10 @@ package com.placeholder.ringhealth.core.common
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
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.util.Calendar
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
@@ -15,6 +18,9 @@ fun formatDistance(km: Double): String = String.format(Locale.CHINA, "%.2f", km)
|
||||
/** 卡路里:只做千分位,单位「千卡」由 UI 用字符串资源拼接(ledger Ruling R13)。 */
|
||||
fun formatCalories(kcal: Int): String = String.format(Locale.CHINA, "%,d", kcal)
|
||||
|
||||
/** 活动累计分钟数:只取整,单位「分钟」由 UI 用字符串资源拼接。 */
|
||||
fun formatActiveMinutes(minutes: Int): String = minutes.toString()
|
||||
|
||||
/** 睡眠时长:7小时32分 / 45分 / 8小时。 */
|
||||
fun formatSleepDuration(minutes: Int): String {
|
||||
val hours = minutes / 60
|
||||
@@ -45,11 +51,119 @@ fun formatClock(timestamp: Long): String =
|
||||
fun formatDate(timestamp: Long): String =
|
||||
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
|
||||
fun stressLevelLabelRes(stress: Int): Int = when {
|
||||
stress < 30 -> R.string.stress_relaxed
|
||||
stress < 60 -> R.string.stress_normal
|
||||
stress < 80 -> R.string.stress_medium
|
||||
else -> R.string.stress_high
|
||||
fun weekdayResFor(timestamp: Long): Int = when (calendarField(timestamp, Calendar.DAY_OF_WEEK)) {
|
||||
Calendar.MONDAY -> R.string.weekday_monday
|
||||
Calendar.TUESDAY -> R.string.weekday_tuesday
|
||||
Calendar.WEDNESDAY -> R.string.weekday_wednesday
|
||||
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
|
||||
|
||||
/** 品牌主色(健康绿,仿小米运动健康首页大环)。 */
|
||||
/** 品牌主色(健康绿)。首页大环、主按钮与选中态都用它。 */
|
||||
val BrandGreen = Color(0xFF34C759)
|
||||
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 TextPrimary = Color(0xFF1C1C1E)
|
||||
val TextSecondary = Color(0xFF8E8E93)
|
||||
val TextPrimary = Color(0xFF14161C)
|
||||
val TextSecondary = Color(0xFF8A8F98)
|
||||
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 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.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
private val RingHealthColors = lightColorScheme(
|
||||
primary = BrandGreen,
|
||||
onPrimary = androidx.compose.ui.graphics.Color.White,
|
||||
onPrimary = Color.White,
|
||||
secondary = BrandGreenDark,
|
||||
background = AppBackground,
|
||||
onBackground = TextPrimary,
|
||||
surface = CardSurface,
|
||||
onSurface = TextPrimary,
|
||||
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.unit.sp
|
||||
|
||||
/** 大号数值样式:首页大环与详情页主数字。 */
|
||||
/**
|
||||
* 大号数值样式:首页大环与详情页主数字。
|
||||
*
|
||||
* `fontFeatureSettings = "tnum"` 打开等宽数字 —— 数值变化时每个字形宽度一致,
|
||||
* 数字不会左右跳动。字体不支持时是空操作,无副作用。
|
||||
* 负字距(-1sp)让多位数字排得更紧,是让大号数字「显贵」的关键一步。
|
||||
*/
|
||||
val MetricValueStyle = TextStyle(
|
||||
fontSize = 40.sp,
|
||||
fontSize = 44.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = (-1).sp,
|
||||
fontFeatureSettings = "tnum",
|
||||
color = TextPrimary,
|
||||
)
|
||||
|
||||
/** 中等数值样式:指标卡主数字。 */
|
||||
/** 中等数值样式:指标卡主数字。同样开等宽数字,避免网格里各卡数字宽度参差。 */
|
||||
val MetricTileValueStyle = TextStyle(
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
+58
@@ -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,
|
||||
)
|
||||
}
|
||||
+44
-11
@@ -3,40 +3,73 @@ package com.placeholder.ringhealth.core.designsystem.component
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.shadow
|
||||
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]
|
||||
* 映射为设计系统的 CardSurface),因此升级深色模式时无需改动本文件。
|
||||
*
|
||||
* @param modifier 外部修饰符,会在 fillMaxWidth 之前生效。
|
||||
* @param onClick 非空时整卡可点击(如水/体重等可跳转的卡片);为空则纯展示。
|
||||
* @param content 卡片内容,作用域为 ColumnScope,默认内边距 16dp。
|
||||
* @param onClick 非空时整卡可点击(如可跳转的卡片);为空则纯展示。
|
||||
* @param contentPadding 卡片内边距,默认 16dp;网格里的紧凑卡片可调小。
|
||||
* @param content 卡片内容,作用域为 ColumnScope。
|
||||
*/
|
||||
@Composable
|
||||
fun HealthCard(
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: (() -> Unit)? = null,
|
||||
contentPadding: PaddingValues = PaddingValues(16.dp),
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
// 仅当传入点击回调时才挂 clickable,避免给纯展示卡片带来无谓的水波纹与无障碍焦点。
|
||||
val clickModifier = if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier
|
||||
Card(
|
||||
modifier = modifier.fillMaxWidth().then(clickModifier),
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
// clip = false 让阴影画在卡片轮廓之外;卡片自身的形状由下面的 shape 参数裁剪。
|
||||
.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),
|
||||
)
|
||||
}
|
||||
}
|
||||
+112
-14
@@ -1,6 +1,8 @@
|
||||
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
|
||||
@@ -9,53 +11,149 @@ 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.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 手绘,无第三方依赖)。
|
||||
* values 为空时不绘制;被高亮的一根用纯色,其余半透明。
|
||||
* 柱状图(Compose Canvas 手绘,无第三方依赖)。
|
||||
*
|
||||
* 由三部分叠成:**基线** + **参考虚线** + **胶囊柱**,下方另有一行 X 轴刻度文字。
|
||||
*
|
||||
* 早先只有一排浮空的柱子 —— 没有基线,柱子像悬在半空;没有刻度,看不出哪根是哪天;
|
||||
* 没有参照,也读不出「今天比平时高还是低」。三个都补上之后这张图才开始承载信息。
|
||||
*
|
||||
* 几何说明:画布横向按 values.size 等分得到每个槽位 slot,柱宽取槽位的 65%,
|
||||
* 余下 35% 作为间隔,柱子因此在槽内水平居中;柱高 = 画布高 × (value / maxValue),
|
||||
* 底边贴合画布底边(y = 画布高 - 柱高),圆角半径取柱宽一半,形成胶囊柱。
|
||||
* maxValue 下限为 1f,避免「全为 0」时出现 0 除。
|
||||
* 余下 35% 作为间隔,柱子因此在槽内水平居中;柱高 = 画布高 ×
|
||||
* (value - 窗口下限) / (窗口上限 - 窗口下限),底边贴合基线(y = 画布高),
|
||||
* 圆角半径取柱宽一半,形成胶囊柱。窗口见 [valueRange]。
|
||||
*
|
||||
* @param values 各柱数值(须为非负),按时间顺序从左到右。
|
||||
* @param color 柱子颜色;高亮柱用纯色,其余用其 30% 透明度。
|
||||
* @param modifier 外部修饰符。
|
||||
* @param barHeight 画布高度,即最高柱的高度。
|
||||
* @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
|
||||
fun MetricBarChart(
|
||||
values: List<Float>,
|
||||
color: Color,
|
||||
modifier: Modifier = Modifier,
|
||||
barHeight: Dp = 120.dp,
|
||||
barHeight: Dp = 170.dp,
|
||||
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
|
||||
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 slot = size.width / values.size
|
||||
val barWidth = slot * (1f - gapRatio)
|
||||
// 注意:maxValue 已被 coerceAtLeast(1f) 抬高,当所有值都小于 1 时
|
||||
// values.indexOf(maxValue) 会返回 -1 导致没有任何柱子被高亮,
|
||||
// 因此这里改为直接取最大值的下标。
|
||||
// 注意:不能拿 `values.indexOf(maxValue)` 找高亮柱 —— 无窗口时 high 被
|
||||
// coerceAtLeast(1f) 抬高过,当所有值都小于 1 时 indexOf 返回 -1,没有柱子会被高亮。
|
||||
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 ->
|
||||
val ratio = (value / maxValue).coerceIn(0f, 1f)
|
||||
val h = size.height * ratio
|
||||
val h = size.height - yOf(value)
|
||||
val isHighlight = index == highlightIndex
|
||||
// 调用方指定了按值取色就优先用它;返回 null 表示「这根交回默认规则」。
|
||||
val barColor = barColorOf?.invoke(value)
|
||||
?: if (isHighlight) color else color.copy(alpha = 0.3f)
|
||||
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),
|
||||
size = Size(width = barWidth, height = h),
|
||||
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)
|
||||
|
||||
+108
@@ -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)
|
||||
}
|
||||
}
|
||||
+91
@@ -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)
|
||||
}
|
||||
}
|
||||
+113
@@ -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)
|
||||
}
|
||||
}
|
||||
+33
-11
@@ -2,10 +2,11 @@ package com.placeholder.ringhealth.core.designsystem.component
|
||||
|
||||
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.layout.size
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -19,18 +20,25 @@ import com.placeholder.ringhealth.core.designsystem.MetricTileValueStyle
|
||||
import com.placeholder.ringhealth.core.designsystem.TextSecondary
|
||||
|
||||
/**
|
||||
* 单个健康指标小卡:图标 + 标题 + 数值 + 单位。
|
||||
* 单个健康指标小卡:色底图标徽章 + 标题 + 数值 + 单位。
|
||||
*
|
||||
* 为**两列网格**设计(首页 2×3),因此刻意做得紧凑:卡片内边距 14dp、图标 17dp。
|
||||
* 图标外面套一层同色浅底的圆形徽章,是为了让网格里六张卡各自带一点颜色 —— 纯白卡片 +
|
||||
* 灰色小图标排成网格会显得很素,色彩只能靠数字本身,而数字是黑白的。
|
||||
*
|
||||
* 文案全部由调用方传入(标题/数值/单位),组件内不出现任何用户可见字面量。
|
||||
* 图标为纯装饰(contentDescription = null),语义由 title 承担,避免无障碍重复朗读。
|
||||
*
|
||||
* @param icon 指标图标,如 Icons.Filled.DirectionsWalk。
|
||||
* @param icon 指标图标,如 Icons.Filled.Favorite。
|
||||
* @param iconTint 图标着色,通常取设计系统中该指标的语义色。
|
||||
* @param title 指标名(由调用方从字符串资源取得)。
|
||||
* @param value 主数值文本。
|
||||
* @param unit 单位文本,如「步」「次/分」。
|
||||
* @param unit 单位文本,如「次/分」,可为空串。
|
||||
* @param modifier 外部修饰符。
|
||||
* @param onClick 点击回调,默认空实现(配合 HealthCard 的整卡可点击)。
|
||||
* @param enabled 为 false 时数值降级为普通灰字(用于「未开启」这类占位),
|
||||
* 避免「未开启」三个字用大号粗体排出来,看着比真实数值还重。
|
||||
* @param onClick 点击回调;为 null 表示卡片不可点击 —— 不挂 clickable,因此既没有水波纹
|
||||
* 也不会触发导航,用于「未开启」这类没有数值的指标卡(有值的卡才可跳转详情)。
|
||||
*/
|
||||
@Composable
|
||||
fun MetricTile(
|
||||
@@ -40,23 +48,37 @@ fun MetricTile(
|
||||
value: String,
|
||||
unit: String,
|
||||
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) {
|
||||
Icon(imageVector = icon, contentDescription = null, tint = iconTint, modifier = Modifier.size(18.dp))
|
||||
IconBadge(icon = icon, tint = iconTint)
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = TextSecondary,
|
||||
maxLines = 1,
|
||||
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) {
|
||||
if (enabled) {
|
||||
Text(text = value, style = MetricTileValueStyle)
|
||||
} else {
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = TextSecondary,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = unit,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
|
||||
+272
@@ -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)
|
||||
}
|
||||
}
|
||||
+52
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package com.placeholder.ringhealth.core.navigation
|
||||
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.FitnessCenter
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.NavigationBarItemDefaults
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.navigation.NavGraph.Companion.findStartDestination
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import com.placeholder.ringhealth.R
|
||||
import com.placeholder.ringhealth.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.feature.device.DeviceScreen
|
||||
import com.placeholder.ringhealth.feature.device.DeviceSettingsScreen
|
||||
import com.placeholder.ringhealth.feature.detail.MetricDetailScreen
|
||||
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.SettingsScreen
|
||||
import com.placeholder.ringhealth.feature.workout.WorkoutHistoryScreen
|
||||
import com.placeholder.ringhealth.feature.workout.WorkoutScreen
|
||||
|
||||
/** 底部导航项定义。 */
|
||||
private data class BottomTab(
|
||||
val route: String,
|
||||
val labelRes: Int,
|
||||
val icon: ImageVector,
|
||||
)
|
||||
|
||||
private val BOTTOM_TABS = listOf(
|
||||
BottomTab(Routes.HOME, R.string.tab_home, Icons.Filled.Home),
|
||||
BottomTab(Routes.WORKOUT, R.string.tab_workout, Icons.Filled.FitnessCenter),
|
||||
BottomTab(Routes.DEVICE, R.string.tab_device, Icons.Filled.Settings),
|
||||
BottomTab(Routes.PROFILE, R.string.tab_profile, Icons.Filled.Person),
|
||||
)
|
||||
|
||||
/**
|
||||
* 把当前路由归到它所属的底部 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。
|
||||
*/
|
||||
@Composable
|
||||
fun RingHealthApp(navController: NavHostController = rememberNavController()) {
|
||||
val backStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentRoute = tabRouteFor(backStackEntry?.destination?.route)
|
||||
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
bottomBar = {
|
||||
NavigationBar(containerColor = MaterialTheme.colorScheme.surface) {
|
||||
BOTTOM_TABS.forEach { tab ->
|
||||
NavigationBarItem(
|
||||
selected = currentRoute == tab.route,
|
||||
onClick = { navController.navigateToTab(tab.route) },
|
||||
icon = { Icon(imageVector = tab.icon, contentDescription = null) },
|
||||
label = { Text(stringResource(tab.labelRes)) },
|
||||
// 颜色显式指定,不吃主题默认值。M3 的默认选中底色来自
|
||||
// secondaryContainer,本应用虽已在 Theme 里把它改成浅绿,但底栏是全应用
|
||||
// 最显眼的一处选中态,写明确一点,避免将来有人改主题时无意改到这里。
|
||||
colors = NavigationBarItemDefaults.colors(
|
||||
selectedIconColor = NavSelected,
|
||||
selectedTextColor = NavSelected,
|
||||
indicatorColor = NavIndicator,
|
||||
unselectedIconColor = NavUnselected,
|
||||
unselectedTextColor = NavUnselected,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
) { innerPadding ->
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = Routes.HOME,
|
||||
// consumeWindowInsets 必须跟着 padding 一起写:外层 Scaffold 没有 topBar,
|
||||
// 它的 innerPadding.top 就是状态栏高度,若只 padding 不消费,这个 inset 会继续
|
||||
// 往下传,详情页内层 Scaffold 的 TopAppBar 会再加一次状态栏内边距 ——
|
||||
// AppBar 被推低约两个状态栏,上方露出一条背景色带,与四个 Tab 页明显不同。
|
||||
// 消费后内层拿到的是已扣减的 inset,两个方向都安全(若外层早已消费,这里是 no-op)。
|
||||
modifier = Modifier.padding(innerPadding).consumeWindowInsets(innerPadding),
|
||||
) {
|
||||
composable(Routes.HOME) {
|
||||
HomeScreen(onOpenDetail = { metric -> navController.navigate(Routes.detail(metric)) })
|
||||
}
|
||||
composable(Routes.WORKOUT) {
|
||||
WorkoutScreen(onOpenHistory = { navController.navigate(Routes.WORKOUT_HISTORY) })
|
||||
}
|
||||
composable(Routes.DEVICE) {
|
||||
DeviceScreen(onOpenSettings = { navController.navigate(Routes.DEVICE_SETTINGS) })
|
||||
}
|
||||
composable(Routes.PROFILE) {
|
||||
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) {
|
||||
DeviceSettingsScreen(onBack = { navController.popBackStack() })
|
||||
}
|
||||
composable(Routes.WORKOUT_HISTORY) {
|
||||
WorkoutHistoryScreen(onBack = { navController.popBackStack() })
|
||||
}
|
||||
composable(
|
||||
route = Routes.DETAIL_PATTERN,
|
||||
arguments = listOf(navArgument(Routes.DETAIL_ARG_METRIC) { type = NavType.StringType }),
|
||||
) { entry ->
|
||||
// 参数无法解析属于编程错误,直接抛(ledger Ruling R22):这里原先是
|
||||
// `?: MetricType.STEP`,一旦路由模板与 Routes.detail() 拼串不一致,
|
||||
// 页面会静默显示步数的数据而不报错 —— 那是「错数据」而不是「响亮失败」。
|
||||
val raw = requireNotNull(entry.arguments?.getString(Routes.DETAIL_ARG_METRIC)) {
|
||||
"详情路由缺少参数 ${Routes.DETAIL_ARG_METRIC}"
|
||||
}
|
||||
val metric = requireNotNull(MetricType.entries.firstOrNull { it.name == raw }) {
|
||||
"无法识别的指标:$raw"
|
||||
}
|
||||
MetricDetailScreen(metric = metric, onBack = { navController.popBackStack() })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 底部 Tab 切换:保留状态、避免回退栈堆积。 */
|
||||
private fun NavHostController.navigateToTab(route: String) {
|
||||
navigate(route) {
|
||||
popUpTo(graph.findStartDestination().id) { saveState = true }
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.placeholder.ringhealth.core.navigation
|
||||
|
||||
import com.placeholder.ringhealth.domain.model.MetricType
|
||||
|
||||
/**
|
||||
* 全局路由表。字符串集中定义,避免散落在各处。
|
||||
*/
|
||||
object Routes {
|
||||
const val HOME = "home"
|
||||
const val WORKOUT = "workout"
|
||||
const val DEVICE = "device"
|
||||
const val PROFILE = "profile"
|
||||
|
||||
const val DEVICE_SETTINGS = "device/settings"
|
||||
const val WORKOUT_HISTORY = "workout/history"
|
||||
|
||||
// 「我的」子页:四张真页面替代了阶段一首批的四个死链接
|
||||
// (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"
|
||||
|
||||
/** 指标详情路由模板,参数为 MetricType.name。 */
|
||||
const val DETAIL_PATTERN = "detail/{$DETAIL_ARG_METRIC}"
|
||||
|
||||
/** 拼出某个指标的详情路由。 */
|
||||
fun detail(metric: MetricType): String = "detail/${metric.name}"
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
package com.placeholder.ringhealth.data.mock
|
||||
|
||||
import com.placeholder.ringhealth.domain.model.DateRange
|
||||
import com.placeholder.ringhealth.domain.model.DeviceFunctionSetting
|
||||
import com.placeholder.ringhealth.domain.model.HealthSummary
|
||||
import com.placeholder.ringhealth.domain.model.MetricPoint
|
||||
import com.placeholder.ringhealth.domain.model.MetricType
|
||||
import com.placeholder.ringhealth.domain.model.RingDeviceState
|
||||
import com.placeholder.ringhealth.domain.model.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.valueOf
|
||||
import java.util.Calendar
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* 演示数据生成器。所有方法都是确定性的(同参数、同时刻返回同结果),
|
||||
* 保证页面展示与截图稳定。M2 接入真机后本类整体弃用。
|
||||
*
|
||||
* @param seed 随机种子,决定历史数据的取值序列。
|
||||
* @param now 时间源,默认取系统时钟;测试注入固定值后可得到完全可复现的时间戳
|
||||
* (ledger Ruling R17)。
|
||||
*/
|
||||
class MockDataProvider(
|
||||
private val seed: Long = DEFAULT_SEED,
|
||||
private val now: () -> Long = System::currentTimeMillis,
|
||||
) {
|
||||
|
||||
/**
|
||||
* 今日概览:固定演示值,但**未开启的指标一律为 null**。
|
||||
*
|
||||
* @param enabledMetrics 当前开启的指标集合,来自功能开关(见 [DeviceFunctionSetting])。
|
||||
* 关掉的指标在这里被置空,首页那张卡才会显示「未开启」——
|
||||
* 否则用户在功能设置页翻了开关,首页纹丝不动,开关就是个摆设。
|
||||
* **刻意不给默认值**:默认「全都开着」会让漏传参数的调用方看起来一切正常,
|
||||
* 而那正是这次要修的病。
|
||||
*
|
||||
* 计步与体温没有开关([MetricType.switchId] 为 null),恒定给值。
|
||||
*/
|
||||
fun todaySummary(enabledMetrics: Set<MetricType>): HealthSummary =
|
||||
HealthSummary(
|
||||
steps = 8426,
|
||||
stepGoal = 10000,
|
||||
calories = 327,
|
||||
distanceKm = 6.21,
|
||||
heartRate = if (MetricType.HEART_RATE in enabledMetrics) 72 else null,
|
||||
// 睡眠时长取自昨晚那条 [SleepSession],不另写一个常量:
|
||||
// 头图写 452 分钟、时段图上昨晚那根条却是另一个长度,是同一屏里最刺眼的一种自相矛盾。
|
||||
sleepMinutes = if (MetricType.SLEEP in enabledMetrics) demoLastNight().sleepMinutes else null,
|
||||
bloodOxygen = if (MetricType.BLOOD_OXYGEN in enabledMetrics) 98 else 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 个点。
|
||||
* 时间戳按“每点间隔一天”倒推,值在指标合理区间内波动。
|
||||
*
|
||||
* **最后一个点(时间戳 = 现在,即“今天”)的值取自 [todaySummary]**,不由随机序列生成。
|
||||
* 详情页的头图显示概览里的当前值、趋势图的最后一根柱子显示今天的值,两者本该相同;
|
||||
* 各生成各的会让页面出现「头图写心率 72、图上今天却是 96」这种矛盾,
|
||||
* 而且两个数字看起来都很确定,用户无从判断该信哪个。
|
||||
*
|
||||
* **睡眠是例外:它的点由 [sleepSessions] 折算而来**,不走下面那套随机序列。
|
||||
* 同一个指标有两套数据源,就会出现「头图写 7 小时 32 分、时段图上却是一根 6 小时的条」——
|
||||
* 而这正是睡眠页最显眼的两块内容。
|
||||
*/
|
||||
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 random = Random(seed + metric.ordinal * 100L + range.ordinal)
|
||||
val (min, max) = valueRange(metric)
|
||||
val current = now()
|
||||
val dayMillis = 24L * 60 * 60 * 1000
|
||||
val points = (0 until count).map { index ->
|
||||
val progress = index.toFloat() / (count - 1).coerceAtLeast(1)
|
||||
val value = min + (max - min) * progress + random.nextDouble(-1.0, 1.0) * (max - min) * 0.15
|
||||
MetricPoint(
|
||||
timestamp = current - (count - 1 - index) * dayMillis,
|
||||
value = value.coerceIn(min, max),
|
||||
)
|
||||
}
|
||||
// 指标未开启(概览里为 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 条演示数据,倒序(最近的在最前)。 */
|
||||
fun workouts(): List<WorkoutRecord> {
|
||||
val random = Random(seed + 7)
|
||||
val current = now()
|
||||
val dayMillis = 24L * 60 * 60 * 1000
|
||||
val types = listOf("户外跑步", "健走", "骑行", "自由训练", "户外跑步")
|
||||
return types.mapIndexed { index, type ->
|
||||
WorkoutRecord(
|
||||
id = "workout-$index",
|
||||
type = type,
|
||||
startTime = current - (index + 1) * dayMillis,
|
||||
durationMinutes = 25 + random.nextInt(0, 45),
|
||||
distanceKm = 2.0 + random.nextDouble(0.0, 5.0),
|
||||
calories = 120 + random.nextInt(0, 260),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** 戒指设备状态:固定演示值。 */
|
||||
fun deviceState(): RingDeviceState = RingDeviceState(
|
||||
connected = true,
|
||||
name = "RingHealth 智能戒指",
|
||||
batteryPercent = 78,
|
||||
firmwareVersion = "1.0.5",
|
||||
charging = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* 五个功能开关,对应协议中的自动监测能力。
|
||||
*
|
||||
* id 一律取 [MetricType.switchId],不在这里手写字符串 —— 手写就等于把「开关 ↔ 指标」
|
||||
* 这个对应关系又抄了一份,抄错一份不会报错,只会让某个开关悄悄失效。
|
||||
*
|
||||
* 默认值:血压关着,用来演示「未开启」占位态;其余开着。
|
||||
* 压力此前默认是关的,但 [todaySummary] 里 `stress = 34` 一直有值 ——
|
||||
* 那正是「开关与数据两套系统」留下的痕迹,现在两者由同一个开关决定,默认值必须自洽。
|
||||
*/
|
||||
fun defaultFunctionSettings(): List<DeviceFunctionSetting> = listOf(
|
||||
setting(MetricType.HEART_RATE, "自动心率监测", "每 10 分钟测量一次", enabled = true),
|
||||
setting(MetricType.BLOOD_OXYGEN, "自动血氧监测", "夜间持续监测", enabled = true),
|
||||
setting(MetricType.SLEEP, "睡眠监测", "自动识别睡眠时段", enabled = true),
|
||||
setting(MetricType.STRESS, "压力监测", "全天候压力评估", enabled = true),
|
||||
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,
|
||||
)
|
||||
|
||||
/** 各指标的取值区间,用于生成合理的历史数据。 */
|
||||
private fun valueRange(metric: MetricType): Pair<Double, Double> = when (metric) {
|
||||
MetricType.STEP -> 3000.0 to 12000.0
|
||||
MetricType.HEART_RATE -> 58.0 to 96.0
|
||||
MetricType.SLEEP -> 330.0 to 510.0
|
||||
MetricType.BLOOD_OXYGEN -> 95.0 to 99.0
|
||||
MetricType.STRESS -> 20.0 to 70.0
|
||||
MetricType.TEMPERATURE -> 36.1 to 36.9
|
||||
MetricType.BLOOD_PRESSURE -> 110.0 to 130.0
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** 默认随机种子,保证演示数据稳定。 */
|
||||
const val DEFAULT_SEED: Long = 20260910L
|
||||
|
||||
/** 血压开着时的演示读数(概览里是「收缩压/舒张压」形式)。 */
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.placeholder.ringhealth.data.mock
|
||||
|
||||
import com.placeholder.ringhealth.domain.model.DateRange
|
||||
import com.placeholder.ringhealth.domain.model.DeviceFunctionSetting
|
||||
import com.placeholder.ringhealth.domain.model.HealthSummary
|
||||
import com.placeholder.ringhealth.domain.model.MetricPoint
|
||||
import com.placeholder.ringhealth.domain.model.MetricType
|
||||
import com.placeholder.ringhealth.domain.model.SleepSession
|
||||
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.RingDeviceRepository
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
/**
|
||||
* Mock 健康数据仓库。模拟 200ms 网络/设备延迟,便于阶段一验证加载态。
|
||||
*
|
||||
* **它订阅功能开关。** 这是「开关能影响首页」的关键:原先健康数据与开关状态是
|
||||
* `AppContainer` 里两个互不引用的字段,用户打开「血压监测」只翻动了开关自己那一行,
|
||||
* 这边照样发射写死的概览,首页因而纹丝不动。现在数据由开关算出来,
|
||||
* 开关一变就重发。
|
||||
*
|
||||
* @param functionSettings 功能开关流,来自 [RingDeviceRepository]。
|
||||
* 注意依赖的是 `StateFlow`(会立刻给出当前值)而不是一个一次性读取 ——
|
||||
* 否则开关变化传不过来,又会退回原样。
|
||||
*/
|
||||
class MockHealthDataRepository(
|
||||
private val functionSettings: StateFlow<List<DeviceFunctionSetting>>,
|
||||
private val provider: MockDataProvider = MockDataProvider(),
|
||||
) : HealthDataRepository {
|
||||
|
||||
// flow { delay(...) } 先给出加载态,再 emitAll 接上开关流:初值延迟 200ms 到达
|
||||
// (便于验证 loading),此后每次翻开关都立刻重发。
|
||||
override fun observeTodaySummary(): Flow<HealthSummary> = flow {
|
||||
delay(MOCK_DELAY_MILLIS)
|
||||
emitAll(functionSettings.map { provider.todaySummary(it.enabledMetrics()) })
|
||||
}
|
||||
|
||||
override fun observeMetricHistory(metric: MetricType, range: DateRange): Flow<List<MetricPoint>> = flow {
|
||||
delay(MOCK_DELAY_MILLIS)
|
||||
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 {
|
||||
delay(MOCK_DELAY_MILLIS)
|
||||
emit(provider.workouts())
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val MOCK_DELAY_MILLIS = 200L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.placeholder.ringhealth.data.mock
|
||||
|
||||
import com.placeholder.ringhealth.domain.model.DeviceFunctionSetting
|
||||
import com.placeholder.ringhealth.domain.model.RingDeviceState
|
||||
import com.placeholder.ringhealth.domain.repository.RingDeviceRepository
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
||||
/**
|
||||
* Mock 设备仓库。开关状态保存在内存中,进程重启后重置(阶段一不做持久化)。
|
||||
*
|
||||
* 注意 [refreshDeviceState] 的语义:它以 **suspend 函数正常返回**表示刷新完成,
|
||||
* 而**不是**靠发射新状态。`MutableStateFlow` 会抑制相等值,M1 的设备状态是常量,
|
||||
* 因此刷新不会产生任何可观察的发射。调用方要展示刷新反馈,应当
|
||||
* `launch { repo.refreshDeviceState(); isRefreshing = false }`,
|
||||
* **不要**写成「等待下一个不同的 deviceState 发射」(那样在 M1 会永远等不到)。
|
||||
*/
|
||||
class MockRingDeviceRepository(
|
||||
private val provider: MockDataProvider = MockDataProvider(),
|
||||
) : RingDeviceRepository {
|
||||
|
||||
private val _deviceState = MutableStateFlow(provider.deviceState())
|
||||
override val deviceState: StateFlow<RingDeviceState> = _deviceState.asStateFlow()
|
||||
|
||||
private val _functionSettings = MutableStateFlow(provider.defaultFunctionSettings())
|
||||
override val functionSettings: StateFlow<List<DeviceFunctionSetting>> = _functionSettings.asStateFlow()
|
||||
|
||||
override suspend fun refreshDeviceState() {
|
||||
delay(REFRESH_DELAY_MILLIS)
|
||||
_deviceState.value = provider.deviceState()
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换某个功能开关。
|
||||
*
|
||||
* **必须返回新列表**(`map` / `+` 等),不要原地改写同一个 `MutableList`:
|
||||
* `DeviceViewModel` 的 `stateIn(initialValue = …)` 在**构造时**就把当时的 List 引用冻结了,
|
||||
* 而 `DeviceViewModelTest` 第一条用例的鉴别力正依赖于此 —— 若这里改成原地改写,
|
||||
* `initialValue` 会被连带改动,那条断言就退化成自我确认,测试会静默失去意义。
|
||||
*/
|
||||
override fun setFunctionEnabled(id: String, enabled: Boolean) {
|
||||
_functionSettings.update { settings ->
|
||||
settings.map { if (it.id == id) it.copy(enabled = enabled) else it }
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val REFRESH_DELAY_MILLIS = 300L
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,26 @@
|
||||
package com.placeholder.ringhealth.domain.model
|
||||
|
||||
/** 健康指标类型。路由与详情页以此区分。 */
|
||||
enum class MetricType {
|
||||
STEP,
|
||||
HEART_RATE,
|
||||
SLEEP,
|
||||
BLOOD_OXYGEN,
|
||||
STRESS,
|
||||
TEMPERATURE,
|
||||
BLOOD_PRESSURE,
|
||||
/**
|
||||
* 健康指标类型。路由与详情页以此区分。
|
||||
*
|
||||
* @param switchId 该指标对应 [DeviceFunctionSetting.id] 里的哪个开关;**为 null 表示常开** ——
|
||||
* 计步与体温由戒指持续采集,没有「关掉」这个选项。
|
||||
*
|
||||
* 这个对应关系必须显式写出来,不能靠两边字符串长得像来对齐。原先它只存在于巧合里
|
||||
* (开关 id 是 `"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。
|
||||
*
|
||||
* @param activeMinutes 今日活动累计分钟数(高强度活动 + 中等强度活动合并)。
|
||||
* 与 [steps] / [calories] 同属「每日总量」型数据,**不属于任何一个 [MetricType]**
|
||||
* (首页三件套横排里展示,M3 接 SDK 后由真实活动汇总接口填入)。
|
||||
*/
|
||||
data class HealthSummary(
|
||||
val steps: Int,
|
||||
@@ -39,11 +55,98 @@ data class HealthSummary(
|
||||
val stress: Int?,
|
||||
val temperature: Double?,
|
||||
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)
|
||||
|
||||
/**
|
||||
* 一段睡眠分期:起点到终点(相对于 [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(
|
||||
val id: String,
|
||||
@@ -61,3 +164,29 @@ data class DeviceFunctionSetting(
|
||||
val subtitle: String,
|
||||
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.MetricType
|
||||
import com.placeholder.ringhealth.domain.model.RingDeviceState
|
||||
import com.placeholder.ringhealth.domain.model.SleepSession
|
||||
import com.placeholder.ringhealth.domain.model.WorkoutRecord
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -20,6 +21,16 @@ interface HealthDataRepository {
|
||||
/** 指定指标在指定时间范围内的历史数据。 */
|
||||
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>>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
package com.placeholder.ringhealth.feature.detail
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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.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.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.placeholder.ringhealth.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.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
|
||||
|
||||
/**
|
||||
* 指标详情页(有状态外壳)。
|
||||
*
|
||||
* 本页自带 `Scaffold` + `TopAppBar`(外层 `RingHealthApp` 也有一个 `Scaffold`,
|
||||
* 它传入的 `innerPadding` 已用于 NavHost 的 `Modifier.padding`,底部栏因此保持可见)。
|
||||
* 页面内容全部在 `MetricDetailContent` 里,本函数只负责取状态与传回调(ledger Ruling R26)。
|
||||
*/
|
||||
@Composable
|
||||
fun MetricDetailScreen(
|
||||
metric: MetricType,
|
||||
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 }
|
||||
+145
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+98
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+133
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
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.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.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.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
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.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
|
||||
|
||||
/** 设备(戒指管理)页。 */
|
||||
@Composable
|
||||
fun DeviceScreen(
|
||||
onOpenSettings: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: DeviceViewModel = viewModel(factory = AppViewModelProvider.Factory),
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
DeviceContent(state = state, onOpenSettings = onOpenSettings, modifier = modifier)
|
||||
}
|
||||
|
||||
/**
|
||||
* 无状态设备页内容。
|
||||
*
|
||||
* **空态 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),
|
||||
)
|
||||
@@ -0,0 +1,125 @@
|
||||
package com.placeholder.ringhealth.feature.device
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.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.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.placeholder.ringhealth.AppViewModelProvider
|
||||
import com.placeholder.ringhealth.R
|
||||
import com.placeholder.ringhealth.core.designsystem.MetricGreen
|
||||
import com.placeholder.ringhealth.core.designsystem.RingHealthTheme
|
||||
import com.placeholder.ringhealth.core.designsystem.TextSecondary
|
||||
import com.placeholder.ringhealth.core.designsystem.component.HealthCard
|
||||
import com.placeholder.ringhealth.core.designsystem.component.SubPageTopAppBar
|
||||
import com.placeholder.ringhealth.domain.model.DeviceFunctionSetting
|
||||
|
||||
/**
|
||||
* 设备设置子页:可交互的功能开关列表。
|
||||
* M1 只改内存状态;M2 后写入设备(对应协议 0502/0504)。
|
||||
*/
|
||||
@Composable
|
||||
fun DeviceSettingsScreen(
|
||||
onBack: () -> Unit,
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
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.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
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.layout.width
|
||||
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.filled.MonitorHeart
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
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.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.placeholder.ringhealth.AppViewModelProvider
|
||||
import com.placeholder.ringhealth.R
|
||||
import com.placeholder.ringhealth.core.common.formatActiveMinutes
|
||||
import com.placeholder.ringhealth.core.common.formatCalories
|
||||
import com.placeholder.ringhealth.core.common.formatMonthDay
|
||||
import com.placeholder.ringhealth.core.common.formatSleepDuration
|
||||
import com.placeholder.ringhealth.core.common.formatSteps
|
||||
import com.placeholder.ringhealth.core.common.greetingResFor
|
||||
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.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.MetricTile
|
||||
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.MetricType
|
||||
import com.placeholder.ringhealth.domain.model.RingDeviceState
|
||||
|
||||
/**
|
||||
* 一个指标卡的数据。
|
||||
*
|
||||
* 只有文案和数值在这里 —— 图标与配色是指标的固有属性,取自 [metricIcon] / [metricTint],
|
||||
* 不随卡片走。否则同一份映射会被复制到每个用到它的地方。
|
||||
*/
|
||||
private data class MetricCardSpec(
|
||||
val type: MetricType,
|
||||
val title: String,
|
||||
val value: String?,
|
||||
val unit: String,
|
||||
)
|
||||
|
||||
/** 首页网格列数。 */
|
||||
private const val HOME_GRID_COLUMNS = 2
|
||||
|
||||
/** 健康首页。 */
|
||||
@Composable
|
||||
fun HomeScreen(
|
||||
onOpenDetail: (MetricType) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: HomeViewModel = viewModel(factory = AppViewModelProvider.Factory),
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
HomeContent(
|
||||
state = state,
|
||||
onOpenDetail = onOpenDetail,
|
||||
// 进页面时取一次当前时间,供问候语与日期行使用。
|
||||
// 取一次而不是起一个每秒跳动的时钟:问候语在「早上好→下午好」之间切换没有实时性要求,
|
||||
// 而一个常驻协程会在页面不可见时也持续重组。remember 保证配置变更外的重组不重取。
|
||||
nowMillis = remember { System.currentTimeMillis() },
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 无状态首页内容,便于 Preview。
|
||||
*
|
||||
* 布局用 `LazyVerticalGrid` 两列:整宽内容(问候、设备条、步数大环、区块标题)用
|
||||
* `GridItemSpan(maxLineSpan)` 跨满整行,六个指标卡各占一格,排成 2×3。
|
||||
* 之前是 `LazyColumn` 单列 —— 每张指标卡被拉到整屏宽(约 1100px)而高只有 180px,
|
||||
* 宽高比 6:1,横向大片留白、纵向却被撑开,六个数字要滚三屏才看得完。
|
||||
*/
|
||||
@Composable
|
||||
private fun HomeContent(
|
||||
state: HomeUiState,
|
||||
onOpenDetail: (MetricType) -> Unit,
|
||||
nowMillis: Long,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (state.loading || state.summary == null) {
|
||||
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = MetricGreen)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val summary = state.summary
|
||||
// 注意:LazyVerticalGrid 的 content lambda 不是 Composable 上下文,
|
||||
// 所有需要 stringResource / Composable 调用的内容都必须在网格之前算好。
|
||||
val cards = buildMetricCards(summary)
|
||||
val notEnabledText = stringResource(R.string.home_not_enabled)
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(HOME_GRID_COLUMNS),
|
||||
modifier = modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 4.dp, bottom = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
// 跨满整行的元素一律显式传 GridItemSpan(maxLineSpan),而不是写死 2 ——
|
||||
// 将来改列数时这些元素自动跟随,不会漏改。
|
||||
item(span = { GridItemSpan(maxLineSpan) }) { GreetingHeader(nowMillis = nowMillis) }
|
||||
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(
|
||||
icon = metricIcon(card.type),
|
||||
iconTint = metricTint(card.type),
|
||||
title = card.title,
|
||||
value = card.value ?: notEnabledText,
|
||||
// 显示「未开启」时它不是一个数值,也就没有单位可跟。
|
||||
unit = if (enabled) card.unit else "",
|
||||
enabled = enabled,
|
||||
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
|
||||
private fun DeviceStrip(device: RingDeviceState?, modifier: Modifier = Modifier) {
|
||||
HealthCard(modifier = modifier, contentPadding = PaddingValues(horizontal = 16.dp, vertical = 14.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.MonitorHeart,
|
||||
contentDescription = null,
|
||||
tint = MetricGreen,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
Column(modifier = Modifier.padding(start = 10.dp)) {
|
||||
Text(
|
||||
text = if (device?.connected == true) {
|
||||
stringResource(R.string.home_device_connected, device.name)
|
||||
} else {
|
||||
stringResource(R.string.home_device_disconnected)
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
if (device != null) {
|
||||
Text(
|
||||
text = stringResource(R.string.home_battery, device.batteryPercent),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = TextSecondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 今日数据卡片:三件套横排(步数 / 卡路里 / 活动时长)。
|
||||
*
|
||||
* 早先是「步数大环 + 距离/千卡」两件套 —— 大环占近 1/3 屏,环外信息只有两个换算结果,
|
||||
* 留白多、信息密度低。换成三件套后:
|
||||
* - 三列等宽,无主导项 —— 步数不再被环放大成「今天的全部重点」
|
||||
* - 步数下挂目标("目标 10,000"),目标本身仍是用户对照自己的尺子
|
||||
* - 步数一格整格可点进详情;卡路里/活动时长一格整格也点了能进(暂未挂路由,行为同环外区域)
|
||||
*
|
||||
* **第一版的「value + 单位同行」写法装不下**:30sp 字号下「8,426 步」「327 千卡」「47 分钟」
|
||||
* 三件套单元格里都会换行 —— 视觉上「8,426」「步」分两行变成「数值」「单位」两截,反而像丢了内容。
|
||||
* 改为「label 表达单位 + value 纯数字」:label 写成「步数 / 卡路里(千卡)/ 活动(分钟)」,
|
||||
* 把单位塞进 label,value 行只摆数字。
|
||||
*/
|
||||
@Composable
|
||||
private fun TodaySummaryCard(summary: HealthSummary, onOpenSteps: () -> Unit, modifier: Modifier = Modifier) {
|
||||
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(
|
||||
modifier = clickableModifier.padding(horizontal = 8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 三件套单格内的数值字号: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
|
||||
private fun buildMetricCards(summary: HealthSummary): List<MetricCardSpec> = listOf(
|
||||
MetricCardSpec(
|
||||
type = MetricType.HEART_RATE,
|
||||
title = stringResource(R.string.metric_heart_rate),
|
||||
value = summary.heartRate?.toString(),
|
||||
unit = stringResource(R.string.unit_bpm),
|
||||
),
|
||||
MetricCardSpec(
|
||||
type = MetricType.SLEEP,
|
||||
title = stringResource(R.string.metric_sleep),
|
||||
value = summary.sleepMinutes?.let { formatSleepDuration(it) },
|
||||
unit = "",
|
||||
),
|
||||
MetricCardSpec(
|
||||
type = MetricType.BLOOD_OXYGEN,
|
||||
title = stringResource(R.string.metric_blood_oxygen),
|
||||
value = summary.bloodOxygen?.toString(),
|
||||
unit = stringResource(R.string.unit_percent),
|
||||
),
|
||||
MetricCardSpec(
|
||||
type = MetricType.STRESS,
|
||||
title = stringResource(R.string.metric_stress),
|
||||
value = summary.stress?.toString(),
|
||||
// 单位位改放压力等级文案(数值落在哪个区间就是放松/正常/中等/偏高),
|
||||
// 于是卡片读作「34 正常」而不是一个孤零零的数字。无值时留空(上面的 value 分支
|
||||
// 会整卡退化成「未开启」,此时单位本来也会被清空)。
|
||||
unit = summary.stress?.let { stringResource(stressLevelLabelRes(it)) } ?: "",
|
||||
),
|
||||
MetricCardSpec(
|
||||
type = MetricType.TEMPERATURE,
|
||||
title = stringResource(R.string.metric_temperature),
|
||||
value = summary.temperature?.toString(),
|
||||
unit = stringResource(R.string.unit_celsius),
|
||||
),
|
||||
MetricCardSpec(
|
||||
type = MetricType.BLOOD_PRESSURE,
|
||||
title = stringResource(R.string.metric_blood_pressure),
|
||||
value = summary.bloodPressure,
|
||||
unit = stringResource(R.string.unit_mmhg),
|
||||
),
|
||||
)
|
||||
|
||||
/**
|
||||
* 预览入口:以固定示例数据渲染 [HomeContent],便于在 IDE 中免真机查看首页效果。
|
||||
*
|
||||
* `nowMillis` 写死成固定时刻,预览里的问候语与日期才不会随运行时间变化。
|
||||
*/
|
||||
@Preview(showBackground = true, heightDp = 900)
|
||||
@Composable
|
||||
private fun HomeContentPreview() {
|
||||
RingHealthTheme {
|
||||
HomeContent(
|
||||
state = HomeUiState(
|
||||
loading = false,
|
||||
summary = HealthSummary(8426, 10000, 327, 6.21, 72, 452, 98, 34, 36.5, null),
|
||||
device = RingDeviceState(true, "RingHealth 智能戒指", 78, "1.0.5", false),
|
||||
),
|
||||
onOpenDetail = {},
|
||||
// 固定为 2026-09-10 08:26(周四)—— 取固定值是为了让预览里的问候语「早上好」
|
||||
// 与日期行不随运行时间变化,否则同一份 Preview 在不同时段渲染出不同文案。
|
||||
nowMillis = 1_789_000_000_000L,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.placeholder.ringhealth.feature.home
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.placeholder.ringhealth.domain.model.HealthSummary
|
||||
import com.placeholder.ringhealth.domain.model.RingDeviceState
|
||||
import com.placeholder.ringhealth.domain.repository.HealthDataRepository
|
||||
import com.placeholder.ringhealth.domain.repository.RingDeviceRepository
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
|
||||
/**
|
||||
* 首页 UI 状态。summary/device 为 null 表示仍在加载。
|
||||
*/
|
||||
data class HomeUiState(
|
||||
val loading: Boolean,
|
||||
val summary: HealthSummary?,
|
||||
val device: RingDeviceState?,
|
||||
)
|
||||
|
||||
/**
|
||||
* 健康首页 ViewModel:合并今日概览与设备状态。
|
||||
*/
|
||||
class HomeViewModel(
|
||||
healthDataRepository: HealthDataRepository,
|
||||
ringDeviceRepository: RingDeviceRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
val uiState: StateFlow<HomeUiState> = combine(
|
||||
healthDataRepository.observeTodaySummary(),
|
||||
ringDeviceRepository.deviceState,
|
||||
) { summary, device ->
|
||||
HomeUiState(loading = false, summary = summary, device = device)
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(STOP_TIMEOUT_MILLIS),
|
||||
initialValue = HomeUiState(loading = true, summary = null, device = null),
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val STOP_TIMEOUT_MILLIS = 5_000L
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package com.placeholder.ringhealth.feature.profile
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.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.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.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.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
|
||||
|
||||
/**
|
||||
* “我的”页。
|
||||
*
|
||||
* 2026-09-10 晚收口:四个入口(单位设置 / 通知权限 / 检查更新 / 帮助与反馈)
|
||||
* 全部挂上 `onClick`,导航到 [SettingsScreen] / [NotificationsScreen] / [AboutScreen] / [HelpScreen],
|
||||
* 告别阶段一首批的死链接(PROGRESS.md §3.2)。
|
||||
*
|
||||
* 本页**不需要**再拆「有状态外壳 + 无状态内容」(ledger Ruling R26 的常规形状):
|
||||
* 它本身就是无状态的,直接对它挂 `@Preview` 即可。
|
||||
*/
|
||||
@Composable
|
||||
fun ProfileScreen(
|
||||
onOpenSettings: () -> Unit = {},
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package com.placeholder.ringhealth.feature.workout
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
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.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.placeholder.ringhealth.AppViewModelProvider
|
||||
import com.placeholder.ringhealth.R
|
||||
import com.placeholder.ringhealth.core.designsystem.MetricGreen
|
||||
import com.placeholder.ringhealth.core.designsystem.RingHealthTheme
|
||||
import com.placeholder.ringhealth.core.designsystem.TextSecondary
|
||||
import com.placeholder.ringhealth.core.designsystem.component.SubPageTopAppBar
|
||||
import com.placeholder.ringhealth.domain.model.WorkoutRecord
|
||||
|
||||
/** 运动历史子页:展示全部运动记录。 */
|
||||
@Composable
|
||||
fun WorkoutHistoryScreen(
|
||||
onBack: () -> Unit,
|
||||
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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
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.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.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.TextButton
|
||||
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.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.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.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
|
||||
|
||||
/**
|
||||
* 运动类型。
|
||||
*
|
||||
* 戒指协议覆盖的运动模式列表([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
|
||||
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) {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -6,4 +6,230 @@
|
||||
<string name="stress_normal">正常</string>
|
||||
<string name="stress_medium">中等</string>
|
||||
<string name="stress_high">偏高</string>
|
||||
|
||||
<string name="tab_home">健康</string>
|
||||
<string name="tab_workout">运动</string>
|
||||
<string name="tab_device">设备</string>
|
||||
<string name="tab_profile">我的</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_disconnected">未连接设备</string>
|
||||
<string name="home_battery">电量 %1$d%%</string>
|
||||
<string name="home_steps_title">今日步数</string>
|
||||
<string name="home_steps_goal">目标 %1$s 步</string>
|
||||
<string name="home_calories_label">千卡</string>
|
||||
<string name="home_distance_label">公里</string>
|
||||
<string name="home_metrics">健康指标</string>
|
||||
<string name="home_not_enabled">未开启</string>
|
||||
<string name="unit_bpm">次/分</string>
|
||||
<string name="unit_percent">%</string>
|
||||
<string name="unit_celsius">°C</string>
|
||||
<string name="unit_mmhg">mmHg</string>
|
||||
<!-- 详情页统计摘要的单位位用。步数此前没有单位资源,摘要里只能显示一个光秃秃的 8,426。 -->
|
||||
<string name="unit_steps">步</string>
|
||||
|
||||
<string name="metric_heart_rate">心率</string>
|
||||
<string name="metric_sleep">睡眠</string>
|
||||
<string name="metric_blood_oxygen">血氧</string>
|
||||
<string name="metric_stress">压力</string>
|
||||
<string name="metric_temperature">体温</string>
|
||||
<string name="metric_blood_pressure">血压</string>
|
||||
<string name="metric_step">步数</string>
|
||||
|
||||
<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>
|
||||
|
||||
@@ -39,6 +39,13 @@ class FormattersTest {
|
||||
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
|
||||
fun `stressLevelLabelRes 按区间映射到对应字符串资源`() {
|
||||
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}}"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
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.DeviceFunctionSetting
|
||||
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.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class MockDataProviderTest {
|
||||
|
||||
/**
|
||||
* 固定时钟。Mock 的「确定性」承诺依赖时间源固定,否则 `timestamp` 会随毫秒跳动
|
||||
* (ledger Ruling R17)。
|
||||
*/
|
||||
private val provider = MockDataProvider(now = { FIXED_NOW })
|
||||
|
||||
private companion object {
|
||||
/** 固定的“当前时间”,让历史数据的时间戳可复现。 */
|
||||
const val FIXED_NOW = 1_760_000_000_000L
|
||||
}
|
||||
|
||||
/** 默认演示状态下的开关:血压关着,其余开着(见 [MockDataProvider.defaultFunctionSettings])。 */
|
||||
private val defaultEnabled: Set<MetricType> =
|
||||
provider.defaultFunctionSettings().enabledMetrics()
|
||||
|
||||
@Test
|
||||
fun `今日概览返回固定演示数据`() {
|
||||
val summary = provider.todaySummary(defaultEnabled)
|
||||
assertEquals(8426, summary.steps)
|
||||
assertEquals(10000, summary.stepGoal)
|
||||
assertEquals(327, summary.calories)
|
||||
assertEquals(72, summary.heartRate)
|
||||
assertEquals(452, summary.sleepMinutes)
|
||||
// 血压默认未开启,应为 null
|
||||
assertEquals(null, summary.bloodPressure)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `关掉的指标在概览里没有值`() {
|
||||
// 这是「功能开关能影响首页」的根:概览的字段必须由开关算出来。
|
||||
// 若这里退回写死的常量,首页就会在开关关着时照样显示数字,用户翻开关毫无反应。
|
||||
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
|
||||
fun `历史数据长度与范围一致`() {
|
||||
assertEquals(7, provider.metricHistory(MetricType.STEP, DateRange.WEEK, defaultEnabled).size)
|
||||
assertEquals(30, provider.metricHistory(MetricType.STEP, DateRange.MONTH, defaultEnabled).size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `历史数据可重复生成且值合理`() {
|
||||
val first = provider.metricHistory(MetricType.HEART_RATE, DateRange.WEEK, defaultEnabled)
|
||||
val second = provider.metricHistory(MetricType.HEART_RATE, DateRange.WEEK, defaultEnabled)
|
||||
assertEquals(first, second)
|
||||
assertTrue(first.all { it.value > 40.0 && it.value < 180.0 })
|
||||
// 时间戳须严格递增(“每点间隔一天”倒推,最早的在最前)
|
||||
assertTrue(first.zipWithNext().all { (a, b) -> b.timestamp > a.timestamp })
|
||||
// 断言绝对时间戳,让注入的时钟真正成为被测对象:若退回 System.currentTimeMillis(),
|
||||
// 这两条会立刻失败(ledger R17 / R20)。只比较 first == second 是不够的 ——
|
||||
// 两次相邻调用几乎总落在同一毫秒,那时去掉时钟注入测试照样通过。
|
||||
assertEquals(FIXED_NOW, first.last().timestamp)
|
||||
assertEquals(FIXED_NOW - 6L * 24 * 60 * 60 * 1000, first.first().timestamp)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `历史数据的最后一个点与今日概览一致`() {
|
||||
// 详情页的头图显示概览的当前值、趋势图的最后一根柱子显示今天的值。两者若各生成各的,
|
||||
// 页面会出现「头图写心率 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
|
||||
fun `设备状态与开关数据合法`() {
|
||||
val device = provider.deviceState()
|
||||
assertTrue(device.batteryPercent in 0..100)
|
||||
assertEquals(true, device.connected)
|
||||
assertEquals(5, provider.defaultFunctionSettings().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `运动记录非空且字段合理`() {
|
||||
val records = provider.workouts()
|
||||
assertTrue(records.isNotEmpty())
|
||||
assertTrue(records.all { it.durationMinutes > 0 && it.calories > 0 })
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
+155
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.placeholder.ringhealth.feature.home
|
||||
|
||||
import com.placeholder.ringhealth.data.mock.mockDeviceRepository
|
||||
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.Before
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* [HomeViewModel] 的单元测试:验证加载完成后输出的今日概览与设备状态。
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class HomeViewModelTest {
|
||||
|
||||
private val dispatcher = StandardTestDispatcher()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(dispatcher)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `加载完成后输出今日概览与设备状态`() = runTest(dispatcher) {
|
||||
// 两个仓库必须共用同一个开关实例,理由见 TestRepositories.kt 文件头。
|
||||
val device = mockDeviceRepository()
|
||||
val viewModel = HomeViewModel(mockHealthRepository(device), device)
|
||||
|
||||
// uiState 用 SharingStarted.WhileSubscribed 构建,必须有订阅者才会启动上游数据流;
|
||||
// 否则 .value 永远是初始值(loading = true)。用 backgroundScope 起一个收集者。
|
||||
backgroundScope.launch { viewModel.uiState.collect {} }
|
||||
|
||||
advanceUntilIdle()
|
||||
|
||||
val state = viewModel.uiState.value
|
||||
assertFalse(state.loading)
|
||||
assertEquals(8426, state.summary?.steps)
|
||||
assertEquals(78, state.device?.batteryPercent)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)。
|
||||
Reference in New Issue
Block a user