feat: add adaptive authenticated navigation
This commit is contained in:
+92
@@ -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.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.safeDrawing
|
import androidx.compose.foundation.layout.safeDrawing
|
||||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
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.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.ABoxLoadingIndicator
|
||||||
import com.flagship.abox.manager.designsystem.ABoxSurface
|
import com.flagship.abox.manager.designsystem.ABoxSurface
|
||||||
import com.flagship.abox.manager.designsystem.ABoxTheme
|
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.ProviderProfile
|
||||||
import com.flagship.abox.manager.domain.Username
|
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.LoginRoute
|
||||||
import com.flagship.abox.manager.ui.login.LoginViewModel
|
import com.flagship.abox.manager.ui.login.LoginViewModel
|
||||||
import com.flagship.abox.manager.ui.navigation.deviceDetailGraph
|
import com.flagship.abox.manager.ui.navigation.AuthenticatedNavigation
|
||||||
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.preferences.SettingsViewModel
|
import com.flagship.abox.manager.ui.preferences.SettingsViewModel
|
||||||
import com.flagship.abox.manager.ui.scene.SceneListScreen
|
import com.flagship.abox.manager.ui.provider.ProviderSelectionRoute
|
||||||
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.session.AppSessionUiState
|
import com.flagship.abox.manager.ui.session.AppSessionUiState
|
||||||
import com.flagship.abox.manager.ui.session.AppSessionViewModel
|
import com.flagship.abox.manager.ui.session.AppSessionViewModel
|
||||||
import com.flagship.abox.manager.ui.session.SessionActionsViewModel
|
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
|
@Composable
|
||||||
fun ABoxManagerApp(
|
fun ABoxManagerApp(
|
||||||
@@ -54,12 +43,14 @@ fun ABoxManagerApp(
|
|||||||
val settingsState = settingsViewModel.uiState.collectAsStateWithLifecycle().value
|
val settingsState = settingsViewModel.uiState.collectAsStateWithLifecycle().value
|
||||||
val actionError = actionState.errorMessage?.asString()
|
val actionError = actionState.errorMessage?.asString()
|
||||||
val screenKey = sessionState.screenKey()
|
val screenKey = sessionState.screenKey()
|
||||||
|
|
||||||
LaunchedEffect(screenKey) {
|
LaunchedEffect(screenKey) {
|
||||||
sessionActionsViewModel.dismissError()
|
sessionActionsViewModel.dismissError()
|
||||||
if (sessionState !is AppSessionUiState.Login) {
|
if (sessionState !is AppSessionUiState.Login) {
|
||||||
loginViewModel.clearSensitiveState()
|
loginViewModel.clearSensitiveState()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ABoxTheme(preferences = settingsState.preferences) {
|
ABoxTheme(preferences = settingsState.preferences) {
|
||||||
ABoxSurface(modifier = Modifier.fillMaxSize()) {
|
ABoxSurface(modifier = Modifier.fillMaxSize()) {
|
||||||
when (val state = sessionState) {
|
when (val state = sessionState) {
|
||||||
@@ -92,6 +83,7 @@ fun ABoxManagerApp(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun AuthenticatedNavHost(
|
private fun AuthenticatedNavHost(
|
||||||
username: Username,
|
username: Username,
|
||||||
@@ -100,100 +92,25 @@ private fun AuthenticatedNavHost(
|
|||||||
errorMessage: String?,
|
errorMessage: String?,
|
||||||
onLogout: () -> Unit,
|
onLogout: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val navController = rememberNavController()
|
var showAccountSheet by remember { mutableStateOf(false) }
|
||||||
NavHost(
|
|
||||||
navController = navController,
|
AuthenticatedNavigation(
|
||||||
startDestination = "home",
|
username = username,
|
||||||
) {
|
profile = profile,
|
||||||
composable("home") {
|
onLogout = onLogout,
|
||||||
MainAppShell(
|
onProviderClick = { showAccountSheet = true },
|
||||||
userName = username.value,
|
onAvatarClick = { showAccountSheet = true },
|
||||||
providerName = profile.displayName,
|
)
|
||||||
onLogout = onLogout,
|
|
||||||
actionInProgress = actionInProgress,
|
if (showAccountSheet) {
|
||||||
errorMessage = errorMessage,
|
AccountProviderBottomSheet(
|
||||||
devicesSlot = {
|
userName = username.value,
|
||||||
val encodedProfileId = android.net.Uri.encode(profile.id.value)
|
providerName = profile.displayName,
|
||||||
DeviceListScreen(
|
onDismissRequest = { showAccountSheet = false },
|
||||||
onSocketClick = {
|
onLogout = onLogout,
|
||||||
navController.navigateToSocket(
|
actionInProgress = actionInProgress,
|
||||||
profileId = profile.id,
|
errorMessage = errorMessage,
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+391
@@ -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 STARTUP = "startup"
|
||||||
const val PROVIDER_SELECTION = "provider_selection"
|
const val PROVIDER_SELECTION = "provider_selection"
|
||||||
const val LOGIN = "login"
|
const val LOGIN = "login"
|
||||||
|
|
||||||
/** Login-complete route contract; the device navigation owner replaces its placeholder. */
|
|
||||||
const val AUTHENTICATED = "authenticated"
|
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
|
package com.flagship.abox.manager.ui.shell
|
||||||
|
|
||||||
import androidx.compose.animation.Crossfade
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
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.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.statusBarsPadding
|
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.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.NavigationRail
|
||||||
|
import androidx.compose.material3.NavigationRailItem
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.res.stringResource
|
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 androidx.compose.ui.unit.dp
|
||||||
import com.flagship.abox.manager.R
|
import com.flagship.abox.manager.R
|
||||||
import com.flagship.abox.manager.designsystem.ABoxNavigationBar
|
import com.flagship.abox.manager.designsystem.ABoxNavigationBar
|
||||||
import com.flagship.abox.manager.designsystem.ABoxNavigationBarItem
|
import com.flagship.abox.manager.designsystem.ABoxNavigationBarItem
|
||||||
import com.flagship.abox.manager.designsystem.ABoxTopPillBar
|
import com.flagship.abox.manager.designsystem.ABoxUserAvatar
|
||||||
import com.flagship.abox.manager.ui.devicecontrollist.DeviceListScreen
|
|
||||||
|
|
||||||
/**
|
/** Stateless authenticated shell; navigation state belongs to AuthenticatedNavigation. */
|
||||||
* 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)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MainAppShell(
|
fun MainAppShell(
|
||||||
|
currentTab: MainTab,
|
||||||
|
windowSizeClass: ABoxWindowSizeClass,
|
||||||
userName: String,
|
userName: String,
|
||||||
providerName: String,
|
providerName: String,
|
||||||
onLogout: () -> Unit,
|
onTabClick: (MainTab) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
onProviderClick: () -> Unit,
|
||||||
initialTab: MainTab = MainTab.DEVICES,
|
onAvatarClick: () -> Unit,
|
||||||
onTabSelected: (MainTab) -> Unit = {},
|
content: @Composable (PaddingValues) -> Unit,
|
||||||
onAddClick: (() -> Unit)? = null,
|
|
||||||
actionInProgress: Boolean = false,
|
|
||||||
errorMessage: String? = null,
|
|
||||||
devicesSlot: @Composable () -> Unit = { DeviceListScreen() },
|
|
||||||
scenesSlot: @Composable () -> Unit = { DefaultScenesSlot() },
|
|
||||||
activitySlot: @Composable () -> Unit = { DefaultActivitySlot() },
|
|
||||||
) {
|
) {
|
||||||
var activeTab by rememberSaveable { mutableStateOf(initialTab) }
|
val useRail = navigationPresentation(windowSizeClass) == MainNavigationPresentation.NAVIGATION_RAIL
|
||||||
var showAccountSheet by remember { mutableStateOf(false) }
|
|
||||||
val tabStateHolder = rememberSaveableStateHolder()
|
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
topBar = {
|
topBar = {
|
||||||
Box(
|
ProviderTopBar(
|
||||||
modifier = Modifier
|
currentTab = currentTab,
|
||||||
.fillMaxWidth()
|
providerName = providerName,
|
||||||
.statusBarsPadding()
|
userName = userName,
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
onProviderClick = onProviderClick,
|
||||||
) {
|
onAvatarClick = onAvatarClick,
|
||||||
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),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
bottomBar = {
|
bottomBar = {
|
||||||
ABoxNavigationBar {
|
if (!useRail) {
|
||||||
MainTab.values().forEach { tab ->
|
ABoxNavigationBar {
|
||||||
val isSelected = (tab == activeTab)
|
MainTab.values().forEach { tab ->
|
||||||
ABoxNavigationBarItem(
|
val selected = tab == currentTab
|
||||||
selected = isSelected,
|
ABoxNavigationBarItem(
|
||||||
onClick = {
|
selected = selected,
|
||||||
activeTab = tab
|
onClick = { onTabClick(tab) },
|
||||||
onTabSelected(tab)
|
icon = {
|
||||||
},
|
Icon(
|
||||||
icon = {
|
imageVector = if (selected) tab.selectedIcon else tab.unselectedIcon,
|
||||||
Icon(
|
contentDescription = stringResource(tab.labelRes),
|
||||||
imageVector = if (isSelected) tab.selectedIcon else tab.unselectedIcon,
|
)
|
||||||
contentDescription = stringResource(tab.labelRes),
|
},
|
||||||
)
|
label = { Text(stringResource(tab.labelRes)) },
|
||||||
},
|
)
|
||||||
label = {
|
}
|
||||||
Text(text = stringResource(tab.labelRes))
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
Box(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxSize(),
|
||||||
.fillMaxSize()
|
|
||||||
.padding(innerPadding),
|
|
||||||
) {
|
) {
|
||||||
Crossfade(
|
if (useRail) {
|
||||||
targetState = activeTab,
|
MainNavigationRail(
|
||||||
label = "MainTabCrossfade",
|
currentTab = currentTab,
|
||||||
) { tab ->
|
onTabClick = onTabClick,
|
||||||
tabStateHolder.SaveableStateProvider(tab) {
|
)
|
||||||
when (tab) {
|
}
|
||||||
MainTab.DEVICES -> devicesSlot()
|
Box(modifier = Modifier.weight(1f).fillMaxHeight()) {
|
||||||
MainTab.SCENES -> scenesSlot()
|
content(innerPadding)
|
||||||
MainTab.ACTIVITY -> activitySlot()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showAccountSheet) {
|
|
||||||
AccountProviderBottomSheet(
|
|
||||||
userName = userName,
|
|
||||||
providerName = providerName,
|
|
||||||
onDismissRequest = { showAccountSheet = false },
|
|
||||||
onLogout = onLogout,
|
|
||||||
actionInProgress = actionInProgress,
|
|
||||||
errorMessage = errorMessage,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DefaultScenesSlot() {
|
private fun ProviderTopBar(
|
||||||
SlotPlaceholder(
|
currentTab: MainTab,
|
||||||
title = stringResource(R.string.scenes_placeholder_title),
|
providerName: String,
|
||||||
description = stringResource(R.string.scenes_placeholder_description),
|
userName: String,
|
||||||
)
|
onProviderClick: () -> Unit,
|
||||||
}
|
onAvatarClick: () -> Unit,
|
||||||
|
|
||||||
@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,
|
|
||||||
) {
|
) {
|
||||||
Column(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxWidth()
|
||||||
.padding(24.dp),
|
.statusBarsPadding()
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
verticalArrangement = Arrangement.Center,
|
|
||||||
) {
|
) {
|
||||||
Text(
|
Surface(
|
||||||
text = title,
|
modifier = Modifier.fillMaxWidth(),
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
shape = RoundedCornerShape(32.dp),
|
||||||
textAlign = TextAlign.Center,
|
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f),
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
tonalElevation = 2.dp,
|
||||||
)
|
) {
|
||||||
Text(
|
Row(
|
||||||
text = description,
|
modifier = Modifier
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
.fillMaxWidth()
|
||||||
textAlign = TextAlign.Center,
|
.height(64.dp)
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
.padding(horizontal = 8.dp),
|
||||||
modifier = Modifier.padding(top = 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 androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import com.flagship.abox.manager.R
|
import com.flagship.abox.manager.R
|
||||||
|
|
||||||
/**
|
/** Main destinations available while authenticated. */
|
||||||
* Main bottom navigation tabs for the authenticated application shell.
|
|
||||||
*/
|
|
||||||
enum class MainTab(
|
enum class MainTab(
|
||||||
@StringRes val labelRes: Int,
|
@StringRes val labelRes: Int,
|
||||||
val selectedIcon: ImageVector,
|
val selectedIcon: ImageVector,
|
||||||
@@ -24,18 +22,29 @@ enum class MainTab(
|
|||||||
labelRes = R.string.tab_devices,
|
labelRes = R.string.tab_devices,
|
||||||
selectedIcon = Icons.Filled.Home,
|
selectedIcon = Icons.Filled.Home,
|
||||||
unselectedIcon = Icons.Outlined.Home,
|
unselectedIcon = Icons.Outlined.Home,
|
||||||
route = "shell/devices",
|
route = "tab/devices",
|
||||||
),
|
),
|
||||||
SCENES(
|
SCENES(
|
||||||
labelRes = R.string.tab_scenes,
|
labelRes = R.string.tab_scenes,
|
||||||
selectedIcon = Icons.Filled.AutoAwesome,
|
selectedIcon = Icons.Filled.AutoAwesome,
|
||||||
unselectedIcon = Icons.Outlined.AutoAwesome,
|
unselectedIcon = Icons.Outlined.AutoAwesome,
|
||||||
route = "shell/scenes",
|
route = "tab/scenes",
|
||||||
),
|
),
|
||||||
ACTIVITY(
|
ACTIVITY(
|
||||||
labelRes = R.string.tab_activity,
|
labelRes = R.string.tab_activity,
|
||||||
selectedIcon = Icons.Filled.ViewAgenda,
|
selectedIcon = Icons.Filled.ViewAgenda,
|
||||||
unselectedIcon = Icons.Outlined.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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+91
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user