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/shell/MainAppShellTest.kt b/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/shell/MainAppShellTest.kt index 5962902..e00078f 100644 --- 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 @@ -5,7 +5,7 @@ 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.onAllNodesWithText +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 @@ -38,8 +38,8 @@ class MainAppShellTest { } } - composeRule.onAllNodesWithText("Devices")[0].assertIsDisplayed() - composeRule.onAllNodesWithText("Scenes")[0].performClick() + composeRule.onNodeWithTag("main_tab_devices").assertIsDisplayed() + composeRule.onNodeWithTag("main_tab_scenes").performClick() assertEquals(MainTab.SCENES, selectedTab) } @@ -83,8 +83,8 @@ class MainAppShellTest { } } - composeRule.onNodeWithText("ABox").performClick() - composeRule.onNodeWithText("U").performClick() + composeRule.onNodeWithTag("main_provider_pill").performClick() + composeRule.onNodeWithTag("main_avatar").performClick() assertEquals(1, providerClicks) assertEquals(1, avatarClicks) diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/AuthenticatedNavigation.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/AuthenticatedNavigation.kt index b39264f..3ee8944 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/AuthenticatedNavigation.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/AuthenticatedNavigation.kt @@ -15,12 +15,15 @@ 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 @@ -55,12 +58,14 @@ 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.values().associateWith { listOf(it.route) }, + private val backStacks: Map> = MainTab.entries.associateWith { listOf(it.route) }, val selectedDevice: DeviceSelection? = null, ) { fun selectTab(tab: MainTab): AuthenticatedGraphState { @@ -99,65 +104,90 @@ data class AuthenticatedGraphState( } } -@Composable -fun AuthenticatedNavigation( - username: Username, - profile: ProviderProfile, - onLogout: () -> Unit, - onProviderClick: () -> Unit, - onAvatarClick: () -> Unit, - windowSizeClass: ABoxWindowSizeClass? = null, -) { - val resolvedWindowSizeClass = windowSizeClass ?: widthClass(LocalConfiguration.current.screenWidthDp) - key(profile.id.value) { - AuthenticatedNavigationContent( - username = username, - profile = profile, - onLogout = onLogout, - onProviderClick = onProviderClick, - onAvatarClick = onAvatarClick, - windowSizeClass = resolvedWindowSizeClass, - ) - } -} +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 -private fun AuthenticatedNavigationContent( - username: Username, - profile: ProviderProfile, - onLogout: () -> Unit, - onProviderClick: () -> Unit, - onAvatarClick: () -> Unit, +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 navController = rememberNavController() - var selectedDevice by remember { mutableStateOf(null) } val backStackEntry by navController.currentBackStackEntryAsState() - val currentRoute = backStackEntry?.destination?.route + 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 selectedDetailPresentation = selectedDevice?.let { + val selectedDevice = when { + routeSelection != null -> routeSelection + currentRoute == MainTab.DEVICES.route -> savedSelection?.takeIf { it.profileId == profileId } + else -> null + } + val selectedPresentation = selectedDevice?.let { detailPresentation(windowSizeClass, it) } - val isExpandedDevicePane = selectedDevice != null && - selectedDetailPresentation == DetailPresentation.PANE && - currentTab == MainTab.DEVICES - BackHandler(enabled = navController.previousBackStackEntry != null || selectedDevice != null) { - if (selectedDevice != null && selectedDetailPresentation == DetailPresentation.PANE) { - selectedDevice = null - } else { - selectedDevice = null - navController.popBackStack() + LaunchedEffect(profileId, currentRoute, selectedDevice, windowSizeClass) { + if (savedSelection?.profileId != null && savedSelection?.profileId != profileId) { + savedSelection = null + return@LaunchedEffect + } + + val selection = selectedDevice ?: return@LaunchedEffect + if (savedSelection != selection) { + savedSelection = selection + } + + when (detailPresentation(windowSizeClass, selection)) { + DetailPresentation.PANE -> { + 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 + } + } + } } } - MainAppShell( - currentTab = currentTab, - windowSizeClass = windowSizeClass, - userName = username.value, - providerName = profile.displayName, - onTabClick = { tab -> - selectedDevice = 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 { @@ -170,32 +200,73 @@ private fun AuthenticatedNavigationContent( } } }, - onProviderClick = onProviderClick, - onAvatarClick = onAvatarClick, - content = { paddingValues -> - AuthenticatedGraphContent( - navController = navController, - profile = profile, - paddingValues = paddingValues, - isExpandedDevicePane = isExpandedDevicePane, - selectedDevice = selectedDevice, - onSelectDevice = { deviceName, type -> - val selection = DeviceSelection(profile.id, deviceName, type) - selectedDevice = selection - val route = when (type) { - DeviceType.SOCKET -> DeviceDetailRoutes.socketRoute(profile.id, deviceName) - DeviceType.TEMPERATURE_HUMIDITY -> DeviceDetailRoutes.temperatureHumidityRoute(profile.id, deviceName) - DeviceType.INFRARED -> "${Routes.INFRARED_HUB}/${Uri.encode(profile.id.value)}" - } - if (detailPresentation(windowSizeClass, selection) == DetailPresentation.FULL_SCREEN) { - navController.navigate(route) - } - }, - onClearDeviceSelection = { selectedDevice = null }, - onLogout = onLogout, - ) + { deviceName, type -> + savedSelection = DeviceSelection(profileId, deviceName, type) }, + { savedSelection = null }, ) + + BackHandler(enabled = selectedDevice != null || navController.previousBackStackEntry != null) { + savedSelection = null + if (currentRoute != MainTab.DEVICES.route) { + navController.popBackStack() + } + } +} + +@Composable +fun AuthenticatedNavigation( + username: Username, + profile: ProviderProfile, + onLogout: () -> Unit, + onProviderClick: () -> Unit, + onAvatarClick: () -> 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.INFRARED_HUB}/${Uri.encode(profile.id.value)}" + } + }, + selectionForRoute = { route -> profile.selectionForRoute(route) }, + ) { snapshot, onTabClick, onSelectDevice, onClearDeviceSelection -> + val currentTab = MainTab.fromRoute(snapshot.currentRoute) ?: MainTab.DEVICES + MainAppShell( + currentTab = currentTab, + windowSizeClass = resolvedWindowSizeClass, + userName = username.value, + providerName = profile.displayName, + onTabClick = onTabClick, + onProviderClick = onProviderClick, + onAvatarClick = onAvatarClick, + content = { paddingValues -> + AuthenticatedGraphContent( + navController = navController, + profile = profile, + paddingValues = paddingValues, + isExpandedDevicePane = snapshot.detailPresentation == DetailPresentation.PANE && + snapshot.currentRoute == MainTab.DEVICES.route, + selectedDevice = snapshot.selectedDevice, + onSelectDevice = onSelectDevice, + onClearDeviceSelection = onClearDeviceSelection, + onLogout = onLogout, + ) + }, + ) + } + } } @Composable @@ -214,7 +285,7 @@ private fun AuthenticatedGraphContent( .fillMaxSize() .padding(paddingValues), ) { - if (isExpandedDevicePane) { + if (isExpandedDevicePane && selectedDevice != null) { Row(modifier = Modifier.fillMaxSize()) { AuthenticatedNavHost( modifier = Modifier.weight(1f), @@ -226,7 +297,7 @@ private fun AuthenticatedGraphContent( ) DeviceDetailPane( modifier = Modifier.weight(1f), - selection = requireNotNull(selectedDevice), + selection = selectedDevice, onBack = onClearDeviceSelection, ) } @@ -347,9 +418,10 @@ private fun DeviceDetailPane( 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() + val viewModel: SocketViewModel = hiltViewModel(key = viewModelKey) val state by viewModel.state.collectAsStateWithLifecycle() LaunchedEffect(selection) { viewModel.onEvent(SocketUiEvent.Initialize(selection.profileId, selection.deviceName)) @@ -362,7 +434,7 @@ private fun DeviceDetailPane( ) } DeviceType.TEMPERATURE_HUMIDITY -> { - val viewModel: TemperatureHumidityViewModel = hiltViewModel() + val viewModel: TemperatureHumidityViewModel = hiltViewModel(key = viewModelKey) val state by viewModel.state.collectAsStateWithLifecycle() LaunchedEffect(selection) { viewModel.onEvent( @@ -389,3 +461,39 @@ private fun ActivityDestination() { ) } } + +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 >= 2 && parts[0] == Routes.INFRARED_HUB -> DeviceSelection( + profileId = id, + deviceName = DeviceName("红外遥控器"), + 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/shell/AdaptiveLayout.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/AdaptiveLayout.kt index dc65248..a8be65d 100644 --- 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 @@ -1,6 +1,7 @@ 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 @@ -35,7 +36,32 @@ 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, 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 9094683..744939c 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 @@ -26,6 +26,7 @@ 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.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -63,11 +64,12 @@ fun MainAppShell( bottomBar = { if (!useRail) { ABoxNavigationBar { - MainTab.values().forEach { tab -> + MainTab.entries.forEach { tab -> val selected = tab == currentTab ABoxNavigationBarItem( selected = selected, onClick = { onTabClick(tab) }, + modifier = Modifier.testTag("main_tab_${tab.name.lowercase()}"), icon = { Icon( imageVector = if (selected) tab.selectedIcon else tab.unselectedIcon, @@ -127,6 +129,7 @@ private fun ProviderTopBar( Row( modifier = Modifier .clip(RoundedCornerShape(24.dp)) + .testTag("main_provider_pill") .clickable( onClickLabel = stringResource(R.string.account_sheet_provider), onClick = onProviderClick, @@ -159,12 +162,14 @@ private fun ProviderTopBar( .weight(1f) .padding(horizontal = 8.dp), ) - ABoxUserAvatar( - name = userName, - onClick = onAvatarClick, - size = 36.dp, - contentDescription = stringResource(R.string.action_account), - ) + Box(modifier = Modifier.testTag("main_avatar")) { + ABoxUserAvatar( + name = userName, + onClick = onAvatarClick, + size = 36.dp, + contentDescription = stringResource(R.string.action_account), + ) + } } } } @@ -180,11 +185,12 @@ private fun MainNavigationRail( containerColor = MaterialTheme.colorScheme.surface, ) { Spacer(modifier = Modifier.height(8.dp)) - MainTab.values().forEach { tab -> + 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, 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 39b090c..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 @@ -40,9 +40,10 @@ enum class MainTab( companion object { fun fromRoute(route: String?): MainTab? = when { - route == DEVICES.route || route?.startsWith("device/") == true || - route?.startsWith("infrared_") == true -> DEVICES - route == SCENES.route || route?.startsWith("scene/") == true -> SCENES + 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/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 }