Initial commit (unpacked platform)
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
import { useState } from "react";
|
||||
import { Text } from "react-native";
|
||||
import { Link } from "expo-router";
|
||||
import { api } from "@/lib/api";
|
||||
import { t } from "@/lib/i18n";
|
||||
import { Body, Button, Input, Screen, Spacer, Title } from "@/components/ui";
|
||||
import { colors, spacing } from "@/lib/theme";
|
||||
|
||||
/**
|
||||
* Glömt lösenord (steg 1): begär återställningsmejl. Svaret är alltid samma
|
||||
* oavsett om kontot finns – ingen kontouppräkning. Mejlet innehåller en
|
||||
* app-länk (urlScheme://reset-password?token=…) som öppnar steg 2.
|
||||
*/
|
||||
export default function ForgotPasswordScreen() {
|
||||
const [email, setEmail] = useState("");
|
||||
const [sent, setSent] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const submit = async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api("/v1/auth/forgot-password", { method: "POST", body: { email } });
|
||||
setSent(true);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("common.error"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (sent) {
|
||||
return (
|
||||
<Screen scroll={false} style={{ justifyContent: "center", gap: spacing.md }}>
|
||||
<Title>{t("auth.forgotSentTitle")}</Title>
|
||||
<Body muted>{t("auth.forgotSentBody")}</Body>
|
||||
<Spacer size={spacing.sm} />
|
||||
<Link href="/(auth)/login" style={{ textAlign: "center", color: colors.primaryDark }}>
|
||||
{t("auth.backToLogin")}
|
||||
</Link>
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Screen scroll={false} style={{ justifyContent: "center", gap: spacing.md }}>
|
||||
<Title>{t("auth.forgotTitle")}</Title>
|
||||
<Body muted>{t("auth.forgotBody")}</Body>
|
||||
<Input
|
||||
placeholder={t("auth.email")}
|
||||
autoCapitalize="none"
|
||||
keyboardType="email-address"
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
/>
|
||||
{error && <Text style={{ color: colors.danger }}>{error}</Text>}
|
||||
<Button label={t("auth.forgotSubmit")} onPress={() => void submit()} loading={busy} />
|
||||
<Spacer size={spacing.sm} />
|
||||
<Link href="/(auth)/login" style={{ textAlign: "center", color: colors.primaryDark }}>
|
||||
{t("auth.backToLogin")}
|
||||
</Link>
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useState } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { Link, Redirect, router } from "expo-router";
|
||||
import { api } from "@/lib/api";
|
||||
import { useAuth, type AuthUser } from "@/lib/auth";
|
||||
import { t } from "@/lib/i18n";
|
||||
import { Body, Button, Input, Screen, Spacer, Title } from "@/components/ui";
|
||||
import { colors, spacing } from "@/lib/theme";
|
||||
import { BRAND } from "@/lib/brand";
|
||||
|
||||
interface LoginResponse {
|
||||
user: AuthUser;
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
export default function LoginScreen() {
|
||||
const { accessToken, setSession } = useAuth();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
if (accessToken) return <Redirect href="/(tabs)" />;
|
||||
|
||||
const submit = async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await api<LoginResponse>("/v1/auth/login", {
|
||||
method: "POST",
|
||||
body: { email, password },
|
||||
});
|
||||
await setSession(
|
||||
{ accessToken: result.accessToken, refreshToken: result.refreshToken },
|
||||
result.user,
|
||||
);
|
||||
router.replace("/(tabs)");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("common.error"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Screen scroll={false} style={{ justifyContent: "center", gap: spacing.md }}>
|
||||
<View style={{ alignItems: "center", marginBottom: spacing.lg }}>
|
||||
<Title>{BRAND.name}</Title>
|
||||
<Body muted>Hushållets mat-OS</Body>
|
||||
</View>
|
||||
<Input
|
||||
placeholder={t("auth.email")}
|
||||
autoCapitalize="none"
|
||||
keyboardType="email-address"
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
/>
|
||||
<Input
|
||||
placeholder={t("auth.password")}
|
||||
secureTextEntry
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
/>
|
||||
{error && <Text style={{ color: colors.danger }}>{error}</Text>}
|
||||
<Button label={t("auth.login")} onPress={() => void submit()} loading={busy} />
|
||||
<Spacer size={spacing.sm} />
|
||||
<Link href="/(auth)/register" style={{ textAlign: "center", color: colors.primaryDark }}>
|
||||
{t("auth.noAccount")}
|
||||
</Link>
|
||||
<Link href="/(auth)/forgot-password" style={{ textAlign: "center", color: colors.textMuted }}>
|
||||
{t("auth.forgotLink")}
|
||||
</Link>
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useState } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { Link, router } from "expo-router";
|
||||
import { api } from "@/lib/api";
|
||||
import { persistLanguageTag, useAuth, type AuthUser } from "@/lib/auth";
|
||||
import { detectDeviceLanguageTag, t } from "@/lib/i18n";
|
||||
import { localeDefaultsForRegion } from "@app/shared-types";
|
||||
import { Body, Button, Input, Screen, Small, Spacer, Title } from "@/components/ui";
|
||||
import { colors, spacing } from "@/lib/theme";
|
||||
|
||||
interface RegisterResponse {
|
||||
user: AuthUser;
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
export default function RegisterScreen() {
|
||||
const setSession = useAuth((s) => s.setSession);
|
||||
const setOnboardingCompleted = useAuth((s) => s.setOnboardingCompleted);
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const submit = async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await api<RegisterResponse>("/v1/auth/register", {
|
||||
method: "POST",
|
||||
body: {
|
||||
displayName,
|
||||
email,
|
||||
password,
|
||||
// D-031: enhetens språk – backend skapar locale-preferenser före välkomstmejlet.
|
||||
locale: detectDeviceLanguageTag() ?? "sv-SE",
|
||||
},
|
||||
});
|
||||
await setSession(
|
||||
{ accessToken: result.accessToken, refreshToken: result.refreshToken },
|
||||
result.user,
|
||||
);
|
||||
// D-031: enhetens språk/region synkas till backend direkt så att mejl,
|
||||
// notiser och innehåll kommer på rätt språk från första stund.
|
||||
const deviceTag = detectDeviceLanguageTag();
|
||||
if (deviceTag) {
|
||||
const region = deviceTag.split("-")[1]?.toUpperCase();
|
||||
const defaults = region ? localeDefaultsForRegion(region) : null;
|
||||
await api("/v1/me/locale-preferences", {
|
||||
method: "PATCH",
|
||||
body: {
|
||||
languageTag: deviceTag,
|
||||
...(defaults
|
||||
? {
|
||||
regionCode: defaults.regionCode,
|
||||
timeZone: defaults.timeZone,
|
||||
measurementSystem: defaults.measurementSystem,
|
||||
temperatureUnit: defaults.temperatureUnit,
|
||||
currencyCode: defaults.currencyCode,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
}).catch(() => {});
|
||||
await persistLanguageTag(deviceTag);
|
||||
}
|
||||
setOnboardingCompleted(false);
|
||||
router.replace("/onboarding");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("common.error"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Screen scroll={false} style={{ justifyContent: "center", gap: spacing.md }}>
|
||||
<View style={{ alignItems: "center", marginBottom: spacing.lg }}>
|
||||
<Title>{t("auth.register")}</Title>
|
||||
<Small>{t("auth.trialNote")}</Small>
|
||||
</View>
|
||||
<Input
|
||||
placeholder={t("auth.displayName")}
|
||||
value={displayName}
|
||||
onChangeText={setDisplayName}
|
||||
/>
|
||||
<Input
|
||||
placeholder={t("auth.email")}
|
||||
autoCapitalize="none"
|
||||
keyboardType="email-address"
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
/>
|
||||
<Input
|
||||
placeholder={`${t("auth.password")} (minst 10 tecken)`}
|
||||
secureTextEntry
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
/>
|
||||
{error && <Text style={{ color: colors.danger }}>{error}</Text>}
|
||||
<Button label={t("auth.register")} onPress={() => void submit()} loading={busy} />
|
||||
<Spacer size={spacing.sm} />
|
||||
<Link href="/(auth)/login" style={{ textAlign: "center", color: colors.primaryDark }}>
|
||||
{t("auth.hasAccount")}
|
||||
</Link>
|
||||
<Body muted>{t("onboarding.notMedical")}</Body>
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useState } from "react";
|
||||
import { Text } from "react-native";
|
||||
import { Link, router, useLocalSearchParams } from "expo-router";
|
||||
import { api } from "@/lib/api";
|
||||
import { t } from "@/lib/i18n";
|
||||
import { Body, Button, Input, Screen, Spacer, Title } from "@/components/ui";
|
||||
import { colors, spacing } from "@/lib/theme";
|
||||
|
||||
/**
|
||||
* Glömt lösenord (steg 2): öppnas via app-länken i mejlet
|
||||
* (urlScheme://reset-password?token=…). Token kan även klistras in manuellt.
|
||||
* Efter lyckad återställning loggas ALLA sessioner ut – användaren loggar in
|
||||
* på nytt med sitt nya lösenord.
|
||||
*/
|
||||
export default function ResetPasswordScreen() {
|
||||
const params = useLocalSearchParams<{ token?: string }>();
|
||||
const [token, setToken] = useState(params.token ?? "");
|
||||
const [password, setPassword] = useState("");
|
||||
const [done, setDone] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const submit = async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api("/v1/auth/reset-password", {
|
||||
method: "POST",
|
||||
body: { token: token.trim(), newPassword: password },
|
||||
});
|
||||
setDone(true);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("common.error"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (done) {
|
||||
return (
|
||||
<Screen scroll={false} style={{ justifyContent: "center", gap: spacing.md }}>
|
||||
<Title>{t("auth.resetDoneTitle")}</Title>
|
||||
<Body muted>{t("auth.resetDoneBody")}</Body>
|
||||
<Spacer size={spacing.sm} />
|
||||
<Button label={t("auth.login")} onPress={() => router.replace("/(auth)/login")} />
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Screen scroll={false} style={{ justifyContent: "center", gap: spacing.md }}>
|
||||
<Title>{t("auth.resetTitle")}</Title>
|
||||
<Body muted>{t("auth.resetBody")}</Body>
|
||||
{!params.token && (
|
||||
<Input
|
||||
placeholder={t("auth.resetTokenPlaceholder")}
|
||||
autoCapitalize="none"
|
||||
value={token}
|
||||
onChangeText={setToken}
|
||||
/>
|
||||
)}
|
||||
<Input
|
||||
placeholder={t("auth.newPassword")}
|
||||
secureTextEntry
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
/>
|
||||
{error && <Text style={{ color: colors.danger }}>{error}</Text>}
|
||||
<Button label={t("auth.resetSubmit")} onPress={() => void submit()} loading={busy} />
|
||||
<Spacer size={spacing.sm} />
|
||||
<Link href="/(auth)/login" style={{ textAlign: "center", color: colors.primaryDark }}>
|
||||
{t("auth.backToLogin")}
|
||||
</Link>
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useLocalSearchParams } from "expo-router";
|
||||
import { api } from "@/lib/api";
|
||||
import { t } from "@/lib/i18n";
|
||||
import { Body, LoadingView, Screen, Spacer, Title } from "@/components/ui";
|
||||
import { colors, spacing } from "@/lib/theme";
|
||||
|
||||
/** Öppnas via länken i välkomstmejlet (urlScheme://verify-email?token=…). */
|
||||
export default function VerifyEmailScreen() {
|
||||
const params = useLocalSearchParams<{ token?: string }>();
|
||||
const [state, setState] = useState<"working" | "done" | "failed">("working");
|
||||
|
||||
useEffect(() => {
|
||||
if (!params.token) {
|
||||
setState("failed");
|
||||
return;
|
||||
}
|
||||
api("/v1/auth/verify-email", { method: "POST", body: { token: params.token } })
|
||||
.then(() => setState("done"))
|
||||
.catch(() => setState("failed"));
|
||||
}, [params.token]);
|
||||
|
||||
if (state === "working") return <LoadingView />;
|
||||
return (
|
||||
<Screen scroll={false} style={{ justifyContent: "center", gap: spacing.md }}>
|
||||
<Title>{state === "done" ? t("auth.verifyDoneTitle") : t("auth.verifyFailedTitle")}</Title>
|
||||
<Body muted>{state === "done" ? t("auth.verifyDoneBody") : t("auth.verifyFailedBody")}</Body>
|
||||
<Spacer size={spacing.sm} />
|
||||
<Link href="/(tabs)" style={{ textAlign: "center", color: colors.primaryDark }}>
|
||||
{t("common.done")}
|
||||
</Link>
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Redirect, Tabs } from "expo-router";
|
||||
import { Text } from "react-native";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api";
|
||||
import { persistLanguageTag, useAuth } from "@/lib/auth";
|
||||
import { colors } from "@/lib/theme";
|
||||
import { getLanguageTag, t } from "@/lib/i18n";
|
||||
import { setMeasurementSystem, setUnitLanguage } from "@/lib/units";
|
||||
import type { MeasurementSystem } from "@app/shared-types";
|
||||
|
||||
function TabIcon({ glyph, focused }: { glyph: string; focused: boolean }) {
|
||||
return <Text style={{ fontSize: 22, opacity: focused ? 1 : 0.45 }}>{glyph}</Text>;
|
||||
}
|
||||
|
||||
interface LocalePrefs {
|
||||
languageTag: string;
|
||||
measurementSystem: MeasurementSystem;
|
||||
}
|
||||
|
||||
/** Applicera backend-preferenser på språk/mått vid inloggad start (i18n M4–M5). */
|
||||
function useApplyLocalePreferences(enabled: boolean) {
|
||||
useQuery({
|
||||
queryKey: ["locale-preferences"],
|
||||
queryFn: async () => {
|
||||
const prefs = await api<LocalePrefs>("/v1/me/locale-preferences");
|
||||
setMeasurementSystem(prefs.measurementSystem);
|
||||
setUnitLanguage(prefs.languageTag.split("-")[0] ?? "sv");
|
||||
if (prefs.languageTag !== getLanguageTag()) await persistLanguageTag(prefs.languageTag);
|
||||
return prefs;
|
||||
},
|
||||
enabled,
|
||||
staleTime: 3600_000,
|
||||
});
|
||||
}
|
||||
|
||||
export default function TabsLayout() {
|
||||
const accessToken = useAuth((s) => s.accessToken);
|
||||
const onboardingCompleted = useAuth((s) => s.onboardingCompleted);
|
||||
useApplyLocalePreferences(Boolean(accessToken));
|
||||
|
||||
if (!accessToken) return <Redirect href="/(auth)/login" />;
|
||||
if (!onboardingCompleted) return <Redirect href="/onboarding" />;
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
tabBarActiveTintColor: colors.primaryDark,
|
||||
tabBarInactiveTintColor: colors.textMuted,
|
||||
tabBarStyle: { backgroundColor: colors.surface, borderTopColor: colors.border },
|
||||
}}
|
||||
>
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: t("tabs.whatToEat"),
|
||||
tabBarIcon: ({ focused }) => <TabIcon glyph="🍽️" focused={focused} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="scan"
|
||||
options={{
|
||||
title: t("tabs.scan"),
|
||||
tabBarIcon: ({ focused }) => <TabIcon glyph="📷" focused={focused} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="my-day"
|
||||
options={{
|
||||
title: t("tabs.myDay"),
|
||||
tabBarIcon: ({ focused }) => <TabIcon glyph="📊" focused={focused} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="home"
|
||||
options={{
|
||||
title: t("tabs.home"),
|
||||
tabBarIcon: ({ focused }) => <TabIcon glyph="🏠" focused={focused} />,
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { View } from "react-native";
|
||||
import { router } from "expo-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api";
|
||||
import { t } from "@/lib/i18n";
|
||||
import { formatMinor } from "@/lib/money";
|
||||
import {
|
||||
Body,
|
||||
Card,
|
||||
EmptyState,
|
||||
ErrorView,
|
||||
Heading,
|
||||
LoadingView,
|
||||
Row,
|
||||
Screen,
|
||||
Small,
|
||||
Spacer,
|
||||
Tag,
|
||||
Title,
|
||||
} from "@/components/ui";
|
||||
import { spacing } from "@/lib/theme";
|
||||
import { formatQuantity } from "@/lib/units";
|
||||
|
||||
/** Hemma (spec §4.4): matlager, bäst före, matlådor, inköpslista, budget, hushåll. */
|
||||
|
||||
interface InventoryItem {
|
||||
id: string;
|
||||
displayName: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
locationName: string;
|
||||
expiry: { status: string; daysLeft: number | null; pastBestBefore: boolean };
|
||||
}
|
||||
interface BudgetSummary {
|
||||
currency: string;
|
||||
week: { purchasedMinor: number; wasteMinor: number; budgetMinor: number | null };
|
||||
month: { purchasedMinor: number; wasteMinor: number };
|
||||
}
|
||||
|
||||
export default function HomeScreen() {
|
||||
const inventory = useQuery({
|
||||
queryKey: ["inventory"],
|
||||
queryFn: () => api<{ items: InventoryItem[] }>("/v1/inventory?limit=100"),
|
||||
});
|
||||
const expiring = useQuery({
|
||||
queryKey: ["inventory-expiring"],
|
||||
queryFn: () => api<{ items: InventoryItem[] }>("/v1/inventory/expiring"),
|
||||
});
|
||||
const budget = useQuery({
|
||||
queryKey: ["budget"],
|
||||
queryFn: () => api<BudgetSummary>("/v1/budget/summary"),
|
||||
});
|
||||
|
||||
if (inventory.isLoading) return <LoadingView />;
|
||||
if (inventory.isError) return <ErrorView onRetry={() => void inventory.refetch()} />;
|
||||
|
||||
const items = inventory.data?.items ?? [];
|
||||
const urgent = expiring.data?.items ?? [];
|
||||
|
||||
const grouped = new Map<string, InventoryItem[]>();
|
||||
for (const item of items) {
|
||||
const list = grouped.get(item.locationName) ?? [];
|
||||
list.push(item);
|
||||
grouped.set(item.locationName, list);
|
||||
}
|
||||
|
||||
return (
|
||||
<Screen>
|
||||
<Title>{t("home.title")}</Title>
|
||||
|
||||
<Row>
|
||||
<QuickLink label={t("home.shopping")} glyph="🛒" onPress={() => router.push("/shopping")} />
|
||||
<QuickLink
|
||||
label={t("home.mealBoxes")}
|
||||
glyph="🍱"
|
||||
onPress={() => router.push("/meal-boxes")}
|
||||
/>
|
||||
<QuickLink
|
||||
label={t("home.household")}
|
||||
glyph="👥"
|
||||
onPress={() => router.push("/household")}
|
||||
/>
|
||||
<QuickLink label={t("profile.title")} glyph="⚙️" onPress={() => router.push("/profile")} />
|
||||
</Row>
|
||||
|
||||
{budget.data && (
|
||||
<Card>
|
||||
<Heading>{t("home.budget")}</Heading>
|
||||
<Row style={{ justifyContent: "space-between" }}>
|
||||
<Small>{t("home.thisWeek")}</Small>
|
||||
<Body>
|
||||
{formatMinor(budget.data.week.purchasedMinor, budget.data.currency)}
|
||||
{budget.data.week.budgetMinor != null
|
||||
? ` / ${formatMinor(budget.data.week.budgetMinor, budget.data.currency)}`
|
||||
: ""}
|
||||
</Body>
|
||||
</Row>
|
||||
<Row style={{ justifyContent: "space-between" }}>
|
||||
<Small>{t("home.wasteWeek")}</Small>
|
||||
<Body>{formatMinor(budget.data.week.wasteMinor, budget.data.currency)}</Body>
|
||||
</Row>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{urgent.length > 0 && (
|
||||
<Card>
|
||||
<Heading>⏳ {t("home.useSoon")}</Heading>
|
||||
{urgent.slice(0, 5).map((item) => (
|
||||
<Row key={item.id} style={{ justifyContent: "space-between" }}>
|
||||
<Body>{item.displayName}</Body>
|
||||
<ExpiryTag expiry={item.expiry} />
|
||||
</Row>
|
||||
))}
|
||||
{/* Mjölkprincipen (spec §13): bäst före ≠ dålig – döm aldrig mat i onödan. */}
|
||||
{urgent.some((i) => i.expiry.pastBestBefore) && <Small>{t("home.useSoonHint")}</Small>}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Heading>{t("home.inventory")}</Heading>
|
||||
{items.length === 0 && <EmptyState text={t("home.emptyInventory")} />}
|
||||
{[...grouped.entries()].map(([location, locationItems]) => (
|
||||
<Card key={location}>
|
||||
<Heading>{location}</Heading>
|
||||
{locationItems.slice(0, 12).map((item) => (
|
||||
<Row key={item.id} style={{ justifyContent: "space-between" }}>
|
||||
<Body>
|
||||
{item.displayName} · {formatQuantity(item.quantity, item.unit)}
|
||||
</Body>
|
||||
<ExpiryTag expiry={item.expiry} />
|
||||
</Row>
|
||||
))}
|
||||
{locationItems.length > 12 && (
|
||||
<Small>{t("home.moreItems", { count: locationItems.length - 12 })}</Small>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
<Spacer />
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
||||
function QuickLink({
|
||||
label,
|
||||
glyph,
|
||||
onPress,
|
||||
}: {
|
||||
label: string;
|
||||
glyph: string;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Card onPress={onPress} style={{ flex: 1, alignItems: "center", paddingVertical: spacing.sm }}>
|
||||
<Body>{glyph}</Body>
|
||||
<Small>{label}</Small>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ExpiryTag({ expiry }: { expiry: InventoryItem["expiry"] }) {
|
||||
if (expiry.status === "expired") return <Tag label={t("home.expired")} tone="danger" />;
|
||||
if (expiry.pastBestBefore) return <Tag label={t("home.pastBestBefore")} tone="warning" />;
|
||||
if (expiry.status === "expiring") {
|
||||
return (
|
||||
<Tag
|
||||
label={
|
||||
expiry.daysLeft === 0
|
||||
? t("home.expiresToday")
|
||||
: t("home.expiresIn", { days: expiry.daysLeft ?? 0, count: expiry.daysLeft ?? 0 })
|
||||
}
|
||||
tone="warning"
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (expiry.status === "use_soon") {
|
||||
return (
|
||||
<Tag
|
||||
label={t("home.expiresIn", { days: expiry.daysLeft ?? 0, count: expiry.daysLeft ?? 0 })}
|
||||
tone="neutral"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <View />;
|
||||
}
|
||||
|
||||
function formatQty(quantity: number): string {
|
||||
return Number.isInteger(quantity) ? String(quantity) : quantity.toFixed(1);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { useState } from "react";
|
||||
import { View } from "react-native";
|
||||
import { router } from "expo-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api";
|
||||
import { t } from "@/lib/i18n";
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Card,
|
||||
EmptyState,
|
||||
ErrorView,
|
||||
Heading,
|
||||
Input,
|
||||
LoadingView,
|
||||
Row,
|
||||
Screen,
|
||||
Small,
|
||||
Spacer,
|
||||
Tag,
|
||||
Title,
|
||||
} from "@/components/ui";
|
||||
import { colors, spacing } from "@/lib/theme";
|
||||
|
||||
/** "Vad ska vi äta?" (spec §4.1, §18) – appens viktigaste vy. */
|
||||
|
||||
interface Recommendation {
|
||||
recipeId: string;
|
||||
titleSv: string;
|
||||
score: number;
|
||||
whySv: string;
|
||||
missingIngredients: string[];
|
||||
usesExpiring: Array<{ nameSv: string; daysLeft: number | null }>;
|
||||
coveragePercent: number;
|
||||
}
|
||||
interface MealBoxSuggestion {
|
||||
mealBoxId: string;
|
||||
titleSv: string;
|
||||
portionsRemaining: number;
|
||||
recommendedUseBy: string;
|
||||
whySv: string;
|
||||
}
|
||||
interface WhatToEatResponse {
|
||||
recommendations: Recommendation[];
|
||||
mealBoxSuggestions: MealBoxSuggestion[];
|
||||
context: { activeHolidays: string[]; remainingKcal: number; remainingProteinG: number };
|
||||
}
|
||||
|
||||
export default function WhatToEatScreen() {
|
||||
const [craving, setCraving] = useState("");
|
||||
const [submittedCraving, setSubmittedCraving] = useState("");
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["what-to-eat", submittedCraving],
|
||||
queryFn: () =>
|
||||
api<WhatToEatResponse>(
|
||||
`/v1/recommendations/what-to-eat?limit=5${submittedCraving ? `&craving=${encodeURIComponent(submittedCraving)}` : ""}`,
|
||||
),
|
||||
});
|
||||
|
||||
return (
|
||||
<Screen>
|
||||
<Title>{t("wte.title")}</Title>
|
||||
<Small>{t("wte.subtitle")}</Small>
|
||||
<Spacer size={spacing.sm} />
|
||||
|
||||
<Row>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Input
|
||||
placeholder={t("wte.cravingPlaceholder")}
|
||||
value={craving}
|
||||
onChangeText={setCraving}
|
||||
onSubmitEditing={() => setSubmittedCraving(craving)}
|
||||
returnKeyType="search"
|
||||
/>
|
||||
</View>
|
||||
</Row>
|
||||
<Spacer size={spacing.sm} />
|
||||
|
||||
{query.isLoading && <LoadingView />}
|
||||
{query.isError && <ErrorView onRetry={() => void query.refetch()} />}
|
||||
|
||||
{query.data && (
|
||||
<>
|
||||
{query.data.context.activeHolidays.length > 0 && (
|
||||
<Row>
|
||||
{query.data.context.activeHolidays.map((holiday) => (
|
||||
<Tag key={holiday} label={`🎉 ${holiday}`} tone="accent" />
|
||||
))}
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{query.data.mealBoxSuggestions.length > 0 && (
|
||||
<>
|
||||
<Heading>{t("wte.mealBoxFirst")}</Heading>
|
||||
{query.data.mealBoxSuggestions.map((box) => (
|
||||
<Card key={box.mealBoxId} onPress={() => router.push("/meal-boxes")}>
|
||||
<Row style={{ justifyContent: "space-between" }}>
|
||||
<Body>🍱 {box.titleSv}</Body>
|
||||
<Tag
|
||||
label={t("mealbox.portionsLeft", { count: box.portionsRemaining })}
|
||||
tone="success"
|
||||
/>
|
||||
</Row>
|
||||
<Small>{box.whySv}</Small>
|
||||
</Card>
|
||||
))}
|
||||
<Spacer size={spacing.sm} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{query.data.recommendations.length === 0 && <EmptyState text={t("wte.empty")} />}
|
||||
|
||||
{query.data.recommendations.map((rec, index) => (
|
||||
<Card key={rec.recipeId} onPress={() => router.push(`/recipe/${rec.recipeId}`)}>
|
||||
<Row style={{ justifyContent: "space-between" }}>
|
||||
<Heading>
|
||||
{index === 0 ? "⭐ " : ""}
|
||||
{rec.titleSv}
|
||||
</Heading>
|
||||
</Row>
|
||||
<Row>
|
||||
<Tag
|
||||
label={t("wte.coverage", { pct: rec.coveragePercent })}
|
||||
tone={rec.coveragePercent >= 80 ? "success" : "neutral"}
|
||||
/>
|
||||
{rec.usesExpiring.slice(0, 2).map((item) => (
|
||||
<Tag key={item.nameSv} label={`⏳ ${item.nameSv}`} tone="warning" />
|
||||
))}
|
||||
</Row>
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: colors.surfaceAlt,
|
||||
borderRadius: 10,
|
||||
padding: spacing.sm,
|
||||
marginTop: spacing.xs,
|
||||
}}
|
||||
>
|
||||
<Small>💡 {rec.whySv}</Small>
|
||||
</View>
|
||||
{rec.missingIngredients.length > 0 && (
|
||||
<Small>
|
||||
{t("wte.missing", { items: rec.missingIngredients.slice(0, 4).join(", ") })}
|
||||
</Small>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
|
||||
<Spacer size={spacing.sm} />
|
||||
<Button
|
||||
label={t("wte.refresh")}
|
||||
variant="secondary"
|
||||
onPress={() => void query.refetch()}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { View } from "react-native";
|
||||
import { router } from "expo-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api";
|
||||
import { t } from "@/lib/i18n";
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Card,
|
||||
EmptyState,
|
||||
ErrorView,
|
||||
Heading,
|
||||
LoadingView,
|
||||
ProgressBar,
|
||||
Row,
|
||||
Screen,
|
||||
Small,
|
||||
Spacer,
|
||||
Tag,
|
||||
Title,
|
||||
} from "@/components/ui";
|
||||
import { spacing } from "@/lib/theme";
|
||||
|
||||
/** Min dag (spec §4.3): måltider, makron, återstående mål, nästa steg. */
|
||||
|
||||
interface DayResponse {
|
||||
date: string;
|
||||
meals: Array<{
|
||||
id: string;
|
||||
titleSv: string;
|
||||
mealType: string;
|
||||
nutrition: { kcal: number; proteinG: number };
|
||||
nutritionIsEstimate: boolean;
|
||||
estimateMinKcal: number | null;
|
||||
estimateMaxKcal: number | null;
|
||||
}>;
|
||||
summary: {
|
||||
consumed: {
|
||||
kcal: number;
|
||||
proteinG: number;
|
||||
carbsG: number;
|
||||
fatG: number;
|
||||
fiberG: number;
|
||||
saltG: number;
|
||||
};
|
||||
targets: {
|
||||
kcal: number;
|
||||
proteinG: number;
|
||||
carbsG: number;
|
||||
fatG: number;
|
||||
fiberG: number;
|
||||
saltMaxG: number;
|
||||
};
|
||||
remaining: { kcal: number };
|
||||
progress: {
|
||||
kcal: number;
|
||||
proteinG: number;
|
||||
carbsG: number;
|
||||
fatG: number;
|
||||
fiberG: number;
|
||||
saltOfMax: number;
|
||||
};
|
||||
saltWarning: boolean;
|
||||
};
|
||||
note: string;
|
||||
}
|
||||
|
||||
/** Måltidstypernas etiketter bor i i18n-katalogen: myday.mealType.<typ> (12 språk). */
|
||||
const mealTypeLabel = (mealType: string): string =>
|
||||
["breakfast", "lunch", "dinner", "snack", "dessert"].includes(mealType)
|
||||
? t(`myday.mealType.${mealType}`)
|
||||
: mealType;
|
||||
|
||||
export default function MyDayScreen() {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const query = useQuery({
|
||||
queryKey: ["day", today],
|
||||
queryFn: () => api<DayResponse>(`/v1/meals/day?date=${today}`),
|
||||
});
|
||||
|
||||
if (query.isLoading) return <LoadingView />;
|
||||
if (query.isError || !query.data) return <ErrorView onRetry={() => void query.refetch()} />;
|
||||
|
||||
const { summary, meals, note } = query.data;
|
||||
const remaining = summary.remaining.kcal;
|
||||
|
||||
const macros = [
|
||||
{
|
||||
key: "myday.protein",
|
||||
value: summary.consumed.proteinG,
|
||||
target: summary.targets.proteinG,
|
||||
progress: summary.progress.proteinG,
|
||||
unit: "g",
|
||||
},
|
||||
{
|
||||
key: "myday.carbs",
|
||||
value: summary.consumed.carbsG,
|
||||
target: summary.targets.carbsG,
|
||||
progress: summary.progress.carbsG,
|
||||
unit: "g",
|
||||
},
|
||||
{
|
||||
key: "myday.fat",
|
||||
value: summary.consumed.fatG,
|
||||
target: summary.targets.fatG,
|
||||
progress: summary.progress.fatG,
|
||||
unit: "g",
|
||||
},
|
||||
{
|
||||
key: "myday.fiber",
|
||||
value: summary.consumed.fiberG,
|
||||
target: summary.targets.fiberG,
|
||||
progress: summary.progress.fiberG,
|
||||
unit: "g",
|
||||
},
|
||||
] as const;
|
||||
|
||||
return (
|
||||
<Screen>
|
||||
<Title>{t("myday.title")}</Title>
|
||||
|
||||
<Card>
|
||||
<Row style={{ justifyContent: "space-between" }}>
|
||||
<Heading>{t("myday.calories")}</Heading>
|
||||
<Body>
|
||||
{Math.round(summary.consumed.kcal)} / {summary.targets.kcal} kcal
|
||||
</Body>
|
||||
</Row>
|
||||
<ProgressBar progress={summary.progress.kcal} />
|
||||
<Small>
|
||||
{remaining >= 0
|
||||
? t("myday.remaining", { kcal: remaining })
|
||||
: t("myday.over", { kcal: Math.abs(remaining) })}
|
||||
</Small>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
{macros.map((macro) => (
|
||||
<View key={macro.key} style={{ gap: 4, marginBottom: spacing.sm }}>
|
||||
<Row style={{ justifyContent: "space-between" }}>
|
||||
<Small>{t(macro.key)}</Small>
|
||||
<Small>
|
||||
{Math.round(macro.value)} / {macro.target} {macro.unit}
|
||||
</Small>
|
||||
</Row>
|
||||
<ProgressBar progress={macro.progress} />
|
||||
</View>
|
||||
))}
|
||||
<Row style={{ justifyContent: "space-between" }}>
|
||||
<Small>{t("myday.salt")}</Small>
|
||||
<Row>
|
||||
{summary.saltWarning && <Tag label={t("myday.overRecommended")} tone="warning" />}
|
||||
<Small>
|
||||
{Math.round(summary.consumed.saltG * 10) / 10} / max {summary.targets.saltMaxG} g
|
||||
</Small>
|
||||
</Row>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<Heading>{t("myday.todaysMeals")}</Heading>
|
||||
{meals.length === 0 && <EmptyState text={t("myday.noMeals")} />}
|
||||
{meals.map((meal) => (
|
||||
<Card key={meal.id}>
|
||||
<Row style={{ justifyContent: "space-between" }}>
|
||||
<Body>
|
||||
{meal.titleSv}
|
||||
{meal.nutritionIsEstimate ? " ~" : ""}
|
||||
</Body>
|
||||
<Tag label={mealTypeLabel(meal.mealType)} />
|
||||
</Row>
|
||||
<Small>
|
||||
{meal.nutritionIsEstimate && meal.estimateMinKcal != null
|
||||
? t("myday.estimateRange", {
|
||||
min: meal.estimateMinKcal,
|
||||
max: meal.estimateMaxKcal ?? meal.estimateMinKcal,
|
||||
kcal: Math.round(meal.nutrition.kcal),
|
||||
})
|
||||
: t("myday.kcalProtein", {
|
||||
kcal: Math.round(meal.nutrition.kcal),
|
||||
protein: Math.round(meal.nutrition.proteinG),
|
||||
})}
|
||||
</Small>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
<Spacer size={spacing.sm} />
|
||||
<Button label={t("myday.logMeal")} onPress={() => router.push("/log-meal")} />
|
||||
<Small>{note}</Small>
|
||||
<Small>{t("myday.targetsNote")}</Small>
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useState } from "react";
|
||||
import { Alert, View } from "react-native";
|
||||
import { router } from "expo-router";
|
||||
import * as ImagePicker from "expo-image-picker";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api, uploadImage } from "@/lib/api";
|
||||
import { t } from "@/lib/i18n";
|
||||
import {
|
||||
Body,
|
||||
Card,
|
||||
Heading,
|
||||
LoadingView,
|
||||
Row,
|
||||
Screen,
|
||||
Small,
|
||||
Spacer,
|
||||
Title,
|
||||
} from "@/components/ui";
|
||||
import { spacing } from "@/lib/theme";
|
||||
|
||||
/** Skanna (spec §4.2): kyl, frys, skafferi, ingredienser, tallrik, kvitto, streckkod, datum, näringsdeklaration. */
|
||||
|
||||
const SCAN_TYPES = [
|
||||
{ type: "fridge", glyph: "🧊", labelKey: "scan.fridge" },
|
||||
{ type: "freezer", glyph: "❄️", labelKey: "scan.freezer" },
|
||||
{ type: "pantry", glyph: "🗄️", labelKey: "scan.pantry" },
|
||||
{ type: "ingredients", glyph: "🥕", labelKey: "scan.ingredients" },
|
||||
{ type: "plate", glyph: "🍽️", labelKey: "scan.plate" },
|
||||
{ type: "receipt", glyph: "🧾", labelKey: "scan.receipt" },
|
||||
{ type: "barcode", glyph: "🏷️", labelKey: "scan.barcode" },
|
||||
{ type: "expiry_date", glyph: "📅", labelKey: "scan.expiry" },
|
||||
{ type: "nutrition_label", glyph: "🔬", labelKey: "scan.nutrition" },
|
||||
] as const;
|
||||
|
||||
interface CreateScanResponse {
|
||||
scan: { id: string };
|
||||
uploads: Array<{ key: string; uploadUrl: string; headers: Record<string, string> }>;
|
||||
}
|
||||
interface Entitlements {
|
||||
aiScansPerMonth: number;
|
||||
aiScansUsedThisMonth: number;
|
||||
}
|
||||
|
||||
export default function ScanScreen() {
|
||||
const [busyType, setBusyType] = useState<string | null>(null);
|
||||
const entitlements = useQuery({
|
||||
queryKey: ["entitlements"],
|
||||
queryFn: () => api<Entitlements>("/v1/me/entitlements"),
|
||||
});
|
||||
|
||||
const startScan = async (scanType: string) => {
|
||||
if (scanType === "barcode") {
|
||||
router.push("/barcode");
|
||||
return;
|
||||
}
|
||||
setBusyType(scanType);
|
||||
try {
|
||||
// 1. Ta bild
|
||||
const permission = await ImagePicker.requestCameraPermissionsAsync();
|
||||
let picked: ImagePicker.ImagePickerResult;
|
||||
if (permission.granted) {
|
||||
picked = await ImagePicker.launchCameraAsync({ quality: 0.7 });
|
||||
} else {
|
||||
picked = await ImagePicker.launchImageLibraryAsync({ quality: 0.7 });
|
||||
}
|
||||
const asset = picked.assets?.[0];
|
||||
if (picked.canceled || !asset) return;
|
||||
|
||||
// 2. Skapa jobb + presignad upload (kvotkontroll sker i API:t)
|
||||
const created = await api<CreateScanResponse>("/v1/scans", {
|
||||
method: "POST",
|
||||
body: { scanType, imageCount: 1, contentType: "image/jpeg" },
|
||||
});
|
||||
|
||||
// 3. Ladda upp + starta analysen
|
||||
const upload = created.uploads[0];
|
||||
if (!upload) throw new Error(t("common.error"));
|
||||
await uploadImage(upload, asset.uri);
|
||||
await api(`/v1/scans/${created.scan.id}/start`, { method: "POST" });
|
||||
|
||||
// 4. Vidare till granskningsvyn som pollar tills resultatet kommer
|
||||
router.push(`/scan-review/${created.scan.id}`);
|
||||
} catch (err) {
|
||||
Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error"));
|
||||
} finally {
|
||||
setBusyType(null);
|
||||
}
|
||||
};
|
||||
|
||||
const quota = entitlements.data
|
||||
? entitlements.data.aiScansPerMonth - entitlements.data.aiScansUsedThisMonth
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Screen>
|
||||
<Title>{t("scan.title")}</Title>
|
||||
<Small>{t("scan.subtitle")}</Small>
|
||||
{quota != null && <Small>{t("scan.quotaLeft", { count: Math.max(0, quota) })}</Small>}
|
||||
<Spacer size={spacing.sm} />
|
||||
|
||||
{busyType && <LoadingView />}
|
||||
|
||||
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: spacing.sm }}>
|
||||
{SCAN_TYPES.map((item) => (
|
||||
<Card
|
||||
key={item.type}
|
||||
onPress={() => void startScan(item.type)}
|
||||
style={{ width: "31%", alignItems: "center", paddingVertical: spacing.md }}
|
||||
>
|
||||
<Body>{item.glyph}</Body>
|
||||
<Small>{t(item.labelKey as never)}</Small>
|
||||
</Card>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<Spacer />
|
||||
<Card>
|
||||
<Heading>{t("scan.tips.title")}</Heading>
|
||||
<Small>• {t("scan.tips.overview")}</Small>
|
||||
<Small>• {t("scan.tips.shelf")}</Small>
|
||||
<Small>• {t("scan.tips.light")}</Small>
|
||||
<Small>• {t("scan.tips.move")}</Small>
|
||||
</Card>
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Stack } from "expo-router";
|
||||
import { StatusBar } from "expo-status-bar";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
||||
import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { BRAND } from "@/lib/brand";
|
||||
import { t, useI18nVersion } from "@/lib/i18n";
|
||||
import { colors } from "@/lib/theme";
|
||||
import { LoadingView } from "@/components/ui";
|
||||
|
||||
/**
|
||||
* Rotlayout: React Query med AsyncStorage-persistens för Smart Cache/offline
|
||||
* (spec §44: cachelagrade recept, lager och inköpslista fungerar offline).
|
||||
*/
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30_000,
|
||||
gcTime: 7 * 24 * 3600_000, // behåll cache en vecka för offline-läge
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const persister = createAsyncStoragePersister({
|
||||
storage: AsyncStorage,
|
||||
key: `${BRAND.slug}-query-cache`,
|
||||
});
|
||||
|
||||
export default function RootLayout() {
|
||||
const hydrate = useAuth((s) => s.hydrate);
|
||||
const hydrated = useAuth((s) => s.hydrated);
|
||||
const [ready, setReady] = useState(false);
|
||||
// Språkbyte utan omstart (i18n M4): versionen bumpar vid setLocale och
|
||||
// key={} re-monterar hela trädet så att alla t()-strängar byts direkt.
|
||||
const i18nVersion = useI18nVersion();
|
||||
|
||||
useEffect(() => {
|
||||
void hydrate().then(() => setReady(true));
|
||||
}, [hydrate]);
|
||||
|
||||
if (!hydrated || !ready) return <LoadingView />;
|
||||
|
||||
return (
|
||||
<PersistQueryClientProvider
|
||||
key={`i18n-${i18nVersion}`}
|
||||
client={queryClient}
|
||||
persistOptions={{ persister }}
|
||||
>
|
||||
<StatusBar style="dark" />
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerStyle: { backgroundColor: colors.background },
|
||||
headerTintColor: colors.text,
|
||||
headerShadowVisible: false,
|
||||
contentStyle: { backgroundColor: colors.background },
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="(auth)/login" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="(auth)/register" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="(auth)/forgot-password" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="(auth)/reset-password" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="(auth)/verify-email" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="onboarding" options={{ headerShown: false, gestureEnabled: false }} />
|
||||
<Stack.Screen name="recipe/[id]" options={{ title: "" }} />
|
||||
<Stack.Screen
|
||||
name="cooking/[id]"
|
||||
options={{ title: "", presentation: "fullScreenModal" }}
|
||||
/>
|
||||
<Stack.Screen name="scan-review/[jobId]" options={{ title: t("scan.review.title") }} />
|
||||
<Stack.Screen name="shopping" options={{ title: t("shopping.title") }} />
|
||||
<Stack.Screen name="meal-boxes" options={{ title: t("mealbox.title") }} />
|
||||
<Stack.Screen name="household" options={{ title: t("home.household") }} />
|
||||
<Stack.Screen name="memory" options={{ title: t("memory.title") }} />
|
||||
<Stack.Screen name="profile" options={{ title: t("profile.title") }} />
|
||||
<Stack.Screen
|
||||
name="paywall"
|
||||
options={{ title: t("paywall.title"), presentation: "modal" }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="log-meal"
|
||||
options={{ title: t("myday.logMeal"), presentation: "modal" }}
|
||||
/>
|
||||
<Stack.Screen name="barcode" options={{ title: "Streckkod" }} />
|
||||
</Stack>
|
||||
</PersistQueryClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useState } from "react";
|
||||
import { Alert, StyleSheet, View } from "react-native";
|
||||
import { router } from "expo-router";
|
||||
import { CameraView, useCameraPermissions } from "expo-camera";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api";
|
||||
import { t } from "@/lib/i18n";
|
||||
import { Body, Button, Card, Heading, LoadingView, Screen, Small, Spacer } from "@/components/ui";
|
||||
import { colors } from "@/lib/theme";
|
||||
|
||||
/**
|
||||
* Streckkodsskanning (spec §11): läses LOKALT med kameran, slås upp mot
|
||||
* egen produktdatabas → Open Food Facts. Saknas produkten uppmanas
|
||||
* användaren att fota förpackningen (READ_NUTRITION_LABEL-flödet).
|
||||
*/
|
||||
|
||||
interface BarcodeScanResponse {
|
||||
product: {
|
||||
id: string;
|
||||
name: string;
|
||||
brand: string | null;
|
||||
nutrition: { values: { kcal: number } } | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export default function BarcodeScreen() {
|
||||
const [permission, requestPermission] = useCameraPermissions();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [lastCode, setLastCode] = useState<string | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const onScanned = async (gtin: string) => {
|
||||
if (busy || gtin === lastCode) return;
|
||||
setBusy(true);
|
||||
setLastCode(gtin);
|
||||
try {
|
||||
const result = await api<BarcodeScanResponse>("/v1/scans", {
|
||||
method: "POST",
|
||||
body: { scanType: "barcode", barcode: gtin, imageCount: 0 },
|
||||
});
|
||||
if (result.product) {
|
||||
Alert.alert(
|
||||
result.product.name,
|
||||
`${result.product.brand ?? ""}\n${result.product.nutrition ? t("barcode.kcalPer100", { kcal: result.product.nutrition.values.kcal }) : t("barcode.noNutrition")}\n\n${t("barcode.addPrompt")}`,
|
||||
[
|
||||
{ text: t("common.cancel"), style: "cancel", onPress: () => setBusy(false) },
|
||||
{
|
||||
text: t("common.add"),
|
||||
onPress: () => {
|
||||
void addToInventory(result.product!.name);
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
} else {
|
||||
Alert.alert(t("barcode.unknownTitle"), t("barcode.unknownBody"), [
|
||||
{ text: t("common.cancel"), style: "cancel", onPress: () => setBusy(false) },
|
||||
{ text: t("barcode.photoPackage"), onPress: () => router.replace("/(tabs)/scan") },
|
||||
]);
|
||||
}
|
||||
} catch (err) {
|
||||
Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error"));
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addToInventory = async (name: string) => {
|
||||
try {
|
||||
const me = await api<{ activeHouseholdId: string | null }>("/v1/me");
|
||||
if (!me.activeHouseholdId) throw new Error(t("barcode.needHousehold"));
|
||||
const household = await api<{ storageLocations: Array<{ id: string; type: string }> }>(
|
||||
`/v1/households/${me.activeHouseholdId}`,
|
||||
);
|
||||
const fridge =
|
||||
household.storageLocations.find((l) => l.type === "fridge") ??
|
||||
household.storageLocations[0];
|
||||
if (!fridge) throw new Error(t("barcode.noLocation"));
|
||||
await api("/v1/inventory/items", {
|
||||
method: "POST",
|
||||
body: {
|
||||
displayName: name,
|
||||
quantity: 1,
|
||||
unit: "COUNT",
|
||||
storageLocationId: fridge.id,
|
||||
source: "barcode",
|
||||
},
|
||||
});
|
||||
await queryClient.invalidateQueries({ queryKey: ["inventory"] });
|
||||
router.back();
|
||||
} catch (err) {
|
||||
Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error"));
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!permission) return <LoadingView />;
|
||||
if (!permission.granted) {
|
||||
return (
|
||||
<Screen>
|
||||
<Card>
|
||||
<Heading>{t("barcode.cameraTitle")}</Heading>
|
||||
<Body>{t("barcode.cameraBody")}</Body>
|
||||
<Spacer size={8} />
|
||||
<Button label={t("barcode.allowCamera")} onPress={() => void requestPermission()} />
|
||||
</Card>
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<CameraView
|
||||
style={StyleSheet.absoluteFill}
|
||||
barcodeScannerSettings={{ barcodeTypes: ["ean13", "ean8", "upc_a", "upc_e"] }}
|
||||
onBarcodeScanned={(scan) => void onScanned(scan.data)}
|
||||
/>
|
||||
<View style={styles.overlay}>
|
||||
<View style={styles.frame} />
|
||||
<Small>{busy ? t("scan.analyzing") : t("barcode.aim")}</Small>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: "#000" },
|
||||
overlay: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 16,
|
||||
},
|
||||
frame: {
|
||||
width: 260,
|
||||
height: 160,
|
||||
borderWidth: 3,
|
||||
borderColor: colors.primary,
|
||||
borderRadius: 16,
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Alert, Pressable, Text, View } from "react-native";
|
||||
import { router, useLocalSearchParams } from "expo-router";
|
||||
import { useKeepAwake } from "expo-keep-awake";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api";
|
||||
import { t } from "@/lib/i18n";
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Card,
|
||||
ErrorView,
|
||||
Heading,
|
||||
LoadingView,
|
||||
Row,
|
||||
Screen,
|
||||
Small,
|
||||
Spacer,
|
||||
} from "@/components/ui";
|
||||
import { colors, spacing } from "@/lib/theme";
|
||||
|
||||
/**
|
||||
* Cooking Mode (spec §41): stora steg, stora knappar, skärmen vaken,
|
||||
* timers per steg, portionsskalning. Avslutas med "jag lagade detta"
|
||||
* som drar lager, loggar måltid och kan skapa matlådor (spec §23–24).
|
||||
*/
|
||||
|
||||
interface RecipeForCooking {
|
||||
id: string;
|
||||
titleSv: string;
|
||||
portions: number;
|
||||
steps: Array<{
|
||||
id: string;
|
||||
stepNumber: number;
|
||||
instructionSv: string;
|
||||
timerSeconds: number | null;
|
||||
temperatureC: number | null;
|
||||
tip: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default function CookingScreen() {
|
||||
useKeepAwake();
|
||||
const { id, portions: portionsParam } = useLocalSearchParams<{ id: string; portions?: string }>();
|
||||
const queryClient = useQueryClient();
|
||||
const [stepIndex, setStepIndex] = useState(0);
|
||||
const [timerLeft, setTimerLeft] = useState<number | null>(null);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const [finishing, setFinishing] = useState(false);
|
||||
const [mealBoxPortions, setMealBoxPortions] = useState(0);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["recipe", id],
|
||||
queryFn: () => api<RecipeForCooking>(`/v1/recipes/${id}`),
|
||||
});
|
||||
|
||||
const cook = useMutation({
|
||||
mutationFn: (body: unknown) => api(`/v1/recipes/${id}/cook`, { method: "POST", body }),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["inventory"] });
|
||||
await queryClient.invalidateQueries({ queryKey: ["day"] });
|
||||
await queryClient.invalidateQueries({ queryKey: ["what-to-eat"] });
|
||||
router.dismissAll();
|
||||
},
|
||||
onError: (err) =>
|
||||
Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error")),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (query.isLoading) return <LoadingView />;
|
||||
if (query.isError || !query.data) return <ErrorView onRetry={() => void query.refetch()} />;
|
||||
const recipe = query.data;
|
||||
const portionsCooked = Number(portionsParam ?? recipe.portions) || recipe.portions;
|
||||
const step = recipe.steps[stepIndex];
|
||||
const isLast = stepIndex >= recipe.steps.length - 1;
|
||||
|
||||
const startTimer = (seconds: number) => {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
setTimerLeft(seconds);
|
||||
timerRef.current = setInterval(() => {
|
||||
setTimerLeft((prev) => {
|
||||
if (prev == null || prev <= 1) {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const finish = () => {
|
||||
setFinishing(true);
|
||||
};
|
||||
|
||||
if (finishing) {
|
||||
return (
|
||||
<Screen>
|
||||
<Heading>{t("cooked.title")}</Heading>
|
||||
<Card>
|
||||
<Body>{recipe.titleSv}</Body>
|
||||
<Small>
|
||||
{t("cooked.portionsCooked")}: {portionsCooked}
|
||||
</Small>
|
||||
</Card>
|
||||
<Card>
|
||||
<Row style={{ justifyContent: "space-between" }}>
|
||||
<Body>🍱 {t("cooked.mealBoxes")}</Body>
|
||||
<Row>
|
||||
<Button
|
||||
label="−"
|
||||
variant="ghost"
|
||||
onPress={() => setMealBoxPortions(Math.max(0, mealBoxPortions - 1))}
|
||||
/>
|
||||
<Body>{mealBoxPortions}</Body>
|
||||
<Button
|
||||
label="+"
|
||||
variant="ghost"
|
||||
onPress={() => setMealBoxPortions(Math.min(portionsCooked, mealBoxPortions + 1))}
|
||||
/>
|
||||
</Row>
|
||||
</Row>
|
||||
</Card>
|
||||
<Small>{t("cooked.deductPantryNote")}</Small>
|
||||
<Spacer size={spacing.sm} />
|
||||
<Button
|
||||
label={t("cooking.finish")}
|
||||
loading={cook.isPending}
|
||||
onPress={() =>
|
||||
cook.mutate({
|
||||
portionsCooked,
|
||||
mealBoxPortions,
|
||||
mealBoxFrozen: false,
|
||||
deductInventory: true,
|
||||
mealType: "dinner",
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Button label={t("common.back")} variant="ghost" onPress={() => setFinishing(false)} />
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Screen scroll={false} style={{ justifyContent: "space-between" }}>
|
||||
<View style={{ gap: spacing.md }}>
|
||||
<Small>{recipe.titleSv}</Small>
|
||||
<Heading>
|
||||
{t("cooking.step", { current: stepIndex + 1, total: recipe.steps.length })}
|
||||
</Heading>
|
||||
<Text style={{ fontSize: 24, lineHeight: 34, color: colors.text }}>
|
||||
{step?.instructionSv}
|
||||
{step?.temperatureC ? ` (${step.temperatureC} °C)` : ""}
|
||||
</Text>
|
||||
{step?.tip && <Small>💡 {step.tip}</Small>}
|
||||
|
||||
{step?.timerSeconds != null && (
|
||||
<Pressable
|
||||
onPress={() => startTimer(step.timerSeconds!)}
|
||||
style={{
|
||||
backgroundColor: timerLeft != null ? colors.accentSoft : colors.primarySoft,
|
||||
padding: spacing.lg,
|
||||
borderRadius: 16,
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 32, fontWeight: "700", color: colors.primaryDark }}>
|
||||
{timerLeft != null ? formatTime(timerLeft) : formatTime(step.timerSeconds)}
|
||||
</Text>
|
||||
<Small>
|
||||
{timerLeft != null
|
||||
? timerLeft === 0
|
||||
? `${t("cooking.timerDone")} 🎉`
|
||||
: t("cooking.timerRunning", { time: formatTime(timerLeft) })
|
||||
: t("cooking.timer")}
|
||||
</Small>
|
||||
</Pressable>
|
||||
)}
|
||||
<Small>{t("cooking.keepAwake")}</Small>
|
||||
</View>
|
||||
|
||||
<Row style={{ paddingBottom: spacing.lg }}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Button
|
||||
label={`← ${t("common.back")}`}
|
||||
variant="ghost"
|
||||
disabled={stepIndex === 0}
|
||||
onPress={() => setStepIndex(Math.max(0, stepIndex - 1))}
|
||||
/>
|
||||
</View>
|
||||
<View style={{ flex: 2 }}>
|
||||
<Button
|
||||
label={isLast ? t("cooking.finish") : `${t("common.next")} →`}
|
||||
onPress={() => (isLast ? finish() : setStepIndex(stepIndex + 1))}
|
||||
/>
|
||||
</View>
|
||||
</Row>
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTime(totalSeconds: number): string {
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return `${minutes}:${String(seconds).padStart(2, "0")}`;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api";
|
||||
import { t } from "@/lib/i18n";
|
||||
import {
|
||||
Body,
|
||||
Card,
|
||||
EmptyState,
|
||||
ErrorView,
|
||||
Heading,
|
||||
LoadingView,
|
||||
Row,
|
||||
Screen,
|
||||
Small,
|
||||
Tag,
|
||||
} from "@/components/ui";
|
||||
|
||||
/** Hushåll (spec §7): medlemmar, inbjudningskod, delat vs privat. */
|
||||
|
||||
interface HouseholdDetail {
|
||||
id: string;
|
||||
name: string;
|
||||
inviteCode: string;
|
||||
members: Array<{ userId: string; displayName: string; role: string; portionFactor: number }>;
|
||||
storageLocations: Array<{ id: string; name: string; type: string }>;
|
||||
}
|
||||
|
||||
/** Rolletiketter ur i18n-katalogen: household.role.<roll> (12 språk). */
|
||||
const roleLabel = (role: string): string =>
|
||||
["owner", "adult", "member", "child"].includes(role) ? t(`household.role.${role}`) : role;
|
||||
|
||||
export default function HouseholdScreen() {
|
||||
const me = useQuery({
|
||||
queryKey: ["me"],
|
||||
queryFn: () => api<{ activeHouseholdId: string | null }>("/v1/me"),
|
||||
});
|
||||
const householdId = me.data?.activeHouseholdId;
|
||||
|
||||
const household = useQuery({
|
||||
queryKey: ["household", householdId],
|
||||
queryFn: () => api<HouseholdDetail>(`/v1/households/${householdId}`),
|
||||
enabled: Boolean(householdId),
|
||||
});
|
||||
|
||||
if (me.isLoading || household.isLoading) return <LoadingView />;
|
||||
if (!householdId) {
|
||||
return (
|
||||
<Screen>
|
||||
<EmptyState text={t("household.empty")} />
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
if (household.isError || !household.data) {
|
||||
return <ErrorView onRetry={() => void household.refetch()} />;
|
||||
}
|
||||
const data = household.data;
|
||||
|
||||
return (
|
||||
<Screen>
|
||||
<Heading>{data.name}</Heading>
|
||||
<Card>
|
||||
<Body>{t("household.invite", { code: data.inviteCode })}</Body>
|
||||
<Small>{t("household.shareCode")}</Small>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Heading>{t("household.members")}</Heading>
|
||||
{data.members.map((member) => (
|
||||
<Row key={member.userId} style={{ justifyContent: "space-between" }}>
|
||||
<Body>{member.displayName}</Body>
|
||||
<Row>
|
||||
<Tag label={roleLabel(member.role)} />
|
||||
<Small>{t("household.portionFactor", { factor: member.portionFactor })}</Small>
|
||||
</Row>
|
||||
</Row>
|
||||
))}
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Heading>{t("household.locations")}</Heading>
|
||||
{data.storageLocations.map((location) => (
|
||||
<Body key={location.id}>• {location.name}</Body>
|
||||
))}
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Small>✅ {t("household.shared")}</Small>
|
||||
<Small>🔒 {t("household.private")}</Small>
|
||||
</Card>
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useState } from "react";
|
||||
import { Alert, View } from "react-native";
|
||||
import { router } from "expo-router";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api";
|
||||
import { t } from "@/lib/i18n";
|
||||
import { Body, Button, Card, Heading, Input, Row, Screen, Small, Spacer } from "@/components/ui";
|
||||
|
||||
/**
|
||||
* Måltidsloggning (spec §23): tidigare måltid, favoritrecept eller manuellt.
|
||||
* Tallriksfoto loggas via Skanna-fliken. Näringsvärden hittas ALDRIG på:
|
||||
* manuell loggning kräver egna värden eller känt recept/livsmedel.
|
||||
*/
|
||||
|
||||
/** Etiketter ur i18n-katalogen (myday.mealType.<typ>) – id:na är stabila API-värden. */
|
||||
const MEAL_TYPES = ["breakfast", "lunch", "dinner", "snack"] as const;
|
||||
|
||||
interface RecentMeal {
|
||||
id: string;
|
||||
titleSv: string;
|
||||
mealType: string;
|
||||
nutrition: {
|
||||
kcal: number;
|
||||
proteinG: number;
|
||||
carbsG: number;
|
||||
fatG: number;
|
||||
saturatedFatG: number;
|
||||
fiberG: number;
|
||||
sugarG: number;
|
||||
saltG: number;
|
||||
};
|
||||
}
|
||||
|
||||
export default function LogMealScreen() {
|
||||
const queryClient = useQueryClient();
|
||||
const [mealType, setMealType] = useState<string>("lunch");
|
||||
const [title, setTitle] = useState("");
|
||||
const [kcal, setKcal] = useState("");
|
||||
const [protein, setProtein] = useState("");
|
||||
|
||||
const recent = useQuery({
|
||||
queryKey: ["recent-meals"],
|
||||
queryFn: () => api<{ meals: RecentMeal[] }>("/v1/meals/recent"),
|
||||
});
|
||||
|
||||
const log = useMutation({
|
||||
mutationFn: (body: unknown) => api("/v1/meals", { method: "POST", body }),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["day"] });
|
||||
router.back();
|
||||
},
|
||||
onError: (err) =>
|
||||
Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error")),
|
||||
});
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
const logManual = () => {
|
||||
if (!title.trim() || !kcal) {
|
||||
Alert.alert(t("logmeal.validationTitle"), t("logmeal.validationBody"));
|
||||
return;
|
||||
}
|
||||
log.mutate({
|
||||
date: today,
|
||||
mealType,
|
||||
source: "manual",
|
||||
titleSv: title.trim(),
|
||||
nutritionOverride: {
|
||||
kcal: Number(kcal),
|
||||
proteinG: Number(protein) || 0,
|
||||
carbsG: 0,
|
||||
fatG: 0,
|
||||
saturatedFatG: 0,
|
||||
fiberG: 0,
|
||||
sugarG: 0,
|
||||
saltG: 0,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const logPrevious = (meal: RecentMeal) => {
|
||||
log.mutate({
|
||||
date: today,
|
||||
mealType,
|
||||
source: "previous_meal",
|
||||
titleSv: meal.titleSv,
|
||||
nutritionOverride: meal.nutrition,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Screen>
|
||||
<Heading>{t("logmeal.mealTypeTitle")}</Heading>
|
||||
<Row>
|
||||
{MEAL_TYPES.map((id) => (
|
||||
<Button
|
||||
key={id}
|
||||
label={t(`myday.mealType.${id}`)}
|
||||
variant={mealType === id ? "secondary" : "ghost"}
|
||||
onPress={() => setMealType(id)}
|
||||
/>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
<Spacer />
|
||||
<Heading>{t("logmeal.quickTitle")}</Heading>
|
||||
<Small>{t("logmeal.quickSubtitle")}</Small>
|
||||
{(recent.data?.meals ?? []).slice(0, 5).map((meal) => (
|
||||
<Card key={meal.id} onPress={() => logPrevious(meal)}>
|
||||
<Row style={{ justifyContent: "space-between" }}>
|
||||
<Body>{meal.titleSv}</Body>
|
||||
<Small>{Math.round(meal.nutrition.kcal)} kcal</Small>
|
||||
</Row>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
<Spacer />
|
||||
<Heading>{t("logmeal.manualTitle")}</Heading>
|
||||
<Input placeholder={t("logmeal.whatPlaceholder")} value={title} onChangeText={setTitle} />
|
||||
<Row>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Input placeholder="kcal" keyboardType="numeric" value={kcal} onChangeText={setKcal} />
|
||||
</View>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Input
|
||||
placeholder={t("logmeal.proteinPlaceholder")}
|
||||
keyboardType="numeric"
|
||||
value={protein}
|
||||
onChangeText={setProtein}
|
||||
/>
|
||||
</View>
|
||||
</Row>
|
||||
<Button label={t("myday.logMeal")} onPress={logManual} loading={log.isPending} />
|
||||
<Small>{t("logmeal.photoTip")}</Small>
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Alert } from "react-native";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api";
|
||||
import { t } from "@/lib/i18n";
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Card,
|
||||
EmptyState,
|
||||
ErrorView,
|
||||
LoadingView,
|
||||
Row,
|
||||
Screen,
|
||||
Small,
|
||||
Tag,
|
||||
} from "@/components/ui";
|
||||
|
||||
/** Matlådor (spec §24). */
|
||||
|
||||
interface MealBox {
|
||||
id: string;
|
||||
titleSv: string;
|
||||
portionsRemaining: number;
|
||||
recommendedUseBy: string;
|
||||
frozen: boolean;
|
||||
kcalPerPortion?: number;
|
||||
}
|
||||
|
||||
export default function MealBoxesScreen() {
|
||||
const queryClient = useQueryClient();
|
||||
const query = useQuery({
|
||||
queryKey: ["meal-boxes"],
|
||||
queryFn: () => api<{ mealBoxes: MealBox[] }>("/v1/meal-boxes"),
|
||||
});
|
||||
|
||||
const consume = useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
api(`/v1/meal-boxes/${id}/consume`, {
|
||||
method: "POST",
|
||||
body: { portions: 1, logAsMeal: true },
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["meal-boxes"] });
|
||||
await queryClient.invalidateQueries({ queryKey: ["day"] });
|
||||
Alert.alert(t("mealbox.enjoyTitle"), t("mealbox.enjoyBody"));
|
||||
},
|
||||
onError: (err) =>
|
||||
Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error")),
|
||||
});
|
||||
|
||||
if (query.isLoading) return <LoadingView />;
|
||||
if (query.isError || !query.data) return <ErrorView onRetry={() => void query.refetch()} />;
|
||||
|
||||
const boxes = query.data.mealBoxes;
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
return (
|
||||
<Screen>
|
||||
{boxes.length === 0 && <EmptyState text={t("mealbox.empty")} />}
|
||||
{boxes.map((box) => {
|
||||
const urgent = box.recommendedUseBy <= today;
|
||||
return (
|
||||
<Card key={box.id}>
|
||||
<Row style={{ justifyContent: "space-between" }}>
|
||||
<Body>
|
||||
{box.frozen ? "❄️ " : "🍱 "}
|
||||
{box.titleSv}
|
||||
</Body>
|
||||
<Tag
|
||||
label={t("mealbox.portionsLeft", { count: box.portionsRemaining })}
|
||||
tone="success"
|
||||
/>
|
||||
</Row>
|
||||
<Row style={{ justifyContent: "space-between" }}>
|
||||
<Tag
|
||||
label={t("mealbox.eatBy", { date: box.recommendedUseBy })}
|
||||
tone={urgent ? "danger" : "neutral"}
|
||||
/>
|
||||
<Button
|
||||
label={t("mealbox.eat")}
|
||||
variant="secondary"
|
||||
onPress={() => consume.mutate(box.id)}
|
||||
/>
|
||||
</Row>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
{/* Mjölkprincipen (spec §13): vägledning, inte dom – användaren avgör med sinnena. */}
|
||||
<Small>{t("mealbox.guidanceNote")}</Small>
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { Alert } from "react-native";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api";
|
||||
import { t } from "@/lib/i18n";
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Card,
|
||||
EmptyState,
|
||||
ErrorView,
|
||||
Heading,
|
||||
LoadingView,
|
||||
Row,
|
||||
Screen,
|
||||
Small,
|
||||
Spacer,
|
||||
Tag,
|
||||
} from "@/components/ui";
|
||||
|
||||
/** "Vad plattformen vet om mig" (spec §32): transparens, korrigering, paus, radering. */
|
||||
|
||||
interface MemoryOverview {
|
||||
sections: Array<{
|
||||
kind: string;
|
||||
title: string;
|
||||
titleSv: string;
|
||||
items: Array<{
|
||||
id: string;
|
||||
summary: string;
|
||||
summarySv: string;
|
||||
origin: "user_stated" | "observed" | "ai_inferred";
|
||||
confidence: number;
|
||||
verifiedByUser: boolean;
|
||||
paused: boolean;
|
||||
}>;
|
||||
}>;
|
||||
totalCount: number;
|
||||
pausedCount: number;
|
||||
}
|
||||
|
||||
export default function MemoryScreen() {
|
||||
const queryClient = useQueryClient();
|
||||
const query = useQuery({
|
||||
queryKey: ["memory"],
|
||||
queryFn: () => api<MemoryOverview>("/v1/me/memory"),
|
||||
});
|
||||
|
||||
const invalidate = () => void queryClient.invalidateQueries({ queryKey: ["memory"] });
|
||||
|
||||
const verify = useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
api(`/v1/me/memory/${id}`, { method: "PATCH", body: { verified: true } }),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
const pause = useMutation({
|
||||
mutationFn: ({ id, paused }: { id: string; paused: boolean }) =>
|
||||
api(`/v1/me/memory/${id}`, { method: "PATCH", body: { paused } }),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: string) => api(`/v1/me/memory/${id}`, { method: "DELETE" }),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
const removeAll = useMutation({
|
||||
mutationFn: () => api("/v1/me/memory", { method: "DELETE" }),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
if (query.isLoading) return <LoadingView />;
|
||||
if (query.isError || !query.data) return <ErrorView onRetry={() => void query.refetch()} />;
|
||||
const overview = query.data;
|
||||
|
||||
return (
|
||||
<Screen>
|
||||
<Small>{t("memory.subtitle")}</Small>
|
||||
{overview.totalCount === 0 && <EmptyState text={t("memory.empty")} />}
|
||||
|
||||
{overview.sections.map((section) => (
|
||||
<Card key={section.kind}>
|
||||
<Heading>{section.title}</Heading>
|
||||
{section.items.map((item) => (
|
||||
<Card key={item.id} style={item.paused ? { opacity: 0.5 } : undefined}>
|
||||
<Body>{item.summary}</Body>
|
||||
<Row>
|
||||
<Tag
|
||||
label={t(`memory.origin.${item.origin}` as never)}
|
||||
tone={
|
||||
item.origin === "user_stated"
|
||||
? "success"
|
||||
: item.origin === "observed"
|
||||
? "neutral"
|
||||
: "warning"
|
||||
}
|
||||
/>
|
||||
{item.verifiedByUser && <Tag label={`✓ ${t("memory.verified")}`} tone="success" />}
|
||||
{item.paused && <Tag label={t("memory.paused")} tone="warning" />}
|
||||
</Row>
|
||||
<Row>
|
||||
{!item.verifiedByUser && (
|
||||
<Button
|
||||
label={t("memory.verify")}
|
||||
variant="ghost"
|
||||
onPress={() => verify.mutate(item.id)}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
label={item.paused ? t("memory.resume") : t("memory.pause")}
|
||||
variant="ghost"
|
||||
onPress={() => pause.mutate({ id: item.id, paused: !item.paused })}
|
||||
/>
|
||||
<Button
|
||||
label={t("memory.delete")}
|
||||
variant="danger"
|
||||
onPress={() => remove.mutate(item.id)}
|
||||
/>
|
||||
</Row>
|
||||
</Card>
|
||||
))}
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{overview.totalCount > 0 && (
|
||||
<>
|
||||
<Spacer />
|
||||
<Button
|
||||
label={t("memory.deleteAll")}
|
||||
variant="danger"
|
||||
onPress={() =>
|
||||
Alert.alert(t("memory.deleteAllTitle"), t("memory.irreversible"), [
|
||||
{ text: t("common.cancel"), style: "cancel" },
|
||||
{
|
||||
text: t("memory.delete"),
|
||||
style: "destructive",
|
||||
onPress: () => removeAll.mutate(),
|
||||
},
|
||||
])
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Small>Personligt minne används aldrig som träningsdata utan separat samtycke.</Small>
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
import { useState } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { router } from "expo-router";
|
||||
import { api } from "@/lib/api";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { t } from "@/lib/i18n";
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Card,
|
||||
Heading,
|
||||
Input,
|
||||
Row,
|
||||
Screen,
|
||||
Small,
|
||||
Spacer,
|
||||
Tag,
|
||||
Title,
|
||||
} from "@/components/ui";
|
||||
import { colors, spacing } from "@/lib/theme";
|
||||
|
||||
/** Onboarding (spec §6): mål, kost, allergier, hushåll, läge – allt frivilligt. */
|
||||
|
||||
// Etiketter hämtas ur i18n-katalogen (12 språk) – id:na är stabila API-värden.
|
||||
const GOALS = [
|
||||
"lose_weight",
|
||||
"build_muscle",
|
||||
"maintain_weight",
|
||||
"more_protein",
|
||||
"less_waste",
|
||||
"lower_cost",
|
||||
"cook_more",
|
||||
] as const;
|
||||
|
||||
const DIETS = ["omnivore", "flexitarian", "pescatarian", "vegetarian", "vegan"] as const;
|
||||
|
||||
const ALLERGENS = [
|
||||
"gluten",
|
||||
"milk",
|
||||
"eggs",
|
||||
"tree_nuts",
|
||||
"peanuts",
|
||||
"fish",
|
||||
"crustaceans",
|
||||
"soy",
|
||||
"sesame",
|
||||
] as const;
|
||||
|
||||
export default function OnboardingScreen() {
|
||||
const setOnboardingCompleted = useAuth((s) => s.setOnboardingCompleted);
|
||||
const [step, setStep] = useState(0);
|
||||
const [goal, setGoal] = useState<string | null>(null);
|
||||
const [diet, setDiet] = useState<string>("omnivore");
|
||||
const [allergens, setAllergens] = useState<string[]>([]);
|
||||
const [mode, setMode] = useState<"simple" | "exact">("simple");
|
||||
const [householdKind, setHouseholdKind] = useState<"create" | "join" | "skip">("create");
|
||||
const [householdName, setHouseholdName] = useState(t("onboarding.householdDefaultName"));
|
||||
const [inviteCode, setInviteCode] = useState("");
|
||||
const [weightKg, setWeightKg] = useState("");
|
||||
const [heightCm, setHeightCm] = useState("");
|
||||
const [birthYear, setBirthYear] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const toggleAllergen = (id: string) =>
|
||||
setAllergens((prev) => (prev.includes(id) ? prev.filter((a) => a !== id) : [...prev, id]));
|
||||
|
||||
const finish = async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api("/v1/me/onboarding", {
|
||||
method: "POST",
|
||||
body: {
|
||||
precisionMode: mode,
|
||||
healthProfile: {
|
||||
...(weightKg ? { weightKg: Number(weightKg) } : {}),
|
||||
...(heightCm ? { heightCm: Number(heightCm) } : {}),
|
||||
...(birthYear ? { birthYear: Number(birthYear) } : {}),
|
||||
},
|
||||
preferences: {
|
||||
...(goal ? { primaryGoal: goal } : {}),
|
||||
dietPattern: diet,
|
||||
allergens,
|
||||
},
|
||||
householdChoice:
|
||||
householdKind === "create"
|
||||
? { kind: "create", name: householdName || t("onboarding.householdDefaultName") }
|
||||
: householdKind === "join"
|
||||
? { kind: "join", inviteCode }
|
||||
: { kind: "skip" },
|
||||
},
|
||||
});
|
||||
setOnboardingCompleted(true);
|
||||
router.replace("/(tabs)");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("common.error"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const steps = [
|
||||
// 0: Mål
|
||||
<View key="goal" style={{ gap: spacing.sm }}>
|
||||
<Heading>{t("onboarding.goal")}</Heading>
|
||||
{GOALS.map((id) => (
|
||||
<SelectRow
|
||||
key={id}
|
||||
label={t(`onboarding.goal.${id}`)}
|
||||
selected={goal === id}
|
||||
onPress={() => setGoal(id)}
|
||||
/>
|
||||
))}
|
||||
</View>,
|
||||
// 1: Kost + allergier
|
||||
<View key="diet" style={{ gap: spacing.sm }}>
|
||||
<Heading>{t("onboarding.diet")}</Heading>
|
||||
<Row>
|
||||
{DIETS.map((id) => (
|
||||
<Chip
|
||||
key={id}
|
||||
label={t(`onboarding.diet.${id}`)}
|
||||
selected={diet === id}
|
||||
onPress={() => setDiet(id)}
|
||||
/>
|
||||
))}
|
||||
</Row>
|
||||
<Spacer />
|
||||
<Heading>{t("onboarding.allergies")}</Heading>
|
||||
<Row>
|
||||
{ALLERGENS.map((id) => (
|
||||
<Chip
|
||||
key={id}
|
||||
label={t(`onboarding.allergen.${id}`)}
|
||||
selected={allergens.includes(id)}
|
||||
onPress={() => toggleAllergen(id)}
|
||||
/>
|
||||
))}
|
||||
</Row>
|
||||
<Small>{t("onboarding.allergyNote")}</Small>
|
||||
</View>,
|
||||
// 2: Kroppsdata (frivilligt, för energiberäkning)
|
||||
<View key="body" style={{ gap: spacing.sm }}>
|
||||
<Heading>{t("onboarding.bodyTitle")}</Heading>
|
||||
<Input
|
||||
placeholder={t("onboarding.weightPlaceholder")}
|
||||
keyboardType="numeric"
|
||||
value={weightKg}
|
||||
onChangeText={setWeightKg}
|
||||
/>
|
||||
<Input
|
||||
placeholder={t("onboarding.heightPlaceholder")}
|
||||
keyboardType="numeric"
|
||||
value={heightCm}
|
||||
onChangeText={setHeightCm}
|
||||
/>
|
||||
<Input
|
||||
placeholder={t("onboarding.birthYearPlaceholder")}
|
||||
keyboardType="numeric"
|
||||
value={birthYear}
|
||||
onChangeText={setBirthYear}
|
||||
/>
|
||||
<Small>{t("onboarding.notMedical")}</Small>
|
||||
</View>,
|
||||
// 3: Hushåll
|
||||
<View key="household" style={{ gap: spacing.sm }}>
|
||||
<Heading>{t("onboarding.household")}</Heading>
|
||||
<SelectRow
|
||||
label={t("onboarding.householdCreate")}
|
||||
selected={householdKind === "create"}
|
||||
onPress={() => setHouseholdKind("create")}
|
||||
/>
|
||||
{householdKind === "create" && (
|
||||
<Input
|
||||
placeholder={t("onboarding.householdName")}
|
||||
value={householdName}
|
||||
onChangeText={setHouseholdName}
|
||||
/>
|
||||
)}
|
||||
<SelectRow
|
||||
label={t("onboarding.householdJoin")}
|
||||
selected={householdKind === "join"}
|
||||
onPress={() => setHouseholdKind("join")}
|
||||
/>
|
||||
{householdKind === "join" && (
|
||||
<Input
|
||||
placeholder={t("onboarding.inviteCode")}
|
||||
autoCapitalize="characters"
|
||||
value={inviteCode}
|
||||
onChangeText={setInviteCode}
|
||||
/>
|
||||
)}
|
||||
<SelectRow
|
||||
label={t("common.skip")}
|
||||
selected={householdKind === "skip"}
|
||||
onPress={() => setHouseholdKind("skip")}
|
||||
/>
|
||||
</View>,
|
||||
// 4: Läge (spec §5)
|
||||
<View key="mode" style={{ gap: spacing.sm }}>
|
||||
<Heading>{t("onboarding.mode")}</Heading>
|
||||
<Card onPress={() => setMode("simple")} style={mode === "simple" ? sel : undefined}>
|
||||
<Body>{t("onboarding.modeSimple")}</Body>
|
||||
<Small>{t("onboarding.modeSimpleDesc")}</Small>
|
||||
</Card>
|
||||
<Card onPress={() => setMode("exact")} style={mode === "exact" ? sel : undefined}>
|
||||
<Body>{t("onboarding.modeExact")}</Body>
|
||||
<Small>{t("onboarding.modeExactDesc")}</Small>
|
||||
</Card>
|
||||
<Small>{t("onboarding.modesCombine")}</Small>
|
||||
</View>,
|
||||
];
|
||||
|
||||
const last = step === steps.length - 1;
|
||||
return (
|
||||
<Screen>
|
||||
<Title>{t("onboarding.title")}</Title>
|
||||
<Small>{t("onboarding.subtitle")}</Small>
|
||||
<Row style={{ marginVertical: spacing.sm }}>
|
||||
{steps.map((_, i) => (
|
||||
<View
|
||||
key={i}
|
||||
style={{
|
||||
flex: 1,
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
backgroundColor: i <= step ? colors.primary : colors.border,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Row>
|
||||
{steps[step]}
|
||||
{error && <Text style={{ color: colors.danger }}>{error}</Text>}
|
||||
<Spacer />
|
||||
<Row>
|
||||
{step > 0 && (
|
||||
<View style={{ flex: 1 }}>
|
||||
<Button label={t("common.back")} variant="ghost" onPress={() => setStep(step - 1)} />
|
||||
</View>
|
||||
)}
|
||||
<View style={{ flex: 2 }}>
|
||||
<Button
|
||||
label={last ? t("common.done") : t("common.next")}
|
||||
onPress={() => (last ? void finish() : setStep(step + 1))}
|
||||
loading={busy}
|
||||
/>
|
||||
</View>
|
||||
</Row>
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
||||
const sel = { borderColor: colors.primary, borderWidth: 2, backgroundColor: colors.primarySoft };
|
||||
|
||||
function SelectRow({
|
||||
label,
|
||||
selected,
|
||||
onPress,
|
||||
}: {
|
||||
label: string;
|
||||
selected: boolean;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
style={{
|
||||
padding: spacing.md,
|
||||
borderRadius: 12,
|
||||
borderWidth: selected ? 2 : 1,
|
||||
borderColor: selected ? colors.primary : colors.border,
|
||||
backgroundColor: selected ? colors.primarySoft : colors.surface,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: colors.text, fontWeight: selected ? "700" : "400" }}>{label}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function Chip({
|
||||
label,
|
||||
selected,
|
||||
onPress,
|
||||
}: {
|
||||
label: string;
|
||||
selected: boolean;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
style={{
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 999,
|
||||
borderWidth: 1,
|
||||
borderColor: selected ? colors.primary : colors.border,
|
||||
backgroundColor: selected ? colors.primarySoft : colors.surface,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: selected ? colors.primaryDark : colors.textMuted, fontSize: 13 }}>
|
||||
{label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { Alert } from "react-native";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api";
|
||||
import { t } from "@/lib/i18n";
|
||||
import { formatMinor } from "@/lib/money";
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Card,
|
||||
Heading,
|
||||
Row,
|
||||
Screen,
|
||||
Small,
|
||||
Spacer,
|
||||
Tag,
|
||||
Title,
|
||||
} from "@/components/ui";
|
||||
|
||||
/**
|
||||
* Paywall (spec §45–46). Köpet går via StoreKit/Play Billing i klienten
|
||||
* (react-native-purchases eller expo-in-app-purchases integreras i fas 7);
|
||||
* kvittot verifieras ALLTID av backend (spec §61.14). Här visas planerna och
|
||||
* flödet är förberett med /v1/subscriptions/verify.
|
||||
*/
|
||||
|
||||
interface PlanRow {
|
||||
key: string;
|
||||
priceMinor: number;
|
||||
members: number;
|
||||
highlight?: boolean;
|
||||
}
|
||||
|
||||
const PLANS: PlanRow[] = [
|
||||
{ key: "paywall.household", priceMinor: 7900, members: 3 },
|
||||
{ key: "paywall.family", priceMinor: 12900, members: 6, highlight: true },
|
||||
{ key: "paywall.large", priceMinor: 16900, members: 12 },
|
||||
];
|
||||
|
||||
interface Entitlements {
|
||||
plan: string;
|
||||
status: string;
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
export default function PaywallScreen() {
|
||||
const entitlements = useQuery({
|
||||
queryKey: ["entitlements"],
|
||||
queryFn: () => api<Entitlements>("/v1/me/entitlements"),
|
||||
});
|
||||
|
||||
const trialDaysLeft =
|
||||
entitlements.data?.status === "trial" && entitlements.data.expiresAt
|
||||
? Math.max(0, Math.ceil((Date.parse(entitlements.data.expiresAt) - Date.now()) / 86_400_000))
|
||||
: null;
|
||||
|
||||
const buy = () => {
|
||||
Alert.alert(t("paywall.soonTitle"), t("paywall.soonBody"));
|
||||
};
|
||||
|
||||
return (
|
||||
<Screen>
|
||||
<Title>{t("paywall.title")}</Title>
|
||||
<Body muted>{t("paywall.subtitle")}</Body>
|
||||
{trialDaysLeft != null && (
|
||||
<Tag
|
||||
label={t("paywall.trialActive", { days: trialDaysLeft, count: trialDaysLeft })}
|
||||
tone="accent"
|
||||
/>
|
||||
)}
|
||||
<Spacer />
|
||||
|
||||
{PLANS.map((plan) => (
|
||||
<Card
|
||||
key={plan.key}
|
||||
style={plan.highlight ? { borderColor: "#16803C", borderWidth: 2 } : undefined}
|
||||
>
|
||||
<Row style={{ justifyContent: "space-between" }}>
|
||||
<Heading>{t(plan.key as never)}</Heading>
|
||||
{plan.highlight && <Tag label={t("paywall.popular")} tone="success" />}
|
||||
</Row>
|
||||
<Body>{t("paywall.perMonth", { price: formatMinor(plan.priceMinor, "SEK") })}</Body>
|
||||
<Button
|
||||
label={t("paywall.choose")}
|
||||
variant={plan.highlight ? "primary" : "secondary"}
|
||||
onPress={buy}
|
||||
/>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
<Small>{t("paywall.fairUse")}</Small>
|
||||
<Small>{t("paywall.freeNote")}</Small>
|
||||
<Button label={t("paywall.restore")} variant="ghost" onPress={buy} />
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { Alert } from "react-native";
|
||||
import { router } from "expo-router";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api";
|
||||
import { persistLanguageTag, useAuth } from "@/lib/auth";
|
||||
import { SUPPORTED_LANGUAGES, t } from "@/lib/i18n";
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Card,
|
||||
Heading,
|
||||
LoadingView,
|
||||
Row,
|
||||
Screen,
|
||||
Small,
|
||||
Spacer,
|
||||
Tag,
|
||||
} from "@/components/ui";
|
||||
|
||||
/** Profil: konto, samtycken, minne, prenumeration, GDPR-åtgärder. */
|
||||
|
||||
interface Me {
|
||||
displayName: string;
|
||||
email: string;
|
||||
emailVerified: boolean;
|
||||
precisionMode: string;
|
||||
}
|
||||
interface Entitlements {
|
||||
plan: string;
|
||||
status: string;
|
||||
aiScansPerMonth: number;
|
||||
aiScansUsedThisMonth: number;
|
||||
expiresAt?: string;
|
||||
}
|
||||
interface Consent {
|
||||
kind: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
/** Samtyckesetiketter ur i18n-katalogen: profile.consent.<kind> (12 språk). */
|
||||
const CONSENT_KINDS = [
|
||||
"personalization",
|
||||
"anonymized_improvement",
|
||||
"image_training",
|
||||
"health_integration",
|
||||
"location_weather",
|
||||
"push_notifications",
|
||||
] as const;
|
||||
|
||||
export default function ProfileScreen() {
|
||||
const queryClient = useQueryClient();
|
||||
const logout = useAuth((s) => s.logout);
|
||||
|
||||
const me = useQuery({ queryKey: ["me"], queryFn: () => api<Me>("/v1/me") });
|
||||
const entitlements = useQuery({
|
||||
queryKey: ["entitlements"],
|
||||
queryFn: () => api<Entitlements>("/v1/me/entitlements"),
|
||||
});
|
||||
const consents = useQuery({
|
||||
queryKey: ["consents"],
|
||||
queryFn: () => api<Consent[]>("/v1/me/consents"),
|
||||
});
|
||||
|
||||
const setConsent = useMutation({
|
||||
mutationFn: ({ kind, granted }: { kind: string; granted: boolean }) =>
|
||||
api("/v1/me/consents", { method: "PUT", body: { kind, granted } }),
|
||||
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["consents"] }),
|
||||
});
|
||||
|
||||
const togglePrecision = useMutation({
|
||||
mutationFn: (mode: string) => api("/v1/me", { method: "PATCH", body: { precisionMode: mode } }),
|
||||
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["me"] }),
|
||||
});
|
||||
|
||||
const deleteAccount = useMutation({
|
||||
mutationFn: () => api("/v1/me", { method: "DELETE" }),
|
||||
onSuccess: () => void logout().then(() => router.replace("/(auth)/login")),
|
||||
});
|
||||
|
||||
const resend = useMutation({
|
||||
mutationFn: () => api("/v1/auth/resend-verification", { method: "POST" }),
|
||||
});
|
||||
|
||||
// Språkval (i18n M4): sparas i backend-preferenserna + lokalt, byter direkt.
|
||||
const localePrefs = useQuery({
|
||||
queryKey: ["locale-preferences"],
|
||||
queryFn: () => api<{ languageTag: string }>("/v1/me/locale-preferences"),
|
||||
});
|
||||
const setLanguage = useMutation({
|
||||
mutationFn: (languageTag: string) =>
|
||||
api("/v1/me/locale-preferences", { method: "PATCH", body: { languageTag } }),
|
||||
onSuccess: async (_data, languageTag) => {
|
||||
await persistLanguageTag(languageTag);
|
||||
await queryClient.invalidateQueries();
|
||||
},
|
||||
});
|
||||
|
||||
if (me.isLoading) return <LoadingView />;
|
||||
|
||||
const consentMap = new Map((consents.data ?? []).map((c) => [c.kind, c.status]));
|
||||
|
||||
return (
|
||||
<Screen>
|
||||
<Card>
|
||||
<Heading>{me.data?.displayName}</Heading>
|
||||
<Small>{me.data?.email}</Small>
|
||||
{me.data && !me.data.emailVerified && (
|
||||
<>
|
||||
<Small>⚠︎ {t("profile.emailUnverified")}</Small>
|
||||
<Button
|
||||
label={
|
||||
resend.isSuccess ? t("profile.verificationSent") : t("profile.resendVerification")
|
||||
}
|
||||
variant="ghost"
|
||||
onPress={() => resend.mutate()}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Row>
|
||||
<Body>{t("profile.modeLabel")}</Body>
|
||||
<Button
|
||||
label={t("onboarding.modeSimple")}
|
||||
variant={me.data?.precisionMode === "simple" ? "secondary" : "ghost"}
|
||||
onPress={() => togglePrecision.mutate("simple")}
|
||||
/>
|
||||
<Button
|
||||
label={t("onboarding.modeExact")}
|
||||
variant={me.data?.precisionMode === "exact" ? "secondary" : "ghost"}
|
||||
onPress={() => togglePrecision.mutate("exact")}
|
||||
/>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Heading>{t("profile.language")}</Heading>
|
||||
<Row style={{ flexWrap: "wrap" }}>
|
||||
{SUPPORTED_LANGUAGES.map((lang) => (
|
||||
<Button
|
||||
key={lang.code}
|
||||
label={t(lang.labelKey)}
|
||||
variant={
|
||||
(localePrefs.data?.languageTag ?? "sv-SE").startsWith(lang.code)
|
||||
? "secondary"
|
||||
: "ghost"
|
||||
}
|
||||
onPress={() => setLanguage.mutate(lang.tag)}
|
||||
/>
|
||||
))}
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{entitlements.data && (
|
||||
<Card onPress={() => router.push("/paywall")}>
|
||||
<Row style={{ justifyContent: "space-between" }}>
|
||||
<Heading>{t("profile.subscription")}</Heading>
|
||||
<Tag
|
||||
label={`${entitlements.data.plan} · ${entitlements.data.status}`}
|
||||
tone={entitlements.data.status === "free" ? "neutral" : "success"}
|
||||
/>
|
||||
</Row>
|
||||
<Small>
|
||||
{t("profile.aiScansUsed", {
|
||||
used: entitlements.data.aiScansUsedThisMonth,
|
||||
total: entitlements.data.aiScansPerMonth,
|
||||
})}
|
||||
</Small>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card onPress={() => router.push("/memory")}>
|
||||
<Heading>🧠 {t("profile.memory")}</Heading>
|
||||
<Small>{t("profile.memorySubtitle")}</Small>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Heading>{t("profile.consents")}</Heading>
|
||||
{CONSENT_KINDS.map((kind) => {
|
||||
const granted = consentMap.get(kind) === "granted";
|
||||
return (
|
||||
<Row key={kind} style={{ justifyContent: "space-between" }}>
|
||||
<Small>{t(`profile.consent.${kind}`)}</Small>
|
||||
<Button
|
||||
label={granted ? t("common.on") : t("common.off")}
|
||||
variant={granted ? "secondary" : "ghost"}
|
||||
onPress={() => setConsent.mutate({ kind, granted: !granted })}
|
||||
/>
|
||||
</Row>
|
||||
);
|
||||
})}
|
||||
<Small>{t("profile.consentsNote")}</Small>
|
||||
</Card>
|
||||
|
||||
<Spacer />
|
||||
<Button
|
||||
label={t("profile.logout")}
|
||||
variant="ghost"
|
||||
onPress={() => void logout().then(() => router.replace("/(auth)/login"))}
|
||||
/>
|
||||
<Button
|
||||
label={t("profile.deleteAccount")}
|
||||
variant="danger"
|
||||
onPress={() =>
|
||||
Alert.alert(t("profile.deleteTitle"), t("profile.deleteBody"), [
|
||||
{ text: t("common.cancel"), style: "cancel" },
|
||||
{
|
||||
text: t("memory.delete"),
|
||||
style: "destructive",
|
||||
onPress: () => deleteAccount.mutate(),
|
||||
},
|
||||
])
|
||||
}
|
||||
/>
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import { useState } from "react";
|
||||
import { Alert, View } from "react-native";
|
||||
import { router, useLocalSearchParams } from "expo-router";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api";
|
||||
import { t } from "@/lib/i18n";
|
||||
import { formatMinor } from "@/lib/money";
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Card,
|
||||
ErrorView,
|
||||
Heading,
|
||||
LoadingView,
|
||||
Row,
|
||||
Screen,
|
||||
Small,
|
||||
Spacer,
|
||||
Tag,
|
||||
Title,
|
||||
} from "@/components/ui";
|
||||
import { colors, spacing } from "@/lib/theme";
|
||||
import { formatQuantity } from "@/lib/units";
|
||||
|
||||
/** Receptdetalj: säkerhet, näring, ingredienser vs lager, varianter, betyg. */
|
||||
|
||||
interface RecipeDetail {
|
||||
id: string;
|
||||
titleSv: string;
|
||||
descriptionSv: string;
|
||||
portions: number;
|
||||
totalTimeMinutes: number;
|
||||
spiceLevel: number;
|
||||
nutritionPerPortion: { kcal: number; proteinG: number; carbsG: number; fatG: number };
|
||||
estimatedCostMinorPerPortion: number | null;
|
||||
costCurrency?: string;
|
||||
allergens: string[];
|
||||
ingredients: Array<{
|
||||
id: string;
|
||||
displayNameSv: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
optional: boolean;
|
||||
groupName: string | null;
|
||||
canonicalIngredientId: string;
|
||||
}>;
|
||||
steps: Array<{
|
||||
id: string;
|
||||
stepNumber: number;
|
||||
instructionSv: string;
|
||||
temperatureC: number | null;
|
||||
}>;
|
||||
safety: { safe: boolean; violations: Array<{ severity: string; messageSv: string }> };
|
||||
variants: Array<{ id: string; titleSv: string; variantType: string }>;
|
||||
myRating: { stars: number } | null;
|
||||
isFavorite: boolean;
|
||||
ratingAverage: number | null;
|
||||
ratingCount: number;
|
||||
creatorDisplayName: string | null;
|
||||
verificationStatus: string;
|
||||
}
|
||||
|
||||
export default function RecipeScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const queryClient = useQueryClient();
|
||||
const [portions, setPortions] = useState<number | null>(null);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["recipe", id],
|
||||
queryFn: () => api<RecipeDetail>(`/v1/recipes/${id}`),
|
||||
});
|
||||
|
||||
const scaled = useQuery({
|
||||
queryKey: ["recipe-scaled", id, portions],
|
||||
queryFn: () =>
|
||||
api<{ ingredients: RecipeDetail["ingredients"] }>(
|
||||
`/v1/recipes/${id}/scaled?portions=${portions}`,
|
||||
),
|
||||
enabled: portions != null,
|
||||
});
|
||||
|
||||
const favorite = useMutation({
|
||||
mutationFn: (isFavorite: boolean) =>
|
||||
api(`/v1/recipes/${id}/favorite`, { method: isFavorite ? "DELETE" : "POST" }),
|
||||
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["recipe", id] }),
|
||||
});
|
||||
|
||||
const rate = useMutation({
|
||||
mutationFn: (stars: number) =>
|
||||
api(`/v1/recipes/${id}/rate`, { method: "POST", body: { stars, feedbackTags: [] } }),
|
||||
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["recipe", id] }),
|
||||
});
|
||||
|
||||
if (query.isLoading) return <LoadingView />;
|
||||
if (query.isError || !query.data) return <ErrorView onRetry={() => void query.refetch()} />;
|
||||
const recipe = query.data;
|
||||
const shownPortions = portions ?? recipe.portions;
|
||||
const ingredients =
|
||||
portions != null && scaled.data ? scaled.data.ingredients : recipe.ingredients;
|
||||
const blockers = recipe.safety.violations.filter((v) => v.severity === "blocker");
|
||||
const warnings = recipe.safety.violations.filter((v) => v.severity === "warning");
|
||||
|
||||
return (
|
||||
<Screen>
|
||||
<Title>{recipe.titleSv}</Title>
|
||||
<Row>
|
||||
<Tag label={t("recipe.time", { min: recipe.totalTimeMinutes })} />
|
||||
<Tag label={`${Math.round(recipe.nutritionPerPortion.kcal)} kcal`} />
|
||||
<Tag
|
||||
label={`${Math.round(recipe.nutritionPerPortion.proteinG)} g protein`}
|
||||
tone="success"
|
||||
/>
|
||||
{recipe.estimatedCostMinorPerPortion != null && (
|
||||
<Tag
|
||||
label={t("recipe.cost", {
|
||||
amount: formatMinor(
|
||||
recipe.estimatedCostMinorPerPortion,
|
||||
recipe.costCurrency ?? "SEK",
|
||||
),
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
{recipe.verificationStatus === "editorial" && <Tag label="✓ plattformen" tone="success" />}
|
||||
</Row>
|
||||
<Body muted>{recipe.descriptionSv}</Body>
|
||||
{recipe.creatorDisplayName && <Small>Skapat av {recipe.creatorDisplayName}</Small>}
|
||||
|
||||
{!recipe.safety.safe && (
|
||||
<Card style={{ borderColor: colors.danger, backgroundColor: colors.dangerSoft }}>
|
||||
<Body>⛔ {t("recipe.notSafe")}</Body>
|
||||
{blockers.map((v, i) => (
|
||||
<Small key={i}>{v.messageSv}</Small>
|
||||
))}
|
||||
</Card>
|
||||
)}
|
||||
{warnings.length > 0 && (
|
||||
<Card style={{ backgroundColor: colors.warningSoft, borderColor: colors.warning }}>
|
||||
{warnings.map((v, i) => (
|
||||
<Small key={i}>⚠️ {v.messageSv}</Small>
|
||||
))}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{recipe.variants.length > 0 && (
|
||||
<Row>
|
||||
{recipe.variants.map((variant) => (
|
||||
<Tag key={variant.id} label={`↔ ${variant.titleSv}`} tone="accent" />
|
||||
))}
|
||||
</Row>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<Row style={{ justifyContent: "space-between" }}>
|
||||
<Heading>{t("recipe.ingredients")}</Heading>
|
||||
<Row>
|
||||
<Button
|
||||
label="−"
|
||||
variant="ghost"
|
||||
onPress={() => setPortions(Math.max(1, shownPortions - 1))}
|
||||
/>
|
||||
<Body>{t("recipe.portions", { count: shownPortions })}</Body>
|
||||
<Button
|
||||
label="+"
|
||||
variant="ghost"
|
||||
onPress={() => setPortions(Math.min(24, shownPortions + 1))}
|
||||
/>
|
||||
</Row>
|
||||
</Row>
|
||||
{ingredients.map((ing) => (
|
||||
<Row key={ing.id ?? ing.displayNameSv} style={{ justifyContent: "space-between" }}>
|
||||
<Body>
|
||||
{ing.displayNameSv}
|
||||
{ing.optional ? " (valfritt)" : ""}
|
||||
</Body>
|
||||
<Small>{formatQuantity(ing.quantity, ing.unit)}</Small>
|
||||
</Row>
|
||||
))}
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Heading>{t("recipe.steps")}</Heading>
|
||||
{recipe.steps.map((step) => (
|
||||
<View key={step.id} style={{ marginBottom: spacing.sm }}>
|
||||
<Body>
|
||||
{step.stepNumber}. {step.instructionSv}
|
||||
{step.temperatureC ? ` (${step.temperatureC} °C)` : ""}
|
||||
</Body>
|
||||
</View>
|
||||
))}
|
||||
</Card>
|
||||
|
||||
<Button
|
||||
label={`👨🍳 ${t("recipe.cook")}`}
|
||||
onPress={() => router.push(`/cooking/${recipe.id}?portions=${shownPortions}`)}
|
||||
/>
|
||||
<Row>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Button
|
||||
label={recipe.isFavorite ? `★ ${t("recipe.saved")}` : `☆ ${t("recipe.save")}`}
|
||||
variant="secondary"
|
||||
onPress={() => favorite.mutate(recipe.isFavorite)}
|
||||
/>
|
||||
</View>
|
||||
</Row>
|
||||
|
||||
<Card>
|
||||
<Heading>{t("cooked.rate")}</Heading>
|
||||
<Row>
|
||||
{[1, 2, 3, 4, 5].map((stars) => (
|
||||
<Button
|
||||
key={stars}
|
||||
label={(recipe.myRating?.stars ?? 0) >= stars ? "★" : "☆"}
|
||||
variant="ghost"
|
||||
onPress={() => rate.mutate(stars)}
|
||||
/>
|
||||
))}
|
||||
</Row>
|
||||
{recipe.ratingAverage != null && (
|
||||
<Small>
|
||||
{t("recipe.avgRating", {
|
||||
avg: recipe.ratingAverage.toFixed(1),
|
||||
count: recipe.ratingCount,
|
||||
})}
|
||||
</Small>
|
||||
)}
|
||||
</Card>
|
||||
<Spacer />
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
||||
function formatQty(quantity: number): string {
|
||||
return Number.isInteger(quantity) ? String(quantity) : String(Math.round(quantity * 100) / 100);
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Alert, View } from "react-native";
|
||||
import { router, useLocalSearchParams } from "expo-router";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api";
|
||||
import { t } from "@/lib/i18n";
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Card,
|
||||
ErrorView,
|
||||
Heading,
|
||||
Input,
|
||||
LoadingView,
|
||||
Row,
|
||||
Screen,
|
||||
Small,
|
||||
Spacer,
|
||||
Tag,
|
||||
} from "@/components/ui";
|
||||
import { spacing } from "@/lib/theme";
|
||||
import { parseUnitInput, unitLabel } from "@/lib/units";
|
||||
|
||||
/**
|
||||
* Granska AI-resultat (spec §10, §61.4–5): användaren godkänner, ändrar,
|
||||
* tar bort och lägger till INNAN något skrivs till Food Twin.
|
||||
* Osäkra rader flaggas tydligt. Ändringar blir ai_corrections → AAMOS-träning.
|
||||
*/
|
||||
|
||||
interface DetectedItem {
|
||||
tempId?: string;
|
||||
detectedName: string;
|
||||
canonicalIngredientId: string | null;
|
||||
brand: string | null;
|
||||
estimatedQuantity: number | null;
|
||||
unit: string | null;
|
||||
bestBeforeDate: string | null;
|
||||
confidence: number;
|
||||
requiresConfirmation: boolean;
|
||||
}
|
||||
interface ScanJob {
|
||||
id: string;
|
||||
status: string;
|
||||
scanType: string;
|
||||
error: string | null;
|
||||
result: { items?: DetectedItem[] } | null;
|
||||
}
|
||||
|
||||
interface EditableItem {
|
||||
tempId: string;
|
||||
original: DetectedItem | null;
|
||||
name: string;
|
||||
quantity: string;
|
||||
unit: string;
|
||||
canonicalIngredientId: string | null;
|
||||
date: string;
|
||||
/**
|
||||
* Datumtyp (spec §13): "Bäst före" är en KVALITETSgräns (varan kan vara god
|
||||
* längre – lukta och smaka), "Sista förbrukningsdag" en SÄKERHETSgräns.
|
||||
* Användaren väljer typ så att appen aldrig dömer mat i onödan – och aldrig
|
||||
* mjukar upp en riktig säkerhetsgräns.
|
||||
*/
|
||||
dateIsUseBy: boolean;
|
||||
confidence: number;
|
||||
rejected: boolean;
|
||||
}
|
||||
|
||||
export default function ScanReviewScreen() {
|
||||
const { jobId } = useLocalSearchParams<{ jobId: string }>();
|
||||
const queryClient = useQueryClient();
|
||||
const [items, setItems] = useState<EditableItem[] | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["scan", jobId],
|
||||
queryFn: () => api<ScanJob>(`/v1/scans/${jobId}`),
|
||||
refetchInterval: (q) => {
|
||||
const status = q.state.data?.status;
|
||||
return status === "queued" || status === "running" ? 1500 : false;
|
||||
},
|
||||
});
|
||||
|
||||
const job = query.data;
|
||||
|
||||
// Initiera redigerbara rader när resultatet landar
|
||||
useMemo(() => {
|
||||
if (job?.status === "awaiting_confirmation" && job.result?.items && items == null) {
|
||||
setItems(
|
||||
job.result.items.map((item, index) => ({
|
||||
tempId: item.tempId ?? `item-${index}`,
|
||||
original: item,
|
||||
name: item.detectedName,
|
||||
quantity: item.estimatedQuantity != null ? String(item.estimatedQuantity) : "1",
|
||||
unit: unitLabel(item.unit ?? "COUNT"),
|
||||
canonicalIngredientId: item.canonicalIngredientId,
|
||||
date: item.bestBeforeDate ?? "",
|
||||
dateIsUseBy: false,
|
||||
confidence: item.confidence,
|
||||
rejected: false,
|
||||
})),
|
||||
);
|
||||
}
|
||||
}, [job, items]);
|
||||
|
||||
const update = (tempId: string, patch: Partial<EditableItem>) =>
|
||||
setItems((prev) => prev?.map((i) => (i.tempId === tempId ? { ...i, ...patch } : i)) ?? null);
|
||||
|
||||
const addManual = () =>
|
||||
setItems((prev) => [
|
||||
...(prev ?? []),
|
||||
{
|
||||
tempId: `manual-${Date.now()}`,
|
||||
original: null,
|
||||
name: "",
|
||||
quantity: "1",
|
||||
unit: unitLabel("COUNT"),
|
||||
canonicalIngredientId: null,
|
||||
date: "",
|
||||
dateIsUseBy: false,
|
||||
confidence: 1,
|
||||
rejected: false,
|
||||
},
|
||||
]);
|
||||
|
||||
const confirm = async () => {
|
||||
if (!items) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const payload = items
|
||||
.filter((i) => i.rejected || i.name.trim().length > 0)
|
||||
.map((i) => {
|
||||
const edited =
|
||||
i.original == null ||
|
||||
i.name !== i.original.detectedName ||
|
||||
Number(i.quantity) !== i.original.estimatedQuantity ||
|
||||
i.unit !== (i.original.unit ?? "st");
|
||||
return {
|
||||
tempId: i.tempId,
|
||||
action: i.rejected ? "reject" : i.original == null ? "add" : edited ? "edit" : "accept",
|
||||
displayName: i.name.trim() || i.original?.detectedName || t("scan.review.unknownItem"),
|
||||
canonicalIngredientId: i.canonicalIngredientId ?? undefined,
|
||||
quantity: Math.max(0.01, Number(i.quantity) || 1),
|
||||
unit: parseUnitInput(i.unit) ?? "COUNT",
|
||||
// Rätt kolumn per datumtyp – motorn behandlar dem helt olika (spec §13).
|
||||
bestBeforeDate: !i.dateIsUseBy && i.date ? i.date : undefined,
|
||||
useByDate: i.dateIsUseBy && i.date ? i.date : undefined,
|
||||
};
|
||||
});
|
||||
await api(`/v1/scans/${jobId}/confirm`, { method: "POST", body: { items: payload } });
|
||||
await queryClient.invalidateQueries({ queryKey: ["inventory"] });
|
||||
await queryClient.invalidateQueries({ queryKey: ["what-to-eat"] });
|
||||
router.back();
|
||||
} catch (err) {
|
||||
Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (query.isLoading || job?.status === "queued" || job?.status === "running") {
|
||||
return (
|
||||
<Screen scroll={false}>
|
||||
<LoadingView />
|
||||
<Body muted>{t("scan.analyzing")}</Body>
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
if (query.isError || !job) return <ErrorView onRetry={() => void query.refetch()} />;
|
||||
if (job.status === "failed") {
|
||||
return (
|
||||
<Screen>
|
||||
<ErrorView message={job.error ?? t("scan.failed")} onRetry={() => router.back()} />
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Screen>
|
||||
<Heading>{t("scan.review.title")}</Heading>
|
||||
<Small>{t("scan.review.subtitle")}</Small>
|
||||
<Spacer size={spacing.sm} />
|
||||
|
||||
{(items ?? []).map((item) => (
|
||||
<Card key={item.tempId} style={item.rejected ? { opacity: 0.4 } : undefined}>
|
||||
<Row style={{ justifyContent: "space-between" }}>
|
||||
{item.confidence < 0.7 && !item.rejected ? (
|
||||
<Tag label={`⚠️ ${t("scan.review.uncertain")}`} tone="warning" />
|
||||
) : (
|
||||
<View />
|
||||
)}
|
||||
<Button
|
||||
label={item.rejected ? t("common.undo") : t("common.remove")}
|
||||
variant="ghost"
|
||||
onPress={() => update(item.tempId, { rejected: !item.rejected })}
|
||||
/>
|
||||
</Row>
|
||||
{!item.rejected && (
|
||||
<>
|
||||
<Input
|
||||
value={item.name}
|
||||
onChangeText={(v) => update(item.tempId, { name: v })}
|
||||
placeholder={t("scan.review.itemPlaceholder")}
|
||||
/>
|
||||
<Row>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Input
|
||||
value={item.quantity}
|
||||
keyboardType="numeric"
|
||||
onChangeText={(v) => update(item.tempId, { quantity: v })}
|
||||
placeholder={t("scan.review.quantityPlaceholder")}
|
||||
/>
|
||||
</View>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Input
|
||||
value={item.unit}
|
||||
onChangeText={(v) => update(item.tempId, { unit: v })}
|
||||
placeholder={t("scan.review.unitPlaceholder")}
|
||||
/>
|
||||
</View>
|
||||
</Row>
|
||||
{/* Datumtyp (spec §13): bäst före = kvalitet, sista förbrukningsdag = säkerhet. */}
|
||||
<Row>
|
||||
<Button
|
||||
label={t("scan.review.dateKindBestBefore")}
|
||||
variant={item.dateIsUseBy ? "ghost" : "secondary"}
|
||||
onPress={() => update(item.tempId, { dateIsUseBy: false })}
|
||||
/>
|
||||
<Button
|
||||
label={t("scan.review.dateKindUseBy")}
|
||||
variant={item.dateIsUseBy ? "secondary" : "ghost"}
|
||||
onPress={() => update(item.tempId, { dateIsUseBy: true })}
|
||||
/>
|
||||
</Row>
|
||||
<Input
|
||||
value={item.date}
|
||||
onChangeText={(v) => update(item.tempId, { date: v })}
|
||||
placeholder={t("scan.review.datePlaceholder")}
|
||||
/>
|
||||
{item.date.length > 0 && (
|
||||
<Small>
|
||||
{item.dateIsUseBy ? t("scan.review.useByNote") : t("scan.review.bestBeforeNote")}
|
||||
</Small>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
|
||||
<Button label={t("scan.review.addItem")} variant="ghost" onPress={addManual} />
|
||||
<Spacer size={spacing.sm} />
|
||||
<Button label={t("scan.review.approveAll")} onPress={() => void confirm()} loading={busy} />
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { useState } from "react";
|
||||
import { Alert, Pressable, Text, View } from "react-native";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api";
|
||||
import { t } from "@/lib/i18n";
|
||||
import { formatMinor } from "@/lib/money";
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Card,
|
||||
EmptyState,
|
||||
ErrorView,
|
||||
Heading,
|
||||
Input,
|
||||
LoadingView,
|
||||
Row,
|
||||
Screen,
|
||||
Small,
|
||||
Spacer,
|
||||
} from "@/components/ui";
|
||||
import { colors, spacing } from "@/lib/theme";
|
||||
import { formatQuantity } from "@/lib/units";
|
||||
|
||||
/** Inköpslista (spec §27): delad, avdelningssorterad, uppdaterar lagret vid avslut. */
|
||||
|
||||
interface ShoppingItem {
|
||||
id: string;
|
||||
displayName: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
storeSection: string;
|
||||
estimatedPriceMinor: number | null;
|
||||
checked: boolean;
|
||||
}
|
||||
interface ListResponse {
|
||||
list: { id: string; name: string };
|
||||
items: ShoppingItem[];
|
||||
estimatedTotalMinor: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
/** Butikssektionernas etiketter bor i i18n-katalogen: shopping.section.<id> (12 språk). */
|
||||
const SECTION_IDS = new Set([
|
||||
"frukt_gront",
|
||||
"brod",
|
||||
"mejeri",
|
||||
"kott_fagel",
|
||||
"fisk",
|
||||
"chark",
|
||||
"frys",
|
||||
"skafferi",
|
||||
"konserver",
|
||||
"kryddor_bak",
|
||||
"dryck",
|
||||
"snacks",
|
||||
"hygien_ovrigt",
|
||||
]);
|
||||
const sectionLabel = (id: string): string =>
|
||||
SECTION_IDS.has(id) ? t(`shopping.section.${id}`) : id;
|
||||
|
||||
export default function ShoppingScreen() {
|
||||
const queryClient = useQueryClient();
|
||||
const [newItem, setNewItem] = useState("");
|
||||
|
||||
const lists = useQuery({
|
||||
queryKey: ["shopping-lists"],
|
||||
queryFn: async () => {
|
||||
const result = await api<{ lists: Array<{ id: string }> }>("/v1/shopping-lists");
|
||||
if (result.lists.length === 0) {
|
||||
const created = await api<{ list: { id: string } }>("/v1/shopping-lists", {
|
||||
method: "POST",
|
||||
body: { name: t("shopping.title") },
|
||||
});
|
||||
return [created.list];
|
||||
}
|
||||
return result.lists;
|
||||
},
|
||||
});
|
||||
const listId = lists.data?.[0]?.id;
|
||||
|
||||
const list = useQuery({
|
||||
queryKey: ["shopping-list", listId],
|
||||
queryFn: () => api<ListResponse>(`/v1/shopping-lists/${listId}`),
|
||||
enabled: Boolean(listId),
|
||||
});
|
||||
|
||||
const invalidate = () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["shopping-list", listId] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["shopping-lists"] });
|
||||
};
|
||||
|
||||
const addItem = useMutation({
|
||||
mutationFn: (displayName: string) =>
|
||||
api(`/v1/shopping-lists/${listId}/items`, { method: "POST", body: { displayName } }),
|
||||
onSuccess: () => {
|
||||
setNewItem("");
|
||||
invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const toggle = useMutation({
|
||||
mutationFn: (item: ShoppingItem) =>
|
||||
api(`/v1/shopping-lists/${listId}/items/${item.id}`, {
|
||||
method: "PATCH",
|
||||
body: { checked: !item.checked },
|
||||
}),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const complete = useMutation({
|
||||
mutationFn: () =>
|
||||
api(`/v1/shopping-lists/${listId}/complete`, {
|
||||
method: "POST",
|
||||
body: { addToInventory: true },
|
||||
}),
|
||||
onSuccess: async (result) => {
|
||||
const added = (result as { itemsAddedToInventory?: number }).itemsAddedToInventory ?? 0;
|
||||
await queryClient.invalidateQueries({ queryKey: ["inventory"] });
|
||||
invalidate();
|
||||
Alert.alert(t("shopping.completedTitle"), t("shopping.completedBody", { count: added }));
|
||||
},
|
||||
});
|
||||
|
||||
if (lists.isLoading || list.isLoading) return <LoadingView />;
|
||||
if (list.isError || !list.data) return <ErrorView onRetry={() => void list.refetch()} />;
|
||||
|
||||
const grouped = new Map<string, ShoppingItem[]>();
|
||||
for (const item of list.data.items) {
|
||||
const arr = grouped.get(item.storeSection) ?? [];
|
||||
arr.push(item);
|
||||
grouped.set(item.storeSection, arr);
|
||||
}
|
||||
const checkedCount = list.data.items.filter((i) => i.checked).length;
|
||||
|
||||
return (
|
||||
<Screen>
|
||||
<Row>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Input
|
||||
placeholder={t("shopping.addPlaceholder")}
|
||||
value={newItem}
|
||||
onChangeText={setNewItem}
|
||||
onSubmitEditing={() => newItem.trim() && addItem.mutate(newItem.trim())}
|
||||
returnKeyType="done"
|
||||
/>
|
||||
</View>
|
||||
<Button label="+" onPress={() => newItem.trim() && addItem.mutate(newItem.trim())} />
|
||||
</Row>
|
||||
|
||||
{list.data.items.length === 0 && <EmptyState text={t("shopping.empty")} />}
|
||||
|
||||
{[...grouped.entries()].map(([section, items]) => (
|
||||
<Card key={section}>
|
||||
<Heading>{sectionLabel(section)}</Heading>
|
||||
{items.map((item) => (
|
||||
<Pressable key={item.id} onPress={() => toggle.mutate(item)}>
|
||||
<Row style={{ justifyContent: "space-between", paddingVertical: 6 }}>
|
||||
<Body>
|
||||
<Text style={{ color: item.checked ? colors.primary : colors.textMuted }}>
|
||||
{item.checked ? "☑" : "☐"}
|
||||
</Text>{" "}
|
||||
<Text
|
||||
style={
|
||||
item.checked
|
||||
? { textDecorationLine: "line-through", color: colors.textMuted }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{item.displayName} · {formatQuantity(item.quantity, item.unit)}
|
||||
</Text>
|
||||
</Body>
|
||||
{item.estimatedPriceMinor != null && (
|
||||
<Small>
|
||||
{t("shopping.estimated", {
|
||||
amount: formatMinor(item.estimatedPriceMinor, list.data.currency),
|
||||
})}
|
||||
</Small>
|
||||
)}
|
||||
</Row>
|
||||
</Pressable>
|
||||
))}
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{list.data.estimatedTotalMinor > 0 && (
|
||||
<Small>
|
||||
{t("shopping.estimatedTotal", {
|
||||
amount: formatMinor(list.data.estimatedTotalMinor, list.data.currency),
|
||||
})}
|
||||
</Small>
|
||||
)}
|
||||
<Spacer size={spacing.sm} />
|
||||
{checkedCount > 0 && (
|
||||
<>
|
||||
<Button
|
||||
label={`${t("shopping.complete")} (${checkedCount})`}
|
||||
onPress={() => complete.mutate()}
|
||||
loading={complete.isPending}
|
||||
/>
|
||||
<Small>{t("shopping.completeNote")}</Small>
|
||||
</>
|
||||
)}
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user