feat(mobile): tallriksfoto → meal-review + i18n (otestad, väntar Expo-test)

This commit is contained in:
Sven (AAMOS AI)
2026-08-13 07:04:10 +07:00
parent f7227c40cf
commit 61d60931ad
15 changed files with 223 additions and 13 deletions
+1 -1
View File
@@ -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 {
+1
View File
@@ -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") }} />
+137
View File
@@ -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>
);
}
+7 -1
View File
@@ -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."
} }
+7 -1
View File
@@ -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."
} }
+7 -1
View File
@@ -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."
} }
+7 -1
View File
@@ -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."
} }
+7 -1
View File
@@ -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."
} }
+7 -1
View File
@@ -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."
} }
+7 -1
View File
@@ -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."
} }
+7 -1
View File
@@ -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."
} }
+7 -1
View File
@@ -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."
} }
+7 -1
View File
@@ -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."
} }
+7 -1
View File
@@ -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."
} }
+7 -1
View File
@@ -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."
} }