From 2f596060b4d425ea1f0970950c101aa977eb14bb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 11:57:58 +0000 Subject: [PATCH] feat(streckkod): okand produkt -> fota forpackning -> spara i delad katalog Okand streckkod erbjuder nu 'Fota forpackningen' direkt i streckkodsvyn. Fotot lases av (naring/allergener/innehall) och produkten UPSERTas i den DELADE products-katalogen kopplat till GTIN (dataSource=user_scanned, verificationStatus=unverified, createdByUserId=anvandaren) sa nasta person som skannar samma kod far den direkt. Produkten laggs INTE till i lagret. Naringsdeklaration-knappen tas bort som egen ingang - flodet vavs in i streckkodsskanningen. product_package/nutrition_label -> READ_NUTRITION_LABEL avslutar som 'completed' med result.product (ingen awaiting_confirmation). - validation: barcode-falt i createScanInput.context - worker: ny gren i processScanJob som skriver till products + returnerar tidigt - mobil: inline contributePhoto (fota -> ladda upp -> polla -> visa info) - scan: ta bort naringsdeklaration-knappen - i18n: barcode.contributing.* + uppdaterad unknownBody pa 12 sprak Verifierat: typecheck (validation/worker/api/mobil), worker 23/23, api 104/104, och repro mot testdatabas (products-upsert, ingen inventory, idempotent aterskann). --- apps/mobile/src/app/(tabs)/scan.tsx | 5 +- apps/mobile/src/app/barcode.tsx | 105 +++++++++++++++++++++++-- apps/mobile/src/locales/da/common.json | 5 +- apps/mobile/src/locales/de/common.json | 5 +- apps/mobile/src/locales/en/common.json | 5 +- apps/mobile/src/locales/es/common.json | 5 +- apps/mobile/src/locales/fi/common.json | 5 +- apps/mobile/src/locales/fr/common.json | 5 +- apps/mobile/src/locales/it/common.json | 5 +- apps/mobile/src/locales/nb/common.json | 5 +- apps/mobile/src/locales/nl/common.json | 5 +- apps/mobile/src/locales/pl/common.json | 5 +- apps/mobile/src/locales/pt/common.json | 5 +- apps/mobile/src/locales/sv/common.json | 5 +- apps/worker/src/processors/scans.ts | 103 +++++++++++++++++++++++- packages/validation/src/scans.ts | 5 ++ 16 files changed, 256 insertions(+), 22 deletions(-) diff --git a/apps/mobile/src/app/(tabs)/scan.tsx b/apps/mobile/src/app/(tabs)/scan.tsx index 36bb053..6c7e959 100644 --- a/apps/mobile/src/app/(tabs)/scan.tsx +++ b/apps/mobile/src/app/(tabs)/scan.tsx @@ -21,7 +21,9 @@ import { useAnalytics } from "@/lib/analytics"; import { scanStarted } from "@app/analytics"; import { FirstScanCoach, useFirstScanCoach } from "@/components/FirstScanCoach"; -/** Skanna (spec §4.2): kyl, frys, skafferi, ingredienser, tallrik, kvitto, streckkod, datum, näringsdeklaration. */ +/** Skanna (spec §4.2): kyl, frys, skafferi, ingredienser, tallrik, kvitto, streckkod, datum. + * Näringsdeklaration är INGEN egen knapp – den vävs in i streckkodsflödet: okänd kod → fota + * förpackningen → produkten läses av och sparas i den delade katalogen (se barcode.tsx). */ const SCAN_TYPES = [ { type: "fridge", glyph: "🧊", labelKey: "scan.fridge" }, @@ -32,7 +34,6 @@ const SCAN_TYPES = [ { type: "receipt", glyph: "🧾", labelKey: "scan.receipt" }, { type: "barcode", glyph: "🏷️", labelKey: "scan.barcode" }, { type: "expiry_date", glyph: "📅", labelKey: "scan.expiry" }, - { type: "nutrition_label", glyph: "🔬", labelKey: "scan.nutrition" }, ] as const; /** diff --git a/apps/mobile/src/app/barcode.tsx b/apps/mobile/src/app/barcode.tsx index 04debb4..c1af1a5 100644 --- a/apps/mobile/src/app/barcode.tsx +++ b/apps/mobile/src/app/barcode.tsx @@ -1,9 +1,10 @@ import { useRef, useState } from "react"; import { ScrollView, StyleSheet, View } from "react-native"; import { router } from "expo-router"; +import * as ImagePicker from "expo-image-picker"; import { CameraView, useCameraPermissions } from "expo-camera"; import { ALLERGEN_LABELS_SV, type Allergen } from "@app/shared-types"; -import { api } from "@/lib/api"; +import { api, uploadImage, ApiError } from "@/lib/api"; import { t } from "@/lib/i18n"; import { Body, Button, Card, Heading, Row, Screen, Small, Spacer } from "@/components/ui"; import { colors, spacing } from "@/lib/theme"; @@ -48,6 +49,30 @@ interface ProductInfo { interface BarcodeScanResponse { product: ProductInfo | null; } +interface CreateScanResponse { + scan: { id: string }; + uploads: Array<{ key: string; uploadUrl: string; headers: Record }>; +} +interface ScanJobResponse { + status: string; + result: { product: ProductInfo | null } | null; + error?: string | null; +} + +/** + * Pollar en produktskanning tills AAMOS läst av förpackningen. Vid "completed" + * returneras produkten (redan sparad i delade katalogen av workern), vid + * "failed"/timeout returneras null. Max ~60 s (40 × 1,5 s). + */ +async function pollScan(id: string): Promise { + for (let i = 0; i < 40; i++) { + await new Promise((r) => setTimeout(r, 1500)); + const job = await api(`/v1/scans/${id}`); + if (job.status === "completed") return job.result?.product ?? null; + if (job.status === "failed") return null; + } + return null; +} const fmtG = (n: number) => `${(Math.round(n * 10) / 10).toString().replace(".", ",")} g`; const allergenLabels = (codes: string[] | null | undefined): string => @@ -61,15 +86,19 @@ export default function BarcodeScreen() { const [busy, setBusy] = useState(false); const [product, setProduct] = useState(null); const [unknown, setUnknown] = useState(false); + const [contributing, setContributing] = useState(false); const [errorMsg, setErrorMsg] = useState(null); // Synkron lås: hindrar att kamerans upprepade träffar startar parallella uppslag. const locked = useRef(false); + // Senast avlästa streckkod – används när användaren fotar en okänd produkt. + const lastGtin = useRef(null); const scanning = !busy && !product && !unknown && !errorMsg; const onScanned = async (gtin: string) => { if (locked.current) return; locked.current = true; + lastGtin.current = gtin; setBusy(true); setErrorMsg(null); try { @@ -86,11 +115,66 @@ export default function BarcodeScreen() { } }; + /** + * Okänd produkt → fota förpackningen. Bilden läses av (näring/allergener/ + * innehåll) och produkten sparas i den DELADE katalogen kopplat till + * streckkoden – så nästa person som skannar samma kod får den direkt. + * Lägger INTE till något bland användarens egna varor (spec §11). + */ + const contributePhoto = async () => { + const gtin = lastGtin.current; + if (!gtin) return; + try { + // Fota förpackningen (kamera, med galleri-fallback som i skanningsflödet). + const perm = await ImagePicker.requestCameraPermissionsAsync(); + const picked = perm.granted + ? await ImagePicker.launchCameraAsync({ quality: 0.7 }) + : await ImagePicker.launchImageLibraryAsync({ quality: 0.7 }); + const asset = picked.assets?.[0]; + if (picked.canceled || !asset) return; // avbröt – stanna kvar i "okänd"-vyn + + setContributing(true); + setErrorMsg(null); + + // Produktskanning kopplad till streckkoden – delad katalog, inte lagret. + const created = await api("/v1/scans", { + method: "POST", + body: { + scanType: "product_package", + imageCount: 1, + contentType: "image/jpeg", + context: { barcode: gtin }, + }, + }); + const upload = created.uploads[0]; + if (!upload) throw new Error(t("common.error")); + await uploadImage(upload, asset.uri); + await api(`/v1/scans/${created.scan.id}/start`, { method: "POST" }); + + const found = await pollScan(created.scan.id); + if (found) { + setUnknown(false); + setProduct(found); + } else { + setErrorMsg(t("barcode.contributing.failed")); + } + } catch (err) { + if (err instanceof ApiError && err.code === "QUOTA_EXCEEDED") { + router.push("/paywall"); + } else { + setErrorMsg(err instanceof Error ? err.message : t("common.error")); + } + } finally { + setContributing(false); + } + }; + const scanAgain = () => { setProduct(null); setUnknown(false); setErrorMsg(null); locked.current = false; + lastGtin.current = null; }; if (!permission) return {t("scan.analyzing")}; @@ -203,23 +287,30 @@ export default function BarcodeScreen() { )} - {/* Okänd produkt */} - {unknown && ( + {/* Okänd produkt → erbjud att fota förpackningen (bidra till delade katalogen) */} + {unknown && !contributing && !errorMsg && ( {t("barcode.unknownTitle")} {t("barcode.unknownBody")} -