Merge pull request #1784 from vitorpamplona/claude/improve-zap-settings-ui-D36RD

feat: improve Zap Settings UI with sections, chips, and animations
This commit is contained in:
Vitor Pamplona
2026-03-08 11:59:13 -04:00
committed by GitHub
2 changed files with 349 additions and 169 deletions
@@ -31,6 +31,19 @@ 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.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
@@ -42,12 +55,18 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
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.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.ContentPaste
import androidx.compose.material.icons.outlined.RadioButtonUnchecked
import androidx.compose.material.icons.outlined.Visibility
import androidx.compose.material.icons.outlined.VisibilityOff
import androidx.compose.material3.Button
@@ -55,7 +74,10 @@ import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.InputChip
import androidx.compose.material3.InputChipDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -75,7 +97,6 @@ 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.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.components.TextSpinner
@@ -88,6 +109,9 @@ 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.Font14SP
import com.vitorpamplona.amethyst.ui.theme.RowColSpacing
import com.vitorpamplona.amethyst.ui.theme.SettingsCategoryFirstModifier
import com.vitorpamplona.amethyst.ui.theme.SettingsCategorySpacingModifier
import com.vitorpamplona.amethyst.ui.theme.Size24Modifier
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
@@ -103,7 +127,6 @@ fun UpdateZapAmountContent(
) {
val context = LocalContext.current
val clipboardManager = LocalClipboardManager.current
val uri = LocalUriHandler.current
val zapTypes =
@@ -155,46 +178,82 @@ fun UpdateZapAmountContent(
}
}
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
.padding(10.dp)
.padding(horizontal = 16.dp, vertical = 8.dp)
.fillMaxWidth()
.imePadding()
.verticalScroll(rememberScrollState()),
) {
// ── Section 1: Quick Zap Amounts ──────────────────────────────────────
Text(
text = stringRes(R.string.quick_zap_amounts),
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.titleSmall,
modifier = SettingsCategoryFirstModifier,
)
Text(
text = stringRes(R.string.quick_zap_amounts_explainer),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.placeholderText,
modifier = Modifier.padding(bottom = 12.dp),
)
// Amount chips — animateContentSize gives a smooth expand/collapse when
// chips are added or removed
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
modifier =
Modifier
.fillMaxWidth()
.animateContentSize(animationSpec = spring(stiffness = Spring.StiffnessMediumLow)),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
postViewModel.amountSet.forEach { amountInSats ->
Button(
modifier = Modifier.padding(horizontal = 3.dp),
shape = ButtonBorder,
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
),
InputChip(
selected = false,
onClick = { postViewModel.removeAmount(amountInSats) },
) {
label = {
Text(
"${
showAmount(
amountInSats.toBigDecimal().setScale(1),
text = "${showAmount(amountInSats.toBigDecimal().setScale(1))}",
)
} ",
color = Color.White,
textAlign = TextAlign.Center,
},
trailingIcon = {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = stringRes(R.string.remove),
modifier = Modifier.size(InputChipDefaults.AvatarSize),
)
},
colors =
InputChipDefaults.inputChipColors(
containerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f),
labelColor = MaterialTheme.colorScheme.primary,
trailingIconColor = MaterialTheme.colorScheme.primary,
),
)
}
}
}
Spacer(modifier = Modifier.height(10.dp))
Spacer(modifier = Modifier.height(12.dp))
// Add new amount
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp),
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedTextField(
label = { Text(text = stringRes(R.string.new_amount_in_sats)) },
@@ -212,7 +271,7 @@ fun UpdateZapAmountContent(
)
},
singleLine = true,
modifier = Modifier.padding(end = 10.dp).weight(1f),
modifier = Modifier.weight(1f),
)
Button(
@@ -223,67 +282,159 @@ fun UpdateZapAmountContent(
containerColor = MaterialTheme.colorScheme.primary,
),
) {
Icon(
imageVector = Icons.Outlined.Add,
contentDescription = stringRes(R.string.add),
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.width(4.dp))
Text(text = stringRes(R.string.add), color = Color.White)
}
}
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically,
) {
TextSpinner(
label = stringRes(id = R.string.zap_type_explainer),
placeholder = zapTypes.firstOrNull { it.first == accountViewModel.defaultZapType() }?.second ?: zapTypes.firstOrNull()?.second ?: "",
options = zapOptions,
onSelect = { postViewModel.selectedZapType = zapTypes[it].first },
modifier = Modifier.weight(1f).padding(end = 5.dp),
)
}
// ── Section 2: Zap Privacy ────────────────────────────────────────────
HorizontalDivider(
modifier = Modifier.padding(vertical = 10.dp),
thickness = DividerThickness,
Text(
text = stringRes(R.string.zap_privacy_section),
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.titleSmall,
modifier = SettingsCategorySpacingModifier,
)
Text(
text = stringRes(R.string.zap_type_section_explainer),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.placeholderText,
modifier = Modifier.padding(bottom = 8.dp),
)
var qrScanning by remember { mutableStateOf(false) }
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
stringRes(id = R.string.wallet_connect_service),
Modifier.weight(1f),
TextSpinner(
label = stringRes(id = R.string.zap_type_explainer),
placeholder =
zapTypes
.firstOrNull { it.first == accountViewModel.defaultZapType() }
?.second
?: zapTypes.firstOrNull()?.second
?: "",
options = zapOptions,
onSelect = { postViewModel.selectedZapType = zapTypes[it].first },
modifier = Modifier.fillMaxWidth(),
)
}
// ── Section 3: Nostr Wallet Connect ───────────────────────────────────
HorizontalDivider(
modifier = Modifier.padding(vertical = 16.dp),
thickness = DividerThickness,
)
IconButton(
// Section header + connection status indicator
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = RowColSpacing,
) {
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(
imageVector = if (connected) Icons.Filled.CheckCircle else Icons.Outlined.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().padding(bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
// Primary "Connect Wallet" button — opens the NWC app deep link
OutlinedButton(
modifier = Modifier.weight(1f),
shape = ButtonBorder,
onClick = {
onClose()
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")
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",
)
} catch (_: IllegalArgumentException) {
accountViewModel.toastManager.toast(R.string.couldnt_find_nwc_wallets, R.string.couldnt_find_nwc_wallets_description)
accountViewModel.toastManager.toast(
R.string.couldnt_find_nwc_wallets,
R.string.couldnt_find_nwc_wallets_description,
)
}
},
) {
Icon(
imageVector = Icons.Outlined.Add,
contentDescription = stringRes(id = R.string.connect_to_new_nwc_wallet),
modifier = Size24Modifier,
tint = MaterialTheme.colorScheme.primary,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.width(4.dp))
Text(text = stringRes(R.string.wallet_connect_connect_app))
}
// Paste from clipboard
IconButton(
onClick = {
val uri = clipboardManager.getText()?.text
val clipText = clipboardManager.getText()?.text
try {
uri?.let {
postViewModel.copyFromClipboard(it)
}
clipText?.let { postViewModel.copyFromClipboard(it) }
} catch (e: IllegalArgumentException) {
accountViewModel.toastManager.toast(R.string.invalid_nip47_uri_title, R.string.invalid_nip47_uri_description, uri ?: "")
accountViewModel.toastManager.toast(
R.string.invalid_nip47_uri_title,
R.string.invalid_nip47_uri_description,
clipText ?: "",
)
}
},
) {
@@ -295,6 +446,7 @@ fun UpdateZapAmountContent(
)
}
// QR code scanner
IconButton(onClick = { qrScanning = true }) {
Icon(
painter = painterRes(R.drawable.ic_qrcode, 3),
@@ -305,18 +457,6 @@ fun UpdateZapAmountContent(
}
}
Row(
modifier = Modifier.fillMaxWidth().padding(bottom = 5.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
stringRes(id = R.string.wallet_connect_service_explainer),
Modifier.weight(1f),
color = MaterialTheme.colorScheme.placeholderText,
fontSize = Font14SP,
)
}
if (qrScanning) {
SimpleQrCodeScanner {
qrScanning = false
@@ -340,6 +480,36 @@ fun UpdateZapAmountContent(
}
}
// 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(
imageVector = if (showManualConfig) Icons.Filled.ExpandLess else Icons.Filled.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,
@@ -359,7 +529,7 @@ fun UpdateZapAmountContent(
)
},
singleLine = true,
modifier = Modifier.weight(1f),
modifier = Modifier.fillMaxWidth(),
)
}
@@ -369,7 +539,7 @@ fun UpdateZapAmountContent(
) {
OutlinedTextField(
label = { Text(text = stringRes(R.string.wallet_connect_service_relay)) },
modifier = Modifier.weight(1f),
modifier = Modifier.fillMaxWidth(),
value = postViewModel.walletConnectRelay,
onValueChange = { postViewModel.walletConnectRelay = it },
placeholder = {
@@ -385,7 +555,7 @@ fun UpdateZapAmountContent(
var showPassword by remember { mutableStateOf(false) }
val context = LocalContext.current
val secretContext = LocalContext.current
val keyguardLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult ->
@@ -402,7 +572,7 @@ fun UpdateZapAmountContent(
) {
OutlinedTextField(
label = { Text(text = stringRes(R.string.wallet_connect_service_secret)) },
modifier = Modifier.weight(1f),
modifier = Modifier.fillMaxWidth(),
value = postViewModel.walletConnectSecret,
onValueChange = { postViewModel.walletConnectSecret = it },
keyboardOptions =
@@ -423,7 +593,7 @@ fun UpdateZapAmountContent(
if (!showPassword) {
authenticate(
title = authTitle,
context = context,
context = secretContext,
keyguardLauncher = keyguardLauncher,
onApproved = { showPassword = true },
onError = { title, message -> accountViewModel.toastManager.toast(title, message) },
@@ -444,9 +614,7 @@ fun UpdateZapAmountContent(
if (showPassword) {
stringRes(R.string.show_password)
} else {
stringRes(
R.string.hide_password,
)
stringRes(R.string.hide_password)
},
)
}
@@ -456,6 +624,10 @@ fun UpdateZapAmountContent(
)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
}
}
fun authenticate(
+8
View File
@@ -454,6 +454,14 @@
<string name="wallet_connect_service_secret">Wallet Connect Secret</string>
<string name="wallet_connect_service_show_secret">Show secret key</string>
<string name="wallet_connect_service_secret_placeholder">nsec / hex private key</string>
<string name="wallet_connect_status_connected">Connected</string>
<string name="wallet_connect_status_not_connected">Not connected</string>
<string name="wallet_connect_manual_config">Advanced: enter connection details manually</string>
<string name="quick_zap_amounts">Quick Zap Amounts</string>
<string name="quick_zap_amounts_explainer">Shown when long-pressing the zap button. Tap an amount to remove it.</string>
<string name="zap_privacy_section">Zap Privacy</string>
<string name="zap_type_section_explainer">Controls how your identity is shown when you send a zap.</string>
<string name="wallet_connect_connect_app">Connect Wallet</string>
<string name="pledge_amount_in_sats">Pledge Amount in Sats</string>
<string name="post_poll">Post Poll</string>