269 lines
9.0 KiB
TypeScript
269 lines
9.0 KiB
TypeScript
import { useEffect, useState } from "react";
|
||
import { Alert, View } from "react-native";
|
||
import { router, useLocalSearchParams } from "expo-router";
|
||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import { api } from "@/lib/api";
|
||
import { t } from "@/lib/i18n";
|
||
import {
|
||
Body,
|
||
Button,
|
||
Card,
|
||
ErrorView,
|
||
Heading,
|
||
Input,
|
||
LoadingView,
|
||
Row,
|
||
Screen,
|
||
Small,
|
||
Spacer,
|
||
Tag,
|
||
} from "@/components/ui";
|
||
import { spacing } from "@/lib/theme";
|
||
import { useAnalytics } from "@/lib/analytics";
|
||
import { scanReviewOpened } from "@app/analytics";
|
||
import { parseUnitInput, unitLabel } from "@/lib/units";
|
||
|
||
/**
|
||
* Granska AI-resultat (spec §10, §61.4–5): användaren godkänner, ändrar,
|
||
* tar bort och lägger till INNAN något skrivs till Food Twin.
|
||
* Osäkra rader flaggas tydligt. Ändringar blir ai_corrections → AAMOS-träning.
|
||
*/
|
||
|
||
interface DetectedItem {
|
||
tempId?: string;
|
||
detectedName: string;
|
||
canonicalIngredientId: string | null;
|
||
brand: string | null;
|
||
estimatedQuantity: number | null;
|
||
unit: string | null;
|
||
bestBeforeDate: string | null;
|
||
confidence: number;
|
||
requiresConfirmation: boolean;
|
||
}
|
||
interface ScanJob {
|
||
id: string;
|
||
status: string;
|
||
scanType: string;
|
||
error: string | null;
|
||
result: { items?: DetectedItem[] } | null;
|
||
}
|
||
|
||
interface EditableItem {
|
||
tempId: string;
|
||
original: DetectedItem | null;
|
||
name: string;
|
||
quantity: string;
|
||
unit: string;
|
||
canonicalIngredientId: string | null;
|
||
date: string;
|
||
/**
|
||
* Datumtyp (spec §13): "Bäst före" är en KVALITETSgräns (varan kan vara god
|
||
* längre – lukta och smaka), "Sista förbrukningsdag" en SÄKERHETSgräns.
|
||
* Användaren väljer typ så att appen aldrig dömer mat i onödan – och aldrig
|
||
* mjukar upp en riktig säkerhetsgräns.
|
||
*/
|
||
dateIsUseBy: boolean;
|
||
confidence: number;
|
||
rejected: boolean;
|
||
}
|
||
|
||
export default function ScanReviewScreen() {
|
||
const { jobId } = useLocalSearchParams<{ jobId: string }>();
|
||
const queryClient = useQueryClient();
|
||
const [items, setItems] = useState<EditableItem[] | null>(null);
|
||
const [busy, setBusy] = useState(false);
|
||
const { track } = useAnalytics();
|
||
|
||
useEffect(() => {
|
||
if (jobId) track(scanReviewOpened({ properties: { jobId } }));
|
||
}, [jobId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||
|
||
const query = useQuery({
|
||
queryKey: ["scan", jobId],
|
||
queryFn: () => api<ScanJob>(`/v1/scans/${jobId}`),
|
||
refetchInterval: (q) => {
|
||
const status = q.state.data?.status;
|
||
return status === "queued" || status === "running" ? 1500 : false;
|
||
},
|
||
});
|
||
|
||
const job = query.data;
|
||
|
||
// Initiera redigerbara rader när resultatet landar
|
||
useEffect(() => {
|
||
if (job?.status === "awaiting_confirmation" && job.result?.items && items == null) {
|
||
setItems(
|
||
job.result.items.map((item, index) => ({
|
||
tempId: item.tempId ?? `item-${index}`,
|
||
original: item,
|
||
name: item.detectedName,
|
||
quantity: item.estimatedQuantity != null ? String(item.estimatedQuantity) : "1",
|
||
unit: unitLabel(item.unit ?? "COUNT"),
|
||
canonicalIngredientId: item.canonicalIngredientId,
|
||
date: item.bestBeforeDate ?? "",
|
||
dateIsUseBy: false,
|
||
confidence: item.confidence,
|
||
rejected: false,
|
||
})),
|
||
);
|
||
}
|
||
}, [job, items]);
|
||
|
||
const update = (tempId: string, patch: Partial<EditableItem>) =>
|
||
setItems((prev) => prev?.map((i) => (i.tempId === tempId ? { ...i, ...patch } : i)) ?? null);
|
||
|
||
const addManual = () =>
|
||
setItems((prev) => [
|
||
...(prev ?? []),
|
||
{
|
||
tempId: `manual-${Date.now()}`,
|
||
original: null,
|
||
name: "",
|
||
quantity: "1",
|
||
unit: unitLabel("COUNT"),
|
||
canonicalIngredientId: null,
|
||
date: "",
|
||
dateIsUseBy: false,
|
||
confidence: 1,
|
||
rejected: false,
|
||
},
|
||
]);
|
||
|
||
const confirm = async () => {
|
||
if (!items) return;
|
||
setBusy(true);
|
||
try {
|
||
const payload = items
|
||
.filter((i) => i.rejected || i.name.trim().length > 0)
|
||
.map((i) => {
|
||
const edited =
|
||
i.original == null ||
|
||
i.name !== i.original.detectedName ||
|
||
Number(i.quantity) !== i.original.estimatedQuantity ||
|
||
i.unit !== (i.original.unit ?? "st");
|
||
return {
|
||
tempId: i.tempId,
|
||
action: i.rejected ? "reject" : i.original == null ? "add" : edited ? "edit" : "accept",
|
||
displayName: i.name.trim() || i.original?.detectedName || t("scan.review.unknownItem"),
|
||
canonicalIngredientId: i.canonicalIngredientId ?? undefined,
|
||
quantity: Math.max(0.01, Number(i.quantity) || 1),
|
||
unit: parseUnitInput(i.unit) ?? "COUNT",
|
||
// Rätt kolumn per datumtyp – motorn behandlar dem helt olika (spec §13).
|
||
bestBeforeDate: !i.dateIsUseBy && i.date ? i.date : undefined,
|
||
useByDate: i.dateIsUseBy && i.date ? i.date : undefined,
|
||
};
|
||
});
|
||
await api(`/v1/scans/${jobId}/confirm`, { method: "POST", body: { items: payload } });
|
||
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"));
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
};
|
||
|
||
if (query.isLoading || job?.status === "queued" || job?.status === "running") {
|
||
return (
|
||
<Screen scroll={false}>
|
||
<LoadingView
|
||
messages={[
|
||
t("scan.progress.analyzing"),
|
||
t("scan.progress.finding"),
|
||
t("scan.progress.matching"),
|
||
t("scan.progress.almost"),
|
||
]}
|
||
/>
|
||
<Body muted>{t("scan.analyzing")}</Body>
|
||
</Screen>
|
||
);
|
||
}
|
||
if (query.isError || !job) return <ErrorView onRetry={() => void query.refetch()} />;
|
||
if (job.status === "failed") {
|
||
return (
|
||
<Screen>
|
||
<ErrorView message={job.error ?? t("scan.failed")} onRetry={() => router.back()} />
|
||
</Screen>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<Screen>
|
||
<Heading>{t("scan.review.title")}</Heading>
|
||
<Small>{t("scan.review.subtitle")}</Small>
|
||
<Spacer size={spacing.sm} />
|
||
|
||
{(items ?? []).map((item) => (
|
||
<Card key={item.tempId} style={item.rejected ? { opacity: 0.4 } : undefined}>
|
||
<Row style={{ justifyContent: "space-between" }}>
|
||
{item.confidence < 0.7 && !item.rejected ? (
|
||
<Tag label={`⚠️ ${t("scan.review.uncertain")}`} tone="warning" />
|
||
) : (
|
||
<View />
|
||
)}
|
||
<Button
|
||
label={item.rejected ? t("common.undo") : t("common.remove")}
|
||
variant="ghost"
|
||
onPress={() => update(item.tempId, { rejected: !item.rejected })}
|
||
/>
|
||
</Row>
|
||
{!item.rejected && (
|
||
<>
|
||
<Input
|
||
value={item.name}
|
||
onChangeText={(v) => update(item.tempId, { name: v })}
|
||
placeholder={t("scan.review.itemPlaceholder")}
|
||
/>
|
||
<Row>
|
||
<View style={{ flex: 1 }}>
|
||
<Input
|
||
value={item.quantity}
|
||
keyboardType="numeric"
|
||
onChangeText={(v) => update(item.tempId, { quantity: v })}
|
||
placeholder={t("scan.review.quantityPlaceholder")}
|
||
/>
|
||
</View>
|
||
<View style={{ flex: 1 }}>
|
||
<Input
|
||
value={item.unit}
|
||
onChangeText={(v) => update(item.tempId, { unit: v })}
|
||
placeholder={t("scan.review.unitPlaceholder")}
|
||
/>
|
||
</View>
|
||
</Row>
|
||
{/* Datumtyp (spec §13): bäst före = kvalitet, sista förbrukningsdag = säkerhet. */}
|
||
<Row>
|
||
<Button
|
||
label={t("scan.review.dateKindBestBefore")}
|
||
variant={item.dateIsUseBy ? "ghost" : "secondary"}
|
||
onPress={() => update(item.tempId, { dateIsUseBy: false })}
|
||
/>
|
||
<Button
|
||
label={t("scan.review.dateKindUseBy")}
|
||
variant={item.dateIsUseBy ? "secondary" : "ghost"}
|
||
onPress={() => update(item.tempId, { dateIsUseBy: true })}
|
||
/>
|
||
</Row>
|
||
<Input
|
||
value={item.date}
|
||
onChangeText={(v) => update(item.tempId, { date: v })}
|
||
placeholder={t("scan.review.datePlaceholder")}
|
||
/>
|
||
{item.date.length > 0 && (
|
||
<Small>
|
||
{item.dateIsUseBy ? t("scan.review.useByNote") : t("scan.review.bestBeforeNote")}
|
||
</Small>
|
||
)}
|
||
</>
|
||
)}
|
||
</Card>
|
||
))}
|
||
|
||
<Button label={t("scan.review.addItem")} variant="ghost" onPress={addManual} />
|
||
<Spacer size={spacing.sm} />
|
||
<Button label={t("scan.review.approveAll")} onPress={() => void confirm()} loading={busy} />
|
||
</Screen>
|
||
);
|
||
}
|