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.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)
@@ -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, 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,
) {
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?, 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
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<DeviceSelection?>(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<DeviceSelection?>(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,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,
onAvatarClick = onAvatarClick,
content = { paddingValues ->
@@ -177,26 +256,18 @@ private fun AuthenticatedNavigationContent(
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 },
isExpandedDevicePane = snapshot.detailPresentation == DetailPresentation.PANE &&
snapshot.currentRoute == MainTab.DEVICES.route,
selectedDevice = snapshot.selectedDevice,
onSelectDevice = onSelectDevice,
onClearDeviceSelection = onClearDeviceSelection,
onLogout = onLogout,
)
},
)
}
}
}
@Composable
private fun AuthenticatedGraphContent(
@@ -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
}
@@ -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<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 {
FULL_SCREEN,
@@ -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,6 +162,7 @@ private fun ProviderTopBar(
.weight(1f)
.padding(horizontal = 8.dp),
)
Box(modifier = Modifier.testTag("main_avatar")) {
ABoxUserAvatar(
name = userName,
onClick = onAvatarClick,
@@ -169,6 +173,7 @@ private fun ProviderTopBar(
}
}
}
}
@Composable
private fun MainNavigationRail(
@@ -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,
@@ -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
}
@@ -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 }