Compare commits

..
2 Commits
14 changed files with 681 additions and 8 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
@@ -4,15 +4,12 @@ import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.placeholder.ringhealth.core.designsystem.RingHealthTheme
import com.placeholder.ringhealth.core.navigation.RingHealthApp
/** 唯一 Activity,承载 Compose 界面。 */
class MainActivity : ComponentActivity() {
@@ -25,10 +22,8 @@ class MainActivity : ComponentActivity() {
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
// 文案取自 res/values/strings.xml,不在 Kotlin 里硬编码用户可见文字
Text(stringResource(R.string.app_name))
}
// 界面骨架(底部导航 + 路由)在 core/navigation/RingHealthApp.kt
RingHealthApp()
}
}
}
@@ -0,0 +1,117 @@
package com.placeholder.ringhealth.core.navigation
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.FitnessCenter
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.placeholder.ringhealth.R
import com.placeholder.ringhealth.domain.model.MetricType
import com.placeholder.ringhealth.feature.device.DeviceScreen
import com.placeholder.ringhealth.feature.device.DeviceSettingsPlaceholder
import com.placeholder.ringhealth.feature.detail.MetricDetailPlaceholder
import com.placeholder.ringhealth.feature.home.HomeScreen
import com.placeholder.ringhealth.feature.profile.ProfileScreen
import com.placeholder.ringhealth.feature.workout.WorkoutHistoryPlaceholder
import com.placeholder.ringhealth.feature.workout.WorkoutScreen
/** 底部导航项定义。 */
private data class BottomTab(
val route: String,
val labelRes: Int,
val icon: ImageVector,
)
private val BOTTOM_TABS = listOf(
BottomTab(Routes.HOME, R.string.tab_home, Icons.Filled.Home),
BottomTab(Routes.WORKOUT, R.string.tab_workout, Icons.Filled.FitnessCenter),
BottomTab(Routes.DEVICE, R.string.tab_device, Icons.Filled.Settings),
BottomTab(Routes.PROFILE, R.string.tab_profile, Icons.Filled.Person),
)
/**
* 应用主界面:Scaffold + 底部导航 + NavHost。
*/
@Composable
fun RingHealthApp(navController: NavHostController = rememberNavController()) {
val backStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = backStackEntry?.destination?.route
Scaffold(
containerColor = MaterialTheme.colorScheme.background,
bottomBar = {
NavigationBar(containerColor = MaterialTheme.colorScheme.surface) {
BOTTOM_TABS.forEach { tab ->
NavigationBarItem(
selected = currentRoute == tab.route,
onClick = { navController.navigateToTab(tab.route) },
icon = { Icon(imageVector = tab.icon, contentDescription = null) },
label = { Text(stringResource(tab.labelRes)) },
)
}
}
},
) { innerPadding ->
NavHost(
navController = navController,
startDestination = Routes.HOME,
modifier = Modifier.padding(innerPadding),
) {
composable(Routes.HOME) {
HomeScreen(onOpenDetail = { metric -> navController.navigate(Routes.detail(metric)) })
}
composable(Routes.WORKOUT) {
WorkoutScreen()
}
composable(Routes.DEVICE) {
DeviceScreen()
}
composable(Routes.PROFILE) {
ProfileScreen()
}
composable(Routes.DEVICE_SETTINGS) {
DeviceSettingsPlaceholder()
}
composable(Routes.WORKOUT_HISTORY) {
WorkoutHistoryPlaceholder()
}
composable(
route = Routes.DETAIL_PATTERN,
arguments = listOf(navArgument(Routes.DETAIL_ARG_METRIC) { type = NavType.StringType }),
) { entry ->
val raw = entry.arguments?.getString(Routes.DETAIL_ARG_METRIC).orEmpty()
val metric = MetricType.entries.firstOrNull { it.name == raw } ?: MetricType.STEP
MetricDetailPlaceholder(metric = metric)
}
}
}
}
/** 底部 Tab 切换:保留状态、避免回退栈堆积。 */
private fun NavHostController.navigateToTab(route: String) {
navigate(route) {
popUpTo(graph.findStartDestination().id) { saveState = true }
launchSingleTop = true
restoreState = true
}
}
@@ -0,0 +1,23 @@
package com.placeholder.ringhealth.core.navigation
import com.placeholder.ringhealth.domain.model.MetricType
/**
* 全局路由表。字符串集中定义,避免散落在各处。
*/
object Routes {
const val HOME = "home"
const val WORKOUT = "workout"
const val DEVICE = "device"
const val PROFILE = "profile"
const val DEVICE_SETTINGS = "device/settings"
const val WORKOUT_HISTORY = "workout/history"
/** 指标详情路由模板,参数为 MetricType.name。 */
const val DETAIL_PATTERN = "detail/{metric}"
const val DETAIL_ARG_METRIC = "metric"
/** 拼出某个指标的详情路由。 */
fun detail(metric: MetricType): String = "detail/${metric.name}"
}
@@ -0,0 +1,19 @@
package com.placeholder.ringhealth.feature.detail
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.placeholder.ringhealth.R
import com.placeholder.ringhealth.domain.model.MetricType
/** 指标详情子页(占位,待 Task 12 实现)。 */
@Composable
fun MetricDetailPlaceholder(metric: MetricType, modifier: Modifier = Modifier) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(stringResource(R.string.placeholder_metric_detail))
}
}
@@ -0,0 +1,18 @@
package com.placeholder.ringhealth.feature.device
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.placeholder.ringhealth.R
/** 设备页(占位,待 Task 10 实现)。 */
@Composable
fun DeviceScreen(modifier: Modifier = Modifier) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(stringResource(R.string.tab_device))
}
}
@@ -0,0 +1,18 @@
package com.placeholder.ringhealth.feature.device
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.placeholder.ringhealth.R
/** 设备设置子页(占位,待 Task 10 实现)。 */
@Composable
fun DeviceSettingsPlaceholder(modifier: Modifier = Modifier) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(stringResource(R.string.placeholder_device_settings))
}
}
@@ -0,0 +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
/** 一个指标卡的数据。 */
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) {
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
}
}
@@ -0,0 +1,18 @@
package com.placeholder.ringhealth.feature.profile
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.placeholder.ringhealth.R
/** 我的页(占位,待 Task 11 实现)。 */
@Composable
fun ProfileScreen(modifier: Modifier = Modifier) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(stringResource(R.string.tab_profile))
}
}
@@ -0,0 +1,18 @@
package com.placeholder.ringhealth.feature.workout
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.placeholder.ringhealth.R
/** 运动历史子页(占位,待 Task 9 实现)。 */
@Composable
fun WorkoutHistoryPlaceholder(modifier: Modifier = Modifier) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(stringResource(R.string.placeholder_workout_history))
}
}
@@ -0,0 +1,18 @@
package com.placeholder.ringhealth.feature.workout
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.placeholder.ringhealth.R
/** 运动页(占位,待 Task 9 实现)。 */
@Composable
fun WorkoutScreen(modifier: Modifier = Modifier) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(stringResource(R.string.tab_workout))
}
}
+30
View File
@@ -6,4 +6,34 @@
<string name="stress_normal">正常</string>
<string name="stress_medium">中等</string>
<string name="stress_high">偏高</string>
<string name="tab_home">健康</string>
<string name="tab_workout">运动</string>
<string name="tab_device">设备</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_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)
}
}