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
+3
View File
@@ -65,6 +65,9 @@ jobs:
- name: Typecheck - name: Typecheck
run: pnpm 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 - name: Test
run: pnpm test run: pnpm test
+2 -1
View File
@@ -8,7 +8,8 @@
"android": "expo start --android", "android": "expo start --android",
"ios": "expo start --ios", "ios": "expo start --ios",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests" "test": "vitest run --passWithNoTests",
"i18n:check": "node scripts/i18n-check.mjs"
}, },
"dependencies": { "dependencies": {
"@app/analytics": "workspace:*", "@app/analytics": "workspace:*",
+191
View File
@@ -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*</g;
const allowed = (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.",
);
+1 -1
View File
@@ -51,7 +51,7 @@ export default function LoginScreen() {
<Screen style={{ flexGrow: 1, justifyContent: "center", gap: spacing.md }}> <Screen style={{ flexGrow: 1, justifyContent: "center", gap: spacing.md }}>
<View style={{ alignItems: "center", marginBottom: spacing.lg }}> <View style={{ alignItems: "center", marginBottom: spacing.lg }}>
<Title>{BRAND.name}</Title> <Title>{BRAND.name}</Title>
<Body muted>Mindre svinn, mindre stress, mer kvar i plånboken</Body> <Body muted>{t("auth.tagline")}</Body>
</View> </View>
<Input <Input
placeholder={t("auth.email")} placeholder={t("auth.email")}
+3 -3
View File
@@ -28,11 +28,11 @@ export default function RegisterScreen() {
const submit = async () => { const submit = async () => {
if (password.length < 8) { if (password.length < 8) {
setError("Lösenordet måste vara minst 8 tecken."); setError(t("auth.passwordMin8"));
return; return;
} }
if (password !== confirmPassword) { if (password !== confirmPassword) {
setError("Lösenorden matchar inte."); setError(t("auth.passwordMismatch"));
return; return;
} }
setBusy(true); setBusy(true);
@@ -112,7 +112,7 @@ export default function RegisterScreen() {
onChangeText={setPassword} onChangeText={setPassword}
/> />
<Input <Input
placeholder="Upprepa lösenord" placeholder={t("auth.repeatPassword")}
secureTextEntry secureTextEntry
value={confirmPassword} value={confirmPassword}
onChangeText={setConfirmPassword} onChangeText={setConfirmPassword}
+5 -2
View File
@@ -74,7 +74,7 @@ const CATS: ReadonlyArray<{
{ key: "dinner", labelKey: "myday.mealType.dinner", mealType: "dinner", glyph: "🍽️" }, { key: "dinner", labelKey: "myday.mealType.dinner", mealType: "dinner", glyph: "🍽️" },
{ key: "snack", labelKey: "myday.mealType.snack", mealType: "snack", glyph: "🍎" }, { key: "snack", labelKey: "myday.mealType.snack", mealType: "snack", glyph: "🍎" },
{ key: "dessert", labelKey: "myday.mealType.dessert", mealType: "dessert", 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 => 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. */} {/* "Menade du …?" stavningsförslag när sök gav noll träffar. */}
{allRecs.length === 0 && submittedSearch && query.data.context.searchSuggestion && ( {allRecs.length === 0 && submittedSearch && query.data.context.searchSuggestion && (
<Pressable onPress={() => submitSearch(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> </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. // Veckan börjar ALLTID på måndag (månsön), oavsett vilken dag det är idag.
const thisMonday = useMemo(() => mondayOf(todayMidnight), [todayMidnight]); const thisMonday = useMemo(() => mondayOf(todayMidnight), [todayMidnight]);
const weekStart = useMemo( const weekStart = useMemo(() => addDays(thisMonday, weekOffset * 7), [thisMonday, weekOffset]);
() => addDays(thisMonday, weekOffset * 7),
[thisMonday, weekOffset],
);
const weekStartKey = toKey(weekStart); const weekStartKey = toKey(weekStart);
const weekDays = useMemo( const weekDays = useMemo(
() => Array.from({ length: 7 }, (_, i) => addDays(weekStart, i)), () => Array.from({ length: 7 }, (_, i) => addDays(weekStart, i)),
@@ -116,7 +113,11 @@ export default function PlanScreen() {
const toggleSlot = (dayIdx: number, meal: string) => const toggleSlot = (dayIdx: number, meal: string) =>
setMealSlots((prev) => setMealSlots((prev) =>
prev.map((meals, i) => 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) => ( {weekDays.map((d, i) => (
<Row <Row
key={toKey(d)} key={toKey(d)}
style={{ justifyContent: "space-between", alignItems: "center", marginVertical: 2 }} style={{
justifyContent: "space-between",
alignItems: "center",
marginVertical: 2,
}}
> >
<Small> <Small>
{cap(new Intl.DateTimeFormat(locale, { weekday: "short", day: "numeric" }).format(d))} {cap(
new Intl.DateTimeFormat(locale, { weekday: "short", day: "numeric" }).format(
d,
),
)}
</Small> </Small>
<Row> <Row>
{(["breakfast", "lunch", "dinner"] as const).map((meal) => ( {(["breakfast", "lunch", "dinner"] as const).map((meal) => (
@@ -509,7 +518,7 @@ export default function PlanScreen() {
</> </>
) : null} ) : null}
<Button <Button
label="Byt rätt" label={t("plan.swapDish")}
variant="secondary" variant="secondary"
onPress={() => { onPress={() => {
const e = actionEntry; 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> { function askAddMore(count: number, maxCount: number): Promise<boolean> {
return new Promise((resolve) => { return new Promise((resolve) => {
if (count >= maxCount) { 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) }, { text: "OK", onPress: () => resolve(false) },
]); ]);
return; return;
} }
Alert.alert( Alert.alert(t("scan.photoTaken", { count }), t("scan.multiAngleHint"), [
`Bild ${count} tagen`, { text: t("scan.takeAnother"), onPress: () => resolve(true) },
"Ta gärna fler vinklar av samma plats appen listar varje vara en gång. Varje bild räknas som en skanning.", { text: t("scan.analyzeCount", { count }), style: "default", onPress: () => resolve(false) },
[ ]);
{ text: "Ta en till", onPress: () => resolve(true) },
{ text: `Analysera (${count})`, style: "default", onPress: () => resolve(false) },
],
);
}); });
} }
@@ -220,7 +216,7 @@ export default function ScanScreen() {
<Small> {t("scan.tips.shelf")}</Small> <Small> {t("scan.tips.shelf")}</Small>
<Small> {t("scan.tips.light")}</Small> <Small> {t("scan.tips.light")}</Small>
<Small> {t("scan.tips.move")}</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> </Card>
</Screen> </Screen>
); );
+35 -11
View File
@@ -78,19 +78,43 @@ export default function RootLayout() {
name="cooking/[id]" name="cooking/[id]"
options={{ title: "", presentation: "fullScreenModal" }} options={{ title: "", presentation: "fullScreenModal" }}
/> />
<Stack.Screen name="scan-review/[jobId]" options={{ title: t("scan.review.title"), fullScreenGestureEnabled: false }} /> <Stack.Screen
<Stack.Screen name="swap-meal/[entryId]" options={{ presentation: "modal", title: t("nav.swap") }} /> name="scan-review/[jobId]"
<Stack.Screen name="meal-review/[jobId]" options={{ title: t("scan.meal.title"), fullScreenGestureEnabled: false }} /> 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="reconciliation" options={{ title: t("reconciliation.title") }} />
<Stack.Screen name="scan-diff-review/[jobId]" options={{ title: t("scan.review.title"), fullScreenGestureEnabled: false }} /> <Stack.Screen
<Stack.Screen name="shopping" options={{ title: t("shopping.title"), presentation: "modal" }} /> name="scan-diff-review/[jobId]"
<Stack.Screen name="meal-boxes" options={{ title: t("mealbox.title"), presentation: "modal" }} /> options={{ title: t("scan.review.title"), fullScreenGestureEnabled: false }}
<Stack.Screen name="household" options={{ title: t("home.household"), presentation: "modal" }} /> />
<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="memory" options={{ title: t("memory.title") }} />
<Stack.Screen name="saved-recipes" options={{ title: t("nav.savedRecipes") }} /> <Stack.Screen name="saved-recipes" options={{ title: t("nav.savedRecipes") }} />
<Stack.Screen name="recipes" options={{ title: t("nav.browseRecipes") }} /> <Stack.Screen name="recipes" options={{ title: t("nav.browseRecipes") }} />
<Stack.Screen name="kitchen" options={{ title: t("nav.kitchen") }} /> <Stack.Screen name="kitchen" options={{ title: t("nav.kitchen") }} />
<Stack.Screen name="profile" options={{ title: t("profile.title"), presentation: "modal" }} /> <Stack.Screen
name="profile"
options={{ title: t("profile.title"), presentation: "modal" }}
/>
<Stack.Screen <Stack.Screen
name="paywall" name="paywall"
options={{ title: t("paywall.title"), presentation: "modal" }} options={{ title: t("paywall.title"), presentation: "modal" }}
+26 -5
View File
@@ -140,7 +140,11 @@ export default function BarcodeScreen() {
uris.push(a0.uri); uris.push(a0.uri);
const wantBack = await new Promise<boolean>((resolve) => const wantBack = await new Promise<boolean>((resolve) =>
Alert.alert(t("barcode.contributing.frontDone"), t("barcode.contributing.backPrompt"), [ 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) }, { text: t("barcode.contributing.shootBack"), onPress: () => resolve(true) },
]), ]),
); );
@@ -207,7 +211,12 @@ export default function BarcodeScreen() {
lastGtin.current = null; 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) { if (!permission.granted) {
return ( return (
<Screen> <Screen>
@@ -277,14 +286,26 @@ export default function BarcodeScreen() {
{n ? ( {n ? (
<View style={styles.nutBox}> <View style={styles.nutBox}>
<Small style={styles.nutTitle}>{t("barcode.info.title")}</Small> <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.fat")} value={fmtG(n.fatG)} />
<NutRow label={t("barcode.info.satfat")} value={fmtG(n.saturatedFatG)} sub /> <NutRow label={t("barcode.info.satfat")} value={fmtG(n.saturatedFatG)} sub />
{n.monounsaturatedFatG != null && ( {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 && ( {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.carbs")} value={fmtG(n.carbsG)} />
<NutRow label={t("barcode.info.sugar")} value={fmtG(n.sugarG)} sub /> <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)` : ""} {step?.temperatureC ? ` (${step.temperatureC} °C)` : ""}
</Text> </Text>
{step?.tip && <Small>💡 {step.tip}</Small>} {step?.tip && <Small>💡 {step.tip}</Small>}
<Pressable <Pressable
onPress={() => setShowIngredients((v) => !v)} onPress={() => setShowIngredients((v) => !v)}
hitSlop={8} hitSlop={8}
style={{ alignSelf: "flex-start" }} style={{ alignSelf: "flex-start" }}
>
<Small style={{ color: colors.primaryDark, fontWeight: "600" }}>
{showIngredients ? "▾ " : "▸ "}📋 {t("recipe.ingredients")}
</Small>
</Pressable>
{showIngredients && (
<View
style={{
maxHeight: 200,
backgroundColor: colors.surfaceAlt,
borderRadius: 12,
padding: spacing.md,
}}
> >
<ScrollView> <Small style={{ color: colors.primaryDark, fontWeight: "600" }}>
{recipe.ingredients.map((ing) => { {showIngredients ? "▾ " : "▸ "}📋 {t("recipe.ingredients")}
const scaledQty = (ing.quantity * portionsCooked) / recipe.portions; </Small>
return ( </Pressable>
<Row key={ing.id} style={{ justifyContent: "space-between" }}> {showIngredients && (
<Body> <View
{ing.displayNameSv} style={{
{ing.optional ? " (valfritt)" : ""} maxHeight: 200,
</Body> backgroundColor: colors.surfaceAlt,
<Small>{formatQuantity(scaledQty, ing.unit)}</Small> borderRadius: 12,
</Row> padding: spacing.md,
); }}
})} >
</ScrollView> <ScrollView>
</View> {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 && ( {step?.timerSeconds != null && (
<View <View
@@ -298,7 +298,7 @@ export default function CookingScreen() {
<Text style={{ fontSize: 22, fontWeight: "700", color: colors.primaryDark }}> <Text style={{ fontSize: 22, fontWeight: "700", color: colors.primaryDark }}>
{formatTime(step.timerSeconds)} {formatTime(step.timerSeconds)}
</Text> </Text>
<Small>Ungefärlig tid för steget använd din telefons egen timer.</Small> <Small>{t("cooking.timerHint")}</Small>
</View> </View>
)} )}
<Small>{t("cooking.keepAwake")}</Small> <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_ORDER = ["fridge", "freezer", "pantry", "garage_freezer", "wine_fridge"];
const LOCATION_LABELS: Record<string, string> = { const locLabel = (type: string) =>
fridge: "Kyl", LOCATION_ORDER.includes(type) ? t(`home.location.${type}`) : type;
freezer: "Frys",
pantry: "Skafferi",
garage_freezer: "Garagefrys",
wine_fridge: "Vinkyl",
};
const locLabel = (type: string) => LOCATION_LABELS[type] ?? type;
const orderIndex = (type: string) => { const orderIndex = (type: string) => {
const i = LOCATION_ORDER.indexOf(type); const i = LOCATION_ORDER.indexOf(type);
return i === -1 ? LOCATION_ORDER.length : i; return i === -1 ? LOCATION_ORDER.length : i;
@@ -121,25 +115,30 @@ export default function KitchenScreen() {
const confirmDelete = (ids: string[], label: string) => { const confirmDelete = (ids: string[], label: string) => {
if (ids.length === 0) return; if (ids.length === 0) return;
Alert.alert( Alert.alert(t("common.remove"), t("kitchen.deleteConfirm", { label }), [
"Ta bort", { text: t("common.cancel"), style: "cancel" },
`Är du säker på att du vill ta bort ${ids.length} ${ids.length === 1 ? "vara" : "varor"} ur ${label}?`, { text: t("common.remove"), style: "destructive", onPress: () => bulkRemove.mutate(ids) },
[ ]);
{ text: t("common.cancel"), style: "cancel" },
{ text: "Ta bort", 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 ( return (
<Screen> <Screen>
<Input value={search} onChangeText={setSearch} placeholder="Sök vara…" autoCorrect={false} /> <Input
value={search}
onChangeText={setSearch}
placeholder={t("kitchen.searchPlaceholder")}
autoCorrect={false}
/>
<Row> <Row>
<Button <Button
label="Alla" label={t("common.all")}
variant={locFilter === null ? "primary" : "ghost"} variant={locFilter === null ? "primary" : "ghost"}
onPress={() => setLocFilter(null)} onPress={() => setLocFilter(null)}
/> />
@@ -162,22 +161,25 @@ export default function KitchenScreen() {
<Button <Button
label={`Ta bort valda (${selected.size})`} label={`Ta bort valda (${selected.size})`}
variant="danger" variant="danger"
onPress={() => confirmDelete([...selected], "markeringen")} onPress={() => confirmDelete([...selected], t("kitchen.scopeSelection"))}
/> />
)} )}
{visible.length > 0 && ( {visible.length > 0 && (
<Button <Button
label="Töm listan" label={t("kitchen.clearList")}
variant="ghost" variant="ghost"
onPress={() => confirmDelete(visible.map((i) => i.id), emptyLabel)} onPress={() =>
confirmDelete(
visible.map((i) => i.id),
emptyLabel,
)
}
/> />
)} )}
</Row> </Row>
</Row> </Row>
{visible.length === 0 && ( {visible.length === 0 && <EmptyState text={t("kitchen.empty")} />}
<EmptyState text="Inga varor att visa. Skanna eller lägg till varor." />
)}
{groups.map((g) => ( {groups.map((g) => (
<View key={g.type} style={{ gap: spacing.xs, marginTop: spacing.sm }}> <View key={g.type} style={{ gap: spacing.xs, marginTop: spacing.sm }}>
@@ -191,7 +193,11 @@ export default function KitchenScreen() {
<Card <Card
key={item.id} key={item.id}
onPress={() => toggle(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" }}> <Row style={{ justifyContent: "space-between", alignItems: "center" }}>
<Body> <Body>
@@ -199,7 +205,7 @@ export default function KitchenScreen() {
{item.displayName} · {formatQuantity(item.quantity, item.unit)} {item.displayName} · {formatQuantity(item.quantity, item.unit)}
</Body> </Body>
<Row style={{ alignItems: "center", gap: 12 }}> <Row style={{ alignItems: "center", gap: 12 }}>
{item.expiry.pastBestBefore && <Small> bäst före</Small>} {item.expiry.pastBestBefore && <Small> {t("kitchen.pastBefore")}</Small>}
<Pressable <Pressable
onPress={() => confirmDelete([item.id], locLabel(item.locationType))} onPress={() => confirmDelete([item.id], locLabel(item.locationType))}
hitSlop={8} 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> </Screen>
); );
} }
+15 -5
View File
@@ -65,16 +65,26 @@ const DIETS = [
"low_carb", "low_carb",
"carnivore", "carnivore",
] as const; ] 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; const RELIGIOUS = ["none", "halal", "kosher", "hindu_no_beef", "buddhist_vegetarian"] as const;
export default function ProfileScreen() { export default function ProfileScreen() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const rawLogout = useAuth((s) => s.logout); const rawLogout = useAuth((s) => s.logout);
const logout = async () => { const logout = async () => {
await firebaseAuthProvider.signOut().catch(() => {}); await firebaseAuthProvider.signOut().catch(() => {});
await rawLogout(); await rawLogout();
}; };
const me = useQuery({ queryKey: ["me"], queryFn: () => api<Me>("/v1/me") }); const me = useQuery({ queryKey: ["me"], queryFn: () => api<Me>("/v1/me") });
const entitlements = useQuery({ const entitlements = useQuery({
+6 -6
View File
@@ -181,10 +181,7 @@ export default function RecipeScreen() {
}, },
onSuccess: (count) => { onSuccess: (count) => {
void queryClient.invalidateQueries({ queryKey: ["shopping-lists"] }); void queryClient.invalidateQueries({ queryKey: ["shopping-lists"] });
Alert.alert( Alert.alert(t("recipe.coverage.added"), t("recipe.coverage.addedBody", { count }));
t("recipe.coverage.added"),
t("recipe.coverage.addedBody", { count }),
);
}, },
onError: (err) => { onError: (err) => {
// Visa tydlig diagnostik: HTTP-status vs nätverksfel (hjälper felsökning). // Visa tydlig diagnostik: HTTP-status vs nätverksfel (hjälper felsökning).
@@ -233,7 +230,7 @@ export default function RecipeScreen() {
<Small> <Small>
{recipe.creatorDisplayName.includes("Redaktion") {recipe.creatorDisplayName.includes("Redaktion")
? `Skapat av ${recipe.creatorDisplayName}` ? `Skapat av ${recipe.creatorDisplayName}`
: `Källa: ${recipe.creatorDisplayName}`} : t("recipe.source", { name: recipe.creatorDisplayName })}
</Small> </Small>
)} )}
@@ -266,7 +263,10 @@ export default function RecipeScreen() {
<Row style={{ justifyContent: "space-between", alignItems: "center" }}> <Row style={{ justifyContent: "space-between", alignItems: "center" }}>
<Heading>{t("recipe.coverage.have", { percent: recipe.coverage.percent })}</Heading> <Heading>{t("recipe.coverage.have", { percent: recipe.coverage.percent })}</Heading>
{recipe.coverage.missing.length > 0 && ( {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> </Row>
{recipe.coverage.missing.length === 0 ? ( {recipe.coverage.missing.length === 0 ? (
+13 -19
View File
@@ -53,24 +53,24 @@ const CATEGORIES: ReadonlyArray<{
tag?: string; tag?: string;
glyph: string; glyph: string;
}> = [ }> = [
{ key: "all", labelKey: null, glyph: "🍴" }, { key: "all", labelKey: "common.all", glyph: "🍴" },
{ key: "breakfast", labelKey: "myday.mealType.breakfast", mealType: "breakfast", glyph: "🥣" }, { key: "breakfast", labelKey: "myday.mealType.breakfast", mealType: "breakfast", glyph: "🥣" },
{ key: "lunch", labelKey: "myday.mealType.lunch", mealType: "lunch", glyph: "🥗" }, { key: "lunch", labelKey: "myday.mealType.lunch", mealType: "lunch", glyph: "🥗" },
{ key: "dinner", labelKey: "myday.mealType.dinner", mealType: "dinner", glyph: "🍽️" }, { key: "dinner", labelKey: "myday.mealType.dinner", mealType: "dinner", glyph: "🍽️" },
{ key: "snack", labelKey: "myday.mealType.snack", mealType: "snack", glyph: "🍎" }, { key: "snack", labelKey: "myday.mealType.snack", mealType: "snack", glyph: "🍎" },
{ key: "dessert", labelKey: "myday.mealType.dessert", mealType: "dessert", 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 // 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. // "baking" och en under-typ, så vi kan filtrera på under-typen direkt.
const BAKING_SUBS: ReadonlyArray<{ key: string; label: string; tag?: string }> = [ const BAKING_SUBS: ReadonlyArray<{ key: string; labelKey: string; tag?: string }> = [
{ key: "all", label: "Alla" }, { key: "all", labelKey: "common.all" },
{ key: "bread", label: "Bröd", tag: "bread" }, { key: "bread", labelKey: "recipes.sub.bread", tag: "bread" },
{ key: "bun", label: "Bullar", tag: "bun" }, { key: "bun", labelKey: "recipes.sub.bun", tag: "bun" },
{ key: "pie", label: "Paj", tag: "pie" }, { key: "pie", labelKey: "recipes.sub.pie", tag: "pie" },
{ key: "cake", label: "Tårtor & kakor", tag: "cake" }, { key: "cake", labelKey: "recipes.sub.cake", tag: "cake" },
{ key: "cookie", label: "Småkakor", tag: "cookie" }, { key: "cookie", labelKey: "recipes.sub.cookie", tag: "cookie" },
]; ];
export default function BrowseRecipesScreen() { export default function BrowseRecipesScreen() {
@@ -112,7 +112,7 @@ export default function BrowseRecipesScreen() {
return ( return (
<Screen style={{ gap: spacing.md }}> <Screen style={{ gap: spacing.md }}>
<Input <Input
placeholder="Sök recept…" placeholder={t("recipes.searchPlaceholder")}
autoCapitalize="none" autoCapitalize="none"
autoCorrect={false} autoCorrect={false}
value={search} value={search}
@@ -123,7 +123,7 @@ export default function BrowseRecipesScreen() {
{CATEGORIES.map((c) => ( {CATEGORIES.map((c) => (
<Button <Button
key={c.key} 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"} variant={category === c.key ? "secondary" : "ghost"}
onPress={() => selectCategory(c.key)} onPress={() => selectCategory(c.key)}
/> />
@@ -136,7 +136,7 @@ export default function BrowseRecipesScreen() {
{BAKING_SUBS.map((s) => ( {BAKING_SUBS.map((s) => (
<Button <Button
key={s.key} key={s.key}
label={s.label} label={t(s.labelKey)}
variant={sub === s.key ? "secondary" : "ghost"} variant={sub === s.key ? "secondary" : "ghost"}
onPress={() => setSub(s.key)} onPress={() => setSub(s.key)}
/> />
@@ -149,13 +149,7 @@ export default function BrowseRecipesScreen() {
) : query.isError ? ( ) : query.isError ? (
<ErrorView onRetry={() => void query.refetch()} /> <ErrorView onRetry={() => void query.refetch()} />
) : recipes.length === 0 ? ( ) : recipes.length === 0 ? (
<EmptyState <EmptyState text={term ? t("wte.emptySearch") : t("recipes.emptyCategory")} />
text={
term
? "Inga recept matchar din sökning."
: "Inga recept i den här kategorin än de dyker upp när katalogen växer."
}
/>
) : ( ) : (
recipes.map((r) => ( recipes.map((r) => (
<Card <Card
+61 -47
View File
@@ -2,64 +2,78 @@ import { useState } from "react";
import { router } from "expo-router"; import { router } from "expo-router";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api"; 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"; import { spacing } from "@/lib/theme";
/** Dina sparade recept (favoriter) sökbar lista. Backend: GET /v1/recipes/favorites/mine. */ /** Dina sparade recept (favoriter) sökbar lista. Backend: GET /v1/recipes/favorites/mine. */
interface SavedRecipe { interface SavedRecipe {
id: string; id: string;
titleSv: string; titleSv: string;
totalTimeMinutes: number | null; totalTimeMinutes: number | null;
nutritionPerPortion: { kcal?: number } | null; nutritionPerPortion: { kcal?: number } | null;
} }
interface FavoritesResponse { interface FavoritesResponse {
recipes: SavedRecipe[]; recipes: SavedRecipe[];
} }
export default function SavedRecipesScreen() { export default function SavedRecipesScreen() {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const favs = useQuery<FavoritesResponse>({ const favs = useQuery<FavoritesResponse>({
queryKey: ["favorites-mine"], queryKey: ["favorites-mine"],
queryFn: () => api<FavoritesResponse>("/v1/recipes/favorites/mine"), queryFn: () => api<FavoritesResponse>("/v1/recipes/favorites/mine"),
}); });
if (favs.isLoading) return <LoadingView />; if (favs.isLoading) return <LoadingView />;
if (favs.isError) return <ErrorView onRetry={() => void favs.refetch()} />; if (favs.isError) return <ErrorView onRetry={() => void favs.refetch()} />;
const all = favs.data?.recipes ?? []; const all = favs.data?.recipes ?? [];
const q = query.trim().toLowerCase(); const q = query.trim().toLowerCase();
const filtered = q ? all.filter((r) => r.titleSv.toLowerCase().includes(q)) : all; const filtered = q ? all.filter((r) => r.titleSv.toLowerCase().includes(q)) : all;
return ( return (
<Screen style={{ gap: spacing.md }}> <Screen style={{ gap: spacing.md }}>
<Input <Input
placeholder="Sök bland dina sparade recept" placeholder={t("savedRecipes.searchPlaceholder")}
autoCapitalize="none" autoCapitalize="none"
value={query} value={query}
onChangeText={setQuery} onChangeText={setQuery}
/> />
{all.length === 0 ? ( {all.length === 0 ? (
<EmptyState text="Du har inga sparade recept än. Tryck på ☆ Spara på ett recept så hamnar det här." /> <EmptyState text={t("savedRecipes.empty")} />
) : filtered.length === 0 ? ( ) : filtered.length === 0 ? (
<EmptyState text="Inga sparade recept matchar din sökning." /> <EmptyState text={t("savedRecipes.emptySearch")} />
) : ( ) : (
filtered.map((r) => ( filtered.map((r) => (
<Card key={r.id} onPress={() => router.push(`/recipe/${r.id}`)} style={{ gap: spacing.xs }}> <Card
<Heading>{r.titleSv}</Heading> key={r.id}
<Small> onPress={() => router.push(`/recipe/${r.id}`)}
{[ style={{ gap: spacing.xs }}
r.totalTimeMinutes != null ? `${r.totalTimeMinutes} min` : null, >
r.nutritionPerPortion?.kcal != null <Heading>{r.titleSv}</Heading>
? `${Math.round(r.nutritionPerPortion.kcal)} kcal` <Small>
: null, {[
] r.totalTimeMinutes != null ? `${r.totalTimeMinutes} min` : null,
.filter(Boolean) r.nutritionPerPortion?.kcal != null
.join(" · ")} ? `${Math.round(r.nutritionPerPortion.kcal)} kcal`
</Small> : null,
</Card> ]
)) .filter(Boolean)
)} .join(" · ")}
</Screen> </Small>
); </Card>
))
)}
</Screen>
);
} }
+2 -2
View File
@@ -125,9 +125,9 @@ export default function ShoppingScreen() {
}); });
const confirmRemove = (item: ShoppingItem) => 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: 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({ const complete = useMutation({
+4 -7
View File
@@ -31,12 +31,12 @@ export default function SubmitRecipeScreen() {
setError(null); setError(null);
// Bygg en tydlig fritext-mall som AI:n tolkar robust. // Bygg en tydlig fritext-mall som AI:n tolkar robust.
const text = [ const text = [
`Titel: ${title.trim()}`, `Titel: ${title.trim()}`, // i18n-ignore (fri-text-payload till receptparsern, ej UI)
"", "",
"Ingredienser:", "Ingredienser:", // i18n-ignore
ingredients.trim(), ingredients.trim(),
"", "",
"Gör så här:", "Gör så här:", // i18n-ignore
steps.trim(), steps.trim(),
].join("\n"); ].join("\n");
try { try {
@@ -73,10 +73,7 @@ export default function SubmitRecipeScreen() {
<Heading> {t("submit.premiumTitle")}</Heading> <Heading> {t("submit.premiumTitle")}</Heading>
<Body>{t("submit.premiumBody")}</Body> <Body>{t("submit.premiumBody")}</Body>
<Spacer size={spacing.md} /> <Spacer size={spacing.md} />
<Button <Button label={t("submit.seePlans")} onPress={() => router.replace("/profile")} />
label={t("submit.seePlans")}
onPress={() => router.replace("/profile")}
/>
<Spacer size={spacing.xs} /> <Spacer size={spacing.xs} />
<Button label={t("submit.backHome")} variant="ghost" onPress={() => router.back()} /> <Button label={t("submit.backHome")} variant="ghost" onPress={() => router.back()} />
</Card> </Card>
+12 -8
View File
@@ -80,18 +80,23 @@ export default function SwapMealScreen() {
const recs = query.data?.recommendations ?? []; const recs = query.data?.recommendations ?? [];
const mealLabel = const mealLabel =
mealType === "lunch" ? "lunchen" : mealType === "breakfast" ? "frukosten" : "middagen"; mealType === "lunch"
? t("swap.mealLunch")
: mealType === "breakfast"
? t("swap.mealBreakfast")
: t("swap.mealDinner");
return ( return (
<Screen> <Screen>
<Title>{addMode ? "Lägg till måltid" : "Byt ut måltiden"}</Title> <Title>{addMode ? t("swap.addTitle") : t("swap.swapTitle")}</Title>
<Small> <Small>
{addMode ? `Välj en rätt för ${mealLabel}` : `Välj en annan rätt för ${mealLabel}`} eller {addMode
sök efter vilken rätt som helst. ? t("swap.chooseFor", { meal: mealLabel })
: t("swap.chooseOther", { meal: mealLabel })}
</Small> </Small>
<Spacer size={spacing.sm} /> <Spacer size={spacing.sm} />
<Input <Input
placeholder="Sök bland alla recept …" placeholder={t("swap.searchPlaceholder")}
value={searchText} value={searchText}
onChangeText={setSearchText} onChangeText={setSearchText}
onSubmitEditing={() => setSubmitted(searchText.trim())} onSubmitEditing={() => setSubmitted(searchText.trim())}
@@ -100,8 +105,7 @@ export default function SwapMealScreen() {
/> />
{submitted ? ( {submitted ? (
<Small> <Small>
Visar träffar för {submitted}.{" "} {t("swap.showingResults", { query: submitted })} <Small>{t("swap.clearHint")}</Small>
<Small>Rensa sökrutan och sök tomt för att se förslag igen.</Small>
</Small> </Small>
) : null} ) : null}
<Spacer size={spacing.xs} /> <Spacer size={spacing.xs} />
@@ -109,7 +113,7 @@ export default function SwapMealScreen() {
{query.isLoading && <LoadingView />} {query.isLoading && <LoadingView />}
{query.isError && <ErrorView onRetry={() => void query.refetch()} />} {query.isError && <ErrorView onRetry={() => void query.refetch()} />}
{query.data && recs.length === 0 && ( {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) => ( {recs.map((rec) => (
<Card key={rec.recipeId} onPress={() => swap.mutate(rec.recipeId)}> <Card key={rec.recipeId} onPress={() => swap.mutate(rec.recipeId)}>
+2 -5
View File
@@ -1,5 +1,6 @@
import Constants from "expo-constants"; import Constants from "expo-constants";
import { useAuth } from "./auth"; import { useAuth } from "./auth";
import { t } from "./i18n";
/** /**
* API-klient. Mobilappen pratar ENDAST med Food API aldrig direkt med * 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."); if (!res.ok) throw new ApiError(res.status, "UPLOAD_FAILED", "Bilduppladdningen misslyckades.");
} catch (err) { } catch (err) {
if (err instanceof Error && err.name === "AbortError") { if (err instanceof Error && err.name === "AbortError") {
throw new ApiError( throw new ApiError(0, "UPLOAD_TIMEOUT", t("errors.uploadTimeout"));
0,
"UPLOAD_TIMEOUT",
"Uppladdningen tog för lång tid kontrollera nätverket och försök igen.",
);
} }
throw err; throw err;
} finally { } finally {
+62 -62
View File
@@ -1,15 +1,15 @@
import { initializeApp, getApps, getApp } from "firebase/app"; import { initializeApp, getApps, getApp } from "firebase/app";
import { import {
initializeAuth, initializeAuth,
getAuth, getAuth,
onAuthStateChanged as fbOnAuthStateChanged, onAuthStateChanged as fbOnAuthStateChanged,
signInWithEmailAndPassword, signInWithEmailAndPassword,
createUserWithEmailAndPassword, createUserWithEmailAndPassword,
signOut as fbSignOut, signOut as fbSignOut,
sendPasswordResetEmail, sendPasswordResetEmail,
type Auth, type Auth,
type User, type User,
type Persistence, type Persistence,
} from "firebase/auth"; } from "firebase/auth";
import * as fbAuth from "firebase/auth"; import * as fbAuth from "firebase/auth";
import AsyncStorage from "@react-native-async-storage/async-storage"; 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). * Publik klient-config (identifierare, ingen hemlighet).
*/ */
const firebaseConfig = { const firebaseConfig = {
apiKey: "AIzaSyAH2F-aZRvZXQtGD7X8cF9Zp3yXUhEl3sM", apiKey: "AIzaSyAH2F-aZRvZXQtGD7X8cF9Zp3yXUhEl3sM",
authDomain: "cibello-c2ff3.firebaseapp.com", authDomain: "cibello-c2ff3.firebaseapp.com",
projectId: "cibello-c2ff3", projectId: "cibello-c2ff3",
storageBucket: "cibello-c2ff3.firebasestorage.app", storageBucket: "cibello-c2ff3.firebasestorage.app",
messagingSenderId: "1034102905039", messagingSenderId: "1034102905039",
appId: "1:1034102905039:web:12e31120989655f34affba", appId: "1:1034102905039:web:12e31120989655f34affba",
}; };
const app = getApps().length ? getApp() : initializeApp(firebaseConfig); 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 // getReactNativePersistence finns i runtime men saknas ibland i typerna
// (firebase-js-sdk#9316) hämtas därför via en typsäker modul-cast. // (firebase-js-sdk#9316) hämtas därför via en typsäker modul-cast.
const getReactNativePersistence = ( const getReactNativePersistence = (
fbAuth as unknown as { getReactNativePersistence: (storage: unknown) => Persistence } fbAuth as unknown as { getReactNativePersistence: (storage: unknown) => Persistence }
).getReactNativePersistence; ).getReactNativePersistence;
// Persistens så inloggningen överlever omstart. På webben (react-native-web) // 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. // Där använder vi Firebases standard-webblagring (getAuth), på native AsyncStorage.
let auth: Auth; let auth: Auth;
try { try {
auth = auth =
Platform.OS === "web" Platform.OS === "web"
? getAuth(app) ? getAuth(app)
: initializeAuth(app, { persistence: getReactNativePersistence(AsyncStorage) }); : initializeAuth(app, { persistence: getReactNativePersistence(AsyncStorage) });
} catch { } catch {
// initializeAuth kastar om den redan körts (Fast Refresh) återanvänd instansen. // initializeAuth kastar om den redan körts (Fast Refresh) återanvänd instansen.
auth = getAuth(app); auth = getAuth(app);
} }
function toAuthUser(u: User | null): AuthUser | null { function toAuthUser(u: User | null): AuthUser | null {
if (!u) return null; if (!u) return null;
return { return {
uid: u.uid, uid: u.uid,
email: u.email, email: u.email,
emailVerified: u.emailVerified, emailVerified: u.emailVerified,
displayName: u.displayName, displayName: u.displayName,
providerId: u.providerData[0]?.providerId ?? null, providerId: u.providerData[0]?.providerId ?? null,
}; };
} }
export const firebaseAuthProvider: AuthProvider = { export const firebaseAuthProvider: AuthProvider = {
async signUpWithEmail(email, password) { async signUpWithEmail(email, password) {
const cred = await createUserWithEmailAndPassword(auth, email, password); const cred = await createUserWithEmailAndPassword(auth, email, password);
return toAuthUser(cred.user)!; return toAuthUser(cred.user)!;
}, },
async signInWithEmail(email, password) { async signInWithEmail(email, password) {
const cred = await signInWithEmailAndPassword(auth, email, password); const cred = await signInWithEmailAndPassword(auth, email, password);
return toAuthUser(cred.user)!; return toAuthUser(cred.user)!;
}, },
async signInWithGoogle(): Promise<AuthUser> { async signInWithGoogle(): Promise<AuthUser> {
throw new Error("Google-inloggning läggs till i AUTH-6."); throw new Error("Google-inloggning läggs till i AUTH-6."); // i18n-ignore (temporär dev-stub)
}, },
async signInWithApple(): Promise<AuthUser> { async signInWithApple(): Promise<AuthUser> {
throw new Error("Apple-inloggning läggs till i AUTH-6."); throw new Error("Apple-inloggning läggs till i AUTH-6."); // i18n-ignore (temporär dev-stub)
}, },
async signOut() { async signOut() {
await fbSignOut(auth); await fbSignOut(auth);
}, },
async sendPasswordReset(email) { async sendPasswordReset(email) {
await sendPasswordResetEmail(auth, email); await sendPasswordResetEmail(auth, email);
}, },
currentUser() { currentUser() {
return toAuthUser(auth.currentUser); return toAuthUser(auth.currentUser);
}, },
onAuthStateChanged(callback): Unsubscribe { onAuthStateChanged(callback): Unsubscribe {
return fbOnAuthStateChanged(auth, (u) => callback(toAuthUser(u))); return fbOnAuthStateChanged(auth, (u) => callback(toAuthUser(u)));
}, },
async getIdToken(forceRefresh = false) { async getIdToken(forceRefresh = false) {
const u = auth.currentUser; const u = auth.currentUser;
if (!u) return null; if (!u) return null;
return u.getIdToken(forceRefresh); return u.getIdToken(forceRefresh);
}, },
}; };
+23 -23
View File
@@ -10,13 +10,13 @@
/** Minimal, leverantörsneutral användarrepresentation. */ /** Minimal, leverantörsneutral användarrepresentation. */
export interface AuthUser { export interface AuthUser {
/** Stabil unik identitet från identitetsleverantören (Firebase-UID i dag). */ /** Stabil unik identitet från identitetsleverantören (Firebase-UID i dag). */
uid: string; uid: string;
email: string | null; email: string | null;
emailVerified: boolean; emailVerified: boolean;
displayName: string | null; displayName: string | null;
/** t.ex. "password" | "google.com" | "apple.com". Neutral sträng. */ /** t.ex. "password" | "google.com" | "apple.com". Neutral sträng. */
providerId: string | null; providerId: string | null;
} }
/** Avregistreringsfunktion som returneras av onAuthStateChanged. */ /** 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). * Alla nätverksmetoder är async och kastar vid fel (fångas i UI-lagret).
*/ */
export interface AuthProvider { export interface AuthProvider {
signUpWithEmail(email: string, password: string): Promise<AuthUser>; signUpWithEmail(email: string, password: string): Promise<AuthUser>;
signInWithEmail(email: string, password: string): Promise<AuthUser>; signInWithEmail(email: string, password: string): Promise<AuthUser>;
signInWithGoogle(): Promise<AuthUser>; signInWithGoogle(): Promise<AuthUser>;
signInWithApple(): Promise<AuthUser>; signInWithApple(): Promise<AuthUser>;
signOut(): Promise<void>; signOut(): Promise<void>;
/** Skickar återställningsmejl för lösenord (leverantören sköter mejlet). */ /** Skickar återställningsmejl för lösenord (leverantören sköter mejlet). */
sendPasswordReset(email: string): Promise<void>; sendPasswordReset(email: string): Promise<void>;
/** Nuvarande inloggade användare, eller null. Synkront ögonblicksvärde. */ /** Nuvarande inloggade användare, eller null. Synkront ögonblicksvärde. */
currentUser(): AuthUser | null; currentUser(): AuthUser | null;
/** Prenumererar på inloggningsändringar. Returnerar avregistrering. */ /** Prenumererar på inloggningsändringar. Returnerar avregistrering. */
onAuthStateChanged(callback: (user: AuthUser | null) => void): Unsubscribe; onAuthStateChanged(callback: (user: AuthUser | null) => void): Unsubscribe;
/** /**
* Hämtar aktuell ID-token för backend-verifiering. forceRefresh tvingar * Hämtar aktuell ID-token för backend-verifiering. forceRefresh tvingar
* förnyelse mot leverantören. null om ingen är inloggad. * förnyelse mot leverantören. null om ingen är inloggad.
*/ */
getIdToken(forceRefresh?: boolean): Promise<string | null>; getIdToken(forceRefresh?: boolean): Promise<string | null>;
} }
+2 -1
View File
@@ -88,7 +88,8 @@ interface I18nState {
const useI18nStore = create<I18nState>((set) => ({ const useI18nStore = create<I18nState>((set) => ({
version: 0, version: 0,
languageTag: "sv-SE", 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. */ /** Rotlayouten läser denna och re-monterar trädet vid språkbyte. */
+47
View File
@@ -51,6 +51,53 @@
"common.add": "Tilføj", "common.add": "Tilføj",
"common.back": "Tilbage", "common.back": "Tilbage",
"common.cancel": "Annuller", "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.done": "Færdig",
"common.error": "Noget gik galt. Prøv igen.", "common.error": "Noget gik galt. Prøv igen.",
"common.estimate": "Estimat", "common.estimate": "Estimat",
+47
View File
@@ -51,6 +51,53 @@
"common.add": "Hinzufügen", "common.add": "Hinzufügen",
"common.back": "Zurück", "common.back": "Zurück",
"common.cancel": "Abbrechen", "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.done": "Fertig",
"common.error": "Etwas ist schiefgelaufen. Bitte erneut versuchen.", "common.error": "Etwas ist schiefgelaufen. Bitte erneut versuchen.",
"common.estimate": "Schätzung", "common.estimate": "Schätzung",
+47
View File
@@ -51,6 +51,53 @@
"common.add": "Add", "common.add": "Add",
"common.back": "Back", "common.back": "Back",
"common.cancel": "Cancel", "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.done": "Done",
"common.error": "Something went wrong. Please try again.", "common.error": "Something went wrong. Please try again.",
"common.estimate": "Estimate", "common.estimate": "Estimate",
+47
View File
@@ -51,6 +51,53 @@
"common.add": "Añadir", "common.add": "Añadir",
"common.back": "Atrás", "common.back": "Atrás",
"common.cancel": "Cancelar", "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.done": "Listo",
"common.error": "Algo salió mal. Inténtalo de nuevo.", "common.error": "Algo salió mal. Inténtalo de nuevo.",
"common.estimate": "Estimación", "common.estimate": "Estimación",
+47
View File
@@ -51,6 +51,53 @@
"common.add": "Lisää", "common.add": "Lisää",
"common.back": "Takaisin", "common.back": "Takaisin",
"common.cancel": "Peruuta", "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.done": "Valmis",
"common.error": "Jokin meni pieleen. Yritä uudelleen.", "common.error": "Jokin meni pieleen. Yritä uudelleen.",
"common.estimate": "Arvio", "common.estimate": "Arvio",
+47
View File
@@ -51,6 +51,53 @@
"common.add": "Ajouter", "common.add": "Ajouter",
"common.back": "Retour", "common.back": "Retour",
"common.cancel": "Annuler", "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.done": "Terminé",
"common.error": "Un problème est survenu. Réessayez.", "common.error": "Un problème est survenu. Réessayez.",
"common.estimate": "Estimation", "common.estimate": "Estimation",
+47
View File
@@ -51,6 +51,53 @@
"common.add": "Aggiungi", "common.add": "Aggiungi",
"common.back": "Indietro", "common.back": "Indietro",
"common.cancel": "Annulla", "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.done": "Fatto",
"common.error": "Qualcosa è andato storto. Riprova.", "common.error": "Qualcosa è andato storto. Riprova.",
"common.estimate": "Stima", "common.estimate": "Stima",
+47
View File
@@ -51,6 +51,53 @@
"common.add": "Legg til", "common.add": "Legg til",
"common.back": "Tilbake", "common.back": "Tilbake",
"common.cancel": "Avbryt", "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.done": "Ferdig",
"common.error": "Noe gikk galt. Prøv igjen.", "common.error": "Noe gikk galt. Prøv igjen.",
"common.estimate": "Estimat", "common.estimate": "Estimat",
+47
View File
@@ -51,6 +51,53 @@
"common.add": "Toevoegen", "common.add": "Toevoegen",
"common.back": "Terug", "common.back": "Terug",
"common.cancel": "Annuleren", "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.done": "Klaar",
"common.error": "Er ging iets mis. Probeer opnieuw.", "common.error": "Er ging iets mis. Probeer opnieuw.",
"common.estimate": "Schatting", "common.estimate": "Schatting",
+47
View File
@@ -51,6 +51,53 @@
"common.add": "Dodaj", "common.add": "Dodaj",
"common.back": "Wstecz", "common.back": "Wstecz",
"common.cancel": "Anuluj", "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.done": "Gotowe",
"common.error": "Coś poszło nie tak. Spróbuj ponownie.", "common.error": "Coś poszło nie tak. Spróbuj ponownie.",
"common.estimate": "Szacunek", "common.estimate": "Szacunek",
+47
View File
@@ -51,6 +51,53 @@
"common.add": "Adicionar", "common.add": "Adicionar",
"common.back": "Voltar", "common.back": "Voltar",
"common.cancel": "Cancelar", "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.done": "Concluído",
"common.error": "Algo correu mal. Tente novamente.", "common.error": "Algo correu mal. Tente novamente.",
"common.estimate": "Estimativa", "common.estimate": "Estimativa",
+47
View File
@@ -51,6 +51,53 @@
"common.add": "Lägg till", "common.add": "Lägg till",
"common.back": "Tillbaka", "common.back": "Tillbaka",
"common.cancel": "Avbryt", "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.done": "Klar",
"common.error": "Något gick fel. Försök igen.", "common.error": "Något gick fel. Försök igen.",
"common.estimate": "Uppskattning", "common.estimate": "Uppskattning",