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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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:*",
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
);
|
||||||
@@ -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")}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
// Veckan börjar ALLTID på måndag (mån–sö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;
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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" }}
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 på 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)}>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 – 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.done": "Fatto",
|
||||||
"common.error": "Qualcosa è andato storto. Riprova.",
|
"common.error": "Qualcosa è andato storto. Riprova.",
|
||||||
"common.estimate": "Stima",
|
"common.estimate": "Stima",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user