feat: 实现设备页与设备设置子页(可交互开关)

This commit is contained in:
2026-09-10 11:27:27 +08:00
parent 58573f4657
commit 20fe2bce66
7 changed files with 394 additions and 13 deletions
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewmodel.CreationExtras 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.device.DeviceViewModel
import com.placeholder.ringhealth.feature.home.HomeViewModel import com.placeholder.ringhealth.feature.home.HomeViewModel
import com.placeholder.ringhealth.feature.workout.WorkoutViewModel import com.placeholder.ringhealth.feature.workout.WorkoutViewModel
@@ -25,6 +26,11 @@ object AppViewModelProvider {
healthDataRepository = ringHealthApplication().container.healthDataRepository, healthDataRepository = ringHealthApplication().container.healthDataRepository,
) )
} }
initializer {
DeviceViewModel(
ringDeviceRepository = ringHealthApplication().container.ringDeviceRepository,
)
}
} }
} }
@@ -28,7 +28,7 @@ import androidx.navigation.navArgument
import com.placeholder.ringhealth.R import com.placeholder.ringhealth.R
import com.placeholder.ringhealth.domain.model.MetricType import com.placeholder.ringhealth.domain.model.MetricType
import com.placeholder.ringhealth.feature.device.DeviceScreen import com.placeholder.ringhealth.feature.device.DeviceScreen
import com.placeholder.ringhealth.feature.device.DeviceSettingsPlaceholder import com.placeholder.ringhealth.feature.device.DeviceSettingsScreen
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
@@ -99,13 +99,13 @@ fun RingHealthApp(navController: NavHostController = rememberNavController()) {
WorkoutScreen(onOpenHistory = { navController.navigate(Routes.WORKOUT_HISTORY) }) WorkoutScreen(onOpenHistory = { navController.navigate(Routes.WORKOUT_HISTORY) })
} }
composable(Routes.DEVICE) { composable(Routes.DEVICE) {
DeviceScreen() DeviceScreen(onOpenSettings = { navController.navigate(Routes.DEVICE_SETTINGS) })
} }
composable(Routes.PROFILE) { composable(Routes.PROFILE) {
ProfileScreen() ProfileScreen()
} }
composable(Routes.DEVICE_SETTINGS) { composable(Routes.DEVICE_SETTINGS) {
DeviceSettingsPlaceholder() DeviceSettingsScreen()
} }
composable(Routes.WORKOUT_HISTORY) { composable(Routes.WORKOUT_HISTORY) {
WorkoutHistoryScreen() WorkoutHistoryScreen()
@@ -1,18 +1,179 @@
package com.placeholder.ringhealth.feature.device package com.placeholder.ringhealth.feature.device
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Arrangement
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.Bluetooth
import androidx.compose.material.icons.filled.Watch
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.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.R
import com.placeholder.ringhealth.core.designsystem.MetricGreen
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.SectionHeader
import com.placeholder.ringhealth.domain.model.DeviceFunctionSetting
import com.placeholder.ringhealth.domain.model.RingDeviceState
/** 设备页(占位,待 Task 10 实现)。 */ /** 设备(戒指管理)页。 */
@Composable @Composable
fun DeviceScreen(modifier: Modifier = Modifier) { fun DeviceScreen(
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { onOpenSettings: () -> Unit,
Text(stringResource(R.string.tab_device)) modifier: Modifier = Modifier,
viewModel: DeviceViewModel = viewModel(factory = AppViewModelProvider.Factory),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
DeviceContent(state = state, onOpenSettings = onOpenSettings, modifier = modifier)
}
/** 无状态设备页内容。 */
@Composable
private fun DeviceContent(
state: DeviceUiState,
onOpenSettings: () -> Unit,
modifier: Modifier = Modifier,
) {
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item { DeviceInfoCard(state = state) }
item {
SectionHeader(
title = stringResource(R.string.device_settings_title),
trailing = {
TextButton(onClick = onOpenSettings) {
Text(text = stringResource(R.string.device_more))
}
},
)
}
items(state.settings) { setting ->
SettingRow(setting = setting)
}
} }
} }
/** 戒指信息卡片。 */
@Composable
private fun DeviceInfoCard(state: DeviceUiState, modifier: Modifier = Modifier) {
HealthCard(modifier = modifier) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Filled.Watch,
contentDescription = null,
tint = MetricGreen,
modifier = Modifier.size(32.dp),
)
Column(modifier = Modifier.padding(start = 12.dp)) {
Text(text = state.device.name, style = MaterialTheme.typography.titleMedium)
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Filled.Bluetooth,
contentDescription = null,
tint = if (state.device.connected) MetricGreen else TextSecondary,
modifier = Modifier.size(14.dp),
)
Text(
text = stringResource(
if (state.device.connected) R.string.device_connected else R.string.device_disconnected,
),
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
modifier = Modifier.padding(start = 4.dp),
)
}
}
}
Row(
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.device_battery, state.device.batteryPercent),
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = stringResource(R.string.device_firmware, state.device.firmwareVersion),
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
)
}
}
}
/** 单行设置项(只读展示,开关在详情页操作)。 */
@Composable
internal fun SettingRow(setting: DeviceFunctionSetting, modifier: Modifier = Modifier) {
HealthCard(modifier = modifier) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(text = setting.title, style = MaterialTheme.typography.titleSmall)
Text(text = setting.subtitle, style = MaterialTheme.typography.bodySmall, color = TextSecondary)
}
Text(
text = stringResource(
if (setting.enabled) R.string.device_switch_on else R.string.device_switch_off,
),
style = MaterialTheme.typography.bodySmall,
color = if (setting.enabled) MetricGreen else TextSecondary,
)
}
}
}
/** 预览入口:以固定示例数据渲染 [DeviceContent],便于在 IDE 中免真机查看设备页效果。 */
@Preview(showBackground = true)
@Composable
private fun DeviceContentPreview() {
RingHealthTheme {
DeviceContent(
state = DeviceUiState(
device = RingDeviceState(true, "RingHealth 智能戒指", 78, "1.0.5", false),
settings = previewSettings(),
),
onOpenSettings = {},
)
}
}
/**
* Preview 用的假开关列表。
*
* `internal` 而非 `private`:`DeviceSettingsScreen.kt` 的 Preview 也要用。
* 取值与 `MockDataProvider.defaultFunctionSettings()` 保持一致,
* 免得 Preview 里看到的和跑起来看到的不是同一份数据。
*/
internal fun previewSettings(): List<DeviceFunctionSetting> = listOf(
DeviceFunctionSetting("heart_rate", "自动心率监测", "每 10 分钟测量一次", true),
DeviceFunctionSetting("blood_oxygen", "自动血氧监测", "夜间持续监测", true),
DeviceFunctionSetting("sleep", "睡眠监测", "自动识别睡眠时段", true),
DeviceFunctionSetting("stress", "压力监测", "全天候压力评估", false),
DeviceFunctionSetting("blood_pressure", "血压监测", "需在 App 内手动校准", false),
)
@@ -1,18 +1,107 @@
package com.placeholder.ringhealth.feature.device package com.placeholder.ringhealth.feature.device
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Arrangement
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
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.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.R
import com.placeholder.ringhealth.core.designsystem.MetricGreen
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.SectionHeader
import com.placeholder.ringhealth.domain.model.DeviceFunctionSetting
/** 设备设置子页(占位,待 Task 10 实现)。 */ /**
* 设备设置子页:可交互的功能开关列表。
* M1 只改内存状态;M2 后写入设备(对应协议 0502/0504)。
*/
@Composable @Composable
fun DeviceSettingsPlaceholder(modifier: Modifier = Modifier) { fun DeviceSettingsScreen(
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { modifier: Modifier = Modifier,
Text(stringResource(R.string.placeholder_device_settings)) viewModel: DeviceViewModel = viewModel(factory = AppViewModelProvider.Factory),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
DeviceSettingsContent(
settings = state.settings,
onToggle = viewModel::setFunctionEnabled,
modifier = modifier,
)
}
/**
* 无状态设备设置内容。
* 与 `DeviceContent` 同样拆出独立函数,Preview 才能喂假数据渲染(ledger Ruling R26)。
* 注意回调签名是 `(id, enabled)` 而不是 `(DeviceFunctionSetting, Boolean)`:
* 无状态层不该知道「怎么改」,只负责把「改哪一项、改成什么」报上去。
*/
@Composable
private fun DeviceSettingsContent(
settings: List<DeviceFunctionSetting>,
onToggle: (String, Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item { SectionHeader(title = stringResource(R.string.device_settings_title)) }
items(settings) { setting ->
HealthCard {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(text = setting.title, style = MaterialTheme.typography.titleSmall)
Text(text = setting.subtitle, style = MaterialTheme.typography.bodySmall, color = TextSecondary)
}
Switch(
checked = setting.enabled,
onCheckedChange = { checked -> onToggle(setting.id, checked) },
// SwitchDefaults 走正常 import,不写字面量全限定名(与其余文件的 import 风格一致)
colors = SwitchDefaults.colors(checkedTrackColor = MetricGreen),
)
}
}
}
item {
Text(
text = stringResource(R.string.device_settings_hint),
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
modifier = Modifier.padding(top = 4.dp),
)
}
}
}
/** 预览入口:以固定示例数据渲染 [DeviceSettingsContent],便于在 IDE 中免真机查看开关列表效果。 */
@Preview(showBackground = true)
@Composable
private fun DeviceSettingsContentPreview() {
RingHealthTheme {
DeviceSettingsContent(settings = previewSettings(), onToggle = { _, _ -> })
} }
} }
@@ -0,0 +1,46 @@
package com.placeholder.ringhealth.feature.device
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.placeholder.ringhealth.domain.model.DeviceFunctionSetting
import com.placeholder.ringhealth.domain.model.RingDeviceState
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 状态。 */
data class DeviceUiState(
val device: RingDeviceState,
val settings: List<DeviceFunctionSetting>,
)
/** 设备页 ViewModel:设备状态 + 功能开关。 */
class DeviceViewModel(
private val ringDeviceRepository: RingDeviceRepository,
) : ViewModel() {
val uiState: StateFlow<DeviceUiState> = combine(
ringDeviceRepository.deviceState,
ringDeviceRepository.functionSettings,
) { device, settings ->
DeviceUiState(device = device, settings = settings)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(STOP_TIMEOUT_MILLIS),
initialValue = DeviceUiState(
device = ringDeviceRepository.deviceState.value,
settings = ringDeviceRepository.functionSettings.value,
),
)
/** 切换功能开关(M1 仅改内存,M2 后同步到设备)。 */
fun setFunctionEnabled(id: String, enabled: Boolean) {
ringDeviceRepository.setFunctionEnabled(id, enabled)
}
private companion object {
const val STOP_TIMEOUT_MILLIS = 5_000L
}
}
+10
View File
@@ -44,4 +44,14 @@
<string name="workout_item_summary">%1$s · %2$s 公里</string> <string name="workout_item_summary">%1$s · %2$s 公里</string>
<string name="workout_detail_calories">消耗 %1$s 千卡</string> <string name="workout_detail_calories">消耗 %1$s 千卡</string>
<string name="action_view_all">查看全部</string> <string name="action_view_all">查看全部</string>
<string name="device_connected">已连接</string>
<string name="device_disconnected">未连接</string>
<string name="device_battery">电量 %1$d%%</string>
<string name="device_firmware">固件版本 %1$s</string>
<string name="device_settings_title">功能设置</string>
<string name="device_more">更多设置</string>
<string name="device_switch_on">已开启</string>
<string name="device_switch_off">已关闭</string>
<string name="device_settings_hint">阶段一为演示开关,接入戒指后将同步到设备。</string>
</resources> </resources>
@@ -0,0 +1,69 @@
package com.placeholder.ringhealth.feature.device
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.Assert.assertTrue
import org.junit.Before
import org.junit.Test
/** 设备页 ViewModel 测试。 */
@OptIn(ExperimentalCoroutinesApi::class)
class DeviceViewModelTest {
private val dispatcher = StandardTestDispatcher()
@Before
fun setUp() {
Dispatchers.setMain(dispatcher)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun `输出设备状态与五个功能开关`() = runTest(dispatcher) {
val viewModel = DeviceViewModel(MockRingDeviceRepository())
backgroundScope.launch { viewModel.uiState.collect {} }
advanceUntilIdle()
val state = viewModel.uiState.value
assertEquals(78, state.device.batteryPercent)
assertEquals("1.0.5", state.device.firmwareVersion)
assertEquals(5, state.settings.size)
}
@Test
fun `setFunctionEnabled 只改目标项且可来回切换`() = runTest(dispatcher) {
val viewModel = DeviceViewModel(MockRingDeviceRepository())
backgroundScope.launch { viewModel.uiState.collect {} }
advanceUntilIdle()
// Mock 的 5 项里只有 stress 与 blood_pressure 初始为 false,其余 3 项为 true
// (见 MockDataProvider.defaultFunctionSettings)。切换 stress 后应为 4 项开启。
val target = "stress"
assertFalse(viewModel.uiState.value.settings.first { it.id == target }.enabled)
viewModel.setFunctionEnabled(target, true)
advanceUntilIdle()
assertTrue(viewModel.uiState.value.settings.first { it.id == target }.enabled)
// 关键:其它开关不受影响。只断言目标项变了,无法发现「整表被覆盖」这类回归。
assertEquals(4, viewModel.uiState.value.settings.count { it.enabled })
viewModel.setFunctionEnabled(target, false)
advanceUntilIdle()
assertFalse(viewModel.uiState.value.settings.first { it.id == target }.enabled)
}
}