Merge pull request #2183 from greenart7c3/claude/move-payment-targets-wallet-KQlnM

Integrate Lightning Address and Payment Targets into NIP47 Setup
This commit is contained in:
Vitor Pamplona
2026-04-08 17:12:21 -04:00
committed by GitHub
12 changed files with 219 additions and 48 deletions
@@ -227,22 +227,6 @@ fun NewUserMetadataScreen(
Spacer(modifier = Modifier.height(10.dp)) Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
label = { Text(text = stringRes(R.string.lightning_address)) },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.lnAddress.value,
onValueChange = { postViewModel.lnAddress.value = it },
placeholder = {
Text(
text = "me@mylightningnode.com",
color = MaterialTheme.colorScheme.placeholderText,
)
},
singleLine = true,
)
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField( OutlinedTextField(
label = { Text(text = stringRes(R.string.nip_05) + " (NIP-05)") }, label = { Text(text = stringRes(R.string.nip_05) + " (NIP-05)") },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -57,8 +57,6 @@ class NewUserMetadataViewModel : ViewModel() {
val website = mutableStateOf("") val website = mutableStateOf("")
val pronouns = mutableStateOf("") val pronouns = mutableStateOf("")
val nip05 = mutableStateOf("") val nip05 = mutableStateOf("")
val lnAddress = mutableStateOf("")
val lnURL = mutableStateOf("")
val twitter = mutableStateOf("") val twitter = mutableStateOf("")
val github = mutableStateOf("") val github = mutableStateOf("")
@@ -82,8 +80,6 @@ class NewUserMetadataViewModel : ViewModel() {
website.value = it.info.website ?: "" website.value = it.info.website ?: ""
pronouns.value = it.info.pronouns ?: "" pronouns.value = it.info.pronouns ?: ""
nip05.value = it.info.nip05 ?: "" nip05.value = it.info.nip05 ?: ""
lnAddress.value = it.info.lud16 ?: ""
lnURL.value = it.info.lud06 ?: ""
} }
twitter.value = "" twitter.value = ""
@@ -121,8 +117,6 @@ class NewUserMetadataViewModel : ViewModel() {
pronouns = pronouns.value, pronouns = pronouns.value,
about = about.value, about = about.value,
nip05 = nip05.value, nip05 = nip05.value,
lnAddress = lnAddress.value,
lnURL = lnURL.value,
) )
val identities = val identities =
@@ -146,8 +140,6 @@ class NewUserMetadataViewModel : ViewModel() {
banner.value = "" banner.value = ""
website.value = "" website.value = ""
nip05.value = "" nip05.value = ""
lnAddress.value = ""
lnURL.value = ""
twitter.value = "" twitter.value = ""
github.value = "" github.value = ""
mastodon.value = "" mastodon.value = ""
@@ -79,9 +79,15 @@ class PaymentTargetsViewModel : ViewModel() {
fun savePaymentTargets() { fun savePaymentTargets() {
if (isModified) { if (isModified) {
accountViewModel.launchSigner { accountViewModel.launchSigner {
savePaymentTargetsSuspend()
}
}
}
suspend fun savePaymentTargetsSuspend() {
if (isModified) {
account.savePaymentTargets(_paymentTargets.value) account.savePaymentTargets(_paymentTargets.value)
refresh() refresh()
} }
} }
} }
}
@@ -46,6 +46,7 @@ import androidx.compose.animation.togetherWith
import androidx.compose.foundation.clickable 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.ColumnScope
import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -148,6 +149,7 @@ fun UpdateZapAmountContent(
onClose: () -> Unit, onClose: () -> Unit,
nip47uri: String? = null, nip47uri: String? = null,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
trailingContent: @Composable ColumnScope.() -> Unit = {},
) { ) {
val context = LocalContext.current val context = LocalContext.current
val clipboardManager = LocalClipboard.current val clipboardManager = LocalClipboard.current
@@ -649,6 +651,8 @@ fun UpdateZapAmountContent(
} }
} }
trailingContent()
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
} }
} }
@@ -84,6 +84,12 @@ class UpdateZapAmountViewModel : ViewModel() {
} }
fun sendPost() { fun sendPost() {
accountViewModel.launchSigner {
sendPostSuspend()
}
}
suspend fun sendPostSuspend() {
val nip47Update = val nip47Update =
if (walletConnectRelay.text.isNotBlank() && walletConnectPubkey.text.isNotBlank()) { if (walletConnectRelay.text.isNotBlank() && walletConnectPubkey.text.isNotBlank()) {
val pubkeyHex = val pubkeyHex =
@@ -110,7 +116,7 @@ class UpdateZapAmountViewModel : ViewModel() {
null null
} }
accountViewModel.updateZapAmounts(amountSet, selectedZapType, nip47Update) accountViewModel.account.updateZapAmounts(amountSet, selectedZapType, nip47Update)
nextAmount = TextFieldValue("") nextAmount = TextFieldValue("")
} }
@@ -29,7 +29,6 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.AccountBalanceWallet
import androidx.compose.material.icons.outlined.Bolt import androidx.compose.material.icons.outlined.Bolt
import androidx.compose.material.icons.outlined.CloudUpload import androidx.compose.material.icons.outlined.CloudUpload
import androidx.compose.material.icons.outlined.DeleteForever import androidx.compose.material.icons.outlined.DeleteForever
@@ -122,13 +121,6 @@ fun AllSettingsScreen(
onClick = { nav.nav(Route.EditMediaServers) }, onClick = { nav.nav(Route.EditMediaServers) },
) )
HorizontalDivider() HorizontalDivider()
SettingsNavigationRow(
title = R.string.payment_targets,
icon = Icons.Outlined.AccountBalanceWallet,
tint = tint,
onClick = { nav.nav(Route.EditPaymentTargets) },
)
HorizontalDivider()
SettingsNavigationRow( SettingsNavigationRow(
title = R.string.reactions, title = R.string.reactions,
icon = Icons.Outlined.FavoriteBorder, icon = Icons.Outlined.FavoriteBorder,
@@ -20,20 +20,45 @@
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings
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.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
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.Scaffold
import androidx.compose.material3.Text
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.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.paymentTargets.PaymentTargetAddField
import com.vitorpamplona.amethyst.ui.actions.paymentTargets.PaymentTargetsViewModel
import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar
import com.vitorpamplona.amethyst.ui.note.UpdateZapAmountContent import com.vitorpamplona.amethyst.ui.note.UpdateZapAmountContent
import com.vitorpamplona.amethyst.ui.note.UpdateZapAmountViewModel import com.vitorpamplona.amethyst.ui.note.UpdateZapAmountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.SettingsCategory
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.SettingsCategorySpacingModifier
import com.vitorpamplona.amethyst.ui.theme.grayText
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget
@Composable @Composable
fun NIP47SetupScreen( fun NIP47SetupScreen(
@@ -44,17 +69,27 @@ fun NIP47SetupScreen(
val postViewModel: UpdateZapAmountViewModel = viewModel() val postViewModel: UpdateZapAmountViewModel = viewModel()
postViewModel.init(accountViewModel) postViewModel.init(accountViewModel)
val walletViewModel: WalletViewModel = viewModel()
walletViewModel.init(accountViewModel)
val paymentTargetsViewModel: PaymentTargetsViewModel = viewModel()
paymentTargetsViewModel.init(accountViewModel)
LaunchedEffect(accountViewModel, postViewModel) { LaunchedEffect(accountViewModel, postViewModel) {
postViewModel.load() postViewModel.load()
walletViewModel.loadLnAddress()
paymentTargetsViewModel.load()
} }
NIP47SetupScreen(postViewModel, accountViewModel, nav, nip47) NIP47SetupScreen(postViewModel, walletViewModel, paymentTargetsViewModel, accountViewModel, nav, nip47)
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun NIP47SetupScreen( fun NIP47SetupScreen(
postViewModel: UpdateZapAmountViewModel, postViewModel: UpdateZapAmountViewModel,
walletViewModel: WalletViewModel,
paymentTargetsViewModel: PaymentTargetsViewModel,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav, nav: INav,
nip47: String?, nip47: String?,
@@ -66,20 +101,125 @@ fun NIP47SetupScreen(
isActive = postViewModel::hasChanged, isActive = postViewModel::hasChanged,
onCancel = { onCancel = {
postViewModel.cancel() postViewModel.cancel()
paymentTargetsViewModel.refresh()
nav.popBack() nav.popBack()
}, },
onPost = { onPost = {
postViewModel.sendPost() accountViewModel.launchSigner {
postViewModel.sendPostSuspend()
walletViewModel.saveLnAddressSuspend()
paymentTargetsViewModel.savePaymentTargetsSuspend()
}
nav.popBack() nav.popBack()
}, },
) )
}, },
) { ) {
Column(Modifier.padding(it)) { Column(Modifier.padding(it)) {
UpdateZapAmountContent(postViewModel, onClose = { UpdateZapAmountContent(
postViewModel,
onClose = {
postViewModel.cancel() postViewModel.cancel()
paymentTargetsViewModel.refresh()
nav.popBack() nav.popBack()
}, nip47, accountViewModel) },
nip47,
accountViewModel,
) {
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
LightningAddressSetupSection(walletViewModel)
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
PaymentTargetsInlineSection(paymentTargetsViewModel)
}
}
}
}
@Composable
private fun LightningAddressSetupSection(walletViewModel: WalletViewModel) {
val lnAddress by walletViewModel.lnAddress.collectAsState()
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = stringRes(R.string.lightning_address),
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.titleSmall,
modifier = SettingsCategorySpacingModifier,
)
OutlinedTextField(
value = lnAddress,
onValueChange = { walletViewModel.updateLnAddress(it) },
modifier = Modifier.fillMaxWidth(),
placeholder = {
Text(
text = "me@mylightningnode.com",
color = MaterialTheme.colorScheme.placeholderText,
)
},
singleLine = true,
)
}
}
@Composable
private fun PaymentTargetsInlineSection(viewModel: PaymentTargetsViewModel) {
val targets by viewModel.paymentTargets.collectAsStateWithLifecycle()
SettingsCategory(
R.string.payment_targets,
R.string.payment_targets_section_explainer,
SettingsCategorySpacingModifier,
)
if (targets.isEmpty()) {
Text(
text = stringRes(id = R.string.no_payment_targets_message),
modifier = Modifier.padding(vertical = 8.dp),
color = MaterialTheme.colorScheme.grayText,
style = MaterialTheme.typography.bodyMedium,
)
} else {
Column {
targets.forEach { target ->
PaymentTargetInlineEntry(target = target, onDelete = { viewModel.removeTarget(target) })
}
}
}
PaymentTargetAddField { type, authority ->
viewModel.addTarget(type, authority)
}
}
@Composable
private fun PaymentTargetInlineEntry(
target: PaymentTarget,
onDelete: () -> Unit,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = target.type.replaceFirstChar(Char::titlecase),
style = MaterialTheme.typography.bodyLarge,
)
Text(
text = target.authority,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.grayText,
)
}
IconButton(onClick = onDelete) {
Icon(
imageVector = Icons.Rounded.Delete,
contentDescription = stringRes(id = R.string.delete_payment_target),
)
} }
} }
} }
@@ -82,7 +82,7 @@ fun WalletReceiveScreen(
val walletViewModel: WalletViewModel = viewModel() val walletViewModel: WalletViewModel = viewModel()
LaunchedEffect(accountViewModel) { LaunchedEffect(accountViewModel) {
walletViewModel.init(accountViewModel.account) walletViewModel.init(accountViewModel)
} }
DisposableEffect(Unit) { DisposableEffect(Unit) {
@@ -36,6 +36,7 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.List import androidx.compose.material.icons.automirrored.filled.List
import androidx.compose.material.icons.filled.ArrowDownward import androidx.compose.material.icons.filled.ArrowDownward
import androidx.compose.material.icons.filled.ArrowUpward import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
@@ -73,7 +74,7 @@ fun WalletScreen(
nav: INav, nav: INav,
) { ) {
val walletViewModel: WalletViewModel = viewModel() val walletViewModel: WalletViewModel = viewModel()
walletViewModel.init(accountViewModel.account) walletViewModel.init(accountViewModel)
val hasWallet by walletViewModel.hasWalletSetup.collectAsState() val hasWallet by walletViewModel.hasWalletSetup.collectAsState()
@@ -89,6 +90,16 @@ fun WalletScreen(
) )
} }
}, },
actions = {
if (hasWallet) {
IconButton(onClick = { nav.nav(Route.Nip47NWCSetup()) }) {
Icon(
imageVector = Icons.Outlined.Settings,
contentDescription = stringRes(R.string.settings),
)
}
}
},
) )
}, },
) { padding -> ) { padding ->
@@ -73,7 +73,7 @@ fun WalletSendScreen(
val walletViewModel: WalletViewModel = viewModel() val walletViewModel: WalletViewModel = viewModel()
LaunchedEffect(accountViewModel) { LaunchedEffect(accountViewModel) {
walletViewModel.init(accountViewModel.account) walletViewModel.init(accountViewModel)
} }
DisposableEffect(Unit) { DisposableEffect(Unit) {
@@ -83,7 +83,7 @@ fun WalletTransactionsScreen(
val walletViewModel: WalletViewModel = viewModel() val walletViewModel: WalletViewModel = viewModel()
LaunchedEffect(accountViewModel) { LaunchedEffect(accountViewModel) {
walletViewModel.init(accountViewModel.account) walletViewModel.init(accountViewModel)
walletViewModel.fetchTransactions() walletViewModel.fetchTransactions()
} }
@@ -23,6 +23,7 @@ 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.ui.screen.loggedIn.AccountViewModel
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
@@ -86,10 +87,14 @@ private const val PAYMENT_FAILED = "Payment failed"
class WalletViewModel : ViewModel() { class WalletViewModel : ViewModel() {
private var account: Account? = null private var account: Account? = null
private var accountViewModel: AccountViewModel? = null
private val _hasWalletSetup = MutableStateFlow(false) private val _hasWalletSetup = MutableStateFlow(false)
val hasWalletSetup = _hasWalletSetup.asStateFlow() val hasWalletSetup = _hasWalletSetup.asStateFlow()
private val _lnAddress = MutableStateFlow("")
val lnAddress = _lnAddress.asStateFlow()
private val _balanceSats = MutableStateFlow<Long?>(null) private val _balanceSats = MutableStateFlow<Long?>(null)
val balanceSats = _balanceSats.asStateFlow() val balanceSats = _balanceSats.asStateFlow()
@@ -137,15 +142,46 @@ class WalletViewModel : ViewModel() {
onTimeout() onTimeout()
} }
fun init(account: Account) { fun init(accountViewModel: AccountViewModel) {
this.account = account this.accountViewModel = accountViewModel
_hasWalletSetup.value = account.nip47SignerState.hasWalletConnectSetup() this.account = accountViewModel.account
_hasWalletSetup.value = accountViewModel.account.nip47SignerState.hasWalletConnectSetup()
} }
fun refreshWalletSetup() { fun refreshWalletSetup() {
_hasWalletSetup.value = account?.nip47SignerState?.hasWalletConnectSetup() == true _hasWalletSetup.value = account?.nip47SignerState?.hasWalletConnectSetup() == true
} }
fun loadLnAddress() {
viewModelScope.launch(Dispatchers.IO) {
val info =
account
?.userProfile()
?.metadataOrNull()
?.flow
?.value
?.info
_lnAddress.value = info?.lud16 ?: ""
}
}
fun updateLnAddress(address: String) {
_lnAddress.value = address
}
fun saveLnAddress() {
val accountVm = accountViewModel ?: return
accountVm.launchSigner {
saveLnAddressSuspend()
}
}
suspend fun saveLnAddressSuspend() {
val acc = account ?: return
val event = acc.userMetadata.sendNewUserMetadata(lnAddress = _lnAddress.value)
acc.sendLiterallyEverywhere(event)
}
fun fetchBalance() { fun fetchBalance() {
val acc = account ?: return val acc = account ?: return
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {