146 lines
4.6 KiB
TypeScript
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",
|
|
},
|
|
});
|