diff --git a/app/src/main/java/com/placeholder/ringhealth/AppViewModelProvider.kt b/app/src/main/java/com/placeholder/ringhealth/AppViewModelProvider.kt index 90b1b63..c1a1cd6 100644 --- a/app/src/main/java/com/placeholder/ringhealth/AppViewModelProvider.kt +++ b/app/src/main/java/com/placeholder/ringhealth/AppViewModelProvider.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.viewmodel.CreationExtras import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory import com.placeholder.ringhealth.feature.home.HomeViewModel +import com.placeholder.ringhealth.feature.workout.WorkoutViewModel /** * ViewModel 工厂:把容器中的仓库注入各 ViewModel。 @@ -19,6 +20,11 @@ object AppViewModelProvider { ringDeviceRepository = ringHealthApplication().container.ringDeviceRepository, ) } + initializer { + WorkoutViewModel( + healthDataRepository = ringHealthApplication().container.healthDataRepository, + ) + } } } diff --git a/app/src/main/java/com/placeholder/ringhealth/core/navigation/RingHealthApp.kt b/app/src/main/java/com/placeholder/ringhealth/core/navigation/RingHealthApp.kt index 5bf619b..f7d541b 100644 --- a/app/src/main/java/com/placeholder/ringhealth/core/navigation/RingHealthApp.kt +++ b/app/src/main/java/com/placeholder/ringhealth/core/navigation/RingHealthApp.kt @@ -32,7 +32,7 @@ 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.WorkoutHistoryScreen 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), ) +/** + * 把当前路由归到它所属的底部 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。 */ @Composable fun RingHealthApp(navController: NavHostController = rememberNavController()) { val backStackEntry by navController.currentBackStackEntryAsState() - val currentRoute = backStackEntry?.destination?.route + val currentRoute = tabRouteFor(backStackEntry?.destination?.route) Scaffold( containerColor = MaterialTheme.colorScheme.background, @@ -81,7 +96,7 @@ fun RingHealthApp(navController: NavHostController = rememberNavController()) { HomeScreen(onOpenDetail = { metric -> navController.navigate(Routes.detail(metric)) }) } composable(Routes.WORKOUT) { - WorkoutScreen() + WorkoutScreen(onOpenHistory = { navController.navigate(Routes.WORKOUT_HISTORY) }) } composable(Routes.DEVICE) { DeviceScreen() @@ -93,7 +108,7 @@ fun RingHealthApp(navController: NavHostController = rememberNavController()) { DeviceSettingsPlaceholder() } composable(Routes.WORKOUT_HISTORY) { - WorkoutHistoryPlaceholder() + WorkoutHistoryScreen() } composable( route = Routes.DETAIL_PATTERN, diff --git a/app/src/main/java/com/placeholder/ringhealth/core/navigation/Routes.kt b/app/src/main/java/com/placeholder/ringhealth/core/navigation/Routes.kt index 87bac9c..4d77439 100644 --- a/app/src/main/java/com/placeholder/ringhealth/core/navigation/Routes.kt +++ b/app/src/main/java/com/placeholder/ringhealth/core/navigation/Routes.kt @@ -14,10 +14,12 @@ object Routes { const val DEVICE_SETTINGS = "device/settings" const val WORKOUT_HISTORY = "workout/history" - /** 指标详情路由模板,参数为 MetricType.name。 */ - const val DETAIL_PATTERN = "detail/{metric}" + /** 路由参数的键名。必须声明在 DETAIL_PATTERN 之前:模板引用它。 */ const val DETAIL_ARG_METRIC = "metric" + /** 指标详情路由模板,参数为 MetricType.name。 */ + const val DETAIL_PATTERN = "detail/{$DETAIL_ARG_METRIC}" + /** 拼出某个指标的详情路由。 */ fun detail(metric: MetricType): String = "detail/${metric.name}" } diff --git a/app/src/main/java/com/placeholder/ringhealth/feature/workout/WorkoutHistoryScreen.kt b/app/src/main/java/com/placeholder/ringhealth/feature/workout/WorkoutHistoryScreen.kt index eb31962..0f14fcf 100644 --- a/app/src/main/java/com/placeholder/ringhealth/feature/workout/WorkoutHistoryScreen.kt +++ b/app/src/main/java/com/placeholder/ringhealth/feature/workout/WorkoutHistoryScreen.kt @@ -1,18 +1,64 @@ package com.placeholder.ringhealth.feature.workout +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues 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.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +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.designsystem.MetricGreen -/** 运动历史子页(占位,待 Task 9 实现)。 */ +/** 运动历史子页:展示全部运动记录。 */ @Composable -fun WorkoutHistoryPlaceholder(modifier: Modifier = Modifier) { - Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text(stringResource(R.string.placeholder_workout_history)) +fun WorkoutHistoryScreen( + modifier: Modifier = Modifier, + viewModel: WorkoutViewModel = viewModel(factory = AppViewModelProvider.Factory), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + + // 必须先判 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 = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + return + } + LazyColumn( + modifier = modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(state.records) { record -> + WorkoutRecordCard(record = record) + } } } diff --git a/app/src/main/java/com/placeholder/ringhealth/feature/workout/WorkoutScreen.kt b/app/src/main/java/com/placeholder/ringhealth/feature/workout/WorkoutScreen.kt index 2d20585..ee27966 100644 --- a/app/src/main/java/com/placeholder/ringhealth/feature/workout/WorkoutScreen.kt +++ b/app/src/main/java/com/placeholder/ringhealth/feature/workout/WorkoutScreen.kt @@ -1,18 +1,141 @@ package com.placeholder.ringhealth.feature.workout +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.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.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +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.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.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 -fun WorkoutScreen(modifier: Modifier = Modifier) { - Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text(stringResource(R.string.tab_workout)) +fun WorkoutScreen( + onOpenHistory: () -> Unit, + 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, + ) + } + } } } diff --git a/app/src/main/java/com/placeholder/ringhealth/feature/workout/WorkoutViewModel.kt b/app/src/main/java/com/placeholder/ringhealth/feature/workout/WorkoutViewModel.kt new file mode 100644 index 0000000..9dc85d3 --- /dev/null +++ b/app/src/main/java/com/placeholder/ringhealth/feature/workout/WorkoutViewModel.kt @@ -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, +) + +/** 运动页 ViewModel。 */ +class WorkoutViewModel( + healthDataRepository: HealthDataRepository, +) : ViewModel() { + + /** 页面状态:仓库数据到达前为 loading,到达后为记录列表。 */ + val uiState: StateFlow = 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 + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 56b5dcd..26f75b6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -36,4 +36,12 @@ 体温 血压 步数 + + 开始运动 + 选择运动类型,记录你的训练 + 最近运动 + 暂无运动记录 + %1$s · %2$s 公里 + 消耗 %1$s 千卡 + 查看全部 diff --git a/app/src/test/java/com/placeholder/ringhealth/feature/workout/WorkoutViewModelTest.kt b/app/src/test/java/com/placeholder/ringhealth/feature/workout/WorkoutViewModelTest.kt new file mode 100644 index 0000000..797b1a3 --- /dev/null +++ b/app/src/test/java/com/placeholder/ringhealth/feature/workout/WorkoutViewModelTest.kt @@ -0,0 +1,52 @@ +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.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 的运动记录是倒序生成的,最近的在最前 + assertEquals("户外跑步", state.records.first().type) + } +}