feat(streckkod): visa produktinfo i stallet for lagg-till + fixa avbryt-buggen

QR/streckkod ar nu ett rent informationsflode: skanna for att SE vad
produkten innehaller. Panelen visar full naring per 100 g (energi,
fett/mattat, kolhydrat/socker, fiber, protein, salt), allergener,
'kan innehalla spar av', ingredienslista (Innehall) och kalla. Ingen
'lagg till'-knapp langre.

Buggfix: kameran triggar onBarcodeScanned manga ggr/sek och state-
flaggorna uppdaterades asynkront -> parallella uppslag staplade tva
Alert-rutor (okand forst, produkten bakom -> syntes forst efter Avbryt).
Nu synkront ref-las + panel i stallet for Alert.
This commit is contained in:
Claude
2026-08-19 21:50:37 +00:00
parent 24743fc58f
commit f45eb5724b
2 changed files with 247 additions and 74 deletions
+230 -73
View File
@@ -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<string | null>(null);
const queryClient = useQueryClient();
const [product, setProduct] = useState<ProductInfo | null>(null);
const [unknown, setUnknown] = 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);
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<BarcodeScanResponse>("/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 <LoadingView />;
if (!permission) return <Screen><Body>{t("scan.analyzing")}</Body></Screen>;
if (!permission.granted) {
return (
<Screen>
@@ -108,22 +107,153 @@ export default function BarcodeScreen() {
);
}
const n = product?.nutrition?.values;
const allergens = allergenLabels(product?.allergens);
const mayContain = allergenLabels(product?.mayContainAllergens);
return (
<View style={styles.container}>
<CameraView
style={StyleSheet.absoluteFill}
active={!busy}
active={scanning}
barcodeScannerSettings={{ barcodeTypes: ["ean13", "ean8", "upc_a", "upc_e"] }}
onBarcodeScanned={(scan) => void onScanned(scan.data)}
/>
<View style={styles.overlay}>
<View style={styles.frame} />
<Small>{busy ? t("scan.analyzing") : t("barcode.aim")}</Small>
</View>
{/* Siktruta medan man skannar */}
{scanning && (
<View style={styles.overlay}>
<View style={styles.frame} />
<Small style={styles.hint}>{t("barcode.aim")}</Small>
</View>
)}
{busy && (
<View style={styles.overlay}>
<Small style={styles.hint}>{t("scan.analyzing")}</Small>
</View>
)}
{/* Produktinfo-panel (nedre delen, scrollbar) */}
{product && (
<View style={styles.panel}>
<ScrollView contentContainerStyle={{ padding: spacing.lg, paddingBottom: spacing.md }}>
<Heading>{product.name}</Heading>
{(product.brand || product.packageSizeValue) && (
<Small style={styles.muted}>
{[
product.brand ?? undefined,
product.packageSizeValue
? `${product.packageSizeValue}${product.packageSizeUnit ? " " + product.packageSizeUnit : ""}`
: undefined,
]
.filter(Boolean)
.join(" · ")}
</Small>
)}
<Spacer size={spacing.sm} />
{n ? (
<View style={styles.nutBox}>
<Small style={styles.nutTitle}>{t("barcode.info.title")}</Small>
<NutRow label={t("barcode.info.kcal")} value={`${Math.round(n.kcal)} kcal`} strong />
<NutRow label={t("barcode.info.fat")} value={fmtG(n.fatG)} />
<NutRow label={t("barcode.info.satfat")} value={fmtG(n.saturatedFatG)} sub />
<NutRow label={t("barcode.info.carbs")} value={fmtG(n.carbsG)} />
<NutRow label={t("barcode.info.sugar")} value={fmtG(n.sugarG)} sub />
<NutRow label={t("barcode.info.fiber")} value={fmtG(n.fiberG)} />
<NutRow label={t("barcode.info.protein")} value={fmtG(n.proteinG)} />
<NutRow label={t("barcode.info.salt")} value={fmtG(n.saltG)} />
</View>
) : (
<Small style={styles.muted}>{t("barcode.info.noNutrition")}</Small>
)}
{allergens.length > 0 && (
<>
<Spacer size={spacing.sm} />
<Small style={styles.nutTitle}>{t("barcode.info.allergens")}</Small>
<Small>{allergens}</Small>
</>
)}
{mayContain.length > 0 && (
<Small style={styles.muted}>
{t("barcode.info.mayContain")}: {mayContain}
</Small>
)}
{product.ingredientsText && (
<>
<Spacer size={spacing.sm} />
<Small style={styles.nutTitle}>{t("barcode.info.ingredients")}</Small>
<Small style={styles.muted}>{product.ingredientsText}</Small>
</>
)}
<Spacer size={spacing.sm} />
<Small style={styles.source}>
{product.dataSource === "open_food_facts"
? t("barcode.info.sourceOff")
: t("barcode.info.sourceOwn")}
</Small>
</ScrollView>
<View style={styles.panelFooter}>
<Button label={t("barcode.info.scanAgain")} onPress={scanAgain} />
</View>
</View>
)}
{/* Okänd produkt */}
{unknown && (
<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")}
/>
<Spacer size={spacing.xs} />
<Button label={t("barcode.info.scanAgain")} variant="ghost" onPress={scanAgain} />
</View>
</View>
)}
{/* Fel */}
{errorMsg && (
<View style={styles.panel}>
<View style={{ padding: spacing.lg }}>
<Heading>{t("common.oops")}</Heading>
<Body muted>{errorMsg}</Body>
<Spacer size={spacing.md} />
<Button label={t("barcode.info.scanAgain")} onPress={scanAgain} />
</View>
</View>
)}
</View>
);
}
function NutRow({
label,
value,
strong = false,
sub = false,
}: {
label: string;
value: string;
strong?: boolean;
sub?: boolean;
}) {
return (
<Row style={[styles.nutRow, sub && styles.nutRowSub]}>
<Small style={sub ? styles.muted : undefined}>{label}</Small>
<Small style={strong ? styles.strong : undefined}>{value}</Small>
</Row>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: "#000" },
overlay: {
@@ -144,4 +274,31 @@ const styles = StyleSheet.create({
borderRadius: 16,
backgroundColor: "transparent",
},
hint: { color: "#fff" },
panel: {
position: "absolute",
left: 0,
right: 0,
bottom: 0,
maxHeight: "72%",
backgroundColor: colors.surface,
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
},
panelFooter: {
padding: spacing.md,
borderTopWidth: 1,
borderTopColor: colors.border,
},
nutBox: {
backgroundColor: colors.surfaceAlt,
borderRadius: 12,
padding: spacing.md,
},
nutTitle: { fontWeight: "700", marginBottom: spacing.xs, color: colors.textMuted },
nutRow: { justifyContent: "space-between", paddingVertical: 3 },
nutRowSub: { paddingLeft: spacing.md },
muted: { color: colors.textMuted },
strong: { fontWeight: "700" },
source: { color: colors.textMuted, fontStyle: "italic" },
});
+17 -1
View File
@@ -447,5 +447,21 @@
"scan.progress.analyzing": "Analyserar bilden…",
"scan.progress.finding": "Hittar ingredienser…",
"scan.progress.matching": "Matchar mot ditt kök…",
"scan.progress.almost": "Snart klar…"
"scan.progress.almost": "Snart klar…",
"barcode.info.title": "Näringsvärde per 100 g",
"barcode.info.kcal": "Energi",
"barcode.info.protein": "Protein",
"barcode.info.carbs": "Kolhydrater",
"barcode.info.sugar": " varav socker",
"barcode.info.fat": "Fett",
"barcode.info.satfat": " varav mättat",
"barcode.info.fiber": "Fiber",
"barcode.info.salt": "Salt",
"barcode.info.allergens": "Allergener",
"barcode.info.mayContain": "Kan innehålla spår av",
"barcode.info.ingredients": "Innehåll",
"barcode.info.noNutrition": "Ingen näringsinfo finns för den här produkten ännu.",
"barcode.info.scanAgain": "Skanna igen",
"barcode.info.sourceOwn": "Källa: egen produktdatabas",
"barcode.info.sourceOff": "Källa: Open Food Facts"
}