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 @@
+
+
+