diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 5cc61b1..f8843f4 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -79,6 +79,7 @@ dependencies {
implementation(libs.compose.ui)
implementation(libs.compose.ui.tooling.preview)
implementation(libs.compose.material3)
+ implementation(libs.compose.material.icons.extended)
implementation(libs.hilt.android)
kapt(libs.hilt.compiler)
diff --git a/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/login/LoginScreenTest.kt b/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/login/LoginScreenTest.kt
index 451585b..7b6e87e 100644
--- a/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/login/LoginScreenTest.kt
+++ b/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/login/LoginScreenTest.kt
@@ -1,9 +1,11 @@
package com.flagship.abox.manager.ui.login
import androidx.activity.ComponentActivity
+import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
@@ -29,7 +31,7 @@ class LoginScreenTest {
)
@Test
- fun backControlsSwitchProviderBeforeLogin() {
+ fun topBackControlSwitchesProviderBeforeLogin() {
var switches = 0
composeRule.setContent {
ABoxTheme {
@@ -49,8 +51,8 @@ class LoginScreenTest {
composeRule.onNodeWithContentDescription("back-to-providers")
.assertIsEnabled()
.performClick()
- composeRule.onNodeWithText("Back to providers").performClick()
- assertEquals(2, switches)
+ composeRule.onAllNodesWithText("Back to providers").assertCountEquals(0)
+ assertEquals(1, switches)
}
@Test
@@ -94,6 +96,6 @@ class LoginScreenTest {
}
composeRule.onNodeWithContentDescription("back-to-providers").assertIsNotEnabled()
- composeRule.onNodeWithText("Back to providers").assertIsNotEnabled()
+ composeRule.onAllNodesWithText("Back to providers").assertCountEquals(0)
}
}
diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt
index 5f41001..9a2dc11 100644
--- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt
+++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt
@@ -14,13 +14,13 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
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.ui.authenticated.AuthenticatedPlaceholderScreen
import com.flagship.abox.manager.ui.login.LoginRoute
import com.flagship.abox.manager.ui.login.LoginViewModel
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
@Composable
fun ABoxManagerApp(
@@ -57,12 +57,12 @@ fun ABoxManagerApp(
onSwitchProvider = sessionActionsViewModel::switchProvider,
viewModel = loginViewModel,
)
- is AppSessionUiState.Authenticated -> AuthenticatedPlaceholderScreen(
- profile = state.profile,
+ is AppSessionUiState.Authenticated -> MainAppShell(
+ userName = state.username.value,
+ providerName = state.profile.displayName,
+ onLogout = sessionActionsViewModel::logout,
actionInProgress = actionState.inProgress,
errorMessage = actionState.errorMessage,
- onLogout = sessionActionsViewModel::logout,
- onSwitchProvider = sessionActionsViewModel::switchProvider,
)
}
}
diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/authenticated/AuthenticatedScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/authenticated/AuthenticatedScreen.kt
deleted file mode 100644
index 1df3284..0000000
--- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/authenticated/AuthenticatedScreen.kt
+++ /dev/null
@@ -1,79 +0,0 @@
-package com.flagship.abox.manager.ui.authenticated
-
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.widthIn
-import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.verticalScroll
-import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Scaffold
-import androidx.compose.material3.Text
-import androidx.compose.material3.TopAppBar
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.unit.dp
-import com.flagship.abox.manager.R
-import com.flagship.abox.manager.designsystem.ABoxButton
-import com.flagship.abox.manager.domain.ProviderProfile
-
-@OptIn(ExperimentalMaterial3Api::class)
-@Composable
-fun AuthenticatedPlaceholderScreen(
- profile: ProviderProfile,
- actionInProgress: Boolean,
- errorMessage: String?,
- onLogout: () -> Unit,
- onSwitchProvider: () -> Unit,
-) {
- Scaffold(
- topBar = {
- TopAppBar(
- title = { Text(stringResource(R.string.authenticated_title, profile.displayName)) },
- )
- },
- ) { contentPadding ->
- Box(
- modifier = Modifier
- .fillMaxSize()
- .padding(contentPadding),
- contentAlignment = Alignment.Center,
- ) {
- Column(
- modifier = Modifier
- .widthIn(max = 520.dp)
- .fillMaxWidth()
- .verticalScroll(rememberScrollState())
- .padding(24.dp),
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.spacedBy(14.dp),
- ) {
- Text(
- text = stringResource(R.string.authenticated_description),
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- errorMessage?.let { Text(it, color = MaterialTheme.colorScheme.error) }
- ABoxButton(
- onClick = onLogout,
- enabled = !actionInProgress,
- modifier = Modifier.fillMaxWidth(),
- ) {
- Text(stringResource(R.string.action_logout))
- }
- ABoxButton(
- onClick = onSwitchProvider,
- enabled = !actionInProgress,
- modifier = Modifier.fillMaxWidth(),
- ) {
- Text(stringResource(R.string.action_switch_provider))
- }
- }
- }
- }
-}
diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginScreen.kt
index 51b6ddb..4ef1e85 100644
--- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginScreen.kt
+++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginScreen.kt
@@ -184,13 +184,6 @@ fun LoginScreen(
}
else Text(stringResource(R.string.action_login))
}
- ABoxButton(
- onClick = onSwitchProvider,
- enabled = navigationEnabled,
- modifier = Modifier.fillMaxWidth(),
- ) {
- Text(stringResource(R.string.login_back_to_providers))
- }
}
}
}
diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/session/AppSessionViewModel.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/session/AppSessionViewModel.kt
index 12f57aa..893e6a1 100644
--- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/session/AppSessionViewModel.kt
+++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/session/AppSessionViewModel.kt
@@ -8,6 +8,7 @@ import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.ProviderProfileStore
import com.flagship.abox.manager.domain.Session
import com.flagship.abox.manager.domain.SessionCoordinator
+import com.flagship.abox.manager.domain.Username
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
@@ -99,7 +100,7 @@ class AppSessionViewModel @Inject constructor(
bootstrapError: String?,
): AppSessionUiState = when {
profile == null -> AppSessionUiState.ProviderSelection
- session?.profileId == profile.id -> AppSessionUiState.Authenticated(profile)
+ session?.profileId == profile.id -> AppSessionUiState.Authenticated(profile, session.username)
else -> AppSessionUiState.Login(profile, bootstrapError)
}
}
@@ -123,5 +124,8 @@ sealed interface AppSessionUiState {
val profile: ProviderProfile,
val bootstrapError: String? = null,
) : AppSessionUiState
- data class Authenticated(val profile: ProviderProfile) : AppSessionUiState
+ data class Authenticated(
+ val profile: ProviderProfile,
+ val username: Username,
+ ) : AppSessionUiState
}
diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/AccountProviderBottomSheet.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/AccountProviderBottomSheet.kt
new file mode 100644
index 0000000..106c288
--- /dev/null
+++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/AccountProviderBottomSheet.kt
@@ -0,0 +1,183 @@
+package com.flagship.abox.manager.ui.shell
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ExitToApp
+import androidx.compose.material.icons.filled.CloudDone
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.SheetState
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+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.font.FontWeight
+import androidx.compose.ui.unit.dp
+import com.flagship.abox.manager.R
+import com.flagship.abox.manager.designsystem.ABoxButton
+import com.flagship.abox.manager.designsystem.ABoxUserAvatar
+
+/**
+ * Account and Provider management bottom sheet triggered by clicking the top bar user avatar.
+ * Allows viewing current session info and signing out.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun AccountProviderBottomSheet(
+ userName: String,
+ providerName: String,
+ onDismissRequest: () -> Unit,
+ onLogout: () -> Unit,
+ actionInProgress: Boolean,
+ errorMessage: String?,
+ modifier: Modifier = Modifier,
+ sheetState: SheetState = rememberModalBottomSheetState(),
+) {
+ ModalBottomSheet(
+ onDismissRequest = onDismissRequest,
+ sheetState = sheetState,
+ modifier = modifier,
+ containerColor = MaterialTheme.colorScheme.surface,
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .verticalScroll(rememberScrollState())
+ .padding(horizontal = 24.dp)
+ .padding(bottom = 32.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ // Header with large avatar & user info
+ ABoxUserAvatar(
+ name = userName,
+ onClick = {},
+ size = 56.dp,
+ )
+
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ Text(
+ text = userName.ifBlank { stringResource(R.string.account_sheet_title) },
+ style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold),
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ Text(
+ text = stringResource(R.string.account_sheet_signed_in_as),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+
+ HorizontalDivider(
+ modifier = Modifier.padding(vertical = 4.dp),
+ color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
+ )
+
+ // Provider Info Card
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
+ ),
+ shape = RoundedCornerShape(16.dp),
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Box(
+ modifier = Modifier
+ .size(40.dp)
+ .clip(CircleShape)
+ .background(MaterialTheme.colorScheme.primaryContainer),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ imageVector = Icons.Default.CloudDone,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onPrimaryContainer,
+ modifier = Modifier.size(22.dp),
+ )
+ }
+
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = stringResource(R.string.account_sheet_provider),
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Text(
+ text = providerName,
+ style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold),
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ // Action button
+ ABoxButton(
+ onClick = onLogout,
+ enabled = !actionInProgress,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.ExitToApp,
+ contentDescription = null,
+ modifier = Modifier.size(20.dp),
+ )
+ Spacer(modifier = Modifier.size(8.dp))
+ Text(stringResource(R.string.account_sheet_logout))
+ }
+
+ errorMessage?.let { error ->
+ Text(
+ text = error,
+ color = MaterialTheme.colorScheme.error,
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ }
+
+ TextButton(
+ onClick = onDismissRequest,
+ enabled = !actionInProgress,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text(
+ text = stringResource(R.string.account_sheet_close),
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/MainAppShell.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/MainAppShell.kt
new file mode 100644
index 0000000..6c2c8ce
--- /dev/null
+++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/MainAppShell.kt
@@ -0,0 +1,186 @@
+package com.flagship.abox.manager.ui.shell
+
+import androidx.compose.animation.Crossfade
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+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.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+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
+
+/**
+ * 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
+fun MainAppShell(
+ 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 = { DefaultDevicesSlot() },
+ scenesSlot: @Composable () -> Unit = { DefaultScenesSlot() },
+ activitySlot: @Composable () -> Unit = { DefaultActivitySlot() },
+) {
+ var activeTab by rememberSaveable { mutableStateOf(initialTab) }
+ var showAccountSheet by remember { mutableStateOf(false) }
+ val tabStateHolder = rememberSaveableStateHolder()
+
+ Scaffold(
+ 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),
+ )
+ }
+ },
+ 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))
+ },
+ )
+ }
+ }
+ },
+ ) { 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 DefaultDevicesSlot() {
+ SlotPlaceholder(
+ title = stringResource(R.string.devices_placeholder_title),
+ description = stringResource(R.string.devices_placeholder_description),
+ )
+}
+
+@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),
+ )
+ }
+}
diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/MainTab.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/MainTab.kt
new file mode 100644
index 0000000..32f5cac
--- /dev/null
+++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/MainTab.kt
@@ -0,0 +1,41 @@
+package com.flagship.abox.manager.ui.shell
+
+import androidx.annotation.StringRes
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.AutoAwesome
+import androidx.compose.material.icons.filled.Home
+import androidx.compose.material.icons.filled.ViewAgenda
+import androidx.compose.material.icons.outlined.AutoAwesome
+import androidx.compose.material.icons.outlined.Home
+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.
+ */
+enum class MainTab(
+ @StringRes val labelRes: Int,
+ val selectedIcon: ImageVector,
+ val unselectedIcon: ImageVector,
+ val route: String,
+) {
+ DEVICES(
+ labelRes = R.string.tab_devices,
+ selectedIcon = Icons.Filled.Home,
+ unselectedIcon = Icons.Outlined.Home,
+ route = "shell/devices",
+ ),
+ SCENES(
+ labelRes = R.string.tab_scenes,
+ selectedIcon = Icons.Filled.AutoAwesome,
+ unselectedIcon = Icons.Outlined.AutoAwesome,
+ route = "shell/scenes",
+ ),
+ ACTIVITY(
+ labelRes = R.string.tab_activity,
+ selectedIcon = Icons.Filled.ViewAgenda,
+ unselectedIcon = Icons.Outlined.ViewAgenda,
+ route = "shell/activity",
+ ),
+}
diff --git a/android/app/src/main/res/values-zh-rCN/strings.xml b/android/app/src/main/res/values-zh-rCN/strings.xml
index 28b7def..696dc16 100644
--- a/android/app/src/main/res/values-zh-rCN/strings.xml
+++ b/android/app/src/main/res/values-zh-rCN/strings.xml
@@ -8,7 +8,6 @@
无需网络,通过本地模拟设备体验应用。
登录 %1$s
当前提供方:%1$s
- 返回提供方选择
用户名
密码
记住用户名
@@ -24,4 +23,23 @@
切换提供方
已连接到 %1$s
登录后入口已经就绪,设备导航将由对应模块负责人接入。
+
+
+ 设备
+ 情景
+ 活动记录
+ 问问“%1$s”…
+ 添加
+ 账户与提供方
+ 账户与提供方
+ 当前登录为
+ 当前提供方
+ 退出登录
+ 关闭
+ 设备列表
+ 设备列表与控制卡片插槽(由 alZerNest 模块实现)。
+ 情景模式
+ 情景动作与执行插槽(由 tzh 模块实现)。
+ 活动记录
+ 未记录任何事件。
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index 291774e..c8818bb 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -8,7 +8,6 @@
Explore the app with simulated local devices and no network connection.
Sign in to %1$s
Selected provider: %1$s
- Back to providers
Username
Password
Remember username
@@ -24,4 +23,23 @@
Switch provider
Connected to %1$s
The authenticated route is ready. Device navigation will be added by its module owner.
+
+
+ Devices
+ Scenes
+ Activity
+ Ask "%1$s"…
+ Add
+ Account & Provider
+ Account & Provider
+ Signed in as
+ Active Provider
+ Sign Out
+ Close
+ Device List
+ Device list and control cards slot for alZerNest.
+ Scene Mode
+ Scene actions and execution slot for tzh.
+ Activity Log
+ No recorded events yet.
diff --git a/android/app/src/test/kotlin/com/flagship/abox/manager/ui/session/AppSessionViewModelTest.kt b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/session/AppSessionViewModelTest.kt
index b931400..8c695af 100644
--- a/android/app/src/test/kotlin/com/flagship/abox/manager/ui/session/AppSessionViewModelTest.kt
+++ b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/session/AppSessionViewModelTest.kt
@@ -21,6 +21,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
@@ -117,6 +118,8 @@ class AppSessionViewModelTest {
val state = viewModel.uiState.value
assertTrue(state is AppSessionUiState.Authenticated)
+ val authenticated = state as AppSessionUiState.Authenticated
+ assertEquals(credentials.username, authenticated.username)
}
private companion object {
diff --git a/android/app/src/test/kotlin/com/flagship/abox/manager/ui/shell/MainTabTest.kt b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/shell/MainTabTest.kt
new file mode 100644
index 0000000..b65fdf6
--- /dev/null
+++ b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/shell/MainTabTest.kt
@@ -0,0 +1,28 @@
+package com.flagship.abox.manager.ui.shell
+
+import com.flagship.abox.manager.R
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Test
+
+class MainTabTest {
+
+ @Test
+ fun `all main tabs have distinct non-empty routes and valid label resources`() {
+ val tabs = MainTab.values()
+ assertEquals(3, tabs.size)
+
+ val routes = tabs.map { it.route }
+ assertEquals(routes.distinct().size, tabs.size)
+
+ tabs.forEach { tab ->
+ assertNotNull(tab.selectedIcon)
+ assertNotNull(tab.unselectedIcon)
+ assert(tab.route.isNotBlank())
+ }
+
+ assertEquals(R.string.tab_devices, MainTab.DEVICES.labelRes)
+ assertEquals(R.string.tab_scenes, MainTab.SCENES.labelRes)
+ assertEquals(R.string.tab_activity, MainTab.ACTIVITY.labelRes)
+ }
+}
diff --git a/android/core/designsystem/build.gradle.kts b/android/core/designsystem/build.gradle.kts
index ed36165..18a7dfe 100644
--- a/android/core/designsystem/build.gradle.kts
+++ b/android/core/designsystem/build.gradle.kts
@@ -34,5 +34,6 @@ dependencies {
implementation(libs.compose.ui)
implementation(libs.compose.ui.tooling.preview)
implementation(libs.compose.material3)
+ implementation(libs.compose.material.icons.extended)
debugImplementation(libs.compose.ui.tooling)
}
diff --git a/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Components.kt b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Components.kt
index b05599d..8f3fb5a 100644
--- a/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Components.kt
+++ b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Components.kt
@@ -1,12 +1,43 @@
package com.flagship.abox.manager.designsystem
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.LocationOn
+import androidx.compose.material.icons.filled.Person
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.NavigationBar
+import androidx.compose.material3.NavigationBarItem
+import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+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
@Composable
fun ABoxSurface(
@@ -36,3 +67,162 @@ fun ABoxButton(
fun ABoxLoadingIndicator(modifier: Modifier = Modifier) {
CircularProgressIndicator(modifier = modifier)
}
+
+/**
+ * Circular user avatar displaying the initial character of username or a default person icon.
+ * Styled with an accented profile ring.
+ */
+@Composable
+fun ABoxUserAvatar(
+ name: String?,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ size: Dp = 36.dp,
+ contentDescription: String? = null,
+) {
+ val initial = name?.trim()?.takeIf { it.isNotEmpty() }?.take(1)?.uppercase()
+ Box(
+ modifier = modifier
+ .size(size)
+ .clip(CircleShape)
+ .background(MaterialTheme.colorScheme.primaryContainer)
+ .border(
+ width = 2.dp,
+ color = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f),
+ shape = CircleShape,
+ )
+ .clickable(onClickLabel = contentDescription, onClick = onClick),
+ contentAlignment = Alignment.Center,
+ ) {
+ if (initial != null) {
+ Text(
+ text = initial,
+ style = MaterialTheme.typography.titleMedium.copy(
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onPrimaryContainer,
+ ),
+ )
+ } else {
+ Icon(
+ imageVector = Icons.Default.Person,
+ contentDescription = contentDescription,
+ tint = MaterialTheme.colorScheme.onPrimaryContainer,
+ modifier = Modifier.size(size * 0.6f),
+ )
+ }
+ }
+}
+
+/**
+ * Material 3 pill top bar containing leading icon, title/provider label,
+ * an optional '+' action button, and user avatar.
+ */
+@Composable
+fun ABoxTopPillBar(
+ title: String,
+ onAvatarClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ userName: String? = null,
+ leadingIcon: ImageVector = Icons.Default.LocationOn,
+ onAddClick: (() -> Unit)? = null,
+ addActionContentDescription: String? = "Add",
+ avatarContentDescription: String? = "Account",
+) {
+ Surface(
+ modifier = modifier
+ .fillMaxWidth()
+ .height(56.dp),
+ shape = RoundedCornerShape(28.dp),
+ color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f),
+ tonalElevation = 2.dp,
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Icon(
+ imageVector = leadingIcon,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.size(22.dp),
+ )
+
+ Text(
+ text = title,
+ style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
+ color = MaterialTheme.colorScheme.onSurface,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.weight(1f),
+ )
+
+ if (onAddClick != null) {
+ IconButton(
+ onClick = onAddClick,
+ modifier = Modifier.size(36.dp),
+ ) {
+ Icon(
+ imageVector = Icons.Default.Add,
+ contentDescription = addActionContentDescription,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+
+ ABoxUserAvatar(
+ name = userName,
+ onClick = onAvatarClick,
+ size = 36.dp,
+ contentDescription = avatarContentDescription,
+ )
+ }
+ }
+}
+
+/**
+ * ABox standard Material 3 Navigation Bar with pill indicator styling.
+ */
+@Composable
+fun ABoxNavigationBar(
+ modifier: Modifier = Modifier,
+ containerColor: Color = MaterialTheme.colorScheme.surface,
+ content: @Composable RowScope.() -> Unit,
+) {
+ NavigationBar(
+ modifier = modifier,
+ containerColor = containerColor,
+ content = content,
+ )
+}
+
+/**
+ * ABox standard Navigation Bar Item.
+ */
+@Composable
+fun RowScope.ABoxNavigationBarItem(
+ selected: Boolean,
+ onClick: () -> Unit,
+ icon: @Composable () -> Unit,
+ label: @Composable (() -> Unit)? = null,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+) {
+ NavigationBarItem(
+ selected = selected,
+ onClick = onClick,
+ icon = icon,
+ label = label,
+ modifier = modifier,
+ enabled = enabled,
+ colors = NavigationBarItemDefaults.colors(
+ indicatorColor = MaterialTheme.colorScheme.secondaryContainer,
+ selectedIconColor = MaterialTheme.colorScheme.onSecondaryContainer,
+ unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
+ selectedTextColor = MaterialTheme.colorScheme.onSurface,
+ unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
+ ),
+ )
+}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 2b00e05..1ad4e45 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -26,6 +26,7 @@ compose-ui = { module = "androidx.compose.ui:ui" }
compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
compose-material3 = { module = "androidx.compose.material3:material3" }
+compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" }
compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" }
compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" }
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }