From 876acd1e7b3dc2617cea08d7e96a6d39f8e67155 Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Wed, 26 Aug 2026 19:23:04 +0800 Subject: [PATCH] feat: replace device shortcuts with device center --- .../ui/devicecenter/DeviceCenterModels.kt | 27 +++ .../ui/devicecenter/DeviceCenterRoute.kt | 27 +++ .../ui/devicecenter/DeviceCenterScreen.kt | 166 ++++++++++++++++++ .../ui/devicecenter/DeviceCenterViewModel.kt | 81 +++++++++ .../ui/navigation/AuthenticatedNavigation.kt | 22 +-- .../devicecenter/DeviceCenterViewModelTest.kt | 75 ++++++++ 6 files changed, 381 insertions(+), 17 deletions(-) create mode 100644 android/app/src/main/kotlin/com/flagship/abox/manager/ui/devicecenter/DeviceCenterModels.kt create mode 100644 android/app/src/main/kotlin/com/flagship/abox/manager/ui/devicecenter/DeviceCenterRoute.kt create mode 100644 android/app/src/main/kotlin/com/flagship/abox/manager/ui/devicecenter/DeviceCenterScreen.kt create mode 100644 android/app/src/main/kotlin/com/flagship/abox/manager/ui/devicecenter/DeviceCenterViewModel.kt create mode 100644 android/app/src/test/kotlin/com/flagship/abox/manager/ui/devicecenter/DeviceCenterViewModelTest.kt diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/devicecenter/DeviceCenterModels.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/devicecenter/DeviceCenterModels.kt new file mode 100644 index 0000000..e823aa3 --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/devicecenter/DeviceCenterModels.kt @@ -0,0 +1,27 @@ +package com.flagship.abox.manager.ui.devicecenter + +import com.flagship.abox.manager.domain.DeviceDescriptor +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.domain.DeviceType +import com.flagship.abox.manager.domain.ProviderProfileId + +data class DeviceCardKey(val profileId: ProviderProfileId, val deviceName: DeviceName) +enum class DeviceCategoryFilter { ALL, SOCKET, SENSOR, INFRARED } +data class DeviceCardUiModel( + val key: DeviceCardKey, + val device: DeviceDescriptor, + val displayName: String = device.name.value, + val typeLabel: String = when (device.type) { + DeviceType.SOCKET -> "插座" + DeviceType.TEMPERATURE_HUMIDITY -> "温湿度" + DeviceType.INFRARED -> "红外控制器" + }, +) +enum class DeviceCenterContent { LOADING, CONTENT, EMPTY, ERROR } +data class DeviceCenterUiState( + val profileId: ProviderProfileId? = null, + val filter: DeviceCategoryFilter = DeviceCategoryFilter.ALL, + val cards: List = emptyList(), + val selectedCard: DeviceCardKey? = null, + val content: DeviceCenterContent = DeviceCenterContent.LOADING, +) diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/devicecenter/DeviceCenterRoute.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/devicecenter/DeviceCenterRoute.kt new file mode 100644 index 0000000..99fbc51 --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/devicecenter/DeviceCenterRoute.kt @@ -0,0 +1,27 @@ +package com.flagship.abox.manager.ui.devicecenter + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flagship.abox.manager.domain.DeviceType +import com.flagship.abox.manager.domain.ProviderProfileId + +@Composable +fun DeviceCenterRoute( + profileId: ProviderProfileId, + onOpenDevice: (String, DeviceType) -> Unit, + modifier: Modifier = Modifier, + viewModel: DeviceCenterViewModel = hiltViewModel(), +) { + val state = viewModel.uiState.collectAsStateWithLifecycle().value + LaunchedEffect(profileId) { viewModel.initialize(profileId) } + DeviceCenterScreen( + state = state, + onFilterChanged = viewModel::setFilter, + onOpenDevice = { card -> onOpenDevice(card.device.name.value, card.device.type) }, + onConfigureDevice = viewModel::addConfiguredDevice, + modifier = modifier, + ) +} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/devicecenter/DeviceCenterScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/devicecenter/DeviceCenterScreen.kt new file mode 100644 index 0000000..4efac83 --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/devicecenter/DeviceCenterScreen.kt @@ -0,0 +1,166 @@ +package com.flagship.abox.manager.ui.devicecenter + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.Button +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.OutlinedTextField +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import com.flagship.abox.manager.designsystem.ABoxCard +import com.flagship.abox.manager.designsystem.ABoxEmptyState +import com.flagship.abox.manager.designsystem.ABoxFilterChip +import com.flagship.abox.manager.designsystem.icon.ABoxIcon +import com.flagship.abox.manager.designsystem.icon.ABoxIconKey +import com.flagship.abox.manager.domain.DeviceType + +@Composable +fun DeviceCenterScreen( + state: DeviceCenterUiState, + onFilterChanged: (DeviceCategoryFilter) -> Unit, + onOpenDevice: (DeviceCardUiModel) -> Unit, + onConfigureDevice: (String, DeviceType) -> Unit, + modifier: Modifier = Modifier, +) { + var showConfiguration by remember { mutableStateOf(false) } + Column( + modifier = modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text("设备中心", style = MaterialTheme.typography.headlineSmall) + Text("选择设备以查看状态或打开控制页面", style = MaterialTheme.typography.bodyMedium) + FilterRow(selected = state.filter, onSelected = onFilterChanged) + when (state.content) { + DeviceCenterContent.LOADING -> Text("正在加载设备", modifier = Modifier.testTag("device_center_loading")) + DeviceCenterContent.EMPTY -> ABoxEmptyState( + title = "尚未配置可管理设备", + description = "请先配置设备名称和类型", + modifier = Modifier.testTag("device_center_empty"), + action = { + Button(onClick = { showConfiguration = true }) { Text("配置设备") } + }, + ) + DeviceCenterContent.ERROR -> Text("设备列表加载失败", modifier = Modifier.testTag("device_center_error")) + DeviceCenterContent.CONTENT -> LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 220.dp), + contentPadding = PaddingValues(bottom = 24.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.testTag("device_center_grid"), + ) { + items(state.cards, key = { it.key }) { card -> + DeviceCard(card = card, onClick = { onOpenDevice(card) }) + } + } + } + } + + if (showConfiguration) { + DeviceConfigurationDialog( + onDismiss = { showConfiguration = false }, + onConfirm = { name, type -> + showConfiguration = false + onConfigureDevice(name, type) + }, + ) + } +} + +@Composable +private fun DeviceConfigurationDialog( + onDismiss: () -> Unit, + onConfirm: (String, DeviceType) -> Unit, +) { + var name by remember { mutableStateOf("") } + var type by remember { mutableStateOf(DeviceType.SOCKET) } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("配置设备") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("设备名称") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + FilterRow( + selected = when (type) { + DeviceType.SOCKET -> DeviceCategoryFilter.SOCKET + DeviceType.TEMPERATURE_HUMIDITY -> DeviceCategoryFilter.SENSOR + DeviceType.INFRARED -> DeviceCategoryFilter.INFRARED + }, + onSelected = { selected -> + type = when (selected) { + DeviceCategoryFilter.SOCKET -> DeviceType.SOCKET + DeviceCategoryFilter.SENSOR -> DeviceType.TEMPERATURE_HUMIDITY + DeviceCategoryFilter.INFRARED -> DeviceType.INFRARED + DeviceCategoryFilter.ALL -> type + } + }, + ) + } + }, + confirmButton = { + Button(onClick = { onConfirm(name.trim(), type) }, enabled = name.isNotBlank()) { Text("保存") } + }, + dismissButton = { Button(onClick = onDismiss) { Text("取消") } }, + ) +} + +@Composable +private fun FilterRow( + selected: DeviceCategoryFilter, + onSelected: (DeviceCategoryFilter) -> Unit, +) { + androidx.compose.foundation.lazy.LazyRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + item { FilterChipItem(DeviceCategoryFilter.ALL, "全部", selected, onSelected) } + item { FilterChipItem(DeviceCategoryFilter.SOCKET, "插座", selected, onSelected) } + item { FilterChipItem(DeviceCategoryFilter.SENSOR, "温湿度", selected, onSelected) } + item { FilterChipItem(DeviceCategoryFilter.INFRARED, "红外", selected, onSelected) } + } +} + +@Composable +private fun FilterChipItem( + filter: DeviceCategoryFilter, + label: String, + selected: DeviceCategoryFilter, + onSelected: (DeviceCategoryFilter) -> Unit, +) { + ABoxFilterChip(selected = selected == filter, label = label, onClick = { onSelected(filter) }) +} + +@Composable +private fun DeviceCard(card: DeviceCardUiModel, onClick: () -> Unit) { + val icon = when (card.device.type) { + DeviceType.SOCKET -> ABoxIconKey.SOCKET + DeviceType.TEMPERATURE_HUMIDITY -> ABoxIconKey.SENSOR + DeviceType.INFRARED -> ABoxIconKey.INFRARED + } + ABoxCard( + title = card.displayName, + subtitle = card.typeLabel, + onClick = onClick, + modifier = Modifier.testTag("device_card_${card.device.id.value}"), + leadingContent = { ABoxIcon(icon, contentDescription = card.typeLabel) }, + ) +} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/devicecenter/DeviceCenterViewModel.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/devicecenter/DeviceCenterViewModel.kt new file mode 100644 index 0000000..5d24ece --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/devicecenter/DeviceCenterViewModel.kt @@ -0,0 +1,81 @@ +package com.flagship.abox.manager.ui.devicecenter + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.flagship.abox.manager.domain.DeviceCatalog +import com.flagship.abox.manager.domain.DeviceDescriptor +import com.flagship.abox.manager.domain.DeviceType +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.devicecatalog.data.ConfiguredDeviceRepository +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +class DeviceCenterViewModel @Inject constructor( + private val catalog: DeviceCatalog, + private val configuredRepository: ConfiguredDeviceRepository, +) : ViewModel() { + private val _uiState = MutableStateFlow(DeviceCenterUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + private var catalogJob: Job? = null + private var allCards: List = emptyList() + + fun initialize(profileId: ProviderProfileId) { + if (_uiState.value.profileId == profileId && catalogJob?.isActive == true) return + catalogJob?.cancel() + allCards = emptyList() + _uiState.value = DeviceCenterUiState(profileId = profileId) + catalogJob = viewModelScope.launch { + catalog.observeDevices(profileId).collectLatest { result -> + when (result) { + is DomainResult.Success -> { + allCards = result.value.map(::toCard) + publishCards() + } + is DomainResult.Failure -> _uiState.value = _uiState.value.copy(content = DeviceCenterContent.ERROR) + } + } + } + } + + fun setFilter(filter: DeviceCategoryFilter) { + _uiState.value = _uiState.value.copy(filter = filter) + publishCards() + } + + fun select(device: DeviceDescriptor) { + _uiState.value = _uiState.value.copy(selectedCard = DeviceCardKey(device.profileId, device.name)) + } + + fun addConfiguredDevice(name: String, type: DeviceType) { + val profileId = _uiState.value.profileId ?: return + viewModelScope.launch { configuredRepository.add(profileId, com.flagship.abox.manager.domain.DeviceName(name), type) } + } + + fun removeConfiguredDevice(device: DeviceCardUiModel) { + val profileId = _uiState.value.profileId ?: return + viewModelScope.launch { configuredRepository.remove(profileId, device.device.name) } + } + + private fun publishCards() { + val filter = _uiState.value.filter + val cards = allCards.filter { filter.matches(it.device.type) } + _uiState.value = _uiState.value.copy(cards = cards, content = if (allCards.isEmpty()) DeviceCenterContent.EMPTY else DeviceCenterContent.CONTENT) + } + + private fun toCard(device: DeviceDescriptor) = DeviceCardUiModel(DeviceCardKey(device.profileId, device.name), device) +} + +private fun DeviceCategoryFilter.matches(type: DeviceType): Boolean = when (this) { + DeviceCategoryFilter.ALL -> true + DeviceCategoryFilter.SOCKET -> type == DeviceType.SOCKET + DeviceCategoryFilter.SENSOR -> type == DeviceType.TEMPERATURE_HUMIDITY + DeviceCategoryFilter.INFRARED -> type == DeviceType.INFRARED +} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/AuthenticatedNavigation.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/AuthenticatedNavigation.kt index 3b5abb9..3d03c75 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/AuthenticatedNavigation.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/AuthenticatedNavigation.kt @@ -40,7 +40,7 @@ import com.flagship.abox.manager.domain.Username import com.flagship.abox.manager.infrared.hub.InfraredHubRoute import com.flagship.abox.manager.infrared.panel.RemotePanelRoute import com.flagship.abox.manager.infrared.ui.InfraredControlRoute -import com.flagship.abox.manager.ui.devicecontrollist.DeviceListScreen +import com.flagship.abox.manager.ui.devicecenter.DeviceCenterRoute import com.flagship.abox.manager.ui.scene.SceneListScreen import com.flagship.abox.manager.ui.preferences.SettingsRoute import com.flagship.abox.manager.ui.scene.SceneRoutes @@ -336,7 +336,10 @@ private fun AuthenticatedNavHost( modifier = modifier, ) { composable(MainTab.DEVICES.route) { - DeviceRoot(onSelectDevice = onSelectDevice) + DeviceCenterRoute( + profileId = profile.id, + onOpenDevice = { deviceName, type -> onSelectDevice(DeviceName(deviceName), type) }, + ) } composable(MainTab.SCENES.route) { SceneListScreen( @@ -407,21 +410,6 @@ private fun AuthenticatedNavHost( } } -@Composable -private fun DeviceRoot( - modifier: Modifier = Modifier, - onSelectDevice: (DeviceName, DeviceType) -> Unit, -) { - DeviceListScreen( - modifier = modifier, - onSocketClick = { onSelectDevice(DeviceName("Zigbee插座控制器"), DeviceType.SOCKET) }, - onTemperatureHumidityClick = { - onSelectDevice(DeviceName("温湿度传感器"), DeviceType.TEMPERATURE_HUMIDITY) - }, - onInfraredClick = { onSelectDevice(DeviceName("红外遥控器"), DeviceType.INFRARED) }, - ) -} - @Composable private fun DeviceDetailPane( selection: DeviceSelection, diff --git a/android/app/src/test/kotlin/com/flagship/abox/manager/ui/devicecenter/DeviceCenterViewModelTest.kt b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/devicecenter/DeviceCenterViewModelTest.kt new file mode 100644 index 0000000..c13b47f --- /dev/null +++ b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/devicecenter/DeviceCenterViewModelTest.kt @@ -0,0 +1,75 @@ +package com.flagship.abox.manager.ui.devicecenter + +import com.flagship.abox.manager.domain.DeviceCatalog +import com.flagship.abox.manager.domain.DeviceDescriptor +import com.flagship.abox.manager.domain.DeviceId +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.domain.DeviceType +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.DomainError +import com.flagship.abox.manager.devicecatalog.data.ConfiguredDeviceRepository +import com.flagship.abox.manager.testing.MainDispatcherRule +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class DeviceCenterViewModelTest { + @get:Rule + val mainDispatcherRule = MainDispatcherRule(UnconfinedTestDispatcher()) + + @Test + fun catalogEmissionMapsCardsAndFilterLocally() = runTest { + val profile = ProviderProfileId("p1") + val catalog = TestCatalog(listOf( + descriptor(profile, "plug", DeviceType.SOCKET), + descriptor(profile, "sensor", DeviceType.TEMPERATURE_HUMIDITY), + )) + val viewModel = DeviceCenterViewModel(catalog, TestConfiguredRepository()) + viewModel.initialize(profile) + viewModel.setFilter(DeviceCategoryFilter.SOCKET) + + assertEquals(listOf("plug"), viewModel.uiState.value.cards.map { it.displayName }) + } + + @Test + fun profileChangeClearsSelectionAndLoadsNewCards() = runTest { + val first = ProviderProfileId("one") + val second = ProviderProfileId("two") + val catalog = TestCatalog(listOf(descriptor(first, "old", DeviceType.SOCKET))) + val viewModel = DeviceCenterViewModel(catalog, TestConfiguredRepository()) + viewModel.initialize(first) + viewModel.select(catalog.devices.single()) + catalog.emit(listOf(descriptor(second, "new", DeviceType.INFRARED))) + viewModel.initialize(second) + + assertEquals(second, viewModel.uiState.value.profileId) + assertEquals(null, viewModel.uiState.value.selectedCard) + assertEquals(listOf("new"), viewModel.uiState.value.cards.map { it.displayName }) + } + + private fun descriptor(profile: ProviderProfileId, name: String, type: DeviceType) = + DeviceDescriptor(DeviceId("${profile.value}:$name"), profile, DeviceName(name), type) + + private class TestCatalog(initial: List) : DeviceCatalog { + val devices = initial.toMutableList() + private val state = MutableStateFlow>>(DomainResult.Success(initial)) + override fun observeDevices(profileId: ProviderProfileId): Flow>> = state + fun emit(value: List) { devices.clear(); devices.addAll(value); state.value = DomainResult.Success(value) } + } + + private class TestConfiguredRepository : ConfiguredDeviceRepository { + override fun observe(profileId: ProviderProfileId): Flow>> = + MutableStateFlow(DomainResult.Success(emptyList())) + override suspend fun add(profileId: ProviderProfileId, name: DeviceName, type: DeviceType): DomainResult = + DomainResult.Failure(DomainError.InvalidRequest()) + override suspend fun remove(profileId: ProviderProfileId, deviceName: DeviceName): DomainResult = + DomainResult.Success(Unit) + } +}