feat: redesign payment targets modal with QR, copy and pay buttons

Each target now renders in a single row with the payment method and
address on the left and three trailing icon buttons: QR (opens a dialog
showing the raw address as a QR code), Copy (clipboard + toast), and
Pay (existing payto:// intent). Adds QrCode2 to MaterialSymbols.

https://claude.ai/code/session_01JtJuvSYMusKiDMWo7N8Aj8
This commit is contained in:
Claude
2026-05-20 16:35:47 +00:00
parent baf043343d
commit ec28d4eb4c
2 changed files with 128 additions and 16 deletions
@@ -21,19 +21,36 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header
import android.content.Intent import android.content.Intent
import android.widget.Toast
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.fillMaxWidth
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.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.core.net.toUri import androidx.core.net.toUri
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
@@ -42,13 +59,14 @@ import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent
import com.vitorpamplona.amethyst.ui.components.M3ActionDialog import com.vitorpamplona.amethyst.ui.components.M3ActionDialog
import com.vitorpamplona.amethyst.ui.components.M3ActionRow
import com.vitorpamplona.amethyst.ui.components.M3ActionSection import com.vitorpamplona.amethyst.ui.components.M3ActionSection
import com.vitorpamplona.amethyst.ui.components.util.setText import com.vitorpamplona.amethyst.ui.components.util.setText
import com.vitorpamplona.amethyst.ui.note.ErrorMessageDialog import com.vitorpamplona.amethyst.ui.note.ErrorMessageDialog
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.QrCodeDrawer
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.ZeroPadding import com.vitorpamplona.amethyst.ui.theme.ZeroPadding
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
@@ -86,6 +104,7 @@ fun PaymentButtonWithTargets(targets: List<PaymentTarget>) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var expanded by remember { mutableStateOf(false) } var expanded by remember { mutableStateOf(false) }
var errorMessage by remember { mutableStateOf<String?>(null) } var errorMessage by remember { mutableStateOf<String?>(null) }
var qrContent by remember { mutableStateOf<String?>(null) }
FilledTonalButton( FilledTonalButton(
modifier = modifier =
@@ -108,36 +127,41 @@ fun PaymentButtonWithTargets(targets: List<PaymentTarget>) {
) { ) {
M3ActionSection { M3ActionSection {
targets.forEach { target -> targets.forEach { target ->
M3ActionRow( PaymentTargetRow(
icon = MaterialSymbols.AccountBalanceWallet, target = target,
text = "${target.type.replaceFirstChar(Char::titlecase)}: ${target.authority}", onShowQr = { qrContent = target.authority },
onClick = { onCopy = {
expanded = false scope.launch {
clipboardManager.setText(target.authority)
Toast
.makeText(
context,
stringRes(context, R.string.copied_to_clipboard),
Toast.LENGTH_SHORT,
).show()
}
},
onPay = {
try { try {
val intent = Intent(Intent.ACTION_VIEW, "payto://${target.type}/${target.authority}".toUri()) val intent = Intent(Intent.ACTION_VIEW, "payto://${target.type}/${target.authority}".toUri())
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
context.startActivity(intent) context.startActivity(intent)
expanded = false
} catch (e: Exception) { } catch (e: Exception) {
if (e is kotlinx.coroutines.CancellationException) throw e if (e is kotlinx.coroutines.CancellationException) throw e
errorMessage = stringRes(context, R.string.no_payment_app_found) errorMessage = stringRes(context, R.string.no_payment_app_found)
} }
}, },
) )
M3ActionRow(
icon = MaterialSymbols.ContentCopy,
text = stringRes(R.string.copy_to_clipboard),
onClick = {
expanded = false
scope.launch {
clipboardManager.setText(target.authority)
}
},
)
} }
} }
} }
} }
qrContent?.let { value ->
PaymentTargetQrDialog(value = value, onDismiss = { qrContent = null })
}
errorMessage?.let { msg -> errorMessage?.let { msg ->
ErrorMessageDialog( ErrorMessageDialog(
title = stringRes(R.string.error_dialog_payment_error), title = stringRes(R.string.error_dialog_payment_error),
@@ -146,3 +170,90 @@ fun PaymentButtonWithTargets(targets: List<PaymentTarget>) {
) )
} }
} }
@Composable
private fun PaymentTargetRow(
target: PaymentTarget,
onShowQr: () -> Unit,
onCopy: () -> Unit,
onPay: () -> Unit,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 10.dp),
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = target.type.replaceFirstChar(Char::titlecase),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = target.authority,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
Spacer(modifier = Modifier.width(8.dp))
IconButton(onClick = onShowQr) {
Icon(
symbol = MaterialSymbols.QrCode2,
contentDescription = stringRes(R.string.show_qr),
modifier = Size20Modifier,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
IconButton(onClick = onCopy) {
Icon(
symbol = MaterialSymbols.ContentCopy,
contentDescription = stringRes(R.string.copy_to_clipboard),
modifier = Size20Modifier,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
IconButton(onClick = onPay) {
Icon(
symbol = MaterialSymbols.Bolt,
contentDescription = stringRes(R.string.payment_targets),
modifier = Size20Modifier,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable
private fun PaymentTargetQrDialog(
value: String,
onDismiss: () -> Unit,
) {
Dialog(onDismissRequest = onDismiss) {
Surface(
shape = RoundedCornerShape(28.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
) {
Column(
modifier = Modifier.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
QrCodeDrawer(
contents = value,
modifier = Modifier.size(260.dp),
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = value,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}
}
}
@@ -175,6 +175,7 @@ object MaterialSymbols {
val Public = MaterialSymbol("\uE80B") val Public = MaterialSymbol("\uE80B")
val PublicOff = MaterialSymbol("\uF1CA") val PublicOff = MaterialSymbol("\uF1CA")
val PushPin = MaterialSymbol("\uF10D") val PushPin = MaterialSymbol("\uF10D")
val QrCode2 = MaterialSymbol("\uEF6B")
val RadioButtonChecked = MaterialSymbol("\uE837") val RadioButtonChecked = MaterialSymbol("\uE837")
val RadioButtonUnchecked = MaterialSymbol("\uE836") val RadioButtonUnchecked = MaterialSymbol("\uE836")
val Recommend = MaterialSymbol("\uE9D2") val Recommend = MaterialSymbol("\uE9D2")