feat: kitchen screen, scan dedup, gemini-3.5-flash-lite, seed grapes/beetroot/pea shoots

This commit is contained in:
Sven (AAMOS AI)
2026-08-16 20:13:49 +07:00
parent d098bf705e
commit c0a15bb1b5
20 changed files with 295 additions and 48 deletions
+3
View File
@@ -117,6 +117,9 @@ export default function HomeScreen() {
<Card onPress={() => router.push("/saved-recipes")}>
<Body> Dina sparade recept</Body>
</Card>
<Card onPress={() => router.push("/kitchen")}>
<Body>🧺 Ditt kök</Body>
</Card>
{budget.data && (
<Card>
+1 -1
View File
@@ -58,7 +58,7 @@ export default function WhatToEatScreen() {
queryKey: ["what-to-eat", submittedCraving],
queryFn: () =>
api<WhatToEatResponse>(
`/v1/recommendations/what-to-eat?limit=15${submittedCraving ? `&craving=${encodeURIComponent(submittedCraving)}` : ""}`,
`/v1/recommendations/what-to-eat?limit=15&view=pantry${submittedCraving ? `&craving=${encodeURIComponent(submittedCraving)}` : ""}`,
),
});
+4 -3
View File
@@ -76,15 +76,16 @@ export default function RootLayout() {
name="cooking/[id]"
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="scan-review/[jobId]" options={{ title: t("scan.review.title"), fullScreenGestureEnabled: false }} />
<Stack.Screen name="meal-review/[jobId]" options={{ title: t("scan.meal.title"), fullScreenGestureEnabled: false }} />
<Stack.Screen name="reconciliation" options={{ title: t("reconciliation.title") }} />
<Stack.Screen name="scan-diff-review/[jobId]" options={{ title: t("scan.review.title") }} />
<Stack.Screen name="scan-diff-review/[jobId]" options={{ title: t("scan.review.title"), fullScreenGestureEnabled: false }} />
<Stack.Screen name="shopping" options={{ title: t("shopping.title"), presentation: "modal" }} />
<Stack.Screen name="meal-boxes" options={{ title: t("mealbox.title"), presentation: "modal" }} />
<Stack.Screen name="household" options={{ title: t("home.household"), presentation: "modal" }} />
<Stack.Screen name="memory" options={{ title: t("memory.title") }} />
<Stack.Screen name="saved-recipes" options={{ title: "Sparade recept" }} />
<Stack.Screen name="kitchen" options={{ title: "Ditt kök" }} />
<Stack.Screen name="profile" options={{ title: t("profile.title"), presentation: "modal" }} />
<Stack.Screen
name="paywall"
+44 -1
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from "react";
import { Alert, Pressable, Text, View } from "react-native";
import { Alert, Pressable, ScrollView, Text, View } from "react-native";
import { router, useLocalSearchParams } from "expo-router";
import { useKeepAwake } from "expo-keep-awake";
import * as Haptics from "expo-haptics";
@@ -19,6 +19,7 @@ import {
Spacer,
} from "@/components/ui";
import { colors, spacing } from "@/lib/theme";
import { formatQuantity } from "@/lib/units";
/**
* Cooking Mode (spec §41): stora steg, stora knappar, skärmen vaken,
@@ -38,6 +39,13 @@ interface RecipeForCooking {
temperatureC: number | null;
tip: string | null;
}>;
ingredients: Array<{
id: string;
displayNameSv: string;
quantity: number;
unit: string;
optional: boolean;
}>;
}
export default function CookingScreen() {
@@ -45,6 +53,7 @@ export default function CookingScreen() {
const { id, portions: portionsParam } = useLocalSearchParams<{ id: string; portions?: string }>();
const queryClient = useQueryClient();
const [stepIndex, setStepIndex] = useState(0);
const [showIngredients, setShowIngredients] = useState(false);
const [timerLeft, setTimerLeft] = useState<number | null>(null);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const [finishing, setFinishing] = useState(false);
@@ -265,6 +274,40 @@ export default function CookingScreen() {
{step?.temperatureC ? ` (${step.temperatureC} °C)` : ""}
</Text>
{step?.tip && <Small>💡 {step.tip}</Small>}
<Pressable
onPress={() => setShowIngredients((v) => !v)}
hitSlop={8}
style={{ alignSelf: "flex-start" }}
>
<Small style={{ color: colors.primaryDark, fontWeight: "600" }}>
{showIngredients ? "▾ " : "▸ "}📋 {t("recipe.ingredients")}
</Small>
</Pressable>
{showIngredients && (
<View
style={{
maxHeight: 200,
backgroundColor: colors.surfaceAlt,
borderRadius: 12,
padding: spacing.md,
}}
>
<ScrollView>
{recipe.ingredients.map((ing) => {
const scaledQty = (ing.quantity * portionsCooked) / recipe.portions;
return (
<Row key={ing.id} style={{ justifyContent: "space-between" }}>
<Body>
{ing.displayNameSv}
{ing.optional ? " (valfritt)" : ""}
</Body>
<Small>{formatQuantity(scaledQty, ing.unit)}</Small>
</Row>
);
})}
</ScrollView>
</View>
)}
{step?.timerSeconds != null && (
<Pressable
+87
View File
@@ -0,0 +1,87 @@
import { Alert } from "react-native";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "@/lib/api";
import { t } from "@/lib/i18n";
import { formatQuantity } from "@/lib/units";
import {
Body,
Button,
Card,
EmptyState,
ErrorView,
LoadingView,
Row,
Screen,
Small,
} from "@/components/ui";
/** Ditt kök: se och ta bort varor i lagret. Radering = mjuk (kvantitet → 0, historik bevaras). */
interface InventoryItem {
id: string;
displayName: string;
quantity: number;
unit: string;
expiry: { status: string; daysLeft: number | null; pastBestBefore: boolean };
}
export default function KitchenScreen() {
const queryClient = useQueryClient();
const query = useQuery({
queryKey: ["inventory", "all"],
queryFn: () => api<{ items: InventoryItem[] }>("/v1/inventory?limit=200"),
});
const remove = useMutation({
mutationFn: (id: string) => api(`/v1/inventory/items/${id}`, { method: "DELETE" }),
onMutate: async (id: string) => {
await queryClient.cancelQueries({ queryKey: ["inventory", "all"] });
const previous = queryClient.getQueryData<{ items: InventoryItem[] }>(["inventory", "all"]);
queryClient.setQueryData<{ items: InventoryItem[] }>(["inventory", "all"], (old) =>
old ? { ...old, items: old.items.filter((i) => i.id !== id) } : old,
);
return { previous };
},
onError: (err, _id, context) => {
if (context?.previous) queryClient.setQueryData(["inventory", "all"], context.previous);
Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error"));
},
onSettled: () => {
void queryClient.invalidateQueries({ queryKey: ["inventory"] });
void queryClient.invalidateQueries({ queryKey: ["inventory-expiring"] });
},
});
if (query.isLoading) return <LoadingView />;
if (query.isError) return <ErrorView onRetry={() => void query.refetch()} />;
const items = query.data?.items ?? [];
return (
<Screen>
{items.length === 0 && (
<EmptyState text="Ditt kök är tomt än. Skanna eller lägg till varor." />
)}
{items.map((item) => (
<Card key={item.id}>
<Row style={{ justifyContent: "space-between" }}>
<Body>
{item.displayName} · {formatQuantity(item.quantity, item.unit)}
</Body>
<Button
label="Ta bort"
variant="ghost"
onPress={() =>
Alert.alert("Ta bort", `Ta bort ${item.displayName} ur ditt kök?`, [
{ text: t("common.cancel"), style: "cancel" },
{ text: "Ta bort", style: "destructive", onPress: () => remove.mutate(item.id) },
])
}
/>
</Row>
{item.expiry.pastBestBefore && <Small> bäst före har passerat</Small>}
</Card>
))}
</Screen>
);
}
+45 -15
View File
@@ -1,5 +1,5 @@
import { useState } from "react";
import { Alert, View } from "react-native";
import { Alert, Pressable, View } from "react-native";
import { router, useLocalSearchParams } from "expo-router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "@/lib/api";
@@ -86,17 +86,37 @@ export default function RecipeScreen() {
const favorite = useMutation({
mutationFn: (isFavorite: boolean) =>
api(`/v1/recipes/${id}/favorite`, { method: isFavorite ? "DELETE" : "POST" }),
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["recipe", id] }),
onError: (err) =>
Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error")),
onMutate: async (isFavorite: boolean) => {
await queryClient.cancelQueries({ queryKey: ["recipe", id] });
const previous = queryClient.getQueryData<RecipeDetail>(["recipe", id]);
queryClient.setQueryData<RecipeDetail>(["recipe", id], (old) =>
old ? { ...old, isFavorite: !isFavorite } : old,
);
return { previous };
},
onError: (err, _isFavorite, context) => {
if (context?.previous) queryClient.setQueryData(["recipe", id], context.previous);
Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error"));
},
onSettled: () => void queryClient.invalidateQueries({ queryKey: ["recipe", id] }),
});
const rate = useMutation({
mutationFn: (stars: number) =>
api(`/v1/recipes/${id}/rate`, { method: "POST", body: { stars, feedbackTags: [] } }),
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["recipe", id] }),
onError: (err) =>
Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error")),
onMutate: async (stars: number) => {
await queryClient.cancelQueries({ queryKey: ["recipe", id] });
const previous = queryClient.getQueryData<RecipeDetail>(["recipe", id]);
queryClient.setQueryData<RecipeDetail>(["recipe", id], (old) =>
old ? { ...old, myRating: { stars } } : old,
);
return { previous };
},
onError: (err, _stars, context) => {
if (context?.previous) queryClient.setQueryData(["recipe", id], context.previous);
Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error"));
},
onSettled: () => void queryClient.invalidateQueries({ queryKey: ["recipe", id] }),
});
if (query.isLoading) return <LoadingView />;
@@ -214,14 +234,24 @@ export default function RecipeScreen() {
<Card>
<Heading>{t("cooked.rate")}</Heading>
<Row>
{[1, 2, 3, 4, 5].map((stars) => (
<Button
key={stars}
label={(recipe.myRating?.stars ?? 0) >= stars ? "★" : "☆"}
variant="ghost"
onPress={() => rate.mutate(stars)}
/>
))}
{[1, 2, 3, 4, 5].map((star) => {
const filled = (recipe.myRating?.stars ?? 0) >= star;
return (
<Pressable
key={star}
onPress={() => rate.mutate(star)}
hitSlop={8}
style={({ pressed }) => ({
opacity: pressed ? 0.6 : 1,
paddingHorizontal: spacing.xs,
})}
>
<Small style={{ fontSize: 34, color: filled ? colors.accent : colors.border }}>
{filled ? "★" : "☆"}
</Small>
</Pressable>
);
})}
</Row>
{recipe.ratingAverage != null && (
<Small>