From 61d60931adc9f72d69a5ebcc3b5a9838599df9d6 Mon Sep 17 00:00:00 2001 From: "Sven (AAMOS AI)" Date: Thu, 13 Aug 2026 07:04:10 +0700 Subject: [PATCH] =?UTF-8?q?feat(mobile):=20tallriksfoto=20=E2=86=92=20meal?= =?UTF-8?q?-review=20+=20i18n=20(otestad,=20v=C3=A4ntar=20Expo-test)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/mobile/src/app/(tabs)/scan.tsx | 2 +- apps/mobile/src/app/_layout.tsx | 1 + apps/mobile/src/app/meal-review/[jobId].tsx | 137 ++++++++++++++++++++ apps/mobile/src/locales/da/common.json | 8 +- apps/mobile/src/locales/de/common.json | 8 +- apps/mobile/src/locales/en/common.json | 8 +- apps/mobile/src/locales/es/common.json | 8 +- apps/mobile/src/locales/fi/common.json | 8 +- apps/mobile/src/locales/fr/common.json | 8 +- apps/mobile/src/locales/it/common.json | 8 +- apps/mobile/src/locales/nb/common.json | 8 +- apps/mobile/src/locales/nl/common.json | 8 +- apps/mobile/src/locales/pl/common.json | 8 +- apps/mobile/src/locales/pt/common.json | 8 +- apps/mobile/src/locales/sv/common.json | 8 +- 15 files changed, 223 insertions(+), 13 deletions(-) create mode 100644 apps/mobile/src/app/meal-review/[jobId].tsx diff --git a/apps/mobile/src/app/(tabs)/scan.tsx b/apps/mobile/src/app/(tabs)/scan.tsx index 7815998..6e1963d 100644 --- a/apps/mobile/src/app/(tabs)/scan.tsx +++ b/apps/mobile/src/app/(tabs)/scan.tsx @@ -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 { diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index a51e925..16a5e42 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -75,6 +75,7 @@ export default function RootLayout() { options={{ title: "", presentation: "fullScreenModal" }} /> + diff --git a/apps/mobile/src/app/meal-review/[jobId].tsx b/apps/mobile/src/app/meal-review/[jobId].tsx new file mode 100644 index 0000000..84b2dd2 --- /dev/null +++ b/apps/mobile/src/app/meal-review/[jobId].tsx @@ -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("lunch"); + const [title, setTitle] = useState(""); + const [busy, setBusy] = useState(false); + + const query = useQuery({ + queryKey: ["scan", jobId], + queryFn: () => api(`/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 ( + + + {t("scan.analyzing")} + + ); + } + if (query.isError || !job) return void query.refetch()} />; + if (job.status === "failed") { + return ( + + router.back()} /> + + ); + } + + return ( + + {t("scan.meal.title")} + + {t("scan.meal.estimateNote")} + + + + {t("scan.meal.calories")} + {kcal ? ( + <> + ≈ {Math.round(kcal.mostLikely)} kcal + {Math.round(kcal.min)}–{Math.round(kcal.max)} kcal + + ) : ( + {t("scan.meal.noEstimate")} + )} + + + {components.length > 0 && ( + + {t("scan.meal.components")} + {components.map((c, i) => ( + + {c.name} + {c.estimatedGrams != null && {Math.round(c.estimatedGrams)} g} + + ))} + + )} + + + {t("logmeal.mealTypeTitle")} + + {MEAL_TYPES.map((id) => ( +