feat: redesign account and provider dialog

This commit is contained in:
2026-08-26 19:04:57 +08:00
parent 05d44578b9
commit 6e78b71d31
6 changed files with 321 additions and 204 deletions
@@ -0,0 +1,73 @@
package com.flagship.abox.manager.ui.shell
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.flagship.abox.manager.designsystem.ABoxTheme
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class AccountProviderDialogTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun dialogExposesAccountActionsAndClose() {
var closed = 0
composeRule.setContent {
ABoxTheme {
AccountProviderDialog(
userName = "user",
providerName = "ABox",
onDismissRequest = { closed++ },
onOpenSettings = {},
onSwitchProvider = {},
onEndSession = {},
onForgetCredentials = {},
)
}
}
composeRule.onNodeWithText("账户与提供方").assertIsDisplayed()
composeRule.onNodeWithTag("account_dialog_close").performClick()
assertEquals(1, closed)
}
@Test
fun destructiveActionsRequireSeparateConfirmation() {
var ended = 0
var forgotten = 0
composeRule.setContent {
ABoxTheme {
AccountProviderDialog(
userName = "user",
providerName = "ABox",
onDismissRequest = {},
onOpenSettings = {},
onSwitchProvider = {},
onEndSession = { ended++ },
onForgetCredentials = { forgotten++ },
)
}
}
composeRule.onNodeWithTag("account_dialog_end_session").performClick()
composeRule.onNodeWithText("退出当前会话").assertIsDisplayed()
composeRule.onNodeWithTag("account_dialog_confirm").performClick()
assertEquals(1, ended)
assertEquals(0, forgotten)
composeRule.onNodeWithTag("account_dialog_forget_credentials").performClick()
composeRule.onNodeWithText("清除保存的凭据").assertIsDisplayed()
composeRule.onNodeWithTag("account_dialog_confirm").performClick()
assertEquals(1, forgotten)
}
}
@@ -5,7 +5,7 @@ import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -24,12 +24,14 @@ import com.flagship.abox.manager.domain.Username
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.navigation.AuthenticatedNavigation import com.flagship.abox.manager.ui.navigation.AuthenticatedNavigation
import com.flagship.abox.manager.ui.preferences.SettingsRoute
import com.flagship.abox.manager.ui.preferences.SettingsViewModel import com.flagship.abox.manager.ui.preferences.SettingsViewModel
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.AccountProviderBottomSheet import com.flagship.abox.manager.ui.shell.AccountProviderDialog
import com.flagship.abox.manager.ui.session.SessionUiAction
@Composable @Composable
fun ABoxManagerApp( fun ABoxManagerApp(
@@ -74,42 +76,58 @@ fun ABoxManagerApp(
is AppSessionUiState.Authenticated -> AuthenticatedNavHost( is AppSessionUiState.Authenticated -> AuthenticatedNavHost(
username = state.username, username = state.username,
profile = state.profile, profile = state.profile,
actionInProgress = actionState.inProgress,
errorMessage = actionError,
onLogout = sessionActionsViewModel::logout, onLogout = sessionActionsViewModel::logout,
onSessionAction = sessionActionsViewModel::dispatch,
) )
} }
} }
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun AuthenticatedNavHost( private fun AuthenticatedNavHost(
username: Username, username: Username,
profile: ProviderProfile, profile: ProviderProfile,
actionInProgress: Boolean,
errorMessage: String?,
onLogout: () -> Unit, onLogout: () -> Unit,
onSessionAction: (SessionUiAction) -> Unit,
) { ) {
var showAccountSheet by remember { mutableStateOf(false) } var showAccountDialog by remember { mutableStateOf(false) }
var showSettings by remember { mutableStateOf(false) }
if (showSettings) {
BackHandler { showSettings = false }
SettingsRoute()
} else {
AuthenticatedNavigation( AuthenticatedNavigation(
username = username, username = username,
profile = profile, profile = profile,
onLogout = onLogout, onLogout = onLogout,
onProviderClick = { showAccountSheet = true }, onProviderClick = { showAccountDialog = true },
onAvatarClick = { showAccountSheet = true }, onAvatarClick = { showAccountDialog = true },
) )
}
if (showAccountSheet) { if (showAccountDialog) {
AccountProviderBottomSheet( AccountProviderDialog(
userName = username.value, userName = username.value,
providerName = profile.displayName, providerName = profile.displayName,
onDismissRequest = { showAccountSheet = false }, onDismissRequest = { showAccountDialog = false },
onLogout = onLogout, onOpenSettings = {
actionInProgress = actionInProgress, showAccountDialog = false
errorMessage = errorMessage, showSettings = true
},
onSwitchProvider = {
showAccountDialog = false
onSessionAction(SessionUiAction.SwitchProvider)
},
onEndSession = {
showAccountDialog = false
onSessionAction(SessionUiAction.EndSession)
},
onForgetCredentials = {
showAccountDialog = false
onSessionAction(SessionUiAction.ForgetSavedCredentials)
},
) )
} }
} }
@@ -42,6 +42,7 @@ import com.flagship.abox.manager.infrared.panel.RemotePanelRoute
import com.flagship.abox.manager.infrared.ui.InfraredControlRoute import com.flagship.abox.manager.infrared.ui.InfraredControlRoute
import com.flagship.abox.manager.ui.devicecontrollist.DeviceListScreen import com.flagship.abox.manager.ui.devicecontrollist.DeviceListScreen
import com.flagship.abox.manager.ui.scene.SceneListScreen import com.flagship.abox.manager.ui.scene.SceneListScreen
import com.flagship.abox.manager.ui.preferences.SettingsRoute
import com.flagship.abox.manager.ui.scene.SceneRoutes import com.flagship.abox.manager.ui.scene.SceneRoutes
import com.flagship.abox.manager.ui.scene.sceneEditorDestination import com.flagship.abox.manager.ui.scene.sceneEditorDestination
import com.flagship.abox.manager.ui.scene.sceneExecutionDestination import com.flagship.abox.manager.ui.scene.sceneExecutionDestination
@@ -221,6 +222,7 @@ fun AuthenticatedNavigation(
onLogout: () -> Unit, onLogout: () -> Unit,
onProviderClick: () -> Unit, onProviderClick: () -> Unit,
onAvatarClick: () -> Unit, onAvatarClick: () -> Unit,
onOpenSettings: () -> Unit = {},
windowSizeClass: ABoxWindowSizeClass? = null, windowSizeClass: ABoxWindowSizeClass? = null,
) { ) {
val resolvedWindowSizeClass = windowSizeClass ?: widthClass(LocalConfiguration.current.screenWidthDp) val resolvedWindowSizeClass = windowSizeClass ?: widthClass(LocalConfiguration.current.screenWidthDp)
@@ -262,6 +264,7 @@ fun AuthenticatedNavigation(
onSelectDevice = onSelectDevice, onSelectDevice = onSelectDevice,
onClearDeviceSelection = onClearDeviceSelection, onClearDeviceSelection = onClearDeviceSelection,
onLogout = onLogout, onLogout = onLogout,
onOpenSettings = onOpenSettings,
) )
}, },
) )
@@ -279,6 +282,7 @@ private fun AuthenticatedGraphContent(
onSelectDevice: (DeviceName, DeviceType) -> Unit, onSelectDevice: (DeviceName, DeviceType) -> Unit,
onClearDeviceSelection: () -> Unit, onClearDeviceSelection: () -> Unit,
onLogout: () -> Unit, onLogout: () -> Unit,
onOpenSettings: () -> Unit,
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
@@ -294,6 +298,7 @@ private fun AuthenticatedGraphContent(
onSelectDevice = onSelectDevice, onSelectDevice = onSelectDevice,
onClearDeviceSelection = onClearDeviceSelection, onClearDeviceSelection = onClearDeviceSelection,
onLogout = onLogout, onLogout = onLogout,
onOpenSettings = onOpenSettings,
) )
DeviceDetailPane( DeviceDetailPane(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
@@ -309,6 +314,7 @@ private fun AuthenticatedGraphContent(
onSelectDevice = onSelectDevice, onSelectDevice = onSelectDevice,
onClearDeviceSelection = onClearDeviceSelection, onClearDeviceSelection = onClearDeviceSelection,
onLogout = onLogout, onLogout = onLogout,
onOpenSettings = onOpenSettings,
) )
} }
} }
@@ -322,6 +328,7 @@ private fun AuthenticatedNavHost(
onSelectDevice: (DeviceName, DeviceType) -> Unit, onSelectDevice: (DeviceName, DeviceType) -> Unit,
onClearDeviceSelection: () -> Unit, onClearDeviceSelection: () -> Unit,
onLogout: () -> Unit, onLogout: () -> Unit,
onOpenSettings: () -> Unit,
) { ) {
NavHost( NavHost(
navController = navController, navController = navController,
@@ -342,6 +349,9 @@ private fun AuthenticatedNavHost(
composable(MainTab.ACTIVITY.route) { composable(MainTab.ACTIVITY.route) {
ActivityDestination() ActivityDestination()
} }
composable(Routes.SETTINGS) {
SettingsRoute()
}
deviceDetailGraph( deviceDetailGraph(
onBackClick = { onBackClick = {
onClearDeviceSelection() onClearDeviceSelection()
@@ -5,6 +5,7 @@ object Routes {
const val PROVIDER_SELECTION = "provider_selection" const val PROVIDER_SELECTION = "provider_selection"
const val LOGIN = "login" const val LOGIN = "login"
const val AUTHENTICATED = "authenticated" const val AUTHENTICATED = "authenticated"
const val SETTINGS = "settings"
const val INFRARED_HUB = "infrared_hub" const val INFRARED_HUB = "infrared_hub"
const val INFRARED_PANEL = "infrared_panel" const val INFRARED_PANEL = "infrared_panel"
@@ -1,183 +0,0 @@
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,198 @@
package com.flagship.abox.manager.ui.shell
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.CloudDone
import androidx.compose.material.icons.outlined.Logout
import androidx.compose.material.icons.outlined.Refresh
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp
import com.flagship.abox.manager.designsystem.ABoxButton
import com.flagship.abox.manager.designsystem.ABoxIconButton
import com.flagship.abox.manager.designsystem.ABoxUserAvatar
import com.flagship.abox.manager.designsystem.icon.ABoxIconKey
private enum class DestructiveAction {
END_SESSION,
FORGET_CREDENTIALS,
}
@Composable
fun AccountProviderDialog(
userName: String,
providerName: String,
onDismissRequest: () -> Unit,
onOpenSettings: () -> Unit,
onSwitchProvider: () -> Unit,
onEndSession: () -> Unit,
onForgetCredentials: () -> Unit,
modifier: Modifier = Modifier,
) {
var confirmation by remember { mutableStateOf<DestructiveAction?>(null) }
if (confirmation == null) {
AlertDialog(
onDismissRequest = onDismissRequest,
modifier = modifier.testTag("account_dialog"),
title = {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text("账户与提供方")
ABoxIconButton(
key = ABoxIconKey.CLOSE,
contentDescription = "关闭账户对话框",
onClick = onDismissRequest,
modifier = Modifier.testTag("account_dialog_close"),
)
}
},
text = {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
ABoxUserAvatar(name = userName, onClick = {}, size = 64.dp)
Text(
text = userName.ifBlank { "当前账户" },
style = MaterialTheme.typography.titleMedium,
)
Text(
text = providerName,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f),
shape = MaterialTheme.shapes.medium,
) {
Row(
modifier = Modifier.fillMaxWidth().padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(
imageVector = Icons.Outlined.CloudDone,
contentDescription = "连接方式",
modifier = Modifier.size(24.dp),
)
Column(modifier = Modifier.weight(1f)) {
Text("当前连接方式", style = MaterialTheme.typography.labelMedium)
Text(providerName, style = MaterialTheme.typography.bodyLarge)
}
TextButton(
onClick = onSwitchProvider,
modifier = Modifier.testTag("account_dialog_switch_provider"),
) {
Icon(Icons.Outlined.Refresh, "切换提供方")
Text("切换")
}
}
}
AccountActionButton(
icon = Icons.Outlined.Settings,
label = "界面设置",
description = "调整主题、渲染器和交互偏好",
tag = "account_dialog_open_settings",
onClick = onOpenSettings,
)
AccountActionButton(
icon = Icons.Outlined.Logout,
label = "退出当前会话",
description = "结束当前提供方会话",
tag = "account_dialog_end_session",
onClick = { confirmation = DestructiveAction.END_SESSION },
)
TextButton(
onClick = { confirmation = DestructiveAction.FORGET_CREDENTIALS },
modifier = Modifier.fillMaxWidth().testTag("account_dialog_forget_credentials"),
) {
Text("清除保存的凭据", color = MaterialTheme.colorScheme.error)
}
}
},
confirmButton = {},
)
} else {
val action = confirmation ?: return
val isForget = action == DestructiveAction.FORGET_CREDENTIALS
AlertDialog(
onDismissRequest = { confirmation = null },
title = { Text(if (isForget) "清除保存的凭据" else "退出当前会话") },
text = {
Text(
if (isForget) {
"确定清除当前提供方保存的登录凭据吗?"
} else {
"确定退出当前会话吗?"
},
)
},
confirmButton = {
ABoxButton(
onClick = {
confirmation = null
if (isForget) onForgetCredentials() else onEndSession()
},
modifier = Modifier.testTag("account_dialog_confirm"),
) {
Text("确定")
}
},
dismissButton = {
TextButton(onClick = { confirmation = null }) { Text("取消") }
},
)
}
}
@Composable
private fun AccountActionButton(
icon: androidx.compose.ui.graphics.vector.ImageVector,
label: String,
description: String,
tag: String,
onClick: () -> Unit,
) {
TextButton(
onClick = onClick,
modifier = Modifier.fillMaxWidth().testTag(tag),
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(icon, contentDescription = label)
Column(modifier = Modifier.weight(1f)) {
Text(label)
Text(description, style = MaterialTheme.typography.bodySmall)
}
}
}
}