diff --git a/apps/mobile/src/app/(tabs)/scan.tsx b/apps/mobile/src/app/(tabs)/scan.tsx index b6a8bf3..c3b9e52 100644 --- a/apps/mobile/src/app/(tabs)/scan.tsx +++ b/apps/mobile/src/app/(tabs)/scan.tsx @@ -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 { + 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 { + 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 }>; @@ -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("/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("/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() { • {t("scan.tips.shelf")} • {t("scan.tips.light")} • {t("scan.tips.move")} + • Kyl/frys/skafferi: ta flera bilder i samma skanning – appen slår ihop dubbletter. );