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:
@@ -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
|
||||
// ---
|
||||
|
||||
+40
-59
@@ -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)
|
||||
|
||||
|
||||
+48
@@ -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()
|
||||
|
||||
+8
-15
@@ -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)
|
||||
|
||||
+145
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+218
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
+331
-131
@@ -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))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
+208
-17
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user