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)}>
+2 -5
View File
@@ -1,5 +1,6 @@
import Constants from "expo-constants";
import { useAuth } from "./auth";
import { t } from "./i18n";
/**
* API-klient. Mobilappen pratar ENDAST med Food API aldrig direkt med
@@ -111,11 +112,7 @@ export async function uploadImage(
if (!res.ok) throw new ApiError(res.status, "UPLOAD_FAILED", "Bilduppladdningen misslyckades.");
} catch (err) {
if (err instanceof Error && err.name === "AbortError") {
throw new ApiError(
0,
"UPLOAD_TIMEOUT",
"Uppladdningen tog för lång tid kontrollera nätverket och försök igen.",
);
throw new ApiError(0, "UPLOAD_TIMEOUT", t("errors.uploadTimeout"));
}
throw err;
} finally {
+62 -62
View File
@@ -1,15 +1,15 @@
import { initializeApp, getApps, getApp } from "firebase/app";
import {
initializeAuth,
getAuth,
onAuthStateChanged as fbOnAuthStateChanged,
signInWithEmailAndPassword,
createUserWithEmailAndPassword,
signOut as fbSignOut,
sendPasswordResetEmail,
type Auth,
type User,
type Persistence,
initializeAuth,
getAuth,
onAuthStateChanged as fbOnAuthStateChanged,
signInWithEmailAndPassword,
createUserWithEmailAndPassword,
signOut as fbSignOut,
sendPasswordResetEmail,
type Auth,
type User,
type Persistence,
} from "firebase/auth";
import * as fbAuth from "firebase/auth";
import AsyncStorage from "@react-native-async-storage/async-storage";
@@ -22,12 +22,12 @@ import type { AuthProvider, AuthUser, Unsubscribe } from "./types";
* Publik klient-config (identifierare, ingen hemlighet).
*/
const firebaseConfig = {
apiKey: "AIzaSyAH2F-aZRvZXQtGD7X8cF9Zp3yXUhEl3sM",
authDomain: "cibello-c2ff3.firebaseapp.com",
projectId: "cibello-c2ff3",
storageBucket: "cibello-c2ff3.firebasestorage.app",
messagingSenderId: "1034102905039",
appId: "1:1034102905039:web:12e31120989655f34affba",
apiKey: "AIzaSyAH2F-aZRvZXQtGD7X8cF9Zp3yXUhEl3sM",
authDomain: "cibello-c2ff3.firebaseapp.com",
projectId: "cibello-c2ff3",
storageBucket: "cibello-c2ff3.firebasestorage.app",
messagingSenderId: "1034102905039",
appId: "1:1034102905039:web:12e31120989655f34affba",
};
const app = getApps().length ? getApp() : initializeApp(firebaseConfig);
@@ -35,7 +35,7 @@ const app = getApps().length ? getApp() : initializeApp(firebaseConfig);
// getReactNativePersistence finns i runtime men saknas ibland i typerna
// (firebase-js-sdk#9316) hämtas därför via en typsäker modul-cast.
const getReactNativePersistence = (
fbAuth as unknown as { getReactNativePersistence: (storage: unknown) => Persistence }
fbAuth as unknown as { getReactNativePersistence: (storage: unknown) => Persistence }
).getReactNativePersistence;
// Persistens så inloggningen överlever omstart. På webben (react-native-web)
@@ -44,56 +44,56 @@ const getReactNativePersistence = (
// Där använder vi Firebases standard-webblagring (getAuth), på native AsyncStorage.
let auth: Auth;
try {
auth =
Platform.OS === "web"
? getAuth(app)
: initializeAuth(app, { persistence: getReactNativePersistence(AsyncStorage) });
auth =
Platform.OS === "web"
? getAuth(app)
: initializeAuth(app, { persistence: getReactNativePersistence(AsyncStorage) });
} catch {
// initializeAuth kastar om den redan körts (Fast Refresh) återanvänd instansen.
auth = getAuth(app);
// initializeAuth kastar om den redan körts (Fast Refresh) återanvänd instansen.
auth = getAuth(app);
}
function toAuthUser(u: User | null): AuthUser | null {
if (!u) return null;
return {
uid: u.uid,
email: u.email,
emailVerified: u.emailVerified,
displayName: u.displayName,
providerId: u.providerData[0]?.providerId ?? null,
};
if (!u) return null;
return {
uid: u.uid,
email: u.email,
emailVerified: u.emailVerified,
displayName: u.displayName,
providerId: u.providerData[0]?.providerId ?? null,
};
}
export const firebaseAuthProvider: AuthProvider = {
async signUpWithEmail(email, password) {
const cred = await createUserWithEmailAndPassword(auth, email, password);
return toAuthUser(cred.user)!;
},
async signInWithEmail(email, password) {
const cred = await signInWithEmailAndPassword(auth, email, password);
return toAuthUser(cred.user)!;
},
async signInWithGoogle(): Promise<AuthUser> {
throw new Error("Google-inloggning läggs till i AUTH-6.");
},
async signInWithApple(): Promise<AuthUser> {
throw new Error("Apple-inloggning läggs till i AUTH-6.");
},
async signOut() {
await fbSignOut(auth);
},
async sendPasswordReset(email) {
await sendPasswordResetEmail(auth, email);
},
currentUser() {
return toAuthUser(auth.currentUser);
},
onAuthStateChanged(callback): Unsubscribe {
return fbOnAuthStateChanged(auth, (u) => callback(toAuthUser(u)));
},
async getIdToken(forceRefresh = false) {
const u = auth.currentUser;
if (!u) return null;
return u.getIdToken(forceRefresh);
},
async signUpWithEmail(email, password) {
const cred = await createUserWithEmailAndPassword(auth, email, password);
return toAuthUser(cred.user)!;
},
async signInWithEmail(email, password) {
const cred = await signInWithEmailAndPassword(auth, email, password);
return toAuthUser(cred.user)!;
},
async signInWithGoogle(): Promise<AuthUser> {
throw new Error("Google-inloggning läggs till i AUTH-6."); // i18n-ignore (temporär dev-stub)
},
async signInWithApple(): Promise<AuthUser> {
throw new Error("Apple-inloggning läggs till i AUTH-6."); // i18n-ignore (temporär dev-stub)
},
async signOut() {
await fbSignOut(auth);
},
async sendPasswordReset(email) {
await sendPasswordResetEmail(auth, email);
},
currentUser() {
return toAuthUser(auth.currentUser);
},
onAuthStateChanged(callback): Unsubscribe {
return fbOnAuthStateChanged(auth, (u) => callback(toAuthUser(u)));
},
async getIdToken(forceRefresh = false) {
const u = auth.currentUser;
if (!u) return null;
return u.getIdToken(forceRefresh);
},
};
+23 -23
View File
@@ -10,13 +10,13 @@
/** Minimal, leverantörsneutral användarrepresentation. */
export interface AuthUser {
/** Stabil unik identitet från identitetsleverantören (Firebase-UID i dag). */
uid: string;
email: string | null;
emailVerified: boolean;
displayName: string | null;
/** t.ex. "password" | "google.com" | "apple.com". Neutral sträng. */
providerId: string | null;
/** Stabil unik identitet från identitetsleverantören (Firebase-UID i dag). */
uid: string;
email: string | null;
emailVerified: boolean;
displayName: string | null;
/** t.ex. "password" | "google.com" | "apple.com". Neutral sträng. */
providerId: string | null;
}
/** Avregistreringsfunktion som returneras av onAuthStateChanged. */
@@ -27,20 +27,20 @@ export type Unsubscribe = () => void;
* Alla nätverksmetoder är async och kastar vid fel (fångas i UI-lagret).
*/
export interface AuthProvider {
signUpWithEmail(email: string, password: string): Promise<AuthUser>;
signInWithEmail(email: string, password: string): Promise<AuthUser>;
signInWithGoogle(): Promise<AuthUser>;
signInWithApple(): Promise<AuthUser>;
signOut(): Promise<void>;
/** Skickar återställningsmejl för lösenord (leverantören sköter mejlet). */
sendPasswordReset(email: string): Promise<void>;
/** Nuvarande inloggade användare, eller null. Synkront ögonblicksvärde. */
currentUser(): AuthUser | null;
/** Prenumererar på inloggningsändringar. Returnerar avregistrering. */
onAuthStateChanged(callback: (user: AuthUser | null) => void): Unsubscribe;
/**
* Hämtar aktuell ID-token för backend-verifiering. forceRefresh tvingar
* förnyelse mot leverantören. null om ingen är inloggad.
*/
getIdToken(forceRefresh?: boolean): Promise<string | null>;
signUpWithEmail(email: string, password: string): Promise<AuthUser>;
signInWithEmail(email: string, password: string): Promise<AuthUser>;
signInWithGoogle(): Promise<AuthUser>;
signInWithApple(): Promise<AuthUser>;
signOut(): Promise<void>;
/** Skickar återställningsmejl för lösenord (leverantören sköter mejlet). */
sendPasswordReset(email: string): Promise<void>;
/** Nuvarande inloggade användare, eller null. Synkront ögonblicksvärde. */
currentUser(): AuthUser | null;
/** Prenumererar på inloggningsändringar. Returnerar avregistrering. */
onAuthStateChanged(callback: (user: AuthUser | null) => void): Unsubscribe;
/**
* Hämtar aktuell ID-token för backend-verifiering. forceRefresh tvingar
* förnyelse mot leverantören. null om ingen är inloggad.
*/
getIdToken(forceRefresh?: boolean): Promise<string | null>;
}
+2 -1
View File
@@ -88,7 +88,8 @@ interface I18nState {
const useI18nStore = create<I18nState>((set) => ({
version: 0,
languageTag: "sv-SE",
bump: (languageTag) => set((s) => (s.languageTag === languageTag ? s : { version: s.version + 1, languageTag })),
bump: (languageTag) =>
set((s) => (s.languageTag === languageTag ? s : { version: s.version + 1, languageTag })),
}));
/** Rotlayouten läser denna och re-monterar trädet vid språkbyte. */
+47
View File
@@ -51,6 +51,53 @@
"common.add": "Tilføj",
"common.back": "Tilbage",
"common.cancel": "Annuller",
"common.remove": "Fjern",
"common.all": "Alle",
"auth.tagline": "Mindre spild, mindre stress, mere tilbage i pungen",
"auth.passwordMin8": "Adgangskoden skal være mindst 8 tegn.",
"auth.passwordMismatch": "Adgangskoderne er ikke ens.",
"auth.repeatPassword": "Gentag adgangskode",
"plan.swapDish": "Skift ret",
"scan.maxTitle": "Færdig",
"scan.maxBody": "Maks. {max} billeder i denne scanning analyserer.",
"scan.photoTaken": "Billede {count} taget",
"scan.multiAngleHint": "Tag gerne flere vinkler af samme sted appen viser hver vare én gang. Hvert billede tæller som en scanning.",
"scan.takeAnother": "Tag en mere",
"scan.analyzeCount": "Analysér ({count})",
"scan.tips.merge": "Køl/frys/spisekammer: tag flere billeder i samme scanning appen slår dubletter sammen.",
"cooking.timerHint": "Omtrentlig tid for trinnet brug din telefons egen timer.",
"kitchen.deleteConfirm": "Fjern valgte varer fra {label}?",
"kitchen.scopeSearch": "søgningen",
"kitchen.scopeAll": "hele lageret",
"kitchen.scopeSelection": "markeringen",
"kitchen.searchPlaceholder": "Søg vare…",
"kitchen.clearList": "Tøm listen",
"kitchen.empty": "Ingen varer at vise. Scan eller tilføj varer.",
"kitchen.pastBefore": "bedst før",
"memory.trainingNote": "Personlig hukommelse bruges aldrig som træningsdata uden separat samtykke.",
"recipe.source": "Kilde: {name}",
"recipes.sub.bread": "Brød",
"recipes.sub.bun": "Boller",
"recipes.sub.pie": "Tærte",
"recipes.sub.cake": "Lagkager & kager",
"recipes.sub.cookie": "Småkager",
"recipes.searchPlaceholder": "Søg opskrift…",
"recipes.emptyCategory": "Ingen opskrifter i denne kategori endnu de dukker op, når kataloget vokser.",
"savedRecipes.searchPlaceholder": "Søg blandt dine gemte opskrifter",
"savedRecipes.empty": "Du har ingen gemte opskrifter endnu. Tryk på ☆ Gem på en opskrift, så havner den her.",
"savedRecipes.emptySearch": "Ingen gemte opskrifter matcher din søgning.",
"shopping.removeConfirm": "Fjern varen fra listen?",
"swap.addTitle": "Tilføj måltid",
"swap.swapTitle": "Skift måltid",
"swap.chooseFor": "Vælg en ret til {meal} eller søg efter en hvilken som helst ret.",
"swap.chooseOther": "Vælg en anden ret til {meal} eller søg efter en hvilken som helst ret.",
"swap.mealLunch": "frokosten",
"swap.mealBreakfast": "morgenmaden",
"swap.mealDinner": "aftensmaden",
"swap.searchPlaceholder": "Søg blandt alle opskrifter …",
"swap.showingResults": "Viser resultater for ”{query}”.",
"swap.clearHint": "Ryd søgefeltet og søg på tomt for at se forslag igen.",
"errors.uploadTimeout": "Uploadet tog for lang tid tjek netværket og prøv igen.",
"common.done": "Færdig",
"common.error": "Noget gik galt. Prøv igen.",
"common.estimate": "Estimat",
+47
View File
@@ -51,6 +51,53 @@
"common.add": "Hinzufügen",
"common.back": "Zurück",
"common.cancel": "Abbrechen",
"common.remove": "Entfernen",
"common.all": "Alle",
"auth.tagline": "Weniger Verschwendung, weniger Stress, mehr im Geldbeutel",
"auth.passwordMin8": "Das Passwort muss mindestens 8 Zeichen lang sein.",
"auth.passwordMismatch": "Die Passwörter stimmen nicht überein.",
"auth.repeatPassword": "Passwort wiederholen",
"plan.swapDish": "Gericht tauschen",
"scan.maxTitle": "Fertig",
"scan.maxBody": "Max. {max} Bilder für diesen Scan wird analysiert.",
"scan.photoTaken": "Bild {count} aufgenommen",
"scan.multiAngleHint": "Fotografiere die gleiche Stelle ruhig aus mehreren Winkeln die App listet jede Ware nur einmal. Jedes Bild zählt als ein Scan.",
"scan.takeAnother": "Noch eins aufnehmen",
"scan.analyzeCount": "Analysieren ({count})",
"scan.tips.merge": "Kühlschrank/Gefrierfach/Vorrat: Nimm mehrere Bilder im selben Scan auf die App führt Duplikate zusammen.",
"cooking.timerHint": "Ungefähre Zeit für den Schritt nutze den Timer deines Handys.",
"kitchen.deleteConfirm": "Ausgewählte Waren aus {label} entfernen?",
"kitchen.scopeSearch": "der Suche",
"kitchen.scopeAll": "dem gesamten Bestand",
"kitchen.scopeSelection": "der Auswahl",
"kitchen.searchPlaceholder": "Ware suchen…",
"kitchen.clearList": "Liste leeren",
"kitchen.empty": "Keine Waren vorhanden. Scanne oder füge Waren hinzu.",
"kitchen.pastBefore": "mindestens haltbar bis",
"memory.trainingNote": "Persönliche Erinnerungen werden niemals ohne gesonderte Einwilligung als Trainingsdaten verwendet.",
"recipe.source": "Quelle: {name}",
"recipes.sub.bread": "Brot",
"recipes.sub.bun": "Brötchen",
"recipes.sub.pie": "Tarte",
"recipes.sub.cake": "Torten & Kuchen",
"recipes.sub.cookie": "Kekse",
"recipes.searchPlaceholder": "Rezepte suchen…",
"recipes.emptyCategory": "Noch keine Rezepte in dieser Kategorie sie erscheinen, sobald der Katalog wächst.",
"savedRecipes.searchPlaceholder": "In deinen gespeicherten Rezepten suchen",
"savedRecipes.empty": "Du hast noch keine gespeicherten Rezepte. Tippe bei einem Rezept auf ☆ Speichern, dann landet es hier.",
"savedRecipes.emptySearch": "Keine gespeicherten Rezepte passen zu deiner Suche.",
"shopping.removeConfirm": "Ware von der Liste entfernen?",
"swap.addTitle": "Mahlzeit hinzufügen",
"swap.swapTitle": "Mahlzeit austauschen",
"swap.chooseFor": "Wähle ein Gericht für {meal} oder suche nach einem beliebigen Gericht.",
"swap.chooseOther": "Wähle ein anderes Gericht für {meal} oder suche nach einem beliebigen Gericht.",
"swap.mealLunch": "das Mittagessen",
"swap.mealBreakfast": "das Frühstück",
"swap.mealDinner": "das Abendessen",
"swap.searchPlaceholder": "In allen Rezepten suchen …",
"swap.showingResults": "Treffer für ”{query}” werden angezeigt.",
"swap.clearHint": "Leere das Suchfeld und suche mit leerer Eingabe, um wieder Vorschläge zu sehen.",
"errors.uploadTimeout": "Der Upload hat zu lange gedauert prüfe dein Netzwerk und versuche es erneut.",
"common.done": "Fertig",
"common.error": "Etwas ist schiefgelaufen. Bitte erneut versuchen.",
"common.estimate": "Schätzung",
+47
View File
@@ -51,6 +51,53 @@
"common.add": "Add",
"common.back": "Back",
"common.cancel": "Cancel",
"common.remove": "Remove",
"common.all": "All",
"auth.tagline": "Less waste, less stress, more left in your wallet",
"auth.passwordMin8": "Password must be at least 8 characters.",
"auth.passwordMismatch": "Passwords don't match.",
"auth.repeatPassword": "Repeat password",
"plan.swapDish": "Swap dish",
"scan.maxTitle": "Done",
"scan.maxBody": "Max {max} photos this scan analyzing.",
"scan.photoTaken": "Photo {count} taken",
"scan.multiAngleHint": "Feel free to take more angles of the same spot the app lists each item once. Each photo counts as one scan.",
"scan.takeAnother": "Take another",
"scan.analyzeCount": "Analyze ({count})",
"scan.tips.merge": "Fridge/freezer/pantry: take several photos in the same scan the app merges duplicates.",
"cooking.timerHint": "Approximate time for the step use your phone's own timer.",
"kitchen.deleteConfirm": "Remove selected items from {label}?",
"kitchen.scopeSearch": "the search",
"kitchen.scopeAll": "the whole inventory",
"kitchen.scopeSelection": "the selection",
"kitchen.searchPlaceholder": "Search item…",
"kitchen.clearList": "Clear list",
"kitchen.empty": "No items to show. Scan or add items.",
"kitchen.pastBefore": "best before",
"memory.trainingNote": "Personal memory is never used as training data without separate consent.",
"recipe.source": "Source: {name}",
"recipes.sub.bread": "Bread",
"recipes.sub.bun": "Buns",
"recipes.sub.pie": "Pie",
"recipes.sub.cake": "Cakes & bakes",
"recipes.sub.cookie": "Cookies",
"recipes.searchPlaceholder": "Search recipes…",
"recipes.emptyCategory": "No recipes in this category yet they'll appear as the catalog grows.",
"savedRecipes.searchPlaceholder": "Search your saved recipes",
"savedRecipes.empty": "You don't have any saved recipes yet. Tap ☆ Save on a recipe and it'll show up here.",
"savedRecipes.emptySearch": "No saved recipes match your search.",
"shopping.removeConfirm": "Remove the item from the list?",
"swap.addTitle": "Add meal",
"swap.swapTitle": "Swap meal",
"swap.chooseFor": "Choose a dish for {meal} or search for any dish.",
"swap.chooseOther": "Choose a different dish for {meal} or search for any dish.",
"swap.mealLunch": "lunch",
"swap.mealBreakfast": "breakfast",
"swap.mealDinner": "dinner",
"swap.searchPlaceholder": "Search all recipes …",
"swap.showingResults": "Showing results for ”{query}”.",
"swap.clearHint": "Clear the search box and search with it empty to see suggestions again.",
"errors.uploadTimeout": "The upload took too long check your network and try again.",
"common.done": "Done",
"common.error": "Something went wrong. Please try again.",
"common.estimate": "Estimate",
+47
View File
@@ -51,6 +51,53 @@
"common.add": "Añadir",
"common.back": "Atrás",
"common.cancel": "Cancelar",
"common.remove": "Eliminar",
"common.all": "Todos",
"auth.tagline": "Menos desperdicio, menos estrés, más dinero en el bolsillo",
"auth.passwordMin8": "La contraseña debe tener al menos 8 caracteres.",
"auth.passwordMismatch": "Las contraseñas no coinciden.",
"auth.repeatPassword": "Repite la contraseña",
"plan.swapDish": "Cambiar plato",
"scan.maxTitle": "Listo",
"scan.maxBody": "Máximo {max} fotos en este escaneo: analizando.",
"scan.photoTaken": "Foto {count} hecha",
"scan.multiAngleHint": "Haz varias fotos del mismo sitio desde distintos ángulos: la app lista cada producto una sola vez. Cada foto cuenta como un escaneo.",
"scan.takeAnother": "Hacer otra",
"scan.analyzeCount": "Analizar ({count})",
"scan.tips.merge": "Nevera/congelador/despensa: haz varias fotos en el mismo escaneo y la app combina los duplicados.",
"cooking.timerHint": "Tiempo aproximado del paso: usa el temporizador de tu teléfono.",
"kitchen.deleteConfirm": "¿Eliminar los productos seleccionados de {label}?",
"kitchen.scopeSearch": "la búsqueda",
"kitchen.scopeAll": "todo el inventario",
"kitchen.scopeSelection": "la selección",
"kitchen.searchPlaceholder": "Buscar producto…",
"kitchen.clearList": "Vaciar la lista",
"kitchen.empty": "No hay productos que mostrar. Escanea o añade algunos.",
"kitchen.pastBefore": "consumir antes de",
"memory.trainingNote": "La memoria personal nunca se usa como datos de entrenamiento sin un consentimiento por separado.",
"recipe.source": "Fuente: {name}",
"recipes.sub.bread": "Pan",
"recipes.sub.bun": "Bollos",
"recipes.sub.pie": "Pasteles",
"recipes.sub.cake": "Tartas y bizcochos",
"recipes.sub.cookie": "Galletas",
"recipes.searchPlaceholder": "Buscar recetas…",
"recipes.emptyCategory": "Aún no hay recetas en esta categoría: aparecerán a medida que crezca el catálogo.",
"savedRecipes.searchPlaceholder": "Busca en tus recetas guardadas",
"savedRecipes.empty": "Aún no tienes recetas guardadas. Toca ☆ Guardar en una receta y aparecerá aquí.",
"savedRecipes.emptySearch": "Ninguna receta guardada coincide con tu búsqueda.",
"shopping.removeConfirm": "¿Eliminar el producto de la lista?",
"swap.addTitle": "Añadir comida",
"swap.swapTitle": "Cambiar la comida",
"swap.chooseFor": "Elige un plato para {meal} o busca el que quieras.",
"swap.chooseOther": "Elige otro plato para {meal} o busca el que quieras.",
"swap.mealLunch": "la comida",
"swap.mealBreakfast": "el desayuno",
"swap.mealDinner": "la cena",
"swap.searchPlaceholder": "Busca en todas las recetas…",
"swap.showingResults": "Mostrando resultados de ”{query}”.",
"swap.clearHint": "Vacía el cuadro de búsqueda y busca sin texto para volver a ver las sugerencias.",
"errors.uploadTimeout": "La subida ha tardado demasiado: comprueba la red e inténtalo de nuevo.",
"common.done": "Listo",
"common.error": "Algo salió mal. Inténtalo de nuevo.",
"common.estimate": "Estimación",
+47
View File
@@ -51,6 +51,53 @@
"common.add": "Lisää",
"common.back": "Takaisin",
"common.cancel": "Peruuta",
"common.remove": "Poista",
"common.all": "Kaikki",
"auth.tagline": "Vähemmän hävikkiä, vähemmän stressiä, enemmän jää lompakkoon",
"auth.passwordMin8": "Salasanan on oltava vähintään 8 merkkiä.",
"auth.passwordMismatch": "Salasanat eivät täsmää.",
"auth.repeatPassword": "Toista salasana",
"plan.swapDish": "Vaihda ruoka",
"scan.maxTitle": "Valmis",
"scan.maxBody": "Enintään {max} kuvaa tässä skannauksessa analysoidaan.",
"scan.photoTaken": "Kuva {count} otettu",
"scan.multiAngleHint": "Ota mielellään useampia kuvakulmia samasta paikasta sovellus listaa jokaisen tuotteen kerran. Jokainen kuva lasketaan skannaukseksi.",
"scan.takeAnother": "Ota vielä yksi",
"scan.analyzeCount": "Analysoi ({count})",
"scan.tips.merge": "Jääkaappi/pakastin/ruokakomero: ota useita kuvia samassa skannauksessa sovellus yhdistää kaksoiskappaleet.",
"cooking.timerHint": "Vaiheen arvioitu aika käytä puhelimesi omaa ajastinta.",
"kitchen.deleteConfirm": "Poistetaanko valitut tuotteet {label}?",
"kitchen.scopeSearch": "hausta",
"kitchen.scopeAll": "koko varastosta",
"kitchen.scopeSelection": "valinnasta",
"kitchen.searchPlaceholder": "Hae tuotetta…",
"kitchen.clearList": "Tyhjennä lista",
"kitchen.empty": "Ei näytettäviä tuotteita. Skannaa tai lisää tuotteita.",
"kitchen.pastBefore": "parasta ennen",
"memory.trainingNote": "Henkilökohtaista muistia ei koskaan käytetä harjoitusdatana ilman erillistä suostumusta.",
"recipe.source": "Lähde: {name}",
"recipes.sub.bread": "Leipä",
"recipes.sub.bun": "Pullat",
"recipes.sub.pie": "Piirakka",
"recipes.sub.cake": "Kakut & leivokset",
"recipes.sub.cookie": "Pikkuleivät",
"recipes.searchPlaceholder": "Hae reseptejä…",
"recipes.emptyCategory": "Tässä kategoriassa ei ole vielä reseptejä niitä ilmestyy, kun valikoima kasvaa.",
"savedRecipes.searchPlaceholder": "Hae tallennetuista resepteistäsi",
"savedRecipes.empty": "Sinulla ei ole vielä tallennettuja reseptejä. Paina ☆ Tallenna reseptin kohdalla, niin se ilmestyy tänne.",
"savedRecipes.emptySearch": "Yksikään tallennettu resepti ei vastaa hakuasi.",
"shopping.removeConfirm": "Poistetaanko tuote listalta?",
"swap.addTitle": "Lisää ateria",
"swap.swapTitle": "Vaihda ateria",
"swap.chooseFor": "Valitse ruoka {meal} tai hae mitä tahansa ruokaa.",
"swap.chooseOther": "Valitse toinen ruoka {meal} tai hae mitä tahansa ruokaa.",
"swap.mealLunch": "lounaalle",
"swap.mealBreakfast": "aamiaiselle",
"swap.mealDinner": "päivälliselle",
"swap.searchPlaceholder": "Hae kaikista resepteistä …",
"swap.showingResults": "Näytetään osumat haulle ”{query}”.",
"swap.clearHint": "Tyhjennä hakukenttä ja hae tyhjällä nähdäksesi ehdotukset uudelleen.",
"errors.uploadTimeout": "Lataus kesti liian kauan tarkista verkkoyhteys ja yritä uudelleen.",
"common.done": "Valmis",
"common.error": "Jokin meni pieleen. Yritä uudelleen.",
"common.estimate": "Arvio",
+47
View File
@@ -51,6 +51,53 @@
"common.add": "Ajouter",
"common.back": "Retour",
"common.cancel": "Annuler",
"common.remove": "Supprimer",
"common.all": "Tous",
"auth.tagline": "Moins de gaspillage, moins de stress, plus d'argent dans votre portefeuille",
"auth.passwordMin8": "Le mot de passe doit contenir au moins 8 caractères.",
"auth.passwordMismatch": "Les mots de passe ne correspondent pas.",
"auth.repeatPassword": "Confirmer le mot de passe",
"plan.swapDish": "Changer de plat",
"scan.maxTitle": "Terminé",
"scan.maxBody": "Max {max} photos pour ce scan analyse en cours.",
"scan.photoTaken": "Photo {count} prise",
"scan.multiAngleHint": "N'hésitez pas à prendre plusieurs angles du même emplacement l'application liste chaque article une seule fois. Chaque photo compte comme un scan.",
"scan.takeAnother": "En prendre une autre",
"scan.analyzeCount": "Analyser ({count})",
"scan.tips.merge": "Réfrigérateur/congélateur/garde-manger : prenez plusieurs photos dans le même scan l'application fusionne les doublons.",
"cooking.timerHint": "Durée approximative de l'étape utilisez le minuteur de votre téléphone.",
"kitchen.deleteConfirm": "Supprimer les articles sélectionnés de {label} ?",
"kitchen.scopeSearch": "la recherche",
"kitchen.scopeAll": "tout le stock",
"kitchen.scopeSelection": "la sélection",
"kitchen.searchPlaceholder": "Rechercher un article…",
"kitchen.clearList": "Vider la liste",
"kitchen.empty": "Aucun article à afficher. Scannez ou ajoutez des articles.",
"kitchen.pastBefore": "à consommer avant",
"memory.trainingNote": "La mémoire personnelle n'est jamais utilisée comme données d'entraînement sans consentement distinct.",
"recipe.source": "Source : {name}",
"recipes.sub.bread": "Pain",
"recipes.sub.bun": "Brioches",
"recipes.sub.pie": "Tartes",
"recipes.sub.cake": "Gâteaux",
"recipes.sub.cookie": "Biscuits",
"recipes.searchPlaceholder": "Rechercher une recette…",
"recipes.emptyCategory": "Aucune recette dans cette catégorie pour l'instant elles apparaîtront à mesure que le catalogue s'agrandit.",
"savedRecipes.searchPlaceholder": "Rechercher parmi vos recettes enregistrées",
"savedRecipes.empty": "Vous n'avez pas encore de recettes enregistrées. Appuyez sur ☆ Enregistrer sur une recette pour la retrouver ici.",
"savedRecipes.emptySearch": "Aucune recette enregistrée ne correspond à votre recherche.",
"shopping.removeConfirm": "Supprimer l'article de la liste ?",
"swap.addTitle": "Ajouter un repas",
"swap.swapTitle": "Remplacer le repas",
"swap.chooseFor": "Choisissez un plat pour {meal} ou recherchez n'importe quel plat.",
"swap.chooseOther": "Choisissez un autre plat pour {meal} ou recherchez n'importe quel plat.",
"swap.mealLunch": "le déjeuner",
"swap.mealBreakfast": "le petit-déjeuner",
"swap.mealDinner": "le dîner",
"swap.searchPlaceholder": "Rechercher parmi toutes les recettes …",
"swap.showingResults": "Affichage des résultats pour ”{query}”.",
"swap.clearHint": "Videz le champ de recherche et lancez une recherche vide pour revoir les suggestions.",
"errors.uploadTimeout": "L'envoi a pris trop de temps vérifiez votre réseau et réessayez.",
"common.done": "Terminé",
"common.error": "Un problème est survenu. Réessayez.",
"common.estimate": "Estimation",
+47
View File
@@ -51,6 +51,53 @@
"common.add": "Aggiungi",
"common.back": "Indietro",
"common.cancel": "Annulla",
"common.remove": "Rimuovi",
"common.all": "Tutti",
"auth.tagline": "Meno sprechi, meno stress, più soldi in tasca",
"auth.passwordMin8": "La password deve contenere almeno 8 caratteri.",
"auth.passwordMismatch": "Le password non corrispondono.",
"auth.repeatPassword": "Ripeti la password",
"plan.swapDish": "Cambia piatto",
"scan.maxTitle": "Fatto",
"scan.maxBody": "Massimo {max} foto per questa scansione analisi in corso.",
"scan.photoTaken": "Foto {count} scattata",
"scan.multiAngleHint": "Scatta pure più angolazioni dello stesso posto lapp elenca ogni prodotto una sola volta. Ogni foto conta come una scansione.",
"scan.takeAnother": "Scatta unaltra",
"scan.analyzeCount": "Analizza ({count})",
"scan.tips.merge": "Frigo/freezer/dispensa: scatta più foto nella stessa scansione lapp unisce i duplicati.",
"cooking.timerHint": "Tempo indicativo per il passaggio usa il timer del tuo telefono.",
"kitchen.deleteConfirm": "Rimuovere i prodotti selezionati da {label}?",
"kitchen.scopeSearch": "la ricerca",
"kitchen.scopeAll": "tutto linventario",
"kitchen.scopeSelection": "la selezione",
"kitchen.searchPlaceholder": "Cerca prodotto…",
"kitchen.clearList": "Svuota la lista",
"kitchen.empty": "Nessun prodotto da mostrare. Scansiona o aggiungi prodotti.",
"kitchen.pastBefore": "da consumarsi preferibilmente entro",
"memory.trainingNote": "La memoria personale non viene mai usata come dati di addestramento senza un consenso separato.",
"recipe.source": "Fonte: {name}",
"recipes.sub.bread": "Pane",
"recipes.sub.bun": "Panini dolci",
"recipes.sub.pie": "Crostate",
"recipes.sub.cake": "Torte e dolci",
"recipes.sub.cookie": "Biscotti",
"recipes.searchPlaceholder": "Cerca ricette…",
"recipes.emptyCategory": "Ancora nessuna ricetta in questa categoria appariranno man mano che il catalogo cresce.",
"savedRecipes.searchPlaceholder": "Cerca tra le tue ricette salvate",
"savedRecipes.empty": "Non hai ancora ricette salvate. Tocca ☆ Salva su una ricetta e comparirà qui.",
"savedRecipes.emptySearch": "Nessuna ricetta salvata corrisponde alla tua ricerca.",
"shopping.removeConfirm": "Rimuovere il prodotto dalla lista?",
"swap.addTitle": "Aggiungi pasto",
"swap.swapTitle": "Sostituisci il pasto",
"swap.chooseFor": "Scegli un piatto per {meal} oppure cerca un piatto qualsiasi.",
"swap.chooseOther": "Scegli un altro piatto per {meal} oppure cerca un piatto qualsiasi.",
"swap.mealLunch": "il pranzo",
"swap.mealBreakfast": "la colazione",
"swap.mealDinner": "la cena",
"swap.searchPlaceholder": "Cerca tra tutte le ricette …",
"swap.showingResults": "Risultati per ”{query}”.",
"swap.clearHint": "Svuota la casella di ricerca e lasciala vuota per rivedere i suggerimenti.",
"errors.uploadTimeout": "Il caricamento ha impiegato troppo tempo controlla la rete e riprova.",
"common.done": "Fatto",
"common.error": "Qualcosa è andato storto. Riprova.",
"common.estimate": "Stima",
+47
View File
@@ -51,6 +51,53 @@
"common.add": "Legg til",
"common.back": "Tilbake",
"common.cancel": "Avbryt",
"common.remove": "Fjern",
"common.all": "Alle",
"auth.tagline": "Mindre svinn, mindre stress, mer igjen i lommeboka",
"auth.passwordMin8": "Passordet må være minst 8 tegn.",
"auth.passwordMismatch": "Passordene er ikke like.",
"auth.repeatPassword": "Gjenta passord",
"plan.swapDish": "Bytt rett",
"scan.maxTitle": "Ferdig",
"scan.maxBody": "Maks {max} bilder denne skanningen analyserer.",
"scan.photoTaken": "Bilde {count} tatt",
"scan.multiAngleHint": "Ta gjerne flere vinkler av samme sted appen lister hver vare én gang. Hvert bilde teller som en skanning.",
"scan.takeAnother": "Ta en til",
"scan.analyzeCount": "Analyser ({count})",
"scan.tips.merge": "Kjøl/frys/spiskammer: ta flere bilder i samme skanning appen slår sammen duplikater.",
"cooking.timerHint": "Omtrentlig tid for steget bruk telefonens egen tidtaker.",
"kitchen.deleteConfirm": "Fjerne valgte varer fra {label}?",
"kitchen.scopeSearch": "søket",
"kitchen.scopeAll": "hele lageret",
"kitchen.scopeSelection": "utvalget",
"kitchen.searchPlaceholder": "Søk vare…",
"kitchen.clearList": "Tøm listen",
"kitchen.empty": "Ingen varer å vise. Skann eller legg til varer.",
"kitchen.pastBefore": "best før",
"memory.trainingNote": "Personlig minne brukes aldri som treningsdata uten separat samtykke.",
"recipe.source": "Kilde: {name}",
"recipes.sub.bread": "Brød",
"recipes.sub.bun": "Boller",
"recipes.sub.pie": "Pai",
"recipes.sub.cake": "Bløtkaker & kaker",
"recipes.sub.cookie": "Småkaker",
"recipes.searchPlaceholder": "Søk oppskrift…",
"recipes.emptyCategory": "Ingen oppskrifter i denne kategorien ennå de dukker opp når katalogen vokser.",
"savedRecipes.searchPlaceholder": "Søk blant dine lagrede oppskrifter",
"savedRecipes.empty": "Du har ingen lagrede oppskrifter ennå. Trykk på ☆ Lagre på en oppskrift, så havner den her.",
"savedRecipes.emptySearch": "Ingen lagrede oppskrifter matcher søket ditt.",
"shopping.removeConfirm": "Fjerne varen fra listen?",
"swap.addTitle": "Legg til måltid",
"swap.swapTitle": "Bytt ut måltidet",
"swap.chooseFor": "Velg en rett for {meal} eller søk etter hvilken som helst rett.",
"swap.chooseOther": "Velg en annen rett for {meal} eller søk etter hvilken som helst rett.",
"swap.mealLunch": "lunsjen",
"swap.mealBreakfast": "frokosten",
"swap.mealDinner": "middagen",
"swap.searchPlaceholder": "Søk blant alle oppskrifter …",
"swap.showingResults": "Viser treff for ”{query}”.",
"swap.clearHint": "Tøm søkefeltet og søk med tomt felt for å se forslag igjen.",
"errors.uploadTimeout": "Opplastingen tok for lang tid sjekk nettverket og prøv igjen.",
"common.done": "Ferdig",
"common.error": "Noe gikk galt. Prøv igjen.",
"common.estimate": "Estimat",
+47
View File
@@ -51,6 +51,53 @@
"common.add": "Toevoegen",
"common.back": "Terug",
"common.cancel": "Annuleren",
"common.remove": "Verwijderen",
"common.all": "Alle",
"auth.tagline": "Minder verspilling, minder stress, meer over in je portemonnee",
"auth.passwordMin8": "Het wachtwoord moet minstens 8 tekens bevatten.",
"auth.passwordMismatch": "De wachtwoorden komen niet overeen.",
"auth.repeatPassword": "Herhaal wachtwoord",
"plan.swapDish": "Gerecht wisselen",
"scan.maxTitle": "Klaar",
"scan.maxBody": "Max {max} foto's deze scan analyseren.",
"scan.photoTaken": "Foto {count} gemaakt",
"scan.multiAngleHint": "Maak gerust foto's vanuit meer hoeken van dezelfde plek de app vermeldt elk product één keer. Elke foto telt als een scan.",
"scan.takeAnother": "Nog een maken",
"scan.analyzeCount": "Analyseren ({count})",
"scan.tips.merge": "Koelkast/vriezer/voorraadkast: maak meerdere foto's in dezelfde scan de app voegt dubbele items samen.",
"cooking.timerHint": "Geschatte tijd voor de stap gebruik de eigen timer van je telefoon.",
"kitchen.deleteConfirm": "Geselecteerde producten uit {label} verwijderen?",
"kitchen.scopeSearch": "de zoekopdracht",
"kitchen.scopeAll": "de hele voorraad",
"kitchen.scopeSelection": "de selectie",
"kitchen.searchPlaceholder": "Zoek product…",
"kitchen.clearList": "Lijst legen",
"kitchen.empty": "Geen producten om weer te geven. Scan of voeg producten toe.",
"kitchen.pastBefore": "houdbaar tot",
"memory.trainingNote": "Persoonlijk geheugen wordt zonder aparte toestemming nooit als trainingsdata gebruikt.",
"recipe.source": "Bron: {name}",
"recipes.sub.bread": "Brood",
"recipes.sub.bun": "Broodjes",
"recipes.sub.pie": "Taart",
"recipes.sub.cake": "Taarten & cake",
"recipes.sub.cookie": "Koekjes",
"recipes.searchPlaceholder": "Zoek recept…",
"recipes.emptyCategory": "Nog geen recepten in deze categorie ze verschijnen naarmate de catalogus groeit.",
"savedRecipes.searchPlaceholder": "Zoek in je opgeslagen recepten",
"savedRecipes.empty": "Je hebt nog geen opgeslagen recepten. Tik op ☆ Opslaan bij een recept en het komt hier terecht.",
"savedRecipes.emptySearch": "Geen opgeslagen recepten gevonden voor je zoekopdracht.",
"shopping.removeConfirm": "Product van de lijst verwijderen?",
"swap.addTitle": "Maaltijd toevoegen",
"swap.swapTitle": "Maaltijd vervangen",
"swap.chooseFor": "Kies een gerecht voor {meal} of zoek naar een willekeurig gerecht.",
"swap.chooseOther": "Kies een ander gerecht voor {meal} of zoek naar een willekeurig gerecht.",
"swap.mealLunch": "de lunch",
"swap.mealBreakfast": "het ontbijt",
"swap.mealDinner": "het avondeten",
"swap.searchPlaceholder": "Zoek in alle recepten …",
"swap.showingResults": "Resultaten voor ”{query}” worden getoond.",
"swap.clearHint": "Maak het zoekveld leeg en zoek zonder tekst om weer suggesties te zien.",
"errors.uploadTimeout": "Het uploaden duurde te lang controleer je netwerk en probeer het opnieuw.",
"common.done": "Klaar",
"common.error": "Er ging iets mis. Probeer opnieuw.",
"common.estimate": "Schatting",
+47
View File
@@ -51,6 +51,53 @@
"common.add": "Dodaj",
"common.back": "Wstecz",
"common.cancel": "Anuluj",
"common.remove": "Usuń",
"common.all": "Wszystkie",
"auth.tagline": "Mniej marnowania, mniej stresu, więcej w portfelu",
"auth.passwordMin8": "Hasło musi mieć co najmniej 8 znaków.",
"auth.passwordMismatch": "Hasła nie są zgodne.",
"auth.repeatPassword": "Powtórz hasło",
"plan.swapDish": "Zmień danie",
"scan.maxTitle": "Gotowe",
"scan.maxBody": "Maks. {max} zdjęć w tym skanowaniu analizuję.",
"scan.photoTaken": "Zrobiono zdjęcie {count}",
"scan.multiAngleHint": "Zrób więcej ujęć tego samego miejsca aplikacja wymieni każdy produkt tylko raz. Każde zdjęcie liczy się jako skanowanie.",
"scan.takeAnother": "Zrób kolejne",
"scan.analyzeCount": "Analizuj ({count})",
"scan.tips.merge": "Lodówka/zamrażarka/spiżarnia: zrób kilka zdjęć w jednym skanowaniu aplikacja połączy duplikaty.",
"cooking.timerHint": "Przybliżony czas etapu użyj minutnika w telefonie.",
"kitchen.deleteConfirm": "Usunąć wybrane produkty z {label}?",
"kitchen.scopeSearch": "wyszukiwania",
"kitchen.scopeAll": "całego zapasu",
"kitchen.scopeSelection": "zaznaczenia",
"kitchen.searchPlaceholder": "Szukaj produktu…",
"kitchen.clearList": "Wyczyść listę",
"kitchen.empty": "Brak produktów do wyświetlenia. Zeskanuj lub dodaj produkty.",
"kitchen.pastBefore": "najlepiej spożyć przed",
"memory.trainingNote": "Pamięć osobista nigdy nie jest wykorzystywana jako dane treningowe bez osobnej zgody.",
"recipe.source": "Źródło: {name}",
"recipes.sub.bread": "Chleb",
"recipes.sub.bun": "Bułki",
"recipes.sub.pie": "Tarta",
"recipes.sub.cake": "Torty i ciasta",
"recipes.sub.cookie": "Ciasteczka",
"recipes.searchPlaceholder": "Szukaj przepisu…",
"recipes.emptyCategory": "Na razie brak przepisów w tej kategorii pojawią się, gdy katalog się powiększy.",
"savedRecipes.searchPlaceholder": "Szukaj wśród zapisanych przepisów",
"savedRecipes.empty": "Nie masz jeszcze zapisanych przepisów. Naciśnij ☆ Zapisz przy przepisie, a pojawi się tutaj.",
"savedRecipes.emptySearch": "Żaden zapisany przepis nie pasuje do wyszukiwania.",
"shopping.removeConfirm": "Usunąć produkt z listy?",
"swap.addTitle": "Dodaj posiłek",
"swap.swapTitle": "Zmień posiłek",
"swap.chooseFor": "Wybierz danie na {meal} lub wyszukaj dowolne danie.",
"swap.chooseOther": "Wybierz inne danie na {meal} lub wyszukaj dowolne danie.",
"swap.mealLunch": "obiad",
"swap.mealBreakfast": "śniadanie",
"swap.mealDinner": "kolację",
"swap.searchPlaceholder": "Szukaj wśród wszystkich przepisów …",
"swap.showingResults": "Pokazuję wyniki dla ”{query}”.",
"swap.clearHint": "Wyczyść pole wyszukiwania i wyszukaj pustą frazę, aby znów zobaczyć propozycje.",
"errors.uploadTimeout": "Przesyłanie trwało zbyt długo sprawdź połączenie sieciowe i spróbuj ponownie.",
"common.done": "Gotowe",
"common.error": "Coś poszło nie tak. Spróbuj ponownie.",
"common.estimate": "Szacunek",
+47
View File
@@ -51,6 +51,53 @@
"common.add": "Adicionar",
"common.back": "Voltar",
"common.cancel": "Cancelar",
"common.remove": "Remover",
"common.all": "Todos",
"auth.tagline": "Menos desperdício, menos stress, mais dinheiro na carteira",
"auth.passwordMin8": "A palavra-passe tem de ter pelo menos 8 caracteres.",
"auth.passwordMismatch": "As palavras-passe não coincidem.",
"auth.repeatPassword": "Repetir palavra-passe",
"plan.swapDish": "Trocar prato",
"scan.maxTitle": "Concluído",
"scan.maxBody": "Máximo de {max} fotos nesta digitalização a analisar.",
"scan.photoTaken": "Foto {count} tirada",
"scan.multiAngleHint": "Podes tirar mais ângulos do mesmo sítio a app lista cada artigo uma vez. Cada foto conta como uma digitalização.",
"scan.takeAnother": "Tirar mais uma",
"scan.analyzeCount": "Analisar ({count})",
"scan.tips.merge": "Frigorífico/congelador/despensa: tira várias fotos na mesma digitalização a app junta os duplicados.",
"cooking.timerHint": "Tempo aproximado para o passo usa o temporizador do teu telemóvel.",
"kitchen.deleteConfirm": "Remover os artigos selecionados de {label}?",
"kitchen.scopeSearch": "a pesquisa",
"kitchen.scopeAll": "todo o inventário",
"kitchen.scopeSelection": "a seleção",
"kitchen.searchPlaceholder": "Pesquisar artigo…",
"kitchen.clearList": "Esvaziar lista",
"kitchen.empty": "Não há artigos para mostrar. Digitaliza ou adiciona artigos.",
"kitchen.pastBefore": "validade",
"memory.trainingNote": "A memória pessoal nunca é usada como dados de treino sem consentimento em separado.",
"recipe.source": "Fonte: {name}",
"recipes.sub.bread": "Pão",
"recipes.sub.bun": "Pãezinhos",
"recipes.sub.pie": "Tarte",
"recipes.sub.cake": "Bolos e tortas",
"recipes.sub.cookie": "Bolachas",
"recipes.searchPlaceholder": "Pesquisar receitas…",
"recipes.emptyCategory": "Ainda não há receitas nesta categoria aparecem à medida que o catálogo cresce.",
"savedRecipes.searchPlaceholder": "Pesquisar nas tuas receitas guardadas",
"savedRecipes.empty": "Ainda não tens receitas guardadas. Toca em ☆ Guardar numa receita e aparece aqui.",
"savedRecipes.emptySearch": "Nenhuma receita guardada corresponde à tua pesquisa.",
"shopping.removeConfirm": "Remover o artigo da lista?",
"swap.addTitle": "Adicionar refeição",
"swap.swapTitle": "Trocar refeição",
"swap.chooseFor": "Escolhe um prato para {meal} ou pesquisa qualquer prato.",
"swap.chooseOther": "Escolhe outro prato para {meal} ou pesquisa qualquer prato.",
"swap.mealLunch": "o almoço",
"swap.mealBreakfast": "o pequeno-almoço",
"swap.mealDinner": "o jantar",
"swap.searchPlaceholder": "Pesquisar em todas as receitas …",
"swap.showingResults": "A mostrar resultados para ”{query}”.",
"swap.clearHint": "Limpa a caixa de pesquisa e pesquisa sem texto para voltares a ver as sugestões.",
"errors.uploadTimeout": "O carregamento demorou demasiado tempo verifica a ligação e tenta novamente.",
"common.done": "Concluído",
"common.error": "Algo correu mal. Tente novamente.",
"common.estimate": "Estimativa",
+47
View File
@@ -51,6 +51,53 @@
"common.add": "Lägg till",
"common.back": "Tillbaka",
"common.cancel": "Avbryt",
"common.remove": "Ta bort",
"common.all": "Alla",
"auth.tagline": "Mindre svinn, mindre stress, mer kvar i plånboken",
"auth.passwordMin8": "Lösenordet måste vara minst 8 tecken.",
"auth.passwordMismatch": "Lösenorden matchar inte.",
"auth.repeatPassword": "Upprepa lösenord",
"plan.swapDish": "Byt rätt",
"scan.maxTitle": "Klart",
"scan.maxBody": "Max {max} bilder den här skanningen analyserar.",
"scan.photoTaken": "Bild {count} tagen",
"scan.multiAngleHint": "Ta gärna fler vinklar av samma plats appen listar varje vara en gång. Varje bild räknas som en skanning.",
"scan.takeAnother": "Ta en till",
"scan.analyzeCount": "Analysera ({count})",
"scan.tips.merge": "Kyl/frys/skafferi: ta flera bilder i samma skanning appen slår ihop dubbletter.",
"cooking.timerHint": "Ungefärlig tid för steget använd din telefons egen timer.",
"kitchen.deleteConfirm": "Ta bort valda varor ur {label}?",
"kitchen.scopeSearch": "sökningen",
"kitchen.scopeAll": "hela lagret",
"kitchen.scopeSelection": "markeringen",
"kitchen.searchPlaceholder": "Sök vara…",
"kitchen.clearList": "Töm listan",
"kitchen.empty": "Inga varor att visa. Skanna eller lägg till varor.",
"kitchen.pastBefore": "bäst före",
"memory.trainingNote": "Personligt minne används aldrig som träningsdata utan separat samtycke.",
"recipe.source": "Källa: {name}",
"recipes.sub.bread": "Bröd",
"recipes.sub.bun": "Bullar",
"recipes.sub.pie": "Paj",
"recipes.sub.cake": "Tårtor & kakor",
"recipes.sub.cookie": "Småkakor",
"recipes.searchPlaceholder": "Sök recept…",
"recipes.emptyCategory": "Inga recept i den här kategorin än de dyker upp när katalogen växer.",
"savedRecipes.searchPlaceholder": "Sök bland dina sparade recept",
"savedRecipes.empty": "Du har inga sparade recept än. Tryck på ☆ Spara på ett recept så hamnar det här.",
"savedRecipes.emptySearch": "Inga sparade recept matchar din sökning.",
"shopping.removeConfirm": "Ta bort varan från listan?",
"swap.addTitle": "Lägg till måltid",
"swap.swapTitle": "Byt ut måltiden",
"swap.chooseFor": "Välj en rätt för {meal} eller sök efter vilken rätt som helst.",
"swap.chooseOther": "Välj en annan rätt för {meal} eller sök efter vilken rätt som helst.",
"swap.mealLunch": "lunchen",
"swap.mealBreakfast": "frukosten",
"swap.mealDinner": "middagen",
"swap.searchPlaceholder": "Sök bland alla recept …",
"swap.showingResults": "Visar träffar för ”{query}”.",
"swap.clearHint": "Rensa sökrutan och sök på tomt för att se förslag igen.",
"errors.uploadTimeout": "Uppladdningen tog för lång tid kontrollera nätverket och försök igen.",
"common.done": "Klar",
"common.error": "Något gick fel. Försök igen.",
"common.estimate": "Uppskattning",