diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index b366d3a..07152b2 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -65,6 +65,9 @@ jobs:
- name: Typecheck
run: pnpm typecheck
+ - name: i18n-vakt (alla språk kompletta, ingen hårdkodad UI-text)
+ run: pnpm --filter @app/mobile i18n:check
+
- name: Test
run: pnpm test
diff --git a/apps/mobile/package.json b/apps/mobile/package.json
index c8c269b..b9acd13 100644
--- a/apps/mobile/package.json
+++ b/apps/mobile/package.json
@@ -8,7 +8,8 @@
"android": "expo start --android",
"ios": "expo start --ios",
"typecheck": "tsc --noEmit",
- "test": "vitest run --passWithNoTests"
+ "test": "vitest run --passWithNoTests",
+ "i18n:check": "node scripts/i18n-check.mjs"
},
"dependencies": {
"@app/analytics": "workspace:*",
diff --git a/apps/mobile/scripts/i18n-check.mjs b/apps/mobile/scripts/i18n-check.mjs
new file mode 100644
index 0000000..e8f2cda
--- /dev/null
+++ b/apps/mobile/scripts/i18n-check.mjs
@@ -0,0 +1,191 @@
+#!/usr/bin/env node
+/**
+ * i18n-vakt: fäller bygget om översättningarna inte är kompletta eller om det
+ * finns hårdkodad text i UI:t. Kör: `pnpm --filter @app/mobile i18n:check`.
+ *
+ * Poängen (långsiktigt): när ett nytt språk läggs till behöver ingen leta
+ * skärm för skärm – vakten listar exakt vilka nycklar som saknas, och hindrar
+ * att ny hårdkodad svenska smyger in. Wire:a in den i CI så kan bygget aldrig
+ * gå live med halvfärdig lokalisering.
+ *
+ * FEL (exit 1):
+ * 1. Ett språk saknar en nyckel som svenska (källan) har → faller till svenska.
+ * 2. En använd t("nyckel") saknas i svenska.
+ * 3. Hårdkodad användartext (svenska diakriter, eller råsträng i label/
+ * placeholder/title/text/Alert/setError som inte går via t()).
+ * VARNING (exit 0): språk med extra nycklar som svenska saknar (t.ex. pl-plural).
+ *
+ * Undanta enskild rad med kommentaren // i18n-ignore
+ */
+import { readdirSync, readFileSync, statSync } from "node:fs";
+import { join, relative } from "node:path";
+
+const ROOT = process.cwd(); // apps/mobile
+const LOCALES_DIR = join(ROOT, "src/locales");
+const SRC_DIR = join(ROOT, "src");
+const SOURCE_LANG = "sv";
+
+// Filer/mönster som INTE är UI-text: locale-data och enhetsvokabulär per språk.
+const SKIP_FILES = [/src\/locales\//, /src\/lib\/i18n\.ts$/, /src\/lib\/units\.ts$/];
+// Råsträngar som är legitima (varumärke, format, tekniska värden).
+const ALLOW = [
+ /^[\s\p{Emoji}\p{P}\p{S}·•⏳⭐★☆✍️🍽️🥣🥗🥐🍎🍰🧺🧠🛒👥⚙️❄️🗄️🥕→↔↩︎▸▾⚠︎%–—-]+$/u, // bara symboler/emoji
+ /^https?:\/\//,
+ /Cibello/,
+];
+// Ord som är identiska/universella på alla målspråk – ingen översättning behövs.
+const ALLOW_EXACT = new Set(["OK", "kcal", "g", "kg", "ml", "dl", "l", "st"]);
+
+/** Ta bort kommentarer men BEHÅLL radnummer (ersätt med blanksteg, ej borttag). */
+function stripComments(src) {
+ const keepNL = (m) => m.replace(/[^\n]/g, " ");
+ return src
+ .replace(/\{\/\*[\s\S]*?\*\/\}/g, keepNL) // {/* JSX-kommentar */}
+ .replace(/\/\*[\s\S]*?\*\//g, keepNL) // /* blockkommentar */
+ .split("\n")
+ .map((line) => line.replace(/([^:"'`\\])\/\/.*$/, "$1").replace(/^\s*\/\/.*$/, "")) // // radslut (ej URL)
+ .join("\n");
+}
+
+function walk(dir) {
+ const out = [];
+ for (const name of readdirSync(dir)) {
+ const p = join(dir, name);
+ const st = statSync(p);
+ if (st.isDirectory()) out.push(...walk(p));
+ else if (/\.(tsx|ts)$/.test(p)) out.push(p);
+ }
+ return out;
+}
+
+function loadLocales() {
+ const langs = readdirSync(LOCALES_DIR).filter((d) =>
+ statSync(join(LOCALES_DIR, d)).isDirectory(),
+ );
+ const map = {};
+ for (const lang of langs) {
+ map[lang] = JSON.parse(readFileSync(join(LOCALES_DIR, lang, "common.json"), "utf8"));
+ }
+ return map;
+}
+
+const errors = [];
+const warnings = [];
+
+// ---- 1. Nyckel-paritet ----------------------------------------------------
+const locales = loadLocales();
+const svKeys = new Set(Object.keys(locales[SOURCE_LANG]));
+for (const [lang, data] of Object.entries(locales)) {
+ if (lang === SOURCE_LANG) continue;
+ const keys = new Set(Object.keys(data));
+ const missing = [...svKeys].filter((k) => !keys.has(k));
+ const extra = [...keys].filter((k) => !svKeys.has(k));
+ if (missing.length)
+ errors.push(
+ `[${lang}] saknar ${missing.length} nyckel/nycklar som ${SOURCE_LANG} har (faller till svenska):\n ` +
+ missing.slice(0, 30).join("\n ") +
+ (missing.length > 30 ? `\n …(+${missing.length - 30})` : ""),
+ );
+ if (extra.length)
+ warnings.push(
+ `[${lang}] har ${extra.length} extra nyckel/nycklar (oanvända?): ${extra.slice(0, 8).join(", ")}${extra.length > 8 ? "…" : ""}`,
+ );
+}
+
+// ---- 2 & 3. Använda nycklar + hårdkodad text ------------------------------
+const files = walk(SRC_DIR).filter((f) => !SKIP_FILES.some((re) => re.test(f)));
+const usedStatic = new Set();
+let dynamicUses = 0;
+const DIACRITIC = /[åäöÅÄÖ]/;
+// t("nyckel") / t('nyckel') / t(`nyckel`) – statisk nyckel utan ${}
+const T_STATIC = /\bt\(\s*["'`]([^"'`$}{]+)["'`]/g;
+// t(`...${...}`) – dynamisk nyckel, hoppa men räkna
+const T_DYNAMIC = /\bt\(\s*`[^`]*\$\{/g;
+// användarnära positioner med RÅSTRÄNG (inte {t(...)})
+const POS =
+ /(?:label|placeholder|title|text|header|message)\s*[=:]\s*(["'`])((?:(?!\1).)*[A-Za-zÅÄÖåäö]{2,}(?:(?!\1).)*)\1/g;
+const ALERT =
+ /(?:Alert\.alert|setError|EmptyState\s+text=)\s*\(?\s*(["'`])((?:(?!\1).)*[A-Za-zÅÄÖåäö]{2,}(?:(?!\1).)*)\1/g;
+// JSX-text: >Svensk text< (fångar diakriter i textnoder)
+const JSXTEXT = />\s*([^<>{}\n]*[åäöÅÄÖ][^<>{}\n]*?)\s* ALLOW_EXACT.has(s.trim()) || ALLOW.some((re) => re.test(s.trim()));
+
+for (const file of files) {
+ const rel = relative(ROOT, file);
+ const raw = readFileSync(file, "utf8");
+ const rawLines = raw.split("\n");
+ const src = stripComments(raw);
+ let m;
+ while ((m = T_STATIC.exec(src))) usedStatic.add(m[1]);
+ dynamicUses += (src.match(T_DYNAMIC) || []).length;
+
+ const lines = src.split("\n");
+ lines.forEach((line, i) => {
+ const trimmed = line.trim();
+ if (trimmed.startsWith("//") || trimmed.startsWith("*") || trimmed.startsWith("/*")) return;
+ // i18n-ignore läses på RÅraden (kommentaren strippas ju bort i `line`).
+ if (/i18n-ignore/.test(rawLines[i] ?? "")) return;
+ const hits = new Set();
+ for (const re of [POS, ALERT]) {
+ re.lastIndex = 0;
+ let mm;
+ while ((mm = re.exec(line))) {
+ const val = mm[2];
+ if (val.includes("${")) continue; // ren interpolationsdel
+ if (!allowed(val)) hits.add(val);
+ }
+ }
+ JSXTEXT.lastIndex = 0;
+ let jm;
+ while ((jm = JSXTEXT.exec(line))) {
+ const val = jm[1];
+ if (val.includes("{") || val.includes("`")) continue;
+ if (!allowed(val)) hits.add(val);
+ }
+ // diakriter i råsträngar OCH mallsträngar (även med ${…} – svensk text runt
+ // interpolationen behöver ändå gå via t() med parametrar).
+ if (DIACRITIC.test(line)) {
+ const strs = line.match(/"[^"\n]*[åäöÅÄÖ][^"\n]*"|`[^`\n]*[åäöÅÄÖ][^`\n]*`/g) || [];
+ for (const s of strs) {
+ const val = s.slice(1, -1);
+ const bare = val.replace(/\$\{[^}]*\}/g, ""); // ta bort interpolation, kolla resten
+ if (DIACRITIC.test(bare) && !allowed(val)) hits.add(val);
+ }
+ }
+ // fristående sträng-/mallliteral på egen rad (multi-rad call-argument, t.ex.
+ // `Bild ${count} tagen`) – en fras (innehåller mellanslag) är nästan alltid UI-text.
+ const st = trimmed.match(/^(["'`])((?:(?!\1).)*)\1,?$/);
+ if (st) {
+ const val = st[2];
+ const bare = val.replace(/\$\{[^}]*\}/g, "");
+ if (/\s/.test(bare) && /[A-Za-zÅÄÖåäö]{2,}/.test(bare) && !allowed(val)) hits.add(val);
+ }
+ for (const h of hits) errors.push(`[hårdkodad] ${rel}:${i + 1} «${h.slice(0, 60)}»`);
+ });
+}
+
+// använda nycklar måste finnas i svenska (plural: tillåt _one/_other-varianter)
+const missingUsed = [...usedStatic].filter(
+ (k) => !svKeys.has(k) && !svKeys.has(`${k}_other`) && !svKeys.has(`${k}_one`),
+);
+if (missingUsed.length)
+ errors.push(
+ `Använda t()-nycklar som saknas i ${SOURCE_LANG}:\n ` + missingUsed.join("\n "),
+ );
+
+// ---- Rapport --------------------------------------------------------------
+const langCount = Object.keys(locales).length;
+console.log(
+ `i18n-vakt: ${langCount} språk, ${svKeys.size} nycklar, ${usedStatic.size} statiska t()-anrop, ${dynamicUses} dynamiska.`,
+);
+for (const w of warnings) console.log("VARNING " + w);
+if (errors.length) {
+ console.error(`\n✗ ${errors.length} fel:\n`);
+ for (const e of errors) console.error(" " + e);
+ console.error("\nÅtgärda ovan (eller markera en rad med // i18n-ignore om den är avsiktlig).");
+ process.exit(1);
+}
+console.log(
+ "✓ i18n komplett: alla språk har alla nycklar, inga saknade nycklar, ingen hårdkodad UI-text.",
+);
diff --git a/apps/mobile/src/app/(auth)/login.tsx b/apps/mobile/src/app/(auth)/login.tsx
index 9f84098..ef8d45d 100644
--- a/apps/mobile/src/app/(auth)/login.tsx
+++ b/apps/mobile/src/app/(auth)/login.tsx
@@ -51,7 +51,7 @@ export default function LoginScreen() {
{BRAND.name}
- Mindre svinn, mindre stress, mer kvar i plånboken
+ {t("auth.tagline")}
{
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}
/>
@@ -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 && (
submitSearch(query.data!.context.searchSuggestion!)}>
-
+
)}
diff --git a/apps/mobile/src/app/(tabs)/plan.tsx b/apps/mobile/src/app/(tabs)/plan.tsx
index bdafdeb..a8d6c28 100644
--- a/apps/mobile/src/app/(tabs)/plan.tsx
+++ b/apps/mobile/src/app/(tabs)/plan.tsx
@@ -92,10 +92,7 @@ export default function PlanScreen() {
}, []);
// Veckan börjar ALLTID på måndag (mån–sö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) => (
- {cap(new Intl.DateTimeFormat(locale, { weekday: "short", day: "numeric" }).format(d))}
+ {cap(
+ new Intl.DateTimeFormat(locale, { weekday: "short", day: "numeric" }).format(
+ d,
+ ),
+ )}
{(["breakfast", "lunch", "dinner"] as const).map((meal) => (
@@ -509,7 +518,7 @@ export default function PlanScreen() {
>
) : null}
);
diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx
index 834f1d4..235e178 100644
--- a/apps/mobile/src/app/_layout.tsx
+++ b/apps/mobile/src/app/_layout.tsx
@@ -78,19 +78,43 @@ export default function RootLayout() {
name="cooking/[id]"
options={{ title: "", presentation: "fullScreenModal" }}
/>
-
-
-
+
+
+
-
-
-
-
+
+
+
+
-
-
-
-
+
+
+
+
((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 {t("scan.analyzing")};
+ if (!permission)
+ return (
+
+ {t("scan.analyzing")}
+
+ );
if (!permission.granted) {
return (
@@ -277,14 +286,26 @@ export default function BarcodeScreen() {
{n ? (
{t("barcode.info.title")}
-
+
{n.monounsaturatedFatG != null && (
-
+
)}
{n.polyunsaturatedFatG != null && (
-
+
)}
diff --git a/apps/mobile/src/app/cooking/[id].tsx b/apps/mobile/src/app/cooking/[id].tsx
index 6fb60c6..16a8bc9 100644
--- a/apps/mobile/src/app/cooking/[id].tsx
+++ b/apps/mobile/src/app/cooking/[id].tsx
@@ -251,40 +251,40 @@ export default function CookingScreen() {
{step?.temperatureC ? ` (${step.temperatureC} °C)` : ""}
{step?.tip && 💡 {step.tip}}
- setShowIngredients((v) => !v)}
- hitSlop={8}
- style={{ alignSelf: "flex-start" }}
- >
-
- {showIngredients ? "▾ " : "▸ "}📋 {t("recipe.ingredients")}
-
-
- {showIngredients && (
- setShowIngredients((v) => !v)}
+ hitSlop={8}
+ style={{ alignSelf: "flex-start" }}
>
-
- {recipe.ingredients.map((ing) => {
- const scaledQty = (ing.quantity * portionsCooked) / recipe.portions;
- return (
-
-
- {ing.displayNameSv}
- {ing.optional ? " (valfritt)" : ""}
-
- {formatQuantity(scaledQty, ing.unit)}
-
- );
- })}
-
-
- )}
+
+ {showIngredients ? "▾ " : "▸ "}📋 {t("recipe.ingredients")}
+
+
+ {showIngredients && (
+
+
+ {recipe.ingredients.map((ing) => {
+ const scaledQty = (ing.quantity * portionsCooked) / recipe.portions;
+ return (
+
+
+ {ing.displayNameSv}
+ {ing.optional ? " (valfritt)" : ""}
+
+ {formatQuantity(scaledQty, ing.unit)}
+
+ );
+ })}
+
+
+ )}
{step?.timerSeconds != null && (
⏱ {formatTime(step.timerSeconds)}
- Ungefärlig tid för steget – använd din telefons egen timer.
+ {t("cooking.timerHint")}
)}
{t("cooking.keepAwake")}
diff --git a/apps/mobile/src/app/kitchen.tsx b/apps/mobile/src/app/kitchen.tsx
index 6da4904..ed54d82 100644
--- a/apps/mobile/src/app/kitchen.tsx
+++ b/apps/mobile/src/app/kitchen.tsx
@@ -37,14 +37,8 @@ interface InventoryItem {
}
const LOCATION_ORDER = ["fridge", "freezer", "pantry", "garage_freezer", "wine_fridge"];
-const LOCATION_LABELS: Record = {
- 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 (
-
+
- {visible.length === 0 && (
-
- )}
+ {visible.length === 0 && }
{groups.map((g) => (
@@ -191,7 +193,11 @@ export default function KitchenScreen() {
toggle(item.id)}
- style={isSel ? { borderColor: colors.primary, backgroundColor: colors.primarySoft } : undefined}
+ style={
+ isSel
+ ? { borderColor: colors.primary, backgroundColor: colors.primarySoft }
+ : undefined
+ }
>
@@ -199,7 +205,7 @@ export default function KitchenScreen() {
{item.displayName} · {formatQuantity(item.quantity, item.unit)}
- {item.expiry.pastBestBefore && ⚠︎ bäst före}
+ {item.expiry.pastBestBefore && ⚠︎ {t("kitchen.pastBefore")}}
confirmDelete([item.id], locLabel(item.locationType))}
hitSlop={8}
diff --git a/apps/mobile/src/app/memory.tsx b/apps/mobile/src/app/memory.tsx
index 9676bd0..3fddddf 100644
--- a/apps/mobile/src/app/memory.tsx
+++ b/apps/mobile/src/app/memory.tsx
@@ -146,7 +146,7 @@ export default function MemoryScreen() {
/>
>
)}
- Personligt minne används aldrig som träningsdata utan separat samtycke.
+ {t("memory.trainingNote")}
);
}
diff --git a/apps/mobile/src/app/profile.tsx b/apps/mobile/src/app/profile.tsx
index aaa34a6..49cb508 100644
--- a/apps/mobile/src/app/profile.tsx
+++ b/apps/mobile/src/app/profile.tsx
@@ -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("/v1/me") });
const entitlements = useQuery({
diff --git a/apps/mobile/src/app/recipe/[id].tsx b/apps/mobile/src/app/recipe/[id].tsx
index 3b96825..c7ba9f0 100644
--- a/apps/mobile/src/app/recipe/[id].tsx
+++ b/apps/mobile/src/app/recipe/[id].tsx
@@ -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() {
{recipe.creatorDisplayName.includes("Redaktion")
? `Skapat av ${recipe.creatorDisplayName}`
- : `Källa: ${recipe.creatorDisplayName}`}
+ : t("recipe.source", { name: recipe.creatorDisplayName })}
)}
@@ -266,7 +263,10 @@ export default function RecipeScreen() {
{t("recipe.coverage.have", { percent: recipe.coverage.percent })}
{recipe.coverage.missing.length > 0 && (
-
+
)}
{recipe.coverage.missing.length === 0 ? (
diff --git a/apps/mobile/src/app/recipes.tsx b/apps/mobile/src/app/recipes.tsx
index cda583c..9752da4 100644
--- a/apps/mobile/src/app/recipes.tsx
+++ b/apps/mobile/src/app/recipes.tsx
@@ -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 (
(
selectCategory(c.key)}
/>
@@ -136,7 +136,7 @@ export default function BrowseRecipesScreen() {
{BAKING_SUBS.map((s) => (
setSub(s.key)}
/>
@@ -149,13 +149,7 @@ export default function BrowseRecipesScreen() {
) : query.isError ? (
void query.refetch()} />
) : recipes.length === 0 ? (
-
+
) : (
recipes.map((r) => (
({
- queryKey: ["favorites-mine"],
- queryFn: () => api("/v1/recipes/favorites/mine"),
- });
+ const [query, setQuery] = useState("");
+ const favs = useQuery({
+ queryKey: ["favorites-mine"],
+ queryFn: () => api("/v1/recipes/favorites/mine"),
+ });
- if (favs.isLoading) return ;
- if (favs.isError) return void favs.refetch()} />;
+ if (favs.isLoading) return ;
+ if (favs.isError) return 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 (
-
-
- {all.length === 0 ? (
-
- ) : filtered.length === 0 ? (
-
- ) : (
- filtered.map((r) => (
- router.push(`/recipe/${r.id}`)} style={{ gap: spacing.xs }}>
- {r.titleSv}
-
- {[
- r.totalTimeMinutes != null ? `${r.totalTimeMinutes} min` : null,
- r.nutritionPerPortion?.kcal != null
- ? `${Math.round(r.nutritionPerPortion.kcal)} kcal`
- : null,
- ]
- .filter(Boolean)
- .join(" · ")}
-
-
- ))
- )}
-
- );
+ return (
+
+
+ {all.length === 0 ? (
+
+ ) : filtered.length === 0 ? (
+
+ ) : (
+ filtered.map((r) => (
+ router.push(`/recipe/${r.id}`)}
+ style={{ gap: spacing.xs }}
+ >
+ {r.titleSv}
+
+ {[
+ r.totalTimeMinutes != null ? `${r.totalTimeMinutes} min` : null,
+ r.nutritionPerPortion?.kcal != null
+ ? `${Math.round(r.nutritionPerPortion.kcal)} kcal`
+ : null,
+ ]
+ .filter(Boolean)
+ .join(" · ")}
+
+
+ ))
+ )}
+
+ );
}
diff --git a/apps/mobile/src/app/shopping.tsx b/apps/mobile/src/app/shopping.tsx
index b2dd486..1b3865c 100644
--- a/apps/mobile/src/app/shopping.tsx
+++ b/apps/mobile/src/app/shopping.tsx
@@ -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({
diff --git a/apps/mobile/src/app/submit-recipe.tsx b/apps/mobile/src/app/submit-recipe.tsx
index 3f8e09c..091ccdc 100644
--- a/apps/mobile/src/app/submit-recipe.tsx
+++ b/apps/mobile/src/app/submit-recipe.tsx
@@ -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() {
⭐ {t("submit.premiumTitle")}
{t("submit.premiumBody")}
- router.replace("/profile")}
- />
+ router.replace("/profile")} />
router.back()} />
diff --git a/apps/mobile/src/app/swap-meal/[entryId].tsx b/apps/mobile/src/app/swap-meal/[entryId].tsx
index 798305d..a14cb18 100644
--- a/apps/mobile/src/app/swap-meal/[entryId].tsx
+++ b/apps/mobile/src/app/swap-meal/[entryId].tsx
@@ -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 (
- {addMode ? "Lägg till måltid" : "Byt ut måltiden"}
+ {addMode ? t("swap.addTitle") : t("swap.swapTitle")}
- {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 })}
setSubmitted(searchText.trim())}
@@ -100,8 +105,7 @@ export default function SwapMealScreen() {
/>
{submitted ? (
- Visar träffar för ”{submitted}”.{" "}
- Rensa sökrutan och sök på tomt för att se förslag igen.
+ {t("swap.showingResults", { query: submitted })} {t("swap.clearHint")}
) : null}
@@ -109,7 +113,7 @@ export default function SwapMealScreen() {
{query.isLoading && }
{query.isError && void query.refetch()} />}
{query.data && recs.length === 0 && (
-
+
)}
{recs.map((rec) => (
swap.mutate(rec.recipeId)}>
diff --git a/apps/mobile/src/lib/api.ts b/apps/mobile/src/lib/api.ts
index 32a597a..812e2a3 100644
--- a/apps/mobile/src/lib/api.ts
+++ b/apps/mobile/src/lib/api.ts
@@ -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 {
diff --git a/apps/mobile/src/lib/auth-provider/firebase.ts b/apps/mobile/src/lib/auth-provider/firebase.ts
index 4306ced..d07beec 100644
--- a/apps/mobile/src/lib/auth-provider/firebase.ts
+++ b/apps/mobile/src/lib/auth-provider/firebase.ts
@@ -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 {
- throw new Error("Google-inloggning läggs till i AUTH-6.");
- },
- async signInWithApple(): Promise {
- 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 {
+ throw new Error("Google-inloggning läggs till i AUTH-6."); // i18n-ignore (temporär dev-stub)
+ },
+ async signInWithApple(): Promise {
+ 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);
+ },
};
diff --git a/apps/mobile/src/lib/auth-provider/types.ts b/apps/mobile/src/lib/auth-provider/types.ts
index f84c625..69e3ce6 100644
--- a/apps/mobile/src/lib/auth-provider/types.ts
+++ b/apps/mobile/src/lib/auth-provider/types.ts
@@ -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;
- signInWithEmail(email: string, password: string): Promise;
- signInWithGoogle(): Promise;
- signInWithApple(): Promise;
- signOut(): Promise;
- /** Skickar återställningsmejl för lösenord (leverantören sköter mejlet). */
- sendPasswordReset(email: string): Promise;
- /** 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;
+ signUpWithEmail(email: string, password: string): Promise;
+ signInWithEmail(email: string, password: string): Promise;
+ signInWithGoogle(): Promise;
+ signInWithApple(): Promise;
+ signOut(): Promise;
+ /** Skickar återställningsmejl för lösenord (leverantören sköter mejlet). */
+ sendPasswordReset(email: string): Promise;
+ /** 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;
}
diff --git a/apps/mobile/src/lib/i18n.ts b/apps/mobile/src/lib/i18n.ts
index bcf3a9f..3b5a13d 100644
--- a/apps/mobile/src/lib/i18n.ts
+++ b/apps/mobile/src/lib/i18n.ts
@@ -88,7 +88,8 @@ interface I18nState {
const useI18nStore = create((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. */
diff --git a/apps/mobile/src/locales/da/common.json b/apps/mobile/src/locales/da/common.json
index cb371eb..2b2e935 100644
--- a/apps/mobile/src/locales/da/common.json
+++ b/apps/mobile/src/locales/da/common.json
@@ -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",
diff --git a/apps/mobile/src/locales/de/common.json b/apps/mobile/src/locales/de/common.json
index 2172f84..4efcdc6 100644
--- a/apps/mobile/src/locales/de/common.json
+++ b/apps/mobile/src/locales/de/common.json
@@ -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",
diff --git a/apps/mobile/src/locales/en/common.json b/apps/mobile/src/locales/en/common.json
index 9927f50..23f9c43 100644
--- a/apps/mobile/src/locales/en/common.json
+++ b/apps/mobile/src/locales/en/common.json
@@ -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",
diff --git a/apps/mobile/src/locales/es/common.json b/apps/mobile/src/locales/es/common.json
index 822642d..8b94815 100644
--- a/apps/mobile/src/locales/es/common.json
+++ b/apps/mobile/src/locales/es/common.json
@@ -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",
diff --git a/apps/mobile/src/locales/fi/common.json b/apps/mobile/src/locales/fi/common.json
index 607d205..72a0211 100644
--- a/apps/mobile/src/locales/fi/common.json
+++ b/apps/mobile/src/locales/fi/common.json
@@ -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",
diff --git a/apps/mobile/src/locales/fr/common.json b/apps/mobile/src/locales/fr/common.json
index 6bb44bf..d3c5719 100644
--- a/apps/mobile/src/locales/fr/common.json
+++ b/apps/mobile/src/locales/fr/common.json
@@ -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",
diff --git a/apps/mobile/src/locales/it/common.json b/apps/mobile/src/locales/it/common.json
index 1ceb7f8..2dfd4f2 100644
--- a/apps/mobile/src/locales/it/common.json
+++ b/apps/mobile/src/locales/it/common.json
@@ -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 – l’app elenca ogni prodotto una sola volta. Ogni foto conta come una scansione.",
+ "scan.takeAnother": "Scatta un’altra",
+ "scan.analyzeCount": "Analizza ({count})",
+ "scan.tips.merge": "Frigo/freezer/dispensa: scatta più foto nella stessa scansione – l’app 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 l’inventario",
+ "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",
diff --git a/apps/mobile/src/locales/nb/common.json b/apps/mobile/src/locales/nb/common.json
index fa457c4..1660d9d 100644
--- a/apps/mobile/src/locales/nb/common.json
+++ b/apps/mobile/src/locales/nb/common.json
@@ -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",
diff --git a/apps/mobile/src/locales/nl/common.json b/apps/mobile/src/locales/nl/common.json
index 14fdb1f..f0a74a5 100644
--- a/apps/mobile/src/locales/nl/common.json
+++ b/apps/mobile/src/locales/nl/common.json
@@ -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",
diff --git a/apps/mobile/src/locales/pl/common.json b/apps/mobile/src/locales/pl/common.json
index 20c102a..4f75d6f 100644
--- a/apps/mobile/src/locales/pl/common.json
+++ b/apps/mobile/src/locales/pl/common.json
@@ -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",
diff --git a/apps/mobile/src/locales/pt/common.json b/apps/mobile/src/locales/pt/common.json
index ad651c8..dc216ca 100644
--- a/apps/mobile/src/locales/pt/common.json
+++ b/apps/mobile/src/locales/pt/common.json
@@ -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",
diff --git a/apps/mobile/src/locales/sv/common.json b/apps/mobile/src/locales/sv/common.json
index e484c5a..8ba71d6 100644
--- a/apps/mobile/src/locales/sv/common.json
+++ b/apps/mobile/src/locales/sv/common.json
@@ -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",