fix: stabilize adaptive navigation state

This commit is contained in:
2026-08-26 15:29:41 +08:00
parent 54eef78a46
commit 05d44578b9
7 changed files with 450 additions and 94 deletions
@@ -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<ComponentActivity>()
@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"))
}
}
}
}
}
}
@@ -5,7 +5,7 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule 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.onNodeWithText
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
@@ -38,8 +38,8 @@ class MainAppShellTest {
} }
} }
composeRule.onAllNodesWithText("Devices")[0].assertIsDisplayed() composeRule.onNodeWithTag("main_tab_devices").assertIsDisplayed()
composeRule.onAllNodesWithText("Scenes")[0].performClick() composeRule.onNodeWithTag("main_tab_scenes").performClick()
assertEquals(MainTab.SCENES, selectedTab) assertEquals(MainTab.SCENES, selectedTab)
} }
@@ -83,8 +83,8 @@ class MainAppShellTest {
} }
} }
composeRule.onNodeWithText("ABox").performClick() composeRule.onNodeWithTag("main_provider_pill").performClick()
composeRule.onNodeWithText("U").performClick() composeRule.onNodeWithTag("main_avatar").performClick()
assertEquals(1, providerClicks) assertEquals(1, providerClicks)
assertEquals(1, avatarClicks) assertEquals(1, avatarClicks)
@@ -15,12 +15,15 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.key import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember 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.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.NavType import androidx.navigation.NavType
import androidx.navigation.compose.NavHost 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.TemperatureHumidityScreen
import com.flagship.abox.manager.ui.temperaturehumidity.TemperatureHumidityUiEvent import com.flagship.abox.manager.ui.temperaturehumidity.TemperatureHumidityUiEvent
import com.flagship.abox.manager.ui.temperaturehumidity.TemperatureHumidityViewModel 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. */ /** Pure authenticated graph state for tab, detail, and profile continuity tests. */
data class AuthenticatedGraphState( data class AuthenticatedGraphState(
val profileId: ProviderProfileId, val profileId: ProviderProfileId,
val currentTab: MainTab = MainTab.DEVICES, val currentTab: MainTab = MainTab.DEVICES,
private val backStacks: Map<MainTab, List<String>> = MainTab.values().associateWith { listOf(it.route) }, private val backStacks: Map<MainTab, List<String>> = MainTab.entries.associateWith { listOf(it.route) },
val selectedDevice: DeviceSelection? = null, val selectedDevice: DeviceSelection? = null,
) { ) {
fun selectTab(tab: MainTab): AuthenticatedGraphState { fun selectTab(tab: MainTab): AuthenticatedGraphState {
@@ -99,65 +104,90 @@ data class AuthenticatedGraphState(
} }
} }
@Composable data class AuthenticatedNavigationSnapshot(
fun AuthenticatedNavigation( val currentRoute: String?,
username: Username, val selectedDevice: DeviceSelection?,
profile: ProviderProfile, val detailPresentation: DetailPresentation?,
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,
)
}
}
private val NullableDeviceSelectionSaver: Saver<DeviceSelection?, Any> = 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 @Composable
private fun AuthenticatedNavigationContent( fun AuthenticatedNavigationStateHost(
username: Username, profileId: ProviderProfileId,
profile: ProviderProfile,
onLogout: () -> Unit,
onProviderClick: () -> Unit,
onAvatarClick: () -> Unit,
windowSizeClass: ABoxWindowSizeClass, 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<DeviceSelection?>(null) }
val backStackEntry by navController.currentBackStackEntryAsState() 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<DeviceSelection?>(null)
}
val currentTab = MainTab.fromRoute(currentRoute) ?: MainTab.DEVICES 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) detailPresentation(windowSizeClass, it)
} }
val isExpandedDevicePane = selectedDevice != null &&
selectedDetailPresentation == DetailPresentation.PANE &&
currentTab == MainTab.DEVICES
BackHandler(enabled = navController.previousBackStackEntry != null || selectedDevice != null) { LaunchedEffect(profileId, currentRoute, selectedDevice, windowSizeClass) {
if (selectedDevice != null && selectedDetailPresentation == DetailPresentation.PANE) { if (savedSelection?.profileId != null && savedSelection?.profileId != profileId) {
selectedDevice = null savedSelection = null
} else { return@LaunchedEffect
selectedDevice = null }
navController.popBackStack()
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( val snapshot = AuthenticatedNavigationSnapshot(
currentTab = currentTab, currentRoute = currentRoute,
windowSizeClass = windowSizeClass, selectedDevice = selectedDevice,
userName = username.value, detailPresentation = selectedPresentation,
providerName = profile.displayName, )
onTabClick = { tab ->
selectedDevice = null content(
snapshot,
{ tab ->
savedSelection = null
if (tab == currentTab) { if (tab == currentTab) {
navController.popBackStack(tab.route, inclusive = false) navController.popBackStack(tab.route, inclusive = false)
} else { } else {
@@ -170,6 +200,55 @@ private fun AuthenticatedNavigationContent(
} }
} }
}, },
{ 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, onProviderClick = onProviderClick,
onAvatarClick = onAvatarClick, onAvatarClick = onAvatarClick,
content = { paddingValues -> content = { paddingValues ->
@@ -177,25 +256,17 @@ private fun AuthenticatedNavigationContent(
navController = navController, navController = navController,
profile = profile, profile = profile,
paddingValues = paddingValues, paddingValues = paddingValues,
isExpandedDevicePane = isExpandedDevicePane, isExpandedDevicePane = snapshot.detailPresentation == DetailPresentation.PANE &&
selectedDevice = selectedDevice, snapshot.currentRoute == MainTab.DEVICES.route,
onSelectDevice = { deviceName, type -> selectedDevice = snapshot.selectedDevice,
val selection = DeviceSelection(profile.id, deviceName, type) onSelectDevice = onSelectDevice,
selectedDevice = selection onClearDeviceSelection = onClearDeviceSelection,
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, onLogout = onLogout,
) )
}, },
) )
}
}
} }
@Composable @Composable
@@ -214,7 +285,7 @@ private fun AuthenticatedGraphContent(
.fillMaxSize() .fillMaxSize()
.padding(paddingValues), .padding(paddingValues),
) { ) {
if (isExpandedDevicePane) { if (isExpandedDevicePane && selectedDevice != null) {
Row(modifier = Modifier.fillMaxSize()) { Row(modifier = Modifier.fillMaxSize()) {
AuthenticatedNavHost( AuthenticatedNavHost(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
@@ -226,7 +297,7 @@ private fun AuthenticatedGraphContent(
) )
DeviceDetailPane( DeviceDetailPane(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
selection = requireNotNull(selectedDevice), selection = selectedDevice,
onBack = onClearDeviceSelection, onBack = onClearDeviceSelection,
) )
} }
@@ -347,9 +418,10 @@ private fun DeviceDetailPane(
onBack: () -> Unit, onBack: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val viewModelKey = "${selection.profileId.value}:${selection.deviceName.value}:${selection.type.name}"
when (selection.type) { when (selection.type) {
DeviceType.SOCKET -> { DeviceType.SOCKET -> {
val viewModel: SocketViewModel = hiltViewModel() val viewModel: SocketViewModel = hiltViewModel(key = viewModelKey)
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
LaunchedEffect(selection) { LaunchedEffect(selection) {
viewModel.onEvent(SocketUiEvent.Initialize(selection.profileId, selection.deviceName)) viewModel.onEvent(SocketUiEvent.Initialize(selection.profileId, selection.deviceName))
@@ -362,7 +434,7 @@ private fun DeviceDetailPane(
) )
} }
DeviceType.TEMPERATURE_HUMIDITY -> { DeviceType.TEMPERATURE_HUMIDITY -> {
val viewModel: TemperatureHumidityViewModel = hiltViewModel() val viewModel: TemperatureHumidityViewModel = hiltViewModel(key = viewModelKey)
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
LaunchedEffect(selection) { LaunchedEffect(selection) {
viewModel.onEvent( 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
}
@@ -1,6 +1,7 @@
package com.flagship.abox.manager.ui.shell package com.flagship.abox.manager.ui.shell
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.runtime.saveable.Saver
import com.flagship.abox.manager.domain.DeviceName import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DeviceType import com.flagship.abox.manager.domain.DeviceType
import com.flagship.abox.manager.domain.ProviderProfileId import com.flagship.abox.manager.domain.ProviderProfileId
@@ -35,7 +36,32 @@ data class DeviceSelection(
val profileId: ProviderProfileId, val profileId: ProviderProfileId,
val deviceName: DeviceName, val deviceName: DeviceName,
val type: DeviceType, val type: DeviceType,
) ) {
companion object {
/** Saver used by navigation-owned state so selection survives recreation and resize. */
val Saver: Saver<DeviceSelection?, Any> = 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 { enum class DetailPresentation {
FULL_SCREEN, FULL_SCREEN,
@@ -26,6 +26,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
@@ -63,11 +64,12 @@ fun MainAppShell(
bottomBar = { bottomBar = {
if (!useRail) { if (!useRail) {
ABoxNavigationBar { ABoxNavigationBar {
MainTab.values().forEach { tab -> MainTab.entries.forEach { tab ->
val selected = tab == currentTab val selected = tab == currentTab
ABoxNavigationBarItem( ABoxNavigationBarItem(
selected = selected, selected = selected,
onClick = { onTabClick(tab) }, onClick = { onTabClick(tab) },
modifier = Modifier.testTag("main_tab_${tab.name.lowercase()}"),
icon = { icon = {
Icon( Icon(
imageVector = if (selected) tab.selectedIcon else tab.unselectedIcon, imageVector = if (selected) tab.selectedIcon else tab.unselectedIcon,
@@ -127,6 +129,7 @@ private fun ProviderTopBar(
Row( Row(
modifier = Modifier modifier = Modifier
.clip(RoundedCornerShape(24.dp)) .clip(RoundedCornerShape(24.dp))
.testTag("main_provider_pill")
.clickable( .clickable(
onClickLabel = stringResource(R.string.account_sheet_provider), onClickLabel = stringResource(R.string.account_sheet_provider),
onClick = onProviderClick, onClick = onProviderClick,
@@ -159,6 +162,7 @@ private fun ProviderTopBar(
.weight(1f) .weight(1f)
.padding(horizontal = 8.dp), .padding(horizontal = 8.dp),
) )
Box(modifier = Modifier.testTag("main_avatar")) {
ABoxUserAvatar( ABoxUserAvatar(
name = userName, name = userName,
onClick = onAvatarClick, onClick = onAvatarClick,
@@ -168,6 +172,7 @@ private fun ProviderTopBar(
} }
} }
} }
}
} }
@Composable @Composable
@@ -180,11 +185,12 @@ private fun MainNavigationRail(
containerColor = MaterialTheme.colorScheme.surface, containerColor = MaterialTheme.colorScheme.surface,
) { ) {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
MainTab.values().forEach { tab -> MainTab.entries.forEach { tab ->
val selected = tab == currentTab val selected = tab == currentTab
NavigationRailItem( NavigationRailItem(
selected = selected, selected = selected,
onClick = { onTabClick(tab) }, onClick = { onTabClick(tab) },
modifier = Modifier.testTag("main_tab_${tab.name.lowercase()}"),
icon = { icon = {
Icon( Icon(
imageVector = if (selected) tab.selectedIcon else tab.unselectedIcon, imageVector = if (selected) tab.selectedIcon else tab.unselectedIcon,
@@ -40,9 +40,10 @@ enum class MainTab(
companion object { companion object {
fun fromRoute(route: String?): MainTab? = when { fun fromRoute(route: String?): MainTab? = when {
route == DEVICES.route || route?.startsWith("device/") == true || route == null -> null
route?.startsWith("infrared_") == true -> DEVICES route == DEVICES.route || route.startsWith("device/") ||
route == SCENES.route || route?.startsWith("scene/") == true -> SCENES route.startsWith("infrared_") -> DEVICES
route == SCENES.route || route.startsWith("scene/") -> SCENES
route == ACTIVITY.route -> ACTIVITY route == ACTIVITY.route -> ACTIVITY
else -> null else -> null
} }
@@ -9,7 +9,7 @@ class MainTabTest {
@Test @Test
fun `all main tabs have distinct non-empty routes and valid label resources`() { 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) assertEquals(3, tabs.size)
val routes = tabs.map { it.route } val routes = tabs.map { it.route }