18388bb31c
- API: översatt title/displayName/toName läggs nu bredvid Sv-fälten i favoriter,
receptvarianter, /scaled, skapar-profiler, topplistor, memory-impact,
substitutions — samt de denormaliserade titlarna (min dag, historik, matlådor,
matlåde-förslag, veckoplan) via nullable recipeId med svensk fallback.
10 endpoints, batchade resolvers (ett anrop per endpoint).
- Mobil: konsumerar de nya fälten med ?? Sv-fallback (swap-meal, sparade recept,
varianter, min dag, logg, matlådor, veckoplan, inköpslista). Hårdkodade
strängar -> t() (kitchen, scan-review, register, recept protein/Skapat av);
decimalkomma -> Intl.NumberFormat. Allergen-etiketter -> t() (+5 nya nycklar).
11 nya nycklar i alla 12 språk.
- i18n-vakt härdad: fångar nu hårdkodad svenska i prop={`...`}-mallliteraler
(blind fläck förr). Verifierat att den fäller men inte ger falska positiv.
- whySv var redan lokaliserad (buildWhy med språktagg) - orörd.
- typecheck grönt (alla paket), vakt grön (603 nycklar).
Co-Authored-By: Claude <noreply@anthropic.com>
139 lines
4.0 KiB
TypeScript
139 lines
4.0 KiB
TypeScript
import { useState } from "react";
|
||
import { Alert, View } from "react-native";
|
||
import { router } from "expo-router";
|
||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import { api } from "@/lib/api";
|
||
import { t } from "@/lib/i18n";
|
||
import { Body, Button, Card, Heading, Input, Row, Screen, Small, Spacer } from "@/components/ui";
|
||
|
||
/**
|
||
* Måltidsloggning (spec §23): tidigare måltid, favoritrecept eller manuellt.
|
||
* Tallriksfoto loggas via Skanna-fliken. Näringsvärden hittas ALDRIG på:
|
||
* manuell loggning kräver egna värden eller känt recept/livsmedel.
|
||
*/
|
||
|
||
/** Etiketter ur i18n-katalogen (myday.mealType.<typ>) – id:na är stabila API-värden. */
|
||
const MEAL_TYPES = ["breakfast", "lunch", "dinner", "snack"] as const;
|
||
|
||
interface RecentMeal {
|
||
id: string;
|
||
titleSv: string;
|
||
title?: string;
|
||
mealType: string;
|
||
nutrition: {
|
||
kcal: number;
|
||
proteinG: number;
|
||
carbsG: number;
|
||
fatG: number;
|
||
saturatedFatG: number;
|
||
fiberG: number;
|
||
sugarG: number;
|
||
saltG: number;
|
||
};
|
||
}
|
||
|
||
export default function LogMealScreen() {
|
||
const queryClient = useQueryClient();
|
||
const [mealType, setMealType] = useState<string>("lunch");
|
||
const [title, setTitle] = useState("");
|
||
const [kcal, setKcal] = useState("");
|
||
const [protein, setProtein] = useState("");
|
||
|
||
const recent = useQuery({
|
||
queryKey: ["recent-meals"],
|
||
queryFn: () => api<{ meals: RecentMeal[] }>("/v1/meals/recent"),
|
||
});
|
||
|
||
const log = useMutation({
|
||
mutationFn: (body: unknown) => api("/v1/meals", { method: "POST", body }),
|
||
onSuccess: async () => {
|
||
await queryClient.invalidateQueries({ queryKey: ["day"] });
|
||
router.back();
|
||
},
|
||
onError: (err) =>
|
||
Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error")),
|
||
});
|
||
|
||
const today = new Date().toISOString().slice(0, 10);
|
||
|
||
const logManual = () => {
|
||
if (!title.trim() || !kcal) {
|
||
Alert.alert(t("logmeal.validationTitle"), t("logmeal.validationBody"));
|
||
return;
|
||
}
|
||
log.mutate({
|
||
date: today,
|
||
mealType,
|
||
source: "manual",
|
||
titleSv: title.trim(),
|
||
nutritionOverride: {
|
||
kcal: Number(kcal),
|
||
proteinG: Number(protein) || 0,
|
||
carbsG: 0,
|
||
fatG: 0,
|
||
saturatedFatG: 0,
|
||
fiberG: 0,
|
||
sugarG: 0,
|
||
saltG: 0,
|
||
},
|
||
});
|
||
};
|
||
|
||
const logPrevious = (meal: RecentMeal) => {
|
||
log.mutate({
|
||
date: today,
|
||
mealType,
|
||
source: "previous_meal",
|
||
titleSv: meal.titleSv,
|
||
nutritionOverride: meal.nutrition,
|
||
});
|
||
};
|
||
|
||
return (
|
||
<Screen>
|
||
<Heading>{t("logmeal.mealTypeTitle")}</Heading>
|
||
<Row>
|
||
{MEAL_TYPES.map((id) => (
|
||
<Button
|
||
key={id}
|
||
label={t(`myday.mealType.${id}`)}
|
||
variant={mealType === id ? "secondary" : "ghost"}
|
||
onPress={() => setMealType(id)}
|
||
/>
|
||
))}
|
||
</Row>
|
||
|
||
<Spacer />
|
||
<Heading>{t("logmeal.quickTitle")}</Heading>
|
||
<Small>{t("logmeal.quickSubtitle")}</Small>
|
||
{(recent.data?.meals ?? []).slice(0, 5).map((meal) => (
|
||
<Card key={meal.id} onPress={() => logPrevious(meal)}>
|
||
<Row style={{ justifyContent: "space-between" }}>
|
||
<Body>{meal.title ?? meal.titleSv}</Body>
|
||
<Small>{Math.round(meal.nutrition.kcal)} kcal</Small>
|
||
</Row>
|
||
</Card>
|
||
))}
|
||
|
||
<Spacer />
|
||
<Heading>{t("logmeal.manualTitle")}</Heading>
|
||
<Input placeholder={t("logmeal.whatPlaceholder")} value={title} onChangeText={setTitle} />
|
||
<Row>
|
||
<View style={{ flex: 1 }}>
|
||
<Input placeholder="kcal" keyboardType="numeric" value={kcal} onChangeText={setKcal} />
|
||
</View>
|
||
<View style={{ flex: 1 }}>
|
||
<Input
|
||
placeholder={t("logmeal.proteinPlaceholder")}
|
||
keyboardType="numeric"
|
||
value={protein}
|
||
onChangeText={setProtein}
|
||
/>
|
||
</View>
|
||
</Row>
|
||
<Button label={t("myday.logMeal")} onPress={logManual} loading={log.isPending} />
|
||
<Small>{t("logmeal.photoTip")}</Small>
|
||
</Screen>
|
||
);
|
||
}
|