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 84089985c..7c5edcff8 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -198,6 +198,7 @@ fun main() { val workspaceManager = remember { WorkspaceManager(deckScope).also { it.load() } } val accountManager = remember { AccountManager.create() } val accountState by accountManager.accountState.collectAsState() + val allAccounts by accountManager.allAccounts.collectAsState() var showAppDrawer by remember { mutableStateOf(false) } // Tor state at Window level — survives key() app rebuild @@ -663,6 +664,9 @@ fun App( subscriptionsCoordinator.start() scope.launch(Dispatchers.IO) { + // Migrate legacy single-account files and load account list + accountManager.migrateAndLoadAccounts() + if (accountManager.hasBunkerAccount()) { // Show connecting UI while dedicated NIP-46 client connects accountManager.setConnectingRelays() @@ -1016,7 +1020,18 @@ fun MainContent( LayoutMode.DECK -> { if (!isImmersive) { + val allAccountsState by accountManager.allAccounts.collectAsState() DeckSidebar( + activeNpub = accountManager.currentAccount()?.npub, + allAccounts = allAccountsState, + onSwitchAccount = { npub -> + scope.launch(Dispatchers.IO) { + accountManager.switchAccount(npub) + } + }, + onAddAccount = { + // TODO: Show add account dialog + }, onAddColumn = onShowAppDrawer, onOpenSettings = { if (deckState.hasColumnOfType(DeckColumnType.Settings)) { 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 new file mode 100644 index 000000000..b7420da0f --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/account/AccountSwitcherDropdown.kt @@ -0,0 +1,138 @@ +/* + * 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.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.size +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.Person +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +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.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.model.account.AccountInfo +import com.vitorpamplona.amethyst.commons.model.account.SignerType +import kotlinx.collections.immutable.ImmutableList + +@Composable +fun AccountSwitcherDropdown( + activeNpub: String?, + allAccounts: ImmutableList, + onSwitchAccount: (String) -> Unit, + onAddAccount: () -> Unit, + modifier: Modifier = Modifier, +) { + var expanded by remember { mutableStateOf(false) } + + Box(modifier = modifier) { + IconButton( + onClick = { expanded = true }, + modifier = Modifier.size(48.dp), + ) { + Icon( + Icons.Default.Person, + contentDescription = "Switch account", + tint = MaterialTheme.colorScheme.primary, + ) + } + + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + offset = DpOffset(x = 48.dp, y = 0.dp), + ) { + if (allAccounts.isEmpty()) { + DropdownMenuItem( + text = { Text("No accounts") }, + onClick = {}, + enabled = false, + ) + } else { + allAccounts.forEach { account -> + val isActive = account.npub == activeNpub + DropdownMenuItem( + text = { + Column { + Text( + account.npub.take(16) + "...", + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontWeight = if (isActive) FontWeight.SemiBold else FontWeight.Normal, + ) + val label = + when (account.signerType) { + is SignerType.Remote -> "Bunker" + is SignerType.ViewOnly -> "View only" + is SignerType.Internal -> null + } + if (label != null) { + Text( + label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + ) + } + } + }, + trailingIcon = { + if (isActive) { + Icon( + Icons.Default.Check, + contentDescription = "Active", + tint = MaterialTheme.colorScheme.primary, + ) + } + }, + onClick = { + expanded = false + if (!isActive) onSwitchAccount(account.npub) + }, + ) + } + } + HorizontalDivider() + DropdownMenuItem( + text = { Text("Add Account") }, + leadingIcon = { Icon(Icons.Default.Add, contentDescription = null) }, + onClick = { + expanded = false + onAddAccount() + }, + ) + } + } +} 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 32f470b9f..5d02fc9c0 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 @@ -34,20 +34,24 @@ import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.commons.domain.nip46.SignerConnectionState +import com.vitorpamplona.amethyst.commons.model.account.AccountInfo import com.vitorpamplona.amethyst.commons.tor.TorServiceStatus import com.vitorpamplona.amethyst.commons.ui.components.BunkerHeartbeatIndicator +import com.vitorpamplona.amethyst.desktop.ui.account.AccountSwitcherDropdown import com.vitorpamplona.amethyst.desktop.ui.tor.TorStatusIndicator +import kotlinx.collections.immutable.ImmutableList @Composable fun DeckSidebar( + activeNpub: String?, + allAccounts: ImmutableList, + onSwitchAccount: (String) -> Unit, + onAddAccount: () -> Unit, onAddColumn: () -> Unit, onOpenSettings: () -> Unit, signerConnectionState: SignerConnectionState, @@ -65,12 +69,11 @@ fun DeckSidebar( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Top, ) { - Text( - "A", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary, - fontSize = 20.sp, + AccountSwitcherDropdown( + activeNpub = activeNpub, + allAccounts = allAccounts, + onSwitchAccount = onSwitchAccount, + onAddAccount = onAddAccount, ) Spacer(Modifier.size(16.dp))