Initial commit (unpacked platform)

This commit is contained in:
Sven (AAMOS AI)
2026-08-05 19:21:11 +07:00
commit ac5340195a
314 changed files with 57584 additions and 0 deletions
+46
View File
@@ -0,0 +1,46 @@
// Expo-konfiguration varumärket läses från brand.config.json (i18n-spec §28).
import type { ConfigContext, ExpoConfig } from "expo/config";
import brand from "../../brand.config.json";
export default ({ config }: ConfigContext): ExpoConfig => ({
...config,
name: brand.name,
slug: brand.slug,
version: "0.1.0",
orientation: "portrait",
icon: "./assets/images/icon.png",
scheme: brand.urlScheme,
userInterfaceStyle: "automatic",
ios: {
bundleIdentifier: brand.iosBundleId,
supportsTablet: false,
infoPlist: {
NSCameraUsageDescription: `${brand.name} använder kameran för att skanna kyl, skafferi, kvitton, streckkoder och tallrikar så att ditt matlager hålls uppdaterat.`,
NSPhotoLibraryUsageDescription: `${brand.name} kan läsa bilder du väljer för att analysera mat och kvitton.`,
},
},
android: {
package: brand.androidPackage,
permissions: ["CAMERA"],
adaptiveIcon: {
backgroundColor: "#0F2417",
foregroundImage: "./assets/images/android-icon-foreground.png",
},
},
plugins: [
"expo-router",
"expo-secure-store",
[
"expo-splash-screen",
{ backgroundColor: "#0F2417", image: "./assets/images/splash-icon.png", imageWidth: 96 },
],
[
"expo-camera",
{
cameraPermission: `${brand.name} använder kameran för att skanna mat, kvitton och streckkoder.`,
},
],
],
experiments: { typedRoutes: false },
extra: { apiBaseUrl: process.env.API_BASE_URL ?? "http://localhost:4000" },
});
@@ -0,0 +1,3 @@
<svg width="652" height="606" viewBox="0 0 652 606" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M353.554 0H298.446C273.006 0 249.684 14.6347 237.962 37.9539L4.37994 502.646C-1.04325 513.435 -1.45067 526.178 3.2716 537.313L22.6123 582.918C34.6475 611.297 72.5404 614.156 88.4414 587.885L309.863 222.063C313.34 216.317 319.439 212.826 326 212.826C332.561 212.826 338.659 216.317 342.137 222.063L563.559 587.885C579.46 614.156 617.352 611.297 629.388 582.918L648.728 537.313C653.451 526.178 653.043 513.435 647.62 502.646L414.038 37.9539C402.316 14.6347 378.994 0 353.554 0Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 608 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

+35
View File
@@ -0,0 +1,35 @@
{
"fill": {
"automatic-gradient": "extended-srgb:0.00000,0.47843,1.00000,1.00000"
},
"groups": [
{
"layers": [
{
"image-name": "expo-symbol 2.svg",
"name": "expo-symbol 2",
"position": {
"scale": 1,
"translation-in-points": [1.1008400065293245e-5, -16.046875]
}
},
{
"image-name": "grid.png",
"name": "grid"
}
],
"shadow": {
"kind": "neutral",
"opacity": 0.5
},
"translucency": {
"enabled": true,
"value": 0.5
}
}
],
"supported-platforms": {
"circles": ["watchOS"],
"squares": "shared"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 780 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 324 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 347 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 468 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 479 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

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