From 7eeb9f8e265c35a5677e2dee65da3fc87e0a4cb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B4=94=E9=B2=81=E6=AD=A3?= <13780741225@163.com> Date: Thu, 10 Sep 2026 11:02:14 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E5=81=A5=E5=BA=B7?= =?UTF-8?q?=E9=A6=96=E9=A1=B5=EF=BC=88=E4=BB=8A=E6=97=A5=E5=A4=A7=E7=8E=AF?= =?UTF-8?q?=20+=20=E6=8C=87=E6=A0=87=E5=8D=A1=E7=89=87=EF=BC=8C=E5=90=AB?= =?UTF-8?q?=20ViewModel=20=E6=B5=8B=E8=AF=95=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ringhealth/AppViewModelProvider.kt | 27 ++ .../ringhealth/feature/home/HomeScreen.kt | 259 +++++++++++++++++- .../ringhealth/feature/home/HomeViewModel.kt | 45 +++ app/src/main/res/values/strings.xml | 22 ++ .../feature/home/HomeViewModelTest.kt | 52 ++++ 5 files changed, 402 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/com/placeholder/ringhealth/AppViewModelProvider.kt create mode 100644 app/src/main/java/com/placeholder/ringhealth/feature/home/HomeViewModel.kt create mode 100644 app/src/test/java/com/placeholder/ringhealth/feature/home/HomeViewModelTest.kt diff --git a/app/src/main/java/com/placeholder/ringhealth/AppViewModelProvider.kt b/app/src/main/java/com/placeholder/ringhealth/AppViewModelProvider.kt new file mode 100644 index 0000000..90b1b63 --- /dev/null +++ b/app/src/main/java/com/placeholder/ringhealth/AppViewModelProvider.kt @@ -0,0 +1,27 @@ +package com.placeholder.ringhealth + +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewmodel.CreationExtras +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import com.placeholder.ringhealth.feature.home.HomeViewModel + +/** + * ViewModel 工厂:把容器中的仓库注入各 ViewModel。 + * 每个 ViewModel 在这里注册一个 initializer;后续任务按需追加。 + */ +object AppViewModelProvider { + + val Factory = viewModelFactory { + initializer { + HomeViewModel( + healthDataRepository = ringHealthApplication().container.healthDataRepository, + ringDeviceRepository = ringHealthApplication().container.ringDeviceRepository, + ) + } + } +} + +/** 从 CreationExtras 中取出 Application 实例。 */ +fun CreationExtras.ringHealthApplication(): RingHealthApplication = + this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as RingHealthApplication 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 249bc6f..ad40192 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,22 +1,275 @@ package com.placeholder.ringhealth.feature.home +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Bloodtype +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.MonitorHeart +import androidx.compose.material.icons.filled.Spa +import androidx.compose.material.icons.filled.Thermostat +import androidx.compose.material.icons.filled.WaterDrop +import androidx.compose.material.icons.filled.Bedtime +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.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.core.common.formatCalories +import com.placeholder.ringhealth.core.common.formatDistance +import com.placeholder.ringhealth.core.common.formatSleepDuration +import com.placeholder.ringhealth.core.common.formatSteps +import com.placeholder.ringhealth.core.designsystem.MetricBlue +import com.placeholder.ringhealth.core.designsystem.MetricCyan +import com.placeholder.ringhealth.core.designsystem.MetricGreen +import com.placeholder.ringhealth.core.designsystem.MetricOrange +import com.placeholder.ringhealth.core.designsystem.MetricPurple +import com.placeholder.ringhealth.core.designsystem.MetricRed +import com.placeholder.ringhealth.core.designsystem.MetricYellow +import com.placeholder.ringhealth.core.designsystem.MetricValueStyle +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.MetricTile +import com.placeholder.ringhealth.core.designsystem.component.RingProgress +import com.placeholder.ringhealth.core.designsystem.component.SectionHeader +import com.placeholder.ringhealth.domain.model.HealthSummary import com.placeholder.ringhealth.domain.model.MetricType +import com.placeholder.ringhealth.domain.model.RingDeviceState -/** 健康首页(占位,待 Task 8 实现)。 */ +/** 一个指标卡的数据。 */ +private data class MetricCardSpec( + val type: MetricType, + val icon: ImageVector, + val tint: Color, + val title: String, + val value: String?, + val unit: String, +) + +/** 健康首页。 */ @Composable fun HomeScreen( onOpenDetail: (MetricType) -> Unit, modifier: Modifier = Modifier, + viewModel: HomeViewModel = viewModel(factory = AppViewModelProvider.Factory), ) { - Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text(stringResource(R.string.tab_home)) + val state by viewModel.uiState.collectAsStateWithLifecycle() + HomeContent(state = state, onOpenDetail = onOpenDetail, modifier = modifier) +} + +/** 无状态首页内容,便于 Preview。 */ +@Composable +private fun HomeContent( + state: HomeUiState, + onOpenDetail: (MetricType) -> Unit, + modifier: Modifier = Modifier, +) { + if (state.loading || state.summary == null) { + Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator(color = MetricGreen) + } + return + } + + val summary = state.summary + // 注意:LazyColumn 的 content lambda 不是 Composable 上下文, + // 所有需要 stringResource / Composable 调用的内容都必须在 LazyColumn 之前算好。 + val cards = buildMetricCards(summary) + val notEnabledText = stringResource(R.string.home_not_enabled) + LazyColumn( + modifier = modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item { DeviceStrip(device = state.device) } + item { StepsCard(summary = summary, onClick = { onOpenDetail(MetricType.STEP) }) } + item { SectionHeader(title = stringResource(R.string.home_metrics)) } + items(cards) { card -> + MetricTile( + icon = card.icon, + iconTint = card.tint, + title = card.title, + value = card.value ?: notEnabledText, + unit = if (card.value == null) "" else card.unit, + onClick = { onOpenDetail(card.type) }, + ) + } + } +} + +/** 顶部设备连接状态条。 */ +@Composable +private fun DeviceStrip(device: RingDeviceState?, modifier: Modifier = Modifier) { + HealthCard(modifier = modifier) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Filled.MonitorHeart, + contentDescription = null, + tint = MetricGreen, + modifier = Modifier.size(20.dp), + ) + Column(modifier = Modifier.padding(start = 10.dp)) { + Text( + text = if (device?.connected == true) { + stringResource(R.string.home_device_connected, device.name) + } else { + stringResource(R.string.home_device_disconnected) + }, + style = MaterialTheme.typography.bodyMedium, + ) + if (device != null) { + Text( + text = stringResource(R.string.home_battery, device.batteryPercent), + style = MaterialTheme.typography.bodySmall, + color = TextSecondary, + ) + } + } + } + } +} + +/** 今日步数大环卡片。 */ +@Composable +private fun StepsCard(summary: HealthSummary, onClick: () -> Unit, modifier: Modifier = Modifier) { + HealthCard(modifier = modifier, onClick = onClick) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(text = stringResource(R.string.home_steps_title), style = MaterialTheme.typography.titleMedium) + Spacer(modifier = Modifier.height(12.dp)) + RingProgress( + progress = summary.steps.toFloat() / summary.stepGoal.coerceAtLeast(1), + color = MetricGreen, + trackColor = MetricGreen.copy(alpha = 0.15f), + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text(text = formatSteps(summary.steps), style = MetricValueStyle) + Text( + text = stringResource(R.string.home_steps_goal, formatSteps(summary.stepGoal)), + style = MaterialTheme.typography.bodySmall, + color = TextSecondary, + ) + } + } + Spacer(modifier = Modifier.height(12.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + SummaryMetric( + value = formatDistance(summary.distanceKm), + label = stringResource(R.string.home_distance_label), + tint = MetricBlue, + ) + SummaryMetric( + value = formatCalories(summary.calories), + label = stringResource(R.string.home_calories_label), + tint = MetricOrange, + ) + } + } + } +} + +/** 环下方的小结指标。 */ +@Composable +private fun SummaryMetric(value: String, label: String, tint: Color) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text(text = value, style = MaterialTheme.typography.titleMedium, color = tint) + Text(text = label, style = MaterialTheme.typography.bodySmall, color = TextSecondary) + } +} + +/** 组装指标卡列表;value 为 null 表示该指标未开启。 */ +@Composable +private fun buildMetricCards(summary: HealthSummary): List = listOf( + MetricCardSpec( + type = MetricType.HEART_RATE, + icon = Icons.Filled.Favorite, + tint = MetricRed, + title = stringResource(R.string.metric_heart_rate), + value = summary.heartRate?.toString(), + unit = stringResource(R.string.unit_bpm), + ), + MetricCardSpec( + type = MetricType.SLEEP, + icon = Icons.Filled.Bedtime, + tint = MetricBlue, + title = stringResource(R.string.metric_sleep), + value = summary.sleepMinutes?.let { formatSleepDuration(it) }, + unit = "", + ), + MetricCardSpec( + type = MetricType.BLOOD_OXYGEN, + icon = Icons.Filled.Bloodtype, + tint = MetricCyan, + title = stringResource(R.string.metric_blood_oxygen), + value = summary.bloodOxygen?.toString(), + unit = stringResource(R.string.unit_percent), + ), + MetricCardSpec( + type = MetricType.STRESS, + icon = Icons.Filled.Spa, + tint = MetricPurple, + title = stringResource(R.string.metric_stress), + value = summary.stress?.toString(), + unit = "", + ), + MetricCardSpec( + type = MetricType.TEMPERATURE, + icon = Icons.Filled.Thermostat, + tint = MetricOrange, + title = stringResource(R.string.metric_temperature), + value = summary.temperature?.toString(), + unit = stringResource(R.string.unit_celsius), + ), + MetricCardSpec( + type = MetricType.BLOOD_PRESSURE, + icon = Icons.Filled.WaterDrop, + tint = MetricYellow, + title = stringResource(R.string.metric_blood_pressure), + value = summary.bloodPressure, + unit = stringResource(R.string.unit_mmhg), + ), +) + +@Preview(showBackground = true) +@Composable +private fun HomeContentPreview() { + RingHealthTheme { + HomeContent( + state = HomeUiState( + loading = false, + summary = HealthSummary(8426, 10000, 327, 6.21, 72, 452, 98, 34, 36.5, null), + device = RingDeviceState(true, "RingHealth 智能戒指", 78, "1.0.5", false), + ), + onOpenDetail = {}, + ) } } diff --git a/app/src/main/java/com/placeholder/ringhealth/feature/home/HomeViewModel.kt b/app/src/main/java/com/placeholder/ringhealth/feature/home/HomeViewModel.kt new file mode 100644 index 0000000..e4df04f --- /dev/null +++ b/app/src/main/java/com/placeholder/ringhealth/feature/home/HomeViewModel.kt @@ -0,0 +1,45 @@ +package com.placeholder.ringhealth.feature.home + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.placeholder.ringhealth.domain.model.HealthSummary +import com.placeholder.ringhealth.domain.model.RingDeviceState +import com.placeholder.ringhealth.domain.repository.HealthDataRepository +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 状态。summary/device 为 null 表示仍在加载。 + */ +data class HomeUiState( + val loading: Boolean, + val summary: HealthSummary?, + val device: RingDeviceState?, +) + +/** + * 健康首页 ViewModel:合并今日概览与设备状态。 + */ +class HomeViewModel( + healthDataRepository: HealthDataRepository, + ringDeviceRepository: RingDeviceRepository, +) : ViewModel() { + + val uiState: StateFlow = combine( + healthDataRepository.observeTodaySummary(), + ringDeviceRepository.deviceState, + ) { summary, device -> + HomeUiState(loading = false, summary = summary, device = device) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(STOP_TIMEOUT_MILLIS), + initialValue = HomeUiState(loading = true, summary = null, device = null), + ) + + private companion object { + const val STOP_TIMEOUT_MILLIS = 5_000L + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 001209e..56b5dcd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -14,4 +14,26 @@ 设备设置 运动历史 指标详情 + + %1$s · 已连接 + 未连接设备 + 电量 %1$d%% + 今日步数 + 目标 %1$s 步 + 千卡 + 公里 + 健康指标 + 未开启 + 次/分 + % + °C + mmHg + + 心率 + 睡眠 + 血氧 + 压力 + 体温 + 血压 + 步数 diff --git a/app/src/test/java/com/placeholder/ringhealth/feature/home/HomeViewModelTest.kt b/app/src/test/java/com/placeholder/ringhealth/feature/home/HomeViewModelTest.kt new file mode 100644 index 0000000..92c10ee --- /dev/null +++ b/app/src/test/java/com/placeholder/ringhealth/feature/home/HomeViewModelTest.kt @@ -0,0 +1,52 @@ +package com.placeholder.ringhealth.feature.home + +import com.placeholder.ringhealth.data.mock.MockHealthDataRepository +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.Before +import org.junit.Test + +/** + * [HomeViewModel] 的单元测试:验证加载完成后输出的今日概览与设备状态。 + */ +@OptIn(ExperimentalCoroutinesApi::class) +class HomeViewModelTest { + + private val dispatcher = StandardTestDispatcher() + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `加载完成后输出今日概览与设备状态`() = runTest(dispatcher) { + val viewModel = HomeViewModel(MockHealthDataRepository(), MockRingDeviceRepository()) + + // uiState 用 SharingStarted.WhileSubscribed 构建,必须有订阅者才会启动上游数据流; + // 否则 .value 永远是初始值(loading = true)。用 backgroundScope 起一个收集者。 + backgroundScope.launch { viewModel.uiState.collect {} } + + advanceUntilIdle() + + val state = viewModel.uiState.value + assertFalse(state.loading) + assertEquals(8426, state.summary?.steps) + assertEquals(78, state.device?.batteryPercent) + } +}