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:
@@ -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,
|
||||
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,
|
||||
)
|
||||
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,
|
||||
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,47 +157,70 @@ 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) {
|
||||
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(46.dp)
|
||||
.size(48.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MetricGreen),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.FitnessCenter,
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(24.dp),
|
||||
modifier = Modifier.size(26.dp),
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.weight(1f).padding(start = 14.dp),
|
||||
) {
|
||||
Text(text = stringResource(R.string.workout_start), style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.workout_start_desc),
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = TextSecondary,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowForward,
|
||||
contentDescription = null,
|
||||
tint = TextSecondary,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 单条运动记录卡片。 */
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user