feat: add nfc step 2 - receive with invoice

This commit is contained in:
2026-06-05 15:43:10 +02:00
parent cc50ff08df
commit 82c048f6c0
13 changed files with 588 additions and 316 deletions
@@ -58,6 +58,7 @@ import com.bitcointxoko.gudariwallet.ui.history.HistoryScreen
import com.bitcointxoko.gudariwallet.ui.history.HistoryViewModel
import com.bitcointxoko.gudariwallet.ui.history.HistoryViewModelFactory
import com.bitcointxoko.gudariwallet.ui.history.PaymentDetailScreen
import com.bitcointxoko.gudariwallet.ui.nfc.NfcViewModel
import com.bitcointxoko.gudariwallet.ui.receive.ReceiveScreen
import com.bitcointxoko.gudariwallet.ui.send.SendScreen
import com.bitcointxoko.gudariwallet.util.SendInputType
@@ -67,7 +68,7 @@ import kotlinx.coroutines.delay
sealed class TabItem(val route: String, val label: String, val icon: ImageVector) {
data object Receive : TabItem("receive", "Receive", Icons.Filled.QrCode)
data object Send : TabItem("send", "Send", Icons.AutoMirrored.Filled.Send)
data object Send : TabItem("send", "Send", Icons.AutoMirrored.Filled.Send)
}
private const val ROUTE_HOME = "home"
@@ -78,6 +79,7 @@ private const val ROUTE_PAYMENT_DETAIL = "payment_detail/{checkingId}"
@Composable
fun WalletScreen(
vm : WalletViewModel,
nfcVm : NfcViewModel, // ← new
pendingPaymentHash: MutableState<String?>,
pendingPaymentUri : MutableState<String?>
) {
@@ -85,10 +87,10 @@ fun WalletScreen(
val context = LocalContext.current
val historyFactory = remember {
HistoryViewModelFactory(
repo = vm.repo,
aliasRepo = vm.aliasRepo,
paymentCache = vm.paymentCache,
fiatCurrency = vm.selectedCurrency,
repo = vm.repo,
aliasRepo = vm.aliasRepo,
paymentCache = vm.paymentCache,
fiatCurrency = vm.selectedCurrency,
fiatSatsPerUnit = vm.fiatSatsPerUnit
)
}
@@ -143,7 +145,7 @@ fun WalletScreen(
LaunchedEffect(intentUri) {
if (intentUri != null) {
Log.d("WalletScreen", "INTENT [URI] deep link received: $intentUri")
pendingPaymentUri.value = null // consume immediately
pendingPaymentUri.value = null
vm.scan(intentUri)
navController.navigate(TabItem.Send.route) {
launchSingleTop = true
@@ -151,9 +153,9 @@ fun WalletScreen(
}
}
// ── Clipboard offer ───────────────────────────────────────────────────────
val clipboardOffer by vm.clipboardOfferState.collectAsStateWithLifecycle()
// Auto-dismiss banner after 8 seconds
LaunchedEffect(clipboardOffer) {
if (clipboardOffer is ClipboardOfferState.Detected) {
delay(8_000)
@@ -161,33 +163,13 @@ fun WalletScreen(
}
}
// ── NFC reader mode — register on composition, unregister on disposal ────────
val nfcAdapter = remember {
android.nfc.NfcAdapter.getDefaultAdapter(context)
}
// ── NFC offer (tag read while app is foregrounded) ────────────────────────
val nfcOffer by nfcVm.nfcOfferState.collectAsStateWithLifecycle()
DisposableEffect(Unit) {
val activity = context as MainActivity
nfcAdapter?.enableReaderMode(
activity,
{ tag -> vm.onNfcTagDiscovered(tag) }, // runs on NFC binder thread
android.nfc.NfcAdapter.FLAG_READER_NFC_A or
android.nfc.NfcAdapter.FLAG_READER_NFC_B or
android.nfc.NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK.inv(), // keep NDEF
null
)
onDispose {
nfcAdapter?.disableReaderMode(activity)
}
}
val nfcOffer by vm.nfcOfferState.collectAsStateWithLifecycle()
// Auto-dismiss NFC banner after 8 seconds (same as clipboard)
LaunchedEffect(nfcOffer) {
if (nfcOffer is NfcOfferState.Detected) {
delay(8_000)
vm.dismissNfcOffer()
nfcVm.dismissNfcOffer()
}
}
@@ -304,7 +286,8 @@ fun WalletScreen(
}
composable(TabItem.Receive.route) {
ReceiveScreen(
vm,
vm = vm,
nfcVm = nfcVm, // ← new
onNavigateToPaymentDetail = { checkingId ->
navController.navigate("payment_detail/$checkingId")
}
@@ -339,9 +322,8 @@ fun WalletScreen(
// History — full screen, no bottom chrome, close → home
composable(
ROUTE_HISTORY,
enterTransition = { fadeIn(animationSpec = tween(150)) },
popExitTransition = { fadeOut(animationSpec = tween(150)) }
enterTransition = { fadeIn(animationSpec = tween(150)) },
popExitTransition = { fadeOut(animationSpec = tween(150)) }
) {
HistoryScreen(
vm = historyVm,
@@ -371,8 +353,8 @@ fun WalletScreen(
if (payment != null) {
PaymentDetailScreen(
payment = payment,
vm = historyVm,
onBack = { navController.popBackStack() }
vm = historyVm,
onBack = { navController.popBackStack() }
)
} else {
LaunchedEffect(checkingId) { historyVm.refresh() }
@@ -393,13 +375,13 @@ fun WalletScreen(
exit = slideOutVertically(targetOffsetY = { -it }),
modifier = Modifier
.align(Alignment.TopCenter)
.zIndex(1f)
.zIndex(2f) // above NFC banner
.fillMaxWidth()
.statusBarsPadding()
) {
if (clipboardOffer is ClipboardOfferState.Detected) {
val offer = clipboardOffer as ClipboardOfferState.Detected
val label = when (offer.inputType) {
val offer = clipboardOffer as ClipboardOfferState.Detected
val label = when (offer.inputType) {
is SendInputType.Bolt11 -> "Invoice"
is SendInputType.Lnurl -> "LNURL"
is SendInputType.LightningAddress -> "Lightning Address"
@@ -445,22 +427,23 @@ fun WalletScreen(
}
}
}
// ── NFC banner — floats below clipboard banner, slides in from top ────────────
// ── NFC banner — floats over content, slides in from top ──────────
// Shown when an NFC tag is tapped while the app is foregrounded.
// Mirrors the clipboard banner pattern exactly.
AnimatedVisibility(
visible = nfcOffer is NfcOfferState.Detected,
enter = slideInVertically(initialOffsetY = { -it }),
exit = slideOutVertically(targetOffsetY = { -it }),
modifier = Modifier
.align(Alignment.TopCenter)
.zIndex(1f)
.zIndex(1f) // below clipboard banner if both show
.fillMaxWidth()
.statusBarsPadding()
// push down if clipboard banner is also showing
.padding(top = if (clipboardOffer is ClipboardOfferState.Detected) 64.dp else 0.dp)
) {
if (nfcOffer is NfcOfferState.Detected) {
val offer = nfcOffer as NfcOfferState.Detected
val label = when (offer.inputType) {
val offer = nfcOffer as NfcOfferState.Detected
val label = when (offer.inputType) {
is SendInputType.Bolt11 -> "Invoice"
is SendInputType.Lnurl -> "LNURL"
is SendInputType.LightningAddress -> "Lightning Address"
@@ -471,15 +454,12 @@ fun WalletScreen(
else -> "Tap to pay"
}
Surface(
color = MaterialTheme.colorScheme.secondaryContainer, // distinct from clipboard's primaryContainer
color = MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier
.fillMaxWidth()
.clickable {
vm.dismissNfcOffer()
nfcVm.dismissNfcOffer()
vm.scan(offer.raw)
// scan() already emits navigateToReceive for LNURL-withdraw,
// so we only need to navigate to Send for everything else.
// The existing navigateToReceive LaunchedEffect handles BoltCard.
navController.navigate(TabItem.Send.route) {
launchSingleTop = true
}
@@ -489,7 +469,7 @@ fun WalletScreen(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Filled.Nfc, contentDescription = null)
Icon(Icons.Default.Nfc, contentDescription = null)
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(
@@ -502,7 +482,7 @@ fun WalletScreen(
color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.7f)
)
}
IconButton(onClick = { vm.dismissNfcOffer() }) {
IconButton(onClick = { nfcVm.dismissNfcOffer() }) {
Icon(Icons.Default.Close, contentDescription = "Dismiss")
}
}