Compare commits
8
Commits
4805b027e0
...
9c233e5750
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c233e5750 | ||
|
|
0e17502334 | ||
|
|
01689b24fc | ||
|
|
88e88cf0ba | ||
|
|
7170a49664 | ||
|
|
20fe2bce66 | ||
|
|
58573f4657 | ||
|
|
031aea0fa2 |
@@ -5,6 +5,21 @@
|
|||||||
- 设计文档:见 `ring-health-docs` 仓库 `superpowers/specs/`
|
- 设计文档:见 `ring-health-docs` 仓库 `superpowers/specs/`
|
||||||
- 当前阶段:M1 静态页面(数据来自 Mock,未接入蓝牙 SDK)
|
- 当前阶段:M1 静态页面(数据来自 Mock,未接入蓝牙 SDK)
|
||||||
|
|
||||||
|
## 页面清单(M1)
|
||||||
|
|
||||||
|
| 页面 | 路由 |
|
||||||
|
|---|---|
|
||||||
|
| 健康首页 | `home` |
|
||||||
|
| 运动 | `workout` |
|
||||||
|
| 运动历史 | `workout/history` |
|
||||||
|
| 设备 | `device` |
|
||||||
|
| 设备设置 | `device/settings` |
|
||||||
|
| 我的 | `profile` |
|
||||||
|
| 指标详情(步数/心率/睡眠/血氧/压力/体温/血压) | `detail/{metric}` |
|
||||||
|
|
||||||
|
> 页面路由的唯一定义处是 `core/navigation/Routes.kt`,上表与其保持一致。
|
||||||
|
> M1 的验收状态见 `docs/verification-m1.md`。
|
||||||
|
|
||||||
## 构建
|
## 构建
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import androidx.lifecycle.ViewModelProvider
|
|||||||
import androidx.lifecycle.viewmodel.CreationExtras
|
import androidx.lifecycle.viewmodel.CreationExtras
|
||||||
import androidx.lifecycle.viewmodel.initializer
|
import androidx.lifecycle.viewmodel.initializer
|
||||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||||
|
import com.placeholder.ringhealth.feature.device.DeviceViewModel
|
||||||
import com.placeholder.ringhealth.feature.home.HomeViewModel
|
import com.placeholder.ringhealth.feature.home.HomeViewModel
|
||||||
|
import com.placeholder.ringhealth.feature.workout.WorkoutViewModel
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ViewModel 工厂:把容器中的仓库注入各 ViewModel。
|
* ViewModel 工厂:把容器中的仓库注入各 ViewModel。
|
||||||
@@ -19,6 +21,16 @@ object AppViewModelProvider {
|
|||||||
ringDeviceRepository = ringHealthApplication().container.ringDeviceRepository,
|
ringDeviceRepository = ringHealthApplication().container.ringDeviceRepository,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
initializer {
|
||||||
|
WorkoutViewModel(
|
||||||
|
healthDataRepository = ringHealthApplication().container.healthDataRepository,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
initializer {
|
||||||
|
DeviceViewModel(
|
||||||
|
ringDeviceRepository = ringHealthApplication().container.ringDeviceRepository,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ fun formatClock(timestamp: Long): String =
|
|||||||
fun formatDate(timestamp: Long): String =
|
fun formatDate(timestamp: Long): String =
|
||||||
SimpleDateFormat("yyyy-MM-dd", Locale.CHINA).format(Date(timestamp))
|
SimpleDateFormat("yyyy-MM-dd", Locale.CHINA).format(Date(timestamp))
|
||||||
|
|
||||||
|
/** 数值 → 保留一位小数。详情页的统计摘要用(ledger Ruling R29)。 */
|
||||||
|
fun formatDecimal(value: Double): String = String.format(Locale.CHINA, "%.1f", value)
|
||||||
|
|
||||||
/** 压力值 → 字符串资源 id。中文描述的唯一来源是 `strings.xml`(ledger Ruling R13)。 */
|
/** 压力值 → 字符串资源 id。中文描述的唯一来源是 `strings.xml`(ledger Ruling R13)。 */
|
||||||
@StringRes
|
@StringRes
|
||||||
fun stressLevelLabelRes(stress: Int): Int = when {
|
fun stressLevelLabelRes(stress: Int): Int = when {
|
||||||
|
|||||||
@@ -28,11 +28,11 @@ import androidx.navigation.navArgument
|
|||||||
import com.placeholder.ringhealth.R
|
import com.placeholder.ringhealth.R
|
||||||
import com.placeholder.ringhealth.domain.model.MetricType
|
import com.placeholder.ringhealth.domain.model.MetricType
|
||||||
import com.placeholder.ringhealth.feature.device.DeviceScreen
|
import com.placeholder.ringhealth.feature.device.DeviceScreen
|
||||||
import com.placeholder.ringhealth.feature.device.DeviceSettingsPlaceholder
|
import com.placeholder.ringhealth.feature.device.DeviceSettingsScreen
|
||||||
import com.placeholder.ringhealth.feature.detail.MetricDetailPlaceholder
|
import com.placeholder.ringhealth.feature.detail.MetricDetailScreen
|
||||||
import com.placeholder.ringhealth.feature.home.HomeScreen
|
import com.placeholder.ringhealth.feature.home.HomeScreen
|
||||||
import com.placeholder.ringhealth.feature.profile.ProfileScreen
|
import com.placeholder.ringhealth.feature.profile.ProfileScreen
|
||||||
import com.placeholder.ringhealth.feature.workout.WorkoutHistoryPlaceholder
|
import com.placeholder.ringhealth.feature.workout.WorkoutHistoryScreen
|
||||||
import com.placeholder.ringhealth.feature.workout.WorkoutScreen
|
import com.placeholder.ringhealth.feature.workout.WorkoutScreen
|
||||||
|
|
||||||
/** 底部导航项定义。 */
|
/** 底部导航项定义。 */
|
||||||
@@ -49,13 +49,28 @@ private val BOTTOM_TABS = listOf(
|
|||||||
BottomTab(Routes.PROFILE, R.string.tab_profile, Icons.Filled.Person),
|
BottomTab(Routes.PROFILE, R.string.tab_profile, Icons.Filled.Person),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把当前路由归到它所属的底部 Tab。
|
||||||
|
*
|
||||||
|
* 子页位于栈顶时,底栏应当保持父 Tab 点亮。这里必须与 `Routes.DETAIL_PATTERN`
|
||||||
|
* 比较 —— `NavDestination.route` 给出的是路由**模板**而非填充后的实际路由。
|
||||||
|
* 本 App 的路由是扁平注册的(没有嵌套导航图),`destination.hierarchy` 帮不上忙,
|
||||||
|
* 只能显式映射。
|
||||||
|
*/
|
||||||
|
private fun tabRouteFor(route: String?): String? = when (route) {
|
||||||
|
Routes.DETAIL_PATTERN -> Routes.HOME
|
||||||
|
Routes.DEVICE_SETTINGS -> Routes.DEVICE
|
||||||
|
Routes.WORKOUT_HISTORY -> Routes.WORKOUT
|
||||||
|
else -> route
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 应用主界面:Scaffold + 底部导航 + NavHost。
|
* 应用主界面:Scaffold + 底部导航 + NavHost。
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun RingHealthApp(navController: NavHostController = rememberNavController()) {
|
fun RingHealthApp(navController: NavHostController = rememberNavController()) {
|
||||||
val backStackEntry by navController.currentBackStackEntryAsState()
|
val backStackEntry by navController.currentBackStackEntryAsState()
|
||||||
val currentRoute = backStackEntry?.destination?.route
|
val currentRoute = tabRouteFor(backStackEntry?.destination?.route)
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
containerColor = MaterialTheme.colorScheme.background,
|
containerColor = MaterialTheme.colorScheme.background,
|
||||||
@@ -81,27 +96,34 @@ fun RingHealthApp(navController: NavHostController = rememberNavController()) {
|
|||||||
HomeScreen(onOpenDetail = { metric -> navController.navigate(Routes.detail(metric)) })
|
HomeScreen(onOpenDetail = { metric -> navController.navigate(Routes.detail(metric)) })
|
||||||
}
|
}
|
||||||
composable(Routes.WORKOUT) {
|
composable(Routes.WORKOUT) {
|
||||||
WorkoutScreen()
|
WorkoutScreen(onOpenHistory = { navController.navigate(Routes.WORKOUT_HISTORY) })
|
||||||
}
|
}
|
||||||
composable(Routes.DEVICE) {
|
composable(Routes.DEVICE) {
|
||||||
DeviceScreen()
|
DeviceScreen(onOpenSettings = { navController.navigate(Routes.DEVICE_SETTINGS) })
|
||||||
}
|
}
|
||||||
composable(Routes.PROFILE) {
|
composable(Routes.PROFILE) {
|
||||||
ProfileScreen()
|
ProfileScreen()
|
||||||
}
|
}
|
||||||
composable(Routes.DEVICE_SETTINGS) {
|
composable(Routes.DEVICE_SETTINGS) {
|
||||||
DeviceSettingsPlaceholder()
|
DeviceSettingsScreen()
|
||||||
}
|
}
|
||||||
composable(Routes.WORKOUT_HISTORY) {
|
composable(Routes.WORKOUT_HISTORY) {
|
||||||
WorkoutHistoryPlaceholder()
|
WorkoutHistoryScreen()
|
||||||
}
|
}
|
||||||
composable(
|
composable(
|
||||||
route = Routes.DETAIL_PATTERN,
|
route = Routes.DETAIL_PATTERN,
|
||||||
arguments = listOf(navArgument(Routes.DETAIL_ARG_METRIC) { type = NavType.StringType }),
|
arguments = listOf(navArgument(Routes.DETAIL_ARG_METRIC) { type = NavType.StringType }),
|
||||||
) { entry ->
|
) { entry ->
|
||||||
val raw = entry.arguments?.getString(Routes.DETAIL_ARG_METRIC).orEmpty()
|
// 参数无法解析属于编程错误,直接抛(ledger Ruling R22):这里原先是
|
||||||
val metric = MetricType.entries.firstOrNull { it.name == raw } ?: MetricType.STEP
|
// `?: MetricType.STEP`,一旦路由模板与 Routes.detail() 拼串不一致,
|
||||||
MetricDetailPlaceholder(metric = metric)
|
// 页面会静默显示步数的数据而不报错 —— 那是「错数据」而不是「响亮失败」。
|
||||||
|
val raw = requireNotNull(entry.arguments?.getString(Routes.DETAIL_ARG_METRIC)) {
|
||||||
|
"详情路由缺少参数 ${Routes.DETAIL_ARG_METRIC}"
|
||||||
|
}
|
||||||
|
val metric = requireNotNull(MetricType.entries.firstOrNull { it.name == raw }) {
|
||||||
|
"无法识别的指标:$raw"
|
||||||
|
}
|
||||||
|
MetricDetailScreen(metric = metric, onBack = { navController.popBackStack() })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,10 +14,12 @@ object Routes {
|
|||||||
const val DEVICE_SETTINGS = "device/settings"
|
const val DEVICE_SETTINGS = "device/settings"
|
||||||
const val WORKOUT_HISTORY = "workout/history"
|
const val WORKOUT_HISTORY = "workout/history"
|
||||||
|
|
||||||
/** 指标详情路由模板,参数为 MetricType.name。 */
|
/** 路由参数的键名。必须声明在 DETAIL_PATTERN 之前:模板引用它。 */
|
||||||
const val DETAIL_PATTERN = "detail/{metric}"
|
|
||||||
const val DETAIL_ARG_METRIC = "metric"
|
const val DETAIL_ARG_METRIC = "metric"
|
||||||
|
|
||||||
|
/** 指标详情路由模板,参数为 MetricType.name。 */
|
||||||
|
const val DETAIL_PATTERN = "detail/{$DETAIL_ARG_METRIC}"
|
||||||
|
|
||||||
/** 拼出某个指标的详情路由。 */
|
/** 拼出某个指标的详情路由。 */
|
||||||
fun detail(metric: MetricType): String = "detail/${metric.name}"
|
fun detail(metric: MetricType): String = "detail/${metric.name}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,14 @@ class MockRingDeviceRepository(
|
|||||||
_deviceState.value = provider.deviceState()
|
_deviceState.value = provider.deviceState()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换某个功能开关。
|
||||||
|
*
|
||||||
|
* **必须返回新列表**(`map` / `+` 等),不要原地改写同一个 `MutableList`:
|
||||||
|
* `DeviceViewModel` 的 `stateIn(initialValue = …)` 在**构造时**就把当时的 List 引用冻结了,
|
||||||
|
* 而 `DeviceViewModelTest` 第一条用例的鉴别力正依赖于此 —— 若这里改成原地改写,
|
||||||
|
* `initialValue` 会被连带改动,那条断言就退化成自我确认,测试会静默失去意义。
|
||||||
|
*/
|
||||||
override fun setFunctionEnabled(id: String, enabled: Boolean) {
|
override fun setFunctionEnabled(id: String, enabled: Boolean) {
|
||||||
_functionSettings.update { settings ->
|
_functionSettings.update { settings ->
|
||||||
settings.map { if (it.id == id) it.copy(enabled = enabled) else it }
|
settings.map { if (it.id == id) it.copy(enabled = enabled) else it }
|
||||||
|
|||||||
@@ -1,19 +1,331 @@
|
|||||||
package com.placeholder.ringhealth.feature.detail
|
package com.placeholder.ringhealth.feature.detail
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import com.placeholder.ringhealth.R
|
import com.placeholder.ringhealth.R
|
||||||
|
import com.placeholder.ringhealth.core.common.formatClock
|
||||||
|
import com.placeholder.ringhealth.core.common.formatDecimal
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.MetricBlue
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.MetricGreen
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.RingHealthTheme
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.TextSecondary
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.component.HealthCard
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.component.MetricBarChart
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.component.SectionHeader
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.component.StatBar
|
||||||
|
import com.placeholder.ringhealth.core.designsystem.component.StatSegment
|
||||||
|
import com.placeholder.ringhealth.domain.model.DateRange
|
||||||
|
import com.placeholder.ringhealth.domain.model.MetricPoint
|
||||||
import com.placeholder.ringhealth.domain.model.MetricType
|
import com.placeholder.ringhealth.domain.model.MetricType
|
||||||
|
|
||||||
/** 指标详情子页(占位,待 Task 12 实现)。 */
|
/**
|
||||||
|
* 指标详情页(有状态外壳)。
|
||||||
|
*
|
||||||
|
* 本页自带 `Scaffold` + `TopAppBar`(外层 `RingHealthApp` 也有一个 `Scaffold`,
|
||||||
|
* 它传入的 `innerPadding` 已用于 NavHost 的 `Modifier.padding`,底部栏因此保持可见)。
|
||||||
|
* 页面内容全部在 `MetricDetailContent` 里,本函数只负责取状态与传回调(ledger Ruling R26)。
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun MetricDetailPlaceholder(metric: MetricType, modifier: Modifier = Modifier) {
|
fun MetricDetailScreen(
|
||||||
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
metric: MetricType,
|
||||||
Text(stringResource(R.string.placeholder_metric_detail))
|
onBack: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: MetricDetailViewModel = viewModel(factory = MetricDetailViewModel.factory(metric)),
|
||||||
|
) {
|
||||||
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
MetricDetailContent(
|
||||||
|
state = state,
|
||||||
|
onBack = onBack,
|
||||||
|
onSelectRange = viewModel::switchRange,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 无状态指标详情内容:范围切换 + 柱状趋势图 + 统计摘要(+ 睡眠分期)。
|
||||||
|
*
|
||||||
|
* 标题取自 `state.metric` 而不是外面传进来的 `metric` —— 无状态层只有 `state` 一个数据来源,
|
||||||
|
* 免得出现「标题写心率、数据画睡眠」这种两者不一致的可能。
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun MetricDetailContent(
|
||||||
|
state: MetricDetailUiState,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onSelectRange: (DateRange) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Scaffold(
|
||||||
|
modifier = modifier,
|
||||||
|
containerColor = MaterialTheme.colorScheme.background,
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(text = stringResource(metricTitleRes(state.metric))) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = stringResource(R.string.action_back),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { innerPadding ->
|
||||||
|
if (state.loading) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(innerPadding),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(color = MetricGreen)
|
||||||
|
}
|
||||||
|
return@Scaffold
|
||||||
|
}
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(innerPadding),
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
item { RangeSelector(current = state.range, onSelect = onSelectRange) }
|
||||||
|
item {
|
||||||
|
HealthCard {
|
||||||
|
SectionHeader(title = stringResource(R.string.detail_trend))
|
||||||
|
MetricBarChart(
|
||||||
|
values = state.points.map { it.value.toFloat() },
|
||||||
|
color = MetricGreen,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
HealthCard {
|
||||||
|
val values = state.points.map { it.value }
|
||||||
|
StatLine(label = stringResource(R.string.detail_average), value = values.averageOrZero())
|
||||||
|
StatLine(label = stringResource(R.string.detail_max), value = values.maxOrNull() ?: 0.0)
|
||||||
|
StatLine(label = stringResource(R.string.detail_min), value = values.minOrNull() ?: 0.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (state.metric == MetricType.SLEEP) {
|
||||||
|
item { SleepStagesCard(points = state.points) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 睡眠分期卡片:深睡/浅睡/清醒分段条 + 入睡/起床时间(对应 spec §6 的 `detail/sleep`)。
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun SleepStagesCard(points: List<MetricPoint>, modifier: Modifier = Modifier) {
|
||||||
|
// 阶段一没有真实分期数据,按固定比例演示;M3 接入真实睡眠数据后改为按实际时段计算。
|
||||||
|
val segments = listOf(
|
||||||
|
StatSegment(weight = 0.25f, color = MetricBlue),
|
||||||
|
StatSegment(weight = 0.65f, color = MetricBlue.copy(alpha = 0.45f)),
|
||||||
|
StatSegment(weight = 0.10f, color = MetricBlue.copy(alpha = 0.2f)),
|
||||||
|
)
|
||||||
|
HealthCard(modifier = modifier) {
|
||||||
|
SectionHeader(title = stringResource(R.string.detail_sleep_stages))
|
||||||
|
StatBar(segments = segments)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
StageLegend(label = stringResource(R.string.detail_sleep_deep), tint = MetricBlue)
|
||||||
|
StageLegend(label = stringResource(R.string.detail_sleep_light), tint = MetricBlue.copy(alpha = 0.45f))
|
||||||
|
StageLegend(label = stringResource(R.string.detail_sleep_awake), tint = MetricBlue.copy(alpha = 0.2f))
|
||||||
|
}
|
||||||
|
// 入睡/起床演示值(ledger Ruling R29):Mock 的睡眠历史点是「每天一个」,首点是 7 天前、
|
||||||
|
// 末点是现在。直接拿 first/last 当入睡/起床会显示成「睡了 7 天」,在验收时看着像 bug。
|
||||||
|
// 这里改成以最后一个数据点为起床时刻、往前推 8 小时作为入睡时刻 —— 一张卡片讲「一晚」。
|
||||||
|
// M3 接入真实睡眠数据后改为读取实际分期时段。
|
||||||
|
val wakeUp = points.lastOrNull()?.timestamp
|
||||||
|
if (wakeUp != null) {
|
||||||
|
// 只有拿到起床时刻才渲染这一行;入睡时刻由它推出,所以判空只需判 wakeUp
|
||||||
|
// (brief 原文写的是 `fallAsleep != null && wakeUp != null`,Kotlin 会因其恒真而告警,
|
||||||
|
// 语义与此处完全等价)。
|
||||||
|
val fallAsleep = wakeUp - DEMO_SLEEP_WINDOW_MILLIS
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "${stringResource(R.string.detail_sleep_fall_asleep)} ${formatClock(fallAsleep)}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = TextSecondary,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "${stringResource(R.string.detail_sleep_wake_up)} ${formatClock(wakeUp)}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = TextSecondary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 阶段一演示用的「一晚」时长:8 小时。M3 接入真实睡眠数据后删除。 */
|
||||||
|
private const val DEMO_SLEEP_WINDOW_MILLIS = 8L * 60 * 60 * 1000
|
||||||
|
|
||||||
|
/** 分期图例:小色块 + 名称。 */
|
||||||
|
@Composable
|
||||||
|
private fun StageLegend(label: String, tint: Color, modifier: Modifier = Modifier) {
|
||||||
|
Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(10.dp)
|
||||||
|
.clip(RoundedCornerShape(5.dp))
|
||||||
|
.background(tint),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = TextSecondary,
|
||||||
|
modifier = Modifier.padding(start = 6.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 指标类型 → 标题字符串资源。文案的唯一来源是 `strings.xml`(ledger Ruling R3)。 */
|
||||||
|
@Composable
|
||||||
|
private fun metricTitleRes(type: MetricType): Int = when (type) {
|
||||||
|
MetricType.STEP -> R.string.metric_step
|
||||||
|
MetricType.HEART_RATE -> R.string.metric_heart_rate
|
||||||
|
MetricType.SLEEP -> R.string.metric_sleep
|
||||||
|
MetricType.BLOOD_OXYGEN -> R.string.metric_blood_oxygen
|
||||||
|
MetricType.STRESS -> R.string.metric_stress
|
||||||
|
MetricType.TEMPERATURE -> R.string.metric_temperature
|
||||||
|
MetricType.BLOOD_PRESSURE -> R.string.metric_blood_pressure
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 周/月切换。 */
|
||||||
|
@Composable
|
||||||
|
private fun RangeSelector(
|
||||||
|
current: DateRange,
|
||||||
|
onSelect: (DateRange) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
FilterChip(
|
||||||
|
selected = current == DateRange.WEEK,
|
||||||
|
onClick = { onSelect(DateRange.WEEK) },
|
||||||
|
label = { Text(stringResource(R.string.detail_range_week)) },
|
||||||
|
)
|
||||||
|
FilterChip(
|
||||||
|
selected = current == DateRange.MONTH,
|
||||||
|
onClick = { onSelect(DateRange.MONTH) },
|
||||||
|
label = { Text(stringResource(R.string.detail_range_month)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 一行统计摘要。 */
|
||||||
|
@Composable
|
||||||
|
private fun StatLine(label: String, value: Double, modifier: Modifier = Modifier) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Text(text = label, style = MaterialTheme.typography.bodyMedium, color = TextSecondary)
|
||||||
|
// 数值格式化一律走 core/common/Formatters.kt(Step 1c),页面里不写 String.format:
|
||||||
|
// 那是格式串副本,改口径时要满仓库找。
|
||||||
|
Text(text = formatDecimal(value), style = MaterialTheme.typography.bodyMedium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 空列表时返回 0.0,避免 NaN。 */
|
||||||
|
private fun List<Double>.averageOrZero(): Double = if (isEmpty()) 0.0 else average()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preview 用的样例数据:7 个点、每天一个,取值从 `base` 起每点递增 `step`。
|
||||||
|
*
|
||||||
|
* `base` 与 `step` 由调用方按**所在指标的取值区间**给出:`base` 取区间低位,
|
||||||
|
* `step` 取区间跨度的约 1/7,使 7 个点铺满区间的大半。这样柱状图才有可见的高低差
|
||||||
|
* (`MetricBarChart` 按 `value / maxValue` 定高,点值若都挤在一起,7 根柱子会齐平,
|
||||||
|
* 预览就看不出渲染问题),统计摘要的「平均/最高/最低」三行也不会几乎相等。
|
||||||
|
*/
|
||||||
|
private fun previewPoints(base: Double, step: Double): List<MetricPoint> {
|
||||||
|
val dayMillis = 24L * 60 * 60 * 1000
|
||||||
|
val start = 1_700_000_000_000L
|
||||||
|
return List(7) { index -> MetricPoint(timestamp = start + index * dayMillis, value = base + index * step) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 睡眠指标的详情页预览:走 `metric == SLEEP` 分支,带分期卡片。
|
||||||
|
* 取 SLEEP 不是随便挑的 —— 睡眠是唯一有专属区块的指标,预览它才能看见 `SleepStagesCard`。
|
||||||
|
*/
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
private fun MetricDetailContentSleepPreview() {
|
||||||
|
RingHealthTheme {
|
||||||
|
MetricDetailContent(
|
||||||
|
state = MetricDetailUiState(
|
||||||
|
loading = false,
|
||||||
|
metric = MetricType.SLEEP,
|
||||||
|
range = DateRange.WEEK,
|
||||||
|
// 睡眠时长(分钟):330→480,落在 Mock 的 330~510 区间内,跨度 150 分钟,
|
||||||
|
// 柱高从约 69% 递增到 100%,肉眼能看出梯度。
|
||||||
|
points = previewPoints(base = 330.0, step = 25.0),
|
||||||
|
),
|
||||||
|
onBack = {},
|
||||||
|
onSelectRange = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 非睡眠指标的详情页预览:走「只有趋势图 + 统计摘要」的分支。
|
||||||
|
* 两个 Preview 各覆盖一个分支,是因为只看睡眠那个无法发现「分期卡片漏判 metric、所有指标都显示」这类回归。
|
||||||
|
*/
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
private fun MetricDetailContentStepsPreview() {
|
||||||
|
RingHealthTheme {
|
||||||
|
MetricDetailContent(
|
||||||
|
state = MetricDetailUiState(
|
||||||
|
loading = false,
|
||||||
|
metric = MetricType.STEP,
|
||||||
|
range = DateRange.WEEK,
|
||||||
|
// 步数:3200→11000,落在 Mock 的 3000~12000 区间内,跨度 7800 步,
|
||||||
|
// 柱高从约 27% 递增到 100%。原来的 7500 起点会让 7 根柱子全挤在 99%~100% 高度上,
|
||||||
|
// 看起来是一条平带 —— 恰是预览本该暴露的问题。
|
||||||
|
points = previewPoints(base = 3200.0, step = 1300.0),
|
||||||
|
),
|
||||||
|
onBack = {},
|
||||||
|
onSelectRange = {},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package com.placeholder.ringhealth.feature.detail
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import androidx.lifecycle.viewmodel.initializer
|
||||||
|
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||||
|
import com.placeholder.ringhealth.domain.model.DateRange
|
||||||
|
import com.placeholder.ringhealth.domain.model.MetricPoint
|
||||||
|
import com.placeholder.ringhealth.domain.model.MetricType
|
||||||
|
import com.placeholder.ringhealth.domain.repository.HealthDataRepository
|
||||||
|
import com.placeholder.ringhealth.ringHealthApplication
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
|
||||||
|
/** 指标详情 UI 状态。 */
|
||||||
|
data class MetricDetailUiState(
|
||||||
|
val loading: Boolean,
|
||||||
|
val metric: MetricType,
|
||||||
|
val range: DateRange,
|
||||||
|
val points: List<MetricPoint>,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 指标详情 ViewModel。因需要指标参数,使用独立工厂创建。
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class MetricDetailViewModel(
|
||||||
|
private val metric: MetricType,
|
||||||
|
healthDataRepository: HealthDataRepository,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val range = MutableStateFlow(DateRange.WEEK)
|
||||||
|
|
||||||
|
val uiState: StateFlow<MetricDetailUiState> = range
|
||||||
|
.flatMapLatest { currentRange ->
|
||||||
|
healthDataRepository.observeMetricHistory(metric, currentRange)
|
||||||
|
.map { points ->
|
||||||
|
MetricDetailUiState(
|
||||||
|
loading = false,
|
||||||
|
metric = metric,
|
||||||
|
range = currentRange,
|
||||||
|
points = points,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(STOP_TIMEOUT_MILLIS),
|
||||||
|
initialValue = MetricDetailUiState(
|
||||||
|
loading = true,
|
||||||
|
metric = metric,
|
||||||
|
range = DateRange.WEEK,
|
||||||
|
points = emptyList(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 切换时间范围(周/月)。 */
|
||||||
|
fun switchRange(newRange: DateRange) {
|
||||||
|
range.update { newRange }
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val STOP_TIMEOUT_MILLIS = 5_000L
|
||||||
|
|
||||||
|
/** 为指定指标构造 ViewModel 工厂。 */
|
||||||
|
fun factory(metric: MetricType): ViewModelProvider.Factory = viewModelFactory {
|
||||||
|
initializer {
|
||||||
|
MetricDetailViewModel(
|
||||||
|
metric = metric,
|
||||||
|
healthDataRepository = ringHealthApplication().container.healthDataRepository,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +1,179 @@
|
|||||||
package com.placeholder.ringhealth.feature.device
|
package com.placeholder.ringhealth.feature.device
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Box
|
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.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Bluetooth
|
||||||
|
import androidx.compose.material.icons.filled.Watch
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
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.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.domain.model.DeviceFunctionSetting
|
||||||
|
import com.placeholder.ringhealth.domain.model.RingDeviceState
|
||||||
|
|
||||||
/** 设备页(占位,待 Task 10 实现)。 */
|
/** 设备(戒指管理)页。 */
|
||||||
@Composable
|
@Composable
|
||||||
fun DeviceScreen(modifier: Modifier = Modifier) {
|
fun DeviceScreen(
|
||||||
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
onOpenSettings: () -> Unit,
|
||||||
Text(stringResource(R.string.tab_device))
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: DeviceViewModel = viewModel(factory = AppViewModelProvider.Factory),
|
||||||
|
) {
|
||||||
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
DeviceContent(state = state, onOpenSettings = onOpenSettings, modifier = modifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 无状态设备页内容。 */
|
||||||
|
@Composable
|
||||||
|
private fun DeviceContent(
|
||||||
|
state: DeviceUiState,
|
||||||
|
onOpenSettings: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
item { DeviceInfoCard(state = state) }
|
||||||
|
item {
|
||||||
|
SectionHeader(
|
||||||
|
title = stringResource(R.string.device_settings_title),
|
||||||
|
trailing = {
|
||||||
|
TextButton(onClick = onOpenSettings) {
|
||||||
|
Text(text = stringResource(R.string.device_more))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
items(state.settings) { setting ->
|
||||||
|
SettingRow(setting = setting)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 戒指信息卡片。 */
|
||||||
|
@Composable
|
||||||
|
private fun DeviceInfoCard(state: DeviceUiState, modifier: Modifier = Modifier) {
|
||||||
|
HealthCard(modifier = modifier) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Watch,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MetricGreen,
|
||||||
|
modifier = Modifier.size(32.dp),
|
||||||
|
)
|
||||||
|
Column(modifier = Modifier.padding(start = 12.dp)) {
|
||||||
|
Text(text = state.device.name, style = MaterialTheme.typography.titleMedium)
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Bluetooth,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = if (state.device.connected) MetricGreen else TextSecondary,
|
||||||
|
modifier = Modifier.size(14.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(
|
||||||
|
if (state.device.connected) R.string.device_connected else R.string.device_disconnected,
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = TextSecondary,
|
||||||
|
modifier = Modifier.padding(start = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.device_battery, state.device.batteryPercent),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.device_firmware, state.device.firmwareVersion),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = TextSecondary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单行设置项(只读展示,开关在详情页操作)。 */
|
||||||
|
@Composable
|
||||||
|
private fun SettingRow(setting: DeviceFunctionSetting, modifier: Modifier = Modifier) {
|
||||||
|
HealthCard(modifier = modifier) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(text = setting.title, style = MaterialTheme.typography.titleSmall)
|
||||||
|
Text(text = setting.subtitle, style = MaterialTheme.typography.bodySmall, color = TextSecondary)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = stringResource(
|
||||||
|
if (setting.enabled) R.string.device_switch_on else R.string.device_switch_off,
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = if (setting.enabled) MetricGreen else TextSecondary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 预览入口:以固定示例数据渲染 [DeviceContent],便于在 IDE 中免真机查看设备页效果。 */
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
private fun DeviceContentPreview() {
|
||||||
|
RingHealthTheme {
|
||||||
|
DeviceContent(
|
||||||
|
state = DeviceUiState(
|
||||||
|
device = RingDeviceState(true, "RingHealth 智能戒指", 78, "1.0.5", false),
|
||||||
|
settings = previewSettings(),
|
||||||
|
),
|
||||||
|
onOpenSettings = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preview 用的假开关列表。
|
||||||
|
*
|
||||||
|
* `internal` 而非 `private`:`DeviceSettingsScreen.kt` 的 Preview 也要用。
|
||||||
|
* 取值与 `MockDataProvider.defaultFunctionSettings()` 保持一致,
|
||||||
|
* 免得 Preview 里看到的和跑起来看到的不是同一份数据。
|
||||||
|
*/
|
||||||
|
internal fun previewSettings(): List<DeviceFunctionSetting> = listOf(
|
||||||
|
DeviceFunctionSetting("heart_rate", "自动心率监测", "每 10 分钟测量一次", true),
|
||||||
|
DeviceFunctionSetting("blood_oxygen", "自动血氧监测", "夜间持续监测", true),
|
||||||
|
DeviceFunctionSetting("sleep", "睡眠监测", "自动识别睡眠时段", true),
|
||||||
|
DeviceFunctionSetting("stress", "压力监测", "全天候压力评估", false),
|
||||||
|
DeviceFunctionSetting("blood_pressure", "血压监测", "需在 App 内手动校准", false),
|
||||||
|
)
|
||||||
|
|||||||
+94
-5
@@ -1,18 +1,107 @@
|
|||||||
package com.placeholder.ringhealth.feature.device
|
package com.placeholder.ringhealth.feature.device
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Box
|
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.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.SwitchDefaults
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import 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.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.domain.model.DeviceFunctionSetting
|
||||||
|
|
||||||
/** 设备设置子页(占位,待 Task 10 实现)。 */
|
/**
|
||||||
|
* 设备设置子页:可交互的功能开关列表。
|
||||||
|
* M1 只改内存状态;M2 后写入设备(对应协议 0502/0504)。
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun DeviceSettingsPlaceholder(modifier: Modifier = Modifier) {
|
fun DeviceSettingsScreen(
|
||||||
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
modifier: Modifier = Modifier,
|
||||||
Text(stringResource(R.string.placeholder_device_settings))
|
viewModel: DeviceViewModel = viewModel(factory = AppViewModelProvider.Factory),
|
||||||
|
) {
|
||||||
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
DeviceSettingsContent(
|
||||||
|
settings = state.settings,
|
||||||
|
onToggle = viewModel::setFunctionEnabled,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 无状态设备设置内容。
|
||||||
|
* 与 `DeviceContent` 同样拆出独立函数,Preview 才能喂假数据渲染(ledger Ruling R26)。
|
||||||
|
* 注意回调签名是 `(id, enabled)` 而不是 `(DeviceFunctionSetting, Boolean)`:
|
||||||
|
* 无状态层不该知道「怎么改」,只负责把「改哪一项、改成什么」报上去。
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun DeviceSettingsContent(
|
||||||
|
settings: List<DeviceFunctionSetting>,
|
||||||
|
onToggle: (String, Boolean) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
item { SectionHeader(title = stringResource(R.string.device_settings_title)) }
|
||||||
|
items(settings) { setting ->
|
||||||
|
HealthCard {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(text = setting.title, style = MaterialTheme.typography.titleSmall)
|
||||||
|
Text(text = setting.subtitle, style = MaterialTheme.typography.bodySmall, color = TextSecondary)
|
||||||
|
}
|
||||||
|
Switch(
|
||||||
|
checked = setting.enabled,
|
||||||
|
onCheckedChange = { checked -> onToggle(setting.id, checked) },
|
||||||
|
// SwitchDefaults 走正常 import,不写字面量全限定名(与其余文件的 import 风格一致)
|
||||||
|
colors = SwitchDefaults.colors(checkedTrackColor = MetricGreen),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.device_settings_hint),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = TextSecondary,
|
||||||
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 预览入口:以固定示例数据渲染 [DeviceSettingsContent],便于在 IDE 中免真机查看开关列表效果。 */
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
private fun DeviceSettingsContentPreview() {
|
||||||
|
RingHealthTheme {
|
||||||
|
DeviceSettingsContent(settings = previewSettings(), onToggle = { _, _ -> })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package com.placeholder.ringhealth.feature.device
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.placeholder.ringhealth.domain.model.DeviceFunctionSetting
|
||||||
|
import com.placeholder.ringhealth.domain.model.RingDeviceState
|
||||||
|
import com.placeholder.ringhealth.domain.repository.RingDeviceRepository
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
|
||||||
|
/** 设备页 UI 状态。 */
|
||||||
|
data class DeviceUiState(
|
||||||
|
val device: RingDeviceState,
|
||||||
|
val settings: List<DeviceFunctionSetting>,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 设备页 ViewModel:设备状态 + 功能开关。 */
|
||||||
|
class DeviceViewModel(
|
||||||
|
private val ringDeviceRepository: RingDeviceRepository,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
val uiState: StateFlow<DeviceUiState> = combine(
|
||||||
|
ringDeviceRepository.deviceState,
|
||||||
|
ringDeviceRepository.functionSettings,
|
||||||
|
) { device, settings ->
|
||||||
|
DeviceUiState(device = device, settings = settings)
|
||||||
|
}.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(STOP_TIMEOUT_MILLIS),
|
||||||
|
initialValue = DeviceUiState(
|
||||||
|
device = ringDeviceRepository.deviceState.value,
|
||||||
|
settings = ringDeviceRepository.functionSettings.value,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 切换功能开关(M1 仅改内存,M2 后同步到设备)。 */
|
||||||
|
fun setFunctionEnabled(id: String, enabled: Boolean) {
|
||||||
|
ringDeviceRepository.setFunctionEnabled(id, enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val STOP_TIMEOUT_MILLIS = 5_000L
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +1,138 @@
|
|||||||
package com.placeholder.ringhealth.feature.profile
|
package com.placeholder.ringhealth.feature.profile
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Box
|
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.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
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.AccountCircle
|
||||||
|
import androidx.compose.material.icons.filled.Info
|
||||||
|
import androidx.compose.material.icons.filled.Notifications
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import com.placeholder.ringhealth.R
|
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
|
||||||
|
|
||||||
/** 我的页(占位,待 Task 11 实现)。 */
|
/**
|
||||||
|
* “我的”页。全部为静态入口,无状态、无 ViewModel。
|
||||||
|
*
|
||||||
|
* 本页**不需要**再拆「有状态外壳 + 无状态内容」(ledger Ruling R26 的常规形状):
|
||||||
|
* 它本身就是无状态的,直接对它挂 `@Preview` 即可。
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ProfileScreen(modifier: Modifier = Modifier) {
|
fun ProfileScreen(modifier: Modifier = Modifier) {
|
||||||
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
LazyColumn(
|
||||||
Text(stringResource(R.string.tab_profile))
|
modifier = modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
HealthCard {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.AccountCircle,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MetricGreen,
|
||||||
|
modifier = Modifier.size(48.dp),
|
||||||
|
)
|
||||||
|
Column(modifier = Modifier.padding(start = 12.dp)) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.profile_nickname),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.profile_login_hint),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = TextSecondary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item { SectionHeader(title = stringResource(R.string.profile_group_app)) }
|
||||||
|
item {
|
||||||
|
ProfileEntry(
|
||||||
|
icon = Icons.Filled.Settings,
|
||||||
|
title = stringResource(R.string.profile_units),
|
||||||
|
subtitle = stringResource(R.string.profile_units_desc),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
ProfileEntry(
|
||||||
|
icon = Icons.Filled.Notifications,
|
||||||
|
title = stringResource(R.string.profile_notification),
|
||||||
|
subtitle = stringResource(R.string.profile_notification_desc),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item { SectionHeader(title = stringResource(R.string.profile_group_about)) }
|
||||||
|
item {
|
||||||
|
ProfileEntry(
|
||||||
|
icon = Icons.Filled.Refresh,
|
||||||
|
title = stringResource(R.string.profile_check_update),
|
||||||
|
subtitle = stringResource(R.string.profile_check_update_desc),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
ProfileEntry(
|
||||||
|
icon = Icons.Filled.Info,
|
||||||
|
title = stringResource(R.string.profile_help),
|
||||||
|
subtitle = stringResource(R.string.profile_help_desc),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单条设置入口。 */
|
||||||
|
@Composable
|
||||||
|
private fun ProfileEntry(
|
||||||
|
icon: ImageVector,
|
||||||
|
title: String,
|
||||||
|
subtitle: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
HealthCard(modifier = modifier) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = TextSecondary,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
)
|
||||||
|
Column(modifier = Modifier.padding(start = 12.dp)) {
|
||||||
|
Text(text = title, style = MaterialTheme.typography.titleSmall)
|
||||||
|
Text(text = subtitle, style = MaterialTheme.typography.bodySmall, color = TextSecondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 预览入口:直接渲染 [ProfileScreen](本页无状态、无示例数据),便于在 IDE 中免真机查看“我的”页效果。 */
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
private fun ProfileScreenPreview() {
|
||||||
|
RingHealthTheme {
|
||||||
|
ProfileScreen()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+93
-4
@@ -1,18 +1,107 @@
|
|||||||
package com.placeholder.ringhealth.feature.workout
|
package com.placeholder.ringhealth.feature.workout
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import 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.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.domain.model.WorkoutRecord
|
||||||
|
|
||||||
/** 运动历史子页(占位,待 Task 9 实现)。 */
|
/** 运动历史子页:展示全部运动记录。 */
|
||||||
@Composable
|
@Composable
|
||||||
fun WorkoutHistoryPlaceholder(modifier: Modifier = Modifier) {
|
fun WorkoutHistoryScreen(
|
||||||
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
modifier: Modifier = Modifier,
|
||||||
Text(stringResource(R.string.placeholder_workout_history))
|
viewModel: WorkoutViewModel = viewModel(factory = AppViewModelProvider.Factory),
|
||||||
|
) {
|
||||||
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
WorkoutHistoryContent(state = state, modifier = modifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 无状态运动历史内容,便于 Preview。 */
|
||||||
|
@Composable
|
||||||
|
private fun WorkoutHistoryContent(
|
||||||
|
state: WorkoutUiState,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
// 必须先判 loading 再判空(ledger Ruling R25):Mock 仓库有 200ms 延迟,
|
||||||
|
// records 在加载完成前就是 emptyList,若先判空会先闪一下「暂无运动记录」,
|
||||||
|
// 数据到了再跳成列表 —— 用户看到的是「明明有记录却说没有」。
|
||||||
|
if (state.loading) {
|
||||||
|
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
CircularProgressIndicator(color = MetricGreen)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (state.records.isEmpty()) {
|
||||||
|
Box(
|
||||||
|
modifier = modifier.fillMaxSize().padding(16.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.workout_empty),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = TextSecondary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
LazyColumn(
|
||||||
|
modifier = modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
items(state.records) { record ->
|
||||||
|
WorkoutRecordCard(record = record)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 预览入口:以固定示例数据渲染 [WorkoutHistoryContent],便于在 IDE 中免真机查看历史页效果。 */
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
private fun WorkoutHistoryContentPreview() {
|
||||||
|
RingHealthTheme {
|
||||||
|
WorkoutHistoryContent(
|
||||||
|
state = WorkoutUiState(
|
||||||
|
loading = false,
|
||||||
|
records = listOf(
|
||||||
|
WorkoutRecord(
|
||||||
|
id = "preview-0",
|
||||||
|
type = "户外跑步",
|
||||||
|
startTime = 1_757_000_000_000L,
|
||||||
|
durationMinutes = 42,
|
||||||
|
distanceKm = 5.20,
|
||||||
|
calories = 286,
|
||||||
|
),
|
||||||
|
WorkoutRecord(
|
||||||
|
id = "preview-1",
|
||||||
|
type = "健走",
|
||||||
|
startTime = 1_756_900_000_000L,
|
||||||
|
durationMinutes = 31,
|
||||||
|
distanceKm = 2.80,
|
||||||
|
calories = 143,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,175 @@
|
|||||||
package com.placeholder.ringhealth.feature.workout
|
package com.placeholder.ringhealth.feature.workout
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.FitnessCenter
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
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.R
|
||||||
|
import com.placeholder.ringhealth.core.common.formatCalories
|
||||||
|
import com.placeholder.ringhealth.core.common.formatClock
|
||||||
|
import com.placeholder.ringhealth.core.common.formatDate
|
||||||
|
import com.placeholder.ringhealth.core.common.formatDistance
|
||||||
|
import com.placeholder.ringhealth.core.common.formatDuration
|
||||||
|
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.domain.model.WorkoutRecord
|
||||||
|
|
||||||
/** 运动页(占位,待 Task 9 实现)。 */
|
/** 运动页。 */
|
||||||
@Composable
|
@Composable
|
||||||
fun WorkoutScreen(modifier: Modifier = Modifier) {
|
fun WorkoutScreen(
|
||||||
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
onOpenHistory: () -> Unit,
|
||||||
Text(stringResource(R.string.tab_workout))
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: WorkoutViewModel = viewModel(factory = AppViewModelProvider.Factory),
|
||||||
|
) {
|
||||||
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
WorkoutContent(state = state, onOpenHistory = onOpenHistory, modifier = modifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 无状态运动页内容。 */
|
||||||
|
@Composable
|
||||||
|
private fun WorkoutContent(
|
||||||
|
state: WorkoutUiState,
|
||||||
|
onOpenHistory: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
if (state.loading) {
|
||||||
|
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
CircularProgressIndicator(color = MetricGreen)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier = modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
HealthCard {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.FitnessCenter,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MetricGreen,
|
||||||
|
modifier = Modifier.size(28.dp),
|
||||||
|
)
|
||||||
|
Column(modifier = Modifier.padding(start = 12.dp)) {
|
||||||
|
Text(text = stringResource(R.string.workout_start), style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.workout_start_desc),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = TextSecondary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
SectionHeader(
|
||||||
|
title = stringResource(R.string.workout_recent),
|
||||||
|
trailing = {
|
||||||
|
TextButton(onClick = onOpenHistory) {
|
||||||
|
Text(text = stringResource(R.string.action_view_all))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
items(state.records) { record ->
|
||||||
|
WorkoutRecordCard(record = record)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单条运动记录卡片。 */
|
||||||
|
@Composable
|
||||||
|
internal fun WorkoutRecordCard(record: WorkoutRecord, modifier: Modifier = Modifier) {
|
||||||
|
HealthCard(modifier = modifier) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
Text(text = record.type, style = MaterialTheme.typography.titleSmall)
|
||||||
|
// 日期 + 空格 + 时间。这里**不**违反「不硬编码文案」(ledger Ruling R25):
|
||||||
|
// 两个值都由 Formatters 做本地化格式化(SimpleDateFormat + Locale.CHINA),
|
||||||
|
// 中间的连接符是一个空格,不含任何需要翻译的文字;也没有第二份资源与它重复。
|
||||||
|
Text(
|
||||||
|
text = "${formatDate(record.startTime)} ${formatClock(record.startTime)}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = TextSecondary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Column(horizontalAlignment = Alignment.End) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.workout_item_summary, formatDuration(record.durationMinutes), formatDistance(record.distanceKm)),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.workout_detail_calories, formatCalories(record.calories)),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = TextSecondary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 预览入口:以固定示例数据渲染 [WorkoutContent],便于在 IDE 中免真机查看运动页效果。 */
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
private fun WorkoutContentPreview() {
|
||||||
|
RingHealthTheme {
|
||||||
|
WorkoutContent(
|
||||||
|
state = WorkoutUiState(
|
||||||
|
loading = false,
|
||||||
|
records = listOf(
|
||||||
|
WorkoutRecord(
|
||||||
|
id = "preview-0",
|
||||||
|
type = "户外跑步",
|
||||||
|
startTime = 1_757_000_000_000L,
|
||||||
|
durationMinutes = 42,
|
||||||
|
distanceKm = 5.20,
|
||||||
|
calories = 286,
|
||||||
|
),
|
||||||
|
WorkoutRecord(
|
||||||
|
id = "preview-1",
|
||||||
|
type = "健走",
|
||||||
|
startTime = 1_756_900_000_000L,
|
||||||
|
durationMinutes = 31,
|
||||||
|
distanceKm = 2.80,
|
||||||
|
calories = 143,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onOpenHistory = {},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package com.placeholder.ringhealth.feature.workout
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.placeholder.ringhealth.domain.model.WorkoutRecord
|
||||||
|
import com.placeholder.ringhealth.domain.repository.HealthDataRepository
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
|
||||||
|
/** 运动页 UI 状态。 */
|
||||||
|
data class WorkoutUiState(
|
||||||
|
val loading: Boolean,
|
||||||
|
val records: List<WorkoutRecord>,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 运动页 ViewModel。 */
|
||||||
|
class WorkoutViewModel(
|
||||||
|
healthDataRepository: HealthDataRepository,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
/** 页面状态:仓库数据到达前为 loading,到达后为记录列表。 */
|
||||||
|
val uiState: StateFlow<WorkoutUiState> = healthDataRepository.observeWorkouts()
|
||||||
|
.map { WorkoutUiState(loading = false, records = it) }
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(STOP_TIMEOUT_MILLIS),
|
||||||
|
initialValue = WorkoutUiState(loading = true, records = emptyList()),
|
||||||
|
)
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val STOP_TIMEOUT_MILLIS = 5_000L
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,9 +11,6 @@
|
|||||||
<string name="tab_workout">运动</string>
|
<string name="tab_workout">运动</string>
|
||||||
<string name="tab_device">设备</string>
|
<string name="tab_device">设备</string>
|
||||||
<string name="tab_profile">我的</string>
|
<string name="tab_profile">我的</string>
|
||||||
<string name="placeholder_device_settings">设备设置</string>
|
|
||||||
<string name="placeholder_workout_history">运动历史</string>
|
|
||||||
<string name="placeholder_metric_detail">指标详情</string>
|
|
||||||
|
|
||||||
<string name="home_device_connected">%1$s · 已连接</string>
|
<string name="home_device_connected">%1$s · 已连接</string>
|
||||||
<string name="home_device_disconnected">未连接设备</string>
|
<string name="home_device_disconnected">未连接设备</string>
|
||||||
@@ -36,4 +33,49 @@
|
|||||||
<string name="metric_temperature">体温</string>
|
<string name="metric_temperature">体温</string>
|
||||||
<string name="metric_blood_pressure">血压</string>
|
<string name="metric_blood_pressure">血压</string>
|
||||||
<string name="metric_step">步数</string>
|
<string name="metric_step">步数</string>
|
||||||
|
|
||||||
|
<string name="workout_start">开始运动</string>
|
||||||
|
<string name="workout_start_desc">选择运动类型,记录你的训练</string>
|
||||||
|
<string name="workout_recent">最近运动</string>
|
||||||
|
<string name="workout_empty">暂无运动记录</string>
|
||||||
|
<string name="workout_item_summary">%1$s · %2$s 公里</string>
|
||||||
|
<string name="workout_detail_calories">消耗 %1$s 千卡</string>
|
||||||
|
<string name="action_view_all">查看全部</string>
|
||||||
|
|
||||||
|
<string name="device_connected">已连接</string>
|
||||||
|
<string name="device_disconnected">未连接</string>
|
||||||
|
<string name="device_battery">电量 %1$d%%</string>
|
||||||
|
<string name="device_firmware">固件版本 %1$s</string>
|
||||||
|
<string name="device_settings_title">功能设置</string>
|
||||||
|
<string name="device_more">更多设置</string>
|
||||||
|
<string name="device_switch_on">已开启</string>
|
||||||
|
<string name="device_switch_off">已关闭</string>
|
||||||
|
<string name="device_settings_hint">阶段一为演示开关,接入戒指后将同步到设备。</string>
|
||||||
|
|
||||||
|
<string name="profile_nickname">健康用户</string>
|
||||||
|
<string name="profile_login_hint">登录后可同步数据(阶段一未开放)</string>
|
||||||
|
<string name="profile_group_app">应用设置</string>
|
||||||
|
<string name="profile_units">单位设置</string>
|
||||||
|
<string name="profile_units_desc">公里 / 公斤</string>
|
||||||
|
<string name="profile_notification">通知权限</string>
|
||||||
|
<string name="profile_notification_desc">接收健康提醒</string>
|
||||||
|
<string name="profile_group_about">关于</string>
|
||||||
|
<string name="profile_check_update">检查更新</string>
|
||||||
|
<string name="profile_check_update_desc">当前版本 0.1.0</string>
|
||||||
|
<string name="profile_help">帮助与反馈</string>
|
||||||
|
<string name="profile_help_desc">常见问题与意见反馈</string>
|
||||||
|
|
||||||
|
<string name="detail_range_week">近 7 天</string>
|
||||||
|
<string name="detail_range_month">近 30 天</string>
|
||||||
|
<string name="detail_average">平均</string>
|
||||||
|
<string name="detail_max">最高</string>
|
||||||
|
<string name="detail_min">最低</string>
|
||||||
|
<string name="detail_trend">趋势</string>
|
||||||
|
<string name="detail_sleep_stages">睡眠分期</string>
|
||||||
|
<string name="detail_sleep_deep">深睡</string>
|
||||||
|
<string name="detail_sleep_light">浅睡</string>
|
||||||
|
<string name="detail_sleep_awake">清醒</string>
|
||||||
|
<string name="detail_sleep_fall_asleep">入睡时间</string>
|
||||||
|
<string name="detail_sleep_wake_up">起床时间</string>
|
||||||
|
<string name="action_back">返回</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -39,6 +39,13 @@ class FormattersTest {
|
|||||||
assertEquals("1,234", formatCalories(1234))
|
assertEquals("1,234", formatCalories(1234))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `formatDecimal 保留一位小数`() {
|
||||||
|
assertEquals("72.0", formatDecimal(72.0))
|
||||||
|
assertEquals("98.6", formatDecimal(98.56))
|
||||||
|
assertEquals("0.0", formatDecimal(0.0))
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `stressLevelLabelRes 按区间映射到对应字符串资源`() {
|
fun `stressLevelLabelRes 按区间映射到对应字符串资源`() {
|
||||||
assertEquals(R.string.stress_relaxed, stressLevelLabelRes(20))
|
assertEquals(R.string.stress_relaxed, stressLevelLabelRes(20))
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.placeholder.ringhealth.core.navigation
|
||||||
|
|
||||||
|
import com.placeholder.ringhealth.domain.model.MetricType
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/** 路由表的一致性测试:模板与实际拼出的路由必须始终吻合(ledger Ruling R22)。 */
|
||||||
|
class RoutesTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `detail 拼出的路由能被详情模板匹配`() {
|
||||||
|
val expectedPrefix = Routes.DETAIL_PATTERN.substringBefore("{")
|
||||||
|
MetricType.entries.forEach { metric ->
|
||||||
|
val route = Routes.detail(metric)
|
||||||
|
assertTrue("$route 不以 $expectedPrefix 开头", route.startsWith(expectedPrefix))
|
||||||
|
assertEquals(metric.name, route.removePrefix(expectedPrefix))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `占位符名与参数常量一致`() {
|
||||||
|
assertTrue(Routes.DETAIL_PATTERN.contains("{${Routes.DETAIL_ARG_METRIC}}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
+101
@@ -0,0 +1,101 @@
|
|||||||
|
package com.placeholder.ringhealth.feature.detail
|
||||||
|
|
||||||
|
import com.placeholder.ringhealth.data.mock.MockHealthDataRepository
|
||||||
|
import com.placeholder.ringhealth.domain.model.DateRange
|
||||||
|
import com.placeholder.ringhealth.domain.model.MetricType
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
|
import kotlinx.coroutines.test.resetMain
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlinx.coroutines.test.setMain
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/** 指标详情 ViewModel 测试。 */
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class MetricDetailViewModelTest {
|
||||||
|
|
||||||
|
private val dispatcher = StandardTestDispatcher()
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
Dispatchers.setMain(dispatcher)
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
Dispatchers.resetMain()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `默认输出近 7 天的数据`() = runTest(dispatcher) {
|
||||||
|
val viewModel = MetricDetailViewModel(MetricType.HEART_RATE, MockHealthDataRepository())
|
||||||
|
backgroundScope.launch { viewModel.uiState.collect {} }
|
||||||
|
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
val state = viewModel.uiState.value
|
||||||
|
assertFalse(state.loading)
|
||||||
|
assertEquals(MetricType.HEART_RATE, state.metric)
|
||||||
|
assertEquals(DateRange.WEEK, state.range)
|
||||||
|
assertEquals(7, state.points.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `switchRange 切到月度后变成 30 个点`() = runTest(dispatcher) {
|
||||||
|
val viewModel = MetricDetailViewModel(MetricType.HEART_RATE, MockHealthDataRepository())
|
||||||
|
backgroundScope.launch { viewModel.uiState.collect {} }
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
viewModel.switchRange(DateRange.MONTH)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
val state = viewModel.uiState.value
|
||||||
|
// 三个断言缺一不可:只断言点数,无法发现「上游没重新订阅、只是把旧数据换了标签」;
|
||||||
|
// 只断言 range,无法发现 flatMapLatest 没生效。
|
||||||
|
assertEquals(DateRange.MONTH, state.range)
|
||||||
|
assertEquals(30, state.points.size)
|
||||||
|
assertEquals(MetricType.HEART_RATE, state.metric)
|
||||||
|
|
||||||
|
viewModel.switchRange(DateRange.WEEK)
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertEquals(7, viewModel.uiState.value.points.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `SLEEP 指标查到的是睡眠区间的数据`() = runTest(dispatcher) {
|
||||||
|
// 名字不叫「构造时传入的 metric 被原样带进状态」——本用例真正锁的是
|
||||||
|
// 「构造函数的 metric 被传给了仓库查询」,而不是状态字段回显(ledger R34)。
|
||||||
|
// 也不要写成「标题映射不同」:`metricTitleRes` 是 @Composable,JVM 测试里根本调不了,
|
||||||
|
// 那样命名会让人以为这里验了标题映射(其实没有,映射由 `when` 的穷尽性在编译期保证)。
|
||||||
|
val viewModel = MetricDetailViewModel(MetricType.SLEEP, MockHealthDataRepository())
|
||||||
|
backgroundScope.launch { viewModel.uiState.collect {} }
|
||||||
|
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
val state = viewModel.uiState.value
|
||||||
|
// 承重断言一:`initialValue.points` 是 emptyList(),所以数据管线
|
||||||
|
// (observeMetricHistory / flatMapLatest / map)只要不发射,这一条必挂。
|
||||||
|
assertEquals(7, state.points.size)
|
||||||
|
// 承重断言二:上面那条只证明「有数据」,不证明「数据问对了指标」。
|
||||||
|
// `observeMetricHistory(metric, currentRange)` 里若把 metric 写成常量(如 MetricType.STEP),
|
||||||
|
// 点数仍是 7(点数只由 range 决定),只有取值区间会变 —— Mock 按指标区间生成并 coerceIn,
|
||||||
|
// 睡眠是 330~510、步数是 3000~12000,两者不相交(见 MockDataProvider.valueRange)。
|
||||||
|
// 注意 `all {}` 对空列表恒真,所以它必须与上面那条 size 断言成对出现,不能单用。
|
||||||
|
assertTrue(
|
||||||
|
"SLEEP 详情页拿到了区间外的值:${state.points.map { it.value }}",
|
||||||
|
state.points.all { it.value in 330.0..510.0 },
|
||||||
|
)
|
||||||
|
// 这一条单看**没有鉴别力**:`state.metric` 就是构造函数传进来的值,`stateIn` 的
|
||||||
|
// initialValue 里已经有它,数据管线从不发射时它照样成立(ledger R30)。保留它只是为了
|
||||||
|
// 钉住「状态里的 metric 与构造函数一致」,真正承重的是上面两条。
|
||||||
|
assertEquals(MetricType.SLEEP, state.metric)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package com.placeholder.ringhealth.feature.device
|
||||||
|
|
||||||
|
import com.placeholder.ringhealth.data.mock.MockRingDeviceRepository
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
|
import kotlinx.coroutines.test.resetMain
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlinx.coroutines.test.setMain
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/** 设备页 ViewModel 测试。 */
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class DeviceViewModelTest {
|
||||||
|
|
||||||
|
private val dispatcher = StandardTestDispatcher()
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
Dispatchers.setMain(dispatcher)
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
Dispatchers.resetMain()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `输出设备状态与五个功能开关,且仓库变化会回流到 UI 状态`() = runTest(dispatcher) {
|
||||||
|
val repository = MockRingDeviceRepository()
|
||||||
|
val viewModel = DeviceViewModel(repository)
|
||||||
|
backgroundScope.launch { viewModel.uiState.collect {} }
|
||||||
|
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
val state = viewModel.uiState.value
|
||||||
|
assertEquals(78, state.device.batteryPercent)
|
||||||
|
assertEquals("1.0.5", state.device.firmwareVersion)
|
||||||
|
assertEquals(5, state.settings.size)
|
||||||
|
|
||||||
|
// 上面三个值恰好与 stateIn 的 initialValue 相同(DeviceViewModel 的 initialValue
|
||||||
|
// 直接取仓库当前值),所以**单靠它们无法区分「combine 发射过」与「一直停在 initialValue」**
|
||||||
|
// (ledger Ruling R30,审查者指出)。这里绕开 ViewModel 直接改仓库,再断言状态跟着变 ——
|
||||||
|
// 只有 combine 真的在发射,这一条才可能成立。
|
||||||
|
repository.setFunctionEnabled("stress", true)
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertTrue(viewModel.uiState.value.settings.first { it.id == "stress" }.enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `setFunctionEnabled 只改目标项且可来回切换`() = runTest(dispatcher) {
|
||||||
|
val viewModel = DeviceViewModel(MockRingDeviceRepository())
|
||||||
|
backgroundScope.launch { viewModel.uiState.collect {} }
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
// Mock 的 5 项里只有 stress 与 blood_pressure 初始为 false,其余 3 项为 true
|
||||||
|
// (见 MockDataProvider.defaultFunctionSettings)。切换 stress 后应为 4 项开启。
|
||||||
|
val target = "stress"
|
||||||
|
assertFalse(viewModel.uiState.value.settings.first { it.id == target }.enabled)
|
||||||
|
|
||||||
|
viewModel.setFunctionEnabled(target, true)
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertTrue(viewModel.uiState.value.settings.first { it.id == target }.enabled)
|
||||||
|
// 关键:其它开关不受影响。只断言目标项变了,无法发现「整表被覆盖」这类回归。
|
||||||
|
assertEquals(4, viewModel.uiState.value.settings.count { it.enabled })
|
||||||
|
|
||||||
|
viewModel.setFunctionEnabled(target, false)
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertFalse(viewModel.uiState.value.settings.first { it.id == target }.enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package com.placeholder.ringhealth.feature.workout
|
||||||
|
|
||||||
|
import com.placeholder.ringhealth.data.mock.MockHealthDataRepository
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
|
import kotlinx.coroutines.test.resetMain
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlinx.coroutines.test.setMain
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [WorkoutViewModel] 的单元测试:验证加载完成后输出的运动记录列表。
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class WorkoutViewModelTest {
|
||||||
|
|
||||||
|
private val dispatcher = StandardTestDispatcher()
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
Dispatchers.setMain(dispatcher)
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
Dispatchers.resetMain()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `加载完成后输出运动记录`() = runTest(dispatcher) {
|
||||||
|
val viewModel = WorkoutViewModel(MockHealthDataRepository())
|
||||||
|
|
||||||
|
// 与 HomeViewModelTest 同理:uiState 用 WhileSubscribed 构建,
|
||||||
|
// 没有订阅者时 .value 会停在 initialValue(loading = true)。
|
||||||
|
backgroundScope.launch { viewModel.uiState.collect {} }
|
||||||
|
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
val state = viewModel.uiState.value
|
||||||
|
assertFalse(state.loading)
|
||||||
|
assertEquals(5, state.records.size)
|
||||||
|
// 列表必须按时间倒序(最近的在最前):直接比首尾时间戳,而不是比记录类型
|
||||||
|
// —— Mock 数据的首尾类型恰好相同,用类型断言无法察觉顺序被反转。
|
||||||
|
assertTrue(state.records.first().startTime > state.records.last().startTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# M1 静态页面验收记录
|
||||||
|
|
||||||
|
## 环境
|
||||||
|
|
||||||
|
- 记录日期:2026-09-10
|
||||||
|
- 构建版本:0.1.0 (versionCode 1)
|
||||||
|
- 构建机:Windows 11,JDK 17.0.12,Android SDK `D:\Android\Sdk`(仅 `platform-tools` /
|
||||||
|
`platforms;android-36` / `build-tools;36.1.0`)
|
||||||
|
- **测试设备:无** —— 本机没有安装任何模拟器镜像,也没有连接真机
|
||||||
|
|
||||||
|
补充证据(说明「无设备」不是随口一说,而是逐项查过的):
|
||||||
|
|
||||||
|
```
|
||||||
|
$ ls D:\Android\Sdk
|
||||||
|
build-tools cmdline-tools licenses platform-tools platforms
|
||||||
|
# 没有 emulator/,也没有 system-images/
|
||||||
|
|
||||||
|
$ adb devices
|
||||||
|
List of devices attached
|
||||||
|
# 空列表,没有连接真机
|
||||||
|
```
|
||||||
|
|
||||||
|
`C:\Users\10314\.android\avd\` 下**残留着两个 AVD 定义**(`Medium_Phone_API_36.1`、
|
||||||
|
`Pixel_9_Pro`),但它们是**不可用的空壳**:二者都指向已删除的旧 SDK
|
||||||
|
`C:\Users\10314\AppData\Local\Android\Sdk`(其 `emulator\emulator.exe` 与
|
||||||
|
`system-images\android-36.1\google_apis_playstore\x86_64\` 均已不存在),且属于另一个项目
|
||||||
|
(`D:\work\xiangmu\mediq-app`)。**不要因为看到 AVD 目录就以为本机可以起模拟器。**
|
||||||
|
另注:当前 shell 的 `ANDROID_HOME` 仍指向那个已删除的旧路径,构建实际靠
|
||||||
|
`local.properties` 的 `sdk.dir=D:\Android\Sdk` 生效。
|
||||||
|
|
||||||
|
## 自动化验证(已执行)
|
||||||
|
|
||||||
|
- 单元测试:`./gradlew :app:testDebugUnitTest` —— **21 项全绿**
|
||||||
|
(`FormattersTest` 7 + `MockDataProviderTest` 5 + `HomeViewModelTest` 1 + `WorkoutViewModelTest` 1
|
||||||
|
+ `DeviceViewModelTest` 2 + `MetricDetailViewModelTest` 3 + `RoutesTest` 2)
|
||||||
|
- 构建:`./gradlew clean assembleDebug` —— `BUILD SUCCESSFUL`,
|
||||||
|
产物 `app/build/outputs/apk/debug/app-debug.apk`(18,907,329 字节)
|
||||||
|
- **未执行**:各页面的 `@Preview` 在 Android Studio 中渲染。本机没有 IDE GUI,agent 无法打开
|
||||||
|
Preview 面板,**这一项与人工验收一样是未执行的** —— 不要把它写进「已执行」段落
|
||||||
|
(旧稿把它列在上面这一节里,那等于声称验证过一件没做过的事)。
|
||||||
|
|
||||||
|
## 人工验收(未执行 —— 无设备)
|
||||||
|
|
||||||
|
下表**全部未打勾**。这份清单已原样保留,交给有设备的人执行;不要在没有实测的情况下填入结果。
|
||||||
|
|
||||||
|
| 项 | 结果 | 备注 |
|
||||||
|
|---|---|---|
|
||||||
|
| 应用启动无崩溃 | 未执行 | 无设备 |
|
||||||
|
| 四 Tab 切换 | 未执行 | 无设备 |
|
||||||
|
| 重按已高亮的父 Tab 的行为 | 未执行 | 需实测:推测会停在子页而非回到 Tab 根 |
|
||||||
|
| 每个页面的 `@Preview` 可渲染 | 未执行 | 需在 Android Studio 中逐个打开确认 |
|
||||||
|
| 次要文字灰度一致 | 未执行 | 不得出现 `TextSecondary` 与 `onSurfaceVariant` 混用 |
|
||||||
|
| 首页:设备状态条 / 步数大环 8,426 / 六个指标卡(血压“未开启”) | 未执行 | 无设备 |
|
||||||
|
| 指标详情页:周/月切换(7 根 / 30 根柱子) | 未执行 | 无设备 |
|
||||||
|
| 睡眠详情:分期卡片只在睡眠指标出现,入睡/起床读起来像「一晚」 | 未执行 | 无设备 |
|
||||||
|
| 运动页 / 运动历史页 | 未执行 | 无设备 |
|
||||||
|
| 设备页 / 设备设置开关(返回后状态保持) | 未执行 | 无设备 |
|
||||||
|
| 我的页 | 未执行 | 无设备 |
|
||||||
|
| 旋转屏幕、深色系统设置下不崩溃 | 未执行 | 无设备 |
|
||||||
|
|
||||||
|
### 需在有设备时重点确认的项(原清单细目,逐条保留)
|
||||||
|
|
||||||
|
- 应用启动无崩溃,底部四个 Tab 显示为 健康 / 运动 / 设备 / 我的
|
||||||
|
- 四个 Tab 可自由切换,切换后状态保留、无闪烁
|
||||||
|
- **重按已高亮的父 Tab 的行为**:实现为 `popUpTo(start){saveState}` + `restoreState`,
|
||||||
|
推测会**恢复到该 Tab 保存的子页栈**(停在子页而非回到 Tab 根)。请实测并记录实际行为;
|
||||||
|
若实测是「停在子页」而产品希望「回到根」,那是一个独立的小改动,记下来即可
|
||||||
|
- 首页:步数大环显示 8,426,目标 10,000,环按比例填充
|
||||||
|
- 首页:点击任一指标卡进入详情页,标题为该指标中文名
|
||||||
|
- 详情页:返回后回到首页
|
||||||
|
- 运动页:显示“开始运动”卡片 + 5 条运动记录;「查看全部」进入历史页后底部导航栏仍可见
|
||||||
|
- 设备页:戒指卡片显示电量 78%、固件版本 1.0.5
|
||||||
|
- 睡眠卡片的入睡/起床时间**读起来像「一晚」**(如 入睡 22:41 / 起床 06:41),而不是跨好几天。
|
||||||
|
阶段一按「最后一个数据点往前 8 小时」演示(R29);若显示成跨天,说明
|
||||||
|
`DEMO_SLEEP_WINDOW_MILLIS` 那段被改坏了
|
||||||
|
|
||||||
|
## 已知限制(阶段一有意为之)
|
||||||
|
|
||||||
|
- 数据全部为 Mock,不连蓝牙、不连后端
|
||||||
|
- 血压恒为“未开启”占位(`HealthSummary.bloodPressure = null`)
|
||||||
|
- 设备设置页的开关只改内存状态,重启即重置(对应 M2 的协议 0502/0504)
|
||||||
|
- 睡眠分期条与入睡/起床时间是演示值(固定比例 + 最后一个数据点往前 8 小时),无真实分期数据
|
||||||
|
- 详情页统计摘要一律保留一位小数(步数因此显示为「平均 7512.3」这类精度过剩的形式)
|
||||||
|
- 仅浅色模式
|
||||||
|
|
||||||
|
## 覆盖缺口声明
|
||||||
|
|
||||||
|
**M1 的全部运行时行为目前只有编译期与单元测试保证,没有任何一次真机/模拟器运行。**
|
||||||
|
`assembleDebug` 成功只说明代码能编译、能打包,**不说明界面正确、不说明不会崩溃**。
|
||||||
|
这是本机没有设备导致的已知缺口(ledger Ruling R21),不是「已验证通过」。
|
||||||
|
首次拿到设备时,请从上面那张人工验收表开始逐项补齐。
|
||||||
|
|
||||||
|
具体而言,**没有任何一行 M1 的 Compose 界面代码被真正渲染过**:导航图能否起来、
|
||||||
|
四个 Tab 是否显示正确文案、`MetricBarChart` 的柱子数量、`RingProgress` 的填充比例、
|
||||||
|
睡眠分期卡片是否只在睡眠指标下出现、次要文字灰度是否统一 —— 这些全部未经运行时检验。
|
||||||
|
单元测试覆盖的是 `Formatters` 的纯函数、`MockDataProvider` 的数据形状,以及七个 ViewModel
|
||||||
|
的状态流;**它们不经过 Compose 渲染管线,也不能发现布局、主题或导航层面的错误。**
|
||||||
Reference in New Issue
Block a user