feat: 实现运动页与运动历史子页

This commit is contained in:
2026-09-10 11:12:28 +08:00
parent 4805b027e0
commit 031aea0fa2
8 changed files with 301 additions and 14 deletions
@@ -5,6 +5,7 @@ import androidx.lifecycle.viewmodel.CreationExtras
import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory import androidx.lifecycle.viewmodel.viewModelFactory
import com.placeholder.ringhealth.feature.home.HomeViewModel import com.placeholder.ringhealth.feature.home.HomeViewModel
import com.placeholder.ringhealth.feature.workout.WorkoutViewModel
/** /**
* ViewModel 工厂:把容器中的仓库注入各 ViewModel。 * ViewModel 工厂:把容器中的仓库注入各 ViewModel。
@@ -19,6 +20,11 @@ object AppViewModelProvider {
ringDeviceRepository = ringHealthApplication().container.ringDeviceRepository, ringDeviceRepository = ringHealthApplication().container.ringDeviceRepository,
) )
} }
initializer {
WorkoutViewModel(
healthDataRepository = ringHealthApplication().container.healthDataRepository,
)
}
} }
} }
@@ -32,7 +32,7 @@ import com.placeholder.ringhealth.feature.device.DeviceSettingsPlaceholder
import com.placeholder.ringhealth.feature.detail.MetricDetailPlaceholder import com.placeholder.ringhealth.feature.detail.MetricDetailPlaceholder
import com.placeholder.ringhealth.feature.home.HomeScreen import com.placeholder.ringhealth.feature.home.HomeScreen
import com.placeholder.ringhealth.feature.profile.ProfileScreen 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 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), 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。 * 应用主界面:Scaffold + 底部导航 + NavHost。
*/ */
@Composable @Composable
fun RingHealthApp(navController: NavHostController = rememberNavController()) { fun RingHealthApp(navController: NavHostController = rememberNavController()) {
val backStackEntry by navController.currentBackStackEntryAsState() val backStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = backStackEntry?.destination?.route val currentRoute = tabRouteFor(backStackEntry?.destination?.route)
Scaffold( Scaffold(
containerColor = MaterialTheme.colorScheme.background, containerColor = MaterialTheme.colorScheme.background,
@@ -81,7 +96,7 @@ fun RingHealthApp(navController: NavHostController = rememberNavController()) {
HomeScreen(onOpenDetail = { metric -> navController.navigate(Routes.detail(metric)) }) HomeScreen(onOpenDetail = { metric -> navController.navigate(Routes.detail(metric)) })
} }
composable(Routes.WORKOUT) { composable(Routes.WORKOUT) {
WorkoutScreen() WorkoutScreen(onOpenHistory = { navController.navigate(Routes.WORKOUT_HISTORY) })
} }
composable(Routes.DEVICE) { composable(Routes.DEVICE) {
DeviceScreen() DeviceScreen()
@@ -93,7 +108,7 @@ fun RingHealthApp(navController: NavHostController = rememberNavController()) {
DeviceSettingsPlaceholder() DeviceSettingsPlaceholder()
} }
composable(Routes.WORKOUT_HISTORY) { composable(Routes.WORKOUT_HISTORY) {
WorkoutHistoryPlaceholder() WorkoutHistoryScreen()
} }
composable( composable(
route = Routes.DETAIL_PATTERN, route = Routes.DETAIL_PATTERN,
@@ -14,10 +14,12 @@ object Routes {
const val DEVICE_SETTINGS = "device/settings" const val DEVICE_SETTINGS = "device/settings"
const val WORKOUT_HISTORY = "workout/history" const val WORKOUT_HISTORY = "workout/history"
/** 指标详情路由模板,参数为 MetricType.name。 */ /** 路由参数的键名。必须声明在 DETAIL_PATTERN 之前:模板引用它。 */
const val DETAIL_PATTERN = "detail/{metric}"
const val DETAIL_ARG_METRIC = "metric" const val DETAIL_ARG_METRIC = "metric"
/** 指标详情路由模板,参数为 MetricType.name。 */
const val DETAIL_PATTERN = "detail/{$DETAIL_ARG_METRIC}"
/** 拼出某个指标的详情路由。 */ /** 拼出某个指标的详情路由。 */
fun detail(metric: MetricType): String = "detail/${metric.name}" fun detail(metric: MetricType): String = "detail/${metric.name}"
} }
@@ -1,18 +1,64 @@
package com.placeholder.ringhealth.feature.workout package com.placeholder.ringhealth.feature.workout
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize 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.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource 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.R
import com.placeholder.ringhealth.core.designsystem.MetricGreen
/** 运动历史子页(占位,待 Task 9 实现)。 */ /** 运动历史子页:展示全部运动记录。 */
@Composable @Composable
fun WorkoutHistoryPlaceholder(modifier: Modifier = Modifier) { fun WorkoutHistoryScreen(
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { modifier: Modifier = Modifier,
Text(stringResource(R.string.placeholder_workout_history)) 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)
}
} }
} }
@@ -1,18 +1,141 @@
package com.placeholder.ringhealth.feature.workout package com.placeholder.ringhealth.feature.workout
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box 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.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.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource 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.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 @Composable
fun WorkoutScreen(modifier: Modifier = Modifier) { fun WorkoutScreen(
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { onOpenHistory: () -> Unit,
Text(stringResource(R.string.tab_workout)) 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,
)
}
}
} }
} }
@@ -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<WorkoutRecord>,
)
/** 运动页 ViewModel。 */
class WorkoutViewModel(
healthDataRepository: HealthDataRepository,
) : ViewModel() {
/** 页面状态:仓库数据到达前为 loading,到达后为记录列表。 */
val uiState: StateFlow<WorkoutUiState> = 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
}
}
+8
View File
@@ -36,4 +36,12 @@
<string name="metric_temperature">体温</string> <string name="metric_temperature">体温</string>
<string name="metric_blood_pressure">血压</string> <string name="metric_blood_pressure">血压</string>
<string name="metric_step">步数</string> <string name="metric_step">步数</string>
<string name="workout_start">开始运动</string>
<string name="workout_start_desc">选择运动类型,记录你的训练</string>
<string name="workout_recent">最近运动</string>
<string name="workout_empty">暂无运动记录</string>
<string name="workout_item_summary">%1$s · %2$s 公里</string>
<string name="workout_detail_calories">消耗 %1$s 千卡</string>
<string name="action_view_all">查看全部</string>
</resources> </resources>
@@ -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)
}
}