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:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user