feat(ui): complete ABox app experience redesign #10
+215
@@ -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
-5
@@ -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)
|
||||||
|
|||||||
+175
-67
@@ -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 }
|
||||||
|
|||||||
Reference in New Issue
Block a user