feat(wallet): scan/upload chooser on every open + native scanner handoff
The scan modal now always lands on a chooser pane — Scan with camera / Upload image / paste — instead of auto-starting the camera. When the Android companion's ArchipelagoQr bridge is present, live scanning is delegated to the native camera modal (WebView getUserMedia lags); animated-QR progress and errors mirror onto it via setStatus. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
37c8992d21
commit
ff4013bd3b
@ -18,7 +18,7 @@
|
||||
<div class="flex items-center justify-between gap-4 mb-4">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<button
|
||||
v-if="pane !== 'scan'"
|
||||
v-if="pane !== 'choose'"
|
||||
@click="goBack"
|
||||
class="p-2 -ml-2 rounded-lg hover:bg-white/10 text-white/70 hover:text-white transition-colors shrink-0"
|
||||
aria-label="Back"
|
||||
@ -41,8 +41,62 @@
|
||||
</div>
|
||||
|
||||
<Transition :name="direction === 'forward' ? 'pane-forward' : 'pane-back'" mode="out-in">
|
||||
<!-- ============ CHOOSE PANE ============ -->
|
||||
<!-- Always the first stop: the user picks camera or image upload
|
||||
every time, instead of the camera auto-starting on open. -->
|
||||
<div v-if="pane === 'choose'" key="choose">
|
||||
<div class="grid grid-cols-2 gap-3 mb-4">
|
||||
<button
|
||||
@click="chooseCamera"
|
||||
class="flex flex-col items-center gap-3 p-6 rounded-xl bg-white/5 border border-white/10 hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<svg class="w-8 h-8 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
<span class="text-sm font-medium text-white">Scan with camera</span>
|
||||
</button>
|
||||
<button
|
||||
@click="uploadInput?.click()"
|
||||
class="flex flex-col items-center gap-3 p-6 rounded-xl bg-white/5 border border-white/10 hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<svg class="w-8 h-8 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 7.5L12 3m0 0L7.5 7.5M12 3v13.5" />
|
||||
</svg>
|
||||
<span class="text-sm font-medium text-white">Upload image</span>
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
ref="uploadInput"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
@change="onPhotoPicked"
|
||||
/>
|
||||
|
||||
<div v-if="scanStatus" class="mb-4 p-3 bg-white/5 rounded-lg">
|
||||
<p class="text-sm text-center" :class="scanStatusIsError ? 'text-red-400' : 'text-white/60'">
|
||||
{{ scanStatus }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Paste fallback (also the path on camera-less nodes) -->
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="pasteInput"
|
||||
type="text"
|
||||
placeholder="…or paste an invoice / address / token"
|
||||
class="flex-1 input-glass font-mono text-xs"
|
||||
@keydown.enter="submitPaste"
|
||||
/>
|
||||
<button @click="submitPaste" :disabled="!pasteInput.trim()" class="glass-button px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-40">
|
||||
Use
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ SCAN PANE ============ -->
|
||||
<div v-if="pane === 'scan'" key="scan">
|
||||
<div v-else-if="pane === 'scan'" key="scan">
|
||||
<div class="relative w-full aspect-square rounded-xl overflow-hidden bg-black/40 border border-white/10 mb-4">
|
||||
<!-- opacity (not v-if/v-show): the scanner needs the element, and a
|
||||
source-less <video> flashes a native play glyph in Android WebViews -->
|
||||
@ -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<Pane>('scan')
|
||||
const pane = ref<Pane>('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<HTMLVideoElement | null>(null)
|
||||
const isScanning = ref(false)
|
||||
@ -302,6 +384,27 @@ const pasteInput = ref('')
|
||||
const qrScanner = ref<QrScanner | null>(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 <input capture> opens the native camera in any browser,
|
||||
// PWA or WebView; the shot is decoded locally by qr-scanner's scanImage.
|
||||
const photoInput = ref<HTMLInputElement | null>(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<HTMLInputElement | null>(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()
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user