diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 9991fb1..9528820 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -64,6 +64,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/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt
index e31f830..9edeefa 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
@@ -5,6 +5,7 @@ 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.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
@@ -19,9 +20,10 @@ import com.flagship.abox.manager.R
import com.flagship.abox.manager.designsystem.ABoxButton
import com.flagship.abox.manager.designsystem.ABoxSurface
import com.flagship.abox.manager.designsystem.ABoxTheme
+import com.flagship.abox.manager.ui.shell.MainAppShell
private const val FOUNDATION_ROUTE = "foundation"
-private const val PLACEHOLDER_ROUTE = "placeholder"
+private const val MAIN_SHELL_ROUTE = "main_shell"
@Composable
fun ABoxManagerApp() {
@@ -30,15 +32,27 @@ fun ABoxManagerApp() {
val navController = rememberNavController()
NavHost(
navController = navController,
- startDestination = FOUNDATION_ROUTE,
+ startDestination = MAIN_SHELL_ROUTE,
) {
- composable(FOUNDATION_ROUTE) {
- FoundationScreen(onContinue = { navController.navigate(PLACEHOLDER_ROUTE) })
+ composable(MAIN_SHELL_ROUTE) {
+ MainAppShell(
+ userName = "KremeCN",
+ providerName = "ABox",
+ onSwitchProvider = {
+ navController.navigate(FOUNDATION_ROUTE)
+ },
+ onLogout = {
+ navController.navigate(FOUNDATION_ROUTE)
+ },
+ )
}
- composable(PLACEHOLDER_ROUTE) {
- MessageScreen(
- title = stringResource(R.string.placeholder_title),
- description = stringResource(R.string.placeholder_description),
+ composable(FOUNDATION_ROUTE) {
+ FoundationScreen(
+ onContinue = {
+ navController.navigate(MAIN_SHELL_ROUTE) {
+ popUpTo(FOUNDATION_ROUTE) { inclusive = true }
+ }
+ },
)
}
}
@@ -55,10 +69,22 @@ private fun FoundationScreen(onContinue: () -> Unit) {
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
- MessageScreen(
- title = stringResource(R.string.foundation_title),
- description = stringResource(R.string.foundation_description),
- )
+ Column(
+ modifier = Modifier.padding(24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Text(
+ text = stringResource(R.string.foundation_title),
+ style = MaterialTheme.typography.headlineMedium,
+ textAlign = TextAlign.Center,
+ )
+ Text(
+ text = stringResource(R.string.foundation_description),
+ style = MaterialTheme.typography.bodyLarge,
+ textAlign = TextAlign.Center,
+ )
+ }
ABoxButton(
onClick = onContinue,
modifier = Modifier
@@ -69,26 +95,3 @@ private fun FoundationScreen(onContinue: () -> Unit) {
}
}
}
-
-@Composable
-private fun MessageScreen(
- title: String,
- description: String,
-) {
- Column(
- modifier = Modifier.padding(24.dp),
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.spacedBy(12.dp),
- ) {
- Text(
- text = title,
- style = androidx.compose.material3.MaterialTheme.typography.headlineMedium,
- textAlign = TextAlign.Center,
- )
- Text(
- text = description,
- style = androidx.compose.material3.MaterialTheme.typography.bodyLarge,
- textAlign = TextAlign.Center,
- )
- }
-}
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..c040c2d
--- /dev/null
+++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/AccountProviderBottomSheet.kt
@@ -0,0 +1,192 @@
+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.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ExitToApp
+import androidx.compose.material.icons.filled.CloudDone
+import androidx.compose.material.icons.filled.SwapHoriz
+import androidx.compose.material3.ButtonDefaults
+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.OutlinedButton
+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, switching providers, and signing out.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun AccountProviderBottomSheet(
+ userName: String?,
+ providerName: String,
+ onDismissRequest: () -> Unit,
+ onSwitchProvider: () -> Unit,
+ onLogout: () -> Unit,
+ modifier: Modifier = Modifier,
+ sheetState: SheetState = rememberModalBottomSheetState(),
+) {
+ ModalBottomSheet(
+ onDismissRequest = onDismissRequest,
+ sheetState = sheetState,
+ modifier = modifier,
+ containerColor = MaterialTheme.colorScheme.surface,
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .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 { null } ?: 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 Buttons
+ OutlinedButton(
+ onClick = {
+ onDismissRequest()
+ onSwitchProvider()
+ },
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(12.dp),
+ ) {
+ Icon(
+ imageVector = Icons.Default.SwapHoriz,
+ contentDescription = null,
+ modifier = Modifier.size(20.dp),
+ )
+ Spacer(modifier = Modifier.size(8.dp))
+ Text(stringResource(R.string.account_sheet_switch_provider))
+ }
+
+ ABoxButton(
+ onClick = {
+ onDismissRequest()
+ onLogout()
+ },
+ 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))
+ }
+
+ TextButton(
+ onClick = onDismissRequest,
+ 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..4794d05
--- /dev/null
+++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/MainAppShell.kt
@@ -0,0 +1,182 @@
+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.navigationBarsPadding
+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.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(
+ modifier: Modifier = Modifier,
+ userName: String? = "KremeCN",
+ providerName: String = "ABox",
+ initialTab: MainTab = MainTab.DEVICES,
+ onTabSelected: (MainTab) -> Unit = {},
+ onAddClick: (() -> Unit)? = null,
+ onSwitchProvider: () -> Unit = {},
+ onLogout: () -> Unit = {},
+ devicesSlot: @Composable () -> Unit = { DefaultDevicesSlot() },
+ scenesSlot: @Composable () -> Unit = { DefaultScenesSlot() },
+ activitySlot: @Composable () -> Unit = { DefaultActivitySlot() },
+) {
+ var activeTab by remember(initialTab) { mutableStateOf(initialTab) }
+ var showAccountSheet by remember { mutableStateOf(false) }
+
+ 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(
+ modifier = Modifier.navigationBarsPadding(),
+ ) {
+ 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 ->
+ when (tab) {
+ MainTab.DEVICES -> devicesSlot()
+ MainTab.SCENES -> scenesSlot()
+ MainTab.ACTIVITY -> activitySlot()
+ }
+ }
+ }
+ }
+
+ if (showAccountSheet) {
+ AccountProviderBottomSheet(
+ userName = userName,
+ providerName = providerName,
+ onDismissRequest = { showAccountSheet = false },
+ onSwitchProvider = onSwitchProvider,
+ onLogout = onLogout,
+ )
+ }
+}
+
+@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 bc378ad..e2184d0 100644
--- a/android/app/src/main/res/values-zh-rCN/strings.xml
+++ b/android/app/src/main/res/values-zh-rCN/strings.xml
@@ -5,4 +5,30 @@
继续
功能模块可以开始开发
设备、红外和情景页面将由对应模块负责人实现。
+
+
+ 设备
+ 情景
+ 活动记录
+
+
+ 问问“%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 ec5c8d7..4b954b7 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -5,4 +5,30 @@
Continue
Feature development can start
Device, infrared and scene screens will be added by their module owners.
+
+
+ Devices
+ Scenes
+ Activity
+
+
+ Ask \"%1$s\"…
+ Add
+ Account & Provider
+
+
+ Account & Provider
+ Signed in as
+ Active Provider
+ Switch 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/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 d6f33fb..6526025 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -24,6 +24,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" }