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" });
// 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 {
+1
View File
@@ -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") }} />
+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>
);
}