import { ref, watch } from 'vue' import { scanBarcodes } from 'src/api/ocr' import { useOfflineStore } from 'src/stores/offline' const SCAN_TIMEOUT_MS = 8000 /** * Barcode scanner using device camera photo capture + Gemini Vision AI. * * Strategy: Use which triggers * the native camera app — this gives proper autofocus, tap-to-focus, * and high-res photos. Then send to Gemini Vision for barcode extraction. * * Resilience: if Gemini doesn't answer within SCAN_TIMEOUT_MS (weak LTE), * the photo is queued in IndexedDB via the offline store and retried when * the signal comes back. The tech gets a "scan en attente" indicator and * can keep working; late results are delivered via onNewCode(). * * @param {object} options * @param {(code: string) => void} [options.onNewCode] — called for each * newly detected code, whether the scan was synchronous or delivered * later from the offline queue. Typically used to trigger lookup + notify. */ export function useScanner (options = {}) { const onNewCode = options.onNewCode || (() => {}) const barcodes = ref([]) // Array of { value, region } — max 3 const scanning = ref(false) // true while Gemini is processing const error = ref(null) const lastPhoto = ref(null) // data URI of last captured photo (thumbnail) const photos = ref([]) // all captured photo thumbnails const offline = useOfflineStore() // Pick up any scans that completed while the page was unmounted (e.g. tech // queued a photo, locked phone, walked out of the basement, signal returns). for (const result of offline.scanResults) { mergeCodes(result.barcodes || [], 'queued') offline.consumeScanResult(result.id) } // Watch for sync completions during the lifetime of this scanner. // Vue auto-disposes the watcher when the host component unmounts. watch( () => offline.scanResults.length, () => { for (const result of [...offline.scanResults]) { mergeCodes(result.barcodes || [], 'queued') offline.consumeScanResult(result.id) } } ) function addCode (code, region) { if (barcodes.value.length >= 3) return false if (barcodes.value.find(b => b.value === code)) return false barcodes.value.push({ value: code, region }) onNewCode(code) return true } function mergeCodes (codes, region) { const added = [] for (const code of codes) { if (addCode(code, region)) added.push(code) } return added } /** * Process a photo file from camera input. * Resizes for AI, keeps thumbnail, sends to Gemini with an 8s timeout. * On timeout/failure, the photo is queued for background retry. */ async function processPhoto (file) { if (!file) return [] error.value = null scanning.value = true let aiImage = null const photoIdx = photos.value.length let found = [] try { // Create thumbnail for display (small) const thumbUrl = await resizeImage(file, 400) lastPhoto.value = thumbUrl photos.value.push({ url: thumbUrl, ts: Date.now(), codes: [], queued: false }) // Create optimized image for AI — keep high res for text readability aiImage = await resizeImage(file, 1600, 0.92) const result = await scanBarcodesWithTimeout(aiImage, SCAN_TIMEOUT_MS) found = mergeCodes(result.barcodes || [], 'photo') photos.value[photoIdx].codes = found if (found.length === 0) { error.value = 'Aucun code détecté — rapprochez-vous ou améliorez la mise au point' } } catch (e) { if (aiImage && isRetryable(e)) { await offline.enqueueVisionScan({ image: aiImage }) if (photos.value[photoIdx]) photos.value[photoIdx].queued = true error.value = 'Réseau faible — scan en attente. Reprise automatique au retour du signal.' } else { error.value = e.message || 'Erreur' } } finally { scanning.value = false } return found } async function scanBarcodesWithTimeout (image, ms) { return await Promise.race([ scanBarcodes(image), new Promise((_, reject) => setTimeout( () => reject(new Error('ScanTimeout')), ms, )), ]) } function isRetryable (e) { const msg = (e?.message || '').toLowerCase() return msg.includes('scantimeout') || msg.includes('failed to fetch') || msg.includes('networkerror') || msg.includes('load failed') || e?.name === 'TypeError' // fetch throws TypeError on network error } /** * Resize an image file to a max dimension, return as base64 data URI. */ function resizeImage (file, maxDim, quality = 0.85) { return new Promise((resolve, reject) => { const img = new Image() img.onload = () => { let { width, height } = img if (width > maxDim || height > maxDim) { const ratio = Math.min(maxDim / width, maxDim / height) width = Math.round(width * ratio) height = Math.round(height * ratio) } const canvas = document.createElement('canvas') canvas.width = width canvas.height = height canvas.getContext('2d').drawImage(img, 0, 0, width, height) resolve(canvas.toDataURL('image/jpeg', quality)) } img.onerror = reject img.src = URL.createObjectURL(file) }) } function removeBarcode (value) { barcodes.value = barcodes.value.filter(b => b.value !== value) } function clearBarcodes () { barcodes.value = [] error.value = null lastPhoto.value = null photos.value = [] } return { barcodes, scanning, error, lastPhoto, photos, processPhoto, removeBarcode, clearBarcodes, } }