Compare commits
4
Commits
3cbcafd4e2
...
bf373a2a5a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf373a2a5a | ||
|
|
ea8897f901 | ||
|
|
75a6749619 | ||
|
|
8c245c099b |
@@ -12,6 +12,7 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.RingHealthTheme
|
||||||
|
|
||||||
/** 唯一 Activity,承载 Compose 界面。 */
|
/** 唯一 Activity,承载 Compose 界面。 */
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
@@ -19,8 +20,11 @@ class MainActivity : ComponentActivity() {
|
|||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
setContent {
|
setContent {
|
||||||
MaterialTheme {
|
RingHealthTheme {
|
||||||
Surface(modifier = Modifier.fillMaxSize()) {
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
color = MaterialTheme.colorScheme.background,
|
||||||
|
) {
|
||||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
// 文案取自 res/values/strings.xml,不在 Kotlin 里硬编码用户可见文字
|
// 文案取自 res/values/strings.xml,不在 Kotlin 里硬编码用户可见文字
|
||||||
Text(stringResource(R.string.app_name))
|
Text(stringResource(R.string.app_name))
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package com.placeholder.ringhealth.core.common
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import com.placeholder.ringhealth.R
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/** 步数:千分位,如 8,426。 */
|
||||||
|
fun formatSteps(steps: Int): String = String.format(Locale.CHINA, "%,d", steps)
|
||||||
|
|
||||||
|
/** 距离:保留两位小数(单位 km 由 UI 单独展示)。 */
|
||||||
|
fun formatDistance(km: Double): String = String.format(Locale.CHINA, "%.2f", km)
|
||||||
|
|
||||||
|
/** 卡路里:只做千分位,单位「千卡」由 UI 用字符串资源拼接(ledger Ruling R13)。 */
|
||||||
|
fun formatCalories(kcal: Int): String = String.format(Locale.CHINA, "%,d", kcal)
|
||||||
|
|
||||||
|
/** 睡眠时长:7小时32分 / 45分 / 8小时。 */
|
||||||
|
fun formatSleepDuration(minutes: Int): String {
|
||||||
|
val hours = minutes / 60
|
||||||
|
val mins = minutes % 60
|
||||||
|
return when {
|
||||||
|
hours > 0 && mins > 0 -> "${hours}小时${mins}分"
|
||||||
|
hours > 0 -> "${hours}小时"
|
||||||
|
else -> "${mins}分"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 运动时长:1小时05分 / 42分(分钟补零)。 */
|
||||||
|
fun formatDuration(minutes: Int): String {
|
||||||
|
val hours = minutes / 60
|
||||||
|
val mins = minutes % 60
|
||||||
|
return if (hours > 0) {
|
||||||
|
String.format(Locale.CHINA, "%d小时%02d分", hours, mins)
|
||||||
|
} else {
|
||||||
|
"${mins}分"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 时间戳 → HH:mm。 */
|
||||||
|
fun formatClock(timestamp: Long): String =
|
||||||
|
SimpleDateFormat("HH:mm", Locale.CHINA).format(Date(timestamp))
|
||||||
|
|
||||||
|
/** 时间戳 → yyyy-MM-dd。 */
|
||||||
|
fun formatDate(timestamp: Long): String =
|
||||||
|
SimpleDateFormat("yyyy-MM-dd", Locale.CHINA).format(Date(timestamp))
|
||||||
|
|
||||||
|
/** 压力值 → 字符串资源 id。中文描述的唯一来源是 `strings.xml`(ledger Ruling R13)。 */
|
||||||
|
@StringRes
|
||||||
|
fun stressLevelLabelRes(stress: Int): Int = when {
|
||||||
|
stress < 30 -> R.string.stress_relaxed
|
||||||
|
stress < 60 -> R.string.stress_normal
|
||||||
|
stress < 80 -> R.string.stress_medium
|
||||||
|
else -> R.string.stress_high
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.placeholder.ringhealth.core.designsystem
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
/** 品牌主色(健康绿,仿小米运动健康首页大环)。 */
|
||||||
|
val BrandGreen = Color(0xFF34C759)
|
||||||
|
val BrandGreenDark = Color(0xFF248A3D)
|
||||||
|
|
||||||
|
/** 页面与卡片背景。 */
|
||||||
|
val AppBackground = Color(0xFFF5F6F8)
|
||||||
|
val CardSurface = Color(0xFFFFFFFF)
|
||||||
|
val TextPrimary = Color(0xFF1C1C1E)
|
||||||
|
val TextSecondary = Color(0xFF8E8E93)
|
||||||
|
val DividerColor = Color(0xFFEFEFF4)
|
||||||
|
|
||||||
|
/** 各健康指标的语义色,全应用统一引用。 */
|
||||||
|
val MetricGreen = Color(0xFF34C759) // 步数
|
||||||
|
val MetricRed = Color(0xFFFF3B30) // 心率
|
||||||
|
val MetricBlue = Color(0xFF3B5BFF) // 睡眠
|
||||||
|
val MetricCyan = Color(0xFF00C7BE) // 血氧
|
||||||
|
val MetricPurple = Color(0xFFAF52DE) // 压力
|
||||||
|
val MetricOrange = Color(0xFFFF9500) // 体温
|
||||||
|
val MetricYellow = Color(0xFFFFCC00) // 血压 / 提醒
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package com.placeholder.ringhealth.core.designsystem
|
||||||
|
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.lightColorScheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
|
||||||
|
private val RingHealthColors = lightColorScheme(
|
||||||
|
primary = BrandGreen,
|
||||||
|
onPrimary = androidx.compose.ui.graphics.Color.White,
|
||||||
|
secondary = BrandGreenDark,
|
||||||
|
background = AppBackground,
|
||||||
|
onBackground = TextPrimary,
|
||||||
|
surface = CardSurface,
|
||||||
|
onSurface = TextPrimary,
|
||||||
|
surfaceVariant = DividerColor,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用主题。阶段一仅提供浅色模式(深色留到 M5)。
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun RingHealthTheme(content: @Composable () -> Unit) {
|
||||||
|
MaterialTheme(
|
||||||
|
colorScheme = RingHealthColors,
|
||||||
|
typography = RingHealthTypography,
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.placeholder.ringhealth.core.designsystem
|
||||||
|
|
||||||
|
import androidx.compose.material3.Typography
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
|
/** 大号数值样式:首页大环与详情页主数字。 */
|
||||||
|
val MetricValueStyle = TextStyle(
|
||||||
|
fontSize = 40.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = TextPrimary,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 中等数值样式:指标卡主数字。 */
|
||||||
|
val MetricTileValueStyle = TextStyle(
|
||||||
|
fontSize = 22.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = TextPrimary,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 全局字体排版。 */
|
||||||
|
val RingHealthTypography = Typography()
|
||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
package com.placeholder.ringhealth.core.designsystem.component
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一卡片容器:白底、大圆角、轻阴影。所有页面内容块都应包在它里面。
|
||||||
|
*
|
||||||
|
* 颜色取自主题的 surface(由 [com.placeholder.ringhealth.core.designsystem.RingHealthTheme]
|
||||||
|
* 映射为设计系统的 CardSurface),因此升级深色模式时无需改动本文件。
|
||||||
|
*
|
||||||
|
* @param modifier 外部修饰符,会在 fillMaxWidth 之前生效。
|
||||||
|
* @param onClick 非空时整卡可点击(如水/体重等可跳转的卡片);为空则纯展示。
|
||||||
|
* @param content 卡片内容,作用域为 ColumnScope,默认内边距 16dp。
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun HealthCard(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onClick: (() -> Unit)? = null,
|
||||||
|
content: @Composable ColumnScope.() -> Unit,
|
||||||
|
) {
|
||||||
|
// 仅当传入点击回调时才挂 clickable,避免给纯展示卡片带来无谓的水波纹与无障碍焦点。
|
||||||
|
val clickModifier = if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier
|
||||||
|
Card(
|
||||||
|
modifier = modifier.fillMaxWidth().then(clickModifier),
|
||||||
|
shape = RoundedCornerShape(20.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(16.dp), content = content)
|
||||||
|
}
|
||||||
|
}
|
||||||
+61
@@ -0,0 +1,61 @@
|
|||||||
|
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 androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 极简柱状图(Compose Canvas 手绘,无第三方依赖)。
|
||||||
|
* values 为空时不绘制;被高亮的一根用纯色,其余半透明。
|
||||||
|
*
|
||||||
|
* 几何说明:画布横向按 values.size 等分得到每个槽位 slot,柱宽取槽位的 65%,
|
||||||
|
* 余下 35% 作为间隔,柱子因此在槽内水平居中;柱高 = 画布高 × (value / maxValue),
|
||||||
|
* 底边贴合画布底边(y = 画布高 - 柱高),圆角半径取柱宽一半,形成胶囊柱。
|
||||||
|
* maxValue 下限为 1f,避免「全为 0」时出现 0 除。
|
||||||
|
*
|
||||||
|
* @param values 各柱数值(须为非负),按时间顺序从左到右。
|
||||||
|
* @param color 柱子颜色;高亮柱用纯色,其余用其 30% 透明度。
|
||||||
|
* @param modifier 外部修饰符。
|
||||||
|
* @param barHeight 画布高度,即最高柱的高度。
|
||||||
|
* @param highlightLast 为 true 时高亮最后一根(如「今天」),否则高亮数值最大的一根。
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun MetricBarChart(
|
||||||
|
values: List<Float>,
|
||||||
|
color: Color,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
barHeight: Dp = 120.dp,
|
||||||
|
highlightLast: Boolean = true,
|
||||||
|
) {
|
||||||
|
Canvas(modifier = modifier.fillMaxWidth().height(barHeight)) {
|
||||||
|
if (values.isEmpty()) return@Canvas
|
||||||
|
val maxValue = values.max().coerceAtLeast(1f)
|
||||||
|
val gapRatio = 0.35f
|
||||||
|
val slot = size.width / values.size
|
||||||
|
val barWidth = slot * (1f - gapRatio)
|
||||||
|
// 注意:maxValue 已被 coerceAtLeast(1f) 抬高,当所有值都小于 1 时
|
||||||
|
// values.indexOf(maxValue) 会返回 -1 导致没有任何柱子被高亮,
|
||||||
|
// 因此这里改为直接取最大值的下标。
|
||||||
|
val highlightIndex = if (highlightLast) values.lastIndex else values.indices.maxByOrNull { values[it] } ?: 0
|
||||||
|
|
||||||
|
values.forEachIndexed { index, value ->
|
||||||
|
val ratio = (value / maxValue).coerceIn(0f, 1f)
|
||||||
|
val h = size.height * ratio
|
||||||
|
val isHighlight = index == highlightIndex
|
||||||
|
drawRoundRect(
|
||||||
|
color = if (isHighlight) color else color.copy(alpha = 0.3f),
|
||||||
|
topLeft = Offset(x = index * slot + (slot - barWidth) / 2f, y = size.height - h),
|
||||||
|
size = Size(width = barWidth, height = h),
|
||||||
|
cornerRadius = CornerRadius(barWidth / 2f, barWidth / 2f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+69
@@ -0,0 +1,69 @@
|
|||||||
|
package com.placeholder.ringhealth.core.designsystem.component
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
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.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.MetricTileValueStyle
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.TextSecondary
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单个健康指标小卡:图标 + 标题 + 数值 + 单位。
|
||||||
|
*
|
||||||
|
* 文案全部由调用方传入(标题/数值/单位),组件内不出现任何用户可见字面量。
|
||||||
|
* 图标为纯装饰(contentDescription = null),语义由 title 承担,避免无障碍重复朗读。
|
||||||
|
*
|
||||||
|
* @param icon 指标图标,如 Icons.Filled.DirectionsWalk。
|
||||||
|
* @param iconTint 图标着色,通常取设计系统中该指标的语义色。
|
||||||
|
* @param title 指标名(由调用方从字符串资源取得)。
|
||||||
|
* @param value 主数值文本。
|
||||||
|
* @param unit 单位文本,如「步」「次/分」。
|
||||||
|
* @param modifier 外部修饰符。
|
||||||
|
* @param onClick 点击回调,默认空实现(配合 HealthCard 的整卡可点击)。
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun MetricTile(
|
||||||
|
icon: ImageVector,
|
||||||
|
iconTint: Color,
|
||||||
|
title: String,
|
||||||
|
value: String,
|
||||||
|
unit: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onClick: () -> Unit = {},
|
||||||
|
) {
|
||||||
|
HealthCard(modifier = modifier, onClick = onClick) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(imageVector = icon, contentDescription = null, tint = iconTint, modifier = Modifier.size(18.dp))
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = TextSecondary,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.padding(start = 6.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Column(modifier = Modifier.padding(top = 6.dp), verticalArrangement = Arrangement.Bottom) {
|
||||||
|
Row(verticalAlignment = Alignment.Bottom) {
|
||||||
|
Text(text = value, style = MetricTileValueStyle)
|
||||||
|
Text(
|
||||||
|
text = unit,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = TextSecondary,
|
||||||
|
modifier = Modifier.padding(start = 4.dp, bottom = 3.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+70
@@ -0,0 +1,70 @@
|
|||||||
|
package com.placeholder.ringhealth.core.designsystem.component
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.StrokeCap
|
||||||
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 环形进度(首页大环与详情页复用)。progress 取 0f..1f。
|
||||||
|
* content 渲染在环中心(如步数大数字与单位)。
|
||||||
|
*
|
||||||
|
* 几何说明:描边以路径为中心向两侧各扩 stroke/2,因此圆弧的包围盒必须整体内缩
|
||||||
|
* stroke/2 后作为 topLeft,并以「边长 - stroke」作为 Size,否则圆环会被画布裁掉半个线宽。
|
||||||
|
* 进度从 12 点方向(-90°)开始顺时针增长;底环从 3 点方向起画满 360°(起点不影响整圆观感)。
|
||||||
|
*
|
||||||
|
* @param progress 进度比例,超出 0f..1f 会被裁剪。
|
||||||
|
* @param color 进度弧颜色,通常取设计系统的指标色。
|
||||||
|
* @param trackColor 底环颜色,通常取 DividerColor。
|
||||||
|
* @param modifier 外部修饰符。
|
||||||
|
* @param ringSize 圆环外径(含描边)。
|
||||||
|
* @param strokeWidth 描边宽度。
|
||||||
|
* @param content 环心内容。
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun RingProgress(
|
||||||
|
progress: Float,
|
||||||
|
color: Color,
|
||||||
|
trackColor: Color,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
ringSize: Dp = 180.dp,
|
||||||
|
strokeWidth: Dp = 14.dp,
|
||||||
|
content: @Composable () -> Unit = {},
|
||||||
|
) {
|
||||||
|
Box(modifier = modifier.size(ringSize), contentAlignment = Alignment.Center) {
|
||||||
|
Canvas(modifier = Modifier.size(ringSize)) {
|
||||||
|
val stroke = strokeWidth.toPx()
|
||||||
|
val inset = stroke / 2f
|
||||||
|
// 内缩后的包围盒:宽高均为画布尺寸减去一个线宽。
|
||||||
|
val arcSize = Size(this.size.width - stroke, this.size.height - stroke)
|
||||||
|
drawArc(
|
||||||
|
color = trackColor,
|
||||||
|
startAngle = 0f,
|
||||||
|
sweepAngle = 360f,
|
||||||
|
useCenter = false,
|
||||||
|
topLeft = Offset(inset, inset),
|
||||||
|
size = arcSize,
|
||||||
|
style = Stroke(width = stroke, cap = StrokeCap.Round),
|
||||||
|
)
|
||||||
|
drawArc(
|
||||||
|
color = color,
|
||||||
|
startAngle = -90f,
|
||||||
|
sweepAngle = 360f * progress.coerceIn(0f, 1f),
|
||||||
|
useCenter = false,
|
||||||
|
topLeft = Offset(inset, inset),
|
||||||
|
size = arcSize,
|
||||||
|
style = Stroke(width = stroke, cap = StrokeCap.Round),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
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.foundation.layout.padding
|
||||||
|
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.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 区块标题,可选右侧扩展内容(如「查看全部」按钮)。
|
||||||
|
*
|
||||||
|
* 标题与右侧内容左右两端对齐;trailing 为空时不占位。
|
||||||
|
*
|
||||||
|
* @param title 标题文本,由调用方从字符串资源取得。
|
||||||
|
* @param modifier 外部修饰符。
|
||||||
|
* @param trailing 右侧内容槽,例如一个 TextButton 或「更多」图标。
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun SectionHeader(
|
||||||
|
title: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
trailing: (@Composable () -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier.fillMaxWidth().padding(vertical = 8.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(text = title, style = MaterialTheme.typography.titleMedium)
|
||||||
|
trailing?.invoke()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package com.placeholder.ringhealth.core.designsystem.component
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/** 分段条的一段:权重决定宽度。 */
|
||||||
|
data class StatSegment(val weight: Float, val color: Color)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 横向分段条,用于睡眠分期(深睡/浅睡/清醒)等占比展示。
|
||||||
|
*
|
||||||
|
* 各段宽度按其 weight 在总权重中的占比分配;两端整体裁剪为半圆角。
|
||||||
|
* 权重被下限保护为极小正数,避免全零权重导致 Row 的 weight 分配异常。
|
||||||
|
*
|
||||||
|
* @param segments 分段列表,顺序即从左到右的绘制顺序。
|
||||||
|
* @param modifier 外部修饰符。
|
||||||
|
* @param barHeight 条高,同时决定两端圆角半径(barHeight / 2)。
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun StatBar(
|
||||||
|
segments: List<StatSegment>,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
barHeight: Dp = 12.dp,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(barHeight)
|
||||||
|
.clip(RoundedCornerShape(barHeight / 2)),
|
||||||
|
) {
|
||||||
|
segments.forEach { segment ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(segment.weight.coerceAtLeast(0.0001f))
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(barHeight)
|
||||||
|
.background(segment.color),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package com.placeholder.ringhealth.domain.model
|
||||||
|
|
||||||
|
/** 健康指标类型。路由与详情页以此区分。 */
|
||||||
|
enum class MetricType {
|
||||||
|
STEP,
|
||||||
|
HEART_RATE,
|
||||||
|
SLEEP,
|
||||||
|
BLOOD_OXYGEN,
|
||||||
|
STRESS,
|
||||||
|
TEMPERATURE,
|
||||||
|
BLOOD_PRESSURE,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 历史数据的时间范围。 */
|
||||||
|
enum class DateRange { WEEK, MONTH }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 戒指设备状态。M2 接入蓝牙后由真实 SDK 填充,当前由 Mock 提供。
|
||||||
|
*/
|
||||||
|
data class RingDeviceState(
|
||||||
|
val connected: Boolean,
|
||||||
|
val name: String,
|
||||||
|
val batteryPercent: Int,
|
||||||
|
val firmwareVersion: String,
|
||||||
|
val charging: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 今日健康概览。字段为 null 表示该指标未开启或暂无数据,UI 需展示“未开启”占位而非 0。
|
||||||
|
*/
|
||||||
|
data class HealthSummary(
|
||||||
|
val steps: Int,
|
||||||
|
val stepGoal: Int,
|
||||||
|
val calories: Int,
|
||||||
|
val distanceKm: Double,
|
||||||
|
val heartRate: Int?,
|
||||||
|
val sleepMinutes: Int?,
|
||||||
|
val bloodOxygen: Int?,
|
||||||
|
val stress: Int?,
|
||||||
|
val temperature: Double?,
|
||||||
|
val bloodPressure: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 一条历史数据点。 */
|
||||||
|
data class MetricPoint(val timestamp: Long, val value: Double)
|
||||||
|
|
||||||
|
/** 一条运动记录。 */
|
||||||
|
data class WorkoutRecord(
|
||||||
|
val id: String,
|
||||||
|
val type: String,
|
||||||
|
val startTime: Long,
|
||||||
|
val durationMinutes: Int,
|
||||||
|
val distanceKm: Double,
|
||||||
|
val calories: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 戒指功能开关项(对应协议中的自动监测开关)。 */
|
||||||
|
data class DeviceFunctionSetting(
|
||||||
|
val id: String,
|
||||||
|
val title: String,
|
||||||
|
val subtitle: String,
|
||||||
|
val enabled: Boolean,
|
||||||
|
)
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package com.placeholder.ringhealth.domain.repository
|
||||||
|
|
||||||
|
import com.placeholder.ringhealth.domain.model.DateRange
|
||||||
|
import com.placeholder.ringhealth.domain.model.DeviceFunctionSetting
|
||||||
|
import com.placeholder.ringhealth.domain.model.HealthSummary
|
||||||
|
import com.placeholder.ringhealth.domain.model.MetricPoint
|
||||||
|
import com.placeholder.ringhealth.domain.model.MetricType
|
||||||
|
import com.placeholder.ringhealth.domain.model.RingDeviceState
|
||||||
|
import com.placeholder.ringhealth.domain.model.WorkoutRecord
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 健康数据来源。M1 由 Mock 实现,M2/M3 由厂商 SDK 适配层实现,UI 无需改动。
|
||||||
|
*/
|
||||||
|
interface HealthDataRepository {
|
||||||
|
/** 今日概览,随数据变化持续发射。 */
|
||||||
|
fun observeTodaySummary(): Flow<HealthSummary>
|
||||||
|
|
||||||
|
/** 指定指标在指定时间范围内的历史数据。 */
|
||||||
|
fun observeMetricHistory(metric: MetricType, range: DateRange): Flow<List<MetricPoint>>
|
||||||
|
|
||||||
|
/** 运动记录列表。 */
|
||||||
|
fun observeWorkouts(): Flow<List<WorkoutRecord>>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 戒指设备状态与功能开关。M2 接入蓝牙后由真实实现替换。
|
||||||
|
*/
|
||||||
|
interface RingDeviceRepository {
|
||||||
|
/** 设备状态(连接/电量/固件)。 */
|
||||||
|
val deviceState: StateFlow<RingDeviceState>
|
||||||
|
|
||||||
|
/** 功能开关列表。 */
|
||||||
|
val functionSettings: StateFlow<List<DeviceFunctionSetting>>
|
||||||
|
|
||||||
|
/** 主动刷新设备状态。 */
|
||||||
|
suspend fun refreshDeviceState()
|
||||||
|
|
||||||
|
/** 切换某个功能开关(M1 仅改内存状态)。 */
|
||||||
|
fun setFunctionEnabled(id: String, enabled: Boolean)
|
||||||
|
}
|
||||||
@@ -1,4 +1,9 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">RingHealth</string>
|
<string name="app_name">RingHealth</string>
|
||||||
|
|
||||||
|
<string name="stress_relaxed">放松</string>
|
||||||
|
<string name="stress_normal">正常</string>
|
||||||
|
<string name="stress_medium">中等</string>
|
||||||
|
<string name="stress_high">偏高</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package com.placeholder.ringhealth.core.common
|
||||||
|
|
||||||
|
import com.placeholder.ringhealth.R
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class FormattersTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `formatSleepDuration 处理小时与分钟`() {
|
||||||
|
assertEquals("7小时32分", formatSleepDuration(452))
|
||||||
|
assertEquals("45分", formatSleepDuration(45))
|
||||||
|
assertEquals("8小时", formatSleepDuration(480))
|
||||||
|
assertEquals("0分", formatSleepDuration(0))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `formatDuration 处理小时与分钟`() {
|
||||||
|
assertEquals("1小时05分", formatDuration(65))
|
||||||
|
assertEquals("42分", formatDuration(42))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `formatSteps 添加千分位`() {
|
||||||
|
assertEquals("8,426", formatSteps(8426))
|
||||||
|
assertEquals("0", formatSteps(0))
|
||||||
|
assertEquals("1,234,567", formatSteps(1234567))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `formatDistance 保留两位小数`() {
|
||||||
|
assertEquals("6.21", formatDistance(6.212))
|
||||||
|
assertEquals("0.00", formatDistance(0.0))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `formatCalories 只返回千分位数字`() {
|
||||||
|
assertEquals("327", formatCalories(327))
|
||||||
|
assertEquals("1,234", formatCalories(1234))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `stressLevelLabelRes 按区间映射到对应字符串资源`() {
|
||||||
|
assertEquals(R.string.stress_relaxed, stressLevelLabelRes(20))
|
||||||
|
assertEquals(R.string.stress_normal, stressLevelLabelRes(45))
|
||||||
|
assertEquals(R.string.stress_medium, stressLevelLabelRes(65))
|
||||||
|
assertEquals(R.string.stress_high, stressLevelLabelRes(90))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user