From 6637a7806af94014498718a6cd198a5c59b1caa1 Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Mon, 24 Aug 2026 11:54:18 +0800 Subject: [PATCH] feat(app): add authenticated app shell with top bar and bottom navigation --- android/app/build.gradle.kts | 1 + .../abox/manager/ui/ABoxManagerApp.kt | 73 +++---- .../ui/shell/AccountProviderBottomSheet.kt | 192 ++++++++++++++++++ .../abox/manager/ui/shell/MainAppShell.kt | 182 +++++++++++++++++ .../flagship/abox/manager/ui/shell/MainTab.kt | 41 ++++ .../src/main/res/values-zh-rCN/strings.xml | 26 +++ android/app/src/main/res/values/strings.xml | 26 +++ .../abox/manager/ui/shell/MainTabTest.kt | 28 +++ android/core/designsystem/build.gradle.kts | 1 + .../abox/manager/designsystem/Components.kt | 190 +++++++++++++++++ gradle/libs.versions.toml | 1 + 11 files changed, 726 insertions(+), 35 deletions(-) create mode 100644 android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/AccountProviderBottomSheet.kt create mode 100644 android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/MainAppShell.kt create mode 100644 android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/MainTab.kt create mode 100644 android/app/src/test/kotlin/com/flagship/abox/manager/ui/shell/MainTabTest.kt 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" }