318 lines
11 KiB
TypeScript
318 lines
11 KiB
TypeScript
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 §23–24).
|
||
*/
|
||
|
||
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")}`;
|
||
}
|