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:
@@ -79,6 +79,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)
|
||||||
|
|
||||||
|
|||||||
+6
-4
@@ -1,9 +1,11 @@
|
|||||||
package com.flagship.abox.manager.ui.login
|
package com.flagship.abox.manager.ui.login
|
||||||
|
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.ui.test.assertCountEquals
|
||||||
import androidx.compose.ui.test.assertIsEnabled
|
import androidx.compose.ui.test.assertIsEnabled
|
||||||
import androidx.compose.ui.test.assertIsNotEnabled
|
import androidx.compose.ui.test.assertIsNotEnabled
|
||||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.onAllNodesWithText
|
||||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
import androidx.compose.ui.test.performClick
|
import androidx.compose.ui.test.performClick
|
||||||
@@ -29,7 +31,7 @@ class LoginScreenTest {
|
|||||||
)
|
)
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun backControlsSwitchProviderBeforeLogin() {
|
fun topBackControlSwitchesProviderBeforeLogin() {
|
||||||
var switches = 0
|
var switches = 0
|
||||||
composeRule.setContent {
|
composeRule.setContent {
|
||||||
ABoxTheme {
|
ABoxTheme {
|
||||||
@@ -49,8 +51,8 @@ class LoginScreenTest {
|
|||||||
composeRule.onNodeWithContentDescription("back-to-providers")
|
composeRule.onNodeWithContentDescription("back-to-providers")
|
||||||
.assertIsEnabled()
|
.assertIsEnabled()
|
||||||
.performClick()
|
.performClick()
|
||||||
composeRule.onNodeWithText("Back to providers").performClick()
|
composeRule.onAllNodesWithText("Back to providers").assertCountEquals(0)
|
||||||
assertEquals(2, switches)
|
assertEquals(1, switches)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -94,6 +96,6 @@ class LoginScreenTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
composeRule.onNodeWithContentDescription("back-to-providers").assertIsNotEnabled()
|
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.ABoxLoadingIndicator
|
||||||
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.authenticated.AuthenticatedPlaceholderScreen
|
|
||||||
import com.flagship.abox.manager.ui.login.LoginRoute
|
import com.flagship.abox.manager.ui.login.LoginRoute
|
||||||
import com.flagship.abox.manager.ui.login.LoginViewModel
|
import com.flagship.abox.manager.ui.login.LoginViewModel
|
||||||
import com.flagship.abox.manager.ui.provider.ProviderSelectionRoute
|
import com.flagship.abox.manager.ui.provider.ProviderSelectionRoute
|
||||||
import com.flagship.abox.manager.ui.session.AppSessionUiState
|
import com.flagship.abox.manager.ui.session.AppSessionUiState
|
||||||
import com.flagship.abox.manager.ui.session.AppSessionViewModel
|
import com.flagship.abox.manager.ui.session.AppSessionViewModel
|
||||||
import com.flagship.abox.manager.ui.session.SessionActionsViewModel
|
import com.flagship.abox.manager.ui.session.SessionActionsViewModel
|
||||||
|
import com.flagship.abox.manager.ui.shell.MainAppShell
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ABoxManagerApp(
|
fun ABoxManagerApp(
|
||||||
@@ -57,12 +57,12 @@ fun ABoxManagerApp(
|
|||||||
onSwitchProvider = sessionActionsViewModel::switchProvider,
|
onSwitchProvider = sessionActionsViewModel::switchProvider,
|
||||||
viewModel = loginViewModel,
|
viewModel = loginViewModel,
|
||||||
)
|
)
|
||||||
is AppSessionUiState.Authenticated -> AuthenticatedPlaceholderScreen(
|
is AppSessionUiState.Authenticated -> MainAppShell(
|
||||||
profile = state.profile,
|
userName = state.username.value,
|
||||||
|
providerName = state.profile.displayName,
|
||||||
|
onLogout = sessionActionsViewModel::logout,
|
||||||
actionInProgress = actionState.inProgress,
|
actionInProgress = actionState.inProgress,
|
||||||
errorMessage = actionState.errorMessage,
|
errorMessage = actionState.errorMessage,
|
||||||
onLogout = sessionActionsViewModel::logout,
|
|
||||||
onSwitchProvider = sessionActionsViewModel::switchProvider,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
-79
@@ -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))
|
else Text(stringResource(R.string.action_login))
|
||||||
}
|
}
|
||||||
ABoxButton(
|
|
||||||
onClick = onSwitchProvider,
|
|
||||||
enabled = navigationEnabled,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.login_back_to_providers))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-2
@@ -8,6 +8,7 @@ import com.flagship.abox.manager.domain.ProviderProfileId
|
|||||||
import com.flagship.abox.manager.domain.ProviderProfileStore
|
import com.flagship.abox.manager.domain.ProviderProfileStore
|
||||||
import com.flagship.abox.manager.domain.Session
|
import com.flagship.abox.manager.domain.Session
|
||||||
import com.flagship.abox.manager.domain.SessionCoordinator
|
import com.flagship.abox.manager.domain.SessionCoordinator
|
||||||
|
import com.flagship.abox.manager.domain.Username
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -99,7 +100,7 @@ class AppSessionViewModel @Inject constructor(
|
|||||||
bootstrapError: String?,
|
bootstrapError: String?,
|
||||||
): AppSessionUiState = when {
|
): AppSessionUiState = when {
|
||||||
profile == null -> AppSessionUiState.ProviderSelection
|
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)
|
else -> AppSessionUiState.Login(profile, bootstrapError)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -123,5 +124,8 @@ sealed interface AppSessionUiState {
|
|||||||
val profile: ProviderProfile,
|
val profile: ProviderProfile,
|
||||||
val bootstrapError: String? = null,
|
val bootstrapError: String? = null,
|
||||||
) : AppSessionUiState
|
) : AppSessionUiState
|
||||||
data class Authenticated(val profile: ProviderProfile) : AppSessionUiState
|
data class Authenticated(
|
||||||
|
val profile: ProviderProfile,
|
||||||
|
val username: Username,
|
||||||
|
) : AppSessionUiState
|
||||||
}
|
}
|
||||||
|
|||||||
+183
@@ -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="provider_local_description">无需网络,通过本地模拟设备体验应用。</string>
|
||||||
<string name="login_title">登录 %1$s</string>
|
<string name="login_title">登录 %1$s</string>
|
||||||
<string name="login_provider_context">当前提供方:%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_username">用户名</string>
|
||||||
<string name="login_password">密码</string>
|
<string name="login_password">密码</string>
|
||||||
<string name="login_remember_username">记住用户名</string>
|
<string name="login_remember_username">记住用户名</string>
|
||||||
@@ -24,4 +23,23 @@
|
|||||||
<string name="action_switch_provider">切换提供方</string>
|
<string name="action_switch_provider">切换提供方</string>
|
||||||
<string name="authenticated_title">已连接到 %1$s</string>
|
<string name="authenticated_title">已连接到 %1$s</string>
|
||||||
<string name="authenticated_description">登录后入口已经就绪,设备导航将由对应模块负责人接入。</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”…</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>
|
</resources>
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
<string name="provider_local_description">Explore the app with simulated local devices and no network connection.</string>
|
<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_title">Sign in to %1$s</string>
|
||||||
<string name="login_provider_context">Selected provider: %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_username">Username</string>
|
||||||
<string name="login_password">Password</string>
|
<string name="login_password">Password</string>
|
||||||
<string name="login_remember_username">Remember username</string>
|
<string name="login_remember_username">Remember username</string>
|
||||||
@@ -24,4 +23,23 @@
|
|||||||
<string name="action_switch_provider">Switch provider</string>
|
<string name="action_switch_provider">Switch provider</string>
|
||||||
<string name="authenticated_title">Connected to %1$s</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>
|
<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"…</string>
|
||||||
|
<string name="action_add">Add</string>
|
||||||
|
<string name="action_account">Account & Provider</string>
|
||||||
|
<string name="account_sheet_title">Account & 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>
|
</resources>
|
||||||
|
|||||||
+3
@@ -21,6 +21,7 @@ import kotlinx.coroutines.launch
|
|||||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||||
import kotlinx.coroutines.test.advanceUntilIdle
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
@@ -117,6 +118,8 @@ class AppSessionViewModelTest {
|
|||||||
|
|
||||||
val state = viewModel.uiState.value
|
val state = viewModel.uiState.value
|
||||||
assertTrue(state is AppSessionUiState.Authenticated)
|
assertTrue(state is AppSessionUiState.Authenticated)
|
||||||
|
val authenticated = state as AppSessionUiState.Authenticated
|
||||||
|
assertEquals(credentials.username, authenticated.username)
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
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)
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|||||||
+190
@@ -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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,6 +26,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" }
|
||||||
|
|||||||
Reference in New Issue
Block a user