fix(veckoplan+streckkod): byt ratt, planera om, variation, man-son + fota fram/bak
Veckoplan/kalender: - Byt ratt: PATCH satter nu titeln (och portioner/status) efter det nya receptet, inte bara recipeId - annars visade kalendern kvar gamla ratten. - Planera om veckan: ersatter nu veckans plan (raderar tidigare + entries via kaskad) sa man aldrig far dubbletter och alltid ser den nya planen. Nytt plan-id ger ocksa ny variation. - Variation: liten deterministisk jitter per recept, seedad pa plan-id + veckostart, sa olika veckor OCH omgenereringar blir olika. Tackning/utgang dominerar fortfarande. Skalas av varietyLevel. - Veckoplaneraren visar alltid mandag-sondag (mondayOf), oavsett dagens dag. Streckkod (fota okand produkt): - Fotar nu fram- OCH baksida (multi-foto) sa AI:n far bade namn (fram) och naring/innehall (bak). Prompt uppdaterad for flera bilder. - Worker uppdaterar en befintlig delad produkt aven utan nytt namn (behaller namnet, fyller pa naring/innehall/allergener) - och sparar ny produkt aven fran baksidesfoto (fallback-namn) sa data inte tappas. - Produktpanelen visar 'Fota baksidan (naring & innehall)' nar info saknas, sa man kan komplettera en halvfardig produkt. Fel visas som dialog utan att tappa vyn. - 5 nya i18n-nycklar x 12 sprak. Verifierat: typecheck (ai-contracts/worker/api/mobil), worker 23/23, ai-contracts 24/24, api 104/104, samt repro mot testdatabas (byt ratt satter titel; planera om ger 1 plan; 3 veckor + omgenerering varierar; delvis produkt kompletteras och behaller namnet).
This commit is contained in:
@@ -60,6 +60,14 @@ function addDays(d: Date, n: number): Date {
|
||||
copy.setDate(copy.getDate() + n);
|
||||
return copy;
|
||||
}
|
||||
/** Måndagen i veckan som d ligger i – veckoplaneraren visar alltid mån–sön. */
|
||||
function mondayOf(d: Date): Date {
|
||||
const copy = new Date(d);
|
||||
const dow = copy.getDay(); // 0=sön, 1=mån … 6=lör
|
||||
copy.setDate(copy.getDate() + (dow === 0 ? -6 : 1 - dow));
|
||||
copy.setHours(0, 0, 0, 0);
|
||||
return copy;
|
||||
}
|
||||
function cap(s: string): string {
|
||||
return s.length ? s[0]!.toUpperCase() + s.slice(1) : s;
|
||||
}
|
||||
@@ -82,9 +90,11 @@ export default function PlanScreen() {
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d;
|
||||
}, []);
|
||||
// Veckan börjar ALLTID på måndag (mån–sön), oavsett vilken dag det är idag.
|
||||
const thisMonday = useMemo(() => mondayOf(todayMidnight), [todayMidnight]);
|
||||
const weekStart = useMemo(
|
||||
() => addDays(todayMidnight, weekOffset * 7),
|
||||
[todayMidnight, weekOffset],
|
||||
() => addDays(thisMonday, weekOffset * 7),
|
||||
[thisMonday, weekOffset],
|
||||
);
|
||||
const weekStartKey = toKey(weekStart);
|
||||
const weekDays = useMemo(
|
||||
@@ -95,7 +105,8 @@ export default function PlanScreen() {
|
||||
const goWeek = (delta: number) =>
|
||||
setWeekOffset((o) => {
|
||||
const next = Math.max(0, o + delta);
|
||||
setSelectedKey(toKey(addDays(todayMidnight, next * 7)));
|
||||
// Denna vecka: markera idag. Kommande veckor: markera måndagen.
|
||||
setSelectedKey(next === 0 ? todayKey : toKey(addDays(thisMonday, next * 7)));
|
||||
return next;
|
||||
});
|
||||
// Lunch + middag varje dag som default (fler fyllda måltider), går att toggla.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { ScrollView, StyleSheet, View } from "react-native";
|
||||
import { Alert, ScrollView, StyleSheet, View } from "react-native";
|
||||
import { router } from "expo-router";
|
||||
import * as ImagePicker from "expo-image-picker";
|
||||
import { CameraView, useCameraPermissions } from "expo-camera";
|
||||
@@ -121,34 +121,57 @@ export default function BarcodeScreen() {
|
||||
* streckkoden – så nästa person som skannar samma kod får den direkt.
|
||||
* Lägger INTE till något bland användarens egna varor (spec §11).
|
||||
*/
|
||||
// Fota fram- OCH baksida: framsidan ger namn/varumärke, baksidan näring +
|
||||
// innehåll. AI:n slår ihop bilderna. Kamera med galleri-fallback.
|
||||
const capturePackagePhotos = async (): Promise<string[]> => {
|
||||
const perm = await ImagePicker.requestCameraPermissionsAsync();
|
||||
const shoot = () =>
|
||||
perm.granted
|
||||
? ImagePicker.launchCameraAsync({ quality: 0.7 })
|
||||
: ImagePicker.launchImageLibraryAsync({ quality: 0.7 });
|
||||
const uris: string[] = [];
|
||||
const first = await shoot();
|
||||
const a0 = first.assets?.[0];
|
||||
if (first.canceled || !a0) return uris;
|
||||
uris.push(a0.uri);
|
||||
const wantBack = await new Promise<boolean>((resolve) =>
|
||||
Alert.alert(t("barcode.contributing.frontDone"), t("barcode.contributing.backPrompt"), [
|
||||
{ text: t("barcode.contributing.skipBack"), style: "cancel", onPress: () => resolve(false) },
|
||||
{ text: t("barcode.contributing.shootBack"), onPress: () => resolve(true) },
|
||||
]),
|
||||
);
|
||||
if (wantBack) {
|
||||
const second = await shoot();
|
||||
const a1 = second.assets?.[0];
|
||||
if (!second.canceled && a1) uris.push(a1.uri);
|
||||
}
|
||||
return uris;
|
||||
};
|
||||
|
||||
const contributePhoto = async () => {
|
||||
const gtin = lastGtin.current;
|
||||
if (!gtin) return;
|
||||
try {
|
||||
// Fota förpackningen (kamera, med galleri-fallback som i skanningsflödet).
|
||||
const perm = await ImagePicker.requestCameraPermissionsAsync();
|
||||
const picked = perm.granted
|
||||
? await ImagePicker.launchCameraAsync({ quality: 0.7 })
|
||||
: await ImagePicker.launchImageLibraryAsync({ quality: 0.7 });
|
||||
const asset = picked.assets?.[0];
|
||||
if (picked.canceled || !asset) return; // avbröt – stanna kvar i "okänd"-vyn
|
||||
const uris = await capturePackagePhotos();
|
||||
if (uris.length === 0) return; // avbröt – stanna kvar i nuvarande vy
|
||||
|
||||
setContributing(true);
|
||||
setErrorMsg(null);
|
||||
|
||||
// Produktskanning kopplad till streckkoden – delad katalog, inte lagret.
|
||||
const created = await api<CreateScanResponse>("/v1/scans", {
|
||||
method: "POST",
|
||||
body: {
|
||||
scanType: "product_package",
|
||||
imageCount: 1,
|
||||
imageCount: uris.length,
|
||||
contentType: "image/jpeg",
|
||||
context: { barcode: gtin },
|
||||
},
|
||||
});
|
||||
const upload = created.uploads[0];
|
||||
if (!upload) throw new Error(t("common.error"));
|
||||
await uploadImage(upload, asset.uri);
|
||||
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" });
|
||||
|
||||
const found = await pollScan(created.scan.id);
|
||||
@@ -156,13 +179,14 @@ export default function BarcodeScreen() {
|
||||
setUnknown(false);
|
||||
setProduct(found);
|
||||
} else {
|
||||
setErrorMsg(t("barcode.contributing.failed"));
|
||||
// Behåll nuvarande vy (okänd/produkt) och visa felet som dialog.
|
||||
Alert.alert(t("common.oops"), t("barcode.contributing.failed"));
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.code === "QUOTA_EXCEEDED") {
|
||||
router.push("/paywall");
|
||||
} else {
|
||||
setErrorMsg(err instanceof Error ? err.message : t("common.error"));
|
||||
Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error"));
|
||||
}
|
||||
} finally {
|
||||
setContributing(false);
|
||||
@@ -194,6 +218,8 @@ export default function BarcodeScreen() {
|
||||
const n = product?.nutrition?.values;
|
||||
const allergens = allergenLabels(product?.allergens);
|
||||
const mayContain = allergenLabels(product?.mayContainAllergens);
|
||||
// Saknar produkten näring eller innehåll? Erbjud att komplettera med baksidesfoto.
|
||||
const incomplete = !!product && (!n || !product.ingredientsText);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
@@ -219,7 +245,7 @@ export default function BarcodeScreen() {
|
||||
)}
|
||||
|
||||
{/* Produktinfo-panel (nedre delen, scrollbar) */}
|
||||
{product && (
|
||||
{product && !contributing && (
|
||||
<View style={styles.panel}>
|
||||
<ScrollView contentContainerStyle={{ padding: spacing.lg, paddingBottom: spacing.md }}>
|
||||
<Heading>{product.name}</Heading>
|
||||
@@ -282,7 +308,15 @@ export default function BarcodeScreen() {
|
||||
</Small>
|
||||
</ScrollView>
|
||||
<View style={styles.panelFooter}>
|
||||
<Button label={t("barcode.info.scanAgain")} onPress={scanAgain} />
|
||||
{incomplete ? (
|
||||
<>
|
||||
<Button label={t("barcode.complete")} onPress={() => void contributePhoto()} />
|
||||
<Spacer size={spacing.xs} />
|
||||
<Button label={t("barcode.info.scanAgain")} variant="ghost" onPress={scanAgain} />
|
||||
</>
|
||||
) : (
|
||||
<Button label={t("barcode.info.scanAgain")} onPress={scanAgain} />
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -33,6 +33,11 @@
|
||||
"barcode.contributing.title": "Aflæser emballagen…",
|
||||
"barcode.contributing.body": "Vi finder næring, allergener og indhold – og gemmer produktet i det delte katalog, så den næste slipper for at fotografere det.",
|
||||
"barcode.contributing.failed": "Kunne ikke aflæse emballagen. Prøv igen med et skarpt billede og godt lys.",
|
||||
"barcode.contributing.frontDone": "Forside taget",
|
||||
"barcode.contributing.backPrompt": "Tag nu et billede af bagsiden med næringsdeklaration og indhold – så bliver produktet komplet.",
|
||||
"barcode.contributing.skipBack": "Færdig, analysér",
|
||||
"barcode.contributing.shootBack": "Tag billede af bagsiden",
|
||||
"barcode.complete": "Tag billede af bagsiden (næring & indhold)",
|
||||
"barcode.kcalPer100": "{kcal} kcal/100 g",
|
||||
"barcode.needHousehold": "Du skal først have en husstand.",
|
||||
"barcode.noLocation": "Ingen opbevaringssteder fundet.",
|
||||
|
||||
@@ -33,6 +33,11 @@
|
||||
"barcode.contributing.title": "Verpackung wird gelesen…",
|
||||
"barcode.contributing.body": "Wir ermitteln Nährwerte, Allergene und Zutaten – und speichern das Produkt im gemeinsamen Katalog, damit es der Nächste nicht fotografieren muss.",
|
||||
"barcode.contributing.failed": "Die Verpackung konnte nicht gelesen werden. Versuche es mit einem scharfen Foto und gutem Licht erneut.",
|
||||
"barcode.contributing.frontDone": "Vorderseite aufgenommen",
|
||||
"barcode.contributing.backPrompt": "Fotografiere jetzt die Rückseite mit Nährwerttabelle und Zutaten – so wird das Produkt vollständig.",
|
||||
"barcode.contributing.skipBack": "Fertig, analysieren",
|
||||
"barcode.contributing.shootBack": "Rückseite fotografieren",
|
||||
"barcode.complete": "Rückseite fotografieren (Nährwerte & Zutaten)",
|
||||
"barcode.kcalPer100": "{kcal} kcal/100 g",
|
||||
"barcode.needHousehold": "Du brauchst zuerst einen Haushalt.",
|
||||
"barcode.noLocation": "Kein Lagerort gefunden.",
|
||||
|
||||
@@ -33,6 +33,11 @@
|
||||
"barcode.contributing.title": "Reading the package…",
|
||||
"barcode.contributing.body": "We're extracting nutrition, allergens and ingredients – and saving the product to the shared catalogue so the next person doesn't have to photograph it.",
|
||||
"barcode.contributing.failed": "Couldn't read the package. Try again with a sharp photo and good lighting.",
|
||||
"barcode.contributing.frontDone": "Front photo taken",
|
||||
"barcode.contributing.backPrompt": "Now photograph the back with the nutrition label and ingredients – that completes the product.",
|
||||
"barcode.contributing.skipBack": "Done, analyse",
|
||||
"barcode.contributing.shootBack": "Photograph the back",
|
||||
"barcode.complete": "Photograph the back (nutrition & ingredients)",
|
||||
"barcode.kcalPer100": "{kcal} kcal/100 g",
|
||||
"barcode.needHousehold": "You need a household first.",
|
||||
"barcode.noLocation": "No storage location found.",
|
||||
|
||||
@@ -33,6 +33,11 @@
|
||||
"barcode.contributing.title": "Leyendo el envase…",
|
||||
"barcode.contributing.body": "Estamos extrayendo la información nutricional, los alérgenos y los ingredientes, y guardando el producto en el catálogo compartido para que la próxima persona no tenga que fotografiarlo.",
|
||||
"barcode.contributing.failed": "No se pudo leer el envase. Inténtalo de nuevo con una foto nítida y buena luz.",
|
||||
"barcode.contributing.frontDone": "Foto del frente tomada",
|
||||
"barcode.contributing.backPrompt": "Ahora fotografía la parte de atrás con la información nutricional y los ingredientes: así el producto queda completo.",
|
||||
"barcode.contributing.skipBack": "Listo, analizar",
|
||||
"barcode.contributing.shootBack": "Fotografiar la parte de atrás",
|
||||
"barcode.complete": "Fotografiar la parte de atrás (nutrición e ingredientes)",
|
||||
"barcode.kcalPer100": "{kcal} kcal/100 g",
|
||||
"barcode.needHousehold": "Primero necesitas un hogar.",
|
||||
"barcode.noLocation": "No se encontró ningún lugar de almacenamiento.",
|
||||
|
||||
@@ -33,6 +33,11 @@
|
||||
"barcode.contributing.title": "Luetaan pakkausta…",
|
||||
"barcode.contributing.body": "Poimimme ravintoarvot, allergeenit ja ainesosat – ja tallennamme tuotteen jaettuun luetteloon, jottei seuraavan tarvitse kuvata sitä.",
|
||||
"barcode.contributing.failed": "Pakkausta ei voitu lukea. Yritä uudelleen terävällä kuvalla ja hyvässä valossa.",
|
||||
"barcode.contributing.frontDone": "Etupuoli kuvattu",
|
||||
"barcode.contributing.backPrompt": "Kuvaa nyt takapuoli, jossa on ravintosisältö ja ainesosat – näin tuote täydentyy.",
|
||||
"barcode.contributing.skipBack": "Valmis, analysoi",
|
||||
"barcode.contributing.shootBack": "Kuvaa takapuoli",
|
||||
"barcode.complete": "Kuvaa takapuoli (ravinto & ainesosat)",
|
||||
"barcode.kcalPer100": "{kcal} kcal/100 g",
|
||||
"barcode.needHousehold": "Tarvitset ensin kotitalouden.",
|
||||
"barcode.noLocation": "Säilytyspaikkaa ei löytynyt.",
|
||||
|
||||
@@ -33,6 +33,11 @@
|
||||
"barcode.contributing.title": "Lecture de l'emballage…",
|
||||
"barcode.contributing.body": "Nous extrayons les valeurs nutritionnelles, les allergènes et les ingrédients, et enregistrons le produit dans le catalogue partagé pour que la prochaine personne n'ait pas à le photographier.",
|
||||
"barcode.contributing.failed": "Impossible de lire l'emballage. Réessayez avec une photo nette et un bon éclairage.",
|
||||
"barcode.contributing.frontDone": "Photo de l'avant prise",
|
||||
"barcode.contributing.backPrompt": "Photographiez maintenant l'arrière avec la déclaration nutritionnelle et les ingrédients – le produit sera ainsi complet.",
|
||||
"barcode.contributing.skipBack": "Terminé, analyser",
|
||||
"barcode.contributing.shootBack": "Photographier l'arrière",
|
||||
"barcode.complete": "Photographier l'arrière (nutrition et ingrédients)",
|
||||
"barcode.kcalPer100": "{kcal} kcal/100 g",
|
||||
"barcode.needHousehold": "Il vous faut d'abord un foyer.",
|
||||
"barcode.noLocation": "Aucun lieu de stockage trouvé.",
|
||||
|
||||
@@ -33,6 +33,11 @@
|
||||
"barcode.contributing.title": "Lettura della confezione…",
|
||||
"barcode.contributing.body": "Stiamo estraendo valori nutrizionali, allergeni e ingredienti e salvando il prodotto nel catalogo condiviso, così il prossimo non dovrà fotografarlo.",
|
||||
"barcode.contributing.failed": "Impossibile leggere la confezione. Riprova con una foto nitida e una buona illuminazione.",
|
||||
"barcode.contributing.frontDone": "Foto del fronte scattata",
|
||||
"barcode.contributing.backPrompt": "Ora fotografa il retro con la tabella nutrizionale e gli ingredienti: così il prodotto è completo.",
|
||||
"barcode.contributing.skipBack": "Fatto, analizza",
|
||||
"barcode.contributing.shootBack": "Fotografa il retro",
|
||||
"barcode.complete": "Fotografa il retro (valori e ingredienti)",
|
||||
"barcode.kcalPer100": "{kcal} kcal/100 g",
|
||||
"barcode.needHousehold": "Prima ti serve una famiglia.",
|
||||
"barcode.noLocation": "Nessun luogo di conservazione trovato.",
|
||||
|
||||
@@ -33,6 +33,11 @@
|
||||
"barcode.contributing.title": "Leser av emballasjen…",
|
||||
"barcode.contributing.body": "Vi henter næring, allergener og innhold – og lagrer produktet i den delte katalogen, så den neste slipper å fotografere det.",
|
||||
"barcode.contributing.failed": "Kunne ikke lese av emballasjen. Prøv igjen med et skarpt bilde og godt lys.",
|
||||
"barcode.contributing.frontDone": "Forside tatt",
|
||||
"barcode.contributing.backPrompt": "Ta nå bilde av baksiden med næringsinnhold og ingredienser – da blir produktet komplett.",
|
||||
"barcode.contributing.skipBack": "Ferdig, analyser",
|
||||
"barcode.contributing.shootBack": "Ta bilde av baksiden",
|
||||
"barcode.complete": "Ta bilde av baksiden (næring og innhold)",
|
||||
"barcode.kcalPer100": "{kcal} kcal/100 g",
|
||||
"barcode.needHousehold": "Du trenger en husstand først.",
|
||||
"barcode.noLocation": "Fant ingen oppbevaringssteder.",
|
||||
|
||||
@@ -33,6 +33,11 @@
|
||||
"barcode.contributing.title": "Verpakking wordt gelezen…",
|
||||
"barcode.contributing.body": "We halen voedingswaarde, allergenen en ingrediënten op – en slaan het product op in de gedeelde catalogus, zodat de volgende het niet hoeft te fotograferen.",
|
||||
"barcode.contributing.failed": "Kon de verpakking niet lezen. Probeer het opnieuw met een scherpe foto en goed licht.",
|
||||
"barcode.contributing.frontDone": "Voorkant gefotografeerd",
|
||||
"barcode.contributing.backPrompt": "Fotografeer nu de achterkant met de voedingswaarde en ingrediënten – dan is het product compleet.",
|
||||
"barcode.contributing.skipBack": "Klaar, analyseren",
|
||||
"barcode.contributing.shootBack": "Achterkant fotograferen",
|
||||
"barcode.complete": "Achterkant fotograferen (voeding & ingrediënten)",
|
||||
"barcode.kcalPer100": "{kcal} kcal/100 g",
|
||||
"barcode.needHousehold": "Je hebt eerst een huishouden nodig.",
|
||||
"barcode.noLocation": "Geen bewaarplek gevonden.",
|
||||
|
||||
@@ -33,6 +33,11 @@
|
||||
"barcode.contributing.title": "Odczytywanie opakowania…",
|
||||
"barcode.contributing.body": "Odczytujemy wartości odżywcze, alergeny i składniki – i zapisujemy produkt we wspólnym katalogu, aby następna osoba nie musiała go fotografować.",
|
||||
"barcode.contributing.failed": "Nie udało się odczytać opakowania. Spróbuj ponownie z ostrym zdjęciem i dobrym oświetleniem.",
|
||||
"barcode.contributing.frontDone": "Zrobiono zdjęcie przodu",
|
||||
"barcode.contributing.backPrompt": "Teraz sfotografuj tył z tabelą wartości odżywczych i składnikami – dzięki temu produkt będzie kompletny.",
|
||||
"barcode.contributing.skipBack": "Gotowe, analizuj",
|
||||
"barcode.contributing.shootBack": "Sfotografuj tył",
|
||||
"barcode.complete": "Sfotografuj tył (wartości i składniki)",
|
||||
"barcode.kcalPer100": "{kcal} kcal/100 g",
|
||||
"barcode.needHousehold": "Najpierw potrzebujesz gospodarstwa.",
|
||||
"barcode.noLocation": "Nie znaleziono miejsca przechowywania.",
|
||||
|
||||
@@ -33,6 +33,11 @@
|
||||
"barcode.contributing.title": "A ler a embalagem…",
|
||||
"barcode.contributing.body": "Estamos a extrair informação nutricional, alergénios e ingredientes – e a guardar o produto no catálogo partilhado, para que a próxima pessoa não precise de o fotografar.",
|
||||
"barcode.contributing.failed": "Não foi possível ler a embalagem. Tente novamente com uma foto nítida e boa iluminação.",
|
||||
"barcode.contributing.frontDone": "Foto da frente tirada",
|
||||
"barcode.contributing.backPrompt": "Agora fotografe a parte de trás com a declaração nutricional e os ingredientes – assim o produto fica completo.",
|
||||
"barcode.contributing.skipBack": "Pronto, analisar",
|
||||
"barcode.contributing.shootBack": "Fotografar a parte de trás",
|
||||
"barcode.complete": "Fotografar a parte de trás (nutrição e ingredientes)",
|
||||
"barcode.kcalPer100": "{kcal} kcal/100 g",
|
||||
"barcode.needHousehold": "Primeiro precisa de um agregado.",
|
||||
"barcode.noLocation": "Nenhum local de armazenamento encontrado.",
|
||||
|
||||
@@ -33,6 +33,11 @@
|
||||
"barcode.contributing.title": "Läser av förpackningen…",
|
||||
"barcode.contributing.body": "Vi tar fram näring, allergener och innehåll – och sparar produkten i den delade katalogen så nästa person slipper fota den.",
|
||||
"barcode.contributing.failed": "Kunde inte läsa av förpackningen. Försök igen med skarp bild och bra ljus.",
|
||||
"barcode.contributing.frontDone": "Framsida tagen",
|
||||
"barcode.contributing.backPrompt": "Fota nu baksidan med näringsdeklaration och innehåll – då blir produkten komplett.",
|
||||
"barcode.contributing.skipBack": "Klar, analysera",
|
||||
"barcode.contributing.shootBack": "Fota baksidan",
|
||||
"barcode.complete": "Fota baksidan (näring & innehåll)",
|
||||
"barcode.kcalPer100": "{kcal} kcal/100 g",
|
||||
"barcode.needHousehold": "Du behöver ett hushåll först.",
|
||||
"barcode.noLocation": "Ingen förvaringsplats hittades.",
|
||||
|
||||
Reference in New Issue
Block a user