Initial commit (unpacked platform)

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