Files
Cibello-app/apps/mobile/src/app/cooking/[id].tsx
T
Sven (AAMOS AI) c32a7e33c7
CI / Typecheck, test & build (push) Failing after 2s
ci: trigga på master + formatfix inför Gitea Actions
2026-08-13 17:25:14 +07:00

318 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useRef, useState } from "react";
import { Alert, Pressable, Text, View } from "react-native";
import { router, useLocalSearchParams } from "expo-router";
import { useKeepAwake } from "expo-keep-awake";
import * as Haptics from "expo-haptics";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "@/lib/api";
import { t } from "@/lib/i18n";
import {
Body,
Button,
Card,
ErrorView,
Heading,
LoadingView,
Row,
Screen,
Small,
Spacer,
} from "@/components/ui";
import { colors, spacing } from "@/lib/theme";
/**
* Cooking Mode (spec §41): stora steg, stora knappar, skärmen vaken,
* timers per steg, portionsskalning. Avslutas med "jag lagade detta"
* som drar lager, loggar måltid och kan skapa matlådor (spec §2324).
*/
interface RecipeForCooking {
id: string;
titleSv: string;
portions: number;
steps: Array<{
id: string;
stepNumber: number;
instructionSv: string;
timerSeconds: number | null;
temperatureC: number | null;
tip: string | null;
}>;
}
export default function CookingScreen() {
useKeepAwake();
const { id, portions: portionsParam } = useLocalSearchParams<{ id: string; portions?: string }>();
const queryClient = useQueryClient();
const [stepIndex, setStepIndex] = useState(0);
const [timerLeft, setTimerLeft] = useState<number | null>(null);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const [finishing, setFinishing] = useState(false);
const [completedSessionId, setCompletedSessionId] = useState<string | null>(null);
const [mealBoxPortions, setMealBoxPortions] = useState(0);
const [actualPortionsEaten, setActualPortionsEaten] = useState<number | null>(null);
const [leftoverEstimatePortions, setLeftoverEstimatePortions] = useState<number | null>(null);
const query = useQuery({
queryKey: ["recipe", id],
queryFn: () => api<RecipeForCooking>(`/v1/recipes/${id}`),
});
const cook = useMutation({
mutationFn: (body: unknown) =>
api<{
sessionId: string;
mealBoxMutations?: Array<{ mealBoxId: string; deltaPortions: number; frozen: boolean }>;
}>(`/v1/recipes/${id}/cook`, { method: "POST", body }),
onSuccess: async (data) => {
await queryClient.invalidateQueries({ queryKey: ["inventory"] });
await queryClient.invalidateQueries({ queryKey: ["day"] });
await queryClient.invalidateQueries({ queryKey: ["what-to-eat"] });
setCompletedSessionId(data.sessionId);
},
onError: (err) =>
Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error")),
});
const undo = useMutation({
mutationFn: () => api(`/v1/cooking-sessions/${completedSessionId}/undo`, { method: "POST" }),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["meal-boxes"] });
await queryClient.invalidateQueries({ queryKey: ["inventory"] });
await queryClient.invalidateQueries({ queryKey: ["day"] });
Alert.alert(t("common.done"), t("mealbox.undoSuccess"));
router.dismissAll();
},
onError: (err) =>
Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error")),
});
useEffect(() => {
return () => {
if (timerRef.current) clearInterval(timerRef.current);
};
}, []);
if (query.isLoading) return <LoadingView />;
if (query.isError || !query.data) return <ErrorView onRetry={() => void query.refetch()} />;
const recipe = query.data;
const portionsCooked = Number(portionsParam ?? recipe.portions) || recipe.portions;
const step = recipe.steps[stepIndex];
const isLast = stepIndex >= recipe.steps.length - 1;
const startTimer = (seconds: number) => {
if (timerRef.current) clearInterval(timerRef.current);
setTimerLeft(seconds);
timerRef.current = setInterval(() => {
setTimerLeft((prev) => {
if (prev == null || prev <= 1) {
if (timerRef.current) clearInterval(timerRef.current);
void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
return 0;
}
return prev - 1;
});
}, 1000);
};
const finish = () => {
setFinishing(true);
};
if (completedSessionId) {
return (
<Screen>
<Card>
<Heading> {t("cooked.title")}</Heading>
<Body>{recipe.titleSv}</Body>
<Small>
{t("cooked.portionsCooked")}: {portionsCooked}
</Small>
</Card>
<Small>{t("mealbox.guidanceNote")}</Small>
<Button
label={t("common.undo")}
variant="secondary"
loading={undo.isPending}
onPress={() =>
Alert.alert(t("mealbox.undoConfirmTitle"), t("mealbox.undoConfirmBody"), [
{ text: t("common.cancel"), style: "cancel" },
{ text: t("common.undo"), style: "destructive", onPress: () => undo.mutate() },
])
}
/>
<Button label={t("common.done")} onPress={() => router.dismissAll()} />
</Screen>
);
}
if (finishing) {
const defaultEaten = actualPortionsEaten ?? Math.max(0, portionsCooked - mealBoxPortions);
const defaultLeftovers = leftoverEstimatePortions ?? mealBoxPortions;
return (
<Screen>
<Heading>{t("cooked.title")}</Heading>
<Card>
<Body>{recipe.titleSv}</Body>
<Small>
{t("cooked.portionsCooked")}: {portionsCooked}
</Small>
</Card>
<Card>
<Row style={{ justifyContent: "space-between" }}>
<Body>🍱 {t("cooked.mealBoxes")}</Body>
<Row>
<Button
label=""
variant="ghost"
onPress={() => setMealBoxPortions(Math.max(0, mealBoxPortions - 1))}
/>
<Body>{mealBoxPortions}</Body>
<Button
label="+"
variant="ghost"
onPress={() => setMealBoxPortions(Math.min(portionsCooked, mealBoxPortions + 1))}
/>
</Row>
</Row>
</Card>
<Card>
<Body>{t("cooked.actualPortionsEaten")}</Body>
<Row>
<Button
label=""
variant="ghost"
onPress={() => setActualPortionsEaten(Math.max(0, defaultEaten - 1))}
/>
<Body>{defaultEaten}</Body>
<Button
label="+"
variant="ghost"
onPress={() => setActualPortionsEaten(Math.min(portionsCooked, defaultEaten + 1))}
/>
</Row>
<Small>{t("cooked.actualPortionsEatenHint")}</Small>
</Card>
<Card>
<Body>{t("cooked.leftoverEstimatePortions")}</Body>
<Row>
<Button
label=""
variant="ghost"
onPress={() => setLeftoverEstimatePortions(Math.max(0, defaultLeftovers - 1))}
/>
<Body>{defaultLeftovers}</Body>
<Button
label="+"
variant="ghost"
onPress={() =>
setLeftoverEstimatePortions(
Math.min(portionsCooked - defaultEaten, defaultLeftovers + 1),
)
}
/>
</Row>
<Small>{t("cooked.leftoverEstimateHint")}</Small>
</Card>
<Small>{t("cooked.deductPantryNote")}</Small>
<Spacer size={spacing.sm} />
<Button
label={t("cooking.finish")}
loading={cook.isPending}
onPress={() =>
cook.mutate({
portionsCooked,
mealBoxPortions,
actualPortionsEaten: actualPortionsEaten ?? defaultEaten,
leftoverEstimatePortions: leftoverEstimatePortions ?? defaultLeftovers,
mealBoxFrozen: false,
deductInventory: true,
mealType: "dinner",
})
}
/>
<Button
label={t("common.skip")}
variant="ghost"
onPress={() =>
cook.mutate({
portionsCooked,
mealBoxPortions,
actualPortionsEaten: defaultEaten,
leftoverEstimatePortions: defaultLeftovers,
mealBoxFrozen: false,
deductInventory: true,
mealType: "dinner",
})
}
/>
<Button label={t("common.back")} variant="ghost" onPress={() => setFinishing(false)} />
</Screen>
);
}
return (
<Screen scroll={false} style={{ justifyContent: "space-between" }}>
<View style={{ gap: spacing.md }}>
<Small>{recipe.titleSv}</Small>
<Heading>
{t("cooking.step", { current: stepIndex + 1, total: recipe.steps.length })}
</Heading>
<Text style={{ fontSize: 24, lineHeight: 34, color: colors.text }}>
{step?.instructionSv}
{step?.temperatureC ? ` (${step.temperatureC} °C)` : ""}
</Text>
{step?.tip && <Small>💡 {step.tip}</Small>}
{step?.timerSeconds != null && (
<Pressable
onPress={() => startTimer(step.timerSeconds!)}
style={{
backgroundColor: timerLeft != null ? colors.accentSoft : colors.primarySoft,
padding: spacing.lg,
borderRadius: 16,
alignItems: "center",
}}
>
<Text style={{ fontSize: 32, fontWeight: "700", color: colors.primaryDark }}>
{timerLeft != null ? formatTime(timerLeft) : formatTime(step.timerSeconds)}
</Text>
<Small>
{timerLeft != null
? timerLeft === 0
? `${t("cooking.timerDone")} 🎉`
: t("cooking.timerRunning", { time: formatTime(timerLeft) })
: t("cooking.timer")}
</Small>
</Pressable>
)}
<Small>{t("cooking.keepAwake")}</Small>
</View>
<Row style={{ paddingBottom: spacing.lg }}>
<View style={{ flex: 1 }}>
<Button
label={`${t("common.back")}`}
variant="ghost"
disabled={stepIndex === 0}
onPress={() => setStepIndex(Math.max(0, stepIndex - 1))}
/>
</View>
<View style={{ flex: 2 }}>
<Button
label={isLast ? t("cooking.finish") : `${t("common.next")}`}
onPress={() => (isLast ? finish() : setStepIndex(stepIndex + 1))}
/>
</View>
</Row>
</Screen>
);
}
function formatTime(totalSeconds: number): string {
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}:${String(seconds).padStart(2, "0")}`;
}