diff --git a/fusion_plating/fusion_plating_shopfloor/__manifest__.py b/fusion_plating/fusion_plating_shopfloor/__manifest__.py index 301d8312..53744a4f 100644 --- a/fusion_plating/fusion_plating_shopfloor/__manifest__.py +++ b/fusion_plating/fusion_plating_shopfloor/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Plating — Shop Floor', - 'version': '19.0.23.0.0', + 'version': '19.0.24.0.0', 'category': 'Manufacturing/Plating', 'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, ' 'first-piece inspection gates.', diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/js/qr_scanner.js b/fusion_plating/fusion_plating_shopfloor/static/src/js/qr_scanner.js index e68b1b4b..6663d449 100644 --- a/fusion_plating/fusion_plating_shopfloor/static/src/js/qr_scanner.js +++ b/fusion_plating/fusion_plating_shopfloor/static/src/js/qr_scanner.js @@ -30,6 +30,11 @@ import { Component, useState, useRef, onWillUnmount } from "@odoo/owl"; import { useService } from "@web/core/utils/hooks"; +// Hint type values from zxing-js (DecodeHintType enum): +// 2 = POSSIBLE_FORMATS, 3 = TRY_HARDER +const ZXING_HINT_POSSIBLE_FORMATS = 2; +const ZXING_HINT_TRY_HARDER = 3; + export class QrScanner extends Component { static template = "fusion_plating_shopfloor.QrScanner"; static props = { @@ -257,23 +262,20 @@ export class QrScanner extends Component { return; } const Z = window.ZXing; - const reader = new Z.BrowserMultiFormatReader(); - this._zxingReader = reader; - - // Restrict to QR codes — skips Code128 / EAN / PDF417 probes - // we don't need, halves per-frame work. - try { - const hints = new Map(); - // DecodeHintType.POSSIBLE_FORMATS == 2 in the zxing-js enum. - const formatsKey = (Z.DecodeHintType && Z.DecodeHintType.POSSIBLE_FORMATS) || 2; - const qrFormat = (Z.BarcodeFormat && Z.BarcodeFormat.QR_CODE); - if (qrFormat !== undefined) { - hints.set(formatsKey, [qrFormat]); - reader.hints = hints; - } - } catch (e) { - // Hint API differs across versions — fine, decode all formats. + // Pass hints via the constructor — assignment to .hints + // afterward doesn't work because decodeBitmap reads from + // this._hints (set by MultiFormatReader.setHints during + // construction). TRY_HARDER makes the QR finder more + // aggressive about perspective and contrast. + const hints = new Map(); + if (Z.BarcodeFormat && Z.BarcodeFormat.QR_CODE !== undefined) { + hints.set(ZXING_HINT_POSSIBLE_FORMATS, [Z.BarcodeFormat.QR_CODE]); } + hints.set(ZXING_HINT_TRY_HARDER, true); + // Second arg is timeBetweenScansMillis — drop from 500 default + // to 100 so we attempt ~10 decodes/sec instead of ~2. + const reader = new Z.BrowserMultiFormatReader(hints, 100); + this._zxingReader = reader; // Live status — ZXing manages its own timing internally so we // count callbacks instead of rAF ticks. Hits is what matters. @@ -427,6 +429,107 @@ export class QrScanner extends Component { } } + /** + * Decode a QR from a still photo taken via the iOS / Android + * native camera UI. Triggered when the user taps "Take photo" + * (the in the template + * backs this). + * + * Works on every browser that supports file inputs — including + * iOS Chrome / Safari, where the live-video decode path in ZXing + * has been unreliable. iOS hands us a JPEG that's been autofocused + * and properly exposed; we just need to run ONE decode on it + * rather than a noisy decode loop. + */ + async onPhotoCapture(ev) { + const file = ev.target.files && ev.target.files[0]; + // Reset so the same file can be picked twice in a row. + ev.target.value = ""; + if (!file) return; + this.state.error = null; + this.state.statusLine = "Decoding photo…"; + + // Load the file into an via Object URL. + const url = URL.createObjectURL(file); + try { + const img = await new Promise((resolve, reject) => { + const i = new Image(); + i.onload = () => resolve(i); + i.onerror = () => reject(new Error("Failed to load photo")); + i.src = url; + }); + + // Draw onto a canvas at native resolution. + if (!this._canvas) { + this._canvas = document.createElement("canvas"); + this._ctx = this._canvas.getContext("2d", { willReadFrequently: true }); + } + const w = img.naturalWidth; + const h = img.naturalHeight; + this._canvas.width = w; + this._canvas.height = h; + this._ctx.drawImage(img, 0, 0, w, h); + + // Try ZXing first (more tolerant), then jsQR as fallback. + const text = this._decodeStillFromCanvas(this._canvas); + if (text) { + this._handleCode(text); + return; + } + this.state.error = "Couldn't read a QR in that photo. " + + "Try moving closer or improving lighting."; + this.state.statusLine = ""; + } catch (e) { + this.state.error = "Photo decode failed: " + (e.message || e); + } finally { + URL.revokeObjectURL(url); + } + } + + /** + * Single-shot decode of a canvas. Uses whichever decoder is + * available, in order of robustness. + */ + _decodeStillFromCanvas(canvas) { + const Z = window.ZXing; + if (Z && typeof Z.BrowserMultiFormatReader === "function") { + try { + const hints = new Map(); + if (Z.BarcodeFormat && Z.BarcodeFormat.QR_CODE !== undefined) { + hints.set(ZXING_HINT_POSSIBLE_FORMATS, [Z.BarcodeFormat.QR_CODE]); + } + hints.set(ZXING_HINT_TRY_HARDER, true); + const reader = new Z.BrowserMultiFormatReader(hints); + // decodeFromImageElement / decodeFromCanvas — try the + // canvas-friendly path: build a luminance source + + // binary bitmap manually and call MultiFormatReader. + const luminance = new Z.HTMLCanvasElementLuminanceSource(canvas); + const binarizer = new Z.HybridBinarizer(luminance); + const bitmap = new Z.BinaryBitmap(binarizer); + const result = new Z.MultiFormatReader(); + result.setHints(hints); + const decoded = result.decode(bitmap); + const text = decoded && (decoded.getText ? decoded.getText() : decoded.text); + if (text) return text; + } catch (e) { + // ZXing miss — fall through to jsQR. + } + } + if (typeof window.jsQR === "function") { + try { + const ctx = canvas.getContext("2d"); + const data = ctx.getImageData(0, 0, canvas.width, canvas.height); + const code = window.jsQR(data.data, canvas.width, canvas.height, { + inversionAttempts: "attemptBoth", + }); + if (code && code.data) return code.data; + } catch (e) { + // fall through + } + } + return null; + } + /** * Route a decoded value to the right backend page. * diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/scss/qr_scanner.scss b/fusion_plating/fusion_plating_shopfloor/static/src/scss/qr_scanner.scss index fd42bfb8..e9203b36 100644 --- a/fusion_plating/fusion_plating_shopfloor/static/src/scss/qr_scanner.scss +++ b/fusion_plating/fusion_plating_shopfloor/static/src/scss/qr_scanner.scss @@ -64,6 +64,19 @@ object-fit: cover; } +.o_fp_qr_photo_row { + display: flex; + justify-content: center; +} + +.o_fp_qr_photo_btn { + cursor: pointer; + width: 100%; + text-align: center; + padding: $fp-space-3; + font-weight: 600; +} + .o_fp_qr_error, .o_fp_qr_warn, .o_fp_qr_detected, diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/xml/qr_scanner.xml b/fusion_plating/fusion_plating_shopfloor/static/src/xml/qr_scanner.xml index 6ce24767..6bbe475f 100644 --- a/fusion_plating/fusion_plating_shopfloor/static/src/xml/qr_scanner.xml +++ b/fusion_plating/fusion_plating_shopfloor/static/src/xml/qr_scanner.xml @@ -36,6 +36,25 @@