diff --git a/neode-ui/src/components/WalletScanModal.vue b/neode-ui/src/components/WalletScanModal.vue index 8c38f345..6aa86204 100644 --- a/neode-ui/src/components/WalletScanModal.vue +++ b/neode-ui/src/components/WalletScanModal.vue @@ -18,7 +18,7 @@
+ +
+ + +
+

+ {{ scanStatus }} +

+
+ + +
+ + +
+
+ -
+
@@ -251,7 +305,23 @@ import { useBodyScrollLock } from '@/composables/useBodyScrollLock' type Rail = 'onchain' | 'lightning' | 'cashu' | 'fedimint' type Action = 'pay-invoice' | 'send-onchain' | 'redeem-token' | 'fedimint-join' -type Pane = 'scan' | 'amount' | 'success' +type Pane = 'choose' | 'scan' | 'amount' | 'success' + +// JS bridge the Android companion app injects: when present, live scanning is +// delegated to a native camera modal (styled like this one) β€” the WebView's +// getUserMedia preview lags badly on phones. Decodes come back through the +// window.__archyQr* callbacks; we mirror status lines out to the native modal. +interface ArchipelagoQrBridge { + open(): void + setStatus(message: string, isError: boolean): void + close(): void +} +interface NativeWindow extends Window { + ArchipelagoQr?: ArchipelagoQrBridge + __archyQrResult?: (text: string) => void + __archyQrCancelled?: () => void +} +const nativeWin = window as NativeWindow const PRESETS = [21, 2100, 21000, 100000] @@ -263,9 +333,10 @@ useModalKeyboard(modalRef, computed(() => props.show), close) useBodyScrollLock(computed(() => props.show)) // --- Pane state --- -const pane = ref('scan') +const pane = ref('choose') const direction = ref<'forward' | 'back'>('forward') const paneTitle = computed(() => { + if (pane.value === 'choose') return 'Send' if (pane.value === 'scan') return 'Scan to send' if (pane.value === 'success') return 'Success' if (action.value === 'fedimint-join') return 'Join federation' @@ -279,15 +350,26 @@ function goTo(p: Pane, dir: 'forward' | 'back' = 'forward') { } function goBack() { - if (pane.value === 'amount') { + if (pane.value === 'scan') { + stopScanning() + goTo('choose', 'back') + } else if (pane.value === 'amount') { error.value = '' - goTo('scan', 'back') - nextTick(() => { if (!liveCameraUnavailable.value) startScanning() }) + goTo('choose', 'back') } else if (pane.value === 'success') { close() } } +// --- Chooser --- +function chooseCamera() { + scanStatus.value = '' + scanStatusIsError.value = false + if (startNativeScan()) return + goTo('scan') + nextTick(() => { if (!liveCameraUnavailable.value) startScanning() }) +} + // --- Scanner --- const videoElement = ref(null) const isScanning = ref(false) @@ -302,6 +384,27 @@ const pasteInput = ref('') const qrScanner = ref(null) const animatedDecoder = useAnimatedQRDecoder() +// --- Native scanner (companion app) --- +const nativeScanActive = ref(false) + +function startNativeScan(): boolean { + const bridge = nativeWin.ArchipelagoQr + if (!bridge) return false + nativeWin.__archyQrResult = (text: string) => handleScanned(text) + nativeWin.__archyQrCancelled = () => { nativeScanActive.value = false } + nativeScanActive.value = true + bridge.open() + return true +} + +// Mirror scan status (animated-QR progress, "not recognised" errors) onto the +// native modal's status strip while it's up. +watch([scanStatus, scanStatusIsError], () => { + if (nativeScanActive.value) { + nativeWin.ArchipelagoQr?.setStatus(scanStatus.value, scanStatusIsError.value) + } +}) + async function startScanning() { cameraError.value = false scanStatusIsError.value = false @@ -344,6 +447,10 @@ function stopScanning() { qrScanner.value?.destroy() qrScanner.value = null isScanning.value = false + if (nativeScanActive.value) { + nativeScanActive.value = false + nativeWin.ArchipelagoQr?.close() + } } function submitPaste() { @@ -357,6 +464,9 @@ function submitPaste() { // undefined), but opens the native camera in any browser, // PWA or WebView; the shot is decoded locally by qr-scanner's scanImage. const photoInput = ref(null) +// Chooser-pane picker: same decode path, but no capture attribute β€” the user +// picks an existing image (screenshot, saved QR) rather than taking a photo. +const uploadInput = ref(null) const liveCameraUnavailable = computed(() => !navigator.mediaDevices?.getUserMedia) async function onPhotoPicked(e: Event) { @@ -663,7 +773,7 @@ async function confirmSend() { function resetAll() { stopScanning() animatedDecoder.reset() - pane.value = 'scan' + pane.value = 'choose' direction.value = 'forward' scanStatus.value = '' scanStatusIsError.value = false @@ -683,10 +793,10 @@ function close() { emit('close') } +// Every open lands on the chooser β€” the camera never auto-starts. watch(() => props.show, (open) => { if (open) { resetAll() - nextTick(() => { if (!liveCameraUnavailable.value) startScanning() }) } else { stopScanning() }