feat: 实现健康首页(今日大环 + 指标卡片,含 ViewModel 测试)

This commit is contained in:
2026-09-10 11:02:14 +08:00
parent 6d3cb1b418
commit 7eeb9f8e26
5 changed files with 402 additions and 3 deletions
@@ -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
@@ -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),
) {
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) {
Text(stringResource(R.string.tab_home))
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<MetricCardSpec> = 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 = {},
)
}
}
@@ -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<HomeUiState> = 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
}
}
+22
View File
@@ -14,4 +14,26 @@
<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_disconnected">未连接设备</string>
<string name="home_battery">电量 %1$d%%</string>
<string name="home_steps_title">今日步数</string>
<string name="home_steps_goal">目标 %1$s 步</string>
<string name="home_calories_label">千卡</string>
<string name="home_distance_label">公里</string>
<string name="home_metrics">健康指标</string>
<string name="home_not_enabled">未开启</string>
<string name="unit_bpm">次/分</string>
<string name="unit_percent">%</string>
<string name="unit_celsius">°C</string>
<string name="unit_mmhg">mmHg</string>
<string name="metric_heart_rate">心率</string>
<string name="metric_sleep">睡眠</string>
<string name="metric_blood_oxygen">血氧</string>
<string name="metric_stress">压力</string>
<string name="metric_temperature">体温</string>
<string name="metric_blood_pressure">血压</string>
<string name="metric_step">步数</string>
</resources>
@@ -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)
}
}