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