i18n(app-svep) + i18n-vakt: hela mobil-UI:t översatt + CI-vakt mot regress

- Svepte 51 hårdkodade strängar -> t() över konto, sök/bläddra, sparade recept,
  kök, planera, skanna, byt måltid, inköp, minne, cooking, api-fel (+ locLabel/
  kategorier via nycklar). 47 nya nycklar, översatta till alla 12 språk.
- NY: apps/mobile/scripts/i18n-check.mjs (i18n:check) – fäller bygget om ett språk
  saknar en nyckel, en använd nyckel saknas, eller det finns hårdkodad UI-text.
  Wire:ad i CI (.github/workflows/ci.yml) efter typecheck. => 'lägg till språk'
  blir: kör vakten, den listar exakt vad som fattas. Aldrig mer leta för hand.
- Prettier-fixade 5 filer från tidigare leveranser så format:check blir grön.
- submit-recipe fri-text-payload + firebase dev-stubs markerade // i18n-ignore.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Claude
2026-08-21 01:50:08 +00:00
parent cadaa1a41f
commit 2d59a3e636
36 changed files with 1123 additions and 288 deletions
+1 -1
View File
@@ -51,7 +51,7 @@ export default function LoginScreen() {
<Screen style={{ flexGrow: 1, justifyContent: "center", gap: spacing.md }}>
<View style={{ alignItems: "center", marginBottom: spacing.lg }}>
<Title>{BRAND.name}</Title>
<Body muted>Mindre svinn, mindre stress, mer kvar i plånboken</Body>
<Body muted>{t("auth.tagline")}</Body>
</View>
<Input
placeholder={t("auth.email")}
+3 -3
View File
@@ -28,11 +28,11 @@ export default function RegisterScreen() {
const submit = async () => {
if (password.length < 8) {
setError("Lösenordet måste vara minst 8 tecken.");
setError(t("auth.passwordMin8"));
return;
}
if (password !== confirmPassword) {
setError("Lösenorden matchar inte.");
setError(t("auth.passwordMismatch"));
return;
}
setBusy(true);
@@ -112,7 +112,7 @@ export default function RegisterScreen() {
onChangeText={setPassword}
/>
<Input
placeholder="Upprepa lösenord"
placeholder={t("auth.repeatPassword")}
secureTextEntry
value={confirmPassword}
onChangeText={setConfirmPassword}
+5 -2
View File
@@ -74,7 +74,7 @@ const CATS: ReadonlyArray<{
{ key: "dinner", labelKey: "myday.mealType.dinner", mealType: "dinner", glyph: "🍽️" },
{ key: "snack", labelKey: "myday.mealType.snack", mealType: "snack", glyph: "🍎" },
{ key: "dessert", labelKey: "myday.mealType.dessert", mealType: "dessert", glyph: "🍰" },
{ key: "baking", labelKey: "cat.baking", label: "Baka", tag: "baking", ctxMeal: "dessert", glyph: "🥐" },
{ key: "baking", labelKey: "cat.baking", tag: "baking", ctxMeal: "dessert", glyph: "🥐" },
];
const errText = (e: unknown): string | undefined =>
@@ -249,7 +249,10 @@ export default function WhatToEatScreen() {
{/* "Menade du …?" stavningsförslag när sök gav noll träffar. */}
{allRecs.length === 0 && submittedSearch && query.data.context.searchSuggestion && (
<Pressable onPress={() => submitSearch(query.data!.context.searchSuggestion!)}>
<Tag label={t("wte.didYouMean", { suggestion: query.data.context.searchSuggestion })} tone="accent" />
<Tag
label={t("wte.didYouMean", { suggestion: query.data.context.searchSuggestion })}
tone="accent"
/>
</Pressable>
)}
+17 -8
View File
@@ -92,10 +92,7 @@ export default function PlanScreen() {
}, []);
// Veckan börjar ALLTID på måndag (månsön), oavsett vilken dag det är idag.
const thisMonday = useMemo(() => mondayOf(todayMidnight), [todayMidnight]);
const weekStart = useMemo(
() => addDays(thisMonday, weekOffset * 7),
[thisMonday, weekOffset],
);
const weekStart = useMemo(() => addDays(thisMonday, weekOffset * 7), [thisMonday, weekOffset]);
const weekStartKey = toKey(weekStart);
const weekDays = useMemo(
() => Array.from({ length: 7 }, (_, i) => addDays(weekStart, i)),
@@ -116,7 +113,11 @@ export default function PlanScreen() {
const toggleSlot = (dayIdx: number, meal: string) =>
setMealSlots((prev) =>
prev.map((meals, i) =>
i !== dayIdx ? meals : meals.includes(meal) ? meals.filter((m) => m !== meal) : [...meals, meal],
i !== dayIdx
? meals
: meals.includes(meal)
? meals.filter((m) => m !== meal)
: [...meals, meal],
),
);
@@ -362,10 +363,18 @@ export default function PlanScreen() {
{weekDays.map((d, i) => (
<Row
key={toKey(d)}
style={{ justifyContent: "space-between", alignItems: "center", marginVertical: 2 }}
style={{
justifyContent: "space-between",
alignItems: "center",
marginVertical: 2,
}}
>
<Small>
{cap(new Intl.DateTimeFormat(locale, { weekday: "short", day: "numeric" }).format(d))}
{cap(
new Intl.DateTimeFormat(locale, { weekday: "short", day: "numeric" }).format(
d,
),
)}
</Small>
<Row>
{(["breakfast", "lunch", "dinner"] as const).map((meal) => (
@@ -509,7 +518,7 @@ export default function PlanScreen() {
</>
) : null}
<Button
label="Byt rätt"
label={t("plan.swapDish")}
variant="secondary"
onPress={() => {
const e = actionEntry;
+6 -10
View File
@@ -48,19 +48,15 @@ const MAX_PHOTOS = 6; // matchar createScanInputSchema.imageCount.max
function askAddMore(count: number, maxCount: number): Promise<boolean> {
return new Promise((resolve) => {
if (count >= maxCount) {
Alert.alert("Klart", `Max ${maxCount} bilder den här skanningen analyserar.`, [
Alert.alert(t("scan.maxTitle"), t("scan.maxBody", { max: maxCount }), [
{ text: "OK", onPress: () => resolve(false) },
]);
return;
}
Alert.alert(
`Bild ${count} tagen`,
"Ta gärna fler vinklar av samma plats appen listar varje vara en gång. Varje bild räknas som en skanning.",
[
{ text: "Ta en till", onPress: () => resolve(true) },
{ text: `Analysera (${count})`, style: "default", onPress: () => resolve(false) },
],
);
Alert.alert(t("scan.photoTaken", { count }), t("scan.multiAngleHint"), [
{ text: t("scan.takeAnother"), onPress: () => resolve(true) },
{ text: t("scan.analyzeCount", { count }), style: "default", onPress: () => resolve(false) },
]);
});
}
@@ -220,7 +216,7 @@ export default function ScanScreen() {
<Small> {t("scan.tips.shelf")}</Small>
<Small> {t("scan.tips.light")}</Small>
<Small> {t("scan.tips.move")}</Small>
<Small> Kyl/frys/skafferi: ta flera bilder i samma skanning appen slår ihop dubbletter.</Small>
<Small> {t("scan.tips.merge")}</Small>
</Card>
</Screen>
);
+35 -11
View File
@@ -78,19 +78,43 @@ export default function RootLayout() {
name="cooking/[id]"
options={{ title: "", presentation: "fullScreenModal" }}
/>
<Stack.Screen name="scan-review/[jobId]" options={{ title: t("scan.review.title"), fullScreenGestureEnabled: false }} />
<Stack.Screen name="swap-meal/[entryId]" options={{ presentation: "modal", title: t("nav.swap") }} />
<Stack.Screen name="meal-review/[jobId]" options={{ title: t("scan.meal.title"), fullScreenGestureEnabled: false }} />
<Stack.Screen
name="scan-review/[jobId]"
options={{ title: t("scan.review.title"), fullScreenGestureEnabled: false }}
/>
<Stack.Screen
name="swap-meal/[entryId]"
options={{ presentation: "modal", title: t("nav.swap") }}
/>
<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"), 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="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: t("nav.savedRecipes") }} />
<Stack.Screen name="recipes" options={{ title: t("nav.browseRecipes") }} />
<Stack.Screen name="kitchen" options={{ title: t("nav.kitchen") }} />
<Stack.Screen name="profile" options={{ title: t("profile.title"), presentation: "modal" }} />
<Stack.Screen name="saved-recipes" options={{ title: t("nav.savedRecipes") }} />
<Stack.Screen name="recipes" options={{ title: t("nav.browseRecipes") }} />
<Stack.Screen name="kitchen" options={{ title: t("nav.kitchen") }} />
<Stack.Screen
name="profile"
options={{ title: t("profile.title"), presentation: "modal" }}
/>
<Stack.Screen
name="paywall"
options={{ title: t("paywall.title"), presentation: "modal" }}
+26 -5
View File
@@ -140,7 +140,11 @@ export default function BarcodeScreen() {
uris.push(a0.uri);
const wantBack = await new Promise<boolean>((resolve) =>
Alert.alert(t("barcode.contributing.frontDone"), t("barcode.contributing.backPrompt"), [
{ text: t("barcode.contributing.skipBack"), style: "cancel", onPress: () => resolve(false) },
{
text: t("barcode.contributing.skipBack"),
style: "cancel",
onPress: () => resolve(false),
},
{ text: t("barcode.contributing.shootBack"), onPress: () => resolve(true) },
]),
);
@@ -207,7 +211,12 @@ export default function BarcodeScreen() {
lastGtin.current = null;
};
if (!permission) return <Screen><Body>{t("scan.analyzing")}</Body></Screen>;
if (!permission)
return (
<Screen>
<Body>{t("scan.analyzing")}</Body>
</Screen>
);
if (!permission.granted) {
return (
<Screen>
@@ -277,14 +286,26 @@ export default function BarcodeScreen() {
{n ? (
<View style={styles.nutBox}>
<Small style={styles.nutTitle}>{t("barcode.info.title")}</Small>
<NutRow label={t("barcode.info.kcal")} value={`${Math.round(n.kcal)} kcal`} strong />
<NutRow
label={t("barcode.info.kcal")}
value={`${Math.round(n.kcal)} kcal`}
strong
/>
<NutRow label={t("barcode.info.fat")} value={fmtG(n.fatG)} />
<NutRow label={t("barcode.info.satfat")} value={fmtG(n.saturatedFatG)} sub />
{n.monounsaturatedFatG != null && (
<NutRow label={t("barcode.info.monofat")} value={fmtG(n.monounsaturatedFatG)} sub />
<NutRow
label={t("barcode.info.monofat")}
value={fmtG(n.monounsaturatedFatG)}
sub
/>
)}
{n.polyunsaturatedFatG != null && (
<NutRow label={t("barcode.info.polyfat")} value={fmtG(n.polyunsaturatedFatG)} sub />
<NutRow
label={t("barcode.info.polyfat")}
value={fmtG(n.polyunsaturatedFatG)}
sub
/>
)}
<NutRow label={t("barcode.info.carbs")} value={fmtG(n.carbsG)} />
<NutRow label={t("barcode.info.sugar")} value={fmtG(n.sugarG)} sub />
+34 -34
View File
@@ -251,40 +251,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,
}}
<Pressable
onPress={() => setShowIngredients((v) => !v)}
hitSlop={8}
style={{ alignSelf: "flex-start" }}
>
<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>
)}
<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 && (
<View
@@ -298,7 +298,7 @@ export default function CookingScreen() {
<Text style={{ fontSize: 22, fontWeight: "700", color: colors.primaryDark }}>
{formatTime(step.timerSeconds)}
</Text>
<Small>Ungefärlig tid för steget använd din telefons egen timer.</Small>
<Small>{t("cooking.timerHint")}</Small>
</View>
)}
<Small>{t("cooking.keepAwake")}</Small>
+33 -27
View File
@@ -37,14 +37,8 @@ interface InventoryItem {
}
const LOCATION_ORDER = ["fridge", "freezer", "pantry", "garage_freezer", "wine_fridge"];
const LOCATION_LABELS: Record<string, string> = {
fridge: "Kyl",
freezer: "Frys",
pantry: "Skafferi",
garage_freezer: "Garagefrys",
wine_fridge: "Vinkyl",
};
const locLabel = (type: string) => LOCATION_LABELS[type] ?? type;
const locLabel = (type: string) =>
LOCATION_ORDER.includes(type) ? t(`home.location.${type}`) : type;
const orderIndex = (type: string) => {
const i = LOCATION_ORDER.indexOf(type);
return i === -1 ? LOCATION_ORDER.length : i;
@@ -121,25 +115,30 @@ export default function KitchenScreen() {
const confirmDelete = (ids: string[], label: string) => {
if (ids.length === 0) return;
Alert.alert(
"Ta bort",
`Är du säker på att du vill ta bort ${ids.length} ${ids.length === 1 ? "vara" : "varor"} ur ${label}?`,
[
{ text: t("common.cancel"), style: "cancel" },
{ text: "Ta bort", style: "destructive", onPress: () => bulkRemove.mutate(ids) },
],
);
Alert.alert(t("common.remove"), t("kitchen.deleteConfirm", { label }), [
{ text: t("common.cancel"), style: "cancel" },
{ text: t("common.remove"), style: "destructive", onPress: () => bulkRemove.mutate(ids) },
]);
};
const emptyLabel = locFilter ? locLabel(locFilter) : q ? "sökningen" : "hela lagret";
const emptyLabel = locFilter
? locLabel(locFilter)
: q
? t("kitchen.scopeSearch")
: t("kitchen.scopeAll");
return (
<Screen>
<Input value={search} onChangeText={setSearch} placeholder="Sök vara…" autoCorrect={false} />
<Input
value={search}
onChangeText={setSearch}
placeholder={t("kitchen.searchPlaceholder")}
autoCorrect={false}
/>
<Row>
<Button
label="Alla"
label={t("common.all")}
variant={locFilter === null ? "primary" : "ghost"}
onPress={() => setLocFilter(null)}
/>
@@ -162,22 +161,25 @@ export default function KitchenScreen() {
<Button
label={`Ta bort valda (${selected.size})`}
variant="danger"
onPress={() => confirmDelete([...selected], "markeringen")}
onPress={() => confirmDelete([...selected], t("kitchen.scopeSelection"))}
/>
)}
{visible.length > 0 && (
<Button
label="Töm listan"
label={t("kitchen.clearList")}
variant="ghost"
onPress={() => confirmDelete(visible.map((i) => i.id), emptyLabel)}
onPress={() =>
confirmDelete(
visible.map((i) => i.id),
emptyLabel,
)
}
/>
)}
</Row>
</Row>
{visible.length === 0 && (
<EmptyState text="Inga varor att visa. Skanna eller lägg till varor." />
)}
{visible.length === 0 && <EmptyState text={t("kitchen.empty")} />}
{groups.map((g) => (
<View key={g.type} style={{ gap: spacing.xs, marginTop: spacing.sm }}>
@@ -191,7 +193,11 @@ export default function KitchenScreen() {
<Card
key={item.id}
onPress={() => toggle(item.id)}
style={isSel ? { borderColor: colors.primary, backgroundColor: colors.primarySoft } : undefined}
style={
isSel
? { borderColor: colors.primary, backgroundColor: colors.primarySoft }
: undefined
}
>
<Row style={{ justifyContent: "space-between", alignItems: "center" }}>
<Body>
@@ -199,7 +205,7 @@ export default function KitchenScreen() {
{item.displayName} · {formatQuantity(item.quantity, item.unit)}
</Body>
<Row style={{ alignItems: "center", gap: 12 }}>
{item.expiry.pastBestBefore && <Small> bäst före</Small>}
{item.expiry.pastBestBefore && <Small> {t("kitchen.pastBefore")}</Small>}
<Pressable
onPress={() => confirmDelete([item.id], locLabel(item.locationType))}
hitSlop={8}
+1 -1
View File
@@ -146,7 +146,7 @@ export default function MemoryScreen() {
/>
</>
)}
<Small>Personligt minne används aldrig som träningsdata utan separat samtycke.</Small>
<Small>{t("memory.trainingNote")}</Small>
</Screen>
);
}
+15 -5
View File
@@ -65,16 +65,26 @@ const DIETS = [
"low_carb",
"carnivore",
] as const;
const ALLERGENS = ["gluten", "milk", "eggs", "tree_nuts", "peanuts", "fish", "crustaceans", "soy", "sesame"] as const;
const ALLERGENS = [
"gluten",
"milk",
"eggs",
"tree_nuts",
"peanuts",
"fish",
"crustaceans",
"soy",
"sesame",
] as const;
const RELIGIOUS = ["none", "halal", "kosher", "hindu_no_beef", "buddhist_vegetarian"] as const;
export default function ProfileScreen() {
const queryClient = useQueryClient();
const rawLogout = useAuth((s) => s.logout);
const logout = async () => {
await firebaseAuthProvider.signOut().catch(() => {});
await rawLogout();
};
const logout = async () => {
await firebaseAuthProvider.signOut().catch(() => {});
await rawLogout();
};
const me = useQuery({ queryKey: ["me"], queryFn: () => api<Me>("/v1/me") });
const entitlements = useQuery({
+6 -6
View File
@@ -181,10 +181,7 @@ export default function RecipeScreen() {
},
onSuccess: (count) => {
void queryClient.invalidateQueries({ queryKey: ["shopping-lists"] });
Alert.alert(
t("recipe.coverage.added"),
t("recipe.coverage.addedBody", { count }),
);
Alert.alert(t("recipe.coverage.added"), t("recipe.coverage.addedBody", { count }));
},
onError: (err) => {
// Visa tydlig diagnostik: HTTP-status vs nätverksfel (hjälper felsökning).
@@ -233,7 +230,7 @@ export default function RecipeScreen() {
<Small>
{recipe.creatorDisplayName.includes("Redaktion")
? `Skapat av ${recipe.creatorDisplayName}`
: `Källa: ${recipe.creatorDisplayName}`}
: t("recipe.source", { name: recipe.creatorDisplayName })}
</Small>
)}
@@ -266,7 +263,10 @@ export default function RecipeScreen() {
<Row style={{ justifyContent: "space-between", alignItems: "center" }}>
<Heading>{t("recipe.coverage.have", { percent: recipe.coverage.percent })}</Heading>
{recipe.coverage.missing.length > 0 && (
<Tag label={t("recipe.coverage.missingCount", { count: recipe.coverage.missing.length })} tone="warning" />
<Tag
label={t("recipe.coverage.missingCount", { count: recipe.coverage.missing.length })}
tone="warning"
/>
)}
</Row>
{recipe.coverage.missing.length === 0 ? (
+13 -19
View File
@@ -53,24 +53,24 @@ const CATEGORIES: ReadonlyArray<{
tag?: string;
glyph: string;
}> = [
{ key: "all", labelKey: null, glyph: "🍴" },
{ key: "all", labelKey: "common.all", glyph: "🍴" },
{ key: "breakfast", labelKey: "myday.mealType.breakfast", mealType: "breakfast", glyph: "🥣" },
{ key: "lunch", labelKey: "myday.mealType.lunch", mealType: "lunch", glyph: "🥗" },
{ key: "dinner", labelKey: "myday.mealType.dinner", mealType: "dinner", glyph: "🍽️" },
{ key: "snack", labelKey: "myday.mealType.snack", mealType: "snack", glyph: "🍎" },
{ key: "dessert", labelKey: "myday.mealType.dessert", mealType: "dessert", glyph: "🍰" },
{ key: "baking", labelKey: null, label: "Baka", tag: "baking", glyph: "🥐" },
{ key: "baking", labelKey: "cat.baking", tag: "baking", glyph: "🥐" },
];
// Under-kategorier som visas när "Baka" är vald. Varje bakverk bär både taggen
// "baking" och en under-typ, så vi kan filtrera på under-typen direkt.
const BAKING_SUBS: ReadonlyArray<{ key: string; label: string; tag?: string }> = [
{ key: "all", label: "Alla" },
{ key: "bread", label: "Bröd", tag: "bread" },
{ key: "bun", label: "Bullar", tag: "bun" },
{ key: "pie", label: "Paj", tag: "pie" },
{ key: "cake", label: "Tårtor & kakor", tag: "cake" },
{ key: "cookie", label: "Småkakor", tag: "cookie" },
const BAKING_SUBS: ReadonlyArray<{ key: string; labelKey: string; tag?: string }> = [
{ key: "all", labelKey: "common.all" },
{ key: "bread", labelKey: "recipes.sub.bread", tag: "bread" },
{ key: "bun", labelKey: "recipes.sub.bun", tag: "bun" },
{ key: "pie", labelKey: "recipes.sub.pie", tag: "pie" },
{ key: "cake", labelKey: "recipes.sub.cake", tag: "cake" },
{ key: "cookie", labelKey: "recipes.sub.cookie", tag: "cookie" },
];
export default function BrowseRecipesScreen() {
@@ -112,7 +112,7 @@ export default function BrowseRecipesScreen() {
return (
<Screen style={{ gap: spacing.md }}>
<Input
placeholder="Sök recept…"
placeholder={t("recipes.searchPlaceholder")}
autoCapitalize="none"
autoCorrect={false}
value={search}
@@ -123,7 +123,7 @@ export default function BrowseRecipesScreen() {
{CATEGORIES.map((c) => (
<Button
key={c.key}
label={`${c.glyph} ${c.labelKey ? t(c.labelKey as never) : (c.label ?? "Alla")}`}
label={`${c.glyph} ${c.labelKey ? t(c.labelKey as never) : ""}`}
variant={category === c.key ? "secondary" : "ghost"}
onPress={() => selectCategory(c.key)}
/>
@@ -136,7 +136,7 @@ export default function BrowseRecipesScreen() {
{BAKING_SUBS.map((s) => (
<Button
key={s.key}
label={s.label}
label={t(s.labelKey)}
variant={sub === s.key ? "secondary" : "ghost"}
onPress={() => setSub(s.key)}
/>
@@ -149,13 +149,7 @@ export default function BrowseRecipesScreen() {
) : query.isError ? (
<ErrorView onRetry={() => void query.refetch()} />
) : recipes.length === 0 ? (
<EmptyState
text={
term
? "Inga recept matchar din sökning."
: "Inga recept i den här kategorin än de dyker upp när katalogen växer."
}
/>
<EmptyState text={term ? t("wte.emptySearch") : t("recipes.emptyCategory")} />
) : (
recipes.map((r) => (
<Card
+61 -47
View File
@@ -2,64 +2,78 @@ import { useState } from "react";
import { router } from "expo-router";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api";
import { Card, EmptyState, ErrorView, Heading, Input, LoadingView, Screen, Small } from "@/components/ui";
import { t } from "@/lib/i18n";
import {
Card,
EmptyState,
ErrorView,
Heading,
Input,
LoadingView,
Screen,
Small,
} from "@/components/ui";
import { spacing } from "@/lib/theme";
/** Dina sparade recept (favoriter) sökbar lista. Backend: GET /v1/recipes/favorites/mine. */
interface SavedRecipe {
id: string;
titleSv: string;
totalTimeMinutes: number | null;
nutritionPerPortion: { kcal?: number } | null;
id: string;
titleSv: string;
totalTimeMinutes: number | null;
nutritionPerPortion: { kcal?: number } | null;
}
interface FavoritesResponse {
recipes: SavedRecipe[];
recipes: SavedRecipe[];
}
export default function SavedRecipesScreen() {
const [query, setQuery] = useState("");
const favs = useQuery<FavoritesResponse>({
queryKey: ["favorites-mine"],
queryFn: () => api<FavoritesResponse>("/v1/recipes/favorites/mine"),
});
const [query, setQuery] = useState("");
const favs = useQuery<FavoritesResponse>({
queryKey: ["favorites-mine"],
queryFn: () => api<FavoritesResponse>("/v1/recipes/favorites/mine"),
});
if (favs.isLoading) return <LoadingView />;
if (favs.isError) return <ErrorView onRetry={() => void favs.refetch()} />;
if (favs.isLoading) return <LoadingView />;
if (favs.isError) return <ErrorView onRetry={() => void favs.refetch()} />;
const all = favs.data?.recipes ?? [];
const q = query.trim().toLowerCase();
const filtered = q ? all.filter((r) => r.titleSv.toLowerCase().includes(q)) : all;
const all = favs.data?.recipes ?? [];
const q = query.trim().toLowerCase();
const filtered = q ? all.filter((r) => r.titleSv.toLowerCase().includes(q)) : all;
return (
<Screen style={{ gap: spacing.md }}>
<Input
placeholder="Sök bland dina sparade recept"
autoCapitalize="none"
value={query}
onChangeText={setQuery}
/>
{all.length === 0 ? (
<EmptyState text="Du har inga sparade recept än. Tryck på ☆ Spara på ett recept så hamnar det här." />
) : filtered.length === 0 ? (
<EmptyState text="Inga sparade recept matchar din sökning." />
) : (
filtered.map((r) => (
<Card key={r.id} onPress={() => router.push(`/recipe/${r.id}`)} style={{ gap: spacing.xs }}>
<Heading>{r.titleSv}</Heading>
<Small>
{[
r.totalTimeMinutes != null ? `${r.totalTimeMinutes} min` : null,
r.nutritionPerPortion?.kcal != null
? `${Math.round(r.nutritionPerPortion.kcal)} kcal`
: null,
]
.filter(Boolean)
.join(" · ")}
</Small>
</Card>
))
)}
</Screen>
);
return (
<Screen style={{ gap: spacing.md }}>
<Input
placeholder={t("savedRecipes.searchPlaceholder")}
autoCapitalize="none"
value={query}
onChangeText={setQuery}
/>
{all.length === 0 ? (
<EmptyState text={t("savedRecipes.empty")} />
) : filtered.length === 0 ? (
<EmptyState text={t("savedRecipes.emptySearch")} />
) : (
filtered.map((r) => (
<Card
key={r.id}
onPress={() => router.push(`/recipe/${r.id}`)}
style={{ gap: spacing.xs }}
>
<Heading>{r.titleSv}</Heading>
<Small>
{[
r.totalTimeMinutes != null ? `${r.totalTimeMinutes} min` : null,
r.nutritionPerPortion?.kcal != null
? `${Math.round(r.nutritionPerPortion.kcal)} kcal`
: null,
]
.filter(Boolean)
.join(" · ")}
</Small>
</Card>
))
)}
</Screen>
);
}
+2 -2
View File
@@ -125,9 +125,9 @@ export default function ShoppingScreen() {
});
const confirmRemove = (item: ShoppingItem) =>
Alert.alert(item.displayName, "Ta bort varan från listan?", [
Alert.alert(item.displayName, t("shopping.removeConfirm"), [
{ text: t("common.cancel"), style: "cancel" },
{ text: "Ta bort", style: "destructive", onPress: () => remove.mutate(item.id) },
{ text: t("common.remove"), style: "destructive", onPress: () => remove.mutate(item.id) },
]);
const complete = useMutation({
+4 -7
View File
@@ -31,12 +31,12 @@ export default function SubmitRecipeScreen() {
setError(null);
// Bygg en tydlig fritext-mall som AI:n tolkar robust.
const text = [
`Titel: ${title.trim()}`,
`Titel: ${title.trim()}`, // i18n-ignore (fri-text-payload till receptparsern, ej UI)
"",
"Ingredienser:",
"Ingredienser:", // i18n-ignore
ingredients.trim(),
"",
"Gör så här:",
"Gör så här:", // i18n-ignore
steps.trim(),
].join("\n");
try {
@@ -73,10 +73,7 @@ export default function SubmitRecipeScreen() {
<Heading> {t("submit.premiumTitle")}</Heading>
<Body>{t("submit.premiumBody")}</Body>
<Spacer size={spacing.md} />
<Button
label={t("submit.seePlans")}
onPress={() => router.replace("/profile")}
/>
<Button label={t("submit.seePlans")} onPress={() => router.replace("/profile")} />
<Spacer size={spacing.xs} />
<Button label={t("submit.backHome")} variant="ghost" onPress={() => router.back()} />
</Card>
+12 -8
View File
@@ -80,18 +80,23 @@ export default function SwapMealScreen() {
const recs = query.data?.recommendations ?? [];
const mealLabel =
mealType === "lunch" ? "lunchen" : mealType === "breakfast" ? "frukosten" : "middagen";
mealType === "lunch"
? t("swap.mealLunch")
: mealType === "breakfast"
? t("swap.mealBreakfast")
: t("swap.mealDinner");
return (
<Screen>
<Title>{addMode ? "Lägg till måltid" : "Byt ut måltiden"}</Title>
<Title>{addMode ? t("swap.addTitle") : t("swap.swapTitle")}</Title>
<Small>
{addMode ? `Välj en rätt för ${mealLabel}` : `Välj en annan rätt för ${mealLabel}`} eller
sök efter vilken rätt som helst.
{addMode
? t("swap.chooseFor", { meal: mealLabel })
: t("swap.chooseOther", { meal: mealLabel })}
</Small>
<Spacer size={spacing.sm} />
<Input
placeholder="Sök bland alla recept …"
placeholder={t("swap.searchPlaceholder")}
value={searchText}
onChangeText={setSearchText}
onSubmitEditing={() => setSubmitted(searchText.trim())}
@@ -100,8 +105,7 @@ export default function SwapMealScreen() {
/>
{submitted ? (
<Small>
Visar träffar för {submitted}.{" "}
<Small>Rensa sökrutan och sök tomt för att se förslag igen.</Small>
{t("swap.showingResults", { query: submitted })} <Small>{t("swap.clearHint")}</Small>
</Small>
) : null}
<Spacer size={spacing.xs} />
@@ -109,7 +113,7 @@ export default function SwapMealScreen() {
{query.isLoading && <LoadingView />}
{query.isError && <ErrorView onRetry={() => void query.refetch()} />}
{query.data && recs.length === 0 && (
<EmptyState text={submitted ? "Inga recept matchar sökningen." : t("wte.empty")} />
<EmptyState text={submitted ? t("wte.emptySearch") : t("wte.empty")} />
)}
{recs.map((rec) => (
<Card key={rec.recipeId} onPress={() => swap.mutate(rec.recipeId)}>