feat(ui): complete ABox app experience redesign #10
+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.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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+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 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+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
设置不可达
账户弹窗是当前可见代码中唯一会打开设置界面的入口,但它始终调用
showTheme(returnToHome = false)。用户会直接进入主题详情,返回时又关闭整个设置层,因此设置首页中的渲染器、触感等选项无法访问。常规设置入口应先调用showHome(),或允许主题详情返回设置首页。