feat(ui): complete ABox app experience redesign #10
+27
@@ -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<DeviceCardUiModel> = emptyList(),
|
||||
val selectedCard: DeviceCardKey? = null,
|
||||
val content: DeviceCenterContent = DeviceCenterContent.LOADING,
|
||||
)
|
||||
+27
@@ -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,
|
||||
)
|
||||
}
|
||||
+166
@@ -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) },
|
||||
)
|
||||
}
|
||||
+81
@@ -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<DeviceCenterUiState> = _uiState.asStateFlow()
|
||||
private var catalogJob: Job? = null
|
||||
private var allCards: List<DeviceCardUiModel> = 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
|
||||
}
|
||||
+5
-17
@@ -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,
|
||||
|
||||
+75
@@ -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<DeviceDescriptor>) : DeviceCatalog {
|
||||
val devices = initial.toMutableList()
|
||||
private val state = MutableStateFlow<DomainResult<List<DeviceDescriptor>>>(DomainResult.Success(initial))
|
||||
override fun observeDevices(profileId: ProviderProfileId): Flow<DomainResult<List<DeviceDescriptor>>> = state
|
||||
fun emit(value: List<DeviceDescriptor>) { devices.clear(); devices.addAll(value); state.value = DomainResult.Success(value) }
|
||||
}
|
||||
|
||||
private class TestConfiguredRepository : ConfiguredDeviceRepository {
|
||||
override fun observe(profileId: ProviderProfileId): Flow<DomainResult<List<DeviceDescriptor>>> =
|
||||
MutableStateFlow(DomainResult.Success(emptyList()))
|
||||
override suspend fun add(profileId: ProviderProfileId, name: DeviceName, type: DeviceType): DomainResult<DeviceDescriptor> =
|
||||
DomainResult.Failure(DomainError.InvalidRequest())
|
||||
override suspend fun remove(profileId: ProviderProfileId, deviceName: DeviceName): DomainResult<Unit> =
|
||||
DomainResult.Success(Unit)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user