feat: improve nwc qr code ux
This commit is contained in:
@@ -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,8 +589,27 @@ 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,
|
||||||
@@ -440,55 +617,49 @@ private fun NwcPairingUrlDialog(
|
|||||||
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())
|
||||||
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
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)
|
|
||||||
)
|
|
||||||
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
|
||||||
|
|||||||
Reference in New Issue
Block a user