feat(skann): flera bilder i EN skanning for kyl/frys/skafferi/ingredienser

Mobilen hardkodade imageCount:1. Nu kan man ta flera vinklar av samma plats i
en och samma skanning (kamera: ta en till mellan varje; galleri: flerval, tak 6
= schemats max). Worker skickade redan alla bilder till modellen tillsammans och
prompten sager "lista varje vara en gang", sa modellen dedupar over bilderna;
scan-review-markeringen fangar residual. En skanning = en kvotenhet oavsett
antal bilder. Enkel-typer (tallrik/kvitto/datum) tar fortf. en bild.
This commit is contained in:
Claude
2026-08-18 20:25:49 +00:00
parent ee5eb32e09
commit 3bb1d309bc
+89 -24
View File
@@ -35,6 +35,72 @@ const SCAN_TYPES = [
{ type: "nutrition_label", glyph: "🔬", labelKey: "scan.nutrition" },
] as const;
/**
* Platser där flera bilder i EN skanning hjälper: modellen ser hela hyllan från
* flera vinklar och listar varje vara en gång (dedup över bilder, spec §9).
* Enkel-typer (tallrik/kvitto/datum/näringsetikett) tar en bild.
*/
const MULTI_PHOTO_TYPES = new Set(["fridge", "freezer", "pantry", "ingredients"]);
const MAX_PHOTOS = 6; // matchar createScanInputSchema.imageCount.max
/** Fråga om användaren vill ta fler bilder i samma skanning. */
function askAddMore(count: number): Promise<boolean> {
return new Promise((resolve) => {
if (count >= MAX_PHOTOS) {
Alert.alert("Klart", `Max ${MAX_PHOTOS} bilder analyserar.`, [
{ text: "OK", onPress: () => resolve(false) },
]);
return;
}
Alert.alert(
`Bild ${count} tagen`,
"Ta gärna fler vinklar av samma plats appen listar varje vara en gång.",
[
{ text: "Ta en till", onPress: () => resolve(true) },
{ text: `Analysera (${count})`, style: "default", onPress: () => resolve(false) },
],
);
});
}
/** Fånga en eller flera bilder. Kamera → loop med "ta en till"; galleri → flerval. */
async function capturePhotos(multi: boolean): Promise<string[]> {
const permission = await ImagePicker.requestCameraPermissionsAsync();
const useCamera = permission.granted;
const uris: string[] = [];
if (!multi) {
const picked = useCamera
? await ImagePicker.launchCameraAsync({ quality: 0.7 })
: await ImagePicker.launchImageLibraryAsync({ quality: 0.7 });
const asset = picked.assets?.[0];
if (!picked.canceled && asset) uris.push(asset.uri);
return uris;
}
if (!useCamera) {
// Utan kameratillstånd: låt användaren välja flera bilder ur galleriet på en gång.
const picked = await ImagePicker.launchImageLibraryAsync({
quality: 0.7,
allowsMultipleSelection: true,
selectionLimit: MAX_PHOTOS,
});
if (!picked.canceled) for (const a of picked.assets ?? []) uris.push(a.uri);
return uris;
}
// Kamera: ta en bild i taget, fråga om fler mellan varje.
while (uris.length < MAX_PHOTOS) {
const picked = await ImagePicker.launchCameraAsync({ quality: 0.7 });
const asset = picked.assets?.[0];
if (picked.canceled || !asset) break;
uris.push(asset.uri);
const more = await askAddMore(uris.length);
if (!more) break;
}
return uris;
}
interface CreateScanResponse {
scan: { id: string };
uploads: Array<{ key: string; uploadUrl: string; headers: Record<string, string> }>;
@@ -49,7 +115,7 @@ export default function ScanScreen() {
const coach = useFirstScanCoach();
const { track } = useAnalytics();
const queryClient = useQueryClient();
const entitlements = useQuery({
const entitlements = useQuery({
queryKey: ["entitlements"],
queryFn: () => api<Entitlements>("/v1/me/entitlements"),
});
@@ -64,41 +130,39 @@ export default function ScanScreen() {
router.push("/barcode");
return;
}
setBusyType(scanType);
try {
// 1. Ta bild
const permission = await ImagePicker.requestCameraPermissionsAsync();
let picked: ImagePicker.ImagePickerResult;
if (permission.granted) {
picked = await ImagePicker.launchCameraAsync({ quality: 0.7 });
} else {
picked = await ImagePicker.launchImageLibraryAsync({ quality: 0.7 });
}
const asset = picked.assets?.[0];
if (picked.canceled || !asset) return;
// 1. Ta bild(er) flera vinklar tillåtna för kyl/frys/skafferi/ingredienser.
const multi = MULTI_PHOTO_TYPES.has(scanType);
const uris = await capturePhotos(multi);
if (uris.length === 0) return; // avbruten
// 2. Skapa jobb + presignad upload (kvotkontroll sker i API:t)
// Visa laddning först nu (kamera/dialoger är egna vyer).
setBusyType(scanType);
// 2. Skapa jobb + presignade uploads (en per bild). Kvotkontroll i API:t;
// en skanning = en kvotenhet oavsett antal bilder.
const created = await api<CreateScanResponse>("/v1/scans", {
method: "POST",
body: { scanType, imageCount: 1, contentType: "image/jpeg" },
body: { scanType, imageCount: uris.length, contentType: "image/jpeg" },
});
queryClient.invalidateQueries({ queryKey: ["entitlements"] });
// 3. Ladda upp + starta analysen
const upload = created.uploads[0];
if (!upload) throw new Error(t("common.error"));
await uploadImage(upload, asset.uri);
// 3. Ladda upp alla bilder, starta sedan analysen.
for (let i = 0; i < uris.length; i++) {
const upload = created.uploads[i];
if (!upload) throw new Error(t("common.error"));
await uploadImage(upload, uris[i]!);
}
await api(`/v1/scans/${created.scan.id}/start`, { method: "POST" });
// 4. Vidare till granskningsvyn som pollar tills resultatet kommer
// 4. Vidare till granskningsvyn som pollar tills resultatet kommer.
router.push(`${scanType === "plate" ? "/meal-review" : "/scan-review"}/${created.scan.id}`);
} catch (err) {
if (err instanceof ApiError && err.code === "QUOTA_EXCEEDED") {
router.push("/paywall");
} else {
Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error"));
}
router.push("/paywall");
} else {
Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error"));
}
} finally {
setBusyType(null);
}
@@ -138,6 +202,7 @@ export default function ScanScreen() {
<Small> {t("scan.tips.shelf")}</Small>
<Small> {t("scan.tips.light")}</Small>
<Small> {t("scan.tips.move")}</Small>
<Small> Kyl/frys/skafferi: ta flera bilder i samma skanning appen slår ihop dubbletter.</Small>
</Card>
</Screen>
);