Initial commit (unpacked platform)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 M4–M5). */
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
@@ -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 §23–24).
|
||||
*/
|
||||
|
||||
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")}`;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 §45–46). 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.4–5): 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" },
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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 });
|
||||
},
|
||||
}));
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* i18n via i18next (i18n-spec §5, §7–8; 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 1–2): 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);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Pengavisning i appen (i18n-spec §20–21): 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()}`;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 §9–10).
|
||||
* 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 ?? {}));
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
Reference in New Issue
Block a user