diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index c8dcdfbee..8fe883a1e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -28,6 +28,8 @@ import androidx.core.content.edit import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.model.TopFilter import com.vitorpamplona.amethyst.model.UiSettings +import com.vitorpamplona.amethyst.model.nip47WalletConnect.NwcWalletEntry +import com.vitorpamplona.amethyst.model.nip47WalletConnect.NwcWalletEntryNorm import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName @@ -101,7 +103,9 @@ private object PrefKeys { const val DEFAULT_PICTURES_FOLLOW_LIST = "defaultPicturesFollowList" const val DEFAULT_SHORTS_FOLLOW_LIST = "defaultShortsFollowList" const val DEFAULT_LONGS_FOLLOW_LIST = "defaultLongsFollowList" - const val ZAP_PAYMENT_REQUEST_SERVER = "zapPaymentServer" + const val ZAP_PAYMENT_REQUEST_SERVER = "zapPaymentServer" // legacy, kept for migration + const val NWC_WALLETS = "nwcWallets" + const val DEFAULT_NWC_WALLET_ID = "defaultNwcWalletId" const val LATEST_USER_METADATA = "latestUserMetadata" const val LATEST_CONTACT_LIST = "latestContactList" const val LATEST_DM_RELAY_LIST = "latestDMRelayList" @@ -342,7 +346,18 @@ object LocalPreferences { putString(PrefKeys.DEFAULT_SHORTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultShortsFollowList.value)) putString(PrefKeys.DEFAULT_LONGS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultLongsFollowList.value)) - putOrRemove(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, settings.zapPaymentRequest.value?.denormalize()) + val walletEntries = settings.nwcWallets.value.mapNotNull { it.denormalize() } + if (walletEntries.isNotEmpty()) { + putString(PrefKeys.NWC_WALLETS, JsonMapper.toJson(walletEntries)) + } else { + remove(PrefKeys.NWC_WALLETS) + } + settings.defaultNwcWalletId.value?.let { + putString(PrefKeys.DEFAULT_NWC_WALLET_ID, it) + } ?: remove(PrefKeys.DEFAULT_NWC_WALLET_ID) + + // Remove legacy key after migration + remove(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER) putOrRemove(PrefKeys.LATEST_CONTACT_LIST, settings.backupContactList) @@ -495,6 +510,8 @@ object LocalPreferences { val defaultLongsFollowListStr = getString(PrefKeys.DEFAULT_LONGS_FOLLOW_LIST, null) val zapPaymentRequestServerStr = getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null) + val nwcWalletsStr = getString(PrefKeys.NWC_WALLETS, null) + val defaultNwcWalletIdStr = getString(PrefKeys.DEFAULT_NWC_WALLET_ID, null) val defaultFileServerStr = getString(PrefKeys.DEFAULT_FILE_SERVER, null) val pendingAttestationsStr = getString(PrefKeys.PENDING_ATTESTATIONS, null) @@ -531,7 +548,32 @@ object LocalPreferences { val defaultShortsFollowList = async { parseOrNull(defaultShortsFollowListStr) ?: TopFilter.Global } val defaultLongsFollowList = async { parseOrNull(defaultLongsFollowListStr) ?: TopFilter.Global } - val zapPaymentRequestServer = async { parseOrNull(zapPaymentRequestServerStr) } + val nwcWalletsLoaded = + async { + val nwcWalletEntries = parseOrNull>(nwcWalletsStr) + if (nwcWalletEntries != null && nwcWalletEntries.isNotEmpty()) { + val wallets = nwcWalletEntries.mapNotNull { it.normalize() } + val defaultId = defaultNwcWalletIdStr ?: wallets.firstOrNull()?.id + Pair(wallets, defaultId) + } else { + val legacyUri = parseOrNull(zapPaymentRequestServerStr) + val legacyNorm = legacyUri?.normalize() + if (legacyNorm != null) { + val migrated = + NwcWalletEntryNorm( + id = + java.util.UUID + .randomUUID() + .toString(), + name = "Wallet", + uri = legacyNorm, + ) + Pair(listOf(migrated), migrated.id) + } else { + Pair(emptyList(), null) + } + } + } val defaultFileServer = async { parseOrNull(defaultFileServerStr) ?: DEFAULT_MEDIA_SERVERS[0] } val viewedPollResultNoteIds = async { parseOrNull>(viewedPollResultNoteIdsStr) ?: mapOf() } @@ -580,7 +622,8 @@ object LocalPreferences { defaultPicturesFollowList = MutableStateFlow(defaultPicturesFollowList.await()), defaultShortsFollowList = MutableStateFlow(defaultShortsFollowList.await()), defaultLongsFollowList = MutableStateFlow(defaultLongsFollowList.await()), - zapPaymentRequest = MutableStateFlow(zapPaymentRequestServer.await()?.normalize()), + nwcWallets = MutableStateFlow(nwcWalletsLoaded.await().first), + defaultNwcWalletId = MutableStateFlow(nwcWalletsLoaded.await().second), hideDeleteRequestDialog = hideDeleteRequestDialog, hideBlockAlertDialog = hideBlockAlertDialog, hideNIP17WarningDialog = hideNIP17WarningDialog, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 5ffd5fcd8..c0935e7da 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -270,7 +270,7 @@ class Account( val userMetadata = UserMetadataState(signer, cache, scope, settings) - override val nip47SignerState = NwcSignerState(signer, nwcFilterAssembler, cache, scope, settings.zapPaymentRequest) + override val nip47SignerState = NwcSignerState(signer, nwcFilterAssembler, cache, scope, settings) val nip65RelayList = Nip65RelayListState(signer, cache, scope, settings) val localRelayList = LocalRelayListState(signer, cache, scope, settings) @@ -616,6 +616,15 @@ class Account( client.publish(event, setOf(relay)) } + suspend fun sendNwcRequestToWallet( + walletUri: Nip47WalletConnect.Nip47URINorm, + request: Request, + onResponse: (Response?) -> Unit, + ) { + val (event, relay) = nip47SignerState.sendNwcRequestToWallet(walletUri, request, onResponse) + client.publish(event, setOf(relay)) + } + suspend fun sendZapPaymentRequestFor( bolt11: String, zappedNote: Note?, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index ef5422280..90d0542ad 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.model import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatRepository import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatListRepository +import com.vitorpamplona.amethyst.model.nip47WalletConnect.NwcWalletEntryNorm import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName import com.vitorpamplona.amethyst.ui.screen.FeedDefinition @@ -170,7 +171,8 @@ class AccountSettings( val defaultPicturesFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), val defaultShortsFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), val defaultLongsFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), - val zapPaymentRequest: MutableStateFlow = MutableStateFlow(null), + val nwcWallets: MutableStateFlow> = MutableStateFlow(emptyList()), + val defaultNwcWalletId: MutableStateFlow = MutableStateFlow(null), var hideDeleteRequestDialog: Boolean = false, var hideBlockAlertDialog: Boolean = false, var hideNIP17WarningDialog: Boolean = false, @@ -253,15 +255,114 @@ class AccountSettings( return false } - fun changeZapPaymentRequest(newServer: Nip47WalletConnect.Nip47URINorm?): Boolean { - if (zapPaymentRequest.value != newServer) { - zapPaymentRequest.tryEmit(newServer) + fun defaultNwcWallet(): NwcWalletEntryNorm? { + val id = defaultNwcWalletId.value + val wallets = nwcWallets.value + return if (id != null) { + wallets.firstOrNull { it.id == id } + } else { + wallets.firstOrNull() + } + } + + fun defaultZapPaymentRequest(): Nip47WalletConnect.Nip47URINorm? = defaultNwcWallet()?.uri + + fun addNwcWallet(wallet: NwcWalletEntryNorm): Boolean { + val existing = nwcWallets.value.indexOfFirst { it.id == wallet.id } + if (existing >= 0) { + nwcWallets.tryEmit(nwcWallets.value.toMutableList().apply { set(existing, wallet) }) + } else { + nwcWallets.tryEmit(nwcWallets.value + wallet) + if (nwcWallets.value.size == 1) { + defaultNwcWalletId.tryEmit(wallet.id) + } + } + saveAccountSettings() + return true + } + + fun removeNwcWallet(walletId: String): Boolean { + val wallets = nwcWallets.value.filter { it.id != walletId } + nwcWallets.tryEmit(wallets) + if (defaultNwcWalletId.value == walletId) { + defaultNwcWalletId.tryEmit(wallets.firstOrNull()?.id) + } + saveAccountSettings() + return true + } + + fun setDefaultNwcWallet(walletId: String): Boolean { + if (defaultNwcWalletId.value != walletId && nwcWallets.value.any { it.id == walletId }) { + defaultNwcWalletId.tryEmit(walletId) saveAccountSettings() return true } return false } + fun renameNwcWallet( + walletId: String, + newName: String, + ): Boolean { + val wallets = nwcWallets.value.toMutableList() + val index = wallets.indexOfFirst { it.id == walletId } + if (index >= 0) { + wallets[index] = wallets[index].copy(name = newName) + nwcWallets.tryEmit(wallets) + saveAccountSettings() + return true + } + return false + } + + fun moveNwcWallet( + fromIndex: Int, + toIndex: Int, + ): Boolean { + val wallets = nwcWallets.value.toMutableList() + if (fromIndex in wallets.indices && toIndex in wallets.indices && fromIndex != toIndex) { + val item = wallets.removeAt(fromIndex) + wallets.add(toIndex, item) + nwcWallets.tryEmit(wallets) + saveAccountSettings() + return true + } + return false + } + + fun changeZapPaymentRequest(newServer: Nip47WalletConnect.Nip47URINorm?): Boolean { + if (newServer == null) { + if (nwcWallets.value.isNotEmpty()) { + nwcWallets.tryEmit(emptyList()) + defaultNwcWalletId.tryEmit(null) + saveAccountSettings() + return true + } + return false + } + val current = defaultZapPaymentRequest() + if (current != newServer) { + val defaultWallet = defaultNwcWallet() + if (defaultWallet != null) { + val updated = defaultWallet.copy(uri = newServer) + addNwcWallet(updated) + } else { + val entry = + NwcWalletEntryNorm( + id = + java.util.UUID + .randomUUID() + .toString(), + name = "Wallet", + uri = newServer, + ) + addNwcWallet(entry) + } + return true + } + return false + } + // --- // file servers // --- diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt index 616ad24e6..5bf93e5ff 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.model.nip47WalletConnect import com.vitorpamplona.amethyst.commons.model.INwcSignerState +import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler @@ -41,8 +42,9 @@ import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn @@ -50,45 +52,42 @@ import kotlinx.coroutines.launch /** * Manages NIP-47 (Nostr Wallet Connect) related signing operations and decryption cache for a given account. - * - * Key Responsibilities: - * - * - Dynamically creates a NIP-47 signer if the wallet setup changes in the account settings. - * - Provides decryption caches to manage decrypted NIP-47 requests and responses efficiently. - * - Handles creating of zap payment requests and waits for responses. - * - * @property signer the main Nostr signer used for general Nostr operations - * @property cache the local cache for handling notes and events - * @property scope the coroutine scope used for async operations - * @property nip47Setup the NIP-47 configuration + * Supports multiple wallets with a default wallet used for zaps. */ class NwcSignerState( val signer: NostrSigner, val nwcFilterAssembler: () -> NWCPaymentFilterAssembler, val cache: LocalCache, val scope: CoroutineScope, - val nip47Setup: MutableStateFlow, + val settings: AccountSettings, ) : INwcSignerState { /** - * Derives a NIP-47 signer from the zap payment request in settings. - * If there's no valid configuration, it defaults to the main signer. - * Flows updates whenever settings change. + * Flow of the default wallet's NWC URI, derived from multi-wallet settings. + */ + val defaultWalletUri: StateFlow = + combine(settings.nwcWallets, settings.defaultNwcWalletId) { wallets, defaultId -> + if (defaultId != null) { + wallets.firstOrNull { it.id == defaultId }?.uri + } else { + wallets.firstOrNull()?.uri + } + }.flowOn(Dispatchers.IO) + .stateIn(scope, SharingStarted.Eagerly, settings.defaultZapPaymentRequest()) + + /** + * Derives a NIP-47 signer from the default wallet configuration. */ val nip47Signer = - nip47Setup + defaultWalletUri .map { buildSigner(it) ?: signer }.flowOn(Dispatchers.IO) .stateIn( scope, SharingStarted.Eagerly, - buildSigner(nip47Setup.value) ?: signer, + buildSigner(defaultWalletUri.value) ?: signer, ) - /** - * Creates a dedicated request decryption cache for the NIP-47 signer. - * Flows updates whenever the signer changes. - */ val zapPaymentRequestDecryptionCache = nip47Signer .map { @@ -96,10 +95,6 @@ class NwcSignerState( }.flowOn(Dispatchers.IO) .stateIn(scope, SharingStarted.Eagerly, NostrWalletConnectRequestCache(nip47Signer.value)) - /** - * Creates a dedicated response decryption cache for the NIP-47 signer. - * Flows updates whenever the signer changes. - */ val zapPaymentResponseDecryptionCache = nip47Signer .map { @@ -112,48 +107,40 @@ class NwcSignerState( NostrSignerInternal(KeyPair(it)) } - fun hasWalletConnectSetup(): Boolean = nip47Setup.value != null + fun hasWalletConnectSetup(): Boolean = settings.nwcWallets.value.isNotEmpty() override fun isNIP47Author(pubKey: HexKey?): Boolean = nip47Signer.value.pubKey == pubKey - /** - * Decrypts a NIP-47 payment request using the current signer. - * - * @param event the NIP-47 payment request event to decrypt - * @return the decrypted request or null if not set up or decryption fails - */ override suspend fun decryptRequest(event: LnZapPaymentRequestEvent): Request? { if (!hasWalletConnectSetup()) return null return zapPaymentRequestDecryptionCache.value.decryptRequest(event) } - /** - * Decrypts a NIP-47 payment response using the current signer. - * - * @param event the NIP-47 payment response event to decrypt - * @return the decrypted response or null if not set up or decryption fails - */ override suspend fun decryptResponse(event: LnZapPaymentResponseEvent): Response? { if (!hasWalletConnectSetup()) return null return zapPaymentResponseDecryptionCache.value.decryptResponse(event) } /** - * Sends a generic NIP-47 request to the connected wallet. - * Subscribes to responses and waits up to 60s for a reply. - * - * @param request the NIP-47 request to send - * @param onResponse callback to handle the response from the wallet - * @return a pair containing the request event and target relay URL - * @throws IllegalArgumentException if no NIP-47 wallet is set up + * Sends a generic NIP-47 request to the default wallet. */ suspend fun sendNwcRequest( request: Request, onResponse: (Response?) -> Unit, - ): Pair { - val walletService = nip47Setup.value ?: throw IllegalArgumentException("No NIP47 setup") + ): Pair = sendNwcRequestToWallet(defaultWalletUri.value, request, onResponse) - val event = LnZapPaymentRequestEvent.createRequest(request, walletService.pubKeyHex, nip47Signer.value) + /** + * Sends a generic NIP-47 request to a specific wallet. + */ + suspend fun sendNwcRequestToWallet( + walletUri: Nip47WalletConnect.Nip47URINorm?, + request: Request, + onResponse: (Response?) -> Unit, + ): Pair { + val walletService = walletUri ?: throw IllegalArgumentException("No NIP47 setup") + val walletSigner = buildSigner(walletService) ?: signer + + val event = LnZapPaymentRequestEvent.createRequest(request, walletService.pubKeyHex, walletSigner) val filter = NWCPaymentQueryState( @@ -172,29 +159,23 @@ class NwcSignerState( assembler.unsubscribe(filter) } + val responseCache = NostrWalletConnectResponseCache(walletSigner) cache.consume(event, null, true, walletService.relayUri) { - onResponse(decryptResponse(it)) + onResponse(responseCache.decryptResponse(it)) } return Pair(event, walletService.relayUri) } /** - * Sends a zap payment request to a connected Lightning wallet. - * Subscribes to responses and waits up to 60s for a reply. - * - * @param bolt11 the BOLT-11 invoice to pay - * @param zappedNote the note being zapped (if any) - * @param onResponse callback to handle the response from the wallet - * @return a pair containing the payment request event and target relay URL - * @throws IllegalArgumentException if no NIP-47 wallet is set up + * Sends a zap payment request to the default wallet. */ suspend fun sendZapPaymentRequestFor( bolt11: String, zappedNote: Note?, onResponse: (Response?) -> Unit, ): Pair { - val walletService = nip47Setup.value ?: throw IllegalArgumentException("No NIP47 setup") + val walletService = defaultWalletUri.value ?: throw IllegalArgumentException("No NIP47 setup") val event = LnZapPaymentRequestEvent.create(bolt11, walletService.pubKeyHex, nip47Signer.value) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcWalletEntry.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcWalletEntry.kt new file mode 100644 index 000000000..3a3e24d99 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcWalletEntry.kt @@ -0,0 +1,48 @@ +/* + * 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.model.nip47WalletConnect + +import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect +import kotlinx.serialization.Serializable +import java.util.UUID + +@Serializable +data class NwcWalletEntry( + val id: String = UUID.randomUUID().toString(), + val name: String, + val uri: Nip47WalletConnect.Nip47URI, +) { + fun normalize(): NwcWalletEntryNorm? = + uri.normalize()?.let { + NwcWalletEntryNorm(id, name, it) + } +} + +data class NwcWalletEntryNorm( + val id: String, + val name: String, + val uri: Nip47WalletConnect.Nip47URINorm, +) { + fun denormalize(): NwcWalletEntry? = + uri.denormalize()?.let { + NwcWalletEntry(id, name, it) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index eff9db9de..083aff9ea 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -144,6 +144,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.UserSettingsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.ShortsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.ThreadScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.VideoScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.AddWalletScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletDetailScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletReceiveScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletSendScreen @@ -223,6 +225,8 @@ fun BuildNavigation( composableFromEnd { WalletSendScreen(accountViewModel, nav) } composableFromEnd { WalletReceiveScreen(accountViewModel, nav) } composableFromEnd { WalletTransactionsScreen(accountViewModel, nav) } + composableFromEndArgs { WalletDetailScreen(it.walletId, accountViewModel, nav) } + composableFromEnd { AddWalletScreen(accountViewModel, nav) } composableFromEnd { ListOfPeopleListsScreen(accountViewModel, nav) } composableFromEndArgs { PeopleListScreen(it.dTag, accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 224ea88c9..9c1d5e497 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -61,6 +61,13 @@ sealed class Route { @Serializable object WalletTransactions : Route() + @Serializable + data class WalletDetail( + val walletId: String, + ) : Route() + + @Serializable object WalletAdd : Route() + @Serializable object Search : Route() @Serializable object SecurityFilters : Route() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountViewModel.kt index 33a090373..8d50017d9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountViewModel.kt @@ -61,7 +61,7 @@ class UpdateZapAmountViewModel : ViewModel() { this.amountSet = accountViewModel.account.settings.syncedSettings.zaps.zapAmountChoices.value this.selectedZapType = accountViewModel.account.settings.syncedSettings.zaps.defaultZapType.value - val nip47 = accountViewModel.account.settings.zapPaymentRequest.value + val nip47 = accountViewModel.account.settings.defaultZapPaymentRequest() this.walletConnectPubkey = nip47?.pubKeyHex?.let { TextFieldValue(it) } ?: TextFieldValue("") this.walletConnectRelay = nip47?.relayUri?.url?.let { TextFieldValue(it) } ?: TextFieldValue("") @@ -125,23 +125,16 @@ class UpdateZapAmountViewModel : ViewModel() { nextAmount = TextFieldValue("") } - fun hasChanged(): Boolean = - ( + fun hasChanged(): Boolean { + val defaultUri = accountViewModel.account.settings.defaultZapPaymentRequest() + return ( selectedZapType != accountViewModel.account.settings.syncedSettings.zaps.defaultZapType.value || amountSet != accountViewModel.account.settings.syncedSettings.zaps.zapAmountChoices.value || - walletConnectPubkey.text != ( - accountViewModel.account.settings.zapPaymentRequest.value - ?.pubKeyHex ?: "" - ) || - walletConnectRelay.text != ( - accountViewModel.account.settings.zapPaymentRequest.value - ?.relayUri ?: "" - ) || - walletConnectSecret.text != ( - accountViewModel.account.settings.zapPaymentRequest.value - ?.secret ?: "" - ) + walletConnectPubkey.text != (defaultUri?.pubKeyHex ?: "") || + walletConnectRelay.text != (defaultUri?.relayUri?.url ?: "") || + walletConnectSecret.text != (defaultUri?.secret ?: "") ) + } fun updateNIP47(uri: String) { val contact = Nip47WalletConnect.parse(uri) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/AddWalletScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/AddWalletScreen.kt new file mode 100644 index 000000000..e0484ccbf --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/AddWalletScreen.kt @@ -0,0 +1,145 @@ +/* + * 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.ui.screen.loggedIn.wallet + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +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.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect +import kotlinx.coroutines.CancellationException + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AddWalletScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val walletViewModel: WalletViewModel = viewModel() + walletViewModel.init(accountViewModel) + + var walletName by remember { mutableStateOf("") } + var nwcUri by remember { mutableStateOf("") } + var error by remember { mutableStateOf(null) } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringRes(R.string.wallet_add_connection)) }, + navigationIcon = { + IconButton(onClick = { nav.popBack() }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringRes(R.string.back), + ) + } + }, + ) + }, + ) { padding -> + Column( + modifier = + Modifier + .fillMaxSize() + .padding(padding) + .padding(horizontal = 16.dp), + ) { + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedTextField( + value = walletName, + onValueChange = { walletName = it }, + label = { Text(stringRes(R.string.wallet_name)) }, + placeholder = { Text(stringRes(R.string.wallet_name_hint)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedTextField( + value = nwcUri, + onValueChange = { + nwcUri = it + error = null + }, + label = { Text(stringRes(R.string.wallet_paste_uri)) }, + placeholder = { Text("nostr+walletconnect://...") }, + minLines = 3, + maxLines = 5, + modifier = Modifier.fillMaxWidth(), + ) + + if (error != null) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = error!!, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + Button( + onClick = { + try { + val parsed = Nip47WalletConnect.parse(nwcUri.trim()) + walletViewModel.addWallet(walletName.trim(), parsed) + nav.popBack() + } catch (e: Exception) { + if (e is CancellationException) throw e + error = e.message ?: "Invalid NWC connection URI" + } + }, + enabled = nwcUri.isNotBlank(), + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringRes(R.string.wallet_save)) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletDetailScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletDetailScreen.kt new file mode 100644 index 000000000..606add251 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletDetailScreen.kt @@ -0,0 +1,218 @@ +/* + * 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.ui.screen.loggedIn.wallet + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +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.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.List +import androidx.compose.material.icons.filled.ArrowDownward +import androidx.compose.material.icons.filled.ArrowUpward +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +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 androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import java.text.NumberFormat + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun WalletDetailScreen( + walletId: String, + accountViewModel: AccountViewModel, + nav: INav, +) { + val walletViewModel: WalletViewModel = viewModel() + walletViewModel.init(accountViewModel) + walletViewModel.selectWallet(walletId) + + val balance by walletViewModel.balanceSats.collectAsState() + val walletAlias by walletViewModel.walletAlias.collectAsState() + val isLoading by walletViewModel.isLoading.collectAsState() + val error by walletViewModel.error.collectAsState() + val wallets by walletViewModel.wallets.collectAsState() + val walletName = wallets.firstOrNull { it.id == walletId }?.name ?: stringRes(R.string.wallet) + + LaunchedEffect(walletId) { + walletViewModel.fetchBalance() + walletViewModel.fetchInfo() + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(walletAlias ?: walletName) }, + navigationIcon = { + IconButton(onClick = { nav.popBack() }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringRes(R.string.back), + ) + } + }, + ) + }, + ) { padding -> + Column( + modifier = + Modifier + .fillMaxSize() + .padding(padding) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.height(32.dp)) + + // Balance display + if (isLoading && balance == null) { + CircularProgressIndicator(modifier = Modifier.size(48.dp)) + } else { + val formattedBalance = + remember(balance) { + val fmt = NumberFormat.getIntegerInstance() + fmt.format(balance ?: 0L) + } + Text( + text = formattedBalance, + fontSize = 48.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onBackground, + ) + Text( + text = stringRes(R.string.wallet_sats), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + // Error + if (error != null) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = error!!, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + + Spacer(modifier = Modifier.weight(1f)) + + // Action buttons + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Button( + onClick = { nav.nav(Route.WalletReceive) }, + modifier = + Modifier + .weight(1f) + .height(56.dp), + shape = RoundedCornerShape(16.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), + ) { + Icon( + imageVector = Icons.Filled.ArrowDownward, + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringRes(R.string.wallet_receive), fontWeight = FontWeight.SemiBold) + } + + Button( + onClick = { nav.nav(Route.WalletSend) }, + modifier = + Modifier + .weight(1f) + .height(56.dp), + shape = RoundedCornerShape(16.dp), + ) { + Icon( + imageVector = Icons.Filled.ArrowUpward, + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringRes(R.string.wallet_send), fontWeight = FontWeight.SemiBold) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Transactions button + OutlinedButton( + onClick = { nav.nav(Route.WalletTransactions) }, + modifier = + Modifier + .fillMaxWidth() + .height(48.dp), + shape = RoundedCornerShape(16.dp), + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.List, + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringRes(R.string.wallet_transactions)) + } + + Spacer(modifier = Modifier.height(24.dp)) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletScreen.kt index d4935ea94..d1a5a7e3f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletScreen.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -30,29 +32,40 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.automirrored.filled.List -import androidx.compose.material.icons.filled.ArrowDownward -import androidx.compose.material.icons.filled.ArrowUpward -import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material.icons.filled.Star +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState 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 @@ -91,13 +104,11 @@ fun WalletScreen( } }, actions = { - if (hasWallet) { - IconButton(onClick = { nav.nav(Route.Nip47NWCSetup()) }) { - Icon( - imageVector = Icons.Outlined.Settings, - contentDescription = stringRes(R.string.settings), - ) - } + IconButton(onClick = { nav.nav(Route.WalletAdd) }) { + Icon( + imageVector = Icons.Filled.Add, + contentDescription = stringRes(R.string.wallet_add), + ) } }, ) @@ -109,7 +120,7 @@ fun WalletScreen( nav = nav, ) } else { - WalletHomeContent( + MultiWalletHomeContent( walletViewModel = walletViewModel, modifier = Modifier.padding(padding), nav = nav, @@ -132,158 +143,347 @@ private fun NoWalletSetup( verticalArrangement = Arrangement.Center, ) { Text( - text = stringRes(R.string.wallet_no_connection), + text = stringRes(R.string.wallet_no_wallets), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, ) Spacer(modifier = Modifier.height(16.dp)) Text( - text = stringRes(R.string.wallet_no_connection_description), + text = stringRes(R.string.wallet_no_wallets_description), style = MaterialTheme.typography.bodyMedium, textAlign = TextAlign.Center, color = MaterialTheme.colorScheme.onSurfaceVariant, ) Spacer(modifier = Modifier.height(24.dp)) - Button(onClick = { nav.nav(Route.Nip47NWCSetup()) }) { - Text(stringRes(R.string.wallet_setup)) + Button(onClick = { nav.nav(Route.WalletAdd) }) { + Icon(Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringRes(R.string.wallet_add_connection)) } } } @Composable -private fun WalletHomeContent( +private fun MultiWalletHomeContent( walletViewModel: WalletViewModel, modifier: Modifier, nav: INav, ) { - val balance by walletViewModel.balanceSats.collectAsState() - val walletAlias by walletViewModel.walletAlias.collectAsState() - val isLoading by walletViewModel.isLoading.collectAsState() - val error by walletViewModel.error.collectAsState() + val walletInfoList by walletViewModel.walletInfoList.collectAsState() LaunchedEffect(Unit) { - walletViewModel.fetchBalance() - walletViewModel.fetchInfo() + walletViewModel.fetchAllBalances() + walletViewModel.wallets.value.forEach { wallet -> + walletViewModel.fetchInfoForWallet(wallet.id) + } } - Column( + LazyColumn( modifier = modifier .fillMaxSize() - .padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally, + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), ) { - Spacer(modifier = Modifier.height(32.dp)) - - // Wallet name - if (walletAlias != null) { - Text( - text = walletAlias!!, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(modifier = Modifier.height(8.dp)) - } - - // Balance display - if (isLoading && balance == null) { - CircularProgressIndicator(modifier = Modifier.size(48.dp)) - } else { - val formattedBalance = - remember(balance) { - val fmt = NumberFormat.getIntegerInstance() - fmt.format(balance ?: 0L) - } - Text( - text = formattedBalance, - fontSize = 48.sp, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onBackground, - ) - Text( - text = stringRes(R.string.wallet_sats), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - // Error - if (error != null) { + item { Spacer(modifier = Modifier.height(8.dp)) Text( - text = error!!, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall, + text = stringRes(R.string.wallet_your_wallets), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, ) } - Spacer(modifier = Modifier.weight(1f)) - - // Action buttons - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(16.dp), - ) { - Button( - onClick = { nav.nav(Route.WalletReceive) }, - modifier = - Modifier - .weight(1f) - .height(56.dp), - shape = RoundedCornerShape(16.dp), - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onPrimaryContainer, - ), - ) { - Icon( - imageVector = Icons.Filled.ArrowDownward, - contentDescription = null, - modifier = Modifier.size(20.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) - Text(stringRes(R.string.wallet_receive), fontWeight = FontWeight.SemiBold) - } - - Button( - onClick = { nav.nav(Route.WalletSend) }, - modifier = - Modifier - .weight(1f) - .height(56.dp), - shape = RoundedCornerShape(16.dp), - ) { - Icon( - imageVector = Icons.Filled.ArrowUpward, - contentDescription = null, - modifier = Modifier.size(20.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) - Text(stringRes(R.string.wallet_send), fontWeight = FontWeight.SemiBold) - } - } - - Spacer(modifier = Modifier.height(16.dp)) - - // Transactions button - OutlinedButton( - onClick = { nav.nav(Route.WalletTransactions) }, - modifier = - Modifier - .fillMaxWidth() - .height(48.dp), - shape = RoundedCornerShape(16.dp), - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.List, - contentDescription = null, - modifier = Modifier.size(20.dp), + itemsIndexed(walletInfoList, key = { _, info -> info.walletId }) { index, walletInfo -> + WalletCard( + walletInfo = walletInfo, + index = index, + totalCount = walletInfoList.size, + onSelect = { + walletViewModel.selectWallet(walletInfo.walletId) + nav.nav(Route.WalletDetail(walletInfo.walletId)) + }, + onSetDefault = { + walletViewModel.setDefaultWallet(walletInfo.walletId) + }, + onRename = { newName -> + walletViewModel.renameWallet(walletInfo.walletId, newName) + }, + onMoveUp = { + walletViewModel.moveWallet(index, index - 1) + }, + onMoveDown = { + walletViewModel.moveWallet(index, index + 1) + }, + onRemove = { + walletViewModel.removeWallet(walletInfo.walletId) + }, ) - Spacer(modifier = Modifier.width(8.dp)) - Text(stringRes(R.string.wallet_transactions)) } - Spacer(modifier = Modifier.height(24.dp)) + item { + Spacer(modifier = Modifier.height(8.dp)) + OutlinedButton( + onClick = { nav.nav(Route.WalletAdd) }, + modifier = + Modifier + .fillMaxWidth() + .height(48.dp), + shape = RoundedCornerShape(12.dp), + ) { + Icon(Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringRes(R.string.wallet_add)) + } + Spacer(modifier = Modifier.height(24.dp)) + } } } + +@Composable +private fun WalletCard( + walletInfo: WalletInfo, + index: Int, + totalCount: Int, + onSelect: () -> Unit, + onSetDefault: () -> Unit, + onRename: (String) -> Unit, + onMoveUp: () -> Unit, + onMoveDown: () -> Unit, + onRemove: () -> Unit, +) { + var showRemoveDialog by remember { mutableStateOf(false) } + var showRenameDialog by remember { mutableStateOf(false) } + + if (showRemoveDialog) { + AlertDialog( + onDismissRequest = { showRemoveDialog = false }, + title = { Text(stringRes(R.string.wallet_remove_confirm)) }, + text = { Text(stringRes(R.string.wallet_remove_confirm_description)) }, + confirmButton = { + TextButton(onClick = { + showRemoveDialog = false + onRemove() + }) { + Text(stringRes(R.string.wallet_remove)) + } + }, + dismissButton = { + TextButton(onClick = { showRemoveDialog = false }) { + Text(stringRes(R.string.cancel)) + } + }, + ) + } + + if (showRenameDialog) { + RenameWalletDialog( + currentName = walletInfo.name, + onDismiss = { showRenameDialog = false }, + onConfirm = { newName -> + showRenameDialog = false + onRename(newName) + }, + ) + } + + Card( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onSelect), + shape = RoundedCornerShape(16.dp), + border = + if (walletInfo.isDefault) { + BorderStroke(2.dp, MaterialTheme.colorScheme.primary) + } else { + null + }, + colors = + CardDefaults.cardColors( + containerColor = + if (walletInfo.isDefault) { + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) + } else { + MaterialTheme.colorScheme.surfaceVariant + }, + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = walletInfo.alias ?: walletInfo.name, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + if (walletInfo.isDefault) { + Spacer(modifier = Modifier.width(8.dp)) + Icon( + imageVector = Icons.Filled.Star, + contentDescription = stringRes(R.string.wallet_default), + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } + } + if (walletInfo.alias != null && walletInfo.alias != walletInfo.name) { + Text( + text = walletInfo.name, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + // Reorder buttons + if (totalCount > 1) { + Column { + IconButton( + onClick = onMoveUp, + enabled = index > 0, + modifier = Modifier.size(28.dp), + ) { + Icon( + Icons.Filled.KeyboardArrowUp, + contentDescription = stringRes(R.string.wallet_move_up), + modifier = Modifier.size(20.dp), + ) + } + IconButton( + onClick = onMoveDown, + enabled = index < totalCount - 1, + modifier = Modifier.size(28.dp), + ) { + Icon( + Icons.Filled.KeyboardArrowDown, + contentDescription = stringRes(R.string.wallet_move_down), + modifier = Modifier.size(20.dp), + ) + } + } + } + + // Balance + if (walletInfo.isLoading && walletInfo.balanceSats == null) { + CircularProgressIndicator(modifier = Modifier.size(24.dp)) + } else { + Column(horizontalAlignment = Alignment.End) { + val formattedBalance = + remember(walletInfo.balanceSats) { + val fmt = NumberFormat.getIntegerInstance() + fmt.format(walletInfo.balanceSats ?: 0L) + } + Text( + text = formattedBalance, + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + ) + Text( + text = stringRes(R.string.wallet_sats), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + if (walletInfo.error != null) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = walletInfo.error, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + // Action buttons row + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (!walletInfo.isDefault) { + OutlinedButton( + onClick = onSetDefault, + modifier = Modifier.height(36.dp), + shape = RoundedCornerShape(8.dp), + ) { + Icon(Icons.Filled.Check, contentDescription = null, modifier = Modifier.size(14.dp)) + Spacer(modifier = Modifier.width(4.dp)) + Text(stringRes(R.string.wallet_set_default), style = MaterialTheme.typography.bodySmall) + } + } + + OutlinedButton( + onClick = { showRenameDialog = true }, + modifier = Modifier.height(36.dp), + shape = RoundedCornerShape(8.dp), + ) { + Icon(Icons.Filled.Edit, contentDescription = null, modifier = Modifier.size(14.dp)) + Spacer(modifier = Modifier.width(4.dp)) + Text(stringRes(R.string.wallet_rename), style = MaterialTheme.typography.bodySmall) + } + + Spacer(modifier = Modifier.weight(1f)) + + IconButton( + onClick = { showRemoveDialog = true }, + modifier = Modifier.size(36.dp), + ) { + Icon( + Icons.Filled.Delete, + contentDescription = stringRes(R.string.wallet_remove), + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.error, + ) + } + } + } + } +} + +@Composable +private fun RenameWalletDialog( + currentName: String, + onDismiss: () -> Unit, + onConfirm: (String) -> Unit, +) { + var name by remember { mutableStateOf(currentName) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringRes(R.string.wallet_rename_title)) }, + text = { + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text(stringRes(R.string.wallet_name)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + }, + confirmButton = { + TextButton( + onClick = { onConfirm(name.trim()) }, + enabled = name.isNotBlank(), + ) { + Text(stringRes(R.string.wallet_save)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringRes(R.string.cancel)) + } + }, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt index ce8ceddff..f05e5c0a6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt @@ -23,7 +23,9 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.nip47WalletConnect.NwcWalletEntryNorm import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceMethod import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceSuccessResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetInfoMethod @@ -82,6 +84,16 @@ enum class TransactionFilter { NON_ZAPS, } +data class WalletInfo( + val walletId: String, + val name: String, + val alias: String? = null, + val balanceSats: Long? = null, + val isDefault: Boolean = false, + val isLoading: Boolean = false, + val error: String? = null, +) + private const val NWC_TIMEOUT_MS = 30_000L private const val PAYMENT_FAILED = "Payment failed" @@ -92,8 +104,33 @@ class WalletViewModel : ViewModel() { private val _hasWalletSetup = MutableStateFlow(false) val hasWalletSetup = _hasWalletSetup.asStateFlow() - private val _lnAddress = MutableStateFlow("") - val lnAddress = _lnAddress.asStateFlow() + private val walletInfoMap = MutableStateFlow>(emptyMap()) + + private val _wallets = MutableStateFlow>(emptyList()) + val wallets = _wallets.asStateFlow() + + private val _defaultWalletId = MutableStateFlow(null) + val defaultWalletId = _defaultWalletId.asStateFlow() + + val walletInfoList = + combine(_wallets, _defaultWalletId, walletInfoMap) { wallets, defaultId, infoMap -> + wallets.map { wallet -> + val info = infoMap[wallet.id] + WalletInfo( + walletId = wallet.id, + name = wallet.name, + alias = info?.alias, + balanceSats = info?.balanceSats, + isDefault = wallet.id == defaultId || (defaultId == null && wallet == wallets.firstOrNull()), + isLoading = info?.isLoading == true, + error = info?.error, + ) + } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + + // Selected wallet for detail view + private val _selectedWalletId = MutableStateFlow(null) + val selectedWalletId = _selectedWalletId.asStateFlow() private val _balanceSats = MutableStateFlow(null) val balanceSats = _balanceSats.asStateFlow() @@ -142,14 +179,24 @@ class WalletViewModel : ViewModel() { onTimeout() } + private val _lnAddress = MutableStateFlow("") + val lnAddress = _lnAddress.asStateFlow() + fun init(accountViewModel: AccountViewModel) { this.accountViewModel = accountViewModel this.account = accountViewModel.account - _hasWalletSetup.value = accountViewModel.account.nip47SignerState.hasWalletConnectSetup() + refreshWalletList() + } + + fun refreshWalletList() { + val acc = account ?: return + _wallets.value = acc.settings.nwcWallets.value + _defaultWalletId.value = acc.settings.defaultNwcWalletId.value + _hasWalletSetup.value = _wallets.value.isNotEmpty() } fun refreshWalletSetup() { - _hasWalletSetup.value = account?.nip47SignerState?.hasWalletConnectSetup() == true + refreshWalletList() } fun loadLnAddress() { @@ -182,19 +229,153 @@ class WalletViewModel : ViewModel() { acc.sendLiterallyEverywhere(event) } - fun fetchBalance() { + fun setDefaultWallet(walletId: String) { val acc = account ?: return + acc.settings.setDefaultNwcWallet(walletId) + _defaultWalletId.value = walletId + } + + fun removeWallet(walletId: String) { + val acc = account ?: return + acc.settings.removeNwcWallet(walletId) + refreshWalletList() + } + + fun addWallet( + name: String, + uri: Nip47WalletConnect.Nip47URINorm, + ): Boolean { + val acc = account ?: return false + val entry = + NwcWalletEntryNorm( + id = + java.util.UUID + .randomUUID() + .toString(), + name = name.ifBlank { "Wallet" }, + uri = uri, + ) + acc.settings.addNwcWallet(entry) + refreshWalletList() + return true + } + + fun renameWallet( + walletId: String, + newName: String, + ) { + val acc = account ?: return + acc.settings.renameNwcWallet(walletId, newName) + refreshWalletList() + } + + fun moveWallet( + fromIndex: Int, + toIndex: Int, + ) { + val acc = account ?: return + acc.settings.moveNwcWallet(fromIndex, toIndex) + refreshWalletList() + } + + fun selectWallet(walletId: String) { + _selectedWalletId.value = walletId + _balanceSats.value = walletInfoMap.value[walletId]?.balanceSats + _walletAlias.value = walletInfoMap.value[walletId]?.alias + allTransactions.value = emptyList() + } + + private fun getWalletUri(walletId: String?): Nip47WalletConnect.Nip47URINorm? = _wallets.value.firstOrNull { it.id == walletId }?.uri + + private fun getSelectedWalletUri(): Nip47WalletConnect.Nip47URINorm? = getWalletUri(_selectedWalletId.value) + + fun fetchAllBalances() { + _wallets.value.forEach { wallet -> + fetchBalanceForWallet(wallet.id) + } + } + + private fun fetchBalanceForWallet(walletId: String) { + val acc = account ?: return + val walletUri = getWalletUri(walletId) ?: return + viewModelScope.launch(Dispatchers.IO) { + updateWalletInfo(walletId) { it.copy(isLoading = true, error = null) } + try { + acc.sendNwcRequestToWallet(walletUri, GetBalanceMethod.create()) { response -> + when (response) { + is GetBalanceSuccessResponse -> { + val sats = (response.result?.balance ?: 0L) / 1000L + updateWalletInfo(walletId) { it.copy(balanceSats = sats, isLoading = false) } + } + + is NwcErrorResponse -> { + updateWalletInfo(walletId) { + it.copy(error = response.error?.message ?: "Balance request failed", isLoading = false) + } + } + + else -> { + updateWalletInfo(walletId) { it.copy(isLoading = false) } + } + } + } + } catch (e: Exception) { + updateWalletInfo(walletId) { it.copy(error = e.message, isLoading = false) } + } + } + } + + fun fetchInfoForWallet(walletId: String) { + val acc = account ?: return + val walletUri = getWalletUri(walletId) ?: return + viewModelScope.launch(Dispatchers.IO) { + try { + acc.sendNwcRequestToWallet(walletUri, GetInfoMethod.create()) { response -> + when (response) { + is GetInfoSuccessResponse -> { + updateWalletInfo(walletId) { it.copy(alias = response.result?.alias) } + } + + else -> {} + } + } + } catch (_: Exception) { + } + } + } + + private fun updateWalletInfo( + walletId: String, + transform: (WalletInfo) -> WalletInfo, + ) { + walletInfoMap.value = + walletInfoMap.value.toMutableMap().apply { + val current = + get(walletId) ?: WalletInfo( + walletId = walletId, + name = _wallets.value.firstOrNull { it.id == walletId }?.name ?: "Wallet", + ) + put(walletId, transform(current)) + } + } + + // --- Methods below operate on the selected wallet --- + + fun fetchBalance() { + val walletId = _selectedWalletId.value ?: _defaultWalletId.value ?: _wallets.value.firstOrNull()?.id ?: return + val acc = account ?: return + val walletUri = getWalletUri(walletId) ?: return viewModelScope.launch(Dispatchers.IO) { _isLoading.value = true _error.value = null val timeoutJob = launchTimeout { _isLoading.value = false } try { - acc.sendNwcRequest(GetBalanceMethod.create()) { response -> + acc.sendNwcRequestToWallet(walletUri, GetBalanceMethod.create()) { response -> timeoutJob.cancel() when (response) { is GetBalanceSuccessResponse -> { - // NWC balance is in millisats, convert to sats _balanceSats.value = (response.result?.balance ?: 0L) / 1000L + updateWalletInfo(walletId) { it.copy(balanceSats = _balanceSats.value) } } is NwcErrorResponse -> { @@ -214,32 +395,37 @@ class WalletViewModel : ViewModel() { } fun fetchInfo() { + val walletId = _selectedWalletId.value ?: _defaultWalletId.value ?: _wallets.value.firstOrNull()?.id ?: return val acc = account ?: return + val walletUri = getWalletUri(walletId) ?: return viewModelScope.launch(Dispatchers.IO) { try { - acc.sendNwcRequest(GetInfoMethod.create()) { response -> + acc.sendNwcRequestToWallet(walletUri, GetInfoMethod.create()) { response -> when (response) { is GetInfoSuccessResponse -> { _walletAlias.value = response.result?.alias + updateWalletInfo(walletId) { it.copy(alias = response.result?.alias) } } else -> {} } } - } catch (e: Exception) { - // ignore info errors + } catch (_: Exception) { } } } fun fetchTransactions() { + val walletId = _selectedWalletId.value ?: _defaultWalletId.value ?: _wallets.value.firstOrNull()?.id ?: return val acc = account ?: return + val walletUri = getWalletUri(walletId) ?: return viewModelScope.launch(Dispatchers.IO) { _isLoading.value = true _hasMoreTransactions.value = true val timeoutJob = launchTimeout { _isLoading.value = false } try { - acc.sendNwcRequest( + acc.sendNwcRequestToWallet( + walletUri, ListTransactionsMethod.create( limit = pageSize, offset = 0, @@ -278,13 +464,16 @@ class WalletViewModel : ViewModel() { fun loadMoreTransactions() { if (_isLoadingMore.value || !_hasMoreTransactions.value) return + val walletId = _selectedWalletId.value ?: _defaultWalletId.value ?: _wallets.value.firstOrNull()?.id ?: return val acc = account ?: return + val walletUri = getWalletUri(walletId) ?: return val currentOffset = allTransactions.value.size viewModelScope.launch(Dispatchers.IO) { _isLoadingMore.value = true val timeoutJob = launchTimeout { _isLoadingMore.value = false } try { - acc.sendNwcRequest( + acc.sendNwcRequestToWallet( + walletUri, ListTransactionsMethod.create( limit = pageSize, offset = currentOffset, @@ -295,7 +484,6 @@ class WalletViewModel : ViewModel() { when (response) { is ListTransactionsSuccessResponse -> { val newTxs = response.result?.transactions ?: emptyList() - allTransactions.value += newTxs val totalCount = response.result?.total_count _hasMoreTransactions.value = @@ -323,15 +511,16 @@ class WalletViewModel : ViewModel() { } fun sendPayment(bolt11: String) { + val walletId = _selectedWalletId.value ?: _defaultWalletId.value ?: _wallets.value.firstOrNull()?.id ?: return val acc = account ?: return + val walletUri = getWalletUri(walletId) ?: return viewModelScope.launch(Dispatchers.IO) { _sendState.value = SendState.Sending try { - acc.sendNwcRequest(PayInvoiceMethod.create(bolt11)) { response -> + acc.sendNwcRequestToWallet(walletUri, PayInvoiceMethod.create(bolt11)) { response -> when (response) { is PayInvoiceSuccessResponse -> { _sendState.value = SendState.Success(response.result?.preimage) - // Refresh balance after payment fetchBalance() } @@ -364,12 +553,14 @@ class WalletViewModel : ViewModel() { amountSats: Long, description: String? = null, ) { + val walletId = _selectedWalletId.value ?: _defaultWalletId.value ?: _wallets.value.firstOrNull()?.id ?: return val acc = account ?: return + val walletUri = getWalletUri(walletId) ?: return viewModelScope.launch(Dispatchers.IO) { _receiveState.value = ReceiveState.Creating try { - // NWC expects millisats - acc.sendNwcRequest( + acc.sendNwcRequestToWallet( + walletUri, MakeInvoiceMethod.create( amount = amountSats * 1000L, description = description, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 22e265c34..d081a5c84 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1380,6 +1380,27 @@ All Zaps Non-Zaps + Add Wallet + Default + Set as Default + Remove + Remove this wallet? + This will remove the wallet connection. You can always add it back later. + Wallet Name + My Wallet + Your Wallets + Manage Wallets + No wallets connected + Connect one or more NWC wallets to send and receive payments. + Add NWC Connection + Paste nostr+walletconnect:// URI + Save + Rename + Rename Wallet + Edit + Invalid NWC connection URI + Move Up + Move Down Security Filters Import Follows