diff --git a/apps/mobile/src/app/barcode.tsx b/apps/mobile/src/app/barcode.tsx index bca0666..04debb4 100644 --- a/apps/mobile/src/app/barcode.tsx +++ b/apps/mobile/src/app/barcode.tsx @@ -1,100 +1,99 @@ -import { useState } from "react"; -import { Alert, StyleSheet, View } from "react-native"; +import { useRef, useState } from "react"; +import { ScrollView, StyleSheet, View } from "react-native"; import { router } from "expo-router"; import { CameraView, useCameraPermissions } from "expo-camera"; -import { useQueryClient } from "@tanstack/react-query"; +import { ALLERGEN_LABELS_SV, type Allergen } from "@app/shared-types"; import { api } from "@/lib/api"; import { t } from "@/lib/i18n"; -import { Body, Button, Card, Heading, LoadingView, Screen, Small, Spacer } from "@/components/ui"; -import { colors } from "@/lib/theme"; +import { Body, Button, Card, Heading, Row, Screen, Small, Spacer } from "@/components/ui"; +import { colors, spacing } from "@/lib/theme"; /** * Streckkodsskanning (spec §11): läses LOKALT med kameran, slås upp mot - * egen produktdatabas → Open Food Facts. Saknas produkten uppmanas - * användaren att fota förpackningen (READ_NUTRITION_LABEL-flödet). + * egen produktdatabas → Open Food Facts. Rent INFORMATIONSFLÖDE – visar + * vad produkten innehåller (näring, allergener, ingredienser). Ingen + * "lägg till"-funktion; man skannar för att se innehållet. + * + * Buggfix: kameran triggar onBarcodeScanned många gånger/sekund. Tidigare + * användes state-flaggor som uppdateras asynkront → flera parallella + * uppslag och staplade Alert-rutor ("okänd" först, produkten bakom – + * syntes först efter man tryckt Avbryt). Nu låser vi synkront med en ref + * och visar resultatet i en egen panel i stället för Alert. */ -interface BarcodeScanResponse { - product: { - id: string; - name: string; - brand: string | null; - nutrition: { values: { kcal: number } } | null; - } | null; +type Nutrition = { + values: { + kcal: number; + proteinG: number; + carbsG: number; + fatG: number; + saturatedFatG: number; + fiberG: number; + sugarG: number; + saltG: number; + }; +} | null; + +interface ProductInfo { + name: string; + brand: string | null; + packageSizeValue: number | null; + packageSizeUnit: string | null; + ingredientsText: string | null; + allergens: string[] | null; + mayContainAllergens: string[] | null; + nutrition: Nutrition; + dataSource: string | null; } +interface BarcodeScanResponse { + product: ProductInfo | null; +} + +const fmtG = (n: number) => `${(Math.round(n * 10) / 10).toString().replace(".", ",")} g`; +const allergenLabels = (codes: string[] | null | undefined): string => + (codes ?? []) + .map((c) => ALLERGEN_LABELS_SV[c as Allergen] ?? c) + .filter(Boolean) + .join(", "); export default function BarcodeScreen() { const [permission, requestPermission] = useCameraPermissions(); const [busy, setBusy] = useState(false); - const [lastCode, setLastCode] = useState(null); - const queryClient = useQueryClient(); + const [product, setProduct] = useState(null); + const [unknown, setUnknown] = useState(false); + const [errorMsg, setErrorMsg] = useState(null); + // Synkron lås: hindrar att kamerans upprepade träffar startar parallella uppslag. + const locked = useRef(false); + + const scanning = !busy && !product && !unknown && !errorMsg; const onScanned = async (gtin: string) => { - if (busy || gtin === lastCode) return; + if (locked.current) return; + locked.current = true; setBusy(true); - setLastCode(gtin); + setErrorMsg(null); try { const result = await api("/v1/scans", { method: "POST", body: { scanType: "barcode", barcode: gtin, imageCount: 0 }, }); - if (result.product) { - Alert.alert( - result.product.name, - `${result.product.brand ?? ""}\n${result.product.nutrition ? t("barcode.kcalPer100", { kcal: result.product.nutrition.values.kcal }) : t("barcode.noNutrition")}\n\n${t("barcode.addPrompt")}`, - [ - { text: t("common.cancel"), style: "cancel", onPress: () => setBusy(false) }, - { - text: t("common.add"), - onPress: () => { - void addToInventory(result.product!.name); - }, - }, - ], - ); - } else { - Alert.alert(t("barcode.unknownTitle"), t("barcode.unknownBody"), [ - { text: t("common.cancel"), style: "cancel", onPress: () => setBusy(false) }, - { text: t("barcode.photoPackage"), onPress: () => router.replace("/(tabs)/scan") }, - ]); - } + if (result.product) setProduct(result.product); + else setUnknown(true); } catch (err) { - Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error")); + setErrorMsg(err instanceof Error ? err.message : t("common.error")); + } finally { setBusy(false); } }; - const addToInventory = async (name: string) => { - try { - const me = await api<{ activeHouseholdId: string | null }>("/v1/me"); - if (!me.activeHouseholdId) throw new Error(t("barcode.needHousehold")); - const household = await api<{ storageLocations: Array<{ id: string; type: string }> }>( - `/v1/households/${me.activeHouseholdId}`, - ); - const fridge = - household.storageLocations.find((l) => l.type === "fridge") ?? - household.storageLocations[0]; - if (!fridge) throw new Error(t("barcode.noLocation")); - await api("/v1/inventory/items", { - method: "POST", - body: { - displayName: name, - quantity: 1, - unit: "COUNT", - storageLocationId: fridge.id, - source: "barcode", - }, - }); - await queryClient.invalidateQueries({ queryKey: ["inventory"] }); - await queryClient.invalidateQueries({ queryKey: ["what-to-eat"] }); - router.back(); - } catch (err) { - Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error")); - setBusy(false); - } + const scanAgain = () => { + setProduct(null); + setUnknown(false); + setErrorMsg(null); + locked.current = false; }; - if (!permission) return ; + if (!permission) return {t("scan.analyzing")}; if (!permission.granted) { return ( @@ -108,22 +107,153 @@ export default function BarcodeScreen() { ); } + const n = product?.nutrition?.values; + const allergens = allergenLabels(product?.allergens); + const mayContain = allergenLabels(product?.mayContainAllergens); + return ( void onScanned(scan.data)} /> - - - {busy ? t("scan.analyzing") : t("barcode.aim")} - + + {/* Siktruta medan man skannar */} + {scanning && ( + + + {t("barcode.aim")} + + )} + + {busy && ( + + {t("scan.analyzing")} + + )} + + {/* Produktinfo-panel (nedre delen, scrollbar) */} + {product && ( + + + {product.name} + {(product.brand || product.packageSizeValue) && ( + + {[ + product.brand ?? undefined, + product.packageSizeValue + ? `${product.packageSizeValue}${product.packageSizeUnit ? " " + product.packageSizeUnit : ""}` + : undefined, + ] + .filter(Boolean) + .join(" · ")} + + )} + + + {n ? ( + + {t("barcode.info.title")} + + + + + + + + + + ) : ( + {t("barcode.info.noNutrition")} + )} + + {allergens.length > 0 && ( + <> + + {t("barcode.info.allergens")} + {allergens} + + )} + {mayContain.length > 0 && ( + + {t("barcode.info.mayContain")}: {mayContain} + + )} + + {product.ingredientsText && ( + <> + + {t("barcode.info.ingredients")} + {product.ingredientsText} + + )} + + + + {product.dataSource === "open_food_facts" + ? t("barcode.info.sourceOff") + : t("barcode.info.sourceOwn")} + + + +