From 82ec89229689d2aa7b0b88571f54601000495290 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Tue, 28 Apr 2026 12:26:00 +0300 Subject: [PATCH] feat(multi-account): add AddAccountDialog and per-account logout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AddAccountDialog: DialogWindow (480×600) wrapping existing LoginCard with back button, "Import Account" title. Supports nsec, npub, bunker, nostrconnect. New account becomes active immediately after login. - Per-account logout: ✕ button on each account row in dropdown, with AlertDialog confirmation before removal. - Wired both into DeckSidebar and Main.kt. Matches Android's AddAccountDialog pattern (full-screen dialog with LoginOrSignupScreen) adapted for desktop (DialogWindow). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../vitorpamplona/amethyst/desktop/Main.kt | 22 +++- .../ui/account/AccountSwitcherDropdown.kt | 84 +++++++++--- .../desktop/ui/account/AddAccountDialog.kt | 124 ++++++++++++++++++ .../amethyst/desktop/ui/deck/DeckSidebar.kt | 2 + 4 files changed, 210 insertions(+), 22 deletions(-) create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/account/AddAccountDialog.kt diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 42b11811a..c1543da02 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -1020,6 +1020,8 @@ fun MainContent( LayoutMode.DECK -> { if (!isImmersive) { val allAccountsState by accountManager.allAccounts.collectAsState() + var showAddAccountDialog by remember { mutableStateOf(false) } + DeckSidebar( activeNpub = accountManager.currentAccount()?.npub, allAccounts = allAccountsState, @@ -1028,8 +1030,11 @@ fun MainContent( accountManager.switchAccount(npub) } }, - onAddAccount = { - // TODO: Show add account dialog + onAddAccount = { showAddAccountDialog = true }, + onRemoveAccount = { npub -> + scope.launch(Dispatchers.IO) { + accountManager.removeAccountFromStorage(npub) + } }, onAddColumn = onShowAppDrawer, onOpenSettings = { @@ -1045,6 +1050,19 @@ fun MainContent( ) VerticalDivider() + + if (showAddAccountDialog) { + com.vitorpamplona.amethyst.desktop.ui.account.AddAccountDialog( + accountManager = accountManager, + onDismiss = { showAddAccountDialog = false }, + onAccountAdded = { + showAddAccountDialog = false + scope.launch(Dispatchers.IO) { + accountManager.refreshAccountList() + } + }, + ) + } } DeckLayout( diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/account/AccountSwitcherDropdown.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/account/AccountSwitcherDropdown.kt index b7420da0f..23c061575 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/account/AccountSwitcherDropdown.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/account/AccountSwitcherDropdown.kt @@ -22,11 +22,16 @@ package com.vitorpamplona.amethyst.desktop.ui.account 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.size +import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Person +import androidx.compose.material3.AlertDialog import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.HorizontalDivider @@ -34,11 +39,13 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme 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.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -54,9 +61,11 @@ fun AccountSwitcherDropdown( allAccounts: ImmutableList, onSwitchAccount: (String) -> Unit, onAddAccount: () -> Unit, + onRemoveAccount: (String) -> Unit, modifier: Modifier = Modifier, ) { var expanded by remember { mutableStateOf(false) } + var confirmLogoutNpub by remember { mutableStateOf(null) } Box(modifier = modifier) { IconButton( @@ -75,18 +84,12 @@ fun AccountSwitcherDropdown( onDismissRequest = { expanded = false }, offset = DpOffset(x = 48.dp, y = 0.dp), ) { - if (allAccounts.isEmpty()) { + allAccounts.forEach { account -> + val isActive = account.npub == activeNpub DropdownMenuItem( - text = { Text("No accounts") }, - onClick = {}, - enabled = false, - ) - } else { - allAccounts.forEach { account -> - val isActive = account.npub == activeNpub - DropdownMenuItem( - text = { - Column { + text = { + Row(verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { Text( account.npub.take(16) + "...", maxLines = 1, @@ -107,22 +110,36 @@ fun AccountSwitcherDropdown( ) } } - }, - trailingIcon = { if (isActive) { Icon( Icons.Default.Check, contentDescription = "Active", tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + Spacer(Modifier.width(4.dp)) + } + IconButton( + onClick = { + expanded = false + confirmLogoutNpub = account.npub + }, + modifier = Modifier.size(28.dp), + ) { + Icon( + Icons.Default.Close, + contentDescription = "Remove account", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(16.dp), ) } - }, - onClick = { - expanded = false - if (!isActive) onSwitchAccount(account.npub) - }, - ) - } + } + }, + onClick = { + expanded = false + if (!isActive) onSwitchAccount(account.npub) + }, + ) } HorizontalDivider() DropdownMenuItem( @@ -135,4 +152,31 @@ fun AccountSwitcherDropdown( ) } } + + // Logout confirmation dialog + val logoutNpub = confirmLogoutNpub + if (logoutNpub != null) { + AlertDialog( + onDismissRequest = { confirmLogoutNpub = null }, + title = { Text("Remove Account") }, + text = { + Text("Remove ${logoutNpub.take(16)}...? This will delete the account from this device.") + }, + confirmButton = { + TextButton( + onClick = { + confirmLogoutNpub = null + onRemoveAccount(logoutNpub) + }, + ) { + Text("Remove", color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = { confirmLogoutNpub = null }) { + Text("Cancel") + } + }, + ) + } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/account/AddAccountDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/account/AddAccountDialog.kt new file mode 100644 index 000000000..4764a1c6f --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/account/AddAccountDialog.kt @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.account + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +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.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogWindow +import androidx.compose.ui.window.rememberDialogState +import com.vitorpamplona.amethyst.desktop.account.AccountManager +import com.vitorpamplona.amethyst.desktop.ui.auth.LoginCard +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AddAccountDialog( + accountManager: AccountManager, + onDismiss: () -> Unit, + onAccountAdded: () -> Unit, +) { + val scope = rememberCoroutineScope() + val loginProgress by accountManager.loginProgress.collectAsState() + + DialogWindow( + onCloseRequest = onDismiss, + title = "Add Account", + state = rememberDialogState(size = DpSize(480.dp, 600.dp)), + resizable = false, + ) { + MaterialTheme { + Scaffold( + topBar = { + TopAppBar( + title = { Text("Add Account") }, + navigationIcon = { + IconButton(onClick = onDismiss) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + ) + } + }, + ) + }, + ) { padding -> + Column( + modifier = Modifier.fillMaxSize().padding(padding).padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + LoginCard( + onLogin = { keyInput -> + accountManager.loginWithKey(keyInput).map { + scope.launch { + withContext(Dispatchers.IO) { accountManager.saveCurrentAccount() } + onAccountAdded() + } + } + }, + onGenerateNew = { + accountManager.generateNewAccount() + scope.launch { + withContext(Dispatchers.IO) { accountManager.saveCurrentAccount() } + onAccountAdded() + } + }, + onLoginBunker = { bunkerUri -> + accountManager.loginWithBunker(bunkerUri).map { + onAccountAdded() + } + }, + onLoginNostrConnect = { onUriGenerated -> + accountManager.loginWithNostrConnect(onUriGenerated).map { + onAccountAdded() + } + }, + loginProgress = loginProgress, + cardWidth = 420.dp, + title = "Import Account", + subtitle = "Paste nsec, npub (view-only), or use a remote signer", + ) + } + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt index 5d02fc9c0..6678c47ea 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt @@ -52,6 +52,7 @@ fun DeckSidebar( allAccounts: ImmutableList, onSwitchAccount: (String) -> Unit, onAddAccount: () -> Unit, + onRemoveAccount: (String) -> Unit, onAddColumn: () -> Unit, onOpenSettings: () -> Unit, signerConnectionState: SignerConnectionState, @@ -74,6 +75,7 @@ fun DeckSidebar( allAccounts = allAccounts, onSwitchAccount = onSwitchAccount, onAddAccount = onAddAccount, + onRemoveAccount = onRemoveAccount, ) Spacer(Modifier.size(16.dp))