diff --git a/app/src/main/java/com/placeholder/ringhealth/AppViewModelProvider.kt b/app/src/main/java/com/placeholder/ringhealth/AppViewModelProvider.kt index c1a1cd6..3ab93cd 100644 --- a/app/src/main/java/com/placeholder/ringhealth/AppViewModelProvider.kt +++ b/app/src/main/java/com/placeholder/ringhealth/AppViewModelProvider.kt @@ -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, + ) + } } } 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 f7d541b..6965db3 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 @@ -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() diff --git a/app/src/main/java/com/placeholder/ringhealth/feature/device/DeviceScreen.kt b/app/src/main/java/com/placeholder/ringhealth/feature/device/DeviceScreen.kt index 31f494f..6dbb58d 100644 --- a/app/src/main/java/com/placeholder/ringhealth/feature/device/DeviceScreen.kt +++ b/app/src/main/java/com/placeholder/ringhealth/feature/device/DeviceScreen.kt @@ -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 = listOf( + DeviceFunctionSetting("heart_rate", "自动心率监测", "每 10 分钟测量一次", true), + DeviceFunctionSetting("blood_oxygen", "自动血氧监测", "夜间持续监测", true), + DeviceFunctionSetting("sleep", "睡眠监测", "自动识别睡眠时段", true), + DeviceFunctionSetting("stress", "压力监测", "全天候压力评估", false), + DeviceFunctionSetting("blood_pressure", "血压监测", "需在 App 内手动校准", false), +) diff --git a/app/src/main/java/com/placeholder/ringhealth/feature/device/DeviceSettingsScreen.kt b/app/src/main/java/com/placeholder/ringhealth/feature/device/DeviceSettingsScreen.kt index 813ec9e..72dd16b 100644 --- a/app/src/main/java/com/placeholder/ringhealth/feature/device/DeviceSettingsScreen.kt +++ b/app/src/main/java/com/placeholder/ringhealth/feature/device/DeviceSettingsScreen.kt @@ -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, + 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 = { _, _ -> }) } } diff --git a/app/src/main/java/com/placeholder/ringhealth/feature/device/DeviceViewModel.kt b/app/src/main/java/com/placeholder/ringhealth/feature/device/DeviceViewModel.kt new file mode 100644 index 0000000..26ae9d1 --- /dev/null +++ b/app/src/main/java/com/placeholder/ringhealth/feature/device/DeviceViewModel.kt @@ -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, +) + +/** 设备页 ViewModel:设备状态 + 功能开关。 */ +class DeviceViewModel( + private val ringDeviceRepository: RingDeviceRepository, +) : ViewModel() { + + val uiState: StateFlow = 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 + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 26f75b6..d85569c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -44,4 +44,14 @@ %1$s · %2$s 公里 消耗 %1$s 千卡 查看全部 + + 已连接 + 未连接 + 电量 %1$d%% + 固件版本 %1$s + 功能设置 + 更多设置 + 已开启 + 已关闭 + 阶段一为演示开关,接入戒指后将同步到设备。 diff --git a/app/src/test/java/com/placeholder/ringhealth/feature/device/DeviceViewModelTest.kt b/app/src/test/java/com/placeholder/ringhealth/feature/device/DeviceViewModelTest.kt new file mode 100644 index 0000000..2a60c78 --- /dev/null +++ b/app/src/test/java/com/placeholder/ringhealth/feature/device/DeviceViewModelTest.kt @@ -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) + } +}