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" },
|
{ type: "nutrition_label", glyph: "🔬", labelKey: "scan.nutrition" },
|
||||||
] as const;
|
] 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 {
|
interface CreateScanResponse {
|
||||||
scan: { id: string };
|
scan: { id: string };
|
||||||
uploads: Array<{ key: string; uploadUrl: string; headers: Record<string, string> }>;
|
uploads: Array<{ key: string; uploadUrl: string; headers: Record<string, string> }>;
|
||||||
@@ -49,7 +115,7 @@ export default function ScanScreen() {
|
|||||||
const coach = useFirstScanCoach();
|
const coach = useFirstScanCoach();
|
||||||
const { track } = useAnalytics();
|
const { track } = useAnalytics();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const entitlements = useQuery({
|
const entitlements = useQuery({
|
||||||
queryKey: ["entitlements"],
|
queryKey: ["entitlements"],
|
||||||
queryFn: () => api<Entitlements>("/v1/me/entitlements"),
|
queryFn: () => api<Entitlements>("/v1/me/entitlements"),
|
||||||
});
|
});
|
||||||
@@ -64,41 +130,39 @@ export default function ScanScreen() {
|
|||||||
router.push("/barcode");
|
router.push("/barcode");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setBusyType(scanType);
|
|
||||||
try {
|
try {
|
||||||
// 1. Ta bild
|
// 1. Ta bild(er) – flera vinklar tillåtna för kyl/frys/skafferi/ingredienser.
|
||||||
const permission = await ImagePicker.requestCameraPermissionsAsync();
|
const multi = MULTI_PHOTO_TYPES.has(scanType);
|
||||||
let picked: ImagePicker.ImagePickerResult;
|
const uris = await capturePhotos(multi);
|
||||||
if (permission.granted) {
|
if (uris.length === 0) return; // avbruten
|
||||||
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;
|
|
||||||
|
|
||||||
// 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", {
|
const created = await api<CreateScanResponse>("/v1/scans", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: { scanType, imageCount: 1, contentType: "image/jpeg" },
|
body: { scanType, imageCount: uris.length, contentType: "image/jpeg" },
|
||||||
});
|
});
|
||||||
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["entitlements"] });
|
queryClient.invalidateQueries({ queryKey: ["entitlements"] });
|
||||||
|
|
||||||
// 3. Ladda upp + starta analysen
|
// 3. Ladda upp alla bilder, starta sedan analysen.
|
||||||
const upload = created.uploads[0];
|
for (let i = 0; i < uris.length; i++) {
|
||||||
if (!upload) throw new Error(t("common.error"));
|
const upload = created.uploads[i];
|
||||||
await uploadImage(upload, asset.uri);
|
if (!upload) throw new Error(t("common.error"));
|
||||||
|
await uploadImage(upload, uris[i]!);
|
||||||
|
}
|
||||||
await api(`/v1/scans/${created.scan.id}/start`, { method: "POST" });
|
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}`);
|
router.push(`${scanType === "plate" ? "/meal-review" : "/scan-review"}/${created.scan.id}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ApiError && err.code === "QUOTA_EXCEEDED") {
|
if (err instanceof ApiError && err.code === "QUOTA_EXCEEDED") {
|
||||||
router.push("/paywall");
|
router.push("/paywall");
|
||||||
} else {
|
} else {
|
||||||
Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error"));
|
Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error"));
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setBusyType(null);
|
setBusyType(null);
|
||||||
}
|
}
|
||||||
@@ -138,6 +202,7 @@ export default function ScanScreen() {
|
|||||||
<Small>• {t("scan.tips.shelf")}</Small>
|
<Small>• {t("scan.tips.shelf")}</Small>
|
||||||
<Small>• {t("scan.tips.light")}</Small>
|
<Small>• {t("scan.tips.light")}</Small>
|
||||||
<Small>• {t("scan.tips.move")}</Small>
|
<Small>• {t("scan.tips.move")}</Small>
|
||||||
|
<Small>• Kyl/frys/skafferi: ta flera bilder i samma skanning – appen slår ihop dubbletter.</Small>
|
||||||
</Card>
|
</Card>
|
||||||
</Screen>
|
</Screen>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user