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:
+230
-73
@@ -1,100 +1,99 @@
|
|||||||
import { useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { Alert, StyleSheet, View } from "react-native";
|
import { ScrollView, StyleSheet, View } from "react-native";
|
||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
import { CameraView, useCameraPermissions } from "expo-camera";
|
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 { api } from "@/lib/api";
|
||||||
import { t } from "@/lib/i18n";
|
import { t } from "@/lib/i18n";
|
||||||
import { Body, Button, Card, Heading, LoadingView, Screen, Small, Spacer } from "@/components/ui";
|
import { Body, Button, Card, Heading, Row, Screen, Small, Spacer } from "@/components/ui";
|
||||||
import { colors } from "@/lib/theme";
|
import { colors, spacing } from "@/lib/theme";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Streckkodsskanning (spec §11): läses LOKALT med kameran, slås upp mot
|
* Streckkodsskanning (spec §11): läses LOKALT med kameran, slås upp mot
|
||||||
* egen produktdatabas → Open Food Facts. Saknas produkten uppmanas
|
* egen produktdatabas → Open Food Facts. Rent INFORMATIONSFLÖDE – visar
|
||||||
* användaren att fota förpackningen (READ_NUTRITION_LABEL-flödet).
|
* 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 {
|
type Nutrition = {
|
||||||
product: {
|
values: {
|
||||||
id: string;
|
kcal: number;
|
||||||
name: string;
|
proteinG: number;
|
||||||
brand: string | null;
|
carbsG: number;
|
||||||
nutrition: { values: { kcal: number } } | null;
|
fatG: number;
|
||||||
} | null;
|
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() {
|
export default function BarcodeScreen() {
|
||||||
const [permission, requestPermission] = useCameraPermissions();
|
const [permission, requestPermission] = useCameraPermissions();
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [lastCode, setLastCode] = useState<string | null>(null);
|
const [product, setProduct] = useState<ProductInfo | null>(null);
|
||||||
const queryClient = useQueryClient();
|
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) => {
|
const onScanned = async (gtin: string) => {
|
||||||
if (busy || gtin === lastCode) return;
|
if (locked.current) return;
|
||||||
|
locked.current = true;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setLastCode(gtin);
|
setErrorMsg(null);
|
||||||
try {
|
try {
|
||||||
const result = await api<BarcodeScanResponse>("/v1/scans", {
|
const result = await api<BarcodeScanResponse>("/v1/scans", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: { scanType: "barcode", barcode: gtin, imageCount: 0 },
|
body: { scanType: "barcode", barcode: gtin, imageCount: 0 },
|
||||||
});
|
});
|
||||||
if (result.product) {
|
if (result.product) setProduct(result.product);
|
||||||
Alert.alert(
|
else setUnknown(true);
|
||||||
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") },
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} 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);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const addToInventory = async (name: string) => {
|
const scanAgain = () => {
|
||||||
try {
|
setProduct(null);
|
||||||
const me = await api<{ activeHouseholdId: string | null }>("/v1/me");
|
setUnknown(false);
|
||||||
if (!me.activeHouseholdId) throw new Error(t("barcode.needHousehold"));
|
setErrorMsg(null);
|
||||||
const household = await api<{ storageLocations: Array<{ id: string; type: string }> }>(
|
locked.current = false;
|
||||||
`/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);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!permission) return <LoadingView />;
|
if (!permission) return <Screen><Body>{t("scan.analyzing")}</Body></Screen>;
|
||||||
if (!permission.granted) {
|
if (!permission.granted) {
|
||||||
return (
|
return (
|
||||||
<Screen>
|
<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 (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<CameraView
|
<CameraView
|
||||||
style={StyleSheet.absoluteFill}
|
style={StyleSheet.absoluteFill}
|
||||||
active={!busy}
|
active={scanning}
|
||||||
barcodeScannerSettings={{ barcodeTypes: ["ean13", "ean8", "upc_a", "upc_e"] }}
|
barcodeScannerSettings={{ barcodeTypes: ["ean13", "ean8", "upc_a", "upc_e"] }}
|
||||||
onBarcodeScanned={(scan) => void onScanned(scan.data)}
|
onBarcodeScanned={(scan) => void onScanned(scan.data)}
|
||||||
/>
|
/>
|
||||||
<View style={styles.overlay}>
|
|
||||||
<View style={styles.frame} />
|
{/* Siktruta medan man skannar */}
|
||||||
<Small>{busy ? t("scan.analyzing") : t("barcode.aim")}</Small>
|
{scanning && (
|
||||||
</View>
|
<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>
|
</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({
|
const styles = StyleSheet.create({
|
||||||
container: { flex: 1, backgroundColor: "#000" },
|
container: { flex: 1, backgroundColor: "#000" },
|
||||||
overlay: {
|
overlay: {
|
||||||
@@ -144,4 +274,31 @@ const styles = StyleSheet.create({
|
|||||||
borderRadius: 16,
|
borderRadius: 16,
|
||||||
backgroundColor: "transparent",
|
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" },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -447,5 +447,21 @@
|
|||||||
"scan.progress.analyzing": "Analyserar bilden…",
|
"scan.progress.analyzing": "Analyserar bilden…",
|
||||||
"scan.progress.finding": "Hittar ingredienser…",
|
"scan.progress.finding": "Hittar ingredienser…",
|
||||||
"scan.progress.matching": "Matchar mot ditt kök…",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user