diff --git a/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/shell/MainAppShellTest.kt b/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/shell/MainAppShellTest.kt new file mode 100644 index 0000000..5962902 --- /dev/null +++ b/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/shell/MainAppShellTest.kt @@ -0,0 +1,92 @@ +package com.flagship.abox.manager.ui.shell + +import androidx.activity.ComponentActivity +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.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.flagship.abox.manager.designsystem.ABoxTheme +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class MainAppShellTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun compactUsesBottomNavigationAndDelegatesTabClick() { + var selectedTab: MainTab? = null + composeRule.setContent { + ABoxTheme { + MainAppShell( + currentTab = MainTab.DEVICES, + windowSizeClass = ABoxWindowSizeClass.COMPACT, + userName = "user", + providerName = "ABox", + onTabClick = { selectedTab = it }, + onProviderClick = {}, + onAvatarClick = {}, + content = { _: PaddingValues -> Text("content") }, + ) + } + } + + composeRule.onAllNodesWithText("Devices")[0].assertIsDisplayed() + composeRule.onAllNodesWithText("Scenes")[0].performClick() + + assertEquals(MainTab.SCENES, selectedTab) + } + + @Test + fun mediumAndExpandedExposeRailNavigation() { + composeRule.setContent { + ABoxTheme { + MainAppShell( + currentTab = MainTab.DEVICES, + windowSizeClass = ABoxWindowSizeClass.MEDIUM, + userName = "user", + providerName = "ABox", + onTabClick = {}, + onProviderClick = {}, + onAvatarClick = {}, + content = { _: PaddingValues -> Text("content") }, + ) + } + } + + composeRule.onNodeWithText("Scenes").assertIsDisplayed() + } + + @Test + fun topAreaDelegatesProviderAndAvatarClicks() { + var providerClicks = 0 + var avatarClicks = 0 + composeRule.setContent { + ABoxTheme { + MainAppShell( + currentTab = MainTab.DEVICES, + windowSizeClass = ABoxWindowSizeClass.COMPACT, + userName = "user", + providerName = "ABox", + onTabClick = {}, + onProviderClick = { providerClicks++ }, + onAvatarClick = { avatarClicks++ }, + content = { _: PaddingValues -> Text("content") }, + ) + } + } + + composeRule.onNodeWithText("ABox").performClick() + composeRule.onNodeWithText("U").performClick() + + assertEquals(1, providerClicks) + assertEquals(1, avatarClicks) + } +} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt index 73d42ba..ae06c1f 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt @@ -5,42 +5,31 @@ import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -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.ABoxLoadingIndicator import com.flagship.abox.manager.designsystem.ABoxSurface import com.flagship.abox.manager.designsystem.ABoxTheme -import com.flagship.abox.manager.domain.DeviceName import com.flagship.abox.manager.domain.ProviderProfile import com.flagship.abox.manager.domain.Username -import com.flagship.abox.manager.infrared.hub.InfraredHubRoute -import com.flagship.abox.manager.infrared.panel.RemotePanelRoute -import com.flagship.abox.manager.infrared.ui.InfraredControlRoute -import com.flagship.abox.manager.ui.devicecontrollist.DeviceListScreen import com.flagship.abox.manager.ui.login.LoginRoute import com.flagship.abox.manager.ui.login.LoginViewModel -import com.flagship.abox.manager.ui.navigation.deviceDetailGraph -import com.flagship.abox.manager.ui.navigation.navigateToSocket -import com.flagship.abox.manager.ui.navigation.navigateToTemperatureHumidity -import com.flagship.abox.manager.ui.provider.ProviderSelectionRoute +import com.flagship.abox.manager.ui.navigation.AuthenticatedNavigation import com.flagship.abox.manager.ui.preferences.SettingsViewModel -import com.flagship.abox.manager.ui.scene.SceneListScreen -import com.flagship.abox.manager.ui.scene.SceneRoutes -import com.flagship.abox.manager.ui.scene.sceneEditorDestination -import com.flagship.abox.manager.ui.scene.sceneExecutionDestination +import com.flagship.abox.manager.ui.provider.ProviderSelectionRoute import com.flagship.abox.manager.ui.session.AppSessionUiState import com.flagship.abox.manager.ui.session.AppSessionViewModel import com.flagship.abox.manager.ui.session.SessionActionsViewModel -import com.flagship.abox.manager.ui.shell.MainAppShell +import com.flagship.abox.manager.ui.shell.AccountProviderBottomSheet @Composable fun ABoxManagerApp( @@ -54,12 +43,14 @@ fun ABoxManagerApp( val settingsState = settingsViewModel.uiState.collectAsStateWithLifecycle().value val actionError = actionState.errorMessage?.asString() val screenKey = sessionState.screenKey() + LaunchedEffect(screenKey) { sessionActionsViewModel.dismissError() if (sessionState !is AppSessionUiState.Login) { loginViewModel.clearSensitiveState() } } + ABoxTheme(preferences = settingsState.preferences) { ABoxSurface(modifier = Modifier.fillMaxSize()) { when (val state = sessionState) { @@ -92,6 +83,7 @@ fun ABoxManagerApp( } } +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun AuthenticatedNavHost( username: Username, @@ -100,100 +92,25 @@ private fun AuthenticatedNavHost( errorMessage: String?, onLogout: () -> Unit, ) { - val navController = rememberNavController() - NavHost( - navController = navController, - startDestination = "home", - ) { - composable("home") { - MainAppShell( - userName = username.value, - providerName = profile.displayName, - onLogout = onLogout, - actionInProgress = actionInProgress, - errorMessage = errorMessage, - devicesSlot = { - val encodedProfileId = android.net.Uri.encode(profile.id.value) - DeviceListScreen( - onSocketClick = { - navController.navigateToSocket( - profileId = profile.id, - deviceName = DeviceName("Zigbee插座控制器"), - ) - }, - onTemperatureHumidityClick = { - navController.navigateToTemperatureHumidity( - profileId = profile.id, - deviceName = DeviceName("温湿度传感器"), - ) - }, - onInfraredClick = { - navController.navigate("infrared_hub/$encodedProfileId") - }, - ) - }, - scenesSlot = { - SceneListScreen( - onAddManual = { navController.navigate(SceneRoutes.EDITOR_MANUAL) }, - onAddAutomatic = { navController.navigate(SceneRoutes.EDITOR_AUTOMATIC) }, - onEditScene = { sceneId -> navController.navigate(SceneRoutes.editorFor(sceneId)) }, - onExecuteScene = { sceneId -> navController.navigate(SceneRoutes.executionFor(sceneId)) }, - ) - }, - ) - } - deviceDetailGraph( - onBackClick = navController::popBackStack, + var showAccountSheet by remember { mutableStateOf(false) } + + AuthenticatedNavigation( + username = username, + profile = profile, + onLogout = onLogout, + onProviderClick = { showAccountSheet = true }, + onAvatarClick = { showAccountSheet = true }, + ) + + if (showAccountSheet) { + AccountProviderBottomSheet( + userName = username.value, + providerName = profile.displayName, + onDismissRequest = { showAccountSheet = false }, + onLogout = onLogout, + actionInProgress = actionInProgress, + errorMessage = errorMessage, ) - sceneEditorDestination( - onBack = { navController.popBackStack() }, - onSaved = { navController.popBackStack() }, - ) - sceneExecutionDestination( - onBack = { navController.popBackStack() }, - ) - composable( - route = "infrared_hub/{profileId}", - arguments = listOf( - navArgument("profileId") { type = NavType.StringType }, - ), - ) { - val encodedProfileId = android.net.Uri.encode(profile.id.value) - InfraredHubRoute( - onBack = { navController.popBackStack() }, - onOpenRemote = { remoteId -> - val encodedRemoteId = android.net.Uri.encode(remoteId) - navController.navigate("infrared_panel/$encodedProfileId/$encodedRemoteId") - }, - onOpenSettings = { - navController.navigate("infrared_settings/$encodedProfileId") - }, - onSessionInvalid = onLogout, - ) - } - composable( - route = "infrared_panel/{profileId}/{remoteId}", - arguments = listOf( - navArgument("profileId") { type = NavType.StringType }, - navArgument("remoteId") { type = NavType.StringType }, - ), - ) { - RemotePanelRoute( - onBack = { navController.popBackStack() }, - onSessionInvalid = onLogout, - ) - } - composable( - route = "infrared_settings/{profileId}", - arguments = listOf( - navArgument("profileId") { type = NavType.StringType }, - ), - ) { - InfraredControlRoute( - onBack = { navController.popBackStack() }, - onSessionInvalid = onLogout, - ) - } } } diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/AuthenticatedNavigation.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/AuthenticatedNavigation.kt new file mode 100644 index 0000000..b39264f --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/AuthenticatedNavigation.kt @@ -0,0 +1,391 @@ +package com.flagship.abox.manager.ui.navigation + +import android.net.Uri +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.LocalConfiguration +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavHostController +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument +import com.flagship.abox.manager.R +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.domain.DeviceType +import com.flagship.abox.manager.domain.ProviderProfile +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.Username +import com.flagship.abox.manager.infrared.hub.InfraredHubRoute +import com.flagship.abox.manager.infrared.panel.RemotePanelRoute +import com.flagship.abox.manager.infrared.ui.InfraredControlRoute +import com.flagship.abox.manager.ui.devicecontrollist.DeviceListScreen +import com.flagship.abox.manager.ui.scene.SceneListScreen +import com.flagship.abox.manager.ui.scene.SceneRoutes +import com.flagship.abox.manager.ui.scene.sceneEditorDestination +import com.flagship.abox.manager.ui.scene.sceneExecutionDestination +import com.flagship.abox.manager.ui.shell.ABoxWindowSizeClass +import com.flagship.abox.manager.ui.shell.DetailPresentation +import com.flagship.abox.manager.ui.shell.DeviceSelection +import com.flagship.abox.manager.ui.shell.MainAppShell +import com.flagship.abox.manager.ui.shell.MainTab +import com.flagship.abox.manager.ui.shell.detailPresentation +import com.flagship.abox.manager.ui.shell.widthClass +import com.flagship.abox.manager.ui.socket.SocketScreen +import com.flagship.abox.manager.ui.socket.SocketUiEvent +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 + +/** Pure authenticated graph state for tab, detail, and profile continuity tests. */ +data class AuthenticatedGraphState( + val profileId: ProviderProfileId, + val currentTab: MainTab = MainTab.DEVICES, + private val backStacks: Map> = MainTab.values().associateWith { listOf(it.route) }, + val selectedDevice: DeviceSelection? = null, +) { + fun selectTab(tab: MainTab): AuthenticatedGraphState { + val nextStack = if (tab == currentTab) { + listOf(tab.route) + } else { + backStacks[tab].orEmpty().ifEmpty { listOf(tab.route) } + } + return copy( + currentTab = tab, + backStacks = backStacks + (tab to nextStack), + selectedDevice = null, + ) + } + + fun pushChild(route: String): AuthenticatedGraphState = copy( + backStacks = backStacks + (currentTab to (backStacks[currentTab].orEmpty() + route)), + ) + + fun tabBackStack(tab: MainTab): List = + backStacks[tab].orEmpty().ifEmpty { listOf(tab.route) } + + fun recreateForProfile(newProfileId: ProviderProfileId): AuthenticatedGraphState = + AuthenticatedGraphState(profileId = newProfileId) + + fun selectDevice(deviceName: DeviceName, type: DeviceType): AuthenticatedGraphState = copy( + selectedDevice = DeviceSelection(profileId, deviceName, type), + ) + + fun handleBack(): AuthenticatedGraphState = when { + selectedDevice != null -> copy(selectedDevice = null) + tabBackStack(currentTab).size > 1 -> copy( + backStacks = backStacks + (currentTab to tabBackStack(currentTab).dropLast(1)), + ) + else -> this + } +} + +@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, + ) + } +} + +@Composable +private fun AuthenticatedNavigationContent( + username: Username, + profile: ProviderProfile, + onLogout: () -> Unit, + onProviderClick: () -> Unit, + onAvatarClick: () -> Unit, + windowSizeClass: ABoxWindowSizeClass, +) { + val navController = rememberNavController() + var selectedDevice by remember { mutableStateOf(null) } + val backStackEntry by navController.currentBackStackEntryAsState() + val currentRoute = backStackEntry?.destination?.route + val currentTab = MainTab.fromRoute(currentRoute) ?: MainTab.DEVICES + val selectedDetailPresentation = 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() + } + } + + MainAppShell( + currentTab = currentTab, + windowSizeClass = windowSizeClass, + userName = username.value, + providerName = profile.displayName, + onTabClick = { tab -> + selectedDevice = null + if (tab == currentTab) { + navController.popBackStack(tab.route, inclusive = false) + } else { + navController.navigate(tab.route) { + launchSingleTop = true + restoreState = true + popUpTo(navController.graph.startDestinationId) { + saveState = true + } + } + } + }, + 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, + ) + }, + ) +} + +@Composable +private fun AuthenticatedGraphContent( + navController: NavHostController, + profile: ProviderProfile, + paddingValues: PaddingValues, + isExpandedDevicePane: Boolean, + selectedDevice: DeviceSelection?, + onSelectDevice: (DeviceName, DeviceType) -> Unit, + onClearDeviceSelection: () -> Unit, + onLogout: () -> Unit, +) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + ) { + if (isExpandedDevicePane) { + Row(modifier = Modifier.fillMaxSize()) { + AuthenticatedNavHost( + modifier = Modifier.weight(1f), + navController = navController, + profile = profile, + onSelectDevice = onSelectDevice, + onClearDeviceSelection = onClearDeviceSelection, + onLogout = onLogout, + ) + DeviceDetailPane( + modifier = Modifier.weight(1f), + selection = requireNotNull(selectedDevice), + onBack = onClearDeviceSelection, + ) + } + } else { + AuthenticatedNavHost( + modifier = Modifier.fillMaxSize(), + navController = navController, + profile = profile, + onSelectDevice = onSelectDevice, + onClearDeviceSelection = onClearDeviceSelection, + onLogout = onLogout, + ) + } + } +} + +@Composable +private fun AuthenticatedNavHost( + modifier: Modifier, + navController: NavHostController, + profile: ProviderProfile, + onSelectDevice: (DeviceName, DeviceType) -> Unit, + onClearDeviceSelection: () -> Unit, + onLogout: () -> Unit, +) { + NavHost( + navController = navController, + startDestination = MainTab.DEVICES.route, + modifier = modifier, + ) { + composable(MainTab.DEVICES.route) { + DeviceRoot(onSelectDevice = onSelectDevice) + } + composable(MainTab.SCENES.route) { + SceneListScreen( + onAddManual = { navController.navigate(SceneRoutes.EDITOR_MANUAL) }, + onAddAutomatic = { navController.navigate(SceneRoutes.EDITOR_AUTOMATIC) }, + onEditScene = { sceneId -> navController.navigate(SceneRoutes.editorFor(sceneId)) }, + onExecuteScene = { sceneId -> navController.navigate(SceneRoutes.executionFor(sceneId)) }, + ) + } + composable(MainTab.ACTIVITY.route) { + ActivityDestination() + } + deviceDetailGraph( + onBackClick = { + onClearDeviceSelection() + navController.popBackStack() + }, + ) + sceneEditorDestination( + onBack = navController::popBackStack, + onSaved = { navController.popBackStack(MainTab.SCENES.route, inclusive = false) }, + ) + sceneExecutionDestination(onBack = navController::popBackStack) + composable( + route = "${Routes.INFRARED_HUB}/{profileId}", + arguments = listOf(navArgument("profileId") { type = NavType.StringType }), + ) { + InfraredHubRoute( + onBack = { + onClearDeviceSelection() + navController.popBackStack() + }, + onOpenRemote = { remoteId -> + navController.navigate( + "${Routes.INFRARED_PANEL}/${Uri.encode(profile.id.value)}/${Uri.encode(remoteId)}", + ) + }, + onOpenSettings = { + navController.navigate("${Routes.INFRARED_SETTINGS}/${Uri.encode(profile.id.value)}") + }, + onSessionInvalid = onLogout, + ) + } + composable( + route = "${Routes.INFRARED_PANEL}/{profileId}/{remoteId}", + arguments = listOf( + navArgument("profileId") { type = NavType.StringType }, + navArgument("remoteId") { type = NavType.StringType }, + ), + ) { + RemotePanelRoute( + onBack = navController::popBackStack, + onSessionInvalid = onLogout, + ) + } + composable( + route = "${Routes.INFRARED_SETTINGS}/{profileId}", + arguments = listOf(navArgument("profileId") { type = NavType.StringType }), + ) { + InfraredControlRoute( + onBack = navController::popBackStack, + onSessionInvalid = onLogout, + ) + } + } +} + +@Composable +private fun DeviceRoot( + modifier: Modifier = Modifier, + onSelectDevice: (DeviceName, DeviceType) -> Unit, +) { + DeviceListScreen( + modifier = modifier, + onSocketClick = { onSelectDevice(DeviceName("Zigbee插座控制器"), DeviceType.SOCKET) }, + onTemperatureHumidityClick = { + onSelectDevice(DeviceName("温湿度传感器"), DeviceType.TEMPERATURE_HUMIDITY) + }, + onInfraredClick = { onSelectDevice(DeviceName("红外遥控器"), DeviceType.INFRARED) }, + ) +} + +@Composable +private fun DeviceDetailPane( + selection: DeviceSelection, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + when (selection.type) { + DeviceType.SOCKET -> { + val viewModel: SocketViewModel = hiltViewModel() + val state by viewModel.state.collectAsStateWithLifecycle() + LaunchedEffect(selection) { + viewModel.onEvent(SocketUiEvent.Initialize(selection.profileId, selection.deviceName)) + } + SocketScreen( + state = state, + onEvent = viewModel::onEvent, + onBackClick = onBack, + modifier = modifier, + ) + } + DeviceType.TEMPERATURE_HUMIDITY -> { + val viewModel: TemperatureHumidityViewModel = hiltViewModel() + val state by viewModel.state.collectAsStateWithLifecycle() + LaunchedEffect(selection) { + viewModel.onEvent( + TemperatureHumidityUiEvent.Initialize(selection.profileId, selection.deviceName), + ) + } + TemperatureHumidityScreen( + state = state, + onEvent = viewModel::onEvent, + onBackClick = onBack, + modifier = modifier, + ) + } + DeviceType.INFRARED -> Unit + } +} + +@Composable +private fun ActivityDestination() { + Box(modifier = Modifier.fillMaxSize().padding(24.dp)) { + Text( + text = androidx.compose.ui.res.stringResource(R.string.activity_placeholder_title), + style = MaterialTheme.typography.headlineMedium, + ) + } +} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/Routes.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/Routes.kt index ee3cb43..cc1c4e0 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/Routes.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/Routes.kt @@ -4,7 +4,9 @@ object Routes { const val STARTUP = "startup" const val PROVIDER_SELECTION = "provider_selection" const val LOGIN = "login" - - /** Login-complete route contract; the device navigation owner replaces its placeholder. */ const val AUTHENTICATED = "authenticated" + + const val INFRARED_HUB = "infrared_hub" + const val INFRARED_PANEL = "infrared_panel" + const val INFRARED_SETTINGS = "infrared_settings" } diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/AdaptiveLayout.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/AdaptiveLayout.kt new file mode 100644 index 0000000..dc65248 --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/AdaptiveLayout.kt @@ -0,0 +1,53 @@ +package com.flagship.abox.manager.ui.shell + +import androidx.compose.runtime.Immutable +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.domain.DeviceType +import com.flagship.abox.manager.domain.ProviderProfileId + +/** Width buckets used by the authenticated shell. */ +enum class ABoxWindowSizeClass { + COMPACT, + MEDIUM, + EXPANDED, +} + +fun widthClass(widthDp: Int): ABoxWindowSizeClass = when { + widthDp < 600 -> ABoxWindowSizeClass.COMPACT + widthDp < 840 -> ABoxWindowSizeClass.MEDIUM + else -> ABoxWindowSizeClass.EXPANDED +} + +enum class MainNavigationPresentation { + BOTTOM_NAVIGATION, + NAVIGATION_RAIL, +} + +fun navigationPresentation(windowSizeClass: ABoxWindowSizeClass): MainNavigationPresentation = when (windowSizeClass) { + ABoxWindowSizeClass.COMPACT -> MainNavigationPresentation.BOTTOM_NAVIGATION + ABoxWindowSizeClass.MEDIUM, + ABoxWindowSizeClass.EXPANDED, + -> MainNavigationPresentation.NAVIGATION_RAIL +} + +@Immutable +data class DeviceSelection( + val profileId: ProviderProfileId, + val deviceName: DeviceName, + val type: DeviceType, +) + +enum class DetailPresentation { + FULL_SCREEN, + PANE, +} + +fun detailPresentation( + windowSizeClass: ABoxWindowSizeClass, + selection: DeviceSelection, +): DetailPresentation = when { + windowSizeClass == ABoxWindowSizeClass.EXPANDED && selection.type != DeviceType.INFRARED -> { + DetailPresentation.PANE + } + else -> DetailPresentation.FULL_SCREEN +} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/MainAppShell.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/MainAppShell.kt index d32ebdc..9094683 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/MainAppShell.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/MainAppShell.kt @@ -1,179 +1,198 @@ package com.flagship.abox.manager.ui.shell -import androidx.compose.animation.Crossfade -import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Bolt import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationRail +import androidx.compose.material3.NavigationRailItem import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.saveable.rememberSaveableStateHolder -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.flagship.abox.manager.R import com.flagship.abox.manager.designsystem.ABoxNavigationBar import com.flagship.abox.manager.designsystem.ABoxNavigationBarItem -import com.flagship.abox.manager.designsystem.ABoxTopPillBar -import com.flagship.abox.manager.ui.devicecontrollist.DeviceListScreen +import com.flagship.abox.manager.designsystem.ABoxUserAvatar -/** - * Main authenticated application shell. - * - * Provides: - * 1. A floating/pinned pill top app bar with provider context, '+' action, and avatar account sheet. - * 2. Material 3 bottom navigation bar with pill indicator for Devices, Scenes, and Activity tabs. - * 3. Clear composable slot boundaries for team member integrations. - */ -@OptIn(ExperimentalMaterial3Api::class) +/** Stateless authenticated shell; navigation state belongs to AuthenticatedNavigation. */ @Composable fun MainAppShell( + currentTab: MainTab, + windowSizeClass: ABoxWindowSizeClass, userName: String, providerName: String, - onLogout: () -> Unit, - modifier: Modifier = Modifier, - initialTab: MainTab = MainTab.DEVICES, - onTabSelected: (MainTab) -> Unit = {}, - onAddClick: (() -> Unit)? = null, - actionInProgress: Boolean = false, - errorMessage: String? = null, - devicesSlot: @Composable () -> Unit = { DeviceListScreen() }, - scenesSlot: @Composable () -> Unit = { DefaultScenesSlot() }, - activitySlot: @Composable () -> Unit = { DefaultActivitySlot() }, + onTabClick: (MainTab) -> Unit, + onProviderClick: () -> Unit, + onAvatarClick: () -> Unit, + content: @Composable (PaddingValues) -> Unit, ) { - var activeTab by rememberSaveable { mutableStateOf(initialTab) } - var showAccountSheet by remember { mutableStateOf(false) } - val tabStateHolder = rememberSaveableStateHolder() + val useRail = navigationPresentation(windowSizeClass) == MainNavigationPresentation.NAVIGATION_RAIL Scaffold( - modifier = modifier.fillMaxSize(), + modifier = Modifier.fillMaxSize(), topBar = { - Box( - modifier = Modifier - .fillMaxWidth() - .statusBarsPadding() - .padding(horizontal = 16.dp, vertical = 8.dp), - ) { - ABoxTopPillBar( - title = stringResource(R.string.top_bar_prompt_format, providerName), - userName = userName, - onAvatarClick = { showAccountSheet = true }, - onAddClick = onAddClick, - addActionContentDescription = stringResource(R.string.action_add), - avatarContentDescription = stringResource(R.string.action_account), - ) - } + ProviderTopBar( + currentTab = currentTab, + providerName = providerName, + userName = userName, + onProviderClick = onProviderClick, + onAvatarClick = onAvatarClick, + ) }, bottomBar = { - ABoxNavigationBar { - MainTab.values().forEach { tab -> - val isSelected = (tab == activeTab) - ABoxNavigationBarItem( - selected = isSelected, - onClick = { - activeTab = tab - onTabSelected(tab) - }, - icon = { - Icon( - imageVector = if (isSelected) tab.selectedIcon else tab.unselectedIcon, - contentDescription = stringResource(tab.labelRes), - ) - }, - label = { - Text(text = stringResource(tab.labelRes)) - }, - ) + if (!useRail) { + ABoxNavigationBar { + MainTab.values().forEach { tab -> + val selected = tab == currentTab + ABoxNavigationBarItem( + selected = selected, + onClick = { onTabClick(tab) }, + icon = { + Icon( + imageVector = if (selected) tab.selectedIcon else tab.unselectedIcon, + contentDescription = stringResource(tab.labelRes), + ) + }, + label = { Text(stringResource(tab.labelRes)) }, + ) + } } } }, ) { innerPadding -> - Box( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding), + Row( + modifier = Modifier.fillMaxSize(), ) { - Crossfade( - targetState = activeTab, - label = "MainTabCrossfade", - ) { tab -> - tabStateHolder.SaveableStateProvider(tab) { - when (tab) { - MainTab.DEVICES -> devicesSlot() - MainTab.SCENES -> scenesSlot() - MainTab.ACTIVITY -> activitySlot() - } - } + if (useRail) { + MainNavigationRail( + currentTab = currentTab, + onTabClick = onTabClick, + ) + } + Box(modifier = Modifier.weight(1f).fillMaxHeight()) { + content(innerPadding) } } } - - if (showAccountSheet) { - AccountProviderBottomSheet( - userName = userName, - providerName = providerName, - onDismissRequest = { showAccountSheet = false }, - onLogout = onLogout, - actionInProgress = actionInProgress, - errorMessage = errorMessage, - ) - } } @Composable -private fun DefaultScenesSlot() { - SlotPlaceholder( - title = stringResource(R.string.scenes_placeholder_title), - description = stringResource(R.string.scenes_placeholder_description), - ) -} - -@Composable -private fun DefaultActivitySlot() { - SlotPlaceholder( - title = stringResource(R.string.activity_placeholder_title), - description = stringResource(R.string.activity_placeholder_description), - ) -} - -@Composable -private fun SlotPlaceholder( - title: String, - description: String, +private fun ProviderTopBar( + currentTab: MainTab, + providerName: String, + userName: String, + onProviderClick: () -> Unit, + onAvatarClick: () -> Unit, ) { - Column( + Box( modifier = Modifier - .fillMaxSize() - .padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, + .fillMaxWidth() + .statusBarsPadding() + .padding(horizontal = 16.dp, vertical = 8.dp), ) { - Text( - text = title, - style = MaterialTheme.typography.headlineSmall, - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.onSurface, - ) - Text( - text = description, - style = MaterialTheme.typography.bodyMedium, - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 8.dp), - ) + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(32.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f), + tonalElevation = 2.dp, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(64.dp) + .padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + modifier = Modifier + .clip(RoundedCornerShape(24.dp)) + .clickable( + onClickLabel = stringResource(R.string.account_sheet_provider), + onClick = onProviderClick, + ) + .padding(start = 8.dp, end = 12.dp, top = 8.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Outlined.Bolt, + contentDescription = providerName, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.width(22.dp), + ) + Text( + text = providerName, + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(start = 8.dp), + ) + } + Text( + text = stringResource(currentTab.labelRes), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .weight(1f) + .padding(horizontal = 8.dp), + ) + ABoxUserAvatar( + name = userName, + onClick = onAvatarClick, + size = 36.dp, + contentDescription = stringResource(R.string.action_account), + ) + } + } + } +} + +@Composable +private fun MainNavigationRail( + currentTab: MainTab, + onTabClick: (MainTab) -> Unit, +) { + NavigationRail( + modifier = Modifier.fillMaxHeight(), + containerColor = MaterialTheme.colorScheme.surface, + ) { + Spacer(modifier = Modifier.height(8.dp)) + MainTab.values().forEach { tab -> + val selected = tab == currentTab + NavigationRailItem( + selected = selected, + onClick = { onTabClick(tab) }, + icon = { + Icon( + imageVector = if (selected) tab.selectedIcon else tab.unselectedIcon, + contentDescription = stringResource(tab.labelRes), + ) + }, + label = { Text(stringResource(tab.labelRes)) }, + ) + } } } diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/MainTab.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/MainTab.kt index 32f5cac..39b090c 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/MainTab.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/MainTab.kt @@ -11,9 +11,7 @@ import androidx.compose.material.icons.outlined.ViewAgenda import androidx.compose.ui.graphics.vector.ImageVector import com.flagship.abox.manager.R -/** - * Main bottom navigation tabs for the authenticated application shell. - */ +/** Main destinations available while authenticated. */ enum class MainTab( @StringRes val labelRes: Int, val selectedIcon: ImageVector, @@ -24,18 +22,29 @@ enum class MainTab( labelRes = R.string.tab_devices, selectedIcon = Icons.Filled.Home, unselectedIcon = Icons.Outlined.Home, - route = "shell/devices", + route = "tab/devices", ), SCENES( labelRes = R.string.tab_scenes, selectedIcon = Icons.Filled.AutoAwesome, unselectedIcon = Icons.Outlined.AutoAwesome, - route = "shell/scenes", + route = "tab/scenes", ), ACTIVITY( labelRes = R.string.tab_activity, selectedIcon = Icons.Filled.ViewAgenda, unselectedIcon = Icons.Outlined.ViewAgenda, - route = "shell/activity", + route = "tab/activity", ), + ; + + 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 == ACTIVITY.route -> ACTIVITY + else -> null + } + } } diff --git a/android/app/src/test/kotlin/com/flagship/abox/manager/ui/navigation/AuthenticatedNavigationTest.kt b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/navigation/AuthenticatedNavigationTest.kt new file mode 100644 index 0000000..4cd9137 --- /dev/null +++ b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/navigation/AuthenticatedNavigationTest.kt @@ -0,0 +1,91 @@ +package com.flagship.abox.manager.ui.navigation + +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.DetailPresentation +import com.flagship.abox.manager.ui.shell.MainNavigationPresentation +import com.flagship.abox.manager.ui.shell.MainTab +import com.flagship.abox.manager.ui.shell.DeviceSelection +import com.flagship.abox.manager.ui.shell.detailPresentation +import com.flagship.abox.manager.ui.shell.navigationPresentation +import com.flagship.abox.manager.ui.shell.widthClass +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class AuthenticatedNavigationTest { + + @Test + fun `profile key change recreates authenticated graph`() { + val firstProfile = ProviderProfileId("first") + val secondProfile = ProviderProfileId("second") + val firstGraph = AuthenticatedGraphState(firstProfile) + .selectTab(MainTab.SCENES) + .selectDevice(DeviceName("socket"), DeviceType.SOCKET) + + val recreated = firstGraph.recreateForProfile(secondProfile) + + assertEquals(secondProfile, recreated.profileId) + assertEquals(MainTab.DEVICES, recreated.currentTab) + assertNull(recreated.selectedDevice) + } + + @Test + fun `reselecting main tab pops to its root`() { + val state = AuthenticatedGraphState(ProviderProfileId("profile")) + .selectTab(MainTab.SCENES) + .pushChild("scene/editor/manual") + + val reselected = state.selectTab(MainTab.SCENES) + + assertEquals(MainTab.SCENES, reselected.currentTab) + assertEquals(listOf(MainTab.SCENES.route), reselected.tabBackStack(MainTab.SCENES)) + } + + @Test + fun `compact uses bottom navigation`() { + assertEquals(MainNavigationPresentation.BOTTOM_NAVIGATION, navigationPresentation(widthClass(599))) + } + + @Test + fun `medium and expanded use rail`() { + assertEquals(MainNavigationPresentation.NAVIGATION_RAIL, navigationPresentation(widthClass(600))) + assertEquals(MainNavigationPresentation.NAVIGATION_RAIL, navigationPresentation(widthClass(840))) + } + + @Test + fun `profile replacement clears previously selected device`() { + val state = AuthenticatedGraphState(ProviderProfileId("first")) + .selectDevice(DeviceName("sensor"), DeviceType.TEMPERATURE_HUMIDITY) + + val replaced = state.recreateForProfile(ProviderProfileId("second")) + + assertNull(replaced.selectedDevice) + } + + @Test + fun `selected device uses pane on expanded layouts and full screen otherwise`() { + val selection = DeviceSelection( + profileId = ProviderProfileId("profile"), + deviceName = DeviceName("sensor"), + type = DeviceType.TEMPERATURE_HUMIDITY, + ) + + assertEquals(DetailPresentation.FULL_SCREEN, detailPresentation(widthClass(599), selection)) + assertEquals(DetailPresentation.PANE, detailPresentation(widthClass(840), selection)) + } + + @Test + fun `back closes child route before leaving its main tab`() { + val state = AuthenticatedGraphState(ProviderProfileId("profile")) + .selectTab(MainTab.SCENES) + .pushChild("scene/editor/manual") + + val afterBack = state.handleBack() + + assertEquals(MainTab.SCENES, afterBack.currentTab) + assertEquals(listOf(MainTab.SCENES.route), afterBack.tabBackStack(MainTab.SCENES)) + } +}