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" android:required="false" />
<uses-feature android:name="android.hardware.nfc.hce" 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 <application
android:name=".GudariWalletApplication" android:name=".GudariWalletApplication"
android:allowBackup="true" android:allowBackup="true"
@@ -330,7 +330,7 @@ data class NwcKey(
val wallet: String, val wallet: String,
val description: String, val description: String,
val expires_at: Long, val expires_at: Long,
val permissions: String, // comma-separated permission names val permissions: String,
val created_at: Long, val created_at: Long,
val last_used: Long val last_used: Long
) )
@@ -352,7 +352,8 @@ data class NwcGetResponse(
data class NwcNewBudget( data class NwcNewBudget(
val budget_msats: Long, 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} */ /** Request body for PUT /nwc/{pubkey} */
@@ -105,6 +105,8 @@ val EnHomeStrings = AppStrings(
qrCopy = "Copy", qrCopy = "Copy",
qrNfcStop = "Stop NFC sharing", qrNfcStop = "Stop NFC sharing",
qrNfcStart = "Share via NFC", qrNfcStart = "Share via NFC",
openInApp = "Open in app",
noAppFound = "No compatible app found",
// ── ReceiveViewModel ────────────────────────────────────────────────────── // ── ReceiveViewModel ──────────────────────────────────────────────────────
receiveVmFailedToCreateInvoice = "Failed to create invoice", receiveVmFailedToCreateInvoice = "Failed to create invoice",
@@ -105,6 +105,8 @@ val EsHomeStrings = AppStrings(
qrCopy = "Copiar", qrCopy = "Copiar",
qrNfcStop = "Detener NFC", qrNfcStop = "Detener NFC",
qrNfcStart = "Compartir por NFC", qrNfcStart = "Compartir por NFC",
openInApp = "Abrir en app",
noAppFound = "No hay aplicación compatible",
// ── ReceiveViewModel ────────────────────────────────────────────────────── // ── ReceiveViewModel ──────────────────────────────────────────────────────
receiveVmFailedToCreateInvoice = "No se pudo crear la factura", receiveVmFailedToCreateInvoice = "No se pudo crear la factura",
@@ -104,6 +104,8 @@ val EuStrings = AppStrings(
qrCopy = "Kopiatu", qrCopy = "Kopiatu",
qrNfcStop = "Gelditu NFC partekatzea", qrNfcStop = "Gelditu NFC partekatzea",
qrNfcStart = "Partekatu NFC bidez", qrNfcStart = "Partekatu NFC bidez",
openInApp = "Ireki aplikazioan",
noAppFound = "Ez da aurkitu aplikazio bateragarririk",
// ── ReceiveViewModel ────────────────────────────────────────────────────── // ── ReceiveViewModel ──────────────────────────────────────────────────────
receiveVmFailedToCreateInvoice = "Ezin izan da faktura sortu", receiveVmFailedToCreateInvoice = "Ezin izan da faktura sortu",
@@ -113,6 +113,8 @@ data class AppStrings(
val qrCopy : String, val qrCopy : String,
val qrNfcStop : String, val qrNfcStop : String,
val qrNfcStart : String, val qrNfcStart : String,
val openInApp : String,
val noAppFound : String,
// ── ReceiveViewModel ────────────────────────────────────────────────────── // ── ReceiveViewModel ──────────────────────────────────────────────────────
val receiveVmFailedToCreateInvoice : String, val receiveVmFailedToCreateInvoice : String,
@@ -1,12 +1,17 @@
package com.bitcointxoko.gudariwallet.ui.nwc 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.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack 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.Add
import androidx.compose.material.icons.filled.Cable import androidx.compose.material.icons.filled.Cable
import androidx.compose.material.icons.filled.CalendarToday 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.Delete
import androidx.compose.material.icons.filled.Schedule import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material3.* import androidx.compose.material3.*
@@ -22,13 +27,14 @@ import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitcointxoko.gudariwallet.api.NwcGetResponse 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 com.bitcointxoko.gudariwallet.ui.theme.semanticColors
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.time.Instant import java.time.Instant
@@ -37,6 +43,7 @@ import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle import java.time.format.FormatStyle
import java.util.Calendar import java.util.Calendar
import java.util.Locale import java.util.Locale
import androidx.core.net.toUri
// ── Available permissions ───────────────────────────────────────────────────── // ── Available permissions ─────────────────────────────────────────────────────
internal data class NwcPermission( internal data class NwcPermission(
@@ -53,6 +60,36 @@ internal val ALL_PERMISSIONS = listOf(
NwcPermission("info", "Read account info", default = false) 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 ──────────────────────────────────────────────────────────────────── // ── Screen ────────────────────────────────────────────────────────────────────
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -149,11 +186,12 @@ fun NwcScreen(
if (showAddSheet) { if (showAddSheet) {
NwcAddConnectionSheet( NwcAddConnectionSheet(
isCreating = isCreating, isCreating = isCreating,
onCreate = { description, permissions, expiresAt -> onCreate = { description, permissions, expiresAt, budgets ->
viewModel.createConnection( viewModel.createConnection(
description = description, description = description,
permissions = permissions, permissions = permissions,
expiresAt = expiresAt expiresAt = expiresAt,
budgets = budgets
) )
showAddSheet = false showAddSheet = false
}, },
@@ -196,7 +234,12 @@ fun NwcScreen(
@Composable @Composable
private fun NwcAddConnectionSheet( private fun NwcAddConnectionSheet(
isCreating : Boolean, 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 onDismiss : () -> Unit
) { ) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
@@ -207,7 +250,6 @@ private fun NwcAddConnectionSheet(
} }
var neverExpires by remember { mutableStateOf(false) } var neverExpires by remember { mutableStateOf(false) }
// Default expiry = now + 30 days, truncated to the minute
val defaultExpiry = remember { val defaultExpiry = remember {
Calendar.getInstance().apply { Calendar.getInstance().apply {
add(Calendar.DAY_OF_YEAR, 30) add(Calendar.DAY_OF_YEAR, 30)
@@ -216,11 +258,14 @@ private fun NwcAddConnectionSheet(
} }
} }
var expiryCalendar by remember { mutableStateOf(defaultExpiry) } var expiryCalendar by remember { mutableStateOf(defaultExpiry) }
var showDatePicker by remember { mutableStateOf(false) } var showDatePicker by remember { mutableStateOf(false) }
var showTimePicker 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) { if (showDatePicker) {
val datePickerState = rememberDatePickerState( val datePickerState = rememberDatePickerState(
initialSelectedDateMillis = expiryCalendar.timeInMillis initialSelectedDateMillis = expiryCalendar.timeInMillis
@@ -243,12 +288,10 @@ private fun NwcAddConnectionSheet(
dismissButton = { dismissButton = {
TextButton(onClick = { showDatePicker = false }) { Text("Cancel") } TextButton(onClick = { showDatePicker = false }) { Text("Cancel") }
} }
) { ) { DatePicker(state = datePickerState) }
DatePicker(state = datePickerState)
}
} }
// ── Time picker dialog ──────────────────────────────────────────────────── // ── Time picker dialog ────────────────────────────────────────────────────
if (showTimePicker) { if (showTimePicker) {
val timePickerState = rememberTimePickerState( val timePickerState = rememberTimePickerState(
initialHour = expiryCalendar.get(Calendar.HOUR_OF_DAY), initialHour = expiryCalendar.get(Calendar.HOUR_OF_DAY),
@@ -292,7 +335,7 @@ private fun NwcAddConnectionSheet(
style = MaterialTheme.typography.titleMedium style = MaterialTheme.typography.titleMedium
) )
// ── Description ─────────────────────────────────────────────── // ── Description ───────────────────────────────────────────────────
OutlinedTextField( OutlinedTextField(
value = description, value = description,
onValueChange = { description = it }, onValueChange = { description = it },
@@ -301,20 +344,14 @@ private fun NwcAddConnectionSheet(
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
// ── Expiry ──────────────────────────────────────────────────── // ── Expiry ────────────────────────────────────────────────────────
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
// horizontalArrangement = Arrangement.spacedBy(2.dp)
) { ) {
Text( Text(text = "Expires", style = MaterialTheme.typography.bodyMedium)
text = "Expires",
style = MaterialTheme.typography.bodyMedium
)
Spacer(Modifier.weight(1f)) Spacer(Modifier.weight(1f))
// Date chip — hidden when "Never" is checked
if (!neverExpires) { if (!neverExpires) {
val dateLabel = remember(expiryCalendar) { val dateLabel = remember(expiryCalendar) {
SimpleDateFormat("dd MMM yyyy", Locale.getDefault()) SimpleDateFormat("dd MMM yyyy", Locale.getDefault())
@@ -331,10 +368,7 @@ private fun NwcAddConnectionSheet(
) )
} }
) )
Spacer(Modifier.weight(1f)) Spacer(Modifier.weight(1f))
// Time chip
val timeLabel = remember(expiryCalendar) { val timeLabel = remember(expiryCalendar) {
String.format( String.format(
"%02d:%02d", "%02d:%02d",
@@ -356,18 +390,53 @@ private fun NwcAddConnectionSheet(
} }
Spacer(Modifier.weight(1f)) Spacer(Modifier.weight(1f))
Checkbox( Checkbox(
checked = neverExpires, checked = neverExpires,
onCheckedChange = { neverExpires = it } onCheckedChange = { neverExpires = it }
) )
Text(text = "Never", style = MaterialTheme.typography.bodyMedium)
}
// ── Budgets ───────────────────────────────────────────────────────
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Text( Text(
text = "Never", text = "Budgets",
style = MaterialTheme.typography.bodyMedium 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(
text = "Permissions", text = "Permissions",
style = MaterialTheme.typography.labelLarge, style = MaterialTheme.typography.labelLarge,
@@ -389,22 +458,21 @@ private fun NwcAddConnectionSheet(
} }
) )
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
Text( Text(text = perm.displayLabel, style = MaterialTheme.typography.bodyMedium)
text = perm.displayLabel,
style = MaterialTheme.typography.bodyMedium
)
} }
} }
// ── Create button ───────────────────────────────────────────── // ── Create button ─────────────────────────────────────────────────
Button( Button(
onClick = { onClick = {
val expiresAtSecs = if (neverExpires) 0L val expiresAtSecs = if (neverExpires) 0L
else expiryCalendar.timeInMillis / 1000L else expiryCalendar.timeInMillis / 1000L
val validBudgets = budgets.mapNotNull { it.toNwcNewBudget() }
onCreate( onCreate(
description.trim().ifBlank { "Unnamed" }, description.trim().ifBlank { "Unnamed" },
selectedPermissions.toList(), selectedPermissions.toList(),
expiresAtSecs expiresAtSecs,
validBudgets
) )
}, },
enabled = selectedPermissions.isNotEmpty() && !isCreating, 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 ──────────────────────────────────────────────────────── // ── Pairing URL dialog ────────────────────────────────────────────────────────
@Composable @Composable
@@ -431,64 +589,77 @@ private fun NwcPairingUrlDialog(
url : String, url : String,
onDismiss : () -> Unit onDismiss : () -> Unit
) { ) {
val clipboard = LocalClipboardManager.current // Brightness state lives here so the dialog owns the screen-brightness side-effect
var copied by remember { mutableStateOf(false) } 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( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { Text("Connection created") }, title = { Text("Connection created") },
text = { text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text( 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, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
Surface( QrDisplayCard(
color = MaterialTheme.colorScheme.surfaceVariant, modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.small content = url,
) { contentDescription = "NWC pairing QR code",
Text( clipLabel = "NWC connection string",
text = url, shareTitle = "Share NWC connection string",
style = MaterialTheme.typography.bodySmall.copy( textToCopy = url,
fontFamily = FontFamily.Monospace brightnessOn = brightnessOn,
), onToggleBrightness = { brightnessOn = !brightnessOn },
modifier = Modifier.padding(12.dp), // No NFC for a one-shot pairing string
maxLines = 6, isNfcEmulating = null,
overflow = TextOverflow.Ellipsis qrModifier = Modifier.fillMaxWidth(),
) onOpenUri = {
} val intent = Intent(Intent.ACTION_VIEW, url.toUri())
AnimatedVisibility(visible = copied) { try {
Text( context.startActivity(
text = "✓ Copied to clipboard", Intent.createChooser(intent, "Open in app")
style = MaterialTheme.typography.labelSmall, )
color = MaterialTheme.colorScheme.primary } catch (e: ActivityNotFoundException) {
) // No app installed that handles nostr+walletconnect://
} // Show a snackbar / toast as appropriate for your app
} Toast.makeText(
}, context,
confirmButton = { "No compatible app found", // "No compatible app found"
Button( Toast.LENGTH_SHORT
onClick = { ).show()
clipboard.setText(AnnotatedString(url)) }
copied = true }
}
) {
Icon(
imageVector = Icons.Default.ContentCopy,
contentDescription = null,
modifier = Modifier.size(16.dp)
) )
Spacer(Modifier.width(6.dp))
Text("Copy")
} }
}, },
confirmButton = {}, // actions live inside QrDisplayCard
dismissButton = { dismissButton = {
TextButton(onClick = onDismiss) { Text("Done") } TextButton(onClick = onDismiss) { Text("Done") }
} }
) )
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun NwcConnectionRow( fun NwcConnectionRow(
@@ -101,8 +101,8 @@ class NwcViewModel(
budgets : List<NwcNewBudget> = emptyList() budgets : List<NwcNewBudget> = emptyList()
) { ) {
Timber.d("[$TAG] createConnection description='$description' permissions=$permissions") Timber.d("[$TAG] createConnection description='$description' permissions=$permissions")
_isCreating.value = true
viewModelScope.launch { viewModelScope.launch {
_isCreating.value = true
runCatching { runCatching {
val keypair = NwcKeyGenerator.generate() val keypair = NwcKeyGenerator.generate()
Timber.d("[$TAG] generated keypair pubkey=${keypair.pubKeyHex.take(16)}") 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.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons 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.ContentCopy
import androidx.compose.material.icons.filled.Nfc import androidx.compose.material.icons.filled.Nfc
import androidx.compose.material.icons.filled.Share 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 onToggleNfc Called when the NFC button is tapped.
* @param nfcEnabled Whether the NFC button should be interactive (e.g. false when invoice expired). * @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 @Composable
internal fun QrDisplayCard( internal fun QrDisplayCard(
modifier : Modifier = Modifier, modifier : Modifier = Modifier,
@@ -65,9 +82,10 @@ internal fun QrDisplayCard(
textToCopy : String, textToCopy : String,
brightnessOn : Boolean, brightnessOn : Boolean,
onToggleBrightness : () -> Unit, onToggleBrightness : () -> Unit,
isNfcEmulating : Boolean? = null, // null → no NFC button shown isNfcEmulating : Boolean? = null,
onToggleNfc : (() -> Unit)? = null, onToggleNfc : (() -> Unit)? = null,
nfcEnabled : Boolean = true, nfcEnabled : Boolean = true,
onOpenUri : (() -> Unit)? = null, // ← NEW
qrModifier : Modifier = Modifier.size(260.dp) qrModifier : Modifier = Modifier.size(260.dp)
) { ) {
val strings = LocalAppStrings.current val strings = LocalAppStrings.current
@@ -91,7 +109,7 @@ internal fun QrDisplayCard(
.clip(RoundedCornerShape(12.dp)) .clip(RoundedCornerShape(12.dp))
) )
// ── Action row: Share | Copy | Brightness | NFC ─────────────────────── // ── Action row: Share | Copy | Brightness | NFC | Open ────────────────
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
@@ -103,7 +121,7 @@ internal fun QrDisplayCard(
) { ) {
Icon( Icon(
imageVector = Icons.Filled.Share, imageVector = Icons.Filled.Share,
contentDescription = strings.qrShare, // ← "Share" contentDescription = strings.qrShare,
tint = MaterialTheme.colorScheme.onSurfaceVariant tint = MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
@@ -120,7 +138,7 @@ internal fun QrDisplayCard(
) { ) {
Icon( Icon(
imageVector = Icons.Filled.ContentCopy, imageVector = Icons.Filled.ContentCopy,
contentDescription = strings.qrCopy, // ← "Copy" contentDescription = strings.qrCopy,
tint = MaterialTheme.colorScheme.onSurfaceVariant tint = MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
@@ -129,8 +147,8 @@ internal fun QrDisplayCard(
FilledTonalIconButton(onClick = onToggleBrightness) { FilledTonalIconButton(onClick = onToggleBrightness) {
Icon( Icon(
imageVector = Icons.Filled.WbSunny, imageVector = Icons.Filled.WbSunny,
contentDescription = if (brightnessOn) strings.brightnessReduce // ← "Reduce brightness" contentDescription = if (brightnessOn) strings.brightnessReduce
else strings.brightnessBoost, // ← "Boost brightness" else strings.brightnessBoost,
tint = if (brightnessOn) MaterialTheme.colorScheme.primary tint = if (brightnessOn) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurfaceVariant
) )
@@ -145,17 +163,29 @@ internal fun QrDisplayCard(
Icon( Icon(
imageVector = if (isNfcEmulating) Icons.Filled.Nfc imageVector = if (isNfcEmulating) Icons.Filled.Nfc
else Icons.Outlined.Nfc, else Icons.Outlined.Nfc,
contentDescription = if (isNfcEmulating) strings.qrNfcStop // ← "Stop NFC sharing" contentDescription = if (isNfcEmulating) strings.qrNfcStop
else strings.qrNfcStart, // ← "Share via NFC" else strings.qrNfcStart,
tint = if (isNfcEmulating) MaterialTheme.colorScheme.primary tint = if (isNfcEmulating) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant 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 ───────────────────────────────────────────────────────────── // ── QR generation ─────────────────────────────────────────────────────────────
private const val QR_BITMAP_SIZE = 512 private const val QR_BITMAP_SIZE = 512