From 237cc74519a488c8a67ccc13987d923bf658d4f0 Mon Sep 17 00:00:00 2001 From: rasputin Date: Wed, 10 Jun 2026 16:08:38 +0200 Subject: [PATCH] feat: improve nwc qr code ux --- app/src/main/AndroidManifest.xml | 8 + .../bitcointxoko/gudariwallet/api/Models.kt | 5 +- .../gudariwallet/i18n/EnStrings.kt | 2 + .../gudariwallet/i18n/EsStrings.kt | 2 + .../gudariwallet/i18n/EuStrings.kt | 2 + .../bitcointxoko/gudariwallet/i18n/Strings.kt | 2 + .../gudariwallet/ui/nwc/NwcScreen.kt | 327 +++++++++++++----- .../gudariwallet/ui/nwc/NwcViewModel.kt | 2 +- .../gudariwallet/ui/receive/QrDisplayCard.kt | 46 ++- 9 files changed, 307 insertions(+), 89 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8eee81b..2d4ca3b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -17,6 +17,14 @@ + + + + + + + + 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, expiresAt: Long) -> Unit, + onCreate : ( + description : String, + permissions : List, + expiresAt : Long, + budgets : List // ← 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()) } + + // ── 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( diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/nwc/NwcViewModel.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/nwc/NwcViewModel.kt index 436206f..9cac362 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/nwc/NwcViewModel.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/nwc/NwcViewModel.kt @@ -101,8 +101,8 @@ class NwcViewModel( budgets : List = 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)}…") diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/receive/QrDisplayCard.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/receive/QrDisplayCard.kt index 96fbe84..e45d52e 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/receive/QrDisplayCard.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/receive/QrDisplayCard.kt @@ -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