From 841f9f1542d7c16d7c97db8e66c986b90ad1e672 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B4=94=E9=B2=81=E6=AD=A3?= <13780741225@163.com> Date: Fri, 11 Sep 2026 08:55:38 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=9B=9B=E4=B8=AA=20Tab=20=E6=94=B6?= =?UTF-8?q?=E5=8F=A3=20=E2=80=94=20=E9=A6=96=E9=A1=B5=E4=B8=89=E4=BB=B6?= =?UTF-8?q?=E5=A5=97=20/=20=E8=BF=90=E5=8A=A8=E7=B1=BB=E5=9E=8B=E7=BD=91?= =?UTF-8?q?=E6=A0=BC=20/=20=E8=AE=BE=E5=A4=87=E7=A9=BA=E6=80=81=20/=20?= =?UTF-8?q?=E6=88=91=E7=9A=84=E5=9B=9B=E4=B8=AA=E7=9C=9F=E5=AD=90=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 新建。 --- .../ringhealth/core/common/Formatters.kt | 3 + .../core/navigation/RingHealthApp.kt | 32 +++- .../ringhealth/core/navigation/Routes.kt | 10 + .../ringhealth/data/mock/MockDataProvider.kt | 13 ++ .../ringhealth/domain/model/HealthModels.kt | 5 + .../ringhealth/feature/device/DeviceScreen.kt | 119 +++++++++++- .../ringhealth/feature/home/HomeScreen.kt | 158 ++++++++------- .../ringhealth/feature/profile/AboutScreen.kt | 161 ++++++++++++++++ .../ringhealth/feature/profile/HelpScreen.kt | 175 +++++++++++++++++ .../feature/profile/NotificationsScreen.kt | 152 +++++++++++++++ .../feature/profile/ProfileScreen.kt | 25 ++- .../feature/profile/SettingsScreen.kt | 180 ++++++++++++++++++ .../feature/workout/WorkoutScreen.kt | 133 +++++++++---- app/src/main/res/values/strings.xml | 76 ++++++++ 14 files changed, 1128 insertions(+), 114 deletions(-) create mode 100644 app/src/main/java/com/placeholder/ringhealth/feature/profile/AboutScreen.kt create mode 100644 app/src/main/java/com/placeholder/ringhealth/feature/profile/HelpScreen.kt create mode 100644 app/src/main/java/com/placeholder/ringhealth/feature/profile/NotificationsScreen.kt create mode 100644 app/src/main/java/com/placeholder/ringhealth/feature/profile/SettingsScreen.kt diff --git a/app/src/main/java/com/placeholder/ringhealth/core/common/Formatters.kt b/app/src/main/java/com/placeholder/ringhealth/core/common/Formatters.kt index 45daa12..14d8902 100644 --- a/app/src/main/java/com/placeholder/ringhealth/core/common/Formatters.kt +++ b/app/src/main/java/com/placeholder/ringhealth/core/common/Formatters.kt @@ -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 diff --git a/app/src/main/java/com/placeholder/ringhealth/core/navigation/RingHealthApp.kt b/app/src/main/java/com/placeholder/ringhealth/core/navigation/RingHealthApp.kt index e0238ff..2722c00 100644 --- a/app/src/main/java/com/placeholder/ringhealth/core/navigation/RingHealthApp.kt +++ b/app/src/main/java/com/placeholder/ringhealth/core/navigation/RingHealthApp.kt @@ -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 离开)。 diff --git a/app/src/main/java/com/placeholder/ringhealth/core/navigation/Routes.kt b/app/src/main/java/com/placeholder/ringhealth/core/navigation/Routes.kt index 4d77439..c15fdf5 100644 --- a/app/src/main/java/com/placeholder/ringhealth/core/navigation/Routes.kt +++ b/app/src/main/java/com/placeholder/ringhealth/core/navigation/Routes.kt @@ -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" diff --git a/app/src/main/java/com/placeholder/ringhealth/data/mock/MockDataProvider.kt b/app/src/main/java/com/placeholder/ringhealth/data/mock/MockDataProvider.kt index ab9148b..c1aff77 100644 --- a/app/src/main/java/com/placeholder/ringhealth/data/mock/MockDataProvider.kt +++ b/app/src/main/java/com/placeholder/ringhealth/data/mock/MockDataProvider.kt @@ -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 diff --git a/app/src/main/java/com/placeholder/ringhealth/domain/model/HealthModels.kt b/app/src/main/java/com/placeholder/ringhealth/domain/model/HealthModels.kt index 14acf21..5cef548 100644 --- a/app/src/main/java/com/placeholder/ringhealth/domain/model/HealthModels.kt +++ b/app/src/main/java/com/placeholder/ringhealth/domain/model/HealthModels.kt @@ -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, ) /** diff --git a/app/src/main/java/com/placeholder/ringhealth/feature/device/DeviceScreen.kt b/app/src/main/java/com/placeholder/ringhealth/feature/device/DeviceScreen.kt index 9be0a7d..b96b3dd 100644 --- a/app/src/main/java/com/placeholder/ringhealth/feature/device/DeviceScreen.kt +++ b/app/src/main/java/com/placeholder/ringhealth/feature/device/DeviceScreen.kt @@ -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 用的假开关列表。 * diff --git a/app/src/main/java/com/placeholder/ringhealth/feature/home/HomeScreen.kt b/app/src/main/java/com/placeholder/ringhealth/feature/home/HomeScreen.kt index 5c03f2b..41a6f23 100644 --- a/app/src/main/java/com/placeholder/ringhealth/feature/home/HomeScreen.kt +++ b/app/src/main/java/com/placeholder/ringhealth/feature/home/HomeScreen.kt @@ -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 表示该指标未开启。 * diff --git a/app/src/main/java/com/placeholder/ringhealth/feature/profile/AboutScreen.kt b/app/src/main/java/com/placeholder/ringhealth/feature/profile/AboutScreen.kt new file mode 100644 index 0000000..6630bd8 --- /dev/null +++ b/app/src/main/java/com/placeholder/ringhealth/feature/profile/AboutScreen.kt @@ -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, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/placeholder/ringhealth/feature/profile/HelpScreen.kt b/app/src/main/java/com/placeholder/ringhealth/feature/profile/HelpScreen.kt new file mode 100644 index 0000000..dd92dbb --- /dev/null +++ b/app/src/main/java/com/placeholder/ringhealth/feature/profile/HelpScreen.kt @@ -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 = 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() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/placeholder/ringhealth/feature/profile/NotificationsScreen.kt b/app/src/main/java/com/placeholder/ringhealth/feature/profile/NotificationsScreen.kt new file mode 100644 index 0000000..ce249b2 --- /dev/null +++ b/app/src/main/java/com/placeholder/ringhealth/feature/profile/NotificationsScreen.kt @@ -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() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/placeholder/ringhealth/feature/profile/ProfileScreen.kt b/app/src/main/java/com/placeholder/ringhealth/feature/profile/ProfileScreen.kt index ed4afbe..201873b 100644 --- a/app/src/main/java/com/placeholder/ringhealth/feature/profile/ProfileScreen.kt +++ b/app/src/main/java/com/placeholder/ringhealth/feature/profile/ProfileScreen.kt @@ -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, diff --git a/app/src/main/java/com/placeholder/ringhealth/feature/profile/SettingsScreen.kt b/app/src/main/java/com/placeholder/ringhealth/feature/profile/SettingsScreen.kt new file mode 100644 index 0000000..2d4f238 --- /dev/null +++ b/app/src/main/java/com/placeholder/ringhealth/feature/profile/SettingsScreen.kt @@ -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() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/placeholder/ringhealth/feature/workout/WorkoutScreen.kt b/app/src/main/java/com/placeholder/ringhealth/feature/workout/WorkoutScreen.kt index decb6dc..c4f07e9 100644 --- a/app/src/main/java/com/placeholder/ringhealth/feature/workout/WorkoutScreen.kt +++ b/app/src/main/java/com/placeholder/ringhealth/feature/workout/WorkoutScreen.kt @@ -1,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.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, + ) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e4bd235..34477e3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -159,4 +159,80 @@ 血压需先在设备页完成手动校准,之后才会生成测量记录。 返回 + + + + + 今日数据 + 步数 + %1$s 步 + 目标 %1$s + 卡路里 + %1$s 千卡 + 活动时长 + %1$d 分钟 + + + 户外跑步 + 健走 + 户外骑行 + 室内跑步 + 跳绳 + 自由训练 + + + 未连接戒指 + 连接戒指后即可查看电量、固件与监测设置 + 扫一扫 + 按型号添加 + + + 应用设置 + 单位 + 距离 + 公里 + 体重 + 公斤 + 通知 + 健康提醒 + 达标 / 异常时推送 + 设备提醒 + 低电量 / 断开连接 + 营销通知 + 活动与产品信息 + + + 通知权限 + 当前状态:%1$s + 已授权 + 未授权 + 通知权限关闭时,健康异常、设备状态等推送将无法送达。开启后本 App 会在系统状态栏推送提醒。 + 请求权限 + 前往系统设置 + + + 关于 + App + App 版本 + 固件版本 + 设备型号 + 操作 + 检查更新 + 已是最新版本 + 当前版本 %1$s + + + 常见问题 + 戒指怎么连? + 在「设备」页点击「添加设备」,扫一扫或按型号添加。 + 数据多久同步一次? + 戒指开启自动监测后,每 10 分钟同步一次到 App。 + 如何切换单位? + 「我的 - 应用设置 - 单位」中切换距离与体重。 + 如何反馈问题? + 可通过邮箱 support@ringhealth.example 联系我们。 + 联系我们 + support@ringhealth.example + 帮助与反馈 + 联系我们