feat: add adaptive authenticated navigation

This commit is contained in:
2026-08-26 15:29:41 +08:00
parent c1c21551fb
commit 54eef78a46
8 changed files with 827 additions and 253 deletions
@@ -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<ComponentActivity>()
@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)
}
}
@@ -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,
)
}
}
}
@@ -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, List<String>> = 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<String> =
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<DeviceSelection?>(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,
)
}
}
@@ -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"
}
@@ -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
}
@@ -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)) },
)
}
}
}
@@ -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
}
}
}
@@ -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))
}
}