feat: improve nwc qr code ux

This commit is contained in:
2026-06-10 16:08:38 +02:00
parent 2ef9a52e6e
commit 237cc74519
9 changed files with 307 additions and 89 deletions
+8
View File
@@ -17,6 +17,14 @@
<uses-feature android:name="android.hardware.nfc" android:required="false" />
<uses-feature android:name="android.hardware.nfc.hce" android:required="false" />
<queries>
<!-- Allows resolveActivity() to see apps that handle the NWC URI scheme -->
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="nostr+walletconnect" />
</intent>
</queries>
<application
android:name=".GudariWalletApplication"
android:allowBackup="true"
@@ -330,7 +330,7 @@ data class NwcKey(
val wallet: String,
val description: String,
val expires_at: Long,
val permissions: String, // comma-separated permission names
val permissions: String,
val created_at: Long,
val last_used: Long
)
@@ -352,7 +352,8 @@ data class NwcGetResponse(
data class NwcNewBudget(
val budget_msats: Long,
val refresh_window: Int // seconds
val refresh_window: Int, // seconds
val created_at : Long = System.currentTimeMillis() / 1000L
)
/** Request body for PUT /nwc/{pubkey} */
@@ -105,6 +105,8 @@ val EnHomeStrings = AppStrings(
qrCopy = "Copy",
qrNfcStop = "Stop NFC sharing",
qrNfcStart = "Share via NFC",
openInApp = "Open in app",
noAppFound = "No compatible app found",
// ── ReceiveViewModel ──────────────────────────────────────────────────────
receiveVmFailedToCreateInvoice = "Failed to create invoice",
@@ -105,6 +105,8 @@ val EsHomeStrings = AppStrings(
qrCopy = "Copiar",
qrNfcStop = "Detener NFC",
qrNfcStart = "Compartir por NFC",
openInApp = "Abrir en app",
noAppFound = "No hay aplicación compatible",
// ── ReceiveViewModel ──────────────────────────────────────────────────────
receiveVmFailedToCreateInvoice = "No se pudo crear la factura",
@@ -104,6 +104,8 @@ val EuStrings = AppStrings(
qrCopy = "Kopiatu",
qrNfcStop = "Gelditu NFC partekatzea",
qrNfcStart = "Partekatu NFC bidez",
openInApp = "Ireki aplikazioan",
noAppFound = "Ez da aurkitu aplikazio bateragarririk",
// ── ReceiveViewModel ──────────────────────────────────────────────────────
receiveVmFailedToCreateInvoice = "Ezin izan da faktura sortu",
@@ -113,6 +113,8 @@ data class AppStrings(
val qrCopy : String,
val qrNfcStop : String,
val qrNfcStart : String,
val openInApp : String,
val noAppFound : String,
// ── ReceiveViewModel ──────────────────────────────────────────────────────
val receiveVmFailedToCreateInvoice : String,
@@ -1,12 +1,17 @@
package com.bitcointxoko.gudariwallet.ui.nwc
import androidx.compose.animation.AnimatedVisibility
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Intent
import android.view.WindowManager
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
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.automirrored.filled.ArrowBack
@@ -14,7 +19,7 @@ import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Cable
import androidx.compose.material.icons.filled.CalendarToday
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material3.*
@@ -22,13 +27,14 @@ import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitcointxoko.gudariwallet.api.NwcGetResponse
import com.bitcointxoko.gudariwallet.api.NwcNewBudget
import com.bitcointxoko.gudariwallet.ui.receive.QrDisplayCard
import com.bitcointxoko.gudariwallet.ui.theme.semanticColors
import java.text.SimpleDateFormat
import java.time.Instant
@@ -37,6 +43,7 @@ import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.util.Calendar
import java.util.Locale
import androidx.core.net.toUri
// ── Available permissions ─────────────────────────────────────────────────────
internal data class NwcPermission(
@@ -53,6 +60,36 @@ internal val ALL_PERMISSIONS = listOf(
NwcPermission("info", "Read account info", default = false)
)
// ── Budget ────────────────────────────────────────────────────────────────────
/**
* Transient UI state for a single budget row in the "Add connection" sheet.
* Converted to [NwcNewBudget] on submit.
*/
enum class BudgetRefreshWindow(val label: String, val seconds: Int) {
DAILY ("Daily", 86_400),
WEEKLY ("Weekly", 604_800),
MONTHLY ("Monthly", 2_592_000),
YEARLY ("Yearly", 31_536_000),
NEVER ("Never", 0);
}
data class BudgetDraft(
val id : Int = 0, // stable key for LazyColumn / forEach
val amountSats : String = "", // raw text field value (sats)
val refreshWindow : BudgetRefreshWindow = BudgetRefreshWindow.MONTHLY
) {
/** Returns null when the amount field is blank or non-numeric. */
fun toNwcNewBudget(): NwcNewBudget? {
val sats = amountSats.trim().toLongOrNull()?.takeIf { it > 0 } ?: return null
return NwcNewBudget(
budget_msats = sats * 1_000L,
refresh_window = refreshWindow.seconds
)
}
}
// ── Screen ────────────────────────────────────────────────────────────────────
@OptIn(ExperimentalMaterial3Api::class)
@@ -149,11 +186,12 @@ fun NwcScreen(
if (showAddSheet) {
NwcAddConnectionSheet(
isCreating = isCreating,
onCreate = { description, permissions, expiresAt ->
onCreate = { description, permissions, expiresAt, budgets ->
viewModel.createConnection(
description = description,
permissions = permissions,
expiresAt = expiresAt
expiresAt = expiresAt,
budgets = budgets
)
showAddSheet = false
},
@@ -196,7 +234,12 @@ fun NwcScreen(
@Composable
private fun NwcAddConnectionSheet(
isCreating : Boolean,
onCreate : (description: String, permissions: List<String>, expiresAt: Long) -> Unit,
onCreate : (
description : String,
permissions : List<String>,
expiresAt : Long,
budgets : List<NwcNewBudget> // ← NEW
) -> Unit,
onDismiss : () -> Unit
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
@@ -207,7 +250,6 @@ private fun NwcAddConnectionSheet(
}
var neverExpires by remember { mutableStateOf(false) }
// Default expiry = now + 30 days, truncated to the minute
val defaultExpiry = remember {
Calendar.getInstance().apply {
add(Calendar.DAY_OF_YEAR, 30)
@@ -216,11 +258,14 @@ private fun NwcAddConnectionSheet(
}
}
var expiryCalendar by remember { mutableStateOf(defaultExpiry) }
var showDatePicker by remember { mutableStateOf(false) }
var showTimePicker by remember { mutableStateOf(false) }
// ── Date picker dialog ────────────────────────────────────────────────────
// ── Budget list state ──────────────────────────────────────────────────────
var nextBudgetId by remember { mutableIntStateOf(0) }
var budgets by remember { mutableStateOf(emptyList<BudgetDraft>()) }
// ── Date picker dialog ─────────────────────────────────────────────────────
if (showDatePicker) {
val datePickerState = rememberDatePickerState(
initialSelectedDateMillis = expiryCalendar.timeInMillis
@@ -243,12 +288,10 @@ private fun NwcAddConnectionSheet(
dismissButton = {
TextButton(onClick = { showDatePicker = false }) { Text("Cancel") }
}
) {
DatePicker(state = datePickerState)
}
) { DatePicker(state = datePickerState) }
}
// ── Time picker dialog ────────────────────────────────────────────────────
// ── Time picker dialog ────────────────────────────────────────────────────
if (showTimePicker) {
val timePickerState = rememberTimePickerState(
initialHour = expiryCalendar.get(Calendar.HOUR_OF_DAY),
@@ -292,7 +335,7 @@ private fun NwcAddConnectionSheet(
style = MaterialTheme.typography.titleMedium
)
// ── Description ───────────────────────────────────────────────
// ── Description ───────────────────────────────────────────────────
OutlinedTextField(
value = description,
onValueChange = { description = it },
@@ -301,20 +344,14 @@ private fun NwcAddConnectionSheet(
modifier = Modifier.fillMaxWidth()
)
// ── Expiry ────────────────────────────────────────────────────
// ── Expiry ────────────────────────────────────────────────────────
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
// horizontalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = "Expires",
style = MaterialTheme.typography.bodyMedium
)
Text(text = "Expires", style = MaterialTheme.typography.bodyMedium)
Spacer(Modifier.weight(1f))
// Date chip — hidden when "Never" is checked
if (!neverExpires) {
val dateLabel = remember(expiryCalendar) {
SimpleDateFormat("dd MMM yyyy", Locale.getDefault())
@@ -331,10 +368,7 @@ private fun NwcAddConnectionSheet(
)
}
)
Spacer(Modifier.weight(1f))
// Time chip
val timeLabel = remember(expiryCalendar) {
String.format(
"%02d:%02d",
@@ -356,18 +390,53 @@ private fun NwcAddConnectionSheet(
}
Spacer(Modifier.weight(1f))
Checkbox(
checked = neverExpires,
onCheckedChange = { neverExpires = it }
)
Text(text = "Never", style = MaterialTheme.typography.bodyMedium)
}
// ── Budgets ───────────────────────────────────────────────────────
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Never",
style = MaterialTheme.typography.bodyMedium
text = "Budgets",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.weight(1f))
TextButton(
onClick = {
budgets = budgets + BudgetDraft(id = nextBudgetId)
nextBudgetId++
}
) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = "Add budget",
modifier = Modifier.size(16.dp)
)
Spacer(Modifier.width(4.dp))
Text("Add budget")
}
}
budgets.forEach { draft ->
BudgetRow(
draft = draft,
onChange = { updated ->
budgets = budgets.map { if (it.id == draft.id) updated else it }
},
onRemove = {
budgets = budgets.filter { it.id != draft.id }
}
)
}
// ── Permissions ───────────────────────────────────────────────
// ── Permissions ───────────────────────────────────────────────────
Text(
text = "Permissions",
style = MaterialTheme.typography.labelLarge,
@@ -389,22 +458,21 @@ private fun NwcAddConnectionSheet(
}
)
Spacer(Modifier.width(8.dp))
Text(
text = perm.displayLabel,
style = MaterialTheme.typography.bodyMedium
)
Text(text = perm.displayLabel, style = MaterialTheme.typography.bodyMedium)
}
}
// ── Create button ─────────────────────────────────────────────
// ── Create button ─────────────────────────────────────────────────
Button(
onClick = {
val expiresAtSecs = if (neverExpires) 0L
else expiryCalendar.timeInMillis / 1000L
val validBudgets = budgets.mapNotNull { it.toNwcNewBudget() }
onCreate(
description.trim().ifBlank { "Unnamed" },
selectedPermissions.toList(),
expiresAtSecs
expiresAtSecs,
validBudgets
)
},
enabled = selectedPermissions.isNotEmpty() && !isCreating,
@@ -424,6 +492,96 @@ private fun NwcAddConnectionSheet(
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun BudgetRow(
draft : BudgetDraft,
onChange : (BudgetDraft) -> Unit,
onRemove : () -> Unit
) {
var dropdownExpanded by remember { mutableStateOf(false) }
// Shared height for both fields so they are always flush
val fieldHeight = 64.dp
Surface(
shape = MaterialTheme.shapes.medium,
tonalElevation = 2.dp,
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
// ── Amount field ──────────────────────────────────────────────────
OutlinedTextField(
value = draft.amountSats,
onValueChange = { onChange(draft.copy(amountSats = it.filter(Char::isDigit))) },
label = { Text("Sats") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier
.weight(1f)
.height(fieldHeight)
)
// ── Refresh-window dropdown ───────────────────────────────────────
ExposedDropdownMenuBox(
expanded = dropdownExpanded,
onExpandedChange = { dropdownExpanded = it },
modifier = Modifier
.weight(1f)
.height(fieldHeight)
) {
OutlinedTextField(
value = draft.refreshWindow.label,
onValueChange = {},
readOnly = true,
label = { Text("Resets") },
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = dropdownExpanded)
},
modifier = Modifier
.menuAnchor()
.fillMaxWidth()
.height(fieldHeight)
)
ExposedDropdownMenu(
expanded = dropdownExpanded,
onDismissRequest = { dropdownExpanded = false }
) {
BudgetRefreshWindow.entries.forEach { window ->
DropdownMenuItem(
text = { Text(window.label) },
onClick = {
onChange(draft.copy(refreshWindow = window))
dropdownExpanded = false
}
)
}
}
}
// ── Remove button ─────────────────────────────────────────────────
IconButton(
onClick = onRemove,
modifier = Modifier.size(fieldHeight) // matches field height
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Remove budget",
tint = MaterialTheme.colorScheme.error
)
}
}
}
}
// ── Pairing URL dialog ────────────────────────────────────────────────────────
@Composable
@@ -431,64 +589,77 @@ private fun NwcPairingUrlDialog(
url : String,
onDismiss : () -> Unit
) {
val clipboard = LocalClipboardManager.current
var copied by remember { mutableStateOf(false) }
// Brightness state lives here so the dialog owns the screen-brightness side-effect
var brightnessOn by remember { mutableStateOf(false) }
val window = (LocalContext.current as? Activity)?.window
val context = LocalContext.current
// Boost / restore screen brightness when the toggle changes
LaunchedEffect(brightnessOn) {
window?.attributes = window.attributes?.apply {
screenBrightness = if (brightnessOn) 1f
else WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
}
}
// Restore brightness unconditionally when the dialog leaves composition
DisposableEffect(Unit) {
onDispose {
window?.attributes = window.attributes?.apply {
screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
}
}
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Connection created") },
text = {
title = { Text("Connection created") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
text = "Copy this connection string into your app. It will not be shown again.",
text = "Scan or copy this connection string into your app. It will not be shown again.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = MaterialTheme.shapes.small
) {
Text(
text = url,
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace
),
modifier = Modifier.padding(12.dp),
maxLines = 6,
overflow = TextOverflow.Ellipsis
)
}
AnimatedVisibility(visible = copied) {
Text(
text = "✓ Copied to clipboard",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary
)
}
}
},
confirmButton = {
Button(
onClick = {
clipboard.setText(AnnotatedString(url))
copied = true
}
) {
Icon(
imageVector = Icons.Default.ContentCopy,
contentDescription = null,
modifier = Modifier.size(16.dp)
QrDisplayCard(
modifier = Modifier.fillMaxWidth(),
content = url,
contentDescription = "NWC pairing QR code",
clipLabel = "NWC connection string",
shareTitle = "Share NWC connection string",
textToCopy = url,
brightnessOn = brightnessOn,
onToggleBrightness = { brightnessOn = !brightnessOn },
// No NFC for a one-shot pairing string
isNfcEmulating = null,
qrModifier = Modifier.fillMaxWidth(),
onOpenUri = {
val intent = Intent(Intent.ACTION_VIEW, url.toUri())
try {
context.startActivity(
Intent.createChooser(intent, "Open in app")
)
} catch (e: ActivityNotFoundException) {
// No app installed that handles nostr+walletconnect://
// Show a snackbar / toast as appropriate for your app
Toast.makeText(
context,
"No compatible app found", // "No compatible app found"
Toast.LENGTH_SHORT
).show()
}
}
)
Spacer(Modifier.width(6.dp))
Text("Copy")
}
},
confirmButton = {}, // actions live inside QrDisplayCard
dismissButton = {
TextButton(onClick = onDismiss) { Text("Done") }
}
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NwcConnectionRow(
@@ -101,8 +101,8 @@ class NwcViewModel(
budgets : List<NwcNewBudget> = emptyList()
) {
Timber.d("[$TAG] createConnection description='$description' permissions=$permissions")
_isCreating.value = true
viewModelScope.launch {
_isCreating.value = true
runCatching {
val keypair = NwcKeyGenerator.generate()
Timber.d("[$TAG] generated keypair pubkey=${keypair.pubKeyHex.take(16)}")
@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.OpenInNew
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Nfc
import androidx.compose.material.icons.filled.Share
@@ -55,6 +56,22 @@ import kotlinx.coroutines.launch
* @param onToggleNfc Called when the NFC button is tapped.
* @param nfcEnabled Whether the NFC button should be interactive (e.g. false when invoice expired).
*/
/**
* QR image followed by Share / Copy / Brightness / NFC / Open-in-app icon-button row.
*
* @param content What to encode into the QR code.
* @param contentDescription Accessibility label for the QR image.
* @param clipLabel Label used when writing to the clipboard.
* @param shareTitle Chooser title for the share intent.
* @param textToCopy The raw string placed on the clipboard and shared.
* @param brightnessOn Current brightness-boost state.
* @param onToggleBrightness Called when the brightness button is tapped.
* @param isNfcEmulating Current NFC emulation state (null = hide NFC button).
* @param onToggleNfc Called when the NFC button is tapped.
* @param nfcEnabled Whether the NFC button should be interactive.
* @param onOpenUri When non-null, shows an "Open in app" icon button that
* fires this callback. Pass null (default) to hide it.
*/
@Composable
internal fun QrDisplayCard(
modifier : Modifier = Modifier,
@@ -65,9 +82,10 @@ internal fun QrDisplayCard(
textToCopy : String,
brightnessOn : Boolean,
onToggleBrightness : () -> Unit,
isNfcEmulating : Boolean? = null, // null → no NFC button shown
isNfcEmulating : Boolean? = null,
onToggleNfc : (() -> Unit)? = null,
nfcEnabled : Boolean = true,
onOpenUri : (() -> Unit)? = null, // ← NEW
qrModifier : Modifier = Modifier.size(260.dp)
) {
val strings = LocalAppStrings.current
@@ -91,7 +109,7 @@ internal fun QrDisplayCard(
.clip(RoundedCornerShape(12.dp))
)
// ── Action row: Share | Copy | Brightness | NFC ───────────────────────
// ── Action row: Share | Copy | Brightness | NFC | Open ────────────────
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
@@ -103,7 +121,7 @@ internal fun QrDisplayCard(
) {
Icon(
imageVector = Icons.Filled.Share,
contentDescription = strings.qrShare, // ← "Share"
contentDescription = strings.qrShare,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
@@ -120,7 +138,7 @@ internal fun QrDisplayCard(
) {
Icon(
imageVector = Icons.Filled.ContentCopy,
contentDescription = strings.qrCopy, // ← "Copy"
contentDescription = strings.qrCopy,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
@@ -129,8 +147,8 @@ internal fun QrDisplayCard(
FilledTonalIconButton(onClick = onToggleBrightness) {
Icon(
imageVector = Icons.Filled.WbSunny,
contentDescription = if (brightnessOn) strings.brightnessReduce // ← "Reduce brightness"
else strings.brightnessBoost, // ← "Boost brightness"
contentDescription = if (brightnessOn) strings.brightnessReduce
else strings.brightnessBoost,
tint = if (brightnessOn) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -145,17 +163,29 @@ internal fun QrDisplayCard(
Icon(
imageVector = if (isNfcEmulating) Icons.Filled.Nfc
else Icons.Outlined.Nfc,
contentDescription = if (isNfcEmulating) strings.qrNfcStop // ← "Stop NFC sharing"
else strings.qrNfcStart, // ← "Share via NFC"
contentDescription = if (isNfcEmulating) strings.qrNfcStop
else strings.qrNfcStart,
tint = if (isNfcEmulating) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
// Open in app — only shown when caller passes onOpenUri
if (onOpenUri != null) {
IconButton(onClick = onOpenUri) {
Icon(
imageVector = Icons.AutoMirrored.Filled.OpenInNew,
contentDescription = strings.openInApp,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
// ── QR generation ─────────────────────────────────────────────────────────────
private const val QR_BITMAP_SIZE = 512