Compare commits
7
Commits
9c233e5750
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6af1078ca5 | ||
|
|
841f9f1542 | ||
|
|
fba26fc372 | ||
|
|
514e5ec9e2 | ||
|
|
e5f935625e | ||
|
|
77de7808cb | ||
|
|
5fe0d4a420 |
@@ -24,8 +24,11 @@
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
./gradlew assembleDebug # 产物在 app/build/outputs/apk/debug/
|
./gradlew assembleDebug # 产物在 app/build/outputs/apk/debug/
|
||||||
./gradlew installDebug # 安装到已连接设备
|
./gradlew installDebug # 安装到已连接设备(需接好设备或已启动模拟器)
|
||||||
./gradlew test # 单元测试
|
./gradlew test # 单元测试
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> `installDebug` 需要一台已连接的设备或正在运行的模拟器。M1 使用的机器两者都没有,
|
||||||
|
> 因此本机实际可执行的是 `./gradlew assembleDebug` 与 `./gradlew :app:testDebugUnitTest`。
|
||||||
|
|
||||||
要求:JDK 17、Android SDK(platform 36 / build-tools 36.1.0)。
|
要求:JDK 17、Android SDK(platform 36 / build-tools 36.1.0)。
|
||||||
|
|||||||
@@ -13,8 +13,18 @@ interface AppContainer {
|
|||||||
val ringDeviceRepository: RingDeviceRepository
|
val ringDeviceRepository: RingDeviceRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 默认容器:阶段一全部使用 Mock 数据。 */
|
/**
|
||||||
|
* 默认容器:阶段一全部使用 Mock 数据。
|
||||||
|
*
|
||||||
|
* 注意 [healthDataRepository] **依赖** [ringDeviceRepository] 的开关流,不是两个平行字段 ——
|
||||||
|
* 之前正是各建各的,导致功能开关与健康数据互不相干(详见 [MockHealthDataRepository])。
|
||||||
|
* 声明里保留具体类型,是为了把开关流传下去而不必向下转型。
|
||||||
|
*/
|
||||||
class DefaultAppContainer : AppContainer {
|
class DefaultAppContainer : AppContainer {
|
||||||
override val healthDataRepository: HealthDataRepository = MockHealthDataRepository()
|
private val mockRingDeviceRepository = MockRingDeviceRepository()
|
||||||
override val ringDeviceRepository: RingDeviceRepository = MockRingDeviceRepository()
|
|
||||||
|
override val ringDeviceRepository: RingDeviceRepository = mockRingDeviceRepository
|
||||||
|
|
||||||
|
override val healthDataRepository: HealthDataRepository =
|
||||||
|
MockHealthDataRepository(functionSettings = mockRingDeviceRepository.functionSettings)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ package com.placeholder.ringhealth.core.common
|
|||||||
|
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import com.placeholder.ringhealth.R
|
import com.placeholder.ringhealth.R
|
||||||
|
import com.placeholder.ringhealth.domain.model.StressLevel
|
||||||
|
import com.placeholder.ringhealth.domain.model.stressLevelOf
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Calendar
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
@@ -15,6 +18,9 @@ fun formatDistance(km: Double): String = String.format(Locale.CHINA, "%.2f", km)
|
|||||||
/** 卡路里:只做千分位,单位「千卡」由 UI 用字符串资源拼接(ledger Ruling R13)。 */
|
/** 卡路里:只做千分位,单位「千卡」由 UI 用字符串资源拼接(ledger Ruling R13)。 */
|
||||||
fun formatCalories(kcal: Int): String = String.format(Locale.CHINA, "%,d", kcal)
|
fun formatCalories(kcal: Int): String = String.format(Locale.CHINA, "%,d", kcal)
|
||||||
|
|
||||||
|
/** 活动累计分钟数:只取整,单位「分钟」由 UI 用字符串资源拼接。 */
|
||||||
|
fun formatActiveMinutes(minutes: Int): String = minutes.toString()
|
||||||
|
|
||||||
/** 睡眠时长:7小时32分 / 45分 / 8小时。 */
|
/** 睡眠时长:7小时32分 / 45分 / 8小时。 */
|
||||||
fun formatSleepDuration(minutes: Int): String {
|
fun formatSleepDuration(minutes: Int): String {
|
||||||
val hours = minutes / 60
|
val hours = minutes / 60
|
||||||
@@ -45,14 +51,119 @@ fun formatClock(timestamp: Long): String =
|
|||||||
fun formatDate(timestamp: Long): String =
|
fun formatDate(timestamp: Long): String =
|
||||||
SimpleDateFormat("yyyy-MM-dd", Locale.CHINA).format(Date(timestamp))
|
SimpleDateFormat("yyyy-MM-dd", Locale.CHINA).format(Date(timestamp))
|
||||||
|
|
||||||
/** 数值 → 保留一位小数。详情页的统计摘要用(ledger Ruling R29)。 */
|
/** 时间戳 → 「9月10日」形式(首页顶部日期行左半部分;星期由 [weekdayResFor] 单独给)。 */
|
||||||
fun formatDecimal(value: Double): String = String.format(Locale.CHINA, "%.1f", value)
|
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
|
@StringRes
|
||||||
fun stressLevelLabelRes(stress: Int): Int = when {
|
fun weekdayResFor(timestamp: Long): Int = when (calendarField(timestamp, Calendar.DAY_OF_WEEK)) {
|
||||||
stress < 30 -> R.string.stress_relaxed
|
Calendar.MONDAY -> R.string.weekday_monday
|
||||||
stress < 60 -> R.string.stress_normal
|
Calendar.TUESDAY -> R.string.weekday_tuesday
|
||||||
stress < 80 -> R.string.stress_medium
|
Calendar.WEDNESDAY -> R.string.weekday_wednesday
|
||||||
else -> R.string.stress_high
|
Calendar.THURSDAY -> R.string.weekday_thursday
|
||||||
|
Calendar.FRIDAY -> R.string.weekday_friday
|
||||||
|
Calendar.SATURDAY -> R.string.weekday_saturday
|
||||||
|
else -> R.string.weekday_sunday
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 时间戳所在时段 → 问候语字符串资源 id。
|
||||||
|
*
|
||||||
|
* 按时段取文案而不是拼字符串,文案本身仍全在 `strings.xml` 里(Ruling R3)。
|
||||||
|
* 分档:[GREETING_NIGHT_START]–[GREETING_MORNING_START] 为「夜深了」,
|
||||||
|
* 其余依次为早上/下午/晚上。
|
||||||
|
*/
|
||||||
|
@StringRes
|
||||||
|
fun greetingResFor(timestamp: Long): Int {
|
||||||
|
val hour = calendarField(timestamp, Calendar.HOUR_OF_DAY)
|
||||||
|
return when {
|
||||||
|
hour < GREETING_MORNING_START -> R.string.home_greeting_night
|
||||||
|
hour < GREETING_AFTERNOON_START -> R.string.home_greeting_morning
|
||||||
|
hour < GREETING_EVENING_START -> R.string.home_greeting_afternoon
|
||||||
|
hour < GREETING_NIGHT_START -> R.string.home_greeting_evening
|
||||||
|
else -> R.string.home_greeting_night
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 一天 1440 分钟。[minutesOfDay] 与 [formatMinuteOfDay] 的取值范围。 */
|
||||||
|
private const val MINUTES_PER_DAY = 24 * 60
|
||||||
|
|
||||||
|
/** 5 点起算「早上好」。 */
|
||||||
|
private const val GREETING_MORNING_START = 5
|
||||||
|
/** 12 点起算「下午好」。 */
|
||||||
|
private const val GREETING_AFTERNOON_START = 12
|
||||||
|
/** 18 点起算「晚上好」。 */
|
||||||
|
private const val GREETING_EVENING_START = 18
|
||||||
|
/** 23 点起算「夜深了」。 */
|
||||||
|
private const val GREETING_NIGHT_START = 23
|
||||||
|
|
||||||
|
/** 取时间戳的某个日历字段。抽出来是为了 [weekdayResFor] / [greetingResFor] 共用一份 Calendar 构造。 */
|
||||||
|
private fun calendarField(timestamp: Long, field: Int): Int =
|
||||||
|
Calendar.getInstance().apply { timeInMillis = timestamp }.get(field)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 时间戳 → 当天 0 点起的分钟数(0–1439),按**设备本地时区**算。
|
||||||
|
*
|
||||||
|
* 睡眠时段图的横轴是「几点」而不是「哪天」,需要这个纯时间量。
|
||||||
|
* 不能用 `timestamp % 86_400_000 / 60_000` —— 那是 UTC 的余数,
|
||||||
|
* 在东八区会把 23:43 算成 15:43,整张图会平移八小时,
|
||||||
|
* 而且平移后每个条看起来都还挺合理,不会有人发现。
|
||||||
|
*/
|
||||||
|
fun minutesOfDay(timestamp: Long): Int =
|
||||||
|
calendarField(timestamp, Calendar.HOUR_OF_DAY) * 60 + calendarField(timestamp, Calendar.MINUTE)
|
||||||
|
|
||||||
|
/** 当天第几分钟(0–1439)→「HH:mm」。上一条的逆运算,给时段图的横轴刻度用。 */
|
||||||
|
fun formatMinuteOfDay(minuteOfDay: Int): String {
|
||||||
|
val normalized = ((minuteOfDay % MINUTES_PER_DAY) + MINUTES_PER_DAY) % MINUTES_PER_DAY
|
||||||
|
return String.format(Locale.CHINA, "%02d:%02d", normalized / 60, normalized % 60)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数值 → 保留 [decimals] 位小数。详情页的统计摘要用(ledger Ruling R29)。
|
||||||
|
*
|
||||||
|
* 小数位由调用方按指标给:体温 36.5 保留一位才有意义,而心率 78.0、步数 8426.0
|
||||||
|
* 这种尾随的 `.0` 只会让人以为精度很高,实际是假精度。
|
||||||
|
*/
|
||||||
|
fun formatDecimal(value: Double, decimals: Int = 1): String =
|
||||||
|
String.format(Locale.CHINA, "%.${decimals}f", value)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数值 → 带正负号、保留 [decimals] 位小数,如 `+0.4` / `-0.3`。
|
||||||
|
*
|
||||||
|
* 体温页的「较基线」与「偏离量」用它:正负号是它一半的信息,
|
||||||
|
* 走 [formatDecimal] 的话 +0.4 会显示成「0.4」,看着像没有偏离。
|
||||||
|
* 零统一带正号(`+0.0`)—— `-0.0` 在浮点里存在,但它不是「低了 0.0」的意思。
|
||||||
|
*/
|
||||||
|
fun formatSignedDecimal(value: Double, decimals: Int = 1): String {
|
||||||
|
val text = String.format(Locale.CHINA, "%.${decimals}f", kotlin.math.abs(value))
|
||||||
|
return if (value < 0) "-$text" else "+$text"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 时间戳 → 「9/10」形式。详情页柱状图的 X 轴刻度用(月度 30 根柱时日期要尽量窄)。 */
|
||||||
|
fun formatMonthDayNumeric(timestamp: Long): String =
|
||||||
|
SimpleDateFormat("M/d", Locale.CHINA).format(Date(timestamp))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 压力等级 → 字符串资源 id。中文描述的唯一来源是 `strings.xml`(ledger Ruling R13)。
|
||||||
|
*
|
||||||
|
* 分档阈值不在这里 —— 它属于领域判断,在 `domain/model/MetricReference.kt` 的
|
||||||
|
* [stressLevelOf] 里,本函数只负责「等级 → 文案」这一段映射。
|
||||||
|
*/
|
||||||
|
@StringRes
|
||||||
|
fun stressLevelLabelRes(level: StressLevel): Int = when (level) {
|
||||||
|
StressLevel.RELAXED -> R.string.stress_relaxed
|
||||||
|
StressLevel.NORMAL -> R.string.stress_normal
|
||||||
|
StressLevel.MEDIUM -> R.string.stress_medium
|
||||||
|
StressLevel.HIGH -> R.string.stress_high
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [stressLevelLabelRes] 的便捷重载:直接吃压力值,省得调用方自己转一次等级。 */
|
||||||
|
@StringRes
|
||||||
|
fun stressLevelLabelRes(stress: Int): Int = stressLevelLabelRes(stressLevelOf(stress.toDouble()))
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.placeholder.ringhealth.core.common
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 睡眠时段的横轴锚点:**晚上 20:00**。
|
||||||
|
*
|
||||||
|
* 一晚睡眠横跨零点(23:43 入睡、07:37 起床),横轴不能直接用「当天第几分钟」——
|
||||||
|
* 那样 23:43 是 1423、07:37 是 457,画出来入睡点跑到了起床点右边,整条时段是反的。
|
||||||
|
* 以 20:00 为界重新起算,两个时刻都落在 0–960 这一段里,先后顺序就对了。
|
||||||
|
*/
|
||||||
|
const val SLEEP_ANCHOR_MINUTE_OF_DAY = 20 * 60
|
||||||
|
|
||||||
|
/** 一天 1440 分钟;[SLEEP_ANCHOR_MINUTE_OF_DAY] 加上它就是把时间推过零点。 */
|
||||||
|
private const val MINUTES_PER_DAY = 24 * 60
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 时间戳 → 「距最近一次 20:00 过了多少分钟」,跨过零点继续累加(0–1439)。
|
||||||
|
*
|
||||||
|
* 20:00–24:00 之间是 0–239,次日 00:00–20:00 之间是 240–1439。
|
||||||
|
* 睡眠时段图与睡眠统计都靠它把「几点睡、几点起」变成可以比较、可以求平均的连续量 ——
|
||||||
|
* 直接对 `HH:mm` 求平均会把 23:00 和 01:00 平均成 12:00,正好是睡得最香的时候。
|
||||||
|
*/
|
||||||
|
fun minutesSinceSleepAnchor(timestamp: Long): Int {
|
||||||
|
val offset = minutesOfDay(timestamp) - SLEEP_ANCHOR_MINUTE_OF_DAY
|
||||||
|
return if (offset >= 0) offset else offset + MINUTES_PER_DAY
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [minutesSinceSleepAnchor] 的逆运算:偏移量 → 「23:43」这样的时刻文案。 */
|
||||||
|
fun formatSleepAnchorOffset(offset: Int): String =
|
||||||
|
formatMinuteOfDay(SLEEP_ANCHOR_MINUTE_OF_DAY + offset)
|
||||||
@@ -2,17 +2,42 @@ package com.placeholder.ringhealth.core.designsystem
|
|||||||
|
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
/** 品牌主色(健康绿,仿小米运动健康首页大环)。 */
|
/** 品牌主色(健康绿)。首页大环、主按钮与选中态都用它。 */
|
||||||
val BrandGreen = Color(0xFF34C759)
|
val BrandGreen = Color(0xFF34C759)
|
||||||
val BrandGreenDark = Color(0xFF248A3D)
|
val BrandGreenDark = Color(0xFF248A3D)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 小字号绿:用于文字与图标。
|
||||||
|
*
|
||||||
|
* 与 [BrandGreen] 的区别是**对比度**而不是色相 —— `#34C759` 在 12sp 的文字上
|
||||||
|
* 对白底只有约 2.0:1,远低于 WCAG AA 要求的 4.5:1,读起来发虚。文字一律用这个更深的值。
|
||||||
|
*/
|
||||||
|
val BrandGreenText = Color(0xFF12894A)
|
||||||
|
|
||||||
/** 页面与卡片背景。 */
|
/** 页面与卡片背景。 */
|
||||||
val AppBackground = Color(0xFFF5F6F8)
|
val AppBackground = Color(0xFFEEF1F6)
|
||||||
val CardSurface = Color(0xFFFFFFFF)
|
val CardSurface = Color(0xFFFFFFFF)
|
||||||
val TextPrimary = Color(0xFF1C1C1E)
|
val TextPrimary = Color(0xFF14161C)
|
||||||
val TextSecondary = Color(0xFF8E8E93)
|
val TextSecondary = Color(0xFF8A8F98)
|
||||||
val DividerColor = Color(0xFFEFEFF4)
|
val DividerColor = Color(0xFFEFEFF4)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 卡片阴影。
|
||||||
|
*
|
||||||
|
* 不用纯黑:纯黑阴影叠在冷灰背景上会发脏、发绿。这两个值是从背景色相推出来的深蓝灰,
|
||||||
|
* 低透明度(6% / 8%),叠出来是「卡片浮起来」而不是「卡片镶了黑边」。
|
||||||
|
*
|
||||||
|
* 注意:`Modifier.shadow` 的 ambientColor / spotColor 仅在 **Android 9(API 28)及以上**生效,
|
||||||
|
* 本应用 minSdk 26,在 26/27 上会退化成系统默认黑阴影 —— 属于可接受的降级,不影响布局。
|
||||||
|
*/
|
||||||
|
val CardShadowAmbient = Color(0x101B2A4A)
|
||||||
|
val CardShadowSpot = Color(0x141B2A4A)
|
||||||
|
|
||||||
|
/** 底部导航选中态:浅绿药丸底 + 深绿图标文字。 */
|
||||||
|
val NavIndicator = Color(0xFFDCF3E4)
|
||||||
|
val NavSelected = BrandGreenText
|
||||||
|
val NavUnselected = Color(0xFF9AA0A6)
|
||||||
|
|
||||||
/** 各健康指标的语义色,全应用统一引用。 */
|
/** 各健康指标的语义色,全应用统一引用。 */
|
||||||
val MetricGreen = Color(0xFF34C759) // 步数
|
val MetricGreen = Color(0xFF34C759) // 步数
|
||||||
val MetricRed = Color(0xFFFF3B30) // 心率
|
val MetricRed = Color(0xFFFF3B30) // 心率
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package com.placeholder.ringhealth.core.designsystem
|
||||||
|
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.DirectionsWalk
|
||||||
|
import androidx.compose.material.icons.filled.Bedtime
|
||||||
|
import androidx.compose.material.icons.filled.Bloodtype
|
||||||
|
import androidx.compose.material.icons.filled.Favorite
|
||||||
|
import androidx.compose.material.icons.filled.Spa
|
||||||
|
import androidx.compose.material.icons.filled.Thermostat
|
||||||
|
import androidx.compose.material.icons.filled.WaterDrop
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import com.placeholder.ringhealth.domain.model.MetricType
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 指标的视觉身份:图标 + 语义色。
|
||||||
|
*
|
||||||
|
* **一个指标只有一处定义。** 此前这份对应关系抄了两份 —— 一份写在首页各指标卡的字面量里,
|
||||||
|
* 一份是详情页里一个从没被调用过的 `metricTint`。两份都在时不会报错,只会在改了一处之后
|
||||||
|
* 让同一个指标在首页和详情页显示成两种颜色,而且看不出是哪边错了。
|
||||||
|
*
|
||||||
|
* 放在 `core/designsystem` 而不是某个 `feature/` 下,是为了让首页和七个详情页都能直接用,
|
||||||
|
* 不必让 `feature/home` 去 import `feature/detail`。色板的唯一来源仍是设计文档 §7.2 与
|
||||||
|
* `Color.kt`,本文件只做映射,不新造颜色。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 指标类型 → 图标。 */
|
||||||
|
fun metricIcon(type: MetricType): ImageVector = when (type) {
|
||||||
|
// 步数在首页不占指标卡(它有一张跨两列的进度环大卡),但详情页的「未开启」说明卡
|
||||||
|
// 需要它,映射因此保持完整 —— 七个指标一个不缺。
|
||||||
|
MetricType.STEP -> Icons.AutoMirrored.Filled.DirectionsWalk
|
||||||
|
MetricType.HEART_RATE -> Icons.Filled.Favorite
|
||||||
|
MetricType.SLEEP -> Icons.Filled.Bedtime
|
||||||
|
MetricType.BLOOD_OXYGEN -> Icons.Filled.Bloodtype
|
||||||
|
MetricType.STRESS -> Icons.Filled.Spa
|
||||||
|
MetricType.TEMPERATURE -> Icons.Filled.Thermostat
|
||||||
|
MetricType.BLOOD_PRESSURE -> Icons.Filled.WaterDrop
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 指标类型 → 语义色(步数=绿、心率=红、睡眠=蓝、血氧=青、压力=紫、体温=橙、血压=黄)。 */
|
||||||
|
fun metricTint(type: MetricType): Color = when (type) {
|
||||||
|
MetricType.STEP -> MetricGreen
|
||||||
|
MetricType.HEART_RATE -> MetricRed
|
||||||
|
MetricType.SLEEP -> MetricBlue
|
||||||
|
MetricType.BLOOD_OXYGEN -> MetricCyan
|
||||||
|
MetricType.STRESS -> MetricPurple
|
||||||
|
MetricType.TEMPERATURE -> MetricOrange
|
||||||
|
MetricType.BLOOD_PRESSURE -> MetricYellow
|
||||||
|
}
|
||||||
@@ -3,16 +3,22 @@ package com.placeholder.ringhealth.core.designsystem
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.lightColorScheme
|
import androidx.compose.material3.lightColorScheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
private val RingHealthColors = lightColorScheme(
|
private val RingHealthColors = lightColorScheme(
|
||||||
primary = BrandGreen,
|
primary = BrandGreen,
|
||||||
onPrimary = androidx.compose.ui.graphics.Color.White,
|
onPrimary = Color.White,
|
||||||
secondary = BrandGreenDark,
|
secondary = BrandGreenDark,
|
||||||
background = AppBackground,
|
background = AppBackground,
|
||||||
onBackground = TextPrimary,
|
onBackground = TextPrimary,
|
||||||
surface = CardSurface,
|
surface = CardSurface,
|
||||||
onSurface = TextPrimary,
|
onSurface = TextPrimary,
|
||||||
surfaceVariant = DividerColor,
|
surfaceVariant = DividerColor,
|
||||||
|
// secondaryContainer 必须显式覆盖:它在 Material 3 里同时是
|
||||||
|
// 「底部导航选中态药丸底」和「FilterChip 选中底」的取色来源。不写的话会落到 M3 默认的
|
||||||
|
// 淡紫 #E8DEF8 —— 于是出现「淡紫底 + 绿色文字」的撞色(M1 验收时被当成正常记录了下来)。
|
||||||
|
secondaryContainer = NavIndicator,
|
||||||
|
onSecondaryContainer = NavSelected,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -5,17 +5,35 @@ import androidx.compose.ui.text.TextStyle
|
|||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
/** 大号数值样式:首页大环与详情页主数字。 */
|
/**
|
||||||
|
* 大号数值样式:首页大环与详情页主数字。
|
||||||
|
*
|
||||||
|
* `fontFeatureSettings = "tnum"` 打开等宽数字 —— 数值变化时每个字形宽度一致,
|
||||||
|
* 数字不会左右跳动。字体不支持时是空操作,无副作用。
|
||||||
|
* 负字距(-1sp)让多位数字排得更紧,是让大号数字「显贵」的关键一步。
|
||||||
|
*/
|
||||||
val MetricValueStyle = TextStyle(
|
val MetricValueStyle = TextStyle(
|
||||||
fontSize = 40.sp,
|
fontSize = 44.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
|
letterSpacing = (-1).sp,
|
||||||
|
fontFeatureSettings = "tnum",
|
||||||
color = TextPrimary,
|
color = TextPrimary,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** 中等数值样式:指标卡主数字。 */
|
/** 中等数值样式:指标卡主数字。同样开等宽数字,避免网格里各卡数字宽度参差。 */
|
||||||
val MetricTileValueStyle = TextStyle(
|
val MetricTileValueStyle = TextStyle(
|
||||||
fontSize = 22.sp,
|
fontSize = 22.sp,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.Bold,
|
||||||
|
letterSpacing = (-0.3).sp,
|
||||||
|
fontFeatureSettings = "tnum",
|
||||||
|
color = TextPrimary,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 首页问候语:一屏里字号最大的文字,承担「这是哪一页」的身份作用。 */
|
||||||
|
val GreetingStyle = TextStyle(
|
||||||
|
fontSize = 26.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
letterSpacing = (-0.4).sp,
|
||||||
color = TextPrimary,
|
color = TextPrimary,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+58
@@ -0,0 +1,58 @@
|
|||||||
|
package com.placeholder.ringhealth.core.designsystem.component
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.TextSecondary
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图表 X 轴刻度行。柱状图、阈值带、偏离条三张图共用,免得同一段对齐逻辑抄三份。
|
||||||
|
*
|
||||||
|
* 两种排布由 [labels] 与 [slotCount] 的关系决定:
|
||||||
|
* - 数量相等(周视图 7 根柱标 7 个星期)时按槽位等分,刻度正对着它那根柱子;
|
||||||
|
* - 数量更少(月视图 30 根柱只标 7 个日期)时两端对齐铺开 —— 此时槽宽只有约 10dp,
|
||||||
|
* 若也按槽位等分,每个文字会被压进 10dp 宽的格子里截断成一两个字。
|
||||||
|
*
|
||||||
|
* @param labels 刻度文字,从左到右。
|
||||||
|
* @param slotCount 数据槽位数量(柱数 / 点数)。
|
||||||
|
* @param modifier 外部修饰符。
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun ChartAxisLabels(
|
||||||
|
labels: List<String>,
|
||||||
|
slotCount: Int,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
if (labels.isEmpty()) return
|
||||||
|
if (slotCount > 0 && labels.size == slotCount) {
|
||||||
|
Row(modifier = modifier.fillMaxWidth()) {
|
||||||
|
labels.forEach { label -> AxisLabel(text = label, modifier = Modifier.weight(1f)) }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Row(modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||||
|
labels.forEach { label -> AxisLabel(text = label) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单个刻度文字。居中、单行、不换行 ——
|
||||||
|
* 换行会把刻度行撑成两行高,柱子与刻度的对齐就散了。
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun AxisLabel(text: String, modifier: Modifier = Modifier) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
modifier = modifier,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = TextSecondary,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
maxLines = 1,
|
||||||
|
softWrap = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
+44
-11
@@ -3,40 +3,73 @@ package com.placeholder.ringhealth.core.designsystem.component
|
|||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.Card
|
|
||||||
import androidx.compose.material3.CardDefaults
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.shadow
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.CardShadowAmbient
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.CardShadowSpot
|
||||||
|
|
||||||
|
/** 卡片圆角。全应用统一,改这里就是改所有卡片。 */
|
||||||
|
val HealthCardShape = RoundedCornerShape(20.dp)
|
||||||
|
|
||||||
|
/** 卡片默认阴影高度。 */
|
||||||
|
private val CardElevation = 5.dp
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 统一卡片容器:白底、大圆角、轻阴影。所有页面内容块都应包在它里面。
|
* 统一卡片容器:白底、大圆角、柔和阴影。所有页面内容块都应包在它里面。
|
||||||
|
*
|
||||||
|
* **为什么不用 `Card` + `CardDefaults.cardElevation`:**
|
||||||
|
* M3 的 `Card` 在 1~2dp 时的阴影几乎看不见,屏幕上就是一排纯白方块贴在灰底上,
|
||||||
|
* 没有纵深,整屏会「糊」成一片。这里改用 `Modifier.shadow` 并传入**带色相的深蓝灰**
|
||||||
|
* (见 [CardShadowAmbient] / [CardShadowSpot])而不是纯黑 —— 纯黑阴影叠在冷灰背景上会发脏。
|
||||||
|
* 同时 `tonalElevation = 0.dp` 关掉 M3 的 surface tint,保证卡片在任何高度都是纯白。
|
||||||
*
|
*
|
||||||
* 颜色取自主题的 surface(由 [com.placeholder.ringhealth.core.designsystem.RingHealthTheme]
|
* 颜色取自主题的 surface(由 [com.placeholder.ringhealth.core.designsystem.RingHealthTheme]
|
||||||
* 映射为设计系统的 CardSurface),因此升级深色模式时无需改动本文件。
|
* 映射为设计系统的 CardSurface),因此升级深色模式时无需改动本文件。
|
||||||
*
|
*
|
||||||
* @param modifier 外部修饰符,会在 fillMaxWidth 之前生效。
|
* @param modifier 外部修饰符,会在 fillMaxWidth 之前生效。
|
||||||
* @param onClick 非空时整卡可点击(如水/体重等可跳转的卡片);为空则纯展示。
|
* @param onClick 非空时整卡可点击(如可跳转的卡片);为空则纯展示。
|
||||||
* @param content 卡片内容,作用域为 ColumnScope,默认内边距 16dp。
|
* @param contentPadding 卡片内边距,默认 16dp;网格里的紧凑卡片可调小。
|
||||||
|
* @param content 卡片内容,作用域为 ColumnScope。
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun HealthCard(
|
fun HealthCard(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onClick: (() -> Unit)? = null,
|
onClick: (() -> Unit)? = null,
|
||||||
|
contentPadding: PaddingValues = PaddingValues(16.dp),
|
||||||
content: @Composable ColumnScope.() -> Unit,
|
content: @Composable ColumnScope.() -> Unit,
|
||||||
) {
|
) {
|
||||||
// 仅当传入点击回调时才挂 clickable,避免给纯展示卡片带来无谓的水波纹与无障碍焦点。
|
// 仅当传入点击回调时才挂 clickable,避免给纯展示卡片带来无谓的水波纹与无障碍焦点。
|
||||||
val clickModifier = if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier
|
val clickModifier = if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier
|
||||||
Card(
|
Surface(
|
||||||
modifier = modifier.fillMaxWidth().then(clickModifier),
|
modifier = modifier
|
||||||
shape = RoundedCornerShape(20.dp),
|
.fillMaxWidth()
|
||||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
// clip = false 让阴影画在卡片轮廓之外;卡片自身的形状由下面的 shape 参数裁剪。
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
|
.shadow(
|
||||||
|
elevation = CardElevation,
|
||||||
|
shape = HealthCardShape,
|
||||||
|
clip = false,
|
||||||
|
ambientColor = CardShadowAmbient,
|
||||||
|
spotColor = CardShadowSpot,
|
||||||
|
)
|
||||||
|
.then(clickModifier),
|
||||||
|
shape = HealthCardShape,
|
||||||
|
color = MaterialTheme.colorScheme.surface,
|
||||||
|
// 关掉 colorSurface 的 tonal 叠加,否则不同高度的卡片会呈现不同深浅的白。
|
||||||
|
tonalElevation = 0.dp,
|
||||||
|
shadowElevation = 0.dp,
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.padding(16.dp), content = content)
|
Column(modifier = Modifier.padding(contentPadding), content = content)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 供需要显式指定阴影的场景复用(如自定义容器)。 */
|
||||||
|
internal val HealthCardElevation: Dp = CardElevation
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package com.placeholder.ringhealth.core.designsystem.component
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/** 徽章底色相对图标色的不透明度:淡到足以让图标本身仍是主体,又足以看出那是一块色底。 */
|
||||||
|
private const val BADGE_ALPHA = 0.14f
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 圆形色底图标徽章:一层同色淡底 + 居中图标。
|
||||||
|
*
|
||||||
|
* 首页指标卡与「我的」页入口共用它。抽出来的直接原因是**两处原本不一致** ——
|
||||||
|
* 首页用色底圆形徽章、我的页用裸灰图标,同一套 App 里两种图标处理方式。
|
||||||
|
* 更实际的原因是:图标是纯装饰,给它配一块淡色底之后,一行「图标 + 文字」的入口
|
||||||
|
* 立刻有了视觉重心,而不必把图标本身调大调粗去抢注意力。
|
||||||
|
*
|
||||||
|
* @param icon 图标。
|
||||||
|
* @param tint 图标色,也是底色来源(底色取它的 [BADGE_ALPHA] 透明度)。
|
||||||
|
* @param modifier 外部修饰符。
|
||||||
|
* @param badgeSize 圆形底的直径。
|
||||||
|
* @param iconSize 图标本身的大小,需明显小于 [badgeSize] 才不会顶满圆。
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun IconBadge(
|
||||||
|
icon: ImageVector,
|
||||||
|
tint: Color,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
badgeSize: Dp = 30.dp,
|
||||||
|
iconSize: Dp = 17.dp,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.size(badgeSize)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(tint.copy(alpha = BADGE_ALPHA)),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
// 徽章是纯装饰,语义由旁边的标题文字承担,避免无障碍重复朗读。
|
||||||
|
contentDescription = null,
|
||||||
|
tint = tint,
|
||||||
|
modifier = Modifier.size(iconSize),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+112
-14
@@ -1,6 +1,8 @@
|
|||||||
package com.placeholder.ringhealth.core.designsystem.component
|
package com.placeholder.ringhealth.core.designsystem.component
|
||||||
|
|
||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -9,53 +11,149 @@ import androidx.compose.ui.geometry.CornerRadius
|
|||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.geometry.Size
|
import androidx.compose.ui.geometry.Size
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.PathEffect
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.DividerColor
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.TextSecondary
|
||||||
|
|
||||||
|
/** 柱状图基线相对灰线的粗细,1dp 在 420dpi 下是可见但不抢戏的一根细线。 */
|
||||||
|
private val BASELINE_WIDTH = 1.dp
|
||||||
|
|
||||||
|
/** 参考线的虚线节拍:6dp 实线 + 5dp 空白。 */
|
||||||
|
private val REFERENCE_DASH = floatArrayOf(6f, 5f)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 极简柱状图(Compose Canvas 手绘,无第三方依赖)。
|
* 柱状图(Compose Canvas 手绘,无第三方依赖)。
|
||||||
* values 为空时不绘制;被高亮的一根用纯色,其余半透明。
|
*
|
||||||
|
* 由三部分叠成:**基线** + **参考虚线** + **胶囊柱**,下方另有一行 X 轴刻度文字。
|
||||||
|
*
|
||||||
|
* 早先只有一排浮空的柱子 —— 没有基线,柱子像悬在半空;没有刻度,看不出哪根是哪天;
|
||||||
|
* 没有参照,也读不出「今天比平时高还是低」。三个都补上之后这张图才开始承载信息。
|
||||||
*
|
*
|
||||||
* 几何说明:画布横向按 values.size 等分得到每个槽位 slot,柱宽取槽位的 65%,
|
* 几何说明:画布横向按 values.size 等分得到每个槽位 slot,柱宽取槽位的 65%,
|
||||||
* 余下 35% 作为间隔,柱子因此在槽内水平居中;柱高 = 画布高 × (value / maxValue),
|
* 余下 35% 作为间隔,柱子因此在槽内水平居中;柱高 = 画布高 ×
|
||||||
* 底边贴合画布底边(y = 画布高 - 柱高),圆角半径取柱宽一半,形成胶囊柱。
|
* (value - 窗口下限) / (窗口上限 - 窗口下限),底边贴合基线(y = 画布高),
|
||||||
* maxValue 下限为 1f,避免「全为 0」时出现 0 除。
|
* 圆角半径取柱宽一半,形成胶囊柱。窗口见 [valueRange]。
|
||||||
*
|
*
|
||||||
* @param values 各柱数值(须为非负),按时间顺序从左到右。
|
* @param values 各柱数值(须为非负),按时间顺序从左到右。
|
||||||
* @param color 柱子颜色;高亮柱用纯色,其余用其 30% 透明度。
|
* @param color 柱子颜色;高亮柱用纯色,其余用其 30% 透明度。
|
||||||
* @param modifier 外部修饰符。
|
* @param modifier 外部修饰符。
|
||||||
* @param barHeight 画布高度,即最高柱的高度。
|
* @param barHeight 画布高度,即最高柱的高度。
|
||||||
* @param highlightLast 为 true 时高亮最后一根(如「今天」),否则高亮数值最大的一根。
|
* @param highlightLast 为 true 时高亮最后一根(如「今天」),否则高亮数值最大的一根。
|
||||||
|
* @param referenceLine 参考线的位置(与 [values] 同单位),如均值或目标值;为 null 则不画。
|
||||||
|
* @param axisLabels X 轴刻度文字。数量与 [values] 一致时按槽位一一对齐;
|
||||||
|
* 数量更少时(月度 30 根柱只标 5 个刻度)改为两端对齐铺开。
|
||||||
|
* @param barColorOf 按数值决定单根柱子的颜色;返回 null 表示用 [color](高亮柱纯色、其余 30%)。
|
||||||
|
* 压力页用它按等级给柱子着色。
|
||||||
|
* @param valueRange 取景窗口(纵轴范围)。**窄区间指标必须传它** ——
|
||||||
|
* 见下方 KDoc 的说明,不传会让血氧/体温这类指标的柱子全部一样高。
|
||||||
|
* @param bandRange 正常区间带(与 [values] 同单位),在柱子后面铺一层极淡的指标色。
|
||||||
|
* 心率页用它标出 60–100 的正常静息区间 —— 柱子高过或低于这条带子,一眼就知道出没出界。
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun MetricBarChart(
|
fun MetricBarChart(
|
||||||
values: List<Float>,
|
values: List<Float>,
|
||||||
color: Color,
|
color: Color,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
barHeight: Dp = 120.dp,
|
barHeight: Dp = 170.dp,
|
||||||
highlightLast: Boolean = true,
|
highlightLast: Boolean = true,
|
||||||
|
referenceLine: Float? = null,
|
||||||
|
axisLabels: List<String> = emptyList(),
|
||||||
|
barColorOf: ((Float) -> Color?)? = null,
|
||||||
|
valueRange: ClosedFloatingPointRange<Float>? = null,
|
||||||
|
bandRange: ClosedFloatingPointRange<Float>? = null,
|
||||||
) {
|
) {
|
||||||
Canvas(modifier = modifier.fillMaxWidth().height(barHeight)) {
|
Column(modifier = modifier.fillMaxWidth()) {
|
||||||
|
Canvas(modifier = Modifier.fillMaxWidth().height(barHeight)) {
|
||||||
if (values.isEmpty()) return@Canvas
|
if (values.isEmpty()) return@Canvas
|
||||||
val maxValue = values.max().coerceAtLeast(1f)
|
// 纵轴:默认从 0 到最大值,给了 valueRange 就按窗口取。
|
||||||
|
// 窄区间指标(血氧 95–99、体温 36.1–36.9)不传窗口时,所有柱子都落在满高的
|
||||||
|
// 96%~100% 之间,七根柱子一样高,整张图读不出任何东西 —— 这不是「观感差」,是数据被抹平了。
|
||||||
|
val low = valueRange?.start ?: 0f
|
||||||
|
val high = valueRange?.endInclusive ?: values.max().coerceAtLeast(1f)
|
||||||
|
// 窗口退化成一条线时直接不画:否则下面的 (value - low) / span 会除零得到 NaN。
|
||||||
|
val span = high - low
|
||||||
|
if (span <= 0f) return@Canvas
|
||||||
|
|
||||||
val gapRatio = 0.35f
|
val gapRatio = 0.35f
|
||||||
val slot = size.width / values.size
|
val slot = size.width / values.size
|
||||||
val barWidth = slot * (1f - gapRatio)
|
val barWidth = slot * (1f - gapRatio)
|
||||||
// 注意:maxValue 已被 coerceAtLeast(1f) 抬高,当所有值都小于 1 时
|
// 注意:不能拿 `values.indexOf(maxValue)` 找高亮柱 —— 无窗口时 high 被
|
||||||
// values.indexOf(maxValue) 会返回 -1 导致没有任何柱子被高亮,
|
// coerceAtLeast(1f) 抬高过,当所有值都小于 1 时 indexOf 返回 -1,没有柱子会被高亮。
|
||||||
// 因此这里改为直接取最大值的下标。
|
|
||||||
val highlightIndex = if (highlightLast) values.lastIndex else values.indices.maxByOrNull { values[it] } ?: 0
|
val highlightIndex = if (highlightLast) values.lastIndex else values.indices.maxByOrNull { values[it] } ?: 0
|
||||||
|
val baselineWidthPx = BASELINE_WIDTH.toPx()
|
||||||
|
|
||||||
|
// 基线先画,后画的柱子会压住它两端,视觉上柱子是「立在线上的」。
|
||||||
|
// y 取 height - 半线宽,否则 1dp 的线会有一半落在画布外被裁掉。
|
||||||
|
drawLine(
|
||||||
|
color = DividerColor,
|
||||||
|
start = Offset(x = 0f, y = size.height - baselineWidthPx / 2f),
|
||||||
|
end = Offset(x = size.width, y = size.height - baselineWidthPx / 2f),
|
||||||
|
strokeWidth = baselineWidthPx,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 值 → 画布纵坐标。窗口换算只写这一处,柱子/参考线/区间带共用。 */
|
||||||
|
fun yOf(v: Float): Float =
|
||||||
|
size.height - size.height * ((v - low) / span).coerceIn(0f, 1f)
|
||||||
|
|
||||||
|
// 正常区间带最先画,柱子压在上面。两层表达:极淡的填充 + 上下各一条边界线。
|
||||||
|
// 只有填充时,一整块横贯全宽的颜色块视觉重量会盖过柱子 —— 心率页试过 10% 的填充,
|
||||||
|
// 结果是「一堵粉墙,柱子贴在墙上」,看不出柱子是穿进带子里还是没够到。
|
||||||
|
// 加上两条边界线之后,带子的范围有了明确的边,「有没有出界」才读得出来。
|
||||||
|
if (bandRange != null) {
|
||||||
|
val top = yOf(bandRange.endInclusive)
|
||||||
|
val bottom = yOf(bandRange.start)
|
||||||
|
if (bottom > top) {
|
||||||
|
drawRect(
|
||||||
|
color = color.copy(alpha = 0.06f),
|
||||||
|
topLeft = Offset(0f, top),
|
||||||
|
size = Size(size.width, bottom - top),
|
||||||
|
)
|
||||||
|
listOf(top, bottom).forEach { y ->
|
||||||
|
drawLine(
|
||||||
|
color = color.copy(alpha = 0.35f),
|
||||||
|
start = Offset(0f, y),
|
||||||
|
end = Offset(size.width, y),
|
||||||
|
strokeWidth = BASELINE_WIDTH.toPx(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 参考线画在柱子下方:它是一条读数参照,不该盖住任何一根柱子。
|
||||||
|
// 用中性灰而不是柱色:参考线不是数据,跟着柱色走会被当成「第 8 根柱子」。
|
||||||
|
if (referenceLine != null) {
|
||||||
|
val y = yOf(referenceLine)
|
||||||
|
drawLine(
|
||||||
|
color = REFERENCE_LINE_COLOR,
|
||||||
|
start = Offset(x = 0f, y = y),
|
||||||
|
end = Offset(x = size.width, y = y),
|
||||||
|
strokeWidth = baselineWidthPx,
|
||||||
|
pathEffect = PathEffect.dashPathEffect(REFERENCE_DASH),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
values.forEachIndexed { index, value ->
|
values.forEachIndexed { index, value ->
|
||||||
val ratio = (value / maxValue).coerceIn(0f, 1f)
|
val h = size.height - yOf(value)
|
||||||
val h = size.height * ratio
|
|
||||||
val isHighlight = index == highlightIndex
|
val isHighlight = index == highlightIndex
|
||||||
|
// 调用方指定了按值取色就优先用它;返回 null 表示「这根交回默认规则」。
|
||||||
|
val barColor = barColorOf?.invoke(value)
|
||||||
|
?: if (isHighlight) color else color.copy(alpha = 0.3f)
|
||||||
drawRoundRect(
|
drawRoundRect(
|
||||||
color = if (isHighlight) color else color.copy(alpha = 0.3f),
|
color = barColor,
|
||||||
topLeft = Offset(x = index * slot + (slot - barWidth) / 2f, y = size.height - h),
|
topLeft = Offset(x = index * slot + (slot - barWidth) / 2f, y = size.height - h),
|
||||||
size = Size(width = barWidth, height = h),
|
size = Size(width = barWidth, height = h),
|
||||||
cornerRadius = CornerRadius(barWidth / 2f, barWidth / 2f),
|
cornerRadius = CornerRadius(barWidth / 2f, barWidth / 2f),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (axisLabels.isEmpty()) return@Column
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
ChartAxisLabels(labels = axisLabels, slotCount = values.size)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 参考虚线的颜色:中性灰,与任何指标色都不撞,一眼能分得出它是参照而非数据。 */
|
||||||
|
private val REFERENCE_LINE_COLOR = TextSecondary.copy(alpha = 0.65f)
|
||||||
|
|||||||
+108
@@ -0,0 +1,108 @@
|
|||||||
|
package com.placeholder.ringhealth.core.designsystem.component
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.CornerRadius
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.PathEffect
|
||||||
|
import androidx.compose.ui.graphics.StrokeCap
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.TextSecondary
|
||||||
|
|
||||||
|
/** 画布高度。偏离条是纵向的,比柱状图矮一点就够 —— 半幅只有 ±1℃。 */
|
||||||
|
private val DEVIATION_HEIGHT = 132.dp
|
||||||
|
|
||||||
|
/** 每根「棒棒糖」的杆粗。 */
|
||||||
|
private val STEM_WIDTH = 2.dp
|
||||||
|
|
||||||
|
/** 杆顶圆点的直径。 */
|
||||||
|
private val DOT_SIZE = 9.dp
|
||||||
|
|
||||||
|
/** 基线的虚线节拍,与柱状图参考线一致。 */
|
||||||
|
private val BASELINE_DASH = floatArrayOf(6f, 5f)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 偏离条:一条基线 + 每天一根从基线出发的「棒棒糖」,向上偏高、向下偏低。
|
||||||
|
*
|
||||||
|
* 体温页的头图。体温的特殊之处在于**绝对值没有意义** —— 36.5 和 36.8 都属于正常,
|
||||||
|
* 真正要读的是「比自己的基线高还是低」。柱状图从 y=0 起画,36.1–36.9 的差别会被压成
|
||||||
|
* 一条平带(这正是改造前体温页的样子);即使加了取景窗口,柱子仍然是从底部往上长,
|
||||||
|
* 视觉上仍在暗示「越高越好」,而体温不是这样。
|
||||||
|
*
|
||||||
|
* 把基线画在正中间、让值向两侧偏离,极性才读得出来:偏上一片暖色、偏下一片冷色。
|
||||||
|
* 上方用 [aboveColor]、下方用 [belowColor] —— 这是一套**页内发散配色**,
|
||||||
|
* 与「一个指标一个专属色」的规则不冲突:它在同一张图里表达方向,不是另一个指标的身份。
|
||||||
|
*
|
||||||
|
* @param values 各天的值,按时间顺序从左到右。
|
||||||
|
* @param baseline 基线值(体温取 36.5),画在画布垂直中央。
|
||||||
|
* @param span 半幅,即基线到画布上下边缘代表多少(体温取 1.0℃)。
|
||||||
|
* @param aboveColor 高于基线的颜色。
|
||||||
|
* @param belowColor 低于基线的颜色。
|
||||||
|
* @param modifier 外部修饰符。
|
||||||
|
* @param axisLabels X 轴刻度文字。
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun MetricDeviationBar(
|
||||||
|
values: List<Double>,
|
||||||
|
baseline: Double,
|
||||||
|
span: Double,
|
||||||
|
aboveColor: Color,
|
||||||
|
belowColor: Color,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
axisLabels: List<String> = emptyList(),
|
||||||
|
) {
|
||||||
|
Column(modifier = modifier.fillMaxWidth()) {
|
||||||
|
Canvas(modifier = Modifier.fillMaxWidth().height(DEVIATION_HEIGHT)) {
|
||||||
|
if (values.isEmpty()) return@Canvas
|
||||||
|
if (span <= 0.0) return@Canvas
|
||||||
|
|
||||||
|
val radius = DOT_SIZE.toPx() / 2f
|
||||||
|
// 可用半高:留出一个圆点的半径,偏离到半幅的点才不会被画布边缘裁掉。
|
||||||
|
val halfHeight = size.height / 2f - radius
|
||||||
|
if (halfHeight <= 0f) return@Canvas
|
||||||
|
val centerY = size.height / 2f
|
||||||
|
val slot = size.width / values.size
|
||||||
|
|
||||||
|
// 基线:体温的参照就是它,必须画得比数据更「静」—— 中性灰 + 虚线。
|
||||||
|
drawLine(
|
||||||
|
color = TextSecondary.copy(alpha = 0.55f),
|
||||||
|
start = Offset(0f, centerY),
|
||||||
|
end = Offset(size.width, centerY),
|
||||||
|
strokeWidth = 1.dp.toPx(),
|
||||||
|
pathEffect = PathEffect.dashPathEffect(BASELINE_DASH),
|
||||||
|
)
|
||||||
|
|
||||||
|
values.forEachIndexed { index, value ->
|
||||||
|
val cx = index * slot + slot / 2f
|
||||||
|
val deviation = value - baseline
|
||||||
|
// 超出半幅的值夹到边缘,而不是丢弃 —— 丢弃会让「那天没有数据」和
|
||||||
|
// 「那天偏得离谱」看起来一模一样。
|
||||||
|
val ratio = (deviation / span).coerceIn(-1.0, 1.0).toFloat()
|
||||||
|
val y = centerY - halfHeight * ratio
|
||||||
|
val tint = if (deviation >= 0.0) aboveColor else belowColor
|
||||||
|
|
||||||
|
// 杆从基线长到点,点画在杆顶。比单纯一个点更能读出「从基线偏出去多少」。
|
||||||
|
drawLine(
|
||||||
|
color = tint.copy(alpha = 0.45f),
|
||||||
|
start = Offset(cx, centerY),
|
||||||
|
end = Offset(cx, y),
|
||||||
|
strokeWidth = STEM_WIDTH.toPx(),
|
||||||
|
cap = StrokeCap.Round,
|
||||||
|
)
|
||||||
|
drawCircle(color = tint, radius = radius, center = Offset(cx, y))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (axisLabels.isEmpty()) return@Column
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
ChartAxisLabels(labels = axisLabels, slotCount = values.size)
|
||||||
|
}
|
||||||
|
}
|
||||||
+91
@@ -0,0 +1,91 @@
|
|||||||
|
package com.placeholder.ringhealth.core.designsystem.component
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.CornerRadius
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.CardSurface
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.DividerColor
|
||||||
|
|
||||||
|
/** 轨道的粗细。10dp 在 420dpi 下是一条能看清、又不至于喧宾夺主的带子。 */
|
||||||
|
private val TRACK_HEIGHT = 10.dp
|
||||||
|
|
||||||
|
/** 当前值圆点的直径。比轨道粗一截,让人一眼找到「我在哪」。 */
|
||||||
|
private val MARKER_SIZE = 18.dp
|
||||||
|
|
||||||
|
/** 圆点外的白圈宽度。 */
|
||||||
|
private val MARKER_RING = 3.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 区间条:一根横轴 + 一段高亮区间 + 一个当前值圆点。
|
||||||
|
*
|
||||||
|
* 心率页的头图。它回答的是「这个数落在哪儿」,而柱状图回答的是「这几天怎么变的」——
|
||||||
|
* 前者是单值判读,后者是趋势。心率的单值判读基准是「正常静息 60–100」,
|
||||||
|
* 一根光秃秃的数字「72」看不出这件事,落在区间条上就一目了然。
|
||||||
|
*
|
||||||
|
* 几何说明:横轴按 [axis] 线性映射到画布宽度,但左右各内缩一个圆点半径 ——
|
||||||
|
* 否则贴边的值(比如 40 或 120)圆点会有一半落在画布外被裁掉。
|
||||||
|
*
|
||||||
|
* @param value 当前值,须与 [axis]、[normal] 同单位。
|
||||||
|
* @param axis 横轴范围,通常比 [normal] 左右各留余量(见 `MetricReference.HEART_RATE_AXIS`)。
|
||||||
|
* @param normal 正常区间,在轨道上高亮出来。
|
||||||
|
* @param color 指标色,用于高亮段与圆点。
|
||||||
|
* @param modifier 外部修饰符。
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun MetricRangeBar(
|
||||||
|
value: Double,
|
||||||
|
axis: ClosedFloatingPointRange<Double>,
|
||||||
|
normal: ClosedFloatingPointRange<Double>,
|
||||||
|
color: Color,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Canvas(modifier = modifier.fillMaxWidth().height(MARKER_SIZE)) {
|
||||||
|
val span = (axis.endInclusive - axis.start).toFloat()
|
||||||
|
// 轴退化成一条线时直接不画,否则下面除法会得到 NaN。
|
||||||
|
if (span <= 0f) return@Canvas
|
||||||
|
|
||||||
|
val radius = MARKER_SIZE.toPx() / 2f
|
||||||
|
// 可用宽度左右各让出一个半径,圆点因此永远不会被画布边缘切掉。
|
||||||
|
val usable = size.width - radius * 2f
|
||||||
|
if (usable <= 0f) return@Canvas
|
||||||
|
|
||||||
|
/** 值 → 画布横坐标。 */
|
||||||
|
fun xOf(v: Double): Float =
|
||||||
|
radius + usable * ((v - axis.start).toFloat() / span).coerceIn(0f, 1f)
|
||||||
|
|
||||||
|
val centerY = size.height / 2f
|
||||||
|
val trackH = TRACK_HEIGHT.toPx()
|
||||||
|
val trackRadius = trackH / 2f
|
||||||
|
|
||||||
|
// 轨道:整条横轴的底
|
||||||
|
drawRoundRect(
|
||||||
|
color = DividerColor,
|
||||||
|
topLeft = Offset(radius, centerY - trackRadius),
|
||||||
|
size = Size(usable, trackH),
|
||||||
|
cornerRadius = CornerRadius(trackRadius, trackRadius),
|
||||||
|
)
|
||||||
|
|
||||||
|
// 正常区间高亮。宽度下限取一个轨道高,免得区间极窄时退化成看不见的一条。
|
||||||
|
val left = xOf(normal.start)
|
||||||
|
val width = (xOf(normal.endInclusive) - left).coerceAtLeast(trackH)
|
||||||
|
drawRoundRect(
|
||||||
|
color = color.copy(alpha = 0.30f),
|
||||||
|
topLeft = Offset(left, centerY - trackRadius),
|
||||||
|
size = Size(width, trackH),
|
||||||
|
cornerRadius = CornerRadius(trackRadius, trackRadius),
|
||||||
|
)
|
||||||
|
|
||||||
|
// 当前值圆点:先铺一圈与卡片同色的底,再叠实心点。
|
||||||
|
// 少了这圈底,点落在高亮段上时两个同色系会糊成一片,看不出点的边界。
|
||||||
|
val center = Offset(xOf(value), centerY)
|
||||||
|
drawCircle(color = CardSurface, radius = radius, center = center)
|
||||||
|
drawCircle(color = color, radius = radius - MARKER_RING.toPx(), center = center)
|
||||||
|
}
|
||||||
|
}
|
||||||
+113
@@ -0,0 +1,113 @@
|
|||||||
|
package com.placeholder.ringhealth.core.designsystem.component
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.CornerRadius
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.PathEffect
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.TextSecondary
|
||||||
|
|
||||||
|
/** 阈值带的高度。比柱状图矮 —— 它只承载一条窄区间,撑高只会留出空白。 */
|
||||||
|
private val STRIP_HEIGHT = 108.dp
|
||||||
|
|
||||||
|
/** 每天那个点的直径。 */
|
||||||
|
private val DOT_SIZE = 11.dp
|
||||||
|
|
||||||
|
/** 阈值虚线的节拍,与柱状图的参考线保持一致(6dp 实线 + 5dp 空白)。 */
|
||||||
|
private val THRESHOLD_DASH = floatArrayOf(6f, 5f)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 阈值带:一条窄区间 + 一条阈值虚线 + 每天一个点。
|
||||||
|
*
|
||||||
|
* 血氧页的头图。它和柱状图的区别是**回答的问题不同**:柱状图比的是「谁高谁矮」,
|
||||||
|
* 而血氧要判的是「有没有掉到线下」—— 一条 95% 的线画在那里,点在线下方就是偏低。
|
||||||
|
* 用柱状图表达这件事会很别扭:柱高按 0–100 画全是满格,按取景窗口画又读不出绝对位置。
|
||||||
|
*
|
||||||
|
* 点用圆点而不是细柱,是为了在 30 天的月视图里依然能分辨 —— 细柱互相挨着会连成一片色块。
|
||||||
|
*
|
||||||
|
* @param values 各天的值,按时间顺序从左到右。
|
||||||
|
* @param axis 纵轴范围(注意这是**纵向**映射),见 `MetricReference.BLOOD_OXYGEN_FLOOR`。
|
||||||
|
* @param threshold 阈值线的位置,低于它的点用 [warningColor] 画。
|
||||||
|
* @param color 达标点的颜色,通常是血氧的指标色。
|
||||||
|
* @param warningColor 低于阈值时的警示色。
|
||||||
|
* @param modifier 外部修饰符。
|
||||||
|
* @param axisLabels X 轴刻度文字,数量与 [values] 一致时按槽位对齐,否则两端铺开。
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun MetricThresholdStrip(
|
||||||
|
values: List<Double>,
|
||||||
|
axis: ClosedFloatingPointRange<Double>,
|
||||||
|
threshold: Double,
|
||||||
|
color: Color,
|
||||||
|
warningColor: Color,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
axisLabels: List<String> = emptyList(),
|
||||||
|
) {
|
||||||
|
Column(modifier = modifier.fillMaxWidth()) {
|
||||||
|
Canvas(modifier = Modifier.fillMaxWidth().height(STRIP_HEIGHT)) {
|
||||||
|
if (values.isEmpty()) return@Canvas
|
||||||
|
val span = (axis.endInclusive - axis.start).toFloat()
|
||||||
|
// 轴退化成一条线时直接不画,否则下面除法会得到 NaN。
|
||||||
|
if (span <= 0f) return@Canvas
|
||||||
|
|
||||||
|
val radius = DOT_SIZE.toPx() / 2f
|
||||||
|
// 纵向可用高度上下各让出一个半径,贴轴的点才不会被裁掉。
|
||||||
|
val usableY = size.height - radius * 2f
|
||||||
|
if (usableY <= 0f) return@Canvas
|
||||||
|
val slot = size.width / values.size
|
||||||
|
|
||||||
|
/** 值 → 画布纵坐标(值越大越靠上)。 */
|
||||||
|
fun yOf(v: Double): Float =
|
||||||
|
radius + usableY * (1f - ((v - axis.start).toFloat() / span).coerceIn(0f, 1f))
|
||||||
|
|
||||||
|
// 达标区:阈值以上铺一层极淡的指标色,扫一眼就知道「上面这块是安全的」。
|
||||||
|
val thresholdY = yOf(threshold)
|
||||||
|
drawRoundRect(
|
||||||
|
color = color.copy(alpha = 0.10f),
|
||||||
|
topLeft = Offset(0f, 0f),
|
||||||
|
size = Size(size.width, thresholdY.coerceAtLeast(0f)),
|
||||||
|
cornerRadius = CornerRadius(6.dp.toPx(), 6.dp.toPx()),
|
||||||
|
)
|
||||||
|
|
||||||
|
// 阈值线用中性灰:它是判读基准,不是数据,跟着指标色走会被当成「第 N+1 个点」。
|
||||||
|
drawLine(
|
||||||
|
color = TextSecondary.copy(alpha = 0.65f),
|
||||||
|
start = Offset(0f, thresholdY),
|
||||||
|
end = Offset(size.width, thresholdY),
|
||||||
|
strokeWidth = 1.dp.toPx(),
|
||||||
|
pathEffect = PathEffect.dashPathEffect(THRESHOLD_DASH),
|
||||||
|
)
|
||||||
|
|
||||||
|
values.forEachIndexed { index, value ->
|
||||||
|
val cx = index * slot + slot / 2f
|
||||||
|
val below = value < threshold
|
||||||
|
// 偏低的点额外套一圈警示色的淡晕,在月视图里连成一串时尤其醒目。
|
||||||
|
if (below) {
|
||||||
|
drawCircle(
|
||||||
|
color = warningColor.copy(alpha = 0.25f),
|
||||||
|
radius = radius * 2.1f,
|
||||||
|
center = Offset(cx, yOf(value)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
drawCircle(
|
||||||
|
color = if (below) warningColor else color,
|
||||||
|
radius = radius,
|
||||||
|
center = Offset(cx, yOf(value)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (axisLabels.isEmpty()) return@Column
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
ChartAxisLabels(labels = axisLabels, slotCount = values.size)
|
||||||
|
}
|
||||||
|
}
|
||||||
+33
-11
@@ -2,10 +2,11 @@ package com.placeholder.ringhealth.core.designsystem.component
|
|||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -19,18 +20,25 @@ import com.placeholder.ringhealth.core.designsystem.MetricTileValueStyle
|
|||||||
import com.placeholder.ringhealth.core.designsystem.TextSecondary
|
import com.placeholder.ringhealth.core.designsystem.TextSecondary
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 单个健康指标小卡:图标 + 标题 + 数值 + 单位。
|
* 单个健康指标小卡:色底图标徽章 + 标题 + 数值 + 单位。
|
||||||
|
*
|
||||||
|
* 为**两列网格**设计(首页 2×3),因此刻意做得紧凑:卡片内边距 14dp、图标 17dp。
|
||||||
|
* 图标外面套一层同色浅底的圆形徽章,是为了让网格里六张卡各自带一点颜色 —— 纯白卡片 +
|
||||||
|
* 灰色小图标排成网格会显得很素,色彩只能靠数字本身,而数字是黑白的。
|
||||||
*
|
*
|
||||||
* 文案全部由调用方传入(标题/数值/单位),组件内不出现任何用户可见字面量。
|
* 文案全部由调用方传入(标题/数值/单位),组件内不出现任何用户可见字面量。
|
||||||
* 图标为纯装饰(contentDescription = null),语义由 title 承担,避免无障碍重复朗读。
|
* 图标为纯装饰(contentDescription = null),语义由 title 承担,避免无障碍重复朗读。
|
||||||
*
|
*
|
||||||
* @param icon 指标图标,如 Icons.Filled.DirectionsWalk。
|
* @param icon 指标图标,如 Icons.Filled.Favorite。
|
||||||
* @param iconTint 图标着色,通常取设计系统中该指标的语义色。
|
* @param iconTint 图标着色,通常取设计系统中该指标的语义色。
|
||||||
* @param title 指标名(由调用方从字符串资源取得)。
|
* @param title 指标名(由调用方从字符串资源取得)。
|
||||||
* @param value 主数值文本。
|
* @param value 主数值文本。
|
||||||
* @param unit 单位文本,如「步」「次/分」。
|
* @param unit 单位文本,如「次/分」,可为空串。
|
||||||
* @param modifier 外部修饰符。
|
* @param modifier 外部修饰符。
|
||||||
* @param onClick 点击回调,默认空实现(配合 HealthCard 的整卡可点击)。
|
* @param enabled 为 false 时数值降级为普通灰字(用于「未开启」这类占位),
|
||||||
|
* 避免「未开启」三个字用大号粗体排出来,看着比真实数值还重。
|
||||||
|
* @param onClick 点击回调;为 null 表示卡片不可点击 —— 不挂 clickable,因此既没有水波纹
|
||||||
|
* 也不会触发导航,用于「未开启」这类没有数值的指标卡(有值的卡才可跳转详情)。
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun MetricTile(
|
fun MetricTile(
|
||||||
@@ -40,23 +48,37 @@ fun MetricTile(
|
|||||||
value: String,
|
value: String,
|
||||||
unit: String,
|
unit: String,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onClick: () -> Unit = {},
|
enabled: Boolean = true,
|
||||||
|
onClick: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
HealthCard(modifier = modifier, onClick = onClick) {
|
HealthCard(
|
||||||
|
modifier = modifier,
|
||||||
|
onClick = onClick,
|
||||||
|
contentPadding = PaddingValues(14.dp),
|
||||||
|
) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Icon(imageVector = icon, contentDescription = null, tint = iconTint, modifier = Modifier.size(18.dp))
|
IconBadge(icon = icon, tint = iconTint)
|
||||||
Text(
|
Text(
|
||||||
text = title,
|
text = title,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = TextSecondary,
|
color = TextSecondary,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = Modifier.padding(start = 6.dp),
|
modifier = Modifier.padding(start = 8.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Column(modifier = Modifier.padding(top = 6.dp), verticalArrangement = Arrangement.Bottom) {
|
Spacer(modifier = Modifier.height(10.dp))
|
||||||
|
Column(verticalArrangement = Arrangement.Bottom) {
|
||||||
Row(verticalAlignment = Alignment.Bottom) {
|
Row(verticalAlignment = Alignment.Bottom) {
|
||||||
|
if (enabled) {
|
||||||
Text(text = value, style = MetricTileValueStyle)
|
Text(text = value, style = MetricTileValueStyle)
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
text = value,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = TextSecondary,
|
||||||
|
)
|
||||||
|
}
|
||||||
Text(
|
Text(
|
||||||
text = unit,
|
text = unit,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
|||||||
+272
@@ -0,0 +1,272 @@
|
|||||||
|
package com.placeholder.ringhealth.core.designsystem.component
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.CornerRadius
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.drawText
|
||||||
|
import androidx.compose.ui.text.rememberTextMeasurer
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.placeholder.ringhealth.core.common.formatSleepAnchorOffset
|
||||||
|
import com.placeholder.ringhealth.core.common.minutesSinceSleepAnchor
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.DividerColor
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.MetricBlue
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.TextSecondary
|
||||||
|
import com.placeholder.ringhealth.domain.model.SleepSession
|
||||||
|
import com.placeholder.ringhealth.domain.model.SleepSegment
|
||||||
|
import com.placeholder.ringhealth.domain.model.SleepStage
|
||||||
|
|
||||||
|
/** 每行日期标签的宽度,够放下「周三」两个字。 */
|
||||||
|
private val ROW_LABEL_WIDTH = 40.dp
|
||||||
|
|
||||||
|
/** 刻度文字所占的高度(文字本身 + 与图表之间的空隙)。 */
|
||||||
|
private val AXIS_LABEL_BAND = 20.dp
|
||||||
|
|
||||||
|
/** 刻度文字与图表下沿的间距。 */
|
||||||
|
private val AXIS_LABEL_GAP = 6.dp
|
||||||
|
|
||||||
|
/** 竖向网格线的粗细。比柱图的基线还细一档:它是背景参照,不该和时段条抢注意力。 */
|
||||||
|
private val GRID_WIDTH = 1.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 时段图圆角条的默认「底色」(分期模式下的轨道)。极浅蓝,与 MetricBlue 同色系,
|
||||||
|
* 让深睡/浅睡子矩形自然浮起来;AWAKE 用另一族暖色,更显眼。给 [StageTints.track] 用作兜底值。
|
||||||
|
*/
|
||||||
|
internal val DefaultStageTrackTint: Color = MetricBlue.copy(alpha = 0.10f)
|
||||||
|
|
||||||
|
/** 一分钟对应的毫秒数。本地常量,不引用 `MockDataProvider` 那份以免引入循环依赖。 */
|
||||||
|
private const val MILLIS_PER_MINUTE = 60_000L
|
||||||
|
|
||||||
|
/** 分期 → 着色。单色模式下没有 `stageTints`,仍用 [color];分期模式按 stage 取色。 */
|
||||||
|
private fun colorForStage(stage: SleepStage, tints: StageTints?, fallback: Color): Color = when (stage) {
|
||||||
|
SleepStage.DEEP -> tints?.deep ?: fallback
|
||||||
|
SleepStage.LIGHT -> tints?.light ?: fallback
|
||||||
|
SleepStage.AWAKE -> tints?.awake ?: fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 时段图内单条睡眠条的分期配色。**为 null 表示该图不画分期**,所有夜晚都按单色画。
|
||||||
|
*
|
||||||
|
* 三色通常取自同一色系的两个深浅(DEEP / LIGHT)+ 一个跳出该色系的 AWAKE 暖色 ——
|
||||||
|
* 「中途醒」是用户最该被告知的信息之一,让它和浅睡同色系会让它和「睡得浅」混淆;
|
||||||
|
* 暖色与睡眠的冷色调形成对比,眼睛会先抓到这里。颜色同时驱动头图分期条与时段图。
|
||||||
|
*
|
||||||
|
* `track` 是时段图内圆角条的底色(rail),子矩形落在这条底色之上 —— 与 AWAKE 区分开来。
|
||||||
|
* 不传则用 [DefaultStageTrackTint]。
|
||||||
|
*/
|
||||||
|
data class StageTints(
|
||||||
|
val deep: Color,
|
||||||
|
val light: Color,
|
||||||
|
val awake: Color,
|
||||||
|
val track: Color = DefaultStageTrackTint,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 睡眠时段图:**每晚一行**,一根圆角条从入睡时刻横到醒来时刻,横轴是钟点。
|
||||||
|
*
|
||||||
|
* 它回答的是柱状图回答不了的那个问题:「我这一周都是几点睡的」。
|
||||||
|
* 柱状图只画「睡了多久」,七根一样高的柱子看不出作息是不是在往后拖;
|
||||||
|
* 时段条的长短同样是时长,但**左端的位置**是入睡时刻,连起来看就是一条作息曲线。
|
||||||
|
*
|
||||||
|
* 横轴以晚上 20:00 为锚点跨过零点:一晚从 23:43 到 07:37,用「当天第几分钟」表示会得到
|
||||||
|
* 1423 → 457 这样一对反过来的数,画出来入睡点跑到起床点右边。换算见 `minutesSinceSleepAnchor`。
|
||||||
|
*
|
||||||
|
* **分期染色**:[SleepSession.stages] 非空的那几晚,条会被拆成多个染色子矩形;
|
||||||
|
* 传 [stageTints] 启用这一支。不传则所有夜晚走单色原行为 —— 多数 Mock 数据没有分期序列,
|
||||||
|
* 这是默认状态;有这层数据的夜晚再单独着色。M2 接入真机后会逐步补全序列,
|
||||||
|
* 把每一晚都变成可分期着色的版本。
|
||||||
|
*
|
||||||
|
* @param sessions 睡眠时段,按时间正序。
|
||||||
|
* @param color 单色模式下的时段条颜色;分期模式下充当未指定分期的兜底色。
|
||||||
|
* @param rowLabel 每行左侧的标签(如「周三」)。由调用方从 [SleepSession.wakeDate] 取,
|
||||||
|
* 组件本身不做资源查找;标成 `@Composable` 是因为调用方多半要 `stringResource`。
|
||||||
|
* @param stageTints 为非 null 时启用分期染色;为 null 时全部夜晚走单色。
|
||||||
|
* @param modifier 外部修饰符。
|
||||||
|
* @param rowHeight 时段条的高度。
|
||||||
|
* @param rowGap 行距。
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun SleepWindowChart(
|
||||||
|
sessions: List<SleepSession>,
|
||||||
|
color: Color,
|
||||||
|
rowLabel: @Composable (SleepSession) -> String,
|
||||||
|
stageTints: StageTints? = null,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
rowHeight: Dp = 16.dp,
|
||||||
|
rowGap: Dp = 10.dp,
|
||||||
|
) {
|
||||||
|
if (sessions.isEmpty()) return
|
||||||
|
|
||||||
|
// 横轴范围由这批数据自己决定:写死「20:00–12:00」的话,某晚熬到中午才睡,
|
||||||
|
// 那根条会被右端裁掉一截,看起来像睡得短,而它其实只是被画短了。
|
||||||
|
val axis = remember(sessions) { SleepAxis.of(sessions) }
|
||||||
|
val measurer = rememberTextMeasurer()
|
||||||
|
val tickStyle: TextStyle = MaterialTheme.typography.labelSmall.copy(color = TextSecondary)
|
||||||
|
val labelStyle = MaterialTheme.typography.labelSmall
|
||||||
|
|
||||||
|
// 行高 + 行距的展开只写一次,左侧标签列与画布共用,两边的行才对得齐。
|
||||||
|
val pitch = rowHeight + rowGap
|
||||||
|
val chartHeight = pitch * sessions.size - rowGap
|
||||||
|
|
||||||
|
Row(modifier = modifier.fillMaxWidth()) {
|
||||||
|
Column(modifier = Modifier.width(ROW_LABEL_WIDTH)) {
|
||||||
|
sessions.forEach { session ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.height(rowHeight),
|
||||||
|
contentAlignment = Alignment.CenterStart,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = rowLabel(session),
|
||||||
|
style = labelStyle,
|
||||||
|
color = TextSecondary,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(rowGap))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Canvas(modifier = Modifier.weight(1f).height(chartHeight + AXIS_LABEL_BAND)) {
|
||||||
|
val chartPx = chartHeight.toPx()
|
||||||
|
val rowHeightPx = rowHeight.toPx()
|
||||||
|
val rowPitchPx = pitch.toPx()
|
||||||
|
|
||||||
|
/** 横轴偏移量(距锚点 20:00 的分钟数)→ 画布横坐标。 */
|
||||||
|
fun xOf(minute: Int): Float =
|
||||||
|
size.width * ((minute - axis.lo).toFloat() / axis.span.toFloat())
|
||||||
|
|
||||||
|
// 竖向网格线先画,时段条压在它上面。整张图共用一张画布而不是每晚一个,
|
||||||
|
// 网格线才能从上到下连成一条,而不是每晚断成一截。
|
||||||
|
axis.ticks.forEach { tick ->
|
||||||
|
val x = xOf(tick)
|
||||||
|
drawLine(
|
||||||
|
color = DividerColor,
|
||||||
|
start = Offset(x = x, y = 0f),
|
||||||
|
end = Offset(x = x, y = chartPx),
|
||||||
|
strokeWidth = GRID_WIDTH.toPx(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
sessions.forEachIndexed { index, session ->
|
||||||
|
val top = index * rowPitchPx
|
||||||
|
// 轴的上下界由这批数据算出,所以 coerce 在正常情况下不生效;
|
||||||
|
// 留着是防止将来有人把 axis 换成固定窗口后,某根条悄悄画到画布外面。
|
||||||
|
val start = xOf(minutesSinceSleepAnchor(session.sleepStart)).coerceIn(0f, size.width)
|
||||||
|
val end = xOf(minutesSinceSleepAnchor(session.sleepEnd)).coerceIn(0f, size.width)
|
||||||
|
if (end <= start) return@forEachIndexed
|
||||||
|
|
||||||
|
val stages = session.stages
|
||||||
|
if (stages.isNullOrEmpty() || stageTints == null) {
|
||||||
|
// 单色原行为:所有夜晚(默认)或没拿到分期数据的夜晚。
|
||||||
|
drawRoundRect(
|
||||||
|
color = color,
|
||||||
|
topLeft = Offset(x = start, y = top),
|
||||||
|
size = Size(width = end - start, height = rowHeightPx),
|
||||||
|
cornerRadius = CornerRadius(rowHeightPx / 2f, rowHeightPx / 2f),
|
||||||
|
)
|
||||||
|
return@forEachIndexed
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分期模式:先画整条 track 浅色圆角条作底色(保住两端圆角 + 给未覆盖区域兜底),
|
||||||
|
// 再把每个分期子矩形用方角 `drawRect` 叠在上面 —— 子矩形落在圆角范围内,
|
||||||
|
// 视觉上左右两端仍是 pill 形。AWAKE 子矩形此时是独立暖色,从 track 上明显浮出,
|
||||||
|
// 不再是上一版的「看起来像渲染瑕疵的窄缝」。
|
||||||
|
val baseTint = stageTints.track
|
||||||
|
drawRoundRect(
|
||||||
|
color = baseTint,
|
||||||
|
topLeft = Offset(x = start, y = top),
|
||||||
|
size = Size(width = end - start, height = rowHeightPx),
|
||||||
|
cornerRadius = CornerRadius(rowHeightPx / 2f, rowHeightPx / 2f),
|
||||||
|
)
|
||||||
|
stages.forEach { segment ->
|
||||||
|
val segStartTs = session.sleepStart + segment.startOffsetMinute * MILLIS_PER_MINUTE
|
||||||
|
val segEndTs = session.sleepStart + segment.endOffsetMinute * MILLIS_PER_MINUTE
|
||||||
|
val segStart = xOf(minutesSinceSleepAnchor(segStartTs)).coerceIn(0f, size.width)
|
||||||
|
val segEnd = xOf(minutesSinceSleepAnchor(segEndTs)).coerceIn(0f, size.width)
|
||||||
|
if (segEnd <= segStart) return@forEach
|
||||||
|
drawRect(
|
||||||
|
color = colorForStage(segment.stage, stageTints, color),
|
||||||
|
topLeft = Offset(x = segStart, y = top),
|
||||||
|
size = Size(width = segEnd - segStart, height = rowHeightPx),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 刻度文字画在画布内,而不是另起一行等宽 Text —— 等宽的文本框中心落在
|
||||||
|
// (i + 0.5) / n,刻度线落在 i / (n - 1),中间几档会差出小半个格。
|
||||||
|
// 在画布里量一次宽度再居中,刻度线和文字是同一个 xOf,对得严丝合缝。
|
||||||
|
val labelTop = chartPx + AXIS_LABEL_GAP.toPx()
|
||||||
|
axis.ticks.forEachIndexed { index, tick ->
|
||||||
|
val layout = measurer.measure(formatSleepAnchorOffset(tick), tickStyle)
|
||||||
|
val x = when (index) {
|
||||||
|
// 首尾两档贴边:居中的话各有一半落在画布外,会被裁掉半个字。
|
||||||
|
0 -> 0f
|
||||||
|
axis.ticks.last -> size.width - layout.size.width
|
||||||
|
else -> xOf(tick) - layout.size.width / 2f
|
||||||
|
}
|
||||||
|
drawText(textLayoutResult = layout, topLeft = Offset(x = x, y = labelTop))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 横轴窗口:`lo`–`hi` 是距锚点 20:00 的分钟数,[step] 是刻度间隔。
|
||||||
|
*
|
||||||
|
* 三者满足 `(hi - lo) % step == 0`,刻度因此均匀铺满整条轴 ——
|
||||||
|
* 首尾两档正好落在画布两端,中间每档等距,读起来不需要心算。
|
||||||
|
*/
|
||||||
|
private data class SleepAxis(val lo: Int, val hi: Int, val step: Int) {
|
||||||
|
val span: Int get() = hi - lo
|
||||||
|
|
||||||
|
/** 各刻度相对锚点的分钟数,从 [lo] 到 [hi] 递增。 */
|
||||||
|
val ticks: IntProgression get() = lo..hi step step
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** 刻度间隔候选,从密到疏:2 小时、3 小时、4 小时、6 小时。 */
|
||||||
|
private val STEP_CANDIDATES = listOf(120, 180, 240, 360)
|
||||||
|
|
||||||
|
/** 刻度数量的上限。再多就该换更疏的间隔,否则刻度文字会挤成一团。 */
|
||||||
|
private const val MAX_TICKS = 8
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按数据选窗口:起点向下取整、终点向上取整到最近的刻度线。
|
||||||
|
*
|
||||||
|
* 取整是均匀刻度的前提 —— 直接拿最早入睡时刻当左端,第一个刻度就不在整点上,
|
||||||
|
* 后面每一档都跟着错位。
|
||||||
|
*/
|
||||||
|
fun of(sessions: List<SleepSession>): SleepAxis {
|
||||||
|
val rawLo = sessions.minOf { minutesSinceSleepAnchor(it.sleepStart) }
|
||||||
|
val rawHi = sessions.maxOf { minutesSinceSleepAnchor(it.sleepEnd) }
|
||||||
|
val step = STEP_CANDIDATES.firstOrNull { (rawHi - rawLo) / it + 3 <= MAX_TICKS }
|
||||||
|
?: STEP_CANDIDATES.last()
|
||||||
|
val lo = floorTo(rawLo, step)
|
||||||
|
// 所有夜晚长度都短于一个间隔时 hi 会等于 lo,轴退成一条线,除零得到 NaN。
|
||||||
|
// 至少留一格。
|
||||||
|
val hi = ceilTo(rawHi, step).let { if (it <= lo) lo + step else it }
|
||||||
|
return SleepAxis(lo = lo, hi = hi, step = step)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 向下取整到 [step] 的整数倍([value] 为负时也要正确,即 -60、180 → -180)。 */
|
||||||
|
private fun floorTo(value: Int, step: Int): Int = value - ((value % step) + step) % step
|
||||||
|
|
||||||
|
/** 向上取整到 [step] 的整数倍。 */
|
||||||
|
private fun ceilTo(value: Int, step: Int): Int = value + ((step - value % step) % step)
|
||||||
|
}
|
||||||
|
}
|
||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
package com.placeholder.ringhealth.core.designsystem.component
|
||||||
|
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import com.placeholder.ringhealth.R
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 子页顶栏:返回箭头 + 标题。
|
||||||
|
*
|
||||||
|
* 抽成组件而不是让三个子页各写一遍,是因为**底色这件事必须三个页面同时正确**:
|
||||||
|
* 顶栏默认容器色是 `surface`(本应用里是纯白),而页面底色是浅灰 `background` ——
|
||||||
|
* 两者不同色时,AppBar 会在浅灰页面上糊出一条白色横带,与下方内容形成硬分界线,
|
||||||
|
* 且状态栏那一条仍是页面底色,于是顶部出现「灰、白、灰」三层。
|
||||||
|
* 统一取 `background` 后顶栏与页面融为一体,页面只剩「浅灰底 + 白卡片」两层,干净得多。
|
||||||
|
*
|
||||||
|
* @param title 标题文案,由调用方从字符串资源取得(不在此处硬编码)。
|
||||||
|
* @param onBack 返回回调。
|
||||||
|
* @param modifier 外部修饰符。
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun SubPageTopAppBar(
|
||||||
|
title: String,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
TopAppBar(
|
||||||
|
modifier = modifier,
|
||||||
|
title = { Text(text = title) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = stringResource(R.string.action_back),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.background,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.placeholder.ringhealth.core.navigation
|
package com.placeholder.ringhealth.core.navigation
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.FitnessCenter
|
import androidx.compose.material.icons.filled.FitnessCenter
|
||||||
@@ -10,6 +11,7 @@ import androidx.compose.material3.Icon
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.NavigationBar
|
import androidx.compose.material3.NavigationBar
|
||||||
import androidx.compose.material3.NavigationBarItem
|
import androidx.compose.material3.NavigationBarItem
|
||||||
|
import androidx.compose.material3.NavigationBarItemDefaults
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -26,12 +28,19 @@ import androidx.navigation.compose.currentBackStackEntryAsState
|
|||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import androidx.navigation.navArgument
|
import androidx.navigation.navArgument
|
||||||
import com.placeholder.ringhealth.R
|
import com.placeholder.ringhealth.R
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.NavIndicator
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.NavSelected
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.NavUnselected
|
||||||
import com.placeholder.ringhealth.domain.model.MetricType
|
import com.placeholder.ringhealth.domain.model.MetricType
|
||||||
import com.placeholder.ringhealth.feature.device.DeviceScreen
|
import com.placeholder.ringhealth.feature.device.DeviceScreen
|
||||||
import com.placeholder.ringhealth.feature.device.DeviceSettingsScreen
|
import com.placeholder.ringhealth.feature.device.DeviceSettingsScreen
|
||||||
import com.placeholder.ringhealth.feature.detail.MetricDetailScreen
|
import com.placeholder.ringhealth.feature.detail.MetricDetailScreen
|
||||||
import com.placeholder.ringhealth.feature.home.HomeScreen
|
import com.placeholder.ringhealth.feature.home.HomeScreen
|
||||||
|
import com.placeholder.ringhealth.feature.profile.AboutScreen
|
||||||
|
import com.placeholder.ringhealth.feature.profile.HelpScreen
|
||||||
|
import com.placeholder.ringhealth.feature.profile.NotificationsScreen
|
||||||
import com.placeholder.ringhealth.feature.profile.ProfileScreen
|
import com.placeholder.ringhealth.feature.profile.ProfileScreen
|
||||||
|
import com.placeholder.ringhealth.feature.profile.SettingsScreen
|
||||||
import com.placeholder.ringhealth.feature.workout.WorkoutHistoryScreen
|
import com.placeholder.ringhealth.feature.workout.WorkoutHistoryScreen
|
||||||
import com.placeholder.ringhealth.feature.workout.WorkoutScreen
|
import com.placeholder.ringhealth.feature.workout.WorkoutScreen
|
||||||
|
|
||||||
@@ -61,6 +70,14 @@ private fun tabRouteFor(route: String?): String? = when (route) {
|
|||||||
Routes.DETAIL_PATTERN -> Routes.HOME
|
Routes.DETAIL_PATTERN -> Routes.HOME
|
||||||
Routes.DEVICE_SETTINGS -> Routes.DEVICE
|
Routes.DEVICE_SETTINGS -> Routes.DEVICE
|
||||||
Routes.WORKOUT_HISTORY -> Routes.WORKOUT
|
Routes.WORKOUT_HISTORY -> Routes.WORKOUT
|
||||||
|
// 2026-09-10 晚:四个「我的」子页加进来,子页栈顶时底栏仍高亮「我的」。
|
||||||
|
// 之前一批 PROFILE_* 路由不存在,对应的「未开启」死链是 PROGRESS.md §3.2 报告的
|
||||||
|
// 已知结构性问题;本批一并收口。
|
||||||
|
Routes.PROFILE_SETTINGS,
|
||||||
|
Routes.PROFILE_NOTIFICATIONS,
|
||||||
|
Routes.PROFILE_ABOUT,
|
||||||
|
Routes.PROFILE_HELP,
|
||||||
|
-> Routes.PROFILE
|
||||||
else -> route
|
else -> route
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,6 +99,16 @@ fun RingHealthApp(navController: NavHostController = rememberNavController()) {
|
|||||||
onClick = { navController.navigateToTab(tab.route) },
|
onClick = { navController.navigateToTab(tab.route) },
|
||||||
icon = { Icon(imageVector = tab.icon, contentDescription = null) },
|
icon = { Icon(imageVector = tab.icon, contentDescription = null) },
|
||||||
label = { Text(stringResource(tab.labelRes)) },
|
label = { Text(stringResource(tab.labelRes)) },
|
||||||
|
// 颜色显式指定,不吃主题默认值。M3 的默认选中底色来自
|
||||||
|
// secondaryContainer,本应用虽已在 Theme 里把它改成浅绿,但底栏是全应用
|
||||||
|
// 最显眼的一处选中态,写明确一点,避免将来有人改主题时无意改到这里。
|
||||||
|
colors = NavigationBarItemDefaults.colors(
|
||||||
|
selectedIconColor = NavSelected,
|
||||||
|
selectedTextColor = NavSelected,
|
||||||
|
indicatorColor = NavIndicator,
|
||||||
|
unselectedIconColor = NavUnselected,
|
||||||
|
unselectedTextColor = NavUnselected,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -90,7 +117,12 @@ fun RingHealthApp(navController: NavHostController = rememberNavController()) {
|
|||||||
NavHost(
|
NavHost(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
startDestination = Routes.HOME,
|
startDestination = Routes.HOME,
|
||||||
modifier = Modifier.padding(innerPadding),
|
// consumeWindowInsets 必须跟着 padding 一起写:外层 Scaffold 没有 topBar,
|
||||||
|
// 它的 innerPadding.top 就是状态栏高度,若只 padding 不消费,这个 inset 会继续
|
||||||
|
// 往下传,详情页内层 Scaffold 的 TopAppBar 会再加一次状态栏内边距 ——
|
||||||
|
// AppBar 被推低约两个状态栏,上方露出一条背景色带,与四个 Tab 页明显不同。
|
||||||
|
// 消费后内层拿到的是已扣减的 inset,两个方向都安全(若外层早已消费,这里是 no-op)。
|
||||||
|
modifier = Modifier.padding(innerPadding).consumeWindowInsets(innerPadding),
|
||||||
) {
|
) {
|
||||||
composable(Routes.HOME) {
|
composable(Routes.HOME) {
|
||||||
HomeScreen(onOpenDetail = { metric -> navController.navigate(Routes.detail(metric)) })
|
HomeScreen(onOpenDetail = { metric -> navController.navigate(Routes.detail(metric)) })
|
||||||
@@ -102,13 +134,33 @@ fun RingHealthApp(navController: NavHostController = rememberNavController()) {
|
|||||||
DeviceScreen(onOpenSettings = { navController.navigate(Routes.DEVICE_SETTINGS) })
|
DeviceScreen(onOpenSettings = { navController.navigate(Routes.DEVICE_SETTINGS) })
|
||||||
}
|
}
|
||||||
composable(Routes.PROFILE) {
|
composable(Routes.PROFILE) {
|
||||||
ProfileScreen()
|
ProfileScreen(
|
||||||
|
onOpenSettings = { navController.navigate(Routes.PROFILE_SETTINGS) },
|
||||||
|
onOpenNotifications = { navController.navigate(Routes.PROFILE_NOTIFICATIONS) },
|
||||||
|
onOpenAbout = { navController.navigate(Routes.PROFILE_ABOUT) },
|
||||||
|
onOpenHelp = { navController.navigate(Routes.PROFILE_HELP) },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
// 「我的」四个子页:每个都带自己的 TopAppBar 与返回箭头,与详情页一致。
|
||||||
|
composable(Routes.PROFILE_SETTINGS) {
|
||||||
|
SettingsScreen(onBack = { navController.popBackStack() })
|
||||||
|
}
|
||||||
|
composable(Routes.PROFILE_NOTIFICATIONS) {
|
||||||
|
NotificationsScreen(onBack = { navController.popBackStack() })
|
||||||
|
}
|
||||||
|
composable(Routes.PROFILE_ABOUT) {
|
||||||
|
AboutScreen(onBack = { navController.popBackStack() })
|
||||||
|
}
|
||||||
|
composable(Routes.PROFILE_HELP) {
|
||||||
|
HelpScreen(onBack = { navController.popBackStack() })
|
||||||
|
}
|
||||||
|
// 两个子页都带自己的 TopAppBar 与返回箭头(原先没有,与详情页不一致 ——
|
||||||
|
// 详情页有 ←,这两页只能靠系统返回手势或点底部 Tab 离开)。
|
||||||
composable(Routes.DEVICE_SETTINGS) {
|
composable(Routes.DEVICE_SETTINGS) {
|
||||||
DeviceSettingsScreen()
|
DeviceSettingsScreen(onBack = { navController.popBackStack() })
|
||||||
}
|
}
|
||||||
composable(Routes.WORKOUT_HISTORY) {
|
composable(Routes.WORKOUT_HISTORY) {
|
||||||
WorkoutHistoryScreen()
|
WorkoutHistoryScreen(onBack = { navController.popBackStack() })
|
||||||
}
|
}
|
||||||
composable(
|
composable(
|
||||||
route = Routes.DETAIL_PATTERN,
|
route = Routes.DETAIL_PATTERN,
|
||||||
|
|||||||
@@ -14,6 +14,16 @@ object Routes {
|
|||||||
const val DEVICE_SETTINGS = "device/settings"
|
const val DEVICE_SETTINGS = "device/settings"
|
||||||
const val WORKOUT_HISTORY = "workout/history"
|
const val WORKOUT_HISTORY = "workout/history"
|
||||||
|
|
||||||
|
// 「我的」子页:四张真页面替代了阶段一首批的四个死链接
|
||||||
|
// (PROGRESS.md §3.2 中报告的「未开启」占位状态)。
|
||||||
|
// 路由名按语义独立、不再嵌在 `profile/` 下 —— 子页与 Tab 用同一字符串前缀
|
||||||
|
// 容易让 `tabRouteFor` 误把它们归到「我的」Tab(实际上它们就是「我的」的内容,
|
||||||
|
// 这里归到「我的」才对,所以单独加分支,见 `RingHealthApp.tabRouteFor`)。
|
||||||
|
const val PROFILE_SETTINGS = "profile_settings"
|
||||||
|
const val PROFILE_NOTIFICATIONS = "profile_notifications"
|
||||||
|
const val PROFILE_ABOUT = "profile_about"
|
||||||
|
const val PROFILE_HELP = "profile_help"
|
||||||
|
|
||||||
/** 路由参数的键名。必须声明在 DETAIL_PATTERN 之前:模板引用它。 */
|
/** 路由参数的键名。必须声明在 DETAIL_PATTERN 之前:模板引用它。 */
|
||||||
const val DETAIL_ARG_METRIC = "metric"
|
const val DETAIL_ARG_METRIC = "metric"
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,13 @@ import com.placeholder.ringhealth.domain.model.HealthSummary
|
|||||||
import com.placeholder.ringhealth.domain.model.MetricPoint
|
import com.placeholder.ringhealth.domain.model.MetricPoint
|
||||||
import com.placeholder.ringhealth.domain.model.MetricType
|
import com.placeholder.ringhealth.domain.model.MetricType
|
||||||
import com.placeholder.ringhealth.domain.model.RingDeviceState
|
import com.placeholder.ringhealth.domain.model.RingDeviceState
|
||||||
|
import com.placeholder.ringhealth.domain.model.SleepSegment
|
||||||
|
import com.placeholder.ringhealth.domain.model.SleepSession
|
||||||
|
import com.placeholder.ringhealth.domain.model.SleepStage
|
||||||
import com.placeholder.ringhealth.domain.model.WorkoutRecord
|
import com.placeholder.ringhealth.domain.model.WorkoutRecord
|
||||||
|
import com.placeholder.ringhealth.domain.model.valueOf
|
||||||
|
import java.util.Calendar
|
||||||
|
import kotlin.math.roundToInt
|
||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -22,31 +28,68 @@ class MockDataProvider(
|
|||||||
private val now: () -> Long = System::currentTimeMillis,
|
private val now: () -> Long = System::currentTimeMillis,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
/** 今日概览:固定演示值。血压为 null 以演示“未开启”占位态。 */
|
/**
|
||||||
fun todaySummary(): HealthSummary = HealthSummary(
|
* 今日概览:固定演示值,但**未开启的指标一律为 null**。
|
||||||
|
*
|
||||||
|
* @param enabledMetrics 当前开启的指标集合,来自功能开关(见 [DeviceFunctionSetting])。
|
||||||
|
* 关掉的指标在这里被置空,首页那张卡才会显示「未开启」——
|
||||||
|
* 否则用户在功能设置页翻了开关,首页纹丝不动,开关就是个摆设。
|
||||||
|
* **刻意不给默认值**:默认「全都开着」会让漏传参数的调用方看起来一切正常,
|
||||||
|
* 而那正是这次要修的病。
|
||||||
|
*
|
||||||
|
* 计步与体温没有开关([MetricType.switchId] 为 null),恒定给值。
|
||||||
|
*/
|
||||||
|
fun todaySummary(enabledMetrics: Set<MetricType>): HealthSummary =
|
||||||
|
HealthSummary(
|
||||||
steps = 8426,
|
steps = 8426,
|
||||||
stepGoal = 10000,
|
stepGoal = 10000,
|
||||||
calories = 327,
|
calories = 327,
|
||||||
distanceKm = 6.21,
|
distanceKm = 6.21,
|
||||||
heartRate = 72,
|
heartRate = if (MetricType.HEART_RATE in enabledMetrics) 72 else null,
|
||||||
sleepMinutes = 452,
|
// 睡眠时长取自昨晚那条 [SleepSession],不另写一个常量:
|
||||||
bloodOxygen = 98,
|
// 头图写 452 分钟、时段图上昨晚那根条却是另一个长度,是同一屏里最刺眼的一种自相矛盾。
|
||||||
stress = 34,
|
sleepMinutes = if (MetricType.SLEEP in enabledMetrics) demoLastNight().sleepMinutes else null,
|
||||||
temperature = 36.5,
|
bloodOxygen = if (MetricType.BLOOD_OXYGEN in enabledMetrics) 98 else null,
|
||||||
bloodPressure = null,
|
stress = if (MetricType.STRESS in enabledMetrics) 34 else null,
|
||||||
|
temperature = if (MetricType.TEMPERATURE in enabledMetrics) 36.5 else null,
|
||||||
|
// 血压在概览里是「收缩压/舒张压」字符串。开着给一个演示值,关着为 null ——
|
||||||
|
// 首页那张卡据此在「118/76」和「未开启」之间切换。
|
||||||
|
bloodPressure = if (MetricType.BLOOD_PRESSURE in enabledMetrics) DEMO_BLOOD_PRESSURE else null,
|
||||||
|
// 今日活动累计分钟数:与 steps/calories 同性质,**不属于任何一个 MetricType**,
|
||||||
|
// 因此不参与 enabledMetrics 的过滤 —— 它没有开关页可关,给 null 会变成「未开启」
|
||||||
|
// 跟步数一样荒谬。值是演示用,M3 接 SDK 后由真实活动汇总接口填入。
|
||||||
|
activeMinutes = DEMO_ACTIVE_MINUTES,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 指定指标的历史数据:WEEK 返回 7 个点,MONTH 返回 30 个点。
|
* 指定指标的历史数据:WEEK 返回 7 个点,MONTH 返回 30 个点。
|
||||||
* 时间戳按“每点间隔一天”倒推,值在指标合理区间内波动。
|
* 时间戳按“每点间隔一天”倒推,值在指标合理区间内波动。
|
||||||
|
*
|
||||||
|
* **最后一个点(时间戳 = 现在,即“今天”)的值取自 [todaySummary]**,不由随机序列生成。
|
||||||
|
* 详情页的头图显示概览里的当前值、趋势图的最后一根柱子显示今天的值,两者本该相同;
|
||||||
|
* 各生成各的会让页面出现「头图写心率 72、图上今天却是 96」这种矛盾,
|
||||||
|
* 而且两个数字看起来都很确定,用户无从判断该信哪个。
|
||||||
|
*
|
||||||
|
* **睡眠是例外:它的点由 [sleepSessions] 折算而来**,不走下面那套随机序列。
|
||||||
|
* 同一个指标有两套数据源,就会出现「头图写 7 小时 32 分、时段图上却是一根 6 小时的条」——
|
||||||
|
* 而这正是睡眠页最显眼的两块内容。
|
||||||
*/
|
*/
|
||||||
fun metricHistory(metric: MetricType, range: DateRange): List<MetricPoint> {
|
fun metricHistory(
|
||||||
|
metric: MetricType,
|
||||||
|
range: DateRange,
|
||||||
|
enabledMetrics: Set<MetricType>,
|
||||||
|
): List<MetricPoint> {
|
||||||
|
if (metric == MetricType.SLEEP) {
|
||||||
|
return sleepSessions(range).map {
|
||||||
|
MetricPoint(timestamp = it.wakeDate, value = it.sleepMinutes.toDouble())
|
||||||
|
}
|
||||||
|
}
|
||||||
val count = if (range == DateRange.WEEK) 7 else 30
|
val count = if (range == DateRange.WEEK) 7 else 30
|
||||||
val random = Random(seed + metric.ordinal * 100L + range.ordinal)
|
val random = Random(seed + metric.ordinal * 100L + range.ordinal)
|
||||||
val (min, max) = valueRange(metric)
|
val (min, max) = valueRange(metric)
|
||||||
val current = now()
|
val current = now()
|
||||||
val dayMillis = 24L * 60 * 60 * 1000
|
val dayMillis = 24L * 60 * 60 * 1000
|
||||||
return (0 until count).map { index ->
|
val points = (0 until count).map { index ->
|
||||||
val progress = index.toFloat() / (count - 1).coerceAtLeast(1)
|
val progress = index.toFloat() / (count - 1).coerceAtLeast(1)
|
||||||
val value = min + (max - min) * progress + random.nextDouble(-1.0, 1.0) * (max - min) * 0.15
|
val value = min + (max - min) * progress + random.nextDouble(-1.0, 1.0) * (max - min) * 0.15
|
||||||
MetricPoint(
|
MetricPoint(
|
||||||
@@ -54,8 +97,93 @@ class MockDataProvider(
|
|||||||
value = value.coerceIn(min, max),
|
value = value.coerceIn(min, max),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// 指标未开启(概览里为 null)时保留随机序列:没有「今日值」可对齐。
|
||||||
|
// 这些点不会显示 —— 未开启的指标,详情页整页走「未开启」分支,不画图(见 MetricDetailScreen)。
|
||||||
|
val todayValue = todaySummary(enabledMetrics).valueOf(metric) ?: return points
|
||||||
|
return points.dropLast(1) + MetricPoint(timestamp = current, value = todayValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 睡眠时段:WEEK 返回最近 7 晚,MONTH 返回最近 30 晚,按时间正序(最后一晚是昨晚)。
|
||||||
|
*
|
||||||
|
* 与其他指标不同,这里的**时刻本身就是数据**,所以不能用「现在往前减 N 天」那种做法 ——
|
||||||
|
* 那会让每一晚的入睡时刻都等于「此刻减若干小时」,于是七根条在时段图上排成整齐的一列,
|
||||||
|
* 看不出作息差异,而「作息差在哪」正是这张图存在的理由。这里按日历把每晚上落到
|
||||||
|
* 早上 6–9 点之间的某个时刻,入睡时刻再由卧床时长倒推。
|
||||||
|
*
|
||||||
|
* 昨晚(daysAgo = 0)用固定值 [demoLastNight],与 [todaySummary] 给出的睡眠时长对齐。
|
||||||
|
*/
|
||||||
|
fun sleepSessions(range: DateRange): List<SleepSession> {
|
||||||
|
val count = if (range == DateRange.WEEK) 7 else 30
|
||||||
|
return (count - 1 downTo 0).map { daysAgo ->
|
||||||
|
if (daysAgo == 0) {
|
||||||
|
demoLastNight()
|
||||||
|
} else {
|
||||||
|
// 每晚一个独立种子:结果与生成顺序无关,多取几晚也不会让已有那几晚变样。
|
||||||
|
randomSession(Random(seed + SLEEP_SEED_OFFSET + daysAgo), daysAgo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 昨晚(今天凌晨那次睡眠)。入睡 23:43、醒来 07:37,睡着 7 小时 32 分。
|
||||||
|
*
|
||||||
|
* 它是一个**写死的演示值**,和步数 8426、心率 72 同一性质;随机生成的那几晚负责
|
||||||
|
* 「作息有波动」的观感,昨晚这一条负责让第一屏的数字好看且固定。
|
||||||
|
* 时长(452 分钟)就是 `demoLastNight().sleepMinutes`,见 [todaySummary]。
|
||||||
|
*
|
||||||
|
* 此外**只有这一晚带 [SleepSession.stages]** —— 24 小时采样里 6 晚没有这层数据,
|
||||||
|
* 时段图按单色画;昨晚给了它,时段图把这一行拆成多个染色子矩形,让用户能直观看到
|
||||||
|
* 「夜里深睡 / 浅睡 / 中途醒是怎么交替的」。各段时长加起来正好等于上面的累计三件套,
|
||||||
|
* 否则头图分期条与时段图条内分期的颜色块会对不上。
|
||||||
|
*/
|
||||||
|
private fun demoLastNight(): SleepSession {
|
||||||
|
val wake = localTimeAt(daysAgo = 0, hour = DEMO_WAKE_HOUR, minute = DEMO_WAKE_MINUTE)
|
||||||
|
return SleepSession(
|
||||||
|
wakeDate = wake,
|
||||||
|
sleepStart = wake - DEMO_IN_BED_MINUTES * MILLIS_PER_MINUTE,
|
||||||
|
sleepEnd = wake,
|
||||||
|
deepMinutes = DEMO_DEEP_MINUTES,
|
||||||
|
lightMinutes = DEMO_LIGHT_MINUTES,
|
||||||
|
awakeMinutes = DEMO_AWAKE_MINUTES,
|
||||||
|
stages = DEMO_LAST_NIGHT_STAGES,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 随机构造一晚:睡着 350–500 分钟,夜里清醒 0–40 分钟,其中深睡占 18%–28%。 */
|
||||||
|
private fun randomSession(random: Random, daysAgo: Int): SleepSession {
|
||||||
|
val asleep = random.nextInt(350, 501)
|
||||||
|
val awake = random.nextInt(0, 41)
|
||||||
|
// 入睡时刻倒推:先定醒来时刻,再往前挪「卧床时长」。
|
||||||
|
// 醒来时刻在 6–9 点之间随机,作息差异才看得出来。
|
||||||
|
val wake = localTimeAt(daysAgo, hour = random.nextInt(6, 10), minute = random.nextInt(0, 60))
|
||||||
|
val deep = (asleep * random.nextDouble(DEEP_RATIO_MIN, DEEP_RATIO_MAX)).roundToInt()
|
||||||
|
return SleepSession(
|
||||||
|
wakeDate = wake,
|
||||||
|
sleepStart = wake - (asleep + awake) * MILLIS_PER_MINUTE,
|
||||||
|
sleepEnd = wake,
|
||||||
|
deepMinutes = deep,
|
||||||
|
lightMinutes = asleep - deep,
|
||||||
|
awakeMinutes = awake,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 时间戳:今天往前 [daysAgo] 天的 [hour]:[minute](本地时区)。
|
||||||
|
*
|
||||||
|
* 必须走 Calendar 而不是 `now() - daysAgo * 86400_000` —— 后者只会平移,改不了「几点」,
|
||||||
|
* 生成的每个时刻都带着当前这一秒的分钟数。跨夏令时的那种一天也不是 86400 秒。
|
||||||
|
*/
|
||||||
|
private fun localTimeAt(daysAgo: Int, hour: Int, minute: Int): Long =
|
||||||
|
Calendar.getInstance().apply {
|
||||||
|
timeInMillis = now()
|
||||||
|
add(Calendar.DAY_OF_YEAR, -daysAgo)
|
||||||
|
set(Calendar.HOUR_OF_DAY, hour)
|
||||||
|
set(Calendar.MINUTE, minute)
|
||||||
|
set(Calendar.SECOND, 0)
|
||||||
|
set(Calendar.MILLISECOND, 0)
|
||||||
|
}.timeInMillis
|
||||||
|
|
||||||
/** 运动记录:固定 5 条演示数据,倒序(最近的在最前)。 */
|
/** 运动记录:固定 5 条演示数据,倒序(最近的在最前)。 */
|
||||||
fun workouts(): List<WorkoutRecord> {
|
fun workouts(): List<WorkoutRecord> {
|
||||||
val random = Random(seed + 7)
|
val random = Random(seed + 7)
|
||||||
@@ -83,13 +211,40 @@ class MockDataProvider(
|
|||||||
charging = false,
|
charging = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** 五个功能开关,对应协议中的自动监测能力。 */
|
/**
|
||||||
|
* 五个功能开关,对应协议中的自动监测能力。
|
||||||
|
*
|
||||||
|
* id 一律取 [MetricType.switchId],不在这里手写字符串 —— 手写就等于把「开关 ↔ 指标」
|
||||||
|
* 这个对应关系又抄了一份,抄错一份不会报错,只会让某个开关悄悄失效。
|
||||||
|
*
|
||||||
|
* 默认值:血压关着,用来演示「未开启」占位态;其余开着。
|
||||||
|
* 压力此前默认是关的,但 [todaySummary] 里 `stress = 34` 一直有值 ——
|
||||||
|
* 那正是「开关与数据两套系统」留下的痕迹,现在两者由同一个开关决定,默认值必须自洽。
|
||||||
|
*/
|
||||||
fun defaultFunctionSettings(): List<DeviceFunctionSetting> = listOf(
|
fun defaultFunctionSettings(): List<DeviceFunctionSetting> = listOf(
|
||||||
DeviceFunctionSetting("heart_rate", "自动心率监测", "每 10 分钟测量一次", true),
|
setting(MetricType.HEART_RATE, "自动心率监测", "每 10 分钟测量一次", enabled = true),
|
||||||
DeviceFunctionSetting("blood_oxygen", "自动血氧监测", "夜间持续监测", true),
|
setting(MetricType.BLOOD_OXYGEN, "自动血氧监测", "夜间持续监测", enabled = true),
|
||||||
DeviceFunctionSetting("sleep", "睡眠监测", "自动识别睡眠时段", true),
|
setting(MetricType.SLEEP, "睡眠监测", "自动识别睡眠时段", enabled = true),
|
||||||
DeviceFunctionSetting("stress", "压力监测", "全天候压力评估", false),
|
setting(MetricType.STRESS, "压力监测", "全天候压力评估", enabled = true),
|
||||||
DeviceFunctionSetting("blood_pressure", "血压监测", "需在 App 内手动校准", false),
|
setting(MetricType.BLOOD_PRESSURE, "血压监测", "需在 App 内手动校准", enabled = false),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构造一个开关项,id 从指标上取。
|
||||||
|
*
|
||||||
|
* 常开指标([MetricType.switchId] 为 null)不该出现在开关列表里,
|
||||||
|
* 传进来会立刻抛异常而不是生成一个 id 为 "null" 的幽灵开关。
|
||||||
|
*/
|
||||||
|
private fun setting(
|
||||||
|
metric: MetricType,
|
||||||
|
title: String,
|
||||||
|
subtitle: String,
|
||||||
|
enabled: Boolean,
|
||||||
|
): DeviceFunctionSetting = DeviceFunctionSetting(
|
||||||
|
id = requireNotNull(metric.switchId) { "$metric 是常开指标,不该出现在功能开关列表里" },
|
||||||
|
title = title,
|
||||||
|
subtitle = subtitle,
|
||||||
|
enabled = enabled,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** 各指标的取值区间,用于生成合理的历史数据。 */
|
/** 各指标的取值区间,用于生成合理的历史数据。 */
|
||||||
@@ -106,5 +261,74 @@ class MockDataProvider(
|
|||||||
companion object {
|
companion object {
|
||||||
/** 默认随机种子,保证演示数据稳定。 */
|
/** 默认随机种子,保证演示数据稳定。 */
|
||||||
const val DEFAULT_SEED: Long = 20260910L
|
const val DEFAULT_SEED: Long = 20260910L
|
||||||
|
|
||||||
|
/** 血压开着时的演示读数(概览里是「收缩压/舒张压」形式)。 */
|
||||||
|
const val DEMO_BLOOD_PRESSURE: String = "118/76"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 今日活动累计分钟数演示值。
|
||||||
|
*
|
||||||
|
* 与 [DEMO_BLOOD_PRESSURE] 同一性质:固定写死,让第一屏有数字;M3 接 SDK 后整体替换。
|
||||||
|
* 47 分钟是「轻办公 + 通勤走路 + 一点通勤骑」的合理区间,比 0 像真人,比 120 不会让用户
|
||||||
|
* 误以为自己达标。
|
||||||
|
*/
|
||||||
|
const val DEMO_ACTIVE_MINUTES: Int = 47
|
||||||
|
|
||||||
|
/** 睡眠时段的随机种子偏移,避开 [metricHistory] 用的 `ordinal * 100`。 */
|
||||||
|
private const val SLEEP_SEED_OFFSET = 31L
|
||||||
|
|
||||||
|
private const val MILLIS_PER_MINUTE = 60_000L
|
||||||
|
|
||||||
|
/** 深睡占比区间。真实设备大约在这个范围,太低会让「深睡 3 分钟」这种数出现在演示里。 */
|
||||||
|
private const val DEEP_RATIO_MIN = 0.18
|
||||||
|
private const val DEEP_RATIO_MAX = 0.28
|
||||||
|
|
||||||
|
/** 昨晚的醒来时刻:07:37。 */
|
||||||
|
private const val DEMO_WAKE_HOUR = 7
|
||||||
|
private const val DEMO_WAKE_MINUTE = 37
|
||||||
|
|
||||||
|
/** 昨晚分期:深睡 96 + 浅睡 356 = 睡着 452 分钟,另有 22 分钟夜里清醒。 */
|
||||||
|
private const val DEMO_DEEP_MINUTES = 96
|
||||||
|
private const val DEMO_LIGHT_MINUTES = 356
|
||||||
|
private const val DEMO_AWAKE_MINUTES = 22
|
||||||
|
|
||||||
|
/** 昨晚卧床时长 = 睡着 452 + 清醒 22,由 07:37 倒推得入睡时刻 23:43。 */
|
||||||
|
private const val DEMO_IN_BED_MINUTES = DEMO_DEEP_MINUTES + DEMO_LIGHT_MINUTES + DEMO_AWAKE_MINUTES
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 昨晚的分期序列(相对 `sleepStart` 的分钟偏移)。
|
||||||
|
*
|
||||||
|
* 三段时间加起来严格等于 [DEMO_DEEP_MINUTES] / [DEMO_LIGHT_MINUTES] / [DEMO_AWAKE_MINUTES]:
|
||||||
|
* 深睡 48 + 35 + 13 = 96
|
||||||
|
* 浅睡 30 + 60 + 60 + 44 + 57 + 105 = 356
|
||||||
|
* 清醒 5 + 5 + 7 + 5 = 22
|
||||||
|
* 否则头图分期条与时段图条内分期的色块会对不上。形态上模拟「入睡后两轮深睡 + 一段长浅睡
|
||||||
|
* (含 REM)+ 中途两段短暂清醒」的常见曲线。
|
||||||
|
*/
|
||||||
|
private val DEMO_LAST_NIGHT_STAGES: List<SleepSegment> = listOf(
|
||||||
|
// 入睡后短暂清醒 → 浅睡入睡
|
||||||
|
SleepSegment(SleepStage.AWAKE, 0, 5),
|
||||||
|
SleepSegment(SleepStage.LIGHT, 5, 35),
|
||||||
|
// 第一轮深睡
|
||||||
|
SleepSegment(SleepStage.DEEP, 35, 83),
|
||||||
|
// 第一轮长浅睡
|
||||||
|
SleepSegment(SleepStage.LIGHT, 83, 143),
|
||||||
|
// 第二轮深睡(比第一轮短)
|
||||||
|
SleepSegment(SleepStage.DEEP, 143, 178),
|
||||||
|
// 第二轮浅睡
|
||||||
|
SleepSegment(SleepStage.LIGHT, 178, 238),
|
||||||
|
// 第一次中途醒(5 分钟)
|
||||||
|
SleepSegment(SleepStage.AWAKE, 238, 243),
|
||||||
|
SleepSegment(SleepStage.LIGHT, 243, 287),
|
||||||
|
// 第三轮深睡(很短)
|
||||||
|
SleepSegment(SleepStage.DEEP, 287, 300),
|
||||||
|
SleepSegment(SleepStage.LIGHT, 300, 357),
|
||||||
|
// 第二次中途醒(7 分钟)
|
||||||
|
SleepSegment(SleepStage.AWAKE, 357, 364),
|
||||||
|
// 临近醒来的长浅睡(含 REM 密集期)
|
||||||
|
SleepSegment(SleepStage.LIGHT, 364, 469),
|
||||||
|
// 最终清醒
|
||||||
|
SleepSegment(SleepStage.AWAKE, 469, 474),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,57 @@
|
|||||||
package com.placeholder.ringhealth.data.mock
|
package com.placeholder.ringhealth.data.mock
|
||||||
|
|
||||||
import com.placeholder.ringhealth.domain.model.DateRange
|
import com.placeholder.ringhealth.domain.model.DateRange
|
||||||
|
import com.placeholder.ringhealth.domain.model.DeviceFunctionSetting
|
||||||
import com.placeholder.ringhealth.domain.model.HealthSummary
|
import com.placeholder.ringhealth.domain.model.HealthSummary
|
||||||
import com.placeholder.ringhealth.domain.model.MetricPoint
|
import com.placeholder.ringhealth.domain.model.MetricPoint
|
||||||
import com.placeholder.ringhealth.domain.model.MetricType
|
import com.placeholder.ringhealth.domain.model.MetricType
|
||||||
|
import com.placeholder.ringhealth.domain.model.SleepSession
|
||||||
import com.placeholder.ringhealth.domain.model.WorkoutRecord
|
import com.placeholder.ringhealth.domain.model.WorkoutRecord
|
||||||
|
import com.placeholder.ringhealth.domain.model.enabledMetrics
|
||||||
import com.placeholder.ringhealth.domain.repository.HealthDataRepository
|
import com.placeholder.ringhealth.domain.repository.HealthDataRepository
|
||||||
|
import com.placeholder.ringhealth.domain.repository.RingDeviceRepository
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.emitAll
|
||||||
import kotlinx.coroutines.flow.flow
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mock 健康数据仓库。模拟 200ms 网络/设备延迟,便于阶段一验证加载态。
|
* Mock 健康数据仓库。模拟 200ms 网络/设备延迟,便于阶段一验证加载态。
|
||||||
|
*
|
||||||
|
* **它订阅功能开关。** 这是「开关能影响首页」的关键:原先健康数据与开关状态是
|
||||||
|
* `AppContainer` 里两个互不引用的字段,用户打开「血压监测」只翻动了开关自己那一行,
|
||||||
|
* 这边照样发射写死的概览,首页因而纹丝不动。现在数据由开关算出来,
|
||||||
|
* 开关一变就重发。
|
||||||
|
*
|
||||||
|
* @param functionSettings 功能开关流,来自 [RingDeviceRepository]。
|
||||||
|
* 注意依赖的是 `StateFlow`(会立刻给出当前值)而不是一个一次性读取 ——
|
||||||
|
* 否则开关变化传不过来,又会退回原样。
|
||||||
*/
|
*/
|
||||||
class MockHealthDataRepository(
|
class MockHealthDataRepository(
|
||||||
|
private val functionSettings: StateFlow<List<DeviceFunctionSetting>>,
|
||||||
private val provider: MockDataProvider = MockDataProvider(),
|
private val provider: MockDataProvider = MockDataProvider(),
|
||||||
) : HealthDataRepository {
|
) : HealthDataRepository {
|
||||||
|
|
||||||
|
// flow { delay(...) } 先给出加载态,再 emitAll 接上开关流:初值延迟 200ms 到达
|
||||||
|
// (便于验证 loading),此后每次翻开关都立刻重发。
|
||||||
override fun observeTodaySummary(): Flow<HealthSummary> = flow {
|
override fun observeTodaySummary(): Flow<HealthSummary> = flow {
|
||||||
delay(MOCK_DELAY_MILLIS)
|
delay(MOCK_DELAY_MILLIS)
|
||||||
emit(provider.todaySummary())
|
emitAll(functionSettings.map { provider.todaySummary(it.enabledMetrics()) })
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun observeMetricHistory(metric: MetricType, range: DateRange): Flow<List<MetricPoint>> = flow {
|
override fun observeMetricHistory(metric: MetricType, range: DateRange): Flow<List<MetricPoint>> = flow {
|
||||||
delay(MOCK_DELAY_MILLIS)
|
delay(MOCK_DELAY_MILLIS)
|
||||||
emit(provider.metricHistory(metric, range))
|
emitAll(functionSettings.map { provider.metricHistory(metric, range, it.enabledMetrics()) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 与 observeMetricHistory 同一口径:**照常发数据,判开关是页面的事**(见 MetricDetailScreen
|
||||||
|
// 的 enabled 分支)。不在这里按开关置空,是因为长度/取值是随 range 与种子固定的,
|
||||||
|
// 在这里分叉会让「关了再打开」拿到一段与之前不同的序列,而 Mock 承诺的是可复现。
|
||||||
|
override fun observeSleepSessions(range: DateRange): Flow<List<SleepSession>> = flow {
|
||||||
|
delay(MOCK_DELAY_MILLIS)
|
||||||
|
emitAll(functionSettings.map { provider.sleepSessions(range) })
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun observeWorkouts(): Flow<List<WorkoutRecord>> = flow {
|
override fun observeWorkouts(): Flow<List<WorkoutRecord>> = flow {
|
||||||
|
|||||||
@@ -1,14 +1,26 @@
|
|||||||
package com.placeholder.ringhealth.domain.model
|
package com.placeholder.ringhealth.domain.model
|
||||||
|
|
||||||
/** 健康指标类型。路由与详情页以此区分。 */
|
/**
|
||||||
enum class MetricType {
|
* 健康指标类型。路由与详情页以此区分。
|
||||||
STEP,
|
*
|
||||||
HEART_RATE,
|
* @param switchId 该指标对应 [DeviceFunctionSetting.id] 里的哪个开关;**为 null 表示常开** ——
|
||||||
SLEEP,
|
* 计步与体温由戒指持续采集,没有「关掉」这个选项。
|
||||||
BLOOD_OXYGEN,
|
*
|
||||||
STRESS,
|
* 这个对应关系必须显式写出来,不能靠两边字符串长得像来对齐。原先它只存在于巧合里
|
||||||
TEMPERATURE,
|
* (开关 id 是 `"blood_pressure"`、枚举是 `BLOOD_PRESSURE`,撞得上),没有一行代码声明过
|
||||||
BLOOD_PRESSURE,
|
* 它们是同一件事 —— 于是开关状态和健康数据成了两个互不相干的系统:用户在功能设置页打开
|
||||||
|
* 「血压监测」,首页那张卡照样写「未开启」,因为首页读的数据根本不认识开关。
|
||||||
|
*/
|
||||||
|
enum class MetricType(val switchId: String?) {
|
||||||
|
/** 常开:戒指数着步数,没有开关。 */
|
||||||
|
STEP(null),
|
||||||
|
HEART_RATE("heart_rate"),
|
||||||
|
SLEEP("sleep"),
|
||||||
|
BLOOD_OXYGEN("blood_oxygen"),
|
||||||
|
STRESS("stress"),
|
||||||
|
/** 常开:戒指持续采集体温,没有开关。 */
|
||||||
|
TEMPERATURE(null),
|
||||||
|
BLOOD_PRESSURE("blood_pressure"),
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 历史数据的时间范围。 */
|
/** 历史数据的时间范围。 */
|
||||||
@@ -27,6 +39,10 @@ data class RingDeviceState(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 今日健康概览。字段为 null 表示该指标未开启或暂无数据,UI 需展示“未开启”占位而非 0。
|
* 今日健康概览。字段为 null 表示该指标未开启或暂无数据,UI 需展示“未开启”占位而非 0。
|
||||||
|
*
|
||||||
|
* @param activeMinutes 今日活动累计分钟数(高强度活动 + 中等强度活动合并)。
|
||||||
|
* 与 [steps] / [calories] 同属「每日总量」型数据,**不属于任何一个 [MetricType]**
|
||||||
|
* (首页三件套横排里展示,M3 接 SDK 后由真实活动汇总接口填入)。
|
||||||
*/
|
*/
|
||||||
data class HealthSummary(
|
data class HealthSummary(
|
||||||
val steps: Int,
|
val steps: Int,
|
||||||
@@ -39,11 +55,98 @@ data class HealthSummary(
|
|||||||
val stress: Int?,
|
val stress: Int?,
|
||||||
val temperature: Double?,
|
val temperature: Double?,
|
||||||
val bloodPressure: String?,
|
val bloodPressure: String?,
|
||||||
|
val activeMinutes: Int = 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取某个指标在今日概览里的值;该指标未开启或暂无数据时返回 null。
|
||||||
|
*
|
||||||
|
* 存在的理由是**让「当前值」与「历史点」用同一把尺子**:详情页的头图取概览里的当前值,
|
||||||
|
* 趋势图的最后一个点代表今天,两者说的本该是同一件事。数据源若各给各的,
|
||||||
|
* 页面就会出现「头图写心率 72、图上今天却是 96」这种自相矛盾 ——
|
||||||
|
* 用户没法判断该信哪个,而两个数字看起来都很确定。
|
||||||
|
*/
|
||||||
|
fun HealthSummary.valueOf(metric: MetricType): Double? = when (metric) {
|
||||||
|
MetricType.STEP -> steps.toDouble()
|
||||||
|
MetricType.HEART_RATE -> heartRate?.toDouble()
|
||||||
|
MetricType.SLEEP -> sleepMinutes?.toDouble()
|
||||||
|
MetricType.BLOOD_OXYGEN -> bloodOxygen?.toDouble()
|
||||||
|
MetricType.STRESS -> stress?.toDouble()
|
||||||
|
MetricType.TEMPERATURE -> temperature
|
||||||
|
// 血压在概览里是「收缩压/舒张压」形式的字符串,取不出单一数值。
|
||||||
|
// M3 把 bloodPressure 改成结构化的两个数值后再补上这一支。
|
||||||
|
MetricType.BLOOD_PRESSURE -> null
|
||||||
|
}
|
||||||
|
|
||||||
/** 一条历史数据点。 */
|
/** 一条历史数据点。 */
|
||||||
data class MetricPoint(val timestamp: Long, val value: Double)
|
data class MetricPoint(val timestamp: Long, val value: Double)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一段睡眠分期:起点到终点(相对于 [SleepSession.sleepStart] 的分钟数)+ 哪一期。
|
||||||
|
*
|
||||||
|
* 时段图渲染时按这一份序列把一根条拆成多个染色子矩形。没有这层序列时只能画
|
||||||
|
* 「累计深睡 96 分钟」这样的比例块 —— 那是一段并不存在的分期位置,比不画更容易让人当真
|
||||||
|
* (见 `SleepWindowChart` 的 KDoc)。有这份序列才能画「23:43–23:48 清醒、23:48–00:18 浅睡」这样的真顺序。
|
||||||
|
*
|
||||||
|
* @param stage 这一段是哪个分期。
|
||||||
|
* @param startOffsetMinute 起点相对 [SleepSession.sleepStart] 的分钟数。
|
||||||
|
* @param endOffsetMinute 终点相对 [SleepSession.sleepStart] 的分钟数。必须大于 [startOffsetMinute]。
|
||||||
|
*/
|
||||||
|
data class SleepSegment(
|
||||||
|
val stage: SleepStage,
|
||||||
|
val startOffsetMinute: Int,
|
||||||
|
val endOffsetMinute: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一晚睡眠。
|
||||||
|
*
|
||||||
|
* 睡眠与其他指标不一样:它在一天里占的是**一段区间**,不是一个数。
|
||||||
|
* [MetricPoint] 只装得下一个 `Double`,于是「睡了 7 小时 32 分」之外什么都说不了 ——
|
||||||
|
* 而「从几点睡到几点」恰恰是作息规律的关键,也是用户真正会问的问题
|
||||||
|
* (「我不知道一天内具体什么时候睡的觉」)。所以睡眠单独有这个类型。
|
||||||
|
*
|
||||||
|
* @param wakeDate 醒来的时刻;这条记录算在哪一天,以它为准。
|
||||||
|
* @param sleepStart 入睡时刻。
|
||||||
|
* @param sleepEnd 醒来时刻。
|
||||||
|
* @param deepMinutes 深睡时长。
|
||||||
|
* @param lightMinutes 浅睡时长。
|
||||||
|
* @param awakeMinutes 夜里清醒的累计时长(中途醒来)。
|
||||||
|
* @param stages 分期序列(相对 [sleepStart] 的分钟偏移)。**为 null 表示该晚不画分期染色**,
|
||||||
|
* 时段图按单色画这根条 —— 大多数 Mock 数据没有这层序列,M2 接入真机后会逐步补上。
|
||||||
|
*/
|
||||||
|
data class SleepSession(
|
||||||
|
val wakeDate: Long,
|
||||||
|
val sleepStart: Long,
|
||||||
|
val sleepEnd: Long,
|
||||||
|
val deepMinutes: Int,
|
||||||
|
val lightMinutes: Int,
|
||||||
|
val awakeMinutes: Int,
|
||||||
|
val stages: List<SleepSegment>? = null,
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* 实际睡着的时长 = 深睡 + 浅睡,**不含夜里清醒**。
|
||||||
|
* 首页那张卡和详情页头图显示的都是这个数。
|
||||||
|
*/
|
||||||
|
val sleepMinutes: Int get() = deepMinutes + lightMinutes
|
||||||
|
|
||||||
|
/** 卧床时长 = 睡着 + 夜里清醒,等于 [sleepStart] 到 [sleepEnd] 的跨度。 */
|
||||||
|
val inBedMinutes: Int get() = sleepMinutes + awakeMinutes
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 睡眠分期。头图的分期条与图例按它取色取文案。 */
|
||||||
|
enum class SleepStage { DEEP, LIGHT, AWAKE }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 某一期的时长(分钟)。注意各期时长**不表示先后顺序** ——
|
||||||
|
* 真实的分期是交替出现的,而这里只有累计时长。
|
||||||
|
*/
|
||||||
|
fun SleepSession.minutesOf(stage: SleepStage): Int = when (stage) {
|
||||||
|
SleepStage.DEEP -> deepMinutes
|
||||||
|
SleepStage.LIGHT -> lightMinutes
|
||||||
|
SleepStage.AWAKE -> awakeMinutes
|
||||||
|
}
|
||||||
|
|
||||||
/** 一条运动记录。 */
|
/** 一条运动记录。 */
|
||||||
data class WorkoutRecord(
|
data class WorkoutRecord(
|
||||||
val id: String,
|
val id: String,
|
||||||
@@ -61,3 +164,29 @@ data class DeviceFunctionSetting(
|
|||||||
val subtitle: String,
|
val subtitle: String,
|
||||||
val enabled: Boolean,
|
val enabled: Boolean,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从开关列表算出「当前开着哪些指标」。
|
||||||
|
*
|
||||||
|
* 常开指标([MetricType.switchId] 为 null)永远算作开启 —— 它们不在开关列表里,
|
||||||
|
* 若按「找不到对应开关就算关」处理,计步和体温会凭空变成「未开启」。
|
||||||
|
*
|
||||||
|
* 开关 id 找不到对应指标时被忽略:那说明协议里有我们还没接入的功能,
|
||||||
|
* 不该让整个首页崩掉。
|
||||||
|
*/
|
||||||
|
fun List<DeviceFunctionSetting>.enabledMetrics(): Set<MetricType> =
|
||||||
|
MetricType.entries.filterTo(mutableSetOf()) { metric ->
|
||||||
|
val switchId = metric.switchId ?: return@filterTo true
|
||||||
|
any { it.id == switchId && it.enabled }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 该指标当前是否开启。
|
||||||
|
*
|
||||||
|
* 判据是「今日概览里有没有这个指标的值」—— 概览的字段由功能开关生成
|
||||||
|
* (见 `MockHealthDataRepository`),所以「概览里取不到值」与「开关是关的」是同一件事。
|
||||||
|
*
|
||||||
|
* 血压要单独判:它的值在概览里是「收缩压/舒张压」字符串,[valueOf] 按设计取不出单一数值。
|
||||||
|
*/
|
||||||
|
fun HealthSummary.isEnabled(metric: MetricType): Boolean =
|
||||||
|
if (metric == MetricType.BLOOD_PRESSURE) bloodPressure != null else valueOf(metric) != null
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package com.placeholder.ringhealth.domain.model
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 指标参考区间与判读基线。
|
||||||
|
*
|
||||||
|
* 这些是**领域数据**,不是展示细节 —— 「这个值算不算正常」的回答依据不该写死在 UI 层,
|
||||||
|
* 否则将来接真实数据、按用户画像下发区间时,得去七个页面里把常量一个个抠出来。
|
||||||
|
*
|
||||||
|
* 取值为通用成人临床参考值,**仅供演示,不构成医疗建议**。
|
||||||
|
* M3 接入真实数据后改为由数据层按用户画像提供。
|
||||||
|
*/
|
||||||
|
object MetricReference {
|
||||||
|
|
||||||
|
/** 成人静息心率正常区间(次/分)。 */
|
||||||
|
val HEART_RATE_NORMAL = 60.0..100.0
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 心率区间条的横轴范围(次/分)。
|
||||||
|
*
|
||||||
|
* 比 [HEART_RATE_NORMAL] 左右各留出 20 的余量:若横轴恰好等于正常区间,
|
||||||
|
* 落在 60 或 100 附近的点会紧贴条的边缘,看不出它到底是「刚好在界内」还是「已经出界」。
|
||||||
|
*/
|
||||||
|
val HEART_RATE_AXIS = 40.0..120.0
|
||||||
|
|
||||||
|
/** 血氧饱和度下限(%)。低于此值的日子需要被单独标出并计数。 */
|
||||||
|
const val BLOOD_OXYGEN_MIN = 95.0
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 血氧阈值带的下沿(%)。
|
||||||
|
*
|
||||||
|
* 比 [BLOOD_OXYGEN_MIN] 低的点仍要能画在带子上(否则低于阈值的点会集体贴边,
|
||||||
|
* 看不出低了多少),所以下沿再往下留 5。
|
||||||
|
*/
|
||||||
|
const val BLOOD_OXYGEN_FLOOR = 90.0
|
||||||
|
|
||||||
|
/** 体温基线(℃)。偏离条以它为中线。 */
|
||||||
|
const val TEMPERATURE_BASELINE = 36.5
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 体温偏离条的半幅(℃),即纵轴范围是 基线 ± 本值。
|
||||||
|
*
|
||||||
|
* 取 1.0 而不是更大:演示数据的体温落在 36.1–36.9(偏离 ±0.4),
|
||||||
|
* 半幅 1.0 时点会落在中线到边缘的 40% 处,偏离看得出来;半幅 1.5 就被压到中间一小团了。
|
||||||
|
*/
|
||||||
|
const val TEMPERATURE_SPAN = 1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 压力等级。 */
|
||||||
|
enum class StressLevel { RELAXED, NORMAL, MEDIUM, HIGH }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 压力值 → 等级。
|
||||||
|
*
|
||||||
|
* 抽成独立的纯函数,是为了让「按等级给柱子着色」「统计放松天数」和「显示等级文案」
|
||||||
|
* 三处用的是**同一份**分档逻辑 —— 此前文案分档藏在 `Formatters.stressLevelLabelRes` 里,
|
||||||
|
* 一旦别处再写一份阈值,两边迟早对不上。
|
||||||
|
*/
|
||||||
|
fun stressLevelOf(stress: Double): StressLevel = when {
|
||||||
|
stress < 30.0 -> StressLevel.RELAXED
|
||||||
|
stress < 60.0 -> StressLevel.NORMAL
|
||||||
|
stress < 80.0 -> StressLevel.MEDIUM
|
||||||
|
else -> StressLevel.HIGH
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import com.placeholder.ringhealth.domain.model.HealthSummary
|
|||||||
import com.placeholder.ringhealth.domain.model.MetricPoint
|
import com.placeholder.ringhealth.domain.model.MetricPoint
|
||||||
import com.placeholder.ringhealth.domain.model.MetricType
|
import com.placeholder.ringhealth.domain.model.MetricType
|
||||||
import com.placeholder.ringhealth.domain.model.RingDeviceState
|
import com.placeholder.ringhealth.domain.model.RingDeviceState
|
||||||
|
import com.placeholder.ringhealth.domain.model.SleepSession
|
||||||
import com.placeholder.ringhealth.domain.model.WorkoutRecord
|
import com.placeholder.ringhealth.domain.model.WorkoutRecord
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -20,6 +21,16 @@ interface HealthDataRepository {
|
|||||||
/** 指定指标在指定时间范围内的历史数据。 */
|
/** 指定指标在指定时间范围内的历史数据。 */
|
||||||
fun observeMetricHistory(metric: MetricType, range: DateRange): Flow<List<MetricPoint>>
|
fun observeMetricHistory(metric: MetricType, range: DateRange): Flow<List<MetricPoint>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 睡眠时段:每晚的入睡/醒来时刻与各期时长,按时间正序。
|
||||||
|
*
|
||||||
|
* 睡眠无法复用 [observeMetricHistory] —— [MetricPoint] 只装得下一个 `Double`,
|
||||||
|
* 而「几点睡到几点」是这一段区间的全部意义(见 [SleepSession])。
|
||||||
|
*
|
||||||
|
* @param range 与 [observeMetricHistory] 同一套口径:WEEK 七晚,MONTH 三十晚。
|
||||||
|
*/
|
||||||
|
fun observeSleepSessions(range: DateRange): Flow<List<SleepSession>>
|
||||||
|
|
||||||
/** 运动记录列表。 */
|
/** 运动记录列表。 */
|
||||||
fun observeWorkouts(): Flow<List<WorkoutRecord>>
|
fun observeWorkouts(): Flow<List<WorkoutRecord>>
|
||||||
}
|
}
|
||||||
|
|||||||
+199
-180
@@ -1,6 +1,5 @@
|
|||||||
package com.placeholder.ringhealth.feature.detail
|
package com.placeholder.ringhealth.feature.detail
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
@@ -8,46 +7,39 @@ import androidx.compose.foundation.layout.Row
|
|||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
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.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.FilterChip
|
import androidx.compose.material3.FilterChip
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import com.placeholder.ringhealth.R
|
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.MetricGreen
|
import com.placeholder.ringhealth.core.designsystem.MetricGreen
|
||||||
import com.placeholder.ringhealth.core.designsystem.RingHealthTheme
|
import com.placeholder.ringhealth.core.designsystem.RingHealthTheme
|
||||||
import com.placeholder.ringhealth.core.designsystem.TextSecondary
|
import com.placeholder.ringhealth.core.designsystem.TextSecondary
|
||||||
import com.placeholder.ringhealth.core.designsystem.component.HealthCard
|
import com.placeholder.ringhealth.core.designsystem.component.SubPageTopAppBar
|
||||||
import com.placeholder.ringhealth.core.designsystem.component.MetricBarChart
|
import com.placeholder.ringhealth.data.mock.MockDataProvider
|
||||||
import com.placeholder.ringhealth.core.designsystem.component.SectionHeader
|
|
||||||
import com.placeholder.ringhealth.core.designsystem.component.StatBar
|
|
||||||
import com.placeholder.ringhealth.core.designsystem.component.StatSegment
|
|
||||||
import com.placeholder.ringhealth.domain.model.DateRange
|
import com.placeholder.ringhealth.domain.model.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.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
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 指标详情页(有状态外壳)。
|
* 指标详情页(有状态外壳)。
|
||||||
@@ -73,7 +65,15 @@ fun MetricDetailScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 无状态指标详情内容:范围切换 + 柱状趋势图 + 统计摘要(+ 睡眠分期)。
|
* 指标详情内容:**一具骨架**。
|
||||||
|
*
|
||||||
|
* 这里只剩三件事:页头(返回 + 标题)、范围切换、以及把内容分派给对应指标。
|
||||||
|
* **七个指标的页面内容一行都不在本文件里** —— 它们各自待在 `feature/detail/pages/` 下自己的
|
||||||
|
* 文件里(`StepDetail.kt` / `HeartRateDetail.kt` / …)。
|
||||||
|
*
|
||||||
|
* 之所以这么拆:改造前七个指标共用「范围切换 + 一张柱状图 + 平均/最高/最低」这同一套模板,
|
||||||
|
* 页面之间只有柱子颜色不同 —— 打开哪个都像同一个页面。视觉上的雷同,根子是代码上的共用。
|
||||||
|
* 拆开之后,每个指标「要突出什么」写在它自己的文件里,改一页不必碰其他六页。
|
||||||
*
|
*
|
||||||
* 标题取自 `state.metric` 而不是外面传进来的 `metric` —— 无状态层只有 `state` 一个数据来源,
|
* 标题取自 `state.metric` 而不是外面传进来的 `metric` —— 无状态层只有 `state` 一个数据来源,
|
||||||
* 免得出现「标题写心率、数据画睡眠」这种两者不一致的可能。
|
* 免得出现「标题写心率、数据画睡眠」这种两者不一致的可能。
|
||||||
@@ -90,16 +90,9 @@ private fun MetricDetailContent(
|
|||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
containerColor = MaterialTheme.colorScheme.background,
|
containerColor = MaterialTheme.colorScheme.background,
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
SubPageTopAppBar(
|
||||||
title = { Text(text = stringResource(metricTitleRes(state.metric))) },
|
title = stringResource(metricTitleRes(state.metric)),
|
||||||
navigationIcon = {
|
onBack = onBack,
|
||||||
IconButton(onClick = onBack) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
|
||||||
contentDescription = stringResource(R.string.action_back),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
@@ -118,114 +111,53 @@ private fun MetricDetailContent(
|
|||||||
contentPadding = PaddingValues(16.dp),
|
contentPadding = PaddingValues(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
|
// 范围切换常驻,空数据时反而更要留着:切到另一个范围是用户看到「暂无数据」后的
|
||||||
|
// 第一反应,chips 也是判断「哪个范围是空的」的唯一线索。它属于页头,不属于数据区。
|
||||||
|
//
|
||||||
|
// 但「未开启」时不给:那一页没有任何数据,切换周/月不会有东西跟着变,
|
||||||
|
// 留两个能点但点了没反应的 chip 比不显示更让人困惑。
|
||||||
|
if (state.enabled) {
|
||||||
item { RangeSelector(current = state.range, onSelect = onSelectRange) }
|
item { RangeSelector(current = state.range, onSelect = onSelectRange) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 未开启的指标整页走说明卡,不画图。
|
||||||
|
// 判在 points 之前:Mock 对未开启的指标照常生成随机序列(见 MockDataProvider),
|
||||||
|
// 那些点画的是一段并不存在的监测数据,比空图更有误导性。
|
||||||
|
if (!state.enabled) {
|
||||||
|
item { MetricDisabledCard(metric = state.metric) }
|
||||||
|
return@LazyColumn
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只有内容区被替换为空态。判空必须在 loading 之后:Mock 有约 200ms 延迟,
|
||||||
|
// 加载期间 points 也是空的,先判空会在数据到达前闪一下空态(loading 分支已提前 return)。
|
||||||
|
if (state.points.isEmpty()) {
|
||||||
item {
|
item {
|
||||||
HealthCard {
|
|
||||||
SectionHeader(title = stringResource(R.string.detail_trend))
|
|
||||||
MetricBarChart(
|
|
||||||
values = state.points.map { it.value.toFloat() },
|
|
||||||
color = MetricGreen,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
item {
|
|
||||||
HealthCard {
|
|
||||||
val values = state.points.map { it.value }
|
|
||||||
StatLine(label = stringResource(R.string.detail_average), value = values.averageOrZero())
|
|
||||||
StatLine(label = stringResource(R.string.detail_max), value = values.maxOrNull() ?: 0.0)
|
|
||||||
StatLine(label = stringResource(R.string.detail_min), value = values.minOrNull() ?: 0.0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (state.metric == MetricType.SLEEP) {
|
|
||||||
item { SleepStagesCard(points = state.points) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 睡眠分期卡片:深睡/浅睡/清醒分段条 + 入睡/起床时间(对应 spec §6 的 `detail/sleep`)。
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun SleepStagesCard(points: List<MetricPoint>, modifier: Modifier = Modifier) {
|
|
||||||
// 阶段一没有真实分期数据,按固定比例演示;M3 接入真实睡眠数据后改为按实际时段计算。
|
|
||||||
val segments = listOf(
|
|
||||||
StatSegment(weight = 0.25f, color = MetricBlue),
|
|
||||||
StatSegment(weight = 0.65f, color = MetricBlue.copy(alpha = 0.45f)),
|
|
||||||
StatSegment(weight = 0.10f, color = MetricBlue.copy(alpha = 0.2f)),
|
|
||||||
)
|
|
||||||
HealthCard(modifier = modifier) {
|
|
||||||
SectionHeader(title = stringResource(R.string.detail_sleep_stages))
|
|
||||||
StatBar(segments = segments)
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
) {
|
|
||||||
StageLegend(label = stringResource(R.string.detail_sleep_deep), tint = MetricBlue)
|
|
||||||
StageLegend(label = stringResource(R.string.detail_sleep_light), tint = MetricBlue.copy(alpha = 0.45f))
|
|
||||||
StageLegend(label = stringResource(R.string.detail_sleep_awake), tint = MetricBlue.copy(alpha = 0.2f))
|
|
||||||
}
|
|
||||||
// 入睡/起床演示值(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(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxWidth().padding(vertical = 48.dp),
|
||||||
.size(10.dp)
|
contentAlignment = Alignment.Center,
|
||||||
.clip(RoundedCornerShape(5.dp))
|
) {
|
||||||
.background(tint),
|
|
||||||
)
|
|
||||||
Text(
|
Text(
|
||||||
text = label,
|
text = stringResource(R.string.detail_empty),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = TextSecondary,
|
color = TextSecondary,
|
||||||
modifier = Modifier.padding(start = 6.dp),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return@LazyColumn
|
||||||
|
}
|
||||||
|
|
||||||
/** 指标类型 → 标题字符串资源。文案的唯一来源是 `strings.xml`(ledger Ruling R3)。 */
|
// 每个指标页自己决定往列表里放几张卡、放什么 —— 见各页文件的 KDoc。
|
||||||
@Composable
|
when (state.metric) {
|
||||||
private fun metricTitleRes(type: MetricType): Int = when (type) {
|
MetricType.STEP -> stepDetail(state)
|
||||||
MetricType.STEP -> R.string.metric_step
|
MetricType.HEART_RATE -> heartRateDetail(state)
|
||||||
MetricType.HEART_RATE -> R.string.metric_heart_rate
|
MetricType.SLEEP -> sleepDetail(state)
|
||||||
MetricType.SLEEP -> R.string.metric_sleep
|
MetricType.BLOOD_OXYGEN -> bloodOxygenDetail(state)
|
||||||
MetricType.BLOOD_OXYGEN -> R.string.metric_blood_oxygen
|
MetricType.STRESS -> stressDetail(state)
|
||||||
MetricType.STRESS -> R.string.metric_stress
|
MetricType.TEMPERATURE -> temperatureDetail(state)
|
||||||
MetricType.TEMPERATURE -> R.string.metric_temperature
|
MetricType.BLOOD_PRESSURE -> bloodPressureDetail(state)
|
||||||
MetricType.BLOOD_PRESSURE -> R.string.metric_blood_pressure
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 周/月切换。 */
|
/** 周/月切换。 */
|
||||||
@@ -252,53 +184,90 @@ private fun RangeSelector(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 一行统计摘要。 */
|
/** Preview 用的今日概览。 */
|
||||||
@Composable
|
private val PreviewSummary = HealthSummary(
|
||||||
private fun StatLine(label: String, value: Double, modifier: Modifier = Modifier) {
|
steps = 8426,
|
||||||
Row(
|
stepGoal = 10000,
|
||||||
modifier = modifier.fillMaxWidth().padding(vertical = 4.dp),
|
calories = 327,
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
distanceKm = 6.21,
|
||||||
) {
|
heartRate = 72,
|
||||||
Text(text = label, style = MaterialTheme.typography.bodyMedium, color = TextSecondary)
|
sleepMinutes = 452,
|
||||||
// 数值格式化一律走 core/common/Formatters.kt(Step 1c),页面里不写 String.format:
|
bloodOxygen = 98,
|
||||||
// 那是格式串副本,改口径时要满仓库找。
|
stress = 34,
|
||||||
Text(text = formatDecimal(value), style = MaterialTheme.typography.bodyMedium)
|
temperature = 36.5,
|
||||||
}
|
bloodPressure = null,
|
||||||
}
|
)
|
||||||
|
|
||||||
/** 空列表时返回 0.0,避免 NaN。 */
|
|
||||||
private fun List<Double>.averageOrZero(): Double = if (isEmpty()) 0.0 else average()
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Preview 用的样例数据:7 个点、每天一个,取值从 `base` 起每点递增 `step`。
|
* 构造一份预览状态;各指标页的 Preview 共用它,避免七个预览各写一遍 HealthSummary。
|
||||||
*
|
*
|
||||||
* `base` 与 `step` 由调用方按**所在指标的取值区间**给出:`base` 取区间低位,
|
* @param enabled 该指标是否已开启。默认 true 对应 [PreviewSummary] 里给全了的那些值;
|
||||||
* `step` 取区间跨度的约 1/7,使 7 个点铺满区间的大半。这样柱状图才有可见的高低差
|
* 血压是未开启的,它的预览显式传 false。
|
||||||
* (`MetricBarChart` 按 `value / maxValue` 定高,点值若都挤在一起,7 根柱子会齐平,
|
* @param sleepSessions 只有睡眠预览会传。睡眠时段图要的是「几点到几点」,
|
||||||
* 预览就看不出渲染问题),统计摘要的「平均/最高/最低」三行也不会几乎相等。
|
* 而入睡时刻是**本地时钟上的位置**,随手编一个时间戳会让整张图平移几个钟头,
|
||||||
|
* 所以这里直接向 [MockDataProvider] 要一份演示数据,不另写一套日历换算。
|
||||||
*/
|
*/
|
||||||
private fun previewPoints(base: Double, step: Double): List<MetricPoint> {
|
private fun previewState(
|
||||||
val dayMillis = 24L * 60 * 60 * 1000
|
metric: MetricType,
|
||||||
val start = 1_700_000_000_000L
|
base: Double,
|
||||||
return List(7) { index -> MetricPoint(timestamp = start + index * dayMillis, value = base + index * step) }
|
step: Double,
|
||||||
}
|
enabled: Boolean = true,
|
||||||
|
sleepSessions: List<SleepSession> = emptyList(),
|
||||||
|
): MetricDetailUiState =
|
||||||
|
MetricDetailUiState(
|
||||||
|
loading = false,
|
||||||
|
metric = metric,
|
||||||
|
range = DateRange.WEEK,
|
||||||
|
points = previewPoints(base = base, step = step),
|
||||||
|
summary = PreviewSummary,
|
||||||
|
enabled = enabled,
|
||||||
|
sleepSessions = sleepSessions,
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 睡眠指标的详情页预览:走 `metric == SLEEP` 分支,带分期卡片。
|
* 七个指标的预览。
|
||||||
* 取 SLEEP 不是随便挑的 —— 睡眠是唯一有专属区块的指标,预览它才能看见 `SleepStagesCard`。
|
*
|
||||||
|
* 一个 Preview 一个指标,是因为它们现在真的是七个不同的页面 ——
|
||||||
|
* 改造前「预览步数页」看不出别的页有没有坏,现在也看不出,但至少每个页面都有一张自己
|
||||||
|
* 能对照的图。数值区间按各指标的真实量程给(见 [previewPoints] 的说明)。
|
||||||
*/
|
*/
|
||||||
@Preview(showBackground = true)
|
@Preview(showBackground = true, heightDp = 1000)
|
||||||
@Composable
|
@Composable
|
||||||
private fun MetricDetailContentSleepPreview() {
|
private fun StepDetailPreview() {
|
||||||
RingHealthTheme {
|
RingHealthTheme {
|
||||||
MetricDetailContent(
|
MetricDetailContent(
|
||||||
state = MetricDetailUiState(
|
// 步数:3200→11000,落在 Mock 的 3000~12000 区间内,七根柱子高度递增且都在目标线附近。
|
||||||
loading = false,
|
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,
|
metric = MetricType.SLEEP,
|
||||||
range = DateRange.WEEK,
|
base = 330.0,
|
||||||
// 睡眠时长(分钟):330→480,落在 Mock 的 330~510 区间内,跨度 150 分钟,
|
step = 25.0,
|
||||||
// 柱高从约 69% 递增到 100%,肉眼能看出梯度。
|
sleepSessions = MockDataProvider().sleepSessions(DateRange.WEEK),
|
||||||
points = previewPoints(base = 330.0, step = 25.0),
|
|
||||||
),
|
),
|
||||||
onBack = {},
|
onBack = {},
|
||||||
onSelectRange = {},
|
onSelectRange = {},
|
||||||
@@ -306,26 +275,76 @@ private fun MetricDetailContentSleepPreview() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
@Preview(showBackground = true, heightDp = 1000)
|
||||||
* 非睡眠指标的详情页预览:走「只有趋势图 + 统计摘要」的分支。
|
|
||||||
* 两个 Preview 各覆盖一个分支,是因为只看睡眠那个无法发现「分期卡片漏判 metric、所有指标都显示」这类回归。
|
|
||||||
*/
|
|
||||||
@Preview(showBackground = true)
|
|
||||||
@Composable
|
@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 {
|
RingHealthTheme {
|
||||||
MetricDetailContent(
|
MetricDetailContent(
|
||||||
state = MetricDetailUiState(
|
// 血压在演示数据里默认关着,所以这一页预览的是「未开启」说明卡。
|
||||||
loading = false,
|
state = previewState(
|
||||||
metric = MetricType.STEP,
|
metric = MetricType.BLOOD_PRESSURE,
|
||||||
range = DateRange.WEEK,
|
base = 110.0,
|
||||||
// 步数:3200→11000,落在 Mock 的 3000~12000 区间内,跨度 7800 步,
|
step = 3.3,
|
||||||
// 柱高从约 27% 递增到 100%。原来的 7500 起点会让 7 根柱子全挤在 99%~100% 高度上,
|
enabled = false,
|
||||||
// 看起来是一条平带 —— 恰是预览本该暴露的问题。
|
|
||||||
points = previewPoints(base = 3200.0, step = 1300.0),
|
|
||||||
),
|
),
|
||||||
onBack = {},
|
onBack = {},
|
||||||
onSelectRange = {},
|
onSelectRange = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 血压开着时的样子:一对读数 + 「还没开始生成记录」的说明。 */
|
||||||
|
@Preview(showBackground = true, heightDp = 1000)
|
||||||
|
@Composable
|
||||||
|
private fun BloodPressureEnabledPreview() {
|
||||||
|
RingHealthTheme {
|
||||||
|
MetricDetailContent(
|
||||||
|
state = previewState(
|
||||||
|
metric = MetricType.BLOOD_PRESSURE,
|
||||||
|
base = 110.0,
|
||||||
|
step = 3.3,
|
||||||
|
).copy(summary = PreviewSummary.copy(bloodPressure = "118/76")),
|
||||||
|
onBack = {},
|
||||||
|
onSelectRange = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,254 @@
|
|||||||
|
package com.placeholder.ringhealth.feature.detail
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.placeholder.ringhealth.R
|
||||||
|
import com.placeholder.ringhealth.core.common.formatDecimal
|
||||||
|
import com.placeholder.ringhealth.core.common.formatMonthDayNumeric
|
||||||
|
import com.placeholder.ringhealth.core.common.formatSleepDuration
|
||||||
|
import com.placeholder.ringhealth.core.common.formatSteps
|
||||||
|
import com.placeholder.ringhealth.core.common.weekdayResFor
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.TextPrimary
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.TextSecondary
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.component.HealthCard
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.metricIcon
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.metricTint
|
||||||
|
import com.placeholder.ringhealth.domain.model.MetricPoint
|
||||||
|
import com.placeholder.ringhealth.domain.model.MetricType
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 七个指标详情页共用的小件与口径。
|
||||||
|
*
|
||||||
|
* 放在这里的东西有两个共同点:**每个页面都要用**,且**不因指标而变**。
|
||||||
|
* 只要一个东西是「心率页独有的」,它就该待在自己的页面文件里 ——
|
||||||
|
* 那正是这次改造要恢复的东西:此前七页共用一整套模板,于是七页长得一模一样。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 指标类型 → 标题字符串资源。文案的唯一来源是 `strings.xml`(ledger Ruling R3)。 */
|
||||||
|
@Composable
|
||||||
|
internal fun metricTitleRes(type: MetricType): Int = when (type) {
|
||||||
|
MetricType.STEP -> R.string.metric_step
|
||||||
|
MetricType.HEART_RATE -> R.string.metric_heart_rate
|
||||||
|
MetricType.SLEEP -> R.string.metric_sleep
|
||||||
|
MetricType.BLOOD_OXYGEN -> R.string.metric_blood_oxygen
|
||||||
|
MetricType.STRESS -> R.string.metric_stress
|
||||||
|
MetricType.TEMPERATURE -> R.string.metric_temperature
|
||||||
|
MetricType.BLOOD_PRESSURE -> R.string.metric_blood_pressure
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* hero 区的大字读数:数值 + 可选单位,纵向排列在主标签之下。
|
||||||
|
*
|
||||||
|
* 单位用更小更浅的字 —— 「72」与「次/分」一样重的话,一眼看不出哪个是要读的数。
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun HeroReading(
|
||||||
|
value: String,
|
||||||
|
unit: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
unitRes: Int? = null,
|
||||||
|
) {
|
||||||
|
Row(modifier = modifier, verticalAlignment = Alignment.Bottom) {
|
||||||
|
Text(text = value, style = MaterialTheme.typography.displaySmall, color = TextPrimary)
|
||||||
|
val unitText = unitRes?.let { stringResource(it) } ?: unit
|
||||||
|
if (unitText.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = unitText,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = TextSecondary,
|
||||||
|
modifier = Modifier.padding(start = 4.dp, bottom = 6.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一列统计摘要:标签在上、数值在下。
|
||||||
|
*
|
||||||
|
* 三项横排而不是三行竖排:三行会把这张卡撑到 150dp 高去放三个数字,
|
||||||
|
* 横排后高度减半,且三者并列时更容易互相比较。
|
||||||
|
*
|
||||||
|
* @param valueColor 数值的强调色;null 表示用正文色。只有「这个数字本身是坏消息」时才染
|
||||||
|
* (如血氧的偏低天数),全列染色会让三列失去主次。
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun StatColumn(
|
||||||
|
label: String,
|
||||||
|
value: String,
|
||||||
|
unit: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
valueColor: Color? = null,
|
||||||
|
) {
|
||||||
|
Column(modifier = modifier) {
|
||||||
|
Text(text = label, style = MaterialTheme.typography.bodySmall, color = TextSecondary)
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
Row(verticalAlignment = Alignment.Bottom) {
|
||||||
|
Text(
|
||||||
|
text = value,
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
color = valueColor ?: TextPrimary,
|
||||||
|
)
|
||||||
|
if (unit.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = unit,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = TextSecondary,
|
||||||
|
modifier = Modifier.padding(start = 3.dp, bottom = 3.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 统计摘要的三个槽位,横向铺满一行。 */
|
||||||
|
@Composable
|
||||||
|
internal fun StatRow(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 未开启指标的说明卡:图标 + 「XX监测未开启」+ 一句怎么办。
|
||||||
|
*
|
||||||
|
* 七个指标共用一张。哪一项关着就显示哪一项的图标和名字 —— 用户是从首页那张写着
|
||||||
|
* 「未开启」的卡点进来的,这里的图标和颜色必须和那张卡一模一样,他才知道自己没点错。
|
||||||
|
*
|
||||||
|
* 文案拆成「标题」和「怎么办」两句:[titleRes] 只报状态,[R.string.detail_disabled_hint]
|
||||||
|
* 统一说明去设备页开启。此前只有血压一页有这段说明,而用户一旦关掉别的指标,
|
||||||
|
* 详情页仍会画出一整张图 —— 那段并不存在的监测数据比空白更误导。
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun MetricDisabledCard(metric: MetricType, modifier: Modifier = Modifier) {
|
||||||
|
HealthCard(modifier = modifier) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = metricIcon(metric),
|
||||||
|
contentDescription = null,
|
||||||
|
tint = metricTint(metric),
|
||||||
|
modifier = Modifier.size(40.dp),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(14.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(
|
||||||
|
R.string.detail_disabled_title,
|
||||||
|
stringResource(metricTitleRes(metric)),
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
// 说明文案居中对齐、左右留白:它是一段话而不是一行标签,铺满整行会很难读。
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.detail_disabled_hint),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = TextSecondary,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 详情页统计摘要的显示口径:数值保留几位小数、单位是什么。 */
|
||||||
|
internal data class StatFormat(val decimals: Int, val unit: String)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 指标 → 统计摘要的显示口径。
|
||||||
|
*
|
||||||
|
* 小数位按指标分别定:此前一律走 `"%.1f"`,于是心率显示成「78.0」、步数显示成「8426.0」——
|
||||||
|
* 尾随的 `.0` 是假精度,看着像仪器读数精度比实际高一个数量级。
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun statFormatFor(type: MetricType): StatFormat = when (type) {
|
||||||
|
// 体温保留一位:36.5 与 36 之间差的 0.5℃ 是有意义的差别,取整会把它抹掉。
|
||||||
|
MetricType.TEMPERATURE -> StatFormat(decimals = 1, unit = stringResource(R.string.unit_celsius))
|
||||||
|
MetricType.HEART_RATE -> StatFormat(decimals = 0, unit = stringResource(R.string.unit_bpm))
|
||||||
|
MetricType.BLOOD_OXYGEN -> StatFormat(decimals = 0, unit = stringResource(R.string.unit_percent))
|
||||||
|
MetricType.BLOOD_PRESSURE -> StatFormat(decimals = 0, unit = stringResource(R.string.unit_mmhg))
|
||||||
|
MetricType.STEP -> StatFormat(decimals = 0, unit = stringResource(R.string.unit_steps))
|
||||||
|
// 压力是 0–100 的评分,本身没有单位;睡眠的数值在 formatStatValue 里换成时长文本,也不需要单位。
|
||||||
|
MetricType.STRESS, MetricType.SLEEP -> StatFormat(decimals = 0, unit = "")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计数值 → 展示文本。
|
||||||
|
*
|
||||||
|
* 数值格式化一律走 `core/common/Formatters.kt`,页面里不写 String.format:
|
||||||
|
* 那是格式串副本,改口径时要满仓库找。
|
||||||
|
*
|
||||||
|
* 睡眠是个例外:它的数据点单位是「分钟」,但「平均 451 分钟」远不如「平均 7小时31分」好读,
|
||||||
|
* 所以这里换成与首页同一套时长格式,而不是走 [formatDecimal]。
|
||||||
|
*/
|
||||||
|
internal fun formatStatValue(value: Double, type: MetricType, format: StatFormat): String =
|
||||||
|
when (type) {
|
||||||
|
// 睡眠的数值单位是「分钟」,但「平均 451 分钟」远不如「平均 7小时31分」好读。
|
||||||
|
MetricType.SLEEP -> formatSleepDuration(value.roundToInt())
|
||||||
|
// 步数要走千分位:首页的步数是「8,426」,详情页写成「8426」会像是两套口径。
|
||||||
|
MetricType.STEP -> formatSteps(value.roundToInt())
|
||||||
|
else -> formatDecimal(value, format.decimals)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 柱状图 X 轴的刻度数量上限。 */
|
||||||
|
private const val MAX_AXIS_TICKS = 7
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成图表的 X 轴刻度。
|
||||||
|
*
|
||||||
|
* 点数不多(周视图 7 个点)时每根柱子都标,刻度与柱子一一对齐;
|
||||||
|
* 点数多(月视图 30 个点)时等距取 [MAX_AXIS_TICKS] 个 —— 30 个「9/10」在约 350dp 宽里
|
||||||
|
* 每个只分得到 11dp,必然被截成一两个字,不如少标几个能看清的。
|
||||||
|
*
|
||||||
|
* 周视图用星期简写(走 `strings.xml`,Ruling R3);月视图用 `M/d` 纯数字格式 ——
|
||||||
|
* 它不含任何需要翻译的文字,与 R25 对「日期 + 空格 + 时间」的判断一致。
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun chartAxisLabels(points: List<MetricPoint>): List<String> {
|
||||||
|
if (points.isEmpty()) return emptyList()
|
||||||
|
if (points.size <= MAX_AXIS_TICKS) {
|
||||||
|
return points.map { stringResource(weekdayResFor(it.timestamp)) }
|
||||||
|
}
|
||||||
|
val step = (points.size - 1).toFloat() / (MAX_AXIS_TICKS - 1)
|
||||||
|
return (0 until MAX_AXIS_TICKS).map { tick ->
|
||||||
|
val index = (tick * step).roundToInt().coerceIn(0, points.lastIndex)
|
||||||
|
formatMonthDayNumeric(points[index].timestamp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preview 用的样例数据:7 个点、每天一个,取值从 `base` 起每点递增 `step`。
|
||||||
|
*
|
||||||
|
* `base` 与 `step` 由调用方按**所在指标的取值区间**给出:`base` 取区间低位,
|
||||||
|
* `step` 取区间跨度的约 1/7,使 7 个点铺满区间的大半。这样图才有可见的高低差,
|
||||||
|
* 统计摘要的三列也不会几乎相等 —— 若点值都挤在一起,七根柱子会齐平,预览就看不出渲染问题。
|
||||||
|
*/
|
||||||
|
internal fun previewPoints(base: Double, step: Double): List<MetricPoint> {
|
||||||
|
val dayMillis = 24L * 60 * 60 * 1000
|
||||||
|
val start = 1_700_000_000_000L
|
||||||
|
return List(7) { index -> MetricPoint(timestamp = start + index * dayMillis, value = base + index * step) }
|
||||||
|
}
|
||||||
+52
-5
@@ -6,25 +6,45 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import androidx.lifecycle.viewmodel.initializer
|
import androidx.lifecycle.viewmodel.initializer
|
||||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||||
import com.placeholder.ringhealth.domain.model.DateRange
|
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.MetricPoint
|
||||||
import com.placeholder.ringhealth.domain.model.MetricType
|
import com.placeholder.ringhealth.domain.model.MetricType
|
||||||
|
import com.placeholder.ringhealth.domain.model.SleepSession
|
||||||
|
import com.placeholder.ringhealth.domain.model.isEnabled
|
||||||
import com.placeholder.ringhealth.domain.repository.HealthDataRepository
|
import com.placeholder.ringhealth.domain.repository.HealthDataRepository
|
||||||
import com.placeholder.ringhealth.ringHealthApplication
|
import com.placeholder.ringhealth.ringHealthApplication
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
|
|
||||||
/** 指标详情 UI 状态。 */
|
/**
|
||||||
|
* 指标详情 UI 状态。
|
||||||
|
*
|
||||||
|
* [summary] 是今日概览,各指标的 hero 卡从它取「当前值」(今日步数、当前心率……)。
|
||||||
|
* 历史点 [points] 只回答「这几天怎么变的」,取不出「现在是多少」——
|
||||||
|
* 之前 hero 区缺的就是这个值,页面第一屏只有一个标题和一张图。
|
||||||
|
*
|
||||||
|
* [enabled] 是该指标的功能开关是否打开。关掉的指标整页走「未开启」分支、不画图:
|
||||||
|
* 它的历史点即便有(Mock 会照常生成随机序列),画的也是不存在的监测数据。
|
||||||
|
*
|
||||||
|
* [sleepSessions] 只有睡眠页会拿到内容,其余指标恒为空列表(见 [MetricDetailViewModel.sleepSessionsFor])。
|
||||||
|
* 睡眠时段图要的是「几点到几点」,[points] 里装不下,只能另走一路。
|
||||||
|
*/
|
||||||
data class MetricDetailUiState(
|
data class MetricDetailUiState(
|
||||||
val loading: Boolean,
|
val loading: Boolean,
|
||||||
val metric: MetricType,
|
val metric: MetricType,
|
||||||
val range: DateRange,
|
val range: DateRange,
|
||||||
val points: List<MetricPoint>,
|
val points: List<MetricPoint>,
|
||||||
|
val summary: HealthSummary?,
|
||||||
|
val enabled: Boolean,
|
||||||
|
val sleepSessions: List<SleepSession>,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -33,20 +53,30 @@ data class MetricDetailUiState(
|
|||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
class MetricDetailViewModel(
|
class MetricDetailViewModel(
|
||||||
private val metric: MetricType,
|
private val metric: MetricType,
|
||||||
healthDataRepository: HealthDataRepository,
|
private val healthDataRepository: HealthDataRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val range = MutableStateFlow(DateRange.WEEK)
|
private val range = MutableStateFlow(DateRange.WEEK)
|
||||||
|
|
||||||
|
/** 今日概览只需要订阅一次,与时间范围无关,所以放在 flatMapLatest 外面。 */
|
||||||
|
private val summary = healthDataRepository.observeTodaySummary()
|
||||||
|
|
||||||
val uiState: StateFlow<MetricDetailUiState> = range
|
val uiState: StateFlow<MetricDetailUiState> = range
|
||||||
.flatMapLatest { currentRange ->
|
.flatMapLatest { currentRange ->
|
||||||
healthDataRepository.observeMetricHistory(metric, currentRange)
|
combine(
|
||||||
.map { points ->
|
healthDataRepository.observeMetricHistory(metric, currentRange),
|
||||||
|
summary,
|
||||||
|
sleepSessionsFor(currentRange),
|
||||||
|
) { points, today, sessions ->
|
||||||
MetricDetailUiState(
|
MetricDetailUiState(
|
||||||
loading = false,
|
loading = false,
|
||||||
metric = metric,
|
metric = metric,
|
||||||
range = currentRange,
|
range = currentRange,
|
||||||
points = points,
|
points = points,
|
||||||
|
summary = today,
|
||||||
|
// 概览里有没有这个指标的值,就是它开没开 —— 概览本身由开关生成。
|
||||||
|
enabled = today.isEnabled(metric),
|
||||||
|
sleepSessions = sessions,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -58,9 +88,26 @@ class MetricDetailViewModel(
|
|||||||
metric = metric,
|
metric = metric,
|
||||||
range = DateRange.WEEK,
|
range = DateRange.WEEK,
|
||||||
points = emptyList(),
|
points = emptyList(),
|
||||||
|
summary = null,
|
||||||
|
enabled = true,
|
||||||
|
sleepSessions = emptyList(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 睡眠时段只在睡眠页取;其余六个指标拿一个恒空的流。
|
||||||
|
*
|
||||||
|
* 不写成「顺手也查一遍、反正用不上」:那会让每个指标页都多订阅一路睡眠数据,
|
||||||
|
* M2 换成真实实现后就是六次无谓的设备查询,而且多出来的那路数据将来一定会有人用,
|
||||||
|
* 用着用着就分不清哪个页面依赖什么了。
|
||||||
|
*/
|
||||||
|
private fun sleepSessionsFor(currentRange: DateRange): Flow<List<SleepSession>> =
|
||||||
|
if (metric == MetricType.SLEEP) {
|
||||||
|
healthDataRepository.observeSleepSessions(currentRange)
|
||||||
|
} else {
|
||||||
|
flowOf(emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
/** 切换时间范围(周/月)。 */
|
/** 切换时间范围(周/月)。 */
|
||||||
fun switchRange(newRange: DateRange) {
|
fun switchRange(newRange: DateRange) {
|
||||||
range.update { newRange }
|
range.update { newRange }
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package com.placeholder.ringhealth.feature.detail
|
||||||
|
|
||||||
|
import com.placeholder.ringhealth.core.common.minutesSinceSleepAnchor
|
||||||
|
import com.placeholder.ringhealth.domain.model.SleepSession
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一组数据点的统计摘要。
|
||||||
|
*
|
||||||
|
* 抽成独立类型而不是散着传四个 Double,是为了让「空数据」有一个明确的表示 ——
|
||||||
|
* 见 [metricStatsOf]。
|
||||||
|
*/
|
||||||
|
data class MetricStats(
|
||||||
|
val average: Double,
|
||||||
|
val max: Double,
|
||||||
|
val min: Double,
|
||||||
|
val count: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 求统计摘要;**列表为空时返回 null**。
|
||||||
|
*
|
||||||
|
* 此前这里返回 0.0,页面上于是出现「平均 0.0 / 最高 0.0 / 最低 0.0」三行 ——
|
||||||
|
* 看着像三条真实读数,其实是「没有数据」。空数据要么不渲染,要么渲染成「暂无数据」,
|
||||||
|
* 不能渲染成 0:0 次/分的心率是个能吓死人的假数。
|
||||||
|
*/
|
||||||
|
fun metricStatsOf(values: List<Double>): MetricStats? {
|
||||||
|
if (values.isEmpty()) return null
|
||||||
|
return MetricStats(
|
||||||
|
average = values.average(),
|
||||||
|
max = values.max(),
|
||||||
|
min = values.min(),
|
||||||
|
count = values.size,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 睡眠统计。入睡/醒来给的是**距锚点 20:00 的分钟数**(见 `minutesSinceSleepAnchor`),
|
||||||
|
* 不是钟点本身 —— 直接对「23:43」「00:31」这样的时刻求平均会得到 12:07,正好是睡得最沉的时候。
|
||||||
|
* 显示时用 `formatSleepAnchorOffset` 换回钟点。
|
||||||
|
*
|
||||||
|
* @param averageBedtimeMinute 平均入睡时刻(锚点偏移量)。
|
||||||
|
* @param averageWakeMinute 平均醒来时刻(锚点偏移量)。
|
||||||
|
* @param averageSleepMinutes 平均实际睡眠时长,不含夜里清醒。
|
||||||
|
* @param count 统计了几晚。
|
||||||
|
*/
|
||||||
|
data class SleepStats(
|
||||||
|
val averageBedtimeMinute: Int,
|
||||||
|
val averageWakeMinute: Int,
|
||||||
|
val averageSleepMinutes: Int,
|
||||||
|
val count: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 求睡眠统计;**列表为空时返回 null**,理由同 [metricStatsOf]。
|
||||||
|
*
|
||||||
|
* 前提:这几晚的作息相差不到半圈(12 小时)。演示数据里入睡都在 21:00–04:00 之间,成立;
|
||||||
|
* 若将来出现「白天补觉」这类记录,锚点归一化会把 14:00 当成前一天的深夜,
|
||||||
|
* 均值就会被拽偏,那时该改用中位数。
|
||||||
|
*/
|
||||||
|
fun sleepStatsOf(sessions: List<SleepSession>): SleepStats? {
|
||||||
|
if (sessions.isEmpty()) return null
|
||||||
|
fun averageOf(values: List<Int>): Int = values.average().roundToInt()
|
||||||
|
return SleepStats(
|
||||||
|
averageBedtimeMinute = averageOf(sessions.map { minutesSinceSleepAnchor(it.sleepStart) }),
|
||||||
|
averageWakeMinute = averageOf(sessions.map { minutesSinceSleepAnchor(it.sleepEnd) }),
|
||||||
|
averageSleepMinutes = averageOf(sessions.map { it.sleepMinutes }),
|
||||||
|
count = sessions.size,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 落在 [range] 内的天数。心率页的「正常范围天数」用它。 */
|
||||||
|
fun List<Double>.countWithin(range: ClosedFloatingPointRange<Double>): Int = count { it in range }
|
||||||
|
|
||||||
|
/** 低于 [threshold] 的天数。血氧页的「偏低天数」用它。 */
|
||||||
|
fun List<Double>.countBelow(threshold: Double): Int = count { it < threshold }
|
||||||
|
|
||||||
|
/** 不低于 [threshold] 的天数。步数页的「达标天数」用它。 */
|
||||||
|
fun List<Double>.countAtLeast(threshold: Double): Int = count { it >= threshold }
|
||||||
+145
@@ -0,0 +1,145 @@
|
|||||||
|
package com.placeholder.ringhealth.feature.detail.pages
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.lazy.LazyListScope
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.placeholder.ringhealth.R
|
||||||
|
import com.placeholder.ringhealth.core.common.formatDecimal
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.MetricCyan
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.MetricRed
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.TextSecondary
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.component.HealthCard
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.component.MetricThresholdStrip
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.component.SectionHeader
|
||||||
|
import com.placeholder.ringhealth.domain.model.MetricReference
|
||||||
|
import com.placeholder.ringhealth.feature.detail.HeroReading
|
||||||
|
import com.placeholder.ringhealth.feature.detail.MetricDetailUiState
|
||||||
|
import com.placeholder.ringhealth.feature.detail.StatColumn
|
||||||
|
import com.placeholder.ringhealth.feature.detail.StatRow
|
||||||
|
import com.placeholder.ringhealth.feature.detail.chartAxisLabels
|
||||||
|
import com.placeholder.ringhealth.feature.detail.countBelow
|
||||||
|
import com.placeholder.ringhealth.feature.detail.formatStatValue
|
||||||
|
import com.placeholder.ringhealth.feature.detail.metricStatsOf
|
||||||
|
import com.placeholder.ringhealth.feature.detail.statFormatFor
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 血氧详情页。
|
||||||
|
*
|
||||||
|
* 血氧的特点是**只有一个下限**:高了没有意义(人体血氧到 100% 就到顶了),
|
||||||
|
* 低了才是问题。所以这一页全篇围绕「95% 这条线」组织 ——
|
||||||
|
* 头图给出「达标 / 偏低」的判定,趋势图画成阈值带(点在线的哪一侧就是结论),
|
||||||
|
* 统计里第三列是「偏低几天」而不是「最高多少」。
|
||||||
|
*/
|
||||||
|
internal fun LazyListScope.bloodOxygenDetail(state: MetricDetailUiState) {
|
||||||
|
item { BloodOxygenHeroCard(state) }
|
||||||
|
item { BloodOxygenChartCard(state) }
|
||||||
|
item { BloodOxygenStatsCard(state) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 头图:当前血氧 + 达标判定 + 达标线说明。 */
|
||||||
|
@Composable
|
||||||
|
private fun BloodOxygenHeroCard(state: MetricDetailUiState, modifier: Modifier = Modifier) {
|
||||||
|
val current = state.summary?.bloodOxygen ?: return
|
||||||
|
val below = current < MetricReference.BLOOD_OXYGEN_MIN
|
||||||
|
HealthCard(modifier = modifier) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.metric_blood_oxygen),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = TextSecondary,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
|
HeroReading(value = current.toString(), unit = "", unitRes = R.string.unit_percent)
|
||||||
|
Spacer(modifier = Modifier.height(10.dp))
|
||||||
|
// 判定用文字 + 颜色双重表达:单靠颜色的话,色觉障碍用户读不出「达标」还是「偏低」。
|
||||||
|
Text(
|
||||||
|
text = stringResource(
|
||||||
|
if (below) R.string.detail_status_low else R.string.detail_status_normal,
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = if (below) MetricRed else MetricCyan,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.detail_spo2_threshold, formatDecimal(MetricReference.BLOOD_OXYGEN_MIN, 0)),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = TextSecondary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 趋势图:阈值带。 */
|
||||||
|
@Composable
|
||||||
|
private fun BloodOxygenChartCard(state: MetricDetailUiState, modifier: Modifier = Modifier) {
|
||||||
|
HealthCard(modifier = modifier) {
|
||||||
|
SectionHeader(
|
||||||
|
title = stringResource(R.string.detail_chart_daily, stringResource(R.string.metric_blood_oxygen)),
|
||||||
|
trailing = {
|
||||||
|
Text(
|
||||||
|
text = stringResource(
|
||||||
|
R.string.detail_spo2_threshold,
|
||||||
|
formatDecimal(MetricReference.BLOOD_OXYGEN_MIN, 0),
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = TextSecondary,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
MetricThresholdStrip(
|
||||||
|
values = state.points.map { it.value },
|
||||||
|
// 纵轴下沿取 FLOOR(90)而不是阈值(95):低于阈值的点若被夹在轴上,
|
||||||
|
// 会集体贴着底边,看不出「低了 1%」还是「低了 5%」。
|
||||||
|
axis = MetricReference.BLOOD_OXYGEN_FLOOR..MetricReference.BLOOD_OXYGEN_MIN.plus(
|
||||||
|
SPO2_AXIS_HEADROOM,
|
||||||
|
),
|
||||||
|
threshold = MetricReference.BLOOD_OXYGEN_MIN,
|
||||||
|
color = MetricCyan,
|
||||||
|
warningColor = MetricRed,
|
||||||
|
axisLabels = chartAxisLabels(state.points),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 纵轴上沿比阈值高出的部分。
|
||||||
|
*
|
||||||
|
* 血氧正常值 95–100,最高就是 100(血氧仪的上限)。上沿取 100 而不是「数据最大值」——
|
||||||
|
* 固定上沿才能让「点在线的上方多远处」在不同周之间可比。
|
||||||
|
*/
|
||||||
|
private const val SPO2_AXIS_HEADROOM = 5.0
|
||||||
|
|
||||||
|
/** 统计卡:平均 / 最低 / 偏低天数。 */
|
||||||
|
@Composable
|
||||||
|
private fun BloodOxygenStatsCard(state: MetricDetailUiState, modifier: Modifier = Modifier) {
|
||||||
|
val values = state.points.map { it.value }
|
||||||
|
val stats = metricStatsOf(values) ?: return
|
||||||
|
val format = statFormatFor(state.metric)
|
||||||
|
val lowDays = values.countBelow(MetricReference.BLOOD_OXYGEN_MIN)
|
||||||
|
HealthCard(modifier = modifier) {
|
||||||
|
StatRow {
|
||||||
|
StatColumn(
|
||||||
|
label = stringResource(R.string.detail_average),
|
||||||
|
value = formatStatValue(stats.average, state.metric, format),
|
||||||
|
unit = format.unit,
|
||||||
|
)
|
||||||
|
StatColumn(
|
||||||
|
label = stringResource(R.string.detail_min),
|
||||||
|
value = formatStatValue(stats.min, state.metric, format),
|
||||||
|
unit = format.unit,
|
||||||
|
)
|
||||||
|
// 第三列给「偏低天数」,并把数字染成警示色 —— 只在真的出现偏低时才染色,
|
||||||
|
// 「偏低 0 天」染红会让人以为出了事。
|
||||||
|
StatColumn(
|
||||||
|
label = stringResource(R.string.detail_status_low),
|
||||||
|
value = stringResource(R.string.detail_days_count, lowDays),
|
||||||
|
unit = "",
|
||||||
|
valueColor = if (lowDays > 0) MetricRed else null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+98
@@ -0,0 +1,98 @@
|
|||||||
|
package com.placeholder.ringhealth.feature.detail.pages
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.lazy.LazyListScope
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.placeholder.ringhealth.R
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.TextSecondary
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.component.HealthCard
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.component.SectionHeader
|
||||||
|
import com.placeholder.ringhealth.feature.detail.MetricDetailUiState
|
||||||
|
import com.placeholder.ringhealth.feature.detail.StatColumn
|
||||||
|
import com.placeholder.ringhealth.feature.detail.StatRow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 血压详情页:**已开启态**。
|
||||||
|
*
|
||||||
|
* 未开启的那一态不在这里 —— 它由 `MetricDetailScreen` 统一的「未开启」分支负责
|
||||||
|
* (`MetricDisabledCard`),因为「关掉的指标不画图」是七个页面共同的事,不该由某一页各自实现。
|
||||||
|
* 本页因此可以专注于一件事:功能开着的时候显示什么。
|
||||||
|
*
|
||||||
|
* **它不画趋势图,这是有意的。** 血压与其他六个指标不同:一次测量是「收缩压/舒张压」两个数,
|
||||||
|
* 而当前的历史数据模型 `MetricPoint(timestamp, value: Double)` 只装得下一个数,
|
||||||
|
* `metricHistory(BLOOD_PRESSURE)` 返回的那串 110–130 是 Mock 随手生成的单值序列,
|
||||||
|
* 既不是收缩压也不是舒张压,画出来是一张无意义的图。M3 接入真实血压数据后,
|
||||||
|
* 这一页改成双值趋势图,`HealthSummary.bloodPressure` 也从字符串改为结构化的两个数值。
|
||||||
|
*/
|
||||||
|
internal fun LazyListScope.bloodPressureDetail(state: MetricDetailUiState) {
|
||||||
|
item { BloodPressureHeroCard(state) }
|
||||||
|
item { BloodPressurePendingCard() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 头图:一对读数。
|
||||||
|
*
|
||||||
|
* 收缩压与舒张压并排,而不是像其他指标那样给一个大字 —— 「118/76」本身就是一对数,
|
||||||
|
* 拆成两栏才看得出哪个是哪个;挤成一行大字,用户只会看到一个比别的指标长很多的数字。
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun BloodPressureHeroCard(state: MetricDetailUiState, modifier: Modifier = Modifier) {
|
||||||
|
// 概览里是「收缩压/舒张压」字符串(如 "118/76")。M1 的 Mock 保证格式固定,
|
||||||
|
// 取不到两段时整卡不显示,而不是硬凑一个「--/--」。
|
||||||
|
val reading = state.summary?.bloodPressure ?: return
|
||||||
|
val parts = reading.split("/")
|
||||||
|
if (parts.size != 2) return
|
||||||
|
|
||||||
|
HealthCard(modifier = modifier) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.detail_bp_latest),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = TextSecondary,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(24.dp),
|
||||||
|
) {
|
||||||
|
StatColumn(
|
||||||
|
label = stringResource(R.string.detail_bp_systolic),
|
||||||
|
value = parts[0].trim(),
|
||||||
|
unit = stringResource(R.string.unit_mmhg),
|
||||||
|
)
|
||||||
|
StatColumn(
|
||||||
|
label = stringResource(R.string.detail_bp_diastolic),
|
||||||
|
value = parts[1].trim(),
|
||||||
|
unit = stringResource(R.string.unit_mmhg),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 说明卡:功能开着,但还没有可看的历史。
|
||||||
|
*
|
||||||
|
* 这一句必须留着。功能刚打开时用户最想知道的是「然后呢」——
|
||||||
|
* 血压与其余指标不同,它需要先在设备页完成手动校准才会开始生成记录
|
||||||
|
* (同一句话在设备页那个开关的副标题里也写着)。省掉它,这一页就只剩一对孤零零的读数,
|
||||||
|
* 用户会以为历史数据加载失败了。
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun BloodPressurePendingCard(modifier: Modifier = Modifier) {
|
||||||
|
HealthCard(modifier = modifier) {
|
||||||
|
SectionHeader(title = stringResource(R.string.detail_bp_history))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.detail_bp_pending),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = TextSecondary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package com.placeholder.ringhealth.feature.detail.pages
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.lazy.LazyListScope
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.placeholder.ringhealth.R
|
||||||
|
import com.placeholder.ringhealth.core.common.formatDecimal
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.MetricRed
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.TextSecondary
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.component.HealthCard
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.component.MetricBarChart
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.component.MetricRangeBar
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.component.SectionHeader
|
||||||
|
import com.placeholder.ringhealth.domain.model.MetricReference
|
||||||
|
import com.placeholder.ringhealth.feature.detail.HeroReading
|
||||||
|
import com.placeholder.ringhealth.feature.detail.MetricDetailUiState
|
||||||
|
import com.placeholder.ringhealth.feature.detail.StatColumn
|
||||||
|
import com.placeholder.ringhealth.feature.detail.StatRow
|
||||||
|
import com.placeholder.ringhealth.feature.detail.chartAxisLabels
|
||||||
|
import com.placeholder.ringhealth.feature.detail.countWithin
|
||||||
|
import com.placeholder.ringhealth.feature.detail.formatStatValue
|
||||||
|
import com.placeholder.ringhealth.feature.detail.metricStatsOf
|
||||||
|
import com.placeholder.ringhealth.feature.detail.statFormatFor
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 心率详情页。
|
||||||
|
*
|
||||||
|
* 心率的特点是**有正常区间**:「72」这个数字本身说明不了什么,落在 60–100 之内才算正常静息。
|
||||||
|
* 所以头图的核心不是数字而是那根**区间条** —— 数字只回答「是多少」,区间条回答「算不算正常」。
|
||||||
|
* 趋势图同样在柱子后面铺一层正常带,柱子冒出去就是那天出了问题。
|
||||||
|
*/
|
||||||
|
internal fun LazyListScope.heartRateDetail(state: MetricDetailUiState) {
|
||||||
|
item { HeartRateHeroCard(state) }
|
||||||
|
item { HeartRateChartCard(state) }
|
||||||
|
item { HeartRateStatsCard(state) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 头图:当前心率 + 区间条 + 状态文案。 */
|
||||||
|
@Composable
|
||||||
|
private fun HeartRateHeroCard(state: MetricDetailUiState, modifier: Modifier = Modifier) {
|
||||||
|
val current = state.summary?.heartRate ?: return
|
||||||
|
HealthCard(modifier = modifier) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.metric_heart_rate),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = TextSecondary,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
|
HeroReading(
|
||||||
|
value = current.toString(),
|
||||||
|
unit = "",
|
||||||
|
unitRes = R.string.unit_bpm,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(14.dp))
|
||||||
|
MetricRangeBar(
|
||||||
|
value = current.toDouble(),
|
||||||
|
axis = MetricReference.HEART_RATE_AXIS,
|
||||||
|
normal = MetricReference.HEART_RATE_NORMAL,
|
||||||
|
color = MetricRed,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(10.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(
|
||||||
|
R.string.detail_hr_normal_band,
|
||||||
|
formatDecimal(MetricReference.HEART_RATE_NORMAL.start, 0),
|
||||||
|
formatDecimal(MetricReference.HEART_RATE_NORMAL.endInclusive, 0),
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = TextSecondary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 趋势图:柱子 + 正常区间带。
|
||||||
|
*
|
||||||
|
* 纵向窗口固定取 [MetricReference.HEART_RATE_AXIS](40–120)而不是按数据自适应:
|
||||||
|
* 正常带的位置要**每次都一样**,否则同一份 60–100 的带子在不同周里画在不同高度,
|
||||||
|
* 「柱子冒出去了没有」就失去了跨页面的可比性。
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun HeartRateChartCard(state: MetricDetailUiState, modifier: Modifier = Modifier) {
|
||||||
|
HealthCard(modifier = modifier) {
|
||||||
|
SectionHeader(
|
||||||
|
title = stringResource(R.string.detail_chart_daily, stringResource(R.string.metric_heart_rate)),
|
||||||
|
trailing = {
|
||||||
|
// 参考带的含义必须在图边上写清:一条淡色带子不标注,读者只会当成背景色。
|
||||||
|
Text(
|
||||||
|
text = stringResource(
|
||||||
|
R.string.detail_hr_normal_band,
|
||||||
|
formatDecimal(MetricReference.HEART_RATE_NORMAL.start, 0),
|
||||||
|
formatDecimal(MetricReference.HEART_RATE_NORMAL.endInclusive, 0),
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = TextSecondary,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
MetricBarChart(
|
||||||
|
values = state.points.map { it.value.toFloat() },
|
||||||
|
color = MetricRed,
|
||||||
|
valueRange = MetricReference.HEART_RATE_AXIS.start.toFloat()..
|
||||||
|
MetricReference.HEART_RATE_AXIS.endInclusive.toFloat(),
|
||||||
|
bandRange = MetricReference.HEART_RATE_NORMAL.start.toFloat()..
|
||||||
|
MetricReference.HEART_RATE_NORMAL.endInclusive.toFloat(),
|
||||||
|
axisLabels = chartAxisLabels(state.points),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 统计卡:平均 / 最高 / 正常范围天数。 */
|
||||||
|
@Composable
|
||||||
|
private fun HeartRateStatsCard(state: MetricDetailUiState, modifier: Modifier = Modifier) {
|
||||||
|
val values = state.points.map { it.value }
|
||||||
|
val stats = metricStatsOf(values) ?: return
|
||||||
|
val format = statFormatFor(state.metric)
|
||||||
|
HealthCard(modifier = modifier) {
|
||||||
|
StatRow {
|
||||||
|
StatColumn(
|
||||||
|
label = stringResource(R.string.detail_average),
|
||||||
|
value = formatStatValue(stats.average, state.metric, format),
|
||||||
|
unit = format.unit,
|
||||||
|
)
|
||||||
|
StatColumn(
|
||||||
|
label = stringResource(R.string.detail_max),
|
||||||
|
value = formatStatValue(stats.max, state.metric, format),
|
||||||
|
unit = format.unit,
|
||||||
|
)
|
||||||
|
// 第三列不给「最低」而给「正常范围天数」:心率低到 55 对运动员是好事,
|
||||||
|
// 单看一个最低值会被误读成异常,而「7 天里正常 6 天」没有这个歧义。
|
||||||
|
StatColumn(
|
||||||
|
label = stringResource(R.string.detail_hr_normal_days),
|
||||||
|
value = stringResource(
|
||||||
|
R.string.detail_days_count,
|
||||||
|
values.countWithin(MetricReference.HEART_RATE_NORMAL),
|
||||||
|
),
|
||||||
|
unit = "",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
package com.placeholder.ringhealth.feature.detail.pages
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyListScope
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.placeholder.ringhealth.R
|
||||||
|
import com.placeholder.ringhealth.core.common.formatClock
|
||||||
|
import com.placeholder.ringhealth.core.common.formatDecimal
|
||||||
|
import com.placeholder.ringhealth.core.common.formatMonthDayNumeric
|
||||||
|
import com.placeholder.ringhealth.core.common.formatSleepAnchorOffset
|
||||||
|
import com.placeholder.ringhealth.core.common.formatSleepDuration
|
||||||
|
import com.placeholder.ringhealth.core.common.weekdayResFor
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.MetricBlue
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.TextSecondary
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.component.HealthCard
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.component.SectionHeader
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.component.SleepWindowChart
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.component.StageTints
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.component.StatBar
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.component.StatSegment
|
||||||
|
import com.placeholder.ringhealth.domain.model.DateRange
|
||||||
|
import com.placeholder.ringhealth.domain.model.SleepSession
|
||||||
|
import com.placeholder.ringhealth.feature.detail.HeroReading
|
||||||
|
import com.placeholder.ringhealth.feature.detail.MetricDetailUiState
|
||||||
|
import com.placeholder.ringhealth.feature.detail.StatColumn
|
||||||
|
import com.placeholder.ringhealth.feature.detail.StatRow
|
||||||
|
import com.placeholder.ringhealth.feature.detail.sleepStatsOf
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 睡眠详情页。
|
||||||
|
*
|
||||||
|
* 睡眠在一天里占的是**一段区间**,所以这一页的头图不是光秃秃一个数字,而是
|
||||||
|
* 「睡了多久 + 从几点到几点 + 睡得沉不沉」;下面那张时段图把最近几晚的区间并排摆开,
|
||||||
|
* 横轴是钟点 —— 用户真正会问的「我每天都几点睡」只有这张图回答得了。
|
||||||
|
*
|
||||||
|
* 与之相对,通用的柱状图在这一页**不出现**:柱子高度就是时长,而时段图的条长同样是时长,
|
||||||
|
* 两张图讲同一件事,只会让人多看一遍。
|
||||||
|
*/
|
||||||
|
internal fun LazyListScope.sleepDetail(state: MetricDetailUiState) {
|
||||||
|
item { SleepHeroCard(state) }
|
||||||
|
item { SleepWindowCard(state) }
|
||||||
|
item { SleepStatsCard(state) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 演示用的目标睡眠时长:8 小时,与设计文档 §7 的口径一致。 */
|
||||||
|
private const val SLEEP_TARGET_MINUTES = 480
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分期配色:DEEP/LIGHT 走 MetricBlue 的两档深浅(深浅即「睡得沉不沉」),
|
||||||
|
* **AWAKE 故意跳出蓝色族**,用一枚暖灰偏粉 —— 睡眠是蓝色调,「醒了」要明显是另一件事。
|
||||||
|
* 「中途醒」是用户最该被告知的信息之一,不能让它和「睡着了但浅」混淆:
|
||||||
|
* 在时段图里它会落成一段独立暖色子矩形,在头图分期条里它会落成独立暖色段。
|
||||||
|
*/
|
||||||
|
private val DEEP_TINT = MetricBlue
|
||||||
|
private val LIGHT_TINT = MetricBlue.copy(alpha = 0.45f)
|
||||||
|
private val AWAKE_TINT = Color(0xFFE8B4A8)
|
||||||
|
|
||||||
|
/** 头图:昨晚时长 + 入睡/起床时刻 + 分期条与分期时长。 */
|
||||||
|
@Composable
|
||||||
|
private fun SleepHeroCard(state: MetricDetailUiState, modifier: Modifier = Modifier) {
|
||||||
|
// 昨晚 = 最后一个会话。取不到就整卡不画,而不是退回 summary 里那个数字 ——
|
||||||
|
// 那样会出现「头图写着 7 小时 32 分、下面时段图里没有这一根」的两截对不上。
|
||||||
|
val session = state.sleepSessions.lastOrNull() ?: return
|
||||||
|
HealthCard(modifier = modifier) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.detail_sleep_last_night),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = TextSecondary,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.Bottom,
|
||||||
|
) {
|
||||||
|
HeroReading(value = formatSleepDuration(session.sleepMinutes), unit = "")
|
||||||
|
// 目标值留在头图这一行。原来它是柱状图上那条 8 小时参考线的附注,
|
||||||
|
// 而柱状图已被时段图取代 —— 目标本身仍然有用,不该跟着那张图一起消失。
|
||||||
|
Text(
|
||||||
|
text = stringResource(
|
||||||
|
R.string.detail_sleep_target,
|
||||||
|
formatSleepDuration(SLEEP_TARGET_MINUTES),
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = TextSecondary,
|
||||||
|
modifier = Modifier.padding(bottom = 10.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "${stringResource(R.string.detail_sleep_fall_asleep)} ${formatClock(session.sleepStart)}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = TextSecondary,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "${stringResource(R.string.detail_sleep_wake_up)} ${formatClock(session.sleepEnd)}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = TextSecondary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
// 分期条的权重取**卧床时长**而不是睡着时长:清醒的那一段也要占位置,
|
||||||
|
// 否则「睡了 7 小时 32 分」的条里藏着一截其实醒着的时间,比例看着对不上。
|
||||||
|
StatBar(segments = session.stageSegments())
|
||||||
|
Spacer(modifier = Modifier.height(10.dp))
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
StageLegend(
|
||||||
|
label = stringResource(R.string.detail_sleep_deep),
|
||||||
|
minutes = session.deepMinutes,
|
||||||
|
tint = DEEP_TINT,
|
||||||
|
)
|
||||||
|
StageLegend(
|
||||||
|
label = stringResource(R.string.detail_sleep_light),
|
||||||
|
minutes = session.lightMinutes,
|
||||||
|
tint = LIGHT_TINT,
|
||||||
|
)
|
||||||
|
StageLegend(
|
||||||
|
label = stringResource(R.string.detail_sleep_awake),
|
||||||
|
minutes = session.awakeMinutes,
|
||||||
|
tint = AWAKE_TINT,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 三个分段的权重 = 各期时长 / 卧床时长。卧床时长为 0 只可能是坏数据,兜一个极小正数。 */
|
||||||
|
private fun SleepSession.stageSegments(): List<StatSegment> {
|
||||||
|
val total = inBedMinutes.coerceAtLeast(1).toFloat()
|
||||||
|
return listOf(
|
||||||
|
StatSegment(weight = deepMinutes / total, color = DEEP_TINT),
|
||||||
|
StatSegment(weight = lightMinutes / total, color = LIGHT_TINT),
|
||||||
|
StatSegment(weight = awakeMinutes / total, color = AWAKE_TINT),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 分期图例:小色块 + 名称 + 时长。「深睡 1小时36分」比光一个「深睡」多告诉用户一件事。 */
|
||||||
|
@Composable
|
||||||
|
private fun StageLegend(label: String, minutes: Int, tint: Color, modifier: Modifier = Modifier) {
|
||||||
|
Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(10.dp)
|
||||||
|
.clip(RoundedCornerShape(5.dp))
|
||||||
|
.background(tint),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "$label ${formatSleepDuration(minutes)}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = TextSecondary,
|
||||||
|
modifier = Modifier.padding(start = 6.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 时段图:每晚一行,一根条从入睡横到起床,横轴是钟点。
|
||||||
|
*
|
||||||
|
* 行标签按范围换口径:近 7 天用星期(「周三」),近 30 天用日期(「9/10」)——
|
||||||
|
* 三十行里「周三」会出现四次,反而认不出是哪一天。
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun SleepWindowCard(state: MetricDetailUiState, modifier: Modifier = Modifier) {
|
||||||
|
if (state.sleepSessions.isEmpty()) return
|
||||||
|
val stats = sleepStatsOf(state.sleepSessions)
|
||||||
|
HealthCard(modifier = modifier) {
|
||||||
|
SectionHeader(
|
||||||
|
title = stringResource(R.string.detail_sleep_window),
|
||||||
|
trailing = stats?.let {
|
||||||
|
{
|
||||||
|
Text(
|
||||||
|
text = stringResource(
|
||||||
|
R.string.detail_sleep_window_average,
|
||||||
|
formatSleepAnchorOffset(it.averageBedtimeMinute),
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = TextSecondary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
SleepWindowChart(
|
||||||
|
sessions = state.sleepSessions,
|
||||||
|
color = MetricBlue,
|
||||||
|
// 启用分期染色:少数带 [SleepSession.stages] 的夜晚会把条拆成多色子矩形,
|
||||||
|
// 与头图分期条共用同一套色系(深浅 = 睡得沉不沉)。其他夜晚无 `stages` 字段,
|
||||||
|
// 组件会自己退回单色模式 —— 调用方不需要关心「哪些晚有分期」。
|
||||||
|
stageTints = StageTints(
|
||||||
|
deep = DEEP_TINT,
|
||||||
|
light = LIGHT_TINT,
|
||||||
|
awake = AWAKE_TINT,
|
||||||
|
),
|
||||||
|
rowLabel = { session ->
|
||||||
|
if (state.range == DateRange.WEEK) {
|
||||||
|
stringResource(weekdayResFor(session.wakeDate))
|
||||||
|
} else {
|
||||||
|
formatMonthDayNumeric(session.wakeDate)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计卡:平均入睡 / 平均醒来 / 平均时长。
|
||||||
|
*
|
||||||
|
* 前两项给的是**钟点**而不是时长 —— 「这几天平均几点睡」和「平均睡多久」是两个不同的问题,
|
||||||
|
* 而原来的最长/最短只回答后者,且回答得没什么用(最长那晚是几号?页面上没有)。
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun SleepStatsCard(state: MetricDetailUiState, modifier: Modifier = Modifier) {
|
||||||
|
val stats = sleepStatsOf(state.sleepSessions) ?: return
|
||||||
|
HealthCard(modifier = modifier) {
|
||||||
|
StatRow {
|
||||||
|
StatColumn(
|
||||||
|
label = stringResource(R.string.detail_sleep_avg_fall_asleep),
|
||||||
|
value = formatSleepAnchorOffset(stats.averageBedtimeMinute),
|
||||||
|
unit = "",
|
||||||
|
)
|
||||||
|
StatColumn(
|
||||||
|
label = stringResource(R.string.detail_sleep_avg_wake_up),
|
||||||
|
value = formatSleepAnchorOffset(stats.averageWakeMinute),
|
||||||
|
unit = "",
|
||||||
|
)
|
||||||
|
StatColumn(
|
||||||
|
label = stringResource(R.string.detail_sleep_avg_duration),
|
||||||
|
// 用「7.5 小时」而不是「7小时32分」:三列并排,后者的宽度会把前两列的钟点挤扁。
|
||||||
|
value = formatDecimal(stats.averageSleepMinutes / 60.0, decimals = 1),
|
||||||
|
unit = stringResource(R.string.unit_hours),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
package com.placeholder.ringhealth.feature.detail.pages
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyListScope
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.placeholder.ringhealth.R
|
||||||
|
import com.placeholder.ringhealth.core.common.formatCalories
|
||||||
|
import com.placeholder.ringhealth.core.common.formatDistance
|
||||||
|
import com.placeholder.ringhealth.core.common.formatSteps
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.DividerColor
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.MetricGreen
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.TextPrimary
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.TextSecondary
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.component.HealthCard
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.component.MetricBarChart
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.component.RingProgress
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.component.SectionHeader
|
||||||
|
import com.placeholder.ringhealth.feature.detail.MetricDetailUiState
|
||||||
|
import com.placeholder.ringhealth.feature.detail.StatColumn
|
||||||
|
import com.placeholder.ringhealth.feature.detail.StatRow
|
||||||
|
import com.placeholder.ringhealth.feature.detail.chartAxisLabels
|
||||||
|
import com.placeholder.ringhealth.feature.detail.countAtLeast
|
||||||
|
import com.placeholder.ringhealth.feature.detail.formatStatValue
|
||||||
|
import com.placeholder.ringhealth.feature.detail.metricStatsOf
|
||||||
|
import com.placeholder.ringhealth.feature.detail.statFormatFor
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 步数详情页。
|
||||||
|
*
|
||||||
|
* 步数的特点是**有目标**:它不是一个「多少算正常」的体征,而是一个「今天走够了没有」的任务。
|
||||||
|
* 所以头图把「完成度」摆在最显眼处(环 + 百分比),趋势图的参考线画的是目标线而不是均值线 ——
|
||||||
|
* 均值只是「我平时走多少」,目标线才回答「今天够不够」。
|
||||||
|
*/
|
||||||
|
internal fun LazyListScope.stepDetail(state: MetricDetailUiState) {
|
||||||
|
item { StepsHeroCard(state) }
|
||||||
|
item { StepsChartCard(state) }
|
||||||
|
item { StepsStatsCard(state) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 头图:进度环 + 完成度百分比 + 目标,右侧附距离与千卡。 */
|
||||||
|
@Composable
|
||||||
|
private fun StepsHeroCard(state: MetricDetailUiState, modifier: Modifier = Modifier) {
|
||||||
|
val summary = state.summary ?: return
|
||||||
|
val goal = summary.stepGoal.coerceAtLeast(1)
|
||||||
|
// 完成度可以超过 100%(走爆了是好事),但环只画到满;百分比照实显示。
|
||||||
|
val progress = summary.steps.toFloat() / goal
|
||||||
|
HealthCard(modifier = modifier, contentPadding = PaddingValues(16.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
RingProgress(
|
||||||
|
progress = progress,
|
||||||
|
color = MetricGreen,
|
||||||
|
trackColor = MetricGreen.copy(alpha = 0.15f),
|
||||||
|
ringSize = HERO_RING_SIZE,
|
||||||
|
strokeWidth = 10.dp,
|
||||||
|
) {
|
||||||
|
Text(text = formatSteps(summary.steps), style = HeroStepsStyle)
|
||||||
|
}
|
||||||
|
Column(modifier = Modifier.padding(start = 18.dp)) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.detail_steps_progress),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = TextSecondary,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
|
Row(verticalAlignment = Alignment.Bottom) {
|
||||||
|
Text(
|
||||||
|
text = (progress * 100).roundToInt().toString(),
|
||||||
|
style = MaterialTheme.typography.displaySmall,
|
||||||
|
color = MetricGreen,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.unit_percent),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = TextSecondary,
|
||||||
|
modifier = Modifier.padding(start = 2.dp, bottom = 6.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
HorizontalDivider(color = DividerColor)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
|
HeroFact(value = formatDistance(summary.distanceKm), label = stringResource(R.string.home_distance_label))
|
||||||
|
HeroFact(value = formatCalories(summary.calories), label = stringResource(R.string.home_calories_label))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** hero 里的一个小事实:数值在上、标签在下。 */
|
||||||
|
@Composable
|
||||||
|
private fun HeroFact(value: String, label: String, modifier: Modifier = Modifier) {
|
||||||
|
Column(modifier = modifier) {
|
||||||
|
Text(text = value, style = MaterialTheme.typography.titleMedium, color = TextPrimary)
|
||||||
|
Text(text = label, style = MaterialTheme.typography.bodySmall, color = TextSecondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 趋势图:柱子 + 目标线。
|
||||||
|
*
|
||||||
|
* 纵向窗口取「0 到 目标与历史最高中的较大者再上浮 15%」——
|
||||||
|
* 不能让参考线把柱子压扁:若窗口只按历史最高取,而目标恰好高于历史所有点,
|
||||||
|
* 目标线会贴到画布顶端,看着像页面的边框而不是一条线。
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun StepsChartCard(state: MetricDetailUiState, modifier: Modifier = Modifier) {
|
||||||
|
val goal = (state.summary?.stepGoal ?: 0).toFloat()
|
||||||
|
val values = state.points.map { it.value.toFloat() }
|
||||||
|
HealthCard(modifier = modifier) {
|
||||||
|
SectionHeader(
|
||||||
|
title = stringResource(R.string.detail_chart_daily, stringResource(R.string.metric_step)),
|
||||||
|
trailing = {
|
||||||
|
if (goal > 0f) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.detail_goal, formatSteps(goal.toInt())),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = TextSecondary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
MetricBarChart(
|
||||||
|
values = values,
|
||||||
|
color = MetricGreen,
|
||||||
|
valueRange = 0f..(maxOf(values.maxOrNull() ?: 0f, goal) * 1.15f).coerceAtLeast(1f),
|
||||||
|
referenceLine = goal.takeIf { it > 0f },
|
||||||
|
axisLabels = chartAxisLabels(state.points),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 统计卡:日均 / 最高 / 达标天数。 */
|
||||||
|
@Composable
|
||||||
|
private fun StepsStatsCard(state: MetricDetailUiState, modifier: Modifier = Modifier) {
|
||||||
|
val values = state.points.map { it.value }
|
||||||
|
val stats = metricStatsOf(values) ?: return
|
||||||
|
val goal = (state.summary?.stepGoal ?: 0).toDouble()
|
||||||
|
val format = statFormatFor(state.metric)
|
||||||
|
HealthCard(modifier = modifier) {
|
||||||
|
StatRow {
|
||||||
|
StatColumn(
|
||||||
|
label = stringResource(R.string.detail_steps_average),
|
||||||
|
value = formatStatValue(stats.average, state.metric, format),
|
||||||
|
unit = format.unit,
|
||||||
|
)
|
||||||
|
StatColumn(
|
||||||
|
label = stringResource(R.string.detail_max),
|
||||||
|
value = formatStatValue(stats.max, state.metric, format),
|
||||||
|
unit = format.unit,
|
||||||
|
)
|
||||||
|
// 步数页的第三列不给「最低」,给「达标天数」—— 最低步数(比如卧床那天 300 步)
|
||||||
|
// 既不好看也没指导意义,而「7 天里达标 4 天」正是这张页面要回答的问题。
|
||||||
|
if (goal > 0.0) {
|
||||||
|
StatColumn(
|
||||||
|
label = stringResource(R.string.detail_steps_reached_label),
|
||||||
|
value = stringResource(R.string.detail_days_count, values.countAtLeast(goal)),
|
||||||
|
unit = "",
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
StatColumn(
|
||||||
|
label = stringResource(R.string.detail_min),
|
||||||
|
value = formatStatValue(stats.min, state.metric, format),
|
||||||
|
unit = format.unit,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 环内步数字号。比首页那个(28sp)小一号 —— 这里环也更小。 */
|
||||||
|
private val HeroStepsStyle = TextStyle(
|
||||||
|
fontSize = 24.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
letterSpacing = (-1).sp,
|
||||||
|
color = TextPrimary,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 头图圆环外径。比首页的小:详情页第一屏还要留位置给下面的图。 */
|
||||||
|
private val HERO_RING_SIZE = 108.dp
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
package com.placeholder.ringhealth.feature.detail.pages
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyListScope
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.placeholder.ringhealth.R
|
||||||
|
import com.placeholder.ringhealth.core.common.stressLevelLabelRes
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.MetricCyan
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.MetricGreen
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.MetricOrange
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.MetricRed
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.TextSecondary
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.component.HealthCard
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.component.MetricBarChart
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.component.SectionHeader
|
||||||
|
import com.placeholder.ringhealth.domain.model.StressLevel
|
||||||
|
import com.placeholder.ringhealth.domain.model.stressLevelOf
|
||||||
|
import com.placeholder.ringhealth.feature.detail.HeroReading
|
||||||
|
import com.placeholder.ringhealth.feature.detail.MetricDetailUiState
|
||||||
|
import com.placeholder.ringhealth.feature.detail.StatColumn
|
||||||
|
import com.placeholder.ringhealth.feature.detail.StatRow
|
||||||
|
import com.placeholder.ringhealth.feature.detail.chartAxisLabels
|
||||||
|
import com.placeholder.ringhealth.feature.detail.formatStatValue
|
||||||
|
import com.placeholder.ringhealth.feature.detail.metricStatsOf
|
||||||
|
import com.placeholder.ringhealth.feature.detail.statFormatFor
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 压力详情页。
|
||||||
|
*
|
||||||
|
* 压力的特点是**数值本身没有量纲意义**:31 和 62 的差别不是「差了 31 个单位」,
|
||||||
|
* 而是「从放松变成了中等」。所以这一页处处按**等级**而不是按数值组织 ——
|
||||||
|
* 头图给等级名而不是光给数字,趋势图每根柱子按它那天的等级着色,
|
||||||
|
* 统计里第三列是「放松几天」。同一张图上出现四种颜色,这在别的页面是要避免的,
|
||||||
|
* 在压力页恰恰是重点。
|
||||||
|
*/
|
||||||
|
internal fun LazyListScope.stressDetail(state: MetricDetailUiState) {
|
||||||
|
item { StressHeroCard(state) }
|
||||||
|
item { StressChartCard(state) }
|
||||||
|
item { StressStatsCard(state) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 压力等级 → 颜色。等级与颜色的对应只此一份,图例与柱子共用。 */
|
||||||
|
private fun stressLevelColor(level: StressLevel): Color = when (level) {
|
||||||
|
StressLevel.RELAXED -> MetricGreen
|
||||||
|
StressLevel.NORMAL -> MetricCyan
|
||||||
|
StressLevel.MEDIUM -> MetricOrange
|
||||||
|
StressLevel.HIGH -> MetricRed
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 头图:当前压力值 + 等级名(染色)。 */
|
||||||
|
@Composable
|
||||||
|
private fun StressHeroCard(state: MetricDetailUiState, modifier: Modifier = Modifier) {
|
||||||
|
val current = state.summary?.stress ?: return
|
||||||
|
val level = stressLevelOf(current.toDouble())
|
||||||
|
HealthCard(modifier = modifier) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.detail_stress_current),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = TextSecondary,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
|
HeroReading(value = current.toString(), unit = "")
|
||||||
|
Spacer(modifier = Modifier.height(10.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(stressLevelLabelRes(level)),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = stressLevelColor(level),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 趋势图:按等级着色的柱子 + 四色图例。 */
|
||||||
|
@Composable
|
||||||
|
private fun StressChartCard(state: MetricDetailUiState, modifier: Modifier = Modifier) {
|
||||||
|
HealthCard(modifier = modifier) {
|
||||||
|
SectionHeader(
|
||||||
|
title = stringResource(R.string.detail_chart_daily, stringResource(R.string.metric_stress)),
|
||||||
|
)
|
||||||
|
MetricBarChart(
|
||||||
|
values = state.points.map { it.value.toFloat() },
|
||||||
|
// 基础色取紫色(压力指标的语义色),但 barColorOf 会覆盖其中绝大多数柱子 ——
|
||||||
|
// 它只在返回值列表为空时兜底,那时才轮到这条分支。
|
||||||
|
color = stressLevelColor(StressLevel.HIGH),
|
||||||
|
valueRange = 0f..STRESS_AXIS_MAX,
|
||||||
|
barColorOf = { value -> stressLevelColor(stressLevelOf(value.toDouble())) },
|
||||||
|
axisLabels = chartAxisLabels(state.points),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(10.dp))
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
// 四个等级全列出来,而不是只列图上出现过的 —— 图例的作用是给出「颜色 → 等级」的
|
||||||
|
// 对照表,缺一档就会让人以为少的那档不存在。
|
||||||
|
StressLevel.entries.forEach { level ->
|
||||||
|
LevelLegend(
|
||||||
|
label = stringResource(stressLevelLabelRes(level)),
|
||||||
|
tint = stressLevelColor(level),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 压力纵轴上限。
|
||||||
|
*
|
||||||
|
* 固定 100 而不是按数据自适应:压力的分档阈值(30/60/80)是绝对的,
|
||||||
|
* 若轴随数据缩放,同样高度的柱子在不同周里代表不同等级,图例就成了摆设。
|
||||||
|
*/
|
||||||
|
private const val STRESS_AXIS_MAX = 100f
|
||||||
|
|
||||||
|
/** 等级图例:小色块 + 名称。 */
|
||||||
|
@Composable
|
||||||
|
private fun LevelLegend(label: String, tint: Color, modifier: Modifier = Modifier) {
|
||||||
|
Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(10.dp)
|
||||||
|
.clip(RoundedCornerShape(5.dp))
|
||||||
|
.background(tint),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = TextSecondary,
|
||||||
|
modifier = Modifier.padding(start = 6.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 统计卡:平均 / 最高 / 放松天数。 */
|
||||||
|
@Composable
|
||||||
|
private fun StressStatsCard(state: MetricDetailUiState, modifier: Modifier = Modifier) {
|
||||||
|
val values = state.points.map { it.value }
|
||||||
|
val stats = metricStatsOf(values) ?: return
|
||||||
|
val format = statFormatFor(state.metric)
|
||||||
|
val relaxedDays = values.count { stressLevelOf(it) == StressLevel.RELAXED }
|
||||||
|
HealthCard(modifier = modifier) {
|
||||||
|
StatRow {
|
||||||
|
StatColumn(
|
||||||
|
label = stringResource(R.string.detail_average),
|
||||||
|
value = formatStatValue(stats.average, state.metric, format),
|
||||||
|
unit = format.unit,
|
||||||
|
)
|
||||||
|
StatColumn(
|
||||||
|
label = stringResource(R.string.detail_max),
|
||||||
|
value = formatStatValue(stats.max, state.metric, format),
|
||||||
|
unit = format.unit,
|
||||||
|
)
|
||||||
|
// 第三列给「放松天数」:压力的「最低值」没有解读价值(睡着时人人都低),
|
||||||
|
// 「这几天有几天是放松的」才是可用的结论。
|
||||||
|
StatColumn(
|
||||||
|
label = stringResource(R.string.detail_stress_relaxed_days),
|
||||||
|
value = stringResource(R.string.detail_days_count, relaxedDays),
|
||||||
|
unit = "",
|
||||||
|
valueColor = if (relaxedDays > 0) MetricGreen else null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+133
@@ -0,0 +1,133 @@
|
|||||||
|
package com.placeholder.ringhealth.feature.detail.pages
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.lazy.LazyListScope
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.placeholder.ringhealth.R
|
||||||
|
import com.placeholder.ringhealth.core.common.formatDecimal
|
||||||
|
import com.placeholder.ringhealth.core.common.formatSignedDecimal
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.MetricBlue
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.MetricOrange
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.TextSecondary
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.component.HealthCard
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.component.MetricDeviationBar
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.component.SectionHeader
|
||||||
|
import com.placeholder.ringhealth.domain.model.MetricReference
|
||||||
|
import com.placeholder.ringhealth.feature.detail.HeroReading
|
||||||
|
import com.placeholder.ringhealth.feature.detail.MetricDetailUiState
|
||||||
|
import com.placeholder.ringhealth.feature.detail.StatColumn
|
||||||
|
import com.placeholder.ringhealth.feature.detail.StatRow
|
||||||
|
import com.placeholder.ringhealth.feature.detail.chartAxisLabels
|
||||||
|
import com.placeholder.ringhealth.feature.detail.formatStatValue
|
||||||
|
import com.placeholder.ringhealth.feature.detail.metricStatsOf
|
||||||
|
import com.placeholder.ringhealth.feature.detail.statFormatFor
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 体温详情页。
|
||||||
|
*
|
||||||
|
* 体温的特点是**绝对值没有意义**:36.5 与 36.8 都属于正常,要读的是「比自己的基线高还是低」。
|
||||||
|
* 所以这一页的中心是一条基线而不是零点 —— 头图给「较基线 +0.0°C」,
|
||||||
|
* 趋势图画成从基线向两侧偏离的棒棒糖(偏高一片暖色、偏低一片冷色),
|
||||||
|
* 统计里后两列给的是「最高偏离 / 最低偏离」而不是「最高 / 最低体温」。
|
||||||
|
*/
|
||||||
|
internal fun LazyListScope.temperatureDetail(state: MetricDetailUiState) {
|
||||||
|
item { TemperatureHeroCard(state) }
|
||||||
|
item { TemperatureChartCard(state) }
|
||||||
|
item { TemperatureStatsCard(state) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 头图:当前体温 + 较基线的偏离 + 基线说明。 */
|
||||||
|
@Composable
|
||||||
|
private fun TemperatureHeroCard(state: MetricDetailUiState, modifier: Modifier = Modifier) {
|
||||||
|
val current = state.summary?.temperature ?: return
|
||||||
|
val deviation = current - MetricReference.TEMPERATURE_BASELINE
|
||||||
|
HealthCard(modifier = modifier) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.metric_temperature),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = TextSecondary,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
|
HeroReading(value = formatDecimal(current, 1), unit = "", unitRes = R.string.unit_celsius)
|
||||||
|
Spacer(modifier = Modifier.height(10.dp))
|
||||||
|
// 偏离量染成与趋势图一致的方向色(偏高橙、偏低蓝),两处说的是同一件事。
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.detail_temp_above, formatSignedDecimal(deviation, 1)),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = if (deviation >= 0.0) MetricOrange else MetricBlue,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(
|
||||||
|
R.string.detail_temp_baseline,
|
||||||
|
formatDecimal(MetricReference.TEMPERATURE_BASELINE, 1),
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = TextSecondary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 趋势图:以基线为中线的偏离条。 */
|
||||||
|
@Composable
|
||||||
|
private fun TemperatureChartCard(state: MetricDetailUiState, modifier: Modifier = Modifier) {
|
||||||
|
HealthCard(modifier = modifier) {
|
||||||
|
SectionHeader(
|
||||||
|
title = stringResource(R.string.detail_chart_daily, stringResource(R.string.metric_temperature)),
|
||||||
|
trailing = {
|
||||||
|
Text(
|
||||||
|
text = stringResource(
|
||||||
|
R.string.detail_temp_baseline,
|
||||||
|
formatDecimal(MetricReference.TEMPERATURE_BASELINE, 1),
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = TextSecondary,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
MetricDeviationBar(
|
||||||
|
values = state.points.map { it.value },
|
||||||
|
baseline = MetricReference.TEMPERATURE_BASELINE,
|
||||||
|
span = MetricReference.TEMPERATURE_SPAN,
|
||||||
|
aboveColor = MetricOrange,
|
||||||
|
belowColor = MetricBlue,
|
||||||
|
axisLabels = chartAxisLabels(state.points),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 统计卡:平均体温 / 最高偏离 / 最低偏离。 */
|
||||||
|
@Composable
|
||||||
|
private fun TemperatureStatsCard(state: MetricDetailUiState, modifier: Modifier = Modifier) {
|
||||||
|
val values = state.points.map { it.value }
|
||||||
|
val stats = metricStatsOf(values) ?: return
|
||||||
|
val format = statFormatFor(state.metric)
|
||||||
|
val unit = format.unit
|
||||||
|
HealthCard(modifier = modifier) {
|
||||||
|
StatRow {
|
||||||
|
// 第一列是绝对值(36.5°C),后两列是相对基线的偏离量(+0.4°C / -0.3°C)。
|
||||||
|
// 混着放看着不齐,但正是这一页要讲的事:绝对值 + 它相对基线偏了多少。
|
||||||
|
StatColumn(
|
||||||
|
label = stringResource(R.string.detail_average),
|
||||||
|
value = formatStatValue(stats.average, state.metric, format),
|
||||||
|
unit = unit,
|
||||||
|
)
|
||||||
|
StatColumn(
|
||||||
|
label = stringResource(R.string.detail_temp_highest),
|
||||||
|
value = formatSignedDecimal(stats.max - MetricReference.TEMPERATURE_BASELINE, 1),
|
||||||
|
unit = unit,
|
||||||
|
)
|
||||||
|
StatColumn(
|
||||||
|
label = stringResource(R.string.detail_temp_lowest),
|
||||||
|
value = formatSignedDecimal(stats.min - MetricReference.TEMPERATURE_BASELINE, 1),
|
||||||
|
unit = unit,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,35 +1,51 @@
|
|||||||
package com.placeholder.ringhealth.feature.device
|
package com.placeholder.ringhealth.feature.device
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Bluetooth
|
import androidx.compose.material.icons.filled.Bluetooth
|
||||||
|
import androidx.compose.material.icons.filled.Nfc
|
||||||
|
import androidx.compose.material.icons.filled.QrCodeScanner
|
||||||
import androidx.compose.material.icons.filled.Watch
|
import androidx.compose.material.icons.filled.Watch
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import com.placeholder.ringhealth.AppViewModelProvider
|
import com.placeholder.ringhealth.AppViewModelProvider
|
||||||
import com.placeholder.ringhealth.R
|
import com.placeholder.ringhealth.R
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.AppBackground
|
||||||
import com.placeholder.ringhealth.core.designsystem.MetricGreen
|
import com.placeholder.ringhealth.core.designsystem.MetricGreen
|
||||||
import com.placeholder.ringhealth.core.designsystem.RingHealthTheme
|
import com.placeholder.ringhealth.core.designsystem.RingHealthTheme
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.TextPrimary
|
||||||
import com.placeholder.ringhealth.core.designsystem.TextSecondary
|
import com.placeholder.ringhealth.core.designsystem.TextSecondary
|
||||||
import com.placeholder.ringhealth.core.designsystem.component.HealthCard
|
import com.placeholder.ringhealth.core.designsystem.component.HealthCard
|
||||||
import com.placeholder.ringhealth.core.designsystem.component.SectionHeader
|
import com.placeholder.ringhealth.core.designsystem.component.SectionHeader
|
||||||
@@ -47,13 +63,23 @@ fun DeviceScreen(
|
|||||||
DeviceContent(state = state, onOpenSettings = onOpenSettings, modifier = modifier)
|
DeviceContent(state = state, onOpenSettings = onOpenSettings, modifier = modifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 无状态设备页内容。 */
|
/**
|
||||||
|
* 无状态设备页内容。
|
||||||
|
*
|
||||||
|
* **空态 vs 已连态分两条分支。** 阶段一 Mock 始终 `connected = true`,空态走 Preview 验证;
|
||||||
|
* M2 接 SDK 后真实未连时会自动落到这里 —— 戒指扫描/绑定是用户买戒指后的第一件事,
|
||||||
|
* 不能让人看到一张「已连但没数据」的卡(那是 bug)。
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun DeviceContent(
|
private fun DeviceContent(
|
||||||
state: DeviceUiState,
|
state: DeviceUiState,
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
if (!state.device.connected) {
|
||||||
|
DeviceEmptyState(modifier = modifier.fillMaxSize())
|
||||||
|
return
|
||||||
|
}
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = modifier.fillMaxSize(),
|
modifier = modifier.fillMaxSize(),
|
||||||
contentPadding = PaddingValues(16.dp),
|
contentPadding = PaddingValues(16.dp),
|
||||||
@@ -76,6 +102,78 @@ private fun DeviceContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 未连接戒指空态。
|
||||||
|
*
|
||||||
|
* **不做成整页空状态**(参考小米截图),仍用一张卡片托住「扫一扫 / 按型号添加」
|
||||||
|
* 两个操作入口 —— 用户进来这一页是想做事的,不是看「空空如也」。戒指大图放在卡片上方
|
||||||
|
* 的居中位置,没有动作,所以不挂在卡片里。
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun DeviceEmptyState(modifier: Modifier = Modifier) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier.padding(16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.height(80.dp))
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(140.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(MetricGreen.copy(alpha = 0.10f)),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Nfc,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MetricGreen,
|
||||||
|
modifier = Modifier.size(64.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(20.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.device_empty_title),
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
color = TextPrimary,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.device_empty_subtitle),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = TextSecondary,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.padding(horizontal = 32.dp),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(28.dp))
|
||||||
|
HealthCard(contentPadding = PaddingValues(16.dp)) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
Button(
|
||||||
|
onClick = { /* 阶段一未开放:扫一扫入口在 M2 接 BLE 后实接 */ },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = MetricGreen),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.QrCodeScanner,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(text = stringResource(R.string.device_empty_scan))
|
||||||
|
}
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { /* 阶段一未开放 */ },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
) {
|
||||||
|
Text(text = stringResource(R.string.device_empty_pair_by_model))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 戒指信息卡片。 */
|
/** 戒指信息卡片。 */
|
||||||
@Composable
|
@Composable
|
||||||
private fun DeviceInfoCard(state: DeviceUiState, modifier: Modifier = Modifier) {
|
private fun DeviceInfoCard(state: DeviceUiState, modifier: Modifier = Modifier) {
|
||||||
@@ -148,8 +246,8 @@ private fun SettingRow(setting: DeviceFunctionSetting, modifier: Modifier = Modi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 预览入口:以固定示例数据渲染 [DeviceContent],便于在 IDE 中免真机查看设备页效果。 */
|
/** 预览入口:已连态。 */
|
||||||
@Preview(showBackground = true)
|
@Preview(showBackground = true, name = "已连")
|
||||||
@Composable
|
@Composable
|
||||||
private fun DeviceContentPreview() {
|
private fun DeviceContentPreview() {
|
||||||
RingHealthTheme {
|
RingHealthTheme {
|
||||||
@@ -163,6 +261,21 @@ private fun DeviceContentPreview() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 预览入口:未连空态。 */
|
||||||
|
@Preview(showBackground = true, name = "未连")
|
||||||
|
@Composable
|
||||||
|
private fun DeviceEmptyStatePreview() {
|
||||||
|
RingHealthTheme {
|
||||||
|
DeviceContent(
|
||||||
|
state = DeviceUiState(
|
||||||
|
device = RingDeviceState(false, "", 0, "", false),
|
||||||
|
settings = emptyList(),
|
||||||
|
),
|
||||||
|
onOpenSettings = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Preview 用的假开关列表。
|
* Preview 用的假开关列表。
|
||||||
*
|
*
|
||||||
|
|||||||
+22
-4
@@ -9,7 +9,9 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.SwitchDefaults
|
import androidx.compose.material3.SwitchDefaults
|
||||||
import androidx.compose.material3.Text
|
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.RingHealthTheme
|
||||||
import com.placeholder.ringhealth.core.designsystem.TextSecondary
|
import com.placeholder.ringhealth.core.designsystem.TextSecondary
|
||||||
import com.placeholder.ringhealth.core.designsystem.component.HealthCard
|
import com.placeholder.ringhealth.core.designsystem.component.HealthCard
|
||||||
import com.placeholder.ringhealth.core.designsystem.component.SectionHeader
|
import com.placeholder.ringhealth.core.designsystem.component.SubPageTopAppBar
|
||||||
import com.placeholder.ringhealth.domain.model.DeviceFunctionSetting
|
import com.placeholder.ringhealth.domain.model.DeviceFunctionSetting
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -37,6 +39,7 @@ import com.placeholder.ringhealth.domain.model.DeviceFunctionSetting
|
|||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun DeviceSettingsScreen(
|
fun DeviceSettingsScreen(
|
||||||
|
onBack: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: DeviceViewModel = viewModel(factory = AppViewModelProvider.Factory),
|
viewModel: DeviceViewModel = viewModel(factory = AppViewModelProvider.Factory),
|
||||||
) {
|
) {
|
||||||
@@ -44,6 +47,7 @@ fun DeviceSettingsScreen(
|
|||||||
DeviceSettingsContent(
|
DeviceSettingsContent(
|
||||||
settings = state.settings,
|
settings = state.settings,
|
||||||
onToggle = viewModel::setFunctionEnabled,
|
onToggle = viewModel::setFunctionEnabled,
|
||||||
|
onBack = onBack,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -53,19 +57,32 @@ fun DeviceSettingsScreen(
|
|||||||
* 与 `DeviceContent` 同样拆出独立函数,Preview 才能喂假数据渲染(ledger Ruling R26)。
|
* 与 `DeviceContent` 同样拆出独立函数,Preview 才能喂假数据渲染(ledger Ruling R26)。
|
||||||
* 注意回调签名是 `(id, enabled)` 而不是 `(DeviceFunctionSetting, Boolean)`:
|
* 注意回调签名是 `(id, enabled)` 而不是 `(DeviceFunctionSetting, Boolean)`:
|
||||||
* 无状态层不该知道「怎么改」,只负责把「改哪一项、改成什么」报上去。
|
* 无状态层不该知道「怎么改」,只负责把「改哪一项、改成什么」报上去。
|
||||||
|
*
|
||||||
|
* 顶栏的标题取代了原先列表里的「功能设置」区块标题 —— 同一个标题在一屏里出现两次是多余的。
|
||||||
*/
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun DeviceSettingsContent(
|
private fun DeviceSettingsContent(
|
||||||
settings: List<DeviceFunctionSetting>,
|
settings: List<DeviceFunctionSetting>,
|
||||||
onToggle: (String, Boolean) -> Unit,
|
onToggle: (String, Boolean) -> Unit,
|
||||||
|
onBack: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
Scaffold(
|
||||||
|
modifier = modifier,
|
||||||
|
containerColor = MaterialTheme.colorScheme.background,
|
||||||
|
topBar = {
|
||||||
|
SubPageTopAppBar(
|
||||||
|
title = stringResource(R.string.device_settings_title),
|
||||||
|
onBack = onBack,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { innerPadding ->
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize().padding(innerPadding),
|
||||||
contentPadding = PaddingValues(16.dp),
|
contentPadding = PaddingValues(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
item { SectionHeader(title = stringResource(R.string.device_settings_title)) }
|
|
||||||
items(settings) { setting ->
|
items(settings) { setting ->
|
||||||
HealthCard {
|
HealthCard {
|
||||||
Row(
|
Row(
|
||||||
@@ -95,6 +112,7 @@ private fun DeviceSettingsContent(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 预览入口:以固定示例数据渲染 [DeviceSettingsContent],便于在 IDE 中免真机查看开关列表效果。 */
|
/** 预览入口:以固定示例数据渲染 [DeviceSettingsContent],便于在 IDE 中免真机查看开关列表效果。 */
|
||||||
@@ -102,6 +120,6 @@ private fun DeviceSettingsContent(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun DeviceSettingsContentPreview() {
|
private fun DeviceSettingsContentPreview() {
|
||||||
RingHealthTheme {
|
RingHealthTheme {
|
||||||
DeviceSettingsContent(settings = previewSettings(), onToggle = { _, _ -> })
|
DeviceSettingsContent(settings = previewSettings(), onToggle = { _, _ -> }, onBack = {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,75 +1,84 @@
|
|||||||
package com.placeholder.ringhealth.feature.home
|
package com.placeholder.ringhealth.feature.home
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.RowScope
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
|
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||||
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Bloodtype
|
|
||||||
import androidx.compose.material.icons.filled.Favorite
|
|
||||||
import androidx.compose.material.icons.filled.MonitorHeart
|
import androidx.compose.material.icons.filled.MonitorHeart
|
||||||
import androidx.compose.material.icons.filled.Spa
|
|
||||||
import androidx.compose.material.icons.filled.Thermostat
|
|
||||||
import androidx.compose.material.icons.filled.WaterDrop
|
|
||||||
import androidx.compose.material.icons.filled.Bedtime
|
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import com.placeholder.ringhealth.AppViewModelProvider
|
import com.placeholder.ringhealth.AppViewModelProvider
|
||||||
import com.placeholder.ringhealth.R
|
import com.placeholder.ringhealth.R
|
||||||
|
import com.placeholder.ringhealth.core.common.formatActiveMinutes
|
||||||
import com.placeholder.ringhealth.core.common.formatCalories
|
import com.placeholder.ringhealth.core.common.formatCalories
|
||||||
import com.placeholder.ringhealth.core.common.formatDistance
|
import com.placeholder.ringhealth.core.common.formatMonthDay
|
||||||
import com.placeholder.ringhealth.core.common.formatSleepDuration
|
import com.placeholder.ringhealth.core.common.formatSleepDuration
|
||||||
import com.placeholder.ringhealth.core.common.formatSteps
|
import com.placeholder.ringhealth.core.common.formatSteps
|
||||||
import com.placeholder.ringhealth.core.designsystem.MetricBlue
|
import com.placeholder.ringhealth.core.common.greetingResFor
|
||||||
import com.placeholder.ringhealth.core.designsystem.MetricCyan
|
import com.placeholder.ringhealth.core.common.stressLevelLabelRes
|
||||||
|
import com.placeholder.ringhealth.core.common.weekdayResFor
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.GreetingStyle
|
||||||
import com.placeholder.ringhealth.core.designsystem.MetricGreen
|
import com.placeholder.ringhealth.core.designsystem.MetricGreen
|
||||||
import com.placeholder.ringhealth.core.designsystem.MetricOrange
|
|
||||||
import com.placeholder.ringhealth.core.designsystem.MetricPurple
|
|
||||||
import com.placeholder.ringhealth.core.designsystem.MetricRed
|
|
||||||
import com.placeholder.ringhealth.core.designsystem.MetricYellow
|
|
||||||
import com.placeholder.ringhealth.core.designsystem.MetricValueStyle
|
|
||||||
import com.placeholder.ringhealth.core.designsystem.RingHealthTheme
|
import com.placeholder.ringhealth.core.designsystem.RingHealthTheme
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.TextPrimary
|
||||||
import com.placeholder.ringhealth.core.designsystem.TextSecondary
|
import com.placeholder.ringhealth.core.designsystem.TextSecondary
|
||||||
import com.placeholder.ringhealth.core.designsystem.component.HealthCard
|
import com.placeholder.ringhealth.core.designsystem.component.HealthCard
|
||||||
import com.placeholder.ringhealth.core.designsystem.component.MetricTile
|
import com.placeholder.ringhealth.core.designsystem.component.MetricTile
|
||||||
import com.placeholder.ringhealth.core.designsystem.component.RingProgress
|
|
||||||
import com.placeholder.ringhealth.core.designsystem.component.SectionHeader
|
import com.placeholder.ringhealth.core.designsystem.component.SectionHeader
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.metricIcon
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.metricTint
|
||||||
import com.placeholder.ringhealth.domain.model.HealthSummary
|
import com.placeholder.ringhealth.domain.model.HealthSummary
|
||||||
import com.placeholder.ringhealth.domain.model.MetricType
|
import com.placeholder.ringhealth.domain.model.MetricType
|
||||||
import com.placeholder.ringhealth.domain.model.RingDeviceState
|
import com.placeholder.ringhealth.domain.model.RingDeviceState
|
||||||
|
|
||||||
/** 一个指标卡的数据。 */
|
/**
|
||||||
|
* 一个指标卡的数据。
|
||||||
|
*
|
||||||
|
* 只有文案和数值在这里 —— 图标与配色是指标的固有属性,取自 [metricIcon] / [metricTint],
|
||||||
|
* 不随卡片走。否则同一份映射会被复制到每个用到它的地方。
|
||||||
|
*/
|
||||||
private data class MetricCardSpec(
|
private data class MetricCardSpec(
|
||||||
val type: MetricType,
|
val type: MetricType,
|
||||||
val icon: ImageVector,
|
|
||||||
val tint: Color,
|
|
||||||
val title: String,
|
val title: String,
|
||||||
val value: String?,
|
val value: String?,
|
||||||
val unit: String,
|
val unit: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** 首页网格列数。 */
|
||||||
|
private const val HOME_GRID_COLUMNS = 2
|
||||||
|
|
||||||
/** 健康首页。 */
|
/** 健康首页。 */
|
||||||
@Composable
|
@Composable
|
||||||
fun HomeScreen(
|
fun HomeScreen(
|
||||||
@@ -78,14 +87,30 @@ fun HomeScreen(
|
|||||||
viewModel: HomeViewModel = viewModel(factory = AppViewModelProvider.Factory),
|
viewModel: HomeViewModel = viewModel(factory = AppViewModelProvider.Factory),
|
||||||
) {
|
) {
|
||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
HomeContent(state = state, onOpenDetail = onOpenDetail, modifier = modifier)
|
HomeContent(
|
||||||
|
state = state,
|
||||||
|
onOpenDetail = onOpenDetail,
|
||||||
|
// 进页面时取一次当前时间,供问候语与日期行使用。
|
||||||
|
// 取一次而不是起一个每秒跳动的时钟:问候语在「早上好→下午好」之间切换没有实时性要求,
|
||||||
|
// 而一个常驻协程会在页面不可见时也持续重组。remember 保证配置变更外的重组不重取。
|
||||||
|
nowMillis = remember { System.currentTimeMillis() },
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 无状态首页内容,便于 Preview。 */
|
/**
|
||||||
|
* 无状态首页内容,便于 Preview。
|
||||||
|
*
|
||||||
|
* 布局用 `LazyVerticalGrid` 两列:整宽内容(问候、设备条、步数大环、区块标题)用
|
||||||
|
* `GridItemSpan(maxLineSpan)` 跨满整行,六个指标卡各占一格,排成 2×3。
|
||||||
|
* 之前是 `LazyColumn` 单列 —— 每张指标卡被拉到整屏宽(约 1100px)而高只有 180px,
|
||||||
|
* 宽高比 6:1,横向大片留白、纵向却被撑开,六个数字要滚三屏才看得完。
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun HomeContent(
|
private fun HomeContent(
|
||||||
state: HomeUiState,
|
state: HomeUiState,
|
||||||
onOpenDetail: (MetricType) -> Unit,
|
onOpenDetail: (MetricType) -> Unit,
|
||||||
|
nowMillis: Long,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
if (state.loading || state.summary == null) {
|
if (state.loading || state.summary == null) {
|
||||||
@@ -96,35 +121,80 @@ private fun HomeContent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val summary = state.summary
|
val summary = state.summary
|
||||||
// 注意:LazyColumn 的 content lambda 不是 Composable 上下文,
|
// 注意:LazyVerticalGrid 的 content lambda 不是 Composable 上下文,
|
||||||
// 所有需要 stringResource / Composable 调用的内容都必须在 LazyColumn 之前算好。
|
// 所有需要 stringResource / Composable 调用的内容都必须在网格之前算好。
|
||||||
val cards = buildMetricCards(summary)
|
val cards = buildMetricCards(summary)
|
||||||
val notEnabledText = stringResource(R.string.home_not_enabled)
|
val notEnabledText = stringResource(R.string.home_not_enabled)
|
||||||
LazyColumn(
|
LazyVerticalGrid(
|
||||||
|
columns = GridCells.Fixed(HOME_GRID_COLUMNS),
|
||||||
modifier = modifier.fillMaxSize(),
|
modifier = modifier.fillMaxSize(),
|
||||||
contentPadding = PaddingValues(16.dp),
|
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 4.dp, bottom = 16.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
item { DeviceStrip(device = state.device) }
|
// 跨满整行的元素一律显式传 GridItemSpan(maxLineSpan),而不是写死 2 ——
|
||||||
item { StepsCard(summary = summary, onClick = { onOpenDetail(MetricType.STEP) }) }
|
// 将来改列数时这些元素自动跟随,不会漏改。
|
||||||
item { SectionHeader(title = stringResource(R.string.home_metrics)) }
|
item(span = { GridItemSpan(maxLineSpan) }) { GreetingHeader(nowMillis = nowMillis) }
|
||||||
items(cards) { card ->
|
item(span = { GridItemSpan(maxLineSpan) }) { DeviceStrip(device = state.device) }
|
||||||
|
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||||
|
TodaySummaryCard(summary = summary, onOpenSteps = { onOpenDetail(MetricType.STEP) })
|
||||||
|
}
|
||||||
|
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||||
|
SectionHeader(title = stringResource(R.string.home_metrics))
|
||||||
|
}
|
||||||
|
items(cards, key = { it.type.name }) { card ->
|
||||||
|
// 未开启的指标卡(血压)**同样可点开** —— 点进去是「未开启 + 怎么开启」的说明页。
|
||||||
|
//
|
||||||
|
// 这里曾经反过来:没有数值就不挂 clickable(`card.value?.let { ... }`),用来堵
|
||||||
|
// 「首页写未开启、点进去却是一张完整趋势图」这个自相矛盾。堵法过头了 ——
|
||||||
|
// 那张说明页因此一个入口都没有,成了死代码,用户也无从知道该怎么把功能打开。
|
||||||
|
// 矛盾的根源是详情页画了图,不是卡片可点击,所以改由血压页不画图来解决
|
||||||
|
// (见 BloodPressureDetail.kt)。
|
||||||
|
//
|
||||||
|
// 但 enabled 仍按有无数值来定:未开启时数值位显示的是「未开启」三个字,
|
||||||
|
// 用大号粗体排出来会比真实数值还重。
|
||||||
|
val enabled = card.value != null
|
||||||
MetricTile(
|
MetricTile(
|
||||||
icon = card.icon,
|
icon = metricIcon(card.type),
|
||||||
iconTint = card.tint,
|
iconTint = metricTint(card.type),
|
||||||
title = card.title,
|
title = card.title,
|
||||||
value = card.value ?: notEnabledText,
|
value = card.value ?: notEnabledText,
|
||||||
unit = if (card.value == null) "" else card.unit,
|
// 显示「未开启」时它不是一个数值,也就没有单位可跟。
|
||||||
|
unit = if (enabled) card.unit else "",
|
||||||
|
enabled = enabled,
|
||||||
onClick = { onOpenDetail(card.type) },
|
onClick = { onOpenDetail(card.type) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 顶部问候 + 日期。
|
||||||
|
*
|
||||||
|
* 这是设计文档 §6 要求、但 M1 漏做的一块(当时首页直接从设备状态条开始)——
|
||||||
|
* 补上它既对齐了文档,也给页面一个「身份」:此前进入首页第一眼是一张卡片怼上来,
|
||||||
|
* 看不出这是哪一页。
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun GreetingHeader(nowMillis: Long, modifier: Modifier = Modifier) {
|
||||||
|
Column(modifier = modifier.padding(top = 8.dp, bottom = 2.dp)) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(greetingResFor(nowMillis)),
|
||||||
|
style = GreetingStyle,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = "${formatMonthDay(nowMillis)} ${stringResource(weekdayResFor(nowMillis))}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = TextSecondary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 顶部设备连接状态条。 */
|
/** 顶部设备连接状态条。 */
|
||||||
@Composable
|
@Composable
|
||||||
private fun DeviceStrip(device: RingDeviceState?, modifier: Modifier = Modifier) {
|
private fun DeviceStrip(device: RingDeviceState?, modifier: Modifier = Modifier) {
|
||||||
HealthCard(modifier = modifier) {
|
HealthCard(modifier = modifier, contentPadding = PaddingValues(horizontal = 16.dp, vertical = 14.dp)) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Filled.MonitorHeart,
|
imageVector = Icons.Filled.MonitorHeart,
|
||||||
@@ -153,114 +223,167 @@ private fun DeviceStrip(device: RingDeviceState?, modifier: Modifier = Modifier)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 今日步数大环卡片。 */
|
/**
|
||||||
|
* 今日数据卡片:三件套横排(步数 / 卡路里 / 活动时长)。
|
||||||
|
*
|
||||||
|
* 早先是「步数大环 + 距离/千卡」两件套 —— 大环占近 1/3 屏,环外信息只有两个换算结果,
|
||||||
|
* 留白多、信息密度低。换成三件套后:
|
||||||
|
* - 三列等宽,无主导项 —— 步数不再被环放大成「今天的全部重点」
|
||||||
|
* - 步数下挂目标("目标 10,000"),目标本身仍是用户对照自己的尺子
|
||||||
|
* - 步数一格整格可点进详情;卡路里/活动时长一格整格也点了能进(暂未挂路由,行为同环外区域)
|
||||||
|
*
|
||||||
|
* **第一版的「value + 单位同行」写法装不下**:30sp 字号下「8,426 步」「327 千卡」「47 分钟」
|
||||||
|
* 三件套单元格里都会换行 —— 视觉上「8,426」「步」分两行变成「数值」「单位」两截,反而像丢了内容。
|
||||||
|
* 改为「label 表达单位 + value 纯数字」:label 写成「步数 / 卡路里(千卡)/ 活动(分钟)」,
|
||||||
|
* 把单位塞进 label,value 行只摆数字。
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun StepsCard(summary: HealthSummary, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
private fun TodaySummaryCard(summary: HealthSummary, onOpenSteps: () -> Unit, modifier: Modifier = Modifier) {
|
||||||
HealthCard(modifier = modifier, onClick = onClick) {
|
HealthCard(modifier = modifier, contentPadding = PaddingValues(horizontal = 12.dp, vertical = 16.dp)) {
|
||||||
|
Row(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
SummaryCell(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
label = stringResource(R.string.home_summary_steps_label),
|
||||||
|
value = formatSteps(summary.steps),
|
||||||
|
subline = stringResource(R.string.home_summary_steps_goal, formatSteps(summary.stepGoal)),
|
||||||
|
onClick = onOpenSteps,
|
||||||
|
)
|
||||||
|
SummaryDivider()
|
||||||
|
SummaryCell(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
label = stringResource(R.string.home_summary_calories_label),
|
||||||
|
value = formatCalories(summary.calories),
|
||||||
|
subline = null,
|
||||||
|
onClick = null,
|
||||||
|
)
|
||||||
|
SummaryDivider()
|
||||||
|
SummaryCell(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
label = stringResource(R.string.home_summary_active_label),
|
||||||
|
value = formatActiveMinutes(summary.activeMinutes),
|
||||||
|
subline = null,
|
||||||
|
onClick = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 三件套行中两列之间的细分隔线。
|
||||||
|
*
|
||||||
|
* 一根 1dp 宽的浅色竖线,把三件套视觉上连成一张表而不是三张独立卡。
|
||||||
|
* 颜色用本页自定义的浅灰([HomeSummaryDivider]),不放 `core/designsystem/DividerColor`
|
||||||
|
* —— 那个值是卡片之间的整宽分割线(`#EFEFF4`),放在卡片内部会太显眼。
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun RowScope.SummaryDivider() {
|
||||||
|
androidx.compose.foundation.layout.Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.width(1.dp)
|
||||||
|
.padding(vertical = 6.dp)
|
||||||
|
.background(HomeSummaryDivider),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 三件套行内分隔线专用色,比卡片间分割线略深一档但仍属「几乎看不见」。 */
|
||||||
|
private val HomeSummaryDivider = androidx.compose.ui.graphics.Color(0xFFE2E5EB)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 三件套的单格:上标签(含单位)、下大数字、再下一行小字(步数那一格用,挂目标)。
|
||||||
|
*
|
||||||
|
* `onClick` 为 null 时整格不响应点击(卡路里/活动时长暂未挂详情路由);
|
||||||
|
* 不为 null 时整格可点(步数一格进步数详情)。
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun SummaryCell(
|
||||||
|
label: String,
|
||||||
|
value: String,
|
||||||
|
subline: String?,
|
||||||
|
onClick: (() -> Unit)?,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val clickableModifier = if (onClick != null) modifier.clickable(onClick = onClick) else modifier
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = clickableModifier.padding(horizontal = 8.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
Text(text = stringResource(R.string.home_steps_title), style = MaterialTheme.typography.titleMedium)
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
RingProgress(
|
|
||||||
progress = summary.steps.toFloat() / summary.stepGoal.coerceAtLeast(1),
|
|
||||||
color = MetricGreen,
|
|
||||||
trackColor = MetricGreen.copy(alpha = 0.15f),
|
|
||||||
) {
|
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
|
||||||
Text(text = formatSteps(summary.steps), style = MetricValueStyle)
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.home_steps_goal, formatSteps(summary.stepGoal)),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = TextSecondary,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
|
||||||
) {
|
|
||||||
SummaryMetric(
|
|
||||||
value = formatDistance(summary.distanceKm),
|
|
||||||
label = stringResource(R.string.home_distance_label),
|
|
||||||
tint = MetricBlue,
|
|
||||||
)
|
|
||||||
SummaryMetric(
|
|
||||||
value = formatCalories(summary.calories),
|
|
||||||
label = stringResource(R.string.home_calories_label),
|
|
||||||
tint = MetricOrange,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 环下方的小结指标。 */
|
|
||||||
@Composable
|
|
||||||
private fun SummaryMetric(value: String, label: String, tint: Color) {
|
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
|
||||||
Text(text = value, style = MaterialTheme.typography.titleMedium, color = tint)
|
|
||||||
Text(text = label, style = MaterialTheme.typography.bodySmall, color = TextSecondary)
|
Text(text = label, style = MaterialTheme.typography.bodySmall, color = TextSecondary)
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
Text(text = value, style = TodaySummaryValueStyle)
|
||||||
|
if (subline != null) {
|
||||||
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
|
Text(text = subline, style = MaterialTheme.typography.bodySmall, color = TextSecondary)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 组装指标卡列表;value 为 null 表示该指标未开启。 */
|
/** 三件套单格内的数值字号:26sp + tnum;30sp 在三等分格里换行(首版踩过这个坑)。 */
|
||||||
|
private val TodaySummaryValueStyle = TextStyle(
|
||||||
|
fontSize = 26.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
letterSpacing = (-1).sp,
|
||||||
|
color = TextPrimary,
|
||||||
|
fontFeatureSettings = "tnum",
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 组装指标卡列表;value 为 null 表示该指标未开启。
|
||||||
|
*
|
||||||
|
* 图标与配色一律取自 [metricIcon] / [metricTint],不在这里写字面量 ——
|
||||||
|
* 卡片和详情页必须显示成同一个指标的样子,抄两份就会有一份先过期。
|
||||||
|
*
|
||||||
|
* 值为 null 的原因是**功能开关关着**(概览的字段由开关生成,见 `MockHealthDataRepository`),
|
||||||
|
* 所以这里读到的 null 与功能设置页那个开关是同一个事实的两个说法。
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun buildMetricCards(summary: HealthSummary): List<MetricCardSpec> = listOf(
|
private fun buildMetricCards(summary: HealthSummary): List<MetricCardSpec> = listOf(
|
||||||
MetricCardSpec(
|
MetricCardSpec(
|
||||||
type = MetricType.HEART_RATE,
|
type = MetricType.HEART_RATE,
|
||||||
icon = Icons.Filled.Favorite,
|
|
||||||
tint = MetricRed,
|
|
||||||
title = stringResource(R.string.metric_heart_rate),
|
title = stringResource(R.string.metric_heart_rate),
|
||||||
value = summary.heartRate?.toString(),
|
value = summary.heartRate?.toString(),
|
||||||
unit = stringResource(R.string.unit_bpm),
|
unit = stringResource(R.string.unit_bpm),
|
||||||
),
|
),
|
||||||
MetricCardSpec(
|
MetricCardSpec(
|
||||||
type = MetricType.SLEEP,
|
type = MetricType.SLEEP,
|
||||||
icon = Icons.Filled.Bedtime,
|
|
||||||
tint = MetricBlue,
|
|
||||||
title = stringResource(R.string.metric_sleep),
|
title = stringResource(R.string.metric_sleep),
|
||||||
value = summary.sleepMinutes?.let { formatSleepDuration(it) },
|
value = summary.sleepMinutes?.let { formatSleepDuration(it) },
|
||||||
unit = "",
|
unit = "",
|
||||||
),
|
),
|
||||||
MetricCardSpec(
|
MetricCardSpec(
|
||||||
type = MetricType.BLOOD_OXYGEN,
|
type = MetricType.BLOOD_OXYGEN,
|
||||||
icon = Icons.Filled.Bloodtype,
|
|
||||||
tint = MetricCyan,
|
|
||||||
title = stringResource(R.string.metric_blood_oxygen),
|
title = stringResource(R.string.metric_blood_oxygen),
|
||||||
value = summary.bloodOxygen?.toString(),
|
value = summary.bloodOxygen?.toString(),
|
||||||
unit = stringResource(R.string.unit_percent),
|
unit = stringResource(R.string.unit_percent),
|
||||||
),
|
),
|
||||||
MetricCardSpec(
|
MetricCardSpec(
|
||||||
type = MetricType.STRESS,
|
type = MetricType.STRESS,
|
||||||
icon = Icons.Filled.Spa,
|
|
||||||
tint = MetricPurple,
|
|
||||||
title = stringResource(R.string.metric_stress),
|
title = stringResource(R.string.metric_stress),
|
||||||
value = summary.stress?.toString(),
|
value = summary.stress?.toString(),
|
||||||
unit = "",
|
// 单位位改放压力等级文案(数值落在哪个区间就是放松/正常/中等/偏高),
|
||||||
|
// 于是卡片读作「34 正常」而不是一个孤零零的数字。无值时留空(上面的 value 分支
|
||||||
|
// 会整卡退化成「未开启」,此时单位本来也会被清空)。
|
||||||
|
unit = summary.stress?.let { stringResource(stressLevelLabelRes(it)) } ?: "",
|
||||||
),
|
),
|
||||||
MetricCardSpec(
|
MetricCardSpec(
|
||||||
type = MetricType.TEMPERATURE,
|
type = MetricType.TEMPERATURE,
|
||||||
icon = Icons.Filled.Thermostat,
|
|
||||||
tint = MetricOrange,
|
|
||||||
title = stringResource(R.string.metric_temperature),
|
title = stringResource(R.string.metric_temperature),
|
||||||
value = summary.temperature?.toString(),
|
value = summary.temperature?.toString(),
|
||||||
unit = stringResource(R.string.unit_celsius),
|
unit = stringResource(R.string.unit_celsius),
|
||||||
),
|
),
|
||||||
MetricCardSpec(
|
MetricCardSpec(
|
||||||
type = MetricType.BLOOD_PRESSURE,
|
type = MetricType.BLOOD_PRESSURE,
|
||||||
icon = Icons.Filled.WaterDrop,
|
|
||||||
tint = MetricYellow,
|
|
||||||
title = stringResource(R.string.metric_blood_pressure),
|
title = stringResource(R.string.metric_blood_pressure),
|
||||||
value = summary.bloodPressure,
|
value = summary.bloodPressure,
|
||||||
unit = stringResource(R.string.unit_mmhg),
|
unit = stringResource(R.string.unit_mmhg),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
/** 预览入口:以固定示例数据渲染 [HomeContent],便于在 IDE 中免真机查看首页效果。 */
|
/**
|
||||||
@Preview(showBackground = true)
|
* 预览入口:以固定示例数据渲染 [HomeContent],便于在 IDE 中免真机查看首页效果。
|
||||||
|
*
|
||||||
|
* `nowMillis` 写死成固定时刻,预览里的问候语与日期才不会随运行时间变化。
|
||||||
|
*/
|
||||||
|
@Preview(showBackground = true, heightDp = 900)
|
||||||
@Composable
|
@Composable
|
||||||
private fun HomeContentPreview() {
|
private fun HomeContentPreview() {
|
||||||
RingHealthTheme {
|
RingHealthTheme {
|
||||||
@@ -271,6 +394,9 @@ private fun HomeContentPreview() {
|
|||||||
device = RingDeviceState(true, "RingHealth 智能戒指", 78, "1.0.5", false),
|
device = RingDeviceState(true, "RingHealth 智能戒指", 78, "1.0.5", false),
|
||||||
),
|
),
|
||||||
onOpenDetail = {},
|
onOpenDetail = {},
|
||||||
|
// 固定为 2026-09-10 08:26(周四)—— 取固定值是为了让预览里的问候语「早上好」
|
||||||
|
// 与日期行不随运行时间变化,否则同一份 Preview 在不同时段渲染出不同文案。
|
||||||
|
nowMillis = 1_789_000_000_000L,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
package com.placeholder.ringhealth.feature.profile
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.placeholder.ringhealth.R
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.MetricGreen
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.RingHealthTheme
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.TextSecondary
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.component.HealthCard
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.component.SectionHeader
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.component.SubPageTopAppBar
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「关于」子页:版本信息 + 检查更新。
|
||||||
|
*
|
||||||
|
* 把 spec §6「④ 我的」里的 `关于/检查更新` 合并到一页 ——「关于」页就该告诉用户
|
||||||
|
* 当前是什么版本、检查更新是关于页的标准动作之一,没必要再单开。
|
||||||
|
*
|
||||||
|
* 阶段一版本号是写死的 `0.1.0`(与 spec §12 占位命名阶段一致),
|
||||||
|
* 设备型号与固件版本从 [com.placeholder.ringhealth.data.mock.MockDataProvider.deviceState] 取演示值。
|
||||||
|
*
|
||||||
|
* 「检查更新」按钮按下后弹 Snackbar(这里用 `remember` 改一段说明文字代替真弹窗)
|
||||||
|
* —— 阶段一没有后端,「已是最新版本」是唯一合理回答。
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun AboutScreen(
|
||||||
|
onBack: () -> Unit,
|
||||||
|
deviceModel: String = DEMO_DEVICE_MODEL,
|
||||||
|
firmwareVersion: String = DEMO_FIRMWARE_VERSION,
|
||||||
|
appVersion: String = DEMO_APP_VERSION,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Scaffold(
|
||||||
|
modifier = modifier,
|
||||||
|
containerColor = MaterialTheme.colorScheme.background,
|
||||||
|
topBar = {
|
||||||
|
SubPageTopAppBar(
|
||||||
|
title = stringResource(R.string.profile_about_title),
|
||||||
|
onBack = onBack,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { innerPadding ->
|
||||||
|
AboutContent(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(innerPadding),
|
||||||
|
deviceModel = deviceModel,
|
||||||
|
firmwareVersion = firmwareVersion,
|
||||||
|
appVersion = appVersion,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 演示用的版本号常量。真实版本号应在 build.gradle.kts 里取 BuildConfig.VERSION_NAME。 */
|
||||||
|
private const val DEMO_APP_VERSION = "0.1.0"
|
||||||
|
private const val DEMO_DEVICE_MODEL = "RingHealth 智能戒指"
|
||||||
|
private const val DEMO_FIRMWARE_VERSION = "1.0.5"
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AboutContent(
|
||||||
|
deviceModel: String,
|
||||||
|
firmwareVersion: String,
|
||||||
|
appVersion: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
// 「检查更新」按过的提示:阶段一没有真后端,按下后给一段静态文案。
|
||||||
|
// 走完全限定名 + 取 `.value` 而不是 `by remember`,避免在不同包引用 setValue 操作符。
|
||||||
|
val checkedState = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(false) }
|
||||||
|
val checked = checkedState.value
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier = modifier,
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
item { SectionHeader(title = stringResource(R.string.profile_about_section_app)) }
|
||||||
|
item {
|
||||||
|
HealthCard {
|
||||||
|
Column {
|
||||||
|
InfoRow(
|
||||||
|
label = stringResource(R.string.profile_about_app_version_label),
|
||||||
|
value = appVersion,
|
||||||
|
)
|
||||||
|
InfoRow(
|
||||||
|
label = stringResource(R.string.profile_about_firmware_version_label),
|
||||||
|
value = firmwareVersion,
|
||||||
|
)
|
||||||
|
InfoRow(
|
||||||
|
label = stringResource(R.string.profile_about_device_model_label),
|
||||||
|
value = deviceModel,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item { SectionHeader(title = stringResource(R.string.profile_about_section_actions)) }
|
||||||
|
item {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Button(
|
||||||
|
onClick = { checkedState.value = true },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = MetricGreen),
|
||||||
|
) {
|
||||||
|
Text(text = stringResource(R.string.profile_about_check_update))
|
||||||
|
}
|
||||||
|
if (checked) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.profile_about_check_update_done),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = TextSecondary,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.profile_about_check_update_current, appVersion),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = TextSecondary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun InfoRow(label: String, value: String, modifier: Modifier = Modifier) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier.fillMaxWidth().padding(vertical = 6.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(text = label, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
Text(text = value, style = MaterialTheme.typography.bodyMedium, color = TextSecondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
private fun AboutContentPreview() {
|
||||||
|
RingHealthTheme {
|
||||||
|
AboutContent(
|
||||||
|
deviceModel = DEMO_DEVICE_MODEL,
|
||||||
|
firmwareVersion = DEMO_FIRMWARE_VERSION,
|
||||||
|
appVersion = DEMO_APP_VERSION,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
package com.placeholder.ringhealth.feature.profile
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Email
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.placeholder.ringhealth.R
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.MetricGreen
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.RingHealthTheme
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.TextSecondary
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.component.HealthCard
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.component.SectionHeader
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.component.SubPageTopAppBar
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「帮助与反馈」子页。
|
||||||
|
*
|
||||||
|
* 内容分两块:FAQ 折叠列表 + 「联系我们」入口卡。
|
||||||
|
* 阶段一 FAQ 是写死的 4 条,覆盖「连接 / 同步 / 单位 / 反馈」四个常见问题;
|
||||||
|
* M2 接 SDK 后接入真实 FAQ 后端。
|
||||||
|
*
|
||||||
|
* 折叠交互:每条 FAQ 是一个 `Card`,点击整张卡切换展开/收起 —— 整卡可点击比只在标题
|
||||||
|
* 加箭头更符合拇指操作区域。
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun HelpScreen(onBack: () -> Unit, modifier: Modifier = Modifier) {
|
||||||
|
Scaffold(
|
||||||
|
modifier = modifier,
|
||||||
|
containerColor = MaterialTheme.colorScheme.background,
|
||||||
|
topBar = {
|
||||||
|
SubPageTopAppBar(
|
||||||
|
title = stringResource(R.string.profile_help_title),
|
||||||
|
onBack = onBack,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { innerPadding ->
|
||||||
|
HelpContent(modifier = Modifier.fillMaxSize().padding(innerPadding))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** FAQ 一条。题目与答案作为参数传入,便于复用与测试。 */
|
||||||
|
private data class FaqItem(val questionRes: Int, val answerRes: Int)
|
||||||
|
|
||||||
|
/** 阶段一 FAQ 列表 —— 按用户最可能先问的顺序排:连接 → 同步 → 设置 → 反馈。 */
|
||||||
|
private val FaqCatalog: List<FaqItem> = listOf(
|
||||||
|
FaqItem(R.string.profile_help_faq_1_q, R.string.profile_help_faq_1_a),
|
||||||
|
FaqItem(R.string.profile_help_faq_2_q, R.string.profile_help_faq_2_a),
|
||||||
|
FaqItem(R.string.profile_help_faq_3_q, R.string.profile_help_faq_3_a),
|
||||||
|
FaqItem(R.string.profile_help_faq_4_q, R.string.profile_help_faq_4_a),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun HelpContent(modifier: Modifier = Modifier) {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = modifier,
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
item { SectionHeader(title = stringResource(R.string.profile_help_section_faq)) }
|
||||||
|
items(FaqCatalog.size) { index ->
|
||||||
|
FaqCard(item = FaqCatalog[index])
|
||||||
|
}
|
||||||
|
item { Spacer(modifier = Modifier.height(4.dp)) }
|
||||||
|
item { SectionHeader(title = stringResource(R.string.profile_help_section_contact)) }
|
||||||
|
item { ContactCard() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单条 FAQ 卡。
|
||||||
|
*
|
||||||
|
* 展开状态用 `remember { mutableStateOf(false) }` 保留:默认收起,进入页面只看问题列表,
|
||||||
|
* 点开才看答案 —— 减少一屏的信息密度。整张卡可点,比把点击区限制在标题上更顺手。
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun FaqCard(item: FaqItem, modifier: Modifier = Modifier) {
|
||||||
|
var expanded by remember { mutableStateOf(false) }
|
||||||
|
HealthCard(modifier = modifier.fillMaxWidth(), onClick = { expanded = !expanded }) {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = stringResource(item.questionRes),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
)
|
||||||
|
if (expanded) {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(item.answerRes),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = TextSecondary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「联系我们」卡:邮箱 + 图标。
|
||||||
|
*
|
||||||
|
* 阶段一没有真点开的动作 —— 邮件地址是写死的演示值。M2 接后端时换成 `mailto:` Intent。
|
||||||
|
* 卡片上不放按钮,把整张卡做成「邮箱 + Email 图标 + 右箭头」的结构,按下后跳邮件客户端。
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun ContactCard(modifier: Modifier = Modifier) {
|
||||||
|
HealthCard(modifier = modifier) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Email,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MetricGreen,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(36.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.padding(6.dp),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.size(12.dp))
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.profile_help_contact),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.profile_help_contact_email),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = TextSecondary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** LazyListScope.items(count) — 阶段一直接用 lambda,不引入 item DSL。 */
|
||||||
|
private fun androidx.compose.foundation.lazy.LazyListScope.items(
|
||||||
|
count: Int,
|
||||||
|
itemContent: @Composable (index: Int) -> Unit,
|
||||||
|
) {
|
||||||
|
items(count = count, itemContent = { index -> itemContent(index) })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
private fun HelpContentPreview() {
|
||||||
|
RingHealthTheme {
|
||||||
|
HelpContent()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package com.placeholder.ringhealth.feature.profile
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.NotificationsActive
|
||||||
|
import androidx.compose.material.icons.filled.NotificationsOff
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.placeholder.ringhealth.R
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.MetricGreen
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.RingHealthTheme
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.TextPrimary
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.TextSecondary
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.component.HealthCard
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.component.SubPageTopAppBar
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「通知权限」引导子页。
|
||||||
|
*
|
||||||
|
* 阶段一只做 **静态引导**:告诉用户当前权限状态、关闭权限会丢什么,
|
||||||
|
* 并提供「请求权限」与「前往系统设置」两个按钮 —— 后者会真跳到系统设置页
|
||||||
|
* (`Settings.ACTION_APP_NOTIFICATION_SETTINGS` 是个标准 Intent),
|
||||||
|
* 前者阶段一不真发起系统权限弹窗(M2 接 SDK 时再做)。
|
||||||
|
*
|
||||||
|
* 权限状态用 `remember { mutableStateOf(false) }` 当演示值,「未授权」是默认演示态 —
|
||||||
|
* — 引导用户从这里开始走流程。
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun NotificationsScreen(onBack: () -> Unit, modifier: Modifier = Modifier) {
|
||||||
|
Scaffold(
|
||||||
|
modifier = modifier,
|
||||||
|
containerColor = MaterialTheme.colorScheme.background,
|
||||||
|
topBar = {
|
||||||
|
SubPageTopAppBar(
|
||||||
|
title = stringResource(R.string.profile_notifications_title),
|
||||||
|
onBack = onBack,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { innerPadding ->
|
||||||
|
NotificationsContent(modifier = Modifier.fillMaxSize().padding(innerPadding))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun NotificationsContent(modifier: Modifier = Modifier) {
|
||||||
|
// 演示用状态:阶段一始终未授权,引导用户走完整流程。
|
||||||
|
// 走完全限定名 + 取 `.value` 而不是 `var by remember`,避免 setValue 操作符跨包引用。
|
||||||
|
val grantedState = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(false) }
|
||||||
|
val granted = grantedState.value
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier = modifier,
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
item { PermissionStatusCard(granted = granted) }
|
||||||
|
item { PermissionHintCard() }
|
||||||
|
item {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Button(
|
||||||
|
onClick = { grantedState.value = true },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = MetricGreen),
|
||||||
|
) {
|
||||||
|
Text(text = stringResource(R.string.profile_notifications_request))
|
||||||
|
}
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { /* 阶段一未实接:M2 时用 Settings.ACTION_APP_NOTIFICATION_SETTINGS 跳系统设置 */ },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(text = stringResource(R.string.profile_notifications_open_settings))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 顶部权限状态展示:圆形徽章 + 当前状态文字。 */
|
||||||
|
@Composable
|
||||||
|
private fun PermissionStatusCard(granted: Boolean, modifier: Modifier = Modifier) {
|
||||||
|
HealthCard(modifier = modifier) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (granted) Icons.Filled.NotificationsActive else Icons.Filled.NotificationsOff,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = if (granted) MetricGreen else TextSecondary,
|
||||||
|
modifier = Modifier.size(56.dp),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(10.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(
|
||||||
|
R.string.profile_notifications_status,
|
||||||
|
stringResource(
|
||||||
|
if (granted) R.string.profile_notifications_status_granted
|
||||||
|
else R.string.profile_notifications_status_denied,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = TextPrimary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解释关掉通知会丢什么的说明卡。 */
|
||||||
|
@Composable
|
||||||
|
private fun PermissionHintCard(modifier: Modifier = Modifier) {
|
||||||
|
HealthCard(modifier = modifier) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.profile_notifications_hint),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = TextSecondary,
|
||||||
|
textAlign = TextAlign.Start,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 56dp 图标是「权限状态」这个语义节点该有的视觉重量 —— 比下面的开关按钮重,比顶栏轻。 */
|
||||||
|
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
private fun NotificationsContentPreview() {
|
||||||
|
RingHealthTheme {
|
||||||
|
NotificationsContent()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.material.icons.Icons
|
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.AccountCircle
|
||||||
import androidx.compose.material.icons.filled.Info
|
import androidx.compose.material.icons.filled.Info
|
||||||
import androidx.compose.material.icons.filled.Notifications
|
import androidx.compose.material.icons.filled.Notifications
|
||||||
@@ -21,25 +22,41 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.placeholder.ringhealth.R
|
import com.placeholder.ringhealth.R
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.MetricBlue
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.MetricCyan
|
||||||
import com.placeholder.ringhealth.core.designsystem.MetricGreen
|
import com.placeholder.ringhealth.core.designsystem.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.RingHealthTheme
|
||||||
import com.placeholder.ringhealth.core.designsystem.TextSecondary
|
import com.placeholder.ringhealth.core.designsystem.TextSecondary
|
||||||
import com.placeholder.ringhealth.core.designsystem.component.HealthCard
|
import com.placeholder.ringhealth.core.designsystem.component.HealthCard
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.component.IconBadge
|
||||||
import com.placeholder.ringhealth.core.designsystem.component.SectionHeader
|
import com.placeholder.ringhealth.core.designsystem.component.SectionHeader
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* “我的”页。全部为静态入口,无状态、无 ViewModel。
|
* “我的”页。
|
||||||
|
*
|
||||||
|
* 2026-09-10 晚收口:四个入口(单位设置 / 通知权限 / 检查更新 / 帮助与反馈)
|
||||||
|
* 全部挂上 `onClick`,导航到 [SettingsScreen] / [NotificationsScreen] / [AboutScreen] / [HelpScreen],
|
||||||
|
* 告别阶段一首批的死链接(PROGRESS.md §3.2)。
|
||||||
*
|
*
|
||||||
* 本页**不需要**再拆「有状态外壳 + 无状态内容」(ledger Ruling R26 的常规形状):
|
* 本页**不需要**再拆「有状态外壳 + 无状态内容」(ledger Ruling R26 的常规形状):
|
||||||
* 它本身就是无状态的,直接对它挂 `@Preview` 即可。
|
* 它本身就是无状态的,直接对它挂 `@Preview` 即可。
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ProfileScreen(modifier: Modifier = Modifier) {
|
fun ProfileScreen(
|
||||||
|
onOpenSettings: () -> Unit = {},
|
||||||
|
onOpenNotifications: () -> Unit = {},
|
||||||
|
onOpenAbout: () -> Unit = {},
|
||||||
|
onOpenHelp: () -> Unit = {},
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = modifier.fillMaxSize(),
|
modifier = modifier.fillMaxSize(),
|
||||||
contentPadding = PaddingValues(16.dp),
|
contentPadding = PaddingValues(16.dp),
|
||||||
@@ -72,58 +89,77 @@ fun ProfileScreen(modifier: Modifier = Modifier) {
|
|||||||
item {
|
item {
|
||||||
ProfileEntry(
|
ProfileEntry(
|
||||||
icon = Icons.Filled.Settings,
|
icon = Icons.Filled.Settings,
|
||||||
|
tint = MetricBlue,
|
||||||
title = stringResource(R.string.profile_units),
|
title = stringResource(R.string.profile_units),
|
||||||
subtitle = stringResource(R.string.profile_units_desc),
|
subtitle = stringResource(R.string.profile_units_desc),
|
||||||
|
onClick = onOpenSettings,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
ProfileEntry(
|
ProfileEntry(
|
||||||
icon = Icons.Filled.Notifications,
|
icon = Icons.Filled.Notifications,
|
||||||
|
tint = MetricOrange,
|
||||||
title = stringResource(R.string.profile_notification),
|
title = stringResource(R.string.profile_notification),
|
||||||
subtitle = stringResource(R.string.profile_notification_desc),
|
subtitle = stringResource(R.string.profile_notification_desc),
|
||||||
|
onClick = onOpenNotifications,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
item { SectionHeader(title = stringResource(R.string.profile_group_about)) }
|
item { SectionHeader(title = stringResource(R.string.profile_group_about)) }
|
||||||
item {
|
item {
|
||||||
ProfileEntry(
|
ProfileEntry(
|
||||||
icon = Icons.Filled.Refresh,
|
icon = Icons.Filled.Refresh,
|
||||||
|
tint = MetricPurple,
|
||||||
title = stringResource(R.string.profile_check_update),
|
title = stringResource(R.string.profile_check_update),
|
||||||
subtitle = stringResource(R.string.profile_check_update_desc),
|
subtitle = stringResource(R.string.profile_check_update_desc),
|
||||||
|
onClick = onOpenAbout,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
ProfileEntry(
|
ProfileEntry(
|
||||||
icon = Icons.Filled.Info,
|
icon = Icons.Filled.Info,
|
||||||
|
tint = MetricCyan,
|
||||||
title = stringResource(R.string.profile_help),
|
title = stringResource(R.string.profile_help),
|
||||||
subtitle = stringResource(R.string.profile_help_desc),
|
subtitle = stringResource(R.string.profile_help_desc),
|
||||||
|
onClick = onOpenHelp,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 单条设置入口。 */
|
/**
|
||||||
|
* 单条设置入口。
|
||||||
|
*
|
||||||
|
* 图标改用 [IconBadge] 的色底圆徽章,与首页指标卡同一套处理 —— 此前是裸的灰色小图标,
|
||||||
|
* 在全屏白卡上几乎是隐形的;四张卡的图标各配一色之后,一行行扫下来能靠颜色定位。
|
||||||
|
*
|
||||||
|
* 末尾的右箭头表示「本条可进」;`onClick` 为空 lambda 时卡片整张不响应点击(**保留
|
||||||
|
* 默认值兼容旧调用方**,将来如有需要可以让单条退化为纯展示)。
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun ProfileEntry(
|
private fun ProfileEntry(
|
||||||
icon: ImageVector,
|
icon: ImageVector,
|
||||||
|
tint: Color,
|
||||||
title: String,
|
title: String,
|
||||||
subtitle: String,
|
subtitle: String,
|
||||||
|
onClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
HealthCard(modifier = modifier) {
|
HealthCard(modifier = modifier, onClick = onClick) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
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(
|
Icon(
|
||||||
imageVector = icon,
|
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = TextSecondary,
|
tint = TextSecondary,
|
||||||
modifier = Modifier.size(20.dp),
|
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
package com.placeholder.ringhealth.feature.profile
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.SwitchDefaults
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.placeholder.ringhealth.R
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.MetricGreen
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.RingHealthTheme
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.TextSecondary
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.component.HealthCard
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.component.SectionHeader
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.component.SubPageTopAppBar
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「应用设置」子页。
|
||||||
|
*
|
||||||
|
* 把阶段一首批两个死链接(单位设置 + 通知权限)合并到一个页面,
|
||||||
|
* 按 `单位` / `通知` 两个 [SectionHeader] 分组。开关是 **演示用** —— 切了不持久化、
|
||||||
|
* 也不影响任何真实行为;M2 接 SDK 后通知开关才会真正写到设置接口。
|
||||||
|
*
|
||||||
|
* 为什么不拆成两个子页:单位只有两项只读,不值得单独一页;通知三个开关合在一张卡更直观。
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun SettingsScreen(onBack: () -> Unit, modifier: Modifier = Modifier) {
|
||||||
|
Scaffold(
|
||||||
|
modifier = modifier,
|
||||||
|
containerColor = MaterialTheme.colorScheme.background,
|
||||||
|
topBar = {
|
||||||
|
SubPageTopAppBar(
|
||||||
|
title = stringResource(R.string.profile_settings_title),
|
||||||
|
onBack = onBack,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { innerPadding ->
|
||||||
|
SettingsContent(modifier = Modifier.fillMaxSize().padding(innerPadding))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 内容拆出独立函数便于 Preview。 */
|
||||||
|
@Composable
|
||||||
|
private fun SettingsContent(modifier: Modifier = Modifier) {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = modifier,
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
item { SectionHeader(title = stringResource(R.string.profile_settings_section_units)) }
|
||||||
|
item { UnitsCard() }
|
||||||
|
item { Spacer(modifier = Modifier.height(4.dp)) }
|
||||||
|
item { SectionHeader(title = stringResource(R.string.profile_settings_section_notifications)) }
|
||||||
|
item { NotificationsCard() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单位卡:只读展示当前选中的距离/体重单位。
|
||||||
|
*
|
||||||
|
* 阶段一是「静态展示」,没有切换交互 —— 让用户看到单位配置在这里,将来要做切换
|
||||||
|
* 时再补 `onClick` 与路由。M2 接 SDK 后会改成可点的下拉。
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun UnitsCard(modifier: Modifier = Modifier) {
|
||||||
|
HealthCard(modifier = modifier) {
|
||||||
|
Column {
|
||||||
|
SettingRow(
|
||||||
|
title = stringResource(R.string.profile_settings_units_distance_label),
|
||||||
|
value = stringResource(R.string.profile_settings_units_distance_value),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
SettingRow(
|
||||||
|
title = stringResource(R.string.profile_settings_units_weight_label),
|
||||||
|
value = stringResource(R.string.profile_settings_units_weight_value),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通知卡:三个开关,按 `MockDataProvider.defaultFunctionSettings()` 的思路给演示值。
|
||||||
|
*
|
||||||
|
* 三个开关里**默认营销通知关着**、其余默认开着 —— 与「健康/设备提醒应当默认开,
|
||||||
|
* 营销通知应当默认关」的产品惯例一致;M2 接 SDK 后会真正写到系统通知渠道设置。
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun NotificationsCard(modifier: Modifier = Modifier) {
|
||||||
|
HealthCard(modifier = modifier) {
|
||||||
|
Column {
|
||||||
|
ToggleRow(
|
||||||
|
title = stringResource(R.string.profile_settings_notifications_health),
|
||||||
|
subtitle = stringResource(R.string.profile_settings_notifications_health_desc),
|
||||||
|
initial = true,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
ToggleRow(
|
||||||
|
title = stringResource(R.string.profile_settings_notifications_device),
|
||||||
|
subtitle = stringResource(R.string.profile_settings_notifications_device_desc),
|
||||||
|
initial = true,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
ToggleRow(
|
||||||
|
title = stringResource(R.string.profile_settings_notifications_marketing),
|
||||||
|
subtitle = stringResource(R.string.profile_settings_notifications_marketing_desc),
|
||||||
|
initial = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SettingRow(title: String, value: String, modifier: Modifier = Modifier) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(text = title, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
Text(text = value, style = MaterialTheme.typography.bodyMedium, color = TextSecondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单行开关。阶段一切换不持久化 —— 这里用 `remember` 保留每行自己的状态,
|
||||||
|
* 离页即丢。视觉一致即可。
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun ToggleRow(
|
||||||
|
title: String,
|
||||||
|
subtitle: String,
|
||||||
|
initial: Boolean,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
// `var checked by remember { ... }` 需要 `getValue` / `setValue` 两个 delegate 操作符,
|
||||||
|
// 跨包引用时 Compose 不会自动导,所以这里走完全限定名 —— `var` 的左值其实只需要 setValue。
|
||||||
|
val mutableState = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(initial) }
|
||||||
|
val checked = mutableState.value
|
||||||
|
Row(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f).padding(end = 16.dp)) {
|
||||||
|
Text(text = title, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
Text(text = subtitle, style = MaterialTheme.typography.bodySmall, color = TextSecondary)
|
||||||
|
}
|
||||||
|
Switch(
|
||||||
|
checked = checked,
|
||||||
|
onCheckedChange = { mutableState.value = it },
|
||||||
|
colors = SwitchDefaults.colors(checkedTrackColor = MetricGreen),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 预览入口:直接渲染 [SettingsContent],便于在 IDE 中免真机查看应用设置页效果。 */
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
private fun SettingsContentPreview() {
|
||||||
|
RingHealthTheme {
|
||||||
|
SettingsContent()
|
||||||
|
}
|
||||||
|
}
|
||||||
+35
-7
@@ -8,7 +8,9 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -24,36 +26,60 @@ import com.placeholder.ringhealth.R
|
|||||||
import com.placeholder.ringhealth.core.designsystem.MetricGreen
|
import com.placeholder.ringhealth.core.designsystem.MetricGreen
|
||||||
import com.placeholder.ringhealth.core.designsystem.RingHealthTheme
|
import com.placeholder.ringhealth.core.designsystem.RingHealthTheme
|
||||||
import com.placeholder.ringhealth.core.designsystem.TextSecondary
|
import com.placeholder.ringhealth.core.designsystem.TextSecondary
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.component.SubPageTopAppBar
|
||||||
import com.placeholder.ringhealth.domain.model.WorkoutRecord
|
import com.placeholder.ringhealth.domain.model.WorkoutRecord
|
||||||
|
|
||||||
/** 运动历史子页:展示全部运动记录。 */
|
/** 运动历史子页:展示全部运动记录。 */
|
||||||
@Composable
|
@Composable
|
||||||
fun WorkoutHistoryScreen(
|
fun WorkoutHistoryScreen(
|
||||||
|
onBack: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: WorkoutViewModel = viewModel(factory = AppViewModelProvider.Factory),
|
viewModel: WorkoutViewModel = viewModel(factory = AppViewModelProvider.Factory),
|
||||||
) {
|
) {
|
||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
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
|
@Composable
|
||||||
private fun WorkoutHistoryContent(
|
private fun WorkoutHistoryContent(
|
||||||
state: WorkoutUiState,
|
state: WorkoutUiState,
|
||||||
|
onBack: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
Scaffold(
|
||||||
|
modifier = modifier,
|
||||||
|
containerColor = MaterialTheme.colorScheme.background,
|
||||||
|
topBar = {
|
||||||
|
SubPageTopAppBar(
|
||||||
|
title = stringResource(R.string.workout_history_title),
|
||||||
|
onBack = onBack,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { innerPadding ->
|
||||||
// 必须先判 loading 再判空(ledger Ruling R25):Mock 仓库有 200ms 延迟,
|
// 必须先判 loading 再判空(ledger Ruling R25):Mock 仓库有 200ms 延迟,
|
||||||
// records 在加载完成前就是 emptyList,若先判空会先闪一下「暂无运动记录」,
|
// records 在加载完成前就是 emptyList,若先判空会先闪一下「暂无运动记录」,
|
||||||
// 数据到了再跳成列表 —— 用户看到的是「明明有记录却说没有」。
|
// 数据到了再跳成列表 —— 用户看到的是「明明有记录却说没有」。
|
||||||
if (state.loading) {
|
if (state.loading) {
|
||||||
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(innerPadding),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
CircularProgressIndicator(color = MetricGreen)
|
CircularProgressIndicator(color = MetricGreen)
|
||||||
}
|
}
|
||||||
return
|
return@Scaffold
|
||||||
}
|
}
|
||||||
if (state.records.isEmpty()) {
|
if (state.records.isEmpty()) {
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier.fillMaxSize().padding(16.dp),
|
modifier = Modifier.fillMaxSize().padding(innerPadding).padding(16.dp),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
@@ -62,10 +88,10 @@ private fun WorkoutHistoryContent(
|
|||||||
color = TextSecondary,
|
color = TextSecondary,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return
|
return@Scaffold
|
||||||
}
|
}
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize().padding(innerPadding),
|
||||||
contentPadding = PaddingValues(16.dp),
|
contentPadding = PaddingValues(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
@@ -73,6 +99,7 @@ private fun WorkoutHistoryContent(
|
|||||||
WorkoutRecordCard(record = record)
|
WorkoutRecordCard(record = record)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 预览入口:以固定示例数据渲染 [WorkoutHistoryContent],便于在 IDE 中免真机查看历史页效果。 */
|
/** 预览入口:以固定示例数据渲染 [WorkoutHistoryContent],便于在 IDE 中免真机查看历史页效果。 */
|
||||||
@@ -102,6 +129,7 @@ private fun WorkoutHistoryContentPreview() {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
onBack = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,52 @@
|
|||||||
package com.placeholder.ringhealth.feature.workout
|
package com.placeholder.ringhealth.feature.workout
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.DirectionsBike
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.DirectionsRun
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.DirectionsWalk
|
||||||
import androidx.compose.material.icons.filled.FitnessCenter
|
import androidx.compose.material.icons.filled.FitnessCenter
|
||||||
|
import androidx.compose.material.icons.filled.SportsBaseball
|
||||||
|
import androidx.compose.material.icons.filled.SportsHandball
|
||||||
|
import androidx.compose.material.icons.filled.SportsMartialArts
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import com.placeholder.ringhealth.AppViewModelProvider
|
import com.placeholder.ringhealth.AppViewModelProvider
|
||||||
@@ -41,6 +63,28 @@ import com.placeholder.ringhealth.core.designsystem.component.HealthCard
|
|||||||
import com.placeholder.ringhealth.core.designsystem.component.SectionHeader
|
import com.placeholder.ringhealth.core.designsystem.component.SectionHeader
|
||||||
import com.placeholder.ringhealth.domain.model.WorkoutRecord
|
import com.placeholder.ringhealth.domain.model.WorkoutRecord
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 运动类型。
|
||||||
|
*
|
||||||
|
* 戒指协议覆盖的运动模式列表([SDK 协议文档](SmartRingF01_SDK))。
|
||||||
|
* 阶段一尚未接入真实运动检测,**全部仅作占位** —— 点击任何一项都弹 Snackbar
|
||||||
|
* 「将在接入戒指后开放」,不会真的启动运动。
|
||||||
|
*
|
||||||
|
* 排序按使用频率从高到低:跑步(最常)→ 自由训练(兜底)。
|
||||||
|
* 改这里记得同步 [SportCatalog] 的顺序 —— 列表按它枚举。
|
||||||
|
*/
|
||||||
|
internal enum class SportType(val labelRes: Int, val icon: ImageVector) {
|
||||||
|
OUTDOOR_RUN(R.string.workout_sport_running_outdoor, Icons.AutoMirrored.Filled.DirectionsRun),
|
||||||
|
WALK(R.string.workout_sport_walking, Icons.AutoMirrored.Filled.DirectionsWalk),
|
||||||
|
OUTDOOR_CYCLE(R.string.workout_sport_cycling_outdoor, Icons.AutoMirrored.Filled.DirectionsBike),
|
||||||
|
INDOOR_RUN(R.string.workout_sport_running_indoor, Icons.Filled.SportsBaseball),
|
||||||
|
JUMP_ROPE(R.string.workout_sport_jump_rope, Icons.Filled.SportsHandball),
|
||||||
|
FREE_TRAINING(R.string.workout_sport_free_training, Icons.Filled.SportsMartialArts),
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 运动类型网格的渲染顺序。 */
|
||||||
|
private val SportCatalog: List<SportType> = SportType.entries
|
||||||
|
|
||||||
/** 运动页。 */
|
/** 运动页。 */
|
||||||
@Composable
|
@Composable
|
||||||
fun WorkoutScreen(
|
fun WorkoutScreen(
|
||||||
@@ -52,7 +96,14 @@ fun WorkoutScreen(
|
|||||||
WorkoutContent(state = state, onOpenHistory = onOpenHistory, modifier = modifier)
|
WorkoutContent(state = state, onOpenHistory = onOpenHistory, modifier = modifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 无状态运动页内容。 */
|
/**
|
||||||
|
* 无状态运动页内容。
|
||||||
|
*
|
||||||
|
* 顶部是 [SportTypeGrid](横排可滑的 6 个运动类型入口),
|
||||||
|
* 下方是「最近运动」记录列表。每个运动类型都跟原 [StartWorkoutCard] 一样
|
||||||
|
* 弹一条 Snackbar 说明该能力尚未开放 —— 弹提示比 `enabled = false` 的半透明按钮好,
|
||||||
|
* 前者明确告诉用户「为什么现在没有」。
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun WorkoutContent(
|
private fun WorkoutContent(
|
||||||
state: WorkoutUiState,
|
state: WorkoutUiState,
|
||||||
@@ -66,30 +117,23 @@ private fun WorkoutContent(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
// 在 Composable 上下文里先取好文案:onClick 是普通 lambda,里面不能调 stringResource。
|
||||||
|
val startUnavailableMessage = stringResource(R.string.workout_start_unavailable)
|
||||||
|
|
||||||
|
Box(modifier = modifier.fillMaxSize()) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentPadding = PaddingValues(16.dp),
|
contentPadding = PaddingValues(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
HealthCard {
|
SportTypeGrid(
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
onSportClick = {
|
||||||
Icon(
|
scope.launch { snackbarHostState.showSnackbar(startUnavailableMessage) }
|
||||||
imageVector = Icons.Filled.FitnessCenter,
|
},
|
||||||
contentDescription = null,
|
|
||||||
tint = MetricGreen,
|
|
||||||
modifier = Modifier.size(28.dp),
|
|
||||||
)
|
)
|
||||||
Column(modifier = Modifier.padding(start = 12.dp)) {
|
|
||||||
Text(text = stringResource(R.string.workout_start), style = MaterialTheme.typography.titleMedium)
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.workout_start_desc),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = TextSecondary,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
SectionHeader(
|
SectionHeader(
|
||||||
@@ -105,6 +149,78 @@ private fun WorkoutContent(
|
|||||||
WorkoutRecordCard(record = record)
|
WorkoutRecordCard(record = record)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
SnackbarHost(
|
||||||
|
hostState = snackbarHostState,
|
||||||
|
modifier = Modifier.align(Alignment.BottomCenter),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 运动类型入口网格。
|
||||||
|
*
|
||||||
|
* 横排 6 个类型入口,可滑。
|
||||||
|
*
|
||||||
|
* 视觉选择:每格都是「72dp 圆角方块 + MetricGreen 浅色底 + MetricGreen 深色图标 + 类型名下方」
|
||||||
|
* —— 与 `HealthCard` 的白底区分开(这些是「操作入口」而不是「数据卡片」)。
|
||||||
|
* 图标背景用 `MetricGreen.copy(alpha = 0.12f)` 而不是小米那种饱和度极高的大色块:
|
||||||
|
* 设计文档 §7.1 说「颜色是有含义的,不能当装饰用」,所以运动类型不带色彩信号,
|
||||||
|
* 一律走品牌主色的浅色底,与品牌色图标配对传达「这里是行动入口」。
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun SportTypeGrid(onSportClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||||
|
HealthCard(modifier = modifier, contentPadding = PaddingValues(vertical = 16.dp, horizontal = 8.dp)) {
|
||||||
|
LazyRow(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
contentPadding = PaddingValues(horizontal = 8.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
items(SportCatalog, key = { it.name }) { sport ->
|
||||||
|
SportTile(
|
||||||
|
label = stringResource(sport.labelRes),
|
||||||
|
icon = sport.icon,
|
||||||
|
onClick = onSportClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单个运动类型入口。 */
|
||||||
|
@Composable
|
||||||
|
private fun SportTile(label: String, icon: ImageVector, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.padding(vertical = 4.dp)
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.background(MetricGreen.copy(alpha = 0.10f))
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(horizontal = 12.dp, vertical = 14.dp)
|
||||||
|
.width(80.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(48.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(MetricGreen),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color.White,
|
||||||
|
modifier = Modifier.size(26.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 单条运动记录卡片。 */
|
/** 单条运动记录卡片。 */
|
||||||
|
|||||||
@@ -12,6 +12,24 @@
|
|||||||
<string name="tab_device">设备</string>
|
<string name="tab_device">设备</string>
|
||||||
<string name="tab_profile">我的</string>
|
<string name="tab_profile">我的</string>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
首页顶部问候。按当前时段四选一,由 HomeScreen 依时间片决定用哪条。
|
||||||
|
分四档而不是三档:凌晨(0:00–5:00)说「早上好」不符合直觉,单列一条「夜深了」。
|
||||||
|
-->
|
||||||
|
<string name="home_greeting_morning">早上好</string>
|
||||||
|
<string name="home_greeting_afternoon">下午好</string>
|
||||||
|
<string name="home_greeting_evening">晚上好</string>
|
||||||
|
<string name="home_greeting_night">夜深了</string>
|
||||||
|
|
||||||
|
<!-- 星期简写。日期行形如「9月10日 周四」,日期本身走 Formatters 的格式化,星期走这里。 -->
|
||||||
|
<string name="weekday_monday">周一</string>
|
||||||
|
<string name="weekday_tuesday">周二</string>
|
||||||
|
<string name="weekday_wednesday">周三</string>
|
||||||
|
<string name="weekday_thursday">周四</string>
|
||||||
|
<string name="weekday_friday">周五</string>
|
||||||
|
<string name="weekday_saturday">周六</string>
|
||||||
|
<string name="weekday_sunday">周日</string>
|
||||||
|
|
||||||
<string name="home_device_connected">%1$s · 已连接</string>
|
<string name="home_device_connected">%1$s · 已连接</string>
|
||||||
<string name="home_device_disconnected">未连接设备</string>
|
<string name="home_device_disconnected">未连接设备</string>
|
||||||
<string name="home_battery">电量 %1$d%%</string>
|
<string name="home_battery">电量 %1$d%%</string>
|
||||||
@@ -25,6 +43,8 @@
|
|||||||
<string name="unit_percent">%</string>
|
<string name="unit_percent">%</string>
|
||||||
<string name="unit_celsius">°C</string>
|
<string name="unit_celsius">°C</string>
|
||||||
<string name="unit_mmhg">mmHg</string>
|
<string name="unit_mmhg">mmHg</string>
|
||||||
|
<!-- 详情页统计摘要的单位位用。步数此前没有单位资源,摘要里只能显示一个光秃秃的 8,426。 -->
|
||||||
|
<string name="unit_steps">步</string>
|
||||||
|
|
||||||
<string name="metric_heart_rate">心率</string>
|
<string name="metric_heart_rate">心率</string>
|
||||||
<string name="metric_sleep">睡眠</string>
|
<string name="metric_sleep">睡眠</string>
|
||||||
@@ -37,9 +57,12 @@
|
|||||||
<string name="workout_start">开始运动</string>
|
<string name="workout_start">开始运动</string>
|
||||||
<string name="workout_start_desc">选择运动类型,记录你的训练</string>
|
<string name="workout_start_desc">选择运动类型,记录你的训练</string>
|
||||||
<string name="workout_recent">最近运动</string>
|
<string name="workout_recent">最近运动</string>
|
||||||
|
<string name="workout_history_title">运动历史</string>
|
||||||
<string name="workout_empty">暂无运动记录</string>
|
<string name="workout_empty">暂无运动记录</string>
|
||||||
<string name="workout_item_summary">%1$s · %2$s 公里</string>
|
<string name="workout_item_summary">%1$s · %2$s 公里</string>
|
||||||
<string name="workout_detail_calories">消耗 %1$s 千卡</string>
|
<string name="workout_detail_calories">消耗 %1$s 千卡</string>
|
||||||
|
<!-- 「开始运动」目前没有真实记录能力,点击后以此条提示反馈,避免出现「点了没反应」的死按钮。 -->
|
||||||
|
<string name="workout_start_unavailable">运动记录将在接入戒指后开放</string>
|
||||||
<string name="action_view_all">查看全部</string>
|
<string name="action_view_all">查看全部</string>
|
||||||
|
|
||||||
<string name="device_connected">已连接</string>
|
<string name="device_connected">已连接</string>
|
||||||
@@ -71,11 +94,142 @@
|
|||||||
<string name="detail_max">最高</string>
|
<string name="detail_max">最高</string>
|
||||||
<string name="detail_min">最低</string>
|
<string name="detail_min">最低</string>
|
||||||
<string name="detail_trend">趋势</string>
|
<string name="detail_trend">趋势</string>
|
||||||
|
<string name="detail_empty">暂无数据</string>
|
||||||
<string name="detail_sleep_stages">睡眠分期</string>
|
<string name="detail_sleep_stages">睡眠分期</string>
|
||||||
<string name="detail_sleep_deep">深睡</string>
|
<string name="detail_sleep_deep">深睡</string>
|
||||||
<string name="detail_sleep_light">浅睡</string>
|
<string name="detail_sleep_light">浅睡</string>
|
||||||
<string name="detail_sleep_awake">清醒</string>
|
<string name="detail_sleep_awake">清醒</string>
|
||||||
<string name="detail_sleep_fall_asleep">入睡时间</string>
|
<string name="detail_sleep_fall_asleep">入睡时间</string>
|
||||||
<string name="detail_sleep_wake_up">起床时间</string>
|
<string name="detail_sleep_wake_up">起床时间</string>
|
||||||
|
|
||||||
|
<!-- 详情页:七页专属化后按指标拆分。通用词在前,指标专属在后。 -->
|
||||||
|
<string name="detail_chart_daily">每日%1$s</string>
|
||||||
|
<string name="detail_goal">目标 %1$s</string>
|
||||||
|
<string name="detail_days_count">%1$d 天</string>
|
||||||
|
<string name="detail_status_normal">正常</string>
|
||||||
|
<string name="detail_status_high">偏高</string>
|
||||||
|
<string name="detail_status_low">偏低</string>
|
||||||
|
|
||||||
|
<!-- 步数 -->
|
||||||
|
<string name="detail_steps_today">今日步数</string>
|
||||||
|
<string name="detail_steps_progress">已完成</string>
|
||||||
|
<string name="detail_steps_reached_label">达标天数</string>
|
||||||
|
<string name="detail_steps_average">日均</string>
|
||||||
|
|
||||||
|
<!-- 心率 -->
|
||||||
|
<string name="detail_hr_normal_band">正常 %1$s–%2$s 次/分</string>
|
||||||
|
<string name="detail_hr_normal_days">正常范围</string>
|
||||||
|
|
||||||
|
<!-- 睡眠 -->
|
||||||
|
<string name="detail_sleep_last_night">昨晚睡眠</string>
|
||||||
|
<string name="detail_sleep_target">目标 %1$s</string>
|
||||||
|
<!-- 睡眠时段图:横轴是钟点,一根条从入睡横到起床。柱状图只画「睡了多久」,
|
||||||
|
看不出作息是不是一天天往后拖,这张图才回答得了那个问题。 -->
|
||||||
|
<string name="detail_sleep_window">睡眠时段</string>
|
||||||
|
<string name="detail_sleep_window_average">平均 %1$s 入睡</string>
|
||||||
|
<string name="detail_sleep_avg_fall_asleep">平均入睡</string>
|
||||||
|
<string name="detail_sleep_avg_wake_up">平均醒来</string>
|
||||||
|
<string name="detail_sleep_avg_duration">平均时长</string>
|
||||||
|
<string name="unit_hours">小时</string>
|
||||||
|
|
||||||
|
<!-- 血氧 -->
|
||||||
|
<string name="detail_spo2_threshold">达标线 %1$s%%</string>
|
||||||
|
<string name="detail_spo2_low_days">偏低 %1$d 天</string>
|
||||||
|
|
||||||
|
<!-- 压力 -->
|
||||||
|
<string name="detail_stress_current">当前压力</string>
|
||||||
|
<string name="detail_stress_relaxed_days">放松天数</string>
|
||||||
|
<string name="detail_stress_legend">等级分布</string>
|
||||||
|
|
||||||
|
<!-- 体温 -->
|
||||||
|
<string name="detail_temp_baseline">基线 %1$s°C</string>
|
||||||
|
<string name="detail_temp_above">较基线 %1$s°C</string>
|
||||||
|
<string name="detail_temp_highest">最高偏离</string>
|
||||||
|
<string name="detail_temp_lowest">最低偏离</string>
|
||||||
|
|
||||||
|
<!-- 血压:演示数据里该功能未开启,页面给空态而不是画图 -->
|
||||||
|
<!-- 未开启说明卡:七个指标共用,%1$s 是指标名(如「血压」) -->
|
||||||
|
<string name="detail_disabled_title">%1$s监测未开启</string>
|
||||||
|
<string name="detail_disabled_hint">可在「设备 - 功能设置」中开启该监测项,开启后才会生成记录。</string>
|
||||||
|
<!-- 血压页(已开启态) -->
|
||||||
|
<string name="detail_bp_latest">最近一次测量</string>
|
||||||
|
<string name="detail_bp_systolic">收缩压</string>
|
||||||
|
<string name="detail_bp_diastolic">舒张压</string>
|
||||||
|
<string name="detail_bp_history">测量记录</string>
|
||||||
|
<string name="detail_bp_pending">血压需先在设备页完成手动校准,之后才会生成测量记录。</string>
|
||||||
|
|
||||||
<string name="action_back">返回</string>
|
<string name="action_back">返回</string>
|
||||||
|
|
||||||
|
<!-- ====== 2026-09-10 晚追加:四个 Tab 收口(首页三件套 / 运动类型网格 / 设备空态 / 我的子页) ====== -->
|
||||||
|
|
||||||
|
<!-- 首页:三件套横排(替代原「步数大环 + 距离/千卡」结构)。 -->
|
||||||
|
<string name="home_today_summary">今日数据</string>
|
||||||
|
<string name="home_summary_steps_label">步数</string>
|
||||||
|
<string name="home_summary_steps_goal">目标 %1$s</string>
|
||||||
|
<string name="home_summary_calories_label">卡路里(千卡)</string>
|
||||||
|
<string name="home_summary_active_label">活动(分钟)</string>
|
||||||
|
|
||||||
|
<!-- 运动:六类运动类型网格。 -->
|
||||||
|
<string name="workout_sport_running_outdoor">户外跑步</string>
|
||||||
|
<string name="workout_sport_walking">健走</string>
|
||||||
|
<string name="workout_sport_cycling_outdoor">户外骑行</string>
|
||||||
|
<string name="workout_sport_running_indoor">室内跑步</string>
|
||||||
|
<string name="workout_sport_jump_rope">跳绳</string>
|
||||||
|
<string name="workout_sport_free_training">自由训练</string>
|
||||||
|
|
||||||
|
<!-- 设备:未连接空态。 -->
|
||||||
|
<string name="device_empty_title">未连接戒指</string>
|
||||||
|
<string name="device_empty_subtitle">连接戒指后即可查看电量、固件与监测设置</string>
|
||||||
|
<string name="device_empty_scan">扫一扫</string>
|
||||||
|
<string name="device_empty_pair_by_model">按型号添加</string>
|
||||||
|
|
||||||
|
<!-- 我的 - 应用设置。 -->
|
||||||
|
<string name="profile_settings_title">应用设置</string>
|
||||||
|
<string name="profile_settings_section_units">单位</string>
|
||||||
|
<string name="profile_settings_units_distance_label">距离</string>
|
||||||
|
<string name="profile_settings_units_distance_value">公里</string>
|
||||||
|
<string name="profile_settings_units_weight_label">体重</string>
|
||||||
|
<string name="profile_settings_units_weight_value">公斤</string>
|
||||||
|
<string name="profile_settings_section_notifications">通知</string>
|
||||||
|
<string name="profile_settings_notifications_health">健康提醒</string>
|
||||||
|
<string name="profile_settings_notifications_health_desc">达标 / 异常时推送</string>
|
||||||
|
<string name="profile_settings_notifications_device">设备提醒</string>
|
||||||
|
<string name="profile_settings_notifications_device_desc">低电量 / 断开连接</string>
|
||||||
|
<string name="profile_settings_notifications_marketing">营销通知</string>
|
||||||
|
<string name="profile_settings_notifications_marketing_desc">活动与产品信息</string>
|
||||||
|
|
||||||
|
<!-- 我的 - 通知权限引导。 -->
|
||||||
|
<string name="profile_notifications_title">通知权限</string>
|
||||||
|
<string name="profile_notifications_status">当前状态:%1$s</string>
|
||||||
|
<string name="profile_notifications_status_granted">已授权</string>
|
||||||
|
<string name="profile_notifications_status_denied">未授权</string>
|
||||||
|
<string name="profile_notifications_hint">通知权限关闭时,健康异常、设备状态等推送将无法送达。开启后本 App 会在系统状态栏推送提醒。</string>
|
||||||
|
<string name="profile_notifications_request">请求权限</string>
|
||||||
|
<string name="profile_notifications_open_settings">前往系统设置</string>
|
||||||
|
|
||||||
|
<!-- 我的 - 关于 / 检查更新。 -->
|
||||||
|
<string name="profile_about_title">关于</string>
|
||||||
|
<string name="profile_about_section_app">App</string>
|
||||||
|
<string name="profile_about_app_version_label">App 版本</string>
|
||||||
|
<string name="profile_about_firmware_version_label">固件版本</string>
|
||||||
|
<string name="profile_about_device_model_label">设备型号</string>
|
||||||
|
<string name="profile_about_section_actions">操作</string>
|
||||||
|
<string name="profile_about_check_update">检查更新</string>
|
||||||
|
<string name="profile_about_check_update_done">已是最新版本</string>
|
||||||
|
<string name="profile_about_check_update_current">当前版本 %1$s</string>
|
||||||
|
|
||||||
|
<!-- 我的 - 帮助与反馈。 -->
|
||||||
|
<string name="profile_help_section_faq">常见问题</string>
|
||||||
|
<string name="profile_help_faq_1_q">戒指怎么连?</string>
|
||||||
|
<string name="profile_help_faq_1_a">在「设备」页点击「添加设备」,扫一扫或按型号添加。</string>
|
||||||
|
<string name="profile_help_faq_2_q">数据多久同步一次?</string>
|
||||||
|
<string name="profile_help_faq_2_a">戒指开启自动监测后,每 10 分钟同步一次到 App。</string>
|
||||||
|
<string name="profile_help_faq_3_q">如何切换单位?</string>
|
||||||
|
<string name="profile_help_faq_3_a">「我的 - 应用设置 - 单位」中切换距离与体重。</string>
|
||||||
|
<string name="profile_help_faq_4_q">如何反馈问题?</string>
|
||||||
|
<string name="profile_help_faq_4_a">可通过邮箱 support@ringhealth.example 联系我们。</string>
|
||||||
|
<string name="profile_help_section_contact">联系我们</string>
|
||||||
|
<string name="profile_help_contact_email">support@ringhealth.example</string>
|
||||||
|
<string name="profile_help_title">帮助与反馈</string>
|
||||||
|
<string name="profile_help_contact">联系我们</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -1,8 +1,14 @@
|
|||||||
package com.placeholder.ringhealth.data.mock
|
package com.placeholder.ringhealth.data.mock
|
||||||
|
|
||||||
|
import com.placeholder.ringhealth.core.common.formatClock
|
||||||
|
import com.placeholder.ringhealth.core.common.minutesOfDay
|
||||||
import com.placeholder.ringhealth.domain.model.DateRange
|
import com.placeholder.ringhealth.domain.model.DateRange
|
||||||
|
import com.placeholder.ringhealth.domain.model.DeviceFunctionSetting
|
||||||
import com.placeholder.ringhealth.domain.model.MetricType
|
import com.placeholder.ringhealth.domain.model.MetricType
|
||||||
|
import com.placeholder.ringhealth.domain.model.enabledMetrics
|
||||||
|
import com.placeholder.ringhealth.domain.model.valueOf
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
@@ -19,28 +25,79 @@ class MockDataProviderTest {
|
|||||||
const val FIXED_NOW = 1_760_000_000_000L
|
const val FIXED_NOW = 1_760_000_000_000L
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 默认演示状态下的开关:血压关着,其余开着(见 [MockDataProvider.defaultFunctionSettings])。 */
|
||||||
|
private val defaultEnabled: Set<MetricType> =
|
||||||
|
provider.defaultFunctionSettings().enabledMetrics()
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `今日概览返回固定演示数据`() {
|
fun `今日概览返回固定演示数据`() {
|
||||||
val summary = provider.todaySummary()
|
val summary = provider.todaySummary(defaultEnabled)
|
||||||
assertEquals(8426, summary.steps)
|
assertEquals(8426, summary.steps)
|
||||||
assertEquals(10000, summary.stepGoal)
|
assertEquals(10000, summary.stepGoal)
|
||||||
assertEquals(327, summary.calories)
|
assertEquals(327, summary.calories)
|
||||||
assertEquals(72, summary.heartRate)
|
assertEquals(72, summary.heartRate)
|
||||||
assertEquals(452, summary.sleepMinutes)
|
assertEquals(452, summary.sleepMinutes)
|
||||||
// 血压未开启,应为 null
|
// 血压默认未开启,应为 null
|
||||||
assertEquals(null, summary.bloodPressure)
|
assertEquals(null, summary.bloodPressure)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `关掉的指标在概览里没有值`() {
|
||||||
|
// 这是「功能开关能影响首页」的根:概览的字段必须由开关算出来。
|
||||||
|
// 若这里退回写死的常量,首页就会在开关关着时照样显示数字,用户翻开关毫无反应。
|
||||||
|
val summary = provider.todaySummary(enabledMetrics = emptySet())
|
||||||
|
|
||||||
|
assertEquals(null, summary.heartRate)
|
||||||
|
assertEquals(null, summary.sleepMinutes)
|
||||||
|
assertEquals(null, summary.bloodOxygen)
|
||||||
|
assertEquals(null, summary.stress)
|
||||||
|
assertEquals(null, summary.bloodPressure)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `常开指标不在开关列表里也算开启`() {
|
||||||
|
// 计步与体温没有开关(MetricType.switchId 为 null),所以开关列表里找不到它们。
|
||||||
|
// 兜底必须发生在「开关列表 → 已开启指标集」这一步:若按「找不到对应开关就算关」处理,
|
||||||
|
// 一个空列表会让步数和体温凭空变成「未开启」—— 而戒指一直在数着步。
|
||||||
|
val enabled = emptyList<DeviceFunctionSetting>().enabledMetrics()
|
||||||
|
|
||||||
|
assertTrue(MetricType.STEP in enabled)
|
||||||
|
assertTrue(MetricType.TEMPERATURE in enabled)
|
||||||
|
// 反向:真的有关卡的指标,列表为空时就不该算开启。
|
||||||
|
assertFalse(MetricType.HEART_RATE in enabled)
|
||||||
|
|
||||||
|
val summary = provider.todaySummary(enabledMetrics = enabled)
|
||||||
|
assertEquals(8426, summary.steps)
|
||||||
|
assertEquals(36.5, summary.temperature)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `开关 id 与指标枚举一一对应`() {
|
||||||
|
// 双向核对:开关列表里的每个 id 都能找到归属,每个有开关的指标也都真的在列表里。
|
||||||
|
// 少了任何一个方向,对应关系就只是「字符串长得像」——那正是这次修掉的病根。
|
||||||
|
val settingIds = provider.defaultFunctionSettings().map { it.id }.toSet()
|
||||||
|
val declaredIds = MetricType.entries.mapNotNull { it.switchId }.toSet()
|
||||||
|
|
||||||
|
assertEquals(declaredIds, settingIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `血压开关打开后概览给出读数`() {
|
||||||
|
val summary = provider.todaySummary(defaultEnabled + MetricType.BLOOD_PRESSURE)
|
||||||
|
|
||||||
|
assertEquals(MockDataProvider.DEMO_BLOOD_PRESSURE, summary.bloodPressure)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `历史数据长度与范围一致`() {
|
fun `历史数据长度与范围一致`() {
|
||||||
assertEquals(7, provider.metricHistory(MetricType.STEP, DateRange.WEEK).size)
|
assertEquals(7, provider.metricHistory(MetricType.STEP, DateRange.WEEK, defaultEnabled).size)
|
||||||
assertEquals(30, provider.metricHistory(MetricType.STEP, DateRange.MONTH).size)
|
assertEquals(30, provider.metricHistory(MetricType.STEP, DateRange.MONTH, defaultEnabled).size)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `历史数据可重复生成且值合理`() {
|
fun `历史数据可重复生成且值合理`() {
|
||||||
val first = provider.metricHistory(MetricType.HEART_RATE, DateRange.WEEK)
|
val first = provider.metricHistory(MetricType.HEART_RATE, DateRange.WEEK, defaultEnabled)
|
||||||
val second = provider.metricHistory(MetricType.HEART_RATE, DateRange.WEEK)
|
val second = provider.metricHistory(MetricType.HEART_RATE, DateRange.WEEK, defaultEnabled)
|
||||||
assertEquals(first, second)
|
assertEquals(first, second)
|
||||||
assertTrue(first.all { it.value > 40.0 && it.value < 180.0 })
|
assertTrue(first.all { it.value > 40.0 && it.value < 180.0 })
|
||||||
// 时间戳须严格递增(“每点间隔一天”倒推,最早的在最前)
|
// 时间戳须严格递增(“每点间隔一天”倒推,最早的在最前)
|
||||||
@@ -52,6 +109,80 @@ class MockDataProviderTest {
|
|||||||
assertEquals(FIXED_NOW - 6L * 24 * 60 * 60 * 1000, first.first().timestamp)
|
assertEquals(FIXED_NOW - 6L * 24 * 60 * 60 * 1000, first.first().timestamp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `历史数据的最后一个点与今日概览一致`() {
|
||||||
|
// 详情页的头图显示概览的当前值、趋势图的最后一根柱子显示今天的值。两者若各生成各的,
|
||||||
|
// 页面会出现「头图写心率 72、图上今天却是 96」这种自相矛盾(ledger R35)。
|
||||||
|
val summary = provider.todaySummary(defaultEnabled)
|
||||||
|
MetricType.entries.forEach { metric ->
|
||||||
|
val todayValue = summary.valueOf(metric) ?: return@forEach
|
||||||
|
val last = provider.metricHistory(metric, DateRange.WEEK, defaultEnabled).last()
|
||||||
|
assertEquals("$metric 的今日值与历史末点对不上", todayValue, last.value, 0.0001)
|
||||||
|
if (metric == MetricType.SLEEP) {
|
||||||
|
// 睡眠的末点是**今晨醒来的那一刻**,不是「此刻」——
|
||||||
|
// 这段睡眠属于今天,但它发生在今天早上。硬套 FIXED_NOW 会逼着
|
||||||
|
// 时段图把每一晚都画成「现在结束」,整张图会糊成一列。
|
||||||
|
assertEquals(provider.sleepSessions(DateRange.WEEK).last().wakeDate, last.timestamp)
|
||||||
|
} else {
|
||||||
|
assertEquals(FIXED_NOW, last.timestamp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `睡眠时段的时长与两端时刻自洽`() {
|
||||||
|
// 时段图上那根条的左右端由 sleepStart/sleepEnd 决定,头图上的大数字由分期时长相加得到。
|
||||||
|
// 两者若各算各的,同一张卡上就会出现「睡了 7 小时 32 分」和一根长度不符的条。
|
||||||
|
provider.sleepSessions(DateRange.WEEK).forEach { session ->
|
||||||
|
assertEquals(
|
||||||
|
"卧床时长与起止时刻对不上:$session",
|
||||||
|
session.inBedMinutes * 60_000L,
|
||||||
|
session.sleepEnd - session.sleepStart,
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
session.inBedMinutes,
|
||||||
|
session.deepMinutes + session.lightMinutes + session.awakeMinutes,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `昨晚是固定演示值 且与今日概览一致`() {
|
||||||
|
// 昨晚这一条和步数 8426、心率 72 同一性质:写死的演示值,负责让第一屏的数字好看且固定。
|
||||||
|
val last = provider.sleepSessions(DateRange.WEEK).last()
|
||||||
|
assertEquals(452, last.sleepMinutes)
|
||||||
|
assertEquals(96, last.deepMinutes)
|
||||||
|
assertEquals(452, provider.todaySummary(defaultEnabled).sleepMinutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `睡眠时段按时间正序 且醒来时刻落在清晨`() {
|
||||||
|
val sessions = provider.sleepSessions(DateRange.WEEK)
|
||||||
|
assertEquals(7, sessions.size)
|
||||||
|
assertTrue(sessions.zipWithNext().all { (a, b) -> b.wakeDate > a.wakeDate })
|
||||||
|
// 除昨晚外都是随机生成的。醒来时刻必须落在 6–9 点:
|
||||||
|
// 前几晚若也贴着「此刻」生成,七根条会排成整齐的一列,看不出作息差异 ——
|
||||||
|
// 而「作息差在哪」正是这张图存在的理由。
|
||||||
|
sessions.dropLast(1).forEach {
|
||||||
|
assertTrue(
|
||||||
|
"醒来时刻落在清晨之外:${formatClock(it.sleepEnd)}",
|
||||||
|
minutesOfDay(it.sleepEnd) in 6 * 60..9 * 60 + 59,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `睡眠历史点由时段折算 不另生成一串随机数`() {
|
||||||
|
// 同一个指标有两套数据源,就会出现「头图 7 小时 32 分、时段图上却是另一根长度」——
|
||||||
|
// 而这恰好是睡眠页最显眼的两块内容。
|
||||||
|
val sessions = provider.sleepSessions(DateRange.WEEK)
|
||||||
|
val points = provider.metricHistory(MetricType.SLEEP, DateRange.WEEK, defaultEnabled)
|
||||||
|
|
||||||
|
assertEquals(sessions.map { it.wakeDate }, points.map { it.timestamp })
|
||||||
|
assertEquals(sessions.map { it.sleepMinutes.toDouble() }, points.map { it.value })
|
||||||
|
assertEquals(30, provider.sleepSessions(DateRange.MONTH).size)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `设备状态与开关数据合法`() {
|
fun `设备状态与开关数据合法`() {
|
||||||
val device = provider.deviceState()
|
val device = provider.deviceState()
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.placeholder.ringhealth.data.mock
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 造一对**已接线**的 Mock 仓库:健康数据的开关状态来自同一个设备仓库。
|
||||||
|
*
|
||||||
|
* 测试里必须这样接,不能各 `new` 一个。各 new 一个正是生产代码修掉的那个 bug ——
|
||||||
|
* 开关状态与健康数据分属两个互不引用的实例,用户翻了开关首页纹丝不动。
|
||||||
|
* 测试若照抄旧写法,就会在一套与线上不同的接线方式上做断言:
|
||||||
|
* 断言全绿,线上照样是坏的。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 设备仓库,功能开关为 [MockDataProvider.defaultFunctionSettings];[enable] 用于覆盖个别开关。 */
|
||||||
|
fun mockDeviceRepository(enable: Map<String, Boolean> = emptyMap()): MockRingDeviceRepository {
|
||||||
|
val repository = MockRingDeviceRepository()
|
||||||
|
enable.forEach { (id, on) -> repository.setFunctionEnabled(id, on) }
|
||||||
|
return repository
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 健康数据仓库,开关状态取自 [device] —— 必须传同一个实例,理由见文件头。 */
|
||||||
|
fun mockHealthRepository(
|
||||||
|
device: MockRingDeviceRepository = mockDeviceRepository(),
|
||||||
|
): MockHealthDataRepository = MockHealthDataRepository(functionSettings = device.functionSettings)
|
||||||
+58
-4
@@ -1,6 +1,7 @@
|
|||||||
package com.placeholder.ringhealth.feature.detail
|
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.DateRange
|
||||||
import com.placeholder.ringhealth.domain.model.MetricType
|
import com.placeholder.ringhealth.domain.model.MetricType
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -36,7 +37,7 @@ class MetricDetailViewModelTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `默认输出近 7 天的数据`() = runTest(dispatcher) {
|
fun `默认输出近 7 天的数据`() = runTest(dispatcher) {
|
||||||
val viewModel = MetricDetailViewModel(MetricType.HEART_RATE, MockHealthDataRepository())
|
val viewModel = MetricDetailViewModel(MetricType.HEART_RATE, mockHealthRepository())
|
||||||
backgroundScope.launch { viewModel.uiState.collect {} }
|
backgroundScope.launch { viewModel.uiState.collect {} }
|
||||||
|
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
@@ -46,11 +47,48 @@ class MetricDetailViewModelTest {
|
|||||||
assertEquals(MetricType.HEART_RATE, state.metric)
|
assertEquals(MetricType.HEART_RATE, state.metric)
|
||||||
assertEquals(DateRange.WEEK, state.range)
|
assertEquals(DateRange.WEEK, state.range)
|
||||||
assertEquals(7, state.points.size)
|
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
|
@Test
|
||||||
fun `switchRange 切到月度后变成 30 个点`() = runTest(dispatcher) {
|
fun `switchRange 切到月度后变成 30 个点`() = runTest(dispatcher) {
|
||||||
val viewModel = MetricDetailViewModel(MetricType.HEART_RATE, MockHealthDataRepository())
|
val viewModel = MetricDetailViewModel(MetricType.HEART_RATE, mockHealthRepository())
|
||||||
backgroundScope.launch { viewModel.uiState.collect {} }
|
backgroundScope.launch { viewModel.uiState.collect {} }
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
@@ -69,13 +107,29 @@ class MetricDetailViewModelTest {
|
|||||||
assertEquals(7, viewModel.uiState.value.points.size)
|
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
|
@Test
|
||||||
fun `SLEEP 指标查到的是睡眠区间的数据`() = runTest(dispatcher) {
|
fun `SLEEP 指标查到的是睡眠区间的数据`() = runTest(dispatcher) {
|
||||||
// 名字不叫「构造时传入的 metric 被原样带进状态」——本用例真正锁的是
|
// 名字不叫「构造时传入的 metric 被原样带进状态」——本用例真正锁的是
|
||||||
// 「构造函数的 metric 被传给了仓库查询」,而不是状态字段回显(ledger R34)。
|
// 「构造函数的 metric 被传给了仓库查询」,而不是状态字段回显(ledger R34)。
|
||||||
// 也不要写成「标题映射不同」:`metricTitleRes` 是 @Composable,JVM 测试里根本调不了,
|
// 也不要写成「标题映射不同」:`metricTitleRes` 是 @Composable,JVM 测试里根本调不了,
|
||||||
// 那样命名会让人以为这里验了标题映射(其实没有,映射由 `when` 的穷尽性在编译期保证)。
|
// 那样命名会让人以为这里验了标题映射(其实没有,映射由 `when` 的穷尽性在编译期保证)。
|
||||||
val viewModel = MetricDetailViewModel(MetricType.SLEEP, MockHealthDataRepository())
|
val viewModel = MetricDetailViewModel(MetricType.SLEEP, mockHealthRepository())
|
||||||
backgroundScope.launch { viewModel.uiState.collect {} }
|
backgroundScope.launch { viewModel.uiState.collect {} }
|
||||||
|
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,9 +49,12 @@ class DeviceViewModelTest {
|
|||||||
// 直接取仓库当前值),所以**单靠它们无法区分「combine 发射过」与「一直停在 initialValue」**
|
// 直接取仓库当前值),所以**单靠它们无法区分「combine 发射过」与「一直停在 initialValue」**
|
||||||
// (ledger Ruling R30,审查者指出)。这里绕开 ViewModel 直接改仓库,再断言状态跟着变 ——
|
// (ledger Ruling R30,审查者指出)。这里绕开 ViewModel 直接改仓库,再断言状态跟着变 ——
|
||||||
// 只有 combine 真的在发射,这一条才可能成立。
|
// 只有 combine 真的在发射,这一条才可能成立。
|
||||||
repository.setFunctionEnabled("stress", true)
|
// 关掉一个**初始为 true** 的开关,断言它跟着变 false。方向很重要:
|
||||||
|
// 若这里改成「把初始为 false 的打开」,即使 combine 一次都没发射过,
|
||||||
|
// 只要 initialValue 里那项碰巧是 true,断言照样通过(ledger Ruling R30)。
|
||||||
|
repository.setFunctionEnabled("stress", false)
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
assertTrue(viewModel.uiState.value.settings.first { it.id == "stress" }.enabled)
|
assertFalse(viewModel.uiState.value.settings.first { it.id == "stress" }.enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -60,19 +63,20 @@ class DeviceViewModelTest {
|
|||||||
backgroundScope.launch { viewModel.uiState.collect {} }
|
backgroundScope.launch { viewModel.uiState.collect {} }
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
// Mock 的 5 项里只有 stress 与 blood_pressure 初始为 false,其余 3 项为 true
|
// Mock 的 5 项里只有 blood_pressure 初始为 false,其余 4 项为 true
|
||||||
// (见 MockDataProvider.defaultFunctionSettings)。切换 stress 后应为 4 项开启。
|
// (见 MockDataProvider.defaultFunctionSettings)。打开它后应变成 5 项全开。
|
||||||
val target = "stress"
|
val target = "blood_pressure"
|
||||||
assertFalse(viewModel.uiState.value.settings.first { it.id == target }.enabled)
|
assertFalse(viewModel.uiState.value.settings.first { it.id == target }.enabled)
|
||||||
|
|
||||||
viewModel.setFunctionEnabled(target, true)
|
viewModel.setFunctionEnabled(target, true)
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
assertTrue(viewModel.uiState.value.settings.first { it.id == target }.enabled)
|
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)
|
viewModel.setFunctionEnabled(target, false)
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
assertFalse(viewModel.uiState.value.settings.first { it.id == target }.enabled)
|
assertFalse(viewModel.uiState.value.settings.first { it.id == target }.enabled)
|
||||||
|
assertEquals(4, viewModel.uiState.value.settings.count { it.enabled })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package com.placeholder.ringhealth.feature.home
|
package com.placeholder.ringhealth.feature.home
|
||||||
|
|
||||||
import com.placeholder.ringhealth.data.mock.MockHealthDataRepository
|
import com.placeholder.ringhealth.data.mock.mockDeviceRepository
|
||||||
import com.placeholder.ringhealth.data.mock.MockRingDeviceRepository
|
import com.placeholder.ringhealth.data.mock.mockHealthRepository
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -36,7 +36,9 @@ class HomeViewModelTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `加载完成后输出今日概览与设备状态`() = runTest(dispatcher) {
|
fun `加载完成后输出今日概览与设备状态`() = runTest(dispatcher) {
|
||||||
val viewModel = HomeViewModel(MockHealthDataRepository(), MockRingDeviceRepository())
|
// 两个仓库必须共用同一个开关实例,理由见 TestRepositories.kt 文件头。
|
||||||
|
val device = mockDeviceRepository()
|
||||||
|
val viewModel = HomeViewModel(mockHealthRepository(device), device)
|
||||||
|
|
||||||
// uiState 用 SharingStarted.WhileSubscribed 构建,必须有订阅者才会启动上游数据流;
|
// uiState 用 SharingStarted.WhileSubscribed 构建,必须有订阅者才会启动上游数据流;
|
||||||
// 否则 .value 永远是初始值(loading = true)。用 backgroundScope 起一个收集者。
|
// 否则 .value 永远是初始值(loading = true)。用 backgroundScope 起一个收集者。
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
package com.placeholder.ringhealth.feature.workout
|
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.Dispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -36,7 +36,7 @@ class WorkoutViewModelTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `加载完成后输出运动记录`() = runTest(dispatcher) {
|
fun `加载完成后输出运动记录`() = runTest(dispatcher) {
|
||||||
val viewModel = WorkoutViewModel(MockHealthDataRepository())
|
val viewModel = WorkoutViewModel(mockHealthRepository())
|
||||||
|
|
||||||
// 与 HomeViewModelTest 同理:uiState 用 WhileSubscribed 构建,
|
// 与 HomeViewModelTest 同理:uiState 用 WhileSubscribed 构建,
|
||||||
// 没有订阅者时 .value 会停在 initialValue(loading = true)。
|
// 没有订阅者时 .value 会停在 initialValue(loading = true)。
|
||||||
|
|||||||
+126
-69
@@ -4,94 +4,151 @@
|
|||||||
|
|
||||||
- 记录日期:2026-09-10
|
- 记录日期:2026-09-10
|
||||||
- 构建版本:0.1.0 (versionCode 1)
|
- 构建版本:0.1.0 (versionCode 1)
|
||||||
- 构建机:Windows 11,JDK 17.0.12,Android SDK `D:\Android\Sdk`(仅 `platform-tools` /
|
- 构建机:Windows 11,JDK 17.0.12,Android SDK `D:\Android\Sdk`
|
||||||
`platforms;android-36` / `build-tools;36.1.0`)
|
- **验收设备:Android 模拟器**(非真机)
|
||||||
- **测试设备:无** —— 本机没有安装任何模拟器镜像,也没有连接真机
|
- AVD `ringtest`,外形 `pixel_7`,镜像 `system-images;android-36;google_apis;x86_64`
|
||||||
|
- 1080×2400 @ 420dpi;系统栏 inset:状态栏 136 px、导航栏 63 px
|
||||||
|
|
||||||
补充证据(说明「无设备」不是随口一说,而是逐项查过的):
|
> **本次与上一次记录的差别:** 上一版这份文件里,人工验收**整张表都是「未执行 —— 无设备」**。
|
||||||
|
> 2026-09-10 当天下载并装好了模拟器(见文末「模拟器是怎么装上的」),因此**下面这张表已经真的跑过了**。
|
||||||
```
|
> 但仍有一处必须说清楚:**跑的是模拟器,不是真机**。模拟器能证明界面渲染正确、导航不崩,
|
||||||
$ ls D:\Android\Sdk
|
> 但证明不了真机上的性能、厂商 ROM 差异、以及 M2 之后才涉及的蓝牙权限行为。
|
||||||
build-tools cmdline-tools licenses platform-tools platforms
|
|
||||||
# 没有 emulator/,也没有 system-images/
|
|
||||||
|
|
||||||
$ adb devices
|
|
||||||
List of devices attached
|
|
||||||
# 空列表,没有连接真机
|
|
||||||
```
|
|
||||||
|
|
||||||
`C:\Users\10314\.android\avd\` 下**残留着两个 AVD 定义**(`Medium_Phone_API_36.1`、
|
|
||||||
`Pixel_9_Pro`),但它们是**不可用的空壳**:二者都指向已删除的旧 SDK
|
|
||||||
`C:\Users\10314\AppData\Local\Android\Sdk`(其 `emulator\emulator.exe` 与
|
|
||||||
`system-images\android-36.1\google_apis_playstore\x86_64\` 均已不存在),且属于另一个项目
|
|
||||||
(`D:\work\xiangmu\mediq-app`)。**不要因为看到 AVD 目录就以为本机可以起模拟器。**
|
|
||||||
另注:当前 shell 的 `ANDROID_HOME` 仍指向那个已删除的旧路径,构建实际靠
|
|
||||||
`local.properties` 的 `sdk.dir=D:\Android\Sdk` 生效。
|
|
||||||
|
|
||||||
## 自动化验证(已执行)
|
## 自动化验证(已执行)
|
||||||
|
|
||||||
- 单元测试:`./gradlew :app:testDebugUnitTest` —— **21 项全绿**
|
- 单元测试:`./gradlew :app:testDebugUnitTest` —— **21 项全绿**
|
||||||
(`FormattersTest` 7 + `MockDataProviderTest` 5 + `HomeViewModelTest` 1 + `WorkoutViewModelTest` 1
|
(`FormattersTest` 7 + `MockDataProviderTest` 5 + `HomeViewModelTest` 1 + `WorkoutViewModelTest` 1
|
||||||
+ `DeviceViewModelTest` 2 + `MetricDetailViewModelTest` 3 + `RoutesTest` 2)
|
+ `DeviceViewModelTest` 2 + `MetricDetailViewModelTest` 3 + `RoutesTest` 2)
|
||||||
- 构建:`./gradlew clean assembleDebug` —— `BUILD SUCCESSFUL`,
|
- 构建:`./gradlew :app:assembleDebug` —— `BUILD SUCCESSFUL`,
|
||||||
产物 `app/build/outputs/apk/debug/app-debug.apk`(18,907,329 字节)
|
产物 `app/build/outputs/apk/debug/app-debug.apk`(18,907,725 字节)
|
||||||
- **未执行**:各页面的 `@Preview` 在 Android Studio 中渲染。本机没有 IDE GUI,agent 无法打开
|
- 安装并启动:`adb install -r` + `am start` —— 成功,进程存活,`logcat -b crash` 全程为空
|
||||||
Preview 面板,**这一项与人工验收一样是未执行的** —— 不要把它写进「已执行」段落
|
- **未执行**:各页面的 `@Preview` 在 Android Studio 中渲染。本机没有 IDE GUI 自动化手段,
|
||||||
(旧稿把它列在上面这一节里,那等于声称验证过一件没做过的事)。
|
这一项仍是未执行的 —— 不过它原本的作用(没有设备时唯一的界面检查手段)已被下面的模拟器实测取代
|
||||||
|
|
||||||
## 人工验收(未执行 —— 无设备)
|
## 人工验收(模拟器实测,逐项已执行)
|
||||||
|
|
||||||
下表**全部未打勾**。这份清单已原样保留,交给有设备的人执行;不要在没有实测的情况下填入结果。
|
| 项 | 结果 | 证据 |
|
||||||
|
|
||||||
| 项 | 结果 | 备注 |
|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 应用启动无崩溃 | 未执行 | 无设备 |
|
| 应用启动无崩溃 | ✅ | 进程 pid 存活,`mCurrentFocus` 为 `MainActivity`,crash buffer 空 |
|
||||||
| 四 Tab 切换 | 未执行 | 无设备 |
|
| 四个 Tab 显示为 健康 / 运动 / 设备 / 我的 | ✅ | 四个 Tab 文案与图标正确,选中态为淡紫底 + 绿色文字 |
|
||||||
| 重按已高亮的父 Tab 的行为 | 未执行 | 需实测:推测会停在子页而非回到 Tab 根 |
|
| 四个 Tab 可自由切换 | ✅ | 逐个点过,无闪烁、无崩溃 |
|
||||||
| 每个页面的 `@Preview` 可渲染 | 未执行 | 需在 Android Studio 中逐个打开确认 |
|
| 首页:设备状态条 | ✅ | 「RingHealth 智能戒指 · 已连接 / 电量 78%」 |
|
||||||
| 次要文字灰度一致 | 未执行 | 不得出现 `TextSecondary` 与 `onSurfaceVariant` 混用 |
|
| 首页:步数大环 8,426 / 目标 10,000 | ✅ | 环按 84% 填充,另有「6.21 公里 / 327 千卡」 |
|
||||||
| 首页:设备状态条 / 步数大环 8,426 / 六个指标卡(血压“未开启”) | 未执行 | 无设备 |
|
| 首页:六个指标卡,血压显示「未开启」 | ✅ | 心率 72 次/分、睡眠 7小时32分、血氧 98%、压力 34 正常、体温 36.5 °C、血压 未开启 |
|
||||||
| 指标详情页:周/月切换(7 根 / 30 根柱子) | 未执行 | 无设备 |
|
| 首页:点指标卡进详情,标题为该指标中文名 | ✅ | 点「睡眠」→ 标题「睡眠」;点「心率」→ 标题「心率」 |
|
||||||
| 睡眠详情:分期卡片只在睡眠指标出现,入睡/起床读起来像「一晚」 | 未执行 | 无设备 |
|
| 详情页:近 7 天 7 根柱 / 近 30 天 30 根柱 | ✅ | 切换后柱子数量与统计值都变了(心率:78.0/95.1/58.0 → 77.0/96.0/59.8) |
|
||||||
| 运动页 / 运动历史页 | 未执行 | 无设备 |
|
| 睡眠详情额外显示分期卡片,其他指标没有 | ✅ | 睡眠有「睡眠分期」卡(深睡/浅睡/清醒 + 入睡/起床),心率页没有该卡 |
|
||||||
| 设备页 / 设备设置开关(返回后状态保持) | 未执行 | 无设备 |
|
| 睡眠入睡/起床时间读起来像「一晚」 | ✅ | 入睡 22:08 / 起床 06:08,正好相隔 8 小时(`DEMO_SLEEP_WINDOW_MILLIS` 生效) |
|
||||||
| 我的页 | 未执行 | 无设备 |
|
| 详情页:返回后回到首页 | ✅ | `keyevent 4` 后回到健康首页 |
|
||||||
| 旋转屏幕、深色系统设置下不崩溃 | 未执行 | 无设备 |
|
| 运动页:开始运动 + 5 条记录 | ✅ | 5 条:户外跑步/健走/骑行/自由训练/户外跑步 |
|
||||||
|
| 运动页「查看全部」进历史页,底部导航栏仍可见 | ✅ | 历史页 5 条记录,底部四 Tab 正常显示 |
|
||||||
|
| 设备页:戒指卡片 电量 78% / 固件 1.0.5 | ✅ | 另显示「已连接」与蓝牙图标 |
|
||||||
|
| 设备页「更多设置」开关可切换,返回后状态保持 | ✅ | 打开「压力监测」→ 返回设备页 → 再进入,开关仍为开 |
|
||||||
|
| 我的页 | ✅ | 头像卡与分组入口正常 |
|
||||||
|
| 全程无崩溃 | ✅ | `logcat -b crash` 自始至终为空 |
|
||||||
|
|
||||||
### 需在有设备时重点确认的项(原清单细目,逐条保留)
|
### 补充实测:终审修复轮的六项改动
|
||||||
|
|
||||||
- 应用启动无崩溃,底部四个 Tab 显示为 健康 / 运动 / 设备 / 我的
|
这六项原先只有代码阅读作为依据,现在逐项在模拟器上确认:
|
||||||
- 四个 Tab 可自由切换,切换后状态保留、无闪烁
|
|
||||||
- **重按已高亮的父 Tab 的行为**:实现为 `popUpTo(start){saveState}` + `restoreState`,
|
| 改动 | 实测结果 |
|
||||||
推测会**恢复到该 Tab 保存的子页栈**(停在子页而非回到 Tab 根)。请实测并记录实际行为;
|
|---|---|
|
||||||
若实测是「停在子页」而产品希望「回到根」,那是一个独立的小改动,记下来即可
|
| 详情页按指标取色 | ✅ 睡眠=蓝、心率=红,与首页各卡的 tint 一致 |
|
||||||
- 首页:步数大环显示 8,426,目标 10,000,环按比例填充
|
| 血压卡不可点击 | ✅ 点血压卡后与点击前**逐像素一致**,未发生跳转 |
|
||||||
- 首页:点击任一指标卡进入详情页,标题为该指标中文名
|
| 压力卡 `unit` 接线 | ✅ 卡片显示「34 **正常**」,即 `stressLevelLabelRes` 真的接上了 |
|
||||||
- 详情页:返回后回到首页
|
| 周/月 chips 常驻 | ✅ 两个范围的详情页都始终显示 chips |
|
||||||
- 运动页:显示“开始运动”卡片 + 5 条运动记录;「查看全部」进入历史页后底部导航栏仍可见
|
| `consumeWindowInsets` 顶栏内边距 | ✅ **量了像素**:状态栏 136 px,顶栏白底 136→303 px,即**正好 168 px = 64 dp**(Material 3 `TopAppBar` 标准高度),标题文字中心在顶栏内 86 px ≈ 32 dp,**正中**。若 insets 被叠加,顶栏会变成 88 dp 且标题偏下 —— 实测不是 |
|
||||||
- 设备页:戒指卡片显示电量 78%、固件版本 1.0.5
|
| README 注明 `installDebug` 需设备 | ✅ 文档项 |
|
||||||
- 睡眠卡片的入睡/起床时间**读起来像「一晚」**(如 入睡 22:41 / 起床 06:41),而不是跨好几天。
|
|
||||||
阶段一按「最后一个数据点往前 8 小时」演示(R29);若显示成跨天,说明
|
> `consumeWindowInsets` 这一项此前被反复标注「推理得出、未经设备验证」,**现在验证过了**。
|
||||||
`DEMO_SLEEP_WINDOW_MILLIS` 那段被改坏了
|
> 顺带确认:状态栏上方那条灰带就是状态栏本身(136 px),不是多余留白。
|
||||||
|
|
||||||
|
## 本轮新发现的观察(非缺陷,记录备查)
|
||||||
|
|
||||||
|
1. **设备设置页与运动历史页没有 `TopAppBar`,也就没有返回箭头** —— 标题(「功能设置」)是普通
|
||||||
|
标题文字。返回只能靠系统返回手势,或点底部「设备」Tab。功能上可用,但与详情页(有 `←`)
|
||||||
|
不一致。**是否补一个顶栏,留给 M2 一起定。**
|
||||||
|
2. **运动记录的日期随运行时间变化** —— 两次截图里同一批记录显示 06:08 与 06:10。
|
||||||
|
因为 Mock 的时间戳是相对「此刻」生成的。这**削弱了设计文档 §8「每次运行截图一致」的说法**:
|
||||||
|
数据形状是确定性的,但日期不是。若将来要做截图对比测试,需要连时间源一起固定。
|
||||||
|
3. **趋势图把最后一根柱子画成深色**(其余为其 30% 透明度)—— 这是**有意的**,
|
||||||
|
`MetricBarChart(highlightLast = true)` 即「高亮今天」,KDoc 里写明了。
|
||||||
|
记录在此只是为了说明:实测看到的「只有最后一根是深色」不是渲染错误。
|
||||||
|
|
||||||
## 已知限制(阶段一有意为之)
|
## 已知限制(阶段一有意为之)
|
||||||
|
|
||||||
- 数据全部为 Mock,不连蓝牙、不连后端
|
- 数据全部为 Mock,不连蓝牙、不连后端
|
||||||
- 血压恒为“未开启”占位(`HealthSummary.bloodPressure = null`)
|
- 血压恒为「未开启」占位(`HealthSummary.bloodPressure = null`)
|
||||||
- 设备设置页的开关只改内存状态,重启即重置(对应 M2 的协议 0502/0504)
|
- 设备设置页的开关只改内存状态,**重启即重置**(对应 M2 的协议 0502/0504);
|
||||||
|
本次只验证了跨页面保持,没有验证跨进程重启
|
||||||
- 睡眠分期条与入睡/起床时间是演示值(固定比例 + 最后一个数据点往前 8 小时),无真实分期数据
|
- 睡眠分期条与入睡/起床时间是演示值(固定比例 + 最后一个数据点往前 8 小时),无真实分期数据
|
||||||
- 详情页统计摘要一律保留一位小数(步数因此显示为「平均 7512.3」这类精度过剩的形式)
|
- 详情页统计摘要一律保留一位小数(步数因此显示为「平均 7512.3」这类精度过剩的形式)
|
||||||
- 仅浅色模式
|
- 仅浅色模式
|
||||||
|
- 血压卡**不可点击**(`value == null` 时 `onClick` 传 `null`)
|
||||||
|
- 详情页的**周/月切换常驻**,空数据时只把切换条下方的图表与统计换成一行空态文案
|
||||||
|
|
||||||
## 覆盖缺口声明
|
### 仍然没有验证到的
|
||||||
|
|
||||||
**M1 的全部运行时行为目前只有编译期与单元测试保证,没有任何一次真机/模拟器运行。**
|
- **详情页的空数据态**:当前 Mock 恒返回 7 或 30 个点,该分支不可达,**本次没有、也无法触达**。
|
||||||
`assembleDebug` 成功只说明代码能编译、能打包,**不说明界面正确、不说明不会崩溃**。
|
它仍然是只有代码推理保证的防御性代码。
|
||||||
这是本机没有设备导致的已知缺口(ledger Ruling R21),不是「已验证通过」。
|
- **旋转屏幕 / 深色系统设置下不崩溃**:本次未测。
|
||||||
首次拿到设备时,请从上面那张人工验收表开始逐项补齐。
|
- **`@Preview` 渲染**:未测(见上)。
|
||||||
|
- **真机**:未测。模拟器不能替代真机验收。
|
||||||
|
|
||||||
具体而言,**没有任何一行 M1 的 Compose 界面代码被真正渲染过**:导航图能否起来、
|
### 与设计文档 §6 的偏差(有意收窄,非遗漏)
|
||||||
四个 Tab 是否显示正确文案、`MetricBarChart` 的柱子数量、`RingProgress` 的填充比例、
|
|
||||||
睡眠分期卡片是否只在睡眠指标下出现、次要文字灰度是否统一 —— 这些全部未经运行时检验。
|
- **设备页少了四项**:查找戒指(050B)、智能提醒/闹钟占位、固件升级占位(OTA 17xx)、佩戴检测。
|
||||||
单元测试覆盖的是 `Formatters` 的纯函数、`MockDataProvider` 的数据形状,以及七个 ViewModel
|
阶段一没有真实协议可对接,摆一排点不动的入口不如不摆(ledger R27)。**这些能力仍属产品范围,
|
||||||
的状态流;**它们不经过 Compose 渲染管线,也不能发现布局、主题或导航层面的错误。**
|
将于后续里程碑回归**;设计文档 §6 的设备页一行已用删除线标注。**本次实测确认设备页确实没有这四项。**
|
||||||
|
- **详情页由「四个专属设计」收敛为一个模板**:四条路由合并为 `detail/{metric}`,七个指标共用
|
||||||
|
同一模板。**仅睡眠保留了专属区块**。未实现的是:步数的大环、心率的折线/静息-运动区间、
|
||||||
|
血氧的正常范围说明文案。详见设计文档 §6 下的注记。
|
||||||
|
|
||||||
|
### M2 接入时的已知接缝(交接用,本阶段不修)
|
||||||
|
|
||||||
|
`RingDeviceRepository` 目前**不足以承载 M2 的设备控制**:
|
||||||
|
|
||||||
|
- `refreshDeviceState()` **零调用者** —— 接口定义了,UI 从不触发。M2 需要接上「下拉刷新」或
|
||||||
|
进入设备页时刷新,否则真机断连后界面不会更新。
|
||||||
|
- `setFunctionEnabled` 是**非 `suspend`** 的 —— Mock 只改内存态所以无所谓,但 M2 写向 BLE
|
||||||
|
必然是挂起操作,接口签名届时需要改,连带 `DeviceViewModel` 的调用点。
|
||||||
|
- 预计 M2 的改动面:`domain/repository/Repositories.kt`、`data/mock/*`、`di/AppContainer.kt`、
|
||||||
|
`AndroidManifest.xml`(BLE 权限)、`app/build.gradle.kts` + `gradle/libs.versions.toml`
|
||||||
|
(厂商 Java 依赖,**很可能需要 Jetifier**)、`feature/device/DeviceScreen.kt`。
|
||||||
|
完整清单见实施计划的「M2 交接提示」一节。
|
||||||
|
|
||||||
|
## 覆盖缺口声明(已大幅收窄,但仍非零)
|
||||||
|
|
||||||
|
M1 的运行时行为**已在 Android 模拟器上完整走查过一遍**(上表逐项打勾),不再是「只有编译期保证」。
|
||||||
|
但仍有三个缺口,**不要把它们读成「已验证」**:
|
||||||
|
|
||||||
|
1. **跑的是模拟器不是真机** —— 真机上的厂商 ROM、性能、字体缩放、刘海/挖孔适配均未检验。
|
||||||
|
2. **空数据态与旋转/深色模式未触达**(见上)。
|
||||||
|
3. **`@Preview` 未渲染** —— 不过它原本的替代作用已被模拟器实测覆盖。
|
||||||
|
|
||||||
|
首次拿到真机时,请至少重跑上表中的「启动无崩溃」「四 Tab 切换」「详情页顶栏内边距」三项。
|
||||||
|
|
||||||
|
## 附:模拟器是怎么装上的(含一个踩过的坑)
|
||||||
|
|
||||||
|
本机原先 `D:\Android\Sdk` 下没有 `emulator/` 也没有 `system-images/`,本次新装:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1) 装包(注意坑:见下)
|
||||||
|
/d/Android/Sdk/cmdline-tools/latest/bin/sdkmanager.bat \
|
||||||
|
--sdk_root=D:/Android/Sdk "emulator" "system-images;android-36;google_apis;x86_64"
|
||||||
|
|
||||||
|
# 2) 建 AVD(avdmanager 不认 --sdk_root,只认环境变量)
|
||||||
|
export ANDROID_SDK_ROOT='D:\Android\Sdk' ANDROID_AVD_HOME='D:\Android\avd'
|
||||||
|
echo no | /d/Android/Sdk/cmdline-tools/latest/bin/avdmanager.bat create avd \
|
||||||
|
-n ringtest -k "system-images;android-36;google_apis;x86_64" -d pixel_7
|
||||||
|
|
||||||
|
# 3) 启动
|
||||||
|
/d/Android/Sdk/emulator/emulator.exe -avd ringtest
|
||||||
|
```
|
||||||
|
|
||||||
|
**坑:`--sdk_root` 一定要写正斜杠。** 写成 `D:\Android\Sdk` 时,Git Bash 会把反斜杠吃掉,
|
||||||
|
变成 Windows 的**盘符相对路径** `D:AndroidSdk` —— 这不是绝对路径,而是「D 盘当前目录下的
|
||||||
|
AndroidSdk」。结果 sdkmanager 把 5.3 GB 的包装进了 `cmdline-tools\latest\bin\AndroidSdk\`。
|
||||||
|
同盘移动是瞬时的,已搬回 `D:\Android\Sdk\`。**在 Git Bash 里给 Windows 工具传路径,一律用正斜杠。**
|
||||||
|
|
||||||
|
另外 `ANDROID_AVD_HOME` 已设为用户级变量 `D:\Android\avd`(AVD 镜像有几个 G,C 盘只剩 29 G)。
|
||||||
|
|||||||
Reference in New Issue
Block a user