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