Initial commit (unpacked platform)

This commit is contained in:
Sven (AAMOS AI)
2026-08-05 19:21:11 +07:00
commit ac5340195a
314 changed files with 57584 additions and 0 deletions
@@ -0,0 +1,65 @@
import { useState } from "react";
import { Text } from "react-native";
import { Link } from "expo-router";
import { api } from "@/lib/api";
import { t } from "@/lib/i18n";
import { Body, Button, Input, Screen, Spacer, Title } from "@/components/ui";
import { colors, spacing } from "@/lib/theme";
/**
* Glömt lösenord (steg 1): begär återställningsmejl. Svaret är alltid samma
* oavsett om kontot finns ingen kontouppräkning. Mejlet innehåller en
* app-länk (urlScheme://reset-password?token=…) som öppnar steg 2.
*/
export default function ForgotPasswordScreen() {
const [email, setEmail] = useState("");
const [sent, setSent] = useState(false);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const submit = async () => {
setBusy(true);
setError(null);
try {
await api("/v1/auth/forgot-password", { method: "POST", body: { email } });
setSent(true);
} catch (err) {
setError(err instanceof Error ? err.message : t("common.error"));
} finally {
setBusy(false);
}
};
if (sent) {
return (
<Screen scroll={false} style={{ justifyContent: "center", gap: spacing.md }}>
<Title>{t("auth.forgotSentTitle")}</Title>
<Body muted>{t("auth.forgotSentBody")}</Body>
<Spacer size={spacing.sm} />
<Link href="/(auth)/login" style={{ textAlign: "center", color: colors.primaryDark }}>
{t("auth.backToLogin")}
</Link>
</Screen>
);
}
return (
<Screen scroll={false} style={{ justifyContent: "center", gap: spacing.md }}>
<Title>{t("auth.forgotTitle")}</Title>
<Body muted>{t("auth.forgotBody")}</Body>
<Input
placeholder={t("auth.email")}
autoCapitalize="none"
keyboardType="email-address"
value={email}
onChangeText={setEmail}
/>
{error && <Text style={{ color: colors.danger }}>{error}</Text>}
<Button label={t("auth.forgotSubmit")} onPress={() => void submit()} loading={busy} />
<Spacer size={spacing.sm} />
<Link href="/(auth)/login" style={{ textAlign: "center", color: colors.primaryDark }}>
{t("auth.backToLogin")}
</Link>
</Screen>
);
}
+76
View File
@@ -0,0 +1,76 @@
import { useState } from "react";
import { Text, View } from "react-native";
import { Link, Redirect, router } from "expo-router";
import { api } from "@/lib/api";
import { useAuth, type AuthUser } from "@/lib/auth";
import { t } from "@/lib/i18n";
import { Body, Button, Input, Screen, Spacer, Title } from "@/components/ui";
import { colors, spacing } from "@/lib/theme";
import { BRAND } from "@/lib/brand";
interface LoginResponse {
user: AuthUser;
accessToken: string;
refreshToken: string;
}
export default function LoginScreen() {
const { accessToken, setSession } = useAuth();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
if (accessToken) return <Redirect href="/(tabs)" />;
const submit = async () => {
setBusy(true);
setError(null);
try {
const result = await api<LoginResponse>("/v1/auth/login", {
method: "POST",
body: { email, password },
});
await setSession(
{ accessToken: result.accessToken, refreshToken: result.refreshToken },
result.user,
);
router.replace("/(tabs)");
} catch (err) {
setError(err instanceof Error ? err.message : t("common.error"));
} finally {
setBusy(false);
}
};
return (
<Screen scroll={false} style={{ justifyContent: "center", gap: spacing.md }}>
<View style={{ alignItems: "center", marginBottom: spacing.lg }}>
<Title>{BRAND.name}</Title>
<Body muted>Hushållets mat-OS</Body>
</View>
<Input
placeholder={t("auth.email")}
autoCapitalize="none"
keyboardType="email-address"
value={email}
onChangeText={setEmail}
/>
<Input
placeholder={t("auth.password")}
secureTextEntry
value={password}
onChangeText={setPassword}
/>
{error && <Text style={{ color: colors.danger }}>{error}</Text>}
<Button label={t("auth.login")} onPress={() => void submit()} loading={busy} />
<Spacer size={spacing.sm} />
<Link href="/(auth)/register" style={{ textAlign: "center", color: colors.primaryDark }}>
{t("auth.noAccount")}
</Link>
<Link href="/(auth)/forgot-password" style={{ textAlign: "center", color: colors.textMuted }}>
{t("auth.forgotLink")}
</Link>
</Screen>
);
}
+109
View File
@@ -0,0 +1,109 @@
import { useState } from "react";
import { Text, View } from "react-native";
import { Link, router } from "expo-router";
import { api } from "@/lib/api";
import { persistLanguageTag, useAuth, type AuthUser } from "@/lib/auth";
import { detectDeviceLanguageTag, t } from "@/lib/i18n";
import { localeDefaultsForRegion } from "@app/shared-types";
import { Body, Button, Input, Screen, Small, Spacer, Title } from "@/components/ui";
import { colors, spacing } from "@/lib/theme";
interface RegisterResponse {
user: AuthUser;
accessToken: string;
refreshToken: string;
}
export default function RegisterScreen() {
const setSession = useAuth((s) => s.setSession);
const setOnboardingCompleted = useAuth((s) => s.setOnboardingCompleted);
const [displayName, setDisplayName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const submit = async () => {
setBusy(true);
setError(null);
try {
const result = await api<RegisterResponse>("/v1/auth/register", {
method: "POST",
body: {
displayName,
email,
password,
// D-031: enhetens språk backend skapar locale-preferenser före välkomstmejlet.
locale: detectDeviceLanguageTag() ?? "sv-SE",
},
});
await setSession(
{ accessToken: result.accessToken, refreshToken: result.refreshToken },
result.user,
);
// D-031: enhetens språk/region synkas till backend direkt så att mejl,
// notiser och innehåll kommer på rätt språk från första stund.
const deviceTag = detectDeviceLanguageTag();
if (deviceTag) {
const region = deviceTag.split("-")[1]?.toUpperCase();
const defaults = region ? localeDefaultsForRegion(region) : null;
await api("/v1/me/locale-preferences", {
method: "PATCH",
body: {
languageTag: deviceTag,
...(defaults
? {
regionCode: defaults.regionCode,
timeZone: defaults.timeZone,
measurementSystem: defaults.measurementSystem,
temperatureUnit: defaults.temperatureUnit,
currencyCode: defaults.currencyCode,
}
: {}),
},
}).catch(() => {});
await persistLanguageTag(deviceTag);
}
setOnboardingCompleted(false);
router.replace("/onboarding");
} catch (err) {
setError(err instanceof Error ? err.message : t("common.error"));
} finally {
setBusy(false);
}
};
return (
<Screen scroll={false} style={{ justifyContent: "center", gap: spacing.md }}>
<View style={{ alignItems: "center", marginBottom: spacing.lg }}>
<Title>{t("auth.register")}</Title>
<Small>{t("auth.trialNote")}</Small>
</View>
<Input
placeholder={t("auth.displayName")}
value={displayName}
onChangeText={setDisplayName}
/>
<Input
placeholder={t("auth.email")}
autoCapitalize="none"
keyboardType="email-address"
value={email}
onChangeText={setEmail}
/>
<Input
placeholder={`${t("auth.password")} (minst 10 tecken)`}
secureTextEntry
value={password}
onChangeText={setPassword}
/>
{error && <Text style={{ color: colors.danger }}>{error}</Text>}
<Button label={t("auth.register")} onPress={() => void submit()} loading={busy} />
<Spacer size={spacing.sm} />
<Link href="/(auth)/login" style={{ textAlign: "center", color: colors.primaryDark }}>
{t("auth.hasAccount")}
</Link>
<Body muted>{t("onboarding.notMedical")}</Body>
</Screen>
);
}
@@ -0,0 +1,76 @@
import { useState } from "react";
import { Text } from "react-native";
import { Link, router, useLocalSearchParams } from "expo-router";
import { api } from "@/lib/api";
import { t } from "@/lib/i18n";
import { Body, Button, Input, Screen, Spacer, Title } from "@/components/ui";
import { colors, spacing } from "@/lib/theme";
/**
* Glömt lösenord (steg 2): öppnas via app-länken i mejlet
* (urlScheme://reset-password?token=…). Token kan även klistras in manuellt.
* Efter lyckad återställning loggas ALLA sessioner ut användaren loggar in
* på nytt med sitt nya lösenord.
*/
export default function ResetPasswordScreen() {
const params = useLocalSearchParams<{ token?: string }>();
const [token, setToken] = useState(params.token ?? "");
const [password, setPassword] = useState("");
const [done, setDone] = useState(false);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const submit = async () => {
setBusy(true);
setError(null);
try {
await api("/v1/auth/reset-password", {
method: "POST",
body: { token: token.trim(), newPassword: password },
});
setDone(true);
} catch (err) {
setError(err instanceof Error ? err.message : t("common.error"));
} finally {
setBusy(false);
}
};
if (done) {
return (
<Screen scroll={false} style={{ justifyContent: "center", gap: spacing.md }}>
<Title>{t("auth.resetDoneTitle")}</Title>
<Body muted>{t("auth.resetDoneBody")}</Body>
<Spacer size={spacing.sm} />
<Button label={t("auth.login")} onPress={() => router.replace("/(auth)/login")} />
</Screen>
);
}
return (
<Screen scroll={false} style={{ justifyContent: "center", gap: spacing.md }}>
<Title>{t("auth.resetTitle")}</Title>
<Body muted>{t("auth.resetBody")}</Body>
{!params.token && (
<Input
placeholder={t("auth.resetTokenPlaceholder")}
autoCapitalize="none"
value={token}
onChangeText={setToken}
/>
)}
<Input
placeholder={t("auth.newPassword")}
secureTextEntry
value={password}
onChangeText={setPassword}
/>
{error && <Text style={{ color: colors.danger }}>{error}</Text>}
<Button label={t("auth.resetSubmit")} onPress={() => void submit()} loading={busy} />
<Spacer size={spacing.sm} />
<Link href="/(auth)/login" style={{ textAlign: "center", color: colors.primaryDark }}>
{t("auth.backToLogin")}
</Link>
</Screen>
);
}
@@ -0,0 +1,34 @@
import { useEffect, useState } from "react";
import { Link, useLocalSearchParams } from "expo-router";
import { api } from "@/lib/api";
import { t } from "@/lib/i18n";
import { Body, LoadingView, Screen, Spacer, Title } from "@/components/ui";
import { colors, spacing } from "@/lib/theme";
/** Öppnas via länken i välkomstmejlet (urlScheme://verify-email?token=…). */
export default function VerifyEmailScreen() {
const params = useLocalSearchParams<{ token?: string }>();
const [state, setState] = useState<"working" | "done" | "failed">("working");
useEffect(() => {
if (!params.token) {
setState("failed");
return;
}
api("/v1/auth/verify-email", { method: "POST", body: { token: params.token } })
.then(() => setState("done"))
.catch(() => setState("failed"));
}, [params.token]);
if (state === "working") return <LoadingView />;
return (
<Screen scroll={false} style={{ justifyContent: "center", gap: spacing.md }}>
<Title>{state === "done" ? t("auth.verifyDoneTitle") : t("auth.verifyFailedTitle")}</Title>
<Body muted>{state === "done" ? t("auth.verifyDoneBody") : t("auth.verifyFailedBody")}</Body>
<Spacer size={spacing.sm} />
<Link href="/(tabs)" style={{ textAlign: "center", color: colors.primaryDark }}>
{t("common.done")}
</Link>
</Screen>
);
}
+83
View File
@@ -0,0 +1,83 @@
import { Redirect, Tabs } from "expo-router";
import { Text } from "react-native";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api";
import { persistLanguageTag, useAuth } from "@/lib/auth";
import { colors } from "@/lib/theme";
import { getLanguageTag, t } from "@/lib/i18n";
import { setMeasurementSystem, setUnitLanguage } from "@/lib/units";
import type { MeasurementSystem } from "@app/shared-types";
function TabIcon({ glyph, focused }: { glyph: string; focused: boolean }) {
return <Text style={{ fontSize: 22, opacity: focused ? 1 : 0.45 }}>{glyph}</Text>;
}
interface LocalePrefs {
languageTag: string;
measurementSystem: MeasurementSystem;
}
/** Applicera backend-preferenser på språk/mått vid inloggad start (i18n M4M5). */
function useApplyLocalePreferences(enabled: boolean) {
useQuery({
queryKey: ["locale-preferences"],
queryFn: async () => {
const prefs = await api<LocalePrefs>("/v1/me/locale-preferences");
setMeasurementSystem(prefs.measurementSystem);
setUnitLanguage(prefs.languageTag.split("-")[0] ?? "sv");
if (prefs.languageTag !== getLanguageTag()) await persistLanguageTag(prefs.languageTag);
return prefs;
},
enabled,
staleTime: 3600_000,
});
}
export default function TabsLayout() {
const accessToken = useAuth((s) => s.accessToken);
const onboardingCompleted = useAuth((s) => s.onboardingCompleted);
useApplyLocalePreferences(Boolean(accessToken));
if (!accessToken) return <Redirect href="/(auth)/login" />;
if (!onboardingCompleted) return <Redirect href="/onboarding" />;
return (
<Tabs
screenOptions={{
headerShown: false,
tabBarActiveTintColor: colors.primaryDark,
tabBarInactiveTintColor: colors.textMuted,
tabBarStyle: { backgroundColor: colors.surface, borderTopColor: colors.border },
}}
>
<Tabs.Screen
name="index"
options={{
title: t("tabs.whatToEat"),
tabBarIcon: ({ focused }) => <TabIcon glyph="🍽️" focused={focused} />,
}}
/>
<Tabs.Screen
name="scan"
options={{
title: t("tabs.scan"),
tabBarIcon: ({ focused }) => <TabIcon glyph="📷" focused={focused} />,
}}
/>
<Tabs.Screen
name="my-day"
options={{
title: t("tabs.myDay"),
tabBarIcon: ({ focused }) => <TabIcon glyph="📊" focused={focused} />,
}}
/>
<Tabs.Screen
name="home"
options={{
title: t("tabs.home"),
tabBarIcon: ({ focused }) => <TabIcon glyph="🏠" focused={focused} />,
}}
/>
</Tabs>
);
}
+187
View File
@@ -0,0 +1,187 @@
import { View } from "react-native";
import { router } from "expo-router";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api";
import { t } from "@/lib/i18n";
import { formatMinor } from "@/lib/money";
import {
Body,
Card,
EmptyState,
ErrorView,
Heading,
LoadingView,
Row,
Screen,
Small,
Spacer,
Tag,
Title,
} from "@/components/ui";
import { spacing } from "@/lib/theme";
import { formatQuantity } from "@/lib/units";
/** Hemma (spec §4.4): matlager, bäst före, matlådor, inköpslista, budget, hushåll. */
interface InventoryItem {
id: string;
displayName: string;
quantity: number;
unit: string;
locationName: string;
expiry: { status: string; daysLeft: number | null; pastBestBefore: boolean };
}
interface BudgetSummary {
currency: string;
week: { purchasedMinor: number; wasteMinor: number; budgetMinor: number | null };
month: { purchasedMinor: number; wasteMinor: number };
}
export default function HomeScreen() {
const inventory = useQuery({
queryKey: ["inventory"],
queryFn: () => api<{ items: InventoryItem[] }>("/v1/inventory?limit=100"),
});
const expiring = useQuery({
queryKey: ["inventory-expiring"],
queryFn: () => api<{ items: InventoryItem[] }>("/v1/inventory/expiring"),
});
const budget = useQuery({
queryKey: ["budget"],
queryFn: () => api<BudgetSummary>("/v1/budget/summary"),
});
if (inventory.isLoading) return <LoadingView />;
if (inventory.isError) return <ErrorView onRetry={() => void inventory.refetch()} />;
const items = inventory.data?.items ?? [];
const urgent = expiring.data?.items ?? [];
const grouped = new Map<string, InventoryItem[]>();
for (const item of items) {
const list = grouped.get(item.locationName) ?? [];
list.push(item);
grouped.set(item.locationName, list);
}
return (
<Screen>
<Title>{t("home.title")}</Title>
<Row>
<QuickLink label={t("home.shopping")} glyph="🛒" onPress={() => router.push("/shopping")} />
<QuickLink
label={t("home.mealBoxes")}
glyph="🍱"
onPress={() => router.push("/meal-boxes")}
/>
<QuickLink
label={t("home.household")}
glyph="👥"
onPress={() => router.push("/household")}
/>
<QuickLink label={t("profile.title")} glyph="⚙️" onPress={() => router.push("/profile")} />
</Row>
{budget.data && (
<Card>
<Heading>{t("home.budget")}</Heading>
<Row style={{ justifyContent: "space-between" }}>
<Small>{t("home.thisWeek")}</Small>
<Body>
{formatMinor(budget.data.week.purchasedMinor, budget.data.currency)}
{budget.data.week.budgetMinor != null
? ` / ${formatMinor(budget.data.week.budgetMinor, budget.data.currency)}`
: ""}
</Body>
</Row>
<Row style={{ justifyContent: "space-between" }}>
<Small>{t("home.wasteWeek")}</Small>
<Body>{formatMinor(budget.data.week.wasteMinor, budget.data.currency)}</Body>
</Row>
</Card>
)}
{urgent.length > 0 && (
<Card>
<Heading> {t("home.useSoon")}</Heading>
{urgent.slice(0, 5).map((item) => (
<Row key={item.id} style={{ justifyContent: "space-between" }}>
<Body>{item.displayName}</Body>
<ExpiryTag expiry={item.expiry} />
</Row>
))}
{/* Mjölkprincipen (spec §13): bäst före ≠ dålig döm aldrig mat i onödan. */}
{urgent.some((i) => i.expiry.pastBestBefore) && <Small>{t("home.useSoonHint")}</Small>}
</Card>
)}
<Heading>{t("home.inventory")}</Heading>
{items.length === 0 && <EmptyState text={t("home.emptyInventory")} />}
{[...grouped.entries()].map(([location, locationItems]) => (
<Card key={location}>
<Heading>{location}</Heading>
{locationItems.slice(0, 12).map((item) => (
<Row key={item.id} style={{ justifyContent: "space-between" }}>
<Body>
{item.displayName} · {formatQuantity(item.quantity, item.unit)}
</Body>
<ExpiryTag expiry={item.expiry} />
</Row>
))}
{locationItems.length > 12 && (
<Small>{t("home.moreItems", { count: locationItems.length - 12 })}</Small>
)}
</Card>
))}
<Spacer />
</Screen>
);
}
function QuickLink({
label,
glyph,
onPress,
}: {
label: string;
glyph: string;
onPress: () => void;
}) {
return (
<Card onPress={onPress} style={{ flex: 1, alignItems: "center", paddingVertical: spacing.sm }}>
<Body>{glyph}</Body>
<Small>{label}</Small>
</Card>
);
}
function ExpiryTag({ expiry }: { expiry: InventoryItem["expiry"] }) {
if (expiry.status === "expired") return <Tag label={t("home.expired")} tone="danger" />;
if (expiry.pastBestBefore) return <Tag label={t("home.pastBestBefore")} tone="warning" />;
if (expiry.status === "expiring") {
return (
<Tag
label={
expiry.daysLeft === 0
? t("home.expiresToday")
: t("home.expiresIn", { days: expiry.daysLeft ?? 0, count: expiry.daysLeft ?? 0 })
}
tone="warning"
/>
);
}
if (expiry.status === "use_soon") {
return (
<Tag
label={t("home.expiresIn", { days: expiry.daysLeft ?? 0, count: expiry.daysLeft ?? 0 })}
tone="neutral"
/>
);
}
return <View />;
}
function formatQty(quantity: number): string {
return Number.isInteger(quantity) ? String(quantity) : quantity.toFixed(1);
}
+159
View File
@@ -0,0 +1,159 @@
import { useState } from "react";
import { View } from "react-native";
import { router } from "expo-router";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api";
import { t } from "@/lib/i18n";
import {
Body,
Button,
Card,
EmptyState,
ErrorView,
Heading,
Input,
LoadingView,
Row,
Screen,
Small,
Spacer,
Tag,
Title,
} from "@/components/ui";
import { colors, spacing } from "@/lib/theme";
/** "Vad ska vi äta?" (spec §4.1, §18) appens viktigaste vy. */
interface Recommendation {
recipeId: string;
titleSv: string;
score: number;
whySv: string;
missingIngredients: string[];
usesExpiring: Array<{ nameSv: string; daysLeft: number | null }>;
coveragePercent: number;
}
interface MealBoxSuggestion {
mealBoxId: string;
titleSv: string;
portionsRemaining: number;
recommendedUseBy: string;
whySv: string;
}
interface WhatToEatResponse {
recommendations: Recommendation[];
mealBoxSuggestions: MealBoxSuggestion[];
context: { activeHolidays: string[]; remainingKcal: number; remainingProteinG: number };
}
export default function WhatToEatScreen() {
const [craving, setCraving] = useState("");
const [submittedCraving, setSubmittedCraving] = useState("");
const query = useQuery({
queryKey: ["what-to-eat", submittedCraving],
queryFn: () =>
api<WhatToEatResponse>(
`/v1/recommendations/what-to-eat?limit=5${submittedCraving ? `&craving=${encodeURIComponent(submittedCraving)}` : ""}`,
),
});
return (
<Screen>
<Title>{t("wte.title")}</Title>
<Small>{t("wte.subtitle")}</Small>
<Spacer size={spacing.sm} />
<Row>
<View style={{ flex: 1 }}>
<Input
placeholder={t("wte.cravingPlaceholder")}
value={craving}
onChangeText={setCraving}
onSubmitEditing={() => setSubmittedCraving(craving)}
returnKeyType="search"
/>
</View>
</Row>
<Spacer size={spacing.sm} />
{query.isLoading && <LoadingView />}
{query.isError && <ErrorView onRetry={() => void query.refetch()} />}
{query.data && (
<>
{query.data.context.activeHolidays.length > 0 && (
<Row>
{query.data.context.activeHolidays.map((holiday) => (
<Tag key={holiday} label={`🎉 ${holiday}`} tone="accent" />
))}
</Row>
)}
{query.data.mealBoxSuggestions.length > 0 && (
<>
<Heading>{t("wte.mealBoxFirst")}</Heading>
{query.data.mealBoxSuggestions.map((box) => (
<Card key={box.mealBoxId} onPress={() => router.push("/meal-boxes")}>
<Row style={{ justifyContent: "space-between" }}>
<Body>🍱 {box.titleSv}</Body>
<Tag
label={t("mealbox.portionsLeft", { count: box.portionsRemaining })}
tone="success"
/>
</Row>
<Small>{box.whySv}</Small>
</Card>
))}
<Spacer size={spacing.sm} />
</>
)}
{query.data.recommendations.length === 0 && <EmptyState text={t("wte.empty")} />}
{query.data.recommendations.map((rec, index) => (
<Card key={rec.recipeId} onPress={() => router.push(`/recipe/${rec.recipeId}`)}>
<Row style={{ justifyContent: "space-between" }}>
<Heading>
{index === 0 ? "⭐ " : ""}
{rec.titleSv}
</Heading>
</Row>
<Row>
<Tag
label={t("wte.coverage", { pct: rec.coveragePercent })}
tone={rec.coveragePercent >= 80 ? "success" : "neutral"}
/>
{rec.usesExpiring.slice(0, 2).map((item) => (
<Tag key={item.nameSv} label={`${item.nameSv}`} tone="warning" />
))}
</Row>
<View
style={{
backgroundColor: colors.surfaceAlt,
borderRadius: 10,
padding: spacing.sm,
marginTop: spacing.xs,
}}
>
<Small>💡 {rec.whySv}</Small>
</View>
{rec.missingIngredients.length > 0 && (
<Small>
{t("wte.missing", { items: rec.missingIngredients.slice(0, 4).join(", ") })}
</Small>
)}
</Card>
))}
<Spacer size={spacing.sm} />
<Button
label={t("wte.refresh")}
variant="secondary"
onPress={() => void query.refetch()}
/>
</>
)}
</Screen>
);
}
+192
View File
@@ -0,0 +1,192 @@
import { View } from "react-native";
import { router } from "expo-router";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api";
import { t } from "@/lib/i18n";
import {
Body,
Button,
Card,
EmptyState,
ErrorView,
Heading,
LoadingView,
ProgressBar,
Row,
Screen,
Small,
Spacer,
Tag,
Title,
} from "@/components/ui";
import { spacing } from "@/lib/theme";
/** Min dag (spec §4.3): måltider, makron, återstående mål, nästa steg. */
interface DayResponse {
date: string;
meals: Array<{
id: string;
titleSv: string;
mealType: string;
nutrition: { kcal: number; proteinG: number };
nutritionIsEstimate: boolean;
estimateMinKcal: number | null;
estimateMaxKcal: number | null;
}>;
summary: {
consumed: {
kcal: number;
proteinG: number;
carbsG: number;
fatG: number;
fiberG: number;
saltG: number;
};
targets: {
kcal: number;
proteinG: number;
carbsG: number;
fatG: number;
fiberG: number;
saltMaxG: number;
};
remaining: { kcal: number };
progress: {
kcal: number;
proteinG: number;
carbsG: number;
fatG: number;
fiberG: number;
saltOfMax: number;
};
saltWarning: boolean;
};
note: string;
}
/** Måltidstypernas etiketter bor i i18n-katalogen: myday.mealType.<typ> (12 språk). */
const mealTypeLabel = (mealType: string): string =>
["breakfast", "lunch", "dinner", "snack", "dessert"].includes(mealType)
? t(`myday.mealType.${mealType}`)
: mealType;
export default function MyDayScreen() {
const today = new Date().toISOString().slice(0, 10);
const query = useQuery({
queryKey: ["day", today],
queryFn: () => api<DayResponse>(`/v1/meals/day?date=${today}`),
});
if (query.isLoading) return <LoadingView />;
if (query.isError || !query.data) return <ErrorView onRetry={() => void query.refetch()} />;
const { summary, meals, note } = query.data;
const remaining = summary.remaining.kcal;
const macros = [
{
key: "myday.protein",
value: summary.consumed.proteinG,
target: summary.targets.proteinG,
progress: summary.progress.proteinG,
unit: "g",
},
{
key: "myday.carbs",
value: summary.consumed.carbsG,
target: summary.targets.carbsG,
progress: summary.progress.carbsG,
unit: "g",
},
{
key: "myday.fat",
value: summary.consumed.fatG,
target: summary.targets.fatG,
progress: summary.progress.fatG,
unit: "g",
},
{
key: "myday.fiber",
value: summary.consumed.fiberG,
target: summary.targets.fiberG,
progress: summary.progress.fiberG,
unit: "g",
},
] as const;
return (
<Screen>
<Title>{t("myday.title")}</Title>
<Card>
<Row style={{ justifyContent: "space-between" }}>
<Heading>{t("myday.calories")}</Heading>
<Body>
{Math.round(summary.consumed.kcal)} / {summary.targets.kcal} kcal
</Body>
</Row>
<ProgressBar progress={summary.progress.kcal} />
<Small>
{remaining >= 0
? t("myday.remaining", { kcal: remaining })
: t("myday.over", { kcal: Math.abs(remaining) })}
</Small>
</Card>
<Card>
{macros.map((macro) => (
<View key={macro.key} style={{ gap: 4, marginBottom: spacing.sm }}>
<Row style={{ justifyContent: "space-between" }}>
<Small>{t(macro.key)}</Small>
<Small>
{Math.round(macro.value)} / {macro.target} {macro.unit}
</Small>
</Row>
<ProgressBar progress={macro.progress} />
</View>
))}
<Row style={{ justifyContent: "space-between" }}>
<Small>{t("myday.salt")}</Small>
<Row>
{summary.saltWarning && <Tag label={t("myday.overRecommended")} tone="warning" />}
<Small>
{Math.round(summary.consumed.saltG * 10) / 10} / max {summary.targets.saltMaxG} g
</Small>
</Row>
</Row>
</Card>
<Heading>{t("myday.todaysMeals")}</Heading>
{meals.length === 0 && <EmptyState text={t("myday.noMeals")} />}
{meals.map((meal) => (
<Card key={meal.id}>
<Row style={{ justifyContent: "space-between" }}>
<Body>
{meal.titleSv}
{meal.nutritionIsEstimate ? " ~" : ""}
</Body>
<Tag label={mealTypeLabel(meal.mealType)} />
</Row>
<Small>
{meal.nutritionIsEstimate && meal.estimateMinKcal != null
? t("myday.estimateRange", {
min: meal.estimateMinKcal,
max: meal.estimateMaxKcal ?? meal.estimateMinKcal,
kcal: Math.round(meal.nutrition.kcal),
})
: t("myday.kcalProtein", {
kcal: Math.round(meal.nutrition.kcal),
protein: Math.round(meal.nutrition.proteinG),
})}
</Small>
</Card>
))}
<Spacer size={spacing.sm} />
<Button label={t("myday.logMeal")} onPress={() => router.push("/log-meal")} />
<Small>{note}</Small>
<Small>{t("myday.targetsNote")}</Small>
</Screen>
);
}
+126
View File
@@ -0,0 +1,126 @@
import { useState } from "react";
import { Alert, View } from "react-native";
import { router } from "expo-router";
import * as ImagePicker from "expo-image-picker";
import { useQuery } from "@tanstack/react-query";
import { api, uploadImage } from "@/lib/api";
import { t } from "@/lib/i18n";
import {
Body,
Card,
Heading,
LoadingView,
Row,
Screen,
Small,
Spacer,
Title,
} from "@/components/ui";
import { spacing } from "@/lib/theme";
/** Skanna (spec §4.2): kyl, frys, skafferi, ingredienser, tallrik, kvitto, streckkod, datum, näringsdeklaration. */
const SCAN_TYPES = [
{ type: "fridge", glyph: "🧊", labelKey: "scan.fridge" },
{ type: "freezer", glyph: "❄️", labelKey: "scan.freezer" },
{ type: "pantry", glyph: "🗄️", labelKey: "scan.pantry" },
{ type: "ingredients", glyph: "🥕", labelKey: "scan.ingredients" },
{ type: "plate", glyph: "🍽️", labelKey: "scan.plate" },
{ type: "receipt", glyph: "🧾", labelKey: "scan.receipt" },
{ type: "barcode", glyph: "🏷️", labelKey: "scan.barcode" },
{ type: "expiry_date", glyph: "📅", labelKey: "scan.expiry" },
{ type: "nutrition_label", glyph: "🔬", labelKey: "scan.nutrition" },
] as const;
interface CreateScanResponse {
scan: { id: string };
uploads: Array<{ key: string; uploadUrl: string; headers: Record<string, string> }>;
}
interface Entitlements {
aiScansPerMonth: number;
aiScansUsedThisMonth: number;
}
export default function ScanScreen() {
const [busyType, setBusyType] = useState<string | null>(null);
const entitlements = useQuery({
queryKey: ["entitlements"],
queryFn: () => api<Entitlements>("/v1/me/entitlements"),
});
const startScan = async (scanType: string) => {
if (scanType === "barcode") {
router.push("/barcode");
return;
}
setBusyType(scanType);
try {
// 1. Ta bild
const permission = await ImagePicker.requestCameraPermissionsAsync();
let picked: ImagePicker.ImagePickerResult;
if (permission.granted) {
picked = await ImagePicker.launchCameraAsync({ quality: 0.7 });
} else {
picked = await ImagePicker.launchImageLibraryAsync({ quality: 0.7 });
}
const asset = picked.assets?.[0];
if (picked.canceled || !asset) return;
// 2. Skapa jobb + presignad upload (kvotkontroll sker i API:t)
const created = await api<CreateScanResponse>("/v1/scans", {
method: "POST",
body: { scanType, imageCount: 1, contentType: "image/jpeg" },
});
// 3. Ladda upp + starta analysen
const upload = created.uploads[0];
if (!upload) throw new Error(t("common.error"));
await uploadImage(upload, asset.uri);
await api(`/v1/scans/${created.scan.id}/start`, { method: "POST" });
// 4. Vidare till granskningsvyn som pollar tills resultatet kommer
router.push(`/scan-review/${created.scan.id}`);
} catch (err) {
Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error"));
} finally {
setBusyType(null);
}
};
const quota = entitlements.data
? entitlements.data.aiScansPerMonth - entitlements.data.aiScansUsedThisMonth
: null;
return (
<Screen>
<Title>{t("scan.title")}</Title>
<Small>{t("scan.subtitle")}</Small>
{quota != null && <Small>{t("scan.quotaLeft", { count: Math.max(0, quota) })}</Small>}
<Spacer size={spacing.sm} />
{busyType && <LoadingView />}
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: spacing.sm }}>
{SCAN_TYPES.map((item) => (
<Card
key={item.type}
onPress={() => void startScan(item.type)}
style={{ width: "31%", alignItems: "center", paddingVertical: spacing.md }}
>
<Body>{item.glyph}</Body>
<Small>{t(item.labelKey as never)}</Small>
</Card>
))}
</View>
<Spacer />
<Card>
<Heading>{t("scan.tips.title")}</Heading>
<Small> {t("scan.tips.overview")}</Small>
<Small> {t("scan.tips.shelf")}</Small>
<Small> {t("scan.tips.light")}</Small>
<Small> {t("scan.tips.move")}</Small>
</Card>
</Screen>
);
}
+93
View File
@@ -0,0 +1,93 @@
import { useEffect, useState } from "react";
import { Stack } from "expo-router";
import { StatusBar } from "expo-status-bar";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { QueryClient } from "@tanstack/react-query";
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister";
import { useAuth } from "@/lib/auth";
import { BRAND } from "@/lib/brand";
import { t, useI18nVersion } from "@/lib/i18n";
import { colors } from "@/lib/theme";
import { LoadingView } from "@/components/ui";
/**
* Rotlayout: React Query med AsyncStorage-persistens för Smart Cache/offline
* (spec §44: cachelagrade recept, lager och inköpslista fungerar offline).
*/
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
gcTime: 7 * 24 * 3600_000, // behåll cache en vecka för offline-läge
retry: 1,
},
},
});
const persister = createAsyncStoragePersister({
storage: AsyncStorage,
key: `${BRAND.slug}-query-cache`,
});
export default function RootLayout() {
const hydrate = useAuth((s) => s.hydrate);
const hydrated = useAuth((s) => s.hydrated);
const [ready, setReady] = useState(false);
// Språkbyte utan omstart (i18n M4): versionen bumpar vid setLocale och
// key={} re-monterar hela trädet så att alla t()-strängar byts direkt.
const i18nVersion = useI18nVersion();
useEffect(() => {
void hydrate().then(() => setReady(true));
}, [hydrate]);
if (!hydrated || !ready) return <LoadingView />;
return (
<PersistQueryClientProvider
key={`i18n-${i18nVersion}`}
client={queryClient}
persistOptions={{ persister }}
>
<StatusBar style="dark" />
<Stack
screenOptions={{
headerStyle: { backgroundColor: colors.background },
headerTintColor: colors.text,
headerShadowVisible: false,
contentStyle: { backgroundColor: colors.background },
}}
>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="(auth)/login" options={{ headerShown: false }} />
<Stack.Screen name="(auth)/register" options={{ headerShown: false }} />
<Stack.Screen name="(auth)/forgot-password" options={{ headerShown: false }} />
<Stack.Screen name="(auth)/reset-password" options={{ headerShown: false }} />
<Stack.Screen name="(auth)/verify-email" options={{ headerShown: false }} />
<Stack.Screen name="onboarding" options={{ headerShown: false, gestureEnabled: false }} />
<Stack.Screen name="recipe/[id]" options={{ title: "" }} />
<Stack.Screen
name="cooking/[id]"
options={{ title: "", presentation: "fullScreenModal" }}
/>
<Stack.Screen name="scan-review/[jobId]" options={{ title: t("scan.review.title") }} />
<Stack.Screen name="shopping" options={{ title: t("shopping.title") }} />
<Stack.Screen name="meal-boxes" options={{ title: t("mealbox.title") }} />
<Stack.Screen name="household" options={{ title: t("home.household") }} />
<Stack.Screen name="memory" options={{ title: t("memory.title") }} />
<Stack.Screen name="profile" options={{ title: t("profile.title") }} />
<Stack.Screen
name="paywall"
options={{ title: t("paywall.title"), presentation: "modal" }}
/>
<Stack.Screen
name="log-meal"
options={{ title: t("myday.logMeal"), presentation: "modal" }}
/>
<Stack.Screen name="barcode" options={{ title: "Streckkod" }} />
</Stack>
</PersistQueryClientProvider>
);
}
+145
View File
@@ -0,0 +1,145 @@
import { useState } from "react";
import { Alert, StyleSheet, View } from "react-native";
import { router } from "expo-router";
import { CameraView, useCameraPermissions } from "expo-camera";
import { useQueryClient } from "@tanstack/react-query";
import { api } from "@/lib/api";
import { t } from "@/lib/i18n";
import { Body, Button, Card, Heading, LoadingView, Screen, Small, Spacer } from "@/components/ui";
import { colors } from "@/lib/theme";
/**
* Streckkodsskanning (spec §11): läses LOKALT med kameran, slås upp mot
* egen produktdatabas → Open Food Facts. Saknas produkten uppmanas
* användaren att fota förpackningen (READ_NUTRITION_LABEL-flödet).
*/
interface BarcodeScanResponse {
product: {
id: string;
name: string;
brand: string | null;
nutrition: { values: { kcal: number } } | null;
} | null;
}
export default function BarcodeScreen() {
const [permission, requestPermission] = useCameraPermissions();
const [busy, setBusy] = useState(false);
const [lastCode, setLastCode] = useState<string | null>(null);
const queryClient = useQueryClient();
const onScanned = async (gtin: string) => {
if (busy || gtin === lastCode) return;
setBusy(true);
setLastCode(gtin);
try {
const result = await api<BarcodeScanResponse>("/v1/scans", {
method: "POST",
body: { scanType: "barcode", barcode: gtin, imageCount: 0 },
});
if (result.product) {
Alert.alert(
result.product.name,
`${result.product.brand ?? ""}\n${result.product.nutrition ? t("barcode.kcalPer100", { kcal: result.product.nutrition.values.kcal }) : t("barcode.noNutrition")}\n\n${t("barcode.addPrompt")}`,
[
{ text: t("common.cancel"), style: "cancel", onPress: () => setBusy(false) },
{
text: t("common.add"),
onPress: () => {
void addToInventory(result.product!.name);
},
},
],
);
} else {
Alert.alert(t("barcode.unknownTitle"), t("barcode.unknownBody"), [
{ text: t("common.cancel"), style: "cancel", onPress: () => setBusy(false) },
{ text: t("barcode.photoPackage"), onPress: () => router.replace("/(tabs)/scan") },
]);
}
} catch (err) {
Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error"));
setBusy(false);
}
};
const addToInventory = async (name: string) => {
try {
const me = await api<{ activeHouseholdId: string | null }>("/v1/me");
if (!me.activeHouseholdId) throw new Error(t("barcode.needHousehold"));
const household = await api<{ storageLocations: Array<{ id: string; type: string }> }>(
`/v1/households/${me.activeHouseholdId}`,
);
const fridge =
household.storageLocations.find((l) => l.type === "fridge") ??
household.storageLocations[0];
if (!fridge) throw new Error(t("barcode.noLocation"));
await api("/v1/inventory/items", {
method: "POST",
body: {
displayName: name,
quantity: 1,
unit: "COUNT",
storageLocationId: fridge.id,
source: "barcode",
},
});
await queryClient.invalidateQueries({ queryKey: ["inventory"] });
router.back();
} catch (err) {
Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error"));
setBusy(false);
}
};
if (!permission) return <LoadingView />;
if (!permission.granted) {
return (
<Screen>
<Card>
<Heading>{t("barcode.cameraTitle")}</Heading>
<Body>{t("barcode.cameraBody")}</Body>
<Spacer size={8} />
<Button label={t("barcode.allowCamera")} onPress={() => void requestPermission()} />
</Card>
</Screen>
);
}
return (
<View style={styles.container}>
<CameraView
style={StyleSheet.absoluteFill}
barcodeScannerSettings={{ barcodeTypes: ["ean13", "ean8", "upc_a", "upc_e"] }}
onBarcodeScanned={(scan) => void onScanned(scan.data)}
/>
<View style={styles.overlay}>
<View style={styles.frame} />
<Small>{busy ? t("scan.analyzing") : t("barcode.aim")}</Small>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: "#000" },
overlay: {
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
alignItems: "center",
justifyContent: "center",
gap: 16,
},
frame: {
width: 260,
height: 160,
borderWidth: 3,
borderColor: colors.primary,
borderRadius: 16,
backgroundColor: "transparent",
},
});
+212
View File
@@ -0,0 +1,212 @@
import { useEffect, useRef, useState } from "react";
import { Alert, Pressable, Text, View } from "react-native";
import { router, useLocalSearchParams } from "expo-router";
import { useKeepAwake } from "expo-keep-awake";
import * as Haptics from "expo-haptics";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "@/lib/api";
import { t } from "@/lib/i18n";
import {
Body,
Button,
Card,
ErrorView,
Heading,
LoadingView,
Row,
Screen,
Small,
Spacer,
} from "@/components/ui";
import { colors, spacing } from "@/lib/theme";
/**
* Cooking Mode (spec §41): stora steg, stora knappar, skärmen vaken,
* timers per steg, portionsskalning. Avslutas med "jag lagade detta"
* som drar lager, loggar måltid och kan skapa matlådor (spec §2324).
*/
interface RecipeForCooking {
id: string;
titleSv: string;
portions: number;
steps: Array<{
id: string;
stepNumber: number;
instructionSv: string;
timerSeconds: number | null;
temperatureC: number | null;
tip: string | null;
}>;
}
export default function CookingScreen() {
useKeepAwake();
const { id, portions: portionsParam } = useLocalSearchParams<{ id: string; portions?: string }>();
const queryClient = useQueryClient();
const [stepIndex, setStepIndex] = useState(0);
const [timerLeft, setTimerLeft] = useState<number | null>(null);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const [finishing, setFinishing] = useState(false);
const [mealBoxPortions, setMealBoxPortions] = useState(0);
const query = useQuery({
queryKey: ["recipe", id],
queryFn: () => api<RecipeForCooking>(`/v1/recipes/${id}`),
});
const cook = useMutation({
mutationFn: (body: unknown) => api(`/v1/recipes/${id}/cook`, { method: "POST", body }),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["inventory"] });
await queryClient.invalidateQueries({ queryKey: ["day"] });
await queryClient.invalidateQueries({ queryKey: ["what-to-eat"] });
router.dismissAll();
},
onError: (err) =>
Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error")),
});
useEffect(() => {
return () => {
if (timerRef.current) clearInterval(timerRef.current);
};
}, []);
if (query.isLoading) return <LoadingView />;
if (query.isError || !query.data) return <ErrorView onRetry={() => void query.refetch()} />;
const recipe = query.data;
const portionsCooked = Number(portionsParam ?? recipe.portions) || recipe.portions;
const step = recipe.steps[stepIndex];
const isLast = stepIndex >= recipe.steps.length - 1;
const startTimer = (seconds: number) => {
if (timerRef.current) clearInterval(timerRef.current);
setTimerLeft(seconds);
timerRef.current = setInterval(() => {
setTimerLeft((prev) => {
if (prev == null || prev <= 1) {
if (timerRef.current) clearInterval(timerRef.current);
void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
return 0;
}
return prev - 1;
});
}, 1000);
};
const finish = () => {
setFinishing(true);
};
if (finishing) {
return (
<Screen>
<Heading>{t("cooked.title")}</Heading>
<Card>
<Body>{recipe.titleSv}</Body>
<Small>
{t("cooked.portionsCooked")}: {portionsCooked}
</Small>
</Card>
<Card>
<Row style={{ justifyContent: "space-between" }}>
<Body>🍱 {t("cooked.mealBoxes")}</Body>
<Row>
<Button
label=""
variant="ghost"
onPress={() => setMealBoxPortions(Math.max(0, mealBoxPortions - 1))}
/>
<Body>{mealBoxPortions}</Body>
<Button
label="+"
variant="ghost"
onPress={() => setMealBoxPortions(Math.min(portionsCooked, mealBoxPortions + 1))}
/>
</Row>
</Row>
</Card>
<Small>{t("cooked.deductPantryNote")}</Small>
<Spacer size={spacing.sm} />
<Button
label={t("cooking.finish")}
loading={cook.isPending}
onPress={() =>
cook.mutate({
portionsCooked,
mealBoxPortions,
mealBoxFrozen: false,
deductInventory: true,
mealType: "dinner",
})
}
/>
<Button label={t("common.back")} variant="ghost" onPress={() => setFinishing(false)} />
</Screen>
);
}
return (
<Screen scroll={false} style={{ justifyContent: "space-between" }}>
<View style={{ gap: spacing.md }}>
<Small>{recipe.titleSv}</Small>
<Heading>
{t("cooking.step", { current: stepIndex + 1, total: recipe.steps.length })}
</Heading>
<Text style={{ fontSize: 24, lineHeight: 34, color: colors.text }}>
{step?.instructionSv}
{step?.temperatureC ? ` (${step.temperatureC} °C)` : ""}
</Text>
{step?.tip && <Small>💡 {step.tip}</Small>}
{step?.timerSeconds != null && (
<Pressable
onPress={() => startTimer(step.timerSeconds!)}
style={{
backgroundColor: timerLeft != null ? colors.accentSoft : colors.primarySoft,
padding: spacing.lg,
borderRadius: 16,
alignItems: "center",
}}
>
<Text style={{ fontSize: 32, fontWeight: "700", color: colors.primaryDark }}>
{timerLeft != null ? formatTime(timerLeft) : formatTime(step.timerSeconds)}
</Text>
<Small>
{timerLeft != null
? timerLeft === 0
? `${t("cooking.timerDone")} 🎉`
: t("cooking.timerRunning", { time: formatTime(timerLeft) })
: t("cooking.timer")}
</Small>
</Pressable>
)}
<Small>{t("cooking.keepAwake")}</Small>
</View>
<Row style={{ paddingBottom: spacing.lg }}>
<View style={{ flex: 1 }}>
<Button
label={`${t("common.back")}`}
variant="ghost"
disabled={stepIndex === 0}
onPress={() => setStepIndex(Math.max(0, stepIndex - 1))}
/>
</View>
<View style={{ flex: 2 }}>
<Button
label={isLast ? t("cooking.finish") : `${t("common.next")}`}
onPress={() => (isLast ? finish() : setStepIndex(stepIndex + 1))}
/>
</View>
</Row>
</Screen>
);
}
function formatTime(totalSeconds: number): string {
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}:${String(seconds).padStart(2, "0")}`;
}
+91
View File
@@ -0,0 +1,91 @@
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api";
import { t } from "@/lib/i18n";
import {
Body,
Card,
EmptyState,
ErrorView,
Heading,
LoadingView,
Row,
Screen,
Small,
Tag,
} from "@/components/ui";
/** Hushåll (spec §7): medlemmar, inbjudningskod, delat vs privat. */
interface HouseholdDetail {
id: string;
name: string;
inviteCode: string;
members: Array<{ userId: string; displayName: string; role: string; portionFactor: number }>;
storageLocations: Array<{ id: string; name: string; type: string }>;
}
/** Rolletiketter ur i18n-katalogen: household.role.<roll> (12 språk). */
const roleLabel = (role: string): string =>
["owner", "adult", "member", "child"].includes(role) ? t(`household.role.${role}`) : role;
export default function HouseholdScreen() {
const me = useQuery({
queryKey: ["me"],
queryFn: () => api<{ activeHouseholdId: string | null }>("/v1/me"),
});
const householdId = me.data?.activeHouseholdId;
const household = useQuery({
queryKey: ["household", householdId],
queryFn: () => api<HouseholdDetail>(`/v1/households/${householdId}`),
enabled: Boolean(householdId),
});
if (me.isLoading || household.isLoading) return <LoadingView />;
if (!householdId) {
return (
<Screen>
<EmptyState text={t("household.empty")} />
</Screen>
);
}
if (household.isError || !household.data) {
return <ErrorView onRetry={() => void household.refetch()} />;
}
const data = household.data;
return (
<Screen>
<Heading>{data.name}</Heading>
<Card>
<Body>{t("household.invite", { code: data.inviteCode })}</Body>
<Small>{t("household.shareCode")}</Small>
</Card>
<Card>
<Heading>{t("household.members")}</Heading>
{data.members.map((member) => (
<Row key={member.userId} style={{ justifyContent: "space-between" }}>
<Body>{member.displayName}</Body>
<Row>
<Tag label={roleLabel(member.role)} />
<Small>{t("household.portionFactor", { factor: member.portionFactor })}</Small>
</Row>
</Row>
))}
</Card>
<Card>
<Heading>{t("household.locations")}</Heading>
{data.storageLocations.map((location) => (
<Body key={location.id}> {location.name}</Body>
))}
</Card>
<Card>
<Small> {t("household.shared")}</Small>
<Small>🔒 {t("household.private")}</Small>
</Card>
</Screen>
);
}
+137
View File
@@ -0,0 +1,137 @@
import { useState } from "react";
import { Alert, View } from "react-native";
import { router } from "expo-router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "@/lib/api";
import { t } from "@/lib/i18n";
import { Body, Button, Card, Heading, Input, Row, Screen, Small, Spacer } from "@/components/ui";
/**
* Måltidsloggning (spec §23): tidigare måltid, favoritrecept eller manuellt.
* Tallriksfoto loggas via Skanna-fliken. Näringsvärden hittas ALDRIG på:
* manuell loggning kräver egna värden eller känt recept/livsmedel.
*/
/** Etiketter ur i18n-katalogen (myday.mealType.<typ>) id:na är stabila API-värden. */
const MEAL_TYPES = ["breakfast", "lunch", "dinner", "snack"] as const;
interface RecentMeal {
id: string;
titleSv: string;
mealType: string;
nutrition: {
kcal: number;
proteinG: number;
carbsG: number;
fatG: number;
saturatedFatG: number;
fiberG: number;
sugarG: number;
saltG: number;
};
}
export default function LogMealScreen() {
const queryClient = useQueryClient();
const [mealType, setMealType] = useState<string>("lunch");
const [title, setTitle] = useState("");
const [kcal, setKcal] = useState("");
const [protein, setProtein] = useState("");
const recent = useQuery({
queryKey: ["recent-meals"],
queryFn: () => api<{ meals: RecentMeal[] }>("/v1/meals/recent"),
});
const log = useMutation({
mutationFn: (body: unknown) => api("/v1/meals", { method: "POST", body }),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["day"] });
router.back();
},
onError: (err) =>
Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error")),
});
const today = new Date().toISOString().slice(0, 10);
const logManual = () => {
if (!title.trim() || !kcal) {
Alert.alert(t("logmeal.validationTitle"), t("logmeal.validationBody"));
return;
}
log.mutate({
date: today,
mealType,
source: "manual",
titleSv: title.trim(),
nutritionOverride: {
kcal: Number(kcal),
proteinG: Number(protein) || 0,
carbsG: 0,
fatG: 0,
saturatedFatG: 0,
fiberG: 0,
sugarG: 0,
saltG: 0,
},
});
};
const logPrevious = (meal: RecentMeal) => {
log.mutate({
date: today,
mealType,
source: "previous_meal",
titleSv: meal.titleSv,
nutritionOverride: meal.nutrition,
});
};
return (
<Screen>
<Heading>{t("logmeal.mealTypeTitle")}</Heading>
<Row>
{MEAL_TYPES.map((id) => (
<Button
key={id}
label={t(`myday.mealType.${id}`)}
variant={mealType === id ? "secondary" : "ghost"}
onPress={() => setMealType(id)}
/>
))}
</Row>
<Spacer />
<Heading>{t("logmeal.quickTitle")}</Heading>
<Small>{t("logmeal.quickSubtitle")}</Small>
{(recent.data?.meals ?? []).slice(0, 5).map((meal) => (
<Card key={meal.id} onPress={() => logPrevious(meal)}>
<Row style={{ justifyContent: "space-between" }}>
<Body>{meal.titleSv}</Body>
<Small>{Math.round(meal.nutrition.kcal)} kcal</Small>
</Row>
</Card>
))}
<Spacer />
<Heading>{t("logmeal.manualTitle")}</Heading>
<Input placeholder={t("logmeal.whatPlaceholder")} value={title} onChangeText={setTitle} />
<Row>
<View style={{ flex: 1 }}>
<Input placeholder="kcal" keyboardType="numeric" value={kcal} onChangeText={setKcal} />
</View>
<View style={{ flex: 1 }}>
<Input
placeholder={t("logmeal.proteinPlaceholder")}
keyboardType="numeric"
value={protein}
onChangeText={setProtein}
/>
</View>
</Row>
<Button label={t("myday.logMeal")} onPress={logManual} loading={log.isPending} />
<Small>{t("logmeal.photoTip")}</Small>
</Screen>
);
}
+92
View File
@@ -0,0 +1,92 @@
import { Alert } from "react-native";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "@/lib/api";
import { t } from "@/lib/i18n";
import {
Body,
Button,
Card,
EmptyState,
ErrorView,
LoadingView,
Row,
Screen,
Small,
Tag,
} from "@/components/ui";
/** Matlådor (spec §24). */
interface MealBox {
id: string;
titleSv: string;
portionsRemaining: number;
recommendedUseBy: string;
frozen: boolean;
kcalPerPortion?: number;
}
export default function MealBoxesScreen() {
const queryClient = useQueryClient();
const query = useQuery({
queryKey: ["meal-boxes"],
queryFn: () => api<{ mealBoxes: MealBox[] }>("/v1/meal-boxes"),
});
const consume = useMutation({
mutationFn: (id: string) =>
api(`/v1/meal-boxes/${id}/consume`, {
method: "POST",
body: { portions: 1, logAsMeal: true },
}),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["meal-boxes"] });
await queryClient.invalidateQueries({ queryKey: ["day"] });
Alert.alert(t("mealbox.enjoyTitle"), t("mealbox.enjoyBody"));
},
onError: (err) =>
Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error")),
});
if (query.isLoading) return <LoadingView />;
if (query.isError || !query.data) return <ErrorView onRetry={() => void query.refetch()} />;
const boxes = query.data.mealBoxes;
const today = new Date().toISOString().slice(0, 10);
return (
<Screen>
{boxes.length === 0 && <EmptyState text={t("mealbox.empty")} />}
{boxes.map((box) => {
const urgent = box.recommendedUseBy <= today;
return (
<Card key={box.id}>
<Row style={{ justifyContent: "space-between" }}>
<Body>
{box.frozen ? "❄️ " : "🍱 "}
{box.titleSv}
</Body>
<Tag
label={t("mealbox.portionsLeft", { count: box.portionsRemaining })}
tone="success"
/>
</Row>
<Row style={{ justifyContent: "space-between" }}>
<Tag
label={t("mealbox.eatBy", { date: box.recommendedUseBy })}
tone={urgent ? "danger" : "neutral"}
/>
<Button
label={t("mealbox.eat")}
variant="secondary"
onPress={() => consume.mutate(box.id)}
/>
</Row>
</Card>
);
})}
{/* Mjölkprincipen (spec §13): vägledning, inte dom användaren avgör med sinnena. */}
<Small>{t("mealbox.guidanceNote")}</Small>
</Screen>
);
}
+144
View File
@@ -0,0 +1,144 @@
import { Alert } from "react-native";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "@/lib/api";
import { t } from "@/lib/i18n";
import {
Body,
Button,
Card,
EmptyState,
ErrorView,
Heading,
LoadingView,
Row,
Screen,
Small,
Spacer,
Tag,
} from "@/components/ui";
/** "Vad plattformen vet om mig" (spec §32): transparens, korrigering, paus, radering. */
interface MemoryOverview {
sections: Array<{
kind: string;
title: string;
titleSv: string;
items: Array<{
id: string;
summary: string;
summarySv: string;
origin: "user_stated" | "observed" | "ai_inferred";
confidence: number;
verifiedByUser: boolean;
paused: boolean;
}>;
}>;
totalCount: number;
pausedCount: number;
}
export default function MemoryScreen() {
const queryClient = useQueryClient();
const query = useQuery({
queryKey: ["memory"],
queryFn: () => api<MemoryOverview>("/v1/me/memory"),
});
const invalidate = () => void queryClient.invalidateQueries({ queryKey: ["memory"] });
const verify = useMutation({
mutationFn: (id: string) =>
api(`/v1/me/memory/${id}`, { method: "PATCH", body: { verified: true } }),
onSuccess: invalidate,
});
const pause = useMutation({
mutationFn: ({ id, paused }: { id: string; paused: boolean }) =>
api(`/v1/me/memory/${id}`, { method: "PATCH", body: { paused } }),
onSuccess: invalidate,
});
const remove = useMutation({
mutationFn: (id: string) => api(`/v1/me/memory/${id}`, { method: "DELETE" }),
onSuccess: invalidate,
});
const removeAll = useMutation({
mutationFn: () => api("/v1/me/memory", { method: "DELETE" }),
onSuccess: invalidate,
});
if (query.isLoading) return <LoadingView />;
if (query.isError || !query.data) return <ErrorView onRetry={() => void query.refetch()} />;
const overview = query.data;
return (
<Screen>
<Small>{t("memory.subtitle")}</Small>
{overview.totalCount === 0 && <EmptyState text={t("memory.empty")} />}
{overview.sections.map((section) => (
<Card key={section.kind}>
<Heading>{section.title}</Heading>
{section.items.map((item) => (
<Card key={item.id} style={item.paused ? { opacity: 0.5 } : undefined}>
<Body>{item.summary}</Body>
<Row>
<Tag
label={t(`memory.origin.${item.origin}` as never)}
tone={
item.origin === "user_stated"
? "success"
: item.origin === "observed"
? "neutral"
: "warning"
}
/>
{item.verifiedByUser && <Tag label={`${t("memory.verified")}`} tone="success" />}
{item.paused && <Tag label={t("memory.paused")} tone="warning" />}
</Row>
<Row>
{!item.verifiedByUser && (
<Button
label={t("memory.verify")}
variant="ghost"
onPress={() => verify.mutate(item.id)}
/>
)}
<Button
label={item.paused ? t("memory.resume") : t("memory.pause")}
variant="ghost"
onPress={() => pause.mutate({ id: item.id, paused: !item.paused })}
/>
<Button
label={t("memory.delete")}
variant="danger"
onPress={() => remove.mutate(item.id)}
/>
</Row>
</Card>
))}
</Card>
))}
{overview.totalCount > 0 && (
<>
<Spacer />
<Button
label={t("memory.deleteAll")}
variant="danger"
onPress={() =>
Alert.alert(t("memory.deleteAllTitle"), t("memory.irreversible"), [
{ text: t("common.cancel"), style: "cancel" },
{
text: t("memory.delete"),
style: "destructive",
onPress: () => removeAll.mutate(),
},
])
}
/>
</>
)}
<Small>Personligt minne används aldrig som träningsdata utan separat samtycke.</Small>
</Screen>
);
}
+307
View File
@@ -0,0 +1,307 @@
import { useState } from "react";
import { Pressable, Text, View } from "react-native";
import { router } from "expo-router";
import { api } from "@/lib/api";
import { useAuth } from "@/lib/auth";
import { t } from "@/lib/i18n";
import {
Body,
Button,
Card,
Heading,
Input,
Row,
Screen,
Small,
Spacer,
Tag,
Title,
} from "@/components/ui";
import { colors, spacing } from "@/lib/theme";
/** Onboarding (spec §6): mål, kost, allergier, hushåll, läge allt frivilligt. */
// Etiketter hämtas ur i18n-katalogen (12 språk) id:na är stabila API-värden.
const GOALS = [
"lose_weight",
"build_muscle",
"maintain_weight",
"more_protein",
"less_waste",
"lower_cost",
"cook_more",
] as const;
const DIETS = ["omnivore", "flexitarian", "pescatarian", "vegetarian", "vegan"] as const;
const ALLERGENS = [
"gluten",
"milk",
"eggs",
"tree_nuts",
"peanuts",
"fish",
"crustaceans",
"soy",
"sesame",
] as const;
export default function OnboardingScreen() {
const setOnboardingCompleted = useAuth((s) => s.setOnboardingCompleted);
const [step, setStep] = useState(0);
const [goal, setGoal] = useState<string | null>(null);
const [diet, setDiet] = useState<string>("omnivore");
const [allergens, setAllergens] = useState<string[]>([]);
const [mode, setMode] = useState<"simple" | "exact">("simple");
const [householdKind, setHouseholdKind] = useState<"create" | "join" | "skip">("create");
const [householdName, setHouseholdName] = useState(t("onboarding.householdDefaultName"));
const [inviteCode, setInviteCode] = useState("");
const [weightKg, setWeightKg] = useState("");
const [heightCm, setHeightCm] = useState("");
const [birthYear, setBirthYear] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const toggleAllergen = (id: string) =>
setAllergens((prev) => (prev.includes(id) ? prev.filter((a) => a !== id) : [...prev, id]));
const finish = async () => {
setBusy(true);
setError(null);
try {
await api("/v1/me/onboarding", {
method: "POST",
body: {
precisionMode: mode,
healthProfile: {
...(weightKg ? { weightKg: Number(weightKg) } : {}),
...(heightCm ? { heightCm: Number(heightCm) } : {}),
...(birthYear ? { birthYear: Number(birthYear) } : {}),
},
preferences: {
...(goal ? { primaryGoal: goal } : {}),
dietPattern: diet,
allergens,
},
householdChoice:
householdKind === "create"
? { kind: "create", name: householdName || t("onboarding.householdDefaultName") }
: householdKind === "join"
? { kind: "join", inviteCode }
: { kind: "skip" },
},
});
setOnboardingCompleted(true);
router.replace("/(tabs)");
} catch (err) {
setError(err instanceof Error ? err.message : t("common.error"));
} finally {
setBusy(false);
}
};
const steps = [
// 0: Mål
<View key="goal" style={{ gap: spacing.sm }}>
<Heading>{t("onboarding.goal")}</Heading>
{GOALS.map((id) => (
<SelectRow
key={id}
label={t(`onboarding.goal.${id}`)}
selected={goal === id}
onPress={() => setGoal(id)}
/>
))}
</View>,
// 1: Kost + allergier
<View key="diet" style={{ gap: spacing.sm }}>
<Heading>{t("onboarding.diet")}</Heading>
<Row>
{DIETS.map((id) => (
<Chip
key={id}
label={t(`onboarding.diet.${id}`)}
selected={diet === id}
onPress={() => setDiet(id)}
/>
))}
</Row>
<Spacer />
<Heading>{t("onboarding.allergies")}</Heading>
<Row>
{ALLERGENS.map((id) => (
<Chip
key={id}
label={t(`onboarding.allergen.${id}`)}
selected={allergens.includes(id)}
onPress={() => toggleAllergen(id)}
/>
))}
</Row>
<Small>{t("onboarding.allergyNote")}</Small>
</View>,
// 2: Kroppsdata (frivilligt, för energiberäkning)
<View key="body" style={{ gap: spacing.sm }}>
<Heading>{t("onboarding.bodyTitle")}</Heading>
<Input
placeholder={t("onboarding.weightPlaceholder")}
keyboardType="numeric"
value={weightKg}
onChangeText={setWeightKg}
/>
<Input
placeholder={t("onboarding.heightPlaceholder")}
keyboardType="numeric"
value={heightCm}
onChangeText={setHeightCm}
/>
<Input
placeholder={t("onboarding.birthYearPlaceholder")}
keyboardType="numeric"
value={birthYear}
onChangeText={setBirthYear}
/>
<Small>{t("onboarding.notMedical")}</Small>
</View>,
// 3: Hushåll
<View key="household" style={{ gap: spacing.sm }}>
<Heading>{t("onboarding.household")}</Heading>
<SelectRow
label={t("onboarding.householdCreate")}
selected={householdKind === "create"}
onPress={() => setHouseholdKind("create")}
/>
{householdKind === "create" && (
<Input
placeholder={t("onboarding.householdName")}
value={householdName}
onChangeText={setHouseholdName}
/>
)}
<SelectRow
label={t("onboarding.householdJoin")}
selected={householdKind === "join"}
onPress={() => setHouseholdKind("join")}
/>
{householdKind === "join" && (
<Input
placeholder={t("onboarding.inviteCode")}
autoCapitalize="characters"
value={inviteCode}
onChangeText={setInviteCode}
/>
)}
<SelectRow
label={t("common.skip")}
selected={householdKind === "skip"}
onPress={() => setHouseholdKind("skip")}
/>
</View>,
// 4: Läge (spec §5)
<View key="mode" style={{ gap: spacing.sm }}>
<Heading>{t("onboarding.mode")}</Heading>
<Card onPress={() => setMode("simple")} style={mode === "simple" ? sel : undefined}>
<Body>{t("onboarding.modeSimple")}</Body>
<Small>{t("onboarding.modeSimpleDesc")}</Small>
</Card>
<Card onPress={() => setMode("exact")} style={mode === "exact" ? sel : undefined}>
<Body>{t("onboarding.modeExact")}</Body>
<Small>{t("onboarding.modeExactDesc")}</Small>
</Card>
<Small>{t("onboarding.modesCombine")}</Small>
</View>,
];
const last = step === steps.length - 1;
return (
<Screen>
<Title>{t("onboarding.title")}</Title>
<Small>{t("onboarding.subtitle")}</Small>
<Row style={{ marginVertical: spacing.sm }}>
{steps.map((_, i) => (
<View
key={i}
style={{
flex: 1,
height: 4,
borderRadius: 2,
backgroundColor: i <= step ? colors.primary : colors.border,
}}
/>
))}
</Row>
{steps[step]}
{error && <Text style={{ color: colors.danger }}>{error}</Text>}
<Spacer />
<Row>
{step > 0 && (
<View style={{ flex: 1 }}>
<Button label={t("common.back")} variant="ghost" onPress={() => setStep(step - 1)} />
</View>
)}
<View style={{ flex: 2 }}>
<Button
label={last ? t("common.done") : t("common.next")}
onPress={() => (last ? void finish() : setStep(step + 1))}
loading={busy}
/>
</View>
</Row>
</Screen>
);
}
const sel = { borderColor: colors.primary, borderWidth: 2, backgroundColor: colors.primarySoft };
function SelectRow({
label,
selected,
onPress,
}: {
label: string;
selected: boolean;
onPress: () => void;
}) {
return (
<Pressable
onPress={onPress}
style={{
padding: spacing.md,
borderRadius: 12,
borderWidth: selected ? 2 : 1,
borderColor: selected ? colors.primary : colors.border,
backgroundColor: selected ? colors.primarySoft : colors.surface,
}}
>
<Text style={{ color: colors.text, fontWeight: selected ? "700" : "400" }}>{label}</Text>
</Pressable>
);
}
function Chip({
label,
selected,
onPress,
}: {
label: string;
selected: boolean;
onPress: () => void;
}) {
return (
<Pressable
onPress={onPress}
style={{
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 999,
borderWidth: 1,
borderColor: selected ? colors.primary : colors.border,
backgroundColor: selected ? colors.primarySoft : colors.surface,
}}
>
<Text style={{ color: selected ? colors.primaryDark : colors.textMuted, fontSize: 13 }}>
{label}
</Text>
</Pressable>
);
}
+95
View File
@@ -0,0 +1,95 @@
import { Alert } from "react-native";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api";
import { t } from "@/lib/i18n";
import { formatMinor } from "@/lib/money";
import {
Body,
Button,
Card,
Heading,
Row,
Screen,
Small,
Spacer,
Tag,
Title,
} from "@/components/ui";
/**
* Paywall (spec §4546). Köpet går via StoreKit/Play Billing i klienten
* (react-native-purchases eller expo-in-app-purchases integreras i fas 7);
* kvittot verifieras ALLTID av backend (spec §61.14). Här visas planerna och
* flödet är förberett med /v1/subscriptions/verify.
*/
interface PlanRow {
key: string;
priceMinor: number;
members: number;
highlight?: boolean;
}
const PLANS: PlanRow[] = [
{ key: "paywall.household", priceMinor: 7900, members: 3 },
{ key: "paywall.family", priceMinor: 12900, members: 6, highlight: true },
{ key: "paywall.large", priceMinor: 16900, members: 12 },
];
interface Entitlements {
plan: string;
status: string;
expiresAt?: string;
}
export default function PaywallScreen() {
const entitlements = useQuery({
queryKey: ["entitlements"],
queryFn: () => api<Entitlements>("/v1/me/entitlements"),
});
const trialDaysLeft =
entitlements.data?.status === "trial" && entitlements.data.expiresAt
? Math.max(0, Math.ceil((Date.parse(entitlements.data.expiresAt) - Date.now()) / 86_400_000))
: null;
const buy = () => {
Alert.alert(t("paywall.soonTitle"), t("paywall.soonBody"));
};
return (
<Screen>
<Title>{t("paywall.title")}</Title>
<Body muted>{t("paywall.subtitle")}</Body>
{trialDaysLeft != null && (
<Tag
label={t("paywall.trialActive", { days: trialDaysLeft, count: trialDaysLeft })}
tone="accent"
/>
)}
<Spacer />
{PLANS.map((plan) => (
<Card
key={plan.key}
style={plan.highlight ? { borderColor: "#16803C", borderWidth: 2 } : undefined}
>
<Row style={{ justifyContent: "space-between" }}>
<Heading>{t(plan.key as never)}</Heading>
{plan.highlight && <Tag label={t("paywall.popular")} tone="success" />}
</Row>
<Body>{t("paywall.perMonth", { price: formatMinor(plan.priceMinor, "SEK") })}</Body>
<Button
label={t("paywall.choose")}
variant={plan.highlight ? "primary" : "secondary"}
onPress={buy}
/>
</Card>
))}
<Small>{t("paywall.fairUse")}</Small>
<Small>{t("paywall.freeNote")}</Small>
<Button label={t("paywall.restore")} variant="ghost" onPress={buy} />
</Screen>
);
}
+215
View File
@@ -0,0 +1,215 @@
import { Alert } from "react-native";
import { router } from "expo-router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "@/lib/api";
import { persistLanguageTag, useAuth } from "@/lib/auth";
import { SUPPORTED_LANGUAGES, t } from "@/lib/i18n";
import {
Body,
Button,
Card,
Heading,
LoadingView,
Row,
Screen,
Small,
Spacer,
Tag,
} from "@/components/ui";
/** Profil: konto, samtycken, minne, prenumeration, GDPR-åtgärder. */
interface Me {
displayName: string;
email: string;
emailVerified: boolean;
precisionMode: string;
}
interface Entitlements {
plan: string;
status: string;
aiScansPerMonth: number;
aiScansUsedThisMonth: number;
expiresAt?: string;
}
interface Consent {
kind: string;
status: string;
}
/** Samtyckesetiketter ur i18n-katalogen: profile.consent.<kind> (12 språk). */
const CONSENT_KINDS = [
"personalization",
"anonymized_improvement",
"image_training",
"health_integration",
"location_weather",
"push_notifications",
] as const;
export default function ProfileScreen() {
const queryClient = useQueryClient();
const logout = useAuth((s) => s.logout);
const me = useQuery({ queryKey: ["me"], queryFn: () => api<Me>("/v1/me") });
const entitlements = useQuery({
queryKey: ["entitlements"],
queryFn: () => api<Entitlements>("/v1/me/entitlements"),
});
const consents = useQuery({
queryKey: ["consents"],
queryFn: () => api<Consent[]>("/v1/me/consents"),
});
const setConsent = useMutation({
mutationFn: ({ kind, granted }: { kind: string; granted: boolean }) =>
api("/v1/me/consents", { method: "PUT", body: { kind, granted } }),
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["consents"] }),
});
const togglePrecision = useMutation({
mutationFn: (mode: string) => api("/v1/me", { method: "PATCH", body: { precisionMode: mode } }),
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["me"] }),
});
const deleteAccount = useMutation({
mutationFn: () => api("/v1/me", { method: "DELETE" }),
onSuccess: () => void logout().then(() => router.replace("/(auth)/login")),
});
const resend = useMutation({
mutationFn: () => api("/v1/auth/resend-verification", { method: "POST" }),
});
// Språkval (i18n M4): sparas i backend-preferenserna + lokalt, byter direkt.
const localePrefs = useQuery({
queryKey: ["locale-preferences"],
queryFn: () => api<{ languageTag: string }>("/v1/me/locale-preferences"),
});
const setLanguage = useMutation({
mutationFn: (languageTag: string) =>
api("/v1/me/locale-preferences", { method: "PATCH", body: { languageTag } }),
onSuccess: async (_data, languageTag) => {
await persistLanguageTag(languageTag);
await queryClient.invalidateQueries();
},
});
if (me.isLoading) return <LoadingView />;
const consentMap = new Map((consents.data ?? []).map((c) => [c.kind, c.status]));
return (
<Screen>
<Card>
<Heading>{me.data?.displayName}</Heading>
<Small>{me.data?.email}</Small>
{me.data && !me.data.emailVerified && (
<>
<Small> {t("profile.emailUnverified")}</Small>
<Button
label={
resend.isSuccess ? t("profile.verificationSent") : t("profile.resendVerification")
}
variant="ghost"
onPress={() => resend.mutate()}
/>
</>
)}
<Row>
<Body>{t("profile.modeLabel")}</Body>
<Button
label={t("onboarding.modeSimple")}
variant={me.data?.precisionMode === "simple" ? "secondary" : "ghost"}
onPress={() => togglePrecision.mutate("simple")}
/>
<Button
label={t("onboarding.modeExact")}
variant={me.data?.precisionMode === "exact" ? "secondary" : "ghost"}
onPress={() => togglePrecision.mutate("exact")}
/>
</Row>
</Card>
<Card>
<Heading>{t("profile.language")}</Heading>
<Row style={{ flexWrap: "wrap" }}>
{SUPPORTED_LANGUAGES.map((lang) => (
<Button
key={lang.code}
label={t(lang.labelKey)}
variant={
(localePrefs.data?.languageTag ?? "sv-SE").startsWith(lang.code)
? "secondary"
: "ghost"
}
onPress={() => setLanguage.mutate(lang.tag)}
/>
))}
</Row>
</Card>
{entitlements.data && (
<Card onPress={() => router.push("/paywall")}>
<Row style={{ justifyContent: "space-between" }}>
<Heading>{t("profile.subscription")}</Heading>
<Tag
label={`${entitlements.data.plan} · ${entitlements.data.status}`}
tone={entitlements.data.status === "free" ? "neutral" : "success"}
/>
</Row>
<Small>
{t("profile.aiScansUsed", {
used: entitlements.data.aiScansUsedThisMonth,
total: entitlements.data.aiScansPerMonth,
})}
</Small>
</Card>
)}
<Card onPress={() => router.push("/memory")}>
<Heading>🧠 {t("profile.memory")}</Heading>
<Small>{t("profile.memorySubtitle")}</Small>
</Card>
<Card>
<Heading>{t("profile.consents")}</Heading>
{CONSENT_KINDS.map((kind) => {
const granted = consentMap.get(kind) === "granted";
return (
<Row key={kind} style={{ justifyContent: "space-between" }}>
<Small>{t(`profile.consent.${kind}`)}</Small>
<Button
label={granted ? t("common.on") : t("common.off")}
variant={granted ? "secondary" : "ghost"}
onPress={() => setConsent.mutate({ kind, granted: !granted })}
/>
</Row>
);
})}
<Small>{t("profile.consentsNote")}</Small>
</Card>
<Spacer />
<Button
label={t("profile.logout")}
variant="ghost"
onPress={() => void logout().then(() => router.replace("/(auth)/login"))}
/>
<Button
label={t("profile.deleteAccount")}
variant="danger"
onPress={() =>
Alert.alert(t("profile.deleteTitle"), t("profile.deleteBody"), [
{ text: t("common.cancel"), style: "cancel" },
{
text: t("memory.delete"),
style: "destructive",
onPress: () => deleteAccount.mutate(),
},
])
}
/>
</Screen>
);
}
+234
View File
@@ -0,0 +1,234 @@
import { useState } from "react";
import { Alert, View } from "react-native";
import { router, useLocalSearchParams } from "expo-router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "@/lib/api";
import { t } from "@/lib/i18n";
import { formatMinor } from "@/lib/money";
import {
Body,
Button,
Card,
ErrorView,
Heading,
LoadingView,
Row,
Screen,
Small,
Spacer,
Tag,
Title,
} from "@/components/ui";
import { colors, spacing } from "@/lib/theme";
import { formatQuantity } from "@/lib/units";
/** Receptdetalj: säkerhet, näring, ingredienser vs lager, varianter, betyg. */
interface RecipeDetail {
id: string;
titleSv: string;
descriptionSv: string;
portions: number;
totalTimeMinutes: number;
spiceLevel: number;
nutritionPerPortion: { kcal: number; proteinG: number; carbsG: number; fatG: number };
estimatedCostMinorPerPortion: number | null;
costCurrency?: string;
allergens: string[];
ingredients: Array<{
id: string;
displayNameSv: string;
quantity: number;
unit: string;
optional: boolean;
groupName: string | null;
canonicalIngredientId: string;
}>;
steps: Array<{
id: string;
stepNumber: number;
instructionSv: string;
temperatureC: number | null;
}>;
safety: { safe: boolean; violations: Array<{ severity: string; messageSv: string }> };
variants: Array<{ id: string; titleSv: string; variantType: string }>;
myRating: { stars: number } | null;
isFavorite: boolean;
ratingAverage: number | null;
ratingCount: number;
creatorDisplayName: string | null;
verificationStatus: string;
}
export default function RecipeScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const queryClient = useQueryClient();
const [portions, setPortions] = useState<number | null>(null);
const query = useQuery({
queryKey: ["recipe", id],
queryFn: () => api<RecipeDetail>(`/v1/recipes/${id}`),
});
const scaled = useQuery({
queryKey: ["recipe-scaled", id, portions],
queryFn: () =>
api<{ ingredients: RecipeDetail["ingredients"] }>(
`/v1/recipes/${id}/scaled?portions=${portions}`,
),
enabled: portions != null,
});
const favorite = useMutation({
mutationFn: (isFavorite: boolean) =>
api(`/v1/recipes/${id}/favorite`, { method: isFavorite ? "DELETE" : "POST" }),
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["recipe", id] }),
});
const rate = useMutation({
mutationFn: (stars: number) =>
api(`/v1/recipes/${id}/rate`, { method: "POST", body: { stars, feedbackTags: [] } }),
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["recipe", id] }),
});
if (query.isLoading) return <LoadingView />;
if (query.isError || !query.data) return <ErrorView onRetry={() => void query.refetch()} />;
const recipe = query.data;
const shownPortions = portions ?? recipe.portions;
const ingredients =
portions != null && scaled.data ? scaled.data.ingredients : recipe.ingredients;
const blockers = recipe.safety.violations.filter((v) => v.severity === "blocker");
const warnings = recipe.safety.violations.filter((v) => v.severity === "warning");
return (
<Screen>
<Title>{recipe.titleSv}</Title>
<Row>
<Tag label={t("recipe.time", { min: recipe.totalTimeMinutes })} />
<Tag label={`${Math.round(recipe.nutritionPerPortion.kcal)} kcal`} />
<Tag
label={`${Math.round(recipe.nutritionPerPortion.proteinG)} g protein`}
tone="success"
/>
{recipe.estimatedCostMinorPerPortion != null && (
<Tag
label={t("recipe.cost", {
amount: formatMinor(
recipe.estimatedCostMinorPerPortion,
recipe.costCurrency ?? "SEK",
),
})}
/>
)}
{recipe.verificationStatus === "editorial" && <Tag label="✓ plattformen" tone="success" />}
</Row>
<Body muted>{recipe.descriptionSv}</Body>
{recipe.creatorDisplayName && <Small>Skapat av {recipe.creatorDisplayName}</Small>}
{!recipe.safety.safe && (
<Card style={{ borderColor: colors.danger, backgroundColor: colors.dangerSoft }}>
<Body> {t("recipe.notSafe")}</Body>
{blockers.map((v, i) => (
<Small key={i}>{v.messageSv}</Small>
))}
</Card>
)}
{warnings.length > 0 && (
<Card style={{ backgroundColor: colors.warningSoft, borderColor: colors.warning }}>
{warnings.map((v, i) => (
<Small key={i}> {v.messageSv}</Small>
))}
</Card>
)}
{recipe.variants.length > 0 && (
<Row>
{recipe.variants.map((variant) => (
<Tag key={variant.id} label={`${variant.titleSv}`} tone="accent" />
))}
</Row>
)}
<Card>
<Row style={{ justifyContent: "space-between" }}>
<Heading>{t("recipe.ingredients")}</Heading>
<Row>
<Button
label=""
variant="ghost"
onPress={() => setPortions(Math.max(1, shownPortions - 1))}
/>
<Body>{t("recipe.portions", { count: shownPortions })}</Body>
<Button
label="+"
variant="ghost"
onPress={() => setPortions(Math.min(24, shownPortions + 1))}
/>
</Row>
</Row>
{ingredients.map((ing) => (
<Row key={ing.id ?? ing.displayNameSv} style={{ justifyContent: "space-between" }}>
<Body>
{ing.displayNameSv}
{ing.optional ? " (valfritt)" : ""}
</Body>
<Small>{formatQuantity(ing.quantity, ing.unit)}</Small>
</Row>
))}
</Card>
<Card>
<Heading>{t("recipe.steps")}</Heading>
{recipe.steps.map((step) => (
<View key={step.id} style={{ marginBottom: spacing.sm }}>
<Body>
{step.stepNumber}. {step.instructionSv}
{step.temperatureC ? ` (${step.temperatureC} °C)` : ""}
</Body>
</View>
))}
</Card>
<Button
label={`👨‍🍳 ${t("recipe.cook")}`}
onPress={() => router.push(`/cooking/${recipe.id}?portions=${shownPortions}`)}
/>
<Row>
<View style={{ flex: 1 }}>
<Button
label={recipe.isFavorite ? `${t("recipe.saved")}` : `${t("recipe.save")}`}
variant="secondary"
onPress={() => favorite.mutate(recipe.isFavorite)}
/>
</View>
</Row>
<Card>
<Heading>{t("cooked.rate")}</Heading>
<Row>
{[1, 2, 3, 4, 5].map((stars) => (
<Button
key={stars}
label={(recipe.myRating?.stars ?? 0) >= stars ? "★" : "☆"}
variant="ghost"
onPress={() => rate.mutate(stars)}
/>
))}
</Row>
{recipe.ratingAverage != null && (
<Small>
{t("recipe.avgRating", {
avg: recipe.ratingAverage.toFixed(1),
count: recipe.ratingCount,
})}
</Small>
)}
</Card>
<Spacer />
</Screen>
);
}
function formatQty(quantity: number): string {
return Number.isInteger(quantity) ? String(quantity) : String(Math.round(quantity * 100) / 100);
}
+254
View File
@@ -0,0 +1,254 @@
import { useMemo, useState } from "react";
import { Alert, View } from "react-native";
import { router, useLocalSearchParams } from "expo-router";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "@/lib/api";
import { t } from "@/lib/i18n";
import {
Body,
Button,
Card,
ErrorView,
Heading,
Input,
LoadingView,
Row,
Screen,
Small,
Spacer,
Tag,
} from "@/components/ui";
import { spacing } from "@/lib/theme";
import { parseUnitInput, unitLabel } from "@/lib/units";
/**
* Granska AI-resultat (spec §10, §61.45): användaren godkänner, ändrar,
* tar bort och lägger till INNAN något skrivs till Food Twin.
* Osäkra rader flaggas tydligt. Ändringar blir ai_corrections → AAMOS-träning.
*/
interface DetectedItem {
tempId?: string;
detectedName: string;
canonicalIngredientId: string | null;
brand: string | null;
estimatedQuantity: number | null;
unit: string | null;
bestBeforeDate: string | null;
confidence: number;
requiresConfirmation: boolean;
}
interface ScanJob {
id: string;
status: string;
scanType: string;
error: string | null;
result: { items?: DetectedItem[] } | null;
}
interface EditableItem {
tempId: string;
original: DetectedItem | null;
name: string;
quantity: string;
unit: string;
canonicalIngredientId: string | null;
date: string;
/**
* Datumtyp (spec §13): "Bäst före" är en KVALITETSgräns (varan kan vara god
* längre lukta och smaka), "Sista förbrukningsdag" en SÄKERHETSgräns.
* Användaren väljer typ så att appen aldrig dömer mat i onödan och aldrig
* mjukar upp en riktig säkerhetsgräns.
*/
dateIsUseBy: boolean;
confidence: number;
rejected: boolean;
}
export default function ScanReviewScreen() {
const { jobId } = useLocalSearchParams<{ jobId: string }>();
const queryClient = useQueryClient();
const [items, setItems] = useState<EditableItem[] | null>(null);
const [busy, setBusy] = useState(false);
const query = useQuery({
queryKey: ["scan", jobId],
queryFn: () => api<ScanJob>(`/v1/scans/${jobId}`),
refetchInterval: (q) => {
const status = q.state.data?.status;
return status === "queued" || status === "running" ? 1500 : false;
},
});
const job = query.data;
// Initiera redigerbara rader när resultatet landar
useMemo(() => {
if (job?.status === "awaiting_confirmation" && job.result?.items && items == null) {
setItems(
job.result.items.map((item, index) => ({
tempId: item.tempId ?? `item-${index}`,
original: item,
name: item.detectedName,
quantity: item.estimatedQuantity != null ? String(item.estimatedQuantity) : "1",
unit: unitLabel(item.unit ?? "COUNT"),
canonicalIngredientId: item.canonicalIngredientId,
date: item.bestBeforeDate ?? "",
dateIsUseBy: false,
confidence: item.confidence,
rejected: false,
})),
);
}
}, [job, items]);
const update = (tempId: string, patch: Partial<EditableItem>) =>
setItems((prev) => prev?.map((i) => (i.tempId === tempId ? { ...i, ...patch } : i)) ?? null);
const addManual = () =>
setItems((prev) => [
...(prev ?? []),
{
tempId: `manual-${Date.now()}`,
original: null,
name: "",
quantity: "1",
unit: unitLabel("COUNT"),
canonicalIngredientId: null,
date: "",
dateIsUseBy: false,
confidence: 1,
rejected: false,
},
]);
const confirm = async () => {
if (!items) return;
setBusy(true);
try {
const payload = items
.filter((i) => i.rejected || i.name.trim().length > 0)
.map((i) => {
const edited =
i.original == null ||
i.name !== i.original.detectedName ||
Number(i.quantity) !== i.original.estimatedQuantity ||
i.unit !== (i.original.unit ?? "st");
return {
tempId: i.tempId,
action: i.rejected ? "reject" : i.original == null ? "add" : edited ? "edit" : "accept",
displayName: i.name.trim() || i.original?.detectedName || t("scan.review.unknownItem"),
canonicalIngredientId: i.canonicalIngredientId ?? undefined,
quantity: Math.max(0.01, Number(i.quantity) || 1),
unit: parseUnitInput(i.unit) ?? "COUNT",
// Rätt kolumn per datumtyp motorn behandlar dem helt olika (spec §13).
bestBeforeDate: !i.dateIsUseBy && i.date ? i.date : undefined,
useByDate: i.dateIsUseBy && i.date ? i.date : undefined,
};
});
await api(`/v1/scans/${jobId}/confirm`, { method: "POST", body: { items: payload } });
await queryClient.invalidateQueries({ queryKey: ["inventory"] });
await queryClient.invalidateQueries({ queryKey: ["what-to-eat"] });
router.back();
} catch (err) {
Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error"));
} finally {
setBusy(false);
}
};
if (query.isLoading || job?.status === "queued" || job?.status === "running") {
return (
<Screen scroll={false}>
<LoadingView />
<Body muted>{t("scan.analyzing")}</Body>
</Screen>
);
}
if (query.isError || !job) return <ErrorView onRetry={() => void query.refetch()} />;
if (job.status === "failed") {
return (
<Screen>
<ErrorView message={job.error ?? t("scan.failed")} onRetry={() => router.back()} />
</Screen>
);
}
return (
<Screen>
<Heading>{t("scan.review.title")}</Heading>
<Small>{t("scan.review.subtitle")}</Small>
<Spacer size={spacing.sm} />
{(items ?? []).map((item) => (
<Card key={item.tempId} style={item.rejected ? { opacity: 0.4 } : undefined}>
<Row style={{ justifyContent: "space-between" }}>
{item.confidence < 0.7 && !item.rejected ? (
<Tag label={`⚠️ ${t("scan.review.uncertain")}`} tone="warning" />
) : (
<View />
)}
<Button
label={item.rejected ? t("common.undo") : t("common.remove")}
variant="ghost"
onPress={() => update(item.tempId, { rejected: !item.rejected })}
/>
</Row>
{!item.rejected && (
<>
<Input
value={item.name}
onChangeText={(v) => update(item.tempId, { name: v })}
placeholder={t("scan.review.itemPlaceholder")}
/>
<Row>
<View style={{ flex: 1 }}>
<Input
value={item.quantity}
keyboardType="numeric"
onChangeText={(v) => update(item.tempId, { quantity: v })}
placeholder={t("scan.review.quantityPlaceholder")}
/>
</View>
<View style={{ flex: 1 }}>
<Input
value={item.unit}
onChangeText={(v) => update(item.tempId, { unit: v })}
placeholder={t("scan.review.unitPlaceholder")}
/>
</View>
</Row>
{/* Datumtyp (spec §13): bäst före = kvalitet, sista förbrukningsdag = säkerhet. */}
<Row>
<Button
label={t("scan.review.dateKindBestBefore")}
variant={item.dateIsUseBy ? "ghost" : "secondary"}
onPress={() => update(item.tempId, { dateIsUseBy: false })}
/>
<Button
label={t("scan.review.dateKindUseBy")}
variant={item.dateIsUseBy ? "secondary" : "ghost"}
onPress={() => update(item.tempId, { dateIsUseBy: true })}
/>
</Row>
<Input
value={item.date}
onChangeText={(v) => update(item.tempId, { date: v })}
placeholder={t("scan.review.datePlaceholder")}
/>
{item.date.length > 0 && (
<Small>
{item.dateIsUseBy ? t("scan.review.useByNote") : t("scan.review.bestBeforeNote")}
</Small>
)}
</>
)}
</Card>
))}
<Button label={t("scan.review.addItem")} variant="ghost" onPress={addManual} />
<Spacer size={spacing.sm} />
<Button label={t("scan.review.approveAll")} onPress={() => void confirm()} loading={busy} />
</Screen>
);
}
+205
View File
@@ -0,0 +1,205 @@
import { useState } from "react";
import { Alert, Pressable, Text, View } from "react-native";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "@/lib/api";
import { t } from "@/lib/i18n";
import { formatMinor } from "@/lib/money";
import {
Body,
Button,
Card,
EmptyState,
ErrorView,
Heading,
Input,
LoadingView,
Row,
Screen,
Small,
Spacer,
} from "@/components/ui";
import { colors, spacing } from "@/lib/theme";
import { formatQuantity } from "@/lib/units";
/** Inköpslista (spec §27): delad, avdelningssorterad, uppdaterar lagret vid avslut. */
interface ShoppingItem {
id: string;
displayName: string;
quantity: number;
unit: string;
storeSection: string;
estimatedPriceMinor: number | null;
checked: boolean;
}
interface ListResponse {
list: { id: string; name: string };
items: ShoppingItem[];
estimatedTotalMinor: number;
currency: string;
}
/** Butikssektionernas etiketter bor i i18n-katalogen: shopping.section.<id> (12 språk). */
const SECTION_IDS = new Set([
"frukt_gront",
"brod",
"mejeri",
"kott_fagel",
"fisk",
"chark",
"frys",
"skafferi",
"konserver",
"kryddor_bak",
"dryck",
"snacks",
"hygien_ovrigt",
]);
const sectionLabel = (id: string): string =>
SECTION_IDS.has(id) ? t(`shopping.section.${id}`) : id;
export default function ShoppingScreen() {
const queryClient = useQueryClient();
const [newItem, setNewItem] = useState("");
const lists = useQuery({
queryKey: ["shopping-lists"],
queryFn: async () => {
const result = await api<{ lists: Array<{ id: string }> }>("/v1/shopping-lists");
if (result.lists.length === 0) {
const created = await api<{ list: { id: string } }>("/v1/shopping-lists", {
method: "POST",
body: { name: t("shopping.title") },
});
return [created.list];
}
return result.lists;
},
});
const listId = lists.data?.[0]?.id;
const list = useQuery({
queryKey: ["shopping-list", listId],
queryFn: () => api<ListResponse>(`/v1/shopping-lists/${listId}`),
enabled: Boolean(listId),
});
const invalidate = () => {
void queryClient.invalidateQueries({ queryKey: ["shopping-list", listId] });
void queryClient.invalidateQueries({ queryKey: ["shopping-lists"] });
};
const addItem = useMutation({
mutationFn: (displayName: string) =>
api(`/v1/shopping-lists/${listId}/items`, { method: "POST", body: { displayName } }),
onSuccess: () => {
setNewItem("");
invalidate();
},
});
const toggle = useMutation({
mutationFn: (item: ShoppingItem) =>
api(`/v1/shopping-lists/${listId}/items/${item.id}`, {
method: "PATCH",
body: { checked: !item.checked },
}),
onSuccess: invalidate,
});
const complete = useMutation({
mutationFn: () =>
api(`/v1/shopping-lists/${listId}/complete`, {
method: "POST",
body: { addToInventory: true },
}),
onSuccess: async (result) => {
const added = (result as { itemsAddedToInventory?: number }).itemsAddedToInventory ?? 0;
await queryClient.invalidateQueries({ queryKey: ["inventory"] });
invalidate();
Alert.alert(t("shopping.completedTitle"), t("shopping.completedBody", { count: added }));
},
});
if (lists.isLoading || list.isLoading) return <LoadingView />;
if (list.isError || !list.data) return <ErrorView onRetry={() => void list.refetch()} />;
const grouped = new Map<string, ShoppingItem[]>();
for (const item of list.data.items) {
const arr = grouped.get(item.storeSection) ?? [];
arr.push(item);
grouped.set(item.storeSection, arr);
}
const checkedCount = list.data.items.filter((i) => i.checked).length;
return (
<Screen>
<Row>
<View style={{ flex: 1 }}>
<Input
placeholder={t("shopping.addPlaceholder")}
value={newItem}
onChangeText={setNewItem}
onSubmitEditing={() => newItem.trim() && addItem.mutate(newItem.trim())}
returnKeyType="done"
/>
</View>
<Button label="+" onPress={() => newItem.trim() && addItem.mutate(newItem.trim())} />
</Row>
{list.data.items.length === 0 && <EmptyState text={t("shopping.empty")} />}
{[...grouped.entries()].map(([section, items]) => (
<Card key={section}>
<Heading>{sectionLabel(section)}</Heading>
{items.map((item) => (
<Pressable key={item.id} onPress={() => toggle.mutate(item)}>
<Row style={{ justifyContent: "space-between", paddingVertical: 6 }}>
<Body>
<Text style={{ color: item.checked ? colors.primary : colors.textMuted }}>
{item.checked ? "☑" : "☐"}
</Text>{" "}
<Text
style={
item.checked
? { textDecorationLine: "line-through", color: colors.textMuted }
: undefined
}
>
{item.displayName} · {formatQuantity(item.quantity, item.unit)}
</Text>
</Body>
{item.estimatedPriceMinor != null && (
<Small>
{t("shopping.estimated", {
amount: formatMinor(item.estimatedPriceMinor, list.data.currency),
})}
</Small>
)}
</Row>
</Pressable>
))}
</Card>
))}
{list.data.estimatedTotalMinor > 0 && (
<Small>
{t("shopping.estimatedTotal", {
amount: formatMinor(list.data.estimatedTotalMinor, list.data.currency),
})}
</Small>
)}
<Spacer size={spacing.sm} />
{checkedCount > 0 && (
<>
<Button
label={`${t("shopping.complete")} (${checkedCount})`}
onPress={() => complete.mutate()}
loading={complete.isPending}
/>
<Small>{t("shopping.completeNote")}</Small>
</>
)}
</Screen>
);
}
+256
View File
@@ -0,0 +1,256 @@
import type { PropsWithChildren, ReactNode } from "react";
import {
ActivityIndicator,
Pressable,
ScrollView,
StyleSheet,
Text,
TextInput,
View,
type StyleProp,
type TextInputProps,
type ViewStyle,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { colors, radius, spacing, typography } from "@/lib/theme";
import { t } from "@/lib/i18n";
export function Screen({
children,
scroll = true,
style,
}: PropsWithChildren<{ scroll?: boolean; style?: StyleProp<ViewStyle> }>) {
const content = scroll ? (
<ScrollView
contentContainerStyle={[styles.screenContent, style]}
keyboardShouldPersistTaps="handled"
>
{children}
</ScrollView>
) : (
<View style={[styles.screenContent, { flex: 1 }, style]}>{children}</View>
);
return (
<SafeAreaView style={styles.screen} edges={["top"]}>
{content}
</SafeAreaView>
);
}
export function Title({ children }: { children: ReactNode }) {
return <Text style={typography.title}>{children}</Text>;
}
export function Heading({ children }: { children: ReactNode }) {
return <Text style={[typography.heading, { marginBottom: spacing.sm }]}>{children}</Text>;
}
export function Body({ children, muted = false }: { children: ReactNode; muted?: boolean }) {
return <Text style={[typography.body, muted && { color: colors.textMuted }]}>{children}</Text>;
}
export function Small({ children }: { children: ReactNode }) {
return <Text style={typography.small}>{children}</Text>;
}
export function Card({
children,
onPress,
style,
}: PropsWithChildren<{ onPress?: () => void; style?: StyleProp<ViewStyle> }>) {
if (onPress) {
return (
<Pressable
onPress={onPress}
style={({ pressed }) => [styles.card, style, pressed && { opacity: 0.85 }]}
>
{children}
</Pressable>
);
}
return <View style={[styles.card, style]}>{children}</View>;
}
export function Button({
label,
onPress,
variant = "primary",
disabled = false,
loading = false,
}: {
label: string;
onPress: () => void;
variant?: "primary" | "secondary" | "ghost" | "danger";
disabled?: boolean;
loading?: boolean;
}) {
const bg =
variant === "primary"
? colors.primary
: variant === "danger"
? colors.dangerSoft
: variant === "secondary"
? colors.primarySoft
: "transparent";
const fg =
variant === "primary" ? "#fff" : variant === "danger" ? colors.danger : colors.primaryDark;
return (
<Pressable
onPress={onPress}
disabled={disabled || loading}
style={({ pressed }) => [
styles.button,
{ backgroundColor: bg, opacity: disabled ? 0.5 : pressed ? 0.85 : 1 },
variant === "ghost" && { borderWidth: 1, borderColor: colors.border },
]}
>
{loading ? (
<ActivityIndicator color={fg} />
) : (
<Text style={{ color: fg, fontWeight: "600", fontSize: 15 }}>{label}</Text>
)}
</Pressable>
);
}
export function Input(props: TextInputProps) {
return (
<TextInput
placeholderTextColor={colors.textMuted}
{...props}
style={[styles.input, props.style]}
/>
);
}
export function Tag({
label,
tone = "neutral",
}: {
label: string;
tone?: "neutral" | "success" | "warning" | "danger" | "accent";
}) {
const palette = {
neutral: { bg: colors.surfaceAlt, fg: colors.textMuted },
success: { bg: colors.primarySoft, fg: colors.primaryDark },
warning: { bg: colors.warningSoft, fg: colors.warning },
danger: { bg: colors.dangerSoft, fg: colors.danger },
accent: { bg: colors.accentSoft, fg: colors.accent },
}[tone];
return (
<View style={[styles.tag, { backgroundColor: palette.bg }]}>
<Text style={{ color: palette.fg, fontSize: 12, fontWeight: "600" }}>{label}</Text>
</View>
);
}
export function ProgressBar({
progress,
tone = "primary",
}: {
progress: number;
tone?: "primary" | "warning";
}) {
const clamped = Math.max(0, Math.min(1, progress));
const over = progress > 1;
return (
<View style={styles.progressTrack}>
<View
style={[
styles.progressFill,
{
width: `${clamped * 100}%`,
backgroundColor: over
? colors.warning
: tone === "warning"
? colors.warning
: colors.primary,
},
]}
/>
</View>
);
}
export function LoadingView() {
return (
<View style={styles.center}>
<ActivityIndicator size="large" color={colors.primary} />
<Small>{t("common.loading")}</Small>
</View>
);
}
export function ErrorView({ message, onRetry }: { message?: string; onRetry?: () => void }) {
return (
<View style={styles.center}>
<Body>{message ?? t("common.error")}</Body>
{onRetry && <Button label={t("common.retry")} onPress={onRetry} variant="secondary" />}
</View>
);
}
export function EmptyState({ text }: { text: string }) {
return (
<Card style={{ alignItems: "center", paddingVertical: spacing.xl }}>
<Body muted>{text}</Body>
</Card>
);
}
export function Row({ children, style }: PropsWithChildren<{ style?: StyleProp<ViewStyle> }>) {
return <View style={[styles.row, style]}>{children}</View>;
}
export function Spacer({ size = spacing.md }: { size?: number }) {
return <View style={{ height: size }} />;
}
const styles = StyleSheet.create({
screen: { flex: 1, backgroundColor: colors.background },
screenContent: { padding: spacing.md, gap: spacing.sm, paddingBottom: spacing.xl },
card: {
backgroundColor: colors.surface,
borderRadius: radius.lg,
padding: spacing.md,
borderWidth: 1,
borderColor: colors.border,
gap: spacing.xs,
},
button: {
paddingVertical: 13,
paddingHorizontal: spacing.lg,
borderRadius: radius.md,
alignItems: "center",
justifyContent: "center",
minHeight: 48,
},
input: {
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: colors.border,
borderRadius: radius.md,
paddingHorizontal: spacing.md,
paddingVertical: 12,
fontSize: 15,
color: colors.text,
},
tag: {
paddingHorizontal: 10,
paddingVertical: 3,
borderRadius: radius.full,
alignSelf: "flex-start",
},
progressTrack: {
height: 8,
backgroundColor: colors.surfaceAlt,
borderRadius: radius.full,
overflow: "hidden",
},
progressFill: { height: 8, borderRadius: radius.full },
center: {
flex: 1,
alignItems: "center",
justifyContent: "center",
gap: spacing.md,
padding: spacing.xl,
},
row: { flexDirection: "row", alignItems: "center", gap: spacing.sm, flexWrap: "wrap" },
});
+108
View File
@@ -0,0 +1,108 @@
import Constants from "expo-constants";
import { useAuth } from "./auth";
/**
* API-klient. Mobilappen pratar ENDAST med Food API aldrig direkt med
* modelleverantörer, och innehåller inga AI-hemligheter (spec §31, §61.13).
* 401 → automatisk refresh med roterande token → retry en gång.
*/
const API_BASE =
(Constants.expoConfig?.extra as { apiBaseUrl?: string } | undefined)?.apiBaseUrl ??
"http://localhost:4000";
export class ApiError extends Error {
constructor(
public readonly status: number,
public readonly code: string,
message: string,
public readonly details?: unknown,
) {
super(message);
}
}
let refreshPromise: Promise<boolean> | null = null;
async function tryRefresh(): Promise<boolean> {
if (!refreshPromise) {
refreshPromise = (async () => {
const { getRefreshToken, setSession, logout } = useAuth.getState();
const refreshToken = await getRefreshToken();
if (!refreshToken) return false;
try {
const res = await fetch(`${API_BASE}/v1/auth/refresh`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ refreshToken }),
});
if (!res.ok) {
await logout();
return false;
}
const json = (await res.json()) as { accessToken: string; refreshToken: string };
await setSession(
{ accessToken: json.accessToken, refreshToken: json.refreshToken },
useAuth.getState().user,
);
return true;
} catch {
return false;
} finally {
setTimeout(() => {
refreshPromise = null;
}, 100);
}
})();
}
return refreshPromise;
}
export async function api<T = unknown>(
path: string,
options: { method?: string; body?: unknown; retry?: boolean } = {},
): Promise<T> {
const { accessToken } = useAuth.getState();
const res = await fetch(`${API_BASE}${path}`, {
method: options.method ?? "GET",
headers: {
"content-type": "application/json",
...(accessToken ? { authorization: `Bearer ${accessToken}` } : {}),
},
body: options.body != null ? JSON.stringify(options.body) : undefined,
});
if (res.status === 401 && options.retry !== false) {
const refreshed = await tryRefresh();
if (refreshed) return api<T>(path, { ...options, retry: false });
}
const json = (await res.json().catch(() => ({}))) as {
error?: { code?: string; message?: string; details?: unknown };
};
if (!res.ok) {
throw new ApiError(
res.status,
json.error?.code ?? "UNKNOWN",
json.error?.message ?? `HTTP ${res.status}`,
json.error?.details,
);
}
return json as T;
}
/** Ladda upp en bild till en presignad URL (mock-S3 i dev, riktig S3 i prod). */
export async function uploadImage(
upload: { uploadUrl: string; headers: Record<string, string> },
localUri: string,
): Promise<void> {
const blob = await (await fetch(localUri)).blob();
const res = await fetch(upload.uploadUrl, {
method: "PUT",
headers: upload.headers,
body: blob,
});
if (!res.ok) throw new ApiError(res.status, "UPLOAD_FAILED", "Bilduppladdningen misslyckades.");
}
export { API_BASE };
+88
View File
@@ -0,0 +1,88 @@
import { create } from "zustand";
import * as SecureStore from "expo-secure-store";
import { BRAND } from "./brand";
import { detectDeviceLanguageTag, setLocale } from "./i18n";
/**
* Auth-state. Tokens lagras i SecureStore (spec §44: lokal känslig data skyddas).
* Access-token hålls i minne; refresh-token endast i SecureStore.
*/
const ACCESS_KEY = `${BRAND.slug}_access`;
const REFRESH_KEY = `${BRAND.slug}_refresh`;
const LANGUAGE_KEY = `${BRAND.slug}_language`;
/** Spara + aktivera språk (anropas från profilens språkval och vid inloggning). */
export async function persistLanguageTag(languageTag: string): Promise<void> {
await SecureStore.setItemAsync(LANGUAGE_KEY, languageTag);
setLocale(languageTag);
}
export interface AuthUser {
id: string;
email: string;
displayName: string;
role: string;
}
interface AuthState {
hydrated: boolean;
accessToken: string | null;
user: AuthUser | null;
onboardingCompleted: boolean;
hydrate: () => Promise<void>;
setSession: (
tokens: { accessToken: string; refreshToken: string },
user: AuthUser | null,
) => Promise<void>;
setAccessToken: (token: string) => void;
setOnboardingCompleted: (done: boolean) => void;
getRefreshToken: () => Promise<string | null>;
logout: () => Promise<void>;
}
export const useAuth = create<AuthState>((set) => ({
hydrated: false,
accessToken: null,
user: null,
onboardingCompleted: true,
hydrate: async () => {
try {
const accessToken = await SecureStore.getItemAsync(ACCESS_KEY);
set({ accessToken: accessToken ?? null, hydrated: true });
// Språk (D-031): sparat val vinner; annars enhetens språk (stött → det,
// ostött → engelska). Spanjoren som laddar ner appen får spanska direkt.
const savedTag = await SecureStore.getItemAsync(LANGUAGE_KEY);
if (savedTag) {
setLocale(savedTag);
} else {
const deviceTag = detectDeviceLanguageTag();
if (deviceTag) setLocale(deviceTag);
}
} catch {
set({ hydrated: true });
}
},
setSession: async (tokens, user) => {
await SecureStore.setItemAsync(ACCESS_KEY, tokens.accessToken);
await SecureStore.setItemAsync(REFRESH_KEY, tokens.refreshToken);
set({ accessToken: tokens.accessToken, user });
},
setAccessToken: (token) => {
set({ accessToken: token });
void SecureStore.setItemAsync(ACCESS_KEY, token);
},
setOnboardingCompleted: (done) => set({ onboardingCompleted: done }),
getRefreshToken: () => SecureStore.getItemAsync(REFRESH_KEY),
logout: async () => {
await SecureStore.deleteItemAsync(ACCESS_KEY);
await SecureStore.deleteItemAsync(REFRESH_KEY);
set({ accessToken: null, user: null });
},
}));
+13
View File
@@ -0,0 +1,13 @@
import brandConfig from "../../../../brand.config.json";
/** Varumärket enda källan är brand.config.json i repo-roten (i18n-spec §28). */
export const BRAND = brandConfig as {
name: string;
slug: string;
urlScheme: string;
iosBundleId: string;
androidPackage: string;
apiDomain: string;
adminDomain: string;
supportEmail: string;
};
+144
View File
@@ -0,0 +1,144 @@
/**
* i18n via i18next (i18n-spec §5, §78; M4 beslut D-023, D-031).
*
* SPRÅKPOLICY (D-031):
* 1. Första start utan sparat val → enhetens språk om det stöds
* (spanjoren som laddar ner appen får spanska direkt).
* 2. Stöds inte enhetens språk → engelska (internationell fallback),
* svenska är alltid sista fallback för saknade nycklar.
* 3. Manuellt val i profilen vinner ALLTID över enhetens språk och synkas
* till backend (mejl, notiser och innehåll följer samma val)
* spanjoren i Sverige kan köra spanska, svensken i Spanien svenska.
*
* Nytt språk = en JSON-fil + rad i SUPPORTED_LANGUAGES + seed-rader för
* enheter/ingredienser. Paritet vaktas av test (alla filer, samma nycklar).
*/
import i18next from "i18next";
import { create } from "zustand";
import { BRAND } from "./brand";
import sv from "../locales/sv/common.json";
import en from "../locales/en/common.json";
import es from "../locales/es/common.json";
import it from "../locales/it/common.json";
import de from "../locales/de/common.json";
import fr from "../locales/fr/common.json";
import da from "../locales/da/common.json";
import nb from "../locales/nb/common.json";
import fi from "../locales/fi/common.json";
import nl from "../locales/nl/common.json";
import pl from "../locales/pl/common.json";
import pt from "../locales/pt/common.json";
export type TranslationKey = string;
/** Stödda språk renderar språkväljaren och styr detekteringen. */
export const SUPPORTED_LANGUAGES = [
{ code: "sv", tag: "sv-SE", labelKey: "profile.language.sv" },
{ code: "en", tag: "en-US", labelKey: "profile.language.en" },
{ code: "es", tag: "es-ES", labelKey: "profile.language.es" },
{ code: "it", tag: "it-IT", labelKey: "profile.language.it" },
{ code: "de", tag: "de-DE", labelKey: "profile.language.de" },
{ code: "fr", tag: "fr-FR", labelKey: "profile.language.fr" },
{ code: "da", tag: "da-DK", labelKey: "profile.language.da" },
{ code: "nb", tag: "nb-NO", labelKey: "profile.language.nb" },
{ code: "fi", tag: "fi-FI", labelKey: "profile.language.fi" },
{ code: "nl", tag: "nl-NL", labelKey: "profile.language.nl" },
{ code: "pl", tag: "pl-PL", labelKey: "profile.language.pl" },
{ code: "pt", tag: "pt-PT", labelKey: "profile.language.pt" },
] as const;
const SUPPORTED_CODES = new Set<string>(SUPPORTED_LANGUAGES.map((l) => l.code));
void i18next.init({
lng: "sv",
fallbackLng: "sv",
resources: {
sv: { common: sv },
en: { common: en },
es: { common: es },
it: { common: it },
de: { common: de },
fr: { common: fr },
da: { common: da },
nb: { common: nb },
fi: { common: fi },
nl: { common: nl },
pl: { common: pl },
pt: { common: pt },
},
defaultNS: "common",
// Platta nycklar "home.title" är EN nyckel, inte nästling.
keySeparator: false,
nsSeparator: false,
interpolation: {
prefix: "{",
suffix: "}",
escapeValue: false,
defaultVariables: { brand: BRAND.name },
},
returnNull: false,
initAsync: false, // synkron init resurserna är bundlade
});
interface I18nState {
version: number;
languageTag: string;
bump: (languageTag: string) => void;
}
const useI18nStore = create<I18nState>((set) => ({
version: 0,
languageTag: "sv-SE",
bump: (languageTag) => set((s) => ({ version: s.version + 1, languageTag })),
}));
/** Rotlayouten läser denna och re-monterar trädet vid språkbyte. */
export function useI18nVersion(): number {
return useI18nStore((s) => s.version);
}
/** Byt språk utifrån BCP 47-tagg ("es-MX" -> resurser "es", Intl "es-MX"). */
export function setLocale(languageTag: string): void {
const primary = languageTag.split("-")[0] ?? "sv";
const lng = SUPPORTED_CODES.has(primary) ? primary : "sv";
void i18next.changeLanguage(lng);
useI18nStore.getState().bump(lng === primary ? languageTag : "sv-SE");
}
/** Full BCP 47-tagg för Intl-formattering (pengar, datum, tal). */
export function getLanguageTag(): string {
return useI18nStore.getState().languageTag;
}
/**
* Enhetens språk vid FÖRSTA start (D-031 steg 12): stött språk vinner,
* annars engelska. expo-localization saknas i testmiljö → null och sv-default.
*/
export function detectDeviceLanguageTag(): string | null {
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const Localization = require("expo-localization") as {
getLocales(): { languageTag: string; languageCode: string | null }[];
};
const locales = Localization.getLocales();
for (const locale of locales) {
const code =
locale.languageCode === "no" || locale.languageCode === "nn"
? "nb" // norska varianter -> bokmål
: locale.languageCode;
if (code && SUPPORTED_CODES.has(code)) {
return code === locale.languageCode ? locale.languageTag : "nb-NO";
}
}
return locales.length > 0 ? "en-US" : null;
} catch {
return null;
}
}
/**
* Översätt. Plural: skicka `count` (i18next väljer _one/_other via
* Intl.PluralRules). Alla {var} interpoleras; {brand} finns alltid.
*/
export function t(key: TranslationKey, params?: Record<string, string | number>): string {
return i18next.t(key, params);
}
+26
View File
@@ -0,0 +1,26 @@
/**
* Pengavisning i appen (i18n-spec §2021): belopp kommer ALLTID från API:t som
* minor units (heltal) + ISO 4217-valuta. Visning formatteras med Intl enligt
* användarens språk. Ingen aritmetik på flyttalsbelopp i appen.
*/
import { getLanguageTag } from "./i18n";
const MINOR_DIGITS: Record<string, number> = { ISK: 0, JPY: 0, KWD: 3 };
function minorDigits(currency: string): number {
return MINOR_DIGITS[currency.toUpperCase()] ?? 2;
}
/** "7900" + "SEK" -> "79,00 kr" (sv-SE) / "SEK 79.00" (en-US). */
export function formatMinor(amountMinor: number, currency: string): string {
const value = amountMinor / 10 ** minorDigits(currency);
try {
return new Intl.NumberFormat(getLanguageTag(), {
style: "currency",
currency: currency.toUpperCase(),
maximumFractionDigits: Number.isInteger(value) ? 0 : 2,
}).format(value);
} catch {
return `${value} ${currency.toUpperCase()}`;
}
}
+41
View File
@@ -0,0 +1,41 @@
/** plattformens designtokens. Grön bas (mat, hållbarhet), varm accent. */
export const colors = {
background: "#F7F8F5",
surface: "#FFFFFF",
surfaceAlt: "#EFF3EC",
text: "#17241B",
textMuted: "#5C6B60",
primary: "#16803C",
primaryDark: "#0F5C2B",
primarySoft: "#DCF2E3",
accent: "#E2712E",
accentSoft: "#FCE9DC",
danger: "#B91C1C",
dangerSoft: "#FEE2E2",
warning: "#B45309",
warningSoft: "#FEF3C7",
border: "#E3E8E0",
overlay: "rgba(15, 36, 23, 0.55)",
} as const;
export const spacing = {
xs: 4,
sm: 8,
md: 16,
lg: 24,
xl: 32,
} as const;
export const radius = {
sm: 8,
md: 12,
lg: 16,
full: 999,
} as const;
export const typography = {
title: { fontSize: 26, fontWeight: "700" as const, color: colors.text },
heading: { fontSize: 19, fontWeight: "700" as const, color: colors.text },
body: { fontSize: 15, color: colors.text },
small: { fontSize: 13, color: colors.textMuted },
} as const;
+345
View File
@@ -0,0 +1,345 @@
/**
* Enhetsvisning och -tolkning i appen (i18n-spec §9, §11).
* Canonical koder (GRAM, TABLESPOON …) kommer från API:t; här översätts de till
* lokala förkortningar och tolkas tillbaka från användarens fritext.
* Måttsystem-konvertering (M5) sker via displayQuantity i shared-types.
* DB-tabellen unit_translations är källan för fler språk (API: /v1/i18n/units).
*/
import { displayQuantity, type MeasurementSystem, type Unit } from "@app/shared-types";
const LABELS: Record<string, Record<string, string>> = {
sv: {
GRAM: "g",
KILOGRAM: "kg",
MILLILITER: "ml",
DECILITER: "dl",
LITER: "l",
TEASPOON: "tsk",
TABLESPOON: "msk",
CUP_US: "cup",
FLUID_OUNCE_US: "fl oz",
OUNCE: "oz",
POUND: "lb",
COUNT: "st",
PORTION: "portion",
PINCH: "krm",
SLICE: "skiva",
CLOVE: "klyfta",
CAN: "burk",
PACKAGE: "paket",
},
en: {
GRAM: "g",
KILOGRAM: "kg",
MILLILITER: "ml",
DECILITER: "dl",
LITER: "l",
TEASPOON: "tsp",
TABLESPOON: "tbsp",
CUP_US: "cup",
FLUID_OUNCE_US: "fl oz",
OUNCE: "oz",
POUND: "lb",
COUNT: "pcs",
PORTION: "serving",
PINCH: "pinch",
SLICE: "slice",
CLOVE: "clove",
CAN: "can",
PACKAGE: "pack",
},
es: {
GRAM: "g",
KILOGRAM: "kg",
MILLILITER: "ml",
DECILITER: "dl",
LITER: "l",
TEASPOON: "cdta",
TABLESPOON: "cda",
CUP_US: "taza",
FLUID_OUNCE_US: "fl oz",
OUNCE: "oz",
POUND: "lb",
COUNT: "ud",
PORTION: "ración",
PINCH: "pizca",
SLICE: "rebanada",
CLOVE: "diente",
CAN: "lata",
PACKAGE: "paquete",
},
it: {
GRAM: "g",
KILOGRAM: "kg",
MILLILITER: "ml",
DECILITER: "dl",
LITER: "l",
TEASPOON: "cucchiaino",
TABLESPOON: "cucchiaio",
CUP_US: "tazza",
FLUID_OUNCE_US: "fl oz",
OUNCE: "oz",
POUND: "lb",
COUNT: "pz",
PORTION: "porzione",
PINCH: "pizzico",
SLICE: "fetta",
CLOVE: "spicchio",
CAN: "lattina",
PACKAGE: "confezione",
},
de: {
GRAM: "g",
KILOGRAM: "kg",
MILLILITER: "ml",
DECILITER: "dl",
LITER: "l",
TEASPOON: "TL",
TABLESPOON: "EL",
CUP_US: "Cup",
FLUID_OUNCE_US: "fl oz",
OUNCE: "oz",
POUND: "lb",
COUNT: "Stk",
PORTION: "Portion",
PINCH: "Prise",
SLICE: "Scheibe",
CLOVE: "Zehe",
CAN: "Dose",
PACKAGE: "Packung",
},
fr: {
GRAM: "g",
KILOGRAM: "kg",
MILLILITER: "ml",
DECILITER: "dl",
LITER: "l",
TEASPOON: "c. à c.",
TABLESPOON: "c. à s.",
CUP_US: "cup",
FLUID_OUNCE_US: "fl oz",
OUNCE: "oz",
POUND: "lb",
COUNT: "pcs",
PORTION: "portion",
PINCH: "pincée",
SLICE: "tranche",
CLOVE: "gousse",
CAN: "boîte",
PACKAGE: "paquet",
},
da: {
GRAM: "g",
KILOGRAM: "kg",
MILLILITER: "ml",
DECILITER: "dl",
LITER: "l",
TEASPOON: "tsk",
TABLESPOON: "spsk",
CUP_US: "cup",
FLUID_OUNCE_US: "fl oz",
OUNCE: "oz",
POUND: "lb",
COUNT: "stk",
PORTION: "portion",
PINCH: "knsp",
SLICE: "skive",
CLOVE: "fed",
CAN: "dåse",
PACKAGE: "pakke",
},
nb: {
GRAM: "g",
KILOGRAM: "kg",
MILLILITER: "ml",
DECILITER: "dl",
LITER: "l",
TEASPOON: "ts",
TABLESPOON: "ss",
CUP_US: "cup",
FLUID_OUNCE_US: "fl oz",
OUNCE: "oz",
POUND: "lb",
COUNT: "stk",
PORTION: "porsjon",
PINCH: "knivsodd",
SLICE: "skive",
CLOVE: "båt",
CAN: "boks",
PACKAGE: "pakke",
},
fi: {
GRAM: "g",
KILOGRAM: "kg",
MILLILITER: "ml",
DECILITER: "dl",
LITER: "l",
TEASPOON: "tl",
TABLESPOON: "rkl",
CUP_US: "cup",
FLUID_OUNCE_US: "fl oz",
OUNCE: "oz",
POUND: "lb",
COUNT: "kpl",
PORTION: "annos",
PINCH: "hyppysellinen",
SLICE: "viipale",
CLOVE: "kynsi",
CAN: "tölkki",
PACKAGE: "paketti",
},
nl: {
GRAM: "g",
KILOGRAM: "kg",
MILLILITER: "ml",
DECILITER: "dl",
LITER: "l",
TEASPOON: "tl",
TABLESPOON: "el",
CUP_US: "cup",
FLUID_OUNCE_US: "fl oz",
OUNCE: "oz",
POUND: "lb",
COUNT: "st",
PORTION: "portie",
PINCH: "snufje",
SLICE: "plak",
CLOVE: "teentje",
CAN: "blik",
PACKAGE: "pak",
},
pl: {
GRAM: "g",
KILOGRAM: "kg",
MILLILITER: "ml",
DECILITER: "dl",
LITER: "l",
TEASPOON: "łyżeczka",
TABLESPOON: "łyżka",
CUP_US: "cup",
FLUID_OUNCE_US: "fl oz",
OUNCE: "oz",
POUND: "lb",
COUNT: "szt.",
PORTION: "porcja",
PINCH: "szczypta",
SLICE: "plaster",
CLOVE: "ząbek",
CAN: "puszka",
PACKAGE: "opakowanie",
},
pt: {
GRAM: "g",
KILOGRAM: "kg",
MILLILITER: "ml",
DECILITER: "dl",
LITER: "l",
TEASPOON: "c. chá",
TABLESPOON: "c. sopa",
CUP_US: "cup",
FLUID_OUNCE_US: "fl oz",
OUNCE: "oz",
POUND: "lb",
COUNT: "un",
PORTION: "dose",
PINCH: "pitada",
SLICE: "fatia",
CLOVE: "dente",
CAN: "lata",
PACKAGE: "embalagem",
},
};
let currentLanguage = "sv";
export function setUnitLanguage(language: string): void {
currentLanguage = LABELS[language] ? language : "sv";
}
/** Kod → lokal etikett ("GRAM" → "g", "TABLESPOON" → "msk"). */
export function unitLabel(unitCode: string): string {
return LABELS[currentLanguage]?.[unitCode] ?? LABELS.sv?.[unitCode] ?? unitCode.toLowerCase();
}
/** Fritext → kod ("msk" → TABLESPOON, "tbsp" → TABLESPOON, "st" → COUNT …). */
const PARSE: Record<string, string> = {
g: "GRAM",
gram: "GRAM",
grams: "GRAM",
kg: "KILOGRAM",
kilo: "KILOGRAM",
ml: "MILLILITER",
milliliter: "MILLILITER",
dl: "DECILITER",
deciliter: "DECILITER",
l: "LITER",
liter: "LITER",
litre: "LITER",
tsk: "TEASPOON",
tsp: "TEASPOON",
teaspoon: "TEASPOON",
msk: "TABLESPOON",
tbsp: "TABLESPOON",
tablespoon: "TABLESPOON",
krm: "MILLILITER", // 1 krm = 1 ml (dokumenterad standard)
cup: "CUP_US",
cups: "CUP_US",
"fl oz": "FLUID_OUNCE_US",
floz: "FLUID_OUNCE_US",
oz: "OUNCE",
ounce: "OUNCE",
lb: "POUND",
lbs: "POUND",
pound: "POUND",
st: "COUNT",
styck: "COUNT",
pcs: "COUNT",
pc: "COUNT",
stk: "COUNT",
portion: "PORTION",
portioner: "PORTION",
serving: "PORTION",
nypa: "PINCH",
pinch: "PINCH",
skiva: "SLICE",
skivor: "SLICE",
slice: "SLICE",
klyfta: "CLOVE",
klyftor: "CLOVE",
clove: "CLOVE",
burk: "CAN",
can: "CAN",
paket: "PACKAGE",
pkt: "PACKAGE",
pack: "PACKAGE",
package: "PACKAGE",
};
export function parseUnitInput(text: string): string | null {
const key = text.trim().toLowerCase();
if (!key) return null;
if (LABELS.sv?.[text.trim().toUpperCase()]) return text.trim().toUpperCase();
return PARSE[key] ?? null;
}
/** Användarens måttsystem (i18n M5) sätts från locale-preferenserna. */
let currentSystem: MeasurementSystem = "METRIC";
export function setMeasurementSystem(system: MeasurementSystem): void {
currentSystem = system;
}
/**
* Kanonisk mängd -> visningssträng i användarens måttsystem (i18n-spec §910).
* Lagringen är alltid kanonisk; endast visningen konverterar. "≈" markerar
* konverterade (approximativa) värden ärlighet före skenprecision.
*/
export function formatQuantity(quantity: number, unitCode: string): string {
const display = KNOWN_UNITS.has(unitCode)
? displayQuantity(quantity, unitCode as Unit, currentSystem)
: { value: quantity, unit: unitCode, approximate: false };
const v = display.value;
const rounded = Number.isInteger(v) ? String(v) : String(Math.round(v * 100) / 100);
const prefix = display.approximate ? "≈" : "";
return `${prefix}${rounded} ${unitLabel(String(display.unit))}`;
}
const KNOWN_UNITS = new Set(Object.keys(LABELS.sv ?? {}));
+338
View File
@@ -0,0 +1,338 @@
{
"common.loading": "Indlæser …",
"common.error": "Noget gik galt. Prøv igen.",
"common.retry": "Prøv igen",
"common.save": "Gem",
"common.cancel": "Annuller",
"common.next": "Næste",
"common.back": "Tilbage",
"common.done": "Færdig",
"common.skip": "Spring over",
"common.offline": "Offline viser senest hentede data",
"common.estimate": "Estimat",
"tabs.whatToEat": "Hvad skal vi spise?",
"tabs.scan": "Scan",
"tabs.myDay": "Min dag",
"tabs.home": "Derhjemme",
"auth.login": "Log ind",
"auth.register": "Opret konto",
"auth.email": "E-mail",
"auth.password": "Adgangskode",
"auth.displayName": "Hvad skal vi kalde dig?",
"auth.noAccount": "Ny her? Opret en konto",
"auth.hasAccount": "Har du allerede en konto? Log ind",
"auth.trialNote": "7 dages fuld adgang uden kort.",
"auth.forgotLink": "Glemt adgangskode?",
"auth.forgotTitle": "Nulstil adgangskode",
"auth.forgotBody": "Indtast din e-mail, så sender vi et link, hvis kontoen findes.",
"auth.forgotSubmit": "Send link",
"auth.forgotSentTitle": "Tjek din indbakke",
"auth.forgotSentBody": "Hvis adressen har en konto, har vi sendt et link, der gælder i 30 minutter. Åbn det på denne enhed.",
"auth.backToLogin": "Tilbage til login",
"auth.resetTitle": "Vælg en ny adgangskode",
"auth.resetBody": "Linket gælder i 30 minutter og kan kun bruges én gang. Alle enheder logges ud.",
"auth.resetTokenPlaceholder": "Indsæt koden fra e-mailen",
"auth.newPassword": "Ny adgangskode (mindst 10 tegn)",
"auth.resetSubmit": "Skift adgangskode",
"auth.resetDoneTitle": "Færdig!",
"auth.resetDoneBody": "Din adgangskode er ændret. Log ind med den nye.",
"auth.verifyDoneTitle": "E-mail bekræftet",
"auth.verifyDoneBody": "Tak! Din konto er nu verificeret.",
"auth.verifyFailedTitle": "Linket virkede ikke",
"auth.verifyFailedBody": "Linket er ugyldigt eller udløbet. Anmod om et nyt fra profilen.",
"onboarding.title": "Fortæl os lidt om dig",
"onboarding.subtitle": "Alt er frivilligt og kan ændres når som helst. Jo mere du udfylder, jo bedre forslag.",
"onboarding.goal": "Hvad er dit vigtigste mål?",
"onboarding.diet": "Hvordan spiser du?",
"onboarding.allergies": "Allergier og intolerancer",
"onboarding.allergyNote": "Allergifiltrering er altid streng retter med dine allergener vises aldrig.",
"onboarding.household": "Din husstand",
"onboarding.householdCreate": "Opret husstand",
"onboarding.householdJoin": "Deltag med kode",
"onboarding.householdName": "Husstandens navn",
"onboarding.inviteCode": "Invitationskode",
"onboarding.mode": "Hvor præcis vil du være?",
"onboarding.modeSimple": "Enkel tilstand",
"onboarding.modeSimpleDesc": "Tag billeder af maden, accepter estimater, minimalt besvær.",
"onboarding.modeExact": "Præcis tilstand",
"onboarding.modeExactDesc": "Vej maden, indtast gram, fuld kontrol over tallene.",
"onboarding.notMedical": "{brand} giver vejledning ikke lægelig rådgivning.",
"wte.title": "Hvad skal vi spise?",
"wte.subtitle": "Ud fra hvad I har derhjemme, hvad der snart skal bruges, og hvad I kan lide.",
"wte.cravingPlaceholder": "Jeg har lyst til … (fx cremet, asiatisk, under 500 kcal)",
"wte.mealBoxFirst": "Færdig mad derhjemme",
"wte.whyTitle": "Hvorfor dette forslag?",
"wte.coverage": "{pct} % derhjemme",
"wte.missing": "Mangler: {items}",
"wte.empty": "Ingen forslag endnu læg lidt mad på lager eller løsn filtrene.",
"wte.refresh": "Nye forslag",
"scan.title": "Scan",
"scan.subtitle": "Fyld madlageret eller registrér et måltid med kameraet.",
"scan.fridge": "Køleskab",
"scan.freezer": "Fryser",
"scan.pantry": "Spisekammer",
"scan.ingredients": "Ingredienser",
"scan.plate": "Tallerken",
"scan.receipt": "Kvittering",
"scan.barcode": "Stregkode",
"scan.expiry": "Bedst før-dato",
"scan.nutrition": "Næringsdeklaration",
"scan.takePhoto": "Tag billede",
"scan.tips.title": "Sådan bliver analysen bedst",
"scan.tips.overview": "Start med et oversigtsbillede",
"scan.tips.shelf": "Fotografér hylde for hylde",
"scan.tips.light": "Undgå mørke og skygger",
"scan.tips.move": "Flyt varer, der skjuler hinanden",
"scan.analyzing": "Analyserer …",
"scan.quotaLeft_one": "1 AI-scanning tilbage denne måned",
"scan.quotaLeft_other": "{count} AI-scanninger tilbage denne måned",
"scan.review.title": "Gennemgå resultatet",
"scan.review.subtitle": "AI'en er nogle gange usikker du bestemmer. Ret, fjern eller tilføj, før du gemmer.",
"scan.review.uncertain": "Usikker tjek",
"scan.review.approveAll": "Gem på lageret",
"scan.review.rejected": "Fjernet",
"scan.failed": "Analysen mislykkedes. Prøv igen eller registrér manuelt.",
"myday.title": "Min dag",
"myday.calories": "Kalorier",
"myday.protein": "Protein",
"myday.carbs": "Kulhydrater",
"myday.fat": "Fedt",
"myday.fiber": "Fibre",
"myday.salt": "Salt",
"myday.remaining": "{kcal} kcal tilbage",
"myday.over": "{kcal} kcal over målet",
"myday.logMeal": "Registrér måltid",
"myday.noMeals": "Ingen måltider registreret i dag.",
"myday.estimateNote": "Værdier med ~ er estimater, du kan justere.",
"myday.targetsNote": "Målene er vejledning, ikke lægelig rådgivning.",
"home.title": "Derhjemme",
"home.inventory": "Madlager",
"home.useSoon": "Brug snart",
"home.mealBoxes": "Madpakker",
"home.shopping": "Indkøbsliste",
"home.budget": "Madbudget",
"home.household": "Husstand",
"home.waste": "Madspild",
"home.emptyInventory": "Lageret er tomt. Scan køleskabet eller tilføj varer manuelt.",
"home.expiresIn_one": "1 dag tilbage",
"home.expiresIn_other": "{days} dage tilbage",
"home.expiresToday": "Udløber i dag",
"home.expired": "Udløbet",
"home.pastBestBefore": "Bedst før er overskredet lugt og smag først",
"recipe.portions_one": "1 portion",
"recipe.portions_other": "{count} portioner",
"recipe.time": "{min} min",
"recipe.perPortion": "pr. portion",
"recipe.ingredients": "Ingredienser",
"recipe.steps": "Fremgangsmåde",
"recipe.cook": "Lav mad nu",
"recipe.notSafe": "Passer ikke til dine kostindstillinger",
"recipe.substitutions": "Erstat",
"recipe.iCookedThis": "Jeg har lavet denne",
"recipe.cost": "ca. {amount}/portion",
"cooking.step": "Trin {current} af {total}",
"cooking.timer": "Start timer",
"cooking.timerRunning": "{time} tilbage",
"cooking.finish": "Færdig registrér måltidet",
"cooking.scale": "Portioner",
"cooking.keepAwake": "Skærmen forbliver tændt, mens du laver mad",
"cooked.title": "Hvordan gik det?",
"cooked.portionsCooked": "Portioner lavet",
"cooked.whoAte": "Hvem spiste?",
"cooked.mealBoxes": "Portioner til madpakker",
"cooked.deductPantry": "Træk ingredienser fra lageret",
"cooked.rate": "Bedøm",
"shopping.title": "Indkøbsliste",
"shopping.addPlaceholder": "Tilføj vare …",
"shopping.complete": "Afslut indkøbsturen",
"shopping.completeNote": "Afkrydsede varer lægges på madlageret.",
"shopping.empty": "Listen er tom.",
"shopping.estimated": "ca. {amount}",
"shopping.estimatedTotal": "Anslået i alt: ca. {amount}",
"mealbox.title": "Madpakker",
"mealbox.eatBy": "Spis senest {date}",
"mealbox.portionsLeft_one": "1 portion tilbage",
"mealbox.portionsLeft_other": "{count} portioner tilbage",
"mealbox.eat": "Spis nu",
"mealbox.empty": "Ingen madpakker lige nu. Når du laver mad, kan du gemme portioner her.",
"household.members": "Medlemmer",
"household.invite": "Invitér med kode: {code}",
"household.shared": "Deles i husstanden: madlager, indkøbsliste, ugeplan, madpakker og budget.",
"household.private": "Privat pr. person: mål, allergier, helbredsdata og måltidshistorik.",
"memory.title": "Hvad {brand} ved om mig",
"memory.subtitle": "Fuld indsigt. Ret fejl, sæt på pause eller slet du ejer din hukommelse.",
"memory.paused": "På pause",
"memory.verify": "Korrekt",
"memory.pause": "Pause",
"memory.delete": "Slet",
"memory.deleteAll": "Slet al hukommelse",
"memory.empty": "{brand} har ikke lært noget om dig endnu.",
"memory.origin.user_stated": "Du har fortalt det",
"memory.origin.observed": "Observeret mønster",
"memory.origin.ai_inferred": "AI-antagelse",
"paywall.title": "{brand} Premium",
"paywall.subtitle": "Hele husstandens mad-OS: ubegrænset lager, ugeplan og deling.",
"paywall.trialActive_one": "1 dag tilbage af din prøveperiode",
"paywall.trialActive_other": "{days} dage tilbage af din prøveperiode",
"paywall.household": "Household · op til 3 personer",
"paywall.family": "Family · op til 6 personer",
"paywall.large": "Large Household · op til 12 personer",
"paywall.perMonth": "{price}/md.",
"paywall.fairUse": "Fair use-kvote for AI-scanninger inkluderet.",
"paywall.restore": "Gendan køb",
"paywall.freeNote": "Gratis: 10 AI-scanninger/md., manuelt lager, gemte opskrifter og enkel registrering.",
"profile.title": "Profil",
"profile.goals": "Mål & kost",
"profile.consents": "Samtykker & data",
"profile.memory": "Hvad {brand} ved om mig",
"profile.subscription": "Abonnement",
"profile.logout": "Log ud",
"profile.export": "Eksportér mine data",
"profile.deleteAccount": "Slet konto",
"profile.language": "Sprog",
"profile.emailUnverified": "E-mailadressen er ikke bekræftet",
"profile.resendVerification": "Send bekræftelsesmail igen",
"profile.verificationSent": "Sendt! Tjek din indbakke.",
"profile.language.sv": "Svenska",
"profile.language.en": "English",
"profile.language.es": "Español",
"profile.language.it": "Italiano",
"profile.language.de": "Deutsch",
"profile.language.fr": "Français",
"profile.language.da": "Dansk",
"profile.language.nb": "Norsk",
"profile.language.fi": "Suomi",
"profile.language.nl": "Nederlands",
"profile.language.pl": "Polski",
"profile.language.pt": "Português",
"common.oops": "Ups",
"common.undo": "Fortryd",
"common.remove": "Fjern",
"common.add": "Tilføj",
"common.on": "Til",
"common.off": "Fra",
"home.thisWeek": "Denne uge",
"home.wasteWeek": "Madspild denne uge",
"home.useSoonHint": "Bedst før handler om kvalitet lugt, se og smag, før du smider ud. Sidste anvendelsesdato skal derimod respekteres.",
"scan.review.itemPlaceholder": "Vare",
"scan.review.quantityPlaceholder": "Mængde",
"scan.review.unitPlaceholder": "Enhed (g, l, stk. …)",
"scan.review.datePlaceholder": "ÅÅÅÅ-MM-DD (valgfrit)",
"scan.review.dateKindBestBefore": "Bedst før",
"scan.review.dateKindUseBy": "Sidste anvendelsesdato",
"scan.review.bestBeforeNote": "Kvalitetsdato varen kan sagtens holde længere. Lugt og smag, før du smider ud.",
"scan.review.useByNote": "Sikkerhedsdato spis ikke efter denne dato.",
"scan.review.addItem": "+ Tilføj vare",
"scan.review.unknownItem": "Ukendt vare",
"barcode.aim": "Ret kameraet mod stregkoden",
"barcode.addPrompt": "Tilføj til madlageret?",
"barcode.kcalPer100": "{kcal} kcal/100 g",
"barcode.noNutrition": "Ingen næringsdata",
"barcode.unknownTitle": "Ukendt produkt",
"barcode.unknownBody": "Produktet er ikke i databasen endnu. Tag et billede af forsiden og næringsdeklarationen, så tilføjer vi det.",
"barcode.photoPackage": "Tag billede af emballagen",
"barcode.needHousehold": "Du skal først have en husstand.",
"barcode.noLocation": "Ingen opbevaringssteder fundet.",
"barcode.cameraTitle": "Kameraet er nødvendigt",
"barcode.cameraBody": "{brand} skal bruge kameraet til at scanne stregkoder.",
"barcode.allowCamera": "Tillad kamera",
"onboarding.goal.lose_weight": "Tabe mig",
"onboarding.goal.build_muscle": "Opbygge muskler",
"onboarding.goal.maintain_weight": "Holde vægten",
"onboarding.goal.more_protein": "Spise mere protein",
"onboarding.goal.less_waste": "Mindske madspild",
"onboarding.goal.lower_cost": "Bruge færre penge på mad",
"onboarding.goal.cook_more": "Lave mere mad hjemme",
"onboarding.diet.omnivore": "Altædende",
"onboarding.diet.flexitarian": "Flexitar",
"onboarding.diet.pescatarian": "Pescetar",
"onboarding.diet.vegetarian": "Vegetar",
"onboarding.diet.vegan": "Veganer",
"onboarding.allergen.gluten": "Gluten",
"onboarding.allergen.milk": "Mælk/laktose",
"onboarding.allergen.eggs": "Æg",
"onboarding.allergen.tree_nuts": "Nødder",
"onboarding.allergen.peanuts": "Jordnødder",
"onboarding.allergen.fish": "Fisk",
"onboarding.allergen.crustaceans": "Skaldyr",
"onboarding.allergen.soy": "Soja",
"onboarding.allergen.sesame": "Sesam",
"onboarding.bodyTitle": "Til personlige kaloriemål (valgfrit)",
"onboarding.weightPlaceholder": "Vægt (kg)",
"onboarding.heightPlaceholder": "Højde (cm)",
"onboarding.birthYearPlaceholder": "Fødselsår",
"onboarding.householdDefaultName": "Hjemme",
"onboarding.modesCombine": "Tilstandene kan kombineres enkelt til hverdag, præcist når du vil.",
"myday.mealType.breakfast": "Morgenmad",
"myday.mealType.lunch": "Frokost",
"myday.mealType.dinner": "Aftensmad",
"myday.mealType.snack": "Mellemmåltid",
"myday.mealType.dessert": "Dessert",
"myday.overRecommended": "Over det anbefalede",
"myday.todaysMeals": "Dagens måltider",
"myday.estimateRange": "{min}{max} kcal, sandsynligvis {kcal}",
"myday.kcalProtein": "{kcal} kcal · {protein} g protein",
"logmeal.mealTypeTitle": "Måltidstype",
"logmeal.quickTitle": "Hurtig genlogning",
"logmeal.quickSubtitle": "Logger de samme næringsværdier som sidst.",
"logmeal.manualTitle": "Manuelt",
"logmeal.whatPlaceholder": "Hvad spiste du?",
"logmeal.proteinPlaceholder": "protein (g)",
"logmeal.validationTitle": "Udfyld",
"logmeal.validationBody": "Navn og kalorier kræves ved manuel logning.",
"logmeal.photoTip": "Tip: tag et billede af tallerkenen under Scan, så estimerer appen for dig du bekræfter altid.",
"shopping.section.frukt_gront": "Frugt & grønt",
"shopping.section.brod": "Brød",
"shopping.section.mejeri": "Mejeri",
"shopping.section.kott_fagel": "Kød & fjerkræ",
"shopping.section.fisk": "Fisk",
"shopping.section.chark": "Pålæg",
"shopping.section.frys": "Frost",
"shopping.section.skafferi": "Kolonial",
"shopping.section.konserver": "Konserves",
"shopping.section.kryddor_bak": "Krydderier & bagning",
"shopping.section.dryck": "Drikkevarer",
"shopping.section.snacks": "Snacks",
"shopping.section.hygien_ovrigt": "Andet",
"shopping.completedTitle": "Færdig!",
"household.role.owner": "Ejer",
"household.role.adult": "Voksen",
"household.role.member": "Medlem",
"household.role.child": "Barn",
"household.empty": "Du har ingen husstand endnu. Opret en i onboarding, eller deltag med en kode.",
"household.shareCode": "Del koden med familien, så deler I madlager, liste og plan.",
"household.locations": "Opbevaringssteder",
"household.portionFactor": "×{factor} portion",
"mealbox.enjoyTitle": "Velbekomme!",
"mealbox.enjoyBody": "Portionen er logget i Min dag.",
"mealbox.guidanceNote": "Den anbefalede frist er vejledende stol på lugt og smag.",
"paywall.soonTitle": "Snart!",
"paywall.soonBody": "Køb aktiveres via App Store/Google Play, når butiksintegrationen slås til (fase 7). Backend-flowet er allerede klar.",
"paywall.popular": "Mest populær",
"paywall.choose": "Vælg",
"recipe.saved": "Gemt",
"recipe.save": "Gem",
"recipe.avgRating": "Gennemsnit {avg} af {count} bedømmelser",
"memory.verified": "Bekræftet",
"memory.resume": "Genoptag",
"memory.deleteAllTitle": "Slet al hukommelse?",
"memory.irreversible": "Dette kan ikke fortrydes.",
"profile.consent.personalization": "Personlige funktioner (hukommelse & smagsprofil)",
"profile.consent.anonymized_improvement": "Anonymiseret forbedring af AI'en",
"profile.consent.image_training": "Mine billeder må bruges til træning",
"profile.consent.health_integration": "Sundhedsdata (Apple Health / Health Connect)",
"profile.consent.location_weather": "Placering til vejrbaserede forslag",
"profile.consent.push_notifications": "Push-notifikationer",
"profile.modeLabel": "Tilstand:",
"profile.memorySubtitle": "Se, ret, sæt på pause eller slet det, platformen har lært.",
"profile.consentsNote": "Samtykkerne er adskilte personlige funktioner kræver aldrig træningssamtykke.",
"profile.aiScansUsed": "AI-scanninger: {used} / {total} denne måned",
"profile.deleteTitle": "Slet kontoen?",
"profile.deleteBody": "Alle dine data slettes permanent i henhold til GDPR.",
"cooking.timerDone": "Færdig!",
"cooked.deductPantryNote": "Ingredienserne trækkes fra madlageret det, der udløber først, bruges først.",
"home.moreItems_one": "+ 1 mere …",
"home.moreItems_other": "+ {count} flere …",
"shopping.completedBody_one": "1 vare blev lagt i madlageret.",
"shopping.completedBody_other": "{count} varer blev lagt i madlageret."
}
+338
View File
@@ -0,0 +1,338 @@
{
"common.loading": "Wird geladen …",
"common.error": "Etwas ist schiefgelaufen. Bitte erneut versuchen.",
"common.retry": "Erneut versuchen",
"common.save": "Speichern",
"common.cancel": "Abbrechen",
"common.next": "Weiter",
"common.back": "Zurück",
"common.done": "Fertig",
"common.skip": "Überspringen",
"common.offline": "Offline zuletzt geladene Daten",
"common.estimate": "Schätzung",
"tabs.whatToEat": "Was essen wir?",
"tabs.scan": "Scannen",
"tabs.myDay": "Mein Tag",
"tabs.home": "Zuhause",
"auth.login": "Anmelden",
"auth.register": "Konto erstellen",
"auth.email": "E-Mail",
"auth.password": "Passwort",
"auth.displayName": "Wie sollen wir dich nennen?",
"auth.noAccount": "Neu hier? Konto erstellen",
"auth.hasAccount": "Schon ein Konto? Anmelden",
"auth.trialNote": "7 Tage voller Zugriff ohne Karte.",
"auth.forgotLink": "Passwort vergessen?",
"auth.forgotTitle": "Passwort zurücksetzen",
"auth.forgotBody": "Gib deine E-Mail-Adresse ein wir senden einen Link, falls das Konto existiert.",
"auth.forgotSubmit": "Link senden",
"auth.forgotSentTitle": "Prüfe dein Postfach",
"auth.forgotSentBody": "Falls die Adresse ein Konto hat, haben wir einen 30 Minuten gültigen Link gesendet. Öffne ihn auf diesem Gerät.",
"auth.backToLogin": "Zurück zur Anmeldung",
"auth.resetTitle": "Neues Passwort wählen",
"auth.resetBody": "Der Link ist 30 Minuten gültig und nur einmal verwendbar. Alle Geräte werden abgemeldet.",
"auth.resetTokenPlaceholder": "Code aus der E-Mail einfügen",
"auth.newPassword": "Neues Passwort (mind. 10 Zeichen)",
"auth.resetSubmit": "Passwort ändern",
"auth.resetDoneTitle": "Fertig!",
"auth.resetDoneBody": "Dein Passwort wurde geändert. Melde dich mit dem neuen an.",
"auth.verifyDoneTitle": "E-Mail bestätigt",
"auth.verifyDoneBody": "Danke! Dein Konto ist jetzt verifiziert.",
"auth.verifyFailedTitle": "Der Link hat nicht funktioniert",
"auth.verifyFailedBody": "Der Link ist ungültig oder abgelaufen. Fordere im Profil einen neuen an.",
"onboarding.title": "Erzähl uns etwas über dich",
"onboarding.subtitle": "Alles ist optional und jederzeit änderbar. Je mehr du ausfüllst, desto besser die Vorschläge.",
"onboarding.goal": "Was ist dein wichtigstes Ziel?",
"onboarding.diet": "Wie isst du?",
"onboarding.allergies": "Allergien und Unverträglichkeiten",
"onboarding.allergyNote": "Der Allergiefilter ist immer strikt Gerichte mit deinen Allergenen werden nie angezeigt.",
"onboarding.household": "Dein Haushalt",
"onboarding.householdCreate": "Haushalt erstellen",
"onboarding.householdJoin": "Mit Code beitreten",
"onboarding.householdName": "Name des Haushalts",
"onboarding.inviteCode": "Einladungscode",
"onboarding.mode": "Wie genau möchtest du es?",
"onboarding.modeSimple": "Einfacher Modus",
"onboarding.modeSimpleDesc": "Essen fotografieren, Schätzungen akzeptieren, kein Aufwand.",
"onboarding.modeExact": "Exakter Modus",
"onboarding.modeExactDesc": "Essen wiegen, Gramm eingeben, volle Kontrolle über die Zahlen.",
"onboarding.notMedical": "{brand} bietet Orientierung keine medizinische Beratung.",
"wte.title": "Was essen wir?",
"wte.subtitle": "Basierend auf dem, was ihr zuhause habt, was bald verbraucht werden sollte und was ihr mögt.",
"wte.cravingPlaceholder": "Ich habe Lust auf … (z. B. cremig, asiatisch, unter 500 kcal)",
"wte.mealBoxFirst": "Fertiges Essen zuhause",
"wte.whyTitle": "Warum dieser Vorschlag?",
"wte.coverage": "{pct} % zuhause",
"wte.missing": "Fehlt: {items}",
"wte.empty": "Noch keine Vorschläge fülle den Vorrat oder lockere die Filter.",
"wte.refresh": "Neue Vorschläge",
"scan.title": "Scannen",
"scan.subtitle": "Fülle deinen Vorrat oder erfasse eine Mahlzeit mit der Kamera.",
"scan.fridge": "Kühlschrank",
"scan.freezer": "Gefrierschrank",
"scan.pantry": "Vorratsschrank",
"scan.ingredients": "Zutaten",
"scan.plate": "Teller",
"scan.receipt": "Kassenbon",
"scan.barcode": "Barcode",
"scan.expiry": "Mindesthaltbarkeitsdatum",
"scan.nutrition": "Nährwertangaben",
"scan.takePhoto": "Foto aufnehmen",
"scan.tips.title": "So gelingt die Analyse am besten",
"scan.tips.overview": "Beginne mit einem Übersichtsfoto",
"scan.tips.shelf": "Fotografiere Fach für Fach",
"scan.tips.light": "Vermeide Dunkelheit und Schatten",
"scan.tips.move": "Rücke verdeckte Produkte frei",
"scan.analyzing": "Analyse läuft …",
"scan.quotaLeft_one": "1 KI-Scan diesen Monat übrig",
"scan.quotaLeft_other": "{count} KI-Scans diesen Monat übrig",
"scan.review.title": "Ergebnis prüfen",
"scan.review.subtitle": "Die KI ist manchmal unsicher du entscheidest. Bearbeite, entferne oder ergänze vor dem Speichern.",
"scan.review.uncertain": "Unsicher bitte prüfen",
"scan.review.approveAll": "In den Vorrat speichern",
"scan.review.rejected": "Entfernt",
"scan.failed": "Analyse fehlgeschlagen. Erneut versuchen oder manuell erfassen.",
"myday.title": "Mein Tag",
"myday.calories": "Kalorien",
"myday.protein": "Eiweiß",
"myday.carbs": "Kohlenhydrate",
"myday.fat": "Fett",
"myday.fiber": "Ballaststoffe",
"myday.salt": "Salz",
"myday.remaining": "{kcal} kcal übrig",
"myday.over": "{kcal} kcal über dem Ziel",
"myday.logMeal": "Mahlzeit erfassen",
"myday.noMeals": "Heute noch keine Mahlzeiten erfasst.",
"myday.estimateNote": "Werte mit ~ sind Schätzungen, die du anpassen kannst.",
"myday.targetsNote": "Ziele sind Orientierung, keine medizinische Beratung.",
"home.title": "Zuhause",
"home.inventory": "Vorrat",
"home.useSoon": "Bald verbrauchen",
"home.mealBoxes": "Meal-Prep-Boxen",
"home.shopping": "Einkaufsliste",
"home.budget": "Essensbudget",
"home.household": "Haushalt",
"home.waste": "Lebensmittelabfall",
"home.emptyInventory": "Der Vorrat ist leer. Scanne den Kühlschrank oder füge manuell hinzu.",
"home.expiresIn_one": "Noch 1 Tag",
"home.expiresIn_other": "Noch {days} Tage",
"home.expiresToday": "Läuft heute ab",
"home.expired": "Abgelaufen",
"home.pastBestBefore": "MHD überschritten erst riechen und probieren",
"recipe.portions_one": "1 Portion",
"recipe.portions_other": "{count} Portionen",
"recipe.time": "{min} Min.",
"recipe.perPortion": "pro Portion",
"recipe.ingredients": "Zutaten",
"recipe.steps": "Zubereitung",
"recipe.cook": "Jetzt kochen",
"recipe.notSafe": "Passt nicht zu deinen Ernährungseinstellungen",
"recipe.substitutions": "Ersetzen",
"recipe.iCookedThis": "Habe ich gekocht",
"recipe.cost": "ca. {amount}/Portion",
"cooking.step": "Schritt {current} von {total}",
"cooking.timer": "Timer starten",
"cooking.timerRunning": "{time} übrig",
"cooking.finish": "Fertig Mahlzeit erfassen",
"cooking.scale": "Portionen",
"cooking.keepAwake": "Der Bildschirm bleibt beim Kochen an",
"cooked.title": "Wie ist es gelaufen?",
"cooked.portionsCooked": "Gekochte Portionen",
"cooked.whoAte": "Wer hat gegessen?",
"cooked.mealBoxes": "Portionen für Meal-Prep-Boxen",
"cooked.deductPantry": "Zutaten vom Vorrat abziehen",
"cooked.rate": "Bewerten",
"shopping.title": "Einkaufsliste",
"shopping.addPlaceholder": "Artikel hinzufügen …",
"shopping.complete": "Einkauf abschließen",
"shopping.completeNote": "Abgehakte Artikel wandern in den Vorrat.",
"shopping.empty": "Die Liste ist leer.",
"shopping.estimated": "ca. {amount}",
"shopping.estimatedTotal": "Geschätzte Summe: ca. {amount}",
"mealbox.title": "Meal-Prep-Boxen",
"mealbox.eatBy": "Verzehren bis {date}",
"mealbox.portionsLeft_one": "Noch 1 Portion",
"mealbox.portionsLeft_other": "Noch {count} Portionen",
"mealbox.eat": "Jetzt essen",
"mealbox.empty": "Gerade keine Boxen. Beim Kochen kannst du hier Portionen aufheben.",
"household.members": "Mitglieder",
"household.invite": "Einladen mit Code: {code}",
"household.shared": "Im Haushalt geteilt: Vorrat, Einkaufsliste, Wochenplan, Boxen und Budget.",
"household.private": "Privat pro Person: Ziele, Allergien, Gesundheitsdaten und Essenshistorie.",
"memory.title": "Was {brand} über mich weiß",
"memory.subtitle": "Volle Transparenz. Korrigiere Fehler, pausiere oder lösche dein Gedächtnis gehört dir.",
"memory.paused": "Pausiert",
"memory.verify": "Stimmt",
"memory.pause": "Pausieren",
"memory.delete": "Löschen",
"memory.deleteAll": "Gesamtes Gedächtnis löschen",
"memory.empty": "{brand} hat noch nichts über dich gelernt.",
"memory.origin.user_stated": "Von dir erzählt",
"memory.origin.observed": "Beobachtetes Muster",
"memory.origin.ai_inferred": "KI-Annahme",
"paywall.title": "{brand} Premium",
"paywall.subtitle": "Das Food-Betriebssystem für den ganzen Haushalt: unbegrenzter Vorrat, Wochenplan und Teilen.",
"paywall.trialActive_one": "Noch 1 Tag Testzeitraum",
"paywall.trialActive_other": "Noch {days} Tage Testzeitraum",
"paywall.household": "Household · bis zu 3 Personen",
"paywall.family": "Family · bis zu 6 Personen",
"paywall.large": "Large Household · bis zu 12 Personen",
"paywall.perMonth": "{price}/Monat",
"paywall.fairUse": "Fair-Use-Kontingent für KI-Scans inklusive.",
"paywall.restore": "Käufe wiederherstellen",
"paywall.freeNote": "Gratis: 10 KI-Scans/Monat, manueller Vorrat, gespeicherte Rezepte und einfaches Tracking.",
"profile.title": "Profil",
"profile.goals": "Ziele & Ernährung",
"profile.consents": "Einwilligungen & Daten",
"profile.memory": "Was {brand} über mich weiß",
"profile.subscription": "Abo",
"profile.logout": "Abmelden",
"profile.export": "Meine Daten exportieren",
"profile.deleteAccount": "Konto löschen",
"profile.language": "Sprache",
"profile.language.sv": "Svenska",
"profile.language.en": "English",
"profile.emailUnverified": "E-Mail-Adresse nicht bestätigt",
"profile.resendVerification": "Bestätigungsmail erneut senden",
"profile.verificationSent": "Gesendet! Prüfe dein Postfach.",
"profile.language.es": "Español",
"profile.language.it": "Italiano",
"profile.language.de": "Deutsch",
"profile.language.fr": "Français",
"profile.language.da": "Dansk",
"profile.language.nb": "Norsk",
"profile.language.fi": "Suomi",
"profile.language.nl": "Nederlands",
"profile.language.pl": "Polski",
"profile.language.pt": "Português",
"common.oops": "Hoppla",
"common.undo": "Rückgängig",
"common.remove": "Entfernen",
"common.add": "Hinzufügen",
"common.on": "An",
"common.off": "Aus",
"home.thisWeek": "Diese Woche",
"home.wasteWeek": "Lebensmittelabfall diese Woche",
"home.useSoonHint": "Das Mindesthaltbarkeitsdatum ist eine Qualitätsgrenze erst riechen, ansehen und probieren, bevor etwas weggeworfen wird. Das Verbrauchsdatum dagegen gilt strikt.",
"scan.review.itemPlaceholder": "Artikel",
"scan.review.quantityPlaceholder": "Menge",
"scan.review.unitPlaceholder": "Einheit (g, l, Stk. …)",
"scan.review.datePlaceholder": "JJJJ-MM-TT (optional)",
"scan.review.dateKindBestBefore": "Mindesthaltbarkeit (MHD)",
"scan.review.dateKindUseBy": "Verbrauchsdatum",
"scan.review.bestBeforeNote": "Qualitätsdatum oft ist das Lebensmittel danach noch gut. Erst riechen und probieren.",
"scan.review.useByNote": "Sicherheitsdatum danach nicht mehr essen.",
"scan.review.addItem": "+ Artikel hinzufügen",
"scan.review.unknownItem": "Unbekannter Artikel",
"barcode.aim": "Richte die Kamera auf den Barcode",
"barcode.addPrompt": "Zum Vorrat hinzufügen?",
"barcode.kcalPer100": "{kcal} kcal/100 g",
"barcode.noNutrition": "Keine Nährwertdaten",
"barcode.unknownTitle": "Unbekanntes Produkt",
"barcode.unknownBody": "Dieses Produkt ist noch nicht in der Datenbank. Fotografiere Vorderseite und Nährwerttabelle, dann fügen wir es hinzu.",
"barcode.photoPackage": "Verpackung fotografieren",
"barcode.needHousehold": "Du brauchst zuerst einen Haushalt.",
"barcode.noLocation": "Kein Lagerort gefunden.",
"barcode.cameraTitle": "Kamera erforderlich",
"barcode.cameraBody": "{brand} benötigt die Kamera, um Barcodes zu scannen.",
"barcode.allowCamera": "Kamera erlauben",
"onboarding.goal.lose_weight": "Abnehmen",
"onboarding.goal.build_muscle": "Muskeln aufbauen",
"onboarding.goal.maintain_weight": "Gewicht halten",
"onboarding.goal.more_protein": "Mehr Protein essen",
"onboarding.goal.less_waste": "Weniger wegwerfen",
"onboarding.goal.lower_cost": "Weniger für Essen ausgeben",
"onboarding.goal.cook_more": "Öfter selbst kochen",
"onboarding.diet.omnivore": "Allesesser",
"onboarding.diet.flexitarian": "Flexitarier",
"onboarding.diet.pescatarian": "Pescetarier",
"onboarding.diet.vegetarian": "Vegetarier",
"onboarding.diet.vegan": "Veganer",
"onboarding.allergen.gluten": "Gluten",
"onboarding.allergen.milk": "Milch/Laktose",
"onboarding.allergen.eggs": "Eier",
"onboarding.allergen.tree_nuts": "Schalenfrüchte",
"onboarding.allergen.peanuts": "Erdnüsse",
"onboarding.allergen.fish": "Fisch",
"onboarding.allergen.crustaceans": "Krebstiere",
"onboarding.allergen.soy": "Soja",
"onboarding.allergen.sesame": "Sesam",
"onboarding.bodyTitle": "Für persönliche Kalorienziele (optional)",
"onboarding.weightPlaceholder": "Gewicht (kg)",
"onboarding.heightPlaceholder": "Größe (cm)",
"onboarding.birthYearPlaceholder": "Geburtsjahr",
"onboarding.householdDefaultName": "Zuhause",
"onboarding.modesCombine": "Die Modi lassen sich kombinieren im Alltag einfach, exakt wenn du willst.",
"myday.mealType.breakfast": "Frühstück",
"myday.mealType.lunch": "Mittagessen",
"myday.mealType.dinner": "Abendessen",
"myday.mealType.snack": "Snack",
"myday.mealType.dessert": "Nachtisch",
"myday.overRecommended": "Über der Empfehlung",
"myday.todaysMeals": "Heutige Mahlzeiten",
"myday.estimateRange": "{min}{max} kcal, wahrscheinlich {kcal}",
"myday.kcalProtein": "{kcal} kcal · {protein} g Protein",
"logmeal.mealTypeTitle": "Mahlzeitentyp",
"logmeal.quickTitle": "Schnell erneut loggen",
"logmeal.quickSubtitle": "Übernimmt dieselben Nährwerte wie beim letzten Mal.",
"logmeal.manualTitle": "Manuell",
"logmeal.whatPlaceholder": "Was hast du gegessen?",
"logmeal.proteinPlaceholder": "Protein (g)",
"logmeal.validationTitle": "Angaben fehlen",
"logmeal.validationBody": "Für manuelles Loggen sind Name und Kalorien nötig.",
"logmeal.photoTip": "Tipp: Fotografiere den Teller unter Scannen die App schätzt für dich, du bestätigst immer.",
"shopping.section.frukt_gront": "Obst & Gemüse",
"shopping.section.brod": "Brot",
"shopping.section.mejeri": "Molkereiprodukte",
"shopping.section.kott_fagel": "Fleisch & Geflügel",
"shopping.section.fisk": "Fisch",
"shopping.section.chark": "Wurstwaren",
"shopping.section.frys": "Tiefkühl",
"shopping.section.skafferi": "Vorratsschrank",
"shopping.section.konserver": "Konserven",
"shopping.section.kryddor_bak": "Gewürze & Backen",
"shopping.section.dryck": "Getränke",
"shopping.section.snacks": "Snacks",
"shopping.section.hygien_ovrigt": "Sonstiges",
"shopping.completedTitle": "Fertig!",
"household.role.owner": "Inhaber",
"household.role.adult": "Erwachsener",
"household.role.member": "Mitglied",
"household.role.child": "Kind",
"household.empty": "Du hast noch keinen Haushalt. Lege einen im Onboarding an oder tritt per Code bei.",
"household.shareCode": "Teile den Code mit der Familie dann teilt ihr Vorrat, Liste und Plan.",
"household.locations": "Lagerorte",
"household.portionFactor": "×{factor} Portion",
"mealbox.enjoyTitle": "Guten Appetit!",
"mealbox.enjoyBody": "Die Portion wurde in Mein Tag geloggt.",
"mealbox.guidanceNote": "Die empfohlene Frist ist eine Orientierung verlass dich auf Geruch und Geschmack.",
"paywall.soonTitle": "Bald!",
"paywall.soonBody": "Käufe laufen über App Store/Google Play, sobald die Store-Integration aktiviert ist (Phase 7). Das Backend ist bereits fertig.",
"paywall.popular": "Am beliebtesten",
"paywall.choose": "Wählen",
"recipe.saved": "Gespeichert",
"recipe.save": "Speichern",
"recipe.avgRating": "Schnitt {avg} bei {count} Bewertungen",
"memory.verified": "Bestätigt",
"memory.resume": "Fortsetzen",
"memory.deleteAllTitle": "Gesamtes Gedächtnis löschen?",
"memory.irreversible": "Das lässt sich nicht rückgängig machen.",
"profile.consent.personalization": "Persönliche Funktionen (Gedächtnis & Geschmacksprofil)",
"profile.consent.anonymized_improvement": "Anonymisierte Verbesserung der KI",
"profile.consent.image_training": "Meine Bilder dürfen fürs Training genutzt werden",
"profile.consent.health_integration": "Gesundheitsdaten (Apple Health / Health Connect)",
"profile.consent.location_weather": "Standort für wetterbasierte Vorschläge",
"profile.consent.push_notifications": "Push-Benachrichtigungen",
"profile.modeLabel": "Modus:",
"profile.memorySubtitle": "Sieh, korrigiere, pausiere oder lösche, was die Plattform gelernt hat.",
"profile.consentsNote": "Die Einwilligungen sind getrennt persönliche Funktionen erfordern nie eine Trainings-Einwilligung.",
"profile.aiScansUsed": "KI-Scans: {used} / {total} diesen Monat",
"profile.deleteTitle": "Konto löschen?",
"profile.deleteBody": "Alle deine Daten werden gemäß DSGVO dauerhaft gelöscht.",
"cooking.timerDone": "Fertig!",
"cooked.deductPantryNote": "Zutaten werden nach dem Prinzip zuerst-ablaufend-zuerst vom Vorrat abgezogen.",
"home.moreItems_one": "+ 1 weiterer …",
"home.moreItems_other": "+ {count} weitere …",
"shopping.completedBody_one": "1 Artikel wurde in den Vorrat übernommen.",
"shopping.completedBody_other": "{count} Artikel wurden in den Vorrat übernommen."
}
+338
View File
@@ -0,0 +1,338 @@
{
"common.loading": "Loading …",
"common.error": "Something went wrong. Please try again.",
"common.retry": "Try again",
"common.save": "Save",
"common.cancel": "Cancel",
"common.next": "Next",
"common.back": "Back",
"common.done": "Done",
"common.skip": "Skip",
"common.offline": "Offline showing last fetched data",
"common.estimate": "Estimate",
"tabs.whatToEat": "What's for dinner?",
"tabs.scan": "Scan",
"tabs.myDay": "My day",
"tabs.home": "At home",
"auth.login": "Log in",
"auth.register": "Create account",
"auth.email": "Email",
"auth.password": "Password",
"auth.displayName": "What should we call you?",
"auth.noAccount": "New here? Create an account",
"auth.hasAccount": "Already have an account? Log in",
"auth.trialNote": "7 days of full access no card required.",
"onboarding.title": "Tell us a little about yourself",
"onboarding.subtitle": "Everything is optional and can be changed at any time. The more you fill in, the better the suggestions.",
"onboarding.goal": "What is your main goal?",
"onboarding.diet": "How do you eat?",
"onboarding.allergies": "Allergies and intolerances",
"onboarding.allergyNote": "Allergy filtering is always strict dishes with your allergens are never shown.",
"onboarding.household": "Your household",
"onboarding.householdCreate": "Create household",
"onboarding.householdJoin": "Join with a code",
"onboarding.householdName": "Household name",
"onboarding.inviteCode": "Invite code",
"onboarding.mode": "How precise do you want to be?",
"onboarding.modeSimple": "Simple mode",
"onboarding.modeSimpleDesc": "Snap photos of your food, accept estimates, minimal fuss.",
"onboarding.modeExact": "Exact mode",
"onboarding.modeExactDesc": "Weigh food, enter grams, full control of the numbers.",
"onboarding.notMedical": "{brand} provides guidance not medical advice.",
"wte.title": "What's for dinner?",
"wte.subtitle": "Based on what you have at home, what should be used up, and what you like.",
"wte.cravingPlaceholder": "I'm craving … (e.g. creamy, Asian, under 500 kcal)",
"wte.mealBoxFirst": "Ready-made food at home",
"wte.whyTitle": "Why this suggestion?",
"wte.coverage": "{pct}% at home",
"wte.missing": "Missing: {items}",
"wte.empty": "No suggestions yet add some food to your inventory or loosen the filters.",
"wte.refresh": "New suggestions",
"scan.title": "Scan",
"scan.subtitle": "Stock your food inventory or log a meal with the camera.",
"scan.fridge": "Fridge",
"scan.freezer": "Freezer",
"scan.pantry": "Pantry",
"scan.ingredients": "Ingredients",
"scan.plate": "Plate",
"scan.receipt": "Receipt",
"scan.barcode": "Barcode",
"scan.expiry": "Best-before date",
"scan.nutrition": "Nutrition label",
"scan.takePhoto": "Take photo",
"scan.tips.title": "How to get the best analysis",
"scan.tips.overview": "Start with an overview photo",
"scan.tips.shelf": "Photograph shelf by shelf",
"scan.tips.light": "Avoid darkness and shadows",
"scan.tips.move": "Move items that block each other",
"scan.analyzing": "Analyzing …",
"scan.quotaLeft_one": "1 AI scan left this month",
"scan.quotaLeft_other": "{count} AI scans left this month",
"scan.review.title": "Review the result",
"scan.review.subtitle": "AI is sometimes uncertain you decide. Edit, remove or add before saving.",
"scan.review.uncertain": "Uncertain please check",
"scan.review.approveAll": "Save to inventory",
"scan.review.rejected": "Removed",
"scan.failed": "The analysis failed. Try again or add items manually.",
"myday.title": "My day",
"myday.calories": "Calories",
"myday.protein": "Protein",
"myday.carbs": "Carbohydrates",
"myday.fat": "Fat",
"myday.fiber": "Fiber",
"myday.salt": "Salt",
"myday.remaining": "{kcal} kcal remaining",
"myday.over": "{kcal} kcal over target",
"myday.logMeal": "Log meal",
"myday.noMeals": "No meals logged today.",
"myday.estimateNote": "Values marked with ~ are estimates you can adjust.",
"myday.targetsNote": "Targets are guidance, not medical advice.",
"home.title": "At home",
"home.inventory": "Food inventory",
"home.useSoon": "Use soon",
"home.mealBoxes": "Meal boxes",
"home.shopping": "Shopping list",
"home.budget": "Food budget",
"home.household": "Household",
"home.waste": "Food waste",
"home.emptyInventory": "Your inventory is empty. Scan the fridge or add items manually.",
"home.expiresIn_one": "1 day left",
"home.expiresIn_other": "{days} days left",
"home.expiresToday": "Expires today",
"home.expired": "Expired",
"home.pastBestBefore": "Past best before smell and taste first",
"recipe.portions_one": "1 serving",
"recipe.portions_other": "{count} servings",
"recipe.time": "{min} min",
"recipe.perPortion": "per serving",
"recipe.ingredients": "Ingredients",
"recipe.steps": "Instructions",
"recipe.cook": "Cook now",
"recipe.notSafe": "Doesn't match your dietary settings",
"recipe.substitutions": "Substitute",
"recipe.iCookedThis": "I cooked this",
"recipe.cost": "approx. {amount}/serving",
"cooking.step": "Step {current} of {total}",
"cooking.timer": "Start timer",
"cooking.timerRunning": "{time} left",
"cooking.finish": "Done log the meal",
"cooking.scale": "Servings",
"cooking.keepAwake": "The screen stays awake while you cook",
"cooked.title": "How did it go?",
"cooked.portionsCooked": "Servings cooked",
"cooked.whoAte": "Who ate?",
"cooked.mealBoxes": "Servings to meal boxes",
"cooked.deductPantry": "Deduct ingredients from inventory",
"cooked.rate": "Rate it",
"shopping.title": "Shopping list",
"shopping.addPlaceholder": "Add item …",
"shopping.complete": "Finish shopping trip",
"shopping.completeNote": "Checked items are added to your food inventory.",
"shopping.empty": "The list is empty.",
"shopping.estimated": "approx. {amount}",
"shopping.estimatedTotal": "Estimated total: approx. {amount}",
"mealbox.title": "Meal boxes",
"mealbox.eatBy": "Eat by {date}",
"mealbox.portionsLeft_one": "1 serving left",
"mealbox.portionsLeft_other": "{count} servings left",
"mealbox.eat": "Eat now",
"mealbox.empty": "No meal boxes right now. When you cook, you can save servings here.",
"household.members": "Members",
"household.invite": "Invite with code: {code}",
"household.shared": "Shared in the household: food inventory, shopping list, week plan, meal boxes and budget.",
"household.private": "Private per person: goals, allergies, health data and meal history.",
"memory.title": "What {brand} knows about me",
"memory.subtitle": "Full transparency. Correct what's wrong, pause or delete you own your memory.",
"memory.paused": "Paused",
"memory.verify": "Correct",
"memory.pause": "Pause",
"memory.delete": "Delete",
"memory.deleteAll": "Delete all memory",
"memory.empty": "{brand} hasn't learned anything about you yet.",
"memory.origin.user_stated": "You told us",
"memory.origin.observed": "Observed pattern",
"memory.origin.ai_inferred": "AI assumption",
"paywall.title": "{brand} Premium",
"paywall.subtitle": "The whole household's food OS: unlimited inventory, week planning and sharing.",
"paywall.trialActive_one": "1 day left of your trial",
"paywall.trialActive_other": "{days} days left of your trial",
"paywall.household": "Household · up to 3 people",
"paywall.family": "Family · up to 6 people",
"paywall.large": "Large Household · up to 12 people",
"paywall.perMonth": "{price}/month",
"paywall.fairUse": "Fair-use quota for AI scans included.",
"paywall.restore": "Restore purchases",
"paywall.freeNote": "Free: 10 AI scans/month, manual inventory, saved recipes and simple logging.",
"profile.title": "Profile",
"profile.goals": "Goals & diet",
"profile.consents": "Consents & data",
"profile.memory": "What {brand} knows about me",
"profile.subscription": "Subscription",
"profile.logout": "Log out",
"profile.export": "Export my data",
"profile.deleteAccount": "Delete account",
"profile.language": "Language",
"profile.language.sv": "Svenska",
"profile.language.en": "English",
"auth.forgotLink": "Forgot your password?",
"auth.forgotTitle": "Reset password",
"auth.forgotBody": "Enter your email and we'll send a reset link if the account exists.",
"auth.forgotSubmit": "Send link",
"auth.forgotSentTitle": "Check your inbox",
"auth.forgotSentBody": "If the address has an account, we've sent a link valid for 30 minutes. Open it on this device.",
"auth.backToLogin": "Back to login",
"auth.resetTitle": "Choose a new password",
"auth.resetBody": "The link is valid for 30 minutes and can only be used once. All devices are signed out when the password changes.",
"auth.resetTokenPlaceholder": "Paste the code from the email",
"auth.newPassword": "New password (at least 10 characters)",
"auth.resetSubmit": "Change password",
"auth.resetDoneTitle": "Done!",
"auth.resetDoneBody": "Your password has been changed. Log in with your new password.",
"auth.verifyDoneTitle": "Email confirmed",
"auth.verifyDoneBody": "Thanks! Your account is now verified.",
"auth.verifyFailedTitle": "The link didn't work",
"auth.verifyFailedBody": "The link is invalid or has expired. Request a new one from your profile.",
"profile.emailUnverified": "Email address not confirmed",
"profile.resendVerification": "Resend confirmation email",
"profile.verificationSent": "Sent! Check your inbox.",
"profile.language.es": "Español",
"profile.language.it": "Italiano",
"profile.language.de": "Deutsch",
"profile.language.fr": "Français",
"profile.language.da": "Dansk",
"profile.language.nb": "Norsk",
"profile.language.fi": "Suomi",
"profile.language.nl": "Nederlands",
"profile.language.pl": "Polski",
"profile.language.pt": "Português",
"common.oops": "Oops",
"common.undo": "Undo",
"common.remove": "Remove",
"common.add": "Add",
"common.on": "On",
"common.off": "Off",
"home.thisWeek": "This week",
"home.wasteWeek": "Food waste this week",
"home.useSoonHint": "Best before is about quality smell, look and taste before you throw anything away. Use-by dates, however, should be respected.",
"scan.review.itemPlaceholder": "Item",
"scan.review.quantityPlaceholder": "Amount",
"scan.review.unitPlaceholder": "Unit (g, l, pcs …)",
"scan.review.datePlaceholder": "YYYY-MM-DD (optional)",
"scan.review.dateKindBestBefore": "Best before",
"scan.review.dateKindUseBy": "Use by",
"scan.review.bestBeforeNote": "Quality date the food can be fine well beyond it. Smell and taste before discarding.",
"scan.review.useByNote": "Safety date do not eat after this date.",
"scan.review.addItem": "+ Add item",
"scan.review.unknownItem": "Unknown item",
"barcode.aim": "Point the camera at the barcode",
"barcode.addPrompt": "Add to your inventory?",
"barcode.kcalPer100": "{kcal} kcal/100 g",
"barcode.noNutrition": "No nutrition data",
"barcode.unknownTitle": "Unknown product",
"barcode.unknownBody": "This product isn't in the database yet. Photograph the front and the nutrition label and we'll add it.",
"barcode.photoPackage": "Photograph the package",
"barcode.needHousehold": "You need a household first.",
"barcode.noLocation": "No storage location found.",
"barcode.cameraTitle": "Camera needed",
"barcode.cameraBody": "{brand} needs the camera to scan barcodes.",
"barcode.allowCamera": "Allow camera",
"onboarding.goal.lose_weight": "Lose weight",
"onboarding.goal.build_muscle": "Build muscle",
"onboarding.goal.maintain_weight": "Maintain weight",
"onboarding.goal.more_protein": "Eat more protein",
"onboarding.goal.less_waste": "Reduce food waste",
"onboarding.goal.lower_cost": "Spend less on food",
"onboarding.goal.cook_more": "Cook more at home",
"onboarding.diet.omnivore": "Omnivore",
"onboarding.diet.flexitarian": "Flexitarian",
"onboarding.diet.pescatarian": "Pescatarian",
"onboarding.diet.vegetarian": "Vegetarian",
"onboarding.diet.vegan": "Vegan",
"onboarding.allergen.gluten": "Gluten",
"onboarding.allergen.milk": "Milk/lactose",
"onboarding.allergen.eggs": "Eggs",
"onboarding.allergen.tree_nuts": "Tree nuts",
"onboarding.allergen.peanuts": "Peanuts",
"onboarding.allergen.fish": "Fish",
"onboarding.allergen.crustaceans": "Crustaceans",
"onboarding.allergen.soy": "Soy",
"onboarding.allergen.sesame": "Sesame",
"onboarding.bodyTitle": "For personal calorie targets (optional)",
"onboarding.weightPlaceholder": "Weight (kg)",
"onboarding.heightPlaceholder": "Height (cm)",
"onboarding.birthYearPlaceholder": "Birth year",
"onboarding.householdDefaultName": "Home",
"onboarding.modesCombine": "You can mix modes simple day to day, exact when you want.",
"myday.mealType.breakfast": "Breakfast",
"myday.mealType.lunch": "Lunch",
"myday.mealType.dinner": "Dinner",
"myday.mealType.snack": "Snack",
"myday.mealType.dessert": "Dessert",
"myday.overRecommended": "Above recommended",
"myday.todaysMeals": "Today's meals",
"myday.estimateRange": "{min}{max} kcal, likely {kcal}",
"myday.kcalProtein": "{kcal} kcal · {protein} g protein",
"logmeal.mealTypeTitle": "Meal type",
"logmeal.quickTitle": "Quick re-log",
"logmeal.quickSubtitle": "Logs the same nutrition values as last time.",
"logmeal.manualTitle": "Manual",
"logmeal.whatPlaceholder": "What did you eat?",
"logmeal.proteinPlaceholder": "protein (g)",
"logmeal.validationTitle": "Missing info",
"logmeal.validationBody": "Name and calories are required for manual logging.",
"logmeal.photoTip": "Tip: photograph your plate under Scan and the app estimates for you you always confirm.",
"shopping.section.frukt_gront": "Fruit & veg",
"shopping.section.brod": "Bread",
"shopping.section.mejeri": "Dairy",
"shopping.section.kott_fagel": "Meat & poultry",
"shopping.section.fisk": "Fish",
"shopping.section.chark": "Deli & cold cuts",
"shopping.section.frys": "Frozen",
"shopping.section.skafferi": "Pantry",
"shopping.section.konserver": "Canned goods",
"shopping.section.kryddor_bak": "Spices & baking",
"shopping.section.dryck": "Drinks",
"shopping.section.snacks": "Snacks",
"shopping.section.hygien_ovrigt": "Other",
"shopping.completedTitle": "Done!",
"household.role.owner": "Owner",
"household.role.adult": "Adult",
"household.role.member": "Member",
"household.role.child": "Child",
"household.empty": "You don't have a household yet. Create one in onboarding or join with a code.",
"household.shareCode": "Share the code with your family to share inventory, list and plan.",
"household.locations": "Storage locations",
"household.portionFactor": "×{factor} portion",
"mealbox.enjoyTitle": "Enjoy your meal!",
"mealbox.enjoyBody": "The portion is logged in My day.",
"mealbox.guidanceNote": "The recommended use-by is guidance trust smell and taste.",
"paywall.soonTitle": "Coming soon!",
"paywall.soonBody": "Purchases activate via App Store/Google Play once store integration is switched on (phase 7). The backend flow is already done.",
"paywall.popular": "Most popular",
"paywall.choose": "Choose",
"recipe.saved": "Saved",
"recipe.save": "Save",
"recipe.avgRating": "Average {avg} from {count} ratings",
"memory.verified": "Verified",
"memory.resume": "Resume",
"memory.deleteAllTitle": "Delete all memory?",
"memory.irreversible": "This cannot be undone.",
"profile.consent.personalization": "Personal features (memory & taste profile)",
"profile.consent.anonymized_improvement": "Anonymised AI improvement",
"profile.consent.image_training": "My photos may be used for training",
"profile.consent.health_integration": "Health data (Apple Health / Health Connect)",
"profile.consent.location_weather": "Location for weather-based suggestions",
"profile.consent.push_notifications": "Push notifications",
"profile.modeLabel": "Mode:",
"profile.memorySubtitle": "See, correct, pause or delete what the platform has learned.",
"profile.consentsNote": "Consents are separate personal features never require training consent.",
"profile.aiScansUsed": "AI scans: {used} / {total} this month",
"profile.deleteTitle": "Delete your account?",
"profile.deleteBody": "All your data is permanently deleted in line with GDPR.",
"cooking.timerDone": "Done!",
"cooked.deductPantryNote": "Ingredients are deducted from your inventory, first-expiring first.",
"home.moreItems_one": "+ 1 more …",
"home.moreItems_other": "+ {count} more …",
"shopping.completedBody_one": "1 item was added to your inventory.",
"shopping.completedBody_other": "{count} items were added to your inventory."
}
+338
View File
@@ -0,0 +1,338 @@
{
"common.loading": "Cargando …",
"common.error": "Algo salió mal. Inténtalo de nuevo.",
"common.retry": "Reintentar",
"common.save": "Guardar",
"common.cancel": "Cancelar",
"common.next": "Siguiente",
"common.back": "Atrás",
"common.done": "Listo",
"common.skip": "Omitir",
"common.offline": "Sin conexión mostrando los últimos datos",
"common.estimate": "Estimación",
"tabs.whatToEat": "¿Qué comemos?",
"tabs.scan": "Escanear",
"tabs.myDay": "Mi día",
"tabs.home": "En casa",
"auth.login": "Iniciar sesión",
"auth.register": "Crear cuenta",
"auth.email": "Correo electrónico",
"auth.password": "Contraseña",
"auth.displayName": "¿Cómo te llamamos?",
"auth.noAccount": "¿Nuevo aquí? Crea una cuenta",
"auth.hasAccount": "¿Ya tienes cuenta? Inicia sesión",
"auth.trialNote": "7 días de acceso completo sin tarjeta.",
"auth.forgotLink": "¿Olvidaste tu contraseña?",
"auth.forgotTitle": "Restablecer contraseña",
"auth.forgotBody": "Introduce tu correo y te enviaremos un enlace si la cuenta existe.",
"auth.forgotSubmit": "Enviar enlace",
"auth.forgotSentTitle": "Revisa tu bandeja de entrada",
"auth.forgotSentBody": "Si la dirección tiene una cuenta, hemos enviado un enlace válido durante 30 minutos. Ábrelo en este dispositivo.",
"auth.backToLogin": "Volver a iniciar sesión",
"auth.resetTitle": "Elige una nueva contraseña",
"auth.resetBody": "El enlace es válido durante 30 minutos y solo se puede usar una vez. Todos los dispositivos se desconectarán.",
"auth.resetTokenPlaceholder": "Pega el código del correo",
"auth.newPassword": "Nueva contraseña (mínimo 10 caracteres)",
"auth.resetSubmit": "Cambiar contraseña",
"auth.resetDoneTitle": "¡Listo!",
"auth.resetDoneBody": "Tu contraseña se ha cambiado. Inicia sesión con la nueva.",
"auth.verifyDoneTitle": "Correo confirmado",
"auth.verifyDoneBody": "¡Gracias! Tu cuenta está verificada.",
"auth.verifyFailedTitle": "El enlace no funcionó",
"auth.verifyFailedBody": "El enlace no es válido o ha caducado. Solicita uno nuevo desde tu perfil.",
"onboarding.title": "Cuéntanos un poco sobre ti",
"onboarding.subtitle": "Todo es opcional y se puede cambiar cuando quieras. Cuanto más completes, mejores serán las sugerencias.",
"onboarding.goal": "¿Cuál es tu objetivo principal?",
"onboarding.diet": "¿Cómo comes?",
"onboarding.allergies": "Alergias e intolerancias",
"onboarding.allergyNote": "El filtro de alergias es siempre estricto: los platos con tus alérgenos nunca se muestran.",
"onboarding.household": "Tu hogar",
"onboarding.householdCreate": "Crear hogar",
"onboarding.householdJoin": "Unirse con un código",
"onboarding.householdName": "Nombre del hogar",
"onboarding.inviteCode": "Código de invitación",
"onboarding.mode": "¿Cuánta precisión quieres?",
"onboarding.modeSimple": "Modo sencillo",
"onboarding.modeSimpleDesc": "Fotografía tu comida, acepta estimaciones, sin complicaciones.",
"onboarding.modeExact": "Modo exacto",
"onboarding.modeExactDesc": "Pesa la comida, introduce gramos, control total de las cifras.",
"onboarding.notMedical": "{brand} ofrece orientación, no consejo médico.",
"wte.title": "¿Qué comemos?",
"wte.subtitle": "Según lo que tenéis en casa, lo que conviene usar pronto y lo que os gusta.",
"wte.cravingPlaceholder": "Me apetece … (p. ej. cremoso, asiático, menos de 500 kcal)",
"wte.mealBoxFirst": "Comida preparada en casa",
"wte.whyTitle": "¿Por qué esta sugerencia?",
"wte.coverage": "{pct}% en casa",
"wte.missing": "Falta: {items}",
"wte.empty": "Aún no hay sugerencias: añade comida al inventario o relaja los filtros.",
"wte.refresh": "Nuevas sugerencias",
"scan.title": "Escanear",
"scan.subtitle": "Llena tu inventario o registra una comida con la cámara.",
"scan.fridge": "Nevera",
"scan.freezer": "Congelador",
"scan.pantry": "Despensa",
"scan.ingredients": "Ingredientes",
"scan.plate": "Plato",
"scan.receipt": "Ticket",
"scan.barcode": "Código de barras",
"scan.expiry": "Fecha de consumo preferente",
"scan.nutrition": "Etiqueta nutricional",
"scan.takePhoto": "Hacer foto",
"scan.tips.title": "Cómo obtener el mejor análisis",
"scan.tips.overview": "Empieza con una foto general",
"scan.tips.shelf": "Fotografía estante por estante",
"scan.tips.light": "Evita la oscuridad y las sombras",
"scan.tips.move": "Aparta los productos que se tapan entre sí",
"scan.analyzing": "Analizando …",
"scan.quotaLeft_one": "1 escaneo de IA restante este mes",
"scan.quotaLeft_other": "{count} escaneos de IA restantes este mes",
"scan.review.title": "Revisa el resultado",
"scan.review.subtitle": "La IA a veces duda: tú decides. Edita, elimina o añade antes de guardar.",
"scan.review.uncertain": "Dudoso compruébalo",
"scan.review.approveAll": "Guardar en el inventario",
"scan.review.rejected": "Eliminado",
"scan.failed": "El análisis falló. Inténtalo de nuevo o añade manualmente.",
"myday.title": "Mi día",
"myday.calories": "Calorías",
"myday.protein": "Proteínas",
"myday.carbs": "Hidratos",
"myday.fat": "Grasas",
"myday.fiber": "Fibra",
"myday.salt": "Sal",
"myday.remaining": "{kcal} kcal restantes",
"myday.over": "{kcal} kcal por encima del objetivo",
"myday.logMeal": "Registrar comida",
"myday.noMeals": "No hay comidas registradas hoy.",
"myday.estimateNote": "Los valores con ~ son estimaciones que puedes ajustar.",
"myday.targetsNote": "Los objetivos son orientación, no consejo médico.",
"home.title": "En casa",
"home.inventory": "Inventario",
"home.useSoon": "Usar pronto",
"home.mealBoxes": "Táperes",
"home.shopping": "Lista de la compra",
"home.budget": "Presupuesto",
"home.household": "Hogar",
"home.waste": "Desperdicio",
"home.emptyInventory": "El inventario está vacío. Escanea la nevera o añade productos manualmente.",
"home.expiresIn_one": "Queda 1 día",
"home.expiresIn_other": "Quedan {days} días",
"home.expiresToday": "Caduca hoy",
"home.expired": "Caducado",
"home.pastBestBefore": "Pasada la fecha preferente: huele y prueba primero",
"recipe.portions_one": "1 ración",
"recipe.portions_other": "{count} raciones",
"recipe.time": "{min} min",
"recipe.perPortion": "por ración",
"recipe.ingredients": "Ingredientes",
"recipe.steps": "Preparación",
"recipe.cook": "Cocinar ahora",
"recipe.notSafe": "No encaja con tu configuración dietética",
"recipe.substitutions": "Sustituir",
"recipe.iCookedThis": "Lo he cocinado",
"recipe.cost": "aprox. {amount}/ración",
"cooking.step": "Paso {current} de {total}",
"cooking.timer": "Iniciar temporizador",
"cooking.timerRunning": "Quedan {time}",
"cooking.finish": "Hecho registrar la comida",
"cooking.scale": "Raciones",
"cooking.keepAwake": "La pantalla permanece encendida mientras cocinas",
"cooked.title": "¿Qué tal ha ido?",
"cooked.portionsCooked": "Raciones cocinadas",
"cooked.whoAte": "¿Quiénes comieron?",
"cooked.mealBoxes": "Raciones para táperes",
"cooked.deductPantry": "Descontar ingredientes del inventario",
"cooked.rate": "Valorar",
"shopping.title": "Lista de la compra",
"shopping.addPlaceholder": "Añadir producto …",
"shopping.complete": "Terminar la compra",
"shopping.completeNote": "Los productos marcados se añaden al inventario.",
"shopping.empty": "La lista está vacía.",
"shopping.estimated": "aprox. {amount}",
"shopping.estimatedTotal": "Total estimado: aprox. {amount}",
"mealbox.title": "Táperes",
"mealbox.eatBy": "Consumir antes del {date}",
"mealbox.portionsLeft_one": "Queda 1 ración",
"mealbox.portionsLeft_other": "Quedan {count} raciones",
"mealbox.eat": "Comer ahora",
"mealbox.empty": "No hay táperes ahora mismo. Al cocinar puedes guardar raciones aquí.",
"household.members": "Miembros",
"household.invite": "Invita con el código: {code}",
"household.shared": "Compartido en el hogar: inventario, lista de la compra, plan semanal, táperes y presupuesto.",
"household.private": "Privado por persona: objetivos, alergias, datos de salud e historial de comidas.",
"memory.title": "Lo que {brand} sabe de mí",
"memory.subtitle": "Transparencia total. Corrige lo que esté mal, pausa o elimina: tu memoria es tuya.",
"memory.paused": "En pausa",
"memory.verify": "Correcto",
"memory.pause": "Pausar",
"memory.delete": "Eliminar",
"memory.deleteAll": "Eliminar toda la memoria",
"memory.empty": "{brand} aún no ha aprendido nada sobre ti.",
"memory.origin.user_stated": "Nos lo contaste",
"memory.origin.observed": "Patrón observado",
"memory.origin.ai_inferred": "Suposición de la IA",
"paywall.title": "{brand} Premium",
"paywall.subtitle": "El sistema operativo de comida de todo el hogar: inventario ilimitado, plan semanal y compartir.",
"paywall.trialActive_one": "Queda 1 día de prueba",
"paywall.trialActive_other": "Quedan {days} días de prueba",
"paywall.household": "Household · hasta 3 personas",
"paywall.family": "Family · hasta 6 personas",
"paywall.large": "Large Household · hasta 12 personas",
"paywall.perMonth": "{price}/mes",
"paywall.fairUse": "Cuota de uso razonable de escaneos de IA incluida.",
"paywall.restore": "Restaurar compras",
"paywall.freeNote": "Gratis: 10 escaneos de IA/mes, inventario manual, recetas guardadas y registro sencillo.",
"profile.title": "Perfil",
"profile.goals": "Objetivos y dieta",
"profile.consents": "Consentimientos y datos",
"profile.memory": "Lo que {brand} sabe de mí",
"profile.subscription": "Suscripción",
"profile.logout": "Cerrar sesión",
"profile.export": "Exportar mis datos",
"profile.deleteAccount": "Eliminar cuenta",
"profile.language": "Idioma",
"profile.language.sv": "Svenska",
"profile.language.en": "English",
"profile.emailUnverified": "Correo electrónico sin confirmar",
"profile.resendVerification": "Reenviar correo de confirmación",
"profile.verificationSent": "¡Enviado! Revisa tu bandeja de entrada.",
"profile.language.es": "Español",
"profile.language.it": "Italiano",
"profile.language.de": "Deutsch",
"profile.language.fr": "Français",
"profile.language.da": "Dansk",
"profile.language.nb": "Norsk",
"profile.language.fi": "Suomi",
"profile.language.nl": "Nederlands",
"profile.language.pl": "Polski",
"profile.language.pt": "Português",
"common.oops": "Vaya",
"common.undo": "Deshacer",
"common.remove": "Quitar",
"common.add": "Añadir",
"common.on": "Activado",
"common.off": "Desactivado",
"home.thisWeek": "Esta semana",
"home.wasteWeek": "Desperdicio esta semana",
"home.useSoonHint": "La fecha de consumo preferente es de calidad: huele, mira y prueba antes de tirar nada. La fecha de caducidad, en cambio, debe respetarse.",
"scan.review.itemPlaceholder": "Producto",
"scan.review.quantityPlaceholder": "Cantidad",
"scan.review.unitPlaceholder": "Unidad (g, l, uds …)",
"scan.review.datePlaceholder": "AAAA-MM-DD (opcional)",
"scan.review.dateKindBestBefore": "Consumo preferente",
"scan.review.dateKindUseBy": "Fecha de caducidad",
"scan.review.bestBeforeNote": "Fecha de calidad: el alimento puede estar bien más allá. Huele y prueba antes de tirar.",
"scan.review.useByNote": "Fecha de seguridad: no lo comas después de esta fecha.",
"scan.review.addItem": "+ Añadir producto",
"scan.review.unknownItem": "Producto desconocido",
"barcode.aim": "Apunta la cámara al código de barras",
"barcode.addPrompt": "¿Añadir al inventario?",
"barcode.kcalPer100": "{kcal} kcal/100 g",
"barcode.noNutrition": "Sin datos nutricionales",
"barcode.unknownTitle": "Producto desconocido",
"barcode.unknownBody": "Este producto aún no está en la base de datos. Fotografía el frontal y la etiqueta nutricional y lo añadiremos.",
"barcode.photoPackage": "Fotografiar el envase",
"barcode.needHousehold": "Primero necesitas un hogar.",
"barcode.noLocation": "No se encontró ningún lugar de almacenamiento.",
"barcode.cameraTitle": "Se necesita la cámara",
"barcode.cameraBody": "{brand} necesita la cámara para escanear códigos de barras.",
"barcode.allowCamera": "Permitir cámara",
"onboarding.goal.lose_weight": "Perder peso",
"onboarding.goal.build_muscle": "Ganar músculo",
"onboarding.goal.maintain_weight": "Mantener el peso",
"onboarding.goal.more_protein": "Comer más proteína",
"onboarding.goal.less_waste": "Reducir el desperdicio",
"onboarding.goal.lower_cost": "Gastar menos en comida",
"onboarding.goal.cook_more": "Cocinar más en casa",
"onboarding.diet.omnivore": "Omnívoro",
"onboarding.diet.flexitarian": "Flexitariano",
"onboarding.diet.pescatarian": "Pescetariano",
"onboarding.diet.vegetarian": "Vegetariano",
"onboarding.diet.vegan": "Vegano",
"onboarding.allergen.gluten": "Gluten",
"onboarding.allergen.milk": "Leche/lactosa",
"onboarding.allergen.eggs": "Huevo",
"onboarding.allergen.tree_nuts": "Frutos de cáscara",
"onboarding.allergen.peanuts": "Cacahuetes",
"onboarding.allergen.fish": "Pescado",
"onboarding.allergen.crustaceans": "Crustáceos",
"onboarding.allergen.soy": "Soja",
"onboarding.allergen.sesame": "Sésamo",
"onboarding.bodyTitle": "Para objetivos calóricos personales (opcional)",
"onboarding.weightPlaceholder": "Peso (kg)",
"onboarding.heightPlaceholder": "Altura (cm)",
"onboarding.birthYearPlaceholder": "Año de nacimiento",
"onboarding.householdDefaultName": "Casa",
"onboarding.modesCombine": "Puedes combinar los modos: sencillo a diario, exacto cuando quieras.",
"myday.mealType.breakfast": "Desayuno",
"myday.mealType.lunch": "Almuerzo",
"myday.mealType.dinner": "Cena",
"myday.mealType.snack": "Tentempié",
"myday.mealType.dessert": "Postre",
"myday.overRecommended": "Por encima de lo recomendado",
"myday.todaysMeals": "Comidas de hoy",
"myday.estimateRange": "{min}{max} kcal, probablemente {kcal}",
"myday.kcalProtein": "{kcal} kcal · {protein} g de proteína",
"logmeal.mealTypeTitle": "Tipo de comida",
"logmeal.quickTitle": "Registro rápido",
"logmeal.quickSubtitle": "Registra los mismos valores nutricionales que la última vez.",
"logmeal.manualTitle": "Manual",
"logmeal.whatPlaceholder": "¿Qué has comido?",
"logmeal.proteinPlaceholder": "proteína (g)",
"logmeal.validationTitle": "Faltan datos",
"logmeal.validationBody": "Para el registro manual se necesitan nombre y calorías.",
"logmeal.photoTip": "Consejo: fotografía el plato en Escanear y la app lo estimará por ti; tú siempre confirmas.",
"shopping.section.frukt_gront": "Fruta y verdura",
"shopping.section.brod": "Pan",
"shopping.section.mejeri": "Lácteos",
"shopping.section.kott_fagel": "Carne y aves",
"shopping.section.fisk": "Pescado",
"shopping.section.chark": "Charcutería",
"shopping.section.frys": "Congelados",
"shopping.section.skafferi": "Despensa",
"shopping.section.konserver": "Conservas",
"shopping.section.kryddor_bak": "Especias y repostería",
"shopping.section.dryck": "Bebidas",
"shopping.section.snacks": "Aperitivos",
"shopping.section.hygien_ovrigt": "Otros",
"shopping.completedTitle": "¡Listo!",
"household.role.owner": "Propietario",
"household.role.adult": "Adulto",
"household.role.member": "Miembro",
"household.role.child": "Niño",
"household.empty": "Aún no tienes un hogar. Crea uno en la configuración inicial o únete con un código.",
"household.shareCode": "Comparte el código con tu familia para compartir inventario, lista y plan.",
"household.locations": "Lugares de almacenamiento",
"household.portionFactor": "×{factor} ración",
"mealbox.enjoyTitle": "¡Buen provecho!",
"mealbox.enjoyBody": "La ración se ha registrado en Mi día.",
"mealbox.guidanceNote": "El plazo recomendado es orientativo: confía en el olfato y el gusto.",
"paywall.soonTitle": "¡Pronto!",
"paywall.soonBody": "Las compras se activarán a través del App Store/Google Play cuando se active la integración con las tiendas (fase 7). El flujo de backend ya está listo.",
"paywall.popular": "El más popular",
"paywall.choose": "Elegir",
"recipe.saved": "Guardada",
"recipe.save": "Guardar",
"recipe.avgRating": "Media de {avg} con {count} valoraciones",
"memory.verified": "Verificado",
"memory.resume": "Reanudar",
"memory.deleteAllTitle": "¿Borrar toda la memoria?",
"memory.irreversible": "Esto no se puede deshacer.",
"profile.consent.personalization": "Funciones personales (memoria y perfil de gustos)",
"profile.consent.anonymized_improvement": "Mejora anonimizada de la IA",
"profile.consent.image_training": "Mis fotos pueden usarse para entrenamiento",
"profile.consent.health_integration": "Datos de salud (Apple Health / Health Connect)",
"profile.consent.location_weather": "Ubicación para sugerencias según el tiempo",
"profile.consent.push_notifications": "Notificaciones push",
"profile.modeLabel": "Modo:",
"profile.memorySubtitle": "Ve, corrige, pausa o borra lo que la plataforma ha aprendido.",
"profile.consentsNote": "Los consentimientos son independientes: las funciones personales nunca exigen consentimiento de entrenamiento.",
"profile.aiScansUsed": "Escaneos de IA: {used} / {total} este mes",
"profile.deleteTitle": "¿Borrar la cuenta?",
"profile.deleteBody": "Todos tus datos se eliminarán de forma permanente conforme al RGPD.",
"cooking.timerDone": "¡Listo!",
"cooked.deductPantryNote": "Los ingredientes se descuentan del inventario: primero los que caducan antes.",
"home.moreItems_one": "+ 1 más …",
"home.moreItems_other": "+ {count} más …",
"shopping.completedBody_one": "Se añadió 1 producto al inventario.",
"shopping.completedBody_other": "Se añadieron {count} productos al inventario."
}
+338
View File
@@ -0,0 +1,338 @@
{
"common.loading": "Ladataan …",
"common.error": "Jokin meni pieleen. Yritä uudelleen.",
"common.retry": "Yritä uudelleen",
"common.save": "Tallenna",
"common.cancel": "Peruuta",
"common.next": "Seuraava",
"common.back": "Takaisin",
"common.done": "Valmis",
"common.skip": "Ohita",
"common.offline": "Offline näytetään viimeksi haetut tiedot",
"common.estimate": "Arvio",
"tabs.whatToEat": "Mitä syödään?",
"tabs.scan": "Skannaa",
"tabs.myDay": "Minun päiväni",
"tabs.home": "Kotona",
"auth.login": "Kirjaudu",
"auth.register": "Luo tili",
"auth.email": "Sähköposti",
"auth.password": "Salasana",
"auth.displayName": "Miksi kutsumme sinua?",
"auth.noAccount": "Uusi täällä? Luo tili",
"auth.hasAccount": "Onko sinulla jo tili? Kirjaudu",
"auth.trialNote": "7 päivää täyttä käyttöä ilman korttia.",
"auth.forgotLink": "Unohtuiko salasana?",
"auth.forgotTitle": "Nollaa salasana",
"auth.forgotBody": "Anna sähköpostiosoitteesi, niin lähetämme linkin, jos tili on olemassa.",
"auth.forgotSubmit": "Lähetä linkki",
"auth.forgotSentTitle": "Tarkista sähköpostisi",
"auth.forgotSentBody": "Jos osoitteella on tili, lähetimme linkin, joka on voimassa 30 minuuttia. Avaa se tällä laitteella.",
"auth.backToLogin": "Takaisin kirjautumiseen",
"auth.resetTitle": "Valitse uusi salasana",
"auth.resetBody": "Linkki on voimassa 30 minuuttia ja sitä voi käyttää vain kerran. Kaikki laitteet kirjataan ulos.",
"auth.resetTokenPlaceholder": "Liitä koodi sähköpostista",
"auth.newPassword": "Uusi salasana (vähintään 10 merkkiä)",
"auth.resetSubmit": "Vaihda salasana",
"auth.resetDoneTitle": "Valmis!",
"auth.resetDoneBody": "Salasanasi on vaihdettu. Kirjaudu uudella salasanalla.",
"auth.verifyDoneTitle": "Sähköposti vahvistettu",
"auth.verifyDoneBody": "Kiitos! Tilisi on nyt vahvistettu.",
"auth.verifyFailedTitle": "Linkki ei toiminut",
"auth.verifyFailedBody": "Linkki on virheellinen tai vanhentunut. Pyydä uusi profiilista.",
"onboarding.title": "Kerro hieman itsestäsi",
"onboarding.subtitle": "Kaikki on vapaaehtoista ja muutettavissa milloin tahansa. Mitä enemmän täytät, sitä paremmat ehdotukset.",
"onboarding.goal": "Mikä on tärkein tavoitteesi?",
"onboarding.diet": "Miten syöt?",
"onboarding.allergies": "Allergiat ja intoleranssit",
"onboarding.allergyNote": "Allergiasuodatus on aina tiukka ruokia, joissa on allergeenejasi, ei koskaan näytetä.",
"onboarding.household": "Kotitaloutesi",
"onboarding.householdCreate": "Luo kotitalous",
"onboarding.householdJoin": "Liity koodilla",
"onboarding.householdName": "Kotitalouden nimi",
"onboarding.inviteCode": "Kutsukoodi",
"onboarding.mode": "Kuinka tarkka haluat olla?",
"onboarding.modeSimple": "Helppo tila",
"onboarding.modeSimpleDesc": "Kuvaa ruokasi, hyväksy arviot, mahdollisimman vaivatonta.",
"onboarding.modeExact": "Tarkka tila",
"onboarding.modeExactDesc": "Punnitse ruoka, syötä grammat, täysi hallinta lukuihin.",
"onboarding.notMedical": "{brand} antaa ohjeita ei lääketieteellisiä neuvoja.",
"wte.title": "Mitä syödään?",
"wte.subtitle": "Sen mukaan mitä kotona on, mikä pitäisi käyttää pian ja mistä pidätte.",
"wte.cravingPlaceholder": "Tekisi mieli … (esim. kermaista, aasialaista, alle 500 kcal)",
"wte.mealBoxFirst": "Valmista ruokaa kotona",
"wte.whyTitle": "Miksi tämä ehdotus?",
"wte.coverage": "{pct} % kotona",
"wte.missing": "Puuttuu: {items}",
"wte.empty": "Ei vielä ehdotuksia lisää ruokaa varastoon tai löysää suodattimia.",
"wte.refresh": "Uudet ehdotukset",
"scan.title": "Skannaa",
"scan.subtitle": "Täytä ruokavarasto tai kirjaa ateria kameralla.",
"scan.fridge": "Jääkaappi",
"scan.freezer": "Pakastin",
"scan.pantry": "Ruokakomero",
"scan.ingredients": "Ainekset",
"scan.plate": "Lautanen",
"scan.receipt": "Kuitti",
"scan.barcode": "Viivakoodi",
"scan.expiry": "Parasta ennen -päiväys",
"scan.nutrition": "Ravintosisältö",
"scan.takePhoto": "Ota kuva",
"scan.tips.title": "Näin analyysi onnistuu parhaiten",
"scan.tips.overview": "Aloita yleiskuvalla",
"scan.tips.shelf": "Kuvaa hylly kerrallaan",
"scan.tips.light": "Vältä pimeyttä ja varjoja",
"scan.tips.move": "Siirrä toisiaan peittävät tuotteet",
"scan.analyzing": "Analysoidaan …",
"scan.quotaLeft_one": "1 AI-skannaus jäljellä tässä kuussa",
"scan.quotaLeft_other": "{count} AI-skannausta jäljellä tässä kuussa",
"scan.review.title": "Tarkista tulos",
"scan.review.subtitle": "AI on joskus epävarma sinä päätät. Muokkaa, poista tai lisää ennen tallennusta.",
"scan.review.uncertain": "Epävarma tarkista",
"scan.review.approveAll": "Tallenna varastoon",
"scan.review.rejected": "Poistettu",
"scan.failed": "Analyysi epäonnistui. Yritä uudelleen tai lisää käsin.",
"myday.title": "Minun päiväni",
"myday.calories": "Kalorit",
"myday.protein": "Proteiini",
"myday.carbs": "Hiilihydraatit",
"myday.fat": "Rasva",
"myday.fiber": "Kuitu",
"myday.salt": "Suola",
"myday.remaining": "{kcal} kcal jäljellä",
"myday.over": "{kcal} kcal yli tavoitteen",
"myday.logMeal": "Kirjaa ateria",
"myday.noMeals": "Ei kirjattuja aterioita tänään.",
"myday.estimateNote": "Arvot, joissa on ~, ovat arvioita, joita voit säätää.",
"myday.targetsNote": "Tavoitteet ovat ohjeellisia, eivät lääketieteellisiä neuvoja.",
"home.title": "Kotona",
"home.inventory": "Ruokavarasto",
"home.useSoon": "Käytä pian",
"home.mealBoxes": "Eväsrasiat",
"home.shopping": "Ostoslista",
"home.budget": "Ruokabudjetti",
"home.household": "Kotitalous",
"home.waste": "Ruokahävikki",
"home.emptyInventory": "Varasto on tyhjä. Skannaa jääkaappi tai lisää tuotteita käsin.",
"home.expiresIn_one": "1 päivä jäljellä",
"home.expiresIn_other": "{days} päivää jäljellä",
"home.expiresToday": "Vanhenee tänään",
"home.expired": "Vanhentunut",
"home.pastBestBefore": "Parasta ennen ylitetty haista ja maista ensin",
"recipe.portions_one": "1 annos",
"recipe.portions_other": "{count} annosta",
"recipe.time": "{min} min",
"recipe.perPortion": "per annos",
"recipe.ingredients": "Ainekset",
"recipe.steps": "Valmistus",
"recipe.cook": "Kokkaa nyt",
"recipe.notSafe": "Ei sovi ruokavalioasetuksiisi",
"recipe.substitutions": "Korvaa",
"recipe.iCookedThis": "Tein tämän",
"recipe.cost": "n. {amount}/annos",
"cooking.step": "Vaihe {current}/{total}",
"cooking.timer": "Käynnistä ajastin",
"cooking.timerRunning": "{time} jäljellä",
"cooking.finish": "Valmis kirjaa ateria",
"cooking.scale": "Annokset",
"cooking.keepAwake": "Näyttö pysyy päällä, kun kokkaat",
"cooked.title": "Miten meni?",
"cooked.portionsCooked": "Valmistetut annokset",
"cooked.whoAte": "Ketkä söivät?",
"cooked.mealBoxes": "Annoksia eväsrasioihin",
"cooked.deductPantry": "Vähennä ainekset varastosta",
"cooked.rate": "Arvioi",
"shopping.title": "Ostoslista",
"shopping.addPlaceholder": "Lisää tuote …",
"shopping.complete": "Päätä ostosreissu",
"shopping.completeNote": "Merkityt tuotteet lisätään ruokavarastoon.",
"shopping.empty": "Lista on tyhjä.",
"shopping.estimated": "n. {amount}",
"shopping.estimatedTotal": "Arvioitu yhteensä: n. {amount}",
"mealbox.title": "Eväsrasiat",
"mealbox.eatBy": "Syö viimeistään {date}",
"mealbox.portionsLeft_one": "1 annos jäljellä",
"mealbox.portionsLeft_other": "{count} annosta jäljellä",
"mealbox.eat": "Syö nyt",
"mealbox.empty": "Ei eväsrasioita juuri nyt. Kokatessasi voit tallentaa annoksia tänne.",
"household.members": "Jäsenet",
"household.invite": "Kutsu koodilla: {code}",
"household.shared": "Jaettua kotitaloudessa: ruokavarasto, ostoslista, viikkosuunnitelma, eväsrasiat ja budjetti.",
"household.private": "Yksityistä per henkilö: tavoitteet, allergiat, terveystiedot ja ateriahistoria.",
"memory.title": "Mitä {brand} tietää minusta",
"memory.subtitle": "Täysi läpinäkyvyys. Korjaa virheet, keskeytä tai poista omistat muistisi.",
"memory.paused": "Keskeytetty",
"memory.verify": "Oikein",
"memory.pause": "Keskeytä",
"memory.delete": "Poista",
"memory.deleteAll": "Poista koko muisti",
"memory.empty": "{brand} ei ole vielä oppinut sinusta mitään.",
"memory.origin.user_stated": "Kerroit itse",
"memory.origin.observed": "Havaittu tapa",
"memory.origin.ai_inferred": "AI:n oletus",
"paywall.title": "{brand} Premium",
"paywall.subtitle": "Koko kotitalouden ruoka-OS: rajaton varasto, viikkosuunnitelma ja jakaminen.",
"paywall.trialActive_one": "1 päivä kokeilua jäljellä",
"paywall.trialActive_other": "{days} päivää kokeilua jäljellä",
"paywall.household": "Household · enintään 3 henkilöä",
"paywall.family": "Family · enintään 6 henkilöä",
"paywall.large": "Large Household · enintään 12 henkilöä",
"paywall.perMonth": "{price}/kk",
"paywall.fairUse": "Kohtuukäytön AI-skannauskiintiö sisältyy.",
"paywall.restore": "Palauta ostot",
"paywall.freeNote": "Ilmainen: 10 AI-skannausta/kk, manuaalinen varasto, tallennetut reseptit ja helppo kirjaus.",
"profile.title": "Profiili",
"profile.goals": "Tavoitteet & ruokavalio",
"profile.consents": "Suostumukset & tiedot",
"profile.memory": "Mitä {brand} tietää minusta",
"profile.subscription": "Tilaus",
"profile.logout": "Kirjaudu ulos",
"profile.export": "Vie tietoni",
"profile.deleteAccount": "Poista tili",
"profile.language": "Kieli",
"profile.emailUnverified": "Sähköpostia ei ole vahvistettu",
"profile.resendVerification": "Lähetä vahvistusviesti uudelleen",
"profile.verificationSent": "Lähetetty! Tarkista sähköpostisi.",
"profile.language.sv": "Svenska",
"profile.language.en": "English",
"profile.language.es": "Español",
"profile.language.it": "Italiano",
"profile.language.de": "Deutsch",
"profile.language.fr": "Français",
"profile.language.da": "Dansk",
"profile.language.nb": "Norsk",
"profile.language.fi": "Suomi",
"profile.language.nl": "Nederlands",
"profile.language.pl": "Polski",
"profile.language.pt": "Português",
"common.oops": "Hups",
"common.undo": "Kumoa",
"common.remove": "Poista",
"common.add": "Lisää",
"common.on": "Päällä",
"common.off": "Pois",
"home.thisWeek": "Tällä viikolla",
"home.wasteWeek": "Hävikki tällä viikolla",
"home.useSoonHint": "Parasta ennen koskee laatua haista, katso ja maista ennen kuin heität pois. Viimeistä käyttöpäivää sen sijaan on noudatettava.",
"scan.review.itemPlaceholder": "Tuote",
"scan.review.quantityPlaceholder": "Määrä",
"scan.review.unitPlaceholder": "Yksikkö (g, l, kpl …)",
"scan.review.datePlaceholder": "VVVV-KK-PP (valinnainen)",
"scan.review.dateKindBestBefore": "Parasta ennen",
"scan.review.dateKindUseBy": "Viimeinen käyttöpäivä",
"scan.review.bestBeforeNote": "Laatupäivä tuote voi olla hyvää senkin jälkeen. Haista ja maista ennen poisheittoa.",
"scan.review.useByNote": "Turvallisuusraja älä syö tämän päivän jälkeen.",
"scan.review.addItem": "+ Lisää tuote",
"scan.review.unknownItem": "Tuntematon tuote",
"barcode.aim": "Suuntaa kamera viivakoodiin",
"barcode.addPrompt": "Lisätäänkö ruokavarastoon?",
"barcode.kcalPer100": "{kcal} kcal/100 g",
"barcode.noNutrition": "Ei ravintotietoja",
"barcode.unknownTitle": "Tuntematon tuote",
"barcode.unknownBody": "Tuotetta ei ole vielä tietokannassa. Kuvaa etupuoli ja ravintosisältö, niin lisäämme sen.",
"barcode.photoPackage": "Kuvaa pakkaus",
"barcode.needHousehold": "Tarvitset ensin kotitalouden.",
"barcode.noLocation": "Säilytyspaikkaa ei löytynyt.",
"barcode.cameraTitle": "Kamera tarvitaan",
"barcode.cameraBody": "{brand} tarvitsee kameraa viivakoodien skannaamiseen.",
"barcode.allowCamera": "Salli kamera",
"onboarding.goal.lose_weight": "Laihduttaa",
"onboarding.goal.build_muscle": "Kasvattaa lihasta",
"onboarding.goal.maintain_weight": "Pitää painon ennallaan",
"onboarding.goal.more_protein": "Syödä enemmän proteiinia",
"onboarding.goal.less_waste": "Vähentää hävikkiä",
"onboarding.goal.lower_cost": "Säästää ruokakuluissa",
"onboarding.goal.cook_more": "Kokata enemmän kotona",
"onboarding.diet.omnivore": "Sekasyöjä",
"onboarding.diet.flexitarian": "Fleksitaristi",
"onboarding.diet.pescatarian": "Pescetaristi",
"onboarding.diet.vegetarian": "Kasvissyöjä",
"onboarding.diet.vegan": "Vegaani",
"onboarding.allergen.gluten": "Gluteeni",
"onboarding.allergen.milk": "Maito/laktoosi",
"onboarding.allergen.eggs": "Kananmuna",
"onboarding.allergen.tree_nuts": "Pähkinät",
"onboarding.allergen.peanuts": "Maapähkinät",
"onboarding.allergen.fish": "Kala",
"onboarding.allergen.crustaceans": "Äyriäiset",
"onboarding.allergen.soy": "Soija",
"onboarding.allergen.sesame": "Seesami",
"onboarding.bodyTitle": "Henkilökohtaisia kaloritavoitteita varten (valinnainen)",
"onboarding.weightPlaceholder": "Paino (kg)",
"onboarding.heightPlaceholder": "Pituus (cm)",
"onboarding.birthYearPlaceholder": "Syntymävuosi",
"onboarding.householdDefaultName": "Koti",
"onboarding.modesCombine": "Tiloja voi yhdistellä arjessa helposti, tarkasti kun haluat.",
"myday.mealType.breakfast": "Aamiainen",
"myday.mealType.lunch": "Lounas",
"myday.mealType.dinner": "Päivällinen",
"myday.mealType.snack": "Välipala",
"myday.mealType.dessert": "Jälkiruoka",
"myday.overRecommended": "Yli suosituksen",
"myday.todaysMeals": "Päivän ateriat",
"myday.estimateRange": "{min}{max} kcal, todennäköisesti {kcal}",
"myday.kcalProtein": "{kcal} kcal · {protein} g proteiinia",
"logmeal.mealTypeTitle": "Ateriatyyppi",
"logmeal.quickTitle": "Nopea uudelleenkirjaus",
"logmeal.quickSubtitle": "Kirjaa samat ravintoarvot kuin viimeksi.",
"logmeal.manualTitle": "Manuaalisesti",
"logmeal.whatPlaceholder": "Mitä söit?",
"logmeal.proteinPlaceholder": "proteiini (g)",
"logmeal.validationTitle": "Täydennä",
"logmeal.validationBody": "Manuaalinen kirjaus vaatii nimen ja kalorit.",
"logmeal.photoTip": "Vinkki: kuvaa lautanen Skannaa-välilehdellä, niin sovellus arvioi puolestasi sinä vahvistat aina.",
"shopping.section.frukt_gront": "Hedelmät & vihannekset",
"shopping.section.brod": "Leipä",
"shopping.section.mejeri": "Maitotuotteet",
"shopping.section.kott_fagel": "Liha & siipikarja",
"shopping.section.fisk": "Kala",
"shopping.section.chark": "Leikkeleet",
"shopping.section.frys": "Pakasteet",
"shopping.section.skafferi": "Kuivatuotteet",
"shopping.section.konserver": "Säilykkeet",
"shopping.section.kryddor_bak": "Mausteet & leivonta",
"shopping.section.dryck": "Juomat",
"shopping.section.snacks": "Naposteltavat",
"shopping.section.hygien_ovrigt": "Muut",
"shopping.completedTitle": "Valmis!",
"household.role.owner": "Omistaja",
"household.role.adult": "Aikuinen",
"household.role.member": "Jäsen",
"household.role.child": "Lapsi",
"household.empty": "Sinulla ei ole vielä kotitaloutta. Luo sellainen käyttöönotossa tai liity koodilla.",
"household.shareCode": "Jaa koodi perheelle, niin jaatte ruokavaraston, listan ja suunnitelman.",
"household.locations": "Säilytyspaikat",
"household.portionFactor": "×{factor} annos",
"mealbox.enjoyTitle": "Hyvää ruokahalua!",
"mealbox.enjoyBody": "Annos on kirjattu Minun päiväni -näkymään.",
"mealbox.guidanceNote": "Suositeltu käyttöaika on ohjeellinen luota hajuun ja makuun.",
"paywall.soonTitle": "Pian!",
"paywall.soonBody": "Ostot aktivoituvat App Storen/Google Playn kautta, kun kauppaintegraatio otetaan käyttöön (vaihe 7). Taustajärjestelmä on jo valmis.",
"paywall.popular": "Suosituin",
"paywall.choose": "Valitse",
"recipe.saved": "Tallennettu",
"recipe.save": "Tallenna",
"recipe.avgRating": "Keskiarvo {avg}, {count} arviota",
"memory.verified": "Vahvistettu",
"memory.resume": "Jatka",
"memory.deleteAllTitle": "Poistetaanko koko muisti?",
"memory.irreversible": "Tätä ei voi kumota.",
"profile.consent.personalization": "Henkilökohtaiset toiminnot (muisti & makuprofiili)",
"profile.consent.anonymized_improvement": "Tekoälyn anonymisoitu parantaminen",
"profile.consent.image_training": "Kuviani saa käyttää koulutukseen",
"profile.consent.health_integration": "Terveystiedot (Apple Health / Health Connect)",
"profile.consent.location_weather": "Sijainti sääpohjaisia ehdotuksia varten",
"profile.consent.push_notifications": "Push-ilmoitukset",
"profile.modeLabel": "Tila:",
"profile.memorySubtitle": "Katso, korjaa, keskeytä tai poista se, mitä alusta on oppinut.",
"profile.consentsNote": "Suostumukset ovat erillisiä henkilökohtaiset toiminnot eivät koskaan vaadi koulutussuostumusta.",
"profile.aiScansUsed": "AI-skannaukset: {used} / {total} tässä kuussa",
"profile.deleteTitle": "Poistetaanko tili?",
"profile.deleteBody": "Kaikki tietosi poistetaan pysyvästi GDPR:n mukaisesti.",
"cooking.timerDone": "Valmis!",
"cooked.deductPantryNote": "Ainekset vähennetään ruokavarastosta ensin erääntyvät ensin.",
"home.moreItems_one": "+ 1 lisää …",
"home.moreItems_other": "+ {count} lisää …",
"shopping.completedBody_one": "1 tuote lisättiin ruokavarastoon.",
"shopping.completedBody_other": "{count} tuotetta lisättiin ruokavarastoon."
}
+338
View File
@@ -0,0 +1,338 @@
{
"common.loading": "Chargement …",
"common.error": "Un problème est survenu. Réessayez.",
"common.retry": "Réessayer",
"common.save": "Enregistrer",
"common.cancel": "Annuler",
"common.next": "Suivant",
"common.back": "Retour",
"common.done": "Terminé",
"common.skip": "Passer",
"common.offline": "Hors ligne dernières données chargées",
"common.estimate": "Estimation",
"tabs.whatToEat": "On mange quoi ?",
"tabs.scan": "Scanner",
"tabs.myDay": "Ma journée",
"tabs.home": "À la maison",
"auth.login": "Se connecter",
"auth.register": "Créer un compte",
"auth.email": "E-mail",
"auth.password": "Mot de passe",
"auth.displayName": "Comment vous appeler ?",
"auth.noAccount": "Nouveau ? Créez un compte",
"auth.hasAccount": "Déjà un compte ? Connectez-vous",
"auth.trialNote": "7 jours d'accès complet sans carte.",
"auth.forgotLink": "Mot de passe oublié ?",
"auth.forgotTitle": "Réinitialiser le mot de passe",
"auth.forgotBody": "Saisissez votre e-mail : nous enverrons un lien si le compte existe.",
"auth.forgotSubmit": "Envoyer le lien",
"auth.forgotSentTitle": "Vérifiez votre boîte mail",
"auth.forgotSentBody": "Si l'adresse possède un compte, un lien valable 30 minutes a été envoyé. Ouvrez-le sur cet appareil.",
"auth.backToLogin": "Retour à la connexion",
"auth.resetTitle": "Choisissez un nouveau mot de passe",
"auth.resetBody": "Le lien est valable 30 minutes et à usage unique. Tous les appareils seront déconnectés.",
"auth.resetTokenPlaceholder": "Collez le code reçu par e-mail",
"auth.newPassword": "Nouveau mot de passe (10 caractères min.)",
"auth.resetSubmit": "Changer le mot de passe",
"auth.resetDoneTitle": "C'est fait !",
"auth.resetDoneBody": "Votre mot de passe a été changé. Connectez-vous avec le nouveau.",
"auth.verifyDoneTitle": "E-mail confirmé",
"auth.verifyDoneBody": "Merci ! Votre compte est vérifié.",
"auth.verifyFailedTitle": "Le lien n'a pas fonctionné",
"auth.verifyFailedBody": "Le lien est invalide ou expiré. Demandez-en un nouveau depuis votre profil.",
"onboarding.title": "Parlez-nous un peu de vous",
"onboarding.subtitle": "Tout est facultatif et modifiable à tout moment. Plus vous en dites, meilleures sont les suggestions.",
"onboarding.goal": "Quel est votre objectif principal ?",
"onboarding.diet": "Comment mangez-vous ?",
"onboarding.allergies": "Allergies et intolérances",
"onboarding.allergyNote": "Le filtre allergies est toujours strict : les plats contenant vos allergènes ne sont jamais affichés.",
"onboarding.household": "Votre foyer",
"onboarding.householdCreate": "Créer un foyer",
"onboarding.householdJoin": "Rejoindre avec un code",
"onboarding.householdName": "Nom du foyer",
"onboarding.inviteCode": "Code d'invitation",
"onboarding.mode": "Quel niveau de précision ?",
"onboarding.modeSimple": "Mode simple",
"onboarding.modeSimpleDesc": "Photographiez vos aliments, acceptez les estimations, zéro prise de tête.",
"onboarding.modeExact": "Mode exact",
"onboarding.modeExactDesc": "Pesez, saisissez les grammes, contrôle total des chiffres.",
"onboarding.notMedical": "{brand} fournit des repères, pas un avis médical.",
"wte.title": "On mange quoi ?",
"wte.subtitle": "Selon ce que vous avez chez vous, ce qu'il faut consommer vite et vos goûts.",
"wte.cravingPlaceholder": "J'ai envie de … (ex. crémeux, asiatique, moins de 500 kcal)",
"wte.mealBoxFirst": "Plats prêts à la maison",
"wte.whyTitle": "Pourquoi cette suggestion ?",
"wte.coverage": "{pct} % à la maison",
"wte.missing": "Il manque : {items}",
"wte.empty": "Pas encore de suggestions : ajoutez des aliments ou assouplissez les filtres.",
"wte.refresh": "Nouvelles suggestions",
"scan.title": "Scanner",
"scan.subtitle": "Remplissez votre stock ou enregistrez un repas avec l'appareil photo.",
"scan.fridge": "Frigo",
"scan.freezer": "Congélateur",
"scan.pantry": "Placard",
"scan.ingredients": "Ingrédients",
"scan.plate": "Assiette",
"scan.receipt": "Ticket de caisse",
"scan.barcode": "Code-barres",
"scan.expiry": "Date limite",
"scan.nutrition": "Étiquette nutritionnelle",
"scan.takePhoto": "Prendre une photo",
"scan.tips.title": "Pour une analyse optimale",
"scan.tips.overview": "Commencez par une vue d'ensemble",
"scan.tips.shelf": "Photographiez étagère par étagère",
"scan.tips.light": "Évitez l'obscurité et les ombres",
"scan.tips.move": "Écartez les produits qui se cachent",
"scan.analyzing": "Analyse en cours …",
"scan.quotaLeft_one": "1 scan IA restant ce mois-ci",
"scan.quotaLeft_other": "{count} scans IA restants ce mois-ci",
"scan.review.title": "Vérifiez le résultat",
"scan.review.subtitle": "L'IA doute parfois : c'est vous qui décidez. Modifiez, supprimez ou ajoutez avant d'enregistrer.",
"scan.review.uncertain": "Incertain à vérifier",
"scan.review.approveAll": "Enregistrer dans le stock",
"scan.review.rejected": "Supprimé",
"scan.failed": "L'analyse a échoué. Réessayez ou saisissez manuellement.",
"myday.title": "Ma journée",
"myday.calories": "Calories",
"myday.protein": "Protéines",
"myday.carbs": "Glucides",
"myday.fat": "Lipides",
"myday.fiber": "Fibres",
"myday.salt": "Sel",
"myday.remaining": "{kcal} kcal restantes",
"myday.over": "{kcal} kcal au-dessus de l'objectif",
"myday.logMeal": "Enregistrer un repas",
"myday.noMeals": "Aucun repas enregistré aujourd'hui.",
"myday.estimateNote": "Les valeurs avec ~ sont des estimations ajustables.",
"myday.targetsNote": "Les objectifs sont des repères, pas un avis médical.",
"home.title": "À la maison",
"home.inventory": "Stock",
"home.useSoon": "À consommer vite",
"home.mealBoxes": "Lunchbox",
"home.shopping": "Liste de courses",
"home.budget": "Budget",
"home.household": "Foyer",
"home.waste": "Gaspillage",
"home.emptyInventory": "Le stock est vide. Scannez le frigo ou ajoutez manuellement.",
"home.expiresIn_one": "1 jour restant",
"home.expiresIn_other": "{days} jours restants",
"home.expiresToday": "Expire aujourd'hui",
"home.expired": "Expiré",
"home.pastBestBefore": "DLUO dépassée : sentez et goûtez d'abord",
"recipe.portions_one": "1 portion",
"recipe.portions_other": "{count} portions",
"recipe.time": "{min} min",
"recipe.perPortion": "par portion",
"recipe.ingredients": "Ingrédients",
"recipe.steps": "Préparation",
"recipe.cook": "Cuisiner maintenant",
"recipe.notSafe": "Incompatible avec vos réglages alimentaires",
"recipe.substitutions": "Remplacer",
"recipe.iCookedThis": "Je l'ai cuisiné",
"recipe.cost": "env. {amount}/portion",
"cooking.step": "Étape {current} sur {total}",
"cooking.timer": "Lancer le minuteur",
"cooking.timerRunning": "{time} restants",
"cooking.finish": "Terminé enregistrer le repas",
"cooking.scale": "Portions",
"cooking.keepAwake": "L'écran reste allumé pendant que vous cuisinez",
"cooked.title": "Alors, verdict ?",
"cooked.portionsCooked": "Portions cuisinées",
"cooked.whoAte": "Qui a mangé ?",
"cooked.mealBoxes": "Portions en lunchbox",
"cooked.deductPantry": "Déduire les ingrédients du stock",
"cooked.rate": "Noter",
"shopping.title": "Liste de courses",
"shopping.addPlaceholder": "Ajouter un article …",
"shopping.complete": "Terminer les courses",
"shopping.completeNote": "Les articles cochés rejoignent le stock.",
"shopping.empty": "La liste est vide.",
"shopping.estimated": "env. {amount}",
"shopping.estimatedTotal": "Total estimé : env. {amount}",
"mealbox.title": "Lunchbox",
"mealbox.eatBy": "À consommer avant le {date}",
"mealbox.portionsLeft_one": "1 portion restante",
"mealbox.portionsLeft_other": "{count} portions restantes",
"mealbox.eat": "Manger maintenant",
"mealbox.empty": "Aucune lunchbox pour l'instant. En cuisinant, gardez des portions ici.",
"household.members": "Membres",
"household.invite": "Inviter avec le code : {code}",
"household.shared": "Partagé dans le foyer : stock, liste de courses, plan de semaine, lunchbox et budget.",
"household.private": "Privé par personne : objectifs, allergies, données de santé et historique des repas.",
"memory.title": "Ce que {brand} sait de moi",
"memory.subtitle": "Transparence totale. Corrigez, mettez en pause ou supprimez : votre mémoire vous appartient.",
"memory.paused": "En pause",
"memory.verify": "Exact",
"memory.pause": "Pause",
"memory.delete": "Supprimer",
"memory.deleteAll": "Supprimer toute la mémoire",
"memory.empty": "{brand} n'a encore rien appris sur vous.",
"memory.origin.user_stated": "Vous nous l'avez dit",
"memory.origin.observed": "Habitude observée",
"memory.origin.ai_inferred": "Hypothèse de l'IA",
"paywall.title": "{brand} Premium",
"paywall.subtitle": "L'OS alimentaire de tout le foyer : stock illimité, plan de semaine et partage.",
"paywall.trialActive_one": "1 jour d'essai restant",
"paywall.trialActive_other": "{days} jours d'essai restants",
"paywall.household": "Household · jusqu'à 3 personnes",
"paywall.family": "Family · jusqu'à 6 personnes",
"paywall.large": "Large Household · jusqu'à 12 personnes",
"paywall.perMonth": "{price}/mois",
"paywall.fairUse": "Quota d'usage raisonnable de scans IA inclus.",
"paywall.restore": "Restaurer les achats",
"paywall.freeNote": "Gratuit : 10 scans IA/mois, stock manuel, recettes enregistrées et suivi simple.",
"profile.title": "Profil",
"profile.goals": "Objectifs & alimentation",
"profile.consents": "Consentements & données",
"profile.memory": "Ce que {brand} sait de moi",
"profile.subscription": "Abonnement",
"profile.logout": "Se déconnecter",
"profile.export": "Exporter mes données",
"profile.deleteAccount": "Supprimer le compte",
"profile.language": "Langue",
"profile.language.sv": "Svenska",
"profile.language.en": "English",
"profile.emailUnverified": "Adresse e-mail non confirmée",
"profile.resendVerification": "Renvoyer l'e-mail de confirmation",
"profile.verificationSent": "Envoyé ! Vérifiez votre boîte mail.",
"profile.language.es": "Español",
"profile.language.it": "Italiano",
"profile.language.de": "Deutsch",
"profile.language.fr": "Français",
"profile.language.da": "Dansk",
"profile.language.nb": "Norsk",
"profile.language.fi": "Suomi",
"profile.language.nl": "Nederlands",
"profile.language.pl": "Polski",
"profile.language.pt": "Português",
"common.oops": "Oups",
"common.undo": "Rétablir",
"common.remove": "Retirer",
"common.add": "Ajouter",
"common.on": "Activé",
"common.off": "Désactivé",
"home.thisWeek": "Cette semaine",
"home.wasteWeek": "Gaspillage cette semaine",
"home.useSoonHint": "La DDM (à consommer de préférence) concerne la qualité : sentez, regardez et goûtez avant de jeter. La DLC doit en revanche être respectée.",
"scan.review.itemPlaceholder": "Article",
"scan.review.quantityPlaceholder": "Quantité",
"scan.review.unitPlaceholder": "Unité (g, l, pcs …)",
"scan.review.datePlaceholder": "AAAA-MM-JJ (facultatif)",
"scan.review.dateKindBestBefore": "DDM de préférence",
"scan.review.dateKindUseBy": "DLC à consommer jusqu'au",
"scan.review.bestBeforeNote": "Date de qualité : l'aliment peut rester bon après. Sentez et goûtez avant de jeter.",
"scan.review.useByNote": "Date de sécurité : ne pas consommer après cette date.",
"scan.review.addItem": "+ Ajouter un article",
"scan.review.unknownItem": "Article inconnu",
"barcode.aim": "Visez le code-barres avec l'appareil photo",
"barcode.addPrompt": "Ajouter au stock ?",
"barcode.kcalPer100": "{kcal} kcal/100 g",
"barcode.noNutrition": "Pas de données nutritionnelles",
"barcode.unknownTitle": "Produit inconnu",
"barcode.unknownBody": "Ce produit n'est pas encore dans la base. Photographiez l'avant et l'étiquette nutritionnelle et nous l'ajouterons.",
"barcode.photoPackage": "Photographier l'emballage",
"barcode.needHousehold": "Il vous faut d'abord un foyer.",
"barcode.noLocation": "Aucun lieu de stockage trouvé.",
"barcode.cameraTitle": "Appareil photo requis",
"barcode.cameraBody": "{brand} a besoin de l'appareil photo pour scanner les codes-barres.",
"barcode.allowCamera": "Autoriser l'appareil photo",
"onboarding.goal.lose_weight": "Perdre du poids",
"onboarding.goal.build_muscle": "Prendre du muscle",
"onboarding.goal.maintain_weight": "Maintenir mon poids",
"onboarding.goal.more_protein": "Manger plus de protéines",
"onboarding.goal.less_waste": "Réduire le gaspillage",
"onboarding.goal.lower_cost": "Dépenser moins en courses",
"onboarding.goal.cook_more": "Cuisiner plus à la maison",
"onboarding.diet.omnivore": "Omnivore",
"onboarding.diet.flexitarian": "Flexitarien",
"onboarding.diet.pescatarian": "Pescétarien",
"onboarding.diet.vegetarian": "Végétarien",
"onboarding.diet.vegan": "Végane",
"onboarding.allergen.gluten": "Gluten",
"onboarding.allergen.milk": "Lait/lactose",
"onboarding.allergen.eggs": "Œufs",
"onboarding.allergen.tree_nuts": "Fruits à coque",
"onboarding.allergen.peanuts": "Arachides",
"onboarding.allergen.fish": "Poisson",
"onboarding.allergen.crustaceans": "Crustacés",
"onboarding.allergen.soy": "Soja",
"onboarding.allergen.sesame": "Sésame",
"onboarding.bodyTitle": "Pour des objectifs caloriques personnalisés (facultatif)",
"onboarding.weightPlaceholder": "Poids (kg)",
"onboarding.heightPlaceholder": "Taille (cm)",
"onboarding.birthYearPlaceholder": "Année de naissance",
"onboarding.householdDefaultName": "Maison",
"onboarding.modesCombine": "Les modes se combinent : simple au quotidien, précis quand vous voulez.",
"myday.mealType.breakfast": "Petit-déjeuner",
"myday.mealType.lunch": "Déjeuner",
"myday.mealType.dinner": "Dîner",
"myday.mealType.snack": "Encas",
"myday.mealType.dessert": "Dessert",
"myday.overRecommended": "Au-dessus du recommandé",
"myday.todaysMeals": "Repas du jour",
"myday.estimateRange": "{min}{max} kcal, probablement {kcal}",
"myday.kcalProtein": "{kcal} kcal · {protein} g de protéines",
"logmeal.mealTypeTitle": "Type de repas",
"logmeal.quickTitle": "Enregistrement rapide",
"logmeal.quickSubtitle": "Enregistre les mêmes valeurs nutritionnelles que la dernière fois.",
"logmeal.manualTitle": "Manuel",
"logmeal.whatPlaceholder": "Qu'avez-vous mangé ?",
"logmeal.proteinPlaceholder": "protéines (g)",
"logmeal.validationTitle": "Infos manquantes",
"logmeal.validationBody": "Le nom et les calories sont requis pour un enregistrement manuel.",
"logmeal.photoTip": "Astuce : photographiez l'assiette dans Scanner, l'app estime pour vous vous confirmez toujours.",
"shopping.section.frukt_gront": "Fruits & légumes",
"shopping.section.brod": "Pain",
"shopping.section.mejeri": "Produits laitiers",
"shopping.section.kott_fagel": "Viande & volaille",
"shopping.section.fisk": "Poisson",
"shopping.section.chark": "Charcuterie",
"shopping.section.frys": "Surgelés",
"shopping.section.skafferi": "Épicerie",
"shopping.section.konserver": "Conserves",
"shopping.section.kryddor_bak": "Épices & pâtisserie",
"shopping.section.dryck": "Boissons",
"shopping.section.snacks": "Snacks",
"shopping.section.hygien_ovrigt": "Divers",
"shopping.completedTitle": "Terminé !",
"household.role.owner": "Propriétaire",
"household.role.adult": "Adulte",
"household.role.member": "Membre",
"household.role.child": "Enfant",
"household.empty": "Vous n'avez pas encore de foyer. Créez-en un lors de l'intégration ou rejoignez-en un avec un code.",
"household.shareCode": "Partagez le code avec votre famille pour partager stock, liste et planning.",
"household.locations": "Lieux de stockage",
"household.portionFactor": "×{factor} portion",
"mealbox.enjoyTitle": "Bon appétit !",
"mealbox.enjoyBody": "La portion est enregistrée dans Ma journée.",
"mealbox.guidanceNote": "Le délai recommandé est indicatif : fiez-vous à l'odeur et au goût.",
"paywall.soonTitle": "Bientôt !",
"paywall.soonBody": "Les achats s'activeront via l'App Store/Google Play quand l'intégration boutique sera lancée (phase 7). Le backend est déjà prêt.",
"paywall.popular": "Le plus populaire",
"paywall.choose": "Choisir",
"recipe.saved": "Enregistrée",
"recipe.save": "Enregistrer",
"recipe.avgRating": "Moyenne {avg} sur {count} avis",
"memory.verified": "Vérifié",
"memory.resume": "Reprendre",
"memory.deleteAllTitle": "Supprimer toute la mémoire ?",
"memory.irreversible": "Cette action est irréversible.",
"profile.consent.personalization": "Fonctions personnelles (mémoire & profil de goûts)",
"profile.consent.anonymized_improvement": "Amélioration anonymisée de l'IA",
"profile.consent.image_training": "Mes photos peuvent servir à l'entraînement",
"profile.consent.health_integration": "Données de santé (Apple Health / Health Connect)",
"profile.consent.location_weather": "Position pour des suggestions selon la météo",
"profile.consent.push_notifications": "Notifications push",
"profile.modeLabel": "Mode :",
"profile.memorySubtitle": "Voyez, corrigez, mettez en pause ou supprimez ce que la plateforme a appris.",
"profile.consentsNote": "Les consentements sont distincts : les fonctions personnelles n'exigent jamais le consentement d'entraînement.",
"profile.aiScansUsed": "Scans IA : {used} / {total} ce mois-ci",
"profile.deleteTitle": "Supprimer le compte ?",
"profile.deleteBody": "Toutes vos données seront supprimées définitivement conformément au RGPD.",
"cooking.timerDone": "Terminé !",
"cooked.deductPantryNote": "Les ingrédients sont déduits du stock, en commençant par ceux qui expirent en premier.",
"home.moreItems_one": "+ 1 autre …",
"home.moreItems_other": "+ {count} autres …",
"shopping.completedBody_one": "1 article a été ajouté au stock.",
"shopping.completedBody_other": "{count} articles ont été ajoutés au stock."
}
+338
View File
@@ -0,0 +1,338 @@
{
"common.loading": "Caricamento …",
"common.error": "Qualcosa è andato storto. Riprova.",
"common.retry": "Riprova",
"common.save": "Salva",
"common.cancel": "Annulla",
"common.next": "Avanti",
"common.back": "Indietro",
"common.done": "Fatto",
"common.skip": "Salta",
"common.offline": "Offline dati dell'ultimo aggiornamento",
"common.estimate": "Stima",
"tabs.whatToEat": "Cosa mangiamo?",
"tabs.scan": "Scansiona",
"tabs.myDay": "La mia giornata",
"tabs.home": "A casa",
"auth.login": "Accedi",
"auth.register": "Crea account",
"auth.email": "Email",
"auth.password": "Password",
"auth.displayName": "Come ti chiamiamo?",
"auth.noAccount": "Nuovo qui? Crea un account",
"auth.hasAccount": "Hai già un account? Accedi",
"auth.trialNote": "7 giorni di accesso completo senza carta.",
"auth.forgotLink": "Password dimenticata?",
"auth.forgotTitle": "Reimposta la password",
"auth.forgotBody": "Inserisci la tua email e ti invieremo un link se l'account esiste.",
"auth.forgotSubmit": "Invia link",
"auth.forgotSentTitle": "Controlla la tua casella",
"auth.forgotSentBody": "Se l'indirizzo ha un account, abbiamo inviato un link valido per 30 minuti. Aprilo su questo dispositivo.",
"auth.backToLogin": "Torna all'accesso",
"auth.resetTitle": "Scegli una nuova password",
"auth.resetBody": "Il link è valido per 30 minuti e si può usare una sola volta. Tutti i dispositivi verranno disconnessi.",
"auth.resetTokenPlaceholder": "Incolla il codice dall'email",
"auth.newPassword": "Nuova password (almeno 10 caratteri)",
"auth.resetSubmit": "Cambia password",
"auth.resetDoneTitle": "Fatto!",
"auth.resetDoneBody": "La password è stata cambiata. Accedi con quella nuova.",
"auth.verifyDoneTitle": "Email confermata",
"auth.verifyDoneBody": "Grazie! Il tuo account è verificato.",
"auth.verifyFailedTitle": "Il link non ha funzionato",
"auth.verifyFailedBody": "Il link non è valido o è scaduto. Richiedine uno nuovo dal profilo.",
"onboarding.title": "Raccontaci qualcosa di te",
"onboarding.subtitle": "Tutto è facoltativo e modificabile in qualsiasi momento. Più compili, migliori saranno i suggerimenti.",
"onboarding.goal": "Qual è il tuo obiettivo principale?",
"onboarding.diet": "Come mangi?",
"onboarding.allergies": "Allergie e intolleranze",
"onboarding.allergyNote": "Il filtro allergie è sempre rigoroso: i piatti con i tuoi allergeni non vengono mai mostrati.",
"onboarding.household": "La tua famiglia",
"onboarding.householdCreate": "Crea famiglia",
"onboarding.householdJoin": "Unisciti con un codice",
"onboarding.householdName": "Nome della famiglia",
"onboarding.inviteCode": "Codice di invito",
"onboarding.mode": "Quanta precisione vuoi?",
"onboarding.modeSimple": "Modalità semplice",
"onboarding.modeSimpleDesc": "Fotografa il cibo, accetta le stime, zero complicazioni.",
"onboarding.modeExact": "Modalità esatta",
"onboarding.modeExactDesc": "Pesa il cibo, inserisci i grammi, pieno controllo dei numeri.",
"onboarding.notMedical": "{brand} offre indicazioni, non consigli medici.",
"wte.title": "Cosa mangiamo?",
"wte.subtitle": "In base a ciò che avete in casa, a cosa va consumato presto e ai vostri gusti.",
"wte.cravingPlaceholder": "Ho voglia di … (es. cremoso, asiatico, meno di 500 kcal)",
"wte.mealBoxFirst": "Cibo pronto in casa",
"wte.whyTitle": "Perché questo suggerimento?",
"wte.coverage": "{pct}% in casa",
"wte.missing": "Manca: {items}",
"wte.empty": "Ancora nessun suggerimento: aggiungi cibo alla dispensa o allenta i filtri.",
"wte.refresh": "Nuovi suggerimenti",
"scan.title": "Scansiona",
"scan.subtitle": "Riempi la dispensa o registra un pasto con la fotocamera.",
"scan.fridge": "Frigo",
"scan.freezer": "Congelatore",
"scan.pantry": "Dispensa",
"scan.ingredients": "Ingredienti",
"scan.plate": "Piatto",
"scan.receipt": "Scontrino",
"scan.barcode": "Codice a barre",
"scan.expiry": "Data di scadenza",
"scan.nutrition": "Etichetta nutrizionale",
"scan.takePhoto": "Scatta foto",
"scan.tips.title": "Come ottenere la migliore analisi",
"scan.tips.overview": "Inizia con una foto d'insieme",
"scan.tips.shelf": "Fotografa ripiano per ripiano",
"scan.tips.light": "Evita buio e ombre",
"scan.tips.move": "Sposta i prodotti che si coprono a vicenda",
"scan.analyzing": "Analisi in corso …",
"scan.quotaLeft_one": "1 scansione IA rimasta questo mese",
"scan.quotaLeft_other": "{count} scansioni IA rimaste questo mese",
"scan.review.title": "Controlla il risultato",
"scan.review.subtitle": "L'IA a volte è incerta: decidi tu. Modifica, elimina o aggiungi prima di salvare.",
"scan.review.uncertain": "Incerto controlla",
"scan.review.approveAll": "Salva nella dispensa",
"scan.review.rejected": "Rimosso",
"scan.failed": "Analisi non riuscita. Riprova o inserisci manualmente.",
"myday.title": "La mia giornata",
"myday.calories": "Calorie",
"myday.protein": "Proteine",
"myday.carbs": "Carboidrati",
"myday.fat": "Grassi",
"myday.fiber": "Fibre",
"myday.salt": "Sale",
"myday.remaining": "{kcal} kcal rimanenti",
"myday.over": "{kcal} kcal oltre l'obiettivo",
"myday.logMeal": "Registra pasto",
"myday.noMeals": "Nessun pasto registrato oggi.",
"myday.estimateNote": "I valori con ~ sono stime che puoi correggere.",
"myday.targetsNote": "Gli obiettivi sono indicazioni, non consigli medici.",
"home.title": "A casa",
"home.inventory": "Dispensa",
"home.useSoon": "Da consumare presto",
"home.mealBoxes": "Porta pranzo",
"home.shopping": "Lista della spesa",
"home.budget": "Budget",
"home.household": "Famiglia",
"home.waste": "Sprechi",
"home.emptyInventory": "La dispensa è vuota. Scansiona il frigo o aggiungi manualmente.",
"home.expiresIn_one": "1 giorno rimasto",
"home.expiresIn_other": "{days} giorni rimasti",
"home.expiresToday": "Scade oggi",
"home.expired": "Scaduto",
"home.pastBestBefore": "Oltre il termine minimo: annusa e assaggia prima",
"recipe.portions_one": "1 porzione",
"recipe.portions_other": "{count} porzioni",
"recipe.time": "{min} min",
"recipe.perPortion": "a porzione",
"recipe.ingredients": "Ingredienti",
"recipe.steps": "Preparazione",
"recipe.cook": "Cucina ora",
"recipe.notSafe": "Non adatto alle tue impostazioni alimentari",
"recipe.substitutions": "Sostituisci",
"recipe.iCookedThis": "L'ho cucinato",
"recipe.cost": "circa {amount}/porzione",
"cooking.step": "Passo {current} di {total}",
"cooking.timer": "Avvia timer",
"cooking.timerRunning": "{time} rimasti",
"cooking.finish": "Fatto registra il pasto",
"cooking.scale": "Porzioni",
"cooking.keepAwake": "Lo schermo resta acceso mentre cucini",
"cooked.title": "Com'è andata?",
"cooked.portionsCooked": "Porzioni cucinate",
"cooked.whoAte": "Chi ha mangiato?",
"cooked.mealBoxes": "Porzioni nei porta pranzo",
"cooked.deductPantry": "Scala gli ingredienti dalla dispensa",
"cooked.rate": "Valuta",
"shopping.title": "Lista della spesa",
"shopping.addPlaceholder": "Aggiungi prodotto …",
"shopping.complete": "Concludi la spesa",
"shopping.completeNote": "I prodotti spuntati vengono aggiunti alla dispensa.",
"shopping.empty": "La lista è vuota.",
"shopping.estimated": "circa {amount}",
"shopping.estimatedTotal": "Totale stimato: circa {amount}",
"mealbox.title": "Porta pranzo",
"mealbox.eatBy": "Consumare entro il {date}",
"mealbox.portionsLeft_one": "1 porzione rimasta",
"mealbox.portionsLeft_other": "{count} porzioni rimaste",
"mealbox.eat": "Mangia ora",
"mealbox.empty": "Nessun porta pranzo al momento. Quando cucini puoi salvare qui le porzioni.",
"household.members": "Membri",
"household.invite": "Invita con il codice: {code}",
"household.shared": "Condiviso in famiglia: dispensa, lista della spesa, piano settimanale, porta pranzo e budget.",
"household.private": "Privato per persona: obiettivi, allergie, dati sanitari e cronologia dei pasti.",
"memory.title": "Cosa sa {brand} di me",
"memory.subtitle": "Trasparenza totale. Correggi, metti in pausa o elimina: la memoria è tua.",
"memory.paused": "In pausa",
"memory.verify": "Corretto",
"memory.pause": "Pausa",
"memory.delete": "Elimina",
"memory.deleteAll": "Elimina tutta la memoria",
"memory.empty": "{brand} non ha ancora imparato nulla su di te.",
"memory.origin.user_stated": "Ce l'hai detto tu",
"memory.origin.observed": "Schema osservato",
"memory.origin.ai_inferred": "Ipotesi dell'IA",
"paywall.title": "{brand} Premium",
"paywall.subtitle": "Il sistema operativo del cibo per tutta la famiglia: dispensa illimitata, piano settimanale e condivisione.",
"paywall.trialActive_one": "1 giorno di prova rimasto",
"paywall.trialActive_other": "{days} giorni di prova rimasti",
"paywall.household": "Household · fino a 3 persone",
"paywall.family": "Family · fino a 6 persone",
"paywall.large": "Large Household · fino a 12 persone",
"paywall.perMonth": "{price}/mese",
"paywall.fairUse": "Quota fair use di scansioni IA inclusa.",
"paywall.restore": "Ripristina acquisti",
"paywall.freeNote": "Gratis: 10 scansioni IA/mese, dispensa manuale, ricette salvate e registrazione semplice.",
"profile.title": "Profilo",
"profile.goals": "Obiettivi e dieta",
"profile.consents": "Consensi e dati",
"profile.memory": "Cosa sa {brand} di me",
"profile.subscription": "Abbonamento",
"profile.logout": "Esci",
"profile.export": "Esporta i miei dati",
"profile.deleteAccount": "Elimina account",
"profile.language": "Lingua",
"profile.language.sv": "Svenska",
"profile.language.en": "English",
"profile.emailUnverified": "Email non confermata",
"profile.resendVerification": "Reinvia email di conferma",
"profile.verificationSent": "Inviata! Controlla la casella.",
"profile.language.es": "Español",
"profile.language.it": "Italiano",
"profile.language.de": "Deutsch",
"profile.language.fr": "Français",
"profile.language.da": "Dansk",
"profile.language.nb": "Norsk",
"profile.language.fi": "Suomi",
"profile.language.nl": "Nederlands",
"profile.language.pl": "Polski",
"profile.language.pt": "Português",
"common.oops": "Ops",
"common.undo": "Ripristina",
"common.remove": "Rimuovi",
"common.add": "Aggiungi",
"common.on": "Attivato",
"common.off": "Disattivato",
"home.thisWeek": "Questa settimana",
"home.wasteWeek": "Sprechi questa settimana",
"home.useSoonHint": "Il termine minimo di conservazione riguarda la qualità: annusa, osserva e assaggia prima di buttare. La data di scadenza va invece rispettata.",
"scan.review.itemPlaceholder": "Prodotto",
"scan.review.quantityPlaceholder": "Quantità",
"scan.review.unitPlaceholder": "Unità (g, l, pz …)",
"scan.review.datePlaceholder": "AAAA-MM-GG (facoltativo)",
"scan.review.dateKindBestBefore": "Preferibilmente entro",
"scan.review.dateKindUseBy": "Data di scadenza",
"scan.review.bestBeforeNote": "Limite di qualità: l'alimento può essere buono anche dopo. Annusa e assaggia prima di buttare.",
"scan.review.useByNote": "Limite di sicurezza: non consumare dopo questa data.",
"scan.review.addItem": "+ Aggiungi prodotto",
"scan.review.unknownItem": "Prodotto sconosciuto",
"barcode.aim": "Inquadra il codice a barre",
"barcode.addPrompt": "Aggiungere alla dispensa?",
"barcode.kcalPer100": "{kcal} kcal/100 g",
"barcode.noNutrition": "Dati nutrizionali mancanti",
"barcode.unknownTitle": "Prodotto sconosciuto",
"barcode.unknownBody": "Questo prodotto non è ancora nel database. Fotografa il fronte e l'etichetta nutrizionale e lo aggiungeremo.",
"barcode.photoPackage": "Fotografa la confezione",
"barcode.needHousehold": "Prima ti serve una famiglia.",
"barcode.noLocation": "Nessun luogo di conservazione trovato.",
"barcode.cameraTitle": "Serve la fotocamera",
"barcode.cameraBody": "{brand} ha bisogno della fotocamera per leggere i codici a barre.",
"barcode.allowCamera": "Consenti fotocamera",
"onboarding.goal.lose_weight": "Perdere peso",
"onboarding.goal.build_muscle": "Aumentare la massa muscolare",
"onboarding.goal.maintain_weight": "Mantenere il peso",
"onboarding.goal.more_protein": "Mangiare più proteine",
"onboarding.goal.less_waste": "Ridurre gli sprechi",
"onboarding.goal.lower_cost": "Spendere meno per il cibo",
"onboarding.goal.cook_more": "Cucinare di più a casa",
"onboarding.diet.omnivore": "Onnivoro",
"onboarding.diet.flexitarian": "Flexitariano",
"onboarding.diet.pescatarian": "Pescetariano",
"onboarding.diet.vegetarian": "Vegetariano",
"onboarding.diet.vegan": "Vegano",
"onboarding.allergen.gluten": "Glutine",
"onboarding.allergen.milk": "Latte/lattosio",
"onboarding.allergen.eggs": "Uova",
"onboarding.allergen.tree_nuts": "Frutta a guscio",
"onboarding.allergen.peanuts": "Arachidi",
"onboarding.allergen.fish": "Pesce",
"onboarding.allergen.crustaceans": "Crostacei",
"onboarding.allergen.soy": "Soia",
"onboarding.allergen.sesame": "Sesamo",
"onboarding.bodyTitle": "Per obiettivi calorici personali (facoltativo)",
"onboarding.weightPlaceholder": "Peso (kg)",
"onboarding.heightPlaceholder": "Altezza (cm)",
"onboarding.birthYearPlaceholder": "Anno di nascita",
"onboarding.householdDefaultName": "Casa",
"onboarding.modesCombine": "Le modalità si possono combinare: semplice ogni giorno, esatta quando vuoi.",
"myday.mealType.breakfast": "Colazione",
"myday.mealType.lunch": "Pranzo",
"myday.mealType.dinner": "Cena",
"myday.mealType.snack": "Spuntino",
"myday.mealType.dessert": "Dolce",
"myday.overRecommended": "Oltre il consigliato",
"myday.todaysMeals": "Pasti di oggi",
"myday.estimateRange": "{min}{max} kcal, probabilmente {kcal}",
"myday.kcalProtein": "{kcal} kcal · {protein} g di proteine",
"logmeal.mealTypeTitle": "Tipo di pasto",
"logmeal.quickTitle": "Registrazione rapida",
"logmeal.quickSubtitle": "Registra gli stessi valori nutrizionali dell'ultima volta.",
"logmeal.manualTitle": "Manuale",
"logmeal.whatPlaceholder": "Cosa hai mangiato?",
"logmeal.proteinPlaceholder": "proteine (g)",
"logmeal.validationTitle": "Dati mancanti",
"logmeal.validationBody": "Per la registrazione manuale servono nome e calorie.",
"logmeal.photoTip": "Suggerimento: fotografa il piatto in Scansiona e l'app stimerà per te; confermi sempre tu.",
"shopping.section.frukt_gront": "Frutta e verdura",
"shopping.section.brod": "Pane",
"shopping.section.mejeri": "Latticini",
"shopping.section.kott_fagel": "Carne e pollame",
"shopping.section.fisk": "Pesce",
"shopping.section.chark": "Salumi",
"shopping.section.frys": "Surgelati",
"shopping.section.skafferi": "Dispensa",
"shopping.section.konserver": "Conserve",
"shopping.section.kryddor_bak": "Spezie e preparati per dolci",
"shopping.section.dryck": "Bevande",
"shopping.section.snacks": "Snack",
"shopping.section.hygien_ovrigt": "Altro",
"shopping.completedTitle": "Fatto!",
"household.role.owner": "Proprietario",
"household.role.adult": "Adulto",
"household.role.member": "Membro",
"household.role.child": "Bambino",
"household.empty": "Non hai ancora una famiglia. Creane una nell'onboarding o unisciti con un codice.",
"household.shareCode": "Condividi il codice con la famiglia per condividere dispensa, lista e piano.",
"household.locations": "Luoghi di conservazione",
"household.portionFactor": "×{factor} porzione",
"mealbox.enjoyTitle": "Buon appetito!",
"mealbox.enjoyBody": "La porzione è registrata in La mia giornata.",
"mealbox.guidanceNote": "Il termine consigliato è indicativo: fidati di olfatto e gusto.",
"paywall.soonTitle": "Presto!",
"paywall.soonBody": "Gli acquisti si attivano tramite App Store/Google Play quando l'integrazione con gli store sarà attiva (fase 7). Il flusso backend è già pronto.",
"paywall.popular": "Il più scelto",
"paywall.choose": "Scegli",
"recipe.saved": "Salvata",
"recipe.save": "Salva",
"recipe.avgRating": "Media {avg} su {count} valutazioni",
"memory.verified": "Verificato",
"memory.resume": "Riprendi",
"memory.deleteAllTitle": "Eliminare tutta la memoria?",
"memory.irreversible": "Questa operazione non può essere annullata.",
"profile.consent.personalization": "Funzioni personali (memoria e profilo dei gusti)",
"profile.consent.anonymized_improvement": "Miglioramento anonimo dell'IA",
"profile.consent.image_training": "Le mie foto possono essere usate per l'addestramento",
"profile.consent.health_integration": "Dati sanitari (Apple Health / Health Connect)",
"profile.consent.location_weather": "Posizione per suggerimenti in base al meteo",
"profile.consent.push_notifications": "Notifiche push",
"profile.modeLabel": "Modalità:",
"profile.memorySubtitle": "Vedi, correggi, metti in pausa o elimina ciò che la piattaforma ha imparato.",
"profile.consentsNote": "I consensi sono separati: le funzioni personali non richiedono mai il consenso all'addestramento.",
"profile.aiScansUsed": "Scansioni IA: {used} / {total} questo mese",
"profile.deleteTitle": "Eliminare l'account?",
"profile.deleteBody": "Tutti i tuoi dati saranno eliminati definitivamente secondo il GDPR.",
"cooking.timerDone": "Fatto!",
"cooked.deductPantryNote": "Gli ingredienti vengono scalati dalla dispensa: prima quelli in scadenza.",
"home.moreItems_one": "+ 1 altro …",
"home.moreItems_other": "+ {count} altri …",
"shopping.completedBody_one": "1 prodotto aggiunto alla dispensa.",
"shopping.completedBody_other": "{count} prodotti aggiunti alla dispensa."
}
+338
View File
@@ -0,0 +1,338 @@
{
"common.loading": "Laster …",
"common.error": "Noe gikk galt. Prøv igjen.",
"common.retry": "Prøv igjen",
"common.save": "Lagre",
"common.cancel": "Avbryt",
"common.next": "Neste",
"common.back": "Tilbake",
"common.done": "Ferdig",
"common.skip": "Hopp over",
"common.offline": "Frakoblet viser sist hentede data",
"common.estimate": "Estimat",
"tabs.whatToEat": "Hva skal vi spise?",
"tabs.scan": "Skann",
"tabs.myDay": "Min dag",
"tabs.home": "Hjemme",
"auth.login": "Logg inn",
"auth.register": "Opprett konto",
"auth.email": "E-post",
"auth.password": "Passord",
"auth.displayName": "Hva skal vi kalle deg?",
"auth.noAccount": "Ny her? Opprett en konto",
"auth.hasAccount": "Har du allerede en konto? Logg inn",
"auth.trialNote": "7 dager full tilgang uten kort.",
"auth.forgotLink": "Glemt passordet?",
"auth.forgotTitle": "Tilbakestill passord",
"auth.forgotBody": "Skriv inn e-postadressen din, så sender vi en lenke hvis kontoen finnes.",
"auth.forgotSubmit": "Send lenke",
"auth.forgotSentTitle": "Sjekk innboksen din",
"auth.forgotSentBody": "Hvis adressen har en konto, har vi sendt en lenke som gjelder i 30 minutter. Åpne den på denne enheten.",
"auth.backToLogin": "Tilbake til innlogging",
"auth.resetTitle": "Velg et nytt passord",
"auth.resetBody": "Lenken gjelder i 30 minutter og kan bare brukes én gang. Alle enheter logges ut.",
"auth.resetTokenPlaceholder": "Lim inn koden fra e-posten",
"auth.newPassword": "Nytt passord (minst 10 tegn)",
"auth.resetSubmit": "Bytt passord",
"auth.resetDoneTitle": "Ferdig!",
"auth.resetDoneBody": "Passordet ditt er endret. Logg inn med det nye.",
"auth.verifyDoneTitle": "E-post bekreftet",
"auth.verifyDoneBody": "Takk! Kontoen din er verifisert.",
"auth.verifyFailedTitle": "Lenken fungerte ikke",
"auth.verifyFailedBody": "Lenken er ugyldig eller utløpt. Be om en ny fra profilen.",
"onboarding.title": "Fortell oss litt om deg",
"onboarding.subtitle": "Alt er frivillig og kan endres når som helst. Jo mer du fyller ut, desto bedre forslag.",
"onboarding.goal": "Hva er ditt viktigste mål?",
"onboarding.diet": "Hvordan spiser du?",
"onboarding.allergies": "Allergier og intoleranser",
"onboarding.allergyNote": "Allergifiltrering er alltid streng retter med dine allergener vises aldri.",
"onboarding.household": "Husstanden din",
"onboarding.householdCreate": "Opprett husstand",
"onboarding.householdJoin": "Bli med via kode",
"onboarding.householdName": "Husstandens navn",
"onboarding.inviteCode": "Invitasjonskode",
"onboarding.mode": "Hvor nøyaktig vil du være?",
"onboarding.modeSimple": "Enkel modus",
"onboarding.modeSimpleDesc": "Ta bilde av maten, godta estimater, minimalt styr.",
"onboarding.modeExact": "Nøyaktig modus",
"onboarding.modeExactDesc": "Vei maten, angi gram, full kontroll på tallene.",
"onboarding.notMedical": "{brand} gir veiledning ikke medisinske råd.",
"wte.title": "Hva skal vi spise?",
"wte.subtitle": "Basert på hva dere har hjemme, hva som snart bør brukes, og hva dere liker.",
"wte.cravingPlaceholder": "Jeg har lyst på … (f.eks. kremet, asiatisk, under 500 kcal)",
"wte.mealBoxFirst": "Ferdig mat hjemme",
"wte.whyTitle": "Hvorfor dette forslaget?",
"wte.coverage": "{pct} % hjemme",
"wte.missing": "Mangler: {items}",
"wte.empty": "Ingen forslag ennå legg inn litt mat på lageret eller løsne filtrene.",
"wte.refresh": "Nye forslag",
"scan.title": "Skann",
"scan.subtitle": "Fyll matlageret eller loggfør et måltid med kameraet.",
"scan.fridge": "Kjøleskap",
"scan.freezer": "Fryser",
"scan.pantry": "Spiskammer",
"scan.ingredients": "Ingredienser",
"scan.plate": "Tallerken",
"scan.receipt": "Kvittering",
"scan.barcode": "Strekkode",
"scan.expiry": "Best før-dato",
"scan.nutrition": "Næringsdeklarasjon",
"scan.takePhoto": "Ta bilde",
"scan.tips.title": "Slik blir analysen best",
"scan.tips.overview": "Start med et oversiktsbilde",
"scan.tips.shelf": "Fotografer hylle for hylle",
"scan.tips.light": "Unngå mørke og skygger",
"scan.tips.move": "Flytt varer som skjuler hverandre",
"scan.analyzing": "Analyserer …",
"scan.quotaLeft_one": "1 AI-skanning igjen denne måneden",
"scan.quotaLeft_other": "{count} AI-skanninger igjen denne måneden",
"scan.review.title": "Gå gjennom resultatet",
"scan.review.subtitle": "AI-en er noen ganger usikker du bestemmer. Endre, fjern eller legg til før du lagrer.",
"scan.review.uncertain": "Usikker sjekk",
"scan.review.approveAll": "Lagre på lageret",
"scan.review.rejected": "Fjernet",
"scan.failed": "Analysen mislyktes. Prøv igjen eller registrer manuelt.",
"myday.title": "Min dag",
"myday.calories": "Kalorier",
"myday.protein": "Protein",
"myday.carbs": "Karbohydrater",
"myday.fat": "Fett",
"myday.fiber": "Fiber",
"myday.salt": "Salt",
"myday.remaining": "{kcal} kcal igjen",
"myday.over": "{kcal} kcal over målet",
"myday.logMeal": "Loggfør måltid",
"myday.noMeals": "Ingen måltider loggført i dag.",
"myday.estimateNote": "Verdier med ~ er estimater du kan justere.",
"myday.targetsNote": "Målene er veiledning, ikke medisinske råd.",
"home.title": "Hjemme",
"home.inventory": "Matlager",
"home.useSoon": "Bruk snart",
"home.mealBoxes": "Matbokser",
"home.shopping": "Handleliste",
"home.budget": "Matbudsjett",
"home.household": "Husstand",
"home.waste": "Matsvinn",
"home.emptyInventory": "Lageret er tomt. Skann kjøleskapet eller legg til varer manuelt.",
"home.expiresIn_one": "1 dag igjen",
"home.expiresIn_other": "{days} dager igjen",
"home.expiresToday": "Går ut i dag",
"home.expired": "Utløpt",
"home.pastBestBefore": "Best før er passert lukt og smak først",
"recipe.portions_one": "1 porsjon",
"recipe.portions_other": "{count} porsjoner",
"recipe.time": "{min} min",
"recipe.perPortion": "per porsjon",
"recipe.ingredients": "Ingredienser",
"recipe.steps": "Slik gjør du",
"recipe.cook": "Lag mat nå",
"recipe.notSafe": "Passer ikke kostinnstillingene dine",
"recipe.substitutions": "Bytt ut",
"recipe.iCookedThis": "Jeg har laget denne",
"recipe.cost": "ca. {amount}/porsjon",
"cooking.step": "Steg {current} av {total}",
"cooking.timer": "Start timer",
"cooking.timerRunning": "{time} igjen",
"cooking.finish": "Ferdig loggfør måltidet",
"cooking.scale": "Porsjoner",
"cooking.keepAwake": "Skjermen holdes våken mens du lager mat",
"cooked.title": "Hvordan gikk det?",
"cooked.portionsCooked": "Porsjoner laget",
"cooked.whoAte": "Hvem spiste?",
"cooked.mealBoxes": "Porsjoner til matbokser",
"cooked.deductPantry": "Trekk ingredienser fra lageret",
"cooked.rate": "Vurder",
"shopping.title": "Handleliste",
"shopping.addPlaceholder": "Legg til vare …",
"shopping.complete": "Avslutt handleturen",
"shopping.completeNote": "Avkryssede varer legges i matlageret.",
"shopping.empty": "Listen er tom.",
"shopping.estimated": "ca. {amount}",
"shopping.estimatedTotal": "Anslått totalt: ca. {amount}",
"mealbox.title": "Matbokser",
"mealbox.eatBy": "Spis innen {date}",
"mealbox.portionsLeft_one": "1 porsjon igjen",
"mealbox.portionsLeft_other": "{count} porsjoner igjen",
"mealbox.eat": "Spis nå",
"mealbox.empty": "Ingen matbokser akkurat nå. Når du lager mat, kan du lagre porsjoner her.",
"household.members": "Medlemmer",
"household.invite": "Inviter med kode: {code}",
"household.shared": "Deles i husstanden: matlager, handleliste, ukeplan, matbokser og budsjett.",
"household.private": "Privat per person: mål, allergier, helsedata og måltidshistorikk.",
"memory.title": "Hva {brand} vet om meg",
"memory.subtitle": "Full innsikt. Rett feil, sett på pause eller slett du eier minnet ditt.",
"memory.paused": "På pause",
"memory.verify": "Stemmer",
"memory.pause": "Pause",
"memory.delete": "Slett",
"memory.deleteAll": "Slett alt minne",
"memory.empty": "{brand} har ikke lært noe om deg ennå.",
"memory.origin.user_stated": "Du har fortalt det",
"memory.origin.observed": "Observert mønster",
"memory.origin.ai_inferred": "AI-antakelse",
"paywall.title": "{brand} Premium",
"paywall.subtitle": "Hele husstandens mat-OS: ubegrenset lager, ukeplan og deling.",
"paywall.trialActive_one": "1 dag igjen av prøveperioden",
"paywall.trialActive_other": "{days} dager igjen av prøveperioden",
"paywall.household": "Household · opptil 3 personer",
"paywall.family": "Family · opptil 6 personer",
"paywall.large": "Large Household · opptil 12 personer",
"paywall.perMonth": "{price}/mnd.",
"paywall.fairUse": "Fair use-kvote for AI-skanninger inkludert.",
"paywall.restore": "Gjenopprett kjøp",
"paywall.freeNote": "Gratis: 10 AI-skanninger/mnd., manuelt lager, lagrede oppskrifter og enkel loggføring.",
"profile.title": "Profil",
"profile.goals": "Mål & kosthold",
"profile.consents": "Samtykker & data",
"profile.memory": "Hva {brand} vet om meg",
"profile.subscription": "Abonnement",
"profile.logout": "Logg ut",
"profile.export": "Eksporter dataene mine",
"profile.deleteAccount": "Slett konto",
"profile.language": "Språk",
"profile.emailUnverified": "E-postadressen er ikke bekreftet",
"profile.resendVerification": "Send bekreftelses-e-post på nytt",
"profile.verificationSent": "Sendt! Sjekk innboksen.",
"profile.language.sv": "Svenska",
"profile.language.en": "English",
"profile.language.es": "Español",
"profile.language.it": "Italiano",
"profile.language.de": "Deutsch",
"profile.language.fr": "Français",
"profile.language.da": "Dansk",
"profile.language.nb": "Norsk",
"profile.language.fi": "Suomi",
"profile.language.nl": "Nederlands",
"profile.language.pl": "Polski",
"profile.language.pt": "Português",
"common.oops": "Oi sann",
"common.undo": "Angre",
"common.remove": "Fjern",
"common.add": "Legg til",
"common.on": "På",
"common.off": "Av",
"home.thisWeek": "Denne uken",
"home.wasteWeek": "Matsvinn denne uken",
"home.useSoonHint": "Best før handler om kvalitet lukt, se og smak før du kaster. Siste forbruksdag skal derimot respekteres.",
"scan.review.itemPlaceholder": "Vare",
"scan.review.quantityPlaceholder": "Mengde",
"scan.review.unitPlaceholder": "Enhet (g, l, stk. …)",
"scan.review.datePlaceholder": "ÅÅÅÅ-MM-DD (valgfritt)",
"scan.review.dateKindBestBefore": "Best før",
"scan.review.dateKindUseBy": "Siste forbruksdag",
"scan.review.bestBeforeNote": "Kvalitetsdato varen kan være god lenger. Lukt og smak før du kaster.",
"scan.review.useByNote": "Sikkerhetsdato ikke spis etter denne datoen.",
"scan.review.addItem": "+ Legg til vare",
"scan.review.unknownItem": "Ukjent vare",
"barcode.aim": "Rett kameraet mot strekkoden",
"barcode.addPrompt": "Legge til i matlageret?",
"barcode.kcalPer100": "{kcal} kcal/100 g",
"barcode.noNutrition": "Ingen næringsdata",
"barcode.unknownTitle": "Ukjent produkt",
"barcode.unknownBody": "Produktet er ikke i databasen ennå. Ta bilde av forsiden og næringsdeklarasjonen, så legger vi det til.",
"barcode.photoPackage": "Ta bilde av pakken",
"barcode.needHousehold": "Du trenger en husstand først.",
"barcode.noLocation": "Fant ingen oppbevaringssteder.",
"barcode.cameraTitle": "Kameraet trengs",
"barcode.cameraBody": "{brand} trenger kameraet for å skanne strekkoder.",
"barcode.allowCamera": "Tillat kamera",
"onboarding.goal.lose_weight": "Gå ned i vekt",
"onboarding.goal.build_muscle": "Bygge muskler",
"onboarding.goal.maintain_weight": "Holde vekten",
"onboarding.goal.more_protein": "Spise mer protein",
"onboarding.goal.less_waste": "Redusere matsvinn",
"onboarding.goal.lower_cost": "Bruke mindre på mat",
"onboarding.goal.cook_more": "Lage mer mat hjemme",
"onboarding.diet.omnivore": "Alteter",
"onboarding.diet.flexitarian": "Fleksitarianer",
"onboarding.diet.pescatarian": "Pescetarianer",
"onboarding.diet.vegetarian": "Vegetarianer",
"onboarding.diet.vegan": "Veganer",
"onboarding.allergen.gluten": "Gluten",
"onboarding.allergen.milk": "Melk/laktose",
"onboarding.allergen.eggs": "Egg",
"onboarding.allergen.tree_nuts": "Nøtter",
"onboarding.allergen.peanuts": "Peanøtter",
"onboarding.allergen.fish": "Fisk",
"onboarding.allergen.crustaceans": "Skalldyr",
"onboarding.allergen.soy": "Soya",
"onboarding.allergen.sesame": "Sesam",
"onboarding.bodyTitle": "For personlige kalorimål (valgfritt)",
"onboarding.weightPlaceholder": "Vekt (kg)",
"onboarding.heightPlaceholder": "Høyde (cm)",
"onboarding.birthYearPlaceholder": "Fødselsår",
"onboarding.householdDefaultName": "Hjemme",
"onboarding.modesCombine": "Modusene kan kombineres enkelt til hverdags, nøyaktig når du vil.",
"myday.mealType.breakfast": "Frokost",
"myday.mealType.lunch": "Lunsj",
"myday.mealType.dinner": "Middag",
"myday.mealType.snack": "Mellommåltid",
"myday.mealType.dessert": "Dessert",
"myday.overRecommended": "Over anbefalt",
"myday.todaysMeals": "Dagens måltider",
"myday.estimateRange": "{min}{max} kcal, trolig {kcal}",
"myday.kcalProtein": "{kcal} kcal · {protein} g protein",
"logmeal.mealTypeTitle": "Måltidstype",
"logmeal.quickTitle": "Rask gjenlogging",
"logmeal.quickSubtitle": "Logger de samme næringsverdiene som sist.",
"logmeal.manualTitle": "Manuelt",
"logmeal.whatPlaceholder": "Hva spiste du?",
"logmeal.proteinPlaceholder": "protein (g)",
"logmeal.validationTitle": "Fyll ut",
"logmeal.validationBody": "Navn og kalorier trengs for manuell logging.",
"logmeal.photoTip": "Tips: ta bilde av tallerkenen under Skann, så anslår appen for deg du bekrefter alltid.",
"shopping.section.frukt_gront": "Frukt & grønt",
"shopping.section.brod": "Brød",
"shopping.section.mejeri": "Meieri",
"shopping.section.kott_fagel": "Kjøtt & fjærkre",
"shopping.section.fisk": "Fisk",
"shopping.section.chark": "Pålegg",
"shopping.section.frys": "Frys",
"shopping.section.skafferi": "Tørrvarer",
"shopping.section.konserver": "Hermetikk",
"shopping.section.kryddor_bak": "Krydder & baking",
"shopping.section.dryck": "Drikke",
"shopping.section.snacks": "Snacks",
"shopping.section.hygien_ovrigt": "Annet",
"shopping.completedTitle": "Ferdig!",
"household.role.owner": "Eier",
"household.role.adult": "Voksen",
"household.role.member": "Medlem",
"household.role.child": "Barn",
"household.empty": "Du har ingen husstand ennå. Opprett en i onboarding eller bli med via kode.",
"household.shareCode": "Del koden med familien, så deler dere matlager, liste og plan.",
"household.locations": "Oppbevaringssteder",
"household.portionFactor": "×{factor} porsjon",
"mealbox.enjoyTitle": "Vel bekomme!",
"mealbox.enjoyBody": "Porsjonen er logget i Min dag.",
"mealbox.guidanceNote": "Anbefalt frist er veiledende stol på lukt og smak.",
"paywall.soonTitle": "Snart!",
"paywall.soonBody": "Kjøp aktiveres via App Store/Google Play når butikkintegrasjonen slås på (fase 7). Backend-flyten er allerede klar.",
"paywall.popular": "Mest populær",
"paywall.choose": "Velg",
"recipe.saved": "Lagret",
"recipe.save": "Lagre",
"recipe.avgRating": "Snitt {avg} av {count} vurderinger",
"memory.verified": "Bekreftet",
"memory.resume": "Gjenoppta",
"memory.deleteAllTitle": "Slette alt minne?",
"memory.irreversible": "Dette kan ikke angres.",
"profile.consent.personalization": "Personlige funksjoner (minne & smaksprofil)",
"profile.consent.anonymized_improvement": "Anonymisert forbedring av AI-en",
"profile.consent.image_training": "Bildene mine kan brukes til trening",
"profile.consent.health_integration": "Helsedata (Apple Health / Health Connect)",
"profile.consent.location_weather": "Posisjon for værbaserte forslag",
"profile.consent.push_notifications": "Push-varsler",
"profile.modeLabel": "Modus:",
"profile.memorySubtitle": "Se, rett, sett på pause eller slett det plattformen har lært.",
"profile.consentsNote": "Samtykkene er separate personlige funksjoner krever aldri treningssamtykke.",
"profile.aiScansUsed": "AI-skanninger: {used} / {total} denne måneden",
"profile.deleteTitle": "Slette kontoen?",
"profile.deleteBody": "Alle dataene dine slettes permanent i henhold til GDPR.",
"cooking.timerDone": "Ferdig!",
"cooked.deductPantryNote": "Ingrediensene trekkes fra matlageret det som går ut først, brukes først.",
"home.moreItems_one": "+ 1 til …",
"home.moreItems_other": "+ {count} til …",
"shopping.completedBody_one": "1 vare ble lagt inn i matlageret.",
"shopping.completedBody_other": "{count} varer ble lagt inn i matlageret."
}
+338
View File
@@ -0,0 +1,338 @@
{
"common.loading": "Laden …",
"common.error": "Er ging iets mis. Probeer opnieuw.",
"common.retry": "Opnieuw proberen",
"common.save": "Opslaan",
"common.cancel": "Annuleren",
"common.next": "Volgende",
"common.back": "Terug",
"common.done": "Klaar",
"common.skip": "Overslaan",
"common.offline": "Offline laatst opgehaalde gegevens",
"common.estimate": "Schatting",
"tabs.whatToEat": "Wat eten we?",
"tabs.scan": "Scannen",
"tabs.myDay": "Mijn dag",
"tabs.home": "Thuis",
"auth.login": "Inloggen",
"auth.register": "Account aanmaken",
"auth.email": "E-mail",
"auth.password": "Wachtwoord",
"auth.displayName": "Hoe mogen we je noemen?",
"auth.noAccount": "Nieuw hier? Maak een account",
"auth.hasAccount": "Al een account? Log in",
"auth.trialNote": "7 dagen volledige toegang zonder kaart.",
"auth.forgotLink": "Wachtwoord vergeten?",
"auth.forgotTitle": "Wachtwoord opnieuw instellen",
"auth.forgotBody": "Vul je e-mailadres in, dan sturen we een link als het account bestaat.",
"auth.forgotSubmit": "Link versturen",
"auth.forgotSentTitle": "Check je inbox",
"auth.forgotSentBody": "Als het adres een account heeft, hebben we een link gestuurd die 30 minuten geldig is. Open hem op dit apparaat.",
"auth.backToLogin": "Terug naar inloggen",
"auth.resetTitle": "Kies een nieuw wachtwoord",
"auth.resetBody": "De link is 30 minuten geldig en eenmalig te gebruiken. Alle apparaten worden uitgelogd.",
"auth.resetTokenPlaceholder": "Plak de code uit de e-mail",
"auth.newPassword": "Nieuw wachtwoord (minimaal 10 tekens)",
"auth.resetSubmit": "Wachtwoord wijzigen",
"auth.resetDoneTitle": "Klaar!",
"auth.resetDoneBody": "Je wachtwoord is gewijzigd. Log in met het nieuwe wachtwoord.",
"auth.verifyDoneTitle": "E-mail bevestigd",
"auth.verifyDoneBody": "Bedankt! Je account is geverifieerd.",
"auth.verifyFailedTitle": "De link werkte niet",
"auth.verifyFailedBody": "De link is ongeldig of verlopen. Vraag een nieuwe aan via je profiel.",
"onboarding.title": "Vertel ons iets over jezelf",
"onboarding.subtitle": "Alles is optioneel en altijd aan te passen. Hoe meer je invult, hoe beter de suggesties.",
"onboarding.goal": "Wat is je belangrijkste doel?",
"onboarding.diet": "Hoe eet je?",
"onboarding.allergies": "Allergieën en intoleranties",
"onboarding.allergyNote": "Het allergiefilter is altijd strikt gerechten met jouw allergenen worden nooit getoond.",
"onboarding.household": "Jouw huishouden",
"onboarding.householdCreate": "Huishouden aanmaken",
"onboarding.householdJoin": "Deelnemen met code",
"onboarding.householdName": "Naam van het huishouden",
"onboarding.inviteCode": "Uitnodigingscode",
"onboarding.mode": "Hoe precies wil je het?",
"onboarding.modeSimple": "Simpele modus",
"onboarding.modeSimpleDesc": "Fotografeer je eten, accepteer schattingen, minimaal gedoe.",
"onboarding.modeExact": "Exacte modus",
"onboarding.modeExactDesc": "Weeg je eten, voer grammen in, volledige controle over de cijfers.",
"onboarding.notMedical": "{brand} geeft richtlijnen geen medisch advies.",
"wte.title": "Wat eten we?",
"wte.subtitle": "Op basis van wat jullie in huis hebben, wat snel op moet en wat jullie lekker vinden.",
"wte.cravingPlaceholder": "Ik heb zin in … (bijv. romig, Aziatisch, onder 500 kcal)",
"wte.mealBoxFirst": "Kant-en-klaar eten in huis",
"wte.whyTitle": "Waarom deze suggestie?",
"wte.coverage": "{pct}% in huis",
"wte.missing": "Ontbreekt: {items}",
"wte.empty": "Nog geen suggesties vul de voorraad aan of versoepel de filters.",
"wte.refresh": "Nieuwe suggesties",
"scan.title": "Scannen",
"scan.subtitle": "Vul je voorraad aan of registreer een maaltijd met de camera.",
"scan.fridge": "Koelkast",
"scan.freezer": "Vriezer",
"scan.pantry": "Voorraadkast",
"scan.ingredients": "Ingrediënten",
"scan.plate": "Bord",
"scan.receipt": "Kassabon",
"scan.barcode": "Barcode",
"scan.expiry": "THT-datum",
"scan.nutrition": "Voedingswaarde-etiket",
"scan.takePhoto": "Foto maken",
"scan.tips.title": "Zo lukt de analyse het best",
"scan.tips.overview": "Begin met een overzichtsfoto",
"scan.tips.shelf": "Fotografeer plank voor plank",
"scan.tips.light": "Vermijd donker en schaduw",
"scan.tips.move": "Verplaats producten die elkaar bedekken",
"scan.analyzing": "Analyseren …",
"scan.quotaLeft_one": "1 AI-scan over deze maand",
"scan.quotaLeft_other": "{count} AI-scans over deze maand",
"scan.review.title": "Controleer het resultaat",
"scan.review.subtitle": "De AI twijfelt soms jij beslist. Pas aan, verwijder of vul aan vóór het opslaan.",
"scan.review.uncertain": "Onzeker controleer",
"scan.review.approveAll": "Opslaan in voorraad",
"scan.review.rejected": "Verwijderd",
"scan.failed": "De analyse is mislukt. Probeer opnieuw of voer handmatig in.",
"myday.title": "Mijn dag",
"myday.calories": "Calorieën",
"myday.protein": "Eiwitten",
"myday.carbs": "Koolhydraten",
"myday.fat": "Vetten",
"myday.fiber": "Vezels",
"myday.salt": "Zout",
"myday.remaining": "{kcal} kcal over",
"myday.over": "{kcal} kcal boven je doel",
"myday.logMeal": "Maaltijd registreren",
"myday.noMeals": "Vandaag nog geen maaltijden geregistreerd.",
"myday.estimateNote": "Waarden met ~ zijn schattingen die je kunt aanpassen.",
"myday.targetsNote": "Doelen zijn richtlijnen, geen medisch advies.",
"home.title": "Thuis",
"home.inventory": "Voorraad",
"home.useSoon": "Snel gebruiken",
"home.mealBoxes": "Meal-prep bakjes",
"home.shopping": "Boodschappenlijst",
"home.budget": "Voedselbudget",
"home.household": "Huishouden",
"home.waste": "Voedselverspilling",
"home.emptyInventory": "De voorraad is leeg. Scan de koelkast of voeg handmatig toe.",
"home.expiresIn_one": "Nog 1 dag",
"home.expiresIn_other": "Nog {days} dagen",
"home.expiresToday": "Verloopt vandaag",
"home.expired": "Verlopen",
"home.pastBestBefore": "THT verstreken eerst ruiken en proeven",
"recipe.portions_one": "1 portie",
"recipe.portions_other": "{count} porties",
"recipe.time": "{min} min",
"recipe.perPortion": "per portie",
"recipe.ingredients": "Ingrediënten",
"recipe.steps": "Bereiding",
"recipe.cook": "Nu koken",
"recipe.notSafe": "Past niet bij je voedingsinstellingen",
"recipe.substitutions": "Vervangen",
"recipe.iCookedThis": "Dit heb ik gekookt",
"recipe.cost": "ca. {amount}/portie",
"cooking.step": "Stap {current} van {total}",
"cooking.timer": "Timer starten",
"cooking.timerRunning": "{time} over",
"cooking.finish": "Klaar maaltijd registreren",
"cooking.scale": "Porties",
"cooking.keepAwake": "Het scherm blijft aan tijdens het koken",
"cooked.title": "Hoe ging het?",
"cooked.portionsCooked": "Gekookte porties",
"cooked.whoAte": "Wie hebben er gegeten?",
"cooked.mealBoxes": "Porties voor meal-prep",
"cooked.deductPantry": "Ingrediënten van voorraad afboeken",
"cooked.rate": "Beoordelen",
"shopping.title": "Boodschappenlijst",
"shopping.addPlaceholder": "Product toevoegen …",
"shopping.complete": "Boodschappen afronden",
"shopping.completeNote": "Afgevinkte producten gaan naar de voorraad.",
"shopping.empty": "De lijst is leeg.",
"shopping.estimated": "ca. {amount}",
"shopping.estimatedTotal": "Geschat totaal: ca. {amount}",
"mealbox.title": "Meal-prep bakjes",
"mealbox.eatBy": "Eten vóór {date}",
"mealbox.portionsLeft_one": "Nog 1 portie",
"mealbox.portionsLeft_other": "Nog {count} porties",
"mealbox.eat": "Nu eten",
"mealbox.empty": "Momenteel geen bakjes. Tijdens het koken kun je hier porties bewaren.",
"household.members": "Leden",
"household.invite": "Nodig uit met code: {code}",
"household.shared": "Gedeeld in het huishouden: voorraad, boodschappenlijst, weekplanning, bakjes en budget.",
"household.private": "Privé per persoon: doelen, allergieën, gezondheidsgegevens en maaltijdgeschiedenis.",
"memory.title": "Wat {brand} over mij weet",
"memory.subtitle": "Volledige transparantie. Corrigeer, pauzeer of verwijder jouw geheugen is van jou.",
"memory.paused": "Gepauzeerd",
"memory.verify": "Klopt",
"memory.pause": "Pauzeren",
"memory.delete": "Verwijderen",
"memory.deleteAll": "Volledig geheugen wissen",
"memory.empty": "{brand} heeft nog niets over je geleerd.",
"memory.origin.user_stated": "Zelf verteld",
"memory.origin.observed": "Waargenomen patroon",
"memory.origin.ai_inferred": "AI-aanname",
"paywall.title": "{brand} Premium",
"paywall.subtitle": "Het voedsel-OS van het hele huishouden: onbeperkte voorraad, weekplanning en delen.",
"paywall.trialActive_one": "Nog 1 dag proefperiode",
"paywall.trialActive_other": "Nog {days} dagen proefperiode",
"paywall.household": "Household · tot 3 personen",
"paywall.family": "Family · tot 6 personen",
"paywall.large": "Large Household · tot 12 personen",
"paywall.perMonth": "{price}/maand",
"paywall.fairUse": "Fair-use quotum voor AI-scans inbegrepen.",
"paywall.restore": "Aankopen herstellen",
"paywall.freeNote": "Gratis: 10 AI-scans/maand, handmatige voorraad, opgeslagen recepten en eenvoudig registreren.",
"profile.title": "Profiel",
"profile.goals": "Doelen & voeding",
"profile.consents": "Toestemmingen & gegevens",
"profile.memory": "Wat {brand} over mij weet",
"profile.subscription": "Abonnement",
"profile.logout": "Uitloggen",
"profile.export": "Mijn gegevens exporteren",
"profile.deleteAccount": "Account verwijderen",
"profile.language": "Taal",
"profile.emailUnverified": "E-mailadres niet bevestigd",
"profile.resendVerification": "Bevestigingsmail opnieuw sturen",
"profile.verificationSent": "Verstuurd! Check je inbox.",
"profile.language.sv": "Svenska",
"profile.language.en": "English",
"profile.language.es": "Español",
"profile.language.it": "Italiano",
"profile.language.de": "Deutsch",
"profile.language.fr": "Français",
"profile.language.da": "Dansk",
"profile.language.nb": "Norsk",
"profile.language.fi": "Suomi",
"profile.language.nl": "Nederlands",
"profile.language.pl": "Polski",
"profile.language.pt": "Português",
"common.oops": "Oeps",
"common.undo": "Herstel",
"common.remove": "Verwijderen",
"common.add": "Toevoegen",
"common.on": "Aan",
"common.off": "Uit",
"home.thisWeek": "Deze week",
"home.wasteWeek": "Verspilling deze week",
"home.useSoonHint": "THT gaat over kwaliteit ruik, kijk en proef voordat je iets weggooit. De TGT-datum (te gebruiken tot) moet je wél respecteren.",
"scan.review.itemPlaceholder": "Product",
"scan.review.quantityPlaceholder": "Hoeveelheid",
"scan.review.unitPlaceholder": "Eenheid (g, l, st. …)",
"scan.review.datePlaceholder": "JJJJ-MM-DD (optioneel)",
"scan.review.dateKindBestBefore": "THT (ten minste houdbaar tot)",
"scan.review.dateKindUseBy": "TGT (te gebruiken tot)",
"scan.review.bestBeforeNote": "Kwaliteitsdatum vaak nog prima daarna. Ruik en proef voordat je weggooit.",
"scan.review.useByNote": "Veiligheidsdatum niet meer eten na deze datum.",
"scan.review.addItem": "+ Product toevoegen",
"scan.review.unknownItem": "Onbekend product",
"barcode.aim": "Richt de camera op de streepjescode",
"barcode.addPrompt": "Toevoegen aan de voorraad?",
"barcode.kcalPer100": "{kcal} kcal/100 g",
"barcode.noNutrition": "Geen voedingswaarden",
"barcode.unknownTitle": "Onbekend product",
"barcode.unknownBody": "Dit product staat nog niet in de database. Fotografeer de voorkant en het voedingsetiket, dan voegen we het toe.",
"barcode.photoPackage": "Fotografeer de verpakking",
"barcode.needHousehold": "Je hebt eerst een huishouden nodig.",
"barcode.noLocation": "Geen bewaarplek gevonden.",
"barcode.cameraTitle": "Camera nodig",
"barcode.cameraBody": "{brand} heeft de camera nodig om streepjescodes te scannen.",
"barcode.allowCamera": "Camera toestaan",
"onboarding.goal.lose_weight": "Afvallen",
"onboarding.goal.build_muscle": "Spieren opbouwen",
"onboarding.goal.maintain_weight": "Op gewicht blijven",
"onboarding.goal.more_protein": "Meer eiwitten eten",
"onboarding.goal.less_waste": "Minder verspillen",
"onboarding.goal.lower_cost": "Minder uitgeven aan eten",
"onboarding.goal.cook_more": "Vaker thuis koken",
"onboarding.diet.omnivore": "Alleseter",
"onboarding.diet.flexitarian": "Flexitariër",
"onboarding.diet.pescatarian": "Pescotariër",
"onboarding.diet.vegetarian": "Vegetariër",
"onboarding.diet.vegan": "Veganist",
"onboarding.allergen.gluten": "Gluten",
"onboarding.allergen.milk": "Melk/lactose",
"onboarding.allergen.eggs": "Eieren",
"onboarding.allergen.tree_nuts": "Noten",
"onboarding.allergen.peanuts": "Pinda's",
"onboarding.allergen.fish": "Vis",
"onboarding.allergen.crustaceans": "Schaaldieren",
"onboarding.allergen.soy": "Soja",
"onboarding.allergen.sesame": "Sesam",
"onboarding.bodyTitle": "Voor persoonlijke caloriedoelen (optioneel)",
"onboarding.weightPlaceholder": "Gewicht (kg)",
"onboarding.heightPlaceholder": "Lengte (cm)",
"onboarding.birthYearPlaceholder": "Geboortejaar",
"onboarding.householdDefaultName": "Thuis",
"onboarding.modesCombine": "De standen zijn te combineren simpel in het dagelijks leven, exact wanneer je wilt.",
"myday.mealType.breakfast": "Ontbijt",
"myday.mealType.lunch": "Lunch",
"myday.mealType.dinner": "Avondeten",
"myday.mealType.snack": "Tussendoortje",
"myday.mealType.dessert": "Toetje",
"myday.overRecommended": "Boven aanbevolen",
"myday.todaysMeals": "Maaltijden van vandaag",
"myday.estimateRange": "{min}{max} kcal, waarschijnlijk {kcal}",
"myday.kcalProtein": "{kcal} kcal · {protein} g eiwit",
"logmeal.mealTypeTitle": "Maaltijdtype",
"logmeal.quickTitle": "Snel opnieuw loggen",
"logmeal.quickSubtitle": "Logt dezelfde voedingswaarden als de vorige keer.",
"logmeal.manualTitle": "Handmatig",
"logmeal.whatPlaceholder": "Wat heb je gegeten?",
"logmeal.proteinPlaceholder": "eiwit (g)",
"logmeal.validationTitle": "Vul in",
"logmeal.validationBody": "Naam en calorieën zijn nodig voor handmatig loggen.",
"logmeal.photoTip": "Tip: fotografeer je bord onder Scannen en de app schat het voor je jij bevestigt altijd.",
"shopping.section.frukt_gront": "Groente & fruit",
"shopping.section.brod": "Brood",
"shopping.section.mejeri": "Zuivel",
"shopping.section.kott_fagel": "Vlees & gevogelte",
"shopping.section.fisk": "Vis",
"shopping.section.chark": "Vleeswaren",
"shopping.section.frys": "Diepvries",
"shopping.section.skafferi": "Voorraadkast",
"shopping.section.konserver": "Conserven",
"shopping.section.kryddor_bak": "Kruiden & bakken",
"shopping.section.dryck": "Dranken",
"shopping.section.snacks": "Snacks",
"shopping.section.hygien_ovrigt": "Overig",
"shopping.completedTitle": "Klaar!",
"household.role.owner": "Eigenaar",
"household.role.adult": "Volwassene",
"household.role.member": "Lid",
"household.role.child": "Kind",
"household.empty": "Je hebt nog geen huishouden. Maak er een aan in de onboarding of doe mee met een code.",
"household.shareCode": "Deel de code met je gezin om voorraad, lijst en planning te delen.",
"household.locations": "Bewaarplekken",
"household.portionFactor": "×{factor} portie",
"mealbox.enjoyTitle": "Eet smakelijk!",
"mealbox.enjoyBody": "De portie is gelogd in Mijn dag.",
"mealbox.guidanceNote": "De aanbevolen termijn is een richtlijn vertrouw op geur en smaak.",
"paywall.soonTitle": "Binnenkort!",
"paywall.soonBody": "Aankopen lopen via de App Store/Google Play zodra de winkelintegratie aanstaat (fase 7). De backend is al klaar.",
"paywall.popular": "Populairst",
"paywall.choose": "Kies",
"recipe.saved": "Opgeslagen",
"recipe.save": "Opslaan",
"recipe.avgRating": "Gemiddeld {avg} uit {count} beoordelingen",
"memory.verified": "Geverifieerd",
"memory.resume": "Hervatten",
"memory.deleteAllTitle": "Alle geheugen wissen?",
"memory.irreversible": "Dit kan niet ongedaan worden gemaakt.",
"profile.consent.personalization": "Persoonlijke functies (geheugen & smaakprofiel)",
"profile.consent.anonymized_improvement": "Geanonimiseerde verbetering van de AI",
"profile.consent.image_training": "Mijn foto's mogen voor training worden gebruikt",
"profile.consent.health_integration": "Gezondheidsgegevens (Apple Health / Health Connect)",
"profile.consent.location_weather": "Locatie voor weersuggesties",
"profile.consent.push_notifications": "Pushmeldingen",
"profile.modeLabel": "Modus:",
"profile.memorySubtitle": "Bekijk, corrigeer, pauzeer of wis wat het platform heeft geleerd.",
"profile.consentsNote": "Toestemmingen zijn gescheiden persoonlijke functies vereisen nooit trainingstoestemming.",
"profile.aiScansUsed": "AI-scans: {used} / {total} deze maand",
"profile.deleteTitle": "Account verwijderen?",
"profile.deleteBody": "Al je gegevens worden permanent verwijderd volgens de AVG.",
"cooking.timerDone": "Klaar!",
"cooked.deductPantryNote": "Ingrediënten worden van de voorraad afgeboekt wat het eerst afloopt, gaat eerst.",
"home.moreItems_one": "+ nog 1 …",
"home.moreItems_other": "+ nog {count} …",
"shopping.completedBody_one": "1 product is aan de voorraad toegevoegd.",
"shopping.completedBody_other": "{count} producten zijn aan de voorraad toegevoegd."
}
+352
View File
@@ -0,0 +1,352 @@
{
"common.loading": "Ładowanie …",
"common.error": "Coś poszło nie tak. Spróbuj ponownie.",
"common.retry": "Spróbuj ponownie",
"common.save": "Zapisz",
"common.cancel": "Anuluj",
"common.next": "Dalej",
"common.back": "Wstecz",
"common.done": "Gotowe",
"common.skip": "Pomiń",
"common.offline": "Offline ostatnio pobrane dane",
"common.estimate": "Szacunek",
"tabs.whatToEat": "Co jemy?",
"tabs.scan": "Skanuj",
"tabs.myDay": "Mój dzień",
"tabs.home": "W domu",
"auth.login": "Zaloguj się",
"auth.register": "Utwórz konto",
"auth.email": "E-mail",
"auth.password": "Hasło",
"auth.displayName": "Jak mamy się do Ciebie zwracać?",
"auth.noAccount": "Nowy tutaj? Utwórz konto",
"auth.hasAccount": "Masz już konto? Zaloguj się",
"auth.trialNote": "7 dni pełnego dostępu bez karty.",
"auth.forgotLink": "Nie pamiętasz hasła?",
"auth.forgotTitle": "Zresetuj hasło",
"auth.forgotBody": "Podaj swój e-mail, a wyślemy link, jeśli konto istnieje.",
"auth.forgotSubmit": "Wyślij link",
"auth.forgotSentTitle": "Sprawdź skrzynkę",
"auth.forgotSentBody": "Jeśli adres ma konto, wysłaliśmy link ważny 30 minut. Otwórz go na tym urządzeniu.",
"auth.backToLogin": "Wróć do logowania",
"auth.resetTitle": "Wybierz nowe hasło",
"auth.resetBody": "Link jest ważny 30 minut i można go użyć tylko raz. Wszystkie urządzenia zostaną wylogowane.",
"auth.resetTokenPlaceholder": "Wklej kod z e-maila",
"auth.newPassword": "Nowe hasło (min. 10 znaków)",
"auth.resetSubmit": "Zmień hasło",
"auth.resetDoneTitle": "Gotowe!",
"auth.resetDoneBody": "Hasło zostało zmienione. Zaloguj się nowym hasłem.",
"auth.verifyDoneTitle": "E-mail potwierdzony",
"auth.verifyDoneBody": "Dziękujemy! Twoje konto jest zweryfikowane.",
"auth.verifyFailedTitle": "Link nie zadziałał",
"auth.verifyFailedBody": "Link jest nieprawidłowy lub wygasł. Poproś o nowy w profilu.",
"onboarding.title": "Opowiedz nam coś o sobie",
"onboarding.subtitle": "Wszystko jest opcjonalne i można to zmienić w każdej chwili. Im więcej uzupełnisz, tym lepsze propozycje.",
"onboarding.goal": "Jaki jest Twój główny cel?",
"onboarding.diet": "Jak się odżywiasz?",
"onboarding.allergies": "Alergie i nietolerancje",
"onboarding.allergyNote": "Filtr alergii jest zawsze rygorystyczny dania z Twoimi alergenami nigdy się nie pojawią.",
"onboarding.household": "Twoje gospodarstwo",
"onboarding.householdCreate": "Utwórz gospodarstwo",
"onboarding.householdJoin": "Dołącz kodem",
"onboarding.householdName": "Nazwa gospodarstwa",
"onboarding.inviteCode": "Kod zaproszenia",
"onboarding.mode": "Jak dokładnie chcesz to robić?",
"onboarding.modeSimple": "Tryb prosty",
"onboarding.modeSimpleDesc": "Fotografuj jedzenie, akceptuj szacunki, minimum zachodu.",
"onboarding.modeExact": "Tryb dokładny",
"onboarding.modeExactDesc": "Waż jedzenie, wpisuj gramy, pełna kontrola nad liczbami.",
"onboarding.notMedical": "{brand} to wskazówki nie porada medyczna.",
"wte.title": "Co jemy?",
"wte.subtitle": "Na podstawie tego, co macie w domu, co trzeba wkrótce zużyć i co lubicie.",
"wte.cravingPlaceholder": "Mam ochotę na … (np. kremowe, azjatyckie, poniżej 500 kcal)",
"wte.mealBoxFirst": "Gotowe jedzenie w domu",
"wte.whyTitle": "Dlaczego ta propozycja?",
"wte.coverage": "{pct}% w domu",
"wte.missing": "Brakuje: {items}",
"wte.empty": "Brak propozycji dodaj jedzenie do spiżarni albo poluzuj filtry.",
"wte.refresh": "Nowe propozycje",
"scan.title": "Skanuj",
"scan.subtitle": "Uzupełnij spiżarnię lub zarejestruj posiłek aparatem.",
"scan.fridge": "Lodówka",
"scan.freezer": "Zamrażarka",
"scan.pantry": "Spiżarnia",
"scan.ingredients": "Składniki",
"scan.plate": "Talerz",
"scan.receipt": "Paragon",
"scan.barcode": "Kod kreskowy",
"scan.expiry": "Data przydatności",
"scan.nutrition": "Etykieta wartości odżywczych",
"scan.takePhoto": "Zrób zdjęcie",
"scan.tips.title": "Jak uzyskać najlepszą analizę",
"scan.tips.overview": "Zacznij od zdjęcia ogólnego",
"scan.tips.shelf": "Fotografuj półka po półce",
"scan.tips.light": "Unikaj ciemności i cieni",
"scan.tips.move": "Rozsuń zasłaniające się produkty",
"scan.analyzing": "Analizowanie …",
"scan.quotaLeft_one": "Został 1 skan AI w tym miesiącu",
"scan.quotaLeft_few": "Zostały {count} skany AI w tym miesiącu",
"scan.quotaLeft_many": "Zostało {count} skanów AI w tym miesiącu",
"scan.quotaLeft_other": "Zostało {count} skanu AI w tym miesiącu",
"scan.review.title": "Sprawdź wynik",
"scan.review.subtitle": "AI czasem się waha Ty decydujesz. Popraw, usuń lub dodaj przed zapisaniem.",
"scan.review.uncertain": "Niepewne sprawdź",
"scan.review.approveAll": "Zapisz do spiżarni",
"scan.review.rejected": "Usunięto",
"scan.failed": "Analiza się nie powiodła. Spróbuj ponownie lub dodaj ręcznie.",
"myday.title": "Mój dzień",
"myday.calories": "Kalorie",
"myday.protein": "Białko",
"myday.carbs": "Węglowodany",
"myday.fat": "Tłuszcze",
"myday.fiber": "Błonnik",
"myday.salt": "Sól",
"myday.remaining": "Pozostało {kcal} kcal",
"myday.over": "{kcal} kcal ponad cel",
"myday.logMeal": "Zarejestruj posiłek",
"myday.noMeals": "Dziś nie zarejestrowano posiłków.",
"myday.estimateNote": "Wartości z ~ to szacunki, które możesz poprawić.",
"myday.targetsNote": "Cele to wskazówki, nie porada medyczna.",
"home.title": "W domu",
"home.inventory": "Spiżarnia",
"home.useSoon": "Zużyj wkrótce",
"home.mealBoxes": "Pojemniki z jedzeniem",
"home.shopping": "Lista zakupów",
"home.budget": "Budżet na jedzenie",
"home.household": "Gospodarstwo",
"home.waste": "Marnowanie żywności",
"home.emptyInventory": "Spiżarnia jest pusta. Zeskanuj lodówkę albo dodaj produkty ręcznie.",
"home.expiresIn_one": "Został 1 dzień",
"home.expiresIn_few": "Zostały {days} dni",
"home.expiresIn_many": "Zostało {days} dni",
"home.expiresIn_other": "Zostało {days} dnia",
"home.expiresToday": "Kończy się dziś",
"home.expired": "Przeterminowane",
"home.pastBestBefore": "Po terminie przydatności najpierw powąchaj i spróbuj",
"recipe.portions_one": "1 porcja",
"recipe.portions_few": "{count} porcje",
"recipe.portions_many": "{count} porcji",
"recipe.portions_other": "{count} porcji",
"recipe.time": "{min} min",
"recipe.perPortion": "na porcję",
"recipe.ingredients": "Składniki",
"recipe.steps": "Przygotowanie",
"recipe.cook": "Gotuj teraz",
"recipe.notSafe": "Nie pasuje do Twoich ustawień żywieniowych",
"recipe.substitutions": "Zamień",
"recipe.iCookedThis": "Ugotowałem/-am to",
"recipe.cost": "ok. {amount}/porcja",
"cooking.step": "Krok {current} z {total}",
"cooking.timer": "Uruchom minutnik",
"cooking.timerRunning": "Pozostało {time}",
"cooking.finish": "Gotowe zarejestruj posiłek",
"cooking.scale": "Porcje",
"cooking.keepAwake": "Ekran pozostaje włączony podczas gotowania",
"cooked.title": "Jak poszło?",
"cooked.portionsCooked": "Ugotowane porcje",
"cooked.whoAte": "Kto jadł?",
"cooked.mealBoxes": "Porcje do pojemników",
"cooked.deductPantry": "Odejmij składniki ze spiżarni",
"cooked.rate": "Oceń",
"shopping.title": "Lista zakupów",
"shopping.addPlaceholder": "Dodaj produkt …",
"shopping.complete": "Zakończ zakupy",
"shopping.completeNote": "Odhaczone produkty trafiają do spiżarni.",
"shopping.empty": "Lista jest pusta.",
"shopping.estimated": "ok. {amount}",
"shopping.estimatedTotal": "Szacowana suma: ok. {amount}",
"mealbox.title": "Pojemniki z jedzeniem",
"mealbox.eatBy": "Zjedz do {date}",
"mealbox.portionsLeft_one": "Została 1 porcja",
"mealbox.portionsLeft_few": "Zostały {count} porcje",
"mealbox.portionsLeft_many": "Zostało {count} porcji",
"mealbox.portionsLeft_other": "Zostało {count} porcji",
"mealbox.eat": "Zjedz teraz",
"mealbox.empty": "Brak pojemników. Gotując, możesz zapisać tu porcje.",
"household.members": "Członkowie",
"household.invite": "Zaproś kodem: {code}",
"household.shared": "Wspólne w gospodarstwie: spiżarnia, lista zakupów, plan tygodnia, pojemniki i budżet.",
"household.private": "Prywatne dla każdej osoby: cele, alergie, dane zdrowotne i historia posiłków.",
"memory.title": "Co {brand} o mnie wie",
"memory.subtitle": "Pełna przejrzystość. Poprawiaj, wstrzymuj lub usuwaj Twoja pamięć należy do Ciebie.",
"memory.paused": "Wstrzymane",
"memory.verify": "Zgadza się",
"memory.pause": "Wstrzymaj",
"memory.delete": "Usuń",
"memory.deleteAll": "Usuń całą pamięć",
"memory.empty": "{brand} jeszcze niczego się o Tobie nie nauczył.",
"memory.origin.user_stated": "Sam(a) powiedziałeś/-aś",
"memory.origin.observed": "Zaobserwowany wzorzec",
"memory.origin.ai_inferred": "Przypuszczenie AI",
"paywall.title": "{brand} Premium",
"paywall.subtitle": "System operacyjny jedzenia dla całego gospodarstwa: nielimitowana spiżarnia, plan tygodnia i udostępnianie.",
"paywall.trialActive_one": "Został 1 dzień okresu próbnego",
"paywall.trialActive_few": "Zostały {days} dni okresu próbnego",
"paywall.trialActive_many": "Zostało {days} dni okresu próbnego",
"paywall.trialActive_other": "Zostało {days} dnia okresu próbnego",
"paywall.household": "Household · do 3 osób",
"paywall.family": "Family · do 6 osób",
"paywall.large": "Large Household · do 12 osób",
"paywall.perMonth": "{price}/mies.",
"paywall.fairUse": "Limit fair use skanów AI w cenie.",
"paywall.restore": "Przywróć zakupy",
"paywall.freeNote": "Za darmo: 10 skanów AI/mies., ręczna spiżarnia, zapisane przepisy i proste rejestrowanie.",
"profile.title": "Profil",
"profile.goals": "Cele i dieta",
"profile.consents": "Zgody i dane",
"profile.memory": "Co {brand} o mnie wie",
"profile.subscription": "Subskrypcja",
"profile.logout": "Wyloguj się",
"profile.export": "Eksportuj moje dane",
"profile.deleteAccount": "Usuń konto",
"profile.language": "Język",
"profile.emailUnverified": "Adres e-mail niepotwierdzony",
"profile.resendVerification": "Wyślij ponownie e-mail potwierdzający",
"profile.verificationSent": "Wysłano! Sprawdź skrzynkę.",
"profile.language.sv": "Svenska",
"profile.language.en": "English",
"profile.language.es": "Español",
"profile.language.it": "Italiano",
"profile.language.de": "Deutsch",
"profile.language.fr": "Français",
"profile.language.da": "Dansk",
"profile.language.nb": "Norsk",
"profile.language.fi": "Suomi",
"profile.language.nl": "Nederlands",
"profile.language.pl": "Polski",
"profile.language.pt": "Português",
"common.oops": "Ups",
"common.undo": "Cofnij",
"common.remove": "Usuń",
"common.add": "Dodaj",
"common.on": "Wł.",
"common.off": "Wył.",
"home.thisWeek": "W tym tygodniu",
"home.wasteWeek": "Marnowanie w tym tygodniu",
"home.useSoonHint": "„Najlepiej spożyć przed” dotyczy jakości powąchaj, obejrzyj i spróbuj, zanim wyrzucisz. Terminu „należy spożyć do” należy natomiast przestrzegać.",
"scan.review.itemPlaceholder": "Produkt",
"scan.review.quantityPlaceholder": "Ilość",
"scan.review.unitPlaceholder": "Jednostka (g, l, szt. …)",
"scan.review.datePlaceholder": "RRRR-MM-DD (opcjonalnie)",
"scan.review.dateKindBestBefore": "Najlepiej spożyć przed",
"scan.review.dateKindUseBy": "Należy spożyć do",
"scan.review.bestBeforeNote": "Data jakości produkt często jest dobry dłużej. Powąchaj i spróbuj, zanim wyrzucisz.",
"scan.review.useByNote": "Data bezpieczeństwa nie jedz po tej dacie.",
"scan.review.addItem": "+ Dodaj produkt",
"scan.review.unknownItem": "Nieznany produkt",
"barcode.aim": "Skieruj aparat na kod kreskowy",
"barcode.addPrompt": "Dodać do spiżarni?",
"barcode.kcalPer100": "{kcal} kcal/100 g",
"barcode.noNutrition": "Brak danych żywieniowych",
"barcode.unknownTitle": "Nieznany produkt",
"barcode.unknownBody": "Tego produktu nie ma jeszcze w bazie. Sfotografuj przód i etykietę wartości odżywczych, a my go dodamy.",
"barcode.photoPackage": "Sfotografuj opakowanie",
"barcode.needHousehold": "Najpierw potrzebujesz gospodarstwa.",
"barcode.noLocation": "Nie znaleziono miejsca przechowywania.",
"barcode.cameraTitle": "Potrzebny aparat",
"barcode.cameraBody": "{brand} potrzebuje aparatu do skanowania kodów kreskowych.",
"barcode.allowCamera": "Zezwól na aparat",
"onboarding.goal.lose_weight": "Schudnąć",
"onboarding.goal.build_muscle": "Zbudować mięśnie",
"onboarding.goal.maintain_weight": "Utrzymać wagę",
"onboarding.goal.more_protein": "Jeść więcej białka",
"onboarding.goal.less_waste": "Mniej marnować",
"onboarding.goal.lower_cost": "Wydawać mniej na jedzenie",
"onboarding.goal.cook_more": "Częściej gotować w domu",
"onboarding.diet.omnivore": "Wszystkożerca",
"onboarding.diet.flexitarian": "Fleksitarianin",
"onboarding.diet.pescatarian": "Peskatarianin",
"onboarding.diet.vegetarian": "Wegetarianin",
"onboarding.diet.vegan": "Weganin",
"onboarding.allergen.gluten": "Gluten",
"onboarding.allergen.milk": "Mleko/laktoza",
"onboarding.allergen.eggs": "Jaja",
"onboarding.allergen.tree_nuts": "Orzechy",
"onboarding.allergen.peanuts": "Orzeszki ziemne",
"onboarding.allergen.fish": "Ryby",
"onboarding.allergen.crustaceans": "Skorupiaki",
"onboarding.allergen.soy": "Soja",
"onboarding.allergen.sesame": "Sezam",
"onboarding.bodyTitle": "Dla osobistych celów kalorycznych (opcjonalnie)",
"onboarding.weightPlaceholder": "Waga (kg)",
"onboarding.heightPlaceholder": "Wzrost (cm)",
"onboarding.birthYearPlaceholder": "Rok urodzenia",
"onboarding.householdDefaultName": "Dom",
"onboarding.modesCombine": "Tryby można łączyć na co dzień prosto, dokładnie, gdy chcesz.",
"myday.mealType.breakfast": "Śniadanie",
"myday.mealType.lunch": "Obiad",
"myday.mealType.dinner": "Kolacja",
"myday.mealType.snack": "Przekąska",
"myday.mealType.dessert": "Deser",
"myday.overRecommended": "Powyżej zaleceń",
"myday.todaysMeals": "Dzisiejsze posiłki",
"myday.estimateRange": "{min}{max} kcal, prawdopodobnie {kcal}",
"myday.kcalProtein": "{kcal} kcal · {protein} g białka",
"logmeal.mealTypeTitle": "Rodzaj posiłku",
"logmeal.quickTitle": "Szybkie ponowne dodanie",
"logmeal.quickSubtitle": "Zapisuje te same wartości odżywcze co ostatnio.",
"logmeal.manualTitle": "Ręcznie",
"logmeal.whatPlaceholder": "Co zjadłeś/aś?",
"logmeal.proteinPlaceholder": "białko (g)",
"logmeal.validationTitle": "Uzupełnij",
"logmeal.validationBody": "Do ręcznego dodania potrzebne są nazwa i kalorie.",
"logmeal.photoTip": "Wskazówka: sfotografuj talerz w zakładce Skanuj, a aplikacja oszacuje za Ciebie zawsze potwierdzasz.",
"shopping.section.frukt_gront": "Owoce i warzywa",
"shopping.section.brod": "Pieczywo",
"shopping.section.mejeri": "Nabiał",
"shopping.section.kott_fagel": "Mięso i drób",
"shopping.section.fisk": "Ryby",
"shopping.section.chark": "Wędliny",
"shopping.section.frys": "Mrożonki",
"shopping.section.skafferi": "Spiżarnia",
"shopping.section.konserver": "Konserwy",
"shopping.section.kryddor_bak": "Przyprawy i pieczenie",
"shopping.section.dryck": "Napoje",
"shopping.section.snacks": "Przekąski",
"shopping.section.hygien_ovrigt": "Inne",
"shopping.completedTitle": "Gotowe!",
"household.role.owner": "Właściciel",
"household.role.adult": "Dorosły",
"household.role.member": "Członek",
"household.role.child": "Dziecko",
"household.empty": "Nie masz jeszcze gospodarstwa. Utwórz je podczas konfiguracji lub dołącz kodem.",
"household.shareCode": "Udostępnij kod rodzinie, aby dzielić spiżarnię, listę i plan.",
"household.locations": "Miejsca przechowywania",
"household.portionFactor": "×{factor} porcji",
"mealbox.enjoyTitle": "Smacznego!",
"mealbox.enjoyBody": "Porcja została zapisana w Mój dzień.",
"mealbox.guidanceNote": "Zalecany termin to wskazówka zaufaj węchowi i smakowi.",
"paywall.soonTitle": "Wkrótce!",
"paywall.soonBody": "Zakupy będą aktywowane przez App Store/Google Play po włączeniu integracji ze sklepami (faza 7). Backend jest już gotowy.",
"paywall.popular": "Najpopularniejszy",
"paywall.choose": "Wybierz",
"recipe.saved": "Zapisany",
"recipe.save": "Zapisz",
"recipe.avgRating": "Średnia {avg} z {count} ocen",
"memory.verified": "Zweryfikowane",
"memory.resume": "Wznów",
"memory.deleteAllTitle": "Usunąć całą pamięć?",
"memory.irreversible": "Tego nie można cofnąć.",
"profile.consent.personalization": "Funkcje osobiste (pamięć i profil smaku)",
"profile.consent.anonymized_improvement": "Anonimowe ulepszanie AI",
"profile.consent.image_training": "Moje zdjęcia mogą służyć do trenowania",
"profile.consent.health_integration": "Dane zdrowotne (Apple Health / Health Connect)",
"profile.consent.location_weather": "Lokalizacja dla propozycji zależnych od pogody",
"profile.consent.push_notifications": "Powiadomienia push",
"profile.modeLabel": "Tryb:",
"profile.memorySubtitle": "Zobacz, popraw, wstrzymaj lub usuń to, czego nauczyła się platforma.",
"profile.consentsNote": "Zgody są rozdzielne funkcje osobiste nigdy nie wymagają zgody na trenowanie.",
"profile.aiScansUsed": "Skany AI: {used} / {total} w tym miesiącu",
"profile.deleteTitle": "Usunąć konto?",
"profile.deleteBody": "Wszystkie Twoje dane zostaną trwale usunięte zgodnie z RODO.",
"cooking.timerDone": "Gotowe!",
"cooked.deductPantryNote": "Składniki są odejmowane ze spiżarni najpierw te z najkrótszym terminem.",
"home.moreItems_one": "+ jeszcze 1 …",
"home.moreItems_few": "+ jeszcze {count} …",
"home.moreItems_many": "+ jeszcze {count} …",
"home.moreItems_other": "+ jeszcze {count} …",
"shopping.completedBody_one": "1 produkt trafił do spiżarni.",
"shopping.completedBody_few": "{count} produkty trafiły do spiżarni.",
"shopping.completedBody_many": "{count} produktów trafiło do spiżarni.",
"shopping.completedBody_other": "{count} produktu trafiło do spiżarni."
}
+338
View File
@@ -0,0 +1,338 @@
{
"common.loading": "A carregar …",
"common.error": "Algo correu mal. Tente novamente.",
"common.retry": "Tentar novamente",
"common.save": "Guardar",
"common.cancel": "Cancelar",
"common.next": "Seguinte",
"common.back": "Voltar",
"common.done": "Concluído",
"common.skip": "Ignorar",
"common.offline": "Offline a mostrar os últimos dados",
"common.estimate": "Estimativa",
"tabs.whatToEat": "O que vamos comer?",
"tabs.scan": "Digitalizar",
"tabs.myDay": "O meu dia",
"tabs.home": "Em casa",
"auth.login": "Iniciar sessão",
"auth.register": "Criar conta",
"auth.email": "E-mail",
"auth.password": "Palavra-passe",
"auth.displayName": "Como o devemos tratar?",
"auth.noAccount": "Novo por aqui? Crie uma conta",
"auth.hasAccount": "Já tem conta? Inicie sessão",
"auth.trialNote": "7 dias de acesso completo sem cartão.",
"auth.forgotLink": "Esqueceu-se da palavra-passe?",
"auth.forgotTitle": "Repor palavra-passe",
"auth.forgotBody": "Introduza o seu e-mail e enviaremos um link se a conta existir.",
"auth.forgotSubmit": "Enviar link",
"auth.forgotSentTitle": "Verifique a sua caixa de entrada",
"auth.forgotSentBody": "Se o endereço tiver uma conta, enviámos um link válido por 30 minutos. Abra-o neste dispositivo.",
"auth.backToLogin": "Voltar ao início de sessão",
"auth.resetTitle": "Escolha uma nova palavra-passe",
"auth.resetBody": "O link é válido por 30 minutos e de uso único. Todos os dispositivos serão desligados.",
"auth.resetTokenPlaceholder": "Cole o código do e-mail",
"auth.newPassword": "Nova palavra-passe (mín. 10 caracteres)",
"auth.resetSubmit": "Alterar palavra-passe",
"auth.resetDoneTitle": "Concluído!",
"auth.resetDoneBody": "A palavra-passe foi alterada. Inicie sessão com a nova.",
"auth.verifyDoneTitle": "E-mail confirmado",
"auth.verifyDoneBody": "Obrigado! A sua conta está verificada.",
"auth.verifyFailedTitle": "O link não funcionou",
"auth.verifyFailedBody": "O link é inválido ou expirou. Peça um novo no perfil.",
"onboarding.title": "Fale-nos um pouco de si",
"onboarding.subtitle": "Tudo é opcional e pode ser alterado a qualquer momento. Quanto mais preencher, melhores as sugestões.",
"onboarding.goal": "Qual é o seu objetivo principal?",
"onboarding.diet": "Como come?",
"onboarding.allergies": "Alergias e intolerâncias",
"onboarding.allergyNote": "O filtro de alergias é sempre rigoroso pratos com os seus alergénios nunca são mostrados.",
"onboarding.household": "O seu agregado",
"onboarding.householdCreate": "Criar agregado",
"onboarding.householdJoin": "Aderir com código",
"onboarding.householdName": "Nome do agregado",
"onboarding.inviteCode": "Código de convite",
"onboarding.mode": "Que nível de precisão quer?",
"onboarding.modeSimple": "Modo simples",
"onboarding.modeSimpleDesc": "Fotografe a comida, aceite estimativas, zero complicações.",
"onboarding.modeExact": "Modo exato",
"onboarding.modeExactDesc": "Pese a comida, introduza gramas, controlo total dos números.",
"onboarding.notMedical": "{brand} dá orientações não aconselhamento médico.",
"wte.title": "O que vamos comer?",
"wte.subtitle": "Com base no que têm em casa, no que deve ser usado em breve e no que gostam.",
"wte.cravingPlaceholder": "Apetece-me … (ex.: cremoso, asiático, menos de 500 kcal)",
"wte.mealBoxFirst": "Comida pronta em casa",
"wte.whyTitle": "Porquê esta sugestão?",
"wte.coverage": "{pct}% em casa",
"wte.missing": "Falta: {items}",
"wte.empty": "Ainda sem sugestões adicione comida à despensa ou alivie os filtros.",
"wte.refresh": "Novas sugestões",
"scan.title": "Digitalizar",
"scan.subtitle": "Abasteça a despensa ou registe uma refeição com a câmara.",
"scan.fridge": "Frigorífico",
"scan.freezer": "Congelador",
"scan.pantry": "Despensa",
"scan.ingredients": "Ingredientes",
"scan.plate": "Prato",
"scan.receipt": "Talão",
"scan.barcode": "Código de barras",
"scan.expiry": "Data de validade",
"scan.nutrition": "Rótulo nutricional",
"scan.takePhoto": "Tirar foto",
"scan.tips.title": "Como obter a melhor análise",
"scan.tips.overview": "Comece com uma foto geral",
"scan.tips.shelf": "Fotografe prateleira a prateleira",
"scan.tips.light": "Evite escuridão e sombras",
"scan.tips.move": "Afaste produtos que se tapam",
"scan.analyzing": "A analisar …",
"scan.quotaLeft_one": "1 análise de IA restante este mês",
"scan.quotaLeft_other": "{count} análises de IA restantes este mês",
"scan.review.title": "Reveja o resultado",
"scan.review.subtitle": "A IA às vezes hesita você decide. Edite, remova ou acrescente antes de guardar.",
"scan.review.uncertain": "Incerto verifique",
"scan.review.approveAll": "Guardar na despensa",
"scan.review.rejected": "Removido",
"scan.failed": "A análise falhou. Tente novamente ou adicione manualmente.",
"myday.title": "O meu dia",
"myday.calories": "Calorias",
"myday.protein": "Proteínas",
"myday.carbs": "Hidratos",
"myday.fat": "Gorduras",
"myday.fiber": "Fibras",
"myday.salt": "Sal",
"myday.remaining": "Faltam {kcal} kcal",
"myday.over": "{kcal} kcal acima do objetivo",
"myday.logMeal": "Registar refeição",
"myday.noMeals": "Sem refeições registadas hoje.",
"myday.estimateNote": "Valores com ~ são estimativas que pode ajustar.",
"myday.targetsNote": "Os objetivos são orientações, não aconselhamento médico.",
"home.title": "Em casa",
"home.inventory": "Despensa",
"home.useSoon": "Usar em breve",
"home.mealBoxes": "Marmitas",
"home.shopping": "Lista de compras",
"home.budget": "Orçamento alimentar",
"home.household": "Agregado",
"home.waste": "Desperdício",
"home.emptyInventory": "A despensa está vazia. Digitalize o frigorífico ou adicione manualmente.",
"home.expiresIn_one": "Falta 1 dia",
"home.expiresIn_other": "Faltam {days} dias",
"home.expiresToday": "Expira hoje",
"home.expired": "Expirado",
"home.pastBestBefore": "Validade ultrapassada cheire e prove primeiro",
"recipe.portions_one": "1 dose",
"recipe.portions_other": "{count} doses",
"recipe.time": "{min} min",
"recipe.perPortion": "por dose",
"recipe.ingredients": "Ingredientes",
"recipe.steps": "Preparação",
"recipe.cook": "Cozinhar agora",
"recipe.notSafe": "Não se adequa às suas definições alimentares",
"recipe.substitutions": "Substituir",
"recipe.iCookedThis": "Cozinhei isto",
"recipe.cost": "aprox. {amount}/dose",
"cooking.step": "Passo {current} de {total}",
"cooking.timer": "Iniciar temporizador",
"cooking.timerRunning": "Faltam {time}",
"cooking.finish": "Concluído registar a refeição",
"cooking.scale": "Doses",
"cooking.keepAwake": "O ecrã fica ligado enquanto cozinha",
"cooked.title": "Como correu?",
"cooked.portionsCooked": "Doses cozinhadas",
"cooked.whoAte": "Quem comeu?",
"cooked.mealBoxes": "Doses para marmitas",
"cooked.deductPantry": "Descontar ingredientes da despensa",
"cooked.rate": "Avaliar",
"shopping.title": "Lista de compras",
"shopping.addPlaceholder": "Adicionar artigo …",
"shopping.complete": "Terminar as compras",
"shopping.completeNote": "Os artigos marcados vão para a despensa.",
"shopping.empty": "A lista está vazia.",
"shopping.estimated": "aprox. {amount}",
"shopping.estimatedTotal": "Total estimado: aprox. {amount}",
"mealbox.title": "Marmitas",
"mealbox.eatBy": "Comer até {date}",
"mealbox.portionsLeft_one": "Falta 1 dose",
"mealbox.portionsLeft_other": "Faltam {count} doses",
"mealbox.eat": "Comer agora",
"mealbox.empty": "Sem marmitas de momento. Ao cozinhar, pode guardar doses aqui.",
"household.members": "Membros",
"household.invite": "Convide com o código: {code}",
"household.shared": "Partilhado no agregado: despensa, lista de compras, plano semanal, marmitas e orçamento.",
"household.private": "Privado por pessoa: objetivos, alergias, dados de saúde e histórico de refeições.",
"memory.title": "O que o {brand} sabe sobre mim",
"memory.subtitle": "Transparência total. Corrija, pause ou apague a memória é sua.",
"memory.paused": "Em pausa",
"memory.verify": "Correto",
"memory.pause": "Pausar",
"memory.delete": "Apagar",
"memory.deleteAll": "Apagar toda a memória",
"memory.empty": "O {brand} ainda não aprendeu nada sobre si.",
"memory.origin.user_stated": "Disse-nos você",
"memory.origin.observed": "Padrão observado",
"memory.origin.ai_inferred": "Suposição da IA",
"paywall.title": "{brand} Premium",
"paywall.subtitle": "O sistema operativo alimentar de todo o agregado: despensa ilimitada, plano semanal e partilha.",
"paywall.trialActive_one": "Falta 1 dia de período experimental",
"paywall.trialActive_other": "Faltam {days} dias de período experimental",
"paywall.household": "Household · até 3 pessoas",
"paywall.family": "Family · até 6 pessoas",
"paywall.large": "Large Household · até 12 pessoas",
"paywall.perMonth": "{price}/mês",
"paywall.fairUse": "Quota de utilização justa de análises de IA incluída.",
"paywall.restore": "Restaurar compras",
"paywall.freeNote": "Grátis: 10 análises de IA/mês, despensa manual, receitas guardadas e registo simples.",
"profile.title": "Perfil",
"profile.goals": "Objetivos e alimentação",
"profile.consents": "Consentimentos e dados",
"profile.memory": "O que o {brand} sabe sobre mim",
"profile.subscription": "Subscrição",
"profile.logout": "Terminar sessão",
"profile.export": "Exportar os meus dados",
"profile.deleteAccount": "Eliminar conta",
"profile.language": "Idioma",
"profile.emailUnverified": "Endereço de e-mail não confirmado",
"profile.resendVerification": "Reenviar e-mail de confirmação",
"profile.verificationSent": "Enviado! Verifique a caixa de entrada.",
"profile.language.sv": "Svenska",
"profile.language.en": "English",
"profile.language.es": "Español",
"profile.language.it": "Italiano",
"profile.language.de": "Deutsch",
"profile.language.fr": "Français",
"profile.language.da": "Dansk",
"profile.language.nb": "Norsk",
"profile.language.fi": "Suomi",
"profile.language.nl": "Nederlands",
"profile.language.pl": "Polski",
"profile.language.pt": "Português",
"common.oops": "Ops",
"common.undo": "Desfazer",
"common.remove": "Remover",
"common.add": "Adicionar",
"common.on": "Ativado",
"common.off": "Desativado",
"home.thisWeek": "Esta semana",
"home.wasteWeek": "Desperdício esta semana",
"home.useSoonHint": "„A consumir de preferência antes de” é sobre qualidade: cheire, observe e prove antes de deitar fora. Já a data-limite de consumo deve ser respeitada.",
"scan.review.itemPlaceholder": "Produto",
"scan.review.quantityPlaceholder": "Quantidade",
"scan.review.unitPlaceholder": "Unidade (g, l, un. …)",
"scan.review.datePlaceholder": "AAAA-MM-DD (opcional)",
"scan.review.dateKindBestBefore": "Preferencialmente antes de",
"scan.review.dateKindUseBy": "Data-limite de consumo",
"scan.review.bestBeforeNote": "Data de qualidade: o alimento pode estar bom depois dela. Cheire e prove antes de deitar fora.",
"scan.review.useByNote": "Data de segurança: não coma depois desta data.",
"scan.review.addItem": "+ Adicionar produto",
"scan.review.unknownItem": "Produto desconhecido",
"barcode.aim": "Aponte a câmara para o código de barras",
"barcode.addPrompt": "Adicionar à despensa?",
"barcode.kcalPer100": "{kcal} kcal/100 g",
"barcode.noNutrition": "Sem dados nutricionais",
"barcode.unknownTitle": "Produto desconhecido",
"barcode.unknownBody": "Este produto ainda não está na base de dados. Fotografe a frente e o rótulo nutricional e nós adicionamo-lo.",
"barcode.photoPackage": "Fotografar a embalagem",
"barcode.needHousehold": "Primeiro precisa de um agregado.",
"barcode.noLocation": "Nenhum local de armazenamento encontrado.",
"barcode.cameraTitle": "É precisa a câmara",
"barcode.cameraBody": "O {brand} precisa da câmara para ler códigos de barras.",
"barcode.allowCamera": "Permitir câmara",
"onboarding.goal.lose_weight": "Perder peso",
"onboarding.goal.build_muscle": "Ganhar músculo",
"onboarding.goal.maintain_weight": "Manter o peso",
"onboarding.goal.more_protein": "Comer mais proteína",
"onboarding.goal.less_waste": "Reduzir o desperdício",
"onboarding.goal.lower_cost": "Gastar menos em comida",
"onboarding.goal.cook_more": "Cozinhar mais em casa",
"onboarding.diet.omnivore": "Omnívoro",
"onboarding.diet.flexitarian": "Flexitariano",
"onboarding.diet.pescatarian": "Pescetariano",
"onboarding.diet.vegetarian": "Vegetariano",
"onboarding.diet.vegan": "Vegano",
"onboarding.allergen.gluten": "Glúten",
"onboarding.allergen.milk": "Leite/lactose",
"onboarding.allergen.eggs": "Ovos",
"onboarding.allergen.tree_nuts": "Frutos de casca rija",
"onboarding.allergen.peanuts": "Amendoins",
"onboarding.allergen.fish": "Peixe",
"onboarding.allergen.crustaceans": "Crustáceos",
"onboarding.allergen.soy": "Soja",
"onboarding.allergen.sesame": "Sésamo",
"onboarding.bodyTitle": "Para objetivos calóricos pessoais (opcional)",
"onboarding.weightPlaceholder": "Peso (kg)",
"onboarding.heightPlaceholder": "Altura (cm)",
"onboarding.birthYearPlaceholder": "Ano de nascimento",
"onboarding.householdDefaultName": "Casa",
"onboarding.modesCombine": "Os modos podem combinar-se: simples no dia a dia, exato quando quiser.",
"myday.mealType.breakfast": "Pequeno-almoço",
"myday.mealType.lunch": "Almoço",
"myday.mealType.dinner": "Jantar",
"myday.mealType.snack": "Lanche",
"myday.mealType.dessert": "Sobremesa",
"myday.overRecommended": "Acima do recomendado",
"myday.todaysMeals": "Refeições de hoje",
"myday.estimateRange": "{min}{max} kcal, provavelmente {kcal}",
"myday.kcalProtein": "{kcal} kcal · {protein} g de proteína",
"logmeal.mealTypeTitle": "Tipo de refeição",
"logmeal.quickTitle": "Registo rápido",
"logmeal.quickSubtitle": "Regista os mesmos valores nutricionais da última vez.",
"logmeal.manualTitle": "Manual",
"logmeal.whatPlaceholder": "O que comeu?",
"logmeal.proteinPlaceholder": "proteína (g)",
"logmeal.validationTitle": "Preencha",
"logmeal.validationBody": "Nome e calorias são necessários para o registo manual.",
"logmeal.photoTip": "Dica: fotografe o prato em Digitalizar e a app estima por si você confirma sempre.",
"shopping.section.frukt_gront": "Fruta e legumes",
"shopping.section.brod": "Pão",
"shopping.section.mejeri": "Laticínios",
"shopping.section.kott_fagel": "Carne e aves",
"shopping.section.fisk": "Peixe",
"shopping.section.chark": "Charcutaria",
"shopping.section.frys": "Congelados",
"shopping.section.skafferi": "Despensa",
"shopping.section.konserver": "Conservas",
"shopping.section.kryddor_bak": "Especiarias e pastelaria",
"shopping.section.dryck": "Bebidas",
"shopping.section.snacks": "Snacks",
"shopping.section.hygien_ovrigt": "Outros",
"shopping.completedTitle": "Feito!",
"household.role.owner": "Proprietário",
"household.role.adult": "Adulto",
"household.role.member": "Membro",
"household.role.child": "Criança",
"household.empty": "Ainda não tem um agregado. Crie um na configuração inicial ou junte-se com um código.",
"household.shareCode": "Partilhe o código com a família para partilhar despensa, lista e plano.",
"household.locations": "Locais de armazenamento",
"household.portionFactor": "×{factor} dose",
"mealbox.enjoyTitle": "Bom apetite!",
"mealbox.enjoyBody": "A dose foi registada em O meu dia.",
"mealbox.guidanceNote": "O prazo recomendado é indicativo: confie no cheiro e no sabor.",
"paywall.soonTitle": "Em breve!",
"paywall.soonBody": "As compras ativam-se via App Store/Google Play quando a integração com as lojas for ligada (fase 7). O fluxo de backend já está pronto.",
"paywall.popular": "Mais popular",
"paywall.choose": "Escolher",
"recipe.saved": "Guardada",
"recipe.save": "Guardar",
"recipe.avgRating": "Média {avg} em {count} avaliações",
"memory.verified": "Verificado",
"memory.resume": "Retomar",
"memory.deleteAllTitle": "Apagar toda a memória?",
"memory.irreversible": "Isto não pode ser anulado.",
"profile.consent.personalization": "Funções pessoais (memória e perfil de gosto)",
"profile.consent.anonymized_improvement": "Melhoria anonimizada da IA",
"profile.consent.image_training": "As minhas fotos podem ser usadas para treino",
"profile.consent.health_integration": "Dados de saúde (Apple Health / Health Connect)",
"profile.consent.location_weather": "Localização para sugestões conforme o tempo",
"profile.consent.push_notifications": "Notificações push",
"profile.modeLabel": "Modo:",
"profile.memorySubtitle": "Veja, corrija, pause ou apague o que a plataforma aprendeu.",
"profile.consentsNote": "Os consentimentos são separados: as funções pessoais nunca exigem consentimento de treino.",
"profile.aiScansUsed": "Digitalizações de IA: {used} / {total} este mês",
"profile.deleteTitle": "Apagar a conta?",
"profile.deleteBody": "Todos os seus dados são apagados permanentemente conforme o RGPD.",
"cooking.timerDone": "Pronto!",
"cooked.deductPantryNote": "Os ingredientes são descontados da despensa: primeiro os que expiram antes.",
"home.moreItems_one": "+ mais 1 …",
"home.moreItems_other": "+ mais {count} …",
"shopping.completedBody_one": "1 produto foi adicionado à despensa.",
"shopping.completedBody_other": "{count} produtos foram adicionados à despensa."
}
+338
View File
@@ -0,0 +1,338 @@
{
"common.loading": "Laddar …",
"common.error": "Något gick fel. Försök igen.",
"common.retry": "Försök igen",
"common.save": "Spara",
"common.cancel": "Avbryt",
"common.next": "Nästa",
"common.back": "Tillbaka",
"common.done": "Klar",
"common.skip": "Hoppa över",
"common.offline": "Offline visar senast hämtade data",
"common.estimate": "Uppskattning",
"tabs.whatToEat": "Vad ska vi äta?",
"tabs.scan": "Skanna",
"tabs.myDay": "Min dag",
"tabs.home": "Hemma",
"auth.login": "Logga in",
"auth.register": "Skapa konto",
"auth.email": "E-post",
"auth.password": "Lösenord",
"auth.displayName": "Vad ska vi kalla dig?",
"auth.noAccount": "Ny här? Skapa konto",
"auth.hasAccount": "Har du redan ett konto? Logga in",
"auth.trialNote": "7 dagars full tillgång inget kort behövs.",
"onboarding.title": "Berätta lite om dig",
"onboarding.subtitle": "Allt är frivilligt och kan ändras när som helst. Ju mer du fyller i, desto bättre förslag.",
"onboarding.goal": "Vad är ditt främsta mål?",
"onboarding.diet": "Hur äter du?",
"onboarding.allergies": "Allergier och intoleranser",
"onboarding.allergyNote": "Allergifiltrering är alltid strikt rätter med dina allergener visas aldrig.",
"onboarding.household": "Ditt hushåll",
"onboarding.householdCreate": "Skapa hushåll",
"onboarding.householdJoin": "Gå med via kod",
"onboarding.householdName": "Hushållets namn",
"onboarding.inviteCode": "Inbjudningskod",
"onboarding.mode": "Hur noggrann vill du vara?",
"onboarding.modeSimple": "Enkelt läge",
"onboarding.modeSimpleDesc": "Fota maten, acceptera uppskattningar, minimalt pyssel.",
"onboarding.modeExact": "Exakt läge",
"onboarding.modeExactDesc": "Väg mat, ange gram, full kontroll på siffrorna.",
"onboarding.notMedical": "{brand} ger vägledning inte medicinsk rådgivning.",
"wte.title": "Vad ska vi äta?",
"wte.subtitle": "Utifrån vad ni har hemma, vad som bör användas och vad ni gillar.",
"wte.cravingPlaceholder": "Jag är sugen på … (t.ex. krämigt, asiatiskt, under 500 kcal)",
"wte.mealBoxFirst": "Färdig mat hemma",
"wte.whyTitle": "Varför detta förslag?",
"wte.coverage": "{pct} % hemma",
"wte.missing": "Saknas: {items}",
"wte.empty": "Inga förslag ännu lägg in lite mat i lagret eller lätta på filtren.",
"wte.refresh": "Nya förslag",
"scan.title": "Skanna",
"scan.subtitle": "Fyll på matlagret eller logga en måltid med kameran.",
"scan.fridge": "Kyl",
"scan.freezer": "Frys",
"scan.pantry": "Skafferi",
"scan.ingredients": "Ingredienser",
"scan.plate": "Tallrik",
"scan.receipt": "Kvitto",
"scan.barcode": "Streckkod",
"scan.expiry": "Bäst före-datum",
"scan.nutrition": "Näringsdeklaration",
"scan.takePhoto": "Ta bild",
"scan.tips.title": "Så blir analysen bäst",
"scan.tips.overview": "Ta en översiktsbild först",
"scan.tips.shelf": "Fota gärna hylla för hylla",
"scan.tips.light": "Undvik mörker och skugga",
"scan.tips.move": "Flytta varor som skymmer varandra",
"scan.analyzing": "Analyserar …",
"scan.review.title": "Granska resultatet",
"scan.review.subtitle": "AI:n är osäker ibland du bestämmer. Ändra, ta bort eller lägg till innan du sparar.",
"scan.review.uncertain": "Osäker kontrollera",
"scan.review.approveAll": "Spara till lagret",
"scan.review.rejected": "Borttagen",
"scan.failed": "Analysen misslyckades. Försök igen eller registrera manuellt.",
"myday.title": "Min dag",
"myday.calories": "Kalorier",
"myday.protein": "Protein",
"myday.carbs": "Kolhydrater",
"myday.fat": "Fett",
"myday.fiber": "Fibrer",
"myday.salt": "Salt",
"myday.remaining": "{kcal} kcal kvar",
"myday.over": "{kcal} kcal över målet",
"myday.logMeal": "Logga måltid",
"myday.noMeals": "Inga måltider loggade i dag.",
"myday.estimateNote": "Värden med ~ är uppskattningar som du kan justera.",
"myday.targetsNote": "Målen är vägledning, inte medicinsk rådgivning.",
"home.title": "Hemma",
"home.inventory": "Matlager",
"home.useSoon": "Använd snart",
"home.mealBoxes": "Matlådor",
"home.shopping": "Inköpslista",
"home.budget": "Matbudget",
"home.household": "Hushåll",
"home.waste": "Matsvinn",
"home.emptyInventory": "Lagret är tomt. Skanna kylen eller lägg till varor manuellt.",
"home.expiresToday": "Går ut i dag",
"home.expired": "Utgången",
"home.pastBestBefore": "Passerat bäst före lukta och smaka",
"recipe.time": "{min} min",
"recipe.perPortion": "per portion",
"recipe.ingredients": "Ingredienser",
"recipe.steps": "Gör så här",
"recipe.cook": "Laga nu",
"recipe.notSafe": "Passar inte dina kostinställningar",
"recipe.substitutions": "Byt ut",
"recipe.iCookedThis": "Jag lagade detta",
"recipe.cost": "ca {amount}/portion",
"cooking.step": "Steg {current} av {total}",
"cooking.timer": "Starta timer",
"cooking.timerRunning": "{time} kvar",
"cooking.finish": "Klart logga måltiden",
"cooking.scale": "Portioner",
"cooking.keepAwake": "Skärmen hålls vaken medan du lagar",
"cooked.title": "Hur gick det?",
"cooked.portionsCooked": "Portioner lagade",
"cooked.whoAte": "Vilka åt?",
"cooked.mealBoxes": "Portioner till matlådor",
"cooked.deductPantry": "Dra ingredienser från lagret",
"cooked.rate": "Betygsätt",
"shopping.title": "Inköpslista",
"shopping.addPlaceholder": "Lägg till vara …",
"shopping.complete": "Avsluta köprundan",
"shopping.completeNote": "Bockade varor läggs in i matlagret.",
"shopping.empty": "Listan är tom.",
"shopping.estimated": "ca {amount}",
"shopping.estimatedTotal": "Uppskattad totalsumma: ca {amount}",
"mealbox.title": "Matlådor",
"mealbox.eatBy": "Ät senast {date}",
"mealbox.eat": "Ät nu",
"mealbox.empty": "Inga matlådor just nu. När du lagar mat kan du spara portioner här.",
"household.members": "Medlemmar",
"household.invite": "Bjud in med kod: {code}",
"household.shared": "Delas i hushållet: matlager, inköpslista, veckoplan, matlådor och budget.",
"household.private": "Privat per person: mål, allergier, hälsodata och måltidshistorik.",
"memory.title": "Vad {brand} vet om mig",
"memory.subtitle": "Full insyn. Rätta det som är fel, pausa eller radera du äger ditt minne.",
"memory.paused": "Pausad",
"memory.verify": "Stämmer",
"memory.pause": "Pausa",
"memory.delete": "Radera",
"memory.deleteAll": "Radera allt minne",
"memory.empty": "{brand} har inte lärt sig något om dig ännu.",
"memory.origin.user_stated": "Du har berättat",
"memory.origin.observed": "Observerat mönster",
"memory.origin.ai_inferred": "AI-antagande",
"paywall.title": "{brand} Premium",
"paywall.subtitle": "Hela hushållets mat-OS: obegränsat lager, veckoplan och delning.",
"paywall.household": "Household · upp till 3 personer",
"paywall.family": "Family · upp till 6 personer",
"paywall.large": "Large Household · upp till 12 personer",
"paywall.perMonth": "{price}/mån",
"paywall.fairUse": "Fair use-kvot för AI-skanningar ingår.",
"paywall.restore": "Återställ köp",
"paywall.freeNote": "Gratis: 10 AI-skanningar/mån, manuellt lager, sparade recept och enkel loggning.",
"profile.title": "Profil",
"profile.goals": "Mål & kost",
"profile.consents": "Samtycken & data",
"profile.memory": "Vad {brand} vet om mig",
"profile.subscription": "Prenumeration",
"profile.logout": "Logga ut",
"profile.export": "Exportera min data",
"profile.deleteAccount": "Radera konto",
"home.expiresIn_one": "1 dag kvar",
"home.expiresIn_other": "{days} dagar kvar",
"scan.quotaLeft_one": "1 AI-skanning kvar denna månad",
"scan.quotaLeft_other": "{count} AI-skanningar kvar denna månad",
"mealbox.portionsLeft_one": "1 portion kvar",
"mealbox.portionsLeft_other": "{count} portioner kvar",
"recipe.portions_one": "1 portion",
"recipe.portions_other": "{count} portioner",
"paywall.trialActive_one": "1 dag kvar av din provperiod",
"paywall.trialActive_other": "{days} dagar kvar av din provperiod",
"profile.language": "Språk",
"profile.language.sv": "Svenska",
"profile.language.en": "English",
"auth.forgotLink": "Glömt lösenordet?",
"auth.forgotTitle": "Återställ lösenord",
"auth.forgotBody": "Ange din e-postadress så skickar vi en återställningslänk om kontot finns.",
"auth.forgotSubmit": "Skicka länk",
"auth.forgotSentTitle": "Kolla din inkorg",
"auth.forgotSentBody": "Om adressen har ett konto har vi skickat en länk som gäller i 30 minuter. Öppna den på den här enheten.",
"auth.backToLogin": "Tillbaka till inloggning",
"auth.resetTitle": "Välj nytt lösenord",
"auth.resetBody": "Länken gäller i 30 minuter och kan bara användas en gång. Alla enheter loggas ut när lösenordet byts.",
"auth.resetTokenPlaceholder": "Klistra in koden från mejlet",
"auth.newPassword": "Nytt lösenord (minst 10 tecken)",
"auth.resetSubmit": "Byt lösenord",
"auth.resetDoneTitle": "Klart!",
"auth.resetDoneBody": "Ditt lösenord är bytt. Logga in med det nya lösenordet.",
"auth.verifyDoneTitle": "E-postadressen är bekräftad",
"auth.verifyDoneBody": "Tack! Ditt konto är nu verifierat.",
"auth.verifyFailedTitle": "Länken fungerade inte",
"auth.verifyFailedBody": "Länken är ogiltig eller har gått ut. Begär en ny från profilen.",
"profile.emailUnverified": "E-postadressen är inte bekräftad",
"profile.resendVerification": "Skicka bekräftelsemejl igen",
"profile.verificationSent": "Skickat! Kolla din inkorg.",
"profile.language.es": "Español",
"profile.language.it": "Italiano",
"profile.language.de": "Deutsch",
"profile.language.fr": "Français",
"profile.language.da": "Dansk",
"profile.language.nb": "Norsk",
"profile.language.fi": "Suomi",
"profile.language.nl": "Nederlands",
"profile.language.pl": "Polski",
"profile.language.pt": "Português",
"common.oops": "Hoppsan",
"common.undo": "Ångra",
"common.remove": "Ta bort",
"common.add": "Lägg till",
"common.on": "På",
"common.off": "Av",
"home.thisWeek": "Denna vecka",
"home.wasteWeek": "Matsvinn denna vecka",
"home.useSoonHint": "Bäst före handlar om kvalitet lukta, titta och smaka innan du slänger. Sista förbrukningsdag ska däremot respekteras.",
"scan.review.itemPlaceholder": "Vara",
"scan.review.quantityPlaceholder": "Mängd",
"scan.review.unitPlaceholder": "Enhet (g, l, st …)",
"scan.review.datePlaceholder": "ÅÅÅÅ-MM-DD (valfritt)",
"scan.review.dateKindBestBefore": "Bäst före",
"scan.review.dateKindUseBy": "Sista förbrukningsdag",
"scan.review.bestBeforeNote": "Kvalitetsgräns varan kan vara god längre. Lukta och smaka innan du slänger.",
"scan.review.useByNote": "Säkerhetsgräns ät inte efter detta datum.",
"scan.review.addItem": "+ Lägg till vara",
"scan.review.unknownItem": "Okänd vara",
"barcode.aim": "Rikta kameran mot streckkoden",
"barcode.addPrompt": "Lägga till i lagret?",
"barcode.kcalPer100": "{kcal} kcal/100 g",
"barcode.noNutrition": "Näringsdata saknas",
"barcode.unknownTitle": "Okänd produkt",
"barcode.unknownBody": "Produkten finns inte i databasen ännu. Fota framsidan och näringsdeklarationen så lägger vi till den.",
"barcode.photoPackage": "Fota förpackningen",
"barcode.needHousehold": "Du behöver ett hushåll först.",
"barcode.noLocation": "Ingen förvaringsplats hittades.",
"barcode.cameraTitle": "Kameran behövs",
"barcode.cameraBody": "För att skanna streckkoder behöver {brand} kameran.",
"barcode.allowCamera": "Tillåt kamera",
"onboarding.goal.lose_weight": "Gå ner i vikt",
"onboarding.goal.build_muscle": "Bygga muskler",
"onboarding.goal.maintain_weight": "Behålla vikten",
"onboarding.goal.more_protein": "Äta mer protein",
"onboarding.goal.less_waste": "Minska matsvinn",
"onboarding.goal.lower_cost": "Sänka matkostnaden",
"onboarding.goal.cook_more": "Laga mer hemma",
"onboarding.diet.omnivore": "Allätare",
"onboarding.diet.flexitarian": "Flexitarian",
"onboarding.diet.pescatarian": "Pescetarian",
"onboarding.diet.vegetarian": "Vegetarian",
"onboarding.diet.vegan": "Vegan",
"onboarding.allergen.gluten": "Gluten",
"onboarding.allergen.milk": "Mjölk/laktos",
"onboarding.allergen.eggs": "Ägg",
"onboarding.allergen.tree_nuts": "Nötter",
"onboarding.allergen.peanuts": "Jordnötter",
"onboarding.allergen.fish": "Fisk",
"onboarding.allergen.crustaceans": "Skaldjur",
"onboarding.allergen.soy": "Soja",
"onboarding.allergen.sesame": "Sesam",
"onboarding.bodyTitle": "För personliga kalorimål (frivilligt)",
"onboarding.weightPlaceholder": "Vikt (kg)",
"onboarding.heightPlaceholder": "Längd (cm)",
"onboarding.birthYearPlaceholder": "Födelseår",
"onboarding.householdDefaultName": "Hemma",
"onboarding.modesCombine": "Lägena kan kombineras enkelt till vardags, exakt när du vill.",
"myday.mealType.breakfast": "Frukost",
"myday.mealType.lunch": "Lunch",
"myday.mealType.dinner": "Middag",
"myday.mealType.snack": "Mellanmål",
"myday.mealType.dessert": "Efterrätt",
"myday.overRecommended": "Över rekommenderat",
"myday.todaysMeals": "Dagens måltider",
"myday.estimateRange": "{min}{max} kcal, troligen {kcal}",
"myday.kcalProtein": "{kcal} kcal · {protein} g protein",
"logmeal.mealTypeTitle": "Måltidstyp",
"logmeal.quickTitle": "Snabb återloggning",
"logmeal.quickSubtitle": "Loggar samma näringsvärden som förra gången.",
"logmeal.manualTitle": "Manuellt",
"logmeal.whatPlaceholder": "Vad åt du?",
"logmeal.proteinPlaceholder": "protein (g)",
"logmeal.validationTitle": "Fyll i",
"logmeal.validationBody": "Namn och kalorier behövs för manuell loggning.",
"logmeal.photoTip": "Tips: fota tallriken under Skanna så uppskattar appen åt dig du bekräftar alltid.",
"shopping.section.frukt_gront": "Frukt & grönt",
"shopping.section.brod": "Bröd",
"shopping.section.mejeri": "Mejeri",
"shopping.section.kott_fagel": "Kött & fågel",
"shopping.section.fisk": "Fisk",
"shopping.section.chark": "Chark",
"shopping.section.frys": "Frys",
"shopping.section.skafferi": "Skafferi",
"shopping.section.konserver": "Konserver",
"shopping.section.kryddor_bak": "Kryddor & bakning",
"shopping.section.dryck": "Dryck",
"shopping.section.snacks": "Snacks",
"shopping.section.hygien_ovrigt": "Övrigt",
"shopping.completedTitle": "Klart!",
"household.role.owner": "Ägare",
"household.role.adult": "Vuxen",
"household.role.member": "Medlem",
"household.role.child": "Barn",
"household.empty": "Du har inget hushåll ännu. Skapa ett i onboardingen eller gå med via kod.",
"household.shareCode": "Dela koden med familjen så delar ni lager, lista och plan.",
"household.locations": "Förvaringsplatser",
"household.portionFactor": "×{factor} portion",
"mealbox.enjoyTitle": "Smaklig måltid!",
"mealbox.enjoyBody": "Portionen är loggad i Min dag.",
"mealbox.guidanceNote": "Rekommenderad användningstid är vägledning lita på lukt och smak.",
"paywall.soonTitle": "Snart!",
"paywall.soonBody": "Köp aktiveras via App Store/Google Play när butiksintegrationen slås på (fas 7). Backend-flödet är redan klart.",
"paywall.popular": "Populärast",
"paywall.choose": "Välj",
"recipe.saved": "Sparad",
"recipe.save": "Spara",
"recipe.avgRating": "Snitt {avg} av {count} betyg",
"memory.verified": "Verifierad",
"memory.resume": "Återuppta",
"memory.deleteAllTitle": "Radera allt minne?",
"memory.irreversible": "Detta går inte att ångra.",
"profile.consent.personalization": "Personliga funktioner (minne & smakprofil)",
"profile.consent.anonymized_improvement": "Anonymiserad förbättring av AI:n",
"profile.consent.image_training": "Mina bilder får användas för träning",
"profile.consent.health_integration": "Hälsodata (Apple Health / Health Connect)",
"profile.consent.location_weather": "Plats för väderbaserade förslag",
"profile.consent.push_notifications": "Push-notiser",
"profile.modeLabel": "Läge:",
"profile.memorySubtitle": "Se, rätta, pausa eller radera det plattformen lärt sig.",
"profile.consentsNote": "Samtyckena är separata personlig funktion kräver aldrig träningssamtycke.",
"profile.aiScansUsed": "AI-skanningar: {used} / {total} denna månad",
"profile.deleteTitle": "Radera kontot?",
"profile.deleteBody": "All din data raderas permanent enligt GDPR.",
"cooking.timerDone": "Klart!",
"cooked.deductPantryNote": "Ingredienserna dras från lagret enligt först-utgången-först.",
"home.moreItems_one": "+ 1 till …",
"home.moreItems_other": "+ {count} till …",
"shopping.completedBody_one": "1 vara lades in i matlagret.",
"shopping.completedBody_other": "{count} varor lades in i matlagret."
}