diff --git a/app/src/main/java/com/placeholder/ringhealth/AppContainer.kt b/app/src/main/java/com/placeholder/ringhealth/AppContainer.kt index 5015137..3f9d9d3 100644 --- a/app/src/main/java/com/placeholder/ringhealth/AppContainer.kt +++ b/app/src/main/java/com/placeholder/ringhealth/AppContainer.kt @@ -13,8 +13,18 @@ interface AppContainer { val ringDeviceRepository: RingDeviceRepository } -/** 默认容器:阶段一全部使用 Mock 数据。 */ +/** + * 默认容器:阶段一全部使用 Mock 数据。 + * + * 注意 [healthDataRepository] **依赖** [ringDeviceRepository] 的开关流,不是两个平行字段 —— + * 之前正是各建各的,导致功能开关与健康数据互不相干(详见 [MockHealthDataRepository])。 + * 声明里保留具体类型,是为了把开关流传下去而不必向下转型。 + */ class DefaultAppContainer : AppContainer { - override val healthDataRepository: HealthDataRepository = MockHealthDataRepository() - override val ringDeviceRepository: RingDeviceRepository = MockRingDeviceRepository() + private val mockRingDeviceRepository = MockRingDeviceRepository() + + override val ringDeviceRepository: RingDeviceRepository = mockRingDeviceRepository + + override val healthDataRepository: HealthDataRepository = + MockHealthDataRepository(functionSettings = mockRingDeviceRepository.functionSettings) } diff --git a/app/src/main/java/com/placeholder/ringhealth/core/common/Formatters.kt b/app/src/main/java/com/placeholder/ringhealth/core/common/Formatters.kt index d2ec998..45daa12 100644 --- a/app/src/main/java/com/placeholder/ringhealth/core/common/Formatters.kt +++ b/app/src/main/java/com/placeholder/ringhealth/core/common/Formatters.kt @@ -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 @@ -45,14 +48,119 @@ fun formatClock(timestamp: Long): String = fun formatDate(timestamp: Long): String = SimpleDateFormat("yyyy-MM-dd", Locale.CHINA).format(Date(timestamp)) -/** 数值 → 保留一位小数。详情页的统计摘要用(ledger Ruling R29)。 */ -fun formatDecimal(value: Double): String = String.format(Locale.CHINA, "%.1f", value) +/** 时间戳 → 「9月10日」形式(首页顶部日期行左半部分;星期由 [weekdayResFor] 单独给)。 */ +fun formatMonthDay(timestamp: Long): String = + SimpleDateFormat("M月d日", Locale.CHINA).format(Date(timestamp)) -/** 压力值 → 字符串资源 id。中文描述的唯一来源是 `strings.xml`(ledger Ruling R13)。 */ +/** + * 时间戳所在星期 → 字符串资源 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())) diff --git a/app/src/main/java/com/placeholder/ringhealth/core/common/SleepTime.kt b/app/src/main/java/com/placeholder/ringhealth/core/common/SleepTime.kt new file mode 100644 index 0000000..5aefe18 --- /dev/null +++ b/app/src/main/java/com/placeholder/ringhealth/core/common/SleepTime.kt @@ -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) diff --git a/app/src/main/java/com/placeholder/ringhealth/core/designsystem/Color.kt b/app/src/main/java/com/placeholder/ringhealth/core/designsystem/Color.kt index 40d4046..663a782 100644 --- a/app/src/main/java/com/placeholder/ringhealth/core/designsystem/Color.kt +++ b/app/src/main/java/com/placeholder/ringhealth/core/designsystem/Color.kt @@ -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) // 心率 diff --git a/app/src/main/java/com/placeholder/ringhealth/core/designsystem/MetricVisuals.kt b/app/src/main/java/com/placeholder/ringhealth/core/designsystem/MetricVisuals.kt new file mode 100644 index 0000000..a2ac7c9 --- /dev/null +++ b/app/src/main/java/com/placeholder/ringhealth/core/designsystem/MetricVisuals.kt @@ -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 +} diff --git a/app/src/main/java/com/placeholder/ringhealth/core/designsystem/Theme.kt b/app/src/main/java/com/placeholder/ringhealth/core/designsystem/Theme.kt index 3015a35..e9fe19b 100644 --- a/app/src/main/java/com/placeholder/ringhealth/core/designsystem/Theme.kt +++ b/app/src/main/java/com/placeholder/ringhealth/core/designsystem/Theme.kt @@ -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, ) /** diff --git a/app/src/main/java/com/placeholder/ringhealth/core/designsystem/Type.kt b/app/src/main/java/com/placeholder/ringhealth/core/designsystem/Type.kt index b0ac34e..08d8de8 100644 --- a/app/src/main/java/com/placeholder/ringhealth/core/designsystem/Type.kt +++ b/app/src/main/java/com/placeholder/ringhealth/core/designsystem/Type.kt @@ -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, ) diff --git a/app/src/main/java/com/placeholder/ringhealth/core/designsystem/component/ChartAxisLabels.kt b/app/src/main/java/com/placeholder/ringhealth/core/designsystem/component/ChartAxisLabels.kt new file mode 100644 index 0000000..124c46a --- /dev/null +++ b/app/src/main/java/com/placeholder/ringhealth/core/designsystem/component/ChartAxisLabels.kt @@ -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, + 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, + ) +} diff --git a/app/src/main/java/com/placeholder/ringhealth/core/designsystem/component/HealthCard.kt b/app/src/main/java/com/placeholder/ringhealth/core/designsystem/component/HealthCard.kt index d282b93..75d6a7d 100644 --- a/app/src/main/java/com/placeholder/ringhealth/core/designsystem/component/HealthCard.kt +++ b/app/src/main/java/com/placeholder/ringhealth/core/designsystem/component/HealthCard.kt @@ -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 diff --git a/app/src/main/java/com/placeholder/ringhealth/core/designsystem/component/IconBadge.kt b/app/src/main/java/com/placeholder/ringhealth/core/designsystem/component/IconBadge.kt new file mode 100644 index 0000000..b1c0689 --- /dev/null +++ b/app/src/main/java/com/placeholder/ringhealth/core/designsystem/component/IconBadge.kt @@ -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), + ) + } +} diff --git a/app/src/main/java/com/placeholder/ringhealth/core/designsystem/component/MetricBarChart.kt b/app/src/main/java/com/placeholder/ringhealth/core/designsystem/component/MetricBarChart.kt index 96e7dc1..6c2b6ca 100644 --- a/app/src/main/java/com/placeholder/ringhealth/core/designsystem/component/MetricBarChart.kt +++ b/app/src/main/java/com/placeholder/ringhealth/core/designsystem/component/MetricBarChart.kt @@ -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, color: Color, modifier: Modifier = Modifier, - barHeight: Dp = 120.dp, + barHeight: Dp = 170.dp, highlightLast: Boolean = true, + referenceLine: Float? = null, + axisLabels: List = emptyList(), + barColorOf: ((Float) -> Color?)? = null, + valueRange: ClosedFloatingPointRange? = null, + bandRange: ClosedFloatingPointRange? = null, ) { - Canvas(modifier = modifier.fillMaxWidth().height(barHeight)) { - if (values.isEmpty()) return@Canvas - val maxValue = values.max().coerceAtLeast(1f) - val gapRatio = 0.35f - val slot = size.width / values.size - val barWidth = slot * (1f - gapRatio) - // 注意:maxValue 已被 coerceAtLeast(1f) 抬高,当所有值都小于 1 时 - // values.indexOf(maxValue) 会返回 -1 导致没有任何柱子被高亮, - // 因此这里改为直接取最大值的下标。 - val highlightIndex = if (highlightLast) values.lastIndex else values.indices.maxByOrNull { values[it] } ?: 0 + Column(modifier = modifier.fillMaxWidth()) { + Canvas(modifier = Modifier.fillMaxWidth().height(barHeight)) { + if (values.isEmpty()) return@Canvas + // 纵轴:默认从 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 - values.forEachIndexed { index, value -> - val ratio = (value / maxValue).coerceIn(0f, 1f) - val h = size.height * ratio - val isHighlight = index == highlightIndex - drawRoundRect( - color = if (isHighlight) color else color.copy(alpha = 0.3f), - topLeft = Offset(x = index * slot + (slot - barWidth) / 2f, y = size.height - h), - size = Size(width = barWidth, height = h), - cornerRadius = CornerRadius(barWidth / 2f, barWidth / 2f), + val gapRatio = 0.35f + val slot = size.width / values.size + val barWidth = slot * (1f - gapRatio) + // 注意:不能拿 `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 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 = 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) diff --git a/app/src/main/java/com/placeholder/ringhealth/core/designsystem/component/MetricDeviationBar.kt b/app/src/main/java/com/placeholder/ringhealth/core/designsystem/component/MetricDeviationBar.kt new file mode 100644 index 0000000..22e25bd --- /dev/null +++ b/app/src/main/java/com/placeholder/ringhealth/core/designsystem/component/MetricDeviationBar.kt @@ -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, + baseline: Double, + span: Double, + aboveColor: Color, + belowColor: Color, + modifier: Modifier = Modifier, + axisLabels: List = 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) + } +} diff --git a/app/src/main/java/com/placeholder/ringhealth/core/designsystem/component/MetricRangeBar.kt b/app/src/main/java/com/placeholder/ringhealth/core/designsystem/component/MetricRangeBar.kt new file mode 100644 index 0000000..bb8f823 --- /dev/null +++ b/app/src/main/java/com/placeholder/ringhealth/core/designsystem/component/MetricRangeBar.kt @@ -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, + normal: ClosedFloatingPointRange, + 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) + } +} diff --git a/app/src/main/java/com/placeholder/ringhealth/core/designsystem/component/MetricThresholdStrip.kt b/app/src/main/java/com/placeholder/ringhealth/core/designsystem/component/MetricThresholdStrip.kt new file mode 100644 index 0000000..6591678 --- /dev/null +++ b/app/src/main/java/com/placeholder/ringhealth/core/designsystem/component/MetricThresholdStrip.kt @@ -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, + axis: ClosedFloatingPointRange, + threshold: Double, + color: Color, + warningColor: Color, + modifier: Modifier = Modifier, + axisLabels: List = 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) + } +} diff --git a/app/src/main/java/com/placeholder/ringhealth/core/designsystem/component/MetricTile.kt b/app/src/main/java/com/placeholder/ringhealth/core/designsystem/component/MetricTile.kt index 0f559db..d1b330d 100644 --- a/app/src/main/java/com/placeholder/ringhealth/core/designsystem/component/MetricTile.kt +++ b/app/src/main/java/com/placeholder/ringhealth/core/designsystem/component/MetricTile.kt @@ -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,17 +20,23 @@ 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 enabled 为 false 时数值降级为普通灰字(用于「未开启」这类占位), + * 避免「未开启」三个字用大号粗体排出来,看着比真实数值还重。 * @param onClick 点击回调;为 null 表示卡片不可点击 —— 不挂 clickable,因此既没有水波纹 * 也不会触发导航,用于「未开启」这类没有数值的指标卡(有值的卡才可跳转详情)。 */ @@ -41,23 +48,37 @@ fun MetricTile( value: String, unit: String, modifier: Modifier = Modifier, + 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) { - Text(text = value, style = MetricTileValueStyle) + if (enabled) { + Text(text = value, style = MetricTileValueStyle) + } else { + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + color = TextSecondary, + ) + } Text( text = unit, style = MaterialTheme.typography.bodySmall, diff --git a/app/src/main/java/com/placeholder/ringhealth/core/designsystem/component/SleepWindowChart.kt b/app/src/main/java/com/placeholder/ringhealth/core/designsystem/component/SleepWindowChart.kt new file mode 100644 index 0000000..e39d0df --- /dev/null +++ b/app/src/main/java/com/placeholder/ringhealth/core/designsystem/component/SleepWindowChart.kt @@ -0,0 +1,203 @@ +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.TextSecondary +import com.placeholder.ringhealth.domain.model.SleepSession + +/** 每行日期标签的宽度,够放下「周三」两个字。 */ +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 + +/** + * 睡眠时段图:**每晚一行**,一根圆角条从入睡时刻横到醒来时刻,横轴是钟点。 + * + * 它回答的是柱状图回答不了的那个问题:「我这一周都是几点睡的」。 + * 柱状图只画「睡了多久」,七根一样高的柱子看不出作息是不是在往后拖; + * 时段条的长短同样是时长,但**左端的位置**是入睡时刻,连起来看就是一条作息曲线。 + * + * 横轴以晚上 20:00 为锚点跨过零点:一晚从 23:43 到 07:37,用「当天第几分钟」表示会得到 + * 1423 → 457 这样一对反过来的数,画出来入睡点跑到起床点右边。换算见 `minutesSinceSleepAnchor`。 + * + * 只用一个颜色,**不画睡眠分期**:手头只有「深睡共 96 分钟」这样的累计时长, + * 没有「第 47 分钟进入深睡」这种分期序列。按累计时长的比例铺成色块, + * 画的是一段并不存在的分期位置 —— 那比不画更容易让人当真。 + * + * @param sessions 睡眠时段,按时间正序。 + * @param color 时段条颜色。 + * @param rowLabel 每行左侧的标签(如「周三」)。由调用方从 [SleepSession.wakeDate] 取, + * 组件本身不做资源查找;标成 `@Composable` 是因为调用方多半要 `stringResource`。 + * @param modifier 外部修饰符。 + * @param rowHeight 时段条的高度。 + * @param rowGap 行距。 + */ +@Composable +fun SleepWindowChart( + sessions: List, + color: Color, + rowLabel: @Composable (SleepSession) -> String, + 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 + drawRoundRect( + color = color, + topLeft = Offset(x = start, y = top), + size = Size(width = end - start, height = rowHeightPx), + cornerRadius = CornerRadius(rowHeightPx / 2f, rowHeightPx / 2f), + ) + } + + // 刻度文字画在画布内,而不是另起一行等宽 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): 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) + } +} diff --git a/app/src/main/java/com/placeholder/ringhealth/core/designsystem/component/SubPageTopAppBar.kt b/app/src/main/java/com/placeholder/ringhealth/core/designsystem/component/SubPageTopAppBar.kt new file mode 100644 index 0000000..3a7182e --- /dev/null +++ b/app/src/main/java/com/placeholder/ringhealth/core/designsystem/component/SubPageTopAppBar.kt @@ -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, + ), + ) +} diff --git a/app/src/main/java/com/placeholder/ringhealth/core/navigation/RingHealthApp.kt b/app/src/main/java/com/placeholder/ringhealth/core/navigation/RingHealthApp.kt index 32ee89c..e0238ff 100644 --- a/app/src/main/java/com/placeholder/ringhealth/core/navigation/RingHealthApp.kt +++ b/app/src/main/java/com/placeholder/ringhealth/core/navigation/RingHealthApp.kt @@ -11,6 +11,7 @@ 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 @@ -27,6 +28,9 @@ 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 @@ -83,6 +87,16 @@ fun RingHealthApp(navController: NavHostController = rememberNavController()) { 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, + ), ) } } @@ -110,11 +124,13 @@ fun RingHealthApp(navController: NavHostController = rememberNavController()) { composable(Routes.PROFILE) { ProfileScreen() } + // 两个子页都带自己的 TopAppBar 与返回箭头(原先没有,与详情页不一致 —— + // 详情页有 ←,这两页只能靠系统返回手势或点底部 Tab 离开)。 composable(Routes.DEVICE_SETTINGS) { - DeviceSettingsScreen() + DeviceSettingsScreen(onBack = { navController.popBackStack() }) } composable(Routes.WORKOUT_HISTORY) { - WorkoutHistoryScreen() + WorkoutHistoryScreen(onBack = { navController.popBackStack() }) } composable( route = Routes.DETAIL_PATTERN, diff --git a/app/src/main/java/com/placeholder/ringhealth/data/mock/MockDataProvider.kt b/app/src/main/java/com/placeholder/ringhealth/data/mock/MockDataProvider.kt index 911d5da..86ac1a6 100644 --- a/app/src/main/java/com/placeholder/ringhealth/data/mock/MockDataProvider.kt +++ b/app/src/main/java/com/placeholder/ringhealth/data/mock/MockDataProvider.kt @@ -6,7 +6,11 @@ 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 com.placeholder.ringhealth.domain.model.valueOf +import java.util.Calendar +import kotlin.math.roundToInt import kotlin.random.Random /** @@ -22,31 +26,64 @@ class MockDataProvider( private val now: () -> Long = System::currentTimeMillis, ) { - /** 今日概览:固定演示值。血压为 null 以演示“未开启”占位态。 */ - fun todaySummary(): HealthSummary = HealthSummary( - steps = 8426, - stepGoal = 10000, - calories = 327, - distanceKm = 6.21, - heartRate = 72, - sleepMinutes = 452, - bloodOxygen = 98, - stress = 34, - temperature = 36.5, - bloodPressure = null, - ) + /** + * 今日概览:固定演示值,但**未开启的指标一律为 null**。 + * + * @param enabledMetrics 当前开启的指标集合,来自功能开关(见 [DeviceFunctionSetting])。 + * 关掉的指标在这里被置空,首页那张卡才会显示「未开启」—— + * 否则用户在功能设置页翻了开关,首页纹丝不动,开关就是个摆设。 + * **刻意不给默认值**:默认「全都开着」会让漏传参数的调用方看起来一切正常, + * 而那正是这次要修的病。 + * + * 计步与体温没有开关([MetricType.switchId] 为 null),恒定给值。 + */ + fun todaySummary(enabledMetrics: Set): 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, + ) /** * 指定指标的历史数据:WEEK 返回 7 个点,MONTH 返回 30 个点。 * 时间戳按“每点间隔一天”倒推,值在指标合理区间内波动。 + * + * **最后一个点(时间戳 = 现在,即“今天”)的值取自 [todaySummary]**,不由随机序列生成。 + * 详情页的头图显示概览里的当前值、趋势图的最后一根柱子显示今天的值,两者本该相同; + * 各生成各的会让页面出现「头图写心率 72、图上今天却是 96」这种矛盾, + * 而且两个数字看起来都很确定,用户无从判断该信哪个。 + * + * **睡眠是例外:它的点由 [sleepSessions] 折算而来**,不走下面那套随机序列。 + * 同一个指标有两套数据源,就会出现「头图写 7 小时 32 分、时段图上却是一根 6 小时的条」—— + * 而这正是睡眠页最显眼的两块内容。 */ - fun metricHistory(metric: MetricType, range: DateRange): List { + fun metricHistory( + metric: MetricType, + range: DateRange, + enabledMetrics: Set, + ): List { + 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 - return (0 until count).map { index -> + 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( @@ -54,8 +91,87 @@ class MockDataProvider( 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 { + 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]。 + */ + 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, + ) + } + + /** 随机构造一晚:睡着 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 { val random = Random(seed + 7) @@ -83,13 +199,40 @@ class MockDataProvider( charging = false, ) - /** 五个功能开关,对应协议中的自动监测能力。 */ + /** + * 五个功能开关,对应协议中的自动监测能力。 + * + * id 一律取 [MetricType.switchId],不在这里手写字符串 —— 手写就等于把「开关 ↔ 指标」 + * 这个对应关系又抄了一份,抄错一份不会报错,只会让某个开关悄悄失效。 + * + * 默认值:血压关着,用来演示「未开启」占位态;其余开着。 + * 压力此前默认是关的,但 [todaySummary] 里 `stress = 34` 一直有值 —— + * 那正是「开关与数据两套系统」留下的痕迹,现在两者由同一个开关决定,默认值必须自洽。 + */ fun defaultFunctionSettings(): List = listOf( - DeviceFunctionSetting("heart_rate", "自动心率监测", "每 10 分钟测量一次", true), - DeviceFunctionSetting("blood_oxygen", "自动血氧监测", "夜间持续监测", true), - DeviceFunctionSetting("sleep", "睡眠监测", "自动识别睡眠时段", true), - DeviceFunctionSetting("stress", "压力监测", "全天候压力评估", false), - DeviceFunctionSetting("blood_pressure", "血压监测", "需在 App 内手动校准", false), + 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, ) /** 各指标的取值区间,用于生成合理的历史数据。 */ @@ -106,5 +249,29 @@ class MockDataProvider( companion object { /** 默认随机种子,保证演示数据稳定。 */ const val DEFAULT_SEED: Long = 20260910L + + /** 血压开着时的演示读数(概览里是「收缩压/舒张压」形式)。 */ + const val DEMO_BLOOD_PRESSURE: String = "118/76" + + /** 睡眠时段的随机种子偏移,避开 [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 } } diff --git a/app/src/main/java/com/placeholder/ringhealth/data/mock/MockHealthDataRepository.kt b/app/src/main/java/com/placeholder/ringhealth/data/mock/MockHealthDataRepository.kt index 40191ff..1b03775 100644 --- a/app/src/main/java/com/placeholder/ringhealth/data/mock/MockHealthDataRepository.kt +++ b/app/src/main/java/com/placeholder/ringhealth/data/mock/MockHealthDataRepository.kt @@ -1,30 +1,57 @@ 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>, private val provider: MockDataProvider = MockDataProvider(), ) : HealthDataRepository { + // flow { delay(...) } 先给出加载态,再 emitAll 接上开关流:初值延迟 200ms 到达 + // (便于验证 loading),此后每次翻开关都立刻重发。 override fun observeTodaySummary(): Flow = flow { delay(MOCK_DELAY_MILLIS) - emit(provider.todaySummary()) + emitAll(functionSettings.map { provider.todaySummary(it.enabledMetrics()) }) } override fun observeMetricHistory(metric: MetricType, range: DateRange): Flow> = flow { delay(MOCK_DELAY_MILLIS) - emit(provider.metricHistory(metric, range)) + emitAll(functionSettings.map { provider.metricHistory(metric, range, it.enabledMetrics()) }) + } + + // 与 observeMetricHistory 同一口径:**照常发数据,判开关是页面的事**(见 MetricDetailScreen + // 的 enabled 分支)。不在这里按开关置空,是因为长度/取值是随 range 与种子固定的, + // 在这里分叉会让「关了再打开」拿到一段与之前不同的序列,而 Mock 承诺的是可复现。 + override fun observeSleepSessions(range: DateRange): Flow> = flow { + delay(MOCK_DELAY_MILLIS) + emitAll(functionSettings.map { provider.sleepSessions(range) }) } override fun observeWorkouts(): Flow> = flow { diff --git a/app/src/main/java/com/placeholder/ringhealth/domain/model/HealthModels.kt b/app/src/main/java/com/placeholder/ringhealth/domain/model/HealthModels.kt index e696e27..7ff1125 100644 --- a/app/src/main/java/com/placeholder/ringhealth/domain/model/HealthModels.kt +++ b/app/src/main/java/com/placeholder/ringhealth/domain/model/HealthModels.kt @@ -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"), } /** 历史数据的时间范围。 */ @@ -41,9 +53,75 @@ data class HealthSummary( val bloodPressure: String?, ) +/** + * 取某个指标在今日概览里的值;该指标未开启或暂无数据时返回 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) +/** + * 一晚睡眠。 + * + * 睡眠与其他指标不一样:它在一天里占的是**一段区间**,不是一个数。 + * [MetricPoint] 只装得下一个 `Double`,于是「睡了 7 小时 32 分」之外什么都说不了 —— + * 而「从几点睡到几点」恰恰是作息规律的关键,也是用户真正会问的问题 + * (「我不知道一天内具体什么时候睡的觉」)。所以睡眠单独有这个类型。 + * + * @param wakeDate 醒来的时刻;这条记录算在哪一天,以它为准。 + * @param sleepStart 入睡时刻。 + * @param sleepEnd 醒来时刻。 + * @param deepMinutes 深睡时长。 + * @param lightMinutes 浅睡时长。 + * @param awakeMinutes 夜里清醒的累计时长(中途醒来)。 + */ +data class SleepSession( + val wakeDate: Long, + val sleepStart: Long, + val sleepEnd: Long, + val deepMinutes: Int, + val lightMinutes: Int, + val awakeMinutes: Int, +) { + /** + * 实际睡着的时长 = 深睡 + 浅睡,**不含夜里清醒**。 + * 首页那张卡和详情页头图显示的都是这个数。 + */ + 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 +139,29 @@ data class DeviceFunctionSetting( val subtitle: String, val enabled: Boolean, ) + +/** + * 从开关列表算出「当前开着哪些指标」。 + * + * 常开指标([MetricType.switchId] 为 null)永远算作开启 —— 它们不在开关列表里, + * 若按「找不到对应开关就算关」处理,计步和体温会凭空变成「未开启」。 + * + * 开关 id 找不到对应指标时被忽略:那说明协议里有我们还没接入的功能, + * 不该让整个首页崩掉。 + */ +fun List.enabledMetrics(): Set = + 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 diff --git a/app/src/main/java/com/placeholder/ringhealth/domain/model/MetricReference.kt b/app/src/main/java/com/placeholder/ringhealth/domain/model/MetricReference.kt new file mode 100644 index 0000000..bf0ab8b --- /dev/null +++ b/app/src/main/java/com/placeholder/ringhealth/domain/model/MetricReference.kt @@ -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 +} diff --git a/app/src/main/java/com/placeholder/ringhealth/domain/repository/Repositories.kt b/app/src/main/java/com/placeholder/ringhealth/domain/repository/Repositories.kt index ec87243..14937c6 100644 --- a/app/src/main/java/com/placeholder/ringhealth/domain/repository/Repositories.kt +++ b/app/src/main/java/com/placeholder/ringhealth/domain/repository/Repositories.kt @@ -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> + /** + * 睡眠时段:每晚的入睡/醒来时刻与各期时长,按时间正序。 + * + * 睡眠无法复用 [observeMetricHistory] —— [MetricPoint] 只装得下一个 `Double`, + * 而「几点睡到几点」是这一段区间的全部意义(见 [SleepSession])。 + * + * @param range 与 [observeMetricHistory] 同一套口径:WEEK 七晚,MONTH 三十晚。 + */ + fun observeSleepSessions(range: DateRange): Flow> + /** 运动记录列表。 */ fun observeWorkouts(): Flow> } diff --git a/app/src/main/java/com/placeholder/ringhealth/feature/detail/MetricDetailScreen.kt b/app/src/main/java/com/placeholder/ringhealth/feature/detail/MetricDetailScreen.kt index 0a98df2..258e0ca 100644 --- a/app/src/main/java/com/placeholder/ringhealth/feature/detail/MetricDetailScreen.kt +++ b/app/src/main/java/com/placeholder/ringhealth/feature/detail/MetricDetailScreen.kt @@ -1,6 +1,5 @@ package com.placeholder.ringhealth.feature.detail -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues @@ -8,51 +7,39 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.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.common.formatClock -import com.placeholder.ringhealth.core.common.formatDecimal -import com.placeholder.ringhealth.core.designsystem.MetricBlue -import com.placeholder.ringhealth.core.designsystem.MetricCyan import com.placeholder.ringhealth.core.designsystem.MetricGreen -import com.placeholder.ringhealth.core.designsystem.MetricOrange -import com.placeholder.ringhealth.core.designsystem.MetricPurple -import com.placeholder.ringhealth.core.designsystem.MetricRed -import com.placeholder.ringhealth.core.designsystem.MetricYellow import com.placeholder.ringhealth.core.designsystem.RingHealthTheme import com.placeholder.ringhealth.core.designsystem.TextSecondary -import com.placeholder.ringhealth.core.designsystem.component.HealthCard -import com.placeholder.ringhealth.core.designsystem.component.MetricBarChart -import com.placeholder.ringhealth.core.designsystem.component.SectionHeader -import com.placeholder.ringhealth.core.designsystem.component.StatBar -import com.placeholder.ringhealth.core.designsystem.component.StatSegment +import com.placeholder.ringhealth.core.designsystem.component.SubPageTopAppBar +import com.placeholder.ringhealth.data.mock.MockDataProvider import com.placeholder.ringhealth.domain.model.DateRange -import com.placeholder.ringhealth.domain.model.MetricPoint +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 /** * 指标详情页(有状态外壳)。 @@ -78,9 +65,15 @@ fun MetricDetailScreen( } /** - * 无状态指标详情内容:范围切换 + 柱状趋势图 + 统计摘要(+ 睡眠分期)。 + * 指标详情内容:**一具骨架**。 * - * 范围切换常驻;当前范围没有数据点时,只把「趋势图 + 统计摘要」换成一行空态文案。 + * 这里只剩三件事:页头(返回 + 标题)、范围切换、以及把内容分派给对应指标。 + * **七个指标的页面内容一行都不在本文件里** —— 它们各自待在 `feature/detail/pages/` 下自己的 + * 文件里(`StepDetail.kt` / `HeartRateDetail.kt` / …)。 + * + * 之所以这么拆:改造前七个指标共用「范围切换 + 一张柱状图 + 平均/最高/最低」这同一套模板, + * 页面之间只有柱子颜色不同 —— 打开哪个都像同一个页面。视觉上的雷同,根子是代码上的共用。 + * 拆开之后,每个指标「要突出什么」写在它自己的文件里,改一页不必碰其他六页。 * * 标题取自 `state.metric` 而不是外面传进来的 `metric` —— 无状态层只有 `state` 一个数据来源, * 免得出现「标题写心率、数据画睡眠」这种两者不一致的可能。 @@ -97,16 +90,9 @@ private fun MetricDetailContent( modifier = modifier, containerColor = MaterialTheme.colorScheme.background, topBar = { - TopAppBar( - title = { Text(text = stringResource(metricTitleRes(state.metric))) }, - navigationIcon = { - IconButton(onClick = onBack) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.action_back), - ) - } - }, + SubPageTopAppBar( + title = stringResource(metricTitleRes(state.metric)), + onBack = onBack, ) }, ) { innerPadding -> @@ -127,12 +113,24 @@ private fun MetricDetailContent( ) { // 范围切换常驻,空数据时反而更要留着:切到另一个范围是用户看到「暂无数据」后的 // 第一反应,chips 也是判断「哪个范围是空的」的唯一线索。它属于页头,不属于数据区。 - item { RangeSelector(current = state.range, onSelect = onSelectRange) } + // + // 但「未开启」时不给:那一页没有任何数据,切换周/月不会有东西跟着变, + // 留两个能点但点了没反应的 chip 比不显示更让人困惑。 + if (state.enabled) { + item { RangeSelector(current = state.range, onSelect = onSelectRange) } + } - // 只有图与统计被替换为空态。判空必须在 loading 之后:Mock 有约 200ms 延迟, + // 未开启的指标整页走说明卡,不画图。 + // 判在 points 之前:Mock 对未开启的指标照常生成随机序列(见 MockDataProvider), + // 那些点画的是一段并不存在的监测数据,比空图更有误导性。 + if (!state.enabled) { + item { MetricDisabledCard(metric = state.metric) } + return@LazyColumn + } + + // 只有内容区被替换为空态。判空必须在 loading 之后:Mock 有约 200ms 延迟, // 加载期间 points 也是空的,先判空会在数据到达前闪一下空态(loading 分支已提前 return)。 if (state.points.isEmpty()) { - // 不再渲染「全 0 高度的柱状图 + 三行 0.0」,那看着像页面坏了而不是「没有数据」。 item { Box( modifier = Modifier.fillMaxWidth().padding(vertical = 48.dp), @@ -148,132 +146,20 @@ private fun MetricDetailContent( return@LazyColumn } - item { - HealthCard { - SectionHeader(title = stringResource(R.string.detail_trend)) - MetricBarChart( - values = state.points.map { it.value.toFloat() }, - color = metricTint(state.metric), - ) - } - } - item { - HealthCard { - val values = state.points.map { it.value } - StatLine(label = stringResource(R.string.detail_average), value = values.averageOrZero()) - StatLine(label = stringResource(R.string.detail_max), value = values.maxOrNull() ?: 0.0) - StatLine(label = stringResource(R.string.detail_min), value = values.minOrNull() ?: 0.0) - } - } - if (state.metric == MetricType.SLEEP) { - item { SleepStagesCard(points = state.points) } + // 每个指标页自己决定往列表里放几张卡、放什么 —— 见各页文件的 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) } } } } -/** - * 睡眠分期卡片:深睡/浅睡/清醒分段条 + 入睡/起床时间(对应 spec §6 的 `detail/sleep`)。 - */ -@Composable -private fun SleepStagesCard(points: List, modifier: Modifier = Modifier) { - // 阶段一没有真实分期数据,按固定比例演示;M3 接入真实睡眠数据后改为按实际时段计算。 - val segments = listOf( - StatSegment(weight = 0.25f, color = MetricBlue), - StatSegment(weight = 0.65f, color = MetricBlue.copy(alpha = 0.45f)), - StatSegment(weight = 0.10f, color = MetricBlue.copy(alpha = 0.2f)), - ) - HealthCard(modifier = modifier) { - SectionHeader(title = stringResource(R.string.detail_sleep_stages)) - StatBar(segments = segments) - Row( - modifier = Modifier.fillMaxWidth().padding(top = 12.dp), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - StageLegend(label = stringResource(R.string.detail_sleep_deep), tint = MetricBlue) - StageLegend(label = stringResource(R.string.detail_sleep_light), tint = MetricBlue.copy(alpha = 0.45f)) - StageLegend(label = stringResource(R.string.detail_sleep_awake), tint = MetricBlue.copy(alpha = 0.2f)) - } - // 入睡/起床演示值(ledger Ruling R29):Mock 的睡眠历史点是「每天一个」,首点是 7 天前、 - // 末点是现在。直接拿 first/last 当入睡/起床会显示成「睡了 7 天」,在验收时看着像 bug。 - // 这里改成以最后一个数据点为起床时刻、往前推 8 小时作为入睡时刻 —— 一张卡片讲「一晚」。 - // M3 接入真实睡眠数据后改为读取实际分期时段。 - val wakeUp = points.lastOrNull()?.timestamp - if (wakeUp != null) { - // 只有拿到起床时刻才渲染这一行;入睡时刻由它推出,所以判空只需判 wakeUp - // (brief 原文写的是 `fallAsleep != null && wakeUp != null`,Kotlin 会因其恒真而告警, - // 语义与此处完全等价)。 - val fallAsleep = wakeUp - DEMO_SLEEP_WINDOW_MILLIS - Row( - modifier = Modifier.fillMaxWidth().padding(top = 12.dp), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text( - text = "${stringResource(R.string.detail_sleep_fall_asleep)} ${formatClock(fallAsleep)}", - style = MaterialTheme.typography.bodySmall, - color = TextSecondary, - ) - Text( - text = "${stringResource(R.string.detail_sleep_wake_up)} ${formatClock(wakeUp)}", - style = MaterialTheme.typography.bodySmall, - color = TextSecondary, - ) - } - } - } -} - -/** 阶段一演示用的「一晚」时长:8 小时。M3 接入真实睡眠数据后删除。 */ -private const val DEMO_SLEEP_WINDOW_MILLIS = 8L * 60 * 60 * 1000 - -/** 分期图例:小色块 + 名称。 */ -@Composable -private fun StageLegend(label: String, tint: Color, modifier: Modifier = Modifier) { - Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { - Box( - modifier = Modifier - .size(10.dp) - .clip(RoundedCornerShape(5.dp)) - .background(tint), - ) - Text( - text = label, - style = MaterialTheme.typography.bodySmall, - color = TextSecondary, - modifier = Modifier.padding(start = 6.dp), - ) - } -} - -/** 指标类型 → 标题字符串资源。文案的唯一来源是 `strings.xml`(ledger Ruling R3)。 */ -@Composable -private fun metricTitleRes(type: MetricType): Int = when (type) { - MetricType.STEP -> R.string.metric_step - MetricType.HEART_RATE -> R.string.metric_heart_rate - MetricType.SLEEP -> R.string.metric_sleep - MetricType.BLOOD_OXYGEN -> R.string.metric_blood_oxygen - MetricType.STRESS -> R.string.metric_stress - MetricType.TEMPERATURE -> R.string.metric_temperature - MetricType.BLOOD_PRESSURE -> R.string.metric_blood_pressure -} - -/** - * 指标类型 → 语义色。 - * - * 与 `HomeScreen` 里各指标卡的 tint 一一对应(步数=绿、心率=红、睡眠=蓝、血氧=青、 - * 压力=紫、体温=橙、血压=黄)。色板的唯一来源是设计文档 §指标色板与 - * `core/designsystem/Color.kt`,本函数只做映射,不在页面里另造颜色。 - */ -private 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 -} - /** 周/月切换。 */ @Composable private fun RangeSelector( @@ -298,53 +184,90 @@ private fun RangeSelector( } } -/** 一行统计摘要。 */ -@Composable -private fun StatLine(label: String, value: Double, modifier: Modifier = Modifier) { - Row( - modifier = modifier.fillMaxWidth().padding(vertical = 4.dp), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text(text = label, style = MaterialTheme.typography.bodyMedium, color = TextSecondary) - // 数值格式化一律走 core/common/Formatters.kt(Step 1c),页面里不写 String.format: - // 那是格式串副本,改口径时要满仓库找。 - Text(text = formatDecimal(value), style = MaterialTheme.typography.bodyMedium) - } -} - -/** 空列表时返回 0.0,避免 NaN。 */ -private fun List.averageOrZero(): Double = if (isEmpty()) 0.0 else average() +/** 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 用的样例数据:7 个点、每天一个,取值从 `base` 起每点递增 `step`。 + * 构造一份预览状态;各指标页的 Preview 共用它,避免七个预览各写一遍 HealthSummary。 * - * `base` 与 `step` 由调用方按**所在指标的取值区间**给出:`base` 取区间低位, - * `step` 取区间跨度的约 1/7,使 7 个点铺满区间的大半。这样柱状图才有可见的高低差 - * (`MetricBarChart` 按 `value / maxValue` 定高,点值若都挤在一起,7 根柱子会齐平, - * 预览就看不出渲染问题),统计摘要的「平均/最高/最低」三行也不会几乎相等。 + * @param enabled 该指标是否已开启。默认 true 对应 [PreviewSummary] 里给全了的那些值; + * 血压是未开启的,它的预览显式传 false。 + * @param sleepSessions 只有睡眠预览会传。睡眠时段图要的是「几点到几点」, + * 而入睡时刻是**本地时钟上的位置**,随手编一个时间戳会让整张图平移几个钟头, + * 所以这里直接向 [MockDataProvider] 要一份演示数据,不另写一套日历换算。 */ -private fun previewPoints(base: Double, step: Double): List { - 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) } -} +private fun previewState( + metric: MetricType, + base: Double, + step: Double, + enabled: Boolean = true, + sleepSessions: List = emptyList(), +): MetricDetailUiState = + MetricDetailUiState( + loading = false, + metric = metric, + range = DateRange.WEEK, + points = previewPoints(base = base, step = step), + summary = PreviewSummary, + enabled = enabled, + sleepSessions = sleepSessions, + ) /** - * 睡眠指标的详情页预览:走 `metric == SLEEP` 分支,带分期卡片。 - * 取 SLEEP 不是随便挑的 —— 睡眠是唯一有专属区块的指标,预览它才能看见 `SleepStagesCard`。 + * 七个指标的预览。 + * + * 一个 Preview 一个指标,是因为它们现在真的是七个不同的页面 —— + * 改造前「预览步数页」看不出别的页有没有坏,现在也看不出,但至少每个页面都有一张自己 + * 能对照的图。数值区间按各指标的真实量程给(见 [previewPoints] 的说明)。 */ -@Preview(showBackground = true) +@Preview(showBackground = true, heightDp = 1000) @Composable -private fun MetricDetailContentSleepPreview() { +private fun StepDetailPreview() { RingHealthTheme { MetricDetailContent( - state = MetricDetailUiState( - loading = false, + // 步数: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, - range = DateRange.WEEK, - // 睡眠时长(分钟):330→480,落在 Mock 的 330~510 区间内,跨度 150 分钟, - // 柱高从约 69% 递增到 100%,肉眼能看出梯度。 - points = previewPoints(base = 330.0, step = 25.0), + base = 330.0, + step = 25.0, + sleepSessions = MockDataProvider().sleepSessions(DateRange.WEEK), ), onBack = {}, onSelectRange = {}, @@ -352,26 +275,76 @@ private fun MetricDetailContentSleepPreview() { } } -/** - * 非睡眠指标的详情页预览:走「只有趋势图 + 统计摘要」的分支。 - * 两个 Preview 各覆盖一个分支,是因为只看睡眠那个无法发现「分期卡片漏判 metric、所有指标都显示」这类回归。 - */ -@Preview(showBackground = true) +@Preview(showBackground = true, heightDp = 1000) @Composable -private fun MetricDetailContentStepsPreview() { +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 = MetricDetailUiState( - loading = false, - metric = MetricType.STEP, - range = DateRange.WEEK, - // 步数:3200→11000,落在 Mock 的 3000~12000 区间内,跨度 7800 步, - // 柱高从约 27% 递增到 100%。原来的 7500 起点会让 7 根柱子全挤在 99%~100% 高度上, - // 看起来是一条平带 —— 恰是预览本该暴露的问题。 - points = previewPoints(base = 3200.0, step = 1300.0), + // 血压在演示数据里默认关着,所以这一页预览的是「未开启」说明卡。 + 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 = {}, + ) + } +} diff --git a/app/src/main/java/com/placeholder/ringhealth/feature/detail/MetricDetailShared.kt b/app/src/main/java/com/placeholder/ringhealth/feature/detail/MetricDetailShared.kt new file mode 100644 index 0000000..c43ff68 --- /dev/null +++ b/app/src/main/java/com/placeholder/ringhealth/feature/detail/MetricDetailShared.kt @@ -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): List { + 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 { + 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) } +} diff --git a/app/src/main/java/com/placeholder/ringhealth/feature/detail/MetricDetailViewModel.kt b/app/src/main/java/com/placeholder/ringhealth/feature/detail/MetricDetailViewModel.kt index 082bd8e..b524b80 100644 --- a/app/src/main/java/com/placeholder/ringhealth/feature/detail/MetricDetailViewModel.kt +++ b/app/src/main/java/com/placeholder/ringhealth/feature/detail/MetricDetailViewModel.kt @@ -6,25 +6,45 @@ 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.map +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update -/** 指标详情 UI 状态。 */ +/** + * 指标详情 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, + val summary: HealthSummary?, + val enabled: Boolean, + val sleepSessions: List, ) /** @@ -33,22 +53,32 @@ data class MetricDetailUiState( @OptIn(ExperimentalCoroutinesApi::class) class MetricDetailViewModel( private val metric: MetricType, - healthDataRepository: HealthDataRepository, + private val healthDataRepository: HealthDataRepository, ) : ViewModel() { private val range = MutableStateFlow(DateRange.WEEK) + /** 今日概览只需要订阅一次,与时间范围无关,所以放在 flatMapLatest 外面。 */ + private val summary = healthDataRepository.observeTodaySummary() + val uiState: StateFlow = range .flatMapLatest { currentRange -> - healthDataRepository.observeMetricHistory(metric, currentRange) - .map { points -> - MetricDetailUiState( - loading = false, - metric = metric, - range = currentRange, - points = points, - ) - } + 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, @@ -58,9 +88,26 @@ class MetricDetailViewModel( metric = metric, range = DateRange.WEEK, points = emptyList(), + summary = null, + enabled = true, + sleepSessions = emptyList(), ), ) + /** + * 睡眠时段只在睡眠页取;其余六个指标拿一个恒空的流。 + * + * 不写成「顺手也查一遍、反正用不上」:那会让每个指标页都多订阅一路睡眠数据, + * M2 换成真实实现后就是六次无谓的设备查询,而且多出来的那路数据将来一定会有人用, + * 用着用着就分不清哪个页面依赖什么了。 + */ + private fun sleepSessionsFor(currentRange: DateRange): Flow> = + if (metric == MetricType.SLEEP) { + healthDataRepository.observeSleepSessions(currentRange) + } else { + flowOf(emptyList()) + } + /** 切换时间范围(周/月)。 */ fun switchRange(newRange: DateRange) { range.update { newRange } diff --git a/app/src/main/java/com/placeholder/ringhealth/feature/detail/MetricStats.kt b/app/src/main/java/com/placeholder/ringhealth/feature/detail/MetricStats.kt new file mode 100644 index 0000000..06c3d63 --- /dev/null +++ b/app/src/main/java/com/placeholder/ringhealth/feature/detail/MetricStats.kt @@ -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): 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): SleepStats? { + if (sessions.isEmpty()) return null + fun averageOf(values: List): 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.countWithin(range: ClosedFloatingPointRange): Int = count { it in range } + +/** 低于 [threshold] 的天数。血氧页的「偏低天数」用它。 */ +fun List.countBelow(threshold: Double): Int = count { it < threshold } + +/** 不低于 [threshold] 的天数。步数页的「达标天数」用它。 */ +fun List.countAtLeast(threshold: Double): Int = count { it >= threshold } diff --git a/app/src/main/java/com/placeholder/ringhealth/feature/detail/pages/BloodOxygenDetail.kt b/app/src/main/java/com/placeholder/ringhealth/feature/detail/pages/BloodOxygenDetail.kt new file mode 100644 index 0000000..49e57f0 --- /dev/null +++ b/app/src/main/java/com/placeholder/ringhealth/feature/detail/pages/BloodOxygenDetail.kt @@ -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, + ) + } + } +} diff --git a/app/src/main/java/com/placeholder/ringhealth/feature/detail/pages/BloodPressureDetail.kt b/app/src/main/java/com/placeholder/ringhealth/feature/detail/pages/BloodPressureDetail.kt new file mode 100644 index 0000000..b567b78 --- /dev/null +++ b/app/src/main/java/com/placeholder/ringhealth/feature/detail/pages/BloodPressureDetail.kt @@ -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, + ) + } +} diff --git a/app/src/main/java/com/placeholder/ringhealth/feature/detail/pages/HeartRateDetail.kt b/app/src/main/java/com/placeholder/ringhealth/feature/detail/pages/HeartRateDetail.kt new file mode 100644 index 0000000..bf4efbc --- /dev/null +++ b/app/src/main/java/com/placeholder/ringhealth/feature/detail/pages/HeartRateDetail.kt @@ -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 = "", + ) + } + } +} diff --git a/app/src/main/java/com/placeholder/ringhealth/feature/detail/pages/SleepDetail.kt b/app/src/main/java/com/placeholder/ringhealth/feature/detail/pages/SleepDetail.kt new file mode 100644 index 0000000..48e50a0 --- /dev/null +++ b/app/src/main/java/com/placeholder/ringhealth/feature/detail/pages/SleepDetail.kt @@ -0,0 +1,242 @@ +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.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 + +/** 分期配色:同一色系的三个深浅,深浅即「睡得沉不沉」,不必再引入第二种颜色。 */ +private val DEEP_TINT = MetricBlue +private val LIGHT_TINT = MetricBlue.copy(alpha = 0.45f) +private val AWAKE_TINT = MetricBlue.copy(alpha = 0.2f) + +/** 头图:昨晚时长 + 入睡/起床时刻 + 分期条与分期时长。 */ +@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 { + 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, + 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), + ) + } + } +} diff --git a/app/src/main/java/com/placeholder/ringhealth/feature/detail/pages/StepDetail.kt b/app/src/main/java/com/placeholder/ringhealth/feature/detail/pages/StepDetail.kt new file mode 100644 index 0000000..7574199 --- /dev/null +++ b/app/src/main/java/com/placeholder/ringhealth/feature/detail/pages/StepDetail.kt @@ -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 diff --git a/app/src/main/java/com/placeholder/ringhealth/feature/detail/pages/StressDetail.kt b/app/src/main/java/com/placeholder/ringhealth/feature/detail/pages/StressDetail.kt new file mode 100644 index 0000000..21dc144 --- /dev/null +++ b/app/src/main/java/com/placeholder/ringhealth/feature/detail/pages/StressDetail.kt @@ -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, + ) + } + } +} diff --git a/app/src/main/java/com/placeholder/ringhealth/feature/detail/pages/TemperatureDetail.kt b/app/src/main/java/com/placeholder/ringhealth/feature/detail/pages/TemperatureDetail.kt new file mode 100644 index 0000000..ba3e554 --- /dev/null +++ b/app/src/main/java/com/placeholder/ringhealth/feature/detail/pages/TemperatureDetail.kt @@ -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, + ) + } + } +} diff --git a/app/src/main/java/com/placeholder/ringhealth/feature/device/DeviceSettingsScreen.kt b/app/src/main/java/com/placeholder/ringhealth/feature/device/DeviceSettingsScreen.kt index 72dd16b..167ffd3 100644 --- a/app/src/main/java/com/placeholder/ringhealth/feature/device/DeviceSettingsScreen.kt +++ b/app/src/main/java/com/placeholder/ringhealth/feature/device/DeviceSettingsScreen.kt @@ -9,7 +9,9 @@ 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 @@ -28,7 +30,7 @@ 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 import com.placeholder.ringhealth.domain.model.DeviceFunctionSetting /** @@ -37,6 +39,7 @@ import com.placeholder.ringhealth.domain.model.DeviceFunctionSetting */ @Composable fun DeviceSettingsScreen( + onBack: () -> Unit, modifier: Modifier = Modifier, viewModel: DeviceViewModel = viewModel(factory = AppViewModelProvider.Factory), ) { @@ -44,6 +47,7 @@ fun DeviceSettingsScreen( DeviceSettingsContent( settings = state.settings, onToggle = viewModel::setFunctionEnabled, + onBack = onBack, modifier = modifier, ) } @@ -53,46 +57,60 @@ fun DeviceSettingsScreen( * 与 `DeviceContent` 同样拆出独立函数,Preview 才能喂假数据渲染(ledger Ruling R26)。 * 注意回调签名是 `(id, enabled)` 而不是 `(DeviceFunctionSetting, Boolean)`: * 无状态层不该知道「怎么改」,只负责把「改哪一项、改成什么」报上去。 + * + * 顶栏的标题取代了原先列表里的「功能设置」区块标题 —— 同一个标题在一屏里出现两次是多余的。 */ +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun DeviceSettingsContent( settings: List, onToggle: (String, Boolean) -> Unit, + onBack: () -> Unit, modifier: Modifier = Modifier, ) { - LazyColumn( - modifier = modifier.fillMaxSize(), - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - item { SectionHeader(title = stringResource(R.string.device_settings_title)) } - items(settings) { setting -> - HealthCard { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { - Text(text = setting.title, style = MaterialTheme.typography.titleSmall) - Text(text = setting.subtitle, style = MaterialTheme.typography.bodySmall, color = TextSecondary) + 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), + ) } - 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), - ) + item { + Text( + text = stringResource(R.string.device_settings_hint), + style = MaterialTheme.typography.bodySmall, + color = TextSecondary, + modifier = Modifier.padding(top = 4.dp), + ) + } } } } @@ -102,6 +120,6 @@ private fun DeviceSettingsContent( @Composable private fun DeviceSettingsContentPreview() { RingHealthTheme { - DeviceSettingsContent(settings = previewSettings(), onToggle = { _, _ -> }) + DeviceSettingsContent(settings = previewSettings(), onToggle = { _, _ -> }, onBack = {}) } } diff --git a/app/src/main/java/com/placeholder/ringhealth/feature/home/HomeScreen.kt b/app/src/main/java/com/placeholder/ringhealth/feature/home/HomeScreen.kt index 06cb811..5c03f2b 100644 --- a/app/src/main/java/com/placeholder/ringhealth/feature/home/HomeScreen.kt +++ b/app/src/main/java/com/placeholder/ringhealth/feature/home/HomeScreen.kt @@ -7,70 +7,80 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.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.Bloodtype -import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.MonitorHeart -import androidx.compose.material.icons.filled.Spa -import androidx.compose.material.icons.filled.Thermostat -import androidx.compose.material.icons.filled.WaterDrop -import androidx.compose.material.icons.filled.Bedtime import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider 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.graphics.vector.ImageVector 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.formatCalories import com.placeholder.ringhealth.core.common.formatDistance +import com.placeholder.ringhealth.core.common.formatMonthDay import com.placeholder.ringhealth.core.common.formatSleepDuration import com.placeholder.ringhealth.core.common.formatSteps +import com.placeholder.ringhealth.core.common.greetingResFor import com.placeholder.ringhealth.core.common.stressLevelLabelRes -import com.placeholder.ringhealth.core.designsystem.MetricBlue -import com.placeholder.ringhealth.core.designsystem.MetricCyan +import com.placeholder.ringhealth.core.common.weekdayResFor +import com.placeholder.ringhealth.core.designsystem.DividerColor +import com.placeholder.ringhealth.core.designsystem.GreetingStyle import com.placeholder.ringhealth.core.designsystem.MetricGreen -import com.placeholder.ringhealth.core.designsystem.MetricOrange -import com.placeholder.ringhealth.core.designsystem.MetricPurple -import com.placeholder.ringhealth.core.designsystem.MetricRed -import com.placeholder.ringhealth.core.designsystem.MetricYellow -import com.placeholder.ringhealth.core.designsystem.MetricValueStyle import com.placeholder.ringhealth.core.designsystem.RingHealthTheme +import com.placeholder.ringhealth.core.designsystem.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.RingProgress 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 icon: ImageVector, - val tint: Color, val title: String, val value: String?, val unit: String, ) +/** 首页网格列数。 */ +private const val HOME_GRID_COLUMNS = 2 + +/** 步数大环内径占卡片左侧的比例由 [STEPS_RING_SIZE] 决定;数值字号按环内可容纳宽度单独定。 */ +private val STEPS_RING_SIZE = 124.dp + /** 健康首页。 */ @Composable fun HomeScreen( @@ -79,14 +89,30 @@ fun HomeScreen( viewModel: HomeViewModel = viewModel(factory = AppViewModelProvider.Factory), ) { val state by viewModel.uiState.collectAsStateWithLifecycle() - HomeContent(state = state, onOpenDetail = onOpenDetail, modifier = modifier) + HomeContent( + state = state, + onOpenDetail = onOpenDetail, + // 进页面时取一次当前时间,供问候语与日期行使用。 + // 取一次而不是起一个每秒跳动的时钟:问候语在「早上好→下午好」之间切换没有实时性要求, + // 而一个常驻协程会在页面不可见时也持续重组。remember 保证配置变更外的重组不重取。 + nowMillis = remember { System.currentTimeMillis() }, + modifier = modifier, + ) } -/** 无状态首页内容,便于 Preview。 */ +/** + * 无状态首页内容,便于 Preview。 + * + * 布局用 `LazyVerticalGrid` 两列:整宽内容(问候、设备条、步数大环、区块标题)用 + * `GridItemSpan(maxLineSpan)` 跨满整行,六个指标卡各占一格,排成 2×3。 + * 之前是 `LazyColumn` 单列 —— 每张指标卡被拉到整屏宽(约 1100px)而高只有 180px, + * 宽高比 6:1,横向大片留白、纵向却被撑开,六个数字要滚三屏才看得完。 + */ @Composable private fun HomeContent( state: HomeUiState, onOpenDetail: (MetricType) -> Unit, + nowMillis: Long, modifier: Modifier = Modifier, ) { if (state.loading || state.summary == null) { @@ -97,38 +123,80 @@ private fun HomeContent( } val summary = state.summary - // 注意:LazyColumn 的 content lambda 不是 Composable 上下文, - // 所有需要 stringResource / Composable 调用的内容都必须在 LazyColumn 之前算好。 + // 注意:LazyVerticalGrid 的 content lambda 不是 Composable 上下文, + // 所有需要 stringResource / Composable 调用的内容都必须在网格之前算好。 val cards = buildMetricCards(summary) val notEnabledText = stringResource(R.string.home_not_enabled) - LazyColumn( + LazyVerticalGrid( + columns = GridCells.Fixed(HOME_GRID_COLUMNS), modifier = modifier.fillMaxSize(), - contentPadding = PaddingValues(16.dp), + contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 4.dp, bottom = 16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { - item { DeviceStrip(device = state.device) } - item { StepsCard(summary = summary, onClick = { onOpenDetail(MetricType.STEP) }) } - item { SectionHeader(title = stringResource(R.string.home_metrics)) } - items(cards) { card -> - // 没有数值(未开启)的指标卡不可点击:传 null 后 MetricTile/HealthCard 不挂 clickable, - // 无跳转也无水波纹。否则首页显示「未开启」、点进去却是一张完整图表,两页自相矛盾。 - val openDetail = card.value?.let { { onOpenDetail(card.type) } } + // 跨满整行的元素一律显式传 GridItemSpan(maxLineSpan),而不是写死 2 —— + // 将来改列数时这些元素自动跟随,不会漏改。 + item(span = { GridItemSpan(maxLineSpan) }) { GreetingHeader(nowMillis = nowMillis) } + item(span = { GridItemSpan(maxLineSpan) }) { DeviceStrip(device = state.device) } + item(span = { GridItemSpan(maxLineSpan) }) { + StepsCard(summary = summary, onClick = { 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 = card.icon, - iconTint = card.tint, + icon = metricIcon(card.type), + iconTint = metricTint(card.type), title = card.title, value = card.value ?: notEnabledText, - unit = if (card.value == null) "" else card.unit, - onClick = openDetail, + // 显示「未开启」时它不是一个数值,也就没有单位可跟。 + 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) { + HealthCard(modifier = modifier, contentPadding = PaddingValues(horizontal = 16.dp, vertical = 14.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { Icon( imageVector = Icons.Filled.MonitorHeart, @@ -157,90 +225,112 @@ private fun DeviceStrip(device: RingDeviceState?, modifier: Modifier = Modifier) } } -/** 今日步数大环卡片。 */ +/** + * 今日步数卡片:**横向**布局,左侧大环、右侧数值。 + * + * 早先是「大环居中 + 数字在环心 + 距离/千卡横排」的纵向布局,卡片高约 300dp, + * 而内容只有一个环和一个数字 —— 四周全是留白,占掉近三分之一屏。 + * 改成横向后高度降到约 155dp:环贴左,右侧一栏放标题、距离/千卡与目标, + * 横向空间被填满,纵向省下一半。 + */ @Composable private fun StepsCard(summary: HealthSummary, onClick: () -> Unit, modifier: Modifier = Modifier) { - HealthCard(modifier = modifier, onClick = onClick) { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text(text = stringResource(R.string.home_steps_title), style = MaterialTheme.typography.titleMedium) - Spacer(modifier = Modifier.height(12.dp)) + HealthCard(modifier = modifier, onClick = onClick, contentPadding = PaddingValues(16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { RingProgress( progress = summary.steps.toFloat() / summary.stepGoal.coerceAtLeast(1), color = MetricGreen, trackColor = MetricGreen.copy(alpha = 0.15f), + ringSize = STEPS_RING_SIZE, + strokeWidth = 11.dp, ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text(text = formatSteps(summary.steps), style = MetricValueStyle) - Text( - text = stringResource(R.string.home_steps_goal, formatSteps(summary.stepGoal)), - style = MaterialTheme.typography.bodySmall, - color = TextSecondary, - ) - } + // 环内只放步数本身。字号 28sp 是按环内可用直径(124dp - 2×11dp 描边 ≈ 102dp) + // 定的:「8,426」五个字形在该字号下约 84dp,留出余量,换成六位数也不会被裁。 + Text(text = formatSteps(summary.steps), style = StepCountStyle) } - Spacer(modifier = Modifier.height(12.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceEvenly, - ) { - SummaryMetric( - value = formatDistance(summary.distanceKm), - label = stringResource(R.string.home_distance_label), - tint = MetricBlue, + Column(modifier = Modifier.padding(start = 18.dp)) { + Text( + text = stringResource(R.string.home_steps_title), + style = MaterialTheme.typography.bodyMedium, + color = TextSecondary, ) - SummaryMetric( - value = formatCalories(summary.calories), - label = stringResource(R.string.home_calories_label), - tint = MetricOrange, + Spacer(modifier = Modifier.height(10.dp)) + Row(verticalAlignment = Alignment.Bottom) { + SummaryMetric(value = formatDistance(summary.distanceKm), label = stringResource(R.string.home_distance_label)) + Spacer(modifier = Modifier.width(18.dp)) + SummaryMetric(value = formatCalories(summary.calories), label = stringResource(R.string.home_calories_label)) + } + Spacer(modifier = Modifier.height(10.dp)) + HorizontalDivider(color = DividerColor) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.home_steps_goal, formatSteps(summary.stepGoal)), + style = MaterialTheme.typography.bodySmall, + color = TextSecondary, ) } } } } -/** 环下方的小结指标。 */ +/** + * 环内步数字号。 + * + * 独立于 `MetricValueStyle`(44sp)—— 那个是给详情页整屏宽的,塞进 102dp 的环里会被裁。 + */ +private val StepCountStyle = TextStyle( + fontSize = 28.sp, + fontWeight = FontWeight.Bold, + letterSpacing = (-1).sp, + color = TextPrimary, +) + +/** + * 步数卡右侧的小结指标:数值 + 单位,纵向排列。 + * + * 数值用深色而不是蓝/橙彩色 —— 在这个 App 里**颜色是有含义的**(每个健康指标一个专属色), + * 而「距离」「千卡」并不是可跳转的指标,只是步数的两个换算结果。给它们上色会让人以为 + * 蓝=某种指标身份,而点上去并无反应。 + */ @Composable -private fun SummaryMetric(value: String, label: String, tint: Color) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text(text = value, style = MaterialTheme.typography.titleMedium, color = tint) +private fun SummaryMetric(value: String, label: String) { + Column { + Text(text = value, style = MaterialTheme.typography.titleMedium, color = TextPrimary) Text(text = label, style = MaterialTheme.typography.bodySmall, color = TextSecondary) } } -/** 组装指标卡列表;value 为 null 表示该指标未开启。 */ +/** + * 组装指标卡列表;value 为 null 表示该指标未开启。 + * + * 图标与配色一律取自 [metricIcon] / [metricTint],不在这里写字面量 —— + * 卡片和详情页必须显示成同一个指标的样子,抄两份就会有一份先过期。 + * + * 值为 null 的原因是**功能开关关着**(概览的字段由开关生成,见 `MockHealthDataRepository`), + * 所以这里读到的 null 与功能设置页那个开关是同一个事实的两个说法。 + */ @Composable private fun buildMetricCards(summary: HealthSummary): List = listOf( MetricCardSpec( type = MetricType.HEART_RATE, - icon = Icons.Filled.Favorite, - tint = MetricRed, title = stringResource(R.string.metric_heart_rate), value = summary.heartRate?.toString(), unit = stringResource(R.string.unit_bpm), ), MetricCardSpec( type = MetricType.SLEEP, - icon = Icons.Filled.Bedtime, - tint = MetricBlue, title = stringResource(R.string.metric_sleep), value = summary.sleepMinutes?.let { formatSleepDuration(it) }, unit = "", ), MetricCardSpec( type = MetricType.BLOOD_OXYGEN, - icon = Icons.Filled.Bloodtype, - tint = MetricCyan, title = stringResource(R.string.metric_blood_oxygen), value = summary.bloodOxygen?.toString(), unit = stringResource(R.string.unit_percent), ), MetricCardSpec( type = MetricType.STRESS, - icon = Icons.Filled.Spa, - tint = MetricPurple, title = stringResource(R.string.metric_stress), value = summary.stress?.toString(), // 单位位改放压力等级文案(数值落在哪个区间就是放松/正常/中等/偏高), @@ -250,24 +340,24 @@ private fun buildMetricCards(summary: HealthSummary): List = lis ), MetricCardSpec( type = MetricType.TEMPERATURE, - icon = Icons.Filled.Thermostat, - tint = MetricOrange, title = stringResource(R.string.metric_temperature), value = summary.temperature?.toString(), unit = stringResource(R.string.unit_celsius), ), MetricCardSpec( type = MetricType.BLOOD_PRESSURE, - icon = Icons.Filled.WaterDrop, - tint = MetricYellow, title = stringResource(R.string.metric_blood_pressure), value = summary.bloodPressure, unit = stringResource(R.string.unit_mmhg), ), ) -/** 预览入口:以固定示例数据渲染 [HomeContent],便于在 IDE 中免真机查看首页效果。 */ -@Preview(showBackground = true) +/** + * 预览入口:以固定示例数据渲染 [HomeContent],便于在 IDE 中免真机查看首页效果。 + * + * `nowMillis` 写死成固定时刻,预览里的问候语与日期才不会随运行时间变化。 + */ +@Preview(showBackground = true, heightDp = 900) @Composable private fun HomeContentPreview() { RingHealthTheme { @@ -278,6 +368,9 @@ private fun HomeContentPreview() { device = RingDeviceState(true, "RingHealth 智能戒指", 78, "1.0.5", false), ), onOpenDetail = {}, + // 固定为 2026-09-10 08:26(周四)—— 取固定值是为了让预览里的问候语「早上好」 + // 与日期行不随运行时间变化,否则同一份 Preview 在不同时段渲染出不同文案。 + nowMillis = 1_789_000_000_000L, ) } } diff --git a/app/src/main/java/com/placeholder/ringhealth/feature/profile/ProfileScreen.kt b/app/src/main/java/com/placeholder/ringhealth/feature/profile/ProfileScreen.kt index 4471e94..ed4afbe 100644 --- a/app/src/main/java/com/placeholder/ringhealth/feature/profile/ProfileScreen.kt +++ b/app/src/main/java/com/placeholder/ringhealth/feature/profile/ProfileScreen.kt @@ -10,6 +10,7 @@ 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 @@ -21,15 +22,21 @@ 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 /** @@ -72,6 +79,7 @@ fun ProfileScreen(modifier: Modifier = Modifier) { item { ProfileEntry( icon = Icons.Filled.Settings, + tint = MetricBlue, title = stringResource(R.string.profile_units), subtitle = stringResource(R.string.profile_units_desc), ) @@ -79,6 +87,7 @@ fun ProfileScreen(modifier: Modifier = Modifier) { item { ProfileEntry( icon = Icons.Filled.Notifications, + tint = MetricOrange, title = stringResource(R.string.profile_notification), subtitle = stringResource(R.string.profile_notification_desc), ) @@ -87,6 +96,7 @@ fun ProfileScreen(modifier: Modifier = Modifier) { item { ProfileEntry( icon = Icons.Filled.Refresh, + tint = MetricPurple, title = stringResource(R.string.profile_check_update), subtitle = stringResource(R.string.profile_check_update_desc), ) @@ -94,6 +104,7 @@ fun ProfileScreen(modifier: Modifier = Modifier) { item { ProfileEntry( icon = Icons.Filled.Info, + tint = MetricCyan, title = stringResource(R.string.profile_help), subtitle = stringResource(R.string.profile_help_desc), ) @@ -101,10 +112,19 @@ fun ProfileScreen(modifier: Modifier = Modifier) { } } -/** 单条设置入口。 */ +/** + * 单条设置入口。 + * + * 图标改用 [IconBadge] 的色底圆徽章,与首页指标卡同一套处理 —— 此前是裸的灰色小图标, + * 在全屏白卡上几乎是隐形的;四张卡的图标各配一色之后,一行行扫下来能靠颜色定位。 + * + * 末尾的右箭头是**纯装饰性的可达性提示**:这四个入口目前都没有 `onClick`(阶段一未开放), + * 箭头表示「本该可以进」。等 M2 接上真实页面时再补 `onClick`。 + */ @Composable private fun ProfileEntry( icon: ImageVector, + tint: Color, title: String, subtitle: String, modifier: Modifier = Modifier, @@ -114,16 +134,17 @@ private fun ProfileEntry( 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 = icon, + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, tint = TextSecondary, modifier = Modifier.size(20.dp), ) - Column(modifier = Modifier.padding(start = 12.dp)) { - Text(text = title, style = MaterialTheme.typography.titleSmall) - Text(text = subtitle, style = MaterialTheme.typography.bodySmall, color = TextSecondary) - } } } } diff --git a/app/src/main/java/com/placeholder/ringhealth/feature/workout/WorkoutHistoryScreen.kt b/app/src/main/java/com/placeholder/ringhealth/feature/workout/WorkoutHistoryScreen.kt index 4fd455f..9a70977 100644 --- a/app/src/main/java/com/placeholder/ringhealth/feature/workout/WorkoutHistoryScreen.kt +++ b/app/src/main/java/com/placeholder/ringhealth/feature/workout/WorkoutHistoryScreen.kt @@ -8,7 +8,9 @@ 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 @@ -24,53 +26,78 @@ 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, modifier = modifier) + WorkoutHistoryContent(state = state, onBack = onBack, modifier = modifier) } -/** 无状态运动历史内容,便于 Preview。 */ +/** + * 无状态运动历史内容,便于 Preview。 + * + * 本页自带 `Scaffold` + `TopAppBar`(外层 `RingHealthApp` 的 `innerPadding` 已在 NavHost 上 + * 消费过,这里再补一次 `padding(innerPadding)` 是正常的层级内边距,不会叠加状态栏 —— + * 与 `MetricDetailScreen` 同一套写法)。补顶栏是为了和详情页一致:此前本页没有返回箭头, + * 只能靠系统返回手势或点底部 Tab 离开。 + */ +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun WorkoutHistoryContent( state: WorkoutUiState, + onBack: () -> Unit, modifier: Modifier = Modifier, ) { - // 必须先判 loading 再判空(ledger Ruling R25):Mock 仓库有 200ms 延迟, - // records 在加载完成前就是 emptyList,若先判空会先闪一下「暂无运动记录」, - // 数据到了再跳成列表 —— 用户看到的是「明明有记录却说没有」。 - if (state.loading) { - Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - CircularProgressIndicator(color = MetricGreen) - } - return - } - if (state.records.isEmpty()) { - Box( - modifier = modifier.fillMaxSize().padding(16.dp), - contentAlignment = Alignment.Center, - ) { - Text( - text = stringResource(R.string.workout_empty), - style = MaterialTheme.typography.bodyMedium, - color = TextSecondary, + 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 } - return - } - LazyColumn( - modifier = modifier.fillMaxSize(), - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - items(state.records) { record -> - WorkoutRecordCard(record = record) + 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) + } } } } @@ -102,6 +129,7 @@ private fun WorkoutHistoryContentPreview() { ), ), ), + onBack = {}, ) } } diff --git a/app/src/main/java/com/placeholder/ringhealth/feature/workout/WorkoutScreen.kt b/app/src/main/java/com/placeholder/ringhealth/feature/workout/WorkoutScreen.kt index e89eb56..decb6dc 100644 --- a/app/src/main/java/com/placeholder/ringhealth/feature/workout/WorkoutScreen.kt +++ b/app/src/main/java/com/placeholder/ringhealth/feature/workout/WorkoutScreen.kt @@ -1,5 +1,6 @@ package com.placeholder.ringhealth.feature.workout +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -11,20 +12,29 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material.icons.filled.FitnessCenter import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.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.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 @@ -52,7 +62,14 @@ fun WorkoutScreen( WorkoutContent(state = state, onOpenHistory = onOpenHistory, modifier = modifier) } -/** 无状态运动页内容。 */ +/** + * 无状态运动页内容。 + * + * 「开始运动」卡整卡可点击,点击后弹一条 Snackbar 说明该能力尚未开放 —— + * 此前它是一张**没有任何点击响应的死卡**,用户点上去毫无反馈,看着像坏了。 + * 弹提示比把卡片做成 `enabled = false` 的半透明按钮好:后者第一眼就像「功能被禁用了」, + * 而前者明确告诉用户「为什么现在没有」。 + */ @Composable private fun WorkoutContent( state: WorkoutUiState, @@ -66,44 +83,86 @@ private fun WorkoutContent( return } - LazyColumn( - modifier = modifier.fillMaxSize(), - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - item { - HealthCard { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - imageVector = Icons.Filled.FitnessCenter, - contentDescription = null, - tint = MetricGreen, - modifier = Modifier.size(28.dp), - ) - Column(modifier = Modifier.padding(start = 12.dp)) { - Text(text = stringResource(R.string.workout_start), style = MaterialTheme.typography.titleMedium) - Text( - text = stringResource(R.string.workout_start_desc), - style = MaterialTheme.typography.bodySmall, - color = TextSecondary, - ) - } - } + 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 { + StartWorkoutCard( + onClick = { + 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) } } - item { - SectionHeader( - title = stringResource(R.string.workout_recent), - trailing = { - TextButton(onClick = onOpenHistory) { - Text(text = stringResource(R.string.action_view_all)) - } - }, + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier.align(Alignment.BottomCenter), + ) + } +} + +/** + * 「开始运动」入口卡。 + * + * 视觉上做成明确的行动入口:实心绿圆底 + 白色图标 + 右侧箭头。 + * 这三点都是「这里可以点」的通用视觉语言,比原先「灰底 + 绿图标 + 两行字」的一行静置文本强得多。 + */ +@Composable +private fun StartWorkoutCard(onClick: () -> Unit, modifier: Modifier = Modifier) { + HealthCard(modifier = modifier, onClick = onClick, contentPadding = PaddingValues(16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(46.dp) + .clip(CircleShape) + .background(MetricGreen), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Filled.FitnessCenter, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(24.dp), + ) + } + Column( + modifier = Modifier.weight(1f).padding(start = 14.dp), + ) { + Text(text = stringResource(R.string.workout_start), style = MaterialTheme.typography.titleMedium) + Text( + text = stringResource(R.string.workout_start_desc), + style = MaterialTheme.typography.bodySmall, + color = TextSecondary, + ) + } + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = null, + tint = TextSecondary, + modifier = Modifier.size(20.dp), ) } - items(state.records) { record -> - WorkoutRecordCard(record = record) - } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ae6e594..e4bd235 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -12,6 +12,24 @@ 设备 我的 + + 早上好 + 下午好 + 晚上好 + 夜深了 + + + 周一 + 周二 + 周三 + 周四 + 周五 + 周六 + 周日 + %1$s · 已连接 未连接设备 电量 %1$d%% @@ -25,6 +43,8 @@ % °C mmHg + + 步 心率 睡眠 @@ -37,9 +57,12 @@ 开始运动 选择运动类型,记录你的训练 最近运动 + 运动历史 暂无运动记录 %1$s · %2$s 公里 消耗 %1$s 千卡 + + 运动记录将在接入戒指后开放 查看全部 已连接 @@ -78,5 +101,62 @@ 清醒 入睡时间 起床时间 + + + 每日%1$s + 目标 %1$s + %1$d 天 + 正常 + 偏高 + 偏低 + + + 今日步数 + 已完成 + 达标天数 + 日均 + + + 正常 %1$s–%2$s 次/分 + 正常范围 + + + 昨晚睡眠 + 目标 %1$s + + 睡眠时段 + 平均 %1$s 入睡 + 平均入睡 + 平均醒来 + 平均时长 + 小时 + + + 达标线 %1$s%% + 偏低 %1$d 天 + + + 当前压力 + 放松天数 + 等级分布 + + + 基线 %1$s°C + 较基线 %1$s°C + 最高偏离 + 最低偏离 + + + + %1$s监测未开启 + 可在「设备 - 功能设置」中开启该监测项,开启后才会生成记录。 + + 最近一次测量 + 收缩压 + 舒张压 + 测量记录 + 血压需先在设备页完成手动校准,之后才会生成测量记录。 + 返回 diff --git a/app/src/test/java/com/placeholder/ringhealth/core/common/SleepTimeTest.kt b/app/src/test/java/com/placeholder/ringhealth/core/common/SleepTimeTest.kt new file mode 100644 index 0000000..7df86e4 --- /dev/null +++ b/app/src/test/java/com/placeholder/ringhealth/core/common/SleepTimeTest.kt @@ -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 +} diff --git a/app/src/test/java/com/placeholder/ringhealth/data/mock/MockDataProviderTest.kt b/app/src/test/java/com/placeholder/ringhealth/data/mock/MockDataProviderTest.kt index ac96e31..30a8892 100644 --- a/app/src/test/java/com/placeholder/ringhealth/data/mock/MockDataProviderTest.kt +++ b/app/src/test/java/com/placeholder/ringhealth/data/mock/MockDataProviderTest.kt @@ -1,8 +1,14 @@ 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 @@ -19,28 +25,79 @@ class MockDataProviderTest { const val FIXED_NOW = 1_760_000_000_000L } + /** 默认演示状态下的开关:血压关着,其余开着(见 [MockDataProvider.defaultFunctionSettings])。 */ + private val defaultEnabled: Set = + provider.defaultFunctionSettings().enabledMetrics() + @Test fun `今日概览返回固定演示数据`() { - val summary = provider.todaySummary() + 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 + // 血压默认未开启,应为 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().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).size) - assertEquals(30, provider.metricHistory(MetricType.STEP, DateRange.MONTH).size) + 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) - val second = provider.metricHistory(MetricType.HEART_RATE, DateRange.WEEK) + val first = provider.metricHistory(MetricType.HEART_RATE, DateRange.WEEK, defaultEnabled) + val second = provider.metricHistory(MetricType.HEART_RATE, DateRange.WEEK, defaultEnabled) assertEquals(first, second) assertTrue(first.all { it.value > 40.0 && it.value < 180.0 }) // 时间戳须严格递增(“每点间隔一天”倒推,最早的在最前) @@ -52,6 +109,80 @@ class MockDataProviderTest { assertEquals(FIXED_NOW - 6L * 24 * 60 * 60 * 1000, first.first().timestamp) } + @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() diff --git a/app/src/test/java/com/placeholder/ringhealth/data/mock/TestRepositories.kt b/app/src/test/java/com/placeholder/ringhealth/data/mock/TestRepositories.kt new file mode 100644 index 0000000..7cc73ad --- /dev/null +++ b/app/src/test/java/com/placeholder/ringhealth/data/mock/TestRepositories.kt @@ -0,0 +1,22 @@ +package com.placeholder.ringhealth.data.mock + +/** + * 造一对**已接线**的 Mock 仓库:健康数据的开关状态来自同一个设备仓库。 + * + * 测试里必须这样接,不能各 `new` 一个。各 new 一个正是生产代码修掉的那个 bug —— + * 开关状态与健康数据分属两个互不引用的实例,用户翻了开关首页纹丝不动。 + * 测试若照抄旧写法,就会在一套与线上不同的接线方式上做断言: + * 断言全绿,线上照样是坏的。 + */ + +/** 设备仓库,功能开关为 [MockDataProvider.defaultFunctionSettings];[enable] 用于覆盖个别开关。 */ +fun mockDeviceRepository(enable: Map = 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) diff --git a/app/src/test/java/com/placeholder/ringhealth/feature/detail/MetricDetailViewModelTest.kt b/app/src/test/java/com/placeholder/ringhealth/feature/detail/MetricDetailViewModelTest.kt index 9c866ef..000e769 100644 --- a/app/src/test/java/com/placeholder/ringhealth/feature/detail/MetricDetailViewModelTest.kt +++ b/app/src/test/java/com/placeholder/ringhealth/feature/detail/MetricDetailViewModelTest.kt @@ -1,6 +1,7 @@ package com.placeholder.ringhealth.feature.detail -import com.placeholder.ringhealth.data.mock.MockHealthDataRepository +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 @@ -36,7 +37,7 @@ class MetricDetailViewModelTest { @Test fun `默认输出近 7 天的数据`() = runTest(dispatcher) { - val viewModel = MetricDetailViewModel(MetricType.HEART_RATE, MockHealthDataRepository()) + val viewModel = MetricDetailViewModel(MetricType.HEART_RATE, mockHealthRepository()) backgroundScope.launch { viewModel.uiState.collect {} } advanceUntilIdle() @@ -46,11 +47,48 @@ class MetricDetailViewModelTest { 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, MockHealthDataRepository()) + val viewModel = MetricDetailViewModel(MetricType.HEART_RATE, mockHealthRepository()) backgroundScope.launch { viewModel.uiState.collect {} } advanceUntilIdle() @@ -69,13 +107,29 @@ class MetricDetailViewModelTest { 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, MockHealthDataRepository()) + val viewModel = MetricDetailViewModel(MetricType.SLEEP, mockHealthRepository()) backgroundScope.launch { viewModel.uiState.collect {} } advanceUntilIdle() diff --git a/app/src/test/java/com/placeholder/ringhealth/feature/detail/MetricStatsTest.kt b/app/src/test/java/com/placeholder/ringhealth/feature/detail/MetricStatsTest.kt new file mode 100644 index 0000000..65c9c7b --- /dev/null +++ b/app/src/test/java/com/placeholder/ringhealth/feature/detail/MetricStatsTest.kt @@ -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)) + } +} diff --git a/app/src/test/java/com/placeholder/ringhealth/feature/device/DeviceViewModelTest.kt b/app/src/test/java/com/placeholder/ringhealth/feature/device/DeviceViewModelTest.kt index e83ef18..3185801 100644 --- a/app/src/test/java/com/placeholder/ringhealth/feature/device/DeviceViewModelTest.kt +++ b/app/src/test/java/com/placeholder/ringhealth/feature/device/DeviceViewModelTest.kt @@ -49,9 +49,12 @@ class DeviceViewModelTest { // 直接取仓库当前值),所以**单靠它们无法区分「combine 发射过」与「一直停在 initialValue」** // (ledger Ruling R30,审查者指出)。这里绕开 ViewModel 直接改仓库,再断言状态跟着变 —— // 只有 combine 真的在发射,这一条才可能成立。 - repository.setFunctionEnabled("stress", true) + // 关掉一个**初始为 true** 的开关,断言它跟着变 false。方向很重要: + // 若这里改成「把初始为 false 的打开」,即使 combine 一次都没发射过, + // 只要 initialValue 里那项碰巧是 true,断言照样通过(ledger Ruling R30)。 + repository.setFunctionEnabled("stress", false) advanceUntilIdle() - assertTrue(viewModel.uiState.value.settings.first { it.id == "stress" }.enabled) + assertFalse(viewModel.uiState.value.settings.first { it.id == "stress" }.enabled) } @Test @@ -60,19 +63,20 @@ class DeviceViewModelTest { backgroundScope.launch { viewModel.uiState.collect {} } advanceUntilIdle() - // Mock 的 5 项里只有 stress 与 blood_pressure 初始为 false,其余 3 项为 true - // (见 MockDataProvider.defaultFunctionSettings)。切换 stress 后应为 4 项开启。 - val target = "stress" + // 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(4, viewModel.uiState.value.settings.count { it.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 }) } } diff --git a/app/src/test/java/com/placeholder/ringhealth/feature/home/HomeViewModelTest.kt b/app/src/test/java/com/placeholder/ringhealth/feature/home/HomeViewModelTest.kt index 92c10ee..69149d0 100644 --- a/app/src/test/java/com/placeholder/ringhealth/feature/home/HomeViewModelTest.kt +++ b/app/src/test/java/com/placeholder/ringhealth/feature/home/HomeViewModelTest.kt @@ -1,7 +1,7 @@ package com.placeholder.ringhealth.feature.home -import com.placeholder.ringhealth.data.mock.MockHealthDataRepository -import com.placeholder.ringhealth.data.mock.MockRingDeviceRepository +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 @@ -36,7 +36,9 @@ class HomeViewModelTest { @Test fun `加载完成后输出今日概览与设备状态`() = runTest(dispatcher) { - val viewModel = HomeViewModel(MockHealthDataRepository(), MockRingDeviceRepository()) + // 两个仓库必须共用同一个开关实例,理由见 TestRepositories.kt 文件头。 + val device = mockDeviceRepository() + val viewModel = HomeViewModel(mockHealthRepository(device), device) // uiState 用 SharingStarted.WhileSubscribed 构建,必须有订阅者才会启动上游数据流; // 否则 .value 永远是初始值(loading = true)。用 backgroundScope 起一个收集者。 diff --git a/app/src/test/java/com/placeholder/ringhealth/feature/workout/WorkoutViewModelTest.kt b/app/src/test/java/com/placeholder/ringhealth/feature/workout/WorkoutViewModelTest.kt index b15ac43..4a4ef14 100644 --- a/app/src/test/java/com/placeholder/ringhealth/feature/workout/WorkoutViewModelTest.kt +++ b/app/src/test/java/com/placeholder/ringhealth/feature/workout/WorkoutViewModelTest.kt @@ -1,6 +1,6 @@ package com.placeholder.ringhealth.feature.workout -import com.placeholder.ringhealth.data.mock.MockHealthDataRepository +import com.placeholder.ringhealth.data.mock.mockHealthRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.launch @@ -36,7 +36,7 @@ class WorkoutViewModelTest { @Test fun `加载完成后输出运动记录`() = runTest(dispatcher) { - val viewModel = WorkoutViewModel(MockHealthDataRepository()) + val viewModel = WorkoutViewModel(mockHealthRepository()) // 与 HomeViewModelTest 同理:uiState 用 WhileSubscribed 构建, // 没有订阅者时 .value 会停在 initialValue(loading = true)。