feat(app): add authenticated app shell with top bar and bottom navigation (#6)

### 概述 / Summary
实现认证后的主应用壳层(`MainAppShell`),包含胶囊顶栏(`ABoxTopPillBar`)与 Material 3 底部导航栏(`ABoxNavigationBar`),并为团队其他成员预留标准 Composable 插槽。

### 核心变更 / Changes
1. **设计系统 (`:android:core:designsystem`)**:
   - 新增 `ABoxTopPillBar`:展示提供方/空间信息、快捷 `+` 操作与用户头像徽标。
   - 新增 `ABoxUserAvatar`:支持用户名首字符与主题强调轮廓。
   - 新增 `ABoxNavigationBar` & `ABoxNavigationBarItem`:M3 胶囊指示器底部导航栏封装。
2. **应用壳层 (`:android:app`)**:
   - 新增 `MainTab`(`DEVICES`, `SCENES`, `ACTIVITY`),严格对齐需求与分工术语。
   - 新增 `MainAppShell`:承载顶栏、底栏、Tab 平滑切换,并开放 `devicesSlot`、`scenesSlot`、`activitySlot`。
   - 新增 `AccountProviderBottomSheet`:点击头像展示当前会话/提供方信息、支持切换提供方与退出登录。
3. **多语言与测试**:
   - 补充 `values` 与 `values-zh-rCN` 双语资源。
   - 新增 `MainTabTest` 单元测试并通过全量 `check` 与 `test`。

### 分工保护 / Boundaries
- 仅提供顶栏、底栏与主页面容器骨架;
- 设备列表、情景编排与设备控制详情的具体 UI 保持为插槽,供 alZerNest、tzh 和 Aurum 后续无缝填充。

Reviewed-on: #6
Co-authored-by: KremeCN <3265494849@qq.com>
This commit was merged in pull request #6.
This commit is contained in:
2026-08-25 10:49:59 +08:00
committed by KremeCN
parent 06ccfdf952
commit ed98c2be98
16 changed files with 689 additions and 99 deletions
+1
View File
@@ -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)
@@ -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)
}
}
@@ -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,
)
}
}
@@ -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))
}
}
}
}
}
@@ -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))
}
}
}
}
@@ -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
}
@@ -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,
)
}
}
}
}
@@ -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),
)
}
}
@@ -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",
),
}
@@ -8,7 +8,6 @@
<string name="provider_local_description">无需网络,通过本地模拟设备体验应用。</string>
<string name="login_title">登录 %1$s</string>
<string name="login_provider_context">当前提供方:%1$s</string>
<string name="login_back_to_providers">返回提供方选择</string>
<string name="login_username">用户名</string>
<string name="login_password">密码</string>
<string name="login_remember_username">记住用户名</string>
@@ -24,4 +23,23 @@
<string name="action_switch_provider">切换提供方</string>
<string name="authenticated_title">已连接到 %1$s</string>
<string name="authenticated_description">登录后入口已经就绪,设备导航将由对应模块负责人接入。</string>
<!-- 认证后壳层 -->
<string name="tab_devices">设备</string>
<string name="tab_scenes">情景</string>
<string name="tab_activity">活动记录</string>
<string name="top_bar_prompt_format">问问“%1$s”&#8230;</string>
<string name="action_add">添加</string>
<string name="action_account">账户与提供方</string>
<string name="account_sheet_title">账户与提供方</string>
<string name="account_sheet_signed_in_as">当前登录为</string>
<string name="account_sheet_provider">当前提供方</string>
<string name="account_sheet_logout">退出登录</string>
<string name="account_sheet_close">关闭</string>
<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>
+19 -1
View File
@@ -8,7 +8,6 @@
<string name="provider_local_description">Explore the app with simulated local devices and no network connection.</string>
<string name="login_title">Sign in to %1$s</string>
<string name="login_provider_context">Selected provider: %1$s</string>
<string name="login_back_to_providers">Back to providers</string>
<string name="login_username">Username</string>
<string name="login_password">Password</string>
<string name="login_remember_username">Remember username</string>
@@ -24,4 +23,23 @@
<string name="action_switch_provider">Switch provider</string>
<string name="authenticated_title">Connected to %1$s</string>
<string name="authenticated_description">The authenticated route is ready. Device navigation will be added by its module owner.</string>
<!-- Authenticated shell -->
<string name="tab_devices">Devices</string>
<string name="tab_scenes">Scenes</string>
<string name="tab_activity">Activity</string>
<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>
<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_logout">Sign Out</string>
<string name="account_sheet_close">Close</string>
<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>
@@ -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 {
@@ -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.tooling.preview)
implementation(libs.compose.material3)
implementation(libs.compose.material.icons.extended)
debugImplementation(libs.compose.ui.tooling)
}
@@ -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,
),
)
}
+1
View File
@@ -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" }