feat(multi-account): add AddAccountDialog and per-account logout

- 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) <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-04-28 12:26:00 +03:00
parent 0da51d5ae3
commit 82ec892296
4 changed files with 210 additions and 22 deletions
@@ -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(
@@ -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<AccountInfo>,
onSwitchAccount: (String) -> Unit,
onAddAccount: () -> Unit,
onRemoveAccount: (String) -> Unit,
modifier: Modifier = Modifier,
) {
var expanded by remember { mutableStateOf(false) }
var confirmLogoutNpub by remember { mutableStateOf<String?>(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")
}
},
)
}
}
@@ -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",
)
}
}
}
}
}
@@ -52,6 +52,7 @@ fun DeckSidebar(
allAccounts: ImmutableList<AccountInfo>,
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))