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))
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(
label = { Text(text = stringRes(R.string.nip_05) + " (NIP-05)") },
modifier = Modifier.fillMaxWidth(),
@@ -57,8 +57,6 @@ class NewUserMetadataViewModel : ViewModel() {
val website = mutableStateOf("")
val pronouns = mutableStateOf("")
val nip05 = mutableStateOf("")
val lnAddress = mutableStateOf("")
val lnURL = mutableStateOf("")
val twitter = mutableStateOf("")
val github = mutableStateOf("")
@@ -82,8 +80,6 @@ class NewUserMetadataViewModel : ViewModel() {
website.value = it.info.website ?: ""
pronouns.value = it.info.pronouns ?: ""
nip05.value = it.info.nip05 ?: ""
lnAddress.value = it.info.lud16 ?: ""
lnURL.value = it.info.lud06 ?: ""
}
twitter.value = ""
@@ -121,8 +117,6 @@ class NewUserMetadataViewModel : ViewModel() {
pronouns = pronouns.value,
about = about.value,
nip05 = nip05.value,
lnAddress = lnAddress.value,
lnURL = lnURL.value,
)
val identities =
@@ -146,8 +140,6 @@ class NewUserMetadataViewModel : ViewModel() {
banner.value = ""
website.value = ""
nip05.value = ""
lnAddress.value = ""
lnURL.value = ""
twitter.value = ""
github.value = ""
mastodon.value = ""
@@ -79,9 +79,15 @@ class PaymentTargetsViewModel : ViewModel() {
fun savePaymentTargets() {
if (isModified) {
accountViewModel.launchSigner {
account.savePaymentTargets(_paymentTargets.value)
refresh()
savePaymentTargetsSuspend()
}
}
}
suspend fun savePaymentTargetsSuspend() {
if (isModified) {
account.savePaymentTargets(_paymentTargets.value)
refresh()
}
}
}
@@ -46,6 +46,7 @@ import androidx.compose.animation.togetherWith
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
@@ -148,6 +149,7 @@ fun UpdateZapAmountContent(
onClose: () -> Unit,
nip47uri: String? = null,
accountViewModel: AccountViewModel,
trailingContent: @Composable ColumnScope.() -> Unit = {},
) {
val context = LocalContext.current
val clipboardManager = LocalClipboard.current
@@ -649,6 +651,8 @@ fun UpdateZapAmountContent(
}
}
trailingContent()
Spacer(modifier = Modifier.height(16.dp))
}
}
@@ -84,6 +84,12 @@ class UpdateZapAmountViewModel : ViewModel() {
}
fun sendPost() {
accountViewModel.launchSigner {
sendPostSuspend()
}
}
suspend fun sendPostSuspend() {
val nip47Update =
if (walletConnectRelay.text.isNotBlank() && walletConnectPubkey.text.isNotBlank()) {
val pubkeyHex =
@@ -110,7 +116,7 @@ class UpdateZapAmountViewModel : ViewModel() {
null
}
accountViewModel.updateZapAmounts(amountSet, selectedZapType, nip47Update)
accountViewModel.account.updateZapAmounts(amountSet, selectedZapType, nip47Update)
nextAmount = TextFieldValue("")
}
@@ -29,7 +29,6 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
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.CloudUpload
import androidx.compose.material.icons.outlined.DeleteForever
@@ -122,13 +121,6 @@ fun AllSettingsScreen(
onClick = { nav.nav(Route.EditMediaServers) },
)
HorizontalDivider()
SettingsNavigationRow(
title = R.string.payment_targets,
icon = Icons.Outlined.AccountBalanceWallet,
tint = tint,
onClick = { nav.nav(Route.EditPaymentTargets) },
)
HorizontalDivider()
SettingsNavigationRow(
title = R.string.reactions,
icon = Icons.Outlined.FavoriteBorder,
@@ -20,20 +20,45 @@
*/
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.Row
import androidx.compose.foundation.layout.fillMaxWidth
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.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.Text
import androidx.compose.runtime.Composable
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.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
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.topbars.SavingTopBar
import com.vitorpamplona.amethyst.ui.note.UpdateZapAmountContent
import com.vitorpamplona.amethyst.ui.note.UpdateZapAmountViewModel
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
fun NIP47SetupScreen(
@@ -44,17 +69,27 @@ fun NIP47SetupScreen(
val postViewModel: UpdateZapAmountViewModel = viewModel()
postViewModel.init(accountViewModel)
val walletViewModel: WalletViewModel = viewModel()
walletViewModel.init(accountViewModel)
val paymentTargetsViewModel: PaymentTargetsViewModel = viewModel()
paymentTargetsViewModel.init(accountViewModel)
LaunchedEffect(accountViewModel, postViewModel) {
postViewModel.load()
walletViewModel.loadLnAddress()
paymentTargetsViewModel.load()
}
NIP47SetupScreen(postViewModel, accountViewModel, nav, nip47)
NIP47SetupScreen(postViewModel, walletViewModel, paymentTargetsViewModel, accountViewModel, nav, nip47)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NIP47SetupScreen(
postViewModel: UpdateZapAmountViewModel,
walletViewModel: WalletViewModel,
paymentTargetsViewModel: PaymentTargetsViewModel,
accountViewModel: AccountViewModel,
nav: INav,
nip47: String?,
@@ -66,20 +101,125 @@ fun NIP47SetupScreen(
isActive = postViewModel::hasChanged,
onCancel = {
postViewModel.cancel()
paymentTargetsViewModel.refresh()
nav.popBack()
},
onPost = {
postViewModel.sendPost()
accountViewModel.launchSigner {
postViewModel.sendPostSuspend()
walletViewModel.saveLnAddressSuspend()
paymentTargetsViewModel.savePaymentTargetsSuspend()
}
nav.popBack()
},
)
},
) {
Column(Modifier.padding(it)) {
UpdateZapAmountContent(postViewModel, onClose = {
postViewModel.cancel()
nav.popBack()
}, nip47, accountViewModel)
UpdateZapAmountContent(
postViewModel,
onClose = {
postViewModel.cancel()
paymentTargetsViewModel.refresh()
nav.popBack()
},
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()
LaunchedEffect(accountViewModel) {
walletViewModel.init(accountViewModel.account)
walletViewModel.init(accountViewModel)
}
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.filled.ArrowDownward
import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
@@ -73,7 +74,7 @@ fun WalletScreen(
nav: INav,
) {
val walletViewModel: WalletViewModel = viewModel()
walletViewModel.init(accountViewModel.account)
walletViewModel.init(accountViewModel)
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 ->
@@ -73,7 +73,7 @@ fun WalletSendScreen(
val walletViewModel: WalletViewModel = viewModel()
LaunchedEffect(accountViewModel) {
walletViewModel.init(accountViewModel.account)
walletViewModel.init(accountViewModel)
}
DisposableEffect(Unit) {
@@ -83,7 +83,7 @@ fun WalletTransactionsScreen(
val walletViewModel: WalletViewModel = viewModel()
LaunchedEffect(accountViewModel) {
walletViewModel.init(accountViewModel.account)
walletViewModel.init(accountViewModel)
walletViewModel.fetchTransactions()
}
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetInfoMethod
@@ -86,10 +87,14 @@ private const val PAYMENT_FAILED = "Payment failed"
class WalletViewModel : ViewModel() {
private var account: Account? = null
private var accountViewModel: AccountViewModel? = null
private val _hasWalletSetup = MutableStateFlow(false)
val hasWalletSetup = _hasWalletSetup.asStateFlow()
private val _lnAddress = MutableStateFlow("")
val lnAddress = _lnAddress.asStateFlow()
private val _balanceSats = MutableStateFlow<Long?>(null)
val balanceSats = _balanceSats.asStateFlow()
@@ -137,15 +142,46 @@ class WalletViewModel : ViewModel() {
onTimeout()
}
fun init(account: Account) {
this.account = account
_hasWalletSetup.value = account.nip47SignerState.hasWalletConnectSetup()
fun init(accountViewModel: AccountViewModel) {
this.accountViewModel = accountViewModel
this.account = accountViewModel.account
_hasWalletSetup.value = accountViewModel.account.nip47SignerState.hasWalletConnectSetup()
}
fun refreshWalletSetup() {
_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() {
val acc = account ?: return
viewModelScope.launch(Dispatchers.IO) {