i18n(app-svep) + i18n-vakt: hela mobil-UI:t översatt + CI-vakt mot regress
- Svepte 51 hårdkodade strängar -> t() över konto, sök/bläddra, sparade recept, kök, planera, skanna, byt måltid, inköp, minne, cooking, api-fel (+ locLabel/ kategorier via nycklar). 47 nya nycklar, översatta till alla 12 språk. - NY: apps/mobile/scripts/i18n-check.mjs (i18n:check) – fäller bygget om ett språk saknar en nyckel, en använd nyckel saknas, eller det finns hårdkodad UI-text. Wire:ad i CI (.github/workflows/ci.yml) efter typecheck. => 'lägg till språk' blir: kör vakten, den listar exakt vad som fattas. Aldrig mer leta för hand. - Prettier-fixade 5 filer från tidigare leveranser så format:check blir grön. - submit-recipe fri-text-payload + firebase dev-stubs markerade // i18n-ignore. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -51,7 +51,7 @@ export default function LoginScreen() {
|
||||
<Screen style={{ flexGrow: 1, justifyContent: "center", gap: spacing.md }}>
|
||||
<View style={{ alignItems: "center", marginBottom: spacing.lg }}>
|
||||
<Title>{BRAND.name}</Title>
|
||||
<Body muted>Mindre svinn, mindre stress, mer kvar i plånboken</Body>
|
||||
<Body muted>{t("auth.tagline")}</Body>
|
||||
</View>
|
||||
<Input
|
||||
placeholder={t("auth.email")}
|
||||
|
||||
@@ -28,11 +28,11 @@ export default function RegisterScreen() {
|
||||
|
||||
const submit = async () => {
|
||||
if (password.length < 8) {
|
||||
setError("Lösenordet måste vara minst 8 tecken.");
|
||||
setError(t("auth.passwordMin8"));
|
||||
return;
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
setError("Lösenorden matchar inte.");
|
||||
setError(t("auth.passwordMismatch"));
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
@@ -112,7 +112,7 @@ export default function RegisterScreen() {
|
||||
onChangeText={setPassword}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Upprepa lösenord"
|
||||
placeholder={t("auth.repeatPassword")}
|
||||
secureTextEntry
|
||||
value={confirmPassword}
|
||||
onChangeText={setConfirmPassword}
|
||||
|
||||
@@ -74,7 +74,7 @@ const CATS: ReadonlyArray<{
|
||||
{ key: "dinner", labelKey: "myday.mealType.dinner", mealType: "dinner", glyph: "🍽️" },
|
||||
{ key: "snack", labelKey: "myday.mealType.snack", mealType: "snack", glyph: "🍎" },
|
||||
{ key: "dessert", labelKey: "myday.mealType.dessert", mealType: "dessert", glyph: "🍰" },
|
||||
{ key: "baking", labelKey: "cat.baking", label: "Baka", tag: "baking", ctxMeal: "dessert", glyph: "🥐" },
|
||||
{ key: "baking", labelKey: "cat.baking", tag: "baking", ctxMeal: "dessert", glyph: "🥐" },
|
||||
];
|
||||
|
||||
const errText = (e: unknown): string | undefined =>
|
||||
@@ -249,7 +249,10 @@ export default function WhatToEatScreen() {
|
||||
{/* "Menade du …?" – stavningsförslag när sök gav noll träffar. */}
|
||||
{allRecs.length === 0 && submittedSearch && query.data.context.searchSuggestion && (
|
||||
<Pressable onPress={() => submitSearch(query.data!.context.searchSuggestion!)}>
|
||||
<Tag label={t("wte.didYouMean", { suggestion: query.data.context.searchSuggestion })} tone="accent" />
|
||||
<Tag
|
||||
label={t("wte.didYouMean", { suggestion: query.data.context.searchSuggestion })}
|
||||
tone="accent"
|
||||
/>
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
|
||||
@@ -92,10 +92,7 @@ export default function PlanScreen() {
|
||||
}, []);
|
||||
// Veckan börjar ALLTID på måndag (mån–sön), oavsett vilken dag det är idag.
|
||||
const thisMonday = useMemo(() => mondayOf(todayMidnight), [todayMidnight]);
|
||||
const weekStart = useMemo(
|
||||
() => addDays(thisMonday, weekOffset * 7),
|
||||
[thisMonday, weekOffset],
|
||||
);
|
||||
const weekStart = useMemo(() => addDays(thisMonday, weekOffset * 7), [thisMonday, weekOffset]);
|
||||
const weekStartKey = toKey(weekStart);
|
||||
const weekDays = useMemo(
|
||||
() => Array.from({ length: 7 }, (_, i) => addDays(weekStart, i)),
|
||||
@@ -116,7 +113,11 @@ export default function PlanScreen() {
|
||||
const toggleSlot = (dayIdx: number, meal: string) =>
|
||||
setMealSlots((prev) =>
|
||||
prev.map((meals, i) =>
|
||||
i !== dayIdx ? meals : meals.includes(meal) ? meals.filter((m) => m !== meal) : [...meals, meal],
|
||||
i !== dayIdx
|
||||
? meals
|
||||
: meals.includes(meal)
|
||||
? meals.filter((m) => m !== meal)
|
||||
: [...meals, meal],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -362,10 +363,18 @@ export default function PlanScreen() {
|
||||
{weekDays.map((d, i) => (
|
||||
<Row
|
||||
key={toKey(d)}
|
||||
style={{ justifyContent: "space-between", alignItems: "center", marginVertical: 2 }}
|
||||
style={{
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginVertical: 2,
|
||||
}}
|
||||
>
|
||||
<Small>
|
||||
{cap(new Intl.DateTimeFormat(locale, { weekday: "short", day: "numeric" }).format(d))}
|
||||
{cap(
|
||||
new Intl.DateTimeFormat(locale, { weekday: "short", day: "numeric" }).format(
|
||||
d,
|
||||
),
|
||||
)}
|
||||
</Small>
|
||||
<Row>
|
||||
{(["breakfast", "lunch", "dinner"] as const).map((meal) => (
|
||||
@@ -509,7 +518,7 @@ export default function PlanScreen() {
|
||||
</>
|
||||
) : null}
|
||||
<Button
|
||||
label="Byt rätt"
|
||||
label={t("plan.swapDish")}
|
||||
variant="secondary"
|
||||
onPress={() => {
|
||||
const e = actionEntry;
|
||||
|
||||
@@ -48,19 +48,15 @@ const MAX_PHOTOS = 6; // matchar createScanInputSchema.imageCount.max
|
||||
function askAddMore(count: number, maxCount: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
if (count >= maxCount) {
|
||||
Alert.alert("Klart", `Max ${maxCount} bilder den här skanningen – analyserar.`, [
|
||||
Alert.alert(t("scan.maxTitle"), t("scan.maxBody", { max: maxCount }), [
|
||||
{ text: "OK", onPress: () => resolve(false) },
|
||||
]);
|
||||
return;
|
||||
}
|
||||
Alert.alert(
|
||||
`Bild ${count} tagen`,
|
||||
"Ta gärna fler vinklar av samma plats – appen listar varje vara en gång. Varje bild räknas som en skanning.",
|
||||
[
|
||||
{ text: "Ta en till", onPress: () => resolve(true) },
|
||||
{ text: `Analysera (${count})`, style: "default", onPress: () => resolve(false) },
|
||||
],
|
||||
);
|
||||
Alert.alert(t("scan.photoTaken", { count }), t("scan.multiAngleHint"), [
|
||||
{ text: t("scan.takeAnother"), onPress: () => resolve(true) },
|
||||
{ text: t("scan.analyzeCount", { count }), style: "default", onPress: () => resolve(false) },
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -220,7 +216,7 @@ export default function ScanScreen() {
|
||||
<Small>• {t("scan.tips.shelf")}</Small>
|
||||
<Small>• {t("scan.tips.light")}</Small>
|
||||
<Small>• {t("scan.tips.move")}</Small>
|
||||
<Small>• Kyl/frys/skafferi: ta flera bilder i samma skanning – appen slår ihop dubbletter.</Small>
|
||||
<Small>• {t("scan.tips.merge")}</Small>
|
||||
</Card>
|
||||
</Screen>
|
||||
);
|
||||
|
||||
@@ -78,19 +78,43 @@ export default function RootLayout() {
|
||||
name="cooking/[id]"
|
||||
options={{ title: "", presentation: "fullScreenModal" }}
|
||||
/>
|
||||
<Stack.Screen name="scan-review/[jobId]" options={{ title: t("scan.review.title"), fullScreenGestureEnabled: false }} />
|
||||
<Stack.Screen name="swap-meal/[entryId]" options={{ presentation: "modal", title: t("nav.swap") }} />
|
||||
<Stack.Screen name="meal-review/[jobId]" options={{ title: t("scan.meal.title"), fullScreenGestureEnabled: false }} />
|
||||
<Stack.Screen
|
||||
name="scan-review/[jobId]"
|
||||
options={{ title: t("scan.review.title"), fullScreenGestureEnabled: false }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="swap-meal/[entryId]"
|
||||
options={{ presentation: "modal", title: t("nav.swap") }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="meal-review/[jobId]"
|
||||
options={{ title: t("scan.meal.title"), fullScreenGestureEnabled: false }}
|
||||
/>
|
||||
<Stack.Screen name="reconciliation" options={{ title: t("reconciliation.title") }} />
|
||||
<Stack.Screen name="scan-diff-review/[jobId]" options={{ title: t("scan.review.title"), fullScreenGestureEnabled: false }} />
|
||||
<Stack.Screen name="shopping" options={{ title: t("shopping.title"), presentation: "modal" }} />
|
||||
<Stack.Screen name="meal-boxes" options={{ title: t("mealbox.title"), presentation: "modal" }} />
|
||||
<Stack.Screen name="household" options={{ title: t("home.household"), presentation: "modal" }} />
|
||||
<Stack.Screen
|
||||
name="scan-diff-review/[jobId]"
|
||||
options={{ title: t("scan.review.title"), fullScreenGestureEnabled: false }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="shopping"
|
||||
options={{ title: t("shopping.title"), presentation: "modal" }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="meal-boxes"
|
||||
options={{ title: t("mealbox.title"), presentation: "modal" }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="household"
|
||||
options={{ title: t("home.household"), presentation: "modal" }}
|
||||
/>
|
||||
<Stack.Screen name="memory" options={{ title: t("memory.title") }} />
|
||||
<Stack.Screen name="saved-recipes" options={{ title: t("nav.savedRecipes") }} />
|
||||
<Stack.Screen name="recipes" options={{ title: t("nav.browseRecipes") }} />
|
||||
<Stack.Screen name="kitchen" options={{ title: t("nav.kitchen") }} />
|
||||
<Stack.Screen name="profile" options={{ title: t("profile.title"), presentation: "modal" }} />
|
||||
<Stack.Screen name="saved-recipes" options={{ title: t("nav.savedRecipes") }} />
|
||||
<Stack.Screen name="recipes" options={{ title: t("nav.browseRecipes") }} />
|
||||
<Stack.Screen name="kitchen" options={{ title: t("nav.kitchen") }} />
|
||||
<Stack.Screen
|
||||
name="profile"
|
||||
options={{ title: t("profile.title"), presentation: "modal" }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="paywall"
|
||||
options={{ title: t("paywall.title"), presentation: "modal" }}
|
||||
|
||||
@@ -140,7 +140,11 @@ export default function BarcodeScreen() {
|
||||
uris.push(a0.uri);
|
||||
const wantBack = await new Promise<boolean>((resolve) =>
|
||||
Alert.alert(t("barcode.contributing.frontDone"), t("barcode.contributing.backPrompt"), [
|
||||
{ text: t("barcode.contributing.skipBack"), style: "cancel", onPress: () => resolve(false) },
|
||||
{
|
||||
text: t("barcode.contributing.skipBack"),
|
||||
style: "cancel",
|
||||
onPress: () => resolve(false),
|
||||
},
|
||||
{ text: t("barcode.contributing.shootBack"), onPress: () => resolve(true) },
|
||||
]),
|
||||
);
|
||||
@@ -207,7 +211,12 @@ export default function BarcodeScreen() {
|
||||
lastGtin.current = null;
|
||||
};
|
||||
|
||||
if (!permission) return <Screen><Body>{t("scan.analyzing")}</Body></Screen>;
|
||||
if (!permission)
|
||||
return (
|
||||
<Screen>
|
||||
<Body>{t("scan.analyzing")}</Body>
|
||||
</Screen>
|
||||
);
|
||||
if (!permission.granted) {
|
||||
return (
|
||||
<Screen>
|
||||
@@ -277,14 +286,26 @@ export default function BarcodeScreen() {
|
||||
{n ? (
|
||||
<View style={styles.nutBox}>
|
||||
<Small style={styles.nutTitle}>{t("barcode.info.title")}</Small>
|
||||
<NutRow label={t("barcode.info.kcal")} value={`${Math.round(n.kcal)} kcal`} strong />
|
||||
<NutRow
|
||||
label={t("barcode.info.kcal")}
|
||||
value={`${Math.round(n.kcal)} kcal`}
|
||||
strong
|
||||
/>
|
||||
<NutRow label={t("barcode.info.fat")} value={fmtG(n.fatG)} />
|
||||
<NutRow label={t("barcode.info.satfat")} value={fmtG(n.saturatedFatG)} sub />
|
||||
{n.monounsaturatedFatG != null && (
|
||||
<NutRow label={t("barcode.info.monofat")} value={fmtG(n.monounsaturatedFatG)} sub />
|
||||
<NutRow
|
||||
label={t("barcode.info.monofat")}
|
||||
value={fmtG(n.monounsaturatedFatG)}
|
||||
sub
|
||||
/>
|
||||
)}
|
||||
{n.polyunsaturatedFatG != null && (
|
||||
<NutRow label={t("barcode.info.polyfat")} value={fmtG(n.polyunsaturatedFatG)} sub />
|
||||
<NutRow
|
||||
label={t("barcode.info.polyfat")}
|
||||
value={fmtG(n.polyunsaturatedFatG)}
|
||||
sub
|
||||
/>
|
||||
)}
|
||||
<NutRow label={t("barcode.info.carbs")} value={fmtG(n.carbsG)} />
|
||||
<NutRow label={t("barcode.info.sugar")} value={fmtG(n.sugarG)} sub />
|
||||
|
||||
@@ -251,40 +251,40 @@ export default function CookingScreen() {
|
||||
{step?.temperatureC ? ` (${step.temperatureC} °C)` : ""}
|
||||
</Text>
|
||||
{step?.tip && <Small>💡 {step.tip}</Small>}
|
||||
<Pressable
|
||||
onPress={() => setShowIngredients((v) => !v)}
|
||||
hitSlop={8}
|
||||
style={{ alignSelf: "flex-start" }}
|
||||
>
|
||||
<Small style={{ color: colors.primaryDark, fontWeight: "600" }}>
|
||||
{showIngredients ? "▾ " : "▸ "}📋 {t("recipe.ingredients")}
|
||||
</Small>
|
||||
</Pressable>
|
||||
{showIngredients && (
|
||||
<View
|
||||
style={{
|
||||
maxHeight: 200,
|
||||
backgroundColor: colors.surfaceAlt,
|
||||
borderRadius: 12,
|
||||
padding: spacing.md,
|
||||
}}
|
||||
<Pressable
|
||||
onPress={() => setShowIngredients((v) => !v)}
|
||||
hitSlop={8}
|
||||
style={{ alignSelf: "flex-start" }}
|
||||
>
|
||||
<ScrollView>
|
||||
{recipe.ingredients.map((ing) => {
|
||||
const scaledQty = (ing.quantity * portionsCooked) / recipe.portions;
|
||||
return (
|
||||
<Row key={ing.id} style={{ justifyContent: "space-between" }}>
|
||||
<Body>
|
||||
{ing.displayNameSv}
|
||||
{ing.optional ? " (valfritt)" : ""}
|
||||
</Body>
|
||||
<Small>{formatQuantity(scaledQty, ing.unit)}</Small>
|
||||
</Row>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
<Small style={{ color: colors.primaryDark, fontWeight: "600" }}>
|
||||
{showIngredients ? "▾ " : "▸ "}📋 {t("recipe.ingredients")}
|
||||
</Small>
|
||||
</Pressable>
|
||||
{showIngredients && (
|
||||
<View
|
||||
style={{
|
||||
maxHeight: 200,
|
||||
backgroundColor: colors.surfaceAlt,
|
||||
borderRadius: 12,
|
||||
padding: spacing.md,
|
||||
}}
|
||||
>
|
||||
<ScrollView>
|
||||
{recipe.ingredients.map((ing) => {
|
||||
const scaledQty = (ing.quantity * portionsCooked) / recipe.portions;
|
||||
return (
|
||||
<Row key={ing.id} style={{ justifyContent: "space-between" }}>
|
||||
<Body>
|
||||
{ing.displayNameSv}
|
||||
{ing.optional ? " (valfritt)" : ""}
|
||||
</Body>
|
||||
<Small>{formatQuantity(scaledQty, ing.unit)}</Small>
|
||||
</Row>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{step?.timerSeconds != null && (
|
||||
<View
|
||||
@@ -298,7 +298,7 @@ export default function CookingScreen() {
|
||||
<Text style={{ fontSize: 22, fontWeight: "700", color: colors.primaryDark }}>
|
||||
⏱ {formatTime(step.timerSeconds)}
|
||||
</Text>
|
||||
<Small>Ungefärlig tid för steget – använd din telefons egen timer.</Small>
|
||||
<Small>{t("cooking.timerHint")}</Small>
|
||||
</View>
|
||||
)}
|
||||
<Small>{t("cooking.keepAwake")}</Small>
|
||||
|
||||
@@ -37,14 +37,8 @@ interface InventoryItem {
|
||||
}
|
||||
|
||||
const LOCATION_ORDER = ["fridge", "freezer", "pantry", "garage_freezer", "wine_fridge"];
|
||||
const LOCATION_LABELS: Record<string, string> = {
|
||||
fridge: "Kyl",
|
||||
freezer: "Frys",
|
||||
pantry: "Skafferi",
|
||||
garage_freezer: "Garagefrys",
|
||||
wine_fridge: "Vinkyl",
|
||||
};
|
||||
const locLabel = (type: string) => LOCATION_LABELS[type] ?? type;
|
||||
const locLabel = (type: string) =>
|
||||
LOCATION_ORDER.includes(type) ? t(`home.location.${type}`) : type;
|
||||
const orderIndex = (type: string) => {
|
||||
const i = LOCATION_ORDER.indexOf(type);
|
||||
return i === -1 ? LOCATION_ORDER.length : i;
|
||||
@@ -121,25 +115,30 @@ export default function KitchenScreen() {
|
||||
|
||||
const confirmDelete = (ids: string[], label: string) => {
|
||||
if (ids.length === 0) return;
|
||||
Alert.alert(
|
||||
"Ta bort",
|
||||
`Är du säker på att du vill ta bort ${ids.length} ${ids.length === 1 ? "vara" : "varor"} ur ${label}?`,
|
||||
[
|
||||
{ text: t("common.cancel"), style: "cancel" },
|
||||
{ text: "Ta bort", style: "destructive", onPress: () => bulkRemove.mutate(ids) },
|
||||
],
|
||||
);
|
||||
Alert.alert(t("common.remove"), t("kitchen.deleteConfirm", { label }), [
|
||||
{ text: t("common.cancel"), style: "cancel" },
|
||||
{ text: t("common.remove"), style: "destructive", onPress: () => bulkRemove.mutate(ids) },
|
||||
]);
|
||||
};
|
||||
|
||||
const emptyLabel = locFilter ? locLabel(locFilter) : q ? "sökningen" : "hela lagret";
|
||||
const emptyLabel = locFilter
|
||||
? locLabel(locFilter)
|
||||
: q
|
||||
? t("kitchen.scopeSearch")
|
||||
: t("kitchen.scopeAll");
|
||||
|
||||
return (
|
||||
<Screen>
|
||||
<Input value={search} onChangeText={setSearch} placeholder="Sök vara…" autoCorrect={false} />
|
||||
<Input
|
||||
value={search}
|
||||
onChangeText={setSearch}
|
||||
placeholder={t("kitchen.searchPlaceholder")}
|
||||
autoCorrect={false}
|
||||
/>
|
||||
|
||||
<Row>
|
||||
<Button
|
||||
label="Alla"
|
||||
label={t("common.all")}
|
||||
variant={locFilter === null ? "primary" : "ghost"}
|
||||
onPress={() => setLocFilter(null)}
|
||||
/>
|
||||
@@ -162,22 +161,25 @@ export default function KitchenScreen() {
|
||||
<Button
|
||||
label={`Ta bort valda (${selected.size})`}
|
||||
variant="danger"
|
||||
onPress={() => confirmDelete([...selected], "markeringen")}
|
||||
onPress={() => confirmDelete([...selected], t("kitchen.scopeSelection"))}
|
||||
/>
|
||||
)}
|
||||
{visible.length > 0 && (
|
||||
<Button
|
||||
label="Töm listan"
|
||||
label={t("kitchen.clearList")}
|
||||
variant="ghost"
|
||||
onPress={() => confirmDelete(visible.map((i) => i.id), emptyLabel)}
|
||||
onPress={() =>
|
||||
confirmDelete(
|
||||
visible.map((i) => i.id),
|
||||
emptyLabel,
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Row>
|
||||
</Row>
|
||||
|
||||
{visible.length === 0 && (
|
||||
<EmptyState text="Inga varor att visa. Skanna eller lägg till varor." />
|
||||
)}
|
||||
{visible.length === 0 && <EmptyState text={t("kitchen.empty")} />}
|
||||
|
||||
{groups.map((g) => (
|
||||
<View key={g.type} style={{ gap: spacing.xs, marginTop: spacing.sm }}>
|
||||
@@ -191,7 +193,11 @@ export default function KitchenScreen() {
|
||||
<Card
|
||||
key={item.id}
|
||||
onPress={() => toggle(item.id)}
|
||||
style={isSel ? { borderColor: colors.primary, backgroundColor: colors.primarySoft } : undefined}
|
||||
style={
|
||||
isSel
|
||||
? { borderColor: colors.primary, backgroundColor: colors.primarySoft }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Row style={{ justifyContent: "space-between", alignItems: "center" }}>
|
||||
<Body>
|
||||
@@ -199,7 +205,7 @@ export default function KitchenScreen() {
|
||||
{item.displayName} · {formatQuantity(item.quantity, item.unit)}
|
||||
</Body>
|
||||
<Row style={{ alignItems: "center", gap: 12 }}>
|
||||
{item.expiry.pastBestBefore && <Small>⚠︎ bäst före</Small>}
|
||||
{item.expiry.pastBestBefore && <Small>⚠︎ {t("kitchen.pastBefore")}</Small>}
|
||||
<Pressable
|
||||
onPress={() => confirmDelete([item.id], locLabel(item.locationType))}
|
||||
hitSlop={8}
|
||||
|
||||
@@ -146,7 +146,7 @@ export default function MemoryScreen() {
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Small>Personligt minne används aldrig som träningsdata utan separat samtycke.</Small>
|
||||
<Small>{t("memory.trainingNote")}</Small>
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -65,16 +65,26 @@ const DIETS = [
|
||||
"low_carb",
|
||||
"carnivore",
|
||||
] as const;
|
||||
const ALLERGENS = ["gluten", "milk", "eggs", "tree_nuts", "peanuts", "fish", "crustaceans", "soy", "sesame"] as const;
|
||||
const ALLERGENS = [
|
||||
"gluten",
|
||||
"milk",
|
||||
"eggs",
|
||||
"tree_nuts",
|
||||
"peanuts",
|
||||
"fish",
|
||||
"crustaceans",
|
||||
"soy",
|
||||
"sesame",
|
||||
] as const;
|
||||
const RELIGIOUS = ["none", "halal", "kosher", "hindu_no_beef", "buddhist_vegetarian"] as const;
|
||||
|
||||
export default function ProfileScreen() {
|
||||
const queryClient = useQueryClient();
|
||||
const rawLogout = useAuth((s) => s.logout);
|
||||
const logout = async () => {
|
||||
await firebaseAuthProvider.signOut().catch(() => {});
|
||||
await rawLogout();
|
||||
};
|
||||
const logout = async () => {
|
||||
await firebaseAuthProvider.signOut().catch(() => {});
|
||||
await rawLogout();
|
||||
};
|
||||
|
||||
const me = useQuery({ queryKey: ["me"], queryFn: () => api<Me>("/v1/me") });
|
||||
const entitlements = useQuery({
|
||||
|
||||
@@ -181,10 +181,7 @@ export default function RecipeScreen() {
|
||||
},
|
||||
onSuccess: (count) => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["shopping-lists"] });
|
||||
Alert.alert(
|
||||
t("recipe.coverage.added"),
|
||||
t("recipe.coverage.addedBody", { count }),
|
||||
);
|
||||
Alert.alert(t("recipe.coverage.added"), t("recipe.coverage.addedBody", { count }));
|
||||
},
|
||||
onError: (err) => {
|
||||
// Visa tydlig diagnostik: HTTP-status vs nätverksfel (hjälper felsökning).
|
||||
@@ -233,7 +230,7 @@ export default function RecipeScreen() {
|
||||
<Small>
|
||||
{recipe.creatorDisplayName.includes("Redaktion")
|
||||
? `Skapat av ${recipe.creatorDisplayName}`
|
||||
: `Källa: ${recipe.creatorDisplayName}`}
|
||||
: t("recipe.source", { name: recipe.creatorDisplayName })}
|
||||
</Small>
|
||||
)}
|
||||
|
||||
@@ -266,7 +263,10 @@ export default function RecipeScreen() {
|
||||
<Row style={{ justifyContent: "space-between", alignItems: "center" }}>
|
||||
<Heading>{t("recipe.coverage.have", { percent: recipe.coverage.percent })}</Heading>
|
||||
{recipe.coverage.missing.length > 0 && (
|
||||
<Tag label={t("recipe.coverage.missingCount", { count: recipe.coverage.missing.length })} tone="warning" />
|
||||
<Tag
|
||||
label={t("recipe.coverage.missingCount", { count: recipe.coverage.missing.length })}
|
||||
tone="warning"
|
||||
/>
|
||||
)}
|
||||
</Row>
|
||||
{recipe.coverage.missing.length === 0 ? (
|
||||
|
||||
@@ -53,24 +53,24 @@ const CATEGORIES: ReadonlyArray<{
|
||||
tag?: string;
|
||||
glyph: string;
|
||||
}> = [
|
||||
{ key: "all", labelKey: null, glyph: "🍴" },
|
||||
{ key: "all", labelKey: "common.all", glyph: "🍴" },
|
||||
{ key: "breakfast", labelKey: "myday.mealType.breakfast", mealType: "breakfast", glyph: "🥣" },
|
||||
{ key: "lunch", labelKey: "myday.mealType.lunch", mealType: "lunch", glyph: "🥗" },
|
||||
{ key: "dinner", labelKey: "myday.mealType.dinner", mealType: "dinner", glyph: "🍽️" },
|
||||
{ key: "snack", labelKey: "myday.mealType.snack", mealType: "snack", glyph: "🍎" },
|
||||
{ key: "dessert", labelKey: "myday.mealType.dessert", mealType: "dessert", glyph: "🍰" },
|
||||
{ key: "baking", labelKey: null, label: "Baka", tag: "baking", glyph: "🥐" },
|
||||
{ key: "baking", labelKey: "cat.baking", tag: "baking", glyph: "🥐" },
|
||||
];
|
||||
|
||||
// Under-kategorier som visas när "Baka" är vald. Varje bakverk bär både taggen
|
||||
// "baking" och en under-typ, så vi kan filtrera på under-typen direkt.
|
||||
const BAKING_SUBS: ReadonlyArray<{ key: string; label: string; tag?: string }> = [
|
||||
{ key: "all", label: "Alla" },
|
||||
{ key: "bread", label: "Bröd", tag: "bread" },
|
||||
{ key: "bun", label: "Bullar", tag: "bun" },
|
||||
{ key: "pie", label: "Paj", tag: "pie" },
|
||||
{ key: "cake", label: "Tårtor & kakor", tag: "cake" },
|
||||
{ key: "cookie", label: "Småkakor", tag: "cookie" },
|
||||
const BAKING_SUBS: ReadonlyArray<{ key: string; labelKey: string; tag?: string }> = [
|
||||
{ key: "all", labelKey: "common.all" },
|
||||
{ key: "bread", labelKey: "recipes.sub.bread", tag: "bread" },
|
||||
{ key: "bun", labelKey: "recipes.sub.bun", tag: "bun" },
|
||||
{ key: "pie", labelKey: "recipes.sub.pie", tag: "pie" },
|
||||
{ key: "cake", labelKey: "recipes.sub.cake", tag: "cake" },
|
||||
{ key: "cookie", labelKey: "recipes.sub.cookie", tag: "cookie" },
|
||||
];
|
||||
|
||||
export default function BrowseRecipesScreen() {
|
||||
@@ -112,7 +112,7 @@ export default function BrowseRecipesScreen() {
|
||||
return (
|
||||
<Screen style={{ gap: spacing.md }}>
|
||||
<Input
|
||||
placeholder="Sök recept…"
|
||||
placeholder={t("recipes.searchPlaceholder")}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
value={search}
|
||||
@@ -123,7 +123,7 @@ export default function BrowseRecipesScreen() {
|
||||
{CATEGORIES.map((c) => (
|
||||
<Button
|
||||
key={c.key}
|
||||
label={`${c.glyph} ${c.labelKey ? t(c.labelKey as never) : (c.label ?? "Alla")}`}
|
||||
label={`${c.glyph} ${c.labelKey ? t(c.labelKey as never) : ""}`}
|
||||
variant={category === c.key ? "secondary" : "ghost"}
|
||||
onPress={() => selectCategory(c.key)}
|
||||
/>
|
||||
@@ -136,7 +136,7 @@ export default function BrowseRecipesScreen() {
|
||||
{BAKING_SUBS.map((s) => (
|
||||
<Button
|
||||
key={s.key}
|
||||
label={s.label}
|
||||
label={t(s.labelKey)}
|
||||
variant={sub === s.key ? "secondary" : "ghost"}
|
||||
onPress={() => setSub(s.key)}
|
||||
/>
|
||||
@@ -149,13 +149,7 @@ export default function BrowseRecipesScreen() {
|
||||
) : query.isError ? (
|
||||
<ErrorView onRetry={() => void query.refetch()} />
|
||||
) : recipes.length === 0 ? (
|
||||
<EmptyState
|
||||
text={
|
||||
term
|
||||
? "Inga recept matchar din sökning."
|
||||
: "Inga recept i den här kategorin än – de dyker upp när katalogen växer."
|
||||
}
|
||||
/>
|
||||
<EmptyState text={term ? t("wte.emptySearch") : t("recipes.emptyCategory")} />
|
||||
) : (
|
||||
recipes.map((r) => (
|
||||
<Card
|
||||
|
||||
@@ -2,64 +2,78 @@ import { useState } from "react";
|
||||
import { router } from "expo-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api";
|
||||
import { Card, EmptyState, ErrorView, Heading, Input, LoadingView, Screen, Small } from "@/components/ui";
|
||||
import { t } from "@/lib/i18n";
|
||||
import {
|
||||
Card,
|
||||
EmptyState,
|
||||
ErrorView,
|
||||
Heading,
|
||||
Input,
|
||||
LoadingView,
|
||||
Screen,
|
||||
Small,
|
||||
} from "@/components/ui";
|
||||
import { spacing } from "@/lib/theme";
|
||||
|
||||
/** Dina sparade recept (favoriter) – sökbar lista. Backend: GET /v1/recipes/favorites/mine. */
|
||||
|
||||
interface SavedRecipe {
|
||||
id: string;
|
||||
titleSv: string;
|
||||
totalTimeMinutes: number | null;
|
||||
nutritionPerPortion: { kcal?: number } | null;
|
||||
id: string;
|
||||
titleSv: string;
|
||||
totalTimeMinutes: number | null;
|
||||
nutritionPerPortion: { kcal?: number } | null;
|
||||
}
|
||||
interface FavoritesResponse {
|
||||
recipes: SavedRecipe[];
|
||||
recipes: SavedRecipe[];
|
||||
}
|
||||
|
||||
export default function SavedRecipesScreen() {
|
||||
const [query, setQuery] = useState("");
|
||||
const favs = useQuery<FavoritesResponse>({
|
||||
queryKey: ["favorites-mine"],
|
||||
queryFn: () => api<FavoritesResponse>("/v1/recipes/favorites/mine"),
|
||||
});
|
||||
const [query, setQuery] = useState("");
|
||||
const favs = useQuery<FavoritesResponse>({
|
||||
queryKey: ["favorites-mine"],
|
||||
queryFn: () => api<FavoritesResponse>("/v1/recipes/favorites/mine"),
|
||||
});
|
||||
|
||||
if (favs.isLoading) return <LoadingView />;
|
||||
if (favs.isError) return <ErrorView onRetry={() => void favs.refetch()} />;
|
||||
if (favs.isLoading) return <LoadingView />;
|
||||
if (favs.isError) return <ErrorView onRetry={() => void favs.refetch()} />;
|
||||
|
||||
const all = favs.data?.recipes ?? [];
|
||||
const q = query.trim().toLowerCase();
|
||||
const filtered = q ? all.filter((r) => r.titleSv.toLowerCase().includes(q)) : all;
|
||||
const all = favs.data?.recipes ?? [];
|
||||
const q = query.trim().toLowerCase();
|
||||
const filtered = q ? all.filter((r) => r.titleSv.toLowerCase().includes(q)) : all;
|
||||
|
||||
return (
|
||||
<Screen style={{ gap: spacing.md }}>
|
||||
<Input
|
||||
placeholder="Sök bland dina sparade recept"
|
||||
autoCapitalize="none"
|
||||
value={query}
|
||||
onChangeText={setQuery}
|
||||
/>
|
||||
{all.length === 0 ? (
|
||||
<EmptyState text="Du har inga sparade recept än. Tryck på ☆ Spara på ett recept så hamnar det här." />
|
||||
) : filtered.length === 0 ? (
|
||||
<EmptyState text="Inga sparade recept matchar din sökning." />
|
||||
) : (
|
||||
filtered.map((r) => (
|
||||
<Card key={r.id} onPress={() => router.push(`/recipe/${r.id}`)} style={{ gap: spacing.xs }}>
|
||||
<Heading>{r.titleSv}</Heading>
|
||||
<Small>
|
||||
{[
|
||||
r.totalTimeMinutes != null ? `${r.totalTimeMinutes} min` : null,
|
||||
r.nutritionPerPortion?.kcal != null
|
||||
? `${Math.round(r.nutritionPerPortion.kcal)} kcal`
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</Small>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</Screen>
|
||||
);
|
||||
return (
|
||||
<Screen style={{ gap: spacing.md }}>
|
||||
<Input
|
||||
placeholder={t("savedRecipes.searchPlaceholder")}
|
||||
autoCapitalize="none"
|
||||
value={query}
|
||||
onChangeText={setQuery}
|
||||
/>
|
||||
{all.length === 0 ? (
|
||||
<EmptyState text={t("savedRecipes.empty")} />
|
||||
) : filtered.length === 0 ? (
|
||||
<EmptyState text={t("savedRecipes.emptySearch")} />
|
||||
) : (
|
||||
filtered.map((r) => (
|
||||
<Card
|
||||
key={r.id}
|
||||
onPress={() => router.push(`/recipe/${r.id}`)}
|
||||
style={{ gap: spacing.xs }}
|
||||
>
|
||||
<Heading>{r.titleSv}</Heading>
|
||||
<Small>
|
||||
{[
|
||||
r.totalTimeMinutes != null ? `${r.totalTimeMinutes} min` : null,
|
||||
r.nutritionPerPortion?.kcal != null
|
||||
? `${Math.round(r.nutritionPerPortion.kcal)} kcal`
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</Small>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -125,9 +125,9 @@ export default function ShoppingScreen() {
|
||||
});
|
||||
|
||||
const confirmRemove = (item: ShoppingItem) =>
|
||||
Alert.alert(item.displayName, "Ta bort varan från listan?", [
|
||||
Alert.alert(item.displayName, t("shopping.removeConfirm"), [
|
||||
{ text: t("common.cancel"), style: "cancel" },
|
||||
{ text: "Ta bort", style: "destructive", onPress: () => remove.mutate(item.id) },
|
||||
{ text: t("common.remove"), style: "destructive", onPress: () => remove.mutate(item.id) },
|
||||
]);
|
||||
|
||||
const complete = useMutation({
|
||||
|
||||
@@ -31,12 +31,12 @@ export default function SubmitRecipeScreen() {
|
||||
setError(null);
|
||||
// Bygg en tydlig fritext-mall som AI:n tolkar robust.
|
||||
const text = [
|
||||
`Titel: ${title.trim()}`,
|
||||
`Titel: ${title.trim()}`, // i18n-ignore (fri-text-payload till receptparsern, ej UI)
|
||||
"",
|
||||
"Ingredienser:",
|
||||
"Ingredienser:", // i18n-ignore
|
||||
ingredients.trim(),
|
||||
"",
|
||||
"Gör så här:",
|
||||
"Gör så här:", // i18n-ignore
|
||||
steps.trim(),
|
||||
].join("\n");
|
||||
try {
|
||||
@@ -73,10 +73,7 @@ export default function SubmitRecipeScreen() {
|
||||
<Heading>⭐ {t("submit.premiumTitle")}</Heading>
|
||||
<Body>{t("submit.premiumBody")}</Body>
|
||||
<Spacer size={spacing.md} />
|
||||
<Button
|
||||
label={t("submit.seePlans")}
|
||||
onPress={() => router.replace("/profile")}
|
||||
/>
|
||||
<Button label={t("submit.seePlans")} onPress={() => router.replace("/profile")} />
|
||||
<Spacer size={spacing.xs} />
|
||||
<Button label={t("submit.backHome")} variant="ghost" onPress={() => router.back()} />
|
||||
</Card>
|
||||
|
||||
@@ -80,18 +80,23 @@ export default function SwapMealScreen() {
|
||||
|
||||
const recs = query.data?.recommendations ?? [];
|
||||
const mealLabel =
|
||||
mealType === "lunch" ? "lunchen" : mealType === "breakfast" ? "frukosten" : "middagen";
|
||||
mealType === "lunch"
|
||||
? t("swap.mealLunch")
|
||||
: mealType === "breakfast"
|
||||
? t("swap.mealBreakfast")
|
||||
: t("swap.mealDinner");
|
||||
|
||||
return (
|
||||
<Screen>
|
||||
<Title>{addMode ? "Lägg till måltid" : "Byt ut måltiden"}</Title>
|
||||
<Title>{addMode ? t("swap.addTitle") : t("swap.swapTitle")}</Title>
|
||||
<Small>
|
||||
{addMode ? `Välj en rätt för ${mealLabel}` : `Välj en annan rätt för ${mealLabel}`} – eller
|
||||
sök efter vilken rätt som helst.
|
||||
{addMode
|
||||
? t("swap.chooseFor", { meal: mealLabel })
|
||||
: t("swap.chooseOther", { meal: mealLabel })}
|
||||
</Small>
|
||||
<Spacer size={spacing.sm} />
|
||||
<Input
|
||||
placeholder="Sök bland alla recept …"
|
||||
placeholder={t("swap.searchPlaceholder")}
|
||||
value={searchText}
|
||||
onChangeText={setSearchText}
|
||||
onSubmitEditing={() => setSubmitted(searchText.trim())}
|
||||
@@ -100,8 +105,7 @@ export default function SwapMealScreen() {
|
||||
/>
|
||||
{submitted ? (
|
||||
<Small>
|
||||
Visar träffar för ”{submitted}”.{" "}
|
||||
<Small>Rensa sökrutan och sök på tomt för att se förslag igen.</Small>
|
||||
{t("swap.showingResults", { query: submitted })} <Small>{t("swap.clearHint")}</Small>
|
||||
</Small>
|
||||
) : null}
|
||||
<Spacer size={spacing.xs} />
|
||||
@@ -109,7 +113,7 @@ export default function SwapMealScreen() {
|
||||
{query.isLoading && <LoadingView />}
|
||||
{query.isError && <ErrorView onRetry={() => void query.refetch()} />}
|
||||
{query.data && recs.length === 0 && (
|
||||
<EmptyState text={submitted ? "Inga recept matchar sökningen." : t("wte.empty")} />
|
||||
<EmptyState text={submitted ? t("wte.emptySearch") : t("wte.empty")} />
|
||||
)}
|
||||
{recs.map((rec) => (
|
||||
<Card key={rec.recipeId} onPress={() => swap.mutate(rec.recipeId)}>
|
||||
|
||||
Reference in New Issue
Block a user