feat(ui): complete ABox app experience redesign #10

Merged
KremeCN merged 40 commits from feat/ui-ux-redesign into main 2026-08-27 23:25:08 +08:00
8 changed files with 827 additions and 253 deletions
Showing only changes of commit 54eef78a46 - Show all commits
@@ -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 },
onAvatarClick = { showAccountSheet = true },
)
if (showAccountSheet) {
AccountProviderBottomSheet(
userName = username.value, userName = username.value,
providerName = profile.displayName, providerName = profile.displayName,
onDismissRequest = { showAccountSheet = false },
onLogout = onLogout, onLogout = onLogout,
actionInProgress = actionInProgress, actionInProgress = actionInProgress,
errorMessage = errorMessage, 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) },
Review

设置不可达

账户弹窗是当前可见代码中唯一会打开设置界面的入口,但它始终调用 showTheme(returnToHome = false)。用户会直接进入主题详情,返回时又关闭整个设置层,因此设置首页中的渲染器、触感等选项无法访问。常规设置入口应先调用 showHome(),或允许主题详情返回设置首页。

**设置不可达** 账户弹窗是当前可见代码中唯一会打开设置界面的入口,但它始终调用 `showTheme(returnToHome = false)`。用户会直接进入主题详情,返回时又关闭整个设置层,因此设置首页中的渲染器、触感等选项无法访问。常规设置入口应先调用 `showHome()`,或允许主题详情返回设置首页。 <!-- pr-agent-dedup: 42945eab325e --> <!-- pr-agent-key-issue-location: a13e03273d5c -->
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,
)
}
} }
} }
@@ -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 = {
ProviderTopBar(
currentTab = currentTab,
providerName = providerName,
userName = userName,
onProviderClick = onProviderClick,
onAvatarClick = onAvatarClick,
)
},
bottomBar = {
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 ->
Row(
modifier = Modifier.fillMaxSize(),
) {
if (useRail) {
MainNavigationRail(
currentTab = currentTab,
onTabClick = onTabClick,
)
}
Box(modifier = Modifier.weight(1f).fillMaxHeight()) {
content(innerPadding)
}
}
}
}
@Composable
private fun ProviderTopBar(
currentTab: MainTab,
providerName: String,
userName: String,
onProviderClick: () -> Unit,
onAvatarClick: () -> Unit,
) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.statusBarsPadding() .statusBarsPadding()
.padding(horizontal = 16.dp, vertical = 8.dp), .padding(horizontal = 16.dp, vertical = 8.dp),
) { ) {
ABoxTopPillBar( Surface(
title = stringResource(R.string.top_bar_prompt_format, providerName), modifier = Modifier.fillMaxWidth(),
userName = userName, shape = RoundedCornerShape(32.dp),
onAvatarClick = { showAccountSheet = true }, color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f),
onAddClick = onAddClick, tonalElevation = 2.dp,
addActionContentDescription = stringResource(R.string.action_add), ) {
avatarContentDescription = stringResource(R.string.action_account), 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(
bottomBar = { text = stringResource(currentTab.labelRes),
ABoxNavigationBar { 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 -> MainTab.values().forEach { tab ->
val isSelected = (tab == activeTab) val selected = tab == currentTab
ABoxNavigationBarItem( NavigationRailItem(
selected = isSelected, selected = selected,
onClick = { onClick = { onTabClick(tab) },
activeTab = tab
onTabSelected(tab)
},
icon = { icon = {
Icon( Icon(
imageVector = if (isSelected) tab.selectedIcon else tab.unselectedIcon, imageVector = if (selected) tab.selectedIcon else tab.unselectedIcon,
contentDescription = stringResource(tab.labelRes), contentDescription = stringResource(tab.labelRes),
) )
}, },
label = { label = { Text(stringResource(tab.labelRes)) },
Text(text = stringResource(tab.labelRes))
},
) )
} }
} }
},
) { innerPadding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
) {
Crossfade(
targetState = activeTab,
label = "MainTabCrossfade",
) { tab ->
tabStateHolder.SaveableStateProvider(tab) {
when (tab) {
MainTab.DEVICES -> devicesSlot()
MainTab.SCENES -> scenesSlot()
MainTab.ACTIVITY -> activitySlot()
}
}
}
}
}
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,
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
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),
)
}
} }
@@ -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
}
}
} }
@@ -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))
}
}