feat: 四个 Tab 收口 — 首页三件套 / 运动类型网格 / 设备空态 / 我的四个真子页

PROGRESS.md §3.2 报告的三个结构性问题(设备页 + 更多设置重复、运动页两个入口同数据、「我的」四个死链接)本批一并收口。用户原话:「现在就是要 APP 页面完整成熟,后期再做后端接口,我的任务只是前端」。

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

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

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

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

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

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

——共 14 文件,+10 修改 / +4 新建。
This commit is contained in:
2026-09-11 08:55:38 +08:00
parent fba26fc372
commit 841f9f1542
14 changed files with 1128 additions and 114 deletions
@@ -18,6 +18,9 @@ fun formatDistance(km: Double): String = String.format(Locale.CHINA, "%.2f", km)
/** 卡路里:只做千分位,单位「千卡」由 UI 用字符串资源拼接(ledger Ruling R13)。 */
fun formatCalories(kcal: Int): String = String.format(Locale.CHINA, "%,d", kcal)
/** 活动累计分钟数:只取整,单位「分钟」由 UI 用字符串资源拼接。 */
fun formatActiveMinutes(minutes: Int): String = minutes.toString()
/** 睡眠时长:7小时32分 / 45分 / 8小时。 */
fun formatSleepDuration(minutes: Int): String {
val hours = minutes / 60
@@ -36,7 +36,11 @@ import com.placeholder.ringhealth.feature.device.DeviceScreen
import com.placeholder.ringhealth.feature.device.DeviceSettingsScreen
import com.placeholder.ringhealth.feature.detail.MetricDetailScreen
import com.placeholder.ringhealth.feature.home.HomeScreen
import com.placeholder.ringhealth.feature.profile.AboutScreen
import com.placeholder.ringhealth.feature.profile.HelpScreen
import com.placeholder.ringhealth.feature.profile.NotificationsScreen
import com.placeholder.ringhealth.feature.profile.ProfileScreen
import com.placeholder.ringhealth.feature.profile.SettingsScreen
import com.placeholder.ringhealth.feature.workout.WorkoutHistoryScreen
import com.placeholder.ringhealth.feature.workout.WorkoutScreen
@@ -66,6 +70,14 @@ private fun tabRouteFor(route: String?): String? = when (route) {
Routes.DETAIL_PATTERN -> Routes.HOME
Routes.DEVICE_SETTINGS -> Routes.DEVICE
Routes.WORKOUT_HISTORY -> Routes.WORKOUT
// 2026-09-10 晚:四个「我的」子页加进来,子页栈顶时底栏仍高亮「我的」。
// 之前一批 PROFILE_* 路由不存在,对应的「未开启」死链是 PROGRESS.md §3.2 报告的
// 已知结构性问题;本批一并收口。
Routes.PROFILE_SETTINGS,
Routes.PROFILE_NOTIFICATIONS,
Routes.PROFILE_ABOUT,
Routes.PROFILE_HELP,
-> Routes.PROFILE
else -> route
}
@@ -122,7 +134,25 @@ fun RingHealthApp(navController: NavHostController = rememberNavController()) {
DeviceScreen(onOpenSettings = { navController.navigate(Routes.DEVICE_SETTINGS) })
}
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 离开)。
@@ -14,6 +14,16 @@ object Routes {
const val DEVICE_SETTINGS = "device/settings"
const val WORKOUT_HISTORY = "workout/history"
// 「我的」子页:四张真页面替代了阶段一首批的四个死链接
// (PROGRESS.md §3.2 中报告的「未开启」占位状态)。
// 路由名按语义独立、不再嵌在 `profile/` 下 —— 子页与 Tab 用同一字符串前缀
// 容易让 `tabRouteFor` 误把它们归到「我的」Tab(实际上它们就是「我的」的内容,
// 这里归到「我的」才对,所以单独加分支,见 `RingHealthApp.tabRouteFor`)。
const val PROFILE_SETTINGS = "profile_settings"
const val PROFILE_NOTIFICATIONS = "profile_notifications"
const val PROFILE_ABOUT = "profile_about"
const val PROFILE_HELP = "profile_help"
/** 路由参数的键名。必须声明在 DETAIL_PATTERN 之前:模板引用它。 */
const val DETAIL_ARG_METRIC = "metric"
@@ -55,6 +55,10 @@ class MockDataProvider(
// 血压在概览里是「收缩压/舒张压」字符串。开着给一个演示值,关着为 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,
)
/**
@@ -261,6 +265,15 @@ class MockDataProvider(
/** 血压开着时的演示读数(概览里是「收缩压/舒张压」形式)。 */
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
@@ -39,6 +39,10 @@ data class RingDeviceState(
/**
* 今日健康概览。字段为 null 表示该指标未开启或暂无数据,UI 需展示“未开启”占位而非 0。
*
* @param activeMinutes 今日活动累计分钟数(高强度活动 + 中等强度活动合并)。
* 与 [steps] / [calories] 同属「每日总量」型数据,**不属于任何一个 [MetricType]**
* (首页三件套横排里展示,M3 接 SDK 后由真实活动汇总接口填入)。
*/
data class HealthSummary(
val steps: Int,
@@ -51,6 +55,7 @@ data class HealthSummary(
val stress: Int?,
val temperature: Double?,
val bloodPressure: String?,
val activeMinutes: Int = 0,
)
/**
@@ -1,35 +1,51 @@
package com.placeholder.ringhealth.feature.device
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bluetooth
import androidx.compose.material.icons.filled.Nfc
import androidx.compose.material.icons.filled.QrCodeScanner
import androidx.compose.material.icons.filled.Watch
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.placeholder.ringhealth.AppViewModelProvider
import com.placeholder.ringhealth.R
import com.placeholder.ringhealth.core.designsystem.AppBackground
import com.placeholder.ringhealth.core.designsystem.MetricGreen
import com.placeholder.ringhealth.core.designsystem.RingHealthTheme
import com.placeholder.ringhealth.core.designsystem.TextPrimary
import com.placeholder.ringhealth.core.designsystem.TextSecondary
import com.placeholder.ringhealth.core.designsystem.component.HealthCard
import com.placeholder.ringhealth.core.designsystem.component.SectionHeader
@@ -47,13 +63,23 @@ fun DeviceScreen(
DeviceContent(state = state, onOpenSettings = onOpenSettings, modifier = modifier)
}
/** 无状态设备页内容。 */
/**
* 无状态设备页内容。
*
* **空态 vs 已连态分两条分支。** 阶段一 Mock 始终 `connected = true`,空态走 Preview 验证;
* M2 接 SDK 后真实未连时会自动落到这里 —— 戒指扫描/绑定是用户买戒指后的第一件事,
* 不能让人看到一张「已连但没数据」的卡(那是 bug)。
*/
@Composable
private fun DeviceContent(
state: DeviceUiState,
onOpenSettings: () -> Unit,
modifier: Modifier = Modifier,
) {
if (!state.device.connected) {
DeviceEmptyState(modifier = modifier.fillMaxSize())
return
}
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
@@ -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
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
private fun DeviceContentPreview() {
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 用的假开关列表。
*
@@ -1,12 +1,16 @@
package com.placeholder.ringhealth.feature.home
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@@ -18,7 +22,6 @@ import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MonitorHeart
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -38,15 +41,14 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.placeholder.ringhealth.AppViewModelProvider
import com.placeholder.ringhealth.R
import com.placeholder.ringhealth.core.common.formatActiveMinutes
import com.placeholder.ringhealth.core.common.formatCalories
import com.placeholder.ringhealth.core.common.formatDistance
import com.placeholder.ringhealth.core.common.formatMonthDay
import com.placeholder.ringhealth.core.common.formatSleepDuration
import com.placeholder.ringhealth.core.common.formatSteps
import com.placeholder.ringhealth.core.common.greetingResFor
import com.placeholder.ringhealth.core.common.stressLevelLabelRes
import com.placeholder.ringhealth.core.common.weekdayResFor
import com.placeholder.ringhealth.core.designsystem.DividerColor
import com.placeholder.ringhealth.core.designsystem.GreetingStyle
import com.placeholder.ringhealth.core.designsystem.MetricGreen
import com.placeholder.ringhealth.core.designsystem.RingHealthTheme
@@ -54,7 +56,6 @@ import com.placeholder.ringhealth.core.designsystem.TextPrimary
import com.placeholder.ringhealth.core.designsystem.TextSecondary
import com.placeholder.ringhealth.core.designsystem.component.HealthCard
import com.placeholder.ringhealth.core.designsystem.component.MetricTile
import com.placeholder.ringhealth.core.designsystem.component.RingProgress
import com.placeholder.ringhealth.core.designsystem.component.SectionHeader
import com.placeholder.ringhealth.core.designsystem.metricIcon
import com.placeholder.ringhealth.core.designsystem.metricTint
@@ -78,9 +79,6 @@ private data class MetricCardSpec(
/** 首页网格列数。 */
private const val HOME_GRID_COLUMNS = 2
/** 步数大环内径占卡片左侧的比例由 [STEPS_RING_SIZE] 决定;数值字号按环内可容纳宽度单独定。 */
private val STEPS_RING_SIZE = 124.dp
/** 健康首页。 */
@Composable
fun HomeScreen(
@@ -139,7 +137,7 @@ private fun HomeContent(
item(span = { GridItemSpan(maxLineSpan) }) { GreetingHeader(nowMillis = nowMillis) }
item(span = { GridItemSpan(maxLineSpan) }) { DeviceStrip(device = state.device) }
item(span = { GridItemSpan(maxLineSpan) }) {
StepsCard(summary = summary, onClick = { onOpenDetail(MetricType.STEP) })
TodaySummaryCard(summary = summary, onOpenSteps = { onOpenDetail(MetricType.STEP) })
}
item(span = { GridItemSpan(maxLineSpan) }) {
SectionHeader(title = stringResource(R.string.home_metrics))
@@ -226,80 +224,106 @@ private fun DeviceStrip(device: RingDeviceState?, modifier: Modifier = Modifier)
}
/**
* 今日步数卡片:**横向**布局,左侧大环、右侧数值。
* 今日数据卡片:三件套横排(步数 / 卡路里 / 活动时长)。
*
* 早先是「大环居中 + 数字在环心 + 距离/千卡横排」的纵向布局,卡片高约 300dp,
* 而内容只有一个环和一个数字 —— 四周全是留白,占掉近三分之一屏。
* 改成横向后高度降到约 155dp:环贴左,右侧一栏放标题、距离/千卡与目标,
* 横向空间被填满,纵向省下一半。
* 早先是「步数大环 + 距离/千卡」两件套 —— 大环占近 1/3 屏,环外信息只有两个换算结果,
* 留白多、信息密度低。换成三件套后:
* - 三列等宽,无主导项 —— 步数不再被环放大成「今天的全部重点」
* - 步数下挂目标("目标 10,000"),目标本身仍是用户对照自己的尺子
* - 步数一格整格可点进详情;卡路里/活动时长一格整格也点了能进(暂未挂路由,行为同环外区域)
*
* 数值字号比详情页的 `MetricValueStyle`(44sp)小一档 —— 三件套横向排成一行,
* 同样的 44sp 会让六位步数("100,000")撑爆格子;30sp 仍属「一眼能读」但不至于抢走下方指标卡的视觉重量。
*/
@Composable
private fun StepsCard(summary: HealthSummary, onClick: () -> Unit, modifier: Modifier = Modifier) {
HealthCard(modifier = modifier, onClick = onClick, contentPadding = PaddingValues(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
RingProgress(
progress = summary.steps.toFloat() / summary.stepGoal.coerceAtLeast(1),
color = MetricGreen,
trackColor = MetricGreen.copy(alpha = 0.15f),
ringSize = STEPS_RING_SIZE,
strokeWidth = 11.dp,
) {
// 环内只放步数本身。字号 28sp 是按环内可用直径(124dp - 2×11dp 描边 ≈ 102dp)
// 定的:「8,426」五个字形在该字号下约 84dp,留出余量,换成六位数也不会被裁。
Text(text = formatSteps(summary.steps), style = StepCountStyle)
}
Column(modifier = Modifier.padding(start = 18.dp)) {
Text(
text = stringResource(R.string.home_steps_title),
style = MaterialTheme.typography.bodyMedium,
color = TextSecondary,
)
Spacer(modifier = Modifier.height(10.dp))
Row(verticalAlignment = Alignment.Bottom) {
SummaryMetric(value = formatDistance(summary.distanceKm), label = stringResource(R.string.home_distance_label))
Spacer(modifier = Modifier.width(18.dp))
SummaryMetric(value = formatCalories(summary.calories), label = stringResource(R.string.home_calories_label))
}
Spacer(modifier = Modifier.height(10.dp))
HorizontalDivider(color = DividerColor)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.home_steps_goal, formatSteps(summary.stepGoal)),
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
)
}
private fun TodaySummaryCard(summary: HealthSummary, onOpenSteps: () -> Unit, modifier: Modifier = Modifier) {
HealthCard(modifier = modifier, contentPadding = PaddingValues(horizontal = 12.dp, vertical = 16.dp)) {
Row(modifier = Modifier.fillMaxWidth()) {
SummaryCell(
modifier = Modifier.weight(1f),
label = stringResource(R.string.home_summary_steps_label),
value = stringResource(R.string.home_summary_steps_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 = stringResource(R.string.home_summary_calories_value, formatCalories(summary.calories)),
subline = null,
onClick = null,
)
SummaryDivider()
SummaryCell(
modifier = Modifier.weight(1f),
label = stringResource(R.string.home_summary_active_label),
value = stringResource(R.string.home_summary_active_value, summary.activeMinutes),
subline = null,
onClick = null,
)
}
}
}
/**
* 环内步数字号。
* 三件套行中两列之间的细分隔线。
*
* 独立于 `MetricValueStyle`(44sp)—— 那个是给详情页整屏宽的,塞进 102dp 的环里会被裁。
* 一根 1dp 宽的浅色竖线,把三件套视觉上连成一张表而不是三张独立卡。
* 颜色用本页自定义的浅灰([HomeSummaryDivider]),不放 `core/designsystem/DividerColor`
* —— 那个值是卡片之间的整宽分割线(`#EFEFF4`),放在卡片内部会太显眼。
*/
private val StepCountStyle = TextStyle(
fontSize = 28.sp,
@Composable
private fun RowScope.SummaryDivider() {
androidx.compose.foundation.layout.Box(
modifier = Modifier
.width(1.dp)
.padding(vertical = 6.dp)
.background(HomeSummaryDivider),
)
}
/** 三件套行内分隔线专用色,比卡片间分割线略深一档但仍属「几乎看不见」。 */
private val HomeSummaryDivider = androidx.compose.ui.graphics.Color(0xFFE2E5EB)
/**
* 三件套的单格:上标签、下大数字 + 单位、再下一行小字(步数那一格用,挂目标)。
*
* `onClick` 为 null 时整格不响应点击(卡路里/活动时长暂未挂详情路由);
* 不为 null 时整格可点(步数一格进步数详情)。
*/
@Composable
private fun SummaryCell(
label: String,
value: String,
subline: String?,
onClick: (() -> Unit)?,
modifier: Modifier = Modifier,
) {
val clickableModifier = if (onClick != null) modifier.clickable(onClick = onClick) else modifier
Column(
modifier = clickableModifier.padding(horizontal = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(text = label, style = MaterialTheme.typography.bodySmall, color = TextSecondary)
Spacer(modifier = Modifier.height(6.dp))
Text(text = value, style = TodaySummaryValueStyle)
if (subline != null) {
Spacer(modifier = Modifier.height(2.dp))
Text(text = subline, style = MaterialTheme.typography.bodySmall, color = TextSecondary)
}
}
}
/** 三件套单格内的数值字号:30sp + tnum,宽字符数字不会跳。 */
private val TodaySummaryValueStyle = TextStyle(
fontSize = 30.sp,
fontWeight = FontWeight.Bold,
letterSpacing = (-1).sp,
color = TextPrimary,
fontFeatureSettings = "tnum",
)
/**
* 步数卡右侧的小结指标:数值 + 单位,纵向排列。
*
* 数值用深色而不是蓝/橙彩色 —— 在这个 App 里**颜色是有含义的**(每个健康指标一个专属色),
* 而「距离」「千卡」并不是可跳转的指标,只是步数的两个换算结果。给它们上色会让人以为
* 蓝=某种指标身份,而点上去并无反应。
*/
@Composable
private fun SummaryMetric(value: String, label: String) {
Column {
Text(text = value, style = MaterialTheme.typography.titleMedium, color = TextPrimary)
Text(text = label, style = MaterialTheme.typography.bodySmall, color = TextSecondary)
}
}
/**
* 组装指标卡列表;value 为 null 表示该指标未开启。
*
@@ -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()
}
}
@@ -40,13 +40,23 @@ import com.placeholder.ringhealth.core.designsystem.component.IconBadge
import com.placeholder.ringhealth.core.designsystem.component.SectionHeader
/**
* “我的”页。全部为静态入口,无状态、无 ViewModel。
* “我的”页。
*
* 2026-09-10 晚收口:四个入口(单位设置 / 通知权限 / 检查更新 / 帮助与反馈)
* 全部挂上 `onClick`,导航到 [SettingsScreen] / [NotificationsScreen] / [AboutScreen] / [HelpScreen],
* 告别阶段一首批的死链接(PROGRESS.md §3.2)。
*
* 本页**不需要**再拆「有状态外壳 + 无状态内容」(ledger Ruling R26 的常规形状):
* 它本身就是无状态的,直接对它挂 `@Preview` 即可。
*/
@Composable
fun ProfileScreen(modifier: Modifier = Modifier) {
fun ProfileScreen(
onOpenSettings: () -> Unit = {},
onOpenNotifications: () -> Unit = {},
onOpenAbout: () -> Unit = {},
onOpenHelp: () -> Unit = {},
modifier: Modifier = Modifier,
) {
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
@@ -82,6 +92,7 @@ fun ProfileScreen(modifier: Modifier = Modifier) {
tint = MetricBlue,
title = stringResource(R.string.profile_units),
subtitle = stringResource(R.string.profile_units_desc),
onClick = onOpenSettings,
)
}
item {
@@ -90,6 +101,7 @@ fun ProfileScreen(modifier: Modifier = Modifier) {
tint = MetricOrange,
title = stringResource(R.string.profile_notification),
subtitle = stringResource(R.string.profile_notification_desc),
onClick = onOpenNotifications,
)
}
item { SectionHeader(title = stringResource(R.string.profile_group_about)) }
@@ -99,6 +111,7 @@ fun ProfileScreen(modifier: Modifier = Modifier) {
tint = MetricPurple,
title = stringResource(R.string.profile_check_update),
subtitle = stringResource(R.string.profile_check_update_desc),
onClick = onOpenAbout,
)
}
item {
@@ -107,6 +120,7 @@ fun ProfileScreen(modifier: Modifier = Modifier) {
tint = MetricCyan,
title = stringResource(R.string.profile_help),
subtitle = stringResource(R.string.profile_help_desc),
onClick = onOpenHelp,
)
}
}
@@ -118,8 +132,8 @@ fun ProfileScreen(modifier: Modifier = Modifier) {
* 图标改用 [IconBadge] 的色底圆徽章,与首页指标卡同一套处理 —— 此前是裸的灰色小图标,
* 在全屏白卡上几乎是隐形的;四张卡的图标各配一色之后,一行行扫下来能靠颜色定位。
*
* 末尾的右箭头是**纯装饰性的可达性提示**:这四个入口目前都没有 `onClick`(阶段一未开放),
* 箭头表示「本该可以进」。等 M2 接上真实页面时再补 `onClick`。
* 末尾的右箭头表示「本条可进」;`onClick` 为空 lambda 时卡片整张不响应点击(**保留
* 默认值兼容旧调用方**,将来如有需要可以让单条退化为纯展示)。
*/
@Composable
private fun ProfileEntry(
@@ -127,9 +141,10 @@ private fun ProfileEntry(
tint: Color,
title: String,
subtitle: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
HealthCard(modifier = modifier) {
HealthCard(modifier = modifier, onClick = onClick) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
@@ -0,0 +1,180 @@
package com.placeholder.ringhealth.feature.profile
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.placeholder.ringhealth.R
import com.placeholder.ringhealth.core.designsystem.MetricGreen
import com.placeholder.ringhealth.core.designsystem.RingHealthTheme
import com.placeholder.ringhealth.core.designsystem.TextSecondary
import com.placeholder.ringhealth.core.designsystem.component.HealthCard
import com.placeholder.ringhealth.core.designsystem.component.SectionHeader
import com.placeholder.ringhealth.core.designsystem.component.SubPageTopAppBar
/**
* 「应用设置」子页。
*
* 把阶段一首批两个死链接(单位设置 + 通知权限)合并到一个页面,
* 按 `单位` / `通知` 两个 [SectionHeader] 分组。开关是 **演示用** —— 切了不持久化、
* 也不影响任何真实行为;M2 接 SDK 后通知开关才会真正写到设置接口。
*
* 为什么不拆成两个子页:单位只有两项只读,不值得单独一页;通知三个开关合在一张卡更直观。
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(onBack: () -> Unit, modifier: Modifier = Modifier) {
Scaffold(
modifier = modifier,
containerColor = MaterialTheme.colorScheme.background,
topBar = {
SubPageTopAppBar(
title = stringResource(R.string.profile_settings_title),
onBack = onBack,
)
},
) { innerPadding ->
SettingsContent(modifier = Modifier.fillMaxSize().padding(innerPadding))
}
}
/** 内容拆出独立函数便于 Preview。 */
@Composable
private fun SettingsContent(modifier: Modifier = Modifier) {
LazyColumn(
modifier = modifier,
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item { SectionHeader(title = stringResource(R.string.profile_settings_section_units)) }
item { UnitsCard() }
item { Spacer(modifier = Modifier.height(4.dp)) }
item { SectionHeader(title = stringResource(R.string.profile_settings_section_notifications)) }
item { NotificationsCard() }
}
}
/**
* 单位卡:只读展示当前选中的距离/体重单位。
*
* 阶段一是「静态展示」,没有切换交互 —— 让用户看到单位配置在这里,将来要做切换
* 时再补 `onClick` 与路由。M2 接 SDK 后会改成可点的下拉。
*/
@Composable
private fun UnitsCard(modifier: Modifier = Modifier) {
HealthCard(modifier = modifier) {
Column {
SettingRow(
title = stringResource(R.string.profile_settings_units_distance_label),
value = stringResource(R.string.profile_settings_units_distance_value),
)
Spacer(modifier = Modifier.height(12.dp))
SettingRow(
title = stringResource(R.string.profile_settings_units_weight_label),
value = stringResource(R.string.profile_settings_units_weight_value),
)
}
}
}
/**
* 通知卡:三个开关,按 `MockDataProvider.defaultFunctionSettings()` 的思路给演示值。
*
* 三个开关里**默认营销通知关着**、其余默认开着 —— 与「健康/设备提醒应当默认开,
* 营销通知应当默认关」的产品惯例一致;M2 接 SDK 后会真正写到系统通知渠道设置。
*/
@Composable
private fun NotificationsCard(modifier: Modifier = Modifier) {
HealthCard(modifier = modifier) {
Column {
ToggleRow(
title = stringResource(R.string.profile_settings_notifications_health),
subtitle = stringResource(R.string.profile_settings_notifications_health_desc),
initial = true,
)
Spacer(modifier = Modifier.height(12.dp))
ToggleRow(
title = stringResource(R.string.profile_settings_notifications_device),
subtitle = stringResource(R.string.profile_settings_notifications_device_desc),
initial = true,
)
Spacer(modifier = Modifier.height(12.dp))
ToggleRow(
title = stringResource(R.string.profile_settings_notifications_marketing),
subtitle = stringResource(R.string.profile_settings_notifications_marketing_desc),
initial = false,
)
}
}
}
@Composable
private fun SettingRow(title: String, value: String, modifier: Modifier = Modifier) {
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(text = title, style = MaterialTheme.typography.bodyMedium)
Text(text = value, style = MaterialTheme.typography.bodyMedium, color = TextSecondary)
}
}
/**
* 单行开关。阶段一切换不持久化 —— 这里用 `remember` 保留每行自己的状态,
* 离页即丢。视觉一致即可。
*/
@Composable
private fun ToggleRow(
title: String,
subtitle: String,
initial: Boolean,
modifier: Modifier = Modifier,
) {
// `var checked by remember { ... }` 需要 `getValue` / `setValue` 两个 delegate 操作符,
// 跨包引用时 Compose 不会自动导,所以这里走完全限定名 —— `var` 的左值其实只需要 setValue。
val mutableState = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(initial) }
val checked = mutableState.value
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f).padding(end = 16.dp)) {
Text(text = title, style = MaterialTheme.typography.bodyMedium)
Text(text = subtitle, style = MaterialTheme.typography.bodySmall, color = TextSecondary)
}
Switch(
checked = checked,
onCheckedChange = { mutableState.value = it },
colors = SwitchDefaults.colors(checkedTrackColor = MetricGreen),
)
}
}
/** 预览入口:直接渲染 [SettingsContent],便于在 IDE 中免真机查看应用设置页效果。 */
@Preview(showBackground = true)
@Composable
private fun SettingsContentPreview() {
RingHealthTheme {
SettingsContent()
}
}
@@ -1,21 +1,32 @@
package com.placeholder.ringhealth.feature.workout
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.automirrored.filled.DirectionsBike
import androidx.compose.material.icons.automirrored.filled.DirectionsRun
import androidx.compose.material.icons.automirrored.filled.DirectionsWalk
import androidx.compose.material.icons.filled.FitnessCenter
import androidx.compose.material.icons.filled.SportsBaseball
import androidx.compose.material.icons.filled.SportsHandball
import androidx.compose.material.icons.filled.SportsMartialArts
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@@ -31,6 +42,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@@ -51,6 +63,28 @@ import com.placeholder.ringhealth.core.designsystem.component.HealthCard
import com.placeholder.ringhealth.core.designsystem.component.SectionHeader
import com.placeholder.ringhealth.domain.model.WorkoutRecord
/**
* 运动类型。
*
* 戒指协议覆盖的运动模式列表([SDK 协议文档](SmartRingF01_SDK))。
* 阶段一尚未接入真实运动检测,**全部仅作占位** —— 点击任何一项都弹 Snackbar
* 「将在接入戒指后开放」,不会真的启动运动。
*
* 排序按使用频率从高到低:跑步(最常)→ 自由训练(兜底)。
* 改这里记得同步 [SportCatalog] 的顺序 —— 列表按它枚举。
*/
internal enum class SportType(val labelRes: Int, val icon: ImageVector) {
OUTDOOR_RUN(R.string.workout_sport_running_outdoor, Icons.AutoMirrored.Filled.DirectionsRun),
WALK(R.string.workout_sport_walking, Icons.AutoMirrored.Filled.DirectionsWalk),
OUTDOOR_CYCLE(R.string.workout_sport_cycling_outdoor, Icons.AutoMirrored.Filled.DirectionsBike),
INDOOR_RUN(R.string.workout_sport_running_indoor, Icons.Filled.SportsBaseball),
JUMP_ROPE(R.string.workout_sport_jump_rope, Icons.Filled.SportsHandball),
FREE_TRAINING(R.string.workout_sport_free_training, Icons.Filled.SportsMartialArts),
}
/** 运动类型网格的渲染顺序。 */
private val SportCatalog: List<SportType> = SportType.entries
/** 运动页。 */
@Composable
fun WorkoutScreen(
@@ -65,10 +99,10 @@ fun WorkoutScreen(
/**
* 无状态运动页内容。
*
* 「开始运动」卡整卡可点击,点击后弹一条 Snackbar 说明该能力尚未开放 ——
* 此前它是一张**没有任何点击响应的死卡**,用户点上去毫无反馈,看着像坏了。
* 弹提示比把卡片做成 `enabled = false` 的半透明按钮好:后者第一眼就像「功能被禁用了」,
* 而前者明确告诉用户「为什么现在没有」。
* 顶部是 [SportTypeGrid](横排可滑的 6 个运动类型入口),
* 下方是「最近运动」记录列表。每个运动类型都跟原 [StartWorkoutCard] 一样
* 弹一条 Snackbar 说明该能力尚未开放 —— 弹提示比 `enabled = false` 的半透明按钮好,
* 前者明确告诉用户「为什么现在没有」。
*/
@Composable
private fun WorkoutContent(
@@ -95,8 +129,8 @@ private fun WorkoutContent(
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item {
StartWorkoutCard(
onClick = {
SportTypeGrid(
onSportClick = {
scope.launch { snackbarHostState.showSnackbar(startUnavailableMessage) }
},
)
@@ -123,46 +157,69 @@ private fun WorkoutContent(
}
/**
* 「开始运动」入口卡。
* 运动类型入口网格。
*
* 视觉上做成明确的行动入口:实心绿圆底 + 白色图标 + 右侧箭头。
* 这三点都是「这里可以点」的通用视觉语言,比原先「灰底 + 绿图标 + 两行字」的一行静置文本强得多。
* 横排 6 个类型入口,可滑。
*
* 视觉选择:每格都是「72dp 圆角方块 + MetricGreen 浅色底 + MetricGreen 深色图标 + 类型名下方」
* —— 与 `HealthCard` 的白底区分开(这些是「操作入口」而不是「数据卡片」)。
* 图标背景用 `MetricGreen.copy(alpha = 0.12f)` 而不是小米那种饱和度极高的大色块:
* 设计文档 §7.1 说「颜色是有含义的,不能当装饰用」,所以运动类型不带色彩信号,
* 一律走品牌主色的浅色底,与品牌色图标配对传达「这里是行动入口」。
*/
@Composable
private fun StartWorkoutCard(onClick: () -> Unit, modifier: Modifier = Modifier) {
HealthCard(modifier = modifier, onClick = onClick, contentPadding = PaddingValues(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(46.dp)
.clip(CircleShape)
.background(MetricGreen),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Filled.FitnessCenter,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(24.dp),
)
}
Column(
modifier = Modifier.weight(1f).padding(start = 14.dp),
) {
Text(text = stringResource(R.string.workout_start), style = MaterialTheme.typography.titleMedium)
Text(
text = stringResource(R.string.workout_start_desc),
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
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 = Icons.AutoMirrored.Filled.ArrowForward,
imageVector = icon,
contentDescription = null,
tint = TextSecondary,
modifier = Modifier.size(20.dp),
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,
)
}
}
+76
View File
@@ -159,4 +159,80 @@
<string name="detail_bp_pending">血压需先在设备页完成手动校准,之后才会生成测量记录。</string>
<string name="action_back">返回</string>
<!-- ====== 2026-09-10 晚追加:四个 Tab 收口(首页三件套 / 运动类型网格 / 设备空态 / 我的子页) ====== -->
<!-- 首页:三件套横排(替代原「步数大环 + 距离/千卡」结构)。 -->
<string name="home_today_summary">今日数据</string>
<string name="home_summary_steps_label">步数</string>
<string name="home_summary_steps_value">%1$s 步</string>
<string name="home_summary_steps_goal">目标 %1$s</string>
<string name="home_summary_calories_label">卡路里</string>
<string name="home_summary_calories_value">%1$s 千卡</string>
<string name="home_summary_active_label">活动时长</string>
<string name="home_summary_active_value">%1$d 分钟</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>