refactor(zap/wallet): move NWC setup out of zap-amount settings to the Add-NWC screen

The Nostr Wallet Connect section (connect button, paste, QR, manual pubkey/relay/
secret) is removed from the zap-amount settings content. NWC setup now lives only
in the wallet area:

- The `nostr+walletconnect` connect deep link (`dlnwc`) now opens Wallet → Add NWC
  with the URI prefilled (Route.WalletAddNwc gained an optional nip47 arg), instead
  of the old shared NIP-47 setup screen.
- UpdateZapAmountContent loses its nip47uri parameter and the whole wallet-connect
  block; both callers (zap settings + NIP-47 setup) updated. The NIP-47 setup
  screen keeps its Lightning-address and payment-targets sections (both also
  reachable from profile edit / EditPaymentTargets) and is no longer the deep-link
  target.

https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn
This commit is contained in:
Claude
2026-05-29 23:06:29 +00:00
parent 5d6a0e542d
commit c91ef86d4d
8 changed files with 11 additions and 379 deletions
@@ -240,7 +240,7 @@ fun uriToRoute(
val nip47Uri = url.getQueryParameter("value")?.firstOrNull()
if (nip47Uri != null) {
Nip47WalletConnect.parse(nip47Uri)
return Route.Nip47NWCSetup(nip47Uri)
return Route.WalletAddNwc(nip47Uri)
}
} catch (e: Exception) {
if (e is CancellationException) throw e
@@ -249,7 +249,7 @@ fun uriToRoute(
try {
Nip47WalletConnect.parse(uri)
return Route.Nip47NWCSetup(uri)
return Route.WalletAddNwc(uri)
} catch (e: Exception) {
if (e is CancellationException) throw e
}
@@ -310,7 +310,7 @@ fun BuildNavigation(
composableFromEnd<Route.OnchainTransactions> { OnchainTransactionsScreen(accountViewModel, nav) }
composableFromEndArgs<Route.WalletDetail> { WalletDetailScreen(it.walletId, accountViewModel, nav) }
composableFromEnd<Route.WalletAdd> { AddWalletScreen(accountViewModel, nav) }
composableFromEnd<Route.WalletAddNwc> { AddNwcWalletScreen(accountViewModel, nav) }
composableFromEndArgs<Route.WalletAddNwc> { AddNwcWalletScreen(accountViewModel, nav, it.nip47) }
composableFromEnd<Route.WalletAddCashu> { AddCashuWalletScreen(accountViewModel, nav) }
composableFromEnd<Route.CashuWallet> { CashuWalletScreen(accountViewModel, nav) }
composableFromEnd<Route.CashuWalletSettings> { CashuWalletSettingsScreen(accountViewModel, nav) }
@@ -200,7 +200,9 @@ sealed class Route {
@Serializable object WalletAdd : Route()
@Serializable object WalletAddNwc : Route()
@Serializable data class WalletAddNwc(
val nip47: String? = null,
) : Route()
@Serializable object WalletAddCashu : Route()
@@ -20,31 +20,18 @@
*/
package com.vitorpamplona.amethyst.ui.note
import android.app.Activity
import android.app.KeyguardManager
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.activity.compose.ManagedActivityResultLauncher
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.ActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
@@ -61,33 +48,21 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
@@ -96,21 +71,14 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.ui.components.TextSpinner
import com.vitorpamplona.amethyst.ui.components.TitleExplainer
import com.vitorpamplona.amethyst.ui.components.util.getText
import com.vitorpamplona.amethyst.ui.painterRes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.keyBackup.getFragmentActivity
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.SimpleQrCodeScanner
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.Font14SP
import com.vitorpamplona.amethyst.ui.theme.SettingsCategoryFirstModifier
import com.vitorpamplona.amethyst.ui.theme.SettingsCategorySpacingModifier
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.Size24Modifier
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
@@ -139,15 +107,9 @@ fun UpdateZapAmountContentPreview() {
fun UpdateZapAmountContent(
postViewModel: UpdateZapAmountViewModel,
onClose: () -> Unit,
nip47uri: String? = null,
accountViewModel: AccountViewModel,
trailingContent: @Composable ColumnScope.() -> Unit = {},
) {
val context = LocalContext.current
val clipboardManager = LocalClipboard.current
val uri = LocalUriHandler.current
val scope = rememberCoroutineScope()
val zapTypes =
listOf(
Triple(
@@ -177,36 +139,6 @@ fun UpdateZapAmountContent(
zapTypes.map { TitleExplainer(it.second, it.third) }.toImmutableList()
}
LaunchedEffect(accountViewModel, nip47uri) {
if (nip47uri != null) {
try {
postViewModel.updateNIP47(nip47uri)
} catch (e: IllegalArgumentException) {
if (e.message != null) {
accountViewModel.toastManager.toast(
stringRes(context, R.string.error_parsing_nip47_title),
stringRes(context, R.string.error_parsing_nip47, nip47uri, e.message!!),
)
} else {
accountViewModel.toastManager.toast(
stringRes(context, R.string.error_parsing_nip47_title),
stringRes(context, R.string.error_parsing_nip47_no_error, nip47uri),
)
}
}
}
}
var qrScanning by remember { mutableStateOf(false) }
// Expand manual config automatically when a wallet connection exists
var showManualConfig by remember { mutableStateOf(postViewModel.walletConnectPubkey.text.isNotBlank()) }
LaunchedEffect(postViewModel.walletConnectPubkey.text) {
if (postViewModel.walletConnectPubkey.text.isNotBlank()) {
showManualConfig = true
}
}
Column(
modifier =
Modifier
@@ -322,309 +254,6 @@ fun UpdateZapAmountContent(
)
}
// ── Section 3: Nostr Wallet Connect ───────────────────────────────────
HorizontalDivider(
modifier = Modifier.padding(vertical = 16.dp),
thickness = DividerThickness,
)
// Section header + connection status indicator
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringRes(R.string.wallet_connect_service),
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.titleSmall,
)
Text(
text = stringRes(R.string.wallet_connect_service_explainer),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.placeholderText,
modifier = Modifier.padding(top = 2.dp),
)
}
}
// Animated connection status badge
val isConnected = postViewModel.walletConnectPubkey.text.isNotBlank()
val statusColor by animateColorAsState(
targetValue = if (isConnected) Color(0xFF4CAF50) else MaterialTheme.colorScheme.placeholderText,
animationSpec = tween(durationMillis = 400),
label = "nwc_status_color",
)
AnimatedContent(
targetState = isConnected,
transitionSpec = { fadeIn(tween(300)) togetherWith fadeOut(tween(300)) },
label = "nwc_status_badge",
) { connected ->
Row(
modifier = Modifier.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Icon(
symbol = if (connected) MaterialSymbols.CheckCircle else MaterialSymbols.RadioButtonUnchecked,
contentDescription = null,
tint = statusColor,
modifier = Modifier.size(18.dp),
)
Text(
text =
if (connected) {
stringRes(R.string.wallet_connect_status_connected)
} else {
stringRes(R.string.wallet_connect_status_not_connected)
},
color = statusColor,
style = MaterialTheme.typography.bodyMedium,
)
}
}
// Connect action buttons
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
// Primary "Connect Wallet" button — opens the NWC app deep link
OutlinedButton(
modifier = Modifier.weight(1f),
shape = ButtonBorder,
onClick = {
try {
uri.openUri(
"nostrnwc://connect?appname=Amethyst&appicon=https%3A%2F%2Fraw.githubusercontent.com%2Fvitorpamplona%2Famethyst%2Frefs%2Fheads%2Fmain%2Ficon.png&callback=amethyst%2Bwalletconnect%3A%2F%2Fdlnwc",
)
onClose()
} catch (_: IllegalArgumentException) {
accountViewModel.toastManager.toast(
R.string.couldnt_find_nwc_wallets,
R.string.couldnt_find_nwc_wallets_description,
)
}
},
) {
Icon(
symbol = MaterialSymbols.Add,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.width(4.dp))
Text(text = stringRes(R.string.wallet_connect_connect_app))
}
Spacer(DoubleHorzSpacer)
// Paste from clipboard
IconButton(
onClick = {
scope.launch {
val clipText = clipboardManager.getText()
try {
clipText?.let { postViewModel.copyFromClipboard(it) }
} catch (e: IllegalArgumentException) {
accountViewModel.toastManager.toast(
R.string.invalid_nip47_uri_title,
R.string.invalid_nip47_uri_description,
clipText ?: "",
)
}
}
},
) {
Icon(
symbol = MaterialSymbols.ContentPaste,
contentDescription = stringRes(id = R.string.paste_from_clipboard),
modifier = Size24Modifier,
tint = MaterialTheme.colorScheme.primary,
)
}
// QR code scanner
IconButton(onClick = { qrScanning = true }) {
Icon(
painter = painterRes(R.drawable.ic_qrcode, 3),
contentDescription = stringRes(id = R.string.accessibility_scan_qr_code),
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.primary,
)
}
}
if (qrScanning) {
SimpleQrCodeScanner {
qrScanning = false
if (!it.isNullOrEmpty()) {
try {
postViewModel.updateNIP47(it)
} catch (e: IllegalArgumentException) {
if (e.message != null) {
accountViewModel.toastManager.toast(
stringRes(context, R.string.error_parsing_nip47_title),
stringRes(context, R.string.error_parsing_nip47, it, e.message!!),
)
} else {
accountViewModel.toastManager.toast(
stringRes(context, R.string.error_parsing_nip47_title),
stringRes(context, R.string.error_parsing_nip47_no_error, it),
)
}
}
}
}
}
// Expandable manual configuration section
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable { showManualConfig = !showManualConfig }
.padding(vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringRes(R.string.wallet_connect_manual_config),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.placeholderText,
modifier = Modifier.weight(1f),
fontSize = Font14SP,
)
Icon(
symbol = if (showManualConfig) MaterialSymbols.ExpandLess else MaterialSymbols.ExpandMore,
contentDescription = null,
tint = MaterialTheme.colorScheme.placeholderText,
modifier = Modifier.size(20.dp),
)
}
AnimatedVisibility(
visible = showManualConfig,
enter = expandVertically(animationSpec = spring(stiffness = Spring.StiffnessMediumLow)) + fadeIn(),
exit = shrinkVertically(animationSpec = spring(stiffness = Spring.StiffnessMediumLow)) + fadeOut(),
) {
Column {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedTextField(
label = { Text(text = stringRes(R.string.wallet_connect_service_pubkey)) },
value = postViewModel.walletConnectPubkey,
onValueChange = { postViewModel.walletConnectPubkey = it },
keyboardOptions =
KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.None,
),
placeholder = {
Text(
text = "npub, hex",
color = MaterialTheme.colorScheme.placeholderText,
)
},
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
}
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedTextField(
label = { Text(text = stringRes(R.string.wallet_connect_service_relay)) },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.walletConnectRelay,
onValueChange = { postViewModel.walletConnectRelay = it },
placeholder = {
Text(
text = "wss://relay.server.com",
color = MaterialTheme.colorScheme.placeholderText,
maxLines = 1,
)
},
singleLine = true,
)
}
var showPassword by remember { mutableStateOf(false) }
val secretContext = LocalContext.current
val keyguardLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult ->
if (result.resultCode == Activity.RESULT_OK) {
showPassword = true
}
}
val authTitle = stringRes(id = R.string.wallet_connect_service_show_secret)
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedTextField(
label = { Text(text = stringRes(R.string.wallet_connect_service_secret)) },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.walletConnectSecret,
onValueChange = { postViewModel.walletConnectSecret = it },
keyboardOptions =
KeyboardOptions(
autoCorrectEnabled = false,
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Go,
),
placeholder = {
Text(
text = stringRes(R.string.wallet_connect_service_secret_placeholder),
color = MaterialTheme.colorScheme.placeholderText,
)
},
trailingIcon = {
IconButton(
onClick = {
if (!showPassword) {
authenticate(
title = authTitle,
context = secretContext,
keyguardLauncher = keyguardLauncher,
onApproved = { showPassword = true },
onError = { title, message -> accountViewModel.toastManager.toast(title, message) },
)
} else {
showPassword = false
}
},
) {
Icon(
symbol =
if (showPassword) {
MaterialSymbols.VisibilityOff
} else {
MaterialSymbols.Visibility
},
contentDescription =
if (showPassword) {
stringRes(R.string.show_password)
} else {
stringRes(R.string.hide_password)
},
)
}
},
visualTransformation =
if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
)
}
}
}
trailingContent()
Spacer(modifier = Modifier.height(16.dp))
@@ -122,7 +122,6 @@ fun NIP47SetupScreen(
paymentTargetsViewModel.refresh()
nav.popBack()
},
nip47,
accountViewModel,
) {
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
@@ -79,7 +79,7 @@ fun UpdateZapAmountScreen(
UpdateZapAmountContent(postViewModel, onClose = {
postViewModel.cancel()
nav.popBack()
}, nip47, accountViewModel)
}, accountViewModel)
}
}
}
@@ -74,12 +74,14 @@ import kotlinx.coroutines.launch
fun AddNwcWalletScreen(
accountViewModel: AccountViewModel,
nav: INav,
nip47Uri: String? = null,
) {
val walletViewModel: WalletViewModel = viewModel()
walletViewModel.init(accountViewModel)
var walletName by remember { mutableStateOf("") }
var nwcUri by remember { mutableStateOf("") }
// Prefilled when arriving from the `nostr+walletconnect` connect deep link.
var nwcUri by remember { mutableStateOf(nip47Uri.orEmpty()) }
var error by remember { mutableStateOf<String?>(null) }
var qrScanning by remember { mutableStateOf(false) }
@@ -97,7 +97,7 @@ fun AddWalletScreen(
// chooser has done its job. Without this, completing the
// add-wallet form pops back to the chooser, which is a
// pointless dead end.
onClick = { nav.popUpTo(Route.WalletAddNwc, Route.WalletAdd::class) },
onClick = { nav.popUpTo(Route.WalletAddNwc(), Route.WalletAdd::class) },
)
WalletTypeCard(
icon = MaterialSymbols.AccountBalanceWallet,