feat(ui): complete ABox app experience redesign #10

Merged
KremeCN merged 40 commits from feat/ui-ux-redesign into main 2026-08-27 23:25:08 +08:00
6 changed files with 381 additions and 17 deletions
Showing only changes of commit 876acd1e7b - Show all commits
@@ -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,
)
@@ -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,
)
}
@@ -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) },
)
}
@@ -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
}
@@ -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,
@@ -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)
}
}