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
@@ -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,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
}
@@ -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,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,
@@ -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
}