Compare commits
2
Commits
2fba18bc7e
...
7eeb9f8e26
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7eeb9f8e26 | ||
|
|
6d3cb1b418 |
@@ -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.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import com.placeholder.ringhealth.core.designsystem.RingHealthTheme
|
import com.placeholder.ringhealth.core.designsystem.RingHealthTheme
|
||||||
|
import com.placeholder.ringhealth.core.navigation.RingHealthApp
|
||||||
|
|
||||||
/** 唯一 Activity,承载 Compose 界面。 */
|
/** 唯一 Activity,承载 Compose 界面。 */
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
@@ -25,10 +22,8 @@ class MainActivity : ComponentActivity() {
|
|||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
color = MaterialTheme.colorScheme.background,
|
color = MaterialTheme.colorScheme.background,
|
||||||
) {
|
) {
|
||||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
// 界面骨架(底部导航 + 路由)在 core/navigation/RingHealthApp.kt
|
||||||
// 文案取自 res/values/strings.xml,不在 Kotlin 里硬编码用户可见文字
|
RingHealthApp()
|
||||||
Text(stringResource(R.string.app_name))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,4 +6,34 @@
|
|||||||
<string name="stress_normal">正常</string>
|
<string name="stress_normal">正常</string>
|
||||||
<string name="stress_medium">中等</string>
|
<string name="stress_medium">中等</string>
|
||||||
<string name="stress_high">偏高</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>
|
</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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user