diff --git a/absdk-bridge/src/main/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkBridge.kt b/absdk-bridge/src/main/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkBridge.kt index 9c26d29..324d2d5 100644 --- a/absdk-bridge/src/main/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkBridge.kt +++ b/absdk-bridge/src/main/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkBridge.kt @@ -133,6 +133,25 @@ class AbsdkBridge internal constructor( } } + override suspend fun commandPower( + profileId: ProviderProfileId, + deviceName: DeviceName, + powerState: SocketPowerState, + ): DomainResult = serialized { + withActiveSession(profileId) { + when (val control = call { + client.setSocketPower(deviceName.value, powerState.toSdkStatus()) + }) { + is ClientCall.Failed -> DomainResult.Failure(control.error) + is ClientCall.Completed -> if (control.response.isSuccess()) { + DomainResult.Success(Unit) + } else { + DomainResult.Failure(AbsdkErrorMapper.fromResponse(control.response)) + } + } + } + } + override suspend fun getReading( profileId: ProviderProfileId, deviceName: DeviceName, diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 464ffd4..13dab95 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,18 +1,24 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.kapt) alias(libs.plugins.hilt.android) + alias(libs.plugins.compose.screenshot) } android { namespace = "com.flagship.abox.manager" - compileSdk = 34 + compileSdk = 37 + + experimentalProperties["android.experimental.enableScreenshotTest"] = true defaultConfig { applicationId = "com.flagship.abox.manager" minSdk = 26 - targetSdk = 34 + targetSdk = 36 versionCode = 1 versionName = "0.1.0" @@ -33,19 +39,11 @@ android { compose = true } - composeOptions { - kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get() - } - compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } - kotlinOptions { - jvmTarget = "17" - } - packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" @@ -57,6 +55,10 @@ android { } } +kotlin { + compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } +} + kapt { correctErrorTypes = true } @@ -64,8 +66,11 @@ kapt { dependencies { implementation(project(":domain")) implementation(project(":android:core:designsystem")) + implementation(project(":android:core:preferences")) + implementation(project(":android:core:devicecatalog")) implementation(project(":android:core:session")) implementation(project(":android:feature:infrared")) + implementation(project(":android:feature:automation")) implementation(project(":absdk-bridge")) implementation(project(":runtime:simulation")) debugImplementation(project(":testing:fakes")) @@ -95,4 +100,5 @@ dependencies { androidTestImplementation(libs.compose.ui.test.junit4) debugImplementation(libs.compose.ui.tooling) debugImplementation(libs.compose.ui.test.manifest) + screenshotTestImplementation(libs.compose.screenshot.test) } 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/navigation/AuthenticatedNavigationRoutingTest.kt b/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/navigation/AuthenticatedNavigationRoutingTest.kt new file mode 100644 index 0000000..cfc6f8b --- /dev/null +++ b/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/navigation/AuthenticatedNavigationRoutingTest.kt @@ -0,0 +1,215 @@ +package com.flagship.abox.manager.ui.navigation + +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +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.assertTextEquals +import androidx.compose.ui.test.junit4.StateRestorationTester +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument +import com.flagship.abox.manager.designsystem.ABoxTheme +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.ui.shell.ABoxWindowSizeClass +import com.flagship.abox.manager.ui.shell.DeviceSelection +import com.flagship.abox.manager.ui.shell.DetailPresentation +import com.flagship.abox.manager.ui.shell.MainTab +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class AuthenticatedNavigationRoutingTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun compactDetailBackPopsDetailBeforeParentDestination() { + composeRule.setContent { ABoxTheme { RoutingFixture() } } + + composeRule.onNodeWithTag("routing_select_device").performClick() + composeRule.onNodeWithTag("routing_full_screen_detail").assertExists() + + composeRule.runOnUiThread { + composeRule.activity.onBackPressedDispatcher.onBackPressed() + } + composeRule.waitForIdle() + + composeRule.onNodeWithTag("routing_full_screen_detail").assertDoesNotExist() + composeRule.onNodeWithTag("routing_devices_root").assertExists() + composeRule.onNodeWithTag("routing_presentation").assertTextEquals("NONE") + } + + @Test + fun reselectingTabPopsActualNavControllerToItsRoot() { + composeRule.setContent { ABoxTheme { RoutingFixture() } } + + composeRule.onNodeWithTag("routing_scenes_tab").performClick() + composeRule.onNodeWithTag("routing_scene_child").performClick() + composeRule.onNodeWithTag("routing_scenes_tab").performClick() + composeRule.waitForIdle() + + composeRule.onNodeWithTag("routing_scenes_root").assertExists() + composeRule.onNodeWithTag("routing_scene_child_destination").assertDoesNotExist() + } + + @Test + fun resizeReconcilesActualRouteAndPaneFromOneSavedSelection() { + composeRule.setContent { + ABoxTheme { + RoutingFixture(initialWindowSizeClass = ABoxWindowSizeClass.EXPANDED) + } + } + + composeRule.onNodeWithTag("routing_select_device").performClick() + composeRule.onNodeWithTag("routing_route").assertTextEquals(MainTab.DEVICES.route) + composeRule.onNodeWithTag("routing_presentation").assertTextEquals(DetailPresentation.PANE.name) + composeRule.onNodeWithTag("routing_pane_detail").assertExists() + composeRule.onNodeWithTag("routing_full_screen_detail").assertDoesNotExist() + + composeRule.onNodeWithTag("routing_toggle_size").performClick() + composeRule.waitForIdle() + composeRule.onNodeWithTag("routing_full_screen_detail").assertExists() + composeRule.onNodeWithTag("routing_route").assertTextEquals("test/device/socket") + composeRule.onNodeWithTag("routing_presentation").assertTextEquals(DetailPresentation.FULL_SCREEN.name) + composeRule.onNodeWithTag("routing_pane_detail").assertDoesNotExist() + + composeRule.onNodeWithTag("routing_toggle_size").performClick() + composeRule.waitForIdle() + composeRule.onNodeWithTag("routing_pane_detail").assertExists() + composeRule.onNodeWithTag("routing_full_screen_detail").assertDoesNotExist() + composeRule.onNodeWithTag("routing_route").assertTextEquals(MainTab.DEVICES.route) + composeRule.onNodeWithTag("routing_presentation").assertTextEquals(DetailPresentation.PANE.name) + } + + @Test + fun savedSelectionSurvivesStateRestorationAndProfileReplacementClearsIt() { + val restorationTester = StateRestorationTester(composeRule) + restorationTester.setContent { + ABoxTheme { + RoutingFixture(initialWindowSizeClass = ABoxWindowSizeClass.EXPANDED) + } + } + + composeRule.onNodeWithTag("routing_select_device").performClick() + composeRule.onNodeWithTag("routing_presentation").assertTextEquals(DetailPresentation.PANE.name) + + restorationTester.emulateSavedInstanceStateRestore() + composeRule.onNodeWithTag("routing_presentation").assertTextEquals(DetailPresentation.PANE.name) + composeRule.onNodeWithTag("routing_pane_detail").assertExists() + + composeRule.onNodeWithTag("routing_replace_profile").performClick() + composeRule.waitForIdle() + composeRule.onNodeWithTag("routing_presentation").assertTextEquals("NONE") + composeRule.onNodeWithTag("routing_route").assertTextEquals(MainTab.DEVICES.route) + composeRule.onNodeWithTag("routing_pane_detail").assertDoesNotExist() + } +} + +@Composable +private fun RoutingFixture( + initialWindowSizeClass: ABoxWindowSizeClass = ABoxWindowSizeClass.COMPACT, +) { + var windowSizeClass by remember { mutableStateOf(initialWindowSizeClass) } + var profileId by remember { mutableStateOf(ProviderProfileId("profile")) } + + Column(modifier = Modifier.fillMaxSize()) { + Button( + onClick = { + windowSizeClass = if (windowSizeClass == ABoxWindowSizeClass.COMPACT) { + ABoxWindowSizeClass.EXPANDED + } else { + ABoxWindowSizeClass.COMPACT + } + }, + modifier = Modifier.testTag("routing_toggle_size"), + ) { Text("Resize") } + Button( + onClick = { profileId = ProviderProfileId("replacement") }, + modifier = Modifier.testTag("routing_replace_profile"), + ) { Text("Replace profile") } + + key(profileId.value) { + val navController = rememberNavController() + AuthenticatedNavigationStateHost( + profileId = profileId, + windowSizeClass = windowSizeClass, + navController = navController, + deviceRoute = { "test/device/${it.deviceName.value}" }, + selectionForRoute = { route -> + route + ?.takeIf { it.startsWith("test/device/") } + ?.removePrefix("test/device/") + ?.let { deviceName -> + DeviceSelection( + profileId = profileId, + deviceName = DeviceName(deviceName), + type = DeviceType.SOCKET, + ) + } + }, + ) { snapshot, onTabClick, onSelectDevice, _ -> + Text(snapshot.currentRoute.orEmpty(), modifier = Modifier.testTag("routing_route")) + Text( + snapshot.detailPresentation?.name ?: "NONE", + modifier = Modifier.testTag("routing_presentation"), + ) + Button( + onClick = { onSelectDevice(DeviceName("socket"), DeviceType.SOCKET) }, + modifier = Modifier.testTag("routing_select_device"), + ) { Text("Select") } + Button( + onClick = { onTabClick(MainTab.SCENES) }, + modifier = Modifier.testTag("routing_scenes_tab"), + ) { Text("Scenes") } + Button( + onClick = { navController.navigate("scene/child") }, + modifier = Modifier.testTag("routing_scene_child"), + ) { Text("Child") } + + if (snapshot.detailPresentation == DetailPresentation.PANE) { + Text("Pane detail", modifier = Modifier.testTag("routing_pane_detail")) + } + + NavHost( + navController = navController, + startDestination = MainTab.DEVICES.route, + ) { + composable(MainTab.DEVICES.route) { + Text("Devices", modifier = Modifier.testTag("routing_devices_root")) + } + composable(MainTab.SCENES.route) { + Text("Scenes", modifier = Modifier.testTag("routing_scenes_root")) + } + composable("scene/child") { + Text("Child", modifier = Modifier.testTag("routing_scene_child_destination")) + } + composable( + route = "test/device/{deviceName}", + arguments = listOf(navArgument("deviceName") { type = NavType.StringType }), + ) { + Text("Full screen detail", modifier = Modifier.testTag("routing_full_screen_detail")) + } + } + } + } + } +} 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 new file mode 100644 index 0000000..8a5d630 --- /dev/null +++ b/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/preferences/SettingsScreenTest.kt @@ -0,0 +1,126 @@ +package com.flagship.abox.manager.ui.preferences + +import androidx.activity.ComponentActivity +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +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 + +@RunWith(AndroidJUnit4::class) +class SettingsScreenTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun interfaceStyleOpensMenuBeforeChangingRenderer() { + val actions = mutableListOf() + composeRule.setContent { + ABoxTheme { + SettingsScreen( + state = SettingsUiState(preferences = UiPreferences(renderer = DesignRenderer.MATERIAL3), loaded = true), + onAction = actions::add, + ) + } + } + + 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 materialThemeShowsEveryMaterialOptionInReferenceOrder() { + showTheme(DesignRenderer.MATERIAL3) + + assertVerticalOrder( + "settings-material-colors", + "settings-theme-mode", + "settings-color-style", + "settings-color-standard", + "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-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) { + 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.onNodeWithTag("settings-theme-entry").performClick() + + 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/shell/AccountProviderDialogTest.kt b/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/shell/AccountProviderDialogTest.kt new file mode 100644 index 0000000..c1581de --- /dev/null +++ b/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/shell/AccountProviderDialogTest.kt @@ -0,0 +1,73 @@ +package com.flagship.abox.manager.ui.shell + +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.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.flagship.abox.manager.designsystem.ABoxTheme +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class AccountProviderDialogTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun dialogExposesAccountActionsAndClose() { + var closed = 0 + composeRule.setContent { + ABoxTheme { + AccountProviderDialog( + userName = "user", + providerName = "ABox", + onDismissRequest = { closed++ }, + onOpenSettings = {}, + onSwitchProvider = {}, + onEndSession = {}, + onForgetCredentials = {}, + ) + } + } + + composeRule.onNodeWithText("账户与提供方").assertIsDisplayed() + composeRule.onNodeWithTag("account_dialog_close").performClick() + + assertEquals(1, closed) + } + + @Test + fun destructiveActionsRequireSeparateConfirmation() { + var ended = 0 + var forgotten = 0 + composeRule.setContent { + ABoxTheme { + AccountProviderDialog( + userName = "user", + providerName = "ABox", + onDismissRequest = {}, + onOpenSettings = {}, + onSwitchProvider = {}, + onEndSession = { ended++ }, + onForgetCredentials = { forgotten++ }, + ) + } + } + + composeRule.onNodeWithTag("account_dialog_end_session").performClick() + composeRule.onNodeWithText("退出当前会话").assertIsDisplayed() + composeRule.onNodeWithTag("account_dialog_confirm").performClick() + assertEquals(1, ended) + assertEquals(0, forgotten) + + composeRule.onNodeWithTag("account_dialog_forget_credentials").performClick() + composeRule.onNodeWithText("清除保存的凭据").assertIsDisplayed() + composeRule.onNodeWithTag("account_dialog_confirm").performClick() + assertEquals(1, forgotten) + } +} diff --git a/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/shell/MainAppShellTest.kt b/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/shell/MainAppShellTest.kt new file mode 100644 index 0000000..e00078f --- /dev/null +++ b/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/shell/MainAppShellTest.kt @@ -0,0 +1,92 @@ +package com.flagship.abox.manager.ui.shell + +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.material3.Text +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.flagship.abox.manager.designsystem.ABoxTheme +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class MainAppShellTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun compactUsesBottomNavigationAndDelegatesTabClick() { + var selectedTab: MainTab? = null + composeRule.setContent { + ABoxTheme { + MainAppShell( + currentTab = MainTab.DEVICES, + windowSizeClass = ABoxWindowSizeClass.COMPACT, + userName = "user", + providerName = "ABox", + onTabClick = { selectedTab = it }, + onProviderClick = {}, + onAvatarClick = {}, + content = { _: PaddingValues -> Text("content") }, + ) + } + } + + composeRule.onNodeWithTag("main_tab_devices").assertIsDisplayed() + composeRule.onNodeWithTag("main_tab_scenes").performClick() + + assertEquals(MainTab.SCENES, selectedTab) + } + + @Test + fun mediumAndExpandedExposeRailNavigation() { + composeRule.setContent { + ABoxTheme { + MainAppShell( + currentTab = MainTab.DEVICES, + windowSizeClass = ABoxWindowSizeClass.MEDIUM, + userName = "user", + providerName = "ABox", + onTabClick = {}, + onProviderClick = {}, + onAvatarClick = {}, + content = { _: PaddingValues -> Text("content") }, + ) + } + } + + composeRule.onNodeWithText("Scenes").assertIsDisplayed() + } + + @Test + fun topAreaDelegatesProviderAndAvatarClicks() { + var providerClicks = 0 + var avatarClicks = 0 + composeRule.setContent { + ABoxTheme { + MainAppShell( + currentTab = MainTab.DEVICES, + windowSizeClass = ABoxWindowSizeClass.COMPACT, + userName = "user", + providerName = "ABox", + onTabClick = {}, + onProviderClick = { providerClicks++ }, + onAvatarClick = { avatarClicks++ }, + content = { _: PaddingValues -> Text("content") }, + ) + } + } + + composeRule.onNodeWithTag("main_provider_pill").performClick() + composeRule.onNodeWithTag("main_avatar").performClick() + + assertEquals(1, providerClicks) + assertEquals(1, avatarClicks) + } +} 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/debug/kotlin/com/flagship/abox/manager/di/FoundationModule.kt b/android/app/src/debug/kotlin/com/flagship/abox/manager/di/FoundationModule.kt deleted file mode 100644 index cc805b8..0000000 --- a/android/app/src/debug/kotlin/com/flagship/abox/manager/di/FoundationModule.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.flagship.abox.manager.di - -import com.flagship.abox.manager.domain.DeviceCatalog -import com.flagship.abox.manager.testing.fakes.FakeDeviceCatalog -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import javax.inject.Singleton - -@Module -@InstallIn(SingletonComponent::class) -object FoundationModule { - // TEMPORARY boundary: ABSDK has no device catalog, and the provider-scoped production - // ABox catalog is owned by the device-catalog module. Until that exists, debug builds - // keep the fake ABox catalog here. Do NOT route ABox catalog requests to - // SimulationDeviceCatalog; release builds must not request a DeviceCatalog binding - // until a real release consumer appears. - @Provides - @Singleton - fun provideDeviceCatalog(): DeviceCatalog = FakeDeviceCatalog() -} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 5b9189a..508eb01 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,8 +1,12 @@ + delegate(provider.value).getStatus(profileId, deviceName) } + override suspend fun commandPower( + profileId: ProviderProfileId, + deviceName: DeviceName, + powerState: SocketPowerState, + ): DomainResult = when (val provider = resolver.resolve(profileId)) { + is DomainResult.Failure -> provider + is DomainResult.Success -> delegate(provider.value).commandPower(profileId, deviceName, powerState) + } + override suspend fun setPower( profileId: ProviderProfileId, deviceName: DeviceName, diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/di/SceneIntegrationModule.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/di/SceneIntegrationModule.kt index 24df299..a2c537f 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/di/SceneIntegrationModule.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/di/SceneIntegrationModule.kt @@ -7,6 +7,8 @@ import com.flagship.abox.manager.domain.SceneExecutor import com.flagship.abox.manager.domain.SceneRepository import com.flagship.abox.manager.domain.SocketGateway import com.flagship.abox.manager.domain.infrared.SendVirtualRemoteAction +import com.flagship.abox.manager.domain.ActivityEventWriter +import com.flagship.abox.manager.ui.scene.RecordingSceneExecutor import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -29,9 +31,13 @@ object SceneIntegrationModule { socketGateway: SocketGateway, infraredGateway: InfraredGateway, sendVirtualRemoteAction: SendVirtualRemoteAction, - ): SceneExecutor = DefaultSceneExecutor( - socketGateway = socketGateway, - infraredGateway = infraredGateway, - sendVirtualRemoteAction = sendVirtualRemoteAction, + activityWriter: ActivityEventWriter, + ): SceneExecutor = RecordingSceneExecutor( + delegate = DefaultSceneExecutor( + socketGateway = socketGateway, + infraredGateway = infraredGateway, + sendVirtualRemoteAction = sendVirtualRemoteAction, + ), + writer = activityWriter, ) } diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/di/UiPreferencesModule.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/di/UiPreferencesModule.kt new file mode 100644 index 0000000..674cde5 --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/di/UiPreferencesModule.kt @@ -0,0 +1,21 @@ +package com.flagship.abox.manager.di + +import android.content.Context +import com.flagship.abox.manager.preferences.DataStoreUiPreferencesRepository +import com.flagship.abox.manager.preferences.UiPreferencesRepository +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object UiPreferencesModule { + @Provides + @Singleton + fun provideUiPreferencesRepository( + @ApplicationContext context: Context, + ): UiPreferencesRepository = DataStoreUiPreferencesRepository(context) +} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt index 42bda6b..5925e6e 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt @@ -7,56 +7,60 @@ import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation.NavType -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.composable -import androidx.navigation.compose.rememberNavController -import androidx.navigation.navArgument import com.flagship.abox.manager.designsystem.ABoxLoadingIndicator import com.flagship.abox.manager.designsystem.ABoxSurface import com.flagship.abox.manager.designsystem.ABoxTheme -import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.designsystem.ABoxBackLayer import com.flagship.abox.manager.domain.ProviderProfile import com.flagship.abox.manager.domain.Username -import com.flagship.abox.manager.infrared.hub.InfraredHubRoute -import com.flagship.abox.manager.infrared.panel.RemotePanelRoute -import com.flagship.abox.manager.infrared.ui.InfraredControlRoute -import com.flagship.abox.manager.ui.devicecontrollist.DeviceListScreen import com.flagship.abox.manager.ui.login.LoginRoute import com.flagship.abox.manager.ui.login.LoginViewModel -import com.flagship.abox.manager.ui.navigation.deviceDetailGraph -import com.flagship.abox.manager.ui.navigation.navigateToSocket -import com.flagship.abox.manager.ui.navigation.navigateToTemperatureHumidity +import com.flagship.abox.manager.ui.navigation.AuthenticatedNavigation +import com.flagship.abox.manager.ui.preferences.SettingsRoute +import com.flagship.abox.manager.ui.preferences.SettingsExperienceState +import com.flagship.abox.manager.ui.preferences.SettingsViewModel import com.flagship.abox.manager.ui.provider.ProviderSelectionRoute -import com.flagship.abox.manager.ui.scene.SceneListScreen -import com.flagship.abox.manager.ui.scene.SceneRoutes -import com.flagship.abox.manager.ui.scene.sceneEditorDestination -import com.flagship.abox.manager.ui.scene.sceneExecutionDestination import com.flagship.abox.manager.ui.session.AppSessionUiState import com.flagship.abox.manager.ui.session.AppSessionViewModel import com.flagship.abox.manager.ui.session.SessionActionsViewModel -import com.flagship.abox.manager.ui.shell.MainAppShell +import com.flagship.abox.manager.ui.shell.AccountProviderDialog +import com.flagship.abox.manager.ui.session.SessionUiAction @Composable fun ABoxManagerApp( appSessionViewModel: AppSessionViewModel = hiltViewModel(), sessionActionsViewModel: SessionActionsViewModel = hiltViewModel(), loginViewModel: LoginViewModel = hiltViewModel(), + settingsViewModel: SettingsViewModel = hiltViewModel(), ) { val actionState = sessionActionsViewModel.uiState.collectAsStateWithLifecycle().value val sessionState = appSessionViewModel.uiState.collectAsStateWithLifecycle().value + val settingsState = settingsViewModel.uiState.collectAsStateWithLifecycle().value + val actionError = actionState.errorMessage?.asString() val screenKey = sessionState.screenKey() + var showAccountDialog by rememberSaveable(screenKey) { mutableStateOf(false) } + var showSettings by rememberSaveable(screenKey) { mutableStateOf(false) } + val settingsExperienceState = rememberSaveable(screenKey, saver = SettingsExperienceState.Saver) { + SettingsExperienceState() + } + LaunchedEffect(screenKey) { sessionActionsViewModel.dismissError() if (sessionState !is AppSessionUiState.Login) { loginViewModel.clearSensitiveState() } } - ABoxTheme { + + ABoxTheme(preferences = settingsState.preferences) { ABoxSurface(modifier = Modifier.fillMaxSize()) { when (val state = sessionState) { AppSessionUiState.Loading -> Box( @@ -71,7 +75,7 @@ fun ABoxManagerApp( is AppSessionUiState.Login -> LoginRoute( profile = state.profile, bootstrapError = state.bootstrapError, - actionError = actionState.errorMessage, + actionError = actionError, actionInProgress = actionState.inProgress, onSwitchProvider = sessionActionsViewModel::switchProvider, viewModel = loginViewModel, @@ -79,9 +83,13 @@ fun ABoxManagerApp( is AppSessionUiState.Authenticated -> AuthenticatedNavHost( username = state.username, profile = state.profile, - actionInProgress = actionState.inProgress, - errorMessage = actionState.errorMessage, onLogout = sessionActionsViewModel::logout, + onSessionAction = sessionActionsViewModel::dispatch, + showAccountDialog = showAccountDialog, + showSettings = showSettings, + settingsExperienceState = settingsExperienceState, + onShowAccountDialogChange = { showAccountDialog = it }, + onShowSettingsChange = { showSettings = it }, ) } } @@ -92,104 +100,58 @@ fun ABoxManagerApp( private fun AuthenticatedNavHost( username: Username, profile: ProviderProfile, - actionInProgress: Boolean, - errorMessage: String?, onLogout: () -> Unit, + onSessionAction: (SessionUiAction) -> Unit, + showAccountDialog: Boolean, + showSettings: Boolean, + settingsExperienceState: SettingsExperienceState, + onShowAccountDialogChange: (Boolean) -> Unit, + onShowSettingsChange: (Boolean) -> Unit, ) { - val navController = rememberNavController() - NavHost( - navController = navController, - startDestination = "home", - ) { - composable("home") { - MainAppShell( - userName = username.value, - providerName = profile.displayName, - onLogout = onLogout, - actionInProgress = actionInProgress, - errorMessage = errorMessage, - devicesSlot = { - val encodedProfileId = android.net.Uri.encode(profile.id.value) - DeviceListScreen( - onSocketClick = { - navController.navigateToSocket( - profileId = profile.id, - deviceName = DeviceName("Zigbee插座控制器"), - ) - }, - onTemperatureHumidityClick = { - navController.navigateToTemperatureHumidity( - profileId = profile.id, - deviceName = DeviceName("温湿度传感器"), - ) - }, - onInfraredClick = { - navController.navigate("infrared_hub/$encodedProfileId") - }, - ) - }, - scenesSlot = { - 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)) }, - ) - }, - ) - } - deviceDetailGraph( - onBackClick = navController::popBackStack, + Box(Modifier.fillMaxSize()) { + AuthenticatedNavigation( + username = username, + profile = profile, + onLogout = onLogout, + onProviderClick = { onShowAccountDialogChange(true) }, + onAvatarClick = { onShowAccountDialogChange(true) }, ) - sceneEditorDestination( - onBack = { navController.popBackStack() }, - onSaved = { navController.popBackStack() }, + if (showSettings) { + ABoxBackLayer( + onBack = { onShowSettingsChange(false) }, + enabled = settingsExperienceState.isHome, + ) { + SettingsRoute( + experienceState = settingsExperienceState, + onExit = { onShowSettingsChange(false) }, + ) + } + } + } + + if (showAccountDialog) { + AccountProviderDialog( + userName = username.value, + providerName = profile.displayName, + onDismissRequest = { onShowAccountDialogChange(false) }, + onOpenSettings = { + onShowAccountDialogChange(false) + settingsExperienceState.showTheme(returnToHome = false) + onShowSettingsChange(true) + }, + onSwitchProvider = { + onShowAccountDialogChange(false) + onSessionAction(SessionUiAction.SwitchProvider) + }, + onEndSession = { + onShowAccountDialogChange(false) + onSessionAction(SessionUiAction.EndSession) + }, + onForgetCredentials = { + onShowAccountDialogChange(false) + onSessionAction(SessionUiAction.ForgetSavedCredentials) + }, ) - sceneExecutionDestination( - onBack = { navController.popBackStack() }, - ) - composable( - route = "infrared_hub/{profileId}", - arguments = listOf( - navArgument("profileId") { type = NavType.StringType }, - ), - ) { - val encodedProfileId = android.net.Uri.encode(profile.id.value) - InfraredHubRoute( - onBack = { navController.popBackStack() }, - onOpenRemote = { remoteId -> - val encodedRemoteId = android.net.Uri.encode(remoteId) - navController.navigate("infrared_panel/$encodedProfileId/$encodedRemoteId") - }, - onOpenSettings = { - navController.navigate("infrared_settings/$encodedProfileId") - }, - onSessionInvalid = onLogout, - ) - } - composable( - route = "infrared_panel/{profileId}/{remoteId}", - arguments = listOf( - navArgument("profileId") { type = NavType.StringType }, - navArgument("remoteId") { type = NavType.StringType }, - ), - ) { - RemotePanelRoute( - onBack = { navController.popBackStack() }, - onSessionInvalid = onLogout, - ) - } - composable( - route = "infrared_settings/{profileId}", - arguments = listOf( - navArgument("profileId") { type = NavType.StringType }, - ), - ) { - InfraredControlRoute( - onBack = { navController.popBackStack() }, - onSessionInvalid = onLogout, - ) - } } } diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/common/DomainErrorMapper.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/common/DomainErrorMapper.kt index fe37e14..9e47752 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/common/DomainErrorMapper.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/common/DomainErrorMapper.kt @@ -13,13 +13,6 @@ object DomainErrorMapper { is DomainError.InvalidRequest -> UiText.StringResource(R.string.error_invalid_request) is DomainError.OperationFailed -> UiText.StringResource(R.string.error_operation_failed) is DomainError.NoData -> UiText.StringResource(R.string.error_no_data) - is DomainError.Unknown -> { - val detail = error.detail - if (!detail.isNullOrBlank()) { - UiText.DynamicString(detail) - } else { - UiText.StringResource(R.string.error_unknown) - } - } + is DomainError.Unknown -> UiText.StringResource(R.string.error_unknown) } } 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 new file mode 100644 index 0000000..af95ad2 --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/devicecenter/DeviceCenterModels.kt @@ -0,0 +1,55 @@ +package com.flagship.abox.manager.ui.devicecenter + +import com.flagship.abox.manager.domain.DeviceDescriptor +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.domain.DeviceType +import com.flagship.abox.manager.domain.ProviderProfileId +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, + val device: DeviceDescriptor, + val displayName: String = device.name.value, + val typeLabel: String = when (device.type) { + DeviceType.SOCKET -> "插座" + 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, + val filter: DeviceCategoryFilter = DeviceCategoryFilter.ALL, + val cards: List = emptyList(), + val selectedCard: DeviceCardKey? = null, + val content: DeviceCenterContent = DeviceCenterContent.LOADING, +) diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/devicecenter/DeviceCenterRoute.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/devicecenter/DeviceCenterRoute.kt new file mode 100644 index 0000000..353568c --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/devicecenter/DeviceCenterRoute.kt @@ -0,0 +1,31 @@ +package com.flagship.abox.manager.ui.devicecenter + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flagship.abox.manager.domain.DeviceType +import com.flagship.abox.manager.domain.ProviderProfileId + +@Composable +fun DeviceCenterRoute( + profileId: ProviderProfileId, + onOpenDevice: (String, DeviceType) -> Unit, + modifier: Modifier = Modifier, + viewModel: DeviceCenterViewModel = hiltViewModel(), +) { + val state = viewModel.uiState.collectAsStateWithLifecycle().value + LaunchedEffect(profileId) { viewModel.initialize(profileId) } + DeviceCenterScreen( + state = state, + onFilterChanged = viewModel::setFilter, + onOpenDevice = { card -> onOpenDevice(card.device.name.value, card.device.type) }, + onConfigureDevice = viewModel::addConfiguredDevice, + 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 new file mode 100644 index 0000000..8cac803 --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/devicecenter/DeviceCenterScreen.kt @@ -0,0 +1,350 @@ +package com.flagship.abox.manager.ui.devicecenter + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.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 +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.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( + state: DeviceCenterUiState, + onFilterChanged: (DeviceCategoryFilter) -> Unit, + onOpenDevice: (DeviceCardUiModel) -> Unit, + onConfigureDevice: (String, DeviceType) -> Unit, + onQuickAction: (DeviceCardUiModel) -> Unit, + modifier: Modifier = Modifier, +) { + var showConfiguration by remember { mutableStateOf(false) } + Column( + modifier = modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + 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(stringResource(R.string.device_center_loading), modifier = Modifier.testTag("device_center_loading")) + DeviceCenterContent.EMPTY -> ABoxEmptyState( + 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(stringResource(R.string.device_center_configure)) } + }, + ) + 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), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.testTag("device_center_grid"), + ) { + 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) }) + } + } + } + } + } + + if (showConfiguration) { + DeviceConfigurationDialog( + onDismiss = { showConfiguration = false }, + onConfirm = { name, type -> + showConfiguration = false + onConfigureDevice(name, type) + }, + ) + } +} + +@Composable +private fun DeviceConfigurationDialog( + onDismiss: () -> Unit, + onConfirm: (String, DeviceType) -> Unit, +) { + var name by remember { mutableStateOf("") } + var type by remember { mutableStateOf(DeviceType.SOCKET) } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.device_center_configure_title)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text(stringResource(R.string.device_center_device_name)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + FilterRow( + selected = when (type) { + DeviceType.SOCKET -> DeviceCategoryFilter.SOCKET + DeviceType.TEMPERATURE_HUMIDITY -> DeviceCategoryFilter.SENSOR + DeviceType.INFRARED -> DeviceCategoryFilter.INFRARED + }, + onSelected = { selected -> + type = when (selected) { + DeviceCategoryFilter.SOCKET -> DeviceType.SOCKET + DeviceCategoryFilter.SENSOR -> DeviceType.TEMPERATURE_HUMIDITY + DeviceCategoryFilter.INFRARED -> DeviceType.INFRARED + DeviceCategoryFilter.ALL -> type + } + }, + ) + } + }, + confirmButton = { + Button(onClick = { onConfirm(name.trim(), type) }, enabled = name.isNotBlank()) { Text(stringResource(R.string.action_confirm)) } + }, + dismissButton = { Button(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } }, + ) +} + +@Composable +private fun FilterRow( + selected: DeviceCategoryFilter, + onSelected: (DeviceCategoryFilter) -> Unit, +) { + androidx.compose.foundation.lazy.LazyRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + item { FilterChipItem(DeviceCategoryFilter.ALL, "全部", selected, onSelected) } + item { FilterChipItem(DeviceCategoryFilter.SOCKET, "插座", selected, onSelected) } + item { FilterChipItem(DeviceCategoryFilter.SENSOR, "温湿度", selected, onSelected) } + item { FilterChipItem(DeviceCategoryFilter.INFRARED, "红外", selected, onSelected) } + } +} + +@Composable +private fun FilterChipItem( + filter: DeviceCategoryFilter, + label: String, + selected: DeviceCategoryFilter, + onSelected: (DeviceCategoryFilter) -> Unit, +) { + ABoxFilterChip(selected = selected == filter, label = label, onClick = { onSelected(filter) }) +} + +@Composable +private fun 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) + } +} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/devicecenter/DeviceCenterViewModel.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/devicecenter/DeviceCenterViewModel.kt new file mode 100644 index 0000000..de15933 --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/devicecenter/DeviceCenterViewModel.kt @@ -0,0 +1,216 @@ +package com.flagship.abox.manager.ui.devicecenter + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.flagship.abox.manager.domain.DeviceCatalog +import com.flagship.abox.manager.domain.DeviceDescriptor +import com.flagship.abox.manager.domain.DeviceType +import com.flagship.abox.manager.domain.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 + +@HiltViewModel +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() + private var catalogJob: Job? = null + private var allCards: List = emptyList() + + fun initialize(profileId: ProviderProfileId) { + if (_uiState.value.profileId == profileId && catalogJob?.isActive == true) return + catalogJob?.cancel() + allCards = emptyList() + _uiState.value = DeviceCenterUiState(profileId = profileId) + catalogJob = viewModelScope.launch { + 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) + } + } + } + } + + fun setFilter(filter: DeviceCategoryFilter) { + _uiState.value = _uiState.value.copy(filter = filter) + publishCards() + } + + fun select(device: DeviceDescriptor) { + _uiState.value = _uiState.value.copy(selectedCard = DeviceCardKey(device.profileId, device.name)) + } + + fun addConfiguredDevice(name: String, type: DeviceType) { + val profileId = _uiState.value.profileId ?: return + viewModelScope.launch { configuredRepository.add(profileId, com.flagship.abox.manager.domain.DeviceName(name), type) } + } + + fun removeConfiguredDevice(device: DeviceCardUiModel) { + val profileId = _uiState.value.profileId ?: return + viewModelScope.launch { configuredRepository.remove(profileId, device.device.name) } + } + + 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) } + _uiState.value = _uiState.value.copy(cards = cards, content = if (allCards.isEmpty()) DeviceCenterContent.EMPTY else DeviceCenterContent.CONTENT) + } + + private fun toCard(device: DeviceDescriptor) = DeviceCardUiModel(DeviceCardKey(device.profileId, device.name), device) + + private 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 + DeviceCategoryFilter.SENSOR -> type == DeviceType.TEMPERATURE_HUMIDITY + DeviceCategoryFilter.INFRARED -> type == DeviceType.INFRARED +} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/AuthenticatedNavigation.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/AuthenticatedNavigation.kt new file mode 100644 index 0000000..c32b8f3 --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/AuthenticatedNavigation.kt @@ -0,0 +1,537 @@ +package com.flagship.abox.manager.ui.navigation + +import android.net.Uri +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.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +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.platform.LocalConfiguration +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavHostController +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument +import com.flagship.abox.manager.R +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.domain.DeviceType +import com.flagship.abox.manager.domain.ProviderProfile +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.Username +import com.flagship.abox.manager.infrared.hub.InfraredHubRoute +import com.flagship.abox.manager.infrared.panel.RemotePanelRoute +import com.flagship.abox.manager.infrared.ui.InfraredControlRoute +import com.flagship.abox.manager.ui.devicecenter.DeviceCenterRoute +import com.flagship.abox.manager.automation.ui.activity.ActivityRoute +import com.flagship.abox.manager.ui.scene.SceneListScreen +import com.flagship.abox.manager.ui.preferences.SettingsRoute +import com.flagship.abox.manager.ui.scene.SceneRoutes +import com.flagship.abox.manager.ui.scene.sceneEditorDestination +import com.flagship.abox.manager.ui.scene.sceneExecutionDestination +import com.flagship.abox.manager.ui.shell.ABoxWindowSizeClass +import com.flagship.abox.manager.ui.shell.DetailPresentation +import com.flagship.abox.manager.ui.shell.DeviceSelection +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.ui.socket.SocketScreen +import com.flagship.abox.manager.ui.socket.SocketUiEvent +import com.flagship.abox.manager.ui.socket.SocketViewModel +import com.flagship.abox.manager.ui.temperaturehumidity.TemperatureHumidityScreen +import com.flagship.abox.manager.ui.temperaturehumidity.TemperatureHumidityUiEvent +import com.flagship.abox.manager.ui.temperaturehumidity.TemperatureHumidityViewModel +import java.net.URLDecoder +import java.nio.charset.StandardCharsets + +/** Pure authenticated graph state for tab, detail, and profile continuity tests. */ +data class AuthenticatedGraphState( + val profileId: ProviderProfileId, + val currentTab: MainTab = MainTab.DEVICES, + private val backStacks: Map> = MainTab.entries.associateWith { listOf(it.route) }, + val selectedDevice: DeviceSelection? = null, +) { + fun selectTab(tab: MainTab): AuthenticatedGraphState { + val nextStack = if (tab == currentTab) { + listOf(tab.route) + } else { + backStacks[tab].orEmpty().ifEmpty { listOf(tab.route) } + } + return copy( + currentTab = tab, + backStacks = backStacks + (tab to nextStack), + selectedDevice = null, + ) + } + + fun pushChild(route: String): AuthenticatedGraphState = copy( + backStacks = backStacks + (currentTab to (backStacks[currentTab].orEmpty() + route)), + ) + + fun tabBackStack(tab: MainTab): List = + backStacks[tab].orEmpty().ifEmpty { listOf(tab.route) } + + fun recreateForProfile(newProfileId: ProviderProfileId): AuthenticatedGraphState = + AuthenticatedGraphState(profileId = newProfileId) + + fun selectDevice(deviceName: DeviceName, type: DeviceType): AuthenticatedGraphState = copy( + selectedDevice = DeviceSelection(profileId, deviceName, type), + ) + + fun handleBack(): AuthenticatedGraphState = when { + selectedDevice != null -> copy(selectedDevice = null) + tabBackStack(currentTab).size > 1 -> copy( + backStacks = backStacks + (currentTab to tabBackStack(currentTab).dropLast(1)), + ) + else -> this + } +} + +data class AuthenticatedNavigationSnapshot( + val currentRoute: String?, + val selectedDevice: DeviceSelection?, + val detailPresentation: DetailPresentation?, +) + +private val NullableDeviceSelectionSaver: Saver = DeviceSelection.Saver + + +/** + * Navigation-owned state host. The authenticated graph and routing tests share this exact state + * machine so saved selection, route transitions, resize reconciliation, and BackHandler behavior + * cannot drift apart. + */ +@Composable +fun AuthenticatedNavigationStateHost( + profileId: ProviderProfileId, + windowSizeClass: ABoxWindowSizeClass, + navController: NavHostController, + deviceRoute: (DeviceSelection) -> String, + selectionForRoute: (String?) -> DeviceSelection? = { null }, + content: @Composable ( + snapshot: AuthenticatedNavigationSnapshot, + onTabClick: (MainTab) -> Unit, + onSelectDevice: (DeviceName, DeviceType) -> Unit, + onClearDeviceSelection: () -> Unit, + ) -> Unit, +) { + val backStackEntry by navController.currentBackStackEntryAsState() + val currentRoute = backStackEntry?.actualRoute() + val routeSelection = selectionForRoute(currentRoute)?.takeIf { it.profileId == profileId } + var savedSelection by rememberSaveable( + profileId.value, + stateSaver = NullableDeviceSelectionSaver, + ) { + mutableStateOf(null) + } + val currentTab = MainTab.fromRoute(currentRoute) ?: MainTab.DEVICES + val selectedDevice = when { + routeSelection != null -> routeSelection + currentRoute == MainTab.DEVICES.route -> savedSelection?.takeIf { it.profileId == profileId } + else -> null + } + val selectedPresentation = selectedDevice?.let { + detailPresentation(windowSizeClass, it) + } + + LaunchedEffect(profileId, currentRoute, selectedDevice, windowSizeClass) { + if (savedSelection?.profileId != null && savedSelection?.profileId != profileId) { + savedSelection = null + return@LaunchedEffect + } + + val selection = selectedDevice ?: return@LaunchedEffect + when (detailPresentation(windowSizeClass, selection)) { + DetailPresentation.PANE -> { + if (savedSelection != selection) { + savedSelection = selection + } + if (currentRoute != MainTab.DEVICES.route) { + navController.popBackStack(MainTab.DEVICES.route, inclusive = false) + } + } + DetailPresentation.FULL_SCREEN -> { + if (currentRoute == MainTab.DEVICES.route) { + navController.navigate(deviceRoute(selection)) { + launchSingleTop = true + } + savedSelection = null + } else if (savedSelection != null) { + savedSelection = null + } + } + } + } + + val snapshot = AuthenticatedNavigationSnapshot( + currentRoute = currentRoute, + selectedDevice = selectedDevice, + detailPresentation = selectedPresentation, + ) + 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 }, + ) +} + +@Composable +fun AuthenticatedNavigation( + username: Username, + profile: ProviderProfile, + onLogout: () -> Unit, + onProviderClick: () -> Unit, + onAvatarClick: () -> Unit, + onOpenSettings: () -> Unit = {}, + windowSizeClass: ABoxWindowSizeClass? = null, +) { + val resolvedWindowSizeClass = windowSizeClass ?: widthClass(LocalConfiguration.current.screenWidthDp) + key(profile.id.value) { + val navController = rememberNavController() + AuthenticatedNavigationStateHost( + profileId = profile.id, + windowSizeClass = resolvedWindowSizeClass, + navController = navController, + deviceRoute = { selection -> + when (selection.type) { + DeviceType.SOCKET -> DeviceDetailRoutes.socketRoute(profile.id, selection.deviceName) + DeviceType.TEMPERATURE_HUMIDITY -> DeviceDetailRoutes.temperatureHumidityRoute( + profile.id, + selection.deviceName, + ) + DeviceType.INFRARED -> Routes.infraredHub(profile.id.value, selection.deviceName.value) + } + }, + selectionForRoute = { route -> profile.selectionForRoute(route) }, + ) { snapshot, onTabClick, onSelectDevice, onClearDeviceSelection -> + val currentTab = MainTab.fromRoute(snapshot.currentRoute) ?: MainTab.DEVICES + AuthenticatedGraphContent( + navController = navController, + profile = profile, + username = username, + currentTab = currentTab, + windowSizeClass = resolvedWindowSizeClass, + isExpandedDevicePane = snapshot.detailPresentation == DetailPresentation.PANE && + snapshot.currentRoute == MainTab.DEVICES.route, + selectedDevice = snapshot.selectedDevice, + onTabClick = onTabClick, + onSelectDevice = onSelectDevice, + onClearDeviceSelection = onClearDeviceSelection, + onProviderClick = onProviderClick, + onAvatarClick = onAvatarClick, + onLogout = onLogout, + onOpenSettings = onOpenSettings, + ) + } + } +} + +@Composable +private fun AuthenticatedGraphContent( + 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, +) { + 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 +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, +) { + val activeBackStackEntry by navController.currentBackStackEntryAsState() + val activeRoute = activeBackStackEntry?.actualRoute() + var backNavigationInFlight by remember { mutableStateOf(false) } + LaunchedEffect(activeRoute) { + backNavigationInFlight = false + } + val navigateBackFromDetail: () -> Unit = { + val liveRoute = navController.currentBackStackEntry?.actualRoute() + val isMainDestination = MainTab.entries.any { it.route == liveRoute } + if (!backNavigationInFlight && !isMainDestination) { + backNavigationInFlight = true + if (!navController.popBackStack()) { + backNavigationInFlight = false + } + } + } + + NavHost( + navController = navController, + startDestination = MainTab.DEVICES.route, + modifier = modifier, + ) { + composable(MainTab.DEVICES.route) { + 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) { + 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) { + 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() + } + deviceDetailGraph( + onBackClick = { + onClearDeviceSelection() + navigateBackFromDetail() + }, + ) + sceneEditorDestination( + onBack = navigateBackFromDetail, + onSaved = { navController.popBackStack(MainTab.SCENES.route, inclusive = false) }, + ) + sceneExecutionDestination(onBack = navigateBackFromDetail) + composable( + route = Routes.INFRARED_HUB, + arguments = listOf( + navArgument("profileId") { type = NavType.StringType }, + navArgument("deviceName") { type = NavType.StringType }, + ), + ) { entry -> + InfraredHubRoute( + onBack = { + onClearDeviceSelection() + navigateBackFromDetail() + }, + onOpenRemote = { remoteId -> + navController.navigate( + "${Routes.INFRARED_PANEL}/${Uri.encode(profile.id.value)}/${Uri.encode(remoteId)}", + ) + }, + onOpenSettings = { + val deviceName = entry.arguments?.getString("deviceName").orEmpty() + navController.navigate(Routes.infraredSettings(profile.id.value, deviceName)) + }, + onSessionInvalid = onLogout, + ) + } + composable( + route = "${Routes.INFRARED_PANEL}/{profileId}/{remoteId}", + arguments = listOf( + navArgument("profileId") { type = NavType.StringType }, + navArgument("remoteId") { type = NavType.StringType }, + ), + ) { + RemotePanelRoute( + onBack = navigateBackFromDetail, + onSessionInvalid = onLogout, + ) + } + composable( + route = Routes.INFRARED_SETTINGS, + arguments = listOf( + navArgument("profileId") { type = NavType.StringType }, + navArgument("deviceName") { type = NavType.StringType }, + ), + ) { + InfraredControlRoute( + onBack = navigateBackFromDetail, + onSessionInvalid = onLogout, + ) + } + } +} + +@Composable +private fun DeviceDetailPane( + selection: DeviceSelection, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + val viewModelKey = "${selection.profileId.value}:${selection.deviceName.value}:${selection.type.name}" + when (selection.type) { + DeviceType.SOCKET -> { + val viewModel: SocketViewModel = hiltViewModel(key = viewModelKey) + val state by viewModel.state.collectAsStateWithLifecycle() + LaunchedEffect(selection) { + viewModel.onEvent(SocketUiEvent.Initialize(selection.profileId, selection.deviceName)) + } + SocketScreen( + state = state, + onEvent = viewModel::onEvent, + onBackClick = onBack, + modifier = modifier, + ) + } + DeviceType.TEMPERATURE_HUMIDITY -> { + val viewModel: TemperatureHumidityViewModel = hiltViewModel(key = viewModelKey) + val state by viewModel.state.collectAsStateWithLifecycle() + LaunchedEffect(selection) { + viewModel.onEvent( + TemperatureHumidityUiEvent.Initialize(selection.profileId, selection.deviceName), + ) + } + TemperatureHumidityScreen( + state = state, + onEvent = viewModel::onEvent, + onBackClick = onBack, + modifier = modifier, + ) + } + DeviceType.INFRARED -> Unit + } +} + +private fun ProviderProfile.selectionForRoute(route: String?): DeviceSelection? { + val parts = route?.split('/') ?: return null + return when { + parts.size >= 4 && parts[0] == "device" && parts[1] == "socket" -> DeviceSelection( + profileId = id, + deviceName = DeviceName(parts.drop(3).joinToString("/").decodeRoutePart()), + type = DeviceType.SOCKET, + ) + parts.size >= 4 && parts[0] == "device" && parts[1] == "th" -> DeviceSelection( + profileId = id, + deviceName = DeviceName(parts.drop(3).joinToString("/").decodeRoutePart()), + type = DeviceType.TEMPERATURE_HUMIDITY, + ) + parts.size >= 3 && parts[0] == "infrared_hub" -> DeviceSelection( + profileId = id, + deviceName = DeviceName(parts[2].decodeRoutePart()), + type = DeviceType.INFRARED, + ) + else -> null + } +} + +private fun String.decodeRoutePart(): String = runCatching { + URLDecoder.decode(this, StandardCharsets.UTF_8.name()) +}.getOrDefault(this) + +private fun NavBackStackEntry.actualRoute(): String? { + var route = destination.route ?: return null + destination.arguments.keys.forEach { argumentName -> + arguments?.getString(argumentName)?.let { value -> + route = route.replace("{$argumentName}", Uri.encode(value)) + } + } + return route +} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/Routes.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/Routes.kt index ee3cb43..ea9bf10 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/Routes.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/Routes.kt @@ -1,10 +1,21 @@ package com.flagship.abox.manager.ui.navigation +import android.net.Uri + object Routes { const val STARTUP = "startup" const val PROVIDER_SELECTION = "provider_selection" const val LOGIN = "login" - - /** Login-complete route contract; the device navigation owner replaces its placeholder. */ const val AUTHENTICATED = "authenticated" + const val SETTINGS = "settings" + + const val INFRARED_HUB = "infrared_hub/{profileId}/{deviceName}" + const val INFRARED_PANEL = "infrared_panel" + const val INFRARED_SETTINGS = "infrared_settings/{profileId}/{deviceName}" + + fun infraredHub(profileId: String, deviceName: String): String = + "infrared_hub/${Uri.encode(profileId)}/${Uri.encode(deviceName)}" + + fun infraredSettings(profileId: String, deviceName: String): String = + "infrared_settings/${Uri.encode(profileId)}/${Uri.encode(deviceName)}" } 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 new file mode 100644 index 0000000..415439d --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/preferences/SettingsScreen.kt @@ -0,0 +1,595 @@ +package com.flagship.abox.manager.ui.preferences + +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.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.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +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.ABoxErrorState +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.designsystem.tokens.ABoxAccentPalette +import com.flagship.abox.manager.designsystem.ABoxBackLayer +import com.flagship.abox.manager.designsystem.ABoxSurface +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) + var themeBackReturnsHome: Boolean by mutableStateOf(true) + val isHome: Boolean get() = destination == SettingsDestination.HOME.name + + fun showHome() { + destination = SettingsDestination.HOME.name + } + + fun showTheme(returnToHome: Boolean = true) { + themeBackReturnsHome = returnToHome + destination = SettingsDestination.THEME.name + } + + companion object { + val Saver: Saver = Saver( + save = { "${it.destination}|${it.themeBackReturnsHome}" }, + restore = { saved -> + val parts = saved.split('|', limit = 2) + SettingsExperienceState(parts.first()).apply { + themeBackReturnsHome = parts.getOrNull(1)?.toBooleanStrictOrNull() ?: true + } + }, + ) + } +} + +@Composable +fun SettingsRoute( + experienceState: SettingsExperienceState = SettingsExperienceState(), + onExit: () -> Unit = {}, + viewModel: SettingsViewModel = hiltViewModel(), +) { + val state = viewModel.uiState.collectAsStateWithLifecycle().value + 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) + Box(Modifier.fillMaxSize()) { + ABoxSurface(modifier = Modifier.fillMaxSize()) { + SettingsHomeScreen( + state = state, + onOpenTheme = { experienceState.showTheme(returnToHome = true) }, + onBack = onExit, + onAction = onAction, + ) + } + if (destination == SettingsDestination.THEME) { + val leaveTheme = { + if (experienceState.themeBackReturnsHome) experienceState.showHome() else onExit() + } + ABoxBackLayer(onBack = leaveTheme) { + ABoxSurface(modifier = Modifier.fillMaxSize()) { + ThemeSettingsScreen( + state = state, + onBack = leaveTheme, + onAction = onAction, + ) + } + } + } + } +} + +@Composable +fun SettingsScreen( + state: SettingsUiState, + onAction: (SettingsUiAction) -> Unit, + modifier: Modifier = Modifier, +) = SettingsHomeScreen(state, onOpenTheme = {}, onBack = {}, onAction = onAction, modifier = modifier) + +@Composable +private fun SettingsHomeScreen( + state: SettingsUiState, + onOpenTheme: () -> Unit, + onBack: () -> Unit, + onAction: (SettingsUiAction) -> Unit, + modifier: Modifier = Modifier, +) { + 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, + ) + } + + 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 ThemeSettingsScreen( + state: SettingsUiState, + onBack: () -> Unit, + onAction: (SettingsUiAction) -> Unit, +) { + SettingsPage( + title = stringResource(R.string.settings_theme_detail_title), + onBack = onBack, + modifier = Modifier.testTag("settings-theme-detail"), + ) { + ThemePreview(state) + when (state.preferences.renderer) { + DesignRenderer.MATERIAL3 -> MaterialThemeOptions(state, onAction) + DesignRenderer.MIUIX -> MiuixThemeOptions(state, onAction) + } + SettingsError(state) + } +} + +@Composable +private fun ColumnScope.MaterialThemeOptions(state: SettingsUiState, onAction: (SettingsUiAction) -> Unit) { + val manualColorsEnabled = !state.preferences.dynamicColorEnabled || Build.VERSION.SDK_INT < Build.VERSION_CODES.S + 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)) }, + enabled = manualColorsEnabled, + ) + 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)) }, + enabled = manualColorsEnabled, + ) + 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)) } + } + SettingsGroup(modifier = Modifier.testTag("settings-interface-scale")) { ScaleSelector(state, onAction) } +} + +@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, + enabled: Boolean = true, +) { + var expanded by remember { mutableStateOf(false) } + SettingsGroup(modifier = modifier) { + Box(Modifier.fillMaxWidth().alpha(if (enabled) 1f else 0.42f)) { + SettingsLinkRow( + icon = icon, + title = title, + description = stringResource(R.string.settings_tap_to_choose), + value = value, + enabled = enabled, + 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) { + val dark = when (state.preferences.themeMode) { + AppThemeMode.DARK -> true + AppThemeMode.LIGHT -> false + AppThemeMode.SYSTEM -> androidx.compose.foundation.isSystemInDarkTheme() + } + val manualColorsEnabled = !state.preferences.dynamicColorEnabled || Build.VERSION.SDK_INT < Build.VERSION_CODES.S + 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 (manualColorsEnabled) 1f else 0.42f), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + AccentPreference.entries.forEach { accent -> + val color = ABoxAccentPalette.preview( + accent = accent, + style = state.preferences.colorStyle, + standard = state.preferences.colorStandard, + dark = dark, + ) + 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 = manualColorsEnabled) { 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, enabled: Boolean = true, onClick: () -> Unit) { + Row(modifier.fillMaxWidth().clickable(enabled = enabled, 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 }) 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 new file mode 100644 index 0000000..061273c --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/preferences/SettingsViewModel.kt @@ -0,0 +1,156 @@ +package com.flagship.abox.manager.ui.preferences + +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 +import com.flagship.abox.manager.preferences.UiPreferencesRepository +import com.flagship.abox.manager.preferences.UiPreferencesWriteResult +import com.flagship.abox.manager.ui.common.UiText +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +@HiltViewModel +class SettingsViewModel @Inject constructor( + private val repository: UiPreferencesRepository, +) : ViewModel() { + private val _uiState = MutableStateFlow(SettingsUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val observeJob: Job = viewModelScope.launch { + repository.observe().collect { preferences -> + _uiState.value = _uiState.value.copy( + preferences = preferences, + loaded = true, + ) + } + } + + fun onAction(action: SettingsUiAction) { + when (action) { + is SettingsUiAction.SetRenderer -> update( + field = PreferenceField.RENDERER, + transform = { it.copy(renderer = action.value) }, + ) + is SettingsUiAction.SetThemeMode -> update( + field = PreferenceField.THEME_MODE, + transform = { it.copy(themeMode = action.value) }, + ) + is SettingsUiAction.SetDynamicColor -> update( + field = PreferenceField.DYNAMIC_COLOR, + transform = { it.copy(dynamicColorEnabled = action.value) }, + ) + is SettingsUiAction.SetBlur -> update( + field = PreferenceField.BLUR, + transform = { it.copy(blurEnabled = action.value) }, + ) + is SettingsUiAction.SetNavigation -> update( + field = PreferenceField.NAVIGATION, + transform = { it.copy(navigationPresentation = action.value) }, + ) + is SettingsUiAction.SetHaptics -> update( + 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) + } + } + + private fun update( + field: PreferenceField, + transform: (UiPreferences) -> UiPreferences, + ) { + if (field in _uiState.value.saving) return + _uiState.value = _uiState.value.copy( + saving = _uiState.value.saving + field, + error = null, + ) + viewModelScope.launch { + try { + when (repository.update(transform)) { + UiPreferencesWriteResult.Success -> Unit + UiPreferencesWriteResult.Failure -> showWriteFailure(field) + } + } catch (cause: CancellationException) { + throw cause + } catch (_: Throwable) { + showWriteFailure(field) + } finally { + _uiState.value = _uiState.value.copy(saving = _uiState.value.saving - field) + } + } + } + + private fun showWriteFailure(field: PreferenceField) { + _uiState.value = _uiState.value.copy( + error = UiText.StringResource(R.string.settings_error_save), + saving = _uiState.value.saving - field, + ) + } + + override fun onCleared() { + observeJob.cancel() + super.onCleared() + } +} + +data class SettingsUiState( + val preferences: UiPreferences = UiPreferences(), + val loaded: Boolean = false, + val saving: Set = emptySet(), + val error: UiText? = null, +) + +sealed interface SettingsUiAction { + data class SetRenderer(val value: DesignRenderer) : SettingsUiAction + data class SetThemeMode(val value: AppThemeMode) : SettingsUiAction + data class SetDynamicColor(val value: Boolean) : 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 314c4d7..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 @@ -81,12 +84,11 @@ import com.flagship.abox.manager.domain.SceneIcon import com.flagship.abox.manager.domain.SceneId import com.flagship.abox.manager.domain.SceneTrigger import com.flagship.abox.manager.domain.SocketPowerState +import com.flagship.abox.manager.domain.DeviceType +import com.flagship.abox.manager.domain.DeviceDescriptor import com.flagship.abox.manager.domain.infrared.RemoteKeyBinding import com.flagship.abox.manager.domain.infrared.VirtualRemote -private const val DEFAULT_SOCKET_DEVICE_NAME = "Zigbee插座控制器" -private const val DEFAULT_TH_DEVICE_NAME = "温湿度传感器" -private const val DEFAULT_IR_DEVICE_NAME = "红外控制器" @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -505,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) }, @@ -521,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) }, @@ -572,6 +574,7 @@ private fun SceneEditorContent( if (showTriggerDialog) { AddTriggerDialog( + availableDevices = state.availableDevices, onDismiss = { showTriggerDialog = false }, onAdd = { triggerType, devName, time, temp, hum, op -> when (triggerType) { @@ -586,6 +589,7 @@ private fun SceneEditorContent( if (showConditionDialog) { AddConditionDialog( + availableDevices = state.availableDevices, onDismiss = { showConditionDialog = false }, onAddTemp = { devName, op, temp -> viewModel.addTemperatureCondition(devName, op, temp) @@ -609,6 +613,7 @@ private fun SceneEditorContent( if (showActionDialog) { AddActionDialog( availableRemotes = state.availableRemotes, + availableDevices = state.availableDevices, onDismiss = { showActionDialog = false }, viewModel = viewModel, onAddSocket = { name, power -> @@ -745,7 +750,7 @@ private fun ActionItemRow( @Composable private fun SceneTrigger.describe(): String = when (this) { - is SceneTrigger.Time -> "⏰ $timeOfDay" + is SceneTrigger.Time -> "定时 $timeOfDay" is SceneTrigger.Temperature -> stringResource(R.string.scene_trigger_desc_temp, operator.symbol, thresholdCelsius.toInt().toString()) is SceneTrigger.Humidity -> stringResource(R.string.scene_trigger_desc_humidity, operator.symbol, thresholdPercent.toInt().toString()) is SceneTrigger.DeviceState -> stringResource(R.string.scene_trigger_desc_device, deviceName.value, expectedState) @@ -768,13 +773,28 @@ 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, onDismiss: () -> Unit, onAdd: (type: Int, deviceName: String, time: String, temp: Double, hum: Double, op: ComparisonOperator) -> Unit, ) { var selectedType by remember { mutableIntStateOf(0) } - var deviceName by remember { mutableStateOf(DEFAULT_TH_DEVICE_NAME) } + var deviceName by remember { mutableStateOf("") } var timeInput by remember { mutableStateOf("07:30") } var tempThreshold by remember { mutableDoubleStateOf(28.0) } var humThreshold by remember { mutableDoubleStateOf(40.0) } @@ -782,14 +802,18 @@ private fun AddTriggerDialog( val timeRegex = remember { Regex("^([01]\\d|2[0-3]):[0-5]\\d$") } val isTimeValid = timeRegex.matches(timeInput.trim()) - val isDeviceValid = selectedType == 0 || deviceName.isNotBlank() + val matchingDevices = availableDevices.filter { + it.type == if (selectedType == 1 || selectedType == 2) DeviceType.TEMPERATURE_HUMIDITY else DeviceType.SOCKET + } + val isDeviceValid = selectedType == 0 && isTimeValid || selectedType != 0 && + matchingDevices.any { it.name.value == deviceName } AlertDialog( onDismissRequest = onDismiss, 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)) }) @@ -809,14 +833,17 @@ private fun AddTriggerDialog( ) } 1, 2 -> { - OutlinedTextField( - value = deviceName, - onValueChange = { deviceName = it }, - label = { Text(stringResource(R.string.scene_device_name_label)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Text(stringResource(R.string.scene_device_name_label)) + SceneChoiceFlow { + matchingDevices.forEach { device -> + FilterChip( + selected = deviceName == device.name.value, + onClick = { deviceName = device.name.value }, + label = { Text(device.name.value) }, + ) + } + } + 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)) }) } @@ -848,6 +875,7 @@ private fun AddTriggerDialog( @Composable private fun AddConditionDialog( + availableDevices: List, onDismiss: () -> Unit, onAddTemp: (deviceName: String, ComparisonOperator, Double) -> Unit, onAddHumidity: (deviceName: String, ComparisonOperator, Double) -> Unit, @@ -855,9 +883,9 @@ private fun AddConditionDialog( onAddTimeRange: (String, String) -> Unit, ) { var selectedType by remember { mutableIntStateOf(0) } - var tempDeviceName by remember { mutableStateOf(DEFAULT_TH_DEVICE_NAME) } - var humDeviceName by remember { mutableStateOf(DEFAULT_TH_DEVICE_NAME) } - var socketDeviceName by remember { mutableStateOf(DEFAULT_SOCKET_DEVICE_NAME) } + var tempDeviceName by remember { mutableStateOf("") } + var humDeviceName by remember { mutableStateOf("") } + var socketDeviceName by remember { mutableStateOf("") } var operator by remember { mutableStateOf(ComparisonOperator.GREATER_THAN) } var tempVal by remember { mutableDoubleStateOf(26.0) } var humVal by remember { mutableDoubleStateOf(50.0) } @@ -870,10 +898,13 @@ private fun AddConditionDialog( val isEndTimeValid = timeRegex.matches(endTime.trim()) val isTimeRangeValid = isStartTimeValid && isEndTimeValid + val sensors = availableDevices.filter { it.type == DeviceType.TEMPERATURE_HUMIDITY } + val sockets = availableDevices.filter { it.type == DeviceType.SOCKET } + val isDeviceValid = when (selectedType) { - 0 -> tempDeviceName.isNotBlank() - 1 -> humDeviceName.isNotBlank() - 2 -> socketDeviceName.isNotBlank() + 0 -> sensors.any { it.name.value == tempDeviceName } + 1 -> sensors.any { it.name.value == humDeviceName } + 2 -> sockets.any { it.name.value == socketDeviceName } else -> true } @@ -882,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)) }) @@ -890,14 +921,13 @@ private fun AddConditionDialog( } when (selectedType) { 0 -> { - OutlinedTextField( - value = tempDeviceName, - onValueChange = { tempDeviceName = it }, - label = { Text(stringResource(R.string.scene_device_name_label)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Text(stringResource(R.string.scene_device_name_label)) + SceneChoiceFlow { + sensors.forEach { device -> + FilterChip(selected = tempDeviceName == device.name.value, onClick = { tempDeviceName = device.name.value }, label = { Text(device.name.value) }) + } + } + 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)) }) } @@ -905,14 +935,13 @@ private fun AddConditionDialog( Slider(value = tempVal.toFloat(), onValueChange = { tempVal = it.toDouble() }, valueRange = 10f..40f) } 1 -> { - OutlinedTextField( - value = humDeviceName, - onValueChange = { humDeviceName = it }, - label = { Text(stringResource(R.string.scene_device_name_label)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Text(stringResource(R.string.scene_device_name_label)) + SceneChoiceFlow { + sensors.forEach { device -> + FilterChip(selected = humDeviceName == device.name.value, onClick = { humDeviceName = device.name.value }, label = { Text(device.name.value) }) + } + } + 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)) }) } @@ -920,15 +949,14 @@ private fun AddConditionDialog( Slider(value = humVal.toFloat(), onValueChange = { humVal = it.toDouble() }, valueRange = 10f..90f) } 2 -> { - OutlinedTextField( - value = socketDeviceName, - onValueChange = { socketDeviceName = it }, - label = { Text(stringResource(R.string.scene_device_name_label)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) + Text(stringResource(R.string.scene_device_name_label)) + 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)) }) } @@ -985,6 +1013,7 @@ private fun AddConditionDialog( @Composable private fun AddActionDialog( availableRemotes: List, + availableDevices: List, onDismiss: () -> Unit, viewModel: SceneEditorViewModel, onAddSocket: (deviceName: String, powerState: SocketPowerState) -> Unit, @@ -997,7 +1026,7 @@ private fun AddActionDialog( val defaultNotifyTitle = stringResource(R.string.scene_default_notify_title) val defaultNotifyMsg = stringResource(R.string.scene_default_notify_msg) - var socketName by remember { mutableStateOf(DEFAULT_SOCKET_DEVICE_NAME) } + var socketName by remember { mutableStateOf(availableDevices.firstOrNull { it.type == DeviceType.SOCKET }?.name?.value.orEmpty()) } var socketPower by remember { mutableStateOf(SocketPowerState.ON) } var selectedRemoteIndex by remember { mutableIntStateOf(0) } @@ -1032,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)) }) @@ -1040,15 +1069,18 @@ private fun AddActionDialog( } when (actionType) { 0 -> { - OutlinedTextField( - value = socketName, - onValueChange = { socketName = it }, - label = { Text(stringResource(R.string.scene_select_socket)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) + Text(stringResource(R.string.scene_select_socket)) + SceneChoiceFlow { + availableDevices.filter { it.type == DeviceType.SOCKET }.forEach { device -> + FilterChip( + selected = socketName == device.name.value, + onClick = { socketName = device.name.value }, + label = { Text(device.name.value) }, + ) + } + } 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)) }) } @@ -1056,10 +1088,9 @@ private fun AddActionDialog( 1 -> { if (availableRemotes.isEmpty()) { Text(stringResource(R.string.scene_no_virtual_remotes)) - OutlinedTextField(value = selectedButtonId, onValueChange = { selectedButtonId = it }, label = { Text(stringResource(R.string.scene_remote_button_slot_label)) }, singleLine = true) } else { Text(stringResource(R.string.scene_select_virtual_remote)) - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + SceneChoiceFlow { availableRemotes.forEachIndexed { idx, remote -> FilterChip( selected = selectedRemoteIndex == idx, @@ -1070,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( @@ -1106,8 +1137,8 @@ private fun AddActionDialog( }, confirmButton = { val canConfirm = when (actionType) { - 0 -> socketName.isNotBlank() - 1 -> selectedButtonId.isNotBlank() + 0 -> availableDevices.any { it.type == DeviceType.SOCKET && it.name.value == socketName } + 1 -> availableRemotes.isNotEmpty() && selectedButtonId.isNotBlank() 3 -> notifyTitle.isNotBlank() else -> true } @@ -1119,8 +1150,6 @@ private fun AddActionDialog( val remote = availableRemotes.getOrNull(selectedRemoteIndex) if (remote != null) { onAddRemote(remote.id.value, remote.displayName, selectedButtonId, selectedButtonName) - } else { - onAddRemote("default-ir", DEFAULT_IR_DEVICE_NAME, selectedButtonId, selectedButtonName) } } 2 -> onAddDelay(delaySeconds) diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModel.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModel.kt index e38d4ee..4be21f1 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModel.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModel.kt @@ -6,6 +6,9 @@ import com.flagship.abox.manager.domain.AutomationType import com.flagship.abox.manager.domain.ComparisonOperator import com.flagship.abox.manager.domain.ConcurrencyMode import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.domain.DeviceCatalog +import com.flagship.abox.manager.domain.DeviceDescriptor +import com.flagship.abox.manager.domain.DeviceType import com.flagship.abox.manager.domain.DomainResult import com.flagship.abox.manager.domain.ExecutionStrategy import com.flagship.abox.manager.domain.ProviderProfileStore @@ -50,6 +53,7 @@ data class SceneEditorState( val concurrencyMode: ConcurrencyMode = ConcurrencyMode.SEQUENTIAL, val enableLogging: Boolean = true, val availableRemotes: List = emptyList(), + val availableDevices: List = emptyList(), val isLoading: Boolean = false, val isSaving: Boolean = false, val isSaved: Boolean = false, @@ -64,6 +68,7 @@ class SceneEditorViewModel @Inject constructor( private val sceneRepository: SceneRepository, private val virtualRemoteRepository: VirtualRemoteRepository, private val profileStore: ProviderProfileStore, + private val deviceCatalog: DeviceCatalog, ) : ViewModel() { private val _state = MutableStateFlow(SceneEditorState()) @@ -74,6 +79,23 @@ class SceneEditorViewModel @Inject constructor( init { observeAvailableRemotes() + observeAvailableDevices() + } + + private fun observeAvailableDevices() { + viewModelScope.launch { + profileStore.observeActiveProfile().collectLatest { profile -> + if (profile == null) { + _state.update { it.copy(availableDevices = emptyList()) } + } else { + deviceCatalog.observeDevices(profile.id).collectLatest { result -> + if (result is DomainResult.Success) { + _state.update { it.copy(availableDevices = result.value) } + } + } + } + } + } } private fun observeAvailableRemotes() { @@ -191,11 +213,13 @@ class SceneEditorViewModel @Inject constructor( } fun addDeviceStateTrigger(deviceName: String, expectedState: String) { + val parsedState = runCatching { SocketPowerState.valueOf(expectedState.trim().uppercase()) }.getOrNull() + ?: return val trigger = SceneTrigger.DeviceState( id = SceneTriggerId(UUID.randomUUID().toString()), deviceName = DeviceName(deviceName), - expectedState = expectedState, - label = "$deviceName 状态变为 $expectedState", + expectedState = parsedState, + label = "$deviceName 状态变为 ${parsedState.name}", ) _state.update { it.copy(triggers = it.triggers + trigger) } } diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneExecutionViewModel.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneExecutionViewModel.kt index 8bc1c00..200b14d 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneExecutionViewModel.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneExecutionViewModel.kt @@ -8,6 +8,9 @@ import com.flagship.abox.manager.domain.Scene import com.flagship.abox.manager.domain.SceneExecutionResult import com.flagship.abox.manager.domain.SceneExecutor import com.flagship.abox.manager.domain.SceneId +import com.flagship.abox.manager.domain.SceneExecutionId +import com.flagship.abox.manager.domain.SceneExecutionSource +import com.flagship.abox.manager.domain.SceneExecutionUpdate import com.flagship.abox.manager.domain.SceneRepository import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject @@ -15,6 +18,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.collect sealed interface SceneExecutionUiState { data object Loading : SceneExecutionUiState @@ -71,11 +75,19 @@ class SceneExecutionViewModel @Inject constructor( _uiState.value = SceneExecutionUiState.Failed("Active profile mismatch") return@launch } - when (val result = sceneExecutor.execute(scene)) { - is DomainResult.Success -> _uiState.value = SceneExecutionUiState.Completed(result.value) - is DomainResult.Failure -> _uiState.value = SceneExecutionUiState.Failed( - result.error.detail ?: "Execution failed", - ) + sceneExecutor.execute( + scene = scene, + executionId = SceneExecutionId("manual-${scene.id.value}"), + source = SceneExecutionSource.USER, + ).collect { update -> + when (update) { + is SceneExecutionUpdate.Completed -> _uiState.value = SceneExecutionUiState.Completed(update.result) + is SceneExecutionUpdate.Rejected -> _uiState.value = SceneExecutionUiState.Failed( + update.error.detail ?: "Execution failed", + ) + is SceneExecutionUpdate.Started -> _uiState.value = SceneExecutionUiState.Running(update.scene) + else -> Unit + } } } } 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 5560346..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 @@ -68,7 +71,10 @@ import com.flagship.abox.manager.domain.Scene import com.flagship.abox.manager.domain.SceneColor import com.flagship.abox.manager.domain.SceneIcon 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 @@ -84,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) { @@ -97,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, @@ -211,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 @@ -250,9 +328,10 @@ private fun SceneCard( ), contentAlignment = Alignment.Center, ) { - Text( - text = scene.icon.displayEmoji(), - style = MaterialTheme.typography.titleMedium, + ABoxIcon( + key = scene.icon.toABoxIconKey(), + contentDescription = scene.name, + modifier = Modifier.size(28.dp), ) } Spacer(Modifier.width(12.dp)) @@ -375,10 +454,7 @@ private fun EmptySceneList(modifier: Modifier = Modifier) { horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { - Text( - text = "⚡", - style = MaterialTheme.typography.displayMedium, - ) + ABoxIcon(ABoxIconKey.SCENES, contentDescription = "暂无情景", modifier = Modifier.size(48.dp)) Spacer(Modifier.size(8.dp)) Text( text = stringResource(R.string.scene_empty_title), @@ -396,19 +472,19 @@ private fun EmptySceneList(modifier: Modifier = Modifier) { } } -fun SceneIcon.displayEmoji(): String = when (this) { - SceneIcon.HOME -> "🏠" - SceneIcon.OFFICE -> "💼" - SceneIcon.NIGHT -> "🌙" - SceneIcon.MOVIE -> "🎬" - SceneIcon.AWAY -> "🚶" - SceneIcon.READING -> "📖" - SceneIcon.DINING -> "🍽️" - SceneIcon.GAMING -> "🎮" - SceneIcon.THERMOSTAT -> "🌡️" - SceneIcon.LIGHT -> "💡" - SceneIcon.CLEAN -> "🧹" - SceneIcon.CUSTOM -> "✨" +fun SceneIcon.toABoxIconKey(): ABoxIconKey = when (this) { + SceneIcon.HOME -> ABoxIconKey.DEVICES + SceneIcon.OFFICE -> ABoxIconKey.ACTIVITY + SceneIcon.NIGHT -> ABoxIconKey.PENDING + SceneIcon.MOVIE -> ABoxIconKey.SCENES + SceneIcon.AWAY -> ABoxIconKey.OFFLINE + SceneIcon.READING -> ABoxIconKey.DEVICES + SceneIcon.DINING -> ABoxIconKey.DEVICES + SceneIcon.GAMING -> ABoxIconKey.SCENES + SceneIcon.THERMOSTAT -> ABoxIconKey.TEMPERATURE + SceneIcon.LIGHT -> ABoxIconKey.POWER + SceneIcon.CLEAN -> ABoxIconKey.REFRESH + SceneIcon.CUSTOM -> ABoxIconKey.UNKNOWN_DEVICE } internal fun SceneColor.toColorOrNull(): Color? { diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/session/SessionActionsViewModel.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/session/SessionActionsViewModel.kt index f5adb34..d713dde 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/session/SessionActionsViewModel.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/session/SessionActionsViewModel.kt @@ -4,6 +4,9 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.flagship.abox.manager.domain.DomainResult import com.flagship.abox.manager.domain.SessionCoordinator +import com.flagship.abox.manager.domain.SessionValidity +import com.flagship.abox.manager.ui.common.DomainErrorMapper +import com.flagship.abox.manager.ui.common.UiText import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow @@ -18,9 +21,21 @@ class SessionActionsViewModel @Inject constructor( private val _uiState = MutableStateFlow(SessionActionsUiState()) val uiState: StateFlow = _uiState.asStateFlow() + fun onAction(action: SessionUiAction) { + when (action) { + SessionUiAction.EndSession -> runAction(coordinator::endActiveSession) + SessionUiAction.ForgetSavedCredentials -> runAction(coordinator::forgetSavedCredentials) + SessionUiAction.SwitchProvider -> runAction(coordinator::switchProvider) + SessionUiAction.ValidateConnection -> runValidation() + SessionUiAction.DismissError -> dismissError() + } + } + + fun dispatch(action: SessionUiAction) = onAction(action) + fun logout() = runAction(coordinator::logout) - fun switchProvider() = runAction(coordinator::switchProvider) + fun switchProvider() = onAction(SessionUiAction.SwitchProvider) fun dismissError() { _uiState.value = _uiState.value.copy(errorMessage = null) @@ -32,7 +47,19 @@ class SessionActionsViewModel @Inject constructor( _uiState.value = SessionActionsUiState(inProgress = true) val result = action() _uiState.value = SessionActionsUiState( - errorMessage = (result as? DomainResult.Failure)?.error?.detail, + errorMessage = (result as? DomainResult.Failure)?.error?.let(DomainErrorMapper::map), + ) + } + } + + private fun runValidation() { + if (_uiState.value.inProgress) return + viewModelScope.launch { + _uiState.value = SessionActionsUiState(inProgress = true) + val result = coordinator.validateActiveSession() + _uiState.value = SessionActionsUiState( + validity = (result as? DomainResult.Success)?.value, + errorMessage = (result as? DomainResult.Failure)?.error?.let(DomainErrorMapper::map), ) } } @@ -40,5 +67,14 @@ class SessionActionsViewModel @Inject constructor( data class SessionActionsUiState( val inProgress: Boolean = false, - val errorMessage: String? = null, + val validity: SessionValidity? = null, + val errorMessage: UiText? = null, ) + +sealed interface SessionUiAction { + data object EndSession : SessionUiAction + data object ForgetSavedCredentials : SessionUiAction + data object SwitchProvider : SessionUiAction + data object ValidateConnection : SessionUiAction + data object DismissError : SessionUiAction +} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/AccountProviderBottomSheet.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/AccountProviderBottomSheet.kt deleted file mode 100644 index 106c288..0000000 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/AccountProviderBottomSheet.kt +++ /dev/null @@ -1,183 +0,0 @@ -package com.flagship.abox.manager.ui.shell - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -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.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ExitToApp -import androidx.compose.material.icons.filled.CloudDone -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.SheetState -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import com.flagship.abox.manager.R -import com.flagship.abox.manager.designsystem.ABoxButton -import com.flagship.abox.manager.designsystem.ABoxUserAvatar - -/** - * Account and Provider management bottom sheet triggered by clicking the top bar user avatar. - * Allows viewing current session info and signing out. - */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun AccountProviderBottomSheet( - userName: String, - providerName: String, - onDismissRequest: () -> Unit, - onLogout: () -> Unit, - actionInProgress: Boolean, - errorMessage: String?, - modifier: Modifier = Modifier, - sheetState: SheetState = rememberModalBottomSheetState(), -) { - ModalBottomSheet( - onDismissRequest = onDismissRequest, - sheetState = sheetState, - modifier = modifier, - containerColor = MaterialTheme.colorScheme.surface, - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()) - .padding(horizontal = 24.dp) - .padding(bottom = 32.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - // Header with large avatar & user info - ABoxUserAvatar( - name = userName, - onClick = {}, - size = 56.dp, - ) - - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - Text( - text = userName.ifBlank { stringResource(R.string.account_sheet_title) }, - style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold), - color = MaterialTheme.colorScheme.onSurface, - ) - Text( - text = stringResource(R.string.account_sheet_signed_in_as), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - HorizontalDivider( - modifier = Modifier.padding(vertical = 4.dp), - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - ) - - // Provider Info Card - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), - ), - shape = RoundedCornerShape(16.dp), - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Box( - modifier = Modifier - .size(40.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.primaryContainer), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = Icons.Default.CloudDone, - contentDescription = null, - tint = MaterialTheme.colorScheme.onPrimaryContainer, - modifier = Modifier.size(22.dp), - ) - } - - Column(modifier = Modifier.weight(1f)) { - Text( - text = stringResource(R.string.account_sheet_provider), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = providerName, - style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold), - color = MaterialTheme.colorScheme.onSurface, - ) - } - } - } - - Spacer(modifier = Modifier.height(8.dp)) - - // Action button - ABoxButton( - onClick = onLogout, - enabled = !actionInProgress, - modifier = Modifier.fillMaxWidth(), - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ExitToApp, - contentDescription = null, - modifier = Modifier.size(20.dp), - ) - Spacer(modifier = Modifier.size(8.dp)) - Text(stringResource(R.string.account_sheet_logout)) - } - - errorMessage?.let { error -> - Text( - text = error, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodyMedium, - ) - } - - TextButton( - onClick = onDismissRequest, - enabled = !actionInProgress, - modifier = Modifier.fillMaxWidth(), - ) { - Text( - text = stringResource(R.string.account_sheet_close), - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } -} 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 new file mode 100644 index 0000000..d792c1f --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/AccountProviderDialog.kt @@ -0,0 +1,176 @@ +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.Palette +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 +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.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.ABoxUserAvatar + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AccountProviderDialog( + userName: String, + providerName: String, + onDismissRequest: () -> Unit, + onOpenSettings: () -> Unit, + onSwitchProvider: () -> Unit, + onEndSession: () -> Unit, + onForgetCredentials: () -> Unit, + modifier: Modifier = Modifier, +) { + var confirmLogout by remember { mutableStateOf(false) } + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false) + + 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().padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp), + ) { + 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) + } + } + } + + AccountSheetAction( + icon = Icons.Outlined.Palette, + 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 = { confirmLogout = false }, + title = { Text(stringResource(R.string.account_logout)) }, + text = { Text(stringResource(R.string.account_logout_confirm)) }, + confirmButton = { + ABoxButton( + onClick = { + confirmLogout = false + onEndSession() + }, + modifier = Modifier.testTag("account_dialog_confirm"), + ) { Text(stringResource(R.string.account_logout)) } + }, + dismissButton = { TextButton(onClick = { confirmLogout = false }) { Text(stringResource(R.string.action_cancel)) } }, + ) + } +} + +@Composable +private fun AccountSheetAction( + icon: androidx.compose.ui.graphics.vector.ImageVector, + label: String, + description: String, + tag: String, + dangerous: Boolean = false, + onClick: () -> Unit, +) { + TextButton( + onClick = onClick, + modifier = Modifier.fillMaxWidth().testTag(tag), + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp), + ) { + 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/AdaptiveLayout.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/AdaptiveLayout.kt new file mode 100644 index 0000000..a8be65d --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/AdaptiveLayout.kt @@ -0,0 +1,79 @@ +package com.flagship.abox.manager.ui.shell + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.saveable.Saver +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.domain.DeviceType +import com.flagship.abox.manager.domain.ProviderProfileId + +/** Width buckets used by the authenticated shell. */ +enum class ABoxWindowSizeClass { + COMPACT, + MEDIUM, + EXPANDED, +} + +fun widthClass(widthDp: Int): ABoxWindowSizeClass = when { + widthDp < 600 -> ABoxWindowSizeClass.COMPACT + widthDp < 840 -> ABoxWindowSizeClass.MEDIUM + else -> ABoxWindowSizeClass.EXPANDED +} + +enum class MainNavigationPresentation { + BOTTOM_NAVIGATION, + NAVIGATION_RAIL, +} + +fun navigationPresentation(windowSizeClass: ABoxWindowSizeClass): MainNavigationPresentation = when (windowSizeClass) { + ABoxWindowSizeClass.COMPACT -> MainNavigationPresentation.BOTTOM_NAVIGATION + ABoxWindowSizeClass.MEDIUM, + ABoxWindowSizeClass.EXPANDED, + -> MainNavigationPresentation.NAVIGATION_RAIL +} + +@Immutable +data class DeviceSelection( + val profileId: ProviderProfileId, + val deviceName: DeviceName, + val type: DeviceType, +) { + companion object { + /** Saver used by navigation-owned state so selection survives recreation and resize. */ + val Saver: Saver = Saver( + save = { selection -> + selection?.let { + listOf(it.profileId.value, it.deviceName.value, it.type.name) + } + }, + restore = { saved -> + val values = saved as? List<*> ?: return@Saver null + if (values.size != 3) return@Saver null + val profileId = values[0] as? String ?: return@Saver null + val deviceName = values[1] as? String ?: return@Saver null + val type = (values[2] as? String)?.let { value -> + runCatching { DeviceType.valueOf(value) }.getOrNull() + } ?: return@Saver null + DeviceSelection( + profileId = ProviderProfileId(profileId), + deviceName = DeviceName(deviceName), + type = type, + ) + }, + ) + } +} + +enum class DetailPresentation { + FULL_SCREEN, + PANE, +} + +fun detailPresentation( + windowSizeClass: ABoxWindowSizeClass, + selection: DeviceSelection, +): DetailPresentation = when { + windowSizeClass == ABoxWindowSizeClass.EXPANDED && selection.type != DeviceType.INFRARED -> { + DetailPresentation.PANE + } + else -> DetailPresentation.FULL_SCREEN +} 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 d32ebdc..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 @@ -1,179 +1,253 @@ package com.flagship.abox.manager.ui.shell -import androidx.compose.animation.Crossfade -import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight 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.statusBarsPadding -import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Bolt import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationRail +import androidx.compose.material3.NavigationRailItem import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.saveable.rememberSaveableStateHolder -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.draw.clip +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp 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.ABoxTopPillBar -import com.flagship.abox.manager.ui.devicecontrollist.DeviceListScreen +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 -/** - * Main authenticated application shell. - * - * Provides: - * 1. A floating/pinned pill top app bar with provider context, '+' action, and avatar account sheet. - * 2. Material 3 bottom navigation bar with pill indicator for Devices, Scenes, and Activity tabs. - * 3. Clear composable slot boundaries for team member integrations. - */ -@OptIn(ExperimentalMaterial3Api::class) +/** Stateless authenticated shell; navigation state belongs to AuthenticatedNavigation. */ @Composable fun MainAppShell( + currentTab: MainTab, + windowSizeClass: ABoxWindowSizeClass, userName: String, providerName: String, - onLogout: () -> Unit, - modifier: Modifier = Modifier, - initialTab: MainTab = MainTab.DEVICES, - onTabSelected: (MainTab) -> Unit = {}, - onAddClick: (() -> Unit)? = null, - actionInProgress: Boolean = false, - errorMessage: String? = null, - devicesSlot: @Composable () -> Unit = { DeviceListScreen() }, - scenesSlot: @Composable () -> Unit = { DefaultScenesSlot() }, - activitySlot: @Composable () -> Unit = { DefaultActivitySlot() }, + onTabClick: (MainTab) -> Unit, + onProviderClick: () -> Unit, + onAvatarClick: () -> Unit, + showNavigationChrome: Boolean = true, + content: @Composable (PaddingValues) -> Unit, ) { - var activeTab by rememberSaveable { mutableStateOf(initialTab) } - var showAccountSheet by remember { mutableStateOf(false) } - val tabStateHolder = rememberSaveableStateHolder() + 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(), + modifier = Modifier.fillMaxSize(), topBar = { - Box( - modifier = Modifier - .fillMaxWidth() - .statusBarsPadding() - .padding(horizontal = 16.dp, vertical = 8.dp), - ) { - ABoxTopPillBar( - title = stringResource(R.string.top_bar_prompt_format, providerName), + if (showNavigationChrome) { + ProviderTopBar( + currentTab = currentTab, + providerName = providerName, userName = userName, - onAvatarClick = { showAccountSheet = true }, - onAddClick = onAddClick, - addActionContentDescription = stringResource(R.string.action_add), - avatarContentDescription = stringResource(R.string.action_account), + onProviderClick = onProviderClick, + onAvatarClick = onAvatarClick, ) } }, bottomBar = { - ABoxNavigationBar { - MainTab.values().forEach { tab -> - val isSelected = (tab == activeTab) - ABoxNavigationBarItem( - selected = isSelected, - onClick = { - activeTab = tab - onTabSelected(tab) - }, - icon = { - Icon( - imageVector = if (isSelected) tab.selectedIcon else tab.unselectedIcon, - contentDescription = stringResource(tab.labelRes), - ) - }, - label = { - Text(text = stringResource(tab.labelRes)) - }, - ) - } + if (showNavigationChrome && !useRail) { + MainBottomBar(currentTab, onTabClick, renderer, floating) } }, ) { innerPadding -> - Box( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding), + Row( + modifier = Modifier.fillMaxSize(), ) { - Crossfade( - targetState = activeTab, - label = "MainTabCrossfade", - ) { tab -> - tabStateHolder.SaveableStateProvider(tab) { - when (tab) { - MainTab.DEVICES -> devicesSlot() - MainTab.SCENES -> scenesSlot() - MainTab.ACTIVITY -> activitySlot() - } - } + if (showNavigationChrome && useRail) { + MainNavigationRail( + currentTab = currentTab, + onTabClick = onTabClick, + ) + } + Box( + modifier = Modifier.weight(1f).fillMaxHeight(), + ) { + content(innerPadding) } } } - - if (showAccountSheet) { - AccountProviderBottomSheet( - userName = userName, - providerName = providerName, - onDismissRequest = { showAccountSheet = false }, - onLogout = onLogout, - actionInProgress = actionInProgress, - errorMessage = errorMessage, - ) - } } @Composable -private fun DefaultScenesSlot() { - SlotPlaceholder( - title = stringResource(R.string.scenes_placeholder_title), - description = stringResource(R.string.scenes_placeholder_description), - ) -} - -@Composable -private fun DefaultActivitySlot() { - SlotPlaceholder( - title = stringResource(R.string.activity_placeholder_title), - description = stringResource(R.string.activity_placeholder_description), - ) -} - -@Composable -private fun SlotPlaceholder( - title: String, - description: String, +private fun MainBottomBar( + currentTab: MainTab, + onTabClick: (MainTab) -> Unit, + renderer: DesignRenderer, + floating: Boolean, ) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, + 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, + providerName: String, + userName: String, + onProviderClick: () -> Unit, + 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, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(64.dp) + .padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + modifier = Modifier + .clip(RoundedCornerShape(24.dp)) + .testTag("main_provider_pill") + .clickable( + onClickLabel = stringResource(R.string.account_sheet_provider), + onClick = onProviderClick, + ) + .padding(start = 8.dp, end = 12.dp, top = 8.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Outlined.Bolt, + contentDescription = providerName, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.width(22.dp), + ) + Text( + text = providerName, + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(start = 8.dp), + ) + } + Text( + text = stringResource(currentTab.labelRes), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .weight(1f) + .padding(horizontal = 8.dp), + ) + Box(modifier = Modifier.testTag("main_avatar")) { + ABoxUserAvatar( + name = userName, + onClick = onAvatarClick, + size = 36.dp, + contentDescription = stringResource(R.string.action_account), + ) + } + } + } + } +} + +@Composable +private fun MainNavigationRail( + currentTab: MainTab, + onTabClick: (MainTab) -> Unit, +) { + NavigationRail( + modifier = Modifier.fillMaxHeight(), + containerColor = MaterialTheme.colorScheme.surface, ) { - Text( - text = title, - style = MaterialTheme.typography.headlineSmall, - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.onSurface, - ) - Text( - text = description, - style = MaterialTheme.typography.bodyMedium, - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 8.dp), - ) + Spacer(modifier = Modifier.height(8.dp)) + MainTab.entries.forEach { tab -> + val selected = tab == currentTab + NavigationRailItem( + 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)) }, + ) + } } } diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/MainTab.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/MainTab.kt index 32f5cac..e7c2946 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/MainTab.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/MainTab.kt @@ -11,9 +11,7 @@ import androidx.compose.material.icons.outlined.ViewAgenda import androidx.compose.ui.graphics.vector.ImageVector import com.flagship.abox.manager.R -/** - * Main bottom navigation tabs for the authenticated application shell. - */ +/** Main destinations available while authenticated. */ enum class MainTab( @StringRes val labelRes: Int, val selectedIcon: ImageVector, @@ -24,18 +22,30 @@ enum class MainTab( labelRes = R.string.tab_devices, selectedIcon = Icons.Filled.Home, unselectedIcon = Icons.Outlined.Home, - route = "shell/devices", + route = "tab/devices", ), SCENES( labelRes = R.string.tab_scenes, selectedIcon = Icons.Filled.AutoAwesome, unselectedIcon = Icons.Outlined.AutoAwesome, - route = "shell/scenes", + route = "tab/scenes", ), ACTIVITY( labelRes = R.string.tab_activity, selectedIcon = Icons.Filled.ViewAgenda, unselectedIcon = Icons.Outlined.ViewAgenda, - route = "shell/activity", + route = "tab/activity", ), + ; + + companion object { + fun fromRoute(route: String?): MainTab? = when { + route == null -> null + route == DEVICES.route || route.startsWith("device/") || + route.startsWith("infrared_") -> DEVICES + route == SCENES.route || route.startsWith("scene/") -> SCENES + route == ACTIVITY.route -> ACTIVITY + else -> null + } + } } 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 62db0e1..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 @@ -47,12 +50,17 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flagship.abox.manager.R 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( @@ -79,38 +87,29 @@ fun SocketScreen( modifier: Modifier = Modifier, ) { val scrollState = rememberScrollState() - var showEditNameDialog by remember { mutableStateOf(false) } - - Scaffold( + val configuration = LocalConfiguration.current + var showDeviceNameDialog by remember { mutableStateOf(false) } + ABoxDeviceDetailScaffold( modifier = modifier.fillMaxSize(), - topBar = { - TopAppBar( - title = { - Text( - text = state.deviceName?.value ?: stringResource(R.string.socket_title), - style = MaterialTheme.typography.titleLarge, - ) - }, - navigationIcon = { - IconButton(onClick = onBackClick) { - Text("<", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) - } - }, - actions = { - IconButton( - onClick = { showEditNameDialog = true }, - enabled = !state.isLoading && !state.isOperating, - ) { - Text("✎", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) - } - IconButton( - onClick = { onEvent(SocketUiEvent.Refresh) }, - enabled = !state.isLoading && !state.isOperating, - ) { - Text("↻", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) - } - }, - ) + 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( @@ -136,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, ) { @@ -157,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, ) @@ -174,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 @@ -185,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, @@ -196,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), @@ -232,57 +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 (showEditNameDialog) { - var inputName by remember(state.deviceName) { mutableStateOf(state.deviceName?.value.orEmpty()) } - AlertDialog( - onDismissRequest = { showEditNameDialog = false }, - title = { Text(stringResource(R.string.dialog_edit_device_name_title)) }, - text = { - OutlinedTextField( - value = inputName, - onValueChange = { inputName = it }, - label = { Text(stringResource(R.string.dialog_edit_device_name_hint)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - }, - confirmButton = { - TextButton( - onClick = { - val trimmed = inputName.trim() - if (trimmed.isNotBlank()) { - onEvent(SocketUiEvent.ChangeDeviceName(DeviceName(trimmed))) - } - showEditNameDialog = false - }, - enabled = inputName.isNotBlank(), - ) { - Text(stringResource(R.string.action_confirm)) - } - }, - dismissButton = { - TextButton(onClick = { showEditNameDialog = false }) { - Text(stringResource(R.string.action_cancel)) - } + 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 @@ -290,6 +298,9 @@ private fun PowerIndicatorBadge( powerState: SocketPowerState?, isOffline: Boolean, isLoading: Boolean, + isOperating: Boolean, + enabled: Boolean, + onClick: () -> Unit, modifier: Modifier = Modifier, ) { val indicatorColor = when { @@ -301,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 { + 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 = if (powerState == SocketPowerState.ON) "⚡" else "○", - style = MaterialTheme.typography.headlineLarge, - color = indicatorColor, + 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, @@ -377,9 +438,11 @@ private fun ErrorBanner( style = MaterialTheme.typography.bodySmall, modifier = Modifier.weight(1f), ) - TextButton(onClick = onDismiss) { - Text("✕") - } + ABoxIconButton( + key = ABoxIconKey.CLOSE, + contentDescription = "关闭错误提示", + onClick = onDismiss, + ) } } } diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/socket/SocketUiState.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/socket/SocketUiState.kt index 2c47d33..fbb53cf 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/socket/SocketUiState.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/socket/SocketUiState.kt @@ -14,14 +14,27 @@ data class SocketUiState( val isOffline: Boolean = false, val errorMessage: UiText? = null, val lastConfirmedTimeMillis: Long? = null, + val operation: SocketOperationState = SocketOperationState.IdleConfirmed, ) { val isActionEnabled: Boolean - get() = !isLoading && !isOperating && !isOffline && profileId != null && deviceName != null + get() = !isLoading && operation !is SocketOperationState.PendingCommand && + operation !is SocketOperationState.RefreshingConfirmation && + operation !is SocketOperationState.AwaitingConfirmation && + !isOffline && profileId != null && deviceName != null val isPowerOn: Boolean get() = powerState == SocketPowerState.ON } +sealed interface SocketOperationState { + data object IdleConfirmed : SocketOperationState + data class PendingCommand(val target: SocketPowerState) : SocketOperationState + data class RefreshingConfirmation(val target: SocketPowerState) : SocketOperationState + data class AwaitingConfirmation(val target: SocketPowerState) : SocketOperationState + data class Failed(val target: SocketPowerState) : SocketOperationState + data object Unavailable : SocketOperationState +} + sealed interface SocketUiEvent { data class Initialize(val profileId: ProviderProfileId, val deviceName: DeviceName) : SocketUiEvent data class ChangeDeviceName(val deviceName: DeviceName) : SocketUiEvent 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 15364cc..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() { @@ -56,6 +63,7 @@ class SocketViewModel @Inject constructor( it.copy( profileId = event.profileId, deviceName = event.deviceName, + operation = SocketOperationState.Unavailable, ) } loadStatus() @@ -63,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() } } @@ -115,6 +126,7 @@ class SocketViewModel @Inject constructor( isOffline = false, errorMessage = null, lastConfirmedTimeMillis = System.currentTimeMillis(), + operation = SocketOperationState.IdleConfirmed, ) } } @@ -126,6 +138,7 @@ class SocketViewModel @Inject constructor( isLoading = false, isOffline = isOffline, errorMessage = DomainErrorMapper.map(result.error), + operation = SocketOperationState.Unavailable, ) } } @@ -139,36 +152,83 @@ class SocketViewModel @Inject constructor( val deviceName = current.deviceName ?: return // Prevent duplicate action if currently operating, loading or offline - if (current.isOperating || current.isLoading || current.isOffline) { + if (current.isOperating || current.isLoading || current.isOffline || !current.isActionEnabled) { return } + _state.update { it.copy(isOperating = true, errorMessage = null, operation = SocketOperationState.PendingCommand(targetState)) } viewModelScope.launch { - _state.update { it.copy(isOperating = true, errorMessage = null) } - when (val result = socketGateway.setPower(profileId, deviceName, targetState)) { + when (val result = socketGateway.commandPower(profileId, deviceName, targetState)) { is DomainResult.Success -> { + activityWriter.recordDevice(profileId, deviceName, ActivityEventKind.SOCKET_COMMAND, ActivityOutcome.SUCCEEDED) _state.update { it.copy( - powerState = result.value.powerState, - isOperating = false, + isOperating = true, isOffline = false, errorMessage = null, - lastConfirmedTimeMillis = System.currentTimeMillis(), + 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( isOperating = false, isOffline = isOffline, errorMessage = DomainErrorMapper.map(result.error), + operation = SocketOperationState.Failed(targetState), ) } } } } } + + 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 3cf5571..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 @@ -39,11 +41,14 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flagship.abox.manager.R import com.flagship.abox.manager.designsystem.ABoxButton +import com.flagship.abox.manager.designsystem.ABoxDeviceDetailScaffold import com.flagship.abox.manager.designsystem.ABoxLoadingIndicator +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( @@ -70,37 +75,20 @@ fun TemperatureHumidityScreen( modifier: Modifier = Modifier, ) { val scrollState = rememberScrollState() - var showEditNameDialog by remember { mutableStateOf(false) } - - Scaffold( + val configuration = LocalConfiguration.current + var showDeviceNameDialog by remember { mutableStateOf(false) } + ABoxDeviceDetailScaffold( modifier = modifier.fillMaxSize(), - topBar = { - TopAppBar( - title = { - Text( - text = state.deviceName?.value ?: stringResource(R.string.th_title), - style = MaterialTheme.typography.titleLarge, - ) - }, - navigationIcon = { - IconButton(onClick = onBackClick) { - Text("<", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) - } - }, - actions = { - IconButton( - onClick = { showEditNameDialog = true }, - enabled = !state.isLoading && !state.isRefreshing, - ) { - Text("✎", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) - } - IconButton( - onClick = { onEvent(TemperatureHumidityUiEvent.Refresh) }, - enabled = !state.isLoading && !state.isRefreshing, - ) { - Text("↻", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) - } - }, + 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 -> @@ -151,31 +139,41 @@ 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( title = stringResource(R.string.th_temperature_label), - value = state.temperature ?: "--", + value = state.temperature, unit = stringResource(R.string.th_unit_celsius), - icon = "🌡️", + icon = ABoxIconKey.TEMPERATURE, + testTag = "sensor_metric_temperature", modifier = Modifier.weight(1f), ) TelemetryMetricCard( title = stringResource(R.string.th_humidity_label), - value = state.humidity ?: "--", + value = state.humidity, unit = stringResource(R.string.th_unit_percent), - icon = "💧", + 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, @@ -189,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), ), @@ -201,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), @@ -215,7 +213,7 @@ fun TemperatureHumidityScreen( } } - Spacer(modifier = Modifier.height(24.dp)) + Spacer(modifier = Modifier.height(16.dp)) // Refresh Button ABoxButton( @@ -223,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)) @@ -239,53 +237,59 @@ fun TemperatureHumidityScreen( } } - if (showEditNameDialog) { - var inputName by remember(state.deviceName) { mutableStateOf(state.deviceName?.value.orEmpty()) } - AlertDialog( - onDismissRequest = { showEditNameDialog = false }, - title = { Text(stringResource(R.string.dialog_edit_device_name_title)) }, - text = { - OutlinedTextField( - value = inputName, - onValueChange = { inputName = it }, - label = { Text(stringResource(R.string.dialog_edit_device_name_hint)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - }, - confirmButton = { - TextButton( - onClick = { - val trimmed = inputName.trim() - if (trimmed.isNotBlank()) { - onEvent(TemperatureHumidityUiEvent.ChangeDeviceName(DeviceName(trimmed))) - } - showEditNameDialog = false - }, - enabled = inputName.isNotBlank(), - ) { - Text(stringResource(R.string.action_confirm)) - } - }, - dismissButton = { - TextButton(onClick = { showEditNameDialog = false }) { - Text(stringResource(R.string.action_cancel)) - } + 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 private fun TelemetryMetricCard( title: String, - value: String, + value: String?, unit: String, - icon: 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, ), @@ -298,10 +302,7 @@ private fun TelemetryMetricCard( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { - Text( - text = icon, - style = MaterialTheme.typography.headlineMedium, - ) + ABoxIcon(icon, contentDescription = title, modifier = Modifier.size(28.dp)) Spacer(modifier = Modifier.height(8.dp)) @@ -323,14 +324,12 @@ private fun TelemetryMetricCard( fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary, ) - if (value != "--") { - Text( - text = " $unit", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(bottom = 4.dp), - ) - } + Text( + text = " $unit", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 4.dp), + ) } } } @@ -355,7 +354,11 @@ private fun NoDataCard( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { - Text("📭", style = MaterialTheme.typography.displaySmall) + ABoxIcon( + key = ABoxIconKey.SENSOR, + contentDescription = "暂无传感器数据", + modifier = Modifier.size(40.dp), + ) Spacer(modifier = Modifier.height(12.dp)) Text( text = stringResource(R.string.th_no_data_title), @@ -434,9 +437,11 @@ private fun ErrorBanner( style = MaterialTheme.typography.bodySmall, modifier = Modifier.weight(1f), ) - TextButton(onClick = onDismiss) { - Text("✕") - } + ABoxIconButton( + key = ABoxIconKey.CLOSE, + contentDescription = "关闭错误提示", + onClick = onDismiss, + ) } } } diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityUiState.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityUiState.kt index 678a497..39e0cf8 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityUiState.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityUiState.kt @@ -16,11 +16,11 @@ data class TemperatureHumidityUiState( val errorMessage: UiText? = null, val lastUpdatedTimeMillis: Long? = null, ) { - val formattedTemperature: String - get() = if (temperature.isNullOrBlank()) "--" else "$temperature °C" + val formattedTemperature: String? + get() = temperature?.takeIf { it.isNotBlank() }?.let { "$it °C" } - val formattedHumidity: String - get() = if (humidity.isNullOrBlank()) "--" else "$humidity %" + val formattedHumidity: String? + get() = humidity?.takeIf { it.isNotBlank() }?.let { "$it %" } val hasValidData: Boolean get() = !temperature.isNullOrBlank() || !humidity.isNullOrBlank() 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 916fc80..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,12 +107,13 @@ 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.isBlank() && reading.humidity.isBlank() + val isBlankData = reading.temperature.isNullOrBlank() && reading.humidity.isNullOrBlank() _state.update { it.copy( - temperature = reading.temperature.takeIf { t -> t.isNotBlank() }, - humidity = reading.humidity.takeIf { h -> h.isNotBlank() }, + temperature = reading.temperature?.takeIf { t -> t.isNotBlank() }, + humidity = reading.humidity?.takeIf { h -> h.isNotBlank() }, isLoading = false, isRefreshing = false, isOffline = false, @@ -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/drawable/ic_abox_logo_master.png b/android/app/src/main/res/drawable/ic_abox_logo_master.png new file mode 100644 index 0000000..c84077e Binary files /dev/null and b/android/app/src/main/res/drawable/ic_abox_logo_master.png differ diff --git a/android/app/src/main/res/drawable/ic_launcher_background.xml b/android/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..d3c3b9a --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,3 @@ + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..3ba4e35 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..3ba4e35 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..b0ceb1c Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..3a8bef4 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..b0ceb1c Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..20efa46 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..43aad77 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..20efa46 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..606651a Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..ef39cd3 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..606651a Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..5933b63 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..b0b3106 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..5933b63 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..05f968e Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..99d74d0 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..05f968e Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ 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 89d8729..adcff17 100644 --- a/android/app/src/main/res/values-zh-rCN/strings.xml +++ b/android/app/src/main/res/values-zh-rCN/strings.xml @@ -1,5 +1,5 @@ - ABox Manager + ABox 家庭 选择提供方 选择当前配置的连接方式。不同提供方之间的会话、凭据和本地数据相互隔离。 ABox @@ -82,9 +82,9 @@ 其他选项与外观 - + 新增触发器 - + 新增条件 - + 新增动作 + 新增触发器 + 新增条件 + 新增动作 添加 保存指令 @@ -104,10 +104,10 @@ 选择触发器类型 - ⏰ 定时时间触发 - 🌡️ 环境温度触发 - 💧 环境湿度触发 - 🔌 插座状态变更触发 + 定时时间触发 + 环境温度触发 + 环境湿度触发 + 插座状态变更触发 触发时间点(如:07:30) 温度阈值 (°C) 湿度阈值 (%) @@ -118,19 +118,19 @@ 选择限制条件 - 🌡️ 环境温度范围 - 💧 环境湿度范围 - 🔌 插座当前电源状态 - 🕒 生效时间区间 + 环境温度范围 + 环境湿度范围 + 插座当前电源状态 + 生效时间区间 起始时间(如:22:00) 结束时间(如:07:00) 选择执行动作 - 🔌 智能插座控制 - 📺 红外遥控按键 - ⏱️ 延时等待 (缓冲) - 🔔 发送系统提示 + 智能插座控制 + 红外遥控按键 + 延时等待(缓冲) + 发送系统提示 选择目标插座 设定电源状态 选择虚拟遥控器 @@ -144,8 +144,8 @@ 插座【%1$s】: 设为 %2$s 遥控【%1$s】: 发送 %2$s 红外【%1$s】: 槽位 %2$s - ⏱️ 等待 %1$d 秒 - 🔔 提示: %1$s + 等待 %1$d 秒 + 提示:%1$s 执行指令 @@ -164,13 +164,13 @@ 时间格式无效(请按 HH:mm 格式输入,如 07:30) 时间格式无效(请按 HH:mm 格式输入,如 22:00) - ⏰ 定时 - 🌡️ 温度 - 💧 湿度 - 🔌 插座 - 📺 遥控 - ⏱️ 延时 - 🔔 提示 + 定时 + 温度 + 湿度 + 插座 + 遥控 + 延时 + 提示 > 高于 < 低于 开启 @@ -184,7 +184,7 @@ 尚未添加触发器,请点击下方按钮添加 尚未添加限制条件,请点击下方按钮添加 尚未添加执行动作,请点击下方按钮添加 - ⚡ %1$d 个触发器 • %2$d 个动作 + %1$d 个触发器 · %2$d 个动作 %1$d 个执行动作 点击下方按钮立即开始执行 重新执行 @@ -259,4 +259,127 @@ 操作执行失败,请重试。 暂无可用数据。 发生未知错误。 + + + 界面设置 + 样式 + 渲染器 + 选择应用使用的界面渲染方式 + Material + Miuix + 主题 + 主题模式 + 跟随系统 + 浅色 + 深色 + 动态取色 + 使用系统壁纸颜色生成界面配色 + 界面效果 + 模糊效果 + 在支持的界面中启用背景模糊 + 导航样式 + 标准导航栏 + 悬浮导航栏 + 交互反馈 + 触觉反馈 + 操作时提供轻微震动反馈 + 系统辅助功能 + 遵循系统辅助功能设置 + 动画、字体大小和对比度等辅助功能由系统设置控制,应用会遵循系统偏好。 + 无法保存界面设置,请重试 + 界面风格 + 主题 + 配色、明暗、视觉效果与缩放 + 主题 + Material + 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 4745798..677acab 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1,5 +1,5 @@ - ABox Manager + ABox Home Choose a provider Choose how this profile connects. Sessions, credentials, and local data stay isolated between providers. ABox @@ -82,9 +82,9 @@ Other Options & Appearance - + Add Trigger - + Add Condition - + Add Action + Add Trigger + Add Condition + Add Action Add Save Command @@ -104,10 +104,10 @@ Select Trigger Type - ⏰ Time / Schedule Trigger - 🌡️ Temperature Threshold - 💧 Humidity Threshold - 🔌 Socket State Changed + Time / Schedule Trigger + Temperature Threshold + Humidity Threshold + Socket State Changed Trigger Time (e.g. 07:30) Temperature Threshold (°C) Humidity Threshold (%) @@ -118,19 +118,19 @@ Select Condition - 🌡️ Temperature Range - 💧 Humidity Range - 🔌 Current Socket Power - 🕒 Active Time Range + Temperature Range + Humidity Range + Current Socket Power + Active Time Range Start Time (e.g. 22:00) End Time (e.g. 07:00) Select Action - 🔌 Smart Socket Control - 📺 Infrared Remote Button - ⏱️ Delay / Buffer Timer - 🔔 System Notification + Smart Socket Control + Infrared Remote Button + Delay / Buffer Timer + System Notification Select Target Socket Target Power State Select Virtual Remote @@ -144,8 +144,8 @@ Socket [%1$s]: Set %2$s Remote [%1$s]: Send %2$s Infrared [%1$s]: Slot %2$s - ⏱️ Delay %1$d seconds - 🔔 Notify: %1$s + Delay %1$d seconds + Notify: %1$s Execute Command @@ -164,13 +164,13 @@ Invalid time format (use HH:mm, e.g. 07:30) Invalid time format (use HH:mm, e.g. 22:00) - ⏰ Schedule - 🌡️ Temperature - 💧 Humidity - 🔌 Socket - 📺 Remote - ⏱️ Delay - 🔔 Notify + Schedule + Temperature + Humidity + Socket + Remote + Delay + Notify > Above < Below Turn ON @@ -184,7 +184,7 @@ No triggers configured. Tap + below to add. No conditions configured. Tap + below to add. No actions configured. Tap + below to add. - ⚡ %1$d triggers • %2$d actions + %1$d triggers · %2$d actions %1$d actions Tap the button below to run immediately Re-run @@ -259,4 +259,127 @@ Operation failed. Please try again. No sensor data available. An unexpected error occurred. + + + Interface settings + Style + Renderer + Choose the interface renderer used by the app + Material + 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 + 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 + Theme + Colors, brightness, effects and scale + 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/screenshotTest/kotlin/com/flagship/abox/manager/ui/AppScreenshotSmokeTest.kt b/android/app/src/screenshotTest/kotlin/com/flagship/abox/manager/ui/AppScreenshotSmokeTest.kt new file mode 100644 index 0000000..30756cb --- /dev/null +++ b/android/app/src/screenshotTest/kotlin/com/flagship/abox/manager/ui/AppScreenshotSmokeTest.kt @@ -0,0 +1,25 @@ +package com.flagship.abox.manager.ui + +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import com.android.tools.screenshot.PreviewTest +import com.flagship.abox.manager.designsystem.ABoxTheme +import com.flagship.abox.manager.preferences.AppThemeMode +import com.flagship.abox.manager.preferences.DesignRenderer +import com.flagship.abox.manager.preferences.UiPreferences + +@PreviewTest +@Preview +@Composable +fun AppSmokePreview() { + ABoxTheme( + preferences = UiPreferences( + renderer = DesignRenderer.MATERIAL3, + themeMode = AppThemeMode.LIGHT, + dynamicColorEnabled = false, + ), + ) { + Text("ABox Home") + } +} diff --git a/android/app/src/screenshotTestDebug/reference/com/flagship/abox/manager/ui/AppScreenshotSmokeTestKt/AppSmokePreview_0.png b/android/app/src/screenshotTestDebug/reference/com/flagship/abox/manager/ui/AppScreenshotSmokeTestKt/AppSmokePreview_0.png new file mode 100644 index 0000000..067f3ed Binary files /dev/null and b/android/app/src/screenshotTestDebug/reference/com/flagship/abox/manager/ui/AppScreenshotSmokeTestKt/AppSmokePreview_0.png differ diff --git a/android/app/src/test/kotlin/com/flagship/abox/manager/ui/devicecenter/DeviceCenterViewModelTest.kt b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/devicecenter/DeviceCenterViewModelTest.kt new file mode 100644 index 0000000..880a051 --- /dev/null +++ b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/devicecenter/DeviceCenterViewModelTest.kt @@ -0,0 +1,86 @@ +package com.flagship.abox.manager.ui.devicecenter + +import com.flagship.abox.manager.domain.DeviceCatalog +import com.flagship.abox.manager.domain.DeviceDescriptor +import com.flagship.abox.manager.domain.DeviceId +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.domain.DeviceType +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.DomainError +import com.flagship.abox.manager.devicecatalog.data.ConfiguredDeviceRepository +import com.flagship.abox.manager.testing.MainDispatcherRule +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Rule +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class DeviceCenterViewModelTest { + @get:Rule + val mainDispatcherRule = MainDispatcherRule(UnconfinedTestDispatcher()) + + @Test + fun catalogEmissionMapsCardsAndFilterLocally() = runTest { + val profile = ProviderProfileId("p1") + val catalog = TestCatalog(listOf( + descriptor(profile, "plug", DeviceType.SOCKET), + descriptor(profile, "sensor", DeviceType.TEMPERATURE_HUMIDITY), + )) + val viewModel = DeviceCenterViewModel(catalog, TestConfiguredRepository()) + viewModel.initialize(profile) + viewModel.setFilter(DeviceCategoryFilter.SOCKET) + + assertEquals(listOf("plug"), viewModel.uiState.value.cards.map { it.displayName }) + } + + @Test + fun profileChangeClearsSelectionAndLoadsNewCards() = runTest { + val first = ProviderProfileId("one") + val second = ProviderProfileId("two") + val catalog = TestCatalog(listOf(descriptor(first, "old", DeviceType.SOCKET))) + val viewModel = DeviceCenterViewModel(catalog, TestConfiguredRepository()) + viewModel.initialize(first) + viewModel.select(catalog.devices.single()) + catalog.emit(listOf(descriptor(second, "new", DeviceType.INFRARED))) + viewModel.initialize(second) + + assertEquals(second, viewModel.uiState.value.profileId) + assertEquals(null, viewModel.uiState.value.selectedCard) + assertEquals(listOf("new"), viewModel.uiState.value.cards.map { it.displayName }) + } + + @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) + + private class TestCatalog(initial: List) : DeviceCatalog { + val devices = initial.toMutableList() + private val state = MutableStateFlow>>(DomainResult.Success(initial)) + override fun observeDevices(profileId: ProviderProfileId): Flow>> = state + fun emit(value: List) { devices.clear(); devices.addAll(value); state.value = DomainResult.Success(value) } + } + + private class TestConfiguredRepository : ConfiguredDeviceRepository { + override fun observe(profileId: ProviderProfileId): Flow>> = + MutableStateFlow(DomainResult.Success(emptyList())) + override suspend fun add(profileId: ProviderProfileId, name: DeviceName, type: DeviceType): DomainResult = + DomainResult.Failure(DomainError.InvalidRequest()) + override suspend fun remove(profileId: ProviderProfileId, deviceName: DeviceName): DomainResult = + DomainResult.Success(Unit) + } +} diff --git a/android/app/src/test/kotlin/com/flagship/abox/manager/ui/navigation/AuthenticatedNavigationTest.kt b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/navigation/AuthenticatedNavigationTest.kt new file mode 100644 index 0000000..4cd9137 --- /dev/null +++ b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/navigation/AuthenticatedNavigationTest.kt @@ -0,0 +1,91 @@ +package com.flagship.abox.manager.ui.navigation + +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.ui.shell.ABoxWindowSizeClass +import com.flagship.abox.manager.ui.shell.DetailPresentation +import com.flagship.abox.manager.ui.shell.MainNavigationPresentation +import com.flagship.abox.manager.ui.shell.MainTab +import com.flagship.abox.manager.ui.shell.DeviceSelection +import com.flagship.abox.manager.ui.shell.detailPresentation +import com.flagship.abox.manager.ui.shell.navigationPresentation +import com.flagship.abox.manager.ui.shell.widthClass +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class AuthenticatedNavigationTest { + + @Test + fun `profile key change recreates authenticated graph`() { + val firstProfile = ProviderProfileId("first") + val secondProfile = ProviderProfileId("second") + val firstGraph = AuthenticatedGraphState(firstProfile) + .selectTab(MainTab.SCENES) + .selectDevice(DeviceName("socket"), DeviceType.SOCKET) + + val recreated = firstGraph.recreateForProfile(secondProfile) + + assertEquals(secondProfile, recreated.profileId) + assertEquals(MainTab.DEVICES, recreated.currentTab) + assertNull(recreated.selectedDevice) + } + + @Test + fun `reselecting main tab pops to its root`() { + val state = AuthenticatedGraphState(ProviderProfileId("profile")) + .selectTab(MainTab.SCENES) + .pushChild("scene/editor/manual") + + val reselected = state.selectTab(MainTab.SCENES) + + assertEquals(MainTab.SCENES, reselected.currentTab) + assertEquals(listOf(MainTab.SCENES.route), reselected.tabBackStack(MainTab.SCENES)) + } + + @Test + fun `compact uses bottom navigation`() { + assertEquals(MainNavigationPresentation.BOTTOM_NAVIGATION, navigationPresentation(widthClass(599))) + } + + @Test + fun `medium and expanded use rail`() { + assertEquals(MainNavigationPresentation.NAVIGATION_RAIL, navigationPresentation(widthClass(600))) + assertEquals(MainNavigationPresentation.NAVIGATION_RAIL, navigationPresentation(widthClass(840))) + } + + @Test + fun `profile replacement clears previously selected device`() { + val state = AuthenticatedGraphState(ProviderProfileId("first")) + .selectDevice(DeviceName("sensor"), DeviceType.TEMPERATURE_HUMIDITY) + + val replaced = state.recreateForProfile(ProviderProfileId("second")) + + assertNull(replaced.selectedDevice) + } + + @Test + fun `selected device uses pane on expanded layouts and full screen otherwise`() { + val selection = DeviceSelection( + profileId = ProviderProfileId("profile"), + deviceName = DeviceName("sensor"), + type = DeviceType.TEMPERATURE_HUMIDITY, + ) + + assertEquals(DetailPresentation.FULL_SCREEN, detailPresentation(widthClass(599), selection)) + assertEquals(DetailPresentation.PANE, detailPresentation(widthClass(840), selection)) + } + + @Test + fun `back closes child route before leaving its main tab`() { + val state = AuthenticatedGraphState(ProviderProfileId("profile")) + .selectTab(MainTab.SCENES) + .pushChild("scene/editor/manual") + + val afterBack = state.handleBack() + + assertEquals(MainTab.SCENES, afterBack.currentTab) + assertEquals(listOf(MainTab.SCENES.route), afterBack.tabBackStack(MainTab.SCENES)) + } +} 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 new file mode 100644 index 0000000..5037e32 --- /dev/null +++ b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/preferences/SettingsViewModelTest.kt @@ -0,0 +1,148 @@ +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 +import com.flagship.abox.manager.preferences.UiPreferencesRepository +import com.flagship.abox.manager.preferences.UiPreferencesWriteResult +import com.flagship.abox.manager.testing.MainDispatcherRule +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class SettingsViewModelTest { + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @Test + fun rendererUpdatePersistsWithoutCallingSessionCoordinator() = runTest { + val repository = RecordingUiPreferencesRepository() + val viewModel = SettingsViewModel(repository) + advanceUntilIdle() + + viewModel.onAction(SettingsUiAction.SetRenderer(DesignRenderer.MIUIX)) + advanceUntilIdle() + + assertEquals(1, repository.updateCalls.size) + assertEquals(DesignRenderer.MIUIX, repository.value.renderer) + assertEquals(DesignRenderer.MIUIX, viewModel.uiState.value.preferences.renderer) + } + + @Test + fun writeFailureLeavesLastValueAndShowsLocalizedError() = runTest { + val repository = RecordingUiPreferencesRepository( + initial = UiPreferences(renderer = DesignRenderer.MATERIAL3), + writeResult = UiPreferencesWriteResult.Failure, + ) + val viewModel = SettingsViewModel(repository) + advanceUntilIdle() + + viewModel.onAction(SettingsUiAction.SetRenderer(DesignRenderer.MIUIX)) + advanceUntilIdle() + + assertEquals(1, repository.updateCalls.size) + assertEquals(DesignRenderer.MATERIAL3, viewModel.uiState.value.preferences.renderer) + assertTrue(viewModel.uiState.value.error is StringResource) + assertEquals( + R.string.settings_error_save, + (viewModel.uiState.value.error as StringResource).resId, + ) + assertTrue(viewModel.uiState.value.saving.isEmpty()) + } + + @Test + fun collectsRepositoryChanges() = runTest { + val repository = RecordingUiPreferencesRepository() + val viewModel = SettingsViewModel(repository) + advanceUntilIdle() + + repository.emit( + UiPreferences( + renderer = DesignRenderer.MIUIX, + themeMode = AppThemeMode.DARK, + dynamicColorEnabled = false, + blurEnabled = true, + navigationPresentation = NavigationPresentation.FLOATING, + hapticsEnabled = false, + ), + ) + advanceUntilIdle() + + assertTrue(viewModel.uiState.value.loaded) + 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(), + private val writeResult: UiPreferencesWriteResult = UiPreferencesWriteResult.Success, + ) : UiPreferencesRepository { + private val state = MutableStateFlow(initial) + val updateCalls = mutableListOf<(UiPreferences) -> UiPreferences>() + val value: UiPreferences get() = state.value + + override fun observe(): Flow = state.asStateFlow() + + override suspend fun update( + transform: (UiPreferences) -> UiPreferences, + ): UiPreferencesWriteResult { + updateCalls += transform + if (writeResult is UiPreferencesWriteResult.Success) { + state.value = transform(state.value) + } + return writeResult + } + + fun emit(value: UiPreferences) { + state.value = value + } + } +} diff --git a/android/app/src/test/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModelTest.kt b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModelTest.kt index 3458c11..2a56895 100644 --- a/android/app/src/test/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModelTest.kt +++ b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModelTest.kt @@ -20,6 +20,7 @@ import com.flagship.abox.manager.domain.SocketPowerState import com.flagship.abox.manager.testing.MainDispatcherRule import com.flagship.abox.manager.testing.fakes.FakeProviderProfileStore import com.flagship.abox.manager.testing.fakes.FakeSceneRepository +import com.flagship.abox.manager.testing.fakes.FakeDeviceCatalog import com.flagship.abox.manager.testing.fakes.FakeVirtualRemoteRepository import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.advanceUntilIdle @@ -39,6 +40,7 @@ class SceneEditorViewModelTest { private val profileStore = FakeProviderProfileStore() private val sceneRepository = FakeSceneRepository() private val virtualRemoteRepository = FakeVirtualRemoteRepository() + private val deviceCatalog = FakeDeviceCatalog() private val profileId = ProviderProfileId("local-experience") private val existingScene = Scene( @@ -66,7 +68,7 @@ class SceneEditorViewModelTest { @Test fun initializeWithNullPreparesNewScene() = runTest { setupProfile() - val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore) + val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore, deviceCatalog) viewModel.initialize(null, AutomationType.MANUAL) val state = viewModel.state.value @@ -79,7 +81,7 @@ class SceneEditorViewModelTest { @Test fun initializeWithExistingSceneLoadsState() = runTest { setupProfile() - val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore) + val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore, deviceCatalog) viewModel.initialize(existingScene.id) // Immediately binds sceneId and enters loading state @@ -101,7 +103,7 @@ class SceneEditorViewModelTest { @Test fun addingAndReorderingActionsUpdatesState() = runTest { setupProfile() - val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore) + val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore, deviceCatalog) viewModel.initialize(null, AutomationType.MANUAL) viewModel.updateName("My New Scene") @@ -129,7 +131,7 @@ class SceneEditorViewModelTest { @Test fun addingTriggersAndConditionsForAutomaticRule() = runTest { setupProfile() - val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore) + val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore, deviceCatalog) viewModel.initialize(null, AutomationType.AUTOMATIC) viewModel.updateName("Auto Rule") @@ -153,7 +155,7 @@ class SceneEditorViewModelTest { @Test fun saveNewScenePersistsToRepository() = runTest { setupProfile() - val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore) + val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore, deviceCatalog) viewModel.initialize(null, AutomationType.MANUAL) viewModel.updateName("Brand New Scene") viewModel.addSocketAction("socket-main", SocketPowerState.OFF) @@ -170,7 +172,7 @@ class SceneEditorViewModelTest { @Test fun automaticSceneWithoutTriggersIsInvalid() = runTest { setupProfile() - val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore) + val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore, deviceCatalog) viewModel.initialize(null, AutomationType.AUTOMATIC) viewModel.updateName("Auto Without Trigger") viewModel.addSocketAction("socket-1", SocketPowerState.ON) @@ -188,7 +190,7 @@ class SceneEditorViewModelTest { @Test fun initializeIsIdempotentAndPreservesDraftState() = runTest { setupProfile() - val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore) + val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore, deviceCatalog) viewModel.initialize(existingScene.id) advanceUntilIdle() diff --git a/android/app/src/test/kotlin/com/flagship/abox/manager/ui/session/SessionActionsViewModelTest.kt b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/session/SessionActionsViewModelTest.kt new file mode 100644 index 0000000..4d3586b --- /dev/null +++ b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/session/SessionActionsViewModelTest.kt @@ -0,0 +1,197 @@ +package com.flagship.abox.manager.ui.session + +import com.flagship.abox.manager.R +import com.flagship.abox.manager.domain.DomainError +import com.flagship.abox.manager.domain.LoginCredentials +import com.flagship.abox.manager.domain.Password +import com.flagship.abox.manager.domain.ProviderProfile +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.ProviderType +import com.flagship.abox.manager.domain.SessionCoordinator +import com.flagship.abox.manager.domain.SessionValidity +import com.flagship.abox.manager.domain.Username +import com.flagship.abox.manager.testing.MainDispatcherRule +import com.flagship.abox.manager.testing.fakes.FakeCredentialStore +import com.flagship.abox.manager.testing.fakes.FakeLocalSessionGateway +import com.flagship.abox.manager.testing.fakes.FakeProviderProfileStore +import com.flagship.abox.manager.testing.fakes.FakeSessionGateway +import com.flagship.abox.manager.ui.common.UiText +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class SessionActionsViewModelTest { + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + private val profile = ProviderProfile( + id = ProviderProfileId("abox"), + type = ProviderType.ABOX, + displayName = "ABox", + ) + private val credentials = LoginCredentials(Username("demo"), Password("secret")) + + @Test + fun endSessionActionEndsSessionWithoutForgettingCredentials() = runTest { + val fixture = Fixture() + fixture.activateAuthenticatedProfile() + fixture.coordinator.login(profile.id, credentials, rememberUsername = true, autoLogin = true) + val viewModel = SessionActionsViewModel(fixture.coordinator) + + viewModel.onAction(SessionUiAction.EndSession) + advanceUntilIdle() + + assertTrue(fixture.remote.session(profile.id) == null) + assertEquals( + credentials, + fixture.credentials.loadCredentials(profile.id).successValue(), + ) + assertNull(viewModel.uiState.value.errorMessage) + } + + @Test + fun forgetCredentialsActionKeepsSessionAndDisablesAutoLogin() = runTest { + val fixture = Fixture() + fixture.activateAuthenticatedProfile() + fixture.coordinator.login(profile.id, credentials, rememberUsername = true, autoLogin = true) + val viewModel = SessionActionsViewModel(fixture.coordinator) + + viewModel.onAction(SessionUiAction.ForgetSavedCredentials) + advanceUntilIdle() + + assertEquals(profile.id, fixture.remote.session(profile.id)?.profileId) + assertNull(fixture.credentials.loadCredentials(profile.id).successValue()) + assertTrue(!fixture.profiles.loginPreferences(profile.id).autoLoginEnabled) + } + + @Test + fun switchProviderActionClearsProfileOnlyAfterLogout() = runTest { + val fixture = Fixture() + fixture.activateAuthenticatedProfile() + fixture.remote.login(profile.id, credentials) + val viewModel = SessionActionsViewModel(fixture.coordinator) + + viewModel.onAction(SessionUiAction.SwitchProvider) + advanceUntilIdle() + + assertEquals(profile.id, fixture.remote.logoutProfileId) + assertNull(fixture.profiles.activeProfile()) + } + + @Test + fun validateConnectionActionDelegatesWithoutRelogin() = runTest { + val fixture = Fixture() + fixture.activateAuthenticatedProfile() + fixture.remote.setValidationResult( + profile.id, + com.flagship.abox.manager.domain.DomainResult.Success(SessionValidity.INVALID), + ) + val viewModel = SessionActionsViewModel(fixture.coordinator) + + viewModel.onAction(SessionUiAction.ValidateConnection) + advanceUntilIdle() + + assertEquals(SessionValidity.INVALID, fixture.remote.lastValidation) + assertEquals(0, fixture.remote.loginCalls) + } + + @Test + fun mappedActionErrorNeverExposesDomainDetail() = runTest { + val fixture = Fixture() + fixture.activateAuthenticatedProfile() + fixture.remote.logoutError = DomainError.Unknown(detail = "keystore secret detail") + val viewModel = SessionActionsViewModel(fixture.coordinator) + + viewModel.dispatch(SessionUiAction.EndSession) + advanceUntilIdle() + + assertEquals( + UiText.StringResource(R.string.error_unknown), + viewModel.uiState.value.errorMessage, + ) + } + + @Test + fun dismissErrorActionClearsMappedError() = runTest { + val fixture = Fixture() + fixture.activateAuthenticatedProfile() + fixture.remote.logoutError = DomainError.Network(detail = "private network detail") + val viewModel = SessionActionsViewModel(fixture.coordinator) + + viewModel.onAction(SessionUiAction.EndSession) + advanceUntilIdle() + viewModel.onAction(SessionUiAction.DismissError) + + assertNull(viewModel.uiState.value.errorMessage) + } + + private class Fixture { + val profiles = FakeProviderProfileStore() + val credentials = FakeCredentialStore() + val remote = RecordingSessionGateway(profiles) + val local = FakeLocalSessionGateway() + val coordinator = SessionCoordinator(profiles, credentials, remote, local) + + suspend fun activateAuthenticatedProfile() { + profiles.saveProfile( + ProviderProfile( + id = ProviderProfileId("abox"), + type = ProviderType.ABOX, + displayName = "ABox", + ), + ) + profiles.setActiveProfile(ProviderProfileId("abox")) + } + } + + private class RecordingSessionGateway( + private val profiles: FakeProviderProfileStore, + private val delegate: FakeSessionGateway = FakeSessionGateway(), + ) : com.flagship.abox.manager.domain.SessionGateway by delegate { + var loginCalls = 0 + private set + var logoutProfileId: ProviderProfileId? = null + private set + var lastValidation: SessionValidity? = null + private set + var logoutError: DomainError? = null + + override suspend fun login( + profileId: ProviderProfileId, + credentials: LoginCredentials, + ) = delegate.login(profileId, credentials).also { loginCalls++ } + + override suspend fun validateSession(profileId: ProviderProfileId) = delegate + .validateSession(profileId) + .also { result -> + lastValidation = (result as? com.flagship.abox.manager.domain.DomainResult.Success)?.value + } + + override suspend fun logout(profileId: ProviderProfileId) = logoutError?.let { + logoutProfileId = profiles.activeProfile()?.id + com.flagship.abox.manager.domain.DomainResult.Failure(it) + } ?: delegate.logout(profileId).also { + logoutProfileId = profiles.activeProfile()?.id + } + + suspend fun session(profileId: ProviderProfileId) = delegate.observeSession(profileId) + .let { flow -> flow.first() } + + fun setValidationResult( + profileId: ProviderProfileId, + result: com.flagship.abox.manager.domain.DomainResult, + ) { + delegate.setValidationResult(profileId, result) + } + } +} + +private fun com.flagship.abox.manager.domain.DomainResult.successValue(): T? = + (this as? com.flagship.abox.manager.domain.DomainResult.Success)?.value diff --git a/android/app/src/test/kotlin/com/flagship/abox/manager/ui/shell/MainTabTest.kt b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/shell/MainTabTest.kt index b65fdf6..a723da4 100644 --- a/android/app/src/test/kotlin/com/flagship/abox/manager/ui/shell/MainTabTest.kt +++ b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/shell/MainTabTest.kt @@ -9,7 +9,7 @@ class MainTabTest { @Test fun `all main tabs have distinct non-empty routes and valid label resources`() { - val tabs = MainTab.values() + val tabs = MainTab.entries assertEquals(3, tabs.size) val routes = tabs.map { it.route } diff --git a/android/app/src/test/kotlin/com/flagship/abox/manager/ui/socket/SocketViewModelTest.kt b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/socket/SocketViewModelTest.kt index 9c43ba8..9aa680f 100644 --- a/android/app/src/test/kotlin/com/flagship/abox/manager/ui/socket/SocketViewModelTest.kt +++ b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/socket/SocketViewModelTest.kt @@ -157,6 +157,32 @@ class SocketViewModelTest { assertNotNull(state.errorMessage) } + @Test + fun commandSuccessReadsBackConfirmedState() = runTest { + val viewModel = SocketViewModel(fakeSocketGateway, SavedStateHandle()) + viewModel.onEvent(SocketUiEvent.Initialize(profileId, deviceName)) + + viewModel.onEvent(SocketUiEvent.SetPower(SocketPowerState.ON)) + + assertEquals(SocketPowerState.ON, viewModel.state.value.powerState) + assertEquals(SocketOperationState.IdleConfirmed, viewModel.state.value.operation) + } + + @Test + fun readbackFailureKeepsLastConfirmedStateAndMarksAwaitingConfirmation() = runTest { + val viewModel = SocketViewModel(fakeSocketGateway, SavedStateHandle()) + viewModel.onEvent(SocketUiEvent.Initialize(profileId, deviceName)) + fakeSocketGateway.setError(profileId, deviceName, DomainError.Network(detail = "Readback failed")) + + viewModel.onEvent(SocketUiEvent.SetPower(SocketPowerState.ON)) + + assertEquals(SocketPowerState.OFF, viewModel.state.value.powerState) + assertEquals( + SocketOperationState.AwaitingConfirmation(SocketPowerState.ON), + viewModel.state.value.operation, + ) + } + @Test fun refresh_queriesUpdatedGatewayStatus() = runTest { val viewModel = SocketViewModel(fakeSocketGateway, SavedStateHandle()) diff --git a/android/app/src/test/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityViewModelTest.kt b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityViewModelTest.kt index b5c2512..485df55 100644 --- a/android/app/src/test/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityViewModelTest.kt +++ b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityViewModelTest.kt @@ -129,8 +129,8 @@ class TemperatureHumidityViewModelTest { assertTrue(state.hasNoData) assertNull(state.temperature) assertNull(state.humidity) - assertEquals("--", state.formattedTemperature) - assertEquals("--", state.formattedHumidity) + assertNull(state.formattedTemperature) + assertNull(state.formattedHumidity) assertFalse(state.hasValidData) } @@ -190,4 +190,16 @@ class TemperatureHumidityViewModelTest { viewModel.onEvent(TemperatureHumidityUiEvent.ClearError) assertNull(viewModel.state.value.errorMessage) } + + @Test + fun partialReadingKeepsAvailableMetric() = runTest { + fakeGateway.setReading( + TemperatureHumidityReading(profileId, deviceName, temperature = "26.5", humidity = null), + ) + val viewModel = TemperatureHumidityViewModel(fakeGateway, SavedStateHandle()) + viewModel.onEvent(TemperatureHumidityUiEvent.Initialize(profileId, deviceName)) + + assertEquals("26.5", viewModel.state.value.temperature) + assertNull(viewModel.state.value.humidity) + } } diff --git a/android/core/designsystem/build.gradle.kts b/android/core/designsystem/build.gradle.kts index 18a7dfe..8f3340d 100644 --- a/android/core/designsystem/build.gradle.kts +++ b/android/core/designsystem/build.gradle.kts @@ -1,11 +1,17 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + plugins { alias(libs.plugins.android.library) alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.compose.screenshot) } android { namespace = "com.flagship.abox.manager.designsystem" - compileSdk = 34 + compileSdk = 37 + + experimentalProperties["android.experimental.enableScreenshotTest"] = true defaultConfig { minSdk = 26 @@ -15,25 +21,32 @@ android { compose = true } - composeOptions { - kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get() - } - compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } +} - kotlinOptions { - jvmTarget = "17" - } +kotlin { + compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } } dependencies { + implementation(project(":android:core:preferences")) implementation(platform(libs.compose.bom)) implementation(libs.compose.ui) implementation(libs.compose.ui.tooling.preview) implementation(libs.compose.material3) implementation(libs.compose.material.icons.extended) + implementation(libs.androidx.activity.compose) + implementation(libs.miuix.ui.android) debugImplementation(libs.compose.ui.tooling) + screenshotTestImplementation(libs.compose.screenshot.test) + testImplementation(libs.junit) + androidTestImplementation(platform(libs.compose.bom)) + androidTestImplementation(libs.androidx.activity.compose) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(libs.compose.ui.test.junit4) + debugImplementation(libs.compose.ui.test.manifest) } diff --git a/android/core/designsystem/src/androidTest/kotlin/com/flagship/abox/manager/designsystem/ABoxComponentSemanticsTest.kt b/android/core/designsystem/src/androidTest/kotlin/com/flagship/abox/manager/designsystem/ABoxComponentSemanticsTest.kt new file mode 100644 index 0000000..e5657d7 --- /dev/null +++ b/android/core/designsystem/src/androidTest/kotlin/com/flagship/abox/manager/designsystem/ABoxComponentSemanticsTest.kt @@ -0,0 +1,183 @@ +package com.flagship.abox.manager.designsystem + +import android.os.Build +import androidx.activity.ComponentActivity +import androidx.compose.material3.Text +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.assertContentDescriptionEquals +import androidx.compose.ui.test.assertHasClickAction +import androidx.compose.ui.test.assertHeightIsAtLeast +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsOff +import androidx.compose.ui.test.assertWidthIsAtLeast +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.unit.dp +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.flagship.abox.manager.designsystem.icon.ABoxIconKey +import com.flagship.abox.manager.preferences.AppThemeMode +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 + +@RunWith(AndroidJUnit4::class) +class ABoxComponentSemanticsTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun allInteractiveControlsHaveAtLeast48DpBounds() { + composeRule.setContent { + ABoxTheme { + ABoxButton(onClick = {}, modifier = Modifier.testTag("button")) { + Text("确定") + } + ABoxIconButton( + key = ABoxIconKey.ADD, + contentDescription = "添加设备", + onClick = {}, + modifier = Modifier.testTag("icon"), + ) + ABoxSwitch( + checked = false, + onCheckedChange = {}, + contentDescription = "启用设备", + modifier = Modifier.testTag("switch"), + ) + } + } + + listOf("button", "icon", "switch").forEach { tag -> + composeRule.onNodeWithTag(tag) + .assertWidthIsAtLeast(48.dp) + .assertHeightIsAtLeast(48.dp) + } + } + + @Test + fun operableSwitchExposesChineseLabelRoleStateAndToggleAction() { + var checked = false + composeRule.setContent { + ABoxTheme { + ABoxSwitch( + checked = checked, + onCheckedChange = { checked = it }, + contentDescription = "启用设备", + modifier = Modifier.testTag("operable-switch"), + ) + } + } + + val switchNode = composeRule.onNodeWithTag("operable-switch") + .assertContentDescriptionEquals("启用设备") + .assertIsOff() + .assertHasClickAction() + .assertWidthIsAtLeast(48.dp) + .assertHeightIsAtLeast(48.dp) + .fetchSemanticsNode() + assertEquals(Role.Switch, switchNode.config[SemanticsProperties.Role]) + assertTrue(switchNode.config.contains(SemanticsProperties.ToggleableState)) + + composeRule.onNodeWithTag("operable-switch").performClick() + composeRule.waitForIdle() + assertEquals(true, checked) + } + + @Test + fun cardBodyAndQuickActionExposeIndependentSemanticNodes() { + composeRule.setContent { + ABoxTheme { + ABoxCard( + title = "客厅插座", + subtitle = "在线", + onClick = {}, + quickAction = ABoxCardQuickAction( + key = ABoxIconKey.REFRESH, + contentDescription = "刷新设备", + onClick = {}, + ), + ) + } + } + + composeRule.onNodeWithText("客厅插座").assertIsDisplayed().assertHasClickAction() + composeRule.onNodeWithContentDescription("刷新设备").assertIsDisplayed().assertHasClickAction() + } + + @Test + fun renderersExposeSameChineseStateDescription() { + var renderer by mutableStateOf(DesignRenderer.MATERIAL3) + composeRule.setContent { + ABoxTheme( + preferences = UiPreferences( + renderer = renderer, + themeMode = AppThemeMode.LIGHT, + dynamicColorEnabled = false, + ), + ) { + ABoxStatusBanner( + state = ABoxStatus.PENDING, + contentDescription = "正在处理", + ) + } + } + + composeRule.onNodeWithContentDescription("正在处理").assertExists() + composeRule.runOnUiThread { renderer = DesignRenderer.MIUIX } + composeRule.waitForIdle() + composeRule.onNodeWithContentDescription("正在处理").assertExists() + } + + @Test + fun reducedMotionExposesStaticLoadingStateThroughThemeSeam() { + composeRule.setContent { + ABoxThemeContent( + preferences = UiPreferences( + renderer = DesignRenderer.MATERIAL3, + themeMode = AppThemeMode.LIGHT, + dynamicColorEnabled = false, + ), + systemInDarkTheme = false, + sdkInt = Build.VERSION.SDK_INT, + dynamicColorTokens = null, + reducedMotion = true, + ) { + ABoxLoadingState(contentDescription = "正在加载") + } + } + + val loadingNode = composeRule.onNodeWithContentDescription("正在加载") + .assertExists() + .fetchSemanticsNode() + assertEquals("静态加载", loadingNode.config[SemanticsProperties.StateDescription]) + } + + @Test + fun interactiveIconRequiresNonBlankChineseDescription() { + composeRule.setContent { + ABoxTheme { + ABoxIconButton( + key = ABoxIconKey.BACK, + contentDescription = "返回", + onClick = {}, + ) + } + } + + composeRule.onNodeWithContentDescription("返回") + .assertContentDescriptionEquals("返回") + } +} 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 8f3fb5a..f8dda1d 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 @@ -1,51 +1,97 @@ package com.flagship.abox.manager.designsystem +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.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +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.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.LocationOn -import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.outlined.AccountCircle +import androidx.compose.material.icons.outlined.Bolt +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Icon +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.NavigationBarItemDefaults +import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface +import androidx.compose.material3.Switch import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue 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.graphics.vector.ImageVector +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.foundation.selection.toggleable +import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import com.flagship.abox.manager.designsystem.icon.ABoxIcon +import com.flagship.abox.manager.designsystem.icon.ABoxIconKey +import com.flagship.abox.manager.designsystem.tokens.ABoxSpacing +import androidx.compose.material3.Icon as MaterialIcon +@Composable +fun ABoxAppSurface( + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + val colors = LocalABoxColors.current + val blurEnabled = LocalABoxBlurEnabled.current + Surface( + modifier = modifier, + color = if (blurEnabled) colors.surfaceContainer.copy(alpha = 0.92f) else colors.surface, + contentColor = MaterialTheme.colorScheme.onSurface, + content = content, + ) +} + +/** Compatibility surface retained for existing feature modules. */ @Composable fun ABoxSurface( modifier: Modifier = Modifier, content: @Composable () -> Unit, -) { - Surface(modifier = modifier, content = content) -} +) = ABoxAppSurface(modifier = modifier, content = content) @Composable fun ABoxButton( @@ -56,7 +102,7 @@ fun ABoxButton( ) { Button( onClick = onClick, - modifier = modifier, + modifier = modifier.defaultMinSize(minHeight = 48.dp), enabled = enabled, colors = ButtonDefaults.buttonColors(), content = content, @@ -64,14 +110,437 @@ fun ABoxButton( } @Composable -fun ABoxLoadingIndicator(modifier: Modifier = Modifier) { - CircularProgressIndicator(modifier = modifier) +fun ABoxIconButton( + key: ABoxIconKey, + contentDescription: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + require(contentDescription.isNotBlank()) { + "ABoxIconButton contentDescription must not be blank" + } + IconButton( + onClick = onClick, + enabled = enabled, + modifier = modifier.defaultMinSize(minWidth = 48.dp, minHeight = 48.dp), + ) { + ABoxIcon(key = key, contentDescription = contentDescription) + } } -/** - * Circular user avatar displaying the initial character of username or a default person icon. - * Styled with an accented profile ring. - */ +@Composable +fun ABoxSwitch( + checked: Boolean, + onCheckedChange: ((Boolean) -> Unit)?, + contentDescription: String, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + require(contentDescription.isNotBlank()) { + "ABoxSwitch contentDescription must not be blank" + } + Box( + modifier = modifier + .defaultMinSize(minWidth = 48.dp, minHeight = 48.dp) + .toggleable( + value = checked, + enabled = enabled && onCheckedChange != null, + role = Role.Switch, + onValueChange = { onCheckedChange?.invoke(it) }, + ) + .semantics { + this.contentDescription = contentDescription + }, + contentAlignment = Alignment.Center, + ) { + Switch( + checked = checked, + onCheckedChange = null, + enabled = enabled, + modifier = Modifier.clearAndSetSemantics { }, + ) + } +} + +@Composable +fun ABoxFilterChip( + selected: Boolean, + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + leadingIcon: (@Composable () -> Unit)? = null, +) { + FilterChip( + selected = selected, + onClick = onClick, + label = { Text(label) }, + modifier = modifier.defaultMinSize(minHeight = 48.dp), + leadingIcon = leadingIcon, + colors = FilterChipDefaults.filterChipColors(), + ) +} + +@Composable +fun ABoxLoadingIndicator(modifier: Modifier = Modifier) { + val reducedMotion = LocalABoxReducedMotion.current + if (reducedMotion) { + CircularProgressIndicator( + progress = { 0.5f }, + modifier = modifier, + ) + } else { + CircularProgressIndicator(modifier = modifier) + } +} + +@Composable +fun ABoxLoadingState( + contentDescription: String, + modifier: Modifier = Modifier, +) { + require(contentDescription.isNotBlank()) { + "ABoxLoadingState contentDescription must not be blank" + } + val reducedMotion = LocalABoxReducedMotion.current + Column( + modifier = modifier + .fillMaxWidth() + .semantics { + this.contentDescription = contentDescription + stateDescription = if (reducedMotion) "静态加载" else "动态加载" + }, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + ABoxLoadingIndicator(modifier = Modifier.size(32.dp)) + Text(text = contentDescription) + } +} + +enum class ABoxStatus { + ONLINE, + OFFLINE, + PENDING, + WARNING, + ERROR, +} + +@Composable +fun ABoxStatusBanner( + state: ABoxStatus, + contentDescription: String, + modifier: Modifier = Modifier, + message: String = contentDescription, +) { + require(contentDescription.isNotBlank()) { + "ABoxStatusBanner contentDescription must not be blank" + } + val colors = LocalABoxColors.current + val (iconKey, color) = when (state) { + ABoxStatus.ONLINE -> ABoxIconKey.ONLINE to colors.success + ABoxStatus.OFFLINE -> ABoxIconKey.OFFLINE to colors.offline + ABoxStatus.PENDING -> ABoxIconKey.PENDING to colors.pending + ABoxStatus.WARNING -> ABoxIconKey.WARNING to colors.warning + ABoxStatus.ERROR -> ABoxIconKey.ERROR to colors.error + } + Surface( + modifier = modifier + .fillMaxWidth() + .semantics { this.contentDescription = contentDescription }, + color = color.copy(alpha = 0.14f), + shape = RoundedCornerShape(16.dp), + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + ABoxIcon(key = iconKey, contentDescription = "${contentDescription}图标", modifier = Modifier.size(22.dp)) + Text(text = message, color = color, style = MaterialTheme.typography.labelLarge) + } + } +} + +@Composable +fun ABoxEmptyState( + title: String, + description: String, + modifier: Modifier = Modifier, + action: (@Composable () -> Unit)? = null, +) { + Column( + modifier = modifier.fillMaxWidth().padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + ABoxIcon(ABoxIconKey.DEVICES, contentDescription = title, modifier = Modifier.size(40.dp)) + Text(text = title, style = MaterialTheme.typography.titleMedium) + Text(text = description, style = MaterialTheme.typography.bodyMedium) + action?.invoke() + } +} + +@Composable +fun ABoxErrorState( + title: String, + description: String, + onRetry: (() -> Unit)? = null, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth().padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + ABoxIcon(ABoxIconKey.ERROR, contentDescription = title, modifier = Modifier.size(40.dp)) + Text(text = title, style = MaterialTheme.typography.titleMedium) + Text(text = description, style = MaterialTheme.typography.bodyMedium) + if (onRetry != null) { + ABoxButton(onClick = onRetry) { Text("重试") } + } + } +} + +@Composable +fun ABoxCard( + title: String, + subtitle: String? = null, + onClick: (() -> Unit)? = null, + modifier: Modifier = Modifier, + leadingContent: (@Composable () -> Unit)? = null, + quickAction: ABoxCardQuickAction? = null, + content: (@Composable ColumnScope.() -> Unit)? = null, +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + shape = RoundedCornerShape(LocalABoxShapes.current.mediumCornerRadius), + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + leadingContent?.let { + Box(modifier = Modifier.padding(end = 12.dp), contentAlignment = Alignment.Center) { + it() + } + } + Column( + modifier = Modifier + .weight(1f) + .then( + if (onClick != null) { + Modifier + .clickable( + role = Role.Button, + onClickLabel = "打开$title", + onClick = onClick, + ) + .semantics { role = Role.Button } + } else { + Modifier + }, + ) + .padding(vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (subtitle != null) { + Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + content?.invoke(this) + } + if (quickAction != null) { + Spacer(Modifier.width(8.dp)) + ABoxIconButton( + key = quickAction.key, + contentDescription = quickAction.contentDescription, + onClick = quickAction.onClick, + ) + } + } + } +} + +data class ABoxCardQuickAction( + val key: ABoxIconKey, + val contentDescription: String, + val onClick: () -> Unit, +) + +@Composable +fun ABoxDialog( + visible: Boolean, + title: String, + onDismissRequest: () -> Unit, + onConfirm: () -> Unit, + modifier: Modifier = Modifier, + confirmText: String = "确定", + dismissText: String = "取消", + content: @Composable () -> Unit, +) { + if (visible) { + AlertDialog( + onDismissRequest = onDismissRequest, + modifier = modifier, + title = { Text(title) }, + text = content, + confirmButton = { + ABoxButton(onClick = onConfirm) { Text(confirmText) } + }, + dismissButton = { + TextButton(onClick = onDismissRequest) { Text(dismissText) } + }, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ABoxBottomSheet( + visible: Boolean, + onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) { + if (visible) { + ModalBottomSheet( + onDismissRequest = onDismissRequest, + modifier = modifier, + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(16.dp), + content = content, + ) + } + } +} + +@Composable +fun ABoxPreferenceGroup( + title: String, + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) { + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 16.dp), + ) + content() + } +} + +data class ABoxNavigationItem( + val key: ABoxIconKey, + val label: String, + val contentDescription: String = label, +) + +@Composable +fun ABoxNavigationSuite( + items: List, + selectedKey: ABoxIconKey, + onItemSelected: (ABoxIconKey) -> Unit, + modifier: Modifier = Modifier, +) { + ABoxNavigationBar(modifier = modifier) { + items.forEach { item -> + ABoxNavigationBarItem( + selected = item.key == selectedKey, + onClick = { onItemSelected(item.key) }, + icon = { + ABoxIcon( + key = item.key, + contentDescription = item.contentDescription, + ) + }, + label = { Text(item.label) }, + ) + } + } +} + +@Composable +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() + .statusBarsPadding() + .padding(horizontal = 8.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + ABoxIconButton( + key = ABoxIconKey.BACK, + contentDescription = "返回", + onClick = onBack, + ) + 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, + ) +} + +/** Circular user avatar displaying the initial character of username or a default person icon. */ @Composable fun ABoxUserAvatar( name: String?, @@ -83,73 +552,73 @@ fun ABoxUserAvatar( val initial = name?.trim()?.takeIf { it.isNotEmpty() }?.take(1)?.uppercase() Box( modifier = modifier - .size(size) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.primaryContainer) - .border( - width = 2.dp, - color = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f), - shape = CircleShape, - ) + .defaultMinSize(minWidth = 48.dp, minHeight = 48.dp) .clickable(onClickLabel = contentDescription, onClick = onClick), contentAlignment = Alignment.Center, ) { - if (initial != null) { - Text( - text = initial, - style = MaterialTheme.typography.titleMedium.copy( - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onPrimaryContainer, + Box( + modifier = Modifier + .size(size) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primaryContainer) + .border( + width = 2.dp, + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f), + shape = CircleShape, ), - ) - } else { - Icon( - imageVector = Icons.Default.Person, - contentDescription = contentDescription, - tint = MaterialTheme.colorScheme.onPrimaryContainer, - modifier = Modifier.size(size * 0.6f), - ) + contentAlignment = Alignment.Center, + ) { + if (initial != null) { + Text( + text = initial, + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ), + ) + } else { + MaterialIcon( + imageVector = ABoxIconMapperForAccount, + contentDescription = contentDescription, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(size * 0.6f), + ) + } } } } -/** - * Material 3 pill top bar containing leading icon, title/provider label, - * an optional '+' action button, and user avatar. - */ +private val ABoxIconMapperForAccount = Icons.Outlined.AccountCircle + +/** Material 3 pill top bar containing leading icon, title/provider label, actions, and user avatar. */ @Composable fun ABoxTopPillBar( title: String, onAvatarClick: () -> Unit, modifier: Modifier = Modifier, userName: String? = null, - leadingIcon: ImageVector = Icons.Default.LocationOn, + leadingIcon: ImageVector = Icons.Outlined.Bolt, onAddClick: (() -> Unit)? = null, - addActionContentDescription: String? = "Add", - avatarContentDescription: String? = "Account", + addActionContentDescription: String? = "添加", + avatarContentDescription: String? = "账户", ) { Surface( - modifier = modifier - .fillMaxWidth() - .height(56.dp), - shape = RoundedCornerShape(28.dp), + modifier = modifier.fillMaxWidth().height(64.dp), + shape = RoundedCornerShape(32.dp), color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f), tonalElevation = 2.dp, ) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp), + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - Icon( + MaterialIcon( imageVector = leadingIcon, - contentDescription = null, + contentDescription = title, tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(22.dp), + modifier = Modifier.padding(start = 8.dp).size(22.dp), ) - Text( text = title, style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), @@ -158,20 +627,13 @@ fun ABoxTopPillBar( overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f), ) - if (onAddClick != null) { - IconButton( + ABoxIconButton( + key = ABoxIconKey.ADD, + contentDescription = addActionContentDescription ?: "添加", onClick = onAddClick, - modifier = Modifier.size(36.dp), - ) { - Icon( - imageVector = Icons.Default.Add, - contentDescription = addActionContentDescription, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + ) } - ABoxUserAvatar( name = userName, onClick = onAvatarClick, @@ -182,9 +644,6 @@ fun ABoxTopPillBar( } } -/** - * ABox standard Material 3 Navigation Bar with pill indicator styling. - */ @Composable fun ABoxNavigationBar( modifier: Modifier = Modifier, @@ -198,9 +657,6 @@ fun ABoxNavigationBar( ) } -/** - * ABox standard Navigation Bar Item. - */ @Composable fun RowScope.ABoxNavigationBarItem( selected: Boolean, @@ -226,3 +682,16 @@ fun RowScope.ABoxNavigationBarItem( ), ) } + +@Composable +fun ABoxBackLayer( + onBack: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + content: @Composable () -> Unit, +) { + BackHandler(enabled = enabled, onBack = onBack) + Box(modifier = modifier.fillMaxSize()) { + content() + } +} diff --git a/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Shape.kt b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Shape.kt index 5bc6176..f19373c 100644 --- a/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Shape.kt +++ b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Shape.kt @@ -2,10 +2,11 @@ package com.flagship.abox.manager.designsystem import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Shapes -import androidx.compose.ui.unit.dp +import com.flagship.abox.manager.designsystem.tokens.ABoxShapeTokens +import com.flagship.abox.manager.designsystem.tokens.ABoxThemeDefaults -val ABoxShapes = Shapes( - small = RoundedCornerShape(8.dp), - medium = RoundedCornerShape(16.dp), - large = RoundedCornerShape(24.dp), +fun ABoxShapes(tokens: ABoxShapeTokens = ABoxThemeDefaults.material3Shapes) = Shapes( + small = RoundedCornerShape(tokens.smallCornerRadius), + medium = RoundedCornerShape(tokens.mediumCornerRadius), + large = RoundedCornerShape(tokens.largeCornerRadius), ) 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 7d83a34..e8e9518 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 @@ -1,41 +1,205 @@ package com.flagship.abox.manager.designsystem -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.material3.MaterialTheme +import android.os.Build +import android.provider.Settings +import androidx.compose.material3.ColorScheme import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +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 +import com.flagship.abox.manager.designsystem.tokens.ABoxSpacing +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 -private val LightColorScheme = lightColorScheme( - primary = Color(0xFF006780), - onPrimary = Color.White, - primaryContainer = Color(0xFFB8EAFF), - onPrimaryContainer = Color(0xFF001F29), - secondary = Color(0xFF4D616A), - surface = Color(0xFFF8FAFC), - background = Color(0xFFF8FAFC), -) - -private val DarkColorScheme = darkColorScheme( - primary = Color(0xFF5DD5F8), - onPrimary = Color(0xFF003544), - primaryContainer = Color(0xFF004D61), - onPrimaryContainer = Color(0xFFB8EAFF), - secondary = Color(0xFFB4CBD4), - surface = Color(0xFF101416), - background = Color(0xFF101416), -) +val LocalABoxColors = staticCompositionLocalOf { ABoxThemeDefaults.lightColors } +val LocalABoxSpacing = staticCompositionLocalOf { ABoxSpacing() } +val LocalABoxTypography = staticCompositionLocalOf { ABoxTypography } +val LocalABoxShapes = staticCompositionLocalOf { ABoxThemeDefaults.material3Shapes } +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( - darkTheme: Boolean = isSystemInDarkTheme(), + preferences: UiPreferences, content: @Composable () -> Unit, ) { - MaterialTheme( - colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme, - typography = ABoxTypography, - shapes = ABoxShapes, + val systemInDarkTheme = androidx.compose.foundation.isSystemInDarkTheme() + val context = LocalContext.current + val isDarkTheme = when (preferences.themeMode) { + AppThemeMode.SYSTEM -> systemInDarkTheme + AppThemeMode.LIGHT -> false + AppThemeMode.DARK -> true + } + val dynamicTokens = if (preferences.dynamicColorEnabled && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val dynamicColorScheme = if (isDarkTheme) { + dynamicDarkColorScheme(context) + } else { + dynamicLightColorScheme(context) + } + dynamicColorScheme.toABoxColorTokens() + } else { + null + } + ABoxThemeContent( + preferences = preferences, + systemInDarkTheme = systemInDarkTheme, + sdkInt = Build.VERSION.SDK_INT, + dynamicColorTokens = dynamicTokens, + reducedMotion = systemAnimatorDurationScale(context) == 0f, content = content, ) } + +@Composable +internal fun ABoxThemeContent( + preferences: UiPreferences, + systemInDarkTheme: Boolean, + sdkInt: Int, + dynamicColorTokens: ABoxColorTokens?, + reducedMotion: Boolean, + content: @Composable () -> Unit, +) { + val resolution = ABoxThemeResolver.resolve( + preferences = preferences, + systemInDarkTheme = systemInDarkTheme, + sdkInt = sdkInt, + 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, + LocalABoxShapes provides resolution.shapeTokens, + LocalABoxRenderer provides resolution.renderer, + LocalABoxReducedMotion provides reducedMotion, + LocalABoxBlurEnabled provides resolution.blurEnabled, + LocalABoxNavigationPresentation provides preferences.navigationPresentation, + LocalABoxLiquidGlassEnabled provides preferences.liquidGlassEnabled, + LocalABoxPredictiveBackEnabled provides false, + ) { + renderer.Render( + resolution = resolution, + content = content, + ) + } +} + +private fun systemAnimatorDurationScale(context: android.content.Context): Float = + runCatching { + Settings.Global.getFloat( + context.contentResolver, + Settings.Global.ANIMATOR_DURATION_SCALE, + 1f, + ) + }.getOrDefault(1f) + + +@Composable +fun ABoxTheme( + darkTheme: Boolean = androidx.compose.foundation.isSystemInDarkTheme(), + content: @Composable () -> Unit, +) { + ABoxTheme( + preferences = UiPreferences( + themeMode = if (darkTheme) AppThemeMode.DARK else AppThemeMode.LIGHT, + dynamicColorEnabled = false, + ), + content = content, + ) +} + +internal fun materialColorScheme(resolution: ABoxThemeResolution): ColorScheme { + val colors = resolution.colorTokens + val onPrimary = if (resolution.isDarkTheme) Color(0xFF003544) else Color.White + val onSurface = if (resolution.isDarkTheme) Color(0xFFE0E3E5) else Color(0xFF191C1E) + val onSurfaceVariant = if (resolution.isDarkTheme) Color(0xFFC0C8CC) else Color(0xFF40484C) + val primaryContainer = colors.primary.copy(alpha = if (resolution.isDarkTheme) 0.35f else 0.20f) + val secondaryContainer = colors.surfaceContainer + + return if (resolution.isDarkTheme) { + darkColorScheme( + primary = colors.primary, + onPrimary = onPrimary, + primaryContainer = primaryContainer, + onPrimaryContainer = colors.primary, + secondary = colors.pending, + onSecondary = Color(0xFF3F2E00), + secondaryContainer = secondaryContainer, + onSecondaryContainer = onSurface, + tertiary = colors.success, + onTertiary = Color(0xFF00391E), + surface = colors.surface, + onSurface = onSurface, + surfaceVariant = colors.surfaceContainer, + onSurfaceVariant = onSurfaceVariant, + background = colors.surface, + onBackground = onSurface, + outline = colors.outline, + error = colors.error, + onError = Color(0xFF690005), + ) + } else { + lightColorScheme( + primary = colors.primary, + onPrimary = onPrimary, + primaryContainer = primaryContainer, + onPrimaryContainer = Color(0xFF001F29), + secondary = colors.pending, + onSecondary = Color.White, + secondaryContainer = secondaryContainer, + onSecondaryContainer = onSurface, + tertiary = colors.success, + onTertiary = Color.White, + surface = colors.surface, + onSurface = onSurface, + surfaceVariant = colors.surfaceContainer, + onSurfaceVariant = onSurfaceVariant, + background = colors.surface, + onBackground = onSurface, + outline = colors.outline, + error = colors.error, + onError = Color.White, + ) + } +} + +private fun ColorScheme.toABoxColorTokens() = ABoxColorTokens( + primary = primary, + surface = surface, + surfaceContainer = surfaceContainer, + outline = outline, + success = tertiary, + warning = secondary, + error = error, + offline = outline, + pending = secondary, + deviceSocket = primary, + deviceSensor = tertiary, + deviceInfrared = secondary, +) diff --git a/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Type.kt b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Type.kt index 0048478..1f4b05a 100644 --- a/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Type.kt +++ b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Type.kt @@ -2,4 +2,5 @@ package com.flagship.abox.manager.designsystem import androidx.compose.material3.Typography +/** Default typography supplied through [LocalABoxTypography]. */ val ABoxTypography = Typography() 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 new file mode 100644 index 0000000..bbda6fe --- /dev/null +++ b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/icon/ABoxIcon.kt @@ -0,0 +1,129 @@ +package com.flagship.abox.manager.designsystem.icon + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.outlined.AccountCircle +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.Bolt +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.Devices +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 +import androidx.compose.material.icons.outlined.Refresh +import androidx.compose.material.icons.outlined.Schedule +import androidx.compose.material.icons.outlined.Sensors +import androidx.compose.material.icons.outlined.Thermostat +import androidx.compose.material.icons.outlined.Warning +import androidx.compose.material.icons.outlined.WaterDrop +import androidx.compose.material.icons.outlined.Wifi +import androidx.compose.material.icons.outlined.WifiOff +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.material3.Icon as MaterialIcon + +/** Stable semantic identifiers for every icon exposed by the ABox design system. */ +enum class ABoxIconKey { + BACK, + CLOSE, + ADD, + EDIT, + REFRESH, + ACCOUNT, + PROVIDER, + DEVICES, + SCENES, + ACTIVITY, + SOCKET, + SENSOR, + INFRARED, + POWER, + TEMPERATURE, + HUMIDITY, + ONLINE, + OFFLINE, + PENDING, + WARNING, + ERROR, + UNKNOWN_DEVICE, + SETTINGS, + THEME, + EFFECTS, + FEEDBACK, + SCALE, +} + +/** Design-system-owned device categories; this module does not depend on domain models. */ +enum class ABoxDeviceType { + SOCKET, + SENSOR, + INFRARED, + POWER, + UNKNOWN, +} + +object ABoxIconMapper { + fun forDeviceType(type: ABoxDeviceType?): ABoxIconKey = when (type) { + ABoxDeviceType.SOCKET -> ABoxIconKey.SOCKET + ABoxDeviceType.SENSOR -> ABoxIconKey.SENSOR + ABoxDeviceType.INFRARED -> ABoxIconKey.INFRARED + ABoxDeviceType.POWER -> ABoxIconKey.POWER + null, ABoxDeviceType.UNKNOWN -> ABoxIconKey.UNKNOWN_DEVICE + } + + internal fun vectorFor(key: ABoxIconKey): ImageVector = when (key) { + ABoxIconKey.BACK -> Icons.AutoMirrored.Outlined.ArrowBack + ABoxIconKey.CLOSE -> Icons.Outlined.Close + ABoxIconKey.ADD -> Icons.Outlined.Add + ABoxIconKey.EDIT -> Icons.Outlined.Edit + ABoxIconKey.REFRESH -> Icons.Outlined.Refresh + ABoxIconKey.ACCOUNT -> Icons.Outlined.AccountCircle + ABoxIconKey.PROVIDER -> Icons.Outlined.Bolt + ABoxIconKey.DEVICES -> Icons.Outlined.Devices + ABoxIconKey.SCENES -> Icons.Outlined.Layers + ABoxIconKey.ACTIVITY -> Icons.Outlined.History + ABoxIconKey.SOCKET -> Icons.Outlined.Power + ABoxIconKey.SENSOR -> Icons.Outlined.Sensors + ABoxIconKey.INFRARED -> Icons.Outlined.Bolt + ABoxIconKey.POWER -> Icons.Outlined.PowerSettingsNew + ABoxIconKey.TEMPERATURE -> Icons.Outlined.Thermostat + ABoxIconKey.HUMIDITY -> Icons.Outlined.WaterDrop + ABoxIconKey.ONLINE -> Icons.Outlined.Wifi + ABoxIconKey.OFFLINE -> Icons.Outlined.WifiOff + ABoxIconKey.PENDING -> Icons.Outlined.Schedule + 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 + } +} + +@Composable +fun ABoxIcon( + key: ABoxIconKey, + contentDescription: String, + modifier: Modifier = Modifier, +) { + require(contentDescription.isNotBlank()) { + "ABoxIcon contentDescription must not be blank" + } + MaterialIcon( + imageVector = ABoxIconMapper.vectorFor(key), + contentDescription = contentDescription, + modifier = modifier, + ) +} 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 new file mode 100644 index 0000000..7fbe760 --- /dev/null +++ b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/renderer/ABoxRendererAdapter.kt @@ -0,0 +1,17 @@ +package com.flagship.abox.manager.designsystem.renderer + +import androidx.compose.runtime.Composable +import com.flagship.abox.manager.designsystem.tokens.ABoxThemeResolution +import com.flagship.abox.manager.preferences.DesignRenderer + +internal interface ABoxRendererAdapter { + @Composable + fun Render(resolution: ABoxThemeResolution, content: @Composable () -> Unit) +} + +internal object ABoxRendererAdapters { + fun forRenderer(renderer: DesignRenderer): ABoxRendererAdapter = when (renderer) { + DesignRenderer.MATERIAL3 -> Material3RendererAdapter + DesignRenderer.MIUIX -> MiuixRendererAdapter + } +} diff --git a/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/renderer/Material3RendererAdapter.kt b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/renderer/Material3RendererAdapter.kt new file mode 100644 index 0000000..53a18da --- /dev/null +++ b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/renderer/Material3RendererAdapter.kt @@ -0,0 +1,23 @@ +package com.flagship.abox.manager.designsystem.renderer + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import com.flagship.abox.manager.designsystem.ABoxShapes +import com.flagship.abox.manager.designsystem.ABoxTypography +import com.flagship.abox.manager.designsystem.materialColorScheme +import com.flagship.abox.manager.designsystem.tokens.ABoxThemeResolution + +internal object Material3RendererAdapter : ABoxRendererAdapter { + @Composable + override fun Render( + resolution: ABoxThemeResolution, + content: @Composable () -> Unit, + ) { + MaterialTheme( + colorScheme = materialColorScheme(resolution), + typography = ABoxTypography, + shapes = ABoxShapes(resolution.shapeTokens), + content = content, + ) + } +} 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 new file mode 100644 index 0000000..cf481c6 --- /dev/null +++ b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/renderer/MiuixRendererAdapter.kt @@ -0,0 +1,25 @@ +package com.flagship.abox.manager.designsystem.renderer + +import androidx.compose.runtime.Composable +import com.flagship.abox.manager.designsystem.tokens.ABoxThemeResolution +import top.yukonga.miuix.kmp.theme.MiuixTheme +import top.yukonga.miuix.kmp.theme.darkColorScheme +import top.yukonga.miuix.kmp.theme.lightColorScheme + +internal object MiuixRendererAdapter : ABoxRendererAdapter { + @Composable + override fun Render( + resolution: ABoxThemeResolution, + content: @Composable () -> Unit, + ) { + val colors = if (resolution.isDarkTheme) darkColorScheme() else lightColorScheme() + 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 new file mode 100644 index 0000000..8b7d73c --- /dev/null +++ b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/tokens/ABoxTokens.kt @@ -0,0 +1,233 @@ +package com.flagship.abox.manager.designsystem.tokens + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.lerp +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.ColorStandard +import com.flagship.abox.manager.preferences.ColorStyle +import com.flagship.abox.manager.preferences.DesignRenderer +import com.flagship.abox.manager.preferences.UiPreferences + +@Immutable +data class ABoxColorTokens( + val primary: Color, + val surface: Color, + val surfaceContainer: Color, + val outline: Color, + val success: Color, + val warning: Color, + val error: Color, + val offline: Color, + val pending: Color, + val deviceSocket: Color, + val deviceSensor: Color, + val deviceInfrared: Color, +) + +@Immutable +data class ABoxSpacing( + val dp4: Dp = 4.dp, + val dp8: Dp = 8.dp, + val dp12: Dp = 12.dp, + val dp16: Dp = 16.dp, + val dp20: Dp = 20.dp, + val dp24: Dp = 24.dp, + val dp32: Dp = 32.dp, + val minimumTouchTarget: Dp = 48.dp, +) + +@Immutable +data class ABoxComponentState( + val enabled: Boolean = true, + val pending: Boolean = false, + val offline: Boolean = false, + val dangerous: Boolean = false, +) + +@Immutable +data class ABoxShapeTokens( + val smallCornerRadius: Dp, + val mediumCornerRadius: Dp, + val largeCornerRadius: Dp, +) + +@Immutable +data class ABoxThemeResolution( + val isDarkTheme: Boolean, + val colorTokens: ABoxColorTokens, + val shapeTokens: ABoxShapeTokens, + val blurEnabled: Boolean, + val renderer: DesignRenderer, +) + +object ABoxThemeDefaults { + val lightColors = ABoxColorTokens( + primary = Color(0xFF006780), + surface = Color(0xFFF8FAFC), + surfaceContainer = Color(0xFFE2E8F0), + outline = Color(0xFF70797D), + success = Color(0xFF1B7F4B), + warning = Color(0xFF8A5800), + error = Color(0xFFBA1A1A), + offline = Color(0xFF5F6B72), + pending = Color(0xFF8A5800), + deviceSocket = Color(0xFF006E90), + deviceSensor = Color(0xFF476B23), + deviceInfrared = Color(0xFF77536F), + ) + val darkColors = ABoxColorTokens( + primary = Color(0xFF5DD5F8), + surface = Color(0xFF101416), + surfaceContainer = Color(0xFF1F292D), + outline = Color(0xFF8A969B), + success = Color(0xFF7EDFA7), + warning = Color(0xFFFFB95B), + error = Color(0xFFFFB4AB), + offline = Color(0xFFBFC9CD), + pending = Color(0xFFFFB95B), + deviceSocket = Color(0xFF5DD5F8), + deviceSensor = Color(0xFFAFD67C), + deviceInfrared = Color(0xFFE6B5D6), + ) + val material3Shapes = ABoxShapeTokens( + smallCornerRadius = 8.dp, + mediumCornerRadius = 16.dp, + largeCornerRadius = 24.dp, + ) + val miuixShapes = ABoxShapeTokens( + smallCornerRadius = 12.dp, + mediumCornerRadius = 20.dp, + largeCornerRadius = 28.dp, + ) +} + +object ABoxThemeResolver { + fun resolve( + preferences: UiPreferences, + systemInDarkTheme: Boolean, + sdkInt: Int, + dynamicColorTokens: ABoxColorTokens?, + ): ABoxThemeResolution { + val isDarkTheme = when (preferences.themeMode) { + AppThemeMode.SYSTEM -> systemInDarkTheme + AppThemeMode.LIGHT -> false + AppThemeMode.DARK -> true + } + val brandTokens = if (isDarkTheme) { + ABoxThemeDefaults.darkColors + } else { + ABoxThemeDefaults.lightColors + } + val colorTokens = if (preferences.dynamicColorEnabled && sdkInt >= 31 && dynamicColorTokens != null) { + dynamicColorTokens + } else { + ABoxAccentPalette.resolve( + base = brandTokens, + accent = preferences.accentPreference, + style = preferences.colorStyle, + standard = preferences.colorStandard, + dark = isDarkTheme, + ) + } + val shapeTokens = when (preferences.renderer) { + DesignRenderer.MATERIAL3 -> ABoxThemeDefaults.material3Shapes + DesignRenderer.MIUIX -> ABoxThemeDefaults.miuixShapes + } + + return ABoxThemeResolution( + isDarkTheme = isDarkTheme, + colorTokens = colorTokens, + shapeTokens = shapeTokens, + blurEnabled = preferences.blurEnabled && preferences.renderer == DesignRenderer.MIUIX, + renderer = preferences.renderer, + ) + } +} + +object ABoxAccentPalette { + fun preview( + accent: AccentPreference, + style: ColorStyle, + standard: ColorStandard, + dark: Boolean, + ): Color = resolve( + base = if (dark) ABoxThemeDefaults.darkColors else ABoxThemeDefaults.lightColors, + accent = accent, + style = style, + standard = standard, + dark = dark, + ).primary + + fun resolve( + base: ABoxColorTokens, + accent: AccentPreference, + style: ColorStyle, + standard: ColorStandard, + dark: Boolean, + ): ABoxColorTokens { + val source = source(accent, dark) + val primary = when (style) { + ColorStyle.TONAL_SPOT -> lerp(source, base.surface, if (dark) 0.08f else 0.12f) + ColorStyle.EXPRESSIVE -> expressive(accent, dark) + ColorStyle.FIDELITY -> source + } + val deviceSensor = when (style) { + ColorStyle.EXPRESSIVE -> expressiveSensor(accent, dark) + ColorStyle.FIDELITY -> lerp(source, base.success, 0.38f) + ColorStyle.TONAL_SPOT -> base.deviceSensor + } + val deviceInfrared = when (style) { + ColorStyle.EXPRESSIVE -> expressiveInfrared(accent, dark) + ColorStyle.FIDELITY -> lerp(source, base.deviceInfrared, 0.46f) + ColorStyle.TONAL_SPOT -> base.deviceInfrared + } + val styled = base.copy( + primary = primary, + deviceSocket = primary, + deviceSensor = deviceSensor, + deviceInfrared = deviceInfrared, + ) + return when (standard) { + ColorStandard.SPEC_2021 -> styled + ColorStandard.SPEC_2025 -> styled.copy( + surface = if (dark) Color(0xFF0C1114) else Color(0xFFFAF9FC), + surfaceContainer = if (dark) Color(0xFF242C30) else Color(0xFFE9E8EE), + outline = if (dark) Color(0xFFA7B2B7) else Color(0xFF596267), + primary = lerp(primary, if (dark) Color.White else Color.Black, 0.08f), + deviceSocket = lerp(primary, if (dark) Color.White else Color.Black, 0.08f), + ) + } + } + + private fun source(accent: AccentPreference, dark: Boolean): Color = when (accent) { + AccentPreference.BLUE -> if (dark) Color(0xFF9CCAFF) else Color(0xFF4F6FA9) + AccentPreference.GREEN -> if (dark) Color(0xFF7EDFA7) else Color(0xFF2F7D57) + AccentPreference.AMBER -> if (dark) Color(0xFFFFC46B) else Color(0xFFA66700) + AccentPreference.ROSE -> if (dark) Color(0xFFFFB1C1) else Color(0xFFA94C68) + AccentPreference.VIOLET -> if (dark) Color(0xFFD4BBFF) else Color(0xFF7659A8) + } + + private fun expressive(accent: AccentPreference, dark: Boolean): Color = when (accent) { + AccentPreference.BLUE -> if (dark) Color(0xFF72D6FF) else Color(0xFF006B91) + AccentPreference.GREEN -> if (dark) Color(0xFF69E6B1) else Color(0xFF007A54) + AccentPreference.AMBER -> if (dark) Color(0xFFFFC24B) else Color(0xFF9A5C00) + AccentPreference.ROSE -> if (dark) Color(0xFFFFA8C4) else Color(0xFFB12F62) + AccentPreference.VIOLET -> if (dark) Color(0xFFC9B5FF) else Color(0xFF7048B8) + } + + private fun expressiveSensor(accent: AccentPreference, dark: Boolean): Color = when (accent) { + AccentPreference.BLUE, AccentPreference.VIOLET -> if (dark) Color(0xFF78DFA2) else Color(0xFF26764A) + AccentPreference.GREEN, AccentPreference.AMBER -> if (dark) Color(0xFF79D6F3) else Color(0xFF006985) + AccentPreference.ROSE -> if (dark) Color(0xFFFFC45F) else Color(0xFF8B5B00) + } + + private fun expressiveInfrared(accent: AccentPreference, dark: Boolean): Color = when (accent) { + AccentPreference.BLUE, AccentPreference.GREEN -> if (dark) Color(0xFFD1B8FF) else Color(0xFF7453A5) + AccentPreference.AMBER, AccentPreference.ROSE -> if (dark) Color(0xFF9FCBFF) else Color(0xFF38639A) + AccentPreference.VIOLET -> if (dark) Color(0xFFFFB0C7) else Color(0xFFA43E62) + } +} diff --git a/android/core/designsystem/src/screenshotTest/kotlin/com/flagship/abox/manager/designsystem/ABoxThemeScreenshotTest.kt b/android/core/designsystem/src/screenshotTest/kotlin/com/flagship/abox/manager/designsystem/ABoxThemeScreenshotTest.kt new file mode 100644 index 0000000..3541208 --- /dev/null +++ b/android/core/designsystem/src/screenshotTest/kotlin/com/flagship/abox/manager/designsystem/ABoxThemeScreenshotTest.kt @@ -0,0 +1,123 @@ +package com.flagship.abox.manager.designsystem + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.android.tools.screenshot.PreviewTest +import com.flagship.abox.manager.designsystem.icon.ABoxIconKey +import com.flagship.abox.manager.preferences.AppThemeMode +import com.flagship.abox.manager.preferences.DesignRenderer +import com.flagship.abox.manager.preferences.UiPreferences + +@PreviewTest +@Preview(name = "M3 light", widthDp = 360, heightDp = 760) +@Composable +fun ABoxThemeScreenshotMaterialLight() { + ABoxScreenshotFixture(DesignRenderer.MATERIAL3, AppThemeMode.LIGHT) +} + +@PreviewTest +@Preview(name = "M3 dark", widthDp = 360, heightDp = 760) +@Composable +fun ABoxThemeScreenshotMaterialDark() { + ABoxScreenshotFixture(DesignRenderer.MATERIAL3, AppThemeMode.DARK) +} + +@PreviewTest +@Preview(name = "Miuix light", widthDp = 360, heightDp = 760) +@Composable +fun ABoxThemeScreenshotMiuixLight() { + ABoxScreenshotFixture(DesignRenderer.MIUIX, AppThemeMode.LIGHT) +} + +@PreviewTest +@Preview(name = "Miuix dark", widthDp = 360, heightDp = 760) +@Composable +fun ABoxThemeScreenshotMiuixDark() { + ABoxScreenshotFixture(DesignRenderer.MIUIX, AppThemeMode.DARK) +} + +@PreviewTest +@Preview(name = "Large font", widthDp = 360, heightDp = 760, fontScale = 1.3f) +@Composable +fun ABoxThemeScreenshotLargeFont() { + ABoxScreenshotFixture(DesignRenderer.MATERIAL3, AppThemeMode.LIGHT) +} + +@PreviewTest +@Preview(name = "Blur fallback", widthDp = 360, heightDp = 760) +@Composable +fun ABoxThemeScreenshotBlurFallback() { + ABoxScreenshotFixture(DesignRenderer.MIUIX, AppThemeMode.LIGHT, blurEnabled = true) +} + +@Composable +private fun ABoxScreenshotFixture( + renderer: DesignRenderer, + themeMode: AppThemeMode, + blurEnabled: Boolean = false, +) { + ABoxTheme( + preferences = UiPreferences( + renderer = renderer, + themeMode = themeMode, + dynamicColorEnabled = false, + blurEnabled = blurEnabled, + ), + ) { + ABoxAppSurface(modifier = Modifier.padding(16.dp)) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + ABoxTopPillBar( + title = "我的设备", + onAvatarClick = {}, + userName = "小明", + onAddClick = {}, + addActionContentDescription = "添加设备", + avatarContentDescription = "账户", + ) + ABoxCard( + title = "客厅插座", + subtitle = "在线 · 正常", + onClick = {}, + quickAction = ABoxCardQuickAction( + key = ABoxIconKey.REFRESH, + contentDescription = "刷新设备", + onClick = {}, + ), + ) + ABoxStatusBanner( + state = ABoxStatus.PENDING, + contentDescription = "正在处理", + ) + ABoxStatusBanner( + state = ABoxStatus.OFFLINE, + contentDescription = "设备离线", + ) + ABoxLoadingState(contentDescription = "正在加载") + ABoxEmptyState( + title = "暂无设备", + description = "添加设备后会显示在这里。", + ) + ABoxErrorState( + title = "加载失败", + description = "请稍后重试。", + onRetry = {}, + ) + ABoxFilterChip( + selected = true, + label = "全部", + onClick = {}, + ) + } + } + } +} diff --git a/android/core/designsystem/src/screenshotTest/kotlin/com/flagship/abox/manager/designsystem/ScreenshotSmokeTest.kt b/android/core/designsystem/src/screenshotTest/kotlin/com/flagship/abox/manager/designsystem/ScreenshotSmokeTest.kt new file mode 100644 index 0000000..85219f3 --- /dev/null +++ b/android/core/designsystem/src/screenshotTest/kotlin/com/flagship/abox/manager/designsystem/ScreenshotSmokeTest.kt @@ -0,0 +1,24 @@ +package com.flagship.abox.manager.designsystem + +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import com.android.tools.screenshot.PreviewTest +import com.flagship.abox.manager.preferences.AppThemeMode +import com.flagship.abox.manager.preferences.DesignRenderer +import com.flagship.abox.manager.preferences.UiPreferences + +@PreviewTest +@Preview +@Composable +fun DesignSystemSmokePreview() { + ABoxTheme( + preferences = UiPreferences( + renderer = DesignRenderer.MATERIAL3, + themeMode = AppThemeMode.LIGHT, + dynamicColorEnabled = false, + ), + ) { + Text("截图基线") + } +} diff --git a/android/core/designsystem/src/screenshotTestDebug/reference/com/flagship/abox/manager/designsystem/ABoxThemeScreenshotTestKt/ABoxThemeScreenshotBlurFallback_Blur fallback_3cad3a9c_0.png b/android/core/designsystem/src/screenshotTestDebug/reference/com/flagship/abox/manager/designsystem/ABoxThemeScreenshotTestKt/ABoxThemeScreenshotBlurFallback_Blur fallback_3cad3a9c_0.png new file mode 100644 index 0000000..70919a9 Binary files /dev/null and b/android/core/designsystem/src/screenshotTestDebug/reference/com/flagship/abox/manager/designsystem/ABoxThemeScreenshotTestKt/ABoxThemeScreenshotBlurFallback_Blur fallback_3cad3a9c_0.png differ diff --git a/android/core/designsystem/src/screenshotTestDebug/reference/com/flagship/abox/manager/designsystem/ABoxThemeScreenshotTestKt/ABoxThemeScreenshotLargeFont_Large font_062df036_0.png b/android/core/designsystem/src/screenshotTestDebug/reference/com/flagship/abox/manager/designsystem/ABoxThemeScreenshotTestKt/ABoxThemeScreenshotLargeFont_Large font_062df036_0.png new file mode 100644 index 0000000..957bff8 Binary files /dev/null and b/android/core/designsystem/src/screenshotTestDebug/reference/com/flagship/abox/manager/designsystem/ABoxThemeScreenshotTestKt/ABoxThemeScreenshotLargeFont_Large font_062df036_0.png differ diff --git a/android/core/designsystem/src/screenshotTestDebug/reference/com/flagship/abox/manager/designsystem/ABoxThemeScreenshotTestKt/ABoxThemeScreenshotMaterialDark_M3 dark_d09ddd86_0.png b/android/core/designsystem/src/screenshotTestDebug/reference/com/flagship/abox/manager/designsystem/ABoxThemeScreenshotTestKt/ABoxThemeScreenshotMaterialDark_M3 dark_d09ddd86_0.png new file mode 100644 index 0000000..276aac9 Binary files /dev/null and b/android/core/designsystem/src/screenshotTestDebug/reference/com/flagship/abox/manager/designsystem/ABoxThemeScreenshotTestKt/ABoxThemeScreenshotMaterialDark_M3 dark_d09ddd86_0.png differ diff --git a/android/core/designsystem/src/screenshotTestDebug/reference/com/flagship/abox/manager/designsystem/ABoxThemeScreenshotTestKt/ABoxThemeScreenshotMaterialLight_M3 light_0ba11da1_0.png b/android/core/designsystem/src/screenshotTestDebug/reference/com/flagship/abox/manager/designsystem/ABoxThemeScreenshotTestKt/ABoxThemeScreenshotMaterialLight_M3 light_0ba11da1_0.png new file mode 100644 index 0000000..f4f03da Binary files /dev/null and b/android/core/designsystem/src/screenshotTestDebug/reference/com/flagship/abox/manager/designsystem/ABoxThemeScreenshotTestKt/ABoxThemeScreenshotMaterialLight_M3 light_0ba11da1_0.png differ diff --git a/android/core/designsystem/src/screenshotTestDebug/reference/com/flagship/abox/manager/designsystem/ABoxThemeScreenshotTestKt/ABoxThemeScreenshotMiuixDark_Miuix dark_78894dae_0.png b/android/core/designsystem/src/screenshotTestDebug/reference/com/flagship/abox/manager/designsystem/ABoxThemeScreenshotTestKt/ABoxThemeScreenshotMiuixDark_Miuix dark_78894dae_0.png new file mode 100644 index 0000000..0288ec6 Binary files /dev/null and b/android/core/designsystem/src/screenshotTestDebug/reference/com/flagship/abox/manager/designsystem/ABoxThemeScreenshotTestKt/ABoxThemeScreenshotMiuixDark_Miuix dark_78894dae_0.png differ diff --git a/android/core/designsystem/src/screenshotTestDebug/reference/com/flagship/abox/manager/designsystem/ABoxThemeScreenshotTestKt/ABoxThemeScreenshotMiuixLight_Miuix light_62c07a3f_0.png b/android/core/designsystem/src/screenshotTestDebug/reference/com/flagship/abox/manager/designsystem/ABoxThemeScreenshotTestKt/ABoxThemeScreenshotMiuixLight_Miuix light_62c07a3f_0.png new file mode 100644 index 0000000..cb2ea5f Binary files /dev/null and b/android/core/designsystem/src/screenshotTestDebug/reference/com/flagship/abox/manager/designsystem/ABoxThemeScreenshotTestKt/ABoxThemeScreenshotMiuixLight_Miuix light_62c07a3f_0.png differ diff --git a/android/core/designsystem/src/screenshotTestDebug/reference/com/flagship/abox/manager/designsystem/ScreenshotSmokeTestKt/DesignSystemSmokePreview_0.png b/android/core/designsystem/src/screenshotTestDebug/reference/com/flagship/abox/manager/designsystem/ScreenshotSmokeTestKt/DesignSystemSmokePreview_0.png new file mode 100644 index 0000000..d3eba81 Binary files /dev/null and b/android/core/designsystem/src/screenshotTestDebug/reference/com/flagship/abox/manager/designsystem/ScreenshotSmokeTestKt/DesignSystemSmokePreview_0.png differ diff --git a/android/core/designsystem/src/test/kotlin/com/flagship/abox/manager/designsystem/icon/ABoxIconMappingTest.kt b/android/core/designsystem/src/test/kotlin/com/flagship/abox/manager/designsystem/icon/ABoxIconMappingTest.kt new file mode 100644 index 0000000..2a0b9d9 --- /dev/null +++ b/android/core/designsystem/src/test/kotlin/com/flagship/abox/manager/designsystem/icon/ABoxIconMappingTest.kt @@ -0,0 +1,37 @@ +package com.flagship.abox.manager.designsystem.icon + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Test + +class ABoxIconMappingTest { + @Test + fun unknownDeviceNeverMapsToPower() { + assertNotEquals(ABoxIconKey.POWER, ABoxIconMapper.forDeviceType(null)) + assertEquals(ABoxIconKey.UNKNOWN_DEVICE, ABoxIconMapper.forDeviceType(null)) + assertEquals( + ABoxIconKey.UNKNOWN_DEVICE, + ABoxIconMapper.forDeviceType(ABoxDeviceType.UNKNOWN), + ) + } + + @Test + fun knownDeviceCategoriesMapToTheirTypedIcons() { + assertEquals( + ABoxIconKey.SOCKET, + ABoxIconMapper.forDeviceType(ABoxDeviceType.SOCKET), + ) + assertEquals( + ABoxIconKey.SENSOR, + ABoxIconMapper.forDeviceType(ABoxDeviceType.SENSOR), + ) + assertEquals( + ABoxIconKey.INFRARED, + ABoxIconMapper.forDeviceType(ABoxDeviceType.INFRARED), + ) + assertEquals( + ABoxIconKey.POWER, + ABoxIconMapper.forDeviceType(ABoxDeviceType.POWER), + ) + } +} diff --git a/android/core/designsystem/src/test/kotlin/com/flagship/abox/manager/designsystem/renderer/ABoxRendererAdapterTest.kt b/android/core/designsystem/src/test/kotlin/com/flagship/abox/manager/designsystem/renderer/ABoxRendererAdapterTest.kt new file mode 100644 index 0000000..d4dacf6 --- /dev/null +++ b/android/core/designsystem/src/test/kotlin/com/flagship/abox/manager/designsystem/renderer/ABoxRendererAdapterTest.kt @@ -0,0 +1,19 @@ +package com.flagship.abox.manager.designsystem.renderer + +import com.flagship.abox.manager.preferences.DesignRenderer +import org.junit.Assert.assertSame +import org.junit.Test + +class ABoxRendererAdapterTest { + @Test + fun selectsMaterial3AndMiuixAdaptersFromRendererPreference() { + assertSame( + Material3RendererAdapter, + ABoxRendererAdapters.forRenderer(DesignRenderer.MATERIAL3), + ) + assertSame( + MiuixRendererAdapter, + ABoxRendererAdapters.forRenderer(DesignRenderer.MIUIX), + ) + } +} diff --git a/android/core/designsystem/src/test/kotlin/com/flagship/abox/manager/designsystem/tokens/ABoxThemeResolverTest.kt b/android/core/designsystem/src/test/kotlin/com/flagship/abox/manager/designsystem/tokens/ABoxThemeResolverTest.kt new file mode 100644 index 0000000..53ee0d6 --- /dev/null +++ b/android/core/designsystem/src/test/kotlin/com/flagship/abox/manager/designsystem/tokens/ABoxThemeResolverTest.kt @@ -0,0 +1,152 @@ +package com.flagship.abox.manager.designsystem.tokens + +import androidx.compose.ui.graphics.Color +import com.flagship.abox.manager.preferences.AppThemeMode +import com.flagship.abox.manager.preferences.DesignRenderer +import com.flagship.abox.manager.preferences.UiPreferences +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Test + +class ABoxThemeResolverTest { + @Test + fun systemLightAndDarkResolveDeterministically() { + val light = ABoxThemeResolver.resolve( + preferences = UiPreferences(themeMode = AppThemeMode.SYSTEM), + systemInDarkTheme = false, + sdkInt = 30, + dynamicColorTokens = null, + ) + val dark = ABoxThemeResolver.resolve( + preferences = UiPreferences(themeMode = AppThemeMode.SYSTEM), + systemInDarkTheme = true, + sdkInt = 30, + dynamicColorTokens = null, + ) + + assertFalse(light.isDarkTheme) + assertEquals( + ABoxAccentPalette.resolve( + ABoxThemeDefaults.lightColors, + UiPreferences().accentPreference, + UiPreferences().colorStyle, + UiPreferences().colorStandard, + false, + ), + light.colorTokens, + ) + assertEquals( + ABoxAccentPalette.resolve( + ABoxThemeDefaults.darkColors, + UiPreferences().accentPreference, + UiPreferences().colorStyle, + UiPreferences().colorStandard, + true, + ), + dark.colorTokens, + ) + } + + @Test + fun dynamicColorDisabledUsesBrandTokens() { + val resolution = ABoxThemeResolver.resolve( + preferences = UiPreferences( + themeMode = AppThemeMode.LIGHT, + dynamicColorEnabled = false, + ), + systemInDarkTheme = false, + sdkInt = 31, + dynamicColorTokens = dynamicTokens, + ) + + assertEquals( + ABoxAccentPalette.resolve( + ABoxThemeDefaults.lightColors, + UiPreferences().accentPreference, + UiPreferences().colorStyle, + UiPreferences().colorStandard, + false, + ), + resolution.colorTokens, + ) + } + + @Test + fun pre31DynamicColorFallsBackToBrandTokens() { + val resolution = ABoxThemeResolver.resolve( + preferences = UiPreferences( + themeMode = AppThemeMode.DARK, + dynamicColorEnabled = true, + ), + systemInDarkTheme = false, + sdkInt = 30, + dynamicColorTokens = dynamicTokens, + ) + + assertEquals( + ABoxAccentPalette.resolve( + ABoxThemeDefaults.darkColors, + UiPreferences().accentPreference, + UiPreferences().colorStyle, + UiPreferences().colorStandard, + true, + ), + resolution.colorTokens, + ) + } + + @Test + fun blurDisabledResolvesOpaqueSurface() { + val resolution = ABoxThemeResolver.resolve( + preferences = UiPreferences(blurEnabled = false), + systemInDarkTheme = false, + sdkInt = 31, + dynamicColorTokens = null, + ) + + assertFalse(resolution.blurEnabled) + assertEquals(1f, resolution.colorTokens.surface.alpha) + assertEquals(1f, resolution.colorTokens.surfaceContainer.alpha) + } + + @Test + fun rendererChangesShapeButNotSemanticStateRoles() { + val material = ABoxThemeResolver.resolve( + preferences = UiPreferences(renderer = DesignRenderer.MATERIAL3), + systemInDarkTheme = false, + sdkInt = 31, + dynamicColorTokens = null, + ) + val miuix = ABoxThemeResolver.resolve( + preferences = UiPreferences(renderer = DesignRenderer.MIUIX), + systemInDarkTheme = false, + sdkInt = 31, + dynamicColorTokens = null, + ) + + assertEquals(material.colorTokens.success, miuix.colorTokens.success) + assertEquals(material.colorTokens.warning, miuix.colorTokens.warning) + assertEquals(material.colorTokens.error, miuix.colorTokens.error) + assertEquals(material.colorTokens.offline, miuix.colorTokens.offline) + assertEquals(material.colorTokens.pending, miuix.colorTokens.pending) + assertNotEquals(material.shapeTokens, miuix.shapeTokens) + } + + private companion object { + val dynamicTokens = ABoxColorTokens( + primary = Color(0xFF111111), + surface = Color(0xFF222222), + surfaceContainer = Color(0xFF333333), + outline = Color(0xFF444444), + success = Color(0xFF555555), + warning = Color(0xFF666666), + error = Color(0xFF777777), + offline = Color(0xFF888888), + pending = Color(0xFF999999), + deviceSocket = Color(0xFFAAAAAA), + deviceSensor = Color(0xFFBBBBBB), + deviceInfrared = Color(0xFFCCCCCC), + ) + } +} diff --git a/android/core/devicecatalog/build.gradle.kts b/android/core/devicecatalog/build.gradle.kts new file mode 100644 index 0000000..d59a566 --- /dev/null +++ b/android/core/devicecatalog/build.gradle.kts @@ -0,0 +1,46 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.kapt) + alias(libs.plugins.hilt.android) +} + +android { + namespace = "com.flagship.abox.manager.devicecatalog" + compileSdk = 37 + + defaultConfig { + minSdk = 26 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} + +kotlin { + compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) } +} + +kapt { + correctErrorTypes = true + arguments { arg("room.schemaLocation", "$projectDir/schemas") } +} + +dependencies { + api(project(":domain")) + implementation(libs.room.runtime) + implementation(libs.room.ktx) + kapt(libs.room.compiler) + implementation(libs.hilt.android) + kapt(libs.hilt.compiler) + implementation(libs.kotlinx.coroutines.core) + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(libs.room.testing) + androidTestImplementation(libs.kotlinx.coroutines.test) +} diff --git a/android/core/devicecatalog/src/androidTest/kotlin/com/flagship/abox/manager/devicecatalog/RoomConfiguredDeviceCatalogTest.kt b/android/core/devicecatalog/src/androidTest/kotlin/com/flagship/abox/manager/devicecatalog/RoomConfiguredDeviceCatalogTest.kt new file mode 100644 index 0000000..f78936f --- /dev/null +++ b/android/core/devicecatalog/src/androidTest/kotlin/com/flagship/abox/manager/devicecatalog/RoomConfiguredDeviceCatalogTest.kt @@ -0,0 +1,57 @@ +package com.flagship.abox.manager.devicecatalog + +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.flagship.abox.manager.devicecatalog.data.DeviceCatalogDatabase +import com.flagship.abox.manager.devicecatalog.data.RoomConfiguredDeviceRepository +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 kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class RoomConfiguredDeviceCatalogTest { + private lateinit var database: DeviceCatalogDatabase + private lateinit var repository: RoomConfiguredDeviceRepository + + @Before + fun setUp() { + database = Room.inMemoryDatabaseBuilder(ApplicationProvider.getApplicationContext(), DeviceCatalogDatabase::class.java).build() + repository = RoomConfiguredDeviceRepository(database.configuredDeviceDao()) + } + + @After + fun tearDown() = database.close() + + @Test + fun configuredDevicesAreIsolatedByProfile() = runTest { + repository.add(ProviderProfileId("one"), DeviceName("plug"), DeviceType.SOCKET) + val result = repository.observeDevices(ProviderProfileId("two")).first() as DomainResult.Success + assertTrue(result.value.isEmpty()) + } + + @Test + fun sameProfileRejectsDuplicateDeviceNameAcrossTypes() = runTest { + val profile = ProviderProfileId("one") + assertTrue(repository.add(profile, DeviceName("same"), DeviceType.SOCKET) is DomainResult.Success) + assertTrue(repository.add(profile, DeviceName("same"), DeviceType.INFRARED) is DomainResult.Failure) + } + + @Test + fun removeEmitsUpdatedCatalog() = runTest { + val profile = ProviderProfileId("one") + repository.add(profile, DeviceName("plug"), DeviceType.SOCKET) + repository.remove(profile, DeviceName("plug")) + val result = repository.observeDevices(profile).first() as DomainResult.Success + assertEquals(emptyList(), result.value) + } +} diff --git a/android/core/devicecatalog/src/main/kotlin/com/flagship/abox/manager/devicecatalog/RoutingDeviceCatalog.kt b/android/core/devicecatalog/src/main/kotlin/com/flagship/abox/manager/devicecatalog/RoutingDeviceCatalog.kt new file mode 100644 index 0000000..30b050c --- /dev/null +++ b/android/core/devicecatalog/src/main/kotlin/com/flagship/abox/manager/devicecatalog/RoutingDeviceCatalog.kt @@ -0,0 +1,27 @@ +package com.flagship.abox.manager.devicecatalog + +import com.flagship.abox.manager.domain.DeviceCatalog +import com.flagship.abox.manager.domain.DeviceDescriptor +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.ProviderProfileStore +import com.flagship.abox.manager.domain.ProviderType +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow + +class RoutingDeviceCatalog( + private val profileStore: ProviderProfileStore, + private val abox: DeviceCatalog, + private val local: DeviceCatalog, +) : DeviceCatalog { + override fun observeDevices(profileId: ProviderProfileId): Flow>> = flow { + val active = profileStore.activeProfile() + if (active?.id != profileId) { + emit(DomainResult.Failure(DomainError.InvalidRequest(detail = "Provider profile is not active"))) + return@flow + } + emitAll(if (active.type == ProviderType.ABOX) abox.observeDevices(profileId) else local.observeDevices(profileId)) + } +} 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 new file mode 100644 index 0000000..45b2afd --- /dev/null +++ b/android/core/devicecatalog/src/main/kotlin/com/flagship/abox/manager/devicecatalog/data/ConfiguredDeviceDao.kt @@ -0,0 +1,22 @@ +package com.flagship.abox.manager.devicecatalog.data + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import kotlinx.coroutines.flow.Flow + +@Dao +interface ConfiguredDeviceDao { + @Query("SELECT * FROM configured_devices WHERE profileId = :profileId ORDER BY deviceName") + fun observe(profileId: String): Flow> + + @Insert(onConflict = OnConflictStrategy.ABORT) + suspend fun insert(device: ConfiguredDeviceEntity) + + @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/ConfiguredDeviceEntity.kt b/android/core/devicecatalog/src/main/kotlin/com/flagship/abox/manager/devicecatalog/data/ConfiguredDeviceEntity.kt new file mode 100644 index 0000000..d823784 --- /dev/null +++ b/android/core/devicecatalog/src/main/kotlin/com/flagship/abox/manager/devicecatalog/data/ConfiguredDeviceEntity.kt @@ -0,0 +1,16 @@ +package com.flagship.abox.manager.devicecatalog.data + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +@Entity( + tableName = "configured_devices", + indices = [Index(value = ["profileId", "deviceName"], unique = true)], +) +data class ConfiguredDeviceEntity( + @PrimaryKey val id: String, + val profileId: String, + val deviceName: String, + val deviceType: String, +) 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 new file mode 100644 index 0000000..c618af4 --- /dev/null +++ b/android/core/devicecatalog/src/main/kotlin/com/flagship/abox/manager/devicecatalog/data/ConfiguredDeviceRepository.kt @@ -0,0 +1,74 @@ +package com.flagship.abox.manager.devicecatalog.data + +import com.flagship.abox.manager.domain.DeviceCatalog +import com.flagship.abox.manager.domain.DeviceDescriptor +import com.flagship.abox.manager.domain.DeviceId +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.domain.DeviceType +import com.flagship.abox.manager.domain.DomainError +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.ProviderProfileId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +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( + private val dao: ConfiguredDeviceDao, +) : ConfiguredDeviceRepository, DeviceCatalog { + override fun observe(profileId: ProviderProfileId): Flow>> = + dao.observe(profileId.value).map { entities -> DomainResult.Success(entities.map { it.toDomain() }) } + + override fun observeDevices(profileId: ProviderProfileId): Flow>> = observe(profileId) + + override suspend fun add(profileId: ProviderProfileId, name: DeviceName, type: DeviceType): DomainResult { + if (profileId.value.isBlank() || name.value.isBlank()) { + return DomainResult.Failure(DomainError.InvalidRequest(detail = "Profile and device name are required")) + } + val descriptor = DeviceDescriptor(DeviceId("${profileId.value}:${name.value}"), profileId, name, type) + return try { + dao.insert(descriptor.toEntity()) + DomainResult.Success(descriptor) + } catch (error: Exception) { + DomainResult.Failure(DomainError.InvalidRequest(detail = error.message ?: "Duplicate device name")) + } + } + + override suspend fun remove(profileId: ProviderProfileId, deviceName: DeviceName): DomainResult = try { + dao.remove(profileId.value, deviceName.value) + DomainResult.Success(Unit) + } 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( + id = DeviceId(id), + profileId = ProviderProfileId(profileId), + name = DeviceName(deviceName), + type = DeviceType.valueOf(deviceType), +) + +private fun DeviceDescriptor.toEntity() = ConfiguredDeviceEntity( + id = id.value, + profileId = profileId.value, + deviceName = name.value, + deviceType = type.name, +) diff --git a/android/core/devicecatalog/src/main/kotlin/com/flagship/abox/manager/devicecatalog/data/DeviceCatalogDatabase.kt b/android/core/devicecatalog/src/main/kotlin/com/flagship/abox/manager/devicecatalog/data/DeviceCatalogDatabase.kt new file mode 100644 index 0000000..fde611b --- /dev/null +++ b/android/core/devicecatalog/src/main/kotlin/com/flagship/abox/manager/devicecatalog/data/DeviceCatalogDatabase.kt @@ -0,0 +1,9 @@ +package com.flagship.abox.manager.devicecatalog.data + +import androidx.room.Database +import androidx.room.RoomDatabase + +@Database(entities = [ConfiguredDeviceEntity::class], version = 1, exportSchema = false) +abstract class DeviceCatalogDatabase : RoomDatabase() { + abstract fun configuredDeviceDao(): ConfiguredDeviceDao +} diff --git a/android/core/devicecatalog/src/main/kotlin/com/flagship/abox/manager/devicecatalog/di/DeviceCatalogModule.kt b/android/core/devicecatalog/src/main/kotlin/com/flagship/abox/manager/devicecatalog/di/DeviceCatalogModule.kt new file mode 100644 index 0000000..447f4b7 --- /dev/null +++ b/android/core/devicecatalog/src/main/kotlin/com/flagship/abox/manager/devicecatalog/di/DeviceCatalogModule.kt @@ -0,0 +1,31 @@ +package com.flagship.abox.manager.devicecatalog.di + +import android.content.Context +import androidx.room.Room +import com.flagship.abox.manager.devicecatalog.data.ConfiguredDeviceRepository +import com.flagship.abox.manager.devicecatalog.data.DeviceCatalogDatabase +import com.flagship.abox.manager.devicecatalog.data.RoomConfiguredDeviceRepository +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object DeviceCatalogModule { + @Provides + @Singleton + fun provideDatabase(@ApplicationContext context: Context): DeviceCatalogDatabase = + Room.databaseBuilder(context, DeviceCatalogDatabase::class.java, "abox_devices.db").build() + + @Provides + @Singleton + fun provideRepository(database: DeviceCatalogDatabase): RoomConfiguredDeviceRepository = + RoomConfiguredDeviceRepository(database.configuredDeviceDao()) + + @Provides + @Singleton + fun provideConfiguredDeviceRepository(repository: RoomConfiguredDeviceRepository): ConfiguredDeviceRepository = repository +} diff --git a/android/core/preferences/build.gradle.kts b/android/core/preferences/build.gradle.kts new file mode 100644 index 0000000..4888cea --- /dev/null +++ b/android/core/preferences/build.gradle.kts @@ -0,0 +1,32 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) +} + +android { + namespace = "com.flagship.abox.manager.preferences" + compileSdk = 37 + + defaultConfig { + minSdk = 26 + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} + +kotlin { + compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } +} + +dependencies { + implementation(libs.kotlinx.coroutines.core) + implementation(libs.datastore.preferences) + + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) +} 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 new file mode 100644 index 0000000..e2f57e7 --- /dev/null +++ b/android/core/preferences/src/main/kotlin/com/flagship/abox/manager/preferences/DataStoreUiPreferencesRepository.kt @@ -0,0 +1,115 @@ +package com.flagship.abox.manager.preferences + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler +import androidx.datastore.preferences.core.MutablePreferences +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import java.io.IOException +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.map + +private const val UI_PREFERENCES_DATA_STORE_NAME = "ui_preferences" + +private val Context.uiPreferencesDataStore: DataStore by preferencesDataStore( + name = UI_PREFERENCES_DATA_STORE_NAME, + corruptionHandler = ReplaceFileCorruptionHandler { emptyPreferences() }, +) + +class DataStoreUiPreferencesRepository internal constructor( + private val dataStore: DataStore, +) : UiPreferencesRepository { + constructor(context: Context) : this(context.uiPreferencesDataStore) + override fun observe(): Flow = dataStore.data + .map(::preferencesFrom) + .catch { cause -> + if (cause is CancellationException) throw cause + emit(UiPreferences()) + } + + override suspend fun update( + transform: (UiPreferences) -> UiPreferences, + ): UiPreferencesWriteResult = try { + dataStore.edit { storedPreferences -> + val updatedPreferences = try { + transform(preferencesFrom(storedPreferences)) + } catch (cause: Throwable) { + throw TransformFailure(cause) + } + storedPreferences.putAll(updatedPreferences) + } + UiPreferencesWriteResult.Success + } catch (failure: TransformFailure) { + throw failure.cause + } catch (cause: CancellationException) { + throw cause + } catch (_: IOException) { + UiPreferencesWriteResult.Failure + } + + private fun preferencesFrom(preferences: Preferences): UiPreferences = UiPreferences( + renderer = preferences[RENDERER]?.enumOrDefault(DesignRenderer.MATERIAL3) + ?: DesignRenderer.MATERIAL3, + themeMode = preferences[THEME_MODE]?.enumOrDefault(AppThemeMode.SYSTEM) + ?: AppThemeMode.SYSTEM, + dynamicColorEnabled = preferences[DYNAMIC_COLOR] ?: true, + blurEnabled = preferences[BLUR] ?: false, + 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) { + this[RENDERER] = preferences.renderer.name + this[THEME_MODE] = preferences.themeMode.name + this[DYNAMIC_COLOR] = preferences.dynamicColorEnabled + 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 = + enumValues().firstOrNull { it.name == this } ?: default + + private class TransformFailure( + override val cause: Throwable, + ) : RuntimeException(cause) + + private companion object { + val RENDERER = stringPreferencesKey("renderer") + val THEME_MODE = stringPreferencesKey("theme_mode") + val DYNAMIC_COLOR = booleanPreferencesKey("dynamic_color") + 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 new file mode 100644 index 0000000..d6222c5 --- /dev/null +++ b/android/core/preferences/src/main/kotlin/com/flagship/abox/manager/preferences/UiPreferences.kt @@ -0,0 +1,72 @@ +package com.flagship.abox.manager.preferences + +enum class DesignRenderer { + MATERIAL3, + MIUIX, +} + +enum class AppThemeMode { + SYSTEM, + LIGHT, + DARK, +} + +enum class NavigationPresentation { + STANDARD, + 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, + DYNAMIC_COLOR, + BLUR, + NAVIGATION, + HAPTICS, + INTERFACE_SCALE, + ACCENT, + COLOR_STYLE, + COLOR_STANDARD, + LIQUID_GLASS, + PREDICTIVE_BACK, +} + +data class UiPreferences( + val renderer: DesignRenderer = DesignRenderer.MATERIAL3, + val themeMode: AppThemeMode = AppThemeMode.SYSTEM, + val dynamicColorEnabled: Boolean = true, + 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/main/kotlin/com/flagship/abox/manager/preferences/UiPreferencesRepository.kt b/android/core/preferences/src/main/kotlin/com/flagship/abox/manager/preferences/UiPreferencesRepository.kt new file mode 100644 index 0000000..12d4309 --- /dev/null +++ b/android/core/preferences/src/main/kotlin/com/flagship/abox/manager/preferences/UiPreferencesRepository.kt @@ -0,0 +1,16 @@ +package com.flagship.abox.manager.preferences + +import kotlinx.coroutines.flow.Flow + +sealed interface UiPreferencesWriteResult { + data object Success : UiPreferencesWriteResult + data object Failure : UiPreferencesWriteResult +} + +interface UiPreferencesRepository { + fun observe(): Flow + + suspend fun update( + transform: (UiPreferences) -> UiPreferences, + ): UiPreferencesWriteResult +} 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 new file mode 100644 index 0000000..46ba8ca --- /dev/null +++ b/android/core/preferences/src/test/kotlin/com/flagship/abox/manager/preferences/DataStoreUiPreferencesRepositoryTest.kt @@ -0,0 +1,181 @@ +package com.flagship.abox.manager.preferences + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.stringPreferencesKey +import java.io.IOException +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test + +class DataStoreUiPreferencesRepositoryTest { + @Test + fun defaultsAreEmitted() = runTest { + val repository = DataStoreUiPreferencesRepository(InMemoryPreferencesDataStore()) + + assertEquals(UiPreferences(), repository.observe().first()) + } + + @Test + fun eachFieldPersistsIndependently() = runTest { + val dataStore = InMemoryPreferencesDataStore() + val repository = DataStoreUiPreferencesRepository(dataStore) + + assertEquals(UiPreferencesWriteResult.Success, repository.update { + it.copy(renderer = DesignRenderer.MIUIX) + }) + assertEquals(UiPreferencesWriteResult.Success, repository.update { + it.copy(themeMode = AppThemeMode.DARK) + }) + assertEquals(UiPreferencesWriteResult.Success, repository.update { + it.copy(dynamicColorEnabled = false) + }) + assertEquals(UiPreferencesWriteResult.Success, repository.update { + it.copy(blurEnabled = true) + }) + assertEquals(UiPreferencesWriteResult.Success, repository.update { + it.copy(navigationPresentation = NavigationPresentation.FLOATING) + }) + 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( + renderer = DesignRenderer.MIUIX, + themeMode = AppThemeMode.DARK, + dynamicColorEnabled = false, + 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(), + ) + } + + @Test + fun unknownEnumFallsBackToDefault() = runTest { + val dataStore = InMemoryPreferencesDataStore() + dataStore.edit { preferences -> + preferences[stringPreferencesKey("renderer")] = "RETIRED_STYLE" + } + val repository = DataStoreUiPreferencesRepository(dataStore) + + assertEquals(DesignRenderer.MATERIAL3, repository.observe().first().renderer) + } + + @Test + fun corruptDataFallsBackWithoutThrowing() = runTest { + val repository = DataStoreUiPreferencesRepository(CorruptPreferencesDataStore()) + + assertEquals(UiPreferences(), repository.observe().first()) + } + + @Test + fun updateReturnsFailureWhenStorageWriteFails() = runTest { + val repository = DataStoreUiPreferencesRepository(FailingWritePreferencesDataStore()) + + assertEquals( + UiPreferencesWriteResult.Failure, + repository.update { it.copy(blurEnabled = true) }, + ) + } + + @Test + fun updateRethrowsTransformExceptions() = runTest { + val repository = DataStoreUiPreferencesRepository(InMemoryPreferencesDataStore()) + val failure = IllegalStateException("invalid transform") + + val actual = runCatching { + repository.update { throw failure } + }.exceptionOrNull() + + assertEquals(failure, actual) + } + + @Test + fun updateRethrowsTransformIOException() = runTest { + val repository = DataStoreUiPreferencesRepository(InMemoryPreferencesDataStore()) + val failure = IOException("invalid transform") + + val actual = runCatching { + repository.update { throw failure } + }.exceptionOrNull() + + assertEquals(failure, actual) + } + + @Test + fun updateRethrowsCancellationException() = runTest { + val cancellation = CancellationException("cancelled") + val repository = DataStoreUiPreferencesRepository( + FailingWritePreferencesDataStore(cancellation), + ) + + val actual = runCatching { + repository.update { it.copy(blurEnabled = true) } + }.exceptionOrNull() + + assertEquals(cancellation, actual) + } + + private class InMemoryPreferencesDataStore : DataStore { + private val state = MutableStateFlow(emptyPreferences()) + + override val data: Flow = state + + override suspend fun updateData( + transform: suspend (t: Preferences) -> Preferences, + ): Preferences = transform(state.value).also(state::value::set) + } + + private class CorruptPreferencesDataStore : DataStore { + override val data: Flow = kotlinx.coroutines.flow.flow { + throw IOException("corrupt preferences") + } + + override suspend fun updateData( + transform: suspend (t: Preferences) -> Preferences, + ): Preferences = error("not used") + } + + private class FailingWritePreferencesDataStore( + private val failure: Throwable = IOException("write failed"), + ) : DataStore { + override val data: Flow = MutableStateFlow(emptyPreferences()) + + override suspend fun updateData( + transform: suspend (t: Preferences) -> Preferences, + ): Preferences = throw failure + } +} diff --git a/android/core/session/build.gradle.kts b/android/core/session/build.gradle.kts index 2fb4697..d0e15e2 100644 --- a/android/core/session/build.gradle.kts +++ b/android/core/session/build.gradle.kts @@ -1,3 +1,5 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + plugins { alias(libs.plugins.android.library) alias(libs.plugins.kotlin.android) @@ -5,7 +7,7 @@ plugins { android { namespace = "com.flagship.abox.manager.session" - compileSdk = 34 + compileSdk = 37 defaultConfig { minSdk = 26 @@ -16,10 +18,10 @@ android { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } +} - kotlinOptions { - jvmTarget = "17" - } +kotlin { + compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } } dependencies { @@ -31,4 +33,5 @@ dependencies { testImplementation(libs.kotlinx.coroutines.test) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(libs.kotlinx.coroutines.test) } diff --git a/android/core/session/src/test/kotlin/com/flagship/abox/manager/session/CredentialStoreTest.kt b/android/core/session/src/androidTest/kotlin/com/flagship/abox/manager/session/CredentialStoreTest.kt similarity index 97% rename from android/core/session/src/test/kotlin/com/flagship/abox/manager/session/CredentialStoreTest.kt rename to android/core/session/src/androidTest/kotlin/com/flagship/abox/manager/session/CredentialStoreTest.kt index b912d27..829689d 100644 --- a/android/core/session/src/test/kotlin/com/flagship/abox/manager/session/CredentialStoreTest.kt +++ b/android/core/session/src/androidTest/kotlin/com/flagship/abox/manager/session/CredentialStoreTest.kt @@ -1,6 +1,7 @@ package com.flagship.abox.manager.session import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.test.ext.junit.runners.AndroidJUnit4 import com.flagship.abox.manager.domain.DomainResult import com.flagship.abox.manager.domain.LoginCredentials import com.flagship.abox.manager.domain.Password @@ -13,7 +14,9 @@ import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Test +import org.junit.runner.RunWith +@RunWith(AndroidJUnit4::class) class CredentialStoreTest { @Test fun storesClearsAndIsolatesCredentials() = runTest { diff --git a/android/core/session/src/test/kotlin/com/flagship/abox/manager/session/ProviderProfileStoreTest.kt b/android/core/session/src/androidTest/kotlin/com/flagship/abox/manager/session/ProviderProfileStoreTest.kt similarity index 92% rename from android/core/session/src/test/kotlin/com/flagship/abox/manager/session/ProviderProfileStoreTest.kt rename to android/core/session/src/androidTest/kotlin/com/flagship/abox/manager/session/ProviderProfileStoreTest.kt index 8616cd5..f32d788 100644 --- a/android/core/session/src/test/kotlin/com/flagship/abox/manager/session/ProviderProfileStoreTest.kt +++ b/android/core/session/src/androidTest/kotlin/com/flagship/abox/manager/session/ProviderProfileStoreTest.kt @@ -1,6 +1,7 @@ package com.flagship.abox.manager.session import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.test.ext.junit.runners.AndroidJUnit4 import com.flagship.abox.manager.domain.DomainResult import com.flagship.abox.manager.domain.LoginPreferences import com.flagship.abox.manager.domain.ProviderProfile @@ -13,7 +14,9 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Test +import org.junit.runner.RunWith +@RunWith(AndroidJUnit4::class) class ProviderProfileStoreTest { @Test fun persistsActiveProfileAndLoginPreferences() = runTest { diff --git a/android/feature/automation/build.gradle.kts b/android/feature/automation/build.gradle.kts new file mode 100644 index 0000000..e7fce16 --- /dev/null +++ b/android/feature/automation/build.gradle.kts @@ -0,0 +1,55 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.kapt) + alias(libs.plugins.hilt.android) +} + +android { + namespace = "com.flagship.abox.manager.automation" + compileSdk = 37 + + defaultConfig { + minSdk = 26 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildFeatures { compose = true } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} + +kotlin { + compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) } +} + +kapt { + correctErrorTypes = true +} + +dependencies { + api(project(":domain")) + implementation(libs.room.runtime) + implementation(libs.room.ktx) + kapt(libs.room.compiler) + implementation(libs.hilt.android) + kapt(libs.hilt.compiler) + implementation(libs.androidx.work.runtime.ktx) + implementation(platform(libs.compose.bom)) + implementation(libs.compose.ui) + implementation(libs.compose.material3) + implementation(libs.androidx.lifecycle.viewmodel.ktx) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.hilt.navigation.compose) + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) + implementation(libs.kotlinx.coroutines.core) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(libs.room.testing) + androidTestImplementation(libs.kotlinx.coroutines.test) +} diff --git a/android/feature/automation/src/androidTest/kotlin/com/flagship/abox/manager/automation/AutomationDatabaseTest.kt b/android/feature/automation/src/androidTest/kotlin/com/flagship/abox/manager/automation/AutomationDatabaseTest.kt new file mode 100644 index 0000000..56d8e81 --- /dev/null +++ b/android/feature/automation/src/androidTest/kotlin/com/flagship/abox/manager/automation/AutomationDatabaseTest.kt @@ -0,0 +1,83 @@ +package com.flagship.abox.manager.automation + +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.flagship.abox.manager.automation.data.ActivityEventEntity +import com.flagship.abox.manager.automation.data.AutomationDatabase +import com.flagship.abox.manager.automation.data.RoomActivityRepository +import com.flagship.abox.manager.automation.data.RoomAutomationRuntimeRepository +import com.flagship.abox.manager.domain.ActivityEvent +import com.flagship.abox.manager.domain.ActivityEventCursor +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.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.ProviderProfileId +import com.flagship.abox.manager.domain.SceneId +import com.flagship.abox.manager.domain.SceneTriggerId +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class AutomationDatabaseTest { + private lateinit var database: AutomationDatabase + private lateinit var activityRepository: RoomActivityRepository + private lateinit var runtimeRepository: RoomAutomationRuntimeRepository + + @Before + fun setUp() { + database = Room.inMemoryDatabaseBuilder(ApplicationProvider.getApplicationContext(), AutomationDatabase::class.java).build() + activityRepository = RoomActivityRepository(database.automationDao()) + runtimeRepository = RoomAutomationRuntimeRepository(database.automationDao()) + } + + @After + fun tearDown() = database.close() + + @Test + fun activityEventsAreIsolatedByProfileAndPageByCursor() = runTest { + activityRepository.append(event("profile-a", "a", 10L)) + activityRepository.append(event("profile-a", "b", 10L)) + activityRepository.append(event("profile-b", "c", 10L)) + + val first = activityRepository.observeLatest(ProviderProfileId("profile-a"), 1).first() + val firstEvent = (first as DomainResult.Success).value.single() + val next = activityRepository.loadBefore( + ProviderProfileId("profile-a"), + ActivityEventCursor(firstEvent.occurredAtEpochMillis, firstEvent.id), + 1, + ) + + assertEquals("b", firstEvent.id.value) + assertEquals(listOf("a"), (next as DomainResult.Success).value.map { it.id.value }) + } + + @Test + fun occurrenceClaimIsAtomic() = runTest { + val first = runtimeRepository.claimOccurrence(ProviderProfileId("p"), SceneId("s"), SceneTriggerId("t"), "occurrence") + val second = runtimeRepository.claimOccurrence(ProviderProfileId("p"), SceneId("s"), SceneTriggerId("t"), "occurrence") + + assertTrue((first as DomainResult.Success).value) + assertEquals(false, (second as DomainResult.Success).value) + } + + private fun event(profile: String, id: String, time: Long) = ActivityEvent( + id = ActivityEventId(id), + profileId = ProviderProfileId(profile), + occurredAtEpochMillis = time, + kind = ActivityEventKind.SOCKET_COMMAND, + source = ActivitySource.USER, + outcome = ActivityOutcome.SUCCEEDED, + target = ActivityTarget(ActivityTargetType.DEVICE, id, "设备"), + ) +} diff --git a/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/AutomationDao.kt b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/AutomationDao.kt new file mode 100644 index 0000000..6fd478b --- /dev/null +++ b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/AutomationDao.kt @@ -0,0 +1,26 @@ +package com.flagship.abox.manager.automation.data + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import kotlinx.coroutines.flow.Flow + +@Dao +interface AutomationDao { + @Query("SELECT * FROM activity_events WHERE profileId = :profileId ORDER BY occurredAtEpochMillis DESC, id DESC LIMIT :limit") + fun observeLatest(profileId: String, limit: Int): Flow> + + @Query( + "SELECT * FROM activity_events WHERE profileId = :profileId AND " + + "(occurredAtEpochMillis < :beforeTime OR (occurredAtEpochMillis = :beforeTime AND id < :beforeId)) " + + "ORDER BY occurredAtEpochMillis DESC, id DESC LIMIT :limit", + ) + suspend fun loadBefore(profileId: String, beforeTime: Long, beforeId: String, limit: Int): List + + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertEvent(event: ActivityEventEntity): Long + + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun claimOccurrence(occurrence: AutomationOccurrenceEntity): Long +} diff --git a/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/AutomationDatabase.kt b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/AutomationDatabase.kt new file mode 100644 index 0000000..3a8a677 --- /dev/null +++ b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/AutomationDatabase.kt @@ -0,0 +1,9 @@ +package com.flagship.abox.manager.automation.data + +import androidx.room.Database +import androidx.room.RoomDatabase + +@Database(entities = [ActivityEventEntity::class, AutomationOccurrenceEntity::class], version = 1, exportSchema = false) +abstract class AutomationDatabase : RoomDatabase() { + abstract fun automationDao(): AutomationDao +} diff --git a/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/AutomationEntities.kt b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/AutomationEntities.kt new file mode 100644 index 0000000..735ab7d --- /dev/null +++ b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/AutomationEntities.kt @@ -0,0 +1,33 @@ +package com.flagship.abox.manager.automation.data + +import androidx.room.Entity +import androidx.room.Index + +@Entity( + tableName = "activity_events", + primaryKeys = ["profileId", "id"], + indices = [Index(value = ["profileId", "occurredAtEpochMillis", "id"])], +) +data class ActivityEventEntity( + val profileId: String, + val id: String, + val occurredAtEpochMillis: Long, + val kind: String, + val source: String, + val outcome: String, + val targetType: String, + val targetStableId: String, + val targetDisplayName: String, + val errorCode: String?, +) + +@Entity( + tableName = "automation_occurrences", + primaryKeys = ["profileId", "sceneId", "triggerId", "occurrenceKey"], +) +data class AutomationOccurrenceEntity( + val profileId: String, + val sceneId: String, + val triggerId: String, + val occurrenceKey: String, +) 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 new file mode 100644 index 0000000..fe1fd9f --- /dev/null +++ b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/RoomActivityRepository.kt @@ -0,0 +1,70 @@ +package com.flagship.abox.manager.automation.data + +import com.flagship.abox.manager.domain.ActivityEvent +import com.flagship.abox.manager.domain.ActivityEventCursor +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.DomainError +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.ProviderProfileId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +class RoomActivityRepository(private val dao: AutomationDao) : ActivityRepository { + override fun observeLatest(profileId: ProviderProfileId, limit: Int): Flow>> = + dao.observeLatest(profileId.value, limit.coerceAtLeast(0)).map { entities -> DomainResult.Success(entities.map { it.toDomain() }) } + + override suspend fun loadBefore(profileId: ProviderProfileId, before: ActivityEventCursor, limit: Int): DomainResult> = try { + DomainResult.Success(dao.loadBefore(profileId.value, before.occurredAtEpochMillis, before.id.value, limit.coerceAtLeast(0)).map { it.toDomain() }) + } catch (error: Exception) { + DomainResult.Failure(DomainError.Unknown(detail = "Activity storage unavailable")) + } + + override suspend fun append(event: ActivityEvent): DomainResult = try { + dao.insertEvent(event.toEntity()) + DomainResult.Success(Unit) + } catch (error: Exception) { + DomainResult.Failure(DomainError.Unknown(detail = "Activity storage unavailable")) + } +} + +private fun ActivityEventEntity.toDomain() = ActivityEvent( + id = ActivityEventId(id), + profileId = ProviderProfileId(profileId), + occurredAtEpochMillis = occurredAtEpochMillis, + 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, + occurredAtEpochMillis = occurredAtEpochMillis, + kind = kind.name, + source = source.name, + outcome = outcome.name, + targetType = target.type.name, + targetStableId = target.stableId, + targetDisplayName = target.displayName, + errorCode = errorCode, +) diff --git a/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/RoomAutomationRuntimeRepository.kt b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/RoomAutomationRuntimeRepository.kt new file mode 100644 index 0000000..0506e92 --- /dev/null +++ b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/RoomAutomationRuntimeRepository.kt @@ -0,0 +1,29 @@ +package com.flagship.abox.manager.automation.data + +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.SceneId +import com.flagship.abox.manager.domain.SceneTriggerId + +data class TriggerMatchTransition(val wasMatched: Boolean, val isMatched: Boolean) + +interface AutomationRuntimeRepository { + suspend fun claimOccurrence(profileId: ProviderProfileId, sceneId: SceneId, triggerId: SceneTriggerId, occurrenceKey: String): DomainResult +} + +class RoomAutomationRuntimeRepository(private val dao: AutomationDao) : AutomationRuntimeRepository { + override suspend fun claimOccurrence( + profileId: ProviderProfileId, + sceneId: SceneId, + triggerId: SceneTriggerId, + occurrenceKey: String, + ): DomainResult = try { + val inserted = dao.claimOccurrence( + AutomationOccurrenceEntity(profileId.value, sceneId.value, triggerId.value, occurrenceKey), + ) + DomainResult.Success(inserted != -1L) + } catch (error: Exception) { + DomainResult.Failure(DomainError.Unknown(detail = "Automation storage unavailable")) + } +} 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 new file mode 100644 index 0000000..6d5d42c --- /dev/null +++ b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/di/AutomationModule.kt @@ -0,0 +1,48 @@ +package com.flagship.abox.manager.automation.di + +import android.content.Context +import androidx.room.Room +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 +import dagger.Module +import dagger.Provides +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) +object AutomationModule { + @Provides + @Singleton + fun provideDatabase(@ApplicationContext context: Context): AutomationDatabase = + Room.databaseBuilder(context, AutomationDatabase::class.java, "abox_automation.db").build() + + @Provides + @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()) + + @Provides + @Singleton + fun provideNotificationGateway(@ApplicationContext context: Context): NotificationGateway = + AndroidNotificationGateway(context) +} 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 new file mode 100644 index 0000000..329d536 --- /dev/null +++ b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/notification/AndroidNotificationGateway.kt @@ -0,0 +1,49 @@ +package com.flagship.abox.manager.automation.notification + +import android.Manifest +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 +import com.flagship.abox.manager.domain.DomainError +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.NotificationGateway +import com.flagship.abox.manager.domain.ProviderProfileId + +class AndroidNotificationGateway(private val context: Context) : NotificationGateway { + override suspend fun show(profileId: ProviderProfileId, title: String, message: String): DomainResult { + if (android.os.Build.VERSION.SDK_INT >= 33 && + ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED + ) { + return DomainResult.Failure(DomainError.OperationFailed(detail = "Notification permission is not granted")) + } + val manager = context.getSystemService(NotificationManager::class.java) + manager.createNotificationChannel( + NotificationChannel(CHANNEL_ID, "自动化通知", NotificationManager.IMPORTANCE_DEFAULT), + ) + return try { + @SuppressLint("MissingPermission") + fun postNotification() { + NotificationManagerCompat.from(context).notify( + profileId.value.hashCode(), + NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(android.R.drawable.ic_dialog_info) + .setContentTitle(title) + .setContentText(message) + .setAutoCancel(true) + .build(), + ) + } + postNotification() + DomainResult.Success(Unit) + } catch (_: SecurityException) { + DomainResult.Failure(DomainError.OperationFailed(detail = "Notification permission is not granted")) + } + } + + private companion object { const val CHANNEL_ID = "automation_events" } +} diff --git a/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/runtime/AutomationBootstrapper.kt b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/runtime/AutomationBootstrapper.kt new file mode 100644 index 0000000..373ce7a --- /dev/null +++ b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/runtime/AutomationBootstrapper.kt @@ -0,0 +1,25 @@ +package com.flagship.abox.manager.automation.runtime + +import com.flagship.abox.manager.domain.ProviderProfileStore +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import com.flagship.abox.manager.domain.ProviderProfileId + +class AutomationBootstrapper( + private val profileStore: ProviderProfileStore, + private val scheduler: AutomationScheduler, + private val scope: CoroutineScope, +) { + private var previousProfileId: ProviderProfileId? = null + + fun start() { + scope.launch { + profileStore.observeActiveProfile().collectLatest { active -> + previousProfileId?.takeIf { it != active?.id }?.let { scheduler.cancel(it) } + previousProfileId = active?.id + scheduler.reconcileActiveProfile() + } + } + } +} diff --git a/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/runtime/AutomationPollWorker.kt b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/runtime/AutomationPollWorker.kt new file mode 100644 index 0000000..6478dd9 --- /dev/null +++ b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/runtime/AutomationPollWorker.kt @@ -0,0 +1,10 @@ +package com.flagship.abox.manager.automation.runtime + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters + +class AutomationPollWorker(appContext: Context, workerParams: WorkerParameters) : CoroutineWorker(appContext, workerParams) { + override suspend fun doWork(): Result = + if (inputData.getString(WorkManagerAutomationScheduleStore.KEY_PROFILE_ID).isNullOrBlank()) Result.failure() else Result.success() +} diff --git a/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/runtime/AutomationScheduler.kt b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/runtime/AutomationScheduler.kt new file mode 100644 index 0000000..b8fd478 --- /dev/null +++ b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/runtime/AutomationScheduler.kt @@ -0,0 +1,53 @@ +package com.flagship.abox.manager.automation.runtime + +import com.flagship.abox.manager.domain.AutomationType +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.ProviderProfileStore +import com.flagship.abox.manager.domain.Scene +import com.flagship.abox.manager.domain.SceneRepository +import com.flagship.abox.manager.domain.SessionGateway +import com.flagship.abox.manager.domain.SessionValidity +import kotlinx.coroutines.flow.first + +interface AutomationScheduler { + suspend fun reconcileActiveProfile(): DomainResult + suspend fun reconcile(profileId: ProviderProfileId): DomainResult + suspend fun cancel(profileId: ProviderProfileId): DomainResult +} + +interface AutomationScheduleStore { + suspend fun replace(profileId: ProviderProfileId, scenes: List): DomainResult + suspend fun remove(profileId: ProviderProfileId): DomainResult +} + +class ProfileAutomationScheduler( + private val profileStore: ProviderProfileStore, + private val sessionGateway: SessionGateway, + private val sceneRepository: SceneRepository, + private val scheduleStore: AutomationScheduleStore, +) : AutomationScheduler { + override suspend fun reconcileActiveProfile(): DomainResult { + val active = profileStore.activeProfile() ?: return DomainResult.Success(Unit) + return reconcile(active.id) + } + + override suspend fun reconcile(profileId: ProviderProfileId): DomainResult { + val active = profileStore.activeProfile() + if (active?.id != profileId) return DomainResult.Failure(DomainError.InvalidRequest(detail = "Provider profile is not active")) + return when (val validity = sessionGateway.validateSession(profileId)) { + is DomainResult.Success -> if (validity.value == SessionValidity.VALID) { + when (val result = sceneRepository.observeScenes(profileId).first()) { + is DomainResult.Failure -> result + is DomainResult.Success -> scheduleStore.replace(profileId, result.value.filter { it.enabled && it.type == AutomationType.AUTOMATIC }) + } + } else { + scheduleStore.remove(profileId) + } + is DomainResult.Failure -> scheduleStore.remove(profileId) + } + } + + override suspend fun cancel(profileId: ProviderProfileId): DomainResult = scheduleStore.remove(profileId) +} diff --git a/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/runtime/WorkManagerAutomationScheduleStore.kt b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/runtime/WorkManagerAutomationScheduleStore.kt new file mode 100644 index 0000000..5fa0e17 --- /dev/null +++ b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/runtime/WorkManagerAutomationScheduleStore.kt @@ -0,0 +1,32 @@ +package com.flagship.abox.manager.automation.runtime + +import android.content.Context +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.Scene +import java.util.concurrent.TimeUnit + +class WorkManagerAutomationScheduleStore(context: Context) : AutomationScheduleStore { + private val workManager = WorkManager.getInstance(context) + + override suspend fun replace(profileId: ProviderProfileId, scenes: List): DomainResult { + if (scenes.isEmpty()) return remove(profileId) + val request = PeriodicWorkRequestBuilder(15, TimeUnit.MINUTES) + .setInputData(androidx.work.Data.Builder().putString(KEY_PROFILE_ID, profileId.value).build()) + .build() + workManager.enqueueUniquePeriodicWork(workName(profileId), ExistingPeriodicWorkPolicy.UPDATE, request) + return DomainResult.Success(Unit) + } + + override suspend fun remove(profileId: ProviderProfileId): DomainResult { + workManager.cancelUniqueWork(workName(profileId)) + return DomainResult.Success(Unit) + } + + private fun workName(profileId: ProviderProfileId) = "automation:${profileId.value}" + + companion object { const val KEY_PROFILE_ID = "profile_id" } +} diff --git a/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/ui/activity/ActivityRoute.kt b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/ui/activity/ActivityRoute.kt new file mode 100644 index 0000000..790c1cf --- /dev/null +++ b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/ui/activity/ActivityRoute.kt @@ -0,0 +1,15 @@ +package com.flagship.abox.manager.automation.ui.activity + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flagship.abox.manager.domain.ProviderProfileId + +@Composable +fun ActivityRoute(profileId: ProviderProfileId, modifier: Modifier = Modifier, viewModel: ActivityViewModel = hiltViewModel()) { + val state = viewModel.state.collectAsStateWithLifecycle().value + LaunchedEffect(profileId) { viewModel.initialize(profileId) } + ActivityScreen(state = state, onLoadEarlier = viewModel::loadEarlier, modifier = modifier) +} 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 new file mode 100644 index 0000000..b8515cb --- /dev/null +++ b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/ui/activity/ActivityScreen.kt @@ -0,0 +1,94 @@ +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 + +@Composable +fun ActivityScreen( + state: ActivityUiState, + onLoadEarlier: () -> Unit, + modifier: Modifier = Modifier, + zoneId: ZoneId = ZoneId.systemDefault(), +) { + Column(modifier = modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text(stringResource(R.string.activity_title), style = MaterialTheme.typography.headlineSmall) + if (!state.loading && state.groups.isEmpty()) { + 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.value }) { event -> ActivityEventRow(event, zoneId) } + } + if (state.hasMore) item { TextButton(onClick = onLoadEarlier) { Text(stringResource(R.string.activity_load_earlier)) } } + } + } + } +} + +@Composable +private fun ActivityEventRow(event: ActivityEvent, zoneId: ZoneId) { + val outcome = when (event.outcome) { + 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 + } + 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 new file mode 100644 index 0000000..a274729 --- /dev/null +++ b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/ui/activity/ActivityViewModel.kt @@ -0,0 +1,90 @@ +package com.flagship.abox.manager.automation.ui.activity + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.flagship.abox.manager.domain.ActivityEvent +import com.flagship.abox.manager.domain.ActivityEventCursor +import com.flagship.abox.manager.domain.ActivityRepository +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.ProviderProfileId +import dagger.hilt.android.lifecycle.HiltViewModel +import java.time.Instant +import java.time.ZoneId +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.flow.catch +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class ActivityGroup(val label: String, val events: List) + +data class ActivityUiState( + val profileId: ProviderProfileId? = null, + val events: List = emptyList(), + val groups: List = emptyList(), + val loading: Boolean = true, + val loadingEarlier: Boolean = false, + val hasMore: Boolean = true, + val error: Boolean = false, +) + +@HiltViewModel +class ActivityViewModel @Inject constructor( + private val repository: ActivityRepository, +) : ViewModel() { + private val _state = MutableStateFlow(ActivityUiState()) + val state: StateFlow = _state.asStateFlow() + private var observation: Job? = null + + fun initialize(profileId: ProviderProfileId, zoneId: ZoneId = ZoneId.systemDefault()) { + if (_state.value.profileId == profileId && observation?.isActive == true) return + observation?.cancel() + _state.value = ActivityUiState(profileId = profileId) + observation = viewModelScope.launch { + 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) + } + } + } + } + + fun loadEarlier(zoneId: ZoneId = ZoneId.systemDefault()) { + val current = _state.value + val profileId = current.profileId ?: return + val last = current.events.lastOrNull() ?: return + if (current.loadingEarlier) return + viewModelScope.launch { + _state.value = _state.value.copy(loadingEarlier = true) + val result = repository.loadBefore(profileId, ActivityEventCursor(last.occurredAtEpochMillis, last.id), 100) + when (result) { + is DomainResult.Success -> publish((current.events + result.value).distinctBy { it.id }, zoneId) + is DomainResult.Failure -> _state.value = _state.value.copy(error = true) + } + _state.value = _state.value.copy(loadingEarlier = false, hasMore = resultHasMore(result)) + } + } + + private fun publish(events: List, zoneId: ZoneId) { + val sorted = events.sortedWith(compareByDescending { it.occurredAtEpochMillis }.thenByDescending { it.id.value }) + val today = Instant.now().atZone(zoneId).toLocalDate() + val groups = sorted.groupBy { + val date = Instant.ofEpochMilli(it.occurredAtEpochMillis).atZone(zoneId).toLocalDate() + when (date) { + today -> "今天" + today.minusDays(1) -> "昨天" + else -> "更早记录" + } + }.map { ActivityGroup(it.key, it.value) } + _state.value = _state.value.copy(events = sorted, groups = groups, loading = false, error = false) + } + + private fun resultHasMore(result: DomainResult>): Boolean = + result is DomainResult.Success && result.value.size >= 100 +} 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/automation/src/test/kotlin/com/flagship/abox/manager/automation/ui/activity/ActivityViewModelTest.kt b/android/feature/automation/src/test/kotlin/com/flagship/abox/manager/automation/ui/activity/ActivityViewModelTest.kt new file mode 100644 index 0000000..8bdac86 --- /dev/null +++ b/android/feature/automation/src/test/kotlin/com/flagship/abox/manager/automation/ui/activity/ActivityViewModelTest.kt @@ -0,0 +1,113 @@ +package com.flagship.abox.manager.automation.ui.activity + +import com.flagship.abox.manager.domain.ActivityEvent +import com.flagship.abox.manager.domain.ActivityEventCursor +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.DomainResult +import com.flagship.abox.manager.domain.ProviderProfileId +import java.time.Instant +import java.time.ZoneId +import java.time.temporal.ChronoUnit +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ActivityViewModelTest { + private val dispatcher = StandardTestDispatcher() + private val scope = TestScope(dispatcher) + private val profile = ProviderProfileId("profile") + private val zone = ZoneId.of("Asia/Singapore") + private lateinit var repository: TestActivityRepository + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + repository = TestActivityRepository() + } + + @After + fun tearDown() = Dispatchers.resetMain() + + @Test + fun realEmptyRepositoryShowsEmptyState() = scope.runTest { + val viewModel = ActivityViewModel(repository) + viewModel.initialize(profile, zone) + advanceUntilIdle() + + assertTrue(viewModel.state.value.events.isEmpty()) + assertTrue(viewModel.state.value.groups.isEmpty()) + assertEquals(false, viewModel.state.value.error) + } + + @Test + fun eventsGroupByLocalDate() = scope.runTest { + val now = Instant.now().atZone(zone).truncatedTo(ChronoUnit.MINUTES) + repository.latest.value = DomainResult.Success( + listOf( + event("today", now.toInstant().toEpochMilli()), + event("yesterday", now.minusDays(1).toInstant().toEpochMilli()), + event("earlier", now.minusDays(3).toInstant().toEpochMilli()), + ), + ) + val viewModel = ActivityViewModel(repository) + viewModel.initialize(profile, zone) + advanceUntilIdle() + + assertEquals(listOf("今天", "昨天", "更早记录"), viewModel.state.value.groups.map { it.label }) + } + + @Test + fun loadEarlierRemovesDuplicateEventsByStableId() = scope.runTest { + val newest = event("newest", 30L) + val older = event("older", 20L) + repository.latest.value = DomainResult.Success(listOf(newest)) + repository.before = DomainResult.Success(listOf(newest, older)) + val viewModel = ActivityViewModel(repository) + viewModel.initialize(profile, zone) + advanceUntilIdle() + viewModel.loadEarlier(zone) + advanceUntilIdle() + + assertEquals(listOf("newest", "older"), viewModel.state.value.events.map { it.id.value }) + } + + private fun event(id: String, time: Long) = ActivityEvent( + id = ActivityEventId(id), + profileId = profile, + occurredAtEpochMillis = time, + kind = ActivityEventKind.SOCKET_COMMAND, + source = ActivitySource.USER, + outcome = ActivityOutcome.SUCCEEDED, + target = ActivityTarget(ActivityTargetType.DEVICE, id, id), + ) + + private class TestActivityRepository : ActivityRepository { + val latest = MutableStateFlow>>(DomainResult.Success(emptyList())) + var before: DomainResult> = DomainResult.Success(emptyList()) + + override fun observeLatest(profileId: ProviderProfileId, limit: Int): Flow>> = latest + override suspend fun loadBefore(profileId: ProviderProfileId, before: ActivityEventCursor, limit: Int) = this.before + override suspend fun append(event: ActivityEvent): DomainResult = DomainResult.Success(Unit) + } +} diff --git a/android/feature/infrared/build.gradle.kts b/android/feature/infrared/build.gradle.kts index d088f6b..38c0709 100644 --- a/android/feature/infrared/build.gradle.kts +++ b/android/feature/infrared/build.gradle.kts @@ -1,13 +1,16 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + plugins { alias(libs.plugins.android.library) alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.kapt) alias(libs.plugins.hilt.android) } android { namespace = "com.flagship.abox.manager.infrared" - compileSdk = 34 + compileSdk = 37 defaultConfig { minSdk = 26 @@ -18,18 +21,14 @@ android { compose = true } - composeOptions { - kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get() - } - compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } +} - kotlinOptions { - jvmTarget = "17" - } +kotlin { + compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } } kapt { @@ -58,6 +57,7 @@ dependencies { implementation(libs.compose.ui) implementation(libs.compose.ui.tooling.preview) implementation(libs.compose.material3) + implementation(libs.compose.material.icons.extended) testImplementation(libs.junit) testImplementation(libs.kotlinx.coroutines.test) 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/androidTest/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteScreenTest.kt b/android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteScreenTest.kt index a071166..593331b 100644 --- a/android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteScreenTest.kt +++ b/android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteScreenTest.kt @@ -8,6 +8,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import com.flagship.abox.manager.domain.DeviceName import com.flagship.abox.manager.infrared.remote.ui.InfraredRemoteScreen import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith diff --git a/android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreenTest.kt b/android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreenTest.kt index 99699b1..f1a796d 100644 --- a/android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreenTest.kt +++ b/android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreenTest.kt @@ -3,17 +3,13 @@ package com.flagship.abox.manager.infrared.ui import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performTextInput 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.ProviderType -import com.flagship.abox.manager.domain.infrared.InfraredKeyId -import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus -import com.flagship.abox.manager.domain.infrared.SavedInfraredKey import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -25,53 +21,32 @@ class InfraredControlScreenTest { val composeTestRule = createComposeRule() @Test - fun rendersControlScreenAndTriggersKeyOperations() { - val testKey = SavedInfraredKey( - id = InfraredKeyId("k1"), - profileId = ProviderProfileId("profile1"), - deviceName = DeviceName("ir-1"), - displayName = "Power", - sdkKey = "1", - status = InfraredKeyStatus.READY, - lastErrorCode = null, - lastErrorDetail = null, - createdAtEpochMillis = 100L, - updatedAtEpochMillis = 100L, - ) + fun rendersDeviceConfigurationAndAcceptsDeviceNameInput() { val state = InfraredControlUiState( profileId = ProviderProfileId("profile1"), providerType = ProviderType.LOCAL_EXPERIENCE, savedDeviceName = DeviceName("ir-1"), - keys = listOf(testKey), loading = false, ) - var sendClicked = false - var deleteClicked = false + var changedName: String? = null composeTestRule.setContent { InfraredControlScreen( state = state, onBack = {}, onSessionInvalid = {}, - onDeviceNameChanged = {}, + onDeviceNameChanged = { changedName = it }, onSaveDeviceName = {}, - onLearn = { _, _ -> }, - onRetry = {}, - onSend = { sendClicked = true }, - onRename = { _, _ -> }, - onDelete = { deleteClicked = true }, onMessageShown = {}, - nextSlot = 2, ) } - composeTestRule.onNodeWithTag("infrared_key_k1").assertIsDisplayed() - composeTestRule.onNodeWithTag("infrared_send_k1").assertIsDisplayed().performClick() - assertTrue(sendClicked) - - composeTestRule.onNodeWithTag("infrared_delete_btn_k1").assertIsDisplayed().performClick() - composeTestRule.onNodeWithTag("infrared_delete_confirm").assertIsDisplayed().performClick() - assertTrue(deleteClicked) + composeTestRule.onNodeWithText("运行环境与连接信息").assertIsDisplayed() + composeTestRule.onNodeWithTag("infrared_device_name_input") + .assertIsDisplayed() + .performTextInput("-updated") + composeTestRule.waitForIdle() + assertEquals("-updated", changedName) } } diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/InfraredDatabase.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/InfraredDatabase.kt index af179fd..31a2ec3 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/InfraredDatabase.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/InfraredDatabase.kt @@ -10,7 +10,7 @@ import androidx.room.RoomDatabase VirtualRemoteEntity::class, RemoteKeyBindingEntity::class, ], - version = 2, + version = 3, exportSchema = false, ) abstract class InfraredDatabase : RoomDatabase() { diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/InfraredEntities.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/InfraredEntities.kt index 73112e5..aa0e857 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/InfraredEntities.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/InfraredEntities.kt @@ -65,6 +65,11 @@ data class VirtualRemoteEntity( indices = [ Index(value = ["remoteId"]), Index(value = ["profileId"]), + Index( + value = ["profileId", "hardwareDeviceName", "assignedSlot"], + unique = true, + name = "index_remote_key_bindings_hardware_slot", + ), ], ) data class RemoteKeyBindingEntity( @@ -72,6 +77,7 @@ data class RemoteKeyBindingEntity( val id: String, val remoteId: String, val profileId: String, + val hardwareDeviceName: String, val buttonId: String, val displayName: String, val assignedSlot: String?, diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/InfraredMigrations.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/InfraredMigrations.kt new file mode 100644 index 0000000..92e543e --- /dev/null +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/InfraredMigrations.kt @@ -0,0 +1,20 @@ +package com.flagship.abox.manager.infrared.data.local + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +val MIGRATION_2_3 = object : Migration(2, 3) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "ALTER TABLE remote_key_bindings ADD COLUMN hardwareDeviceName TEXT NOT NULL DEFAULT ''", + ) + db.execSQL( + "UPDATE remote_key_bindings SET hardwareDeviceName = " + + "(SELECT deviceName FROM virtual_remotes WHERE virtual_remotes.id = remote_key_bindings.remoteId)", + ) + db.execSQL( + "CREATE UNIQUE INDEX IF NOT EXISTS index_remote_key_bindings_hardware_slot " + + "ON remote_key_bindings(profileId, hardwareDeviceName, assignedSlot)", + ) + } +} 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 712e679..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 @@ -206,19 +206,36 @@ private fun RemoteKeyBindingEntity.toDomain(): RemoteKeyBinding = RemoteKeyBindi id = id, remoteId = VirtualRemoteId(remoteId), profileId = ProviderProfileId(profileId), + hardwareDeviceName = DeviceName(hardwareDeviceName), buttonId = buttonId, 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, profileId = profileId.value, + hardwareDeviceName = hardwareDeviceName.value, buttonId = buttonId, displayName = displayName, assignedSlot = assignedSlot, diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/di/InfraredFeatureModule.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/di/InfraredFeatureModule.kt index 13d499a..00c4b6f 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/di/InfraredFeatureModule.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/di/InfraredFeatureModule.kt @@ -20,6 +20,7 @@ import com.flagship.abox.manager.infrared.data.local.InfraredDao import com.flagship.abox.manager.infrared.data.local.InfraredDatabase import com.flagship.abox.manager.infrared.data.local.RoomInfraredKeyRepository import com.flagship.abox.manager.infrared.data.local.RoomVirtualRemoteRepository +import com.flagship.abox.manager.infrared.data.local.MIGRATION_2_3 import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -49,7 +50,7 @@ object InfraredFeatureModule { context, InfraredDatabase::class.java, "abox_infrared.db", - ).fallbackToDestructiveMigration().build() + ).addMigrations(MIGRATION_2_3).build() } @Provides 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 1ee37cb..71a9957 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,8 @@ 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.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -50,7 +60,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 @@ -59,6 +70,11 @@ import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flagship.abox.manager.designsystem.ABoxSurface +import com.flagship.abox.manager.designsystem.ABoxBackLayer +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 import com.flagship.abox.manager.domain.infrared.PresetCodeLibrary @@ -69,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( @@ -87,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, ) @@ -101,12 +134,28 @@ 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) } LaunchedEffect(state.message) { state.message?.let { msg -> @@ -114,7 +163,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() @@ -122,7 +171,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() @@ -135,40 +184,19 @@ 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 = { - TextButton(onClick = onBack) { - Text(stringResource(R.string.infrared_back)) - } - }, - actions = { - IconButton( - onClick = onNavigateToSettings, - modifier = Modifier.testTag("hub_settings_button"), - ) { - Text("⚙️", fontSize = 20.sp) - } - }, + ABoxDeviceDetailScaffold( + title = state.savedDeviceName.value, + subtitle = stringResource(R.string.infrared_center_subtitle), + icon = ABoxIconKey.INFRARED, + onBack = onBack, + action = { + 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 @@ -178,58 +206,105 @@ internal fun InfraredHubScreen( 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), + ) + } + } + } + } + } + + if (showManagement) { + ABoxBackLayer(onBack = { showManagement = false }) { + ABoxDeviceDetailScaffold( + title = state.savedDeviceName.value, + subtitle = stringResource(R.string.infrared_settings_subtitle), + icon = ABoxIconKey.INFRARED, + onBack = { showManagement = false }, + ) { innerPadding -> + ABoxSurface( + modifier = Modifier.fillMaxSize().padding(innerPadding), + ) { + InfraredManagementScreen( + state = state, + onAddRemote = { showCreateDialog = true }, + onDeleteRemote = onDeleteRemote, + onSaveDeviceName = onSaveDeviceName, + onMoveRemote = onMoveRemote, + onUpdateRemote = onUpdateRemote, + ) } } } @@ -244,6 +319,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 +566,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("🗑️", fontSize = 16.sp) + 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( @@ -288,11 +586,11 @@ private fun VirtualRemoteCard( ) val modeText = if (remote.type == RemoteDeviceType.AIR_CONDITIONER) { - "❄️ 状态机动态写码" + "状态机动态写码" } else if (remote.transmissionMode == TransmissionMode.STATIC_SLOTS) { - "🚀 静态独占槽位" + "静态独占槽位" } else { - "📦 动态写码" + "动态写码" } Text( @@ -305,9 +603,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 +625,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 +662,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, ) @@ -430,7 +747,7 @@ private fun CreateVirtualRemoteDialog( OutlinedTextField( value = name, onValueChange = { name = it }, - label = { Text("遥控器名称(如:客厅电视、我的遥控器)") }, + label = { Text("遥控器名称(如:电视、我的遥控器)") }, singleLine = true, modifier = Modifier.fillMaxWidth().testTag("create_remote_name_input"), ) @@ -443,10 +760,10 @@ private fun CreateVirtualRemoteDialog( ) { OutlinedTextField( value = when (selectedCustomLayout) { - PanelLayoutStyle.TV_NAV -> "📺 电视导航布局 (内置方向盘/音量/频道)" - PanelLayoutStyle.MEDIA_PLAYER -> "🎵 媒体播放器布局 (播放/快进/切歌)" - PanelLayoutStyle.GRID -> "🎛️ 自由网格布局 (可自由增删按键)" - PanelLayoutStyle.AC_COMPACT -> "❄️ 空调布局" + PanelLayoutStyle.TV_NAV -> "电视导航布局(内置方向盘、音量、频道)" + PanelLayoutStyle.MEDIA_PLAYER -> "媒体播放器布局(播放、快进、切歌)" + PanelLayoutStyle.GRID -> "自由网格布局(可自由增删按键)" + PanelLayoutStyle.AC_COMPACT -> "空调布局" }, onValueChange = {}, readOnly = true, @@ -459,21 +776,21 @@ private fun CreateVirtualRemoteDialog( onDismissRequest = { layoutDropdownExpanded = false }, ) { DropdownMenuItem( - text = { Text("📺 电视导航布局 (内置方向盘/音量/频道)") }, + text = { Text("电视导航布局(内置方向盘、音量、频道)") }, onClick = { selectedCustomLayout = PanelLayoutStyle.TV_NAV layoutDropdownExpanded = false }, ) DropdownMenuItem( - text = { Text("🎵 媒体播放器布局 (播放/快进/切歌)") }, + text = { Text("媒体播放器布局(播放、快进、切歌)") }, onClick = { selectedCustomLayout = PanelLayoutStyle.MEDIA_PLAYER layoutDropdownExpanded = false }, ) DropdownMenuItem( - text = { Text("🎛️ 自由网格布局 (可自由增删按键)") }, + text = { Text("自由网格布局(可自由增删按键)") }, onClick = { selectedCustomLayout = PanelLayoutStyle.GRID layoutDropdownExpanded = false @@ -538,7 +855,7 @@ private fun CreateVirtualRemoteDialog( onClick = { transmissionMode = TransmissionMode.STATIC_SLOTS }, ) Column { - Text("🚀 静态独占槽位模式", fontWeight = FontWeight.Medium) + Text("静态独占槽位模式", fontWeight = FontWeight.Medium) Text(stringResource(R.string.infrared_hub_trans_mode_static_desc), fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) } } @@ -552,7 +869,7 @@ private fun CreateVirtualRemoteDialog( onClick = { transmissionMode = TransmissionMode.DYNAMIC_SCRATCH }, ) Column { - Text("📦 动态写码模式", fontWeight = FontWeight.Medium) + Text("动态写码模式", fontWeight = FontWeight.Medium) Text(stringResource(R.string.infrared_hub_trans_mode_dynamic_desc), fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) } } @@ -563,7 +880,7 @@ private fun CreateVirtualRemoteDialog( modifier = Modifier.fillMaxWidth(), ) { Text( - text = "❄️ 空调多维状态自动采用 App 状态机 + 动态缓冲写码,杜绝状态踩踏并节省槽位。", + text = "空调多维状态自动采用状态机和动态缓冲写码,避免状态冲突并节省槽位。", fontSize = 12.sp, modifier = Modifier.padding(10.dp), color = MaterialTheme.colorScheme.onSecondaryContainer, 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 9e69efd..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,26 @@ 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) private data class HubSnapshot( val providerProfile: ProviderProfile? = null, @@ -68,7 +99,12 @@ class InfraredHubViewModel @Inject constructor( when (devResult) { is DomainResult.Failure -> flowOf(HubSnapshot(providerProfile = active, configError = devResult.error)) is DomainResult.Success -> { - val deviceName = devResult.value ?: DEFAULT_DEVICE_NAME + val deviceName = routeDeviceName ?: devResult.value ?: return@flatMapLatest flowOf( + HubSnapshot( + providerProfile = active, + configError = DomainError.InvalidRequest(detail = "No infrared device configured"), + ), + ) combine( virtualRemoteRepository.observeRemotes(profileId, deviceName), virtualRemoteRepository.observeSlotQuota(profileId, deviceName), @@ -88,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 @@ -111,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, @@ -160,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() @@ -181,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 34f4dd6..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 @@ -41,6 +43,16 @@ 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.outlined.Add +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.KeyboardArrowDown +import androidx.compose.material.icons.outlined.KeyboardArrowLeft +import androidx.compose.material.icons.outlined.KeyboardArrowRight +import androidx.compose.material.icons.outlined.KeyboardArrowUp +import androidx.compose.material.icons.outlined.PowerSettingsNew +import androidx.compose.material.icons.outlined.Remove +import androidx.compose.material.icons.outlined.VolumeOff +import androidx.compose.material.icons.outlined.Tv import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -51,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 @@ -60,6 +72,9 @@ import androidx.compose.ui.unit.sp 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.icon.ABoxIcon +import com.flagship.abox.manager.designsystem.icon.ABoxIconKey import com.flagship.abox.manager.domain.infrared.AcFanSpeed import com.flagship.abox.manager.domain.infrared.AcMode import com.flagship.abox.manager.domain.infrared.AcStateSnapshot @@ -118,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) } @@ -128,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() @@ -136,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() @@ -175,17 +190,14 @@ internal fun RemotePanelScreen( } }, navigationIcon = { - TextButton(onClick = onBack) { - Text(stringResource(R.string.infrared_back)) - } + 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, - label = { Text(if (state.isLearnMode) "🔴 录制中" else "⚪ 学习模式") }, + label = { Text(if (state.isLearnMode) "录制中" else "学习模式") }, colors = FilterChipDefaults.filterChipColors( selectedContainerColor = Color(0xFFFF5252), selectedLabelColor = Color.White, @@ -222,7 +234,7 @@ internal fun RemotePanelScreen( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - Text("🔴", fontSize = 16.sp) + ABoxIcon(ABoxIconKey.PENDING, "录制中", modifier = Modifier.size(20.dp)) Text( text = "学习模式已开启:点击任意按键,然后将物理实体遥控器对准转发器按下即可录码", color = Color(0xFFC62828), @@ -233,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), + ) } } } @@ -309,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, ), @@ -332,7 +313,7 @@ private fun AcControlPanel( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = if (acState.power) "● 运行中" else "○ 已关机", + text = if (acState.power) "运行中" else "已关机", color = if (acState.power) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, fontWeight = FontWeight.Bold, ) @@ -344,25 +325,13 @@ private fun AcControlPanel( .background(if (acState.power) Color(0xFFFF5252) else MaterialTheme.colorScheme.outline) .testTag("ac_power_toggle"), ) { - Text("⏻", color = Color.White, fontSize = 20.sp, fontWeight = FontWeight.Bold) + Icon(Icons.Outlined.PowerSettingsNew, contentDescription = "电源", tint = Color.White) } } - 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( @@ -370,31 +339,46 @@ private fun AcControlPanel( modifier = Modifier.size(56.dp).testTag("ac_temp_down"), shape = CircleShape, ) { - Text("-", fontSize = 24.sp, fontWeight = FontWeight.Bold) + 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"), shape = CircleShape, ) { - Text("+", fontSize = 24.sp, fontWeight = FontWeight.Bold) + 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), + ) + } + } } } @@ -407,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), ) } @@ -420,19 +404,19 @@ private fun AcControlPanel( FilterChip( selected = acState.verticalSwing, onClick = onToggleVSwing, - label = { Text("↕ 上下扫风") }, + label = { Text("上下扫风") }, modifier = Modifier.weight(1f), ) FilterChip( selected = acState.horizontalSwing, onClick = onToggleHSwing, - label = { Text("↔ 左右扫风") }, + label = { Text("左右扫风") }, modifier = Modifier.weight(1f), ) FilterChip( selected = acState.eco, onClick = onToggleEco, - label = { Text("🌱 节能 ECO") }, + label = { Text("节能") }, modifier = Modifier.weight(1f), ) } @@ -452,6 +436,7 @@ private fun TvControlPanel( Column( modifier = Modifier .fillMaxSize() + .verticalScroll(rememberScrollState()) .padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp), @@ -462,9 +447,9 @@ private fun TvControlPanel( horizontalArrangement = Arrangement.SpaceAround, verticalAlignment = Alignment.CenterVertically, ) { - InteractiveRemoteButton("TV_POWER", "⏻ 电源", Color(0xFFFF5252), bindingMap["TV_POWER"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick) - InteractiveRemoteButton("TV_MUTE", "🔇 静音", MaterialTheme.colorScheme.secondary, bindingMap["TV_MUTE"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick) - InteractiveRemoteButton("TV_SOURCE", "📺 信号源", MaterialTheme.colorScheme.secondary, bindingMap["TV_SOURCE"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick) + InteractiveRemoteButton("TV_POWER", "电源", Color(0xFFFF5252), bindingMap["TV_POWER"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick) + InteractiveRemoteButton("TV_MUTE", "静音", MaterialTheme.colorScheme.secondary, bindingMap["TV_MUTE"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick) + InteractiveRemoteButton("TV_SOURCE", "信号源", MaterialTheme.colorScheme.secondary, bindingMap["TV_SOURCE"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick) } Spacer(modifier = Modifier.height(8.dp)) @@ -472,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) { @@ -481,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) } // 下 @@ -489,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) } // 左 @@ -497,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) } // 右 @@ -505,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 居中大按钮 @@ -524,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( @@ -539,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 = "降低音量") } } @@ -552,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, @@ -582,8 +668,8 @@ private fun MediaPlayerControlPanel( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, ) { - InteractiveRemoteButton("MEDIA_POWER", "⏻ 电源", Color(0xFFFF5252), bindingMap["MEDIA_POWER"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick) - InteractiveRemoteButton("MEDIA_MUTE", "🔇 静音", MaterialTheme.colorScheme.secondary, bindingMap["MEDIA_MUTE"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick) + InteractiveRemoteButton("MEDIA_POWER", "电源", Color(0xFFFF5252), bindingMap["MEDIA_POWER"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick) + InteractiveRemoteButton("MEDIA_MUTE", "静音", MaterialTheme.colorScheme.secondary, bindingMap["MEDIA_MUTE"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick) } Spacer(modifier = Modifier.height(16.dp)) @@ -641,13 +727,13 @@ private fun GenericGridControlPanel( ) { Text("按键列表 (${bindings.size})", fontWeight = FontWeight.Bold, fontSize = 16.sp) Button(onClick = onAddButton, modifier = Modifier.testTag("grid_add_key_button")) { - Text("➕ 添加按键") + Text("添加按键") } } if (bindings.isEmpty()) { Box(modifier = Modifier.fillMaxWidth().weight(1f), contentAlignment = Alignment.Center) { - Text("暂无自定义按键,点击上方【➕ 添加按键】新建", color = MaterialTheme.colorScheme.onSurfaceVariant) + Text("暂无自定义按键,请使用上方添加操作新建", color = MaterialTheme.colorScheme.onSurfaceVariant) } } else { LazyVerticalGrid( @@ -696,9 +782,10 @@ private fun GridButtonCard( Column(modifier = Modifier.weight(1f)) { Text(binding.displayName, fontWeight = FontWeight.Bold, fontSize = 15.sp) val statusText = when { - isLearning -> "🟡 正在录制信号..." - binding.status == InfraredKeyStatus.READY -> "🟢 已就绪 (Slot ${binding.assignedSlot ?: "动态"})" - else -> "⚪ 待录制 (点击录码)" + isLearning -> "正在录制信号..." + binding.status == InfraredKeyStatus.READY -> "已就绪(槽位 ${binding.assignedSlot ?: "动态"})" + binding.status == InfraredKeyStatus.FAILED -> "学习失败(请重试)" + else -> "未学习(点击录码)" } Text(statusText, fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) } @@ -711,7 +798,7 @@ private fun GridButtonCard( onClick = onDelete, modifier = Modifier.size(36.dp).testTag("delete_key_${binding.buttonId}"), ) { - Text("🗑️", fontSize = 15.sp) + Text("删除", fontSize = 12.sp) } } } @@ -755,7 +842,7 @@ private fun ButtonIndicatorText( sendingButtonId: String?, ) { if (learningButtonId == buttonId) { - Text("🟡 录制中", fontSize = 12.sp, fontWeight = FontWeight.Bold) + Text("录制中", fontSize = 12.sp, fontWeight = FontWeight.Bold) } else if (sendingButtonId == buttonId) { CircularProgressIndicator(modifier = Modifier.size(16.dp), color = Color.White) } else { 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 221dad3..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 @@ -24,6 +24,8 @@ import com.flagship.abox.manager.infrared.remote.UiText import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.withContext import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -59,6 +61,7 @@ class RemotePanelViewModel @Inject constructor( private val localAcStateFlow = MutableStateFlow(null) private val messageFlow = MutableStateFlow(null) private var acDebounceJob: Job? = null + private var learningJob: Job? = null private val remoteFlow = if (profileId == null || remoteId == null) { flowOf(null) @@ -126,8 +129,10 @@ 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() if (newMode) { messageFlow.value = PanelUiMessage.Info(UiText.StringResource(R.string.infrared_msg_learning_mode_enabled)) } @@ -162,12 +167,13 @@ 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 learningButtonIdFlow.value = buttonId - viewModelScope.launch { + learningJob = viewModelScope.launch { try { when (val res = learnVirtualRemoteKey(currentProfile, currentRemoteId, buttonId)) { is DomainResult.Success -> { @@ -175,12 +181,39 @@ class RemotePanelViewModel @Inject constructor( } is DomainResult.Failure -> handleDomainError(res.error) } + } catch (cancelled: kotlinx.coroutines.CancellationException) { + val binding = state.value.bindings.firstOrNull { it.buttonId == buttonId } + if (binding != null) { + withContext(NonCancellable) { + virtualRemoteRepository.updateKeyBinding( + binding.copy( + status = com.flagship.abox.manager.domain.infrared.InfraredKeyStatus.FAILED, + lastErrorCode = null, + lastErrorDetail = "学习已取消,需要重新学习", + ), + ) + } + } + throw cancelled } finally { learningButtonIdFlow.value = null } } } + fun cancelLearning() { + learningJob?.cancel() + learningJob = null + learningButtonIdFlow.value = null + isLearnModeFlow.value = false + } + + override fun onCleared() { + cancelLearning() + acDebounceJob?.cancel() + super.onCleared() + } + fun addCustomButton(displayName: String) { val currentProfile = profileId ?: return val currentRemoteId = remoteId ?: 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/RemoteModels.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/RemoteModels.kt index bb50d7a..3d59edb 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/RemoteModels.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/RemoteModels.kt @@ -26,24 +26,24 @@ data class RemoteButtonSpec( object RemoteSceneConfig { object AirConditioner { - val POWER = RemoteButtonSpec("AC_POWER", "1", R.string.infrared_key_ac_power, "空调-电源", "⏻") - val TEMP_UP = RemoteButtonSpec("AC_TEMP_UP", "2", R.string.infrared_key_ac_temp_up, "空调-温度+", "▲") - val TEMP_DOWN = RemoteButtonSpec("AC_TEMP_DOWN", "3", R.string.infrared_key_ac_temp_down, "空调-温度-", "▼") - val MODE = RemoteButtonSpec("AC_MODE", "4", R.string.infrared_key_ac_mode, "空调-模式", "模式 ❄/☀") - val FAN = RemoteButtonSpec("AC_FAN", "5", R.string.infrared_key_ac_fan, "空调-风速", "风速 💨") - val SWING = RemoteButtonSpec("AC_SWING", "6", R.string.infrared_key_ac_swing, "空调-扫风", "扫风 ↔") - val TIMER = RemoteButtonSpec("AC_TIMER", "7", R.string.infrared_key_ac_timer, "空调-定时", "定时 ⏱") + val POWER = RemoteButtonSpec("AC_POWER", "1", R.string.infrared_key_ac_power, "空调-电源", "电源") + val TEMP_UP = RemoteButtonSpec("AC_TEMP_UP", "2", R.string.infrared_key_ac_temp_up, "空调-温度+", "升温") + val TEMP_DOWN = RemoteButtonSpec("AC_TEMP_DOWN", "3", R.string.infrared_key_ac_temp_down, "空调-温度-", "降温") + val MODE = RemoteButtonSpec("AC_MODE", "4", R.string.infrared_key_ac_mode, "空调-模式", "模式") + val FAN = RemoteButtonSpec("AC_FAN", "5", R.string.infrared_key_ac_fan, "空调-风速", "风速") + val SWING = RemoteButtonSpec("AC_SWING", "6", R.string.infrared_key_ac_swing, "空调-扫风", "扫风") + val TIMER = RemoteButtonSpec("AC_TIMER", "7", R.string.infrared_key_ac_timer, "空调-定时", "定时") val buttons = listOf(POWER, TEMP_UP, TEMP_DOWN, MODE, FAN, SWING, TIMER) } object Television { - val POWER = RemoteButtonSpec("TV_POWER", "21", R.string.infrared_key_tv_power, "电视-电源", "⏻") - val MUTE = RemoteButtonSpec("TV_MUTE", "22", R.string.infrared_key_tv_mute, "电视-静音", "🔇") - val NAV_UP = RemoteButtonSpec("TV_NAV_UP", "23", R.string.infrared_key_tv_nav_up, "电视-上", "▲") - val NAV_DOWN = RemoteButtonSpec("TV_NAV_DOWN", "24", R.string.infrared_key_tv_nav_down, "电视-下", "▼") - val NAV_LEFT = RemoteButtonSpec("TV_NAV_LEFT", "25", R.string.infrared_key_tv_nav_left, "电视-左", "◀") - val NAV_RIGHT = RemoteButtonSpec("TV_NAV_RIGHT", "26", R.string.infrared_key_tv_nav_right, "电视-右", "▶") + val POWER = RemoteButtonSpec("TV_POWER", "21", R.string.infrared_key_tv_power, "电视-电源", "电源") + val MUTE = RemoteButtonSpec("TV_MUTE", "22", R.string.infrared_key_tv_mute, "电视-静音", "静音") + val NAV_UP = RemoteButtonSpec("TV_NAV_UP", "23", R.string.infrared_key_tv_nav_up, "电视-上", "上") + val NAV_DOWN = RemoteButtonSpec("TV_NAV_DOWN", "24", R.string.infrared_key_tv_nav_down, "电视-下", "下") + val NAV_LEFT = RemoteButtonSpec("TV_NAV_LEFT", "25", R.string.infrared_key_tv_nav_left, "电视-左", "左") + val NAV_RIGHT = RemoteButtonSpec("TV_NAV_RIGHT", "26", R.string.infrared_key_tv_nav_right, "电视-右", "右") val OK = RemoteButtonSpec("TV_OK", "27", R.string.infrared_key_tv_ok, "电视-确定", "OK") val VOL_UP = RemoteButtonSpec("TV_VOL_UP", "28", R.string.infrared_key_tv_vol_up, "电视-音量+", "VOL +") val VOL_DOWN = RemoteButtonSpec("TV_VOL_DOWN", "29", R.string.infrared_key_tv_vol_down, "电视-音量-", "VOL -") 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 97ccfc3..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 @@ -45,6 +45,8 @@ import androidx.compose.ui.unit.sp 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.icon.ABoxIconKey import com.flagship.abox.manager.infrared.R import com.flagship.abox.manager.infrared.remote.InfraredRemoteUiState import com.flagship.abox.manager.infrared.remote.InfraredRemoteViewModel @@ -87,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 -> @@ -95,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() @@ -103,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() @@ -158,12 +160,12 @@ internal fun InfraredRemoteScreen( } // 按键管理 / 设置入口按钮 - IconButton( + ABoxIconButton( + key = ABoxIconKey.EDIT, + contentDescription = "打开红外设置", onClick = onOpenSettings, modifier = Modifier.testTag("remote_settings_button"), - ) { - Text("⚙️", fontSize = 20.sp) - } + ) }, ) }, 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/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModel.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModel.kt index 2d494ac..30ba248 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModel.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModel.kt @@ -40,6 +40,9 @@ class InfraredControlViewModel @Inject constructor( private val deviceNameInputFlow = MutableStateFlow(null) private val messageFlow = MutableStateFlow(null) + private val routeDeviceName: DeviceName? = savedStateHandle.get("deviceName") + ?.takeIf { it.isNotBlank() } + ?.let(::DeviceName) private data class ServerState( val providerProfile: com.flagship.abox.manager.domain.ProviderProfile? = null, @@ -65,7 +68,12 @@ class InfraredControlViewModel @Inject constructor( ), ) is DomainResult.Success -> { - val deviceName = deviceResult.value ?: DEFAULT_DEVICE_NAME + val deviceName = routeDeviceName ?: deviceResult.value ?: return@flatMapLatest flowOf( + ServerState( + providerProfile = active, + configError = DomainError.InvalidRequest(detail = "No infrared device configured"), + ), + ) combine( virtualRemoteRepository.observeRemotes(profileId, deviceName), virtualRemoteRepository.observeSlotQuota(profileId, deviceName), 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 0a6dae8..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 @@ -35,12 +35,12 @@ 红外遥控器 录制中 学习模式 - 🔴 学习模式已开启:点击任意按键开始录制实体遥控器信号 - ❄️ 空调面板 - 📺 电视面板 + 学习模式已开启:点击任意按键开始录制实体遥控器信号 + 空调面板 + 电视面板 设置 - 🔴 学习模式已开启:点击任意按键开始录制 + 学习模式已开启:点击任意按键开始录制 正在录制【%1$s】(槽位 %2$s),请按下实体遥控器按键… 【%1$s】录制成功 按键【%1$s】尚未学习,开启右上角学习模式即可录制 @@ -51,8 +51,8 @@ 温度调节 音量 频道 - ❄️ 制冷模式 - 💨 自动风速 | ↔ 自动扫风 + 制冷模式 + 自动风速 | 自动扫风 空调-电源 空调-升温 @@ -73,9 +73,9 @@ 电视-音量- 电视-频道+ 电视-频道- - 电视-主页 - 电视-返回 - 电视-菜单 + 主页 + 返回 + 菜单 红外设备与环境配置 ABSDK 设备名称配置 ABSDK 依靠此设备名称(默认为 红外控制器)寻址具体的硬件转发器,请确保名称与主机端保持一致。 @@ -98,10 +98,10 @@ 静态占用:%1$d 动态缓冲:Slot %1$s 虚拟遥控器 - 暂无虚拟遥控器,点击右上角或下方 ➕ 添加 + 暂无虚拟遥控器,请使用添加操作 添加遥控器 - 🚀 静态独占(%1$s 槽位) - 📦 动态缓冲(共用 Slot %1$s) + 静态独占(%1$s 槽位) + 动态缓冲(共用槽位 %1$s) 添加虚拟遥控器 设备类型 预设码库 / 品牌 @@ -110,8 +110,83 @@ 节省槽位(0 额外槽位),现场写码发射(~80ms) 硬件槽位不足,请选择动态写码模式 硬件槽位与底层按键管理 - 📺 电视机 / 电视盒子 - ❄️ 空调设备 + 电视机 / 电视盒子 + 空调设备 🌀 电风扇 / 空气净化器 - 🎛️ 自定义面板 + 自定义面板 + 设定温度 + 运行模式 + 风速档位 + 制冷 + 制热 + 自动 + 除湿 + 送风 + 低速 + 中速 + 高速 + 上下扫风 + 左右扫风 + 节能 + 状态变更会生成完整红外帧并发送;信号发射不代表设备状态已确认。 + 向上 + 向下 + 向左 + 向右 + 音量 + 频道 + 提高音量 + 降低音量 + 频道上调 + 频道下调 + 媒体控制面板 + %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 e9a5a6f..a93e5c1 100644 --- a/android/feature/infrared/src/main/res/values/strings.xml +++ b/android/feature/infrared/src/main/res/values/strings.xml @@ -36,12 +36,12 @@ Recording My Remotes Learn Mode - 🔴 Learn mode active: tap any button to record - ❄️ Air Conditioner - 📺 Television + Learn mode active: tap any button to record + Air Conditioner + Television Settings - 🔴 Learn mode active: tap any button to record + Learn mode active: tap any button to record Recording [%1$s] (Slot %2$s), press remote button… [%1$s] recorded successfully Button [%1$s] is not learned. Enable learn mode to record. @@ -52,8 +52,8 @@ Temp Control VOL CH - ❄️ Cool Mode - 💨 Auto Fan | ↔ Swing + Cool Mode + Auto Fan | Swing AC Power AC Temp + @@ -100,8 +100,8 @@ Virtual Remotes No virtual remotes added yet. Tap + to add one. Add Remote - 🚀 Static Mode (%1$s slots) - 📦 Dynamic Mode (Scratch Slot %1$s) + Static Mode (%1$s slots) + Dynamic Mode (Scratch Slot %1$s) Add Virtual Remote Device Type Brand / Preset Library @@ -110,8 +110,83 @@ Saves slots (0 extra slots), writes code on-the-fly (~80ms) Slot quota exceeded. Please choose Dynamic mode. Hardware Slot / Raw Key Management - 📺 Television (TV) - ❄️ Air Conditioner (AC) + Television (TV) + Air Conditioner (AC) 🌀 Fan / Air Purifier - 🎛️ Custom Panel + 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/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelViewModelTest.kt b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelViewModelTest.kt index 8db53e1..e29023c 100644 --- a/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelViewModelTest.kt +++ b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelViewModelTest.kt @@ -226,4 +226,29 @@ class RemotePanelViewModelTest { job.cancel() } + + @Test + fun cancellingLearningModeClearsActiveLearningState() = testScope.runTest { + val res = createRemote( + profileId = profileId, + deviceName = device, + displayName = "TV", + type = RemoteDeviceType.TV, + brandModel = "generic_tv", + transmissionMode = TransmissionMode.STATIC_SLOTS, + ) + val remoteId = (res as com.flagship.abox.manager.domain.DomainResult.Success).value + val vm = createViewModel(remoteId.value) + val job = launch { vm.state.collect {} } + advanceUntilIdle() + + vm.toggleLearnMode() + runCurrent() + vm.cancelLearning() + runCurrent() + + assertEquals(false, vm.state.value.isLearnMode) + assertEquals(null, vm.state.value.learningButtonId) + job.cancel() + } } diff --git a/build.gradle.kts b/build.gradle.kts index 46c8d89..3f079d6 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,8 +2,9 @@ plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.android.library) apply false alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.kotlin.jvm) apply false alias(libs.plugins.kotlin.kapt) apply false alias(libs.plugins.hilt.android) apply false + alias(libs.plugins.compose.screenshot) apply false } - diff --git a/docs/architecture.md b/docs/architecture.md index e2231f6..256062d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -94,7 +94,7 @@ ABSDK 防腐层已在 `:absdk-bridge` 中完成同步阻塞调用的后台串行 Fake 只用于开发和测试,不能作为真实 ABox 或真实硬件验收证据。`:testing:fakes` 是测试专用模块,不得加入应用生产依赖。 -一个例外是 `FakeDeviceCatalog`:ABSDK 不提供设备目录,而提供方作用域的生产 ABox 设备目录由设备目录模块负责,尚未落地。在该模块交付前,debug 构建的组合根暂时用 `FakeDeviceCatalog` 充当 ABox 目录占位。这是一个临时的目录所有权边界:不得把 ABox 目录请求路由到本地体验的模拟目录,release 构建在出现真实消费方之前不声明 `DeviceCatalog` 绑定。 +ABSDK 不提供设备目录发现能力。生产 ABox 设备目录由 `:android:core:devicecatalog` 按当前 profile 持久化,来源是用户明确配置或导入的真实 `DeviceName`;它不从本地体验目录复制数据,也不把测试 Fake 绑定到生产组合根。Local profile 仍由 `SimulationDeviceCatalog` 提供独立的确定性目录。 ## 7. 本地体验模拟运行时 @@ -159,8 +159,8 @@ App 组合根已通过 Hilt 单例绑定该 Bridge,绑定处以 `@CleartextAbs 两个提供方在所有构建变体中始终可用,不存在按 debug/release 区分的可用性开关。 -登录完成后进入 `authenticated` 协作路由。当前仅有占位页面,设备列表与主导航由 alZerNest 的设备导航模块接管。 +登录完成后进入 `authenticated` 协作路由。设备中心、详情、红外、情景、自动化和活动记录由对应 Android 模块提供,并通过当前 profile 路由到真实或本地体验端口。 ## 11. 后续边界 -Room 与设备业务 DataStore 的具体 schema/key 只在对应数据所有权明确后创建。生产 ABox 设备目录由设备目录模块交付后替换 debug 构建中的临时 `FakeDeviceCatalog` 占位。历史示例中的 AsyncTask、明文密码存储、全局明文网络和散落 SDK 调用不得进入正式架构。 +Room 与设备业务 DataStore 的具体 schema/key 只在对应数据所有权明确后创建。生产 ABox 设备目录由设备目录模块维护,不能回退到 `FakeDeviceCatalog`。历史示例中的 AsyncTask、明文密码存储和散落 SDK 调用不得进入正式架构;ABSDK 厂商 JAR 的明文局域网传输风险已明确记录并由所有构建变体共同承担。 diff --git a/docs/testing/device-data-capability-matrix.md b/docs/testing/device-data-capability-matrix.md new file mode 100644 index 0000000..5bb0703 --- /dev/null +++ b/docs/testing/device-data-capability-matrix.md @@ -0,0 +1,32 @@ +# 设备数据能力矩阵 + +本文只记录当前代码中有领域端口、实现和测试支撑的设备数据能力。未列出的数据不应在界面中以固定值、随机值、`--` 或伪造图表呈现。 + +| 能力 | ABox | 本地体验 | 依据 | 界面策略 | +|---|---|---|---|---| +| 插座开关状态读取 | 支持 | 支持 | `SocketGateway.getStatus` | 展示已确认状态 | +| 插座开关控制 | 支持 | 支持 | `SocketGateway.commandPower` / `setPower` | 命令完成后回读;回读失败显示等待确认 | +| 温度当前读数 | 支持,字段可能缺失 | 支持 | `TemperatureHumidityGateway.getReading` | 仅字段存在时展示 | +| 湿度当前读数 | 支持,字段可能缺失 | 支持 | `TemperatureHumidityGateway.getReading` | 仅字段存在时展示 | +| 红外按键发送 | 支持 | 支持 | `InfraredGateway.sendKey` | 展示真实操作结果 | +| 红外按键学习 | 支持 | 支持 | `InfraredGateway.learnKey` | 展示学习状态和结果 | +| 红外码下载 | 支持 | 支持 | `InfraredGateway.downloadCodes` | 仅用于真实下载流程 | +| 插座电压/电流/功率/电量 | 不可用 | 不可用 | 没有对应领域端口 | 不渲染 | +| 插座倒计时 | 不可用 | 不可用 | 没有对应领域端口 | 不渲染 | +| 插座历史趋势 | 不可用 | 不可用 | 没有历史数据端口 | 不渲染 | +| 温湿度历史趋势 | 不可用 | 不可用 | 没有历史数据端口 | 不渲染 | +| 网关诊断延迟 | 不可用 | 不适用 | 没有诊断端口 | 不渲染 | +| Token 有效期展示 | 不可用 | 不适用 | SDK 未提供可安全展示的有效期端口 | 不渲染 | + +## Provider 边界 + +- ABox 设备目录只包含用户明确配置的 `DeviceName` 和 `DeviceType`。 +- 本地体验设备来自确定性的模拟目录。 +- 两个 Provider 的设备描述、状态和历史记录不可互换。 +- “不可用”表示没有对应能力,不表示设备故障;界面不添加禁用的伪功能控件。 + +## 数据处理 + +- ABSDK 原始 Map、响应文本、Token、密码和红外原始码值不进入 UI 活动记录。 +- 错误记录最多保留经过领域层确认的外部错误码。 +- 温湿度单个字段缺失时保留另一个有效字段;两个字段均缺失时进入无数据状态。 diff --git a/docs/testing/infrared-hardware-validation.md b/docs/testing/infrared-hardware-validation.md index 2d541ce..eb89865 100644 --- a/docs/testing/infrared-hardware-validation.md +++ b/docs/testing/infrared-hardware-validation.md @@ -2,39 +2,35 @@ ## 1. 硬件环境与已知设备信息 / Hardware Environment -本阶段用于真实验证的实体设备信息如下: +真实硬件验证只允许在获授权的隔离局域网中进行。文档不记录完整网关地址、IEEE 地址、凭据、令牌、红外码值或原始 SDK 响应;设备名称也应使用脱敏别名。 -- **设备名称**: 红外控制器 (例如 `红外控制器` 或厂商平台绑定的 `irDevName`) -- **IEEE 地址**: `1c1cfd001dba533c` (短地址: `d822`) -- **信号强度 (LQI/RSSI)**: `-4` -- **电量**: `100%` -- **设备类型**: 红外控制器 (Infrared Controller) -- **网关/主机**: ABox 智能主机 -- **被控实体**: 实体空调 / 电视遥控器及对应电器 +- **设备名称**: 脱敏的红外控制器别名 +- **设备类型**: 红外控制器 +- **网关/主机**: 获授权的 ABox 智能主机 +- **被控实体**: 获授权的实体空调、电视或其他电器 --- ## 2. 硬件测试记录表 / Hardware Validation Log -| 日期 (Date) | App Commit | 运行模式 (Mode) | Profile ID | 设备名称 (Device Name) | SDK Key | 操作 (Operation) | 耗时 (ms) | ABSDK Code | 脱敏消息 (Message) | 物理响应 (Physical Response) | 结论 (Result) | -|---|---|---|---|---|---|---|---|---|---|---|---| -| 2026-08-24 | b27c67f | FAKE | abox:infrared-fake | ir-living | app_ir_power1 | Learn | 120 | 00000 | success | 模拟学习完成并入库 READY | PASS (Fake) | -| 2026-08-24 | b27c67f | FAKE | abox:infrared-fake | ir-living | app_ir_power1 | Send | 45 | 00000 | success | 模拟发射成功 | PASS (Fake) | -| 2026-08-24 | 待填 | REAL_ABSDK | abox | 红外控制器 | app_ir_xxxx | Learn | - | - | - | 遥控器对准控制器按下目标按键 | 待真机执行 | -| 2026-08-24 | 待填 | REAL_ABSDK | abox | 红外控制器 | app_ir_xxxx | Send | - | - | - | 实体电器执行开关/调节响应 | 待真机执行 | +| 日期 | App Commit | 运行模式 | 脱敏 Profile | 脱敏设备名 | 脱敏操作标识 | 操作 | 耗时区间 | 脱敏错误分类 | 物理响应 | 结论 | +|---|---|---|---|---|---|---|---|---|---| +| 待填 | 待填 | 本地体验 | local-test | simulation-ir | test-1 | Learn/Send | 待填 | 无 | 模拟流程通过 | 仅软件验证 | +| 待填 | 待填 | ABox | profile-redacted | device-redacted | operation-redacted | Learn | 待填 | 待填 | 授权遥控器完成学习 | 待真机执行 | +| 待填 | 待填 | ABox | profile-redacted | device-redacted | operation-redacted | Send | 待填 | 待填 | 授权电器产生预期响应 | 待真机执行 | --- ## 3. 标准真机测试流程 (12 项用例) / Standard 12-Step Test Procedure -### 用例 1: 真实会话认证前置 (Authenticate Shared Bridge) +### 用例 1: 真实会话认证前置 (Authenticate Session) - **前置**: 通过登录模块对进程单例 `AbsdkBridge` 进行登录认证。 -- **步骤**: 检查进入红外控制页面前,会话 Token 是否有效。 -- **预期**: 若未认证,进入 Real 模式操作时返回 `TokenInvalid` 并提示返回登录。 +- **步骤**: 检查进入红外控制页面前,会话是否有效。 +- **预期**: 若未认证,真实模式操作返回会话无效错误并提示返回登录。 ### 用例 2: 输入并保存真实设备名 (Enter and Save irDevName) -- **步骤**: 在“红外设备名称”输入框中输入 `红外控制器` 或后台分配的真实设备名,点击“保存设备名”。 -- **预期**: 本地 Room 保存成功,页面显示“已保存设备:红外控制器”,启用“学习新按键”入口。 +- **步骤**: 在“红外设备名称”输入框中输入获授权设备名,点击“保存设备名”。 +- **预期**: 本地 Room 保存成功,页面显示脱敏设备名并启用学习入口。 ### 用例 3: 学习全新唯一按键 (Learn Unique Key) - **步骤**: 点击“学习新按键”,输入显示名称(如“空调开”),点击“开始学习”;将物理遥控器对准红外控制器按下按键。 @@ -69,17 +65,17 @@ - **预期**: App 启动或重新进入页面时,`RecoverInterruptedInfraredLearning` 将遗留的 `LEARNING` 保守恢复为 `FAILED`,提示用户重新学习。 ### 用例 11: Token 失效与重新登录 (Token Expiration) -- **步骤**: 主机端使 Token 失效或超过 2 小时后发起红外操作。 -- **预期**: 返回 `TokenInvalid`,本地会话被清除,页面提示登录失效并导航返回。 +- **步骤**: 使远端会话失效后发起红外操作。 +- **预期**: 返回会话无效错误,本地会话被清除,页面提示登录失效并导航返回。 ### 用例 12: 检查未文档化响应字段 (Inspect Undocumented Fields) -- **步骤**: 在调试构建中检查 `ABRet.getDicDatas()` 是否包含未文档化字段。 -- **安全约束**: 严禁将 Raw Payload、Token、密码或未脱敏数据打印至生产日志。 +- **步骤**: 仅在授权的隔离环境中确认响应是否包含已知字段;不得保存或传播原始响应。 +- **安全约束**: 严禁将原始响应、令牌、密码、红外码值或未脱敏网络标识打印至日志或写入测试记录。 --- ## 4. 协议与硬件事实确认记录 / Observed Facts - **SDK 阻塞模式**: 同步阻塞 HTTP POST 调用,由 `AbsdkBridge` 调度至 `Dispatchers.IO` 并通过进程级 `Mutex` 串行执行。 -- **数据隔离**: `FAKE` 模式自动使用 `profileId:infrared-fake` 隔离数据,`REAL_ABSDK` 模式使用主会话数据。 -- **安全门禁**: 真实 ABSDK 依赖明文传输,Release 构建中完全禁用硬件接口并由 `InfraredReleaseModule` 安全阻断。 +- **数据隔离**: 本地体验使用独立的模拟 profile;ABox 模式使用当前已认证 profile,二者不共享设备、遥控器或会话数据。 +- **传输事实**: 真实 ABSDK 使用厂商 JAR 中的明文 HTTP 传输。Debug 与 Release 暴露相同的提供方和硬件路由;项目通过显式传输标记和 manifest 的明文配置接受这一局域网集成风险。该配置不提供机密性或完整性。 diff --git a/docs/testing/release-lan-hardware-validation.md b/docs/testing/release-lan-hardware-validation.md new file mode 100644 index 0000000..553cb2a --- /dev/null +++ b/docs/testing/release-lan-hardware-validation.md @@ -0,0 +1,38 @@ +# 发布版局域网硬件验证协议 + +## 目的与边界 + +本协议只用于获授权的隔离局域网。使用签名的 Release APK、已确认的测试账号和明确授权的实体设备;不得把测试扩展到未授权网络或设备。 + +协议验证真实产品路径,不把本地体验或测试 Fake 的结果当作 ABox 硬件证据。每条记录使用脱敏 profile、设备和操作标识。 + +## 执行前检查 + +- 确认 APK 签名、版本和提交号,记录到测试报告。 +- 确认测试网络与设备均获授权,并记录网络的脱敏名称。 +- 先使用已知无效凭据验证登录失败,再使用授权凭据登录。 +- 检查当前 profile、会话状态和已配置设备目录;只验证用户明确配置的设备。 +- 不在报告、截图、日志或缺陷单中保存密码、令牌、原始厂商响应、红外码值、完整网关地址或完整 IEEE 地址。 + +## 验证流程 + +1. 登录失败路径:确认无效凭据停留在登录页,并显示本地化错误。 +2. 授权登录路径:确认授权凭据登录成功,进入认证后的设备中心。 +3. 设备目录:配置一个已授权的真实设备名,确认只显示当前 profile 的设备,并可进行只读列表检查。 +4. 插座操作:对一个已配置插座执行一次开或关命令,随后读取状态确认;记录结果分类,不记录原始响应。 +5. 传感器读取:读取已配置的温湿度设备,验证有数据、部分数据和无数据状态的显示边界。 +6. 红外学习:对已授权红外控制器学习一个测试按键,确认状态从学习中到完成或明确失败。 +7. 红外取消:学习过程中离开页面或取消,确认不会留下可继续发送的半成品记录。 +8. 红外发送:发送已完成的测试按键,确认授权电器产生预期响应。 +9. 情景执行:执行一个只包含已配置设备的情景,观察实际步骤结果和终态。 +10. 活动记录:确认上述真实终态写入当前 profile 的活动时间线,失败、等待确认和取消保持不同分类。 +11. 会话失效:使远端会话失效后重试一次设备操作,确认本地会话清理并返回登录流程。 +12. Local 隔离复核:切换到本地体验后,确认 ABox 设备、遥控器、活动和会话数据不出现;该步骤不作为真实硬件通过证据。 + +## 结果记录 + +每个步骤只记录:日期、Release 版本或提交号、脱敏 profile、脱敏设备名、操作类别、成功/失败/取消/不可用分类、粗粒度耗时和物理响应摘要。错误只记录已脱敏的领域错误分类;不要复制堆栈或原始 SDK 文本。 + +## 风险说明 + +真实 ABSDK 使用厂商 JAR 中的明文 HTTP 传输。Release 与 Debug 使用相同的提供方和硬件路由;明文传输不提供机密性或完整性,因此只能在隔离且获授权的局域网执行本协议。 diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/Activity.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/Activity.kt new file mode 100644 index 0000000..0a5bade --- /dev/null +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/Activity.kt @@ -0,0 +1,68 @@ +package com.flagship.abox.manager.domain + +import kotlinx.coroutines.flow.Flow + +@JvmInline +value class ActivityEventId(val value: String) + +enum class ActivityEventKind { + SESSION_SIGNED_IN, + SESSION_SIGNED_OUT, + SOCKET_COMMAND, + SOCKET_CONFIRMATION, + SENSOR_REFRESH, + INFRARED_SEND, + INFRARED_LEARN, + SCENE_EXECUTION, + SCENE_ACTION, +} + +enum class ActivitySource { USER, AUTOMATION, SYSTEM } + +enum class ActivityOutcome { + SUCCEEDED, + FAILED, + CANCELLED, + AWAITING_CONFIRMATION, +} + +enum class ActivityTargetType { PROFILE, DEVICE, SCENE, SCENE_ACTION, REMOTE, INFRARED_KEY } + +data class ActivityTarget( + val type: ActivityTargetType, + val stableId: String, + val displayName: String, +) + +data class ActivityEvent( + val id: ActivityEventId, + val profileId: ProviderProfileId, + val occurredAtEpochMillis: Long, + val kind: ActivityEventKind, + val source: ActivitySource, + val outcome: ActivityOutcome, + val target: ActivityTarget, + val errorCode: String? = null, +) + +data class ActivityEventCursor( + val occurredAtEpochMillis: Long, + val id: ActivityEventId, +) : Comparable { + override fun compareTo(other: ActivityEventCursor): Int { + val timeComparison = occurredAtEpochMillis.compareTo(other.occurredAtEpochMillis) + return if (timeComparison != 0) timeComparison else id.value.compareTo(other.id.value) + } +} + +interface ActivityRepository { + fun observeLatest(profileId: ProviderProfileId, limit: Int): Flow>> + + suspend fun loadBefore( + profileId: ProviderProfileId, + before: ActivityEventCursor, + limit: Int, + ): DomainResult> + + suspend fun append(event: ActivityEvent): DomainResult +} diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/ActivityEventWriter.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/ActivityEventWriter.kt new file mode 100644 index 0000000..6cf9c04 --- /dev/null +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/ActivityEventWriter.kt @@ -0,0 +1,54 @@ +package com.flagship.abox.manager.domain + +import java.util.UUID +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +class ActivityEventWriter( + private val repository: ActivityRepository, + private val scope: CoroutineScope, +) { + fun recordDevice( + profileId: ProviderProfileId, + deviceName: DeviceName, + 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 { + repository.append( + ActivityEvent( + id = ActivityEventId(UUID.randomUUID().toString()), + profileId = profileId, + occurredAtEpochMillis = System.currentTimeMillis(), + kind = kind, + source = source, + outcome = outcome, + target = target, + errorCode = errorCode, + ), + ) + } + } + } +} diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/Automation.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/Automation.kt new file mode 100644 index 0000000..941bd73 --- /dev/null +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/Automation.kt @@ -0,0 +1,151 @@ +package com.flagship.abox.manager.domain + +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime +import java.time.format.DateTimeFormatter + +interface AutomationClock { + fun nowEpochMillis(): Long +} + +data class AutomationSnapshot( + val observedAtEpochMillis: Long, + val readings: Map> = emptyMap(), + val socketStatuses: Map> = emptyMap(), +) + +sealed interface TriggerEvaluation { + data class Matched(val occurrenceKey: String) : TriggerEvaluation + data object NotMatched : TriggerEvaluation + data class Unavailable(val error: DomainError) : TriggerEvaluation +} + +sealed interface ConditionsEvaluation { + data object Satisfied : ConditionsEvaluation + data class NotSatisfied(val condition: SceneCondition) : ConditionsEvaluation + data class Unavailable(val error: DomainError) : ConditionsEvaluation +} + +class SceneTriggerEvaluator { + fun evaluate(scene: Scene, snapshot: AutomationSnapshot, now: LocalDateTime): TriggerEvaluation { + if (scene.triggers.isEmpty()) return TriggerEvaluation.NotMatched + val matched = scene.triggers.firstNotNullOfOrNull { trigger -> evaluateOne(trigger, snapshot, now) } + return when (matched) { + is TriggerEvaluation.Matched -> matched + is TriggerEvaluation.Unavailable -> matched + is TriggerEvaluation.NotMatched -> matched + null -> TriggerEvaluation.NotMatched + } + } + + private fun evaluateOne(trigger: SceneTrigger, snapshot: AutomationSnapshot, now: LocalDateTime): TriggerEvaluation? = when (trigger) { + is SceneTrigger.Time -> { + val time = parseTime(trigger.timeOfDay) ?: return TriggerEvaluation.Unavailable(DomainError.InvalidRequest(detail = "Invalid trigger time")) + val dayMatches = trigger.daysOfWeek.isEmpty() || now.dayOfWeek.value in trigger.daysOfWeek + if (dayMatches && now.toLocalTime().hour == time.hour && now.toLocalTime().minute == time.minute) { + TriggerEvaluation.Matched("${now.toLocalDate()}:${trigger.timeOfDay}:${trigger.id.value}") + } else null + } + is SceneTrigger.Temperature -> threshold(snapshot.readings[trigger.deviceName], trigger.operator, trigger.thresholdCelsius) { + it.temperature + } + is SceneTrigger.Humidity -> threshold(snapshot.readings[trigger.deviceName], trigger.operator, trigger.thresholdPercent) { + it.humidity + } + is SceneTrigger.DeviceState -> { + when (val result = snapshot.socketStatuses[trigger.deviceName]) { + null -> TriggerEvaluation.Unavailable(DomainError.NoData(detail = "Socket state unavailable")) + is DomainResult.Failure -> TriggerEvaluation.Unavailable(result.error) + is DomainResult.Success -> if (result.value.powerState == trigger.expectedState) { + TriggerEvaluation.Matched("${snapshot.observedAtEpochMillis}:${trigger.id.value}") + } else null + } + } + } + + private fun threshold( + result: DomainResult?, + operator: ComparisonOperator, + threshold: Double, + value: (TemperatureHumidityReading) -> String?, + ): TriggerEvaluation? { + if (!threshold.isFinite()) return TriggerEvaluation.Unavailable(DomainError.InvalidRequest(detail = "Invalid threshold")) + return when (result) { + null -> TriggerEvaluation.Unavailable(DomainError.NoData(detail = "Reading unavailable")) + is DomainResult.Failure -> TriggerEvaluation.Unavailable(result.error) + is DomainResult.Success -> { + val actual = value(result.value)?.toDoubleOrNull() + ?: return TriggerEvaluation.Unavailable(DomainError.NoData(detail = "Reading unavailable")) + if (compare(actual, operator, threshold)) TriggerEvaluation.Matched("threshold") else null + } + } + } + + private fun compare(actual: Double, operator: ComparisonOperator, threshold: Double): Boolean = when (operator) { + ComparisonOperator.GREATER_THAN -> actual > threshold + ComparisonOperator.LESS_THAN -> actual < threshold + ComparisonOperator.EQUALS -> actual == threshold + ComparisonOperator.GREATER_OR_EQUAL -> actual >= threshold + ComparisonOperator.LESS_OR_EQUAL -> actual <= threshold + } + + private fun parseTime(value: String): LocalTime? = runCatching { LocalTime.parse(value, DateTimeFormatter.ofPattern("HH:mm")) }.getOrNull() +} + +class SceneConditionEvaluator { + fun evaluate(conditions: List, snapshot: AutomationSnapshot, now: LocalDateTime): ConditionsEvaluation { + for (condition in conditions) { + when (val result = evaluateOne(condition, snapshot, now)) { + ConditionsEvaluation.Satisfied -> Unit + else -> return result + } + } + return ConditionsEvaluation.Satisfied + } + + private fun evaluateOne(condition: SceneCondition, snapshot: AutomationSnapshot, now: LocalDateTime): ConditionsEvaluation = when (condition) { + is SceneCondition.Temperature -> metricCondition(condition, snapshot.readings[condition.deviceName], condition.operator, condition.thresholdCelsius) { it.temperature } + is SceneCondition.Humidity -> metricCondition(condition, snapshot.readings[condition.deviceName], condition.operator, condition.thresholdPercent) { it.humidity } + is SceneCondition.SocketPower -> when (val result = snapshot.socketStatuses[condition.deviceName]) { + null -> ConditionsEvaluation.Unavailable(DomainError.NoData(detail = "Socket state unavailable")) + is DomainResult.Failure -> ConditionsEvaluation.Unavailable(result.error) + is DomainResult.Success -> if (result.value.powerState == condition.expectedState) ConditionsEvaluation.Satisfied else ConditionsEvaluation.NotSatisfied(condition) + } + is SceneCondition.TimeRange -> { + val start = parseTime(condition.startTime) ?: return ConditionsEvaluation.Unavailable(DomainError.InvalidRequest(detail = "Invalid range start")) + val end = parseTime(condition.endTime) ?: return ConditionsEvaluation.Unavailable(DomainError.InvalidRequest(detail = "Invalid range end")) + val current = now.toLocalTime() + val inside = if (start <= end) current in start..end else current >= start || current <= end + if (inside) ConditionsEvaluation.Satisfied else ConditionsEvaluation.NotSatisfied(condition) + } + } + + private fun metricCondition( + condition: SceneCondition, + result: DomainResult?, + operator: ComparisonOperator, + threshold: Double, + value: (TemperatureHumidityReading) -> String?, + ): ConditionsEvaluation { + if (!threshold.isFinite()) return ConditionsEvaluation.Unavailable(DomainError.InvalidRequest(detail = "Invalid threshold")) + val actual = when (result) { + null -> return ConditionsEvaluation.Unavailable(DomainError.NoData(detail = "Reading unavailable")) + is DomainResult.Failure -> return ConditionsEvaluation.Unavailable(result.error) + is DomainResult.Success -> value(result.value)?.toDoubleOrNull() + ?: return ConditionsEvaluation.Unavailable(DomainError.NoData(detail = "Reading unavailable")) + } + return if (compare(actual, operator, threshold)) ConditionsEvaluation.Satisfied else ConditionsEvaluation.NotSatisfied(condition) + } + + private fun compare(actual: Double, operator: ComparisonOperator, threshold: Double): Boolean = when (operator) { + ComparisonOperator.GREATER_THAN -> actual > threshold + ComparisonOperator.LESS_THAN -> actual < threshold + ComparisonOperator.EQUALS -> actual == threshold + ComparisonOperator.GREATER_OR_EQUAL -> actual >= threshold + ComparisonOperator.LESS_OR_EQUAL -> actual <= threshold + } + + private fun parseTime(value: String): LocalTime? = runCatching { LocalTime.parse(value, DateTimeFormatter.ofPattern("HH:mm")) }.getOrNull() +} diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/DefaultSceneExecutor.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/DefaultSceneExecutor.kt index d0111c9..fe01285 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/DefaultSceneExecutor.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/DefaultSceneExecutor.kt @@ -9,6 +9,8 @@ import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow /** * Production implementation of [SceneExecutor]. @@ -24,6 +26,50 @@ class DefaultSceneExecutor( ) : SceneExecutor { private val runningScenes = ConcurrentHashMap() + override fun execute( + scene: Scene, + executionId: SceneExecutionId, + source: SceneExecutionSource, + ): Flow = flow { + val alreadyRunning = runningScenes.putIfAbsent(scene.id, true) != null + if (alreadyRunning) { + emit(SceneExecutionUpdate.Rejected(DomainError.OperationFailed(detail = "Scene is already running"))) + return@flow + } + val startTime = System.currentTimeMillis() + try { + emit(SceneExecutionUpdate.Started(executionId, scene)) + val results = mutableListOf() + var aborted = false + for (action in scene.actions) { + if (aborted) { + val skipped = SceneActionExecutionResult(action, SceneActionOutcome.SKIPPED, null) + results += skipped + emit(SceneExecutionUpdate.StepFinished(skipped)) + continue + } + emit(SceneExecutionUpdate.StepPending(action)) + emit(SceneExecutionUpdate.StepRunning(action)) + val started = System.currentTimeMillis() + val result = executeAction(scene.profileId, action).copy( + executionDurationMillis = System.currentTimeMillis() - started, + ) + results += result + emit(SceneExecutionUpdate.StepFinished(result)) + if (result.outcome == SceneActionOutcome.FAILURE && scene.strategy == ExecutionStrategy.ABORT_ON_ERROR) { + aborted = true + } + } + emit( + SceneExecutionUpdate.Completed( + SceneExecutionResult(scene, results, System.currentTimeMillis() - startTime), + ), + ) + } finally { + runningScenes.remove(scene.id) + } + } + override suspend fun execute(scene: Scene): DomainResult { val alreadyRunning = runningScenes.putIfAbsent(scene.id, true) != null if (alreadyRunning) { diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/Gateways.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/Gateways.kt index 4108621..64a75aa 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/Gateways.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/Gateways.kt @@ -63,6 +63,15 @@ interface SocketGateway { deviceName: DeviceName, ): DomainResult + suspend fun commandPower( + profileId: ProviderProfileId, + deviceName: DeviceName, + powerState: SocketPowerState, + ): DomainResult = when (val result = setPower(profileId, deviceName, powerState)) { + is DomainResult.Success -> DomainResult.Success(Unit) + is DomainResult.Failure -> result + } + suspend fun setPower( profileId: ProviderProfileId, deviceName: DeviceName, 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/Models.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/Models.kt index b1e1cc4..b3051e6 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/Models.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/Models.kt @@ -83,8 +83,8 @@ data class SocketStatus( data class TemperatureHumidityReading( val profileId: ProviderProfileId, val deviceName: DeviceName, - val temperature: String, - val humidity: String, + val temperature: String?, + val humidity: String?, ) data class InfraredCode( diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/NotificationGateway.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/NotificationGateway.kt new file mode 100644 index 0000000..5bef2b1 --- /dev/null +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/NotificationGateway.kt @@ -0,0 +1,9 @@ +package com.flagship.abox.manager.domain + +interface NotificationGateway { + suspend fun show( + profileId: ProviderProfileId, + title: String, + message: String, + ): DomainResult +} diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/Scene.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/Scene.kt index aa97602..7a71edf 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/Scene.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/Scene.kt @@ -62,7 +62,7 @@ sealed interface SceneTrigger { data class DeviceState( override val id: SceneTriggerId, val deviceName: DeviceName, - val expectedState: String, // e.g. "ON" / "OFF" + val expectedState: SocketPowerState, val label: String = "", ) : SceneTrigger } diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/SceneExecution.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/SceneExecution.kt index f6267ed..2ed7aa9 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/SceneExecution.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/SceneExecution.kt @@ -1,5 +1,19 @@ package com.flagship.abox.manager.domain +@JvmInline +value class SceneExecutionId(val value: String) + +enum class SceneExecutionSource { USER, AUTOMATION } + +sealed interface SceneExecutionUpdate { + data class Started(val executionId: SceneExecutionId, val scene: Scene) : SceneExecutionUpdate + data class StepPending(val action: SceneAction) : SceneExecutionUpdate + data class StepRunning(val action: SceneAction) : SceneExecutionUpdate + data class StepFinished(val result: SceneActionExecutionResult) : SceneExecutionUpdate + data class Completed(val result: SceneExecutionResult) : SceneExecutionUpdate + data class Rejected(val error: DomainError) : SceneExecutionUpdate +} + enum class SceneActionOutcome { SUCCESS, FAILURE, diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/SceneExecutor.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/SceneExecutor.kt index 71432ce..73dbe2e 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/SceneExecutor.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/SceneExecutor.kt @@ -1,5 +1,9 @@ package com.flagship.abox.manager.domain +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import java.util.UUID + /** * Executes a [Scene] sequentially. Per-action failures are recorded but do not abort subsequent * actions; callers receive a [SceneExecutionResult] that summarizes every step. @@ -9,4 +13,23 @@ package com.flagship.abox.manager.domain */ interface SceneExecutor { suspend fun execute(scene: Scene): DomainResult + + fun execute( + scene: Scene, + executionId: SceneExecutionId = SceneExecutionId(UUID.randomUUID().toString()), + source: SceneExecutionSource = SceneExecutionSource.USER, + ): Flow = flow { + emit(SceneExecutionUpdate.Started(executionId, scene)) + when (val result = execute(scene)) { + is DomainResult.Failure -> emit(SceneExecutionUpdate.Rejected(result.error)) + is DomainResult.Success -> { + result.value.results.forEach { actionResult -> + emit(SceneExecutionUpdate.StepPending(actionResult.action)) + emit(SceneExecutionUpdate.StepRunning(actionResult.action)) + emit(SceneExecutionUpdate.StepFinished(actionResult)) + } + emit(SceneExecutionUpdate.Completed(result.value)) + } + } + } } diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/SessionCoordinator.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/SessionCoordinator.kt index 4923c23..7ab6494 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/SessionCoordinator.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/SessionCoordinator.kt @@ -147,6 +147,35 @@ class SessionCoordinator( } } + suspend fun endActiveSession(): DomainResult = storeOperation { + val profile = profileStore.activeProfile() ?: return@storeOperation DomainResult.Success(Unit) + when (val logoutResult = profile.gateway().logout(profile.id)) { + is DomainResult.Failure -> return@storeOperation logoutResult + is DomainResult.Success -> Unit + } + if (profile.type == ProviderType.LOCAL_EXPERIENCE) { + profileStore.setActiveProfile(null) + } else { + DomainResult.Success(Unit) + } + } + + suspend fun forgetSavedCredentials(): DomainResult = storeOperation { + val profile = profileStore.activeProfile() ?: return@storeOperation DomainResult.Success(Unit) + when (val credentialResult = credentialStore.clearCredentials(profile.id)) { + is DomainResult.Failure -> return@storeOperation credentialResult + is DomainResult.Success -> Unit + } + val preferences = profileStore.loginPreferences(profile.id) + profileStore.saveLoginPreferences(preferences.copy(autoLoginEnabled = false)) + } + + suspend fun validateActiveSession(): DomainResult = storeOperation { + val profile = profileStore.activeProfile() + ?: return@storeOperation DomainResult.Success(SessionValidity.INVALID) + profile.gateway().validateSession(profile.id) + } + suspend fun logout(): DomainResult = storeOperation { val profile = profileStore.activeProfile() ?: return@storeOperation DomainResult.Success(Unit) when (val credentialResult = credentialStore.clearCredentials(profile.id)) { 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 26cb835..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,24 +23,16 @@ class AddVirtualRemoteKey( } val buttonId = "CUSTOM_" + UUID.randomUUID().toString().take(8).uppercase() - val assignedSlot = if (remote.transmissionMode == TransmissionMode.STATIC_SLOTS) { - // Find next available slot - val bindings = (repository.getKeyBindings(remoteId) as? DomainResult.Success)?.value.orEmpty() - val usedSlots = bindings.mapNotNull { it.assignedSlot?.toIntOrNull() }.toSet() - 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, profileId = profileId, + 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 cc2f747..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 @@ -64,11 +64,12 @@ class CreateVirtualRemote( id = UUID.randomUUID().toString(), remoteId = remoteId, profileId = profileId, + hardwareDeviceName = deviceName, buttonId = template.buttonId, 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 { @@ -77,6 +78,7 @@ class CreateVirtualRemote( id = UUID.randomUUID().toString(), remoteId = remoteId, profileId = profileId, + hardwareDeviceName = deviceName, buttonId = template.buttonId, displayName = template.displayName, assignedSlot = null, @@ -84,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 fb453a3..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 @@ -26,9 +31,13 @@ class LearnVirtualRemoteKey( ?: return DomainResult.Failure(DomainError.InvalidRequest(detail = "Button not found")) val targetSlot = binding.assignedSlot ?: run { - val allBindings = (repository.getKeyBindings(remoteId) as? DomainResult.Success)?.value.orEmpty() - val usedSlots = allBindings.mapNotNull { it.assignedSlot?.toIntOrNull() }.toSet() - val nextSlot = (1..99).firstOrNull { it !in usedSlots } ?: 1 + val quota = when (val result = repository.slotQuota(profileId, remote.deviceName)) { + is DomainResult.Success -> result.value + is DomainResult.Failure -> return result + } + val nextSlot = quota.availableStaticSlots.firstOrNull() ?: return DomainResult.Failure( + DomainError.InvalidRequest(detail = "槽位配额已满,无法分配静态槽位"), + ) nextSlot.toString() } 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 112a29c..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 @@ -80,11 +80,12 @@ data class RemoteKeyBinding( val id: String, val remoteId: VirtualRemoteId, val profileId: ProviderProfileId, + val hardwareDeviceName: DeviceName = DeviceName(""), val buttonId: String, 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/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/VirtualRemoteRepository.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/VirtualRemoteRepository.kt index fb9e622..53ff586 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/VirtualRemoteRepository.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/VirtualRemoteRepository.kt @@ -4,6 +4,7 @@ import com.flagship.abox.manager.domain.DeviceName import com.flagship.abox.manager.domain.DomainResult import com.flagship.abox.manager.domain.ProviderProfileId import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first interface VirtualRemoteRepository { fun observeRemotes( @@ -25,6 +26,11 @@ interface VirtualRemoteRepository { deviceName: DeviceName, ): Flow> + suspend fun slotQuota( + profileId: ProviderProfileId, + deviceName: DeviceName, + ): DomainResult = observeSlotQuota(profileId, deviceName).first() + suspend fun getRemote( profileId: ProviderProfileId, remoteId: VirtualRemoteId, diff --git a/domain/src/test/kotlin/com/flagship/abox/manager/domain/SceneExecutionFlowTest.kt b/domain/src/test/kotlin/com/flagship/abox/manager/domain/SceneExecutionFlowTest.kt new file mode 100644 index 0000000..31cc8c2 --- /dev/null +++ b/domain/src/test/kotlin/com/flagship/abox/manager/domain/SceneExecutionFlowTest.kt @@ -0,0 +1,35 @@ +package com.flagship.abox.manager.domain + +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertTrue +import org.junit.Test + +class SceneExecutionFlowTest { + @Test + fun observableExecutionPublishesLifecycleEventsWithoutChangingLegacyResult() = runTest { + val scene = Scene( + id = SceneId("scene"), + profileId = ProviderProfileId("profile"), + name = "测试情景", + ) + val executor = DefaultSceneExecutor( + socketGateway = object : SocketGateway { + override suspend fun getStatus(profileId: ProviderProfileId, deviceName: DeviceName) = + DomainResult.Success(SocketStatus(profileId, deviceName, SocketPowerState.OFF)) + override suspend fun setPower(profileId: ProviderProfileId, deviceName: DeviceName, powerState: SocketPowerState) = + DomainResult.Success(SocketStatus(profileId, deviceName, powerState)) + }, + infraredGateway = object : InfraredGateway { + override suspend fun sendKey(profileId: ProviderProfileId, deviceName: DeviceName, key: String) = DomainResult.Success(Unit) + override suspend fun learnKey(profileId: ProviderProfileId, deviceName: DeviceName, key: String) = DomainResult.Success(Unit) + override suspend fun downloadCodes(profileId: ProviderProfileId, codes: List) = DomainResult.Success(emptyList()) + }, + ) + + val updates = executor.execute(scene, SceneExecutionId("run-1")).toList() + + assertTrue(updates.first() is SceneExecutionUpdate.Started) + assertTrue(updates.last() is SceneExecutionUpdate.Completed) + } +} diff --git a/domain/src/test/kotlin/com/flagship/abox/manager/domain/activity/ActivityContractTest.kt b/domain/src/test/kotlin/com/flagship/abox/manager/domain/activity/ActivityContractTest.kt new file mode 100644 index 0000000..cf3a713 --- /dev/null +++ b/domain/src/test/kotlin/com/flagship/abox/manager/domain/activity/ActivityContractTest.kt @@ -0,0 +1,78 @@ +package com.flagship.abox.manager.domain.activity + +import com.flagship.abox.manager.domain.ActivityEvent +import com.flagship.abox.manager.domain.ActivityEventCursor +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.DomainResult +import com.flagship.abox.manager.domain.ProviderProfileId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ActivityContractTest { + @Test + fun eventDoesNotContainCredentialsOrPayloadFields() { + val event = ActivityEvent( + id = ActivityEventId("event-1"), + profileId = ProviderProfileId("profile-1"), + occurredAtEpochMillis = 10L, + kind = ActivityEventKind.SOCKET_COMMAND, + source = ActivitySource.USER, + outcome = ActivityOutcome.SUCCEEDED, + target = ActivityTarget(ActivityTargetType.DEVICE, "device-1", "客厅插座"), + errorCode = "20504", + ) + + val fields = ActivityEvent::class.java.declaredFields.map { it.name } + assertFalse(fields.any { it.contains("password", ignoreCase = true) }) + assertFalse(fields.any { it.contains("token", ignoreCase = true) }) + assertFalse(fields.any { it.contains("payload", ignoreCase = true) }) + assertTrue(event.errorCode == "20504") + } + + @Test + fun cursorOrdersEqualTimestampsById() { + val first = ActivityEventCursor(10L, ActivityEventId("a")) + val second = ActivityEventCursor(10L, ActivityEventId("b")) + + assertTrue(first < second) + } + + @Test + fun recordingFailureDoesNotAlterOriginalDomainResult() = runTest { + val repository = object : ActivityRepository { + override fun observeLatest(profileId: ProviderProfileId, limit: Int): Flow>> = + MutableStateFlow(DomainResult.Success(emptyList())) + override suspend fun loadBefore(profileId: ProviderProfileId, before: ActivityEventCursor, limit: Int) = + DomainResult.Success(emptyList()) + override suspend fun append(event: ActivityEvent) = DomainResult.Failure(com.flagship.abox.manager.domain.DomainError.Unknown()) + } + val original: DomainResult = DomainResult.Success("command-complete") + + val recorded = repository.append(testEvent()) + + assertTrue(recorded is DomainResult.Failure) + assertTrue(original is DomainResult.Success) + assertTrue((original as DomainResult.Success).value == "command-complete") + } + + private fun testEvent() = ActivityEvent( + id = ActivityEventId("event-1"), + profileId = ProviderProfileId("profile-1"), + occurredAtEpochMillis = 10L, + kind = ActivityEventKind.SESSION_SIGNED_IN, + source = ActivitySource.SYSTEM, + outcome = ActivityOutcome.SUCCEEDED, + target = ActivityTarget(ActivityTargetType.PROFILE, "profile-1", "账户"), + ) +} diff --git a/domain/src/test/kotlin/com/flagship/abox/manager/domain/automation/SceneEvaluatorTest.kt b/domain/src/test/kotlin/com/flagship/abox/manager/domain/automation/SceneEvaluatorTest.kt new file mode 100644 index 0000000..8980977 --- /dev/null +++ b/domain/src/test/kotlin/com/flagship/abox/manager/domain/automation/SceneEvaluatorTest.kt @@ -0,0 +1,80 @@ +package com.flagship.abox.manager.domain.automation + +import com.flagship.abox.manager.domain.AutomationSnapshot +import com.flagship.abox.manager.domain.ComparisonOperator +import com.flagship.abox.manager.domain.ConditionsEvaluation +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.Scene +import com.flagship.abox.manager.domain.SceneCondition +import com.flagship.abox.manager.domain.SceneConditionEvaluator +import com.flagship.abox.manager.domain.SceneId +import com.flagship.abox.manager.domain.SceneTrigger +import com.flagship.abox.manager.domain.SceneTriggerEvaluator +import com.flagship.abox.manager.domain.SceneTriggerId +import com.flagship.abox.manager.domain.TemperatureHumidityReading +import com.flagship.abox.manager.domain.TriggerEvaluation +import java.time.LocalDateTime +import org.junit.Assert.assertTrue +import org.junit.Test + +class SceneEvaluatorTest { + private val profile = ProviderProfileId("profile") + private val sensor = DeviceName("sensor") + private val clockTime = LocalDateTime.of(2026, 8, 26, 7, 30) + private val triggerEvaluator = SceneTriggerEvaluator() + private val conditionEvaluator = SceneConditionEvaluator() + + @Test + fun timeMatchesConfiguredWeekday() { + val trigger = SceneTrigger.Time(SceneTriggerId("time"), "07:30", setOf(3)) + val result = triggerEvaluator.evaluate(scene(trigger), AutomationSnapshot(1L), clockTime) + assertTrue(result is TriggerEvaluation.Matched) + } + + @Test + fun timeRangeCrossesMidnight() { + val condition = SceneCondition.TimeRange(com.flagship.abox.manager.domain.SceneConditionId("range"), "22:00", "07:00") + val result = conditionEvaluator.evaluate(listOf(condition), AutomationSnapshot(1L), LocalDateTime.of(2026, 8, 26, 6, 45)) + assertTrue(result is ConditionsEvaluation.Satisfied) + } + + @Test + fun temperatureComparisonAndMissingReadingFailClosed() { + val trigger = SceneTrigger.Temperature(SceneTriggerId("temp"), sensor, ComparisonOperator.GREATER_THAN, 25.0) + val missing = triggerEvaluator.evaluate(scene(trigger), AutomationSnapshot(1L), clockTime) + val reading = AutomationSnapshot( + 1L, + readings = mapOf(sensor to DomainResult.Success(TemperatureHumidityReading(profile, sensor, "26", null))), + ) + val matched = triggerEvaluator.evaluate(scene(trigger), reading, clockTime) + assertTrue(missing is TriggerEvaluation.Unavailable) + assertTrue(matched is TriggerEvaluation.Matched) + } + + @Test + fun allConditionsMustPass() { + val reading = AutomationSnapshot( + 1L, + readings = mapOf(sensor to DomainResult.Success(TemperatureHumidityReading(profile, sensor, "26", "40"))), + ) + val conditions = listOf( + SceneCondition.Temperature(com.flagship.abox.manager.domain.SceneConditionId("temp"), sensor, ComparisonOperator.GREATER_THAN, 25.0), + SceneCondition.Humidity(com.flagship.abox.manager.domain.SceneConditionId("humidity"), sensor, ComparisonOperator.LESS_THAN, 50.0), + ) + assertTrue(conditionEvaluator.evaluate(conditions, reading, clockTime) is ConditionsEvaluation.Satisfied) + } + + @Test + fun invalidNumberReturnsUnavailable() { + val trigger = SceneTrigger.Temperature(SceneTriggerId("temp"), sensor, ComparisonOperator.GREATER_THAN, Double.NaN) + val reading = AutomationSnapshot( + 1L, + readings = mapOf(sensor to DomainResult.Success(TemperatureHumidityReading(profile, sensor, "26", "40"))), + ) + assertTrue(triggerEvaluator.evaluate(scene(trigger), reading, clockTime) is TriggerEvaluation.Unavailable) + } + + private fun scene(trigger: SceneTrigger) = Scene(SceneId("scene"), profile, "测试", triggers = listOf(trigger)) +} diff --git a/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/VirtualRemoteUseCaseTest.kt b/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/VirtualRemoteUseCaseTest.kt index d507536..a8bed9c 100644 --- a/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/VirtualRemoteUseCaseTest.kt +++ b/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/VirtualRemoteUseCaseTest.kt @@ -116,4 +116,46 @@ class VirtualRemoteUseCaseTest { val fetched = (repository.getRemote(profileId, remoteId) as DomainResult.Success).value assertEquals(null, fetched) } + + @Test + fun staticKeyAllocationUsesSlotsAcrossRemotesOnTheSameHardware() = runTest { + val firstRemote = VirtualRemote( + id = VirtualRemoteId("remote-one"), + profileId = profileId, + deviceName = deviceName, + displayName = "First", + type = RemoteDeviceType.TV, + transmissionMode = TransmissionMode.STATIC_SLOTS, + createdAtEpochMillis = 1L, + updatedAtEpochMillis = 1L, + ) + repository.createRemote( + firstRemote, + listOf( + RemoteKeyBinding( + id = "binding-one", + remoteId = firstRemote.id, + profileId = profileId, + buttonId = "POWER", + displayName = "Power", + assignedSlot = "1", + ), + ), + ) + val secondRemote = VirtualRemote( + id = VirtualRemoteId("remote-two"), + profileId = profileId, + deviceName = deviceName, + displayName = "Second", + type = RemoteDeviceType.TV, + transmissionMode = TransmissionMode.STATIC_SLOTS, + createdAtEpochMillis = 2L, + updatedAtEpochMillis = 2L, + ) + repository.createRemote(secondRemote, emptyList()) + + val result = AddVirtualRemoteKey(repository)(profileId, secondRemote.id, "Volume") + + assertEquals("2", (result as DomainResult.Success).value.assignedSlot) + } } diff --git a/gradle.properties b/gradle.properties index dd2a6d4..db68989 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,6 +1,10 @@ -org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 -Dsun.jnu.encoding=UTF-8 +org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8 -Dsun.jnu.encoding=UTF-8 +android.experimental.enableScreenshotTest=true org.gradle.configuration-cache=false android.useAndroidX=true android.nonTransitiveRClass=true kotlin.code.style=official android.overridePathCheck=true +android.builtInKotlin=false +android.newDsl=false +kotlin.jvm.target.validation.mode=warning diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7aa0738..fda3fcc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,15 +1,21 @@ [versions] -agp = "8.7.3" -kotlin = "1.9.24" -composeBom = "2024.06.00" -composeCompiler = "1.5.14" +agp = "9.3.2" +kotlin = "2.4.0" +composeBom = "2026.08.00" coroutines = "1.8.1" -hilt = "2.51.1" -navigationCompose = "2.7.7" -activityCompose = "1.9.0" -lifecycle = "2.8.2" -room = "2.6.1" -datastore = "1.1.1" +hilt = "2.60.1" +hiltNavigationCompose = "1.4.0" +navigationCompose = "2.9.8" +activityCompose = "1.13.0" +lifecycle = "2.11.0" +room = "2.8.4" +datastore = "1.2.1" +work = "2.11.2" +adaptive = "1.3.0" +adaptiveNavigationSuite = "1.4.0" +window = "1.5.1" +composeScreenshot = "0.0.1-alpha16" +miuix = "0.9.3" junit = "4.13.2" androidxJunit = "1.2.1" espresso = "3.6.1" @@ -20,7 +26,12 @@ androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-ru androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycle" } androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" } -androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version = "1.2.0" } +androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hiltNavigationCompose" } +androidx-hilt-work = { module = "androidx.hilt:hilt-work", version.ref = "hiltNavigationCompose" } +androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "work" } +androidx-compose-material3-adaptive = { module = "androidx.compose.material3.adaptive:adaptive", version.ref = "adaptive" } +androidx-compose-material3-adaptive-navigation-suite = { module = "androidx.compose.material3:material3-adaptive-navigation-suite", version.ref = "adaptiveNavigationSuite" } +androidx-window = { module = "androidx.window:window", version.ref = "window" } compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" } compose-ui = { module = "androidx.compose.ui:ui" } compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } @@ -29,6 +40,8 @@ compose-material3 = { module = "androidx.compose.material3:material3" } compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" } compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" } compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" } +compose-screenshot-test = { module = "com.android.tools.screenshot:screenshot-validation-api", version.ref = "composeScreenshot" } +miuix-ui-android = { module = "top.yukonga.miuix.kmp:miuix-ui-android", version.ref = "miuix" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } @@ -47,6 +60,8 @@ espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = android-application = { id = "com.android.application", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlin-kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" } hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } +compose-screenshot = { id = "com.android.compose.screenshot", version.ref = "composeScreenshot" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 496c514..a351597 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip networkTimeout=10000 -validateDistributionUrl=false +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists 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, ), ) diff --git a/runtime/simulation/src/main/kotlin/com/flagship/abox/manager/simulation/SimulationDeviceGateways.kt b/runtime/simulation/src/main/kotlin/com/flagship/abox/manager/simulation/SimulationDeviceGateways.kt index c45d265..9daedf5 100644 --- a/runtime/simulation/src/main/kotlin/com/flagship/abox/manager/simulation/SimulationDeviceGateways.kt +++ b/runtime/simulation/src/main/kotlin/com/flagship/abox/manager/simulation/SimulationDeviceGateways.kt @@ -33,6 +33,15 @@ class SimulationSocketGateway : SocketGateway { states[key] = powerState return DomainResult.Success(SocketStatus(profileId, deviceName, powerState)) } + + override suspend fun commandPower( + profileId: ProviderProfileId, + deviceName: DeviceName, + powerState: SocketPowerState, + ): DomainResult { + states[DeviceKey(profileId, deviceName)] = powerState + return DomainResult.Success(Unit) + } } class SimulationTemperatureHumidityGateway : TemperatureHumidityGateway { diff --git a/settings.gradle.kts b/settings.gradle.kts index 984f670..a5f925f 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -31,6 +31,9 @@ include(":runtime:simulation") include(":absdk-bridge") include(":testing:fakes") include(":android:core:designsystem") +include(":android:core:preferences") +include(":android:core:devicecatalog") include(":android:core:session") include(":android:feature:infrared") +include(":android:feature:automation") include(":android:app") diff --git a/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeDeviceGateways.kt b/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeDeviceGateways.kt index 60c3c21..27b6a55 100644 --- a/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeDeviceGateways.kt +++ b/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeDeviceGateways.kt @@ -18,6 +18,7 @@ import java.util.concurrent.CopyOnWriteArrayList class FakeSocketGateway : SocketGateway { private val states = ConcurrentHashMap() private val errors = ConcurrentHashMap() + private val commandErrors = ConcurrentHashMap() override suspend fun getStatus( profileId: ProviderProfileId, @@ -35,11 +36,27 @@ class FakeSocketGateway : SocketGateway { return DomainResult.Success(SocketStatus(profileId, deviceName, powerState)) } + override suspend fun commandPower( + profileId: ProviderProfileId, + deviceName: DeviceName, + powerState: SocketPowerState, + ): DomainResult { + val key = DeviceKey(profileId, deviceName) + commandErrors[key]?.let { return DomainResult.Failure(it) } + states[key] = powerState + return DomainResult.Success(Unit) + } + fun setError(profileId: ProviderProfileId, deviceName: DeviceName, error: DomainError?) { val key = DeviceKey(profileId, deviceName) if (error == null) errors.remove(key) else errors[key] = error } + fun setCommandError(profileId: ProviderProfileId, deviceName: DeviceName, error: DomainError?) { + val key = DeviceKey(profileId, deviceName) + if (error == null) commandErrors.remove(key) else commandErrors[key] = error + } + private fun resultFor( profileId: ProviderProfileId, deviceName: DeviceName, diff --git a/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeVirtualRemoteRepository.kt b/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeVirtualRemoteRepository.kt index ac183cc..a0b2acf 100644 --- a/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeVirtualRemoteRepository.kt +++ b/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeVirtualRemoteRepository.kt @@ -12,6 +12,7 @@ import com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update import java.util.concurrent.ConcurrentHashMap class FakeVirtualRemoteRepository : VirtualRemoteRepository { @@ -80,13 +81,13 @@ class FakeVirtualRemoteRepository : VirtualRemoteRepository { ): DomainResult { remotes[remote.id] = remote bindings[remote.id] = initialBindings.toMutableList() - changeTrigger.value = System.currentTimeMillis() + changeTrigger.update { it + 1L } return DomainResult.Success(remote.id) } override suspend fun updateRemote(remote: VirtualRemote): DomainResult { remotes[remote.id] = remote - changeTrigger.value = System.currentTimeMillis() + changeTrigger.update { it + 1L } return DomainResult.Success(Unit) } @@ -96,7 +97,7 @@ class FakeVirtualRemoteRepository : VirtualRemoteRepository { ): DomainResult { remotes.remove(remoteId) bindings.remove(remoteId) - changeTrigger.value = System.currentTimeMillis() + changeTrigger.update { it + 1L } return DomainResult.Success(Unit) } @@ -109,7 +110,7 @@ class FakeVirtualRemoteRepository : VirtualRemoteRepository { list.add(binding) } bindings[binding.remoteId] = list - changeTrigger.value = System.currentTimeMillis() + changeTrigger.update { it + 1L } return DomainResult.Success(Unit) } @@ -120,7 +121,7 @@ class FakeVirtualRemoteRepository : VirtualRemoteRepository { val list = bindings[remoteId] ?: return DomainResult.Success(Unit) list.removeAll { it.buttonId == buttonId } bindings[remoteId] = list - changeTrigger.value = System.currentTimeMillis() + changeTrigger.update { it + 1L } return DomainResult.Success(Unit) } @@ -131,7 +132,7 @@ class FakeVirtualRemoteRepository : VirtualRemoteRepository { ): DomainResult { val existing = remotes[remoteId] ?: return DomainResult.Success(Unit) remotes[remoteId] = existing.copy(acState = state) - changeTrigger.value = System.currentTimeMillis() + changeTrigger.update { it + 1L } return DomainResult.Success(Unit) } } diff --git a/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/SessionCoordinatorTest.kt b/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/SessionCoordinatorTest.kt index 43f291a..953124b 100644 --- a/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/SessionCoordinatorTest.kt +++ b/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/SessionCoordinatorTest.kt @@ -12,6 +12,7 @@ import com.flagship.abox.manager.domain.ProviderType import com.flagship.abox.manager.domain.SessionCoordinator import com.flagship.abox.manager.domain.SessionGateway import com.flagship.abox.manager.domain.Session +import com.flagship.abox.manager.domain.SessionValidity import com.flagship.abox.manager.domain.Username import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest @@ -133,6 +134,101 @@ class SessionCoordinatorTest { ) } + @Test + fun endActiveSessionKeepsAboxCredentials() = runTest { + val fixture = Fixture() + fixture.coordinator.selectProvider(remoteProfile) + fixture.coordinator.login( + remoteProfile.id, + credentials, + rememberUsername = true, + autoLogin = true, + ) + + val result = fixture.coordinator.endActiveSession() + + assertTrue(result is DomainResult.Success) + assertEquals(remoteProfile, fixture.profiles.activeProfile()) + assertNull(fixture.remote.observeSession(remoteProfile.id).first()) + assertEquals( + credentials, + (fixture.credentials.loadCredentials(remoteProfile.id) as DomainResult.Success).value, + ) + assertTrue(fixture.profiles.loginPreferences(remoteProfile.id).autoLoginEnabled) + } + + @Test + fun forgetSavedCredentialsDisablesAutoLoginAndKeepsSessionStateExplicit() = runTest { + val fixture = Fixture() + fixture.coordinator.selectProvider(remoteProfile) + fixture.coordinator.login( + remoteProfile.id, + credentials, + rememberUsername = true, + autoLogin = true, + ) + + val result = fixture.coordinator.forgetSavedCredentials() + + assertTrue(result is DomainResult.Success) + assertEquals(remoteProfile, fixture.profiles.activeProfile()) + assertEquals( + remoteProfile.id, + fixture.remote.observeSession(remoteProfile.id).first()?.profileId, + ) + assertNull( + (fixture.credentials.loadCredentials(remoteProfile.id) as DomainResult.Success).value, + ) + val preferences = fixture.profiles.loginPreferences(remoteProfile.id) + assertEquals(credentials.username, preferences.rememberedUsername) + assertTrue(!preferences.autoLoginEnabled) + } + + @Test + fun switchProviderClearsActiveProfileAfterGatewayLogout() = runTest { + val profiles = FakeProviderProfileStore() + val remote = RecordingSessionGateway(profiles) + val coordinator = SessionCoordinator( + profileStore = profiles, + credentialStore = FakeCredentialStore(), + remoteGateway = remote, + localGateway = FakeLocalSessionGateway(), + ) + coordinator.selectProvider(remoteProfile) + remote.login(remoteProfile.id, credentials) + + val result = coordinator.switchProvider() + + assertTrue(result is DomainResult.Success) + assertEquals(remoteProfile.id, remote.activeProfileAtLogout) + assertNull(profiles.activeProfile()) + } + + @Test + fun validateActiveSessionDoesNotRelogin() = runTest { + val remote = CountingSessionGateway() + val credentialsStore = CountingCredentialStore() + val profiles = FakeProviderProfileStore() + val coordinator = SessionCoordinator( + profileStore = profiles, + credentialStore = credentialsStore, + remoteGateway = remote, + localGateway = FakeLocalSessionGateway(), + ) + profiles.saveProfile(remoteProfile) + profiles.setActiveProfile(remoteProfile.id) + remote.setValidationResult( + remoteProfile.id, + DomainResult.Success(SessionValidity.INVALID), + ) + + val result = coordinator.validateActiveSession() + + assertEquals(DomainResult.Success(SessionValidity.INVALID), result) + assertEquals(0, remote.loginCalls) + assertEquals(0, credentialsStore.loadCalls) + } + @Test fun authenticationFailureDuringRecoveryClearsSavedCredential() = runTest { val fixture = Fixture() @@ -189,6 +285,18 @@ class SessionCoordinatorTest { assertTrue(!fixture.profiles.loginPreferences(remoteProfile.id).autoLoginEnabled) } + @Test + fun endActiveSessionClearsLocalActiveProfile() = runTest { + val fixture = Fixture() + fixture.coordinator.selectProvider(localProfile) + + val result = fixture.coordinator.endActiveSession() + + assertTrue(result is DomainResult.Success) + assertNull(fixture.profiles.activeProfile()) + assertNull(fixture.local.observeSession(localProfile.id).first()) + } + private class Fixture { val profiles = FakeProviderProfileStore() val credentials = FakeCredentialStore() @@ -197,6 +305,19 @@ class SessionCoordinatorTest { val coordinator = SessionCoordinator(profiles, credentials, remote, local) } + private class RecordingSessionGateway( + private val profileStore: FakeProviderProfileStore, + private val delegate: FakeSessionGateway = FakeSessionGateway(), + ) : SessionGateway by delegate { + var activeProfileAtLogout: ProviderProfileId? = null + private set + + override suspend fun logout(profileId: ProviderProfileId): DomainResult { + activeProfileAtLogout = profileStore.activeProfile()?.id + return delegate.logout(profileId) + } + } + private class CountingSessionGateway( private val delegate: FakeSessionGateway = FakeSessionGateway(), ) : SessionGateway by delegate { @@ -214,6 +335,13 @@ class SessionCoordinatorTest { fun failLogin(profileId: ProviderProfileId, error: DomainError) { delegate.failLogin(profileId, error) } + + fun setValidationResult( + profileId: ProviderProfileId, + result: DomainResult, + ) { + delegate.setValidationResult(profileId, result) + } } private class CountingCredentialStore(