feat: kitchen screen, scan dedup, gemini-3.5-flash-lite, seed grapes/beetroot/pea shoots
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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)}` : ""}`,
|
||||
),
|
||||
});
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -375,8 +375,8 @@
|
||||
"home.trustStatus.uncertain": "Lagret är osäkert – gör en snabbkoll",
|
||||
"reconciliation.title": "Snabbkoll av lagret",
|
||||
"reconciliation.subtitle": "Bekräfta vad du har kvar så blir förslagen bättre.",
|
||||
"consent.title": "Innan du börjar",
|
||||
"consent.body": "För att använda Cibello behöver du godkänna våra villkor. Bilderna du tar i appen (kylskåp, förpackningar, maträtter) används för att träna och förbättra Cibellos AI – det ingår i villkoren.",
|
||||
"consent.title": "Välkommen till Cibello",
|
||||
"consent.body": "För att använda Cibello behöver du godkänna Användarvillkoren och Integritetspolicyn. I nästa steg väljer du själv hur personlig du vill att appen ska vara – helt frivilligt, och du kan ändra det när som helst.",
|
||||
"consent.termsLink": "Läs Användarvillkoren",
|
||||
"consent.privacyLink": "Läs Integritetspolicyn",
|
||||
"consent.checkbox": "Jag har läst och godkänner Användarvillkoren och Integritetspolicyn.",
|
||||
@@ -419,5 +419,21 @@
|
||||
"scan.meal.calories": "Uppskattade kalorier",
|
||||
"scan.meal.namePlaceholder": "Vad åt du? (valfritt)",
|
||||
"scan.meal.components": "Igenkänt på tallriken",
|
||||
"scan.meal.noEstimate": "Kunde inte uppskatta kalorierna från fotot."
|
||||
"scan.meal.noEstimate": "Kunde inte uppskatta kalorierna från fotot.",
|
||||
"onboarding.consent.personalization.title": "Gör Cibello till din",
|
||||
"onboarding.consent.personalization.body": "Ju mer Cibello lär känna dig, desto bättre blir det – förslag som passar din smak, dina allergier och din vardag. Allt appen lär sig ser du i \"Vad Cibello vet om dig\" och kan ändra eller radera när du vill. Vi rekommenderar att ha den på.",
|
||||
"onboarding.consent.personalization.yes": "Ja, gör appen personlig",
|
||||
"onboarding.consent.skip": "Inte nu",
|
||||
"onboarding.consent.image_training.title": "Var med och göra Cibello smartare",
|
||||
"onboarding.consent.image_training.body": "Låt dina foton hjälpa till att träna vår AI så att den känner igen svenska varor och kvitton allt bättre – för dig och alla andra. Din skanning fungerar lika bra oavsett, men varje ja gör appen vassare.",
|
||||
"onboarding.consent.image_training.yes": "Ja, jag hjälper till",
|
||||
"onboarding.consent.image_training.no": "Nej tack",
|
||||
"onboarding.consent.dataPrivacy.title": "Data & integritet",
|
||||
"onboarding.consent.dataPrivacy.body": "Två saker är på för att göra appen bättre för alla. Du kan stänga av dem när du vill.",
|
||||
"onboarding.consent.anonymized_improvement.body": "Anonym förbättring: helt avidentifierad, aggregerad statistik – aldrig dina bilder eller något som pekar ut dig – hjälper oss göra förslagen bättre.",
|
||||
"onboarding.consent.product_analytics.body": "Produktanalys: genom att se hur appen används kan vi fixa buggar och förbättra det som krånglar. Pseudonymt, och vi säljer aldrig din data.",
|
||||
"onboarding.consent.dataPrivacy.continue": "Fortsätt",
|
||||
"onboarding.consent.health_integration.body": "Koppla Apple Health / Health Connect så kan Cibello sätta kalori- och näringsmål utifrån just dig. Hälsodata är extra känslig och är av tills du själv slår på den.",
|
||||
"onboarding.consent.location_weather.body": "Med din plats föreslår vi mat som passar vädret – värmande när det är kallt, grillat när solen är framme.",
|
||||
"onboarding.consent.push_notifications.body": "Få en påminnelse innan maten går ut och tips när det är dags att handla eller laga."
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user