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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user