From 54aa74126f8a5ed4777d8f8d80c1b17f4c086c40 Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Thu, 27 Aug 2026 11:45:30 +0800 Subject: [PATCH] feat(ui): complete app experience redesign --- .../ui/devicecenter/DeviceCenterScreenTest.kt | 55 ++ .../ui/preferences/SettingsScreenTest.kt | 128 ++-- .../manager/ui/scene/SceneChoiceFlowTest.kt | 40 + .../ui/socket/SocketDetailScreenTest.kt | 41 ++ .../TemperatureHumidityDetailScreenTest.kt | 43 ++ android/app/src/main/AndroidManifest.xml | 1 + .../abox/manager/di/SceneIntegrationModule.kt | 14 +- .../abox/manager/ui/ABoxManagerApp.kt | 43 +- .../defaults/ABoxDefaultContentInitializer.kt | 122 +++ .../ui/devicecenter/DeviceCenterModels.kt | 28 + .../ui/devicecenter/DeviceCenterRoute.kt | 4 + .../ui/devicecenter/DeviceCenterScreen.kt | 236 +++++- .../ui/devicecenter/DeviceCenterViewModel.kt | 135 ++++ .../ui/navigation/AuthenticatedNavigation.kt | 246 ++++--- .../manager/ui/preferences/SettingsScreen.kt | 692 +++++++++++++----- .../ui/preferences/SettingsViewModel.kt | 34 + .../ui/scene/RecordingSceneExecutor.kt | 94 +++ .../manager/ui/scene/SceneEditorScreen.kt | 51 +- .../abox/manager/ui/scene/SceneListScreen.kt | 134 +++- .../manager/ui/shell/AccountProviderDialog.kt | 224 +++--- .../abox/manager/ui/shell/MainAppShell.kt | 137 ++-- .../abox/manager/ui/socket/SocketScreen.kt | 230 ++++-- .../abox/manager/ui/socket/SocketViewModel.kt | 94 ++- .../TemperatureHumidityScreen.kt | 94 ++- .../TemperatureHumidityViewModel.kt | 31 +- .../src/main/res/values-zh-rCN/strings.xml | 123 ++++ android/app/src/main/res/values/strings.xml | 143 +++- .../devicecenter/DeviceCenterViewModelTest.kt | 11 + .../ui/preferences/SettingsViewModelTest.kt | 37 + .../abox/manager/designsystem/Components.kt | 46 +- .../abox/manager/designsystem/Theme.kt | 15 + .../manager/designsystem/icon/ABoxIcon.kt | 15 + .../renderer/ABoxRendererAdapter.kt | 5 +- .../renderer/MiuixRendererAdapter.kt | 10 +- .../manager/designsystem/tokens/ABoxTokens.kt | 14 +- .../devicecatalog/data/ConfiguredDeviceDao.kt | 3 + .../data/ConfiguredDeviceRepository.kt | 13 + .../DataStoreUiPreferencesRepository.kt | 22 + .../abox/manager/preferences/UiPreferences.kt | 37 + .../DataStoreUiPreferencesRepositoryTest.kt | 24 + .../automation/data/RoomActivityRepository.kt | 20 +- .../manager/automation/di/AutomationModule.kt | 9 + .../AndroidNotificationGateway.kt | 9 +- .../automation/ui/activity/ActivityScreen.kt | 59 +- .../ui/activity/ActivityViewModel.kt | 7 +- .../src/main/res/values-zh-rCN/strings.xml | 21 + .../src/main/res/values/strings.xml | 21 + .../infrared/hub/InfraredHubScreenTest.kt | 57 ++ .../infrared/panel/RemotePanelScreenTest.kt | 66 ++ .../data/local/RoomVirtualRemoteRepository.kt | 17 +- .../infrared/hub/InfraredCenterControls.kt | 414 +++++++++++ .../manager/infrared/hub/InfraredHubScreen.kt | 490 ++++++++++--- .../infrared/hub/InfraredHubUiState.kt | 10 + .../infrared/hub/InfraredHubViewModel.kt | 240 +++++- .../infrared/panel/RemotePanelScreen.kt | 272 ++++--- .../infrared/panel/RemotePanelViewModel.kt | 2 + .../remote/InfraredRemoteViewModel.kt | 3 +- .../remote/ui/InfraredRemoteScreen.kt | 8 +- .../infrared/ui/InfraredControlScreen.kt | 8 +- .../src/main/res/values-zh-rCN/strings.xml | 81 +- .../infrared/src/main/res/values/strings.xml | 75 ++ .../manager/domain}/ActivityEventWriter.kt | 35 +- .../manager/domain/InMemorySceneRepository.kt | 61 +- .../domain/infrared/AddVirtualRemoteKey.kt | 16 +- .../domain/infrared/CreateVirtualRemote.kt | 4 +- .../domain/infrared/InfraredKeyModels.kt | 1 + .../domain/infrared/LearnInfraredKey.kt | 1 + .../domain/infrared/LearnVirtualRemoteKey.kt | 5 + .../domain/infrared/VirtualRemoteModels.kt | 2 +- .../simulation/SimulationDeviceCatalog.kt | 12 +- 70 files changed, 4510 insertions(+), 985 deletions(-) create mode 100644 android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/devicecenter/DeviceCenterScreenTest.kt create mode 100644 android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/scene/SceneChoiceFlowTest.kt create mode 100644 android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/socket/SocketDetailScreenTest.kt create mode 100644 android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityDetailScreenTest.kt create mode 100644 android/app/src/main/kotlin/com/flagship/abox/manager/ui/defaults/ABoxDefaultContentInitializer.kt create mode 100644 android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/RecordingSceneExecutor.kt create mode 100644 android/feature/automation/src/main/res/values-zh-rCN/strings.xml create mode 100644 android/feature/automation/src/main/res/values/strings.xml create mode 100644 android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubScreenTest.kt create mode 100644 android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelScreenTest.kt create mode 100644 android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredCenterControls.kt rename {android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/activity => domain/src/main/kotlin/com/flagship/abox/manager/domain}/ActivityEventWriter.kt (54%) diff --git a/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/devicecenter/DeviceCenterScreenTest.kt b/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/devicecenter/DeviceCenterScreenTest.kt new file mode 100644 index 0000000..ac0d2b5 --- /dev/null +++ b/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/devicecenter/DeviceCenterScreenTest.kt @@ -0,0 +1,55 @@ +package com.flagship.abox.manager.ui.devicecenter + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.flagship.abox.manager.designsystem.ABoxTheme +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.ProviderProfileId +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class DeviceCenterScreenTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun configuredDeviceCardRendersWithSaveableGridKey() { + val profileId = ProviderProfileId("profile-1") + val device = DeviceDescriptor( + id = DeviceId("profile-1:socket-1"), + profileId = profileId, + name = DeviceName("socket-1"), + type = DeviceType.SOCKET, + ) + val card = DeviceCardUiModel( + key = DeviceCardKey(profileId, device.name), + device = device, + ) + + composeRule.setContent { + ABoxTheme { + DeviceCenterScreen( + state = DeviceCenterUiState( + profileId = profileId, + cards = listOf(card), + content = DeviceCenterContent.CONTENT, + ), + onFilterChanged = {}, + onOpenDevice = {}, + onConfigureDevice = { _, _ -> }, + onQuickAction = {}, + ) + } + } + + composeRule.onNodeWithTag("device_card_profile-1:socket-1").assertIsDisplayed() + } +} diff --git a/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/preferences/SettingsScreenTest.kt b/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/preferences/SettingsScreenTest.kt index 60a9e50..114d3eb 100644 --- a/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/preferences/SettingsScreenTest.kt +++ b/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/preferences/SettingsScreenTest.kt @@ -1,26 +1,24 @@ package com.flagship.abox.manager.ui.preferences import androidx.activity.ComponentActivity -import androidx.compose.foundation.layout.Column -import androidx.compose.material3.Text -import androidx.compose.ui.unit.dp 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.test.assertIsSelected +import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.assertWidthIsAtLeast -import androidx.compose.ui.test.assertHeightIsAtLeast +import androidx.compose.ui.test.getUnclippedBoundsInRoot import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.performClick import androidx.test.ext.junit.runners.AndroidJUnit4 import com.flagship.abox.manager.designsystem.ABoxTheme import com.flagship.abox.manager.preferences.DesignRenderer import com.flagship.abox.manager.preferences.UiPreferences +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -31,60 +29,100 @@ class SettingsScreenTest { val composeRule = createAndroidComposeRule() @Test - fun settingsUsesChineseLabelsSelectedStateAndAccessibleControlBounds() { + fun interfaceStyleOpensMenuBeforeChangingRenderer() { + val actions = mutableListOf() composeRule.setContent { ABoxTheme { SettingsScreen( - state = SettingsUiState( - preferences = UiPreferences(renderer = DesignRenderer.MATERIAL3), - loaded = true, - ), - onAction = {}, + state = SettingsUiState(preferences = UiPreferences(renderer = DesignRenderer.MATERIAL3), loaded = true), + onAction = actions::add, ) } } - composeRule.onNodeWithText("界面设置").assertIsDisplayed() - composeRule.onNodeWithText("渲染器").assertIsDisplayed() - composeRule.onNodeWithText("Material 3").assertIsSelected() - composeRule.onNodeWithText("跟随系统").assertIsSelected() - composeRule.onNodeWithText("动态取色").assertIsDisplayed() - composeRule.onNodeWithText("系统辅助功能").assertIsDisplayed() - composeRule.onNodeWithTag("settings-dynamic-color") - .assertWidthIsAtLeast(48.dp) - .assertHeightIsAtLeast(48.dp) - composeRule.onNodeWithTag("settings-blur") - .assertWidthIsAtLeast(48.dp) - .assertHeightIsAtLeast(48.dp) - composeRule.onNodeWithTag("settings-haptics") - .assertWidthIsAtLeast(48.dp) - .assertHeightIsAtLeast(48.dp) + composeRule.onNodeWithTag("settings-style-entry").performClick() + + assertTrue(actions.isEmpty()) + composeRule.onNodeWithTag("settings-style-menu").assertIsDisplayed() + composeRule.onNodeWithText("Miuix").performClick() + assertEquals(SettingsUiAction.SetRenderer(DesignRenderer.MIUIX), actions.single()) } @Test - fun switchingRendererKeepsCurrentlyRenderedTestScreenPresent() { + fun materialThemeShowsEveryMaterialOptionInReferenceOrder() { + showTheme(DesignRenderer.MATERIAL3) + + assertVerticalOrder( + "settings-material-colors", + "settings-theme-mode", + "settings-color-style", + "settings-color-standard", + "settings-predictive-back", + "settings-interface-scale", + ) + composeRule.onAllNodesWithTag("settings-renderer-selector").assertCountEquals(0) + composeRule.onAllNodesWithTag("settings-liquid-glass").assertCountEquals(0) + } + + @Test + fun miuixThemeShowsEveryMiuixOptionInReferenceOrder() { + showTheme(DesignRenderer.MIUIX) + + assertVerticalOrder( + "settings-theme-mode", + "settings-miuix-accent", + "settings-blur", + "settings-floating-navigation", + "settings-liquid-glass", + "settings-predictive-back", + "settings-interface-scale", + ) + composeRule.onAllNodesWithTag("settings-renderer-selector").assertCountEquals(0) + composeRule.onAllNodesWithTag("settings-color-style").assertCountEquals(0) + composeRule.onAllNodesWithTag("settings-color-standard").assertCountEquals(0) + } + + @Test + fun themeDetailRemainsOpenWhenRendererChangesFromHomeMenu() { var preferences by mutableStateOf(UiPreferences(renderer = DesignRenderer.MATERIAL3)) + val experienceState = SettingsExperienceState() composeRule.setContent { ABoxTheme(preferences = preferences) { - Column { - Text("当前测试内容") - SettingsScreen( - state = SettingsUiState(preferences = preferences, loaded = true), - onAction = { action -> - if (action is SettingsUiAction.SetRenderer) { - preferences = preferences.copy(renderer = action.value) - } - }, - modifier = Modifier.weight(1f), - ) - } + SettingsExperience( + state = SettingsUiState(preferences = preferences, loaded = true), + experienceState = experienceState, + onAction = { action -> + if (action is SettingsUiAction.SetRenderer) preferences = preferences.copy(renderer = action.value) + }, + onExit = {}, + ) } } + composeRule.onNodeWithTag("settings-style-entry").performClick() composeRule.onNodeWithText("Miuix").performClick() - composeRule.waitForIdle() + composeRule.onNodeWithTag("settings-theme-entry").performClick() - composeRule.onNodeWithText("当前测试内容").assertIsDisplayed() - composeRule.onNodeWithText("Miuix").assertIsSelected() + composeRule.onNodeWithTag("settings-theme-detail").assertIsDisplayed() + composeRule.onNodeWithTag("settings-miuix-accent").assertIsDisplayed() + } + + private fun showTheme(renderer: DesignRenderer) { + composeRule.setContent { + val experienceState = remember { SettingsExperienceState("THEME") } + ABoxTheme { + SettingsExperience( + state = SettingsUiState(preferences = UiPreferences(renderer = renderer), loaded = true), + experienceState = experienceState, + onAction = {}, + onExit = {}, + ) + } + } + } + + private fun assertVerticalOrder(vararg tags: String) { + val tops = tags.map { tag -> composeRule.onNodeWithTag(tag).getUnclippedBoundsInRoot().top } + assertTrue("Expected controls in reference-image order: ${tags.toList()}", tops.zipWithNext().all { it.first < it.second }) } } diff --git a/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/scene/SceneChoiceFlowTest.kt b/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/scene/SceneChoiceFlowTest.kt new file mode 100644 index 0000000..dc18aa4 --- /dev/null +++ b/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/scene/SceneChoiceFlowTest.kt @@ -0,0 +1,40 @@ +package com.flagship.abox.manager.ui.scene + +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.width +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Text +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.getUnclippedBoundsInRoot +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.unit.dp +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.flagship.abox.manager.designsystem.ABoxTheme +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class SceneChoiceFlowTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun choicesWrapOntoAnotherLineAtNarrowEnglishWidth() { + composeRule.setContent { + ABoxTheme { + SceneChoiceFlow(modifier = Modifier.width(240.dp)) { + listOf("Socket", "Remote", "Delay", "Notify").forEach { label -> + FilterChip(selected = false, onClick = {}, label = { Text(label) }) + } + } + } + } + + val firstTop = composeRule.onNodeWithText("Socket").getUnclippedBoundsInRoot().top + val lastTop = composeRule.onNodeWithText("Notify").getUnclippedBoundsInRoot().top + assertTrue("The final choice should wrap below the first row", lastTop > firstTop) + } +} diff --git a/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/socket/SocketDetailScreenTest.kt b/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/socket/SocketDetailScreenTest.kt new file mode 100644 index 0000000..21de05e --- /dev/null +++ b/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/socket/SocketDetailScreenTest.kt @@ -0,0 +1,41 @@ +package com.flagship.abox.manager.ui.socket + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.flagship.abox.manager.designsystem.ABoxTheme +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.SocketPowerState +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class SocketDetailScreenTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun awaitingConfirmationShowsMainControlAndWarning() { + composeRule.setContent { + ABoxTheme { + SocketScreen( + state = SocketUiState( + profileId = ProviderProfileId("p1"), + deviceName = DeviceName("socket-1"), + powerState = SocketPowerState.ON, + operation = SocketOperationState.AwaitingConfirmation(SocketPowerState.OFF), + ), + onEvent = {}, + onBackClick = {}, + ) + } + } + + composeRule.onNodeWithTag("socket_power_dial").assertIsDisplayed() + composeRule.onNodeWithTag("socket_awaiting_confirmation").assertIsDisplayed() + } +} diff --git a/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityDetailScreenTest.kt b/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityDetailScreenTest.kt new file mode 100644 index 0000000..5cdea60 --- /dev/null +++ b/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityDetailScreenTest.kt @@ -0,0 +1,43 @@ +package com.flagship.abox.manager.ui.temperaturehumidity + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.flagship.abox.manager.designsystem.ABoxTheme +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.domain.ProviderProfileId +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class TemperatureHumidityDetailScreenTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun partialReadingRendersOnlyAvailableMetric() { + composeRule.setContent { + ABoxTheme { + TemperatureHumidityScreen( + state = TemperatureHumidityUiState( + profileId = ProviderProfileId("p1"), + deviceName = DeviceName("sensor-1"), + temperature = "24.2", + humidity = null, + ), + onEvent = {}, + onBackClick = {}, + ) + } + } + + composeRule.onNodeWithTag("sensor_metric_temperature").assertIsDisplayed() + composeRule.onAllNodesWithTag("sensor_metric_humidity").assertCountEquals(0) + composeRule.onNodeWithTag("sensor_monitoring_notice").assertIsDisplayed() + } +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 5cda260..9472773 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -3,6 +3,7 @@ Unit, onSessionAction: (SessionUiAction) -> Unit, + showAccountDialog: Boolean, + showSettings: Boolean, + settingsExperienceState: SettingsExperienceState, + onShowAccountDialogChange: (Boolean) -> Unit, + onShowSettingsChange: (Boolean) -> Unit, ) { - var showAccountDialog by remember { mutableStateOf(false) } - var showSettings by remember { mutableStateOf(false) } - if (showSettings) { - BackHandler { showSettings = false } - SettingsRoute() + BackHandler { onShowSettingsChange(false) } + SettingsRoute( + experienceState = settingsExperienceState, + onExit = { onShowSettingsChange(false) }, + ) } else { AuthenticatedNavigation( username = username, profile = profile, onLogout = onLogout, - onProviderClick = { showAccountDialog = true }, - onAvatarClick = { showAccountDialog = true }, + onProviderClick = { onShowAccountDialogChange(true) }, + onAvatarClick = { onShowAccountDialogChange(true) }, ) } @@ -111,21 +128,21 @@ private fun AuthenticatedNavHost( AccountProviderDialog( userName = username.value, providerName = profile.displayName, - onDismissRequest = { showAccountDialog = false }, + onDismissRequest = { onShowAccountDialogChange(false) }, onOpenSettings = { - showAccountDialog = false - showSettings = true + onShowAccountDialogChange(false) + onShowSettingsChange(true) }, onSwitchProvider = { - showAccountDialog = false + onShowAccountDialogChange(false) onSessionAction(SessionUiAction.SwitchProvider) }, onEndSession = { - showAccountDialog = false + onShowAccountDialogChange(false) onSessionAction(SessionUiAction.EndSession) }, onForgetCredentials = { - showAccountDialog = false + onShowAccountDialogChange(false) onSessionAction(SessionUiAction.ForgetSavedCredentials) }, ) diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/defaults/ABoxDefaultContentInitializer.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/defaults/ABoxDefaultContentInitializer.kt new file mode 100644 index 0000000..416c7be --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/defaults/ABoxDefaultContentInitializer.kt @@ -0,0 +1,122 @@ +package com.flagship.abox.manager.ui.defaults + +import android.content.Context +import com.flagship.abox.manager.devicecatalog.data.ConfiguredDeviceRepository +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.ProviderProfileStore +import com.flagship.abox.manager.domain.ProviderType +import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus +import com.flagship.abox.manager.domain.infrared.PanelLayoutStyle +import com.flagship.abox.manager.domain.infrared.PresetCodeLibrary +import com.flagship.abox.manager.domain.infrared.RemoteDeviceType +import com.flagship.abox.manager.domain.infrared.RemoteKeyBinding +import com.flagship.abox.manager.domain.infrared.TransmissionMode +import com.flagship.abox.manager.domain.infrared.VirtualRemote +import com.flagship.abox.manager.domain.infrared.VirtualRemoteId +import com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.flow.first + +@Singleton +class ABoxDefaultContentInitializer @Inject constructor( + @ApplicationContext context: Context, + private val profileStore: ProviderProfileStore, + private val deviceRepository: ConfiguredDeviceRepository, + private val remoteRepository: VirtualRemoteRepository, +) { + private val preferences = context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE) + + suspend fun initialize(profileId: ProviderProfileId) { + val profile = profileStore.activeProfile() + if (profile?.id != profileId || profile.type != ProviderType.ABOX) return + + val marker = "$MARKER_PREFIX${profileId.value}" + if (preferences.getInt(marker, 0) >= CONTENT_VERSION) return + + if (!ensureDevices(profileId)) return + if (!ensureCustomRemotes(profileId)) return + preferences.edit().putInt(marker, CONTENT_VERSION).apply() + } + + private suspend fun ensureDevices(profileId: ProviderProfileId): Boolean { + val existing = when (val result = deviceRepository.observe(profileId).first()) { + is DomainResult.Success -> result.value + is DomainResult.Failure -> return false + } + val defaults = listOf( + DeviceName("Zigbee插座控制器") to DeviceType.SOCKET, + DeviceName("温湿度传感器") to DeviceType.TEMPERATURE_HUMIDITY, + DeviceName("红外控制器") to DeviceType.INFRARED, + ) + for ((name, type) in defaults) { + if (existing.any { it.type == type }) continue + if (deviceRepository.add(profileId, name, type) is DomainResult.Failure) return false + } + return true + } + + private suspend fun ensureCustomRemotes(profileId: ProviderProfileId): Boolean { + val deviceName = DeviceName("红外控制器") + val existing = when (val result = remoteRepository.observeRemotes(profileId, deviceName).first()) { + is DomainResult.Success -> result.value + is DomainResult.Failure -> return false + } + val now = System.currentTimeMillis() + val remotes = listOf( + customRemote(profileId, deviceName, "custom-tv", "自定义电视遥控器", PanelLayoutStyle.TV_NAV, now), + customRemote(profileId, deviceName, "custom-panel", "自定义面板遥控器", PanelLayoutStyle.GRID, now + 1), + ) + for (remote in remotes) { + if (existing.any { it.id == remote.id }) continue + val templates = PresetCodeLibrary.getTemplatesForType( + type = RemoteDeviceType.CUSTOM, + layoutStyle = remote.layoutStyle, + ) + val bindings = templates.mapIndexed { index, template -> + RemoteKeyBinding( + id = "${remote.id.value}:${index + 1}", + remoteId = remote.id, + profileId = profileId, + hardwareDeviceName = deviceName, + buttonId = template.buttonId, + displayName = template.displayName, + assignedSlot = null, + rawHexCode = null, + status = InfraredKeyStatus.UNLEARNED, + ) + } + if (remoteRepository.createRemote(remote, bindings) is DomainResult.Failure) return false + } + return true + } + + private fun customRemote( + profileId: ProviderProfileId, + deviceName: DeviceName, + idSuffix: String, + displayName: String, + layout: PanelLayoutStyle, + createdAt: Long, + ) = VirtualRemote( + id = VirtualRemoteId("${profileId.value}:default:$idSuffix"), + profileId = profileId, + deviceName = deviceName, + displayName = displayName, + type = RemoteDeviceType.CUSTOM, + layoutStyle = layout, + transmissionMode = TransmissionMode.STATIC_SLOTS, + createdAtEpochMillis = createdAt, + updatedAtEpochMillis = createdAt, + ) + + private companion object { + const val PREFERENCES_NAME = "abox_default_content" + const val MARKER_PREFIX = "content_version_" + const val CONTENT_VERSION = 1 + } +} 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 index e823aa3..af95ad2 100644 --- 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 @@ -4,8 +4,13 @@ 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 +import com.flagship.abox.manager.domain.SocketPowerState +import com.flagship.abox.manager.domain.infrared.SlotQuotaSummary data class DeviceCardKey(val profileId: ProviderProfileId, val deviceName: DeviceName) + +fun DeviceCardKey.saveableKey(): String = "${profileId.value}\u0000${deviceName.value}" + enum class DeviceCategoryFilter { ALL, SOCKET, SENSOR, INFRARED } data class DeviceCardUiModel( val key: DeviceCardKey, @@ -16,7 +21,30 @@ data class DeviceCardUiModel( DeviceType.TEMPERATURE_HUMIDITY -> "温湿度" DeviceType.INFRARED -> "红外控制器" }, + val detail: DeviceCardDetail = DeviceCardDetail.Loading, ) + +sealed interface DeviceCardDetail { + data object Loading : DeviceCardDetail + data class Socket( + val powerState: SocketPowerState, + val operating: Boolean = false, + val pendingTarget: SocketPowerState? = null, + val lastConfirmedAtMillis: Long? = null, + ) : DeviceCardDetail + data class Sensor( + val temperature: String?, + val humidity: String?, + val refreshing: Boolean = false, + val lastUpdatedAtMillis: Long? = null, + ) : DeviceCardDetail + data class Infrared( + val remoteNames: List, + val quota: SlotQuotaSummary, + ) : DeviceCardDetail + data object Offline : DeviceCardDetail + data object Unavailable : DeviceCardDetail +} enum class DeviceCenterContent { LOADING, CONTENT, EMPTY, ERROR } data class DeviceCenterUiState( val profileId: ProviderProfileId? = null, 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 index 99fbc51..353568c 100644 --- 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 @@ -22,6 +22,10 @@ fun DeviceCenterRoute( onFilterChanged = viewModel::setFilter, onOpenDevice = { card -> onOpenDevice(card.device.name.value, card.device.type) }, onConfigureDevice = viewModel::addConfiguredDevice, + onQuickAction = { card -> + if (card.device.type == DeviceType.SOCKET) viewModel.toggleSocket(card) + else viewModel.refreshDevice(card) + }, 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 index 4efac83..8cac803 100644 --- 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 @@ -6,6 +6,22 @@ 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.foundation.layout.Row +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.clickable +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.border +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.IconButton +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Surface import androidx.compose.material3.Button import androidx.compose.material3.AlertDialog import androidx.compose.material3.OutlinedTextField @@ -20,14 +36,20 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.Alignment import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import com.flagship.abox.manager.R 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 +import com.flagship.abox.manager.domain.SocketPowerState +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow @Composable fun DeviceCenterScreen( @@ -35,6 +57,7 @@ fun DeviceCenterScreen( onFilterChanged: (DeviceCategoryFilter) -> Unit, onOpenDevice: (DeviceCardUiModel) -> Unit, onConfigureDevice: (String, DeviceType) -> Unit, + onQuickAction: (DeviceCardUiModel) -> Unit, modifier: Modifier = Modifier, ) { var showConfiguration by remember { mutableStateOf(false) } @@ -42,20 +65,21 @@ fun DeviceCenterScreen( 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) + Column(modifier = Modifier.padding(horizontal = 2.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(stringResource(R.string.device_center_heading), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.device_center_description), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } when (state.content) { - DeviceCenterContent.LOADING -> Text("正在加载设备", modifier = Modifier.testTag("device_center_loading")) + DeviceCenterContent.LOADING -> Text(stringResource(R.string.device_center_loading), modifier = Modifier.testTag("device_center_loading")) DeviceCenterContent.EMPTY -> ABoxEmptyState( - title = "尚未配置可管理设备", - description = "请先配置设备名称和类型", + title = stringResource(R.string.device_center_empty_title), + description = stringResource(R.string.device_center_empty_description), modifier = Modifier.testTag("device_center_empty"), action = { - Button(onClick = { showConfiguration = true }) { Text("配置设备") } + Button(onClick = { showConfiguration = true }) { Text(stringResource(R.string.device_center_configure)) } }, ) - DeviceCenterContent.ERROR -> Text("设备列表加载失败", modifier = Modifier.testTag("device_center_error")) + DeviceCenterContent.ERROR -> Text(stringResource(R.string.device_center_error), modifier = Modifier.testTag("device_center_error")) DeviceCenterContent.CONTENT -> LazyVerticalGrid( columns = GridCells.Adaptive(minSize = 220.dp), contentPadding = PaddingValues(bottom = 24.dp), @@ -63,8 +87,12 @@ fun DeviceCenterScreen( verticalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.testTag("device_center_grid"), ) { - items(state.cards, key = { it.key }) { card -> - DeviceCard(card = card, onClick = { onOpenDevice(card) }) + items(state.cards, key = { it.key.saveableKey() }) { card -> + when (card.device.type) { + DeviceType.SOCKET -> SocketDeviceCard(card, { onOpenDevice(card) }, { onQuickAction(card) }) + DeviceType.TEMPERATURE_HUMIDITY -> SensorDeviceCard(card, { onOpenDevice(card) }, { onQuickAction(card) }) + DeviceType.INFRARED -> InfraredDeviceCard(card, { onOpenDevice(card) }) + } } } } @@ -90,13 +118,13 @@ private fun DeviceConfigurationDialog( var type by remember { mutableStateOf(DeviceType.SOCKET) } AlertDialog( onDismissRequest = onDismiss, - title = { Text("配置设备") }, + title = { Text(stringResource(R.string.device_center_configure_title)) }, text = { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { OutlinedTextField( value = name, onValueChange = { name = it }, - label = { Text("设备名称") }, + label = { Text(stringResource(R.string.device_center_device_name)) }, singleLine = true, modifier = Modifier.fillMaxWidth(), ) @@ -118,9 +146,9 @@ private fun DeviceConfigurationDialog( } }, confirmButton = { - Button(onClick = { onConfirm(name.trim(), type) }, enabled = name.isNotBlank()) { Text("保存") } + Button(onClick = { onConfirm(name.trim(), type) }, enabled = name.isNotBlank()) { Text(stringResource(R.string.action_confirm)) } }, - dismissButton = { Button(onClick = onDismiss) { Text("取消") } }, + dismissButton = { Button(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } }, ) } @@ -150,17 +178,173 @@ private fun FilterChipItem( } @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 +private fun SocketDeviceCard(card: DeviceCardUiModel, onOpen: () -> Unit, onToggle: () -> Unit) { + val detail = card.detail as? DeviceCardDetail.Socket + val isOffline = card.detail is DeviceCardDetail.Offline + val isAwaitingConfirmation = detail?.pendingTarget != null && !detail.operating + val isOn = detail?.powerState == SocketPowerState.ON + DeviceSurface(card, onOpen, minimumHeight = 0.dp) { + DeviceHeader( + icon = ABoxIconKey.SOCKET, + title = card.displayName, + subtitle = "${stringResource(R.string.socket_title)} · ${when { isOffline -> stringResource(R.string.device_status_offline); detail == null -> stringResource(R.string.device_status_unavailable); detail.operating -> stringResource(R.string.device_status_switching); isAwaitingConfirmation -> stringResource(R.string.device_status_awaiting_confirmation); isOn -> stringResource(R.string.device_status_on); else -> stringResource(R.string.device_status_off) }}", + action = { + Surface( + modifier = Modifier + .size(48.dp) + .border( + width = if (isOn) 2.dp else 1.dp, + color = if (isOn) MaterialTheme.colorScheme.primary.copy(alpha = 0.45f) + else MaterialTheme.colorScheme.outline, + shape = CircleShape, + ), + shape = CircleShape, + color = if (isOn) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surface, + tonalElevation = if (isOn) 6.dp else 0.dp, + ) { + IconButton( + onClick = onToggle, + enabled = !isOffline && detail != null && !detail.operating && !isAwaitingConfirmation, + ) { + if (detail?.operating == true) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = if (isOn) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.primary, + ) + } else { + ABoxIcon( + ABoxIconKey.POWER, + if (isOn) "关闭插座" else "开启插座", + modifier = Modifier.size(22.dp), + ) + } + } + } + }, + ) + CardFooter( + left = when { + isOffline -> stringResource(R.string.device_status_offline) + isAwaitingConfirmation -> stringResource(R.string.device_status_awaiting_confirmation) + detail?.lastConfirmedAtMillis != null -> stringResource(R.string.device_status_confirmed) + else -> stringResource(R.string.device_status_waiting_first_confirmation) + }, + right = stringResource(R.string.device_open_control_panel), + modifier = Modifier.padding(top = 24.dp), + ) + } +} + +@Composable +private fun SensorDeviceCard(card: DeviceCardUiModel, onOpen: () -> Unit, onRefresh: () -> Unit) { + val detail = card.detail as? DeviceCardDetail.Sensor + val isOffline = card.detail is DeviceCardDetail.Offline + DeviceSurface(card, onOpen, minimumHeight = 190.dp) { + DeviceHeader( + icon = ABoxIconKey.SENSOR, + title = card.displayName, + subtitle = if (isOffline) { + "${stringResource(R.string.device_sensor_subtitle)} · ${stringResource(R.string.device_status_offline)}" + } else { + stringResource(R.string.device_sensor_subtitle) + }, + action = { + IconButton(onClick = onRefresh, enabled = detail?.refreshing != true) { + if (detail?.refreshing == true) CircularProgressIndicator(Modifier.size(20.dp)) + else ABoxIcon(ABoxIconKey.REFRESH, "刷新温湿度") + } + }, + ) + Row(Modifier.fillMaxWidth().padding(vertical = 22.dp), horizontalArrangement = Arrangement.SpaceEvenly) { + Metric("环境温度", detail?.temperature, "°C", ABoxIconKey.TEMPERATURE) + Surface(Modifier.width(1.dp).height(48.dp), color = MaterialTheme.colorScheme.outlineVariant) {} + Metric("相对湿度", detail?.humidity, "%", ABoxIconKey.HUMIDITY) + } + CardFooter( + if (isOffline) stringResource(R.string.device_status_offline) else stringResource(R.string.device_live_reading), + stringResource(R.string.device_details), + Modifier.padding(top = 4.dp), + ) + } +} + +@Composable +private fun InfraredDeviceCard(card: DeviceCardUiModel, onOpen: () -> Unit) { + val detail = card.detail as? DeviceCardDetail.Infrared + DeviceSurface(card, onOpen, minimumHeight = 172.dp) { + DeviceHeader( + icon = ABoxIconKey.INFRARED, + title = card.displayName, + subtitle = stringResource(R.string.device_infrared_subtitle, detail?.remoteNames?.size ?: 0), + ) + Text( + text = stringResource(R.string.device_infrared_mounted, detail?.remoteNames?.joinToString("、")?.takeIf { it.isNotBlank() } ?: stringResource(R.string.device_infrared_no_remotes)), + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 22.dp, bottom = 18.dp), + ) + CardFooter( + left = detail?.quota?.let { stringResource(R.string.device_infrared_slots, it.usedCount, it.totalCapacity) } ?: stringResource(R.string.device_infrared_slots_unavailable), + right = stringResource(R.string.device_infrared_center), + ) + } +} + +@Composable +private fun DeviceSurface( + card: DeviceCardUiModel, + onOpen: () -> Unit, + minimumHeight: androidx.compose.ui.unit.Dp, + content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit, +) { + Card( + modifier = Modifier.fillMaxWidth().testTag("device_card_${card.device.id.value}"), + shape = RoundedCornerShape(6.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + ) { + Column( + Modifier + .fillMaxWidth() + .defaultMinSize(minHeight = minimumHeight) + .clickable(onClick = onOpen) + .padding(horizontal = 18.dp, vertical = 18.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + content = content, + ) + } +} + +@Composable +private fun DeviceHeader(icon: ABoxIconKey, title: String, subtitle: String, action: (@Composable () -> Unit)? = null) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Surface(shape = RoundedCornerShape(6.dp), color = MaterialTheme.colorScheme.primaryContainer) { + ABoxIcon(icon, subtitle, Modifier.padding(10.dp).size(24.dp)) + } + Column(Modifier.weight(1f).padding(start = 12.dp)) { + Text(title, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + action?.invoke() + } +} + +@Composable +private fun Metric(label: String, value: String?, unit: String, icon: ABoxIconKey) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Row(verticalAlignment = Alignment.CenterVertically) { + ABoxIcon(icon, label, Modifier.size(18.dp)) + Text(value ?: stringResource(R.string.device_metric_no_data), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, modifier = Modifier.padding(start = 6.dp)) + if (value != null) Text(unit, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 2.dp)) + } + Text(label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } +} + +@Composable +private fun CardFooter(left: String, right: String, modifier: Modifier = Modifier) { + Row(modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text(left, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(right, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold) } - 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 index 5d24ece..de15933 100644 --- 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 @@ -5,15 +5,26 @@ 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.DomainError import com.flagship.abox.manager.domain.DomainResult import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.SocketGateway +import com.flagship.abox.manager.domain.SocketPowerState +import com.flagship.abox.manager.domain.TemperatureHumidityGateway +import com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository import com.flagship.abox.manager.devicecatalog.data.ConfiguredDeviceRepository +import com.flagship.abox.manager.ui.defaults.ABoxDefaultContentInitializer +import com.flagship.abox.manager.domain.ActivityEventWriter +import com.flagship.abox.manager.domain.ActivityEventKind +import com.flagship.abox.manager.domain.ActivityOutcome 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 kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject @@ -21,6 +32,11 @@ import javax.inject.Inject class DeviceCenterViewModel @Inject constructor( private val catalog: DeviceCatalog, private val configuredRepository: ConfiguredDeviceRepository, + private val socketGateway: SocketGateway, + private val temperatureHumidityGateway: TemperatureHumidityGateway, + private val virtualRemoteRepository: VirtualRemoteRepository, + private val defaultContentInitializer: ABoxDefaultContentInitializer, + private val activityWriter: ActivityEventWriter, ) : ViewModel() { private val _uiState = MutableStateFlow(DeviceCenterUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -33,11 +49,13 @@ class DeviceCenterViewModel @Inject constructor( allCards = emptyList() _uiState.value = DeviceCenterUiState(profileId = profileId) catalogJob = viewModelScope.launch { + defaultContentInitializer.initialize(profileId) catalog.observeDevices(profileId).collectLatest { result -> when (result) { is DomainResult.Success -> { allCards = result.value.map(::toCard) publishCards() + refreshAllCards() } is DomainResult.Failure -> _uiState.value = _uiState.value.copy(content = DeviceCenterContent.ERROR) } @@ -64,6 +82,112 @@ class DeviceCenterViewModel @Inject constructor( viewModelScope.launch { configuredRepository.remove(profileId, device.device.name) } } + fun refreshDevice(card: DeviceCardUiModel) { + when (card.device.type) { + DeviceType.SOCKET -> refreshSocket(card) + DeviceType.TEMPERATURE_HUMIDITY -> refreshSensor(card) + DeviceType.INFRARED -> refreshInfrared(card) + } + } + + fun toggleSocket(card: DeviceCardUiModel) { + val detail = card.detail as? DeviceCardDetail.Socket ?: return + if (detail.operating) return + val target = if (detail.powerState == SocketPowerState.ON) SocketPowerState.OFF else SocketPowerState.ON + updateCard(card.key) { it.copy(detail = detail.copy(operating = true, pendingTarget = target)) } + viewModelScope.launch { + val command = socketGateway.commandPower(card.device.profileId, card.device.name, target) + activityWriter.recordDevice( + card.device.profileId, + card.device.name, + ActivityEventKind.SOCKET_COMMAND, + command.activityOutcome(), + command.activityErrorCode(), + ) + if (command is DomainResult.Success) { + repeat(CONFIRMATION_ATTEMPTS) { attempt -> + if (attempt > 0) delay(CONFIRMATION_RETRY_DELAY_MILLIS) + when (val confirmed = socketGateway.getStatus(card.device.profileId, card.device.name)) { + is DomainResult.Success -> if (confirmed.value.powerState == target) { + activityWriter.recordDevice(card.device.profileId, card.device.name, ActivityEventKind.SOCKET_CONFIRMATION, ActivityOutcome.SUCCEEDED) + updateCard(card.key) { + it.copy(detail = DeviceCardDetail.Socket(target, lastConfirmedAtMillis = System.currentTimeMillis())) + } + return@launch + } + is DomainResult.Failure -> Unit + } + } + activityWriter.recordDevice( + card.device.profileId, + card.device.name, + ActivityEventKind.SOCKET_CONFIRMATION, + ActivityOutcome.AWAITING_CONFIRMATION, + ) + updateCard(card.key) { it.copy(detail = detail.copy(operating = false, pendingTarget = target)) } + } else { + updateCard(card.key) { it.copy(detail = detail.copy(operating = false, pendingTarget = null)) } + } + } + } + + private fun refreshAllCards() = allCards.forEach(::refreshDevice) + + private fun refreshSocket(card: DeviceCardUiModel) { + viewModelScope.launch { + when (val result = socketGateway.getStatus(card.device.profileId, card.device.name)) { + is DomainResult.Success -> updateCard(card.key) { + it.copy(detail = DeviceCardDetail.Socket(result.value.powerState, lastConfirmedAtMillis = System.currentTimeMillis())) + } + is DomainResult.Failure -> updateCard(card.key) { + it.copy(detail = if (result.error is DomainError.DeviceOffline) DeviceCardDetail.Offline else DeviceCardDetail.Unavailable) + } + } + } + } + + private fun refreshSensor(card: DeviceCardUiModel) { + val previous = card.detail as? DeviceCardDetail.Sensor + updateCard(card.key) { it.copy(detail = previous?.copy(refreshing = true) ?: DeviceCardDetail.Loading) } + viewModelScope.launch { + when (val result = temperatureHumidityGateway.getReading(card.device.profileId, card.device.name)) { + is DomainResult.Success -> updateCard(card.key) { + it.copy(detail = DeviceCardDetail.Sensor( + temperature = result.value.temperature, + humidity = result.value.humidity, + lastUpdatedAtMillis = System.currentTimeMillis(), + )) + } + is DomainResult.Failure -> updateCard(card.key) { + it.copy( + detail = if (result.error is DomainError.DeviceOffline) { + DeviceCardDetail.Offline + } else { + previous?.copy(refreshing = false) ?: DeviceCardDetail.Unavailable + }, + ) + } + } + } + } + + private fun refreshInfrared(card: DeviceCardUiModel) { + viewModelScope.launch { + val remotes = virtualRemoteRepository.observeRemotes(card.device.profileId, card.device.name).first() + val quota = virtualRemoteRepository.observeSlotQuota(card.device.profileId, card.device.name).first() + if (remotes is DomainResult.Success && quota is DomainResult.Success) { + updateCard(card.key) { + it.copy(detail = DeviceCardDetail.Infrared(remotes.value.map { remote -> remote.displayName }, quota.value)) + } + } else updateCard(card.key) { it.copy(detail = DeviceCardDetail.Unavailable) } + } + } + + private fun updateCard(key: DeviceCardKey, transform: (DeviceCardUiModel) -> DeviceCardUiModel) { + allCards = allCards.map { if (it.key == key) transform(it) else it } + publishCards() + } + private fun publishCards() { val filter = _uiState.value.filter val cards = allCards.filter { filter.matches(it.device.type) } @@ -71,8 +195,19 @@ class DeviceCenterViewModel @Inject constructor( } private fun toCard(device: DeviceDescriptor) = DeviceCardUiModel(DeviceCardKey(device.profileId, device.name), device) + + private companion object { + const val CONFIRMATION_ATTEMPTS = 4 + const val CONFIRMATION_RETRY_DELAY_MILLIS = 500L + } } +private fun DomainResult<*>.activityOutcome(): ActivityOutcome = + if (this is DomainResult.Success) ActivityOutcome.SUCCEEDED else ActivityOutcome.FAILED + +private fun DomainResult<*>.activityErrorCode(): String? = + (this as? DomainResult.Failure)?.error?.externalCode + private fun DeviceCategoryFilter.matches(type: DeviceType): Boolean = when (this) { DeviceCategoryFilter.ALL -> true DeviceCategoryFilter.SOCKET -> type == DeviceType.SOCKET 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 220a30d..2f6d5db 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 @@ -2,11 +2,14 @@ package com.flagship.abox.manager.ui.navigation import android.net.Uri import androidx.activity.compose.BackHandler +import androidx.activity.compose.PredictiveBackHandler +import android.os.Build import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -14,11 +17,14 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel @@ -54,6 +60,9 @@ import com.flagship.abox.manager.ui.shell.MainAppShell import com.flagship.abox.manager.ui.shell.MainTab import com.flagship.abox.manager.ui.shell.detailPresentation import com.flagship.abox.manager.ui.shell.widthClass +import com.flagship.abox.manager.designsystem.LocalABoxPredictiveBackEnabled +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.collect import com.flagship.abox.manager.ui.socket.SocketScreen import com.flagship.abox.manager.ui.socket.SocketUiEvent import com.flagship.abox.manager.ui.socket.SocketViewModel @@ -185,35 +194,60 @@ fun AuthenticatedNavigationStateHost( selectedDevice = selectedDevice, detailPresentation = selectedPresentation, ) + val canNavigateBack = selectedDevice != null || currentRoute != MainTab.DEVICES.route + val predictiveBackEnabled = LocalABoxPredictiveBackEnabled.current && + Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU + var backProgress by remember { mutableFloatStateOf(0f) } - content( - snapshot, - { tab -> - savedSelection = null - if (tab == currentTab) { - navController.popBackStack(tab.route, inclusive = false) - } else { - navController.navigate(tab.route) { - launchSingleTop = true - restoreState = true - popUpTo(navController.graph.startDestinationId) { - saveState = true + Box( + modifier = Modifier.fillMaxSize() + .clip(RoundedCornerShape((28f * backProgress).dp)) + .graphicsLayer { + val scale = 1f - 0.04f * backProgress + scaleX = scale + scaleY = scale + translationX = 28.dp.toPx() * backProgress + }, + ) { + content( + snapshot, + { tab -> + savedSelection = null + if (tab == currentTab) { + navController.popBackStack(tab.route, inclusive = false) + } else { + navController.navigate(tab.route) { + launchSingleTop = true + restoreState = true + popUpTo(navController.graph.startDestinationId) { + saveState = true + } } } - } - }, - { deviceName, type -> - savedSelection = DeviceSelection(profileId, deviceName, type) - }, - { savedSelection = null }, - ) + }, + { deviceName, type -> + savedSelection = DeviceSelection(profileId, deviceName, type) + }, + { savedSelection = null }, + ) + } - BackHandler(enabled = selectedDevice != null || navController.previousBackStackEntry != null) { - savedSelection = null - if (currentRoute != MainTab.DEVICES.route) { - navController.popBackStack() + PredictiveBackHandler(enabled = canNavigateBack && predictiveBackEnabled) { events -> + try { + events.collect { event -> backProgress = event.progress } + savedSelection = null + navController.popCurrentDestinationUnlessRoot() + } catch (cancelled: CancellationException) { + throw cancelled + } finally { + backProgress = 0f } } + + BackHandler(enabled = canNavigateBack && !predictiveBackEnabled) { + savedSelection = null + navController.popCurrentDestinationUnlessRoot() + } } @Composable @@ -246,28 +280,22 @@ fun AuthenticatedNavigation( selectionForRoute = { route -> profile.selectionForRoute(route) }, ) { snapshot, onTabClick, onSelectDevice, onClearDeviceSelection -> val currentTab = MainTab.fromRoute(snapshot.currentRoute) ?: MainTab.DEVICES - MainAppShell( + AuthenticatedGraphContent( + navController = navController, + profile = profile, + username = username, currentTab = currentTab, windowSizeClass = resolvedWindowSizeClass, - userName = username.value, - providerName = profile.displayName, + isExpandedDevicePane = snapshot.detailPresentation == DetailPresentation.PANE && + snapshot.currentRoute == MainTab.DEVICES.route, + selectedDevice = snapshot.selectedDevice, onTabClick = onTabClick, + onSelectDevice = onSelectDevice, + onClearDeviceSelection = onClearDeviceSelection, onProviderClick = onProviderClick, onAvatarClick = onAvatarClick, - content = { paddingValues -> - AuthenticatedGraphContent( - navController = navController, - profile = profile, - paddingValues = paddingValues, - isExpandedDevicePane = snapshot.detailPresentation == DetailPresentation.PANE && - snapshot.currentRoute == MainTab.DEVICES.route, - selectedDevice = snapshot.selectedDevice, - onSelectDevice = onSelectDevice, - onClearDeviceSelection = onClearDeviceSelection, - onLogout = onLogout, - onOpenSettings = onOpenSettings, - ) - }, + onLogout = onLogout, + onOpenSettings = onOpenSettings, ) } } @@ -277,48 +305,36 @@ fun AuthenticatedNavigation( private fun AuthenticatedGraphContent( navController: NavHostController, profile: ProviderProfile, - paddingValues: PaddingValues, + username: Username, + currentTab: MainTab, + windowSizeClass: ABoxWindowSizeClass, isExpandedDevicePane: Boolean, selectedDevice: DeviceSelection?, + onTabClick: (MainTab) -> Unit, onSelectDevice: (DeviceName, DeviceType) -> Unit, onClearDeviceSelection: () -> Unit, + onProviderClick: () -> Unit, + onAvatarClick: () -> Unit, onLogout: () -> Unit, onOpenSettings: () -> Unit, ) { - Box( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues), - ) { - if (isExpandedDevicePane && selectedDevice != null) { - Row(modifier = Modifier.fillMaxSize()) { - AuthenticatedNavHost( - modifier = Modifier.weight(1f), - navController = navController, - profile = profile, - onSelectDevice = onSelectDevice, - onClearDeviceSelection = onClearDeviceSelection, - onLogout = onLogout, - onOpenSettings = onOpenSettings, - ) - DeviceDetailPane( - modifier = Modifier.weight(1f), - selection = selectedDevice, - onBack = onClearDeviceSelection, - ) - } - } else { - AuthenticatedNavHost( - modifier = Modifier.fillMaxSize(), - navController = navController, - profile = profile, - onSelectDevice = onSelectDevice, - onClearDeviceSelection = onClearDeviceSelection, - onLogout = onLogout, - onOpenSettings = onOpenSettings, - ) - } - } + AuthenticatedNavHost( + modifier = Modifier.fillMaxSize(), + navController = navController, + profile = profile, + username = username, + currentTab = currentTab, + windowSizeClass = windowSizeClass, + isExpandedDevicePane = isExpandedDevicePane, + selectedDevice = selectedDevice, + onTabClick = onTabClick, + onSelectDevice = onSelectDevice, + onClearDeviceSelection = onClearDeviceSelection, + onProviderClick = onProviderClick, + onAvatarClick = onAvatarClick, + onLogout = onLogout, + onOpenSettings = onOpenSettings, + ) } @Composable @@ -326,8 +342,16 @@ private fun AuthenticatedNavHost( modifier: Modifier, navController: NavHostController, profile: ProviderProfile, + username: Username, + currentTab: MainTab, + windowSizeClass: ABoxWindowSizeClass, + isExpandedDevicePane: Boolean, + selectedDevice: DeviceSelection?, + onTabClick: (MainTab) -> Unit, onSelectDevice: (DeviceName, DeviceType) -> Unit, onClearDeviceSelection: () -> Unit, + onProviderClick: () -> Unit, + onAvatarClick: () -> Unit, onLogout: () -> Unit, onOpenSettings: () -> Unit, ) { @@ -337,21 +361,65 @@ private fun AuthenticatedNavHost( modifier = modifier, ) { composable(MainTab.DEVICES.route) { - DeviceCenterRoute( - profileId = profile.id, - onOpenDevice = { deviceName, type -> onSelectDevice(DeviceName(deviceName), type) }, - ) + MainAppShell( + currentTab = currentTab, + windowSizeClass = windowSizeClass, + userName = username.value, + providerName = profile.displayName, + onTabClick = onTabClick, + onProviderClick = onProviderClick, + onAvatarClick = onAvatarClick, + ) { paddingValues -> + Row(Modifier.fillMaxSize().padding(paddingValues)) { + DeviceCenterRoute( + profileId = profile.id, + onOpenDevice = { deviceName, type -> onSelectDevice(DeviceName(deviceName), type) }, + modifier = Modifier.weight(1f), + ) + if (isExpandedDevicePane && selectedDevice != null) { + DeviceDetailPane( + modifier = Modifier.weight(1f), + selection = selectedDevice, + onBack = onClearDeviceSelection, + ) + } + } + } } composable(MainTab.SCENES.route) { - SceneListScreen( - onAddManual = { navController.navigate(SceneRoutes.EDITOR_MANUAL) }, - onAddAutomatic = { navController.navigate(SceneRoutes.EDITOR_AUTOMATIC) }, - onEditScene = { sceneId -> navController.navigate(SceneRoutes.editorFor(sceneId)) }, - onExecuteScene = { sceneId -> navController.navigate(SceneRoutes.executionFor(sceneId)) }, - ) + MainAppShell( + currentTab = currentTab, + windowSizeClass = windowSizeClass, + userName = username.value, + providerName = profile.displayName, + onTabClick = onTabClick, + onProviderClick = onProviderClick, + onAvatarClick = onAvatarClick, + ) { paddingValues -> + Box(Modifier.fillMaxSize().padding(paddingValues)) { + SceneListScreen( + onAddManual = { navController.navigate(SceneRoutes.EDITOR_MANUAL) }, + onAddAutomatic = { navController.navigate(SceneRoutes.EDITOR_AUTOMATIC) }, + onEditScene = { sceneId -> navController.navigate(SceneRoutes.editorFor(sceneId)) }, + onExecuteScene = { sceneId -> navController.navigate(SceneRoutes.executionFor(sceneId)) }, + ) + } + } } composable(MainTab.ACTIVITY.route) { - ActivityRoute(profileId = profile.id) + MainAppShell( + currentTab = currentTab, + windowSizeClass = windowSizeClass, + userName = username.value, + providerName = profile.displayName, + onTabClick = onTabClick, + onProviderClick = onProviderClick, + onAvatarClick = onAvatarClick, + ) { paddingValues -> + Box(Modifier.fillMaxSize().padding(paddingValues)) { + ActivityRoute(profileId = profile.id) + } + } } composable(Routes.SETTINGS) { SettingsRoute() @@ -493,3 +561,9 @@ private fun NavBackStackEntry.actualRoute(): String? { } return route } + +private fun NavHostController.popCurrentDestinationUnlessRoot() { + if (currentBackStackEntry?.actualRoute() != MainTab.DEVICES.route) { + popBackStack() + } +} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/preferences/SettingsScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/preferences/SettingsScreen.kt index 3a99fe5..be914b8 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/preferences/SettingsScreen.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/preferences/SettingsScreen.kt @@ -1,227 +1,573 @@ package com.flagship.abox.manager.ui.preferences +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.outlined.Check +import androidx.compose.material.icons.outlined.ChevronRight +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar +import android.os.Build import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.remember +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.compose.runtime.saveable.Saver import com.flagship.abox.manager.R -import com.flagship.abox.manager.designsystem.ABoxCard import com.flagship.abox.manager.designsystem.ABoxErrorState -import com.flagship.abox.manager.designsystem.ABoxFilterChip -import com.flagship.abox.manager.designsystem.ABoxPreferenceGroup -import com.flagship.abox.manager.designsystem.ABoxSwitch +import com.flagship.abox.manager.designsystem.LocalABoxShapes +import com.flagship.abox.manager.designsystem.icon.ABoxIcon +import com.flagship.abox.manager.designsystem.icon.ABoxIconKey +import com.flagship.abox.manager.preferences.AccentPreference import com.flagship.abox.manager.preferences.AppThemeMode import com.flagship.abox.manager.preferences.DesignRenderer +import com.flagship.abox.manager.preferences.InterfaceScale +import com.flagship.abox.manager.preferences.ColorStandard +import com.flagship.abox.manager.preferences.ColorStyle import com.flagship.abox.manager.preferences.NavigationPresentation +private enum class SettingsDestination { HOME, THEME } + +@Stable +class SettingsExperienceState(initialDestination: String = SettingsDestination.HOME.name) { + var destination: String by mutableStateOf(initialDestination) + + companion object { + val Saver: Saver = Saver( + save = { it.destination }, + restore = { SettingsExperienceState(it) }, + ) + } +} + @Composable fun SettingsRoute( + experienceState: SettingsExperienceState = SettingsExperienceState(), + onExit: () -> Unit = {}, viewModel: SettingsViewModel = hiltViewModel(), ) { val state = viewModel.uiState.collectAsStateWithLifecycle().value - SettingsScreen(state = state, onAction = viewModel::onAction) + SettingsExperience(state, experienceState, viewModel::onAction, onExit) +} + +@Composable +fun SettingsExperience( + state: SettingsUiState, + experienceState: SettingsExperienceState, + onAction: (SettingsUiAction) -> Unit, + onExit: () -> Unit, +) { + val destination = runCatching { SettingsDestination.valueOf(experienceState.destination) } + .getOrDefault(SettingsDestination.HOME) + BackHandler { + if (destination == SettingsDestination.THEME) experienceState.destination = SettingsDestination.HOME.name + else onExit() + } + when (destination) { + SettingsDestination.HOME -> SettingsHomeScreen( + state = state, + onOpenTheme = { experienceState.destination = SettingsDestination.THEME.name }, + onBack = onExit, + onAction = onAction, + ) + SettingsDestination.THEME -> ThemeSettingsScreen( + state = state, + onBack = { experienceState.destination = SettingsDestination.HOME.name }, + onAction = onAction, + ) + } } -@OptIn(ExperimentalMaterial3Api::class) @Composable fun SettingsScreen( state: SettingsUiState, onAction: (SettingsUiAction) -> Unit, modifier: Modifier = Modifier, -) { - Scaffold( - modifier = modifier.fillMaxSize(), - topBar = { TopAppBar(title = { Text(stringResource(R.string.settings_title)) }) }, - ) { contentPadding -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(contentPadding) - .verticalScroll(rememberScrollState()) - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - ABoxPreferenceGroup(title = stringResource(R.string.settings_style_section)) { - PreferenceRow( - title = stringResource(R.string.settings_renderer_label), - description = stringResource(R.string.settings_renderer_description), - ) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - ABoxFilterChip( - selected = state.preferences.renderer == DesignRenderer.MATERIAL3, - label = stringResource(R.string.settings_renderer_material3), - onClick = { onAction(SettingsUiAction.SetRenderer(DesignRenderer.MATERIAL3)) }, - ) - ABoxFilterChip( - selected = state.preferences.renderer == DesignRenderer.MIUIX, - label = stringResource(R.string.settings_renderer_miuix), - onClick = { onAction(SettingsUiAction.SetRenderer(DesignRenderer.MIUIX)) }, - ) - } - } - } - - ABoxPreferenceGroup(title = stringResource(R.string.settings_theme_section)) { - PreferenceRow( - title = stringResource(R.string.settings_theme_label), - description = null, - ) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - ThemeChip(AppThemeMode.SYSTEM, state, onAction, R.string.settings_theme_system) - ThemeChip(AppThemeMode.LIGHT, state, onAction, R.string.settings_theme_light) - ThemeChip(AppThemeMode.DARK, state, onAction, R.string.settings_theme_dark) - } - } - SwitchPreference( - title = stringResource(R.string.settings_dynamic_color), - description = stringResource(R.string.settings_dynamic_color_description), - checked = state.preferences.dynamicColorEnabled, - modifier = Modifier.testTag("settings-dynamic-color"), - onCheckedChange = { onAction(SettingsUiAction.SetDynamicColor(it)) }, - ) - } - - ABoxPreferenceGroup(title = stringResource(R.string.settings_surface_section)) { - SwitchPreference( - title = stringResource(R.string.settings_blur), - description = stringResource(R.string.settings_blur_description), - checked = state.preferences.blurEnabled, - modifier = Modifier.testTag("settings-blur"), - onCheckedChange = { onAction(SettingsUiAction.SetBlur(it)) }, - ) - PreferenceRow( - title = stringResource(R.string.settings_navigation), - description = null, - ) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - NavigationChip( - presentation = NavigationPresentation.STANDARD, - selected = state.preferences.navigationPresentation == NavigationPresentation.STANDARD, - label = stringResource(R.string.settings_navigation_standard), - onAction = onAction, - ) - NavigationChip( - presentation = NavigationPresentation.FLOATING, - selected = state.preferences.navigationPresentation == NavigationPresentation.FLOATING, - label = stringResource(R.string.settings_navigation_floating), - onAction = onAction, - ) - } - } - } - - ABoxPreferenceGroup(title = stringResource(R.string.settings_feedback_section)) { - SwitchPreference( - title = stringResource(R.string.settings_haptics), - description = stringResource(R.string.settings_haptics_description), - checked = state.preferences.hapticsEnabled, - modifier = Modifier.testTag("settings-haptics"), - onCheckedChange = { onAction(SettingsUiAction.SetHaptics(it)) }, - ) - } - - ABoxPreferenceGroup(title = stringResource(R.string.settings_accessibility_section)) { - ABoxCard( - title = stringResource(R.string.settings_accessibility_card_title), - subtitle = stringResource(R.string.settings_accessibility_description), - ) - } - - state.error?.let { error -> - ABoxErrorState( - title = stringResource(R.string.settings_error_save), - description = error.asString(), - ) - } - } - } -} +) = SettingsHomeScreen(state, onOpenTheme = {}, onBack = {}, onAction = onAction, modifier = modifier) @Composable -private fun ThemeChip( - mode: AppThemeMode, +private fun SettingsHomeScreen( state: SettingsUiState, + onOpenTheme: () -> Unit, + onBack: () -> Unit, onAction: (SettingsUiAction) -> Unit, - labelRes: Int, + modifier: Modifier = Modifier, ) { - ABoxFilterChip( - selected = state.preferences.themeMode == mode, - label = stringResource(labelRes), - onClick = { onAction(SettingsUiAction.SetThemeMode(mode)) }, - ) -} - -@Composable -private fun NavigationChip( - presentation: NavigationPresentation, - selected: Boolean, - label: String, - onAction: (SettingsUiAction) -> Unit, -) { - ABoxFilterChip( - selected = selected, - label = label, - onClick = { onAction(SettingsUiAction.SetNavigation(presentation)) }, - ) -} - -@Composable -private fun PreferenceRow( - title: String, - description: String?, - content: @Composable () -> Unit, -) { - Column( - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text(text = title, style = MaterialTheme.typography.titleMedium) - description?.let { - Text(text = it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + var styleMenuExpanded by remember { mutableStateOf(false) } + SettingsPage(modifier = modifier, title = stringResource(R.string.settings_title), onBack = onBack) { + SettingsGroup { + Box(Modifier.fillMaxWidth()) { + SettingsLinkRow( + icon = ABoxIconKey.SETTINGS, + title = stringResource(R.string.settings_style_entry), + description = stringResource(R.string.settings_renderer_description), + value = rendererLabel(state.preferences.renderer), + modifier = Modifier.testTag("settings-style-entry"), + ) { styleMenuExpanded = true } + Box(modifier = Modifier.align(Alignment.TopEnd)) { + DropdownMenu( + expanded = styleMenuExpanded, + onDismissRequest = { styleMenuExpanded = false }, + modifier = Modifier.testTag("settings-style-menu"), + ) { + DesignRenderer.entries.forEach { renderer -> + DropdownMenuItem( + text = { Text(rendererLabel(renderer)) }, + trailingIcon = { + if (renderer == state.preferences.renderer) Icon(Icons.Outlined.Check, null) + }, + onClick = { + styleMenuExpanded = false + onAction(SettingsUiAction.SetRenderer(renderer)) + }, + ) + } + } + } + } + GroupDivider() + SettingsLinkRow( + icon = ABoxIconKey.THEME, + title = stringResource(R.string.settings_theme_entry), + description = stringResource(R.string.settings_theme_entry_description), + value = null, + modifier = Modifier.testTag("settings-theme-entry"), + onClick = onOpenTheme, + ) } - content() + + SettingsGroup { + SettingsSwitchRow( + icon = ABoxIconKey.FEEDBACK, + title = stringResource(R.string.settings_haptics), + description = stringResource(R.string.settings_haptics_description), + checked = state.preferences.hapticsEnabled, + tag = "settings-haptics", + ) { onAction(SettingsUiAction.SetHaptics(it)) } + } + + SettingsGroup { + SettingsInfoRow( + icon = ABoxIconKey.SETTINGS, + title = stringResource(R.string.settings_accessibility_card_title), + description = stringResource(R.string.settings_accessibility_description), + ) + } + SettingsError(state) } } @Composable -private fun SwitchPreference( - title: String, - description: String, - checked: Boolean, - modifier: Modifier, - onCheckedChange: (Boolean) -> Unit, +private fun ThemeSettingsScreen( + state: SettingsUiState, + onBack: () -> Unit, + onAction: (SettingsUiAction) -> Unit, ) { - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), + SettingsPage( + title = stringResource(R.string.settings_theme_detail_title), + onBack = onBack, + modifier = Modifier.testTag("settings-theme-detail"), ) { - Column(modifier = Modifier.weight(1f)) { - Text(text = title, style = MaterialTheme.typography.titleMedium) - Text(text = description, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + ThemePreview(state) + when (state.preferences.renderer) { + DesignRenderer.MATERIAL3 -> MaterialThemeOptions(state, onAction) + DesignRenderer.MIUIX -> MiuixThemeOptions(state, onAction) } - ABoxSwitch( - checked = checked, - onCheckedChange = onCheckedChange, - contentDescription = title, - modifier = modifier, + SettingsError(state) + } +} + +@Composable +private fun ColumnScope.MaterialThemeOptions(state: SettingsUiState, onAction: (SettingsUiAction) -> Unit) { + SettingsGroup(modifier = Modifier.testTag("settings-material-colors")) { + SettingsSwitchRow( + icon = ABoxIconKey.THEME, + title = stringResource(R.string.settings_dynamic_color), + description = stringResource(R.string.settings_dynamic_color_description), + checked = state.preferences.dynamicColorEnabled, + tag = "settings-dynamic-color", + ) { onAction(SettingsUiAction.SetDynamicColor(it)) } + GroupDivider() + AccentSelector(state, onAction) + } + ThemeModeSelector(state, onAction, Modifier.testTag("settings-theme-mode")) + EnumSettingRow( + modifier = Modifier.testTag("settings-color-style"), + icon = ABoxIconKey.THEME, + title = stringResource(R.string.settings_color_style), + value = colorStyleLabel(state.preferences.colorStyle), + values = ColorStyle.entries, + label = { colorStyleLabel(it) }, + onSelected = { onAction(SettingsUiAction.SetColorStyle(it)) }, + ) + EnumSettingRow( + modifier = Modifier.testTag("settings-color-standard"), + icon = ABoxIconKey.SETTINGS, + title = stringResource(R.string.settings_color_standard), + value = colorStandardLabel(state.preferences.colorStandard), + values = ColorStandard.entries, + label = { colorStandardLabel(it) }, + onSelected = { onAction(SettingsUiAction.SetColorStandard(it)) }, + ) + PredictiveBackSetting(state, onAction) + SettingsGroup(modifier = Modifier.testTag("settings-interface-scale")) { ScaleSelector(state, onAction) } +} + +@Composable +private fun ColumnScope.MiuixThemeOptions(state: SettingsUiState, onAction: (SettingsUiAction) -> Unit) { + val renderEffectsSupported = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU + val floatingEnabled = state.preferences.navigationPresentation == NavigationPresentation.FLOATING + ThemeModeSelector(state, onAction, Modifier.testTag("settings-theme-mode")) + SettingsGroup(modifier = Modifier.testTag("settings-miuix-accent")) { + SettingsSwitchRow( + icon = ABoxIconKey.THEME, + title = stringResource(R.string.settings_dynamic_color), + description = stringResource(R.string.settings_dynamic_color_description), + checked = state.preferences.dynamicColorEnabled, + tag = "settings-dynamic-color", + ) { onAction(SettingsUiAction.SetDynamicColor(it)) } + GroupDivider() + AccentSelector(state, onAction) + } + SettingsGroup(modifier = Modifier.testTag("settings-blur")) { + SettingsSwitchRow( + icon = ABoxIconKey.EFFECTS, + title = stringResource(R.string.settings_blur), + description = stringResource(R.string.settings_blur_description), + checked = state.preferences.blurEnabled, + tag = "settings-blur-switch", + enabled = renderEffectsSupported, + unavailableDescription = if (renderEffectsSupported) null else stringResource(R.string.settings_requires_android_13), + ) { onAction(SettingsUiAction.SetBlur(it)) } + } + SettingsGroup(modifier = Modifier.testTag("settings-floating-navigation")) { + SettingsSwitchRow( + icon = ABoxIconKey.EFFECTS, + title = stringResource(R.string.settings_navigation_floating), + description = stringResource(R.string.settings_navigation_floating_description), + checked = state.preferences.navigationPresentation == NavigationPresentation.FLOATING, + tag = "settings-floating-navigation-switch", + ) { onAction(SettingsUiAction.SetNavigation(if (it) NavigationPresentation.FLOATING else NavigationPresentation.STANDARD)) } + } + SettingsGroup(modifier = Modifier.testTag("settings-liquid-glass")) { + SettingsSwitchRow( + icon = ABoxIconKey.EFFECTS, + title = stringResource(R.string.settings_liquid_glass), + description = stringResource(R.string.settings_liquid_glass_description), + checked = state.preferences.liquidGlassEnabled, + tag = "settings-liquid-glass-switch", + enabled = renderEffectsSupported && floatingEnabled, + unavailableDescription = when { + !floatingEnabled -> stringResource(R.string.settings_requires_floating_navigation) + !renderEffectsSupported -> stringResource(R.string.settings_requires_android_13) + else -> null + }, + ) { onAction(SettingsUiAction.SetLiquidGlass(it)) } + } + PredictiveBackSetting(state, onAction) + SettingsGroup(modifier = Modifier.testTag("settings-interface-scale")) { ScaleSelector(state, onAction) } +} + +@Composable +private fun PredictiveBackSetting(state: SettingsUiState, onAction: (SettingsUiAction) -> Unit) { + val supported = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU + SettingsGroup(modifier = Modifier.testTag("settings-predictive-back")) { + SettingsSwitchRow( + icon = ABoxIconKey.BACK, + title = stringResource(R.string.settings_predictive_back), + description = stringResource(R.string.settings_predictive_back_description), + checked = state.preferences.predictiveBackEnabled, + tag = "settings-predictive-back-switch", + enabled = supported, + unavailableDescription = if (supported) null else stringResource(R.string.settings_requires_android_13), + ) { onAction(SettingsUiAction.SetPredictiveBack(it)) } + } +} + +@Composable +private fun SettingsPage( + title: String, + onBack: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) { + Column(modifier = modifier.fillMaxSize().statusBarsPadding()) { + Row( + modifier = Modifier.fillMaxWidth().height(72.dp).padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Outlined.ArrowBack, stringResource(R.string.action_back)) } + Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) + } + Column( + modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + content = content, ) } } + +@Composable +private fun SettingsGroup( + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) { + Surface( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(LocalABoxShapes.current.largeCornerRadius), + color = MaterialTheme.colorScheme.surfaceContainer, + content = { Column(content = content) }, + ) +} + +@Composable +private fun ThemePreview(state: SettingsUiState) { + Surface( + modifier = Modifier.fillMaxWidth().height(190.dp), + shape = RoundedCornerShape(LocalABoxShapes.current.largeCornerRadius), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f), + ) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Surface( + modifier = Modifier.width(184.dp).height(142.dp), + shape = RoundedCornerShape(24.dp), + color = MaterialTheme.colorScheme.surface, + tonalElevation = 3.dp, + ) { + Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.size(22.dp).background(MaterialTheme.colorScheme.primary, CircleShape)) + Spacer(Modifier.width(8.dp)) + Box(Modifier.height(10.dp).weight(1f).background(MaterialTheme.colorScheme.surfaceVariant, CircleShape)) + } + Box(Modifier.fillMaxWidth().height(58.dp).background(MaterialTheme.colorScheme.primaryContainer, RoundedCornerShape(14.dp))) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + repeat(3) { Box(Modifier.weight(1f).height(18.dp).background(if (it == 0) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant, CircleShape)) } + } + } + } + Text( + text = "${rendererLabel(state.preferences.renderer)} · ${state.preferences.interfaceScale.percent}%", + modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 8.dp), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun RendererSelector(state: SettingsUiState, onAction: (SettingsUiAction) -> Unit) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) { + RendererOption(DesignRenderer.MATERIAL3, state.preferences.renderer, Modifier.weight(1f).testTag("settings-renderer-primary"), onAction) + RendererOption(DesignRenderer.MIUIX, state.preferences.renderer, Modifier.weight(1f).testTag("settings-renderer-secondary"), onAction) + } +} + +@Composable +private fun RendererOption(value: DesignRenderer, selected: DesignRenderer, modifier: Modifier, onAction: (SettingsUiAction) -> Unit) { + val active = value == selected + Surface( + modifier = modifier.clickable { onAction(SettingsUiAction.SetRenderer(value)) }, + shape = RoundedCornerShape(18.dp), + color = if (active) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant, + border = if (active) androidx.compose.foundation.BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null, + ) { + Row(Modifier.padding(horizontal = 16.dp, vertical = 14.dp), verticalAlignment = Alignment.CenterVertically) { + Text(rendererLabel(value), modifier = Modifier.weight(1f), fontWeight = FontWeight.SemiBold) + if (active) Icon(Icons.Outlined.Check, null, tint = MaterialTheme.colorScheme.primary) + } + } +} + +@Composable +private fun ThemeModeSelector( + state: SettingsUiState, + onAction: (SettingsUiAction) -> Unit, + modifier: Modifier = Modifier, +) { + Row(modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(4.dp)) { + AppThemeMode.entries.forEach { mode -> + val selected = state.preferences.themeMode == mode + Surface( + modifier = Modifier.weight(1f).clickable { onAction(SettingsUiAction.SetThemeMode(mode)) }, + shape = RoundedCornerShape(14.dp), + color = if (selected) MaterialTheme.colorScheme.primaryContainer else Color.Transparent, + ) { + Text(themeLabel(mode), Modifier.padding(vertical = 14.dp), textAlign = TextAlign.Center, fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal) + } + } + } +} + +@Composable +private fun EnumSettingRow( + modifier: Modifier, + icon: ABoxIconKey, + title: String, + value: String, + values: List, + label: @Composable (T) -> String, + onSelected: (T) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + SettingsGroup(modifier = modifier) { + Box(Modifier.fillMaxWidth()) { + SettingsLinkRow( + icon = icon, + title = title, + description = stringResource(R.string.settings_tap_to_choose), + value = value, + onClick = { expanded = true }, + ) + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + values.forEach { option -> + val optionLabel = label(option) + DropdownMenuItem( + text = { Text(optionLabel) }, + trailingIcon = { if (optionLabel == value) Icon(Icons.Outlined.Check, null) }, + onClick = { + expanded = false + onSelected(option) + }, + ) + } + } + } + } +} + +@Composable +private fun AccentSelector(state: SettingsUiState, onAction: (SettingsUiAction) -> Unit) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text(stringResource(R.string.settings_accent_label), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + Row( + modifier = Modifier.fillMaxWidth().alpha(if (state.preferences.dynamicColorEnabled) 0.42f else 1f), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + AccentPreference.entries.forEach { accent -> + val color = accentColor(accent) + Box( + modifier = Modifier.size(44.dp).background(color, CircleShape) + .then(if (state.preferences.accentPreference == accent) Modifier.border(3.dp, MaterialTheme.colorScheme.onSurface, CircleShape) else Modifier) + .clickable(enabled = !state.preferences.dynamicColorEnabled) { onAction(SettingsUiAction.SetAccent(accent)) }, + contentAlignment = Alignment.Center, + ) { + if (state.preferences.accentPreference == accent) Icon(Icons.Outlined.Check, null, tint = Color.White) + } + } + } + } +} + +@Composable +private fun ScaleSelector(state: SettingsUiState, onAction: (SettingsUiAction) -> Unit) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + SettingsRowHeader(ABoxIconKey.SCALE, stringResource(R.string.settings_scale), stringResource(R.string.settings_scale_description)) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + InterfaceScale.entries.forEach { scale -> + val selected = state.preferences.interfaceScale == scale + Surface( + modifier = Modifier.weight(1f).clickable { onAction(SettingsUiAction.SetInterfaceScale(scale)) }, + shape = RoundedCornerShape(14.dp), + color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surface, + contentColor = if (selected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface, + ) { Text("${scale.percent}%", Modifier.padding(vertical = 12.dp), textAlign = TextAlign.Center, fontWeight = FontWeight.Bold) } + } + } + } +} + +@Composable +private fun SettingsLinkRow(icon: ABoxIconKey, title: String, description: String, value: String?, modifier: Modifier = Modifier, onClick: () -> Unit) { + Row(modifier.fillMaxWidth().clickable(onClick = onClick).padding(14.dp), verticalAlignment = Alignment.CenterVertically) { + SettingsRowHeader(icon, title, description, Modifier.weight(1f)) + if (value != null) Text(value, color = MaterialTheme.colorScheme.onSurfaceVariant) + Icon(Icons.Outlined.ChevronRight, null, tint = MaterialTheme.colorScheme.onSurfaceVariant) + } +} + +@Composable +private fun SettingsSwitchRow( + icon: ABoxIconKey, + title: String, + description: String, + checked: Boolean, + tag: String, + enabled: Boolean = true, + unavailableDescription: String? = null, + onChange: (Boolean) -> Unit, +) { + Row( + Modifier.fillMaxWidth().clickable(enabled = enabled) { onChange(!checked) }.padding(14.dp).alpha(if (enabled) 1f else 0.5f), + verticalAlignment = Alignment.CenterVertically, + ) { + SettingsRowHeader(icon, title, unavailableDescription ?: description, Modifier.weight(1f)) + Switch(checked, onCheckedChange = onChange, enabled = enabled, modifier = Modifier.testTag(tag)) + } +} + +@Composable +private fun SettingsInfoRow(icon: ABoxIconKey, title: String, description: String) { + SettingsRowHeader(icon, title, description, Modifier.padding(16.dp)) +} + +@Composable +private fun SettingsRowHeader(icon: ABoxIconKey, title: String, description: String, modifier: Modifier = Modifier) { + Row(modifier, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) { + ABoxIcon(icon, title, Modifier.size(28.dp)) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + Text(description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } +} + +@Composable private fun GroupDivider() = Box(Modifier.fillMaxWidth().padding(start = 58.dp).height(1.dp).background(MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.55f))) + +@Composable private fun SettingsError(state: SettingsUiState) { state.error?.let { ABoxErrorState(stringResource(R.string.settings_error_save), it.asString()) } } + +@Composable private fun rendererLabel(renderer: DesignRenderer) = stringResource(if (renderer == DesignRenderer.MATERIAL3) R.string.settings_renderer_primary else R.string.settings_renderer_secondary) +@Composable private fun themeLabel(mode: AppThemeMode) = stringResource(when (mode) { AppThemeMode.SYSTEM -> R.string.settings_theme_system; AppThemeMode.LIGHT -> R.string.settings_theme_light; AppThemeMode.DARK -> R.string.settings_theme_dark }) +@Composable private fun colorStyleLabel(value: ColorStyle) = stringResource(when (value) { ColorStyle.TONAL_SPOT -> R.string.settings_color_style_tonal; ColorStyle.EXPRESSIVE -> R.string.settings_color_style_expressive; ColorStyle.FIDELITY -> R.string.settings_color_style_fidelity }) +@Composable private fun colorStandardLabel(value: ColorStandard) = stringResource(when (value) { ColorStandard.SPEC_2021 -> R.string.settings_color_standard_2021; ColorStandard.SPEC_2025 -> R.string.settings_color_standard_2025 }) +private fun accentColor(accent: AccentPreference) = when (accent) { AccentPreference.BLUE -> Color(0xFF4F6FA9); AccentPreference.GREEN -> Color(0xFF2F7D57); AccentPreference.AMBER -> Color(0xFFA66700); AccentPreference.ROSE -> Color(0xFFA94C68); AccentPreference.VIOLET -> Color(0xFF7659A8) } diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/preferences/SettingsViewModel.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/preferences/SettingsViewModel.kt index d8bdb09..061273c 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/preferences/SettingsViewModel.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/preferences/SettingsViewModel.kt @@ -4,7 +4,11 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.flagship.abox.manager.R import com.flagship.abox.manager.preferences.AppThemeMode +import com.flagship.abox.manager.preferences.AccentPreference import com.flagship.abox.manager.preferences.DesignRenderer +import com.flagship.abox.manager.preferences.InterfaceScale +import com.flagship.abox.manager.preferences.ColorStandard +import com.flagship.abox.manager.preferences.ColorStyle import com.flagship.abox.manager.preferences.NavigationPresentation import com.flagship.abox.manager.preferences.PreferenceField import com.flagship.abox.manager.preferences.UiPreferences @@ -62,6 +66,30 @@ class SettingsViewModel @Inject constructor( field = PreferenceField.HAPTICS, transform = { it.copy(hapticsEnabled = action.value) }, ) + is SettingsUiAction.SetInterfaceScale -> update( + field = PreferenceField.INTERFACE_SCALE, + transform = { it.copy(interfaceScale = action.value) }, + ) + is SettingsUiAction.SetAccent -> update( + field = PreferenceField.ACCENT, + transform = { it.copy(accentPreference = action.value) }, + ) + is SettingsUiAction.SetColorStyle -> update( + field = PreferenceField.COLOR_STYLE, + transform = { it.copy(colorStyle = action.value) }, + ) + is SettingsUiAction.SetColorStandard -> update( + field = PreferenceField.COLOR_STANDARD, + transform = { it.copy(colorStandard = action.value) }, + ) + is SettingsUiAction.SetLiquidGlass -> update( + field = PreferenceField.LIQUID_GLASS, + transform = { it.copy(liquidGlassEnabled = action.value) }, + ) + is SettingsUiAction.SetPredictiveBack -> update( + field = PreferenceField.PREDICTIVE_BACK, + transform = { it.copy(predictiveBackEnabled = action.value) }, + ) SettingsUiAction.DismissError -> _uiState.value = _uiState.value.copy(error = null) } } @@ -118,5 +146,11 @@ sealed interface SettingsUiAction { data class SetBlur(val value: Boolean) : SettingsUiAction data class SetNavigation(val value: NavigationPresentation) : SettingsUiAction data class SetHaptics(val value: Boolean) : SettingsUiAction + data class SetInterfaceScale(val value: InterfaceScale) : SettingsUiAction + data class SetAccent(val value: AccentPreference) : SettingsUiAction + data class SetColorStyle(val value: ColorStyle) : SettingsUiAction + data class SetColorStandard(val value: ColorStandard) : SettingsUiAction + data class SetLiquidGlass(val value: Boolean) : SettingsUiAction + data class SetPredictiveBack(val value: Boolean) : SettingsUiAction data object DismissError : SettingsUiAction } diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/RecordingSceneExecutor.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/RecordingSceneExecutor.kt new file mode 100644 index 0000000..4efba59 --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/RecordingSceneExecutor.kt @@ -0,0 +1,94 @@ +package com.flagship.abox.manager.ui.scene + +import com.flagship.abox.manager.domain.ActivityEventWriter +import com.flagship.abox.manager.domain.ActivityEventKind +import com.flagship.abox.manager.domain.ActivityOutcome +import com.flagship.abox.manager.domain.ActivitySource +import com.flagship.abox.manager.domain.ActivityTarget +import com.flagship.abox.manager.domain.ActivityTargetType +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.Scene +import com.flagship.abox.manager.domain.SceneAction +import com.flagship.abox.manager.domain.SceneActionExecutionResult +import com.flagship.abox.manager.domain.SceneActionOutcome +import com.flagship.abox.manager.domain.SceneExecutionId +import com.flagship.abox.manager.domain.SceneExecutionResult +import com.flagship.abox.manager.domain.SceneExecutionSource +import com.flagship.abox.manager.domain.SceneExecutionUpdate +import com.flagship.abox.manager.domain.SceneExecutor +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.onEach + +class RecordingSceneExecutor( + private val delegate: SceneExecutor, + private val writer: ActivityEventWriter, +) : SceneExecutor { + override suspend fun execute(scene: Scene): DomainResult { + val result = delegate.execute(scene) + if (scene.enableLogging) { + when (result) { + is DomainResult.Success -> { + result.value.results.forEach { recordStep(scene, it, ActivitySource.USER) } + recordScene(scene, if (result.value.failedCount == 0) ActivityOutcome.SUCCEEDED else ActivityOutcome.FAILED, ActivitySource.USER) + } + is DomainResult.Failure -> recordScene(scene, ActivityOutcome.FAILED, ActivitySource.USER, result.error.externalCode) + } + } + return result + } + + override fun execute( + scene: Scene, + executionId: SceneExecutionId, + source: SceneExecutionSource, + ): Flow { + val activitySource = if (source == SceneExecutionSource.AUTOMATION) ActivitySource.AUTOMATION else ActivitySource.USER + return delegate.execute(scene, executionId, source).onEach { update -> + if (!scene.enableLogging) return@onEach + when (update) { + is SceneExecutionUpdate.StepFinished -> recordStep(scene, update.result, activitySource) + is SceneExecutionUpdate.Completed -> recordScene( + scene, + if (update.result.failedCount == 0) ActivityOutcome.SUCCEEDED else ActivityOutcome.FAILED, + activitySource, + ) + is SceneExecutionUpdate.Rejected -> recordScene(scene, ActivityOutcome.FAILED, activitySource, update.error.externalCode) + else -> Unit + } + } + } + + private fun recordScene(scene: Scene, outcome: ActivityOutcome, source: ActivitySource, errorCode: String? = null) { + writer.record( + scene.profileId, + ActivityEventKind.SCENE_EXECUTION, + source, + outcome, + ActivityTarget(ActivityTargetType.SCENE, scene.id.value, scene.name), + errorCode, + ) + } + + private fun recordStep(scene: Scene, result: SceneActionExecutionResult, source: ActivitySource) { + writer.record( + scene.profileId, + ActivityEventKind.SCENE_ACTION, + source, + when (result.outcome) { + SceneActionOutcome.SUCCESS -> ActivityOutcome.SUCCEEDED + SceneActionOutcome.FAILURE -> ActivityOutcome.FAILED + SceneActionOutcome.SKIPPED -> ActivityOutcome.CANCELLED + }, + result.action.activityTarget(), + result.error?.externalCode, + ) + } +} + +private fun SceneAction.activityTarget(): ActivityTarget = when (this) { + is SceneAction.SocketPower -> ActivityTarget(ActivityTargetType.DEVICE, deviceName.value, deviceName.value) + is SceneAction.InfraredKey -> ActivityTarget(ActivityTargetType.INFRARED_KEY, key, deviceName.value) + is SceneAction.VirtualRemoteButton -> ActivityTarget(ActivityTargetType.REMOTE, remoteId, "$remoteDisplayName · $buttonDisplayName") + is SceneAction.Delay -> ActivityTarget(ActivityTargetType.SCENE_ACTION, id.value, "Delay ${durationSeconds}s") + is SceneAction.Notification -> ActivityTarget(ActivityTargetType.SCENE_ACTION, id.value, title) +} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorScreen.kt index 22f275f..b4c6199 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorScreen.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorScreen.kt @@ -6,6 +6,9 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.FlowRowScope import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -504,7 +507,7 @@ private fun SceneEditorContent( Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { Text(stringResource(R.string.scene_concurrency_mode), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + SceneChoiceFlow { FilterChip( selected = state.concurrencyMode == ConcurrencyMode.SEQUENTIAL, onClick = { viewModel.updateConcurrencyMode(ConcurrencyMode.SEQUENTIAL) }, @@ -520,7 +523,7 @@ private fun SceneEditorContent( Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { Text(stringResource(R.string.scene_error_policy), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + SceneChoiceFlow { FilterChip( selected = state.strategy == ExecutionStrategy.CONTINUE_ON_ERROR, onClick = { viewModel.updateStrategy(ExecutionStrategy.CONTINUE_ON_ERROR) }, @@ -770,6 +773,20 @@ private fun SceneAction.describe(): String = when (this) { is SceneAction.Notification -> stringResource(R.string.scene_action_notification, title) } +@OptIn(ExperimentalLayoutApi::class) +@Composable +internal fun SceneChoiceFlow( + modifier: Modifier = Modifier, + content: @Composable FlowRowScope.() -> Unit, +) { + FlowRow( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + content = content, + ) +} + @Composable private fun AddTriggerDialog( availableDevices: List, @@ -796,7 +813,7 @@ private fun AddTriggerDialog( title = { Text(stringResource(R.string.scene_dialog_add_trigger_title)) }, text = { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + SceneChoiceFlow { FilterChip(selected = selectedType == 0, onClick = { selectedType = 0 }, label = { Text(stringResource(R.string.scene_chip_time)) }) FilterChip(selected = selectedType == 1, onClick = { selectedType = 1 }, label = { Text(stringResource(R.string.scene_chip_temp)) }) FilterChip(selected = selectedType == 2, onClick = { selectedType = 2 }, label = { Text(stringResource(R.string.scene_chip_humidity)) }) @@ -817,7 +834,7 @@ private fun AddTriggerDialog( } 1, 2 -> { Text(stringResource(R.string.scene_device_name_label)) - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + SceneChoiceFlow { matchingDevices.forEach { device -> FilterChip( selected = deviceName == device.name.value, @@ -826,7 +843,7 @@ private fun AddTriggerDialog( ) } } - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + SceneChoiceFlow { FilterChip(selected = operator == ComparisonOperator.GREATER_THAN, onClick = { operator = ComparisonOperator.GREATER_THAN }, label = { Text(stringResource(R.string.scene_chip_above)) }) FilterChip(selected = operator == ComparisonOperator.LESS_THAN, onClick = { operator = ComparisonOperator.LESS_THAN }, label = { Text(stringResource(R.string.scene_chip_below)) }) } @@ -896,7 +913,7 @@ private fun AddConditionDialog( title = { Text(stringResource(R.string.scene_dialog_add_condition_title)) }, text = { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + SceneChoiceFlow { FilterChip(selected = selectedType == 0, onClick = { selectedType = 0 }, label = { Text(stringResource(R.string.scene_chip_temp)) }) FilterChip(selected = selectedType == 1, onClick = { selectedType = 1 }, label = { Text(stringResource(R.string.scene_chip_humidity)) }) FilterChip(selected = selectedType == 2, onClick = { selectedType = 2 }, label = { Text(stringResource(R.string.scene_chip_socket)) }) @@ -905,12 +922,12 @@ private fun AddConditionDialog( when (selectedType) { 0 -> { Text(stringResource(R.string.scene_device_name_label)) - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + SceneChoiceFlow { sensors.forEach { device -> FilterChip(selected = tempDeviceName == device.name.value, onClick = { tempDeviceName = device.name.value }, label = { Text(device.name.value) }) } } - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + SceneChoiceFlow { FilterChip(selected = operator == ComparisonOperator.GREATER_THAN, onClick = { operator = ComparisonOperator.GREATER_THAN }, label = { Text(stringResource(R.string.scene_chip_above)) }) FilterChip(selected = operator == ComparisonOperator.LESS_THAN, onClick = { operator = ComparisonOperator.LESS_THAN }, label = { Text(stringResource(R.string.scene_chip_below)) }) } @@ -919,12 +936,12 @@ private fun AddConditionDialog( } 1 -> { Text(stringResource(R.string.scene_device_name_label)) - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + SceneChoiceFlow { sensors.forEach { device -> FilterChip(selected = humDeviceName == device.name.value, onClick = { humDeviceName = device.name.value }, label = { Text(device.name.value) }) } } - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + SceneChoiceFlow { FilterChip(selected = operator == ComparisonOperator.GREATER_THAN, onClick = { operator = ComparisonOperator.GREATER_THAN }, label = { Text(stringResource(R.string.scene_chip_above)) }) FilterChip(selected = operator == ComparisonOperator.LESS_THAN, onClick = { operator = ComparisonOperator.LESS_THAN }, label = { Text(stringResource(R.string.scene_chip_below)) }) } @@ -933,13 +950,13 @@ private fun AddConditionDialog( } 2 -> { Text(stringResource(R.string.scene_device_name_label)) - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + SceneChoiceFlow { sockets.forEach { device -> FilterChip(selected = socketDeviceName == device.name.value, onClick = { socketDeviceName = device.name.value }, label = { Text(device.name.value) }) } } Text(stringResource(R.string.scene_target_socket_state)) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + SceneChoiceFlow { FilterChip(selected = socketPower == SocketPowerState.ON, onClick = { socketPower = SocketPowerState.ON }, label = { Text(stringResource(R.string.scene_chip_power_on)) }) FilterChip(selected = socketPower == SocketPowerState.OFF, onClick = { socketPower = SocketPowerState.OFF }, label = { Text(stringResource(R.string.scene_chip_power_off)) }) } @@ -1044,7 +1061,7 @@ private fun AddActionDialog( title = { Text(stringResource(R.string.scene_dialog_add_action_title)) }, text = { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + SceneChoiceFlow { FilterChip(selected = actionType == 0, onClick = { actionType = 0 }, label = { Text(stringResource(R.string.scene_chip_socket)) }) FilterChip(selected = actionType == 1, onClick = { actionType = 1 }, label = { Text(stringResource(R.string.scene_chip_remote)) }) FilterChip(selected = actionType == 2, onClick = { actionType = 2 }, label = { Text(stringResource(R.string.scene_chip_delay)) }) @@ -1053,7 +1070,7 @@ private fun AddActionDialog( when (actionType) { 0 -> { Text(stringResource(R.string.scene_select_socket)) - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + SceneChoiceFlow { availableDevices.filter { it.type == DeviceType.SOCKET }.forEach { device -> FilterChip( selected = socketName == device.name.value, @@ -1063,7 +1080,7 @@ private fun AddActionDialog( } } Text(stringResource(R.string.scene_socket_power_state)) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + SceneChoiceFlow { FilterChip(selected = socketPower == SocketPowerState.ON, onClick = { socketPower = SocketPowerState.ON }, label = { Text(stringResource(R.string.scene_chip_power_on)) }) FilterChip(selected = socketPower == SocketPowerState.OFF, onClick = { socketPower = SocketPowerState.OFF }, label = { Text(stringResource(R.string.scene_chip_power_off)) }) } @@ -1073,7 +1090,7 @@ private fun AddActionDialog( Text(stringResource(R.string.scene_no_virtual_remotes)) } else { Text(stringResource(R.string.scene_select_virtual_remote)) - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + SceneChoiceFlow { availableRemotes.forEachIndexed { idx, remote -> FilterChip( selected = selectedRemoteIndex == idx, @@ -1084,7 +1101,7 @@ private fun AddActionDialog( } Text(stringResource(R.string.scene_select_remote_button)) if (keyBindings.isNotEmpty()) { - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + SceneChoiceFlow { keyBindings.take(4).forEach { binding -> val label = binding.displayName.ifBlank { binding.buttonId } FilterChip( diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneListScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneListScreen.kt index a2659fd..1e182de 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneListScreen.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneListScreen.kt @@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add @@ -28,10 +29,11 @@ import androidx.compose.material.icons.outlined.Bolt import androidx.compose.material.icons.outlined.Schedule import androidx.compose.material3.AlertDialog import androidx.compose.material3.CardDefaults -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.FloatingActionButtonDefaults +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -49,6 +51,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -71,6 +74,7 @@ import com.flagship.abox.manager.domain.SceneId import com.flagship.abox.manager.designsystem.icon.ABoxIcon import com.flagship.abox.manager.designsystem.icon.ABoxIconKey import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -86,6 +90,23 @@ fun SceneListScreen( val selectedTab by viewModel.selectedTab.collectAsStateWithLifecycle() val pendingDelete by viewModel.pendingDelete.collectAsStateWithLifecycle() var showFabMenu by remember { mutableStateOf(false) } + var fabExpanded by remember { mutableStateOf(true) } + val listState = rememberLazyListState() + + LaunchedEffect(listState) { + var previousIndex = 0 + var previousOffset = 0 + snapshotFlow { listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset } + .distinctUntilChanged() + .collect { (index, offset) -> + val movingUp = index > previousIndex || (index == previousIndex && offset > previousOffset) + val movingDown = index < previousIndex || (index == previousIndex && offset < previousOffset) + if (movingUp) fabExpanded = false + if (movingDown || (index == 0 && offset == 0)) fabExpanded = true + previousIndex = index + previousOffset = offset + } + } val context = LocalContext.current LaunchedEffect(Unit) { @@ -99,37 +120,30 @@ fun SceneListScreen( Scaffold( containerColor = Color.Transparent, floatingActionButton = { - Box { - ExtendedFloatingActionButton( - onClick = { showFabMenu = true }, - icon = { Icon(Icons.Filled.Add, contentDescription = null) }, - text = { Text(stringResource(R.string.scene_create_action)) }, - ) - DropdownMenu( - expanded = showFabMenu, - onDismissRequest = { showFabMenu = false }, - ) { - DropdownMenuItem( - text = { Text(stringResource(R.string.scene_create_manual)) }, - leadingIcon = { Icon(Icons.Outlined.Bolt, contentDescription = null) }, - onClick = { - showFabMenu = false - onAddManual() - }, + ExtendedFloatingActionButton( + onClick = { showFabMenu = true }, + expanded = fabExpanded, + icon = { + Icon( + Icons.Filled.Add, + contentDescription = stringResource(R.string.scene_create_action), ) - DropdownMenuItem( - text = { Text(stringResource(R.string.scene_create_automatic)) }, - leadingIcon = { Icon(Icons.Outlined.Schedule, contentDescription = null) }, - onClick = { - showFabMenu = false - onAddAutomatic() - }, - ) - } - } + }, + text = { Text(stringResource(R.string.scene_create_action), fontWeight = FontWeight.Bold) }, + shape = RoundedCornerShape(18.dp), + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + elevation = FloatingActionButtonDefaults.elevation( + defaultElevation = 6.dp, + pressedElevation = 10.dp, + focusedElevation = 8.dp, + hoveredElevation = 8.dp, + ), + ) }, ) { innerPadding -> LazyColumn( + state = listState, modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues( start = 16.dp, @@ -213,6 +227,68 @@ fun SceneListScreen( }, ) } + + if (showFabMenu) { + ModalBottomSheet(onDismissRequest = { showFabMenu = false }) { + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text(stringResource(R.string.scene_create_sheet_title), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text( + stringResource(R.string.scene_create_sheet_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + SceneCreationOption( + icon = ABoxIconKey.SCENES, + title = stringResource(R.string.scene_create_manual), + description = stringResource(R.string.scene_create_manual_description), + onClick = { + showFabMenu = false + onAddManual() + }, + ) + SceneCreationOption( + icon = ABoxIconKey.PENDING, + title = stringResource(R.string.scene_create_automatic), + description = stringResource(R.string.scene_create_automatic_description), + onClick = { + showFabMenu = false + onAddAutomatic() + }, + ) + Spacer(Modifier.height(20.dp)) + } + } + } +} + +@Composable +private fun SceneCreationOption( + icon: ABoxIconKey, + title: String, + description: String, + onClick: () -> Unit, +) { + Surface( + modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Surface(shape = RoundedCornerShape(8.dp), color = MaterialTheme.colorScheme.primaryContainer) { + ABoxIcon(icon, title, modifier = Modifier.padding(10.dp).size(24.dp)) + } + Column(Modifier.weight(1f).padding(start = 14.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Text(description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } } @Composable diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/AccountProviderDialog.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/AccountProviderDialog.kt index c418397..45de938 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/AccountProviderDialog.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/AccountProviderDialog.kt @@ -3,20 +3,27 @@ package com.flagship.abox.manager.ui.shell import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.Logout import androidx.compose.material.icons.outlined.CloudDone -import androidx.compose.material.icons.outlined.Logout -import androidx.compose.material.icons.outlined.Refresh import androidx.compose.material.icons.outlined.Settings import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -25,17 +32,14 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.compose.ui.res.stringResource +import com.flagship.abox.manager.R import com.flagship.abox.manager.designsystem.ABoxButton -import com.flagship.abox.manager.designsystem.ABoxIconButton import com.flagship.abox.manager.designsystem.ABoxUserAvatar -import com.flagship.abox.manager.designsystem.icon.ABoxIconKey - -private enum class DestructiveAction { - END_SESSION, - FORGET_CREDENTIALS, -} +@OptIn(ExperimentalMaterial3Api::class) @Composable fun AccountProviderDialog( userName: String, @@ -47,136 +51,106 @@ fun AccountProviderDialog( onForgetCredentials: () -> Unit, modifier: Modifier = Modifier, ) { - var confirmation by remember { mutableStateOf(null) } + var confirmLogout by remember { mutableStateOf(false) } + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false) - if (confirmation == null) { - AlertDialog( - onDismissRequest = onDismissRequest, - modifier = modifier.testTag("account_dialog"), - title = { + ModalBottomSheet( + onDismissRequest = onDismissRequest, + sheetState = sheetState, + modifier = modifier.testTag("account_dialog"), + dragHandle = { + Surface( + modifier = Modifier.padding(top = 10.dp, bottom = 6.dp).size(width = 40.dp, height = 4.dp), + shape = RoundedCornerShape(2.dp), + color = MaterialTheme.colorScheme.outlineVariant, + ) {} + }, + ) { + Column( + modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(horizontal = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + ABoxUserAvatar( + name = userName, + onClick = {}, + size = 76.dp, + contentDescription = stringResource(R.string.account_current), + ) + Text( + text = stringResource(R.string.account_greeting, userName.ifBlank { stringResource(R.string.account_current) }), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + ) + Text( + text = userName.ifBlank { stringResource(R.string.account_current) }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + ) { Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().padding(16.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, + horizontalArrangement = Arrangement.spacedBy(14.dp), ) { - Text("账户与提供方") - ABoxIconButton( - key = ABoxIconKey.CLOSE, - contentDescription = "关闭账户对话框", - onClick = onDismissRequest, - modifier = Modifier.testTag("account_dialog_close"), - ) - } - }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - ABoxUserAvatar(name = userName, onClick = {}, size = 64.dp) - Text( - text = userName.ifBlank { "当前账户" }, - style = MaterialTheme.typography.titleMedium, - ) - Text( - text = providerName, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Surface( - modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f), - shape = MaterialTheme.shapes.medium, - ) { - Row( - modifier = Modifier.fillMaxWidth().padding(12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Icon( - imageVector = Icons.Outlined.CloudDone, - contentDescription = "连接方式", - modifier = Modifier.size(24.dp), - ) - Column(modifier = Modifier.weight(1f)) { - Text("当前连接方式", style = MaterialTheme.typography.labelMedium) - Text(providerName, style = MaterialTheme.typography.bodyLarge) - } - TextButton( - onClick = onSwitchProvider, - modifier = Modifier.testTag("account_dialog_switch_provider"), - ) { - Icon(Icons.Outlined.Refresh, "切换提供方") - Text("切换") - } - } - } - AccountActionButton( - icon = Icons.Outlined.Settings, - label = "界面设置", - description = "调整主题、渲染器和交互偏好", - tag = "account_dialog_open_settings", - onClick = onOpenSettings, - ) - AccountActionButton( - icon = Icons.Outlined.Logout, - label = "退出当前会话", - description = "结束当前提供方会话", - tag = "account_dialog_end_session", - onClick = { confirmation = DestructiveAction.END_SESSION }, - ) - TextButton( - onClick = { confirmation = DestructiveAction.FORGET_CREDENTIALS }, - modifier = Modifier.fillMaxWidth().testTag("account_dialog_forget_credentials"), - ) { - Text("清除保存的凭据", color = MaterialTheme.colorScheme.error) + Icon(Icons.Outlined.CloudDone, contentDescription = stringResource(R.string.account_connection_method), modifier = Modifier.size(26.dp)) + Column { + Text(stringResource(R.string.account_connection_method), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(providerName, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) } } - }, - confirmButton = {}, - ) - } else { - val action = confirmation ?: return - val isForget = action == DestructiveAction.FORGET_CREDENTIALS + } + + AccountSheetAction( + icon = Icons.Outlined.Settings, + label = stringResource(R.string.account_interface_settings), + description = stringResource(R.string.account_interface_settings_desc), + tag = "account_dialog_open_settings", + onClick = onOpenSettings, + ) + AccountSheetAction( + icon = Icons.AutoMirrored.Outlined.Logout, + label = stringResource(R.string.account_logout), + description = stringResource(R.string.account_logout_desc), + tag = "account_dialog_end_session", + dangerous = true, + onClick = { confirmLogout = true }, + ) + Spacer(Modifier.height(28.dp)) + } + } + + if (confirmLogout) { AlertDialog( - onDismissRequest = { confirmation = null }, - title = { Text(if (isForget) "清除保存的凭据" else "退出当前会话") }, - text = { - Text( - if (isForget) { - "确定清除当前提供方保存的登录凭据吗?" - } else { - "确定退出当前会话吗?" - }, - ) - }, + onDismissRequest = { confirmLogout = false }, + title = { Text(stringResource(R.string.account_logout)) }, + text = { Text(stringResource(R.string.account_logout_confirm)) }, confirmButton = { ABoxButton( onClick = { - confirmation = null - if (isForget) onForgetCredentials() else onEndSession() + confirmLogout = false + onEndSession() }, modifier = Modifier.testTag("account_dialog_confirm"), - ) { - Text("确定") - } - }, - dismissButton = { - TextButton(onClick = { confirmation = null }) { Text("取消") } + ) { Text(stringResource(R.string.account_logout)) } }, + dismissButton = { TextButton(onClick = { confirmLogout = false }) { Text(stringResource(R.string.action_cancel)) } }, ) } } @Composable -private fun AccountActionButton( +private fun AccountSheetAction( icon: androidx.compose.ui.graphics.vector.ImageVector, label: String, description: String, tag: String, + dangerous: Boolean = false, onClick: () -> Unit, ) { TextButton( @@ -184,14 +158,18 @@ private fun AccountActionButton( modifier = Modifier.fillMaxWidth().testTag(tag), ) { Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), + horizontalArrangement = Arrangement.spacedBy(14.dp), ) { - Icon(icon, contentDescription = label) - Column(modifier = Modifier.weight(1f)) { - Text(label) - Text(description, style = MaterialTheme.typography.bodySmall) + Icon( + icon, + contentDescription = label, + tint = if (dangerous) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface, + ) + Column(Modifier.weight(1f)) { + Text(label, color = if (dangerous) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface) + Text(description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } } } diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/MainAppShell.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/MainAppShell.kt index 744939c..79f1133 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/MainAppShell.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/MainAppShell.kt @@ -25,6 +25,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource @@ -35,6 +36,10 @@ import com.flagship.abox.manager.R import com.flagship.abox.manager.designsystem.ABoxNavigationBar import com.flagship.abox.manager.designsystem.ABoxNavigationBarItem import com.flagship.abox.manager.designsystem.ABoxUserAvatar +import com.flagship.abox.manager.designsystem.LocalABoxNavigationPresentation +import com.flagship.abox.manager.designsystem.LocalABoxRenderer +import com.flagship.abox.manager.preferences.DesignRenderer +import com.flagship.abox.manager.preferences.NavigationPresentation /** Stateless authenticated shell; navigation state belongs to AuthenticatedNavigation. */ @Composable @@ -46,59 +51,103 @@ fun MainAppShell( onTabClick: (MainTab) -> Unit, onProviderClick: () -> Unit, onAvatarClick: () -> Unit, + showNavigationChrome: Boolean = true, content: @Composable (PaddingValues) -> Unit, ) { val useRail = navigationPresentation(windowSizeClass) == MainNavigationPresentation.NAVIGATION_RAIL + val renderer = LocalABoxRenderer.current + val floating = renderer == DesignRenderer.MIUIX && + LocalABoxNavigationPresentation.current == NavigationPresentation.FLOATING && !useRail Scaffold( modifier = Modifier.fillMaxSize(), topBar = { - ProviderTopBar( - currentTab = currentTab, - providerName = providerName, - userName = userName, - onProviderClick = onProviderClick, - onAvatarClick = onAvatarClick, - ) + if (showNavigationChrome) { + ProviderTopBar( + currentTab = currentTab, + providerName = providerName, + userName = userName, + onProviderClick = onProviderClick, + onAvatarClick = onAvatarClick, + ) + } }, bottomBar = { - if (!useRail) { - ABoxNavigationBar { - MainTab.entries.forEach { tab -> - val selected = tab == currentTab - ABoxNavigationBarItem( - selected = selected, - onClick = { onTabClick(tab) }, - modifier = Modifier.testTag("main_tab_${tab.name.lowercase()}"), - icon = { - Icon( - imageVector = if (selected) tab.selectedIcon else tab.unselectedIcon, - contentDescription = stringResource(tab.labelRes), - ) - }, - label = { Text(stringResource(tab.labelRes)) }, - ) - } - } + if (showNavigationChrome && !useRail) { + MainBottomBar(currentTab, onTabClick, renderer, floating) } }, ) { innerPadding -> Row( modifier = Modifier.fillMaxSize(), ) { - if (useRail) { + if (showNavigationChrome && useRail) { MainNavigationRail( currentTab = currentTab, onTabClick = onTabClick, ) } - Box(modifier = Modifier.weight(1f).fillMaxHeight()) { + Box( + modifier = Modifier.weight(1f).fillMaxHeight(), + ) { content(innerPadding) } } } } +@Composable +private fun MainBottomBar( + currentTab: MainTab, + onTabClick: (MainTab) -> Unit, + renderer: DesignRenderer, + floating: Boolean, +) { + if (renderer == DesignRenderer.MIUIX) { + Surface( + modifier = if (floating) { + Modifier.padding(horizontal = 20.dp, vertical = 8.dp).clip(RoundedCornerShape(36.dp)) + } else { + Modifier + }, + shape = if (floating) RoundedCornerShape(36.dp) else RoundedCornerShape(0.dp), + color = MaterialTheme.colorScheme.surfaceContainer, + tonalElevation = if (floating) 3.dp else 0.dp, + ) { + ABoxNavigationBar(containerColor = Color.Transparent) { + MainTab.entries.forEach { tab -> + val selected = tab == currentTab + ABoxNavigationBarItem( + selected = tab == currentTab, + onClick = { onTabClick(tab) }, + modifier = Modifier.testTag("main_tab_${tab.name.lowercase()}"), + icon = { + Icon( + imageVector = if (selected) tab.selectedIcon else tab.unselectedIcon, + contentDescription = stringResource(tab.labelRes), + ) + }, + label = { Text(stringResource(tab.labelRes)) }, + ) + } + } + } + } else { + ABoxNavigationBar { + MainTab.entries.forEach { tab -> + val selected = tab == currentTab + ABoxNavigationBarItem( + selected = selected, + onClick = { onTabClick(tab) }, + modifier = Modifier.testTag("main_tab_${tab.name.lowercase()}"), + icon = { Icon(if (selected) tab.selectedIcon else tab.unselectedIcon, stringResource(tab.labelRes)) }, + label = { Text(stringResource(tab.labelRes)) }, + ) + } + } + } +} + @Composable private fun ProviderTopBar( currentTab: MainTab, @@ -108,24 +157,24 @@ private fun ProviderTopBar( onAvatarClick: () -> Unit, ) { Box( - modifier = Modifier - .fillMaxWidth() - .statusBarsPadding() - .padding(horizontal = 16.dp, vertical = 8.dp), - ) { - Surface( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(32.dp), - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f), - tonalElevation = 2.dp, + modifier = Modifier + .fillMaxWidth() + .statusBarsPadding() + .padding(horizontal = 16.dp, vertical = 8.dp), ) { - Row( - modifier = Modifier - .fillMaxWidth() - .height(64.dp) - .padding(horizontal = 8.dp), - verticalAlignment = Alignment.CenterVertically, + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(32.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f), + tonalElevation = 2.dp, ) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(64.dp) + .padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { Row( modifier = Modifier .clip(RoundedCornerShape(24.dp)) @@ -170,8 +219,8 @@ private fun ProviderTopBar( contentDescription = stringResource(R.string.action_account), ) } + } } - } } } diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/socket/SocketScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/socket/SocketScreen.kt index 8bd9dc5..b11ebdf 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/socket/SocketScreen.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/socket/SocketScreen.kt @@ -2,6 +2,7 @@ package com.flagship.abox.manager.ui.socket import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -38,6 +39,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -50,13 +53,14 @@ import com.flagship.abox.manager.designsystem.ABoxButton import com.flagship.abox.manager.designsystem.ABoxDeviceDetailScaffold import com.flagship.abox.manager.designsystem.ABoxIconButton import com.flagship.abox.manager.designsystem.ABoxLoadingIndicator +import com.flagship.abox.manager.designsystem.ABoxStatus +import com.flagship.abox.manager.designsystem.ABoxStatusBanner import com.flagship.abox.manager.designsystem.icon.ABoxIcon import com.flagship.abox.manager.designsystem.icon.ABoxIconKey import com.flagship.abox.manager.domain.DeviceName import com.flagship.abox.manager.domain.SocketPowerState -import java.text.SimpleDateFormat +import java.text.DateFormat import java.util.Date -import java.util.Locale @Composable fun SocketRoute( @@ -83,10 +87,30 @@ fun SocketScreen( modifier: Modifier = Modifier, ) { val scrollState = rememberScrollState() + val configuration = LocalConfiguration.current + var showDeviceNameDialog by remember { mutableStateOf(false) } ABoxDeviceDetailScaffold( modifier = modifier.fillMaxSize(), title = state.deviceName?.value ?: stringResource(R.string.socket_title), onBack = onBackClick, + subtitle = stringResource(R.string.socket_detail_subtitle), + icon = ABoxIconKey.SOCKET, + action = { + Row { + ABoxIconButton( + key = ABoxIconKey.EDIT, + contentDescription = stringResource(R.string.socket_name_settings), + onClick = { showDeviceNameDialog = true }, + enabled = !state.isOperating, + ) + ABoxIconButton( + key = ABoxIconKey.REFRESH, + contentDescription = stringResource(R.string.action_refresh), + onClick = { onEvent(SocketUiEvent.Refresh) }, + enabled = !state.isLoading && !state.isOperating, + ) + } + }, ) { paddingValues -> Column( modifier = Modifier @@ -96,17 +120,6 @@ fun SocketScreen( .verticalScroll(scrollState), horizontalAlignment = Alignment.CenterHorizontally, ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - ) { - ABoxIconButton( - key = ABoxIconKey.REFRESH, - contentDescription = "刷新插座状态", - onClick = { onEvent(SocketUiEvent.Refresh) }, - enabled = !state.isLoading && !state.isOperating, - ) - } if (state.isOffline) { OfflineBanner( onRetry = { onEvent(SocketUiEvent.Refresh) }, @@ -122,20 +135,28 @@ fun SocketScreen( ) } + Box(modifier = Modifier.fillMaxWidth().height(64.dp)) { + SocketOperationBanner(operation = state.operation) + } + // Central Status Card Card( modifier = Modifier .fillMaxWidth() - .padding(vertical = 16.dp), + .padding(top = 8.dp, bottom = 18.dp), colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant, + containerColor = if (state.isPowerOn) { + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.72f) + } else { + MaterialTheme.colorScheme.surfaceVariant + }, ), shape = MaterialTheme.shapes.large, ) { Column( modifier = Modifier .fillMaxWidth() - .padding(32.dp), + .padding(horizontal = 24.dp, vertical = 32.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { @@ -143,13 +164,16 @@ fun SocketScreen( powerState = state.powerState, isOffline = state.isOffline, isLoading = state.isLoading, + isOperating = state.isOperating, + enabled = state.isActionEnabled, + onClick = { onEvent(SocketUiEvent.TogglePower) }, ) - Spacer(modifier = Modifier.height(20.dp)) + Spacer(modifier = Modifier.height(24.dp)) Text( text = stringResource(R.string.socket_status_label), - style = MaterialTheme.typography.labelMedium, + style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -160,7 +184,7 @@ fun SocketScreen( state.powerState == SocketPowerState.OFF -> stringResource(R.string.socket_status_off) else -> stringResource(R.string.socket_status_unknown) }, - style = MaterialTheme.typography.headlineMedium, + style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = when { state.isOffline -> MaterialTheme.colorScheme.error @@ -171,7 +195,9 @@ fun SocketScreen( ) state.lastConfirmedTimeMillis?.let { timeMillis -> - val formattedTime = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date(timeMillis)) + val formattedTime = remember(timeMillis, configuration) { + DateFormat.getTimeInstance(DateFormat.MEDIUM).format(Date(timeMillis)) + } Text( text = stringResource(R.string.socket_confirmed_at, formattedTime), style = MaterialTheme.typography.bodySmall, @@ -182,35 +208,12 @@ fun SocketScreen( } } - Spacer(modifier = Modifier.height(16.dp)) - - // Main Toggle Action Button - ABoxButton( - onClick = { onEvent(SocketUiEvent.TogglePower) }, - enabled = state.isActionEnabled, - modifier = Modifier - .fillMaxWidth() - .height(56.dp), - ) { - if (state.isOperating) { - ABoxLoadingIndicator(modifier = Modifier.size(24.dp)) - Spacer(modifier = Modifier.width(12.dp)) - Text(stringResource(R.string.socket_operating_label)) - } else { - Text( - text = if (state.isPowerOn) { - stringResource(R.string.socket_action_turn_off) - } else { - stringResource(R.string.socket_action_turn_on) - }, - style = MaterialTheme.typography.titleMedium, - ) - } - } - - Spacer(modifier = Modifier.height(12.dp)) - - // Dedicated Segmented Action Buttons + Text( + text = stringResource(R.string.socket_direct_control), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp), + ) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp), @@ -218,22 +221,76 @@ fun SocketScreen( OutlinedButton( onClick = { onEvent(SocketUiEvent.SetPower(SocketPowerState.ON)) }, enabled = state.isActionEnabled && state.powerState != SocketPowerState.ON, - modifier = Modifier.weight(1f).height(48.dp), + modifier = Modifier.weight(1f).height(56.dp), ) { + ABoxIcon(ABoxIconKey.POWER, "开启插座", modifier = Modifier.size(20.dp)) + Spacer(Modifier.width(8.dp)) Text(stringResource(R.string.socket_action_turn_on)) } OutlinedButton( onClick = { onEvent(SocketUiEvent.SetPower(SocketPowerState.OFF)) }, enabled = state.isActionEnabled && state.powerState != SocketPowerState.OFF, - modifier = Modifier.weight(1f).height(48.dp), + modifier = Modifier.weight(1f).height(56.dp), ) { + ABoxIcon(ABoxIconKey.POWER, "关闭插座", modifier = Modifier.size(20.dp)) + Spacer(Modifier.width(8.dp)) Text(stringResource(R.string.socket_action_turn_off)) } } + + Text( + text = stringResource(R.string.socket_confirmation_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.fillMaxWidth().padding(top = 14.dp), + ) } } + if (showDeviceNameDialog) { + DeviceNameDialog( + currentName = state.deviceName?.value.orEmpty(), + title = stringResource(R.string.socket_name_settings), + description = stringResource(R.string.gateway_device_name_socket_desc), + onDismiss = { showDeviceNameDialog = false }, + onConfirm = { name -> + showDeviceNameDialog = false + onEvent(SocketUiEvent.ChangeDeviceName(DeviceName(name))) + }, + ) + } + +} + +@Composable +private fun DeviceNameDialog( + currentName: String, + title: String, + description: String, + onDismiss: () -> Unit, + onConfirm: (String) -> Unit, +) { + var value by remember(currentName) { mutableStateOf(currentName) } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text(description, style = MaterialTheme.typography.bodySmall) + OutlinedTextField( + value = value, + onValueChange = { value = it }, + label = { Text(stringResource(R.string.gateway_device_name)) }, + singleLine = true, + ) + } + }, + confirmButton = { + TextButton(onClick = { onConfirm(value.trim()) }, enabled = value.isNotBlank()) { Text(stringResource(R.string.action_save_device_name)) } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } }, + ) } @Composable @@ -241,6 +298,9 @@ private fun PowerIndicatorBadge( powerState: SocketPowerState?, isOffline: Boolean, isLoading: Boolean, + isOperating: Boolean, + enabled: Boolean, + onClick: () -> Unit, modifier: Modifier = Modifier, ) { val indicatorColor = when { @@ -252,24 +312,74 @@ private fun PowerIndicatorBadge( Box( modifier = modifier - .size(96.dp) + .size(160.dp) .clip(CircleShape) .background(indicatorColor.copy(alpha = 0.15f)) - .border(3.dp, indicatorColor, CircleShape), + .border(4.dp, indicatorColor, CircleShape) + .clickable(enabled = enabled, onClickLabel = "切换插座电源", onClick = onClick) + .testTag("socket_power_dial"), contentAlignment = Alignment.Center, ) { - if (isLoading) { - ABoxLoadingIndicator(modifier = Modifier.size(36.dp)) - } else { - ABoxIcon( - key = if (powerState == SocketPowerState.ON) ABoxIconKey.POWER else ABoxIconKey.SOCKET, - contentDescription = if (powerState == SocketPowerState.ON) "插座已开启" else "插座已关闭", - modifier = Modifier.size(40.dp), + Column(horizontalAlignment = Alignment.CenterHorizontally) { + if (isLoading || isOperating) { + ABoxLoadingIndicator(modifier = Modifier.size(44.dp)) + } else { + ABoxIcon( + key = ABoxIconKey.POWER, + contentDescription = if (powerState == SocketPowerState.ON) "插座已开启" else "插座已关闭", + modifier = Modifier.size(52.dp), + ) + } + Spacer(Modifier.height(10.dp)) + Text( + text = when { + isOperating -> stringResource(R.string.device_status_switching) + powerState == SocketPowerState.ON -> stringResource(R.string.socket_status_on) + powerState == SocketPowerState.OFF -> stringResource(R.string.socket_status_off) + else -> stringResource(R.string.device_status_unavailable) + }, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, ) } } } +@Composable +private fun SocketOperationBanner( + operation: SocketOperationState, + modifier: Modifier = Modifier, +) { + when (operation) { + is SocketOperationState.PendingCommand -> ABoxStatusBanner( + state = ABoxStatus.PENDING, + contentDescription = "正在发送电源指令", + modifier = modifier, + ) + is SocketOperationState.RefreshingConfirmation -> ABoxStatusBanner( + state = ABoxStatus.PENDING, + contentDescription = "正在确认插座状态", + modifier = modifier, + ) + is SocketOperationState.AwaitingConfirmation -> ABoxStatusBanner( + state = ABoxStatus.WARNING, + contentDescription = "指令已发送,等待状态确认", + modifier = modifier.testTag("socket_awaiting_confirmation"), + ) + is SocketOperationState.Failed -> ABoxStatusBanner( + state = ABoxStatus.ERROR, + contentDescription = "插座操作失败", + modifier = modifier, + ) + SocketOperationState.Unavailable -> ABoxStatusBanner( + state = ABoxStatus.OFFLINE, + contentDescription = "插座当前不可用", + modifier = modifier, + ) + SocketOperationState.IdleConfirmed -> Unit + } +} + @Composable private fun OfflineBanner( onRetry: () -> Unit, diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/socket/SocketViewModel.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/socket/SocketViewModel.kt index 264828e..0b687c6 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/socket/SocketViewModel.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/socket/SocketViewModel.kt @@ -9,13 +9,18 @@ import com.flagship.abox.manager.domain.DomainResult import com.flagship.abox.manager.domain.ProviderProfileId import com.flagship.abox.manager.domain.SocketGateway import com.flagship.abox.manager.domain.SocketPowerState +import com.flagship.abox.manager.devicecatalog.data.ConfiguredDeviceRepository import com.flagship.abox.manager.ui.common.DomainErrorMapper +import com.flagship.abox.manager.domain.ActivityEventWriter +import com.flagship.abox.manager.domain.ActivityEventKind +import com.flagship.abox.manager.domain.ActivityOutcome import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.delay import java.net.URLDecoder import java.nio.charset.StandardCharsets import javax.inject.Inject @@ -23,6 +28,8 @@ import javax.inject.Inject @HiltViewModel class SocketViewModel @Inject constructor( private val socketGateway: SocketGateway, + private val configuredDeviceRepository: ConfiguredDeviceRepository, + private val activityWriter: ActivityEventWriter, savedStateHandle: SavedStateHandle, ) : ViewModel() { @@ -64,16 +71,19 @@ class SocketViewModel @Inject constructor( is SocketUiEvent.ChangeDeviceName -> { val trimmed = event.deviceName.value.trim() - if (trimmed.isNotBlank() && trimmed != _state.value.deviceName?.value) { - _state.update { - it.copy( - deviceName = DeviceName(trimmed), - powerState = null, - errorMessage = null, - lastConfirmedTimeMillis = null, - ) + val current = _state.value + val profileId = current.profileId + val oldName = current.deviceName + if (trimmed.isNotBlank() && trimmed != oldName?.value && profileId != null && oldName != null) { + viewModelScope.launch { + when (val result = configuredDeviceRepository.rename(profileId, oldName, DeviceName(trimmed))) { + is DomainResult.Success -> { + _state.update { it.copy(deviceName = DeviceName(trimmed), powerState = null, errorMessage = null, lastConfirmedTimeMillis = null) } + loadStatus() + } + is DomainResult.Failure -> _state.update { it.copy(errorMessage = DomainErrorMapper.map(result.error)) } + } } - loadStatus() } } @@ -146,24 +156,63 @@ class SocketViewModel @Inject constructor( return } + _state.update { it.copy(isOperating = true, errorMessage = null, operation = SocketOperationState.PendingCommand(targetState)) } viewModelScope.launch { - _state.update { it.copy(isOperating = true, errorMessage = null, operation = SocketOperationState.PendingCommand(targetState)) } when (val result = socketGateway.commandPower(profileId, deviceName, targetState)) { is DomainResult.Success -> { - _state.update { it.copy(operation = SocketOperationState.RefreshingConfirmation(targetState)) } - when (val confirmation = socketGateway.getStatus(profileId, deviceName)) { - is DomainResult.Success -> _state.update { - it.copy(powerState = confirmation.value.powerState, isOperating = false, isOffline = false, - errorMessage = null, lastConfirmedTimeMillis = System.currentTimeMillis(), - operation = SocketOperationState.IdleConfirmed) - } - is DomainResult.Failure -> _state.update { - it.copy(isOperating = false, errorMessage = DomainErrorMapper.map(confirmation.error), - operation = SocketOperationState.AwaitingConfirmation(targetState)) + activityWriter.recordDevice(profileId, deviceName, ActivityEventKind.SOCKET_COMMAND, ActivityOutcome.SUCCEEDED) + _state.update { + it.copy( + isOperating = true, + isOffline = false, + errorMessage = null, + operation = SocketOperationState.RefreshingConfirmation(targetState), + ) + } + var confirmationError: DomainError? = null + repeat(CONFIRMATION_ATTEMPTS) { attempt -> + if (attempt > 0) delay(CONFIRMATION_RETRY_DELAY_MILLIS) + when (val confirmation = socketGateway.getStatus(profileId, deviceName)) { + is DomainResult.Success -> if (confirmation.value.powerState == targetState) { + activityWriter.recordDevice( + profileId, + deviceName, + ActivityEventKind.SOCKET_CONFIRMATION, + ActivityOutcome.SUCCEEDED, + ) + _state.update { + it.copy( + powerState = targetState, + isOperating = false, + isOffline = false, + errorMessage = null, + lastConfirmedTimeMillis = System.currentTimeMillis(), + operation = SocketOperationState.IdleConfirmed, + ) + } + return@launch + } + is DomainResult.Failure -> confirmationError = confirmation.error } } + activityWriter.recordDevice( + profileId, + deviceName, + ActivityEventKind.SOCKET_CONFIRMATION, + ActivityOutcome.AWAITING_CONFIRMATION, + confirmationError?.externalCode, + ) + _state.update { + it.copy( + isOperating = false, + isOffline = confirmationError is DomainError.DeviceOffline, + errorMessage = confirmationError?.let(DomainErrorMapper::map), + operation = SocketOperationState.AwaitingConfirmation(targetState), + ) + } } is DomainResult.Failure -> { + activityWriter.recordDevice(profileId, deviceName, ActivityEventKind.SOCKET_COMMAND, ActivityOutcome.FAILED, result.error.externalCode) val isOffline = result.error is DomainError.DeviceOffline _state.update { it.copy( @@ -177,4 +226,9 @@ class SocketViewModel @Inject constructor( } } } + + private companion object { + const val CONFIRMATION_ATTEMPTS = 4 + const val CONFIRMATION_RETRY_DELAY_MILLIS = 500L + } } diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityScreen.kt index 67796fe..5132078 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityScreen.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityScreen.kt @@ -31,6 +31,8 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -45,9 +47,8 @@ import com.flagship.abox.manager.designsystem.ABoxIconButton import com.flagship.abox.manager.designsystem.icon.ABoxIcon import com.flagship.abox.manager.designsystem.icon.ABoxIconKey import com.flagship.abox.manager.domain.DeviceName -import java.text.SimpleDateFormat +import java.text.DateFormat import java.util.Date -import java.util.Locale @Composable fun TemperatureHumidityRoute( @@ -74,10 +75,22 @@ fun TemperatureHumidityScreen( modifier: Modifier = Modifier, ) { val scrollState = rememberScrollState() + val configuration = LocalConfiguration.current + var showDeviceNameDialog by remember { mutableStateOf(false) } ABoxDeviceDetailScaffold( modifier = modifier.fillMaxSize(), title = state.deviceName?.value ?: stringResource(R.string.th_title), onBack = onBackClick, + subtitle = stringResource(R.string.device_sensor_subtitle), + icon = ABoxIconKey.SENSOR, + action = { + ABoxIconButton( + key = ABoxIconKey.EDIT, + contentDescription = stringResource(R.string.sensor_name_settings), + onClick = { showDeviceNameDialog = true }, + enabled = !state.isLoading && !state.isRefreshing, + ) + }, ) { paddingValues -> Column( modifier = Modifier @@ -87,17 +100,6 @@ fun TemperatureHumidityScreen( .verticalScroll(scrollState), horizontalAlignment = Alignment.CenterHorizontally, ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - ) { - ABoxIconButton( - key = ABoxIconKey.REFRESH, - contentDescription = "刷新温湿度数据", - onClick = { onEvent(TemperatureHumidityUiEvent.Refresh) }, - enabled = !state.isLoading && !state.isRefreshing, - ) - } if (state.isOffline) { OfflineBanner( onRetry = { onEvent(TemperatureHumidityUiEvent.Refresh) }, @@ -137,10 +139,16 @@ fun TemperatureHumidityScreen( ) } else { // Telemetry Cards Row / Column + Text( + text = stringResource(R.string.sensor_current_readings), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp, bottom = 10.dp), + ) Row( modifier = Modifier .fillMaxWidth() - .padding(vertical = 12.dp), + .padding(bottom = 16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp), ) { TelemetryMetricCard( @@ -148,6 +156,7 @@ fun TemperatureHumidityScreen( value = state.temperature, unit = stringResource(R.string.th_unit_celsius), icon = ABoxIconKey.TEMPERATURE, + testTag = "sensor_metric_temperature", modifier = Modifier.weight(1f), ) @@ -156,12 +165,15 @@ fun TemperatureHumidityScreen( value = state.humidity, unit = stringResource(R.string.th_unit_percent), icon = ABoxIconKey.HUMIDITY, + testTag = "sensor_metric_humidity", modifier = Modifier.weight(1f), ) } state.lastUpdatedTimeMillis?.let { timeMillis -> - val formattedTime = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date(timeMillis)) + val formattedTime = remember(timeMillis, configuration) { + DateFormat.getTimeInstance(DateFormat.MEDIUM).format(Date(timeMillis)) + } Text( text = stringResource(R.string.th_last_updated, formattedTime), style = MaterialTheme.typography.bodySmall, @@ -175,7 +187,7 @@ fun TemperatureHumidityScreen( // Read-Only Informational Card Card( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().testTag("sensor_monitoring_notice"), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f), ), @@ -187,13 +199,13 @@ fun TemperatureHumidityScreen( .padding(16.dp), ) { Text( - text = stringResource(R.string.th_read_only_notice), + text = stringResource(R.string.sensor_monitoring_title), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurfaceVariant, ) Text( - text = stringResource(R.string.th_read_only_description), + text = stringResource(R.string.sensor_monitoring_description), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.85f), modifier = Modifier.padding(top = 4.dp), @@ -201,7 +213,7 @@ fun TemperatureHumidityScreen( } } - Spacer(modifier = Modifier.height(24.dp)) + Spacer(modifier = Modifier.height(16.dp)) // Refresh Button ABoxButton( @@ -209,7 +221,7 @@ fun TemperatureHumidityScreen( enabled = !state.isLoading && !state.isRefreshing && state.profileId != null, modifier = Modifier .fillMaxWidth() - .height(52.dp), + .height(56.dp), ) { if (state.isRefreshing || state.isLoading) { ABoxLoadingIndicator(modifier = Modifier.size(22.dp)) @@ -225,6 +237,45 @@ fun TemperatureHumidityScreen( } } + if (showDeviceNameDialog) { + SensorDeviceNameDialog( + currentName = state.deviceName?.value.orEmpty(), + onDismiss = { showDeviceNameDialog = false }, + onConfirm = { name -> + showDeviceNameDialog = false + onEvent(TemperatureHumidityUiEvent.ChangeDeviceName(DeviceName(name))) + }, + ) + } + +} + +@Composable +private fun SensorDeviceNameDialog( + currentName: String, + onDismiss: () -> Unit, + onConfirm: (String) -> Unit, +) { + var value by remember(currentName) { mutableStateOf(currentName) } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.sensor_name_settings)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text(stringResource(R.string.gateway_device_name_sensor_desc), style = MaterialTheme.typography.bodySmall) + OutlinedTextField( + value = value, + onValueChange = { value = it }, + label = { Text(stringResource(R.string.gateway_device_name)) }, + singleLine = true, + ) + } + }, + confirmButton = { + TextButton(onClick = { onConfirm(value.trim()) }, enabled = value.isNotBlank()) { Text(stringResource(R.string.action_save_device_name)) } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } }, + ) } @Composable @@ -233,11 +284,12 @@ private fun TelemetryMetricCard( value: String?, unit: String, icon: ABoxIconKey, + testTag: String, modifier: Modifier = Modifier, ) { if (value == null) return Card( - modifier = modifier, + modifier = modifier.testTag(testTag), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surfaceVariant, ), diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityViewModel.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityViewModel.kt index bc5c8e8..bd72512 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityViewModel.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityViewModel.kt @@ -8,7 +8,11 @@ import com.flagship.abox.manager.domain.DomainError import com.flagship.abox.manager.domain.DomainResult import com.flagship.abox.manager.domain.ProviderProfileId import com.flagship.abox.manager.domain.TemperatureHumidityGateway +import com.flagship.abox.manager.devicecatalog.data.ConfiguredDeviceRepository import com.flagship.abox.manager.ui.common.DomainErrorMapper +import com.flagship.abox.manager.domain.ActivityEventWriter +import com.flagship.abox.manager.domain.ActivityEventKind +import com.flagship.abox.manager.domain.ActivityOutcome import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -22,6 +26,8 @@ import javax.inject.Inject @HiltViewModel class TemperatureHumidityViewModel @Inject constructor( private val thGateway: TemperatureHumidityGateway, + private val configuredDeviceRepository: ConfiguredDeviceRepository, + private val activityWriter: ActivityEventWriter, savedStateHandle: SavedStateHandle, ) : ViewModel() { @@ -62,18 +68,19 @@ class TemperatureHumidityViewModel @Inject constructor( is TemperatureHumidityUiEvent.ChangeDeviceName -> { val trimmed = event.deviceName.value.trim() - if (trimmed.isNotBlank() && trimmed != _state.value.deviceName?.value) { - _state.update { - it.copy( - deviceName = DeviceName(trimmed), - temperature = null, - humidity = null, - hasNoData = false, - errorMessage = null, - lastUpdatedTimeMillis = null, - ) + val current = _state.value + val profileId = current.profileId + val oldName = current.deviceName + if (trimmed.isNotBlank() && trimmed != oldName?.value && profileId != null && oldName != null) { + viewModelScope.launch { + when (val result = configuredDeviceRepository.rename(profileId, oldName, DeviceName(trimmed))) { + is DomainResult.Success -> { + _state.update { it.copy(deviceName = DeviceName(trimmed), temperature = null, humidity = null, hasNoData = false, errorMessage = null, lastUpdatedTimeMillis = null) } + loadReading(isRefresh = false) + } + is DomainResult.Failure -> _state.update { it.copy(errorMessage = DomainErrorMapper.map(result.error)) } + } } - loadReading(isRefresh = false) } } @@ -100,6 +107,7 @@ class TemperatureHumidityViewModel @Inject constructor( when (val result = thGateway.getReading(profileId, deviceName)) { is DomainResult.Success -> { + if (isRefresh) activityWriter.recordDevice(profileId, deviceName, ActivityEventKind.SENSOR_REFRESH, ActivityOutcome.SUCCEEDED) val reading = result.value val isBlankData = reading.temperature.isNullOrBlank() && reading.humidity.isNullOrBlank() _state.update { @@ -117,6 +125,7 @@ class TemperatureHumidityViewModel @Inject constructor( } is DomainResult.Failure -> { + if (isRefresh) activityWriter.recordDevice(profileId, deviceName, ActivityEventKind.SENSOR_REFRESH, ActivityOutcome.FAILED, result.error.externalCode) val isOffline = result.error is DomainError.DeviceOffline val hasNoData = result.error is DomainError.NoData _state.update { diff --git a/android/app/src/main/res/values-zh-rCN/strings.xml b/android/app/src/main/res/values-zh-rCN/strings.xml index 6edd76b..72c82f4 100644 --- a/android/app/src/main/res/values-zh-rCN/strings.xml +++ b/android/app/src/main/res/values-zh-rCN/strings.xml @@ -259,4 +259,127 @@ 操作执行失败,请重试。 暂无可用数据。 发生未知错误。 + + + 界面设置 + 样式 + 渲染器 + 选择应用使用的界面渲染方式 + Material 3 + Miuix + 主题 + 主题模式 + 跟随系统 + 浅色 + 深色 + 动态取色 + 使用系统壁纸颜色生成界面配色 + 界面效果 + 模糊效果 + 在支持的界面中启用背景模糊 + 导航样式 + 标准导航栏 + 悬浮导航栏 + 交互反馈 + 触觉反馈 + 操作时提供轻微震动反馈 + 系统辅助功能 + 遵循系统辅助功能设置 + 动画、字体大小和对比度等辅助功能由系统设置控制,应用会遵循系统偏好。 + 无法保存界面设置,请重试 + 界面风格 + 主题 + 配色、明暗、视觉效果与缩放 + 主题 + Material 3 + Miuix + 让底部导航独立悬浮在页面内容上方 + 强调色 + 界面缩放 + 调整界面元素的显示比例 + 色彩风格 + Tonal Spot + Expressive + Fidelity + 色彩规范 + Spec 2021 + Spec 2025 + 液态玻璃 + 为悬浮底栏启用半透明玻璃质感 + 预测性返回手势 + 侧滑返回时预览即将返回的页面 + 点击选择 + 需要 Android 13 或更高版本 + 请先启用悬浮底栏 + + + 已连接终端设备 + 选择需要控制或查看状态的终端设备 + 正在加载设备 + 尚未配置可管理设备 + 请先配置网关设备名称和类型 + 配置设备 + 设备列表加载失败 + 配置设备 + 设备名称 + 状态不可用 + 离线 + 正在切换 + 等待状态确认 + 已开启 + 已关闭 + 状态已确认 + 等待首次确认 + 控制面板 + 环境传感器 · 只读监控 + 实时设备读数 + 详情 + 无数据 + 红外控制器 · %1$d 个虚拟遥控器 + 已挂载:%1$s + 暂无遥控器 + 硬件槽位 %1$d/%2$d + 槽位状态不可用 + 红外中枢 + 当前账户 + %1$s,您好 + 连接方式 + 界面设置 + 主题、渲染风格与交互偏好 + 退出当前会话 + 结束当前连接并返回登录流程 + 确定退出当前会话吗? + 新建指令 + 选择指令的启动方式 + 手动点击运行,适合常用的一键设备组合。 + 根据时间、设备状态或传感器条件自动执行。 + 智能插座 · 状态确认控制 + 设置插座设备名称 + 设置传感器设备名称 + 网关设备名称 + 用于匹配和控制网关设备,必须与网关中的名称完全一致。 + 用于匹配和读取网关设备,必须与网关中的名称完全一致。 + 保存设备名称 + 直接控制 + 指令只有在网关回读到结果状态后才会确认。等待确认时可使用刷新操作。 + 当前读数 + 实时环境监控 + 数据直接来自当前设备。缺失指标保持不显示,不使用估算值或占位数据。 + 红外遥控中心 + 红外设备设置 + 遥控器、槽位与网关设备名称 + 虚拟遥控器 + %1$d 个遥控器 + 添加遥控器 + 硬件槽位 + 网关设备名称 + 用于在网关中匹配红外控制器,必须与网关中的设备名称完全一致。 + 保存设备名称 + 编辑遥控器 + 调整遥控器顺序 + 上移 + 下移 + 删除遥控器 + 面板布局 + 传输模式 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 20b7022..8ccbd56 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -261,30 +261,125 @@ An unexpected error occurred. - 界面设置 - 样式 - 渲染器 - 选择应用使用的界面渲染方式 + Interface settings + Style + Renderer + Choose the interface renderer used by the app Material 3 Miuix - 主题 - 主题模式 - 跟随系统 - 浅色 - 深色 - 动态取色 - 使用系统壁纸颜色生成界面配色 - 界面效果 - 模糊效果 - 在支持的界面中启用背景模糊 - 导航样式 - 标准导航栏 - 悬浮导航栏 - 交互反馈 - 触觉反馈 - 操作时提供轻微震动反馈 - 系统辅助功能 - 遵循系统辅助功能设置 - 动画、字体大小和对比度等辅助功能由系统设置控制,应用会遵循系统偏好。 - 无法保存界面设置,请重试 + Theme + Theme mode + Follow system + Light + Dark + Dynamic color + Generate interface colors from the system wallpaper + Surface effects + Blur effects + Enable background blur on supported screens + Navigation style + Standard navigation + Floating navigation + Interaction feedback + Haptic feedback + Provide subtle vibration feedback for actions + System accessibility + Follow system accessibility settings + Animation, font size, contrast and other accessibility options are controlled by the system. + Unable to save interface settings. Try again. + Interface style + Theme + Colors, brightness, effects and scale + Theme + Material 3 + Miuix + Use a detached navigation surface above page content + Accent color + Interface scale + Adjust the size of interface elements + Color style + Tonal Spot + Expressive + Fidelity + Color standard + Spec 2021 + Spec 2025 + Liquid glass + Apply a translucent glass surface to floating navigation + Predictive back gesture + Preview the destination while swiping back + Tap to choose + Requires Android 13 or later + Enable floating navigation first + + + Connected devices + Select a device to control it or view its current state + Loading devices + No manageable devices configured + Configure a gateway device name and type first + Configure device + Unable to load devices + Configure device + Device name + Status unavailable + Offline + Switching + Awaiting confirmation + On + Off + State confirmed + Waiting for first confirmation + Control panel + Environment sensor · Read-only monitoring + Live device reading + Details + No data + Infrared controller · %1$d virtual remotes + Mounted: %1$s + No remotes + Hardware slots %1$d/%2$d + Slot state unavailable + Infrared center + Current account + Hello, %1$s + Connection method + Interface settings + Theme, renderer and interaction preferences + Sign out + End the current connection and return to sign-in + Sign out of the current session? + New command + Choose how the command starts + Run manually for frequently used device combinations. + Run automatically from time, device, or sensor conditions. + Smart socket · confirmed control + Set socket device name + Set sensor device name + Gateway device name + Used to match and control the gateway device. It must exactly match the gateway name. + Used to match and read the gateway device. It must exactly match the gateway name. + Save device name + Direct control + A command is confirmed only after the gateway reports the resulting state. Refresh while confirmation is pending. + Current readings + Live environment monitoring + Values come directly from the current device. Missing metrics remain absent; no estimates or placeholders are used. + Infrared remote center + Infrared device settings + Remotes, slots, and gateway device name + Virtual remotes + %1$d remotes + Add remote + Hardware slots + Gateway device name + Used to match the infrared controller in the gateway. It must exactly match the gateway name. + Save device name + Edit remote + Reorder remote + Move up + Move down + Delete remote + Panel layout + Transmission mode 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 index c13b47f..880a051 100644 --- 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 @@ -16,6 +16,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.UnconfinedTestDispatcher import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals import org.junit.Rule import org.junit.Test @@ -54,6 +55,16 @@ class DeviceCenterViewModelTest { assertEquals(listOf("new"), viewModel.uiState.value.cards.map { it.displayName }) } + @Test + fun deviceCardKeyHasBundleCompatibleStableRepresentation() { + val first = DeviceCardKey(ProviderProfileId("profile-a"), DeviceName("socket-1")) + val same = DeviceCardKey(ProviderProfileId("profile-a"), DeviceName("socket-1")) + val differentProfile = DeviceCardKey(ProviderProfileId("profile-b"), DeviceName("socket-1")) + + assertEquals(first.saveableKey(), same.saveableKey()) + assertNotEquals(first.saveableKey(), differentProfile.saveableKey()) + } + private fun descriptor(profile: ProviderProfileId, name: String, type: DeviceType) = DeviceDescriptor(DeviceId("${profile.value}:$name"), profile, DeviceName(name), type) diff --git a/android/app/src/test/kotlin/com/flagship/abox/manager/ui/preferences/SettingsViewModelTest.kt b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/preferences/SettingsViewModelTest.kt index 9de023e..5037e32 100644 --- a/android/app/src/test/kotlin/com/flagship/abox/manager/ui/preferences/SettingsViewModelTest.kt +++ b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/preferences/SettingsViewModelTest.kt @@ -2,7 +2,11 @@ package com.flagship.abox.manager.ui.preferences import com.flagship.abox.manager.R import com.flagship.abox.manager.preferences.AppThemeMode +import com.flagship.abox.manager.preferences.AccentPreference import com.flagship.abox.manager.preferences.DesignRenderer +import com.flagship.abox.manager.preferences.InterfaceScale +import com.flagship.abox.manager.preferences.ColorStandard +import com.flagship.abox.manager.preferences.ColorStyle import com.flagship.abox.manager.ui.common.UiText.StringResource import com.flagship.abox.manager.preferences.NavigationPresentation import com.flagship.abox.manager.preferences.UiPreferences @@ -83,6 +87,39 @@ class SettingsViewModelTest { assertEquals(repository.value, viewModel.uiState.value.preferences) } + @Test + fun scaleAndAccentUpdatesPersistThroughRepository() = runTest { + val repository = RecordingUiPreferencesRepository() + val viewModel = SettingsViewModel(repository) + advanceUntilIdle() + + viewModel.onAction(SettingsUiAction.SetInterfaceScale(InterfaceScale.COMPACT)) + advanceUntilIdle() + viewModel.onAction(SettingsUiAction.SetAccent(AccentPreference.ROSE)) + advanceUntilIdle() + + assertEquals(InterfaceScale.COMPACT, repository.value.interfaceScale) + assertEquals(AccentPreference.ROSE, repository.value.accentPreference) + } + + @Test + fun styleSpecificThemeOptionsPersistThroughRepository() = runTest { + val repository = RecordingUiPreferencesRepository() + val viewModel = SettingsViewModel(repository) + advanceUntilIdle() + + viewModel.onAction(SettingsUiAction.SetColorStyle(ColorStyle.FIDELITY)) + viewModel.onAction(SettingsUiAction.SetColorStandard(ColorStandard.SPEC_2025)) + viewModel.onAction(SettingsUiAction.SetLiquidGlass(true)) + viewModel.onAction(SettingsUiAction.SetPredictiveBack(false)) + advanceUntilIdle() + + assertEquals(ColorStyle.FIDELITY, repository.value.colorStyle) + assertEquals(ColorStandard.SPEC_2025, repository.value.colorStandard) + assertTrue(repository.value.liquidGlassEnabled) + assertEquals(false, repository.value.predictiveBackEnabled) + } + private class RecordingUiPreferencesRepository( initial: UiPreferences = UiPreferences(), diff --git a/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Components.kt b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Components.kt index a47ef34..9b38645 100644 --- a/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Components.kt +++ b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Components.kt @@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -476,13 +477,19 @@ fun ABoxDeviceDetailScaffold( title: String, onBack: () -> Unit, modifier: Modifier = Modifier, + subtitle: String? = null, + icon: ABoxIconKey = ABoxIconKey.UNKNOWN_DEVICE, + action: (@Composable () -> Unit)? = null, content: @Composable (PaddingValues) -> Unit, ) { Scaffold( modifier = modifier.fillMaxSize(), topBar = { Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 8.dp), + modifier = Modifier + .fillMaxWidth() + .statusBarsPadding() + .padding(horizontal = 8.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { ABoxIconButton( @@ -490,11 +497,38 @@ fun ABoxDeviceDetailScaffold( contentDescription = "返回", onClick = onBack, ) - Text( - text = title, - style = MaterialTheme.typography.titleLarge, - modifier = Modifier.padding(start = 8.dp), - ) + Surface( + shape = RoundedCornerShape(14.dp), + color = MaterialTheme.colorScheme.primaryContainer, + modifier = Modifier.padding(start = 4.dp), + ) { + ABoxIcon( + key = icon, + contentDescription = subtitle ?: title, + modifier = Modifier.padding(9.dp).size(24.dp), + ) + } + Column( + modifier = Modifier.weight(1f).padding(start = 12.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (subtitle != null) { + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + action?.invoke() } }, content = content, diff --git a/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Theme.kt b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Theme.kt index bed7e71..0a1b683 100644 --- a/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Theme.kt +++ b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Theme.kt @@ -12,6 +12,8 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Density import com.flagship.abox.manager.designsystem.renderer.ABoxRendererAdapters import com.flagship.abox.manager.preferences.DesignRenderer import com.flagship.abox.manager.designsystem.tokens.ABoxColorTokens @@ -20,6 +22,7 @@ import com.flagship.abox.manager.designsystem.tokens.ABoxThemeDefaults import com.flagship.abox.manager.designsystem.tokens.ABoxThemeResolution import com.flagship.abox.manager.designsystem.tokens.ABoxThemeResolver import com.flagship.abox.manager.preferences.AppThemeMode +import com.flagship.abox.manager.preferences.NavigationPresentation import com.flagship.abox.manager.preferences.UiPreferences val LocalABoxColors = staticCompositionLocalOf { ABoxThemeDefaults.lightColors } @@ -29,6 +32,9 @@ val LocalABoxShapes = staticCompositionLocalOf { ABoxThemeDefaults.material3Shap val LocalABoxRenderer = staticCompositionLocalOf { DesignRenderer.MATERIAL3 } val LocalABoxReducedMotion = staticCompositionLocalOf { false } val LocalABoxBlurEnabled = staticCompositionLocalOf { false } +val LocalABoxNavigationPresentation = staticCompositionLocalOf { NavigationPresentation.STANDARD } +val LocalABoxLiquidGlassEnabled = staticCompositionLocalOf { false } +val LocalABoxPredictiveBackEnabled = staticCompositionLocalOf { false } @Composable fun ABoxTheme( @@ -78,8 +84,14 @@ internal fun ABoxThemeContent( dynamicColorTokens = dynamicColorTokens, ) val renderer = ABoxRendererAdapters.forRenderer(resolution.renderer) + val density = LocalDensity.current + val scaledDensity = Density( + density = density.density * (preferences.interfaceScale.percent / 100f), + fontScale = density.fontScale, + ) CompositionLocalProvider( + LocalDensity provides scaledDensity, LocalABoxColors provides resolution.colorTokens, LocalABoxSpacing provides ABoxSpacing(), LocalABoxTypography provides ABoxTypography, @@ -87,6 +99,9 @@ internal fun ABoxThemeContent( LocalABoxRenderer provides resolution.renderer, LocalABoxReducedMotion provides reducedMotion, LocalABoxBlurEnabled provides resolution.blurEnabled, + LocalABoxNavigationPresentation provides preferences.navigationPresentation, + LocalABoxLiquidGlassEnabled provides preferences.liquidGlassEnabled, + LocalABoxPredictiveBackEnabled provides preferences.predictiveBackEnabled, ) { renderer.Render( resolution = resolution, diff --git a/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/icon/ABoxIcon.kt b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/icon/ABoxIcon.kt index 217b822..bbda6fe 100644 --- a/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/icon/ABoxIcon.kt +++ b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/icon/ABoxIcon.kt @@ -11,6 +11,11 @@ import androidx.compose.material.icons.outlined.Edit import androidx.compose.material.icons.outlined.ErrorOutline import androidx.compose.material.icons.automirrored.outlined.HelpOutline import androidx.compose.material.icons.outlined.History +import androidx.compose.material.icons.outlined.Palette +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material.icons.outlined.Tune +import androidx.compose.material.icons.outlined.Vibration +import androidx.compose.material.icons.outlined.ViewCarousel import androidx.compose.material.icons.outlined.Layers import androidx.compose.material.icons.outlined.Power import androidx.compose.material.icons.outlined.PowerSettingsNew @@ -51,6 +56,11 @@ enum class ABoxIconKey { WARNING, ERROR, UNKNOWN_DEVICE, + SETTINGS, + THEME, + EFFECTS, + FEEDBACK, + SCALE, } /** Design-system-owned device categories; this module does not depend on domain models. */ @@ -94,6 +104,11 @@ object ABoxIconMapper { ABoxIconKey.WARNING -> Icons.Outlined.Warning ABoxIconKey.ERROR -> Icons.Outlined.ErrorOutline ABoxIconKey.UNKNOWN_DEVICE -> Icons.AutoMirrored.Outlined.HelpOutline + ABoxIconKey.SETTINGS -> Icons.Outlined.Settings + ABoxIconKey.THEME -> Icons.Outlined.Palette + ABoxIconKey.EFFECTS -> Icons.Outlined.ViewCarousel + ABoxIconKey.FEEDBACK -> Icons.Outlined.Vibration + ABoxIconKey.SCALE -> Icons.Outlined.Tune } } diff --git a/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/renderer/ABoxRendererAdapter.kt b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/renderer/ABoxRendererAdapter.kt index e129305..7fbe760 100644 --- a/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/renderer/ABoxRendererAdapter.kt +++ b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/renderer/ABoxRendererAdapter.kt @@ -6,10 +6,7 @@ import com.flagship.abox.manager.preferences.DesignRenderer internal interface ABoxRendererAdapter { @Composable - fun Render( - resolution: ABoxThemeResolution, - content: @Composable () -> Unit, - ) + fun Render(resolution: ABoxThemeResolution, content: @Composable () -> Unit) } internal object ABoxRendererAdapters { diff --git a/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/renderer/MiuixRendererAdapter.kt b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/renderer/MiuixRendererAdapter.kt index 394a3dd..cf481c6 100644 --- a/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/renderer/MiuixRendererAdapter.kt +++ b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/renderer/MiuixRendererAdapter.kt @@ -13,11 +13,11 @@ internal object MiuixRendererAdapter : ABoxRendererAdapter { content: @Composable () -> Unit, ) { val colors = if (resolution.isDarkTheme) darkColorScheme() else lightColorScheme() - MiuixTheme( - colors = colors, - ) { - Material3RendererAdapter.Render( - resolution = resolution, + MiuixTheme(colors = colors) { + androidx.compose.material3.MaterialTheme( + colorScheme = com.flagship.abox.manager.designsystem.materialColorScheme(resolution), + typography = com.flagship.abox.manager.designsystem.ABoxTypography, + shapes = com.flagship.abox.manager.designsystem.ABoxShapes(resolution.shapeTokens), content = content, ) } diff --git a/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/tokens/ABoxTokens.kt b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/tokens/ABoxTokens.kt index 004b947..a21e57f 100644 --- a/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/tokens/ABoxTokens.kt +++ b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/tokens/ABoxTokens.kt @@ -5,6 +5,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.flagship.abox.manager.preferences.AppThemeMode +import com.flagship.abox.manager.preferences.AccentPreference import com.flagship.abox.manager.preferences.DesignRenderer import com.flagship.abox.manager.preferences.UiPreferences @@ -121,7 +122,7 @@ object ABoxThemeResolver { val colorTokens = if (preferences.dynamicColorEnabled && sdkInt >= 31 && dynamicColorTokens != null) { dynamicColorTokens } else { - brandTokens + brandTokens.withAccent(preferences.accentPreference, isDarkTheme) } val shapeTokens = when (preferences.renderer) { DesignRenderer.MATERIAL3 -> ABoxThemeDefaults.material3Shapes @@ -137,3 +138,14 @@ object ABoxThemeResolver { ) } } + +private fun ABoxColorTokens.withAccent(accent: AccentPreference, dark: Boolean): ABoxColorTokens { + val selected = when (accent) { + AccentPreference.BLUE -> if (dark) Color(0xFF9CCAFF) else Color(0xFF385F9D) + AccentPreference.GREEN -> if (dark) Color(0xFF7EDFA7) else Color(0xFF247A4B) + AccentPreference.AMBER -> if (dark) Color(0xFFFFC46B) else Color(0xFF8A5800) + AccentPreference.ROSE -> if (dark) Color(0xFFFFB1C1) else Color(0xFFA43C59) + AccentPreference.VIOLET -> if (dark) Color(0xFFD4BBFF) else Color(0xFF6D52A2) + } + return copy(primary = selected, deviceSocket = selected) +} diff --git a/android/core/devicecatalog/src/main/kotlin/com/flagship/abox/manager/devicecatalog/data/ConfiguredDeviceDao.kt b/android/core/devicecatalog/src/main/kotlin/com/flagship/abox/manager/devicecatalog/data/ConfiguredDeviceDao.kt index ffe44d0..45b2afd 100644 --- a/android/core/devicecatalog/src/main/kotlin/com/flagship/abox/manager/devicecatalog/data/ConfiguredDeviceDao.kt +++ b/android/core/devicecatalog/src/main/kotlin/com/flagship/abox/manager/devicecatalog/data/ConfiguredDeviceDao.kt @@ -16,4 +16,7 @@ interface ConfiguredDeviceDao { @Query("DELETE FROM configured_devices WHERE profileId = :profileId AND deviceName = :deviceName") suspend fun remove(profileId: String, deviceName: String): Int + + @Query("UPDATE configured_devices SET id = :newId, deviceName = :newName WHERE profileId = :profileId AND deviceName = :oldName") + suspend fun rename(profileId: String, oldName: String, newName: String, newId: String): Int } diff --git a/android/core/devicecatalog/src/main/kotlin/com/flagship/abox/manager/devicecatalog/data/ConfiguredDeviceRepository.kt b/android/core/devicecatalog/src/main/kotlin/com/flagship/abox/manager/devicecatalog/data/ConfiguredDeviceRepository.kt index c29616f..c618af4 100644 --- a/android/core/devicecatalog/src/main/kotlin/com/flagship/abox/manager/devicecatalog/data/ConfiguredDeviceRepository.kt +++ b/android/core/devicecatalog/src/main/kotlin/com/flagship/abox/manager/devicecatalog/data/ConfiguredDeviceRepository.kt @@ -15,6 +15,8 @@ interface ConfiguredDeviceRepository { fun observe(profileId: ProviderProfileId): Flow>> suspend fun add(profileId: ProviderProfileId, name: DeviceName, type: DeviceType): DomainResult suspend fun remove(profileId: ProviderProfileId, deviceName: DeviceName): DomainResult + suspend fun rename(profileId: ProviderProfileId, oldName: DeviceName, newName: DeviceName): DomainResult = + DomainResult.Failure(DomainError.InvalidRequest(detail = "Device rename is not supported")) } class RoomConfiguredDeviceRepository( @@ -44,6 +46,17 @@ class RoomConfiguredDeviceRepository( } catch (error: Exception) { DomainResult.Failure(DomainError.Unknown(detail = error.message ?: "Unable to remove device")) } + + override suspend fun rename(profileId: ProviderProfileId, oldName: DeviceName, newName: DeviceName): DomainResult { + if (newName.value.isBlank()) return DomainResult.Failure(DomainError.InvalidRequest(detail = "Device name is required")) + return try { + val changed = dao.rename(profileId.value, oldName.value, newName.value, "${profileId.value}:${newName.value}") + if (changed == 1) DomainResult.Success(Unit) + else DomainResult.Failure(DomainError.InvalidRequest(detail = "Device was not found")) + } catch (error: Exception) { + DomainResult.Failure(DomainError.InvalidRequest(detail = error.message ?: "Duplicate device name")) + } + } } private fun ConfiguredDeviceEntity.toDomain() = DeviceDescriptor( diff --git a/android/core/preferences/src/main/kotlin/com/flagship/abox/manager/preferences/DataStoreUiPreferencesRepository.kt b/android/core/preferences/src/main/kotlin/com/flagship/abox/manager/preferences/DataStoreUiPreferencesRepository.kt index 9127d88..e2f57e7 100644 --- a/android/core/preferences/src/main/kotlin/com/flagship/abox/manager/preferences/DataStoreUiPreferencesRepository.kt +++ b/android/core/preferences/src/main/kotlin/com/flagship/abox/manager/preferences/DataStoreUiPreferencesRepository.kt @@ -64,6 +64,16 @@ class DataStoreUiPreferencesRepository internal constructor( navigationPresentation = preferences[NAVIGATION]?.enumOrDefault(NavigationPresentation.STANDARD) ?: NavigationPresentation.STANDARD, hapticsEnabled = preferences[HAPTICS] ?: true, + interfaceScale = preferences[INTERFACE_SCALE]?.enumOrDefault(InterfaceScale.STANDARD) + ?: InterfaceScale.STANDARD, + accentPreference = preferences[ACCENT]?.enumOrDefault(AccentPreference.BLUE) + ?: AccentPreference.BLUE, + colorStyle = preferences[COLOR_STYLE]?.enumOrDefault(ColorStyle.TONAL_SPOT) + ?: ColorStyle.TONAL_SPOT, + colorStandard = preferences[COLOR_STANDARD]?.enumOrDefault(ColorStandard.SPEC_2021) + ?: ColorStandard.SPEC_2021, + liquidGlassEnabled = preferences[LIQUID_GLASS] ?: false, + predictiveBackEnabled = preferences[PREDICTIVE_BACK] ?: false, ) private fun MutablePreferences.putAll(preferences: UiPreferences) { @@ -73,6 +83,12 @@ class DataStoreUiPreferencesRepository internal constructor( this[BLUR] = preferences.blurEnabled this[NAVIGATION] = preferences.navigationPresentation.name this[HAPTICS] = preferences.hapticsEnabled + this[INTERFACE_SCALE] = preferences.interfaceScale.name + this[ACCENT] = preferences.accentPreference.name + this[COLOR_STYLE] = preferences.colorStyle.name + this[COLOR_STANDARD] = preferences.colorStandard.name + this[LIQUID_GLASS] = preferences.liquidGlassEnabled + this[PREDICTIVE_BACK] = preferences.predictiveBackEnabled } private inline fun > String.enumOrDefault(default: T): T = @@ -89,5 +105,11 @@ class DataStoreUiPreferencesRepository internal constructor( val BLUR = booleanPreferencesKey("blur") val NAVIGATION = stringPreferencesKey("navigation") val HAPTICS = booleanPreferencesKey("haptics") + val INTERFACE_SCALE = stringPreferencesKey("interface_scale") + val ACCENT = stringPreferencesKey("accent") + val COLOR_STYLE = stringPreferencesKey("color_style") + val COLOR_STANDARD = stringPreferencesKey("color_standard") + val LIQUID_GLASS = booleanPreferencesKey("liquid_glass") + val PREDICTIVE_BACK = booleanPreferencesKey("predictive_back") } } diff --git a/android/core/preferences/src/main/kotlin/com/flagship/abox/manager/preferences/UiPreferences.kt b/android/core/preferences/src/main/kotlin/com/flagship/abox/manager/preferences/UiPreferences.kt index 429f7c3..d6222c5 100644 --- a/android/core/preferences/src/main/kotlin/com/flagship/abox/manager/preferences/UiPreferences.kt +++ b/android/core/preferences/src/main/kotlin/com/flagship/abox/manager/preferences/UiPreferences.kt @@ -16,6 +16,31 @@ enum class NavigationPresentation { FLOATING, } +enum class InterfaceScale(val percent: Int) { + COMPACT(85), + STANDARD(100), + LARGE(115), +} + +enum class AccentPreference { + BLUE, + GREEN, + AMBER, + ROSE, + VIOLET, +} + +enum class ColorStyle { + TONAL_SPOT, + EXPRESSIVE, + FIDELITY, +} + +enum class ColorStandard { + SPEC_2021, + SPEC_2025, +} + enum class PreferenceField { RENDERER, THEME_MODE, @@ -23,6 +48,12 @@ enum class PreferenceField { BLUR, NAVIGATION, HAPTICS, + INTERFACE_SCALE, + ACCENT, + COLOR_STYLE, + COLOR_STANDARD, + LIQUID_GLASS, + PREDICTIVE_BACK, } data class UiPreferences( @@ -32,4 +63,10 @@ data class UiPreferences( val blurEnabled: Boolean = false, val navigationPresentation: NavigationPresentation = NavigationPresentation.STANDARD, val hapticsEnabled: Boolean = true, + val interfaceScale: InterfaceScale = InterfaceScale.STANDARD, + val accentPreference: AccentPreference = AccentPreference.BLUE, + val colorStyle: ColorStyle = ColorStyle.TONAL_SPOT, + val colorStandard: ColorStandard = ColorStandard.SPEC_2021, + val liquidGlassEnabled: Boolean = false, + val predictiveBackEnabled: Boolean = false, ) diff --git a/android/core/preferences/src/test/kotlin/com/flagship/abox/manager/preferences/DataStoreUiPreferencesRepositoryTest.kt b/android/core/preferences/src/test/kotlin/com/flagship/abox/manager/preferences/DataStoreUiPreferencesRepositoryTest.kt index bc71d3e..46ba8ca 100644 --- a/android/core/preferences/src/test/kotlin/com/flagship/abox/manager/preferences/DataStoreUiPreferencesRepositoryTest.kt +++ b/android/core/preferences/src/test/kotlin/com/flagship/abox/manager/preferences/DataStoreUiPreferencesRepositoryTest.kt @@ -45,6 +45,24 @@ class DataStoreUiPreferencesRepositoryTest { assertEquals(UiPreferencesWriteResult.Success, repository.update { it.copy(hapticsEnabled = false) }) + assertEquals(UiPreferencesWriteResult.Success, repository.update { + it.copy(interfaceScale = InterfaceScale.LARGE) + }) + assertEquals(UiPreferencesWriteResult.Success, repository.update { + it.copy(accentPreference = AccentPreference.GREEN) + }) + assertEquals(UiPreferencesWriteResult.Success, repository.update { + it.copy(colorStyle = ColorStyle.EXPRESSIVE) + }) + assertEquals(UiPreferencesWriteResult.Success, repository.update { + it.copy(colorStandard = ColorStandard.SPEC_2025) + }) + assertEquals(UiPreferencesWriteResult.Success, repository.update { + it.copy(liquidGlassEnabled = true) + }) + assertEquals(UiPreferencesWriteResult.Success, repository.update { + it.copy(predictiveBackEnabled = false) + }) assertEquals( UiPreferences( @@ -54,6 +72,12 @@ class DataStoreUiPreferencesRepositoryTest { blurEnabled = true, navigationPresentation = NavigationPresentation.FLOATING, hapticsEnabled = false, + interfaceScale = InterfaceScale.LARGE, + accentPreference = AccentPreference.GREEN, + colorStyle = ColorStyle.EXPRESSIVE, + colorStandard = ColorStandard.SPEC_2025, + liquidGlassEnabled = true, + predictiveBackEnabled = false, ), repository.observe().first(), ) diff --git a/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/RoomActivityRepository.kt b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/RoomActivityRepository.kt index 5e30a10..fe1fd9f 100644 --- a/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/RoomActivityRepository.kt +++ b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/RoomActivityRepository.kt @@ -37,13 +37,25 @@ private fun ActivityEventEntity.toDomain() = ActivityEvent( id = ActivityEventId(id), profileId = ProviderProfileId(profileId), occurredAtEpochMillis = occurredAtEpochMillis, - kind = ActivityEventKind.valueOf(kind), - source = ActivitySource.valueOf(source), - outcome = ActivityOutcome.valueOf(outcome), - target = ActivityTarget(ActivityTargetType.valueOf(targetType), targetStableId, targetDisplayName), + kind = kind.toActivityEventKind(), + source = source.toActivitySource(), + outcome = outcome.toActivityOutcome(), + target = ActivityTarget(targetType.toActivityTargetType(), targetStableId, targetDisplayName), errorCode = errorCode, ) +private fun String.toActivityEventKind(): ActivityEventKind = + runCatching { ActivityEventKind.valueOf(this) }.getOrDefault(ActivityEventKind.SCENE_ACTION) + +private fun String.toActivitySource(): ActivitySource = + runCatching { ActivitySource.valueOf(this) }.getOrDefault(ActivitySource.SYSTEM) + +private fun String.toActivityOutcome(): ActivityOutcome = + runCatching { ActivityOutcome.valueOf(this) }.getOrDefault(ActivityOutcome.FAILED) + +private fun String.toActivityTargetType(): ActivityTargetType = + runCatching { ActivityTargetType.valueOf(this) }.getOrDefault(ActivityTargetType.SCENE_ACTION) + private fun ActivityEvent.toEntity() = ActivityEventEntity( profileId = profileId.value, id = id.value, diff --git a/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/di/AutomationModule.kt b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/di/AutomationModule.kt index 19b142a..6d5d42c 100644 --- a/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/di/AutomationModule.kt +++ b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/di/AutomationModule.kt @@ -6,6 +6,7 @@ import com.flagship.abox.manager.automation.data.AutomationDatabase import com.flagship.abox.manager.automation.data.AutomationRuntimeRepository import com.flagship.abox.manager.automation.data.RoomActivityRepository import com.flagship.abox.manager.automation.data.RoomAutomationRuntimeRepository +import com.flagship.abox.manager.domain.ActivityEventWriter import com.flagship.abox.manager.domain.ActivityRepository import com.flagship.abox.manager.domain.NotificationGateway import com.flagship.abox.manager.automation.notification.AndroidNotificationGateway @@ -15,6 +16,9 @@ import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import javax.inject.Singleton +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob @Module @InstallIn(SingletonComponent::class) @@ -28,6 +32,11 @@ object AutomationModule { @Singleton fun provideActivityRepository(database: AutomationDatabase): ActivityRepository = RoomActivityRepository(database.automationDao()) + @Provides + @Singleton + fun provideActivityEventWriter(repository: ActivityRepository): ActivityEventWriter = + ActivityEventWriter(repository, CoroutineScope(SupervisorJob() + Dispatchers.IO)) + @Provides @Singleton fun provideRuntimeRepository(database: AutomationDatabase): AutomationRuntimeRepository = RoomAutomationRuntimeRepository(database.automationDao()) diff --git a/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/notification/AndroidNotificationGateway.kt b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/notification/AndroidNotificationGateway.kt index a0a2258..329d536 100644 --- a/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/notification/AndroidNotificationGateway.kt +++ b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/notification/AndroidNotificationGateway.kt @@ -5,6 +5,7 @@ import android.app.NotificationChannel import android.app.NotificationManager import android.content.Context import android.content.pm.PackageManager +import android.annotation.SuppressLint import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat @@ -25,7 +26,9 @@ class AndroidNotificationGateway(private val context: Context) : NotificationGat NotificationChannel(CHANNEL_ID, "自动化通知", NotificationManager.IMPORTANCE_DEFAULT), ) return try { - NotificationManagerCompat.from(context).notify( + @SuppressLint("MissingPermission") + fun postNotification() { + NotificationManagerCompat.from(context).notify( profileId.value.hashCode(), NotificationCompat.Builder(context, CHANNEL_ID) .setSmallIcon(android.R.drawable.ic_dialog_info) @@ -33,7 +36,9 @@ class AndroidNotificationGateway(private val context: Context) : NotificationGat .setContentText(message) .setAutoCancel(true) .build(), - ) + ) + } + postNotification() DomainResult.Success(Unit) } catch (_: SecurityException) { DomainResult.Failure(DomainError.OperationFailed(detail = "Notification permission is not granted")) diff --git a/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/ui/activity/ActivityScreen.kt b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/ui/activity/ActivityScreen.kt index 0518550..b8515cb 100644 --- a/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/ui/activity/ActivityScreen.kt +++ b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/ui/activity/ActivityScreen.kt @@ -3,18 +3,26 @@ package com.flagship.abox.manager.automation.ui.activity import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.flagship.abox.manager.domain.ActivityEvent import com.flagship.abox.manager.domain.ActivityOutcome +import com.flagship.abox.manager.domain.ActivityEventKind +import com.flagship.abox.manager.domain.ActivitySource +import com.flagship.abox.manager.automation.R import java.time.Instant import java.time.ZoneId import java.time.format.DateTimeFormatter @@ -27,16 +35,19 @@ fun ActivityScreen( zoneId: ZoneId = ZoneId.systemDefault(), ) { Column(modifier = modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { - Text("活动记录", style = MaterialTheme.typography.headlineSmall) + Text(stringResource(R.string.activity_title), style = MaterialTheme.typography.headlineSmall) if (!state.loading && state.groups.isEmpty()) { - Text("暂无活动记录", modifier = Modifier.testTag("activity_empty")) + Text( + if (state.error) stringResource(R.string.activity_error) else stringResource(R.string.activity_empty), + modifier = Modifier.testTag("activity_empty"), + ) } else { LazyColumn(verticalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.testTag("activity_timeline")) { state.groups.forEach { group -> item { Text(group.label, style = MaterialTheme.typography.titleMedium) } - items(group.events, key = { it.id }) { event -> ActivityEventRow(event, zoneId) } + items(group.events, key = { it.id.value }) { event -> ActivityEventRow(event, zoneId) } } - if (state.hasMore) item { TextButton(onClick = onLoadEarlier) { Text("加载更早记录") } } + if (state.hasMore) item { TextButton(onClick = onLoadEarlier) { Text(stringResource(R.string.activity_load_earlier)) } } } } } @@ -45,13 +56,39 @@ fun ActivityScreen( @Composable private fun ActivityEventRow(event: ActivityEvent, zoneId: ZoneId) { val outcome = when (event.outcome) { - ActivityOutcome.SUCCEEDED -> "成功" - ActivityOutcome.FAILED -> "失败" - ActivityOutcome.CANCELLED -> "已取消" - ActivityOutcome.AWAITING_CONFIRMATION -> "等待确认" + ActivityOutcome.SUCCEEDED -> R.string.activity_outcome_success + ActivityOutcome.FAILED -> R.string.activity_outcome_failed + ActivityOutcome.CANCELLED -> R.string.activity_outcome_cancelled + ActivityOutcome.AWAITING_CONFIRMATION -> R.string.activity_outcome_awaiting } - Column(modifier = Modifier.fillMaxSize().testTag("activity_event_${event.id.value}")) { - Text(event.target.displayName, style = MaterialTheme.typography.bodyLarge) - Text("$outcome · ${Instant.ofEpochMilli(event.occurredAtEpochMillis).atZone(zoneId).format(DateTimeFormatter.ofPattern("HH:mm"))}") + val kind = when (event.kind) { + ActivityEventKind.SOCKET_COMMAND -> R.string.activity_kind_socket_command + ActivityEventKind.SOCKET_CONFIRMATION -> R.string.activity_kind_socket_confirmation + ActivityEventKind.SENSOR_REFRESH -> R.string.activity_kind_sensor_refresh + ActivityEventKind.INFRARED_SEND -> R.string.activity_kind_infrared_send + ActivityEventKind.INFRARED_LEARN -> R.string.activity_kind_infrared_learn + ActivityEventKind.SCENE_EXECUTION -> R.string.activity_kind_scene_execution + ActivityEventKind.SCENE_ACTION -> R.string.activity_kind_scene_action + else -> R.string.activity_title + } + val source = when (event.source) { + ActivitySource.USER -> R.string.activity_source_user + ActivitySource.AUTOMATION -> R.string.activity_source_automation + ActivitySource.SYSTEM -> R.string.activity_source_system + } + Card( + modifier = Modifier.fillMaxWidth().testTag("activity_event_${event.id.value}"), + shape = RoundedCornerShape(6.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + ) { + Column(modifier = Modifier.fillMaxWidth().padding(14.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(stringResource(kind), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary) + Text(event.target.displayName, style = MaterialTheme.typography.bodyLarge) + Text( + "${stringResource(outcome)} · ${stringResource(source)} · ${Instant.ofEpochMilli(event.occurredAtEpochMillis).atZone(zoneId).format(DateTimeFormatter.ofPattern("HH:mm"))}", + style = MaterialTheme.typography.bodyMedium, + ) + event.errorCode?.let { Text(stringResource(R.string.activity_error_code, it), color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) } + } } } diff --git a/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/ui/activity/ActivityViewModel.kt b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/ui/activity/ActivityViewModel.kt index 23cafe1..a274729 100644 --- a/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/ui/activity/ActivityViewModel.kt +++ b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/ui/activity/ActivityViewModel.kt @@ -15,6 +15,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.catch import kotlinx.coroutines.launch import javax.inject.Inject @@ -43,12 +44,14 @@ class ActivityViewModel @Inject constructor( observation?.cancel() _state.value = ActivityUiState(profileId = profileId) observation = viewModelScope.launch { - repository.observeLatest(profileId, 100).collectLatest { result -> + repository.observeLatest(profileId, 100) + .catch { _state.value = _state.value.copy(loading = false, error = true) } + .collectLatest { result -> when (result) { is DomainResult.Success -> publish(result.value, zoneId) is DomainResult.Failure -> _state.value = _state.value.copy(loading = false, error = true) } - } + } } } diff --git a/android/feature/automation/src/main/res/values-zh-rCN/strings.xml b/android/feature/automation/src/main/res/values-zh-rCN/strings.xml new file mode 100644 index 0000000..e5ba976 --- /dev/null +++ b/android/feature/automation/src/main/res/values-zh-rCN/strings.xml @@ -0,0 +1,21 @@ + + 活动记录 + 暂无活动记录 + 活动记录加载失败 + 加载更早记录 + 手动操作 + 自动化 + 系统 + 成功 + 失败 + 已取消 + 等待确认 + 插座控制 + 插座状态确认 + 传感器刷新 + 红外发送 + 红外学习 + 场景执行 + 场景动作 + 错误码:%1$s + diff --git a/android/feature/automation/src/main/res/values/strings.xml b/android/feature/automation/src/main/res/values/strings.xml new file mode 100644 index 0000000..d97cdbd --- /dev/null +++ b/android/feature/automation/src/main/res/values/strings.xml @@ -0,0 +1,21 @@ + + Activity + No activity yet + Activity could not be loaded. + Load earlier activity + Manual + Automation + System + Succeeded + Failed + Cancelled + Awaiting confirmation + Socket command + Socket confirmation + Sensor refresh + Infrared signal + Infrared learning + Scene execution + Scene action + Error code: %1$s + diff --git a/android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubScreenTest.kt b/android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubScreenTest.kt new file mode 100644 index 0000000..4669901 --- /dev/null +++ b/android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubScreenTest.kt @@ -0,0 +1,57 @@ +package com.flagship.abox.manager.infrared.hub + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.infrared.RemoteDeviceType +import com.flagship.abox.manager.domain.infrared.TransmissionMode +import com.flagship.abox.manager.domain.infrared.VirtualRemote +import com.flagship.abox.manager.domain.infrared.VirtualRemoteId +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class InfraredHubScreenTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun rendersHardwareSummaryAndRemoteCollection() { + val profileId = ProviderProfileId("p1") + val remote = VirtualRemote( + id = VirtualRemoteId("remote-1"), + profileId = profileId, + deviceName = DeviceName("ir-1"), + displayName = "电视遥控器", + type = RemoteDeviceType.TV, + transmissionMode = TransmissionMode.STATIC_SLOTS, + createdAtEpochMillis = 1L, + updatedAtEpochMillis = 1L, + ) + + composeRule.setContent { + InfraredHubScreen( + state = InfraredHubUiState( + profileId = profileId, + savedDeviceName = DeviceName("ir-1"), + remotes = listOf(remote), + loading = false, + ), + onBack = {}, + onNavigateToSettings = {}, + onOpenRemote = {}, + onDeleteRemote = {}, + onCreateRemote = { _, _, _, _, _ -> }, + onSessionInvalid = {}, + onMessageShown = {}, + ) + } + + composeRule.onNodeWithTag("infrared_hardware_summary").assertIsDisplayed() + composeRule.onNodeWithTag("infrared_remote_collection").assertIsDisplayed() + } +} diff --git a/android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelScreenTest.kt b/android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelScreenTest.kt new file mode 100644 index 0000000..a264766 --- /dev/null +++ b/android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelScreenTest.kt @@ -0,0 +1,66 @@ +package com.flagship.abox.manager.infrared.panel + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.infrared.PanelLayoutStyle +import com.flagship.abox.manager.domain.infrared.RemoteDeviceType +import com.flagship.abox.manager.domain.infrared.TransmissionMode +import com.flagship.abox.manager.domain.infrared.VirtualRemote +import com.flagship.abox.manager.domain.infrared.VirtualRemoteId +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class RemotePanelScreenTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun acPanelUsesTemperatureStepperSurface() { + composeRule.setContent { + RemotePanelScreen( + state = RemotePanelUiState(remote = remote(PanelLayoutStyle.AC_COMPACT, RemoteDeviceType.AIR_CONDITIONER), loading = false), + onBack = {}, onSessionInvalid = {}, onButtonClick = {}, onToggleLearnMode = {}, + onAddCustomButton = {}, onDeleteButton = {}, onToggleAcPower = {}, onAdjustAcTemp = {}, + onSetAcMode = {}, onSetFanSpeed = {}, onToggleAcVerticalSwing = {}, + onToggleAcHorizontalSwing = {}, onToggleAcEco = {}, onMessageShown = {}, + ) + } + + composeRule.onNodeWithTag("ac_temperature_stepper").assertIsDisplayed() + composeRule.onNodeWithTag("ac_mode_selector").assertIsDisplayed() + } + + @Test + fun tvPanelUsesDirectionalPadAndRockers() { + composeRule.setContent { + RemotePanelScreen( + state = RemotePanelUiState(remote = remote(PanelLayoutStyle.TV_NAV, RemoteDeviceType.TV), loading = false), + onBack = {}, onSessionInvalid = {}, onButtonClick = {}, onToggleLearnMode = {}, + onAddCustomButton = {}, onDeleteButton = {}, onToggleAcPower = {}, onAdjustAcTemp = {}, + onSetAcMode = {}, onSetFanSpeed = {}, onToggleAcVerticalSwing = {}, + onToggleAcHorizontalSwing = {}, onToggleAcEco = {}, onMessageShown = {}, + ) + } + + composeRule.onNodeWithTag("tv_direction_pad").assertIsDisplayed() + composeRule.onNodeWithTag("tv_volume_channel_rockers").assertIsDisplayed() + } + + private fun remote(layout: PanelLayoutStyle, type: RemoteDeviceType) = VirtualRemote( + id = VirtualRemoteId("remote-${layout.name}"), + profileId = ProviderProfileId("p1"), + deviceName = DeviceName("ir-1"), + displayName = "测试遥控器", + type = type, + layoutStyle = layout, + transmissionMode = if (layout == PanelLayoutStyle.AC_COMPACT) TransmissionMode.DYNAMIC_SCRATCH else TransmissionMode.STATIC_SLOTS, + createdAtEpochMillis = 1L, + updatedAtEpochMillis = 1L, + ) +} diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/RoomVirtualRemoteRepository.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/RoomVirtualRemoteRepository.kt index 1a40b17..fafb030 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/RoomVirtualRemoteRepository.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/RoomVirtualRemoteRepository.kt @@ -211,11 +211,26 @@ private fun RemoteKeyBindingEntity.toDomain(): RemoteKeyBinding = RemoteKeyBindi displayName = displayName, assignedSlot = assignedSlot, rawHexCode = rawHexCode, - status = runCatching { InfraredKeyStatus.valueOf(status) }.getOrDefault(InfraredKeyStatus.READY), + status = resolvedStatus(), lastErrorCode = lastErrorCode, lastErrorDetail = lastErrorDetail, ) +private fun RemoteKeyBindingEntity.resolvedStatus(): InfraredKeyStatus { + val parsed = runCatching { InfraredKeyStatus.valueOf(status) }.getOrDefault(InfraredKeyStatus.UNLEARNED) + return if ( + parsed == InfraredKeyStatus.FAILED && + lastErrorCode == null && + lastErrorDetail == null && + assignedSlot == null && + rawHexCode == null + ) { + InfraredKeyStatus.UNLEARNED + } else { + parsed + } +} + private fun RemoteKeyBinding.toEntity(): RemoteKeyBindingEntity = RemoteKeyBindingEntity( id = id, remoteId = remoteId.value, diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredCenterControls.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredCenterControls.kt new file mode 100644 index 0000000..a41f19f --- /dev/null +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredCenterControls.kt @@ -0,0 +1,414 @@ +package com.flagship.abox.manager.infrared.hub + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.KeyboardArrowLeft +import androidx.compose.material.icons.automirrored.outlined.KeyboardArrowRight +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.Home +import androidx.compose.material.icons.outlined.KeyboardArrowDown +import androidx.compose.material.icons.outlined.KeyboardArrowUp +import androidx.compose.material.icons.outlined.Menu +import androidx.compose.material.icons.outlined.PowerSettingsNew +import androidx.compose.material.icons.outlined.Remove +import androidx.compose.material.icons.outlined.Tv +import androidx.compose.material.icons.outlined.VolumeOff +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.res.stringResource +import com.flagship.abox.manager.infrared.R +import com.flagship.abox.manager.domain.infrared.AcFanSpeed +import com.flagship.abox.manager.domain.infrared.AcMode +import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus +import com.flagship.abox.manager.domain.infrared.PanelLayoutStyle +import com.flagship.abox.manager.domain.infrared.RemoteKeyBinding +import com.flagship.abox.manager.domain.infrared.VirtualRemote + +@Composable +internal fun InfraredCenterControlSurface( + state: InfraredHubUiState, + onButtonClick: (String) -> Unit, + onAddCustomButton: (String) -> Unit, + onDeleteButton: (String) -> Unit, + onToggleAcPower: () -> Unit, + onAdjustAcTemp: (Int) -> Unit, + onSetAcMode: (AcMode) -> Unit, + onSetFanSpeed: (AcFanSpeed) -> Unit, + onToggleAcVerticalSwing: () -> Unit, + onToggleAcHorizontalSwing: () -> Unit, + onToggleAcEco: () -> Unit, + modifier: Modifier = Modifier, +) { + val remote = state.selectedRemote ?: return + when (remote.layoutStyle) { + PanelLayoutStyle.AC_COMPACT -> CenterAcPanel( + state = state, + onTogglePower = onToggleAcPower, + onAdjustTemp = onAdjustAcTemp, + onSetMode = onSetAcMode, + onSetFanSpeed = onSetFanSpeed, + onToggleVerticalSwing = onToggleAcVerticalSwing, + onToggleHorizontalSwing = onToggleAcHorizontalSwing, + onToggleEco = onToggleAcEco, + modifier = modifier, + ) + PanelLayoutStyle.TV_NAV -> CenterTvPanel(state, onButtonClick, modifier) + PanelLayoutStyle.MEDIA_PLAYER -> CenterMediaPanel(state, onButtonClick, modifier) + PanelLayoutStyle.GRID -> CenterGridPanel( + remote = remote, + bindings = state.bindings, + learningButtonId = state.learningButtonId, + sendingButtonId = state.sendingButtonId, + onButtonClick = onButtonClick, + onAddButton = onAddCustomButton, + onDeleteButton = onDeleteButton, + modifier = modifier, + ) + } +} + +@Composable +private fun CenterAcPanel( + state: InfraredHubUiState, + onTogglePower: () -> Unit, + onAdjustTemp: (Int) -> Unit, + onSetMode: (AcMode) -> Unit, + onSetFanSpeed: (AcFanSpeed) -> Unit, + onToggleVerticalSwing: () -> Unit, + onToggleHorizontalSwing: () -> Unit, + onToggleEco: () -> Unit, + modifier: Modifier, +) { + val ac = state.acState + Column( + modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Surface( + shape = RoundedCornerShape(6.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier.fillMaxWidth(), + ) { + Box(Modifier.fillMaxWidth().padding(20.dp)) { + IconButton( + onClick = onTogglePower, + modifier = Modifier.align(Alignment.TopEnd).background( + if (ac.power) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline, + CircleShape, + ), + ) { + Icon(Icons.Outlined.PowerSettingsNew, "空调电源", tint = Color.White) + } + Row( + Modifier.fillMaxWidth().padding(top = 38.dp), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, + ) { + StepButton(Icons.Outlined.Remove, "降低温度") { onAdjustTemp(-1) } + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("${ac.temperature}°", style = MaterialTheme.typography.displayMedium, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.infrared_set_temperature), style = MaterialTheme.typography.labelMedium) + } + StepButton(Icons.Outlined.Add, "升高温度") { onAdjustTemp(1) } + } + } + } + CompactOptionGroup(stringResource(R.string.infrared_running_mode)) { + AcMode.entries.forEach { mode -> + FilterChip(ac.mode == mode, { onSetMode(mode) }, { Text(mode.centerLabel()) }) + } + } + CompactOptionGroup(stringResource(R.string.infrared_fan_speed)) { + AcFanSpeed.entries.forEach { speed -> + FilterChip(ac.fanSpeed == speed, { onSetFanSpeed(speed) }, { Text(speed.centerLabel()) }) + } + } + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + CompactToggle(stringResource(R.string.infrared_vertical_swing), ac.verticalSwing, onToggleVerticalSwing, Modifier.weight(1f)) + CompactToggle(stringResource(R.string.infrared_horizontal_swing), ac.horizontalSwing, onToggleHorizontalSwing, Modifier.weight(1f)) + CompactToggle(stringResource(R.string.infrared_eco), ac.eco, onToggleEco, Modifier.weight(1f)) + } + Text( + stringResource(R.string.infrared_transmission_notice), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun CenterTvPanel(state: InfraredHubUiState, onButtonClick: (String) -> Unit, modifier: Modifier) { + Column(modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) { + RemoteIconKey("TV_POWER", stringResource(R.string.infrared_key_tv_power), Icons.Outlined.PowerSettingsNew, state, onButtonClick) + RemoteIconKey("TV_MUTE", stringResource(R.string.infrared_key_tv_mute), Icons.Outlined.VolumeOff, state, onButtonClick) + RemoteIconKey("TV_SOURCE", stringResource(R.string.infrared_key_tv_menu), Icons.Outlined.Tv, state, onButtonClick) + } + Card( + shape = RoundedCornerShape(6.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + modifier = Modifier.fillMaxWidth(), + ) { + Box(Modifier.fillMaxWidth().height(260.dp), contentAlignment = Alignment.Center) { + Surface(shape = CircleShape, color = MaterialTheme.colorScheme.surface, modifier = Modifier.size(220.dp)) { + Box(Modifier.size(220.dp), contentAlignment = Alignment.Center) { + DirectionKey("TV_NAV_UP", Icons.Outlined.KeyboardArrowUp, stringResource(R.string.infrared_up), Alignment.TopCenter, state, onButtonClick) + DirectionKey("TV_NAV_DOWN", Icons.Outlined.KeyboardArrowDown, stringResource(R.string.infrared_down), Alignment.BottomCenter, state, onButtonClick) + DirectionKey("TV_NAV_LEFT", Icons.AutoMirrored.Outlined.KeyboardArrowLeft, stringResource(R.string.infrared_left), Alignment.CenterStart, state, onButtonClick) + DirectionKey("TV_NAV_RIGHT", Icons.AutoMirrored.Outlined.KeyboardArrowRight, stringResource(R.string.infrared_right), Alignment.CenterEnd, state, onButtonClick) + Button(onClick = { onButtonClick("TV_OK") }, shape = CircleShape, modifier = Modifier.size(72.dp)) { Text("OK") } + } + } + } + } + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + RemoteTextKey("TV_BACK", stringResource(R.string.infrared_key_tv_back), state, onButtonClick, Modifier.weight(1f)) + RemoteIconTextKey("TV_HOME", stringResource(R.string.infrared_key_tv_home), Icons.Outlined.Home, state, onButtonClick, Modifier.weight(1f)) + RemoteIconTextKey("TV_MENU", stringResource(R.string.infrared_key_tv_menu), Icons.Outlined.Menu, state, onButtonClick, Modifier.weight(1f)) + } + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Rocker(stringResource(R.string.infrared_volume), "TV_VOL_UP", "TV_VOL_DOWN", state, onButtonClick, Modifier.weight(1f)) + Rocker(stringResource(R.string.infrared_channel), "TV_CH_UP", "TV_CH_DOWN", state, onButtonClick, Modifier.weight(1f)) + } + } +} + +@Composable +private fun CenterMediaPanel(state: InfraredHubUiState, onButtonClick: (String) -> Unit, modifier: Modifier) { + Column(modifier.padding(top = 12.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) { + RemoteIconKey("MEDIA_POWER", stringResource(R.string.infrared_key_ac_power), Icons.Outlined.PowerSettingsNew, state, onButtonClick) + RemoteIconKey("MEDIA_MUTE", stringResource(R.string.infrared_key_tv_mute), Icons.Outlined.VolumeOff, state, onButtonClick) + } + Text(stringResource(R.string.infrared_media_panel), style = MaterialTheme.typography.titleMedium, modifier = Modifier.align(Alignment.CenterHorizontally)) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + listOf("MEDIA_PREV" to "上一项", "MEDIA_PLAY_PAUSE" to "播放/暂停", "MEDIA_NEXT" to "下一项").forEach { (id, label) -> + RemoteTextKey(id, label, state, onButtonClick, Modifier.weight(1f)) + } + } + } +} + +@Composable +private fun CenterGridPanel( + remote: VirtualRemote, + bindings: List, + learningButtonId: String?, + sendingButtonId: String?, + onButtonClick: (String) -> Unit, + onAddButton: (String) -> Unit, + onDeleteButton: (String) -> Unit, + modifier: Modifier, +) { + var showAddDialog by remember(remote.id) { mutableStateOf(false) } + Column(modifier, verticalArrangement = Arrangement.spacedBy(10.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + stringResource(R.string.infrared_remote_keys, remote.displayName), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f), + ) + Button(onClick = { showAddDialog = true }, shape = RoundedCornerShape(6.dp)) { + Icon(Icons.Outlined.Add, contentDescription = null, modifier = Modifier.size(18.dp)) + Text(stringResource(R.string.infrared_add_button), modifier = Modifier.padding(start = 6.dp)) + } + } + if (bindings.isEmpty()) { + Text(stringResource(R.string.infrared_no_buttons_manage), color = MaterialTheme.colorScheme.onSurfaceVariant) + } else { + LazyVerticalGrid(columns = GridCells.Fixed(2), verticalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + items(bindings, key = { it.id }) { binding -> + Card( + modifier = Modifier.fillMaxWidth().clickable { onButtonClick(binding.buttonId) }, + shape = RoundedCornerShape(6.dp), + ) { + Row(Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text( + binding.displayName, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + when { + learningButtonId == binding.buttonId -> stringResource(R.string.infrared_button_learning) + sendingButtonId == binding.buttonId -> stringResource(R.string.infrared_button_sending) + binding.status == InfraredKeyStatus.READY -> stringResource(R.string.infrared_button_ready) + binding.status == InfraredKeyStatus.FAILED -> stringResource(R.string.infrared_button_failed) + binding.status == InfraredKeyStatus.UNLEARNED -> stringResource(R.string.infrared_button_unlearned) + else -> stringResource(R.string.infrared_button_unlearned) + }, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + IconButton( + onClick = { onDeleteButton(binding.buttonId) }, + modifier = Modifier.size(48.dp), + ) { + Icon(Icons.Outlined.Remove, contentDescription = stringResource(R.string.infrared_delete_button)) + } + } + } + } + } + } + } + if (showAddDialog) { + AddGridButtonDialog( + onDismiss = { showAddDialog = false }, + onConfirm = { name -> + onAddButton(name) + showAddDialog = false + }, + ) + } +} + +@Composable +private fun AddGridButtonDialog( + onDismiss: () -> Unit, + onConfirm: (String) -> Unit, +) { + var name by remember { mutableStateOf("") } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.infrared_add_button_title)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text( + stringResource(R.string.infrared_add_button_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text(stringResource(R.string.infrared_button_name)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + }, + confirmButton = { + Button(onClick = { onConfirm(name.trim()) }, enabled = name.isNotBlank()) { + Text(stringResource(R.string.infrared_add_button)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.infrared_dialog_cancel)) + } + }, + ) +} + +@Composable private fun StepButton(icon: ImageVector, description: String, onClick: () -> Unit) { + IconButton(onClick = onClick, modifier = Modifier.size(52.dp).background(MaterialTheme.colorScheme.surface, RoundedCornerShape(6.dp))) { + Icon(icon, description) + } +} + +@Composable private fun CompactOptionGroup(title: String, content: @Composable androidx.compose.foundation.layout.RowScope.() -> Unit) { + Card(shape = RoundedCornerShape(6.dp), modifier = Modifier.fillMaxWidth()) { + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text(title, style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp), content = content) + } + } +} + +@Composable private fun CompactToggle(label: String, selected: Boolean, onClick: () -> Unit, modifier: Modifier) { + Surface( + modifier = modifier.clickable(onClick = onClick), + shape = RoundedCornerShape(6.dp), + color = if (selected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant, + ) { Text(label, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(horizontal = 8.dp, vertical = 14.dp)) } +} + +@Composable private fun RemoteIconKey(id: String, label: String, icon: ImageVector, state: InfraredHubUiState, onClick: (String) -> Unit) { + Surface(shape = RoundedCornerShape(6.dp), color = MaterialTheme.colorScheme.surfaceVariant, modifier = Modifier.size(84.dp).clickable { onClick(id) }) { + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) { + if (state.sendingButtonId == id || state.learningButtonId == id) CircularProgressIndicator(Modifier.size(20.dp)) else Icon(icon, label) + Text(label, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(top = 6.dp)) + } + } +} + +@Composable private fun BoxScope.DirectionKey(id: String, icon: ImageVector, description: String, alignment: Alignment, state: InfraredHubUiState, onClick: (String) -> Unit) { + IconButton(onClick = { onClick(id) }, modifier = Modifier.align(alignment).padding(10.dp)) { + if (state.sendingButtonId == id || state.learningButtonId == id) CircularProgressIndicator(Modifier.size(18.dp)) else Icon(icon, description, Modifier.size(30.dp)) + } +} + +@Composable private fun RemoteTextKey(id: String, label: String, state: InfraredHubUiState, onClick: (String) -> Unit, modifier: Modifier) { + Button(onClick = { onClick(id) }, modifier = modifier.height(52.dp), shape = RoundedCornerShape(6.dp)) { + if (state.sendingButtonId == id || state.learningButtonId == id) CircularProgressIndicator(Modifier.size(18.dp)) else Text(label) + } +} + +@Composable private fun RemoteIconTextKey(id: String, label: String, icon: ImageVector, state: InfraredHubUiState, onClick: (String) -> Unit, modifier: Modifier) { + Button(onClick = { onClick(id) }, modifier = modifier.height(52.dp), shape = RoundedCornerShape(6.dp)) { + Icon(icon, label, Modifier.size(18.dp)); Text(label, Modifier.padding(start = 5.dp)) + } +} + +@Composable private fun Rocker(title: String, upId: String, downId: String, state: InfraredHubUiState, onClick: (String) -> Unit, modifier: Modifier) { + Card(modifier, shape = RoundedCornerShape(6.dp)) { + Column(Modifier.padding(10.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text(title, style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold) + IconButton({ onClick(upId) }, Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(4.dp))) { Icon(Icons.Outlined.Add, "提高$title") } + IconButton({ onClick(downId) }, Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(4.dp))) { Icon(Icons.Outlined.Remove, "降低$title") } + } + } +} + +private fun AcMode.centerLabel() = when (this) { AcMode.COOL -> "制冷"; AcMode.HEAT -> "制热"; AcMode.AUTO -> "自动"; AcMode.DRY -> "除湿"; AcMode.FAN -> "送风" } +private fun AcFanSpeed.centerLabel() = when (this) { AcFanSpeed.AUTO -> "自动"; AcFanSpeed.LOW -> "低速"; AcFanSpeed.MEDIUM -> "中速"; AcFanSpeed.HIGH -> "高速" } diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubScreen.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubScreen.kt index b8bb0a7..ef9d92f 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubScreen.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubScreen.kt @@ -2,6 +2,8 @@ package com.flagship.abox.manager.infrared.hub import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -16,6 +18,10 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.AlertDialog @@ -24,6 +30,8 @@ import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.Icon import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuDefaults @@ -40,6 +48,9 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.SwapVert +import androidx.activity.compose.BackHandler import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -50,7 +61,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -60,6 +72,8 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flagship.abox.manager.designsystem.ABoxSurface import com.flagship.abox.manager.designsystem.ABoxIconButton +import com.flagship.abox.manager.designsystem.ABoxDeviceDetailScaffold +import com.flagship.abox.manager.designsystem.icon.ABoxIcon import com.flagship.abox.manager.designsystem.icon.ABoxIconKey import com.flagship.abox.manager.domain.ProviderProfileId import com.flagship.abox.manager.domain.infrared.PanelLayoutStyle @@ -71,6 +85,8 @@ import com.flagship.abox.manager.domain.infrared.VirtualRemote import com.flagship.abox.manager.domain.infrared.VirtualRemoteId import com.flagship.abox.manager.infrared.R import com.flagship.abox.manager.infrared.remote.UiText +import com.flagship.abox.manager.domain.infrared.AcFanSpeed +import com.flagship.abox.manager.domain.infrared.AcMode @Composable fun InfraredHubRoute( @@ -89,6 +105,21 @@ fun InfraredHubRoute( onOpenRemote = onOpenRemote, onDeleteRemote = viewModel::deleteRemote, onCreateRemote = viewModel::createRemote, + onSaveDeviceName = viewModel::saveDeviceName, + onMoveRemote = viewModel::moveRemote, + onUpdateRemote = viewModel::updateRemote, + onSelectRemote = viewModel::selectRemote, + onToggleLearnMode = viewModel::toggleLearnMode, + onButtonClick = viewModel::onButtonClick, + onAddCustomButton = viewModel::addCustomButton, + onDeleteButton = viewModel::deleteButton, + onToggleAcPower = viewModel::toggleAcPower, + onAdjustAcTemp = viewModel::adjustAcTemperature, + onSetAcMode = viewModel::setAcMode, + onSetFanSpeed = viewModel::setAcFanSpeed, + onToggleAcVerticalSwing = viewModel::toggleAcVerticalSwing, + onToggleAcHorizontalSwing = viewModel::toggleAcHorizontalSwing, + onToggleAcEco = viewModel::toggleAcEco, onSessionInvalid = onSessionInvalid, onMessageShown = viewModel::clearMessage, ) @@ -103,12 +134,30 @@ internal fun InfraredHubScreen( onOpenRemote: (String) -> Unit, onDeleteRemote: (VirtualRemoteId) -> Unit, onCreateRemote: (String, RemoteDeviceType, String?, TransmissionMode, PanelLayoutStyle?) -> Unit, + onSaveDeviceName: (String) -> Unit = {}, + onMoveRemote: (VirtualRemoteId, Int) -> Unit = { _, _ -> }, + onUpdateRemote: (VirtualRemoteId, String, PanelLayoutStyle, TransmissionMode) -> Unit = { _, _, _, _ -> }, onSessionInvalid: () -> Unit, onMessageShown: () -> Unit, + onSelectRemote: (VirtualRemoteId) -> Unit = {}, + onToggleLearnMode: () -> Unit = {}, + onButtonClick: (String) -> Unit = {}, + onAddCustomButton: (String) -> Unit = {}, + onDeleteButton: (String) -> Unit = {}, + onToggleAcPower: () -> Unit = {}, + onAdjustAcTemp: (Int) -> Unit = {}, + onSetAcMode: (AcMode) -> Unit = {}, + onSetFanSpeed: (AcFanSpeed) -> Unit = {}, + onToggleAcVerticalSwing: () -> Unit = {}, + onToggleAcHorizontalSwing: () -> Unit = {}, + onToggleAcEco: () -> Unit = {}, ) { val snackbarHostState = remember { SnackbarHostState() } - val context = LocalContext.current + val resources = LocalResources.current var showCreateDialog by remember { mutableStateOf(false) } + var showManagement by remember { mutableStateOf(false) } + + BackHandler(enabled = showManagement) { showManagement = false } LaunchedEffect(state.message) { state.message?.let { msg -> @@ -116,7 +165,7 @@ internal fun InfraredHubScreen( is HubUiMessage.Info -> { val text = when (val t = msg.text) { is UiText.DynamicString -> t.value - is UiText.StringResource -> context.getString(t.resId, *t.args.toTypedArray()) + is UiText.StringResource -> resources.getString(t.resId, *t.args.toTypedArray()) } snackbarHostState.showSnackbar(text) onMessageShown() @@ -124,7 +173,7 @@ internal fun InfraredHubScreen( is HubUiMessage.Error -> { val text = when (val t = msg.text) { is UiText.DynamicString -> t.value - is UiText.StringResource -> context.getString(t.resId, *t.args.toTypedArray()) + is UiText.StringResource -> resources.getString(t.resId, *t.args.toTypedArray()) } snackbarHostState.showSnackbar(text) onMessageShown() @@ -137,99 +186,114 @@ internal fun InfraredHubScreen( } } - Scaffold( - topBar = { - TopAppBar( - title = { - Column { - Text( - text = stringResource(R.string.infrared_hub_title), - fontWeight = FontWeight.Bold, - ) - state.providerDisplayName?.let { - Text( - text = it, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - }, - navigationIcon = { - ABoxIconButton(ABoxIconKey.BACK, "返回", onBack) - }, - actions = { - ABoxIconButton( - key = ABoxIconKey.EDIT, - contentDescription = "打开红外设置", - onClick = onNavigateToSettings, - modifier = Modifier.testTag("hub_settings_button"), - ) - }, - ) + ABoxDeviceDetailScaffold( + title = state.savedDeviceName.value, + subtitle = if (showManagement) stringResource(R.string.infrared_settings_subtitle) else stringResource(R.string.infrared_center_subtitle), + icon = ABoxIconKey.INFRARED, + onBack = if (showManagement) ({ showManagement = false }) else onBack, + action = { + if (!showManagement) { + ABoxIconButton( + key = ABoxIconKey.EDIT, + contentDescription = stringResource(R.string.infrared_manage_action), + onClick = { showManagement = true }, + modifier = Modifier.testTag("hub_settings_button"), + ) + } }, - snackbarHost = { SnackbarHost(snackbarHostState) }, ) { innerPadding -> ABoxSurface( modifier = Modifier .fillMaxSize() .padding(innerPadding), ) { - Column( + if (showManagement) { + InfraredManagementScreen( + state = state, + onAddRemote = { showCreateDialog = true }, + onDeleteRemote = onDeleteRemote, + onSaveDeviceName = onSaveDeviceName, + onMoveRemote = onMoveRemote, + onUpdateRemote = onUpdateRemote, + ) + } else Column( modifier = Modifier .fillMaxSize() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), + .padding(horizontal = 16.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), ) { - // 1. 硬件槽位配额看板 (100 槽位预算) - QuotaSummaryCard(quota = state.slotQuota) - - // 2. 遥控器列表标题与新建按钮 - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringResource(R.string.infrared_hub_my_remotes), - style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), - ) - - Button( - onClick = { showCreateDialog = true }, - modifier = Modifier.testTag("hub_add_remote_button"), - ) { - Text(stringResource(R.string.infrared_hub_add_remote)) - } - } - - // 3. 遥控器网格卡片 if (state.remotes.isEmpty()) { Box( modifier = Modifier.fillMaxWidth().weight(1f), contentAlignment = Alignment.Center, ) { - Text( - text = stringResource(R.string.infrared_hub_no_remotes), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp)) { + ABoxIcon(ABoxIconKey.INFRARED, stringResource(R.string.infrared_hub_no_remotes), modifier = Modifier.size(48.dp)) + Text(stringResource(R.string.infrared_hub_no_remotes), style = MaterialTheme.typography.titleMedium) + Text(stringResource(R.string.infrared_no_remotes_hint), color = MaterialTheme.colorScheme.onSurfaceVariant) + Button(onClick = { showManagement = true }) { Text(stringResource(R.string.infrared_open_management)) } + } } } else { - LazyVerticalGrid( - columns = GridCells.Fixed(2), - modifier = Modifier.fillMaxWidth().weight(1f), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), + LazyRow( + modifier = Modifier.fillMaxWidth().testTag("infrared_remote_collection"), + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { items(state.remotes, key = { it.id.value }) { remote -> - VirtualRemoteCard( - remote = remote, - onClick = { onOpenRemote(remote.id.value) }, - onDelete = { onDeleteRemote(remote.id) }, + FilterChip( + selected = state.selectedRemoteId == remote.id, + onClick = { onSelectRemote(remote.id) }, + label = { Text(remote.displayName) }, + leadingIcon = { + ABoxIcon( + key = remoteIcon(remote.type), + contentDescription = remote.displayName, + modifier = Modifier.size(18.dp), + ) + }, ) } } + + state.selectedRemote?.let { remote -> + Card(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column { + Text(remote.displayName, fontWeight = FontWeight.Bold) + Text( + text = "${remote.brandModel ?: stringResource(R.string.infrared_custom)} · ${transmissionLabel(remote.transmissionMode)} · ${layoutLabel(remote.layoutStyle)}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (remote.type == RemoteDeviceType.CUSTOM) { + FilterChip( + selected = state.isLearnMode, + onClick = onToggleLearnMode, + label = { Text(if (state.isLearnMode) stringResource(R.string.infrared_recording) else stringResource(R.string.infrared_learn_mode)) }, + ) + } + } + } + InfraredCenterControlSurface( + state = state, + onButtonClick = onButtonClick, + onAddCustomButton = onAddCustomButton, + onDeleteButton = onDeleteButton, + onToggleAcPower = onToggleAcPower, + onAdjustAcTemp = onAdjustAcTemp, + onSetAcMode = onSetAcMode, + onSetFanSpeed = onSetFanSpeed, + onToggleAcVerticalSwing = onToggleAcVerticalSwing, + onToggleAcHorizontalSwing = onToggleAcHorizontalSwing, + onToggleAcEco = onToggleAcEco, + modifier = Modifier.fillMaxWidth().weight(1f), + ) + } } } } @@ -244,6 +308,226 @@ internal fun InfraredHubScreen( }, ) } + + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier.fillMaxSize().padding(bottom = 16.dp), + ) +} + +@Composable +private fun StableSingleLineField( + value: String, + onValueChange: (String) -> Unit, + label: String, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier, + shape = RoundedCornerShape(6.dp), + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), + ) { + Column( + modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + BasicTextField( + value = value, + onValueChange = onValueChange, + singleLine = true, + textStyle = MaterialTheme.typography.bodyLarge.copy( + color = MaterialTheme.colorScheme.onSurface, + ), + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + modifier = Modifier.fillMaxWidth(), + ) + } + } +} + +@Composable +private fun InfraredManagementScreen( + state: InfraredHubUiState, + onAddRemote: () -> Unit, + onDeleteRemote: (VirtualRemoteId) -> Unit, + onSaveDeviceName: (String) -> Unit, + onMoveRemote: (VirtualRemoteId, Int) -> Unit, + onUpdateRemote: (VirtualRemoteId, String, PanelLayoutStyle, TransmissionMode) -> Unit, +) { + var deviceName by remember(state.savedDeviceName.value) { mutableStateOf(state.savedDeviceName.value) } + var reorderRemoteId by remember { mutableStateOf(null) } + var editingRemote by remember { mutableStateOf(null) } + Column( + modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp), + verticalArrangement = Arrangement.spacedBy(18.dp), + ) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(stringResource(R.string.infrared_hardware_slots_title), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + InfraredHardwareSummary( + deviceName = state.savedDeviceName.value, + remoteCount = state.remotes.size, + quota = state.slotQuota, + ) + } + + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text(stringResource(R.string.infrared_hub_remotes_title), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Text( + stringResource(R.string.infrared_remote_count, state.remotes.size), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Button(onClick = onAddRemote) { Text(stringResource(R.string.infrared_add_remote)) } + } + + state.remotes.forEach { remote -> + Card(Modifier.fillMaxWidth(), shape = RoundedCornerShape(6.dp)) { + Row(Modifier.fillMaxWidth().padding(14.dp), verticalAlignment = Alignment.CenterVertically) { + Surface(shape = RoundedCornerShape(6.dp), color = MaterialTheme.colorScheme.primaryContainer) { + ABoxIcon( + remoteIcon(remote.type), + remote.displayName, + modifier = Modifier.padding(9.dp).size(24.dp), + ) + } + Column(Modifier.weight(1f).padding(start = 12.dp)) { + Text(remote.displayName, fontWeight = FontWeight.Bold) + Text( + "${remote.brandModel ?: stringResource(R.string.infrared_custom)} · ${transmissionLabel(remote.transmissionMode)} · ${layoutLabel(remote.layoutStyle)}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Box { + IconButton(onClick = { reorderRemoteId = remote.id }) { + Icon(Icons.Outlined.SwapVert, contentDescription = stringResource(R.string.infrared_move_remote)) + } + DropdownMenu( + expanded = reorderRemoteId == remote.id, + onDismissRequest = { reorderRemoteId = null }, + ) { + val index = state.remotes.indexOf(remote) + DropdownMenuItem( + text = { Text(stringResource(R.string.infrared_move_up)) }, + enabled = index > 0, + onClick = { reorderRemoteId = null; onMoveRemote(remote.id, -1) }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.infrared_move_down)) }, + enabled = index < state.remotes.lastIndex, + onClick = { reorderRemoteId = null; onMoveRemote(remote.id, 1) }, + ) + } + } + ABoxIconButton(ABoxIconKey.EDIT, stringResource(R.string.infrared_edit_remote), { editingRemote = remote }) + } + } + } + + Card(Modifier.fillMaxWidth(), shape = RoundedCornerShape(6.dp)) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text(stringResource(R.string.infrared_gateway_name_title), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Text( + stringResource(R.string.infrared_gateway_name_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + StableSingleLineField( + value = deviceName, + onValueChange = { deviceName = it }, + label = stringResource(R.string.infrared_device_name_label), + modifier = Modifier.fillMaxWidth(), + ) + Button( + onClick = { onSaveDeviceName(deviceName.trim()) }, + enabled = deviceName.isNotBlank() && deviceName != state.savedDeviceName.value, + modifier = Modifier.align(Alignment.End), + ) { Text(stringResource(R.string.infrared_save_name)) } + } + } + Spacer(Modifier.height(24.dp)) + } + editingRemote?.let { remote -> + EditRemoteDialog( + remote = remote, + onDismiss = { editingRemote = null }, + onSave = { name, layout, mode -> + editingRemote = null + onUpdateRemote(remote.id, name, layout, mode) + }, + onDelete = { + editingRemote = null + onDeleteRemote(remote.id) + }, + ) + } +} + +@Composable +private fun EditRemoteDialog( + remote: VirtualRemote, + onDismiss: () -> Unit, + onSave: (String, PanelLayoutStyle, TransmissionMode) -> Unit, + onDelete: () -> Unit, +) { + var name by remember(remote.id) { mutableStateOf(remote.displayName) } + var layout by remember(remote.id) { mutableStateOf(remote.layoutStyle) } + var mode by remember(remote.id) { mutableStateOf(remote.transmissionMode) } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.infrared_edit_remote)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + OutlinedTextField(value = name, onValueChange = { name = it }, label = { Text(stringResource(R.string.infrared_remote_name)) }, singleLine = true) + Text(stringResource(R.string.infrared_layout), fontWeight = FontWeight.Bold) + PanelLayoutStyle.entries.forEach { option -> + Row(Modifier.fillMaxWidth().clickable { layout = option }, verticalAlignment = Alignment.CenterVertically) { + RadioButton(selected = layout == option, onClick = { layout = option }) + Text(layoutLabel(option)) + } + } + Text(stringResource(R.string.infrared_transmission_mode), fontWeight = FontWeight.Bold) + TransmissionMode.entries.forEach { option -> + Row(Modifier.fillMaxWidth().clickable { mode = option }, verticalAlignment = Alignment.CenterVertically) { + RadioButton(selected = mode == option, onClick = { mode = option }) + Text(transmissionLabel(option)) + } + } + TextButton(onClick = onDelete) { Text(stringResource(R.string.infrared_delete_remote), color = MaterialTheme.colorScheme.error) } + } + }, + confirmButton = { Button(onClick = { onSave(name.trim(), layout, mode) }, enabled = name.isNotBlank()) { Text(stringResource(R.string.infrared_device_save_changes)) } }, + dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.infrared_dialog_cancel)) } }, + ) +} + +private fun remoteIcon(type: RemoteDeviceType): ABoxIconKey = when (type) { + RemoteDeviceType.TV -> ABoxIconKey.DEVICES + RemoteDeviceType.AIR_CONDITIONER -> ABoxIconKey.TEMPERATURE + RemoteDeviceType.FAN -> ABoxIconKey.REFRESH + RemoteDeviceType.CUSTOM -> ABoxIconKey.INFRARED +} + +@Composable +private fun layoutLabel(layout: PanelLayoutStyle): String = when (layout) { + PanelLayoutStyle.TV_NAV -> stringResource(R.string.infrared_layout_tv) + PanelLayoutStyle.AC_COMPACT -> stringResource(R.string.infrared_layout_ac) + PanelLayoutStyle.MEDIA_PLAYER -> stringResource(R.string.infrared_layout_media) + PanelLayoutStyle.GRID -> stringResource(R.string.infrared_layout_grid) +} + +@Composable +private fun transmissionLabel(mode: TransmissionMode): String = when (mode) { + TransmissionMode.STATIC_SLOTS -> stringResource(R.string.infrared_static_slots) + TransmissionMode.DYNAMIC_SCRATCH -> stringResource(R.string.infrared_dynamic_codes) } @Composable @@ -271,15 +555,18 @@ private fun VirtualRemoteCard( verticalAlignment = Alignment.CenterVertically, ) { val icon = when (remote.type) { - RemoteDeviceType.TV -> "电视" - RemoteDeviceType.AIR_CONDITIONER -> "空调" - RemoteDeviceType.FAN -> "🌀" - RemoteDeviceType.CUSTOM -> "自定义" - } - Text(icon, fontSize = 28.sp) - IconButton(onClick = onDelete, modifier = Modifier.testTag("delete_remote_${remote.id.value}")) { - Text(stringResource(R.string.infrared_delete)) + RemoteDeviceType.TV -> ABoxIconKey.DEVICES + RemoteDeviceType.AIR_CONDITIONER -> ABoxIconKey.TEMPERATURE + RemoteDeviceType.FAN -> ABoxIconKey.REFRESH + RemoteDeviceType.CUSTOM -> ABoxIconKey.INFRARED } + ABoxIcon(icon, contentDescription = remote.displayName, modifier = Modifier.size(30.dp)) + ABoxIconButton( + key = ABoxIconKey.CLOSE, + contentDescription = "删除${remote.displayName}", + onClick = onDelete, + modifier = Modifier.testTag("delete_remote_${remote.id.value}"), + ) } Text( @@ -305,9 +592,13 @@ private fun VirtualRemoteCard( } @Composable -private fun QuotaSummaryCard(quota: SlotQuotaSummary) { +private fun InfraredHardwareSummary( + deviceName: String, + remoteCount: Int, + quota: SlotQuotaSummary, +) { Card( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().testTag("infrared_hardware_summary"), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surfaceVariant, ), @@ -323,13 +614,28 @@ private fun QuotaSummaryCard(quota: SlotQuotaSummary) { horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Surface( + shape = RoundedCornerShape(14.dp), + color = MaterialTheme.colorScheme.primaryContainer, + ) { + ABoxIcon( + key = ABoxIconKey.INFRARED, + contentDescription = "红外硬件", + modifier = Modifier.padding(10.dp).size(26.dp), + ) + } + Column { + Text(deviceName, fontWeight = FontWeight.Bold, fontSize = 16.sp) + Text( + text = "红外控制器 · $remoteCount 个虚拟遥控器", + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } Text( - text = "硬件槽位配额 (总计 100 槽位)", - fontWeight = FontWeight.Bold, - fontSize = 14.sp, - ) - Text( - text = "${quota.usedCount} / ${quota.totalCapacity} 已占用", + text = "${quota.usedCount}/${quota.totalCapacity}", fontWeight = FontWeight.Bold, color = if (quota.isStaticFull) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary, fontSize = 13.sp, @@ -345,7 +651,7 @@ private fun QuotaSummaryCard(quota: SlotQuotaSummary) { ) Text( - text = "静态槽位占用: ${quota.assignedStaticSlots.size} 个 | 动态缓冲: Slot 100 (共享) | 剩余静态: ${quota.remainingStaticCount} 个", + text = "静态槽位 ${quota.assignedStaticSlots.size} 个 · 动态缓冲槽位 100 · 剩余 ${quota.remainingStaticCount} 个", fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubUiState.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubUiState.kt index 06d10b5..94d501d 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubUiState.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubUiState.kt @@ -4,7 +4,10 @@ import com.flagship.abox.manager.domain.DeviceName import com.flagship.abox.manager.domain.ProviderProfileId import com.flagship.abox.manager.domain.ProviderType import com.flagship.abox.manager.domain.infrared.SlotQuotaSummary +import com.flagship.abox.manager.domain.infrared.AcStateSnapshot +import com.flagship.abox.manager.domain.infrared.RemoteKeyBinding import com.flagship.abox.manager.domain.infrared.VirtualRemote +import com.flagship.abox.manager.domain.infrared.VirtualRemoteId import com.flagship.abox.manager.infrared.remote.UiText sealed interface HubUiMessage { @@ -19,6 +22,13 @@ data class InfraredHubUiState( val providerDisplayName: String? = null, val savedDeviceName: DeviceName = DeviceName("红外控制器"), val remotes: List = emptyList(), + val selectedRemoteId: VirtualRemoteId? = null, + val selectedRemote: VirtualRemote? = null, + val bindings: List = emptyList(), + val acState: AcStateSnapshot = AcStateSnapshot(), + val isLearnMode: Boolean = false, + val learningButtonId: String? = null, + val sendingButtonId: String? = null, val slotQuota: SlotQuotaSummary = SlotQuotaSummary(), val loading: Boolean = true, val message: HubUiMessage? = null, diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubViewModel.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubViewModel.kt index 5952514..5099ccd 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubViewModel.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubViewModel.kt @@ -9,9 +9,23 @@ import com.flagship.abox.manager.domain.DomainResult import com.flagship.abox.manager.domain.ProviderProfile import com.flagship.abox.manager.domain.ProviderProfileId import com.flagship.abox.manager.domain.ProviderProfileStore +import com.flagship.abox.manager.domain.ActivityEventKind +import com.flagship.abox.manager.domain.ActivityOutcome +import com.flagship.abox.manager.domain.ActivitySource +import com.flagship.abox.manager.domain.ActivityTarget +import com.flagship.abox.manager.domain.ActivityTargetType +import com.flagship.abox.manager.domain.ActivityEventWriter import com.flagship.abox.manager.domain.infrared.CreateVirtualRemote import com.flagship.abox.manager.domain.infrared.DeleteVirtualRemote +import com.flagship.abox.manager.domain.infrared.AddVirtualRemoteKey +import com.flagship.abox.manager.domain.infrared.DeleteVirtualRemoteKey import com.flagship.abox.manager.domain.infrared.InfraredDeviceConfigurationRepository +import com.flagship.abox.manager.domain.infrared.LearnVirtualRemoteKey +import com.flagship.abox.manager.domain.infrared.RemoteKeyBinding +import com.flagship.abox.manager.domain.infrared.SendVirtualRemoteAction +import com.flagship.abox.manager.domain.infrared.AcFanSpeed +import com.flagship.abox.manager.domain.infrared.AcMode +import com.flagship.abox.manager.domain.infrared.AcStateSnapshot import com.flagship.abox.manager.domain.infrared.RemoteDeviceType import com.flagship.abox.manager.domain.infrared.SlotQuotaSummary import com.flagship.abox.manager.domain.infrared.TransmissionMode @@ -30,7 +44,10 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import javax.inject.Inject @@ -43,12 +60,23 @@ class InfraredHubViewModel @Inject constructor( private val virtualRemoteRepository: VirtualRemoteRepository, private val createVirtualRemote: CreateVirtualRemote, private val deleteVirtualRemote: DeleteVirtualRemote, + private val sendVirtualRemoteAction: SendVirtualRemoteAction, + private val learnVirtualRemoteKey: LearnVirtualRemoteKey, + private val addVirtualRemoteKey: AddVirtualRemoteKey, + private val deleteVirtualRemoteKey: DeleteVirtualRemoteKey, + private val activityWriter: ActivityEventWriter, ) : ViewModel() { private val rawProfileId: String? = savedStateHandle["profileId"] val profileId: ProviderProfileId? = rawProfileId?.takeIf { it.isNotBlank() }?.let { ProviderProfileId(it) } private val messageFlow = MutableStateFlow(null) + private val selectedRemoteIdFlow = MutableStateFlow(null) + private val isLearnModeFlow = MutableStateFlow(false) + private val learningButtonIdFlow = MutableStateFlow(null) + private val sendingButtonIdFlow = MutableStateFlow(null) + private val localAcStateFlow = MutableStateFlow(null) + private var acDebounceJob: Job? = null private val routeDeviceName: DeviceName? = savedStateHandle.get("deviceName") ?.takeIf { it.isNotBlank() } ?.let(::DeviceName) @@ -96,10 +124,51 @@ class InfraredHubViewModel @Inject constructor( } } + private data class PanelSnapshot( + val selectedId: VirtualRemoteId?, + val remote: VirtualRemote?, + val bindings: List, + ) + + private val panelSnapshotFlow = combine(hubSnapshotFlow, selectedRemoteIdFlow) { hub, selectedId -> + val resolvedId = selectedId?.takeIf { id -> hub.remotes.any { it.id == id } } ?: hub.remotes.firstOrNull()?.id + resolvedId to hub + }.flatMapLatest { (selectedId, hub) -> + if (selectedId == null) { + flowOf(PanelSnapshot(null, null, emptyList())) + } else { + virtualRemoteRepository.observeKeyBindings(selectedId).map { result -> + PanelSnapshot( + selectedId, + hub.remotes.firstOrNull { it.id == selectedId }, + if (result is DomainResult.Success) result.value else emptyList(), + ) + } + } + } + + private data class CenterActions( + val learnMode: Boolean, + val learningButtonId: String?, + val sendingButtonId: String?, + val localAcState: AcStateSnapshot?, + ) + + private val centerActionsFlow = combine( + isLearnModeFlow, + learningButtonIdFlow, + sendingButtonIdFlow, + localAcStateFlow, + ) { learnMode, learningId, sendingId, localAc -> + CenterActions(learnMode, learningId, sendingId, localAc) + } + val state: StateFlow = combine( hubSnapshotFlow, + panelSnapshotFlow, + centerActionsFlow, messageFlow, - ) { snapshot, msg -> + ) { snapshot, panel, actions, msg -> val resolvedMessage = msg ?: snapshot.configError?.let { error -> when (error) { is DomainError.TokenInvalid, is DomainError.Authentication -> HubUiMessage.SessionInvalid @@ -119,6 +188,13 @@ class InfraredHubViewModel @Inject constructor( providerDisplayName = snapshot.providerProfile?.displayName, savedDeviceName = snapshot.savedDevice, remotes = snapshot.remotes, + selectedRemoteId = panel.selectedId, + selectedRemote = panel.remote, + bindings = panel.bindings, + acState = actions.localAcState ?: panel.remote?.acState ?: AcStateSnapshot(), + isLearnMode = actions.learnMode, + learningButtonId = actions.learningButtonId, + sendingButtonId = actions.sendingButtonId, slotQuota = snapshot.slotQuota, loading = false, message = resolvedMessage, @@ -168,6 +244,155 @@ class InfraredHubViewModel @Inject constructor( } } + fun selectRemote(remoteId: VirtualRemoteId) { + selectedRemoteIdFlow.value = remoteId + localAcStateFlow.value = null + isLearnModeFlow.value = false + } + + fun moveRemote(remoteId: VirtualRemoteId, offset: Int) { + val remotes = state.value.remotes + val from = remotes.indexOfFirst { it.id == remoteId } + val to = from + offset + if (from !in remotes.indices || to !in remotes.indices) return + val current = remotes[from] + val target = remotes[to] + viewModelScope.launch { + val first = virtualRemoteRepository.updateRemote( + current.copy(createdAtEpochMillis = target.createdAtEpochMillis), + ) + val second = virtualRemoteRepository.updateRemote( + target.copy(createdAtEpochMillis = current.createdAtEpochMillis), + ) + if (first is DomainResult.Failure) handleDomainError(first.error) + else if (second is DomainResult.Failure) handleDomainError(second.error) + } + } + + fun updateRemote( + remoteId: VirtualRemoteId, + displayName: String, + layoutStyle: com.flagship.abox.manager.domain.infrared.PanelLayoutStyle, + transmissionMode: TransmissionMode, + ) { + val remote = state.value.remotes.firstOrNull { it.id == remoteId } ?: return + val trimmed = displayName.trim() + if (trimmed.isBlank()) return + viewModelScope.launch { + when (val result = virtualRemoteRepository.updateRemote( + remote.copy( + displayName = trimmed, + layoutStyle = layoutStyle, + transmissionMode = transmissionMode, + updatedAtEpochMillis = System.currentTimeMillis(), + ), + )) { + is DomainResult.Success -> messageFlow.value = HubUiMessage.Info(UiText.DynamicString("遥控器配置已保存")) + is DomainResult.Failure -> handleDomainError(result.error) + } + } + } + + fun toggleLearnMode() { + if (state.value.selectedRemote?.type != RemoteDeviceType.CUSTOM) return + isLearnModeFlow.update { !it } + } + + fun onButtonClick(buttonId: String) { + if (isLearnModeFlow.value) learnButton(buttonId) else sendButton(buttonId) + } + + private fun sendButton(buttonId: String) { + val currentProfile = profileId ?: return + val currentRemoteId = state.value.selectedRemoteId ?: return + if (sendingButtonIdFlow.value != null || learningButtonIdFlow.value != null) return + sendingButtonIdFlow.value = buttonId + viewModelScope.launch { + try { + when (val result = sendVirtualRemoteAction.sendButton(currentProfile, currentRemoteId, buttonId)) { + is DomainResult.Success -> { + recordRemoteActivity(ActivityEventKind.INFRARED_SEND, ActivityOutcome.SUCCEEDED) + messageFlow.value = HubUiMessage.Info(UiText.DynamicString("红外信号已发射,未确认设备实际状态")) + } + is DomainResult.Failure -> { + recordRemoteActivity(ActivityEventKind.INFRARED_SEND, ActivityOutcome.FAILED, result.error.externalCode) + handleDomainError(result.error) + } + } + } finally { + sendingButtonIdFlow.value = null + } + } + } + + private fun learnButton(buttonId: String) { + if (state.value.selectedRemote?.type != RemoteDeviceType.CUSTOM) return + val currentProfile = profileId ?: return + val currentRemoteId = state.value.selectedRemoteId ?: return + if (learningButtonIdFlow.value != null) return + learningButtonIdFlow.value = buttonId + viewModelScope.launch { + try { + when (val result = learnVirtualRemoteKey(currentProfile, currentRemoteId, buttonId)) { + is DomainResult.Success -> { + recordRemoteActivity(ActivityEventKind.INFRARED_LEARN, ActivityOutcome.SUCCEEDED) + messageFlow.value = HubUiMessage.Info(UiText.DynamicString("按键信号已学习并保存")) + } + is DomainResult.Failure -> { + recordRemoteActivity(ActivityEventKind.INFRARED_LEARN, ActivityOutcome.FAILED, result.error.externalCode) + handleDomainError(result.error) + } + } + } finally { + learningButtonIdFlow.value = null + } + } + } + + fun addCustomButton(displayName: String) { + val currentProfile = profileId ?: return + val currentRemoteId = state.value.selectedRemoteId ?: return + viewModelScope.launch { + when (val result = addVirtualRemoteKey(currentProfile, currentRemoteId, displayName)) { + is DomainResult.Success -> messageFlow.value = HubUiMessage.Info(UiText.DynamicString("已添加按键")) + is DomainResult.Failure -> handleDomainError(result.error) + } + } + } + + fun deleteButton(buttonId: String) { + val currentProfile = profileId ?: return + val currentRemoteId = state.value.selectedRemoteId ?: return + viewModelScope.launch { + when (val result = deleteVirtualRemoteKey(currentProfile, currentRemoteId, buttonId)) { + is DomainResult.Success -> messageFlow.value = HubUiMessage.Info(UiText.DynamicString("按键已删除")) + is DomainResult.Failure -> handleDomainError(result.error) + } + } + } + + fun toggleAcPower() = updateAc(state.value.acState.copy(power = !state.value.acState.power)) + fun adjustAcTemperature(delta: Int) = updateAc(state.value.acState.copy(temperature = (state.value.acState.temperature + delta).coerceIn(16, 30))) + fun setAcMode(mode: AcMode) = updateAc(state.value.acState.copy(mode = mode)) + fun setAcFanSpeed(speed: AcFanSpeed) = updateAc(state.value.acState.copy(fanSpeed = speed)) + fun toggleAcVerticalSwing() = updateAc(state.value.acState.copy(verticalSwing = !state.value.acState.verticalSwing)) + fun toggleAcHorizontalSwing() = updateAc(state.value.acState.copy(horizontalSwing = !state.value.acState.horizontalSwing)) + fun toggleAcEco() = updateAc(state.value.acState.copy(eco = !state.value.acState.eco)) + + private fun updateAc(newState: AcStateSnapshot) { + val currentProfile = profileId ?: return + val currentRemoteId = state.value.selectedRemoteId ?: return + localAcStateFlow.value = newState + acDebounceJob?.cancel() + acDebounceJob = viewModelScope.launch { + delay(300L) + when (val result = sendVirtualRemoteAction.sendAcState(currentProfile, currentRemoteId, newState)) { + is DomainResult.Success -> messageFlow.value = HubUiMessage.Info(UiText.DynamicString("红外信号已发射,未确认设备实际状态")) + is DomainResult.Failure -> handleDomainError(result.error) + } + } + } + fun saveDeviceName(newName: String) { val currentProfile = profileId ?: return val trimmed = newName.trim() @@ -189,6 +414,19 @@ class InfraredHubViewModel @Inject constructor( messageFlow.value = null } + private fun recordRemoteActivity(kind: ActivityEventKind, outcome: ActivityOutcome, errorCode: String? = null) { + val currentProfile = profileId ?: return + val remote = state.value.selectedRemote ?: return + activityWriter.record( + profileId = currentProfile, + kind = kind, + source = ActivitySource.USER, + outcome = outcome, + target = ActivityTarget(ActivityTargetType.REMOTE, remote.id.value, remote.displayName), + errorCode = errorCode, + ) + } + private fun handleDomainError(error: DomainError) { when (error) { is DomainError.TokenInvalid, is DomainError.Authentication -> messageFlow.value = HubUiMessage.SessionInvalid diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelScreen.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelScreen.kt index 654a178..daa8a8a 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelScreen.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelScreen.kt @@ -16,8 +16,10 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults @@ -61,7 +63,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -131,7 +133,7 @@ internal fun RemotePanelScreen( onMessageShown: () -> Unit, ) { val snackbarHostState = remember { SnackbarHostState() } - val context = LocalContext.current + val resources = LocalResources.current var showAddKeyDialog by remember { mutableStateOf(false) } @@ -141,7 +143,7 @@ internal fun RemotePanelScreen( is PanelUiMessage.Info -> { val text = when (val t = msg.text) { is UiText.DynamicString -> t.value - is UiText.StringResource -> context.getString(t.resId, *t.args.toTypedArray()) + is UiText.StringResource -> resources.getString(t.resId, *t.args.toTypedArray()) } snackbarHostState.showSnackbar(text) onMessageShown() @@ -149,7 +151,7 @@ internal fun RemotePanelScreen( is PanelUiMessage.Error -> { val text = when (val t = msg.text) { is UiText.DynamicString -> t.value - is UiText.StringResource -> context.getString(t.resId, *t.args.toTypedArray()) + is UiText.StringResource -> resources.getString(t.resId, *t.args.toTypedArray()) } snackbarHostState.showSnackbar(text) onMessageShown() @@ -191,8 +193,7 @@ internal fun RemotePanelScreen( ABoxIconButton(ABoxIconKey.BACK, "返回", onBack) }, actions = { - // 非空调面板显示学习模式开关 - if (state.remote?.layoutStyle != PanelLayoutStyle.AC_COMPACT) { + if (state.remote?.type == com.flagship.abox.manager.domain.infrared.RemoteDeviceType.CUSTOM) { FilterChip( selected = state.isLearnMode, onClick = onToggleLearnMode, @@ -244,52 +245,20 @@ internal fun RemotePanelScreen( } } - // 面板内容区 - Box(modifier = Modifier.fillMaxSize().weight(1f)) { - when (remote.layoutStyle) { - PanelLayoutStyle.AC_COMPACT -> { - AcControlPanel( - acState = state.acState, - onTogglePower = onToggleAcPower, - onAdjustTemp = onAdjustAcTemp, - onSetMode = onSetAcMode, - onSetFanSpeed = onSetFanSpeed, - onToggleVSwing = onToggleAcVerticalSwing, - onToggleHSwing = onToggleAcHorizontalSwing, - onToggleEco = onToggleAcEco, - ) - } - PanelLayoutStyle.TV_NAV -> { - TvControlPanel( - bindings = state.bindings, - isLearnMode = state.isLearnMode, - learningButtonId = state.learningButtonId, - sendingButtonId = state.sendingButtonId, - onButtonClick = onButtonClick, - ) - } - PanelLayoutStyle.MEDIA_PLAYER -> { - MediaPlayerControlPanel( - bindings = state.bindings, - isLearnMode = state.isLearnMode, - learningButtonId = state.learningButtonId, - sendingButtonId = state.sendingButtonId, - onButtonClick = onButtonClick, - ) - } - PanelLayoutStyle.GRID -> { - GenericGridControlPanel( - bindings = state.bindings, - isLearnMode = state.isLearnMode, - learningButtonId = state.learningButtonId, - sendingButtonId = state.sendingButtonId, - onButtonClick = onButtonClick, - onDeleteButton = onDeleteButton, - onAddButton = { showAddKeyDialog = true }, - ) - } - } - } + UnifiedRemoteControlSurface( + state = state, + onButtonClick = onButtonClick, + onAddCustomButton = onAddCustomButton, + onDeleteButton = onDeleteButton, + onToggleAcPower = onToggleAcPower, + onAdjustAcTemp = onAdjustAcTemp, + onSetAcMode = onSetAcMode, + onSetFanSpeed = onSetFanSpeed, + onToggleAcVerticalSwing = onToggleAcVerticalSwing, + onToggleAcHorizontalSwing = onToggleAcHorizontalSwing, + onToggleAcEco = onToggleAcEco, + modifier = Modifier.fillMaxSize().weight(1f), + ) } } } @@ -320,12 +289,13 @@ private fun AcControlPanel( Column( modifier = Modifier .fillMaxSize() + .verticalScroll(rememberScrollState()) .padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp), ) { Card( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().testTag("ac_temperature_stepper"), colors = CardDefaults.cardColors( containerColor = if (acState.power) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant, ), @@ -359,21 +329,9 @@ private fun AcControlPanel( } } - Text( - text = "${acState.temperature}°C", - fontSize = 64.sp, - fontWeight = FontWeight.Black, - color = if (acState.power) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant, - ) - - Text( - text = "模式: ${acState.mode.name} | 风速: ${acState.fanSpeed.name} | 扫风: ${if (acState.verticalSwing) "开" else "关"}", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Row( - horizontalArrangement = Arrangement.spacedBy(32.dp), + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically, ) { Button( @@ -383,6 +341,15 @@ private fun AcControlPanel( ) { Icon(Icons.Outlined.Remove, contentDescription = "降低温度") } + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = "${acState.temperature}°", + fontSize = 56.sp, + fontWeight = FontWeight.Black, + color = if (acState.power) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text("设定温度", style = MaterialTheme.typography.labelMedium) + } Button( onClick = { onAdjustTemp(1) }, modifier = Modifier.size(56.dp).testTag("ac_temp_up"), @@ -391,21 +358,27 @@ private fun AcControlPanel( Icon(Icons.Outlined.Add, contentDescription = "升高温度") } } + Text( + text = "本地选择:${acState.mode.name} · ${acState.fanSpeed.name}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } - Text("模式选择", style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), modifier = Modifier.align(Alignment.Start)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - AcMode.entries.forEach { mode -> - FilterChip( - selected = acState.mode == mode, - onClick = { onSetMode(mode) }, - label = { Text(mode.name) }, - modifier = Modifier.weight(1f), - ) + Card(modifier = Modifier.fillMaxWidth().testTag("ac_mode_selector")) { + Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("运行模式", style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold)) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) { + AcMode.entries.forEach { mode -> + FilterChip( + selected = acState.mode == mode, + onClick = { onSetMode(mode) }, + label = { Text(modeLabel(mode)) }, + modifier = Modifier.weight(1f), + ) + } + } } } @@ -418,7 +391,7 @@ private fun AcControlPanel( FilterChip( selected = acState.fanSpeed == speed, onClick = { onSetFanSpeed(speed) }, - label = { Text(speed.name) }, + label = { Text(fanSpeedLabel(speed)) }, modifier = Modifier.weight(1f), ) } @@ -463,6 +436,7 @@ private fun TvControlPanel( Column( modifier = Modifier .fillMaxSize() + .verticalScroll(rememberScrollState()) .padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp), @@ -483,7 +457,7 @@ private fun TvControlPanel( // 中部五向方向导航盘 Card( shape = CircleShape, - modifier = Modifier.size(220.dp), + modifier = Modifier.size(220.dp).testTag("tv_direction_pad"), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), ) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { @@ -492,7 +466,7 @@ private fun TvControlPanel( onClick = { onButtonClick("TV_NAV_UP") }, modifier = Modifier.align(Alignment.TopCenter).padding(top = 10.dp).testTag("tv_nav_up"), ) { - ButtonIndicatorText("TV_NAV_UP", "上", learningButtonId, sendingButtonId) + DirectionIndicator("TV_NAV_UP", Icons.Outlined.KeyboardArrowUp, "向上", learningButtonId, sendingButtonId) } // 下 @@ -500,7 +474,7 @@ private fun TvControlPanel( onClick = { onButtonClick("TV_NAV_DOWN") }, modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 10.dp).testTag("tv_nav_down"), ) { - ButtonIndicatorText("TV_NAV_DOWN", "下", learningButtonId, sendingButtonId) + DirectionIndicator("TV_NAV_DOWN", Icons.Outlined.KeyboardArrowDown, "向下", learningButtonId, sendingButtonId) } // 左 @@ -508,7 +482,7 @@ private fun TvControlPanel( onClick = { onButtonClick("TV_NAV_LEFT") }, modifier = Modifier.align(Alignment.CenterStart).padding(start = 10.dp).testTag("tv_nav_left"), ) { - ButtonIndicatorText("TV_NAV_LEFT", "左", learningButtonId, sendingButtonId) + DirectionIndicator("TV_NAV_LEFT", Icons.Outlined.KeyboardArrowLeft, "向左", learningButtonId, sendingButtonId) } // 右 @@ -516,7 +490,7 @@ private fun TvControlPanel( onClick = { onButtonClick("TV_NAV_RIGHT") }, modifier = Modifier.align(Alignment.CenterEnd).padding(end = 10.dp).testTag("tv_nav_right"), ) { - ButtonIndicatorText("TV_NAV_RIGHT", "右", learningButtonId, sendingButtonId) + DirectionIndicator("TV_NAV_RIGHT", Icons.Outlined.KeyboardArrowRight, "向右", learningButtonId, sendingButtonId) } // OK 居中大按钮 @@ -535,14 +509,14 @@ private fun TvControlPanel( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceAround, ) { - InteractiveRemoteButton("TV_BACK", "↩ 返回", MaterialTheme.colorScheme.outline, bindingMap["TV_BACK"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick) - InteractiveRemoteButton("TV_HOME", "⌂ 主页", MaterialTheme.colorScheme.outline, bindingMap["TV_HOME"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick) - InteractiveRemoteButton("TV_MENU", "☰ 菜单", MaterialTheme.colorScheme.outline, bindingMap["TV_MENU"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick) + InteractiveRemoteButton("TV_BACK", "返回", MaterialTheme.colorScheme.outline, bindingMap["TV_BACK"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick) + InteractiveRemoteButton("TV_HOME", "主页", MaterialTheme.colorScheme.outline, bindingMap["TV_HOME"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick) + InteractiveRemoteButton("TV_MENU", "菜单", MaterialTheme.colorScheme.outline, bindingMap["TV_MENU"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick) } // 底部音量与频道调节柱 Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().testTag("tv_volume_channel_rockers"), horizontalArrangement = Arrangement.SpaceEvenly, ) { Column( @@ -550,11 +524,11 @@ private fun TvControlPanel( verticalArrangement = Arrangement.spacedBy(6.dp), ) { Text("音量 VOL", fontWeight = FontWeight.Bold, fontSize = 12.sp) - Button(onClick = { onButtonClick("TV_VOL_UP") }, modifier = Modifier.size(54.dp), shape = RoundedCornerShape(12.dp)) { - ButtonIndicatorText("TV_VOL_UP", "+", learningButtonId, sendingButtonId) + Button(onClick = { onButtonClick("TV_VOL_UP") }, modifier = Modifier.width(120.dp), shape = RoundedCornerShape(12.dp)) { + Icon(Icons.Outlined.Add, contentDescription = "提高音量") } - Button(onClick = { onButtonClick("TV_VOL_DOWN") }, modifier = Modifier.size(54.dp), shape = RoundedCornerShape(12.dp)) { - ButtonIndicatorText("TV_VOL_DOWN", "-", learningButtonId, sendingButtonId) + Button(onClick = { onButtonClick("TV_VOL_DOWN") }, modifier = Modifier.width(120.dp), shape = RoundedCornerShape(12.dp)) { + Icon(Icons.Outlined.Remove, contentDescription = "降低音量") } } @@ -563,17 +537,118 @@ private fun TvControlPanel( verticalArrangement = Arrangement.spacedBy(6.dp), ) { Text("频道 CH", fontWeight = FontWeight.Bold, fontSize = 12.sp) - Button(onClick = { onButtonClick("TV_CH_UP") }, modifier = Modifier.size(54.dp), shape = RoundedCornerShape(12.dp)) { - ButtonIndicatorText("TV_CH_UP", "频道上调", learningButtonId, sendingButtonId) + Button(onClick = { onButtonClick("TV_CH_UP") }, modifier = Modifier.width(120.dp), shape = RoundedCornerShape(12.dp)) { + Icon(Icons.Outlined.Add, contentDescription = "频道上调") } - Button(onClick = { onButtonClick("TV_CH_DOWN") }, modifier = Modifier.size(54.dp), shape = RoundedCornerShape(12.dp)) { - ButtonIndicatorText("TV_CH_DOWN", "频道下调", learningButtonId, sendingButtonId) + Button(onClick = { onButtonClick("TV_CH_DOWN") }, modifier = Modifier.width(120.dp), shape = RoundedCornerShape(12.dp)) { + Icon(Icons.Outlined.Remove, contentDescription = "频道下调") } } } } } +@Composable +fun UnifiedRemoteControlSurface( + state: RemotePanelUiState, + onButtonClick: (String) -> Unit, + onAddCustomButton: (String) -> Unit, + onDeleteButton: (String) -> Unit, + onToggleAcPower: () -> Unit, + onAdjustAcTemp: (Int) -> Unit, + onSetAcMode: (AcMode) -> Unit, + onSetFanSpeed: (AcFanSpeed) -> Unit, + onToggleAcVerticalSwing: () -> Unit, + onToggleAcHorizontalSwing: () -> Unit, + onToggleAcEco: () -> Unit, + modifier: Modifier = Modifier, +) { + var showAddKeyDialog by remember { mutableStateOf(false) } + val remote = state.remote + Box(modifier = modifier) { + if (remote == null) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } else { + when (remote.layoutStyle) { + PanelLayoutStyle.AC_COMPACT -> AcControlPanel( + acState = state.acState, + onTogglePower = onToggleAcPower, + onAdjustTemp = onAdjustAcTemp, + onSetMode = onSetAcMode, + onSetFanSpeed = onSetFanSpeed, + onToggleVSwing = onToggleAcVerticalSwing, + onToggleHSwing = onToggleAcHorizontalSwing, + onToggleEco = onToggleAcEco, + ) + PanelLayoutStyle.TV_NAV -> TvControlPanel( + bindings = state.bindings, + isLearnMode = state.isLearnMode, + learningButtonId = state.learningButtonId, + sendingButtonId = state.sendingButtonId, + onButtonClick = onButtonClick, + ) + PanelLayoutStyle.MEDIA_PLAYER -> MediaPlayerControlPanel( + bindings = state.bindings, + isLearnMode = state.isLearnMode, + learningButtonId = state.learningButtonId, + sendingButtonId = state.sendingButtonId, + onButtonClick = onButtonClick, + ) + PanelLayoutStyle.GRID -> GenericGridControlPanel( + bindings = state.bindings, + isLearnMode = state.isLearnMode, + learningButtonId = state.learningButtonId, + sendingButtonId = state.sendingButtonId, + onButtonClick = onButtonClick, + onDeleteButton = onDeleteButton, + onAddButton = { showAddKeyDialog = true }, + ) + } + } + } + if (showAddKeyDialog) { + AddCustomKeyDialog( + onDismiss = { showAddKeyDialog = false }, + onConfirm = { + showAddKeyDialog = false + onAddCustomButton(it) + }, + ) + } +} + +@Composable +private fun DirectionIndicator( + buttonId: String, + icon: androidx.compose.ui.graphics.vector.ImageVector, + description: String, + learningButtonId: String?, + sendingButtonId: String?, +) { + when (buttonId) { + learningButtonId -> Text("录制中", fontSize = 11.sp, fontWeight = FontWeight.Bold) + sendingButtonId -> CircularProgressIndicator(modifier = Modifier.size(18.dp)) + else -> Icon(icon, contentDescription = description, modifier = Modifier.size(30.dp)) + } +} + +private fun modeLabel(mode: AcMode): String = when (mode) { + AcMode.COOL -> "制冷" + AcMode.HEAT -> "制热" + AcMode.AUTO -> "自动" + AcMode.DRY -> "除湿" + AcMode.FAN -> "送风" +} + +private fun fanSpeedLabel(speed: AcFanSpeed): String = when (speed) { + AcFanSpeed.AUTO -> "自动" + AcFanSpeed.LOW -> "低速" + AcFanSpeed.MEDIUM -> "中速" + AcFanSpeed.HIGH -> "高速" +} + @Composable private fun MediaPlayerControlPanel( bindings: List, @@ -709,7 +784,8 @@ private fun GridButtonCard( val statusText = when { isLearning -> "正在录制信号..." binding.status == InfraredKeyStatus.READY -> "已就绪(槽位 ${binding.assignedSlot ?: "动态"})" - else -> "待录制(点击录码)" + binding.status == InfraredKeyStatus.FAILED -> "学习失败(请重试)" + else -> "未学习(点击录码)" } Text(statusText, fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) } diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelViewModel.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelViewModel.kt index 6027600..1cbd3a0 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelViewModel.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelViewModel.kt @@ -129,6 +129,7 @@ class RemotePanelViewModel @Inject constructor( ) fun toggleLearnMode() { + if (state.value.remote?.type != com.flagship.abox.manager.domain.infrared.RemoteDeviceType.CUSTOM) return val newMode = !isLearnModeFlow.value isLearnModeFlow.value = newMode if (!newMode) cancelLearning() @@ -166,6 +167,7 @@ class RemotePanelViewModel @Inject constructor( } fun learnButton(buttonId: String) { + if (state.value.remote?.type != com.flagship.abox.manager.domain.infrared.RemoteDeviceType.CUSTOM) return val currentProfile = profileId ?: return val currentRemoteId = remoteId ?: return if (state.value.learningButtonId != null) return diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt index 50057c9..ae2f0c1 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt @@ -204,7 +204,8 @@ class InfraredRemoteViewModel @Inject constructor( activeLearningSlotFlow.value = spec.defaultSlot val deferred = when (existingKey?.status) { InfraredKeyStatus.FAILED -> learningCoordinator.retry(InfraredKeyRef(currentProfile, existingKey.id)) - InfraredKeyStatus.READY, InfraredKeyStatus.LEARNING -> learningCoordinator.relearn(InfraredKeyRef(currentProfile, existingKey.id)) + InfraredKeyStatus.UNLEARNED, InfraredKeyStatus.READY, InfraredKeyStatus.LEARNING -> + learningCoordinator.relearn(InfraredKeyRef(currentProfile, existingKey.id)) null -> learningCoordinator.learn( profileId = currentProfile, deviceName = currentDevice, diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/InfraredRemoteScreen.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/InfraredRemoteScreen.kt index 57b3678..f7216a8 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/InfraredRemoteScreen.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/InfraredRemoteScreen.kt @@ -35,7 +35,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -89,7 +89,7 @@ internal fun InfraredRemoteScreen( onMessageShown: () -> Unit, ) { val snackbarHostState = remember { SnackbarHostState() } - val context = LocalContext.current + val resources = LocalResources.current LaunchedEffect(state.message) { state.message?.let { msg -> @@ -97,7 +97,7 @@ internal fun InfraredRemoteScreen( is RemoteUiMessage.Info -> { val text = when (val t = msg.text) { is UiText.DynamicString -> t.value - is UiText.StringResource -> context.getString(t.resId, *t.args.toTypedArray()) + is UiText.StringResource -> resources.getString(t.resId, *t.args.toTypedArray()) } snackbarHostState.showSnackbar(text) onMessageShown() @@ -105,7 +105,7 @@ internal fun InfraredRemoteScreen( is RemoteUiMessage.Error -> { val text = when (val t = msg.text) { is UiText.DynamicString -> t.value - is UiText.StringResource -> context.getString(t.resId, *t.args.toTypedArray()) + is UiText.StringResource -> resources.getString(t.resId, *t.args.toTypedArray()) } snackbarHostState.showSnackbar(text) onMessageShown() diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreen.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreen.kt index c9b0674..3eded3d 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreen.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreen.kt @@ -33,7 +33,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -76,7 +76,7 @@ internal fun InfraredControlScreen( onMessageShown: () -> Unit, ) { val snackbarHostState = remember { SnackbarHostState() } - val context = LocalContext.current + val resources = LocalResources.current val scrollState = rememberScrollState() LaunchedEffect(state.message) { @@ -85,7 +85,7 @@ internal fun InfraredControlScreen( is InfraredUiMessage.Info -> { val text = when (val t = msg.text) { is UiText.DynamicString -> t.value - is UiText.StringResource -> context.getString(t.resId, *t.args.toTypedArray()) + is UiText.StringResource -> resources.getString(t.resId, *t.args.toTypedArray()) } snackbarHostState.showSnackbar(text) onMessageShown() @@ -93,7 +93,7 @@ internal fun InfraredControlScreen( is InfraredUiMessage.Error -> { val text = when (val t = msg.text) { is UiText.DynamicString -> t.value - is UiText.StringResource -> context.getString(t.resId, *t.args.toTypedArray()) + is UiText.StringResource -> resources.getString(t.resId, *t.args.toTypedArray()) } snackbarHostState.showSnackbar(text) onMessageShown() diff --git a/android/feature/infrared/src/main/res/values-zh-rCN/strings.xml b/android/feature/infrared/src/main/res/values-zh-rCN/strings.xml index b6b5e04..5c11827 100644 --- a/android/feature/infrared/src/main/res/values-zh-rCN/strings.xml +++ b/android/feature/infrared/src/main/res/values-zh-rCN/strings.xml @@ -73,9 +73,9 @@ 电视-音量- 电视-频道+ 电视-频道- - 电视-主页 - 电视-返回 - 电视-菜单 + 主页 + 返回 + 菜单 红外设备与环境配置 ABSDK 设备名称配置 ABSDK 依靠此设备名称(默认为 红外控制器)寻址具体的硬件转发器,请确保名称与主机端保持一致。 @@ -114,4 +114,79 @@ 空调设备 🌀 电风扇 / 空气净化器 自定义面板 + 设定温度 + 运行模式 + 风速档位 + 制冷 + 制热 + 自动 + 除湿 + 送风 + 低速 + 中速 + 高速 + 上下扫风 + 左右扫风 + 节能 + 状态变更会生成完整红外帧并发送;信号发射不代表设备状态已确认。 + 向上 + 向下 + 向左 + 向右 + 音量 + 频道 + 提高音量 + 降低音量 + 频道上调 + 频道下调 + 媒体控制面板 + %1$s 个按键 + 暂无按键,请在管理面板中添加。 + 添加按键 + 添加自定义按键 + 先设置按键名称,录制红外信号时再分配硬件槽位。 + 按键名称 + 可用 + 正在学习 + 正在发送 + 可发送 + 学习失败 + 待学习 + 删除按键 + 红外控制器 · 遥控中心 + 红外设备设置 + 遥控器、槽位与网关设备名称 + 打开管理 + 使用管理操作添加遥控器 + 管理红外设备与遥控器 + 自定义 + 静态槽位 + 动态写码 + 电视导航面板 + 空调状态面板 + 媒体控制面板 + 自定义按键面板 + 学习模式 + 录制中 + 红外控制器 · %1$d 个虚拟遥控器 + 静态槽位 %1$d 个 · 动态缓冲槽位 100 · 剩余 %2$d 个 + 遥控器名称 + 电视导航布局,包含方向、音量和频道控制 + 媒体播放器布局,包含播放控制 + 自由网格布局,可增删按键 + 静态独占槽位模式 + 动态写码模式 + 硬件槽位 + %1$d 个遥控器 + 添加遥控器 + 调整遥控器顺序 + 上移 + 下移 + 编辑遥控器 + 网关设备名称 + 用于在网关中匹配红外控制器,必须与网关中的设备名称完全一致。 + 保存设备名称 + 面板布局 + 传输模式 + 删除遥控器 diff --git a/android/feature/infrared/src/main/res/values/strings.xml b/android/feature/infrared/src/main/res/values/strings.xml index 9deb20c..a93e5c1 100644 --- a/android/feature/infrared/src/main/res/values/strings.xml +++ b/android/feature/infrared/src/main/res/values/strings.xml @@ -114,4 +114,79 @@ Air Conditioner (AC) 🌀 Fan / Air Purifier Custom Panel + Set temperature + Operating mode + Fan speed + Cool + Heat + Auto + Dry + Fan + Low + Medium + High + Vertical swing + Horizontal swing + Eco + State changes generate a complete infrared frame and send it. Transmission does not confirm the appliance state. + Up + Down + Left + Right + Volume + Channel + Raise volume + Lower volume + Raise channel + Lower channel + Media control panel + %1$s buttons + No buttons. Add one in management. + Add button + Add custom button + Name the button now. Its hardware slot will be assigned when you record the infrared signal. + Button name + Available + Learning + Sending + Ready to send + Learning failed + Not learned + Delete button + Infrared controller · Remote center + Infrared device settings + Remotes, slots, and gateway device name + Open management + Use the management action to add a remote + Manage infrared devices and remotes + Custom + Static slots + Dynamic codes + TV navigation panel + Air conditioner panel + Media control panel + Custom button panel + Learn mode + Recording + Infrared controller · %1$d virtual remotes + Static slots: %1$d · Dynamic buffer: slot 100 · Free: %2$d + Remote name + TV navigation layout with direction, volume, and channel controls + Media player layout with playback controls + Custom grid layout with editable buttons + Static slot mode + Dynamic code mode + Hardware slots + %1$d remotes + Add remote + Reorder remote + Move up + Move down + Edit remote + Gateway device name + Used to match the infrared controller in the gateway. It must exactly match the gateway name. + Save device name + Panel layout + Transmission mode + Delete remote diff --git a/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/activity/ActivityEventWriter.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/ActivityEventWriter.kt similarity index 54% rename from android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/activity/ActivityEventWriter.kt rename to domain/src/main/kotlin/com/flagship/abox/manager/domain/ActivityEventWriter.kt index ac36bc7..6cf9c04 100644 --- a/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/activity/ActivityEventWriter.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/ActivityEventWriter.kt @@ -1,15 +1,5 @@ -package com.flagship.abox.manager.automation.activity +package com.flagship.abox.manager.domain -import com.flagship.abox.manager.domain.ActivityEvent -import com.flagship.abox.manager.domain.ActivityEventId -import com.flagship.abox.manager.domain.ActivityEventKind -import com.flagship.abox.manager.domain.ActivityOutcome -import com.flagship.abox.manager.domain.ActivityRepository -import com.flagship.abox.manager.domain.ActivitySource -import com.flagship.abox.manager.domain.ActivityTarget -import com.flagship.abox.manager.domain.ActivityTargetType -import com.flagship.abox.manager.domain.DeviceName -import com.flagship.abox.manager.domain.ProviderProfileId import java.util.UUID import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -24,6 +14,25 @@ class ActivityEventWriter( kind: ActivityEventKind, outcome: ActivityOutcome, errorCode: String? = null, + source: ActivitySource = ActivitySource.USER, + ) { + record( + profileId = profileId, + kind = kind, + source = source, + outcome = outcome, + target = ActivityTarget(ActivityTargetType.DEVICE, deviceName.value, deviceName.value), + errorCode = errorCode, + ) + } + + fun record( + profileId: ProviderProfileId, + kind: ActivityEventKind, + source: ActivitySource, + outcome: ActivityOutcome, + target: ActivityTarget, + errorCode: String? = null, ) { scope.launch { runCatching { @@ -33,9 +42,9 @@ class ActivityEventWriter( profileId = profileId, occurredAtEpochMillis = System.currentTimeMillis(), kind = kind, - source = ActivitySource.USER, + source = source, outcome = outcome, - target = ActivityTarget(ActivityTargetType.DEVICE, deviceName.value, deviceName.value), + target = target, errorCode = errorCode, ), ) diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/InMemorySceneRepository.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/InMemorySceneRepository.kt index c642da1..01db39a 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/InMemorySceneRepository.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/InMemorySceneRepository.kt @@ -92,8 +92,7 @@ class InMemorySceneRepository : SceneRepository { private fun flowFor(profileId: ProviderProfileId): MutableStateFlow>> = flows.getOrPut(profileId) { - val initial = if (profileId == LOCAL_EXPERIENCE_PROFILE) defaultLocalScenes(profileId) else emptyList() - MutableStateFlow(DomainResult.Success(initial)) + MutableStateFlow(DomainResult.Success(defaultScenes(profileId))) } private fun currentScenes(profileId: ProviderProfileId): List? = @@ -107,12 +106,30 @@ class InMemorySceneRepository : SceneRepository { DomainError.InvalidRequest(detail = "Scene ${sceneId.value} not found"), ) - private fun defaultLocalScenes(profileId: ProviderProfileId): List = listOf( + private fun defaultScenes(profileId: ProviderProfileId): List = listOf( + Scene( + id = SceneId("${profileId.value}:scene-arriving-home"), + profileId = profileId, + name = "回家模式", + description = "开启 Zigbee 插座控制器", + type = AutomationType.MANUAL, + enabled = true, + icon = SceneIcon.HOME, + color = SceneColor("#4CAF50"), + actions = listOf( + SceneAction.SocketPower( + id = SceneActionId("${profileId.value}:scene-arriving-home:socket-1"), + profileId = profileId, + deviceName = DeviceName("Zigbee插座控制器"), + powerState = SocketPowerState.ON, + ), + ), + ), Scene( id = SceneId("${profileId.value}:scene-leaving-home"), profileId = profileId, name = "离家模式", - description = "关闭插座并延时关闭电视", + description = "关闭 Zigbee 插座控制器", type = AutomationType.MANUAL, enabled = true, icon = SceneIcon.AWAY, @@ -124,16 +141,32 @@ class InMemorySceneRepository : SceneRepository { deviceName = DeviceName("Zigbee插座控制器"), powerState = SocketPowerState.OFF, ), - SceneAction.Delay( - id = SceneActionId("${profileId.value}:scene-leaving-home:delay-1"), - profileId = profileId, - durationSeconds = 1, + ), + ), + Scene( + id = SceneId("${profileId.value}:scene-temperature-alert"), + profileId = profileId, + name = "高温提醒", + description = "室温高于 28°C 时发送提醒", + type = AutomationType.AUTOMATIC, + enabled = true, + icon = SceneIcon.THERMOSTAT, + color = SceneColor("#EF5350"), + triggers = listOf( + SceneTrigger.Temperature( + id = SceneTriggerId("${profileId.value}:trigger-temp"), + deviceName = DeviceName("温湿度传感器"), + operator = ComparisonOperator.GREATER_THAN, + thresholdCelsius = 28.0, + label = "温度高于 28°C", ), - SceneAction.InfraredKey( - id = SceneActionId("${profileId.value}:scene-leaving-home:ir-1"), + ), + actions = listOf( + SceneAction.Notification( + id = SceneActionId("${profileId.value}:scene-temperature-alert:notification-1"), profileId = profileId, - deviceName = DeviceName("红外控制器"), - key = "1", + title = "温度提醒", + message = "当前室温已高于 28°C", ), ), ), @@ -148,7 +181,7 @@ class InMemorySceneRepository : SceneRepository { color = SceneColor("#26A69A"), triggers = listOf( SceneTrigger.Temperature( - id = SceneTriggerId("${profileId.value}:trigger-temp"), + id = SceneTriggerId("${profileId.value}:trigger-cooling-temp"), deviceName = DeviceName("温湿度传感器"), operator = ComparisonOperator.GREATER_THAN, thresholdCelsius = 28.0, @@ -157,7 +190,7 @@ class InMemorySceneRepository : SceneRepository { ), conditions = listOf( SceneCondition.SocketPower( - id = SceneConditionId("${profileId.value}:cond-socket"), + id = SceneConditionId("${profileId.value}:cond-cooling-socket"), deviceName = DeviceName("Zigbee插座控制器"), expectedState = SocketPowerState.OFF, ), diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/AddVirtualRemoteKey.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/AddVirtualRemoteKey.kt index 95a89c2..4fd4d54 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/AddVirtualRemoteKey.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/AddVirtualRemoteKey.kt @@ -23,18 +23,6 @@ class AddVirtualRemoteKey( } val buttonId = "CUSTOM_" + UUID.randomUUID().toString().take(8).uppercase() - val assignedSlot = if (remote.transmissionMode == TransmissionMode.STATIC_SLOTS) { - // Find next available slot - val quota = when (val result = repository.slotQuota(profileId, remote.deviceName)) { - is DomainResult.Success -> result.value - is DomainResult.Failure -> return result - } - val usedSlots = quota.assignedStaticSlots - val nextSlot = (1..99).firstOrNull { it !in usedSlots } - ?: return DomainResult.Failure(DomainError.InvalidRequest(detail = "槽位配额已满,无法分配静态槽位")) - nextSlot.toString() - } else null - val binding = RemoteKeyBinding( id = UUID.randomUUID().toString(), remoteId = remoteId, @@ -42,9 +30,9 @@ class AddVirtualRemoteKey( hardwareDeviceName = remote.deviceName, buttonId = buttonId, displayName = trimmed, - assignedSlot = assignedSlot, + assignedSlot = null, rawHexCode = null, - status = InfraredKeyStatus.FAILED, // Initially unlearned + status = InfraredKeyStatus.UNLEARNED, ) when (val res = repository.updateKeyBinding(binding)) { diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/CreateVirtualRemote.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/CreateVirtualRemote.kt index c4c333d..fecb403 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/CreateVirtualRemote.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/CreateVirtualRemote.kt @@ -69,7 +69,7 @@ class CreateVirtualRemote( displayName = template.displayName, assignedSlot = (index + 1).toString(), rawHexCode = template.defaultHexCode, - status = if (template.defaultHexCode != null) InfraredKeyStatus.READY else InfraredKeyStatus.FAILED, + status = if (template.defaultHexCode != null) InfraredKeyStatus.READY else InfraredKeyStatus.UNLEARNED, ) } } else { @@ -86,7 +86,7 @@ class CreateVirtualRemote( status = if (template.defaultHexCode != null || type == RemoteDeviceType.AIR_CONDITIONER) { InfraredKeyStatus.READY } else { - InfraredKeyStatus.FAILED + InfraredKeyStatus.UNLEARNED }, ) } diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyModels.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyModels.kt index 9c0a2ec..edf0179 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyModels.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyModels.kt @@ -12,6 +12,7 @@ data class InfraredKeyRef( ) enum class InfraredKeyStatus { + UNLEARNED, LEARNING, READY, FAILED, diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKey.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKey.kt index 43919ad..a172739 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKey.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKey.kt @@ -181,6 +181,7 @@ class RelearnInfraredKey( val result = repository.transitionStatus( ref = ref, expectedStatuses = setOf( + InfraredKeyStatus.UNLEARNED, InfraredKeyStatus.READY, InfraredKeyStatus.FAILED, InfraredKeyStatus.LEARNING, diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/LearnVirtualRemoteKey.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/LearnVirtualRemoteKey.kt index 5b6f3e2..2d8eeb6 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/LearnVirtualRemoteKey.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/LearnVirtualRemoteKey.kt @@ -18,6 +18,11 @@ class LearnVirtualRemoteKey( is DomainResult.Success -> res.value ?: return DomainResult.Failure(DomainError.NoData(detail = "Remote not found")) is DomainResult.Failure -> return res } + if (remote.type != RemoteDeviceType.CUSTOM) { + return DomainResult.Failure( + DomainError.InvalidRequest(detail = "Only custom remotes support learning"), + ) + } val bindings = when (val res = repository.getKeyBindings(remoteId)) { is DomainResult.Success -> res.value is DomainResult.Failure -> return res diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/VirtualRemoteModels.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/VirtualRemoteModels.kt index 4d34693..20d657c 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/VirtualRemoteModels.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/VirtualRemoteModels.kt @@ -85,7 +85,7 @@ data class RemoteKeyBinding( val displayName: String, val assignedSlot: String? = null, val rawHexCode: String? = null, - val status: InfraredKeyStatus = InfraredKeyStatus.READY, + val status: InfraredKeyStatus = InfraredKeyStatus.UNLEARNED, val lastErrorCode: String? = null, val lastErrorDetail: String? = null, ) diff --git a/runtime/simulation/src/main/kotlin/com/flagship/abox/manager/simulation/SimulationDeviceCatalog.kt b/runtime/simulation/src/main/kotlin/com/flagship/abox/manager/simulation/SimulationDeviceCatalog.kt index 3bb677e..d72455d 100644 --- a/runtime/simulation/src/main/kotlin/com/flagship/abox/manager/simulation/SimulationDeviceCatalog.kt +++ b/runtime/simulation/src/main/kotlin/com/flagship/abox/manager/simulation/SimulationDeviceCatalog.kt @@ -29,21 +29,21 @@ class SimulationDeviceCatalog : DeviceCatalog { private fun defaultDevices(profileId: ProviderProfileId): List = listOf( DeviceDescriptor( - id = DeviceId("${profileId.value}:socket-1"), + id = DeviceId("${profileId.value}:Zigbee插座控制器"), profileId = profileId, - name = DeviceName("socket-1"), + name = DeviceName("Zigbee插座控制器"), type = DeviceType.SOCKET, ), DeviceDescriptor( - id = DeviceId("${profileId.value}:th-1"), + id = DeviceId("${profileId.value}:温湿度传感器"), profileId = profileId, - name = DeviceName("th-1"), + name = DeviceName("温湿度传感器"), type = DeviceType.TEMPERATURE_HUMIDITY, ), DeviceDescriptor( - id = DeviceId("${profileId.value}:ir-1"), + id = DeviceId("${profileId.value}:红外控制器"), profileId = profileId, - name = DeviceName("ir-1"), + name = DeviceName("红外控制器"), type = DeviceType.INFRARED, ), )