Files
Cibello-app/apps/mobile/src/app/barcode.tsx
T
2026-08-05 19:21:11 +07:00

146 lines
4.6 KiB
TypeScript

import { useState } from "react";
import { Alert, StyleSheet, View } from "react-native";
import { router } from "expo-router";
import { CameraView, useCameraPermissions } from "expo-camera";
import { useQueryClient } from "@tanstack/react-query";
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";
/**
* 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).
*/
interface BarcodeScanResponse {
product: {
id: string;
name: string;
brand: string | null;
nutrition: { values: { kcal: number } } | null;
} | null;
}
export default function BarcodeScreen() {
const [permission, requestPermission] = useCameraPermissions();
const [busy, setBusy] = useState(false);
const [lastCode, setLastCode] = useState<string | null>(null);
const queryClient = useQueryClient();
const onScanned = async (gtin: string) => {
if (busy || gtin === lastCode) return;
setBusy(true);
setLastCode(gtin);
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") },
]);
}
} catch (err) {
Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error"));
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"] });
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.granted) {
return (
<Screen>
<Card>
<Heading>{t("barcode.cameraTitle")}</Heading>
<Body>{t("barcode.cameraBody")}</Body>
<Spacer size={8} />
<Button label={t("barcode.allowCamera")} onPress={() => void requestPermission()} />
</Card>
</Screen>
);
}
return (
<View style={styles.container}>
<CameraView
style={StyleSheet.absoluteFill}
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>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: "#000" },
overlay: {
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
alignItems: "center",
justifyContent: "center",
gap: 16,
},
frame: {
width: 260,
height: 160,
borderWidth: 3,
borderColor: colors.primary,
borderRadius: 16,
backgroundColor: "transparent",
},
});