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