Merge pull request #1988 from vitorpamplona/claude/multi-wallet-nwc-support-Fb04l

feat: add multi-wallet NWC support with balance view and default picker
This commit is contained in:
Vitor Pamplona
2026-04-08 17:51:04 -04:00
committed by GitHub
13 changed files with 1192 additions and 231 deletions
@@ -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<TopFilter>(defaultShortsFollowListStr) ?: TopFilter.Global }
val defaultLongsFollowList = async { parseOrNull<TopFilter>(defaultLongsFollowListStr) ?: TopFilter.Global }
val zapPaymentRequestServer = async { parseOrNull<Nip47WalletConnect.Nip47URI>(zapPaymentRequestServerStr) }
val nwcWalletsLoaded =
async {
val nwcWalletEntries = parseOrNull<List<NwcWalletEntry>>(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<Nip47WalletConnect.Nip47URI>(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<ServerName>(defaultFileServerStr) ?: DEFAULT_MEDIA_SERVERS[0] }
val viewedPollResultNoteIds = async { parseOrNull<Map<String, Long>>(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,
@@ -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?,
@@ -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<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultShortsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultLongsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val zapPaymentRequest: MutableStateFlow<Nip47WalletConnect.Nip47URINorm?> = MutableStateFlow(null),
val nwcWallets: MutableStateFlow<List<NwcWalletEntryNorm>> = MutableStateFlow(emptyList()),
val defaultNwcWalletId: MutableStateFlow<String?> = 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
// ---
@@ -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<Nip47WalletConnect.Nip47URINorm?>,
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<Nip47WalletConnect.Nip47URINorm?> =
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<LnZapPaymentRequestEvent, NormalizedRelayUrl> {
val walletService = nip47Setup.value ?: throw IllegalArgumentException("No NIP47 setup")
): Pair<LnZapPaymentRequestEvent, NormalizedRelayUrl> = 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<LnZapPaymentRequestEvent, NormalizedRelayUrl> {
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<LnZapPaymentRequestEvent, NormalizedRelayUrl> {
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)
@@ -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)
}
}
@@ -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<Route.WalletSend> { WalletSendScreen(accountViewModel, nav) }
composableFromEnd<Route.WalletReceive> { WalletReceiveScreen(accountViewModel, nav) }
composableFromEnd<Route.WalletTransactions> { WalletTransactionsScreen(accountViewModel, nav) }
composableFromEndArgs<Route.WalletDetail> { WalletDetailScreen(it.walletId, accountViewModel, nav) }
composableFromEnd<Route.WalletAdd> { AddWalletScreen(accountViewModel, nav) }
composableFromEnd<Route.Lists> { ListOfPeopleListsScreen(accountViewModel, nav) }
composableFromEndArgs<Route.MyPeopleListView> { PeopleListScreen(it.dTag, accountViewModel, nav) }
@@ -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()
@@ -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)
@@ -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<String?>(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))
}
}
}
}
@@ -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))
}
}
}
@@ -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))
}
},
)
}
@@ -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<Map<String, WalletInfo>>(emptyMap())
private val _wallets = MutableStateFlow<List<NwcWalletEntryNorm>>(emptyList())
val wallets = _wallets.asStateFlow()
private val _defaultWalletId = MutableStateFlow<String?>(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<String?>(null)
val selectedWalletId = _selectedWalletId.asStateFlow()
private val _balanceSats = MutableStateFlow<Long?>(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,
+21
View File
@@ -1380,6 +1380,27 @@
<string name="wallet_filter_all">All</string>
<string name="wallet_filter_zaps">Zaps</string>
<string name="wallet_filter_non_zaps">Non-Zaps</string>
<string name="wallet_add">Add Wallet</string>
<string name="wallet_default">Default</string>
<string name="wallet_set_default">Set as Default</string>
<string name="wallet_remove">Remove</string>
<string name="wallet_remove_confirm">Remove this wallet?</string>
<string name="wallet_remove_confirm_description">This will remove the wallet connection. You can always add it back later.</string>
<string name="wallet_name">Wallet Name</string>
<string name="wallet_name_hint">My Wallet</string>
<string name="wallet_your_wallets">Your Wallets</string>
<string name="wallet_manage">Manage Wallets</string>
<string name="wallet_no_wallets">No wallets connected</string>
<string name="wallet_no_wallets_description">Connect one or more NWC wallets to send and receive payments.</string>
<string name="wallet_add_connection">Add NWC Connection</string>
<string name="wallet_paste_uri">Paste nostr+walletconnect:// URI</string>
<string name="wallet_save">Save</string>
<string name="wallet_rename">Rename</string>
<string name="wallet_rename_title">Rename Wallet</string>
<string name="wallet_edit">Edit</string>
<string name="wallet_invalid_uri">Invalid NWC connection URI</string>
<string name="wallet_move_up">Move Up</string>
<string name="wallet_move_down">Move Down</string>
<string name="route_security_filters">Security Filters</string>
<string name="route_import_follows">Import Follows</string>