diff --git a/apps/field/package-lock.json b/apps/field/package-lock.json
index b0c5757..3875eb5 100644
--- a/apps/field/package-lock.json
+++ b/apps/field/package-lock.json
@@ -10,7 +10,6 @@
"dependencies": {
"@quasar/cli": "^3.0.0",
"@quasar/extras": "^1.16.12",
- "html5-qrcode": "^2.3.8",
"idb-keyval": "^6.2.1",
"pinia": "^2.1.7",
"quasar": "^2.16.10",
@@ -6580,12 +6579,6 @@
"node": "^14.13.1 || >=16.0.0"
}
},
- "node_modules/html5-qrcode": {
- "version": "2.3.8",
- "resolved": "https://registry.npmjs.org/html5-qrcode/-/html5-qrcode-2.3.8.tgz",
- "integrity": "sha512-jsr4vafJhwoLVEDW3n1KvPnCCXWaQfRng0/EEYk1vNcQGcG/htAdhJX0be8YyqMoSz7+hZvOZSTAepsabiuhiQ==",
- "license": "Apache-2.0"
- },
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
diff --git a/apps/field/package.json b/apps/field/package.json
index 10b73e6..e210bbe 100644
--- a/apps/field/package.json
+++ b/apps/field/package.json
@@ -12,7 +12,6 @@
"dependencies": {
"@quasar/cli": "^3.0.0",
"@quasar/extras": "^1.16.12",
- "html5-qrcode": "^2.3.8",
"idb-keyval": "^6.2.1",
"pinia": "^2.1.7",
"quasar": "^2.16.10",
diff --git a/apps/field/src/composables/useScanner.js b/apps/field/src/composables/useScanner.js
index 5f79bf9..d2c1ab8 100644
--- a/apps/field/src/composables/useScanner.js
+++ b/apps/field/src/composables/useScanner.js
@@ -2,61 +2,43 @@ import { ref, watch } from 'vue'
import { scanBarcodes } from 'src/api/ocr'
import { useOfflineStore } from 'src/stores/offline'
-const GEMINI_TIMEOUT_MS = 8000
-const MAX_CODES = 3
+const SCAN_TIMEOUT_MS = 8000
/**
- * Hybrid barcode scanner for field techs.
+ * Barcode scanner using device camera photo capture + Gemini Vision AI.
*
- * Three modes, all writing into the same `barcodes` array (max 3):
+ * 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.
*
- * 1. `startLive(elId)` — continuous camera stream via html5-qrcode.
- * Instant, offline-capable, works for standard QR / Code128 / EAN.
- * This is the DEFAULT and fastest path.
- *
- * 2. `processPhoto(file)` — take a picture, scan it for codes.
- * Tries html5-qrcode locally first (full image + 3 horizontal strips,
- * catches up to 3 barcodes per photo, offline). If nothing is found
- * AND we're online, falls back to Gemini Vision which can read weird
- * stuff html5-qrcode misses: damaged stickers, multi-line serials
- * printed as text, unusual barcode symbologies.
- * If Gemini times out on weak LTE, the photo is queued in IndexedDB
- * and retried when signal returns — late results come back through
- * `onNewCode` via the watcher on offline.scanResults.
- *
- * 3. Manual entry — handled by the caller, just appends via addCode().
- *
- * This was rewritten in 2026-04-22 after an earlier refactor replaced all
- * local scanning with Gemini-only. Live camera matters for field techs:
- * point-and-scan is 10× faster than take-photo-wait-for-AI on a normal QR.
- * Gemini is kept as a second chance for the hard cases.
+ * 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] — fires for each
- * newly added code whether it came from live, photo, queue, or manual.
- * Typically used to trigger lookup + toast.
+ * @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([]) // [{ value, region }], max MAX_CODES
- const scanning = ref(false) // true while a photo/AI call is in-flight
- const live = ref(false) // true while the live camera is running
+ 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) // thumbnail data URI
- const photos = ref([]) // [{ url, ts, codes, queued }]
+ const lastPhoto = ref(null) // data URI of last captured photo (thumbnail)
+ const photos = ref([]) // all captured photo thumbnails
const offline = useOfflineStore()
- let _html5 = null // Html5Qrcode instance for live mode
- // Pick up any Gemini scans that completed while the page was unmounted
- // (tech queued a photo, locked phone, walked out of the basement, signal
- // returned, hub replied, we stored the result in IndexedDB).
+ // 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 this scanner's lifetime.
+ // Watch for sync completions during the lifetime of this scanner.
// Vue auto-disposes the watcher when the host component unmounts.
watch(
() => offline.scanResults.length,
@@ -69,12 +51,10 @@ export function useScanner (options = {}) {
)
function addCode (code, region) {
- const trimmed = String(code || '').trim()
- if (!trimmed) return false
- if (barcodes.value.length >= MAX_CODES) return false
- if (barcodes.value.find(b => b.value === trimmed)) return false
- barcodes.value.push({ value: trimmed, region })
- onNewCode(trimmed)
+ 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
}
@@ -86,109 +66,44 @@ export function useScanner (options = {}) {
return added
}
- // -------------------------------------------------------------------------
- // 1. Live camera — html5-qrcode continuous stream
- // -------------------------------------------------------------------------
-
- /**
- * Start the live scanner in the given DOM element.
- * Safe to call again after stopLive() — recreates the instance.
- *
- * @param {string} elementId — id of a div to mount the camera view into.
- * Must already be in the DOM when this is called.
- */
- async function startLive (elementId) {
- if (live.value) return
- error.value = null
- try {
- const { Html5Qrcode } = await import('html5-qrcode')
- _html5 = new Html5Qrcode(elementId, { verbose: false })
- await _html5.start(
- { facingMode: 'environment' },
- {
- fps: 10,
- // Wider aimbox so tech doesn't have to line up perfectly.
- qrbox: (w, h) => {
- const side = Math.floor(Math.min(w, h) * 0.7)
- return { width: side, height: Math.floor(side * 0.6) }
- },
- },
- (decoded) => addCode(decoded, 'caméra'),
- () => { /* per-frame decode miss — ignore, this is noisy by design */ },
- )
- live.value = true
- } catch (e) {
- error.value = e?.message || 'Caméra non disponible'
- live.value = false
- _html5 = null
- }
- }
-
- async function stopLive () {
- try {
- if (_html5?.isScanning) await _html5.stop()
- _html5?.clear()
- } catch { /* ignore — teardown races are fine */ }
- _html5 = null
- live.value = false
- }
-
- // -------------------------------------------------------------------------
- // 2. Photo scan — html5-qrcode multi-strip, Gemini fallback
- // -------------------------------------------------------------------------
-
/**
* Process a photo file from camera input.
- *
- * Pass 1: html5-qrcode on the full image (cheap, offline).
- * Pass 2: split into 3 horizontal strips, scan each — catches stickers
- * that contain multiple barcodes stacked vertically (common on
- * modems: SN, MAC, GPON SN each on their own line).
- * Pass 3: if still empty AND online, send to Gemini for OCR-based read.
- * If Gemini times out (weak signal), queue for background retry.
+ * 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
- const found = []
- const photoIdx = photos.value.length
let aiImage = null
+ const photoIdx = photos.value.length
+ let found = []
try {
- // Thumbnail for the UI
+ // 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 })
- // --- Pass 1 + 2: local html5-qrcode on full image + strips ---
- const localCodes = await scanPhotoLocally(file)
- found.push(...mergeCodes(localCodes, 'photo'))
+ // 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
- // --- Pass 3: Gemini fallback if local scan came up empty ---
if (found.length === 0) {
- aiImage = await resizeImage(file, 1600, 0.92)
- try {
- const result = await scanBarcodesWithTimeout(aiImage, GEMINI_TIMEOUT_MS)
- found.push(...mergeCodes(result.barcodes || [], 'IA'))
- } catch (e) {
- if (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 {
- throw e
- }
- }
- }
-
- if (photos.value[photoIdx]) photos.value[photoIdx].codes = found
- if (found.length === 0 && !error.value) {
error.value = 'Aucun code détecté — rapprochez-vous ou améliorez la mise au point'
}
} catch (e) {
- error.value = e?.message || 'Erreur'
+ 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
}
@@ -196,69 +111,6 @@ export function useScanner (options = {}) {
return found
}
- /**
- * Local pass: html5-qrcode on full image + 3 horizontal strips.
- * Returns an array of unique decoded strings. Always safe to call —
- * errors at any stage are swallowed and produce an empty result.
- */
- async function scanPhotoLocally (file) {
- const found = new Set()
- let scratch = document.getElementById('scanner-scratch')
- if (!scratch) {
- scratch = document.createElement('div')
- scratch.id = 'scanner-scratch'
- scratch.style.display = 'none'
- document.body.appendChild(scratch)
- }
-
- let scanner
- try {
- const { Html5Qrcode } = await import('html5-qrcode')
- scanner = new Html5Qrcode('scanner-scratch', { verbose: false })
-
- // Full image first
- try {
- const r = await scanner.scanFileV2(file, false)
- if (r?.decodedText) found.add(r.decodedText)
- } catch { /* no code on full image — try strips */ }
-
- // Split into 3 strips and scan each — only if we still have room
- if (found.size < MAX_CODES) {
- const img = await createImageBitmap(file)
- try {
- const { width, height } = img
- const strips = [
- { y: 0, h: Math.floor(height / 3), label: 'haut' },
- { y: Math.floor(height / 3), h: Math.floor(height / 3), label: 'milieu' },
- { y: Math.floor(height * 2 / 3), h: height - Math.floor(height * 2 / 3), label: 'bas' },
- ]
- const canvas = document.createElement('canvas')
- const ctx = canvas.getContext('2d')
-
- for (const strip of strips) {
- if (found.size >= MAX_CODES) break
- canvas.width = width
- canvas.height = strip.h
- ctx.drawImage(img, 0, strip.y, width, strip.h, 0, 0, width, strip.h)
- try {
- const blob = await new Promise(r => canvas.toBlob(r, 'image/jpeg', 0.9))
- const stripFile = new File([blob], 'strip.jpg', { type: 'image/jpeg' })
- const r = await scanner.scanFileV2(stripFile, false)
- if (r?.decodedText) found.add(r.decodedText)
- } catch { /* strip miss — continue */ }
- }
- } finally {
- img.close()
- }
- }
- } catch { /* library load / instantiation failure — caller falls back to Gemini */ }
- finally {
- try { scanner?.clear() } catch { /* ignore */ }
- }
-
- return Array.from(found)
- }
-
async function scanBarcodesWithTimeout (image, ms) {
return await Promise.race([
scanBarcodes(image),
@@ -278,11 +130,9 @@ export function useScanner (options = {}) {
|| e?.name === 'TypeError' // fetch throws TypeError on network error
}
- // -------------------------------------------------------------------------
- // Utilities
- // -------------------------------------------------------------------------
-
- /** Resize an image file to a max dimension, return as base64 data URI. */
+ /**
+ * 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()
@@ -316,15 +166,7 @@ export function useScanner (options = {}) {
}
return {
- // state
- barcodes, scanning, live, error, lastPhoto, photos,
- // live camera
- startLive, stopLive,
- // photo scan
- processPhoto,
- // shared
- addCode, removeBarcode, clearBarcodes,
- // constants
- MAX_CODES,
+ barcodes, scanning, error, lastPhoto, photos,
+ processPhoto, removeBarcode, clearBarcodes,
}
}
diff --git a/apps/field/src/pages/ScanPage.vue b/apps/field/src/pages/ScanPage.vue
index 1092b0c..107ba65 100644
--- a/apps/field/src/pages/ScanPage.vue
+++ b/apps/field/src/pages/ScanPage.vue
@@ -17,99 +17,55 @@
-
-