diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 5cc61b1..f8843f4 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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) diff --git a/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/login/LoginScreenTest.kt b/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/login/LoginScreenTest.kt index 451585b..7b6e87e 100644 --- a/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/login/LoginScreenTest.kt +++ b/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/login/LoginScreenTest.kt @@ -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) } } diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt index 5f41001..9a2dc11 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt @@ -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, ) } } diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/authenticated/AuthenticatedScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/authenticated/AuthenticatedScreen.kt deleted file mode 100644 index 1df3284..0000000 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/authenticated/AuthenticatedScreen.kt +++ /dev/null @@ -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)) - } - } - } - } -} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginScreen.kt index 51b6ddb..4ef1e85 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginScreen.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginScreen.kt @@ -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)) - } } } } diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/session/AppSessionViewModel.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/session/AppSessionViewModel.kt index 12f57aa..893e6a1 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/session/AppSessionViewModel.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/session/AppSessionViewModel.kt @@ -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 } diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/AccountProviderBottomSheet.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/AccountProviderBottomSheet.kt new file mode 100644 index 0000000..106c288 --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/AccountProviderBottomSheet.kt @@ -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, + ) + } + } + } +} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/MainAppShell.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/MainAppShell.kt new file mode 100644 index 0000000..6c2c8ce --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/MainAppShell.kt @@ -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), + ) + } +} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/MainTab.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/MainTab.kt new file mode 100644 index 0000000..32f5cac --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/MainTab.kt @@ -0,0 +1,41 @@ +package com.flagship.abox.manager.ui.shell + +import androidx.annotation.StringRes +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AutoAwesome +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.ViewAgenda +import androidx.compose.material.icons.outlined.AutoAwesome +import androidx.compose.material.icons.outlined.Home +import androidx.compose.material.icons.outlined.ViewAgenda +import androidx.compose.ui.graphics.vector.ImageVector +import com.flagship.abox.manager.R + +/** + * Main bottom navigation tabs for the authenticated application shell. + */ +enum class MainTab( + @StringRes val labelRes: Int, + val selectedIcon: ImageVector, + val unselectedIcon: ImageVector, + val route: String, +) { + DEVICES( + labelRes = R.string.tab_devices, + selectedIcon = Icons.Filled.Home, + unselectedIcon = Icons.Outlined.Home, + route = "shell/devices", + ), + SCENES( + labelRes = R.string.tab_scenes, + selectedIcon = Icons.Filled.AutoAwesome, + unselectedIcon = Icons.Outlined.AutoAwesome, + route = "shell/scenes", + ), + ACTIVITY( + labelRes = R.string.tab_activity, + selectedIcon = Icons.Filled.ViewAgenda, + unselectedIcon = Icons.Outlined.ViewAgenda, + route = "shell/activity", + ), +} diff --git a/android/app/src/main/res/values-zh-rCN/strings.xml b/android/app/src/main/res/values-zh-rCN/strings.xml index 28b7def..696dc16 100644 --- a/android/app/src/main/res/values-zh-rCN/strings.xml +++ b/android/app/src/main/res/values-zh-rCN/strings.xml @@ -8,7 +8,6 @@ 无需网络,通过本地模拟设备体验应用。 登录 %1$s 当前提供方:%1$s - 返回提供方选择 用户名 密码 记住用户名 @@ -24,4 +23,23 @@ 切换提供方 已连接到 %1$s 登录后入口已经就绪,设备导航将由对应模块负责人接入。 + + + 设备 + 情景 + 活动记录 + 问问“%1$s”… + 添加 + 账户与提供方 + 账户与提供方 + 当前登录为 + 当前提供方 + 退出登录 + 关闭 + 设备列表 + 设备列表与控制卡片插槽(由 alZerNest 模块实现)。 + 情景模式 + 情景动作与执行插槽(由 tzh 模块实现)。 + 活动记录 + 未记录任何事件。 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 291774e..c8818bb 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -8,7 +8,6 @@ Explore the app with simulated local devices and no network connection. Sign in to %1$s Selected provider: %1$s - Back to providers Username Password Remember username @@ -24,4 +23,23 @@ Switch provider Connected to %1$s The authenticated route is ready. Device navigation will be added by its module owner. + + + Devices + Scenes + Activity + Ask "%1$s"… + Add + Account & Provider + Account & Provider + Signed in as + Active Provider + Sign Out + Close + Device List + Device list and control cards slot for alZerNest. + Scene Mode + Scene actions and execution slot for tzh. + Activity Log + No recorded events yet. diff --git a/android/app/src/test/kotlin/com/flagship/abox/manager/ui/session/AppSessionViewModelTest.kt b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/session/AppSessionViewModelTest.kt index b931400..8c695af 100644 --- a/android/app/src/test/kotlin/com/flagship/abox/manager/ui/session/AppSessionViewModelTest.kt +++ b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/session/AppSessionViewModelTest.kt @@ -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 { diff --git a/android/app/src/test/kotlin/com/flagship/abox/manager/ui/shell/MainTabTest.kt b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/shell/MainTabTest.kt new file mode 100644 index 0000000..b65fdf6 --- /dev/null +++ b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/shell/MainTabTest.kt @@ -0,0 +1,28 @@ +package com.flagship.abox.manager.ui.shell + +import com.flagship.abox.manager.R +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Test + +class MainTabTest { + + @Test + fun `all main tabs have distinct non-empty routes and valid label resources`() { + val tabs = MainTab.values() + assertEquals(3, tabs.size) + + val routes = tabs.map { it.route } + assertEquals(routes.distinct().size, tabs.size) + + tabs.forEach { tab -> + assertNotNull(tab.selectedIcon) + assertNotNull(tab.unselectedIcon) + assert(tab.route.isNotBlank()) + } + + assertEquals(R.string.tab_devices, MainTab.DEVICES.labelRes) + assertEquals(R.string.tab_scenes, MainTab.SCENES.labelRes) + assertEquals(R.string.tab_activity, MainTab.ACTIVITY.labelRes) + } +} diff --git a/android/core/designsystem/build.gradle.kts b/android/core/designsystem/build.gradle.kts index ed36165..18a7dfe 100644 --- a/android/core/designsystem/build.gradle.kts +++ b/android/core/designsystem/build.gradle.kts @@ -34,5 +34,6 @@ dependencies { implementation(libs.compose.ui) implementation(libs.compose.ui.tooling.preview) implementation(libs.compose.material3) + implementation(libs.compose.material.icons.extended) debugImplementation(libs.compose.ui.tooling) } diff --git a/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Components.kt b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Components.kt index b05599d..8f3fb5a 100644 --- a/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Components.kt +++ b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Components.kt @@ -1,12 +1,43 @@ package com.flagship.abox.manager.designsystem +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.LocationOn +import androidx.compose.material.icons.filled.Person import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.NavigationBarItemDefaults import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp @Composable fun ABoxSurface( @@ -36,3 +67,162 @@ fun ABoxButton( fun ABoxLoadingIndicator(modifier: Modifier = Modifier) { CircularProgressIndicator(modifier = modifier) } + +/** + * Circular user avatar displaying the initial character of username or a default person icon. + * Styled with an accented profile ring. + */ +@Composable +fun ABoxUserAvatar( + name: String?, + onClick: () -> Unit, + modifier: Modifier = Modifier, + size: Dp = 36.dp, + contentDescription: String? = null, +) { + val initial = name?.trim()?.takeIf { it.isNotEmpty() }?.take(1)?.uppercase() + Box( + modifier = modifier + .size(size) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primaryContainer) + .border( + width = 2.dp, + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f), + shape = CircleShape, + ) + .clickable(onClickLabel = contentDescription, onClick = onClick), + contentAlignment = Alignment.Center, + ) { + if (initial != null) { + Text( + text = initial, + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ), + ) + } else { + Icon( + imageVector = Icons.Default.Person, + contentDescription = contentDescription, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(size * 0.6f), + ) + } + } +} + +/** + * Material 3 pill top bar containing leading icon, title/provider label, + * an optional '+' action button, and user avatar. + */ +@Composable +fun ABoxTopPillBar( + title: String, + onAvatarClick: () -> Unit, + modifier: Modifier = Modifier, + userName: String? = null, + leadingIcon: ImageVector = Icons.Default.LocationOn, + onAddClick: (() -> Unit)? = null, + addActionContentDescription: String? = "Add", + avatarContentDescription: String? = "Account", +) { + Surface( + modifier = modifier + .fillMaxWidth() + .height(56.dp), + shape = RoundedCornerShape(28.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f), + tonalElevation = 2.dp, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = leadingIcon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(22.dp), + ) + + Text( + text = title, + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + + if (onAddClick != null) { + IconButton( + onClick = onAddClick, + modifier = Modifier.size(36.dp), + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = addActionContentDescription, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + ABoxUserAvatar( + name = userName, + onClick = onAvatarClick, + size = 36.dp, + contentDescription = avatarContentDescription, + ) + } + } +} + +/** + * ABox standard Material 3 Navigation Bar with pill indicator styling. + */ +@Composable +fun ABoxNavigationBar( + modifier: Modifier = Modifier, + containerColor: Color = MaterialTheme.colorScheme.surface, + content: @Composable RowScope.() -> Unit, +) { + NavigationBar( + modifier = modifier, + containerColor = containerColor, + content = content, + ) +} + +/** + * ABox standard Navigation Bar Item. + */ +@Composable +fun RowScope.ABoxNavigationBarItem( + selected: Boolean, + onClick: () -> Unit, + icon: @Composable () -> Unit, + label: @Composable (() -> Unit)? = null, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + NavigationBarItem( + selected = selected, + onClick = onClick, + icon = icon, + label = label, + modifier = modifier, + enabled = enabled, + colors = NavigationBarItemDefaults.colors( + indicatorColor = MaterialTheme.colorScheme.secondaryContainer, + selectedIconColor = MaterialTheme.colorScheme.onSecondaryContainer, + unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant, + selectedTextColor = MaterialTheme.colorScheme.onSurface, + unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant, + ), + ) +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2b00e05..1ad4e45 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }