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" });
|
||||
|
||||
// 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) {
|
||||
Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error"));
|
||||
} finally {
|
||||
|
||||
@@ -75,6 +75,7 @@ export default function RootLayout() {
|
||||
options={{ title: "", presentation: "fullScreenModal" }}
|
||||
/>
|
||||
<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="meal-boxes" options={{ title: t("mealbox.title") }} />
|
||||
<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.privacyLink": "Læs 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.privacyLink": "Datenschutzerklärung lesen",
|
||||
"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.privacyLink": "Read 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.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.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.privacyLink": "Lue tietosuojaseloste",
|
||||
"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.privacyLink": "Lire 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.privacyLink": "Leggi 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.privacyLink": "Les 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.privacyLink": "Lees het Privacybeleid",
|
||||
"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.privacyLink": "Przeczytaj 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.privacyLink": "Ler 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.",
|
||||
"mealbox.undoConfirmTitle": "Ångra matlagning?",
|
||||
"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