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).
This commit is contained in:
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<string, string> }>;
|
||||
}
|
||||
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<ProductInfo | null> {
|
||||
for (let i = 0; i < 40; i++) {
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
const job = await api<ScanJobResponse>(`/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<ProductInfo | null>(null);
|
||||
const [unknown, setUnknown] = useState(false);
|
||||
const [contributing, setContributing] = useState(false);
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(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<string | null>(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<CreateScanResponse>("/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 <Screen><Body>{t("scan.analyzing")}</Body></Screen>;
|
||||
@@ -203,23 +287,30 @@ export default function BarcodeScreen() {
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Okänd produkt */}
|
||||
{unknown && (
|
||||
{/* Okänd produkt → erbjud att fota förpackningen (bidra till delade katalogen) */}
|
||||
{unknown && !contributing && !errorMsg && (
|
||||
<View style={styles.panel}>
|
||||
<View style={{ padding: spacing.lg }}>
|
||||
<Heading>{t("barcode.unknownTitle")}</Heading>
|
||||
<Body muted>{t("barcode.unknownBody")}</Body>
|
||||
<Spacer size={spacing.md} />
|
||||
<Button
|
||||
label={t("barcode.photoPackage")}
|
||||
onPress={() => router.replace("/(tabs)/scan")}
|
||||
/>
|
||||
<Button label={t("barcode.photoPackage")} onPress={() => void contributePhoto()} />
|
||||
<Spacer size={spacing.xs} />
|
||||
<Button label={t("barcode.info.scanAgain")} variant="ghost" onPress={scanAgain} />
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Läser av fotad förpackning */}
|
||||
{contributing && (
|
||||
<View style={styles.panel}>
|
||||
<View style={{ padding: spacing.lg }}>
|
||||
<Heading>{t("barcode.contributing.title")}</Heading>
|
||||
<Body muted>{t("barcode.contributing.body")}</Body>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Fel */}
|
||||
{errorMsg && (
|
||||
<View style={styles.panel}>
|
||||
|
||||
Reference in New Issue
Block a user