Compare commits

...
1 Commits
11 changed files with 726 additions and 35 deletions
+1
View File
@@ -64,6 +64,7 @@ dependencies {
implementation(libs.compose.ui) implementation(libs.compose.ui)
implementation(libs.compose.ui.tooling.preview) implementation(libs.compose.ui.tooling.preview)
implementation(libs.compose.material3) implementation(libs.compose.material3)
implementation(libs.compose.material.icons.extended)
implementation(libs.hilt.android) implementation(libs.hilt.android)
kapt(libs.hilt.compiler) kapt(libs.hilt.compiler)
@@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Column
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.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment 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.ABoxButton
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.ui.shell.MainAppShell
private const val FOUNDATION_ROUTE = "foundation" private const val FOUNDATION_ROUTE = "foundation"
private const val PLACEHOLDER_ROUTE = "placeholder" private const val MAIN_SHELL_ROUTE = "main_shell"
@Composable @Composable
fun ABoxManagerApp() { fun ABoxManagerApp() {
@@ -30,15 +32,27 @@ fun ABoxManagerApp() {
val navController = rememberNavController() val navController = rememberNavController()
NavHost( NavHost(
navController = navController, navController = navController,
startDestination = FOUNDATION_ROUTE, startDestination = MAIN_SHELL_ROUTE,
) { ) {
composable(FOUNDATION_ROUTE) { composable(MAIN_SHELL_ROUTE) {
FoundationScreen(onContinue = { navController.navigate(PLACEHOLDER_ROUTE) }) MainAppShell(
userName = "KremeCN",
providerName = "ABox",
onSwitchProvider = {
navController.navigate(FOUNDATION_ROUTE)
},
onLogout = {
navController.navigate(FOUNDATION_ROUTE)
},
)
} }
composable(PLACEHOLDER_ROUTE) { composable(FOUNDATION_ROUTE) {
MessageScreen( FoundationScreen(
title = stringResource(R.string.placeholder_title), onContinue = {
description = stringResource(R.string.placeholder_description), navController.navigate(MAIN_SHELL_ROUTE) {
popUpTo(FOUNDATION_ROUTE) { inclusive = true }
}
},
) )
} }
} }
@@ -55,10 +69,22 @@ private fun FoundationScreen(onContinue: () -> Unit) {
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
) { ) {
MessageScreen( Column(
title = stringResource(R.string.foundation_title), modifier = Modifier.padding(24.dp),
description = stringResource(R.string.foundation_description), 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( ABoxButton(
onClick = onContinue, onClick = onContinue,
modifier = Modifier 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,
)
}
}
@@ -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,
)
}
}
}
}
@@ -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),
)
}
}
@@ -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",
),
}
@@ -5,4 +5,30 @@
<string name="continue_action">继续</string> <string name="continue_action">继续</string>
<string name="placeholder_title">功能模块可以开始开发</string> <string name="placeholder_title">功能模块可以开始开发</string>
<string name="placeholder_description">设备、红外和情景页面将由对应模块负责人实现。</string> <string name="placeholder_description">设备、红外和情景页面将由对应模块负责人实现。</string>
<!-- Navigation Tabs -->
<string name="tab_devices">设备</string>
<string name="tab_scenes">情景</string>
<string name="tab_activity">活动记录</string>
<!-- Top Bar Actions -->
<string name="top_bar_prompt_format">问问“%1$s”&#8230;</string>
<string name="action_add">添加</string>
<string name="action_account">账户与提供方</string>
<!-- Account & Provider Bottom Sheet -->
<string name="account_sheet_title">账户与提供方</string>
<string name="account_sheet_signed_in_as">当前登录为</string>
<string name="account_sheet_provider">当前提供方</string>
<string name="account_sheet_switch_provider">切换提供方</string>
<string name="account_sheet_logout">退出登录</string>
<string name="account_sheet_close">关闭</string>
<!-- Screen Placeholders -->
<string name="devices_placeholder_title">设备列表</string>
<string name="devices_placeholder_description">设备列表与控制卡片插槽(由 alZerNest 模块实现)。</string>
<string name="scenes_placeholder_title">情景模式</string>
<string name="scenes_placeholder_description">情景动作与执行插槽(由 tzh 模块实现)。</string>
<string name="activity_placeholder_title">活动记录</string>
<string name="activity_placeholder_description">未记录任何事件。</string>
</resources> </resources>
@@ -5,4 +5,30 @@
<string name="continue_action">Continue</string> <string name="continue_action">Continue</string>
<string name="placeholder_title">Feature development can start</string> <string name="placeholder_title">Feature development can start</string>
<string name="placeholder_description">Device, infrared and scene screens will be added by their module owners.</string> <string name="placeholder_description">Device, infrared and scene screens will be added by their module owners.</string>
<!-- Navigation Tabs -->
<string name="tab_devices">Devices</string>
<string name="tab_scenes">Scenes</string>
<string name="tab_activity">Activity</string>
<!-- Top Bar Actions -->
<string name="top_bar_prompt_format">Ask \"%1$s\"&#8230;</string>
<string name="action_add">Add</string>
<string name="action_account">Account &amp; Provider</string>
<!-- Account & Provider Bottom Sheet -->
<string name="account_sheet_title">Account &amp; Provider</string>
<string name="account_sheet_signed_in_as">Signed in as</string>
<string name="account_sheet_provider">Active Provider</string>
<string name="account_sheet_switch_provider">Switch Provider</string>
<string name="account_sheet_logout">Sign Out</string>
<string name="account_sheet_close">Close</string>
<!-- Screen Placeholders -->
<string name="devices_placeholder_title">Device List</string>
<string name="devices_placeholder_description">Device list and control cards slot for alZerNest.</string>
<string name="scenes_placeholder_title">Scene Mode</string>
<string name="scenes_placeholder_description">Scene actions and execution slot for tzh.</string>
<string name="activity_placeholder_title">Activity Log</string>
<string name="activity_placeholder_description">No recorded events yet.</string>
</resources> </resources>
@@ -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)
}
}
@@ -34,5 +34,6 @@ dependencies {
implementation(libs.compose.ui) implementation(libs.compose.ui)
implementation(libs.compose.ui.tooling.preview) implementation(libs.compose.ui.tooling.preview)
implementation(libs.compose.material3) implementation(libs.compose.material3)
implementation(libs.compose.material.icons.extended)
debugImplementation(libs.compose.ui.tooling) debugImplementation(libs.compose.ui.tooling)
} }
@@ -1,12 +1,43 @@
package com.flagship.abox.manager.designsystem 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.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.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator 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.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
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.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 @Composable
fun ABoxSurface( fun ABoxSurface(
@@ -36,3 +67,162 @@ fun ABoxButton(
fun ABoxLoadingIndicator(modifier: Modifier = Modifier) { fun ABoxLoadingIndicator(modifier: Modifier = Modifier) {
CircularProgressIndicator(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,
),
)
}
+1
View File
@@ -24,6 +24,7 @@ compose-ui = { module = "androidx.compose.ui:ui" }
compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
compose-material3 = { module = "androidx.compose.material3:material3" } 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-junit4 = { module = "androidx.compose.ui:ui-test-junit4" }
compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" } compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" }
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }