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.AccountSettings
import com.vitorpamplona.amethyst.model.TopFilter import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.model.UiSettings 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.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName 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_PICTURES_FOLLOW_LIST = "defaultPicturesFollowList"
const val DEFAULT_SHORTS_FOLLOW_LIST = "defaultShortsFollowList" const val DEFAULT_SHORTS_FOLLOW_LIST = "defaultShortsFollowList"
const val DEFAULT_LONGS_FOLLOW_LIST = "defaultLongsFollowList" 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_USER_METADATA = "latestUserMetadata"
const val LATEST_CONTACT_LIST = "latestContactList" const val LATEST_CONTACT_LIST = "latestContactList"
const val LATEST_DM_RELAY_LIST = "latestDMRelayList" 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_SHORTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultShortsFollowList.value))
putString(PrefKeys.DEFAULT_LONGS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultLongsFollowList.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) putOrRemove(PrefKeys.LATEST_CONTACT_LIST, settings.backupContactList)
@@ -495,6 +510,8 @@ object LocalPreferences {
val defaultLongsFollowListStr = getString(PrefKeys.DEFAULT_LONGS_FOLLOW_LIST, null) val defaultLongsFollowListStr = getString(PrefKeys.DEFAULT_LONGS_FOLLOW_LIST, null)
val zapPaymentRequestServerStr = getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, 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 defaultFileServerStr = getString(PrefKeys.DEFAULT_FILE_SERVER, null)
val pendingAttestationsStr = getString(PrefKeys.PENDING_ATTESTATIONS, null) val pendingAttestationsStr = getString(PrefKeys.PENDING_ATTESTATIONS, null)
@@ -531,7 +548,32 @@ object LocalPreferences {
val defaultShortsFollowList = async { parseOrNull<TopFilter>(defaultShortsFollowListStr) ?: TopFilter.Global } val defaultShortsFollowList = async { parseOrNull<TopFilter>(defaultShortsFollowListStr) ?: TopFilter.Global }
val defaultLongsFollowList = async { parseOrNull<TopFilter>(defaultLongsFollowListStr) ?: 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 defaultFileServer = async { parseOrNull<ServerName>(defaultFileServerStr) ?: DEFAULT_MEDIA_SERVERS[0] }
val viewedPollResultNoteIds = async { parseOrNull<Map<String, Long>>(viewedPollResultNoteIdsStr) ?: mapOf() } val viewedPollResultNoteIds = async { parseOrNull<Map<String, Long>>(viewedPollResultNoteIdsStr) ?: mapOf() }
@@ -580,7 +622,8 @@ object LocalPreferences {
defaultPicturesFollowList = MutableStateFlow(defaultPicturesFollowList.await()), defaultPicturesFollowList = MutableStateFlow(defaultPicturesFollowList.await()),
defaultShortsFollowList = MutableStateFlow(defaultShortsFollowList.await()), defaultShortsFollowList = MutableStateFlow(defaultShortsFollowList.await()),
defaultLongsFollowList = MutableStateFlow(defaultLongsFollowList.await()), defaultLongsFollowList = MutableStateFlow(defaultLongsFollowList.await()),
zapPaymentRequest = MutableStateFlow(zapPaymentRequestServer.await()?.normalize()), nwcWallets = MutableStateFlow(nwcWalletsLoaded.await().first),
defaultNwcWalletId = MutableStateFlow(nwcWalletsLoaded.await().second),
hideDeleteRequestDialog = hideDeleteRequestDialog, hideDeleteRequestDialog = hideDeleteRequestDialog,
hideBlockAlertDialog = hideBlockAlertDialog, hideBlockAlertDialog = hideBlockAlertDialog,
hideNIP17WarningDialog = hideNIP17WarningDialog, hideNIP17WarningDialog = hideNIP17WarningDialog,
@@ -270,7 +270,7 @@ class Account(
val userMetadata = UserMetadataState(signer, cache, scope, settings) 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 nip65RelayList = Nip65RelayListState(signer, cache, scope, settings)
val localRelayList = LocalRelayListState(signer, cache, scope, settings) val localRelayList = LocalRelayListState(signer, cache, scope, settings)
@@ -616,6 +616,15 @@ class Account(
client.publish(event, setOf(relay)) 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( suspend fun sendZapPaymentRequestFor(
bolt11: String, bolt11: String,
zappedNote: Note?, zappedNote: Note?,
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatRepository import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatRepository
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatListRepository 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.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.screen.FeedDefinition import com.vitorpamplona.amethyst.ui.screen.FeedDefinition
@@ -170,7 +171,8 @@ class AccountSettings(
val defaultPicturesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global), val defaultPicturesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultShortsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global), val defaultShortsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultLongsFollowList: 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 hideDeleteRequestDialog: Boolean = false,
var hideBlockAlertDialog: Boolean = false, var hideBlockAlertDialog: Boolean = false,
var hideNIP17WarningDialog: Boolean = false, var hideNIP17WarningDialog: Boolean = false,
@@ -253,15 +255,114 @@ class AccountSettings(
return false return false
} }
fun changeZapPaymentRequest(newServer: Nip47WalletConnect.Nip47URINorm?): Boolean { fun defaultNwcWallet(): NwcWalletEntryNorm? {
if (zapPaymentRequest.value != newServer) { val id = defaultNwcWalletId.value
zapPaymentRequest.tryEmit(newServer) 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() saveAccountSettings()
return true return true
} }
return false 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 // file servers
// --- // ---
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.model.nip47WalletConnect package com.vitorpamplona.amethyst.model.nip47WalletConnect
import com.vitorpamplona.amethyst.commons.model.INwcSignerState import com.vitorpamplona.amethyst.commons.model.INwcSignerState
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler 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.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn 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. * Manages NIP-47 (Nostr Wallet Connect) related signing operations and decryption cache for a given account.
* * Supports multiple wallets with a default wallet used for zaps.
* 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
*/ */
class NwcSignerState( class NwcSignerState(
val signer: NostrSigner, val signer: NostrSigner,
val nwcFilterAssembler: () -> NWCPaymentFilterAssembler, val nwcFilterAssembler: () -> NWCPaymentFilterAssembler,
val cache: LocalCache, val cache: LocalCache,
val scope: CoroutineScope, val scope: CoroutineScope,
val nip47Setup: MutableStateFlow<Nip47WalletConnect.Nip47URINorm?>, val settings: AccountSettings,
) : INwcSignerState { ) : INwcSignerState {
/** /**
* Derives a NIP-47 signer from the zap payment request in settings. * Flow of the default wallet's NWC URI, derived from multi-wallet settings.
* If there's no valid configuration, it defaults to the main signer. */
* Flows updates whenever settings change. 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 = val nip47Signer =
nip47Setup defaultWalletUri
.map { .map {
buildSigner(it) ?: signer buildSigner(it) ?: signer
}.flowOn(Dispatchers.IO) }.flowOn(Dispatchers.IO)
.stateIn( .stateIn(
scope, scope,
SharingStarted.Eagerly, 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 = val zapPaymentRequestDecryptionCache =
nip47Signer nip47Signer
.map { .map {
@@ -96,10 +95,6 @@ class NwcSignerState(
}.flowOn(Dispatchers.IO) }.flowOn(Dispatchers.IO)
.stateIn(scope, SharingStarted.Eagerly, NostrWalletConnectRequestCache(nip47Signer.value)) .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 = val zapPaymentResponseDecryptionCache =
nip47Signer nip47Signer
.map { .map {
@@ -112,48 +107,40 @@ class NwcSignerState(
NostrSignerInternal(KeyPair(it)) 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 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? { override suspend fun decryptRequest(event: LnZapPaymentRequestEvent): Request? {
if (!hasWalletConnectSetup()) return null if (!hasWalletConnectSetup()) return null
return zapPaymentRequestDecryptionCache.value.decryptRequest(event) 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? { override suspend fun decryptResponse(event: LnZapPaymentResponseEvent): Response? {
if (!hasWalletConnectSetup()) return null if (!hasWalletConnectSetup()) return null
return zapPaymentResponseDecryptionCache.value.decryptResponse(event) return zapPaymentResponseDecryptionCache.value.decryptResponse(event)
} }
/** /**
* Sends a generic NIP-47 request to the connected wallet. * Sends a generic NIP-47 request to the default 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
*/ */
suspend fun sendNwcRequest( suspend fun sendNwcRequest(
request: Request, request: Request,
onResponse: (Response?) -> Unit, onResponse: (Response?) -> Unit,
): Pair<LnZapPaymentRequestEvent, NormalizedRelayUrl> { ): Pair<LnZapPaymentRequestEvent, NormalizedRelayUrl> = sendNwcRequestToWallet(defaultWalletUri.value, request, onResponse)
val walletService = nip47Setup.value ?: throw IllegalArgumentException("No NIP47 setup")
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 = val filter =
NWCPaymentQueryState( NWCPaymentQueryState(
@@ -172,29 +159,23 @@ class NwcSignerState(
assembler.unsubscribe(filter) assembler.unsubscribe(filter)
} }
val responseCache = NostrWalletConnectResponseCache(walletSigner)
cache.consume(event, null, true, walletService.relayUri) { cache.consume(event, null, true, walletService.relayUri) {
onResponse(decryptResponse(it)) onResponse(responseCache.decryptResponse(it))
} }
return Pair(event, walletService.relayUri) return Pair(event, walletService.relayUri)
} }
/** /**
* Sends a zap payment request to a connected Lightning wallet. * Sends a zap payment request to the default 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
*/ */
suspend fun sendZapPaymentRequestFor( suspend fun sendZapPaymentRequestFor(
bolt11: String, bolt11: String,
zappedNote: Note?, zappedNote: Note?,
onResponse: (Response?) -> Unit, onResponse: (Response?) -> Unit,
): Pair<LnZapPaymentRequestEvent, NormalizedRelayUrl> { ): 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) 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.shorts.ShortsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.ThreadScreen 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.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.WalletReceiveScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletSendScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletSendScreen
@@ -223,6 +225,8 @@ fun BuildNavigation(
composableFromEnd<Route.WalletSend> { WalletSendScreen(accountViewModel, nav) } composableFromEnd<Route.WalletSend> { WalletSendScreen(accountViewModel, nav) }
composableFromEnd<Route.WalletReceive> { WalletReceiveScreen(accountViewModel, nav) } composableFromEnd<Route.WalletReceive> { WalletReceiveScreen(accountViewModel, nav) }
composableFromEnd<Route.WalletTransactions> { WalletTransactionsScreen(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) } composableFromEnd<Route.Lists> { ListOfPeopleListsScreen(accountViewModel, nav) }
composableFromEndArgs<Route.MyPeopleListView> { PeopleListScreen(it.dTag, accountViewModel, nav) } composableFromEndArgs<Route.MyPeopleListView> { PeopleListScreen(it.dTag, accountViewModel, nav) }
@@ -61,6 +61,13 @@ sealed class Route {
@Serializable object WalletTransactions : Route() @Serializable object WalletTransactions : Route()
@Serializable
data class WalletDetail(
val walletId: String,
) : Route()
@Serializable object WalletAdd : Route()
@Serializable object Search : Route() @Serializable object Search : Route()
@Serializable object SecurityFilters : Route() @Serializable object SecurityFilters : Route()
@@ -61,7 +61,7 @@ class UpdateZapAmountViewModel : ViewModel() {
this.amountSet = accountViewModel.account.settings.syncedSettings.zaps.zapAmountChoices.value this.amountSet = accountViewModel.account.settings.syncedSettings.zaps.zapAmountChoices.value
this.selectedZapType = accountViewModel.account.settings.syncedSettings.zaps.defaultZapType.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.walletConnectPubkey = nip47?.pubKeyHex?.let { TextFieldValue(it) } ?: TextFieldValue("")
this.walletConnectRelay = nip47?.relayUri?.url?.let { TextFieldValue(it) } ?: TextFieldValue("") this.walletConnectRelay = nip47?.relayUri?.url?.let { TextFieldValue(it) } ?: TextFieldValue("")
@@ -125,23 +125,16 @@ class UpdateZapAmountViewModel : ViewModel() {
nextAmount = TextFieldValue("") nextAmount = TextFieldValue("")
} }
fun hasChanged(): Boolean = fun hasChanged(): Boolean {
( val defaultUri = accountViewModel.account.settings.defaultZapPaymentRequest()
return (
selectedZapType != accountViewModel.account.settings.syncedSettings.zaps.defaultZapType.value || selectedZapType != accountViewModel.account.settings.syncedSettings.zaps.defaultZapType.value ||
amountSet != accountViewModel.account.settings.syncedSettings.zaps.zapAmountChoices.value || amountSet != accountViewModel.account.settings.syncedSettings.zaps.zapAmountChoices.value ||
walletConnectPubkey.text != ( walletConnectPubkey.text != (defaultUri?.pubKeyHex ?: "") ||
accountViewModel.account.settings.zapPaymentRequest.value walletConnectRelay.text != (defaultUri?.relayUri?.url ?: "") ||
?.pubKeyHex ?: "" walletConnectSecret.text != (defaultUri?.secret ?: "")
) ||
walletConnectRelay.text != (
accountViewModel.account.settings.zapPaymentRequest.value
?.relayUri ?: ""
) ||
walletConnectSecret.text != (
accountViewModel.account.settings.zapPaymentRequest.value
?.secret ?: ""
)
) )
}
fun updateNIP47(uri: String) { fun updateNIP47(uri: String) {
val contact = Nip47WalletConnect.parse(uri) 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 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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row 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.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width 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.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.List import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowDownward import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.ArrowUpward import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.outlined.Settings 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.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.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@@ -91,13 +104,11 @@ fun WalletScreen(
} }
}, },
actions = { actions = {
if (hasWallet) { IconButton(onClick = { nav.nav(Route.WalletAdd) }) {
IconButton(onClick = { nav.nav(Route.Nip47NWCSetup()) }) { Icon(
Icon( imageVector = Icons.Filled.Add,
imageVector = Icons.Outlined.Settings, contentDescription = stringRes(R.string.wallet_add),
contentDescription = stringRes(R.string.settings), )
)
}
} }
}, },
) )
@@ -109,7 +120,7 @@ fun WalletScreen(
nav = nav, nav = nav,
) )
} else { } else {
WalletHomeContent( MultiWalletHomeContent(
walletViewModel = walletViewModel, walletViewModel = walletViewModel,
modifier = Modifier.padding(padding), modifier = Modifier.padding(padding),
nav = nav, nav = nav,
@@ -132,158 +143,347 @@ private fun NoWalletSetup(
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
) { ) {
Text( Text(
text = stringRes(R.string.wallet_no_connection), text = stringRes(R.string.wallet_no_wallets),
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Text( Text(
text = stringRes(R.string.wallet_no_connection_description), text = stringRes(R.string.wallet_no_wallets_description),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
Button(onClick = { nav.nav(Route.Nip47NWCSetup()) }) { Button(onClick = { nav.nav(Route.WalletAdd) }) {
Text(stringRes(R.string.wallet_setup)) 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 @Composable
private fun WalletHomeContent( private fun MultiWalletHomeContent(
walletViewModel: WalletViewModel, walletViewModel: WalletViewModel,
modifier: Modifier, modifier: Modifier,
nav: INav, nav: INav,
) { ) {
val balance by walletViewModel.balanceSats.collectAsState() val walletInfoList by walletViewModel.walletInfoList.collectAsState()
val walletAlias by walletViewModel.walletAlias.collectAsState()
val isLoading by walletViewModel.isLoading.collectAsState()
val error by walletViewModel.error.collectAsState()
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
walletViewModel.fetchBalance() walletViewModel.fetchAllBalances()
walletViewModel.fetchInfo() walletViewModel.wallets.value.forEach { wallet ->
walletViewModel.fetchInfoForWallet(wallet.id)
}
} }
Column( LazyColumn(
modifier = modifier =
modifier modifier
.fillMaxSize() .fillMaxSize()
.padding(24.dp), .padding(horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
Spacer(modifier = Modifier.height(32.dp)) item {
// 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) {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = error!!, text = stringRes(R.string.wallet_your_wallets),
color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.titleMedium,
style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.SemiBold,
) )
} }
Spacer(modifier = Modifier.weight(1f)) itemsIndexed(walletInfoList, key = { _, info -> info.walletId }) { index, walletInfo ->
WalletCard(
// Action buttons walletInfo = walletInfo,
Row( index = index,
modifier = Modifier.fillMaxWidth(), totalCount = walletInfoList.size,
horizontalArrangement = Arrangement.spacedBy(16.dp), onSelect = {
) { walletViewModel.selectWallet(walletInfo.walletId)
Button( nav.nav(Route.WalletDetail(walletInfo.walletId))
onClick = { nav.nav(Route.WalletReceive) }, },
modifier = onSetDefault = {
Modifier walletViewModel.setDefaultWallet(walletInfo.walletId)
.weight(1f) },
.height(56.dp), onRename = { newName ->
shape = RoundedCornerShape(16.dp), walletViewModel.renameWallet(walletInfo.walletId, newName)
colors = },
ButtonDefaults.buttonColors( onMoveUp = {
containerColor = MaterialTheme.colorScheme.primaryContainer, walletViewModel.moveWallet(index, index - 1)
contentColor = MaterialTheme.colorScheme.onPrimaryContainer, },
), onMoveDown = {
) { walletViewModel.moveWallet(index, index + 1)
Icon( },
imageVector = Icons.Filled.ArrowDownward, onRemove = {
contentDescription = null, walletViewModel.removeWallet(walletInfo.walletId)
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)) 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.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.nip47WalletConnect.NwcWalletEntryNorm
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel 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.GetBalanceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceSuccessResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetInfoMethod import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetInfoMethod
@@ -82,6 +84,16 @@ enum class TransactionFilter {
NON_ZAPS, 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 NWC_TIMEOUT_MS = 30_000L
private const val PAYMENT_FAILED = "Payment failed" private const val PAYMENT_FAILED = "Payment failed"
@@ -92,8 +104,33 @@ class WalletViewModel : ViewModel() {
private val _hasWalletSetup = MutableStateFlow(false) private val _hasWalletSetup = MutableStateFlow(false)
val hasWalletSetup = _hasWalletSetup.asStateFlow() val hasWalletSetup = _hasWalletSetup.asStateFlow()
private val _lnAddress = MutableStateFlow("") private val walletInfoMap = MutableStateFlow<Map<String, WalletInfo>>(emptyMap())
val lnAddress = _lnAddress.asStateFlow()
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) private val _balanceSats = MutableStateFlow<Long?>(null)
val balanceSats = _balanceSats.asStateFlow() val balanceSats = _balanceSats.asStateFlow()
@@ -142,14 +179,24 @@ class WalletViewModel : ViewModel() {
onTimeout() onTimeout()
} }
private val _lnAddress = MutableStateFlow("")
val lnAddress = _lnAddress.asStateFlow()
fun init(accountViewModel: AccountViewModel) { fun init(accountViewModel: AccountViewModel) {
this.accountViewModel = accountViewModel this.accountViewModel = accountViewModel
this.account = accountViewModel.account 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() { fun refreshWalletSetup() {
_hasWalletSetup.value = account?.nip47SignerState?.hasWalletConnectSetup() == true refreshWalletList()
} }
fun loadLnAddress() { fun loadLnAddress() {
@@ -182,19 +229,153 @@ class WalletViewModel : ViewModel() {
acc.sendLiterallyEverywhere(event) acc.sendLiterallyEverywhere(event)
} }
fun fetchBalance() { fun setDefaultWallet(walletId: String) {
val acc = account ?: return 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) { viewModelScope.launch(Dispatchers.IO) {
_isLoading.value = true _isLoading.value = true
_error.value = null _error.value = null
val timeoutJob = launchTimeout { _isLoading.value = false } val timeoutJob = launchTimeout { _isLoading.value = false }
try { try {
acc.sendNwcRequest(GetBalanceMethod.create()) { response -> acc.sendNwcRequestToWallet(walletUri, GetBalanceMethod.create()) { response ->
timeoutJob.cancel() timeoutJob.cancel()
when (response) { when (response) {
is GetBalanceSuccessResponse -> { is GetBalanceSuccessResponse -> {
// NWC balance is in millisats, convert to sats
_balanceSats.value = (response.result?.balance ?: 0L) / 1000L _balanceSats.value = (response.result?.balance ?: 0L) / 1000L
updateWalletInfo(walletId) { it.copy(balanceSats = _balanceSats.value) }
} }
is NwcErrorResponse -> { is NwcErrorResponse -> {
@@ -214,32 +395,37 @@ class WalletViewModel : ViewModel() {
} }
fun fetchInfo() { fun fetchInfo() {
val walletId = _selectedWalletId.value ?: _defaultWalletId.value ?: _wallets.value.firstOrNull()?.id ?: return
val acc = account ?: return val acc = account ?: return
val walletUri = getWalletUri(walletId) ?: return
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
try { try {
acc.sendNwcRequest(GetInfoMethod.create()) { response -> acc.sendNwcRequestToWallet(walletUri, GetInfoMethod.create()) { response ->
when (response) { when (response) {
is GetInfoSuccessResponse -> { is GetInfoSuccessResponse -> {
_walletAlias.value = response.result?.alias _walletAlias.value = response.result?.alias
updateWalletInfo(walletId) { it.copy(alias = response.result?.alias) }
} }
else -> {} else -> {}
} }
} }
} catch (e: Exception) { } catch (_: Exception) {
// ignore info errors
} }
} }
} }
fun fetchTransactions() { fun fetchTransactions() {
val walletId = _selectedWalletId.value ?: _defaultWalletId.value ?: _wallets.value.firstOrNull()?.id ?: return
val acc = account ?: return val acc = account ?: return
val walletUri = getWalletUri(walletId) ?: return
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
_isLoading.value = true _isLoading.value = true
_hasMoreTransactions.value = true _hasMoreTransactions.value = true
val timeoutJob = launchTimeout { _isLoading.value = false } val timeoutJob = launchTimeout { _isLoading.value = false }
try { try {
acc.sendNwcRequest( acc.sendNwcRequestToWallet(
walletUri,
ListTransactionsMethod.create( ListTransactionsMethod.create(
limit = pageSize, limit = pageSize,
offset = 0, offset = 0,
@@ -278,13 +464,16 @@ class WalletViewModel : ViewModel() {
fun loadMoreTransactions() { fun loadMoreTransactions() {
if (_isLoadingMore.value || !_hasMoreTransactions.value) return if (_isLoadingMore.value || !_hasMoreTransactions.value) return
val walletId = _selectedWalletId.value ?: _defaultWalletId.value ?: _wallets.value.firstOrNull()?.id ?: return
val acc = account ?: return val acc = account ?: return
val walletUri = getWalletUri(walletId) ?: return
val currentOffset = allTransactions.value.size val currentOffset = allTransactions.value.size
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
_isLoadingMore.value = true _isLoadingMore.value = true
val timeoutJob = launchTimeout { _isLoadingMore.value = false } val timeoutJob = launchTimeout { _isLoadingMore.value = false }
try { try {
acc.sendNwcRequest( acc.sendNwcRequestToWallet(
walletUri,
ListTransactionsMethod.create( ListTransactionsMethod.create(
limit = pageSize, limit = pageSize,
offset = currentOffset, offset = currentOffset,
@@ -295,7 +484,6 @@ class WalletViewModel : ViewModel() {
when (response) { when (response) {
is ListTransactionsSuccessResponse -> { is ListTransactionsSuccessResponse -> {
val newTxs = response.result?.transactions ?: emptyList() val newTxs = response.result?.transactions ?: emptyList()
allTransactions.value += newTxs allTransactions.value += newTxs
val totalCount = response.result?.total_count val totalCount = response.result?.total_count
_hasMoreTransactions.value = _hasMoreTransactions.value =
@@ -323,15 +511,16 @@ class WalletViewModel : ViewModel() {
} }
fun sendPayment(bolt11: String) { fun sendPayment(bolt11: String) {
val walletId = _selectedWalletId.value ?: _defaultWalletId.value ?: _wallets.value.firstOrNull()?.id ?: return
val acc = account ?: return val acc = account ?: return
val walletUri = getWalletUri(walletId) ?: return
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
_sendState.value = SendState.Sending _sendState.value = SendState.Sending
try { try {
acc.sendNwcRequest(PayInvoiceMethod.create(bolt11)) { response -> acc.sendNwcRequestToWallet(walletUri, PayInvoiceMethod.create(bolt11)) { response ->
when (response) { when (response) {
is PayInvoiceSuccessResponse -> { is PayInvoiceSuccessResponse -> {
_sendState.value = SendState.Success(response.result?.preimage) _sendState.value = SendState.Success(response.result?.preimage)
// Refresh balance after payment
fetchBalance() fetchBalance()
} }
@@ -364,12 +553,14 @@ class WalletViewModel : ViewModel() {
amountSats: Long, amountSats: Long,
description: String? = null, description: String? = null,
) { ) {
val walletId = _selectedWalletId.value ?: _defaultWalletId.value ?: _wallets.value.firstOrNull()?.id ?: return
val acc = account ?: return val acc = account ?: return
val walletUri = getWalletUri(walletId) ?: return
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
_receiveState.value = ReceiveState.Creating _receiveState.value = ReceiveState.Creating
try { try {
// NWC expects millisats acc.sendNwcRequestToWallet(
acc.sendNwcRequest( walletUri,
MakeInvoiceMethod.create( MakeInvoiceMethod.create(
amount = amountSats * 1000L, amount = amountSats * 1000L,
description = description, description = description,
+21
View File
@@ -1380,6 +1380,27 @@
<string name="wallet_filter_all">All</string> <string name="wallet_filter_all">All</string>
<string name="wallet_filter_zaps">Zaps</string> <string name="wallet_filter_zaps">Zaps</string>
<string name="wallet_filter_non_zaps">Non-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_security_filters">Security Filters</string>
<string name="route_import_follows">Import Follows</string> <string name="route_import_follows">Import Follows</string>