feat(mobile): tallriksfoto → meal-review + i18n (otestad, väntar Expo-test)
This commit is contained in:
@@ -89,7 +89,7 @@ export default function ScanScreen() {
|
|||||||
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(`/scan-review/${created.scan.id}`);
|
router.push(`${scanType === "plate" ? "/meal-review" : "/scan-review"}/${created.scan.id}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
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 {
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ export default function RootLayout() {
|
|||||||
options={{ title: "", presentation: "fullScreenModal" }}
|
options={{ title: "", presentation: "fullScreenModal" }}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen name="scan-review/[jobId]" options={{ title: t("scan.review.title") }} />
|
<Stack.Screen name="scan-review/[jobId]" options={{ title: t("scan.review.title") }} />
|
||||||
|
<Stack.Screen name="meal-review/[jobId]" options={{ title: t("scan.meal.title") }} />
|
||||||
<Stack.Screen name="shopping" options={{ title: t("shopping.title") }} />
|
<Stack.Screen name="shopping" options={{ title: t("shopping.title") }} />
|
||||||
<Stack.Screen name="meal-boxes" options={{ title: t("mealbox.title") }} />
|
<Stack.Screen name="meal-boxes" options={{ title: t("mealbox.title") }} />
|
||||||
<Stack.Screen name="household" options={{ title: t("home.household") }} />
|
<Stack.Screen name="household" options={{ title: t("home.household") }} />
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Alert } from "react-native";
|
||||||
|
import { router, useLocalSearchParams } from "expo-router";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { t } from "@/lib/i18n";
|
||||||
|
import {
|
||||||
|
Body, Button, Card, ErrorView, Heading, Input,
|
||||||
|
LoadingView, Row, Screen, Small, Spacer, Tag, Title,
|
||||||
|
} from "@/components/ui";
|
||||||
|
import { spacing } from "@/lib/theme";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Granska tallriksfoto (spec §22): AI:n uppskattar ett kalori-INTERVALL, aldrig
|
||||||
|
* exakt. Användaren väljer måltidstyp, justerar och loggar. Näringen sparas som
|
||||||
|
* uppskattning (nutritionIsEstimate) och markeras tydligt i "Min dag".
|
||||||
|
*/
|
||||||
|
const MEAL_TYPES = ["breakfast", "lunch", "dinner", "snack"] as const;
|
||||||
|
|
||||||
|
interface MealComponent { name: string; estimatedGrams: number | null; confidence: number }
|
||||||
|
interface ScanJob {
|
||||||
|
id: string; status: string; scanType: string; error: string | null;
|
||||||
|
result: {
|
||||||
|
kcalRange?: { min: number; max: number; mostLikely: number } | null;
|
||||||
|
components?: MealComponent[];
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MealReviewScreen() {
|
||||||
|
const { jobId } = useLocalSearchParams<{ jobId: string }>();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [mealType, setMealType] = useState<string>("lunch");
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: ["scan", jobId],
|
||||||
|
queryFn: () => api<ScanJob>(`/v1/scans/${jobId}`),
|
||||||
|
refetchInterval: (q) => {
|
||||||
|
const s = q.state.data?.status;
|
||||||
|
return s === "queued" || s === "running" ? 1500 : false;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const job = query.data;
|
||||||
|
const kcal = job?.result?.kcalRange ?? null;
|
||||||
|
const components = job?.result?.components ?? [];
|
||||||
|
|
||||||
|
const logMeal = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await api("/v1/meals", {
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
date: new Date().toISOString().slice(0, 10),
|
||||||
|
mealType,
|
||||||
|
source: "plate_photo",
|
||||||
|
titleSv: title.trim() || components[0]?.name || t("scan.plate"),
|
||||||
|
scanJobId: jobId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["day"] });
|
||||||
|
router.back();
|
||||||
|
} catch (err) {
|
||||||
|
Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error"));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (query.isLoading || job?.status === "queued" || job?.status === "running") {
|
||||||
|
return (
|
||||||
|
<Screen scroll={false}>
|
||||||
|
<LoadingView />
|
||||||
|
<Body muted>{t("scan.analyzing")}</Body>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (query.isError || !job) return <ErrorView onRetry={() => void query.refetch()} />;
|
||||||
|
if (job.status === "failed") {
|
||||||
|
return (
|
||||||
|
<Screen>
|
||||||
|
<ErrorView message={job.error ?? t("scan.failed")} onRetry={() => router.back()} />
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Screen>
|
||||||
|
<Heading>{t("scan.meal.title")}</Heading>
|
||||||
|
<Tag label={t("common.estimate")} tone="warning" />
|
||||||
|
<Small>{t("scan.meal.estimateNote")}</Small>
|
||||||
|
<Spacer size={spacing.sm} />
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Small>{t("scan.meal.calories")}</Small>
|
||||||
|
{kcal ? (
|
||||||
|
<>
|
||||||
|
<Title>≈ {Math.round(kcal.mostLikely)} kcal</Title>
|
||||||
|
<Small>{Math.round(kcal.min)}–{Math.round(kcal.max)} kcal</Small>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Body>{t("scan.meal.noEstimate")}</Body>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{components.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<Small>{t("scan.meal.components")}</Small>
|
||||||
|
{components.map((c, i) => (
|
||||||
|
<Row key={i} style={{ justifyContent: "space-between" }}>
|
||||||
|
<Body>{c.name}</Body>
|
||||||
|
{c.estimatedGrams != null && <Small>{Math.round(c.estimatedGrams)} g</Small>}
|
||||||
|
</Row>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Spacer size={spacing.sm} />
|
||||||
|
<Heading>{t("logmeal.mealTypeTitle")}</Heading>
|
||||||
|
<Row>
|
||||||
|
{MEAL_TYPES.map((id) => (
|
||||||
|
<Button
|
||||||
|
key={id}
|
||||||
|
label={t(`myday.mealType.${id}` as never)}
|
||||||
|
variant={mealType === id ? "secondary" : "ghost"}
|
||||||
|
onPress={() => setMealType(id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
<Input value={title} onChangeText={setTitle} placeholder={t("scan.meal.namePlaceholder")} />
|
||||||
|
|
||||||
|
<Spacer size={spacing.sm} />
|
||||||
|
{kcal && <Button label={t("myday.logMeal")} onPress={() => void logMeal()} loading={busy} />}
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -395,5 +395,11 @@
|
|||||||
"consent.termsLink": "Læs Brugervilkårene",
|
"consent.termsLink": "Læs Brugervilkårene",
|
||||||
"consent.privacyLink": "Læs Privatlivspolitikken",
|
"consent.privacyLink": "Læs Privatlivspolitikken",
|
||||||
"consent.checkbox": "Jeg har læst og accepterer Brugervilkårene og Privatlivspolitikken.",
|
"consent.checkbox": "Jeg har læst og accepterer Brugervilkårene og Privatlivspolitikken.",
|
||||||
"consent.submit": "Accepter og fortsæt"
|
"consent.submit": "Accepter og fortsæt",
|
||||||
|
"scan.meal.title": "Log måltid fra foto",
|
||||||
|
"scan.meal.estimateNote": "Estimeret ud fra dit foto – juster før du gemmer.",
|
||||||
|
"scan.meal.calories": "Estimerede kalorier",
|
||||||
|
"scan.meal.namePlaceholder": "Hvad spiste du? (valgfrit)",
|
||||||
|
"scan.meal.components": "Genkendt på tallerkenen",
|
||||||
|
"scan.meal.noEstimate": "Kunne ikke estimere kalorierne ud fra fotoet."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -395,5 +395,11 @@
|
|||||||
"consent.termsLink": "Nutzungsbedingungen lesen",
|
"consent.termsLink": "Nutzungsbedingungen lesen",
|
||||||
"consent.privacyLink": "Datenschutzerklärung lesen",
|
"consent.privacyLink": "Datenschutzerklärung lesen",
|
||||||
"consent.checkbox": "Ich habe die Nutzungsbedingungen und die Datenschutzerklärung gelesen und akzeptiere sie.",
|
"consent.checkbox": "Ich habe die Nutzungsbedingungen und die Datenschutzerklärung gelesen und akzeptiere sie.",
|
||||||
"consent.submit": "Akzeptieren und fortfahren"
|
"consent.submit": "Akzeptieren und fortfahren",
|
||||||
|
"scan.meal.title": "Mahlzeit vom Foto loggen",
|
||||||
|
"scan.meal.estimateNote": "Geschätzt aus deinem Foto – vor dem Speichern anpassen.",
|
||||||
|
"scan.meal.calories": "Geschätzte Kalorien",
|
||||||
|
"scan.meal.namePlaceholder": "Was hast du gegessen? (optional)",
|
||||||
|
"scan.meal.components": "Auf dem Teller erkannt",
|
||||||
|
"scan.meal.noEstimate": "Kalorien konnten nicht aus dem Foto geschätzt werden."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -395,5 +395,11 @@
|
|||||||
"consent.termsLink": "Read the Terms of Use",
|
"consent.termsLink": "Read the Terms of Use",
|
||||||
"consent.privacyLink": "Read the Privacy Policy",
|
"consent.privacyLink": "Read the Privacy Policy",
|
||||||
"consent.checkbox": "I have read and accept the Terms of Use and the Privacy Policy.",
|
"consent.checkbox": "I have read and accept the Terms of Use and the Privacy Policy.",
|
||||||
"consent.submit": "Accept and continue"
|
"consent.submit": "Accept and continue",
|
||||||
|
"scan.meal.title": "Log meal from photo",
|
||||||
|
"scan.meal.estimateNote": "Estimated from your photo – adjust before saving.",
|
||||||
|
"scan.meal.calories": "Estimated calories",
|
||||||
|
"scan.meal.namePlaceholder": "What did you eat? (optional)",
|
||||||
|
"scan.meal.components": "Recognised on the plate",
|
||||||
|
"scan.meal.noEstimate": "Couldn't estimate calories from the photo."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -395,5 +395,11 @@
|
|||||||
"consent.termsLink": "Leer los Términos de uso",
|
"consent.termsLink": "Leer los Términos de uso",
|
||||||
"consent.privacyLink": "Leer la Política de privacidad",
|
"consent.privacyLink": "Leer la Política de privacidad",
|
||||||
"consent.checkbox": "He leído y acepto los Términos de uso y la Política de privacidad.",
|
"consent.checkbox": "He leído y acepto los Términos de uso y la Política de privacidad.",
|
||||||
"consent.submit": "Aceptar y continuar"
|
"consent.submit": "Aceptar y continuar",
|
||||||
|
"scan.meal.title": "Registrar comida desde la foto",
|
||||||
|
"scan.meal.estimateNote": "Estimado a partir de tu foto: ajústalo antes de guardar.",
|
||||||
|
"scan.meal.calories": "Calorías estimadas",
|
||||||
|
"scan.meal.namePlaceholder": "¿Qué comiste? (opcional)",
|
||||||
|
"scan.meal.components": "Reconocido en el plato",
|
||||||
|
"scan.meal.noEstimate": "No se pudieron estimar las calorías de la foto."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -395,5 +395,11 @@
|
|||||||
"consent.termsLink": "Lue käyttöehdot",
|
"consent.termsLink": "Lue käyttöehdot",
|
||||||
"consent.privacyLink": "Lue tietosuojaseloste",
|
"consent.privacyLink": "Lue tietosuojaseloste",
|
||||||
"consent.checkbox": "Olen lukenut käyttöehdot ja tietosuojaselosteen ja hyväksyn ne.",
|
"consent.checkbox": "Olen lukenut käyttöehdot ja tietosuojaselosteen ja hyväksyn ne.",
|
||||||
"consent.submit": "Hyväksy ja jatka"
|
"consent.submit": "Hyväksy ja jatka",
|
||||||
|
"scan.meal.title": "Kirjaa ateria kuvasta",
|
||||||
|
"scan.meal.estimateNote": "Arvioitu kuvastasi – säädä ennen tallennusta.",
|
||||||
|
"scan.meal.calories": "Arvioidut kalorit",
|
||||||
|
"scan.meal.namePlaceholder": "Mitä söit? (valinnainen)",
|
||||||
|
"scan.meal.components": "Tunnistettu lautaselta",
|
||||||
|
"scan.meal.noEstimate": "Kaloreita ei voitu arvioida kuvasta."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -395,5 +395,11 @@
|
|||||||
"consent.termsLink": "Lire les Conditions d'utilisation",
|
"consent.termsLink": "Lire les Conditions d'utilisation",
|
||||||
"consent.privacyLink": "Lire la Politique de confidentialité",
|
"consent.privacyLink": "Lire la Politique de confidentialité",
|
||||||
"consent.checkbox": "J'ai lu et j'accepte les Conditions d'utilisation et la Politique de confidentialité.",
|
"consent.checkbox": "J'ai lu et j'accepte les Conditions d'utilisation et la Politique de confidentialité.",
|
||||||
"consent.submit": "Accepter et continuer"
|
"consent.submit": "Accepter et continuer",
|
||||||
|
"scan.meal.title": "Enregistrer le repas depuis la photo",
|
||||||
|
"scan.meal.estimateNote": "Estimé à partir de ta photo – ajuste avant d'enregistrer.",
|
||||||
|
"scan.meal.calories": "Calories estimées",
|
||||||
|
"scan.meal.namePlaceholder": "Qu'as-tu mangé ? (facultatif)",
|
||||||
|
"scan.meal.components": "Reconnu dans l'assiette",
|
||||||
|
"scan.meal.noEstimate": "Impossible d'estimer les calories à partir de la photo."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -395,5 +395,11 @@
|
|||||||
"consent.termsLink": "Leggi i Termini di servizio",
|
"consent.termsLink": "Leggi i Termini di servizio",
|
||||||
"consent.privacyLink": "Leggi l'Informativa sulla privacy",
|
"consent.privacyLink": "Leggi l'Informativa sulla privacy",
|
||||||
"consent.checkbox": "Ho letto e accetto i Termini di servizio e l'Informativa sulla privacy.",
|
"consent.checkbox": "Ho letto e accetto i Termini di servizio e l'Informativa sulla privacy.",
|
||||||
"consent.submit": "Accetta e continua"
|
"consent.submit": "Accetta e continua",
|
||||||
|
"scan.meal.title": "Registra il pasto dalla foto",
|
||||||
|
"scan.meal.estimateNote": "Stimato dalla tua foto – modifica prima di salvare.",
|
||||||
|
"scan.meal.calories": "Calorie stimate",
|
||||||
|
"scan.meal.namePlaceholder": "Cosa hai mangiato? (facoltativo)",
|
||||||
|
"scan.meal.components": "Riconosciuto nel piatto",
|
||||||
|
"scan.meal.noEstimate": "Impossibile stimare le calorie dalla foto."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -395,5 +395,11 @@
|
|||||||
"consent.termsLink": "Les Brukervilkårene",
|
"consent.termsLink": "Les Brukervilkårene",
|
||||||
"consent.privacyLink": "Les Personvernerklæringen",
|
"consent.privacyLink": "Les Personvernerklæringen",
|
||||||
"consent.checkbox": "Jeg har lest og godtar Brukervilkårene og Personvernerklæringen.",
|
"consent.checkbox": "Jeg har lest og godtar Brukervilkårene og Personvernerklæringen.",
|
||||||
"consent.submit": "Godta og fortsett"
|
"consent.submit": "Godta og fortsett",
|
||||||
|
"scan.meal.title": "Logg måltid fra foto",
|
||||||
|
"scan.meal.estimateNote": "Estimert fra bildet ditt – juster før du lagrer.",
|
||||||
|
"scan.meal.calories": "Estimerte kalorier",
|
||||||
|
"scan.meal.namePlaceholder": "Hva spiste du? (valgfritt)",
|
||||||
|
"scan.meal.components": "Gjenkjent på tallerkenen",
|
||||||
|
"scan.meal.noEstimate": "Kunne ikke estimere kaloriene fra bildet."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -395,5 +395,11 @@
|
|||||||
"consent.termsLink": "Lees de Gebruiksvoorwaarden",
|
"consent.termsLink": "Lees de Gebruiksvoorwaarden",
|
||||||
"consent.privacyLink": "Lees het Privacybeleid",
|
"consent.privacyLink": "Lees het Privacybeleid",
|
||||||
"consent.checkbox": "Ik heb de Gebruiksvoorwaarden en het Privacybeleid gelezen en ga ermee akkoord.",
|
"consent.checkbox": "Ik heb de Gebruiksvoorwaarden en het Privacybeleid gelezen en ga ermee akkoord.",
|
||||||
"consent.submit": "Accepteren en doorgaan"
|
"consent.submit": "Accepteren en doorgaan",
|
||||||
|
"scan.meal.title": "Maaltijd loggen vanaf foto",
|
||||||
|
"scan.meal.estimateNote": "Geschat op basis van je foto – pas aan voordat je opslaat.",
|
||||||
|
"scan.meal.calories": "Geschatte calorieën",
|
||||||
|
"scan.meal.namePlaceholder": "Wat heb je gegeten? (optioneel)",
|
||||||
|
"scan.meal.components": "Herkend op het bord",
|
||||||
|
"scan.meal.noEstimate": "Kon de calorieën niet schatten op basis van de foto."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -409,5 +409,11 @@
|
|||||||
"consent.termsLink": "Przeczytaj Regulamin",
|
"consent.termsLink": "Przeczytaj Regulamin",
|
||||||
"consent.privacyLink": "Przeczytaj Politykę prywatności",
|
"consent.privacyLink": "Przeczytaj Politykę prywatności",
|
||||||
"consent.checkbox": "Przeczytałem(-am) i akceptuję Regulamin oraz Politykę prywatności.",
|
"consent.checkbox": "Przeczytałem(-am) i akceptuję Regulamin oraz Politykę prywatności.",
|
||||||
"consent.submit": "Akceptuję i kontynuuj"
|
"consent.submit": "Akceptuję i kontynuuj",
|
||||||
|
"scan.meal.title": "Zapisz posiłek ze zdjęcia",
|
||||||
|
"scan.meal.estimateNote": "Oszacowano na podstawie zdjęcia – dostosuj przed zapisaniem.",
|
||||||
|
"scan.meal.calories": "Szacowane kalorie",
|
||||||
|
"scan.meal.namePlaceholder": "Co zjadłeś(-aś)? (opcjonalnie)",
|
||||||
|
"scan.meal.components": "Rozpoznano na talerzu",
|
||||||
|
"scan.meal.noEstimate": "Nie udało się oszacować kalorii ze zdjęcia."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -395,5 +395,11 @@
|
|||||||
"consent.termsLink": "Ler os Termos de Utilização",
|
"consent.termsLink": "Ler os Termos de Utilização",
|
||||||
"consent.privacyLink": "Ler a Política de Privacidade",
|
"consent.privacyLink": "Ler a Política de Privacidade",
|
||||||
"consent.checkbox": "Li e aceito os Termos de Utilização e a Política de Privacidade.",
|
"consent.checkbox": "Li e aceito os Termos de Utilização e a Política de Privacidade.",
|
||||||
"consent.submit": "Aceitar e continuar"
|
"consent.submit": "Aceitar e continuar",
|
||||||
|
"scan.meal.title": "Registar refeição a partir da foto",
|
||||||
|
"scan.meal.estimateNote": "Estimado a partir da tua foto – ajusta antes de guardar.",
|
||||||
|
"scan.meal.calories": "Calorias estimadas",
|
||||||
|
"scan.meal.namePlaceholder": "O que comeste? (opcional)",
|
||||||
|
"scan.meal.components": "Reconhecido no prato",
|
||||||
|
"scan.meal.noEstimate": "Não foi possível estimar as calorias a partir da foto."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -395,5 +395,11 @@
|
|||||||
"cooked.leftoverLessThanBox": "Rester måste vara minst lika många som antalet matlådeportioner.",
|
"cooked.leftoverLessThanBox": "Rester måste vara minst lika många som antalet matlådeportioner.",
|
||||||
"mealbox.undoConfirmTitle": "Ångra matlagning?",
|
"mealbox.undoConfirmTitle": "Ångra matlagning?",
|
||||||
"mealbox.undoConfirmBody": "Detta tar tillbaka lagerförbrukningen och matlådan för den här matlagningen.",
|
"mealbox.undoConfirmBody": "Detta tar tillbaka lagerförbrukningen och matlådan för den här matlagningen.",
|
||||||
"mealbox.undoSuccess": "Matlagningen är ångrad."
|
"mealbox.undoSuccess": "Matlagningen är ångrad.",
|
||||||
|
"scan.meal.title": "Logga måltid från foto",
|
||||||
|
"scan.meal.estimateNote": "Uppskattat från ditt foto – justera innan du sparar.",
|
||||||
|
"scan.meal.calories": "Uppskattade kalorier",
|
||||||
|
"scan.meal.namePlaceholder": "Vad åt du? (valfritt)",
|
||||||
|
"scan.meal.components": "Igenkänt på tallriken",
|
||||||
|
"scan.meal.noEstimate": "Kunde inte uppskatta kalorierna från fotot."
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user