feat: 实现设备页与设备设置子页(可交互开关)
This commit is contained in:
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewmodel.CreationExtras
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import com.placeholder.ringhealth.feature.device.DeviceViewModel
|
||||
import com.placeholder.ringhealth.feature.home.HomeViewModel
|
||||
import com.placeholder.ringhealth.feature.workout.WorkoutViewModel
|
||||
|
||||
@@ -25,6 +26,11 @@ object AppViewModelProvider {
|
||||
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.domain.model.MetricType
|
||||
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.home.HomeScreen
|
||||
import com.placeholder.ringhealth.feature.profile.ProfileScreen
|
||||
@@ -99,13 +99,13 @@ fun RingHealthApp(navController: NavHostController = rememberNavController()) {
|
||||
WorkoutScreen(onOpenHistory = { navController.navigate(Routes.WORKOUT_HISTORY) })
|
||||
}
|
||||
composable(Routes.DEVICE) {
|
||||
DeviceScreen()
|
||||
DeviceScreen(onOpenSettings = { navController.navigate(Routes.DEVICE_SETTINGS) })
|
||||
}
|
||||
composable(Routes.PROFILE) {
|
||||
ProfileScreen()
|
||||
}
|
||||
composable(Routes.DEVICE_SETTINGS) {
|
||||
DeviceSettingsPlaceholder()
|
||||
DeviceSettingsScreen()
|
||||
}
|
||||
composable(Routes.WORKOUT_HISTORY) {
|
||||
WorkoutHistoryScreen()
|
||||
|
||||
@@ -1,18 +1,179 @@
|
||||
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.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.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.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.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
|
||||
fun DeviceScreen(modifier: Modifier = Modifier) {
|
||||
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(stringResource(R.string.tab_device))
|
||||
fun DeviceScreen(
|
||||
onOpenSettings: () -> Unit,
|
||||
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),
|
||||
)
|
||||
|
||||
+94
-5
@@ -1,18 +1,107 @@
|
||||
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.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.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.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.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
|
||||
fun DeviceSettingsPlaceholder(modifier: Modifier = Modifier) {
|
||||
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(stringResource(R.string.placeholder_device_settings))
|
||||
fun DeviceSettingsScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -44,4 +44,14 @@
|
||||
<string name="workout_item_summary">%1$s · %2$s 公里</string>
|
||||
<string name="workout_detail_calories">消耗 %1$s 千卡</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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user