* Extended functionalities

* Added different UIs
This commit is contained in:
2026-06-24 21:43:40 +02:00
parent 282f4864c4
commit f15bb7a916
348 changed files with 59057 additions and 498 deletions

View File

@@ -0,0 +1,10 @@
import { Stack } from 'expo-router';
export default function AuthLayout() {
return (
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="login" />
<Stack.Screen name="register" />
</Stack>
);
}

View File

@@ -0,0 +1,117 @@
import { Link, router } from 'expo-router';
import { useState } from 'react';
import { KeyboardAvoidingView, Platform, ScrollView, StyleSheet, Text, View } from 'react-native';
import { useTranslation } from 'react-i18next';
import { Button } from '@/src/components/Button';
import { Input } from '@/src/components/Input';
import { Screen } from '@/src/components/Screen';
import { useAuth } from '@/src/context/AuthContext';
import { useAppearance } from '@/src/context/AppearanceContext';
import { extractApiError } from '@/src/utils/apiError';
export default function LoginScreen() {
const { t } = useTranslation();
const { login } = useAuth();
const { colors } = useAppearance();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function handleSubmit() {
setError(null);
if (!email.trim()) {
setError(t('validation.emailRequired'));
return;
}
if (!password) {
setError(t('validation.passwordRequired'));
return;
}
setLoading(true);
try {
await login({ email: email.trim(), password });
router.replace('/(main)/(tabs)');
} catch (err) {
setError(extractApiError(err, t('errors.signInFailed')));
} finally {
setLoading(false);
}
}
return (
<Screen>
<KeyboardAvoidingView
style={styles.flex}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<ScrollView contentContainerStyle={styles.scroll} keyboardShouldPersistTaps="handled">
<Text style={[styles.brand, { color: colors.brand }]}>{t('app.name')}</Text>
<Text style={[styles.subtitle, { color: colors.textMuted }]}>{t('auth.signInSubtitle')}</Text>
<View style={styles.form}>
<Input
label={t('auth.email')}
autoCapitalize="none"
autoComplete="email"
keyboardType="email-address"
placeholder={t('auth.emailPlaceholder')}
value={email}
onChangeText={setEmail}
/>
<Input
label={t('auth.password')}
secureTextEntry
autoComplete="password"
value={password}
onChangeText={setPassword}
/>
{error ? <Text style={[styles.error, { color: colors.danger }]}>{error}</Text> : null}
<Button
title={loading ? t('auth.signingIn') : t('auth.signIn')}
loading={loading}
onPress={handleSubmit}
/>
</View>
<Text style={[styles.footer, { color: colors.textMuted }]}>
{t('auth.noAccount')}{' '}
<Link href="/(auth)/register" style={{ color: colors.brand, fontWeight: '600' }}>
{t('auth.createOne')}
</Link>
</Text>
</ScrollView>
</KeyboardAvoidingView>
</Screen>
);
}
const styles = StyleSheet.create({
flex: { flex: 1 },
scroll: {
flexGrow: 1,
justifyContent: 'center',
gap: 16,
paddingVertical: 24,
},
brand: {
fontSize: 34,
fontWeight: '800',
},
subtitle: {
fontSize: 16,
lineHeight: 24,
},
form: {
gap: 14,
marginTop: 8,
},
error: {
fontSize: 14,
},
footer: {
textAlign: 'center',
fontSize: 15,
},
});

View File

@@ -0,0 +1,145 @@
import { Link, router } from 'expo-router';
import { useState } from 'react';
import { KeyboardAvoidingView, Platform, ScrollView, StyleSheet, Text, View } from 'react-native';
import { useTranslation } from 'react-i18next';
import { Button } from '@/src/components/Button';
import { Input } from '@/src/components/Input';
import { Screen } from '@/src/components/Screen';
import { useAuth } from '@/src/context/AuthContext';
import { useAppearance } from '@/src/context/AppearanceContext';
import { extractApiError } from '@/src/utils/apiError';
function validatePassword(password: string, t: (key: string) => string): string | null {
if (password.length < 8) return t('validation.passwordMinLength');
if (!/[A-Z]/.test(password)) return t('validation.passwordUppercase');
if (!/[a-z]/.test(password)) return t('validation.passwordLowercase');
if (!/\d/.test(password)) return t('validation.passwordDigit');
return null;
}
export default function RegisterScreen() {
const { t } = useTranslation();
const { register } = useAuth();
const { colors } = useAppearance();
const [email, setEmail] = useState('');
const [userName, setUserName] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function handleSubmit() {
setError(null);
if (!email.trim()) {
setError(t('validation.emailRequired'));
return;
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim())) {
setError(t('validation.emailInvalid'));
return;
}
const passwordError = validatePassword(password, t);
if (passwordError) {
setError(passwordError);
return;
}
if (!confirmPassword) {
setError(t('validation.confirmPasswordRequired'));
return;
}
if (password !== confirmPassword) {
setError(t('validation.passwordsMismatch'));
return;
}
setLoading(true);
try {
await register({
email: email.trim(),
password,
userName: userName.trim() || undefined,
});
router.replace('/(main)/(tabs)');
} catch (err) {
setError(extractApiError(err, t('errors.registerFailed')));
} finally {
setLoading(false);
}
}
return (
<Screen title={t('auth.createAccountTitle')} subtitle={t('auth.createAccountSubtitle')}>
<KeyboardAvoidingView
style={styles.flex}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<ScrollView contentContainerStyle={styles.scroll} keyboardShouldPersistTaps="handled">
<View style={styles.form}>
<Input
label={t('auth.email')}
autoCapitalize="none"
keyboardType="email-address"
placeholder={t('auth.emailPlaceholder')}
value={email}
onChangeText={setEmail}
/>
<Input
label={t('auth.displayName')}
placeholder={t('auth.displayNamePlaceholder')}
value={userName}
onChangeText={setUserName}
/>
<Input
label={t('auth.password')}
secureTextEntry
value={password}
onChangeText={setPassword}
/>
<Text style={[styles.hint, { color: colors.textMuted }]}>{t('auth.passwordHint')}</Text>
<Input
label={t('auth.confirmPassword')}
secureTextEntry
value={confirmPassword}
onChangeText={setConfirmPassword}
/>
{error ? <Text style={[styles.error, { color: colors.danger }]}>{error}</Text> : null}
<Button
title={loading ? t('auth.creatingAccount') : t('auth.createAccount')}
loading={loading}
onPress={handleSubmit}
/>
</View>
<Text style={[styles.footer, { color: colors.textMuted }]}>
{t('auth.hasAccount')}{' '}
<Link href="/(auth)/login" style={{ color: colors.brand, fontWeight: '600' }}>
{t('auth.signIn')}
</Link>
</Text>
</ScrollView>
</KeyboardAvoidingView>
</Screen>
);
}
const styles = StyleSheet.create({
flex: { flex: 1 },
scroll: {
gap: 16,
paddingBottom: 24,
},
form: {
gap: 14,
},
hint: {
fontSize: 13,
marginTop: -6,
},
error: {
fontSize: 14,
},
footer: {
textAlign: 'center',
fontSize: 15,
},
});

View File

@@ -0,0 +1,80 @@
import type { ComponentProps } from 'react';
import { Tabs } from 'expo-router';
import { useTranslation } from 'react-i18next';
import FontAwesome from '@expo/vector-icons/FontAwesome';
import { useAppearance } from '@/src/context/AppearanceContext';
function TabIcon(props: { name: ComponentProps<typeof FontAwesome>['name']; color: string }) {
return <FontAwesome size={22} style={{ marginBottom: -2 }} name={props.name} color={props.color} />;
}
export default function TabLayout() {
const { t } = useTranslation();
const { colors } = useAppearance();
return (
<Tabs
screenOptions={{
headerShown: false,
tabBarActiveTintColor: colors.brand,
tabBarInactiveTintColor: colors.textMuted,
tabBarStyle: {
backgroundColor: colors.surface,
borderTopColor: colors.border,
},
headerStyle: { backgroundColor: colors.surface },
headerTintColor: colors.text,
}}
>
<Tabs.Screen
name="index"
options={{
title: t('nav.dashboard'),
tabBarIcon: ({ color }) => <TabIcon name="home" color={String(color)} />,
}}
/>
<Tabs.Screen
name="meals"
options={{
title: t('nav.allMeals'),
tabBarIcon: ({ color }) => <TabIcon name="list" color={String(color)} />,
}}
/>
<Tabs.Screen
name="diet"
options={{
title: t('nav.dietTracker'),
tabBarIcon: ({ color }) => <TabIcon name="heartbeat" color={String(color)} />,
}}
/>
<Tabs.Screen
name="planner"
options={{
title: t('nav.planner'),
tabBarIcon: ({ color }) => <TabIcon name="calendar" color={String(color)} />,
}}
/>
<Tabs.Screen
name="shopping"
options={{
title: t('nav.shopping'),
tabBarIcon: ({ color }) => <TabIcon name="shopping-cart" color={String(color)} />,
}}
/>
<Tabs.Screen
name="manage"
options={{
title: t('nav.manageMeals'),
tabBarIcon: ({ color }) => <TabIcon name="plus-square" color={String(color)} />,
}}
/>
<Tabs.Screen
name="settings"
options={{
title: t('nav.settings'),
tabBarIcon: ({ color }) => <TabIcon name="cog" color={String(color)} />,
}}
/>
</Tabs>
);
}

View File

@@ -0,0 +1,945 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import {
copyYesterdayMeals,
createReminder,
deleteDrink,
deleteMeal,
deleteReminder,
fetchDailySummary,
fetchDrinkCatalog,
fetchFavoriteCatalogItems,
fetchFavoriteRecipes,
fetchGoals,
fetchHistory,
fetchMealSuggestions,
fetchRecentMeals,
fetchReminders,
logDrink,
logMeal,
previewMeal,
saveGoals,
updateReminder,
} from '@/src/api/diet.api';
import { fetchIngredientCatalogItems } from '@/src/api/ingredient-catalog.api';
import { fetchRecipes } from '@/src/api/recipes.api';
import { Button } from '@/src/components/Button';
import { DateNavigator } from '@/src/components/DateNavigator';
import { ErrorView } from '@/src/components/ErrorView';
import { Input } from '@/src/components/Input';
import { Screen } from '@/src/components/Screen';
import { SimpleBarChart } from '@/src/components/SimpleBarChart';
import { useAppearance } from '@/src/context/AppearanceContext';
import {
CONSUMPTION_CATEGORIES,
DietReminderType,
REMINDER_DAYS_MASK_ALL,
todayIso,
type DietSuggestion,
type LogMealInput,
type MealPreview,
type UserDailyGoals,
} from '@/src/types/diet';
import { extractApiError } from '@/src/utils/apiError';
import { historyRange } from '@/src/utils/date';
import { ingredientDisplayName } from '@/src/utils/ingredient-catalog.util';
import { nextSort, sortBy, sortIndicator, type SortDirection } from '@/src/utils/sort';
type MealLogSortColumn = 'name' | 'calories';
type DrinkLogSortColumn = 'name' | 'volume';
type ReminderSortColumn = 'title' | 'time';
type Tab = 'today' | 'goals' | 'reminders' | 'history';
function mealMacroLine(m: {
calories?: number | null;
proteinG?: number | null;
fatG?: number | null;
carbsG?: number | null;
}): string {
if (m.calories == null) return '';
return `${m.calories} kcal · P ${Number(m.proteinG ?? 0).toFixed(1)}g · F ${Number(m.fatG ?? 0).toFixed(1)}g · C ${Number(m.carbsG ?? 0).toFixed(1)}g`;
}
function ProgressCard({
label,
consumed,
goal,
remaining,
unit,
remainingLabel,
}: {
label: string;
consumed: number;
goal?: number | null;
remaining?: number | null;
unit: string;
remainingLabel: string;
}) {
const { colors } = useAppearance();
const pct = goal && goal > 0 ? Math.min((consumed / goal) * 100, 100) : 0;
return (
<View style={[styles.progressCard, { backgroundColor: colors.surface, borderColor: colors.border }]}>
<Text style={[styles.progressLabel, { color: colors.textMuted }]}>{label}</Text>
<Text style={[styles.progressValue, { color: colors.text }]}>
{consumed}
{goal != null ? (
<Text style={{ fontSize: 14, color: colors.textMuted }}>
{' '}
/ {goal}
{unit}
</Text>
) : null}
</Text>
{remaining != null ? (
<Text style={{ color: colors.textMuted, fontSize: 12 }}>
{remainingLabel}: {remaining}
{unit}
</Text>
) : null}
{goal != null ? (
<View style={[styles.progressTrack, { backgroundColor: colors.background }]}>
<View style={[styles.progressFill, { width: `${pct}%`, backgroundColor: colors.brand }]} />
</View>
) : null}
</View>
);
}
export default function DietScreen() {
const { t, i18n } = useTranslation();
const { colors } = useAppearance();
const queryClient = useQueryClient();
const [tab, setTab] = useState<Tab>('today');
const [date, setDate] = useState(todayIso());
const [mealCategory, setMealCategory] = useState(2);
const [recipeSearch, setRecipeSearch] = useState('');
const [selectedRecipeId, setSelectedRecipeId] = useState<number | null>(null);
const [customMealName, setCustomMealName] = useState('');
const [portions, setPortions] = useState('1');
const [catalogItemId, setCatalogItemId] = useState<number | null>(null);
const [grams, setGrams] = useState('150');
const [mealPreview, setMealPreview] = useState<MealPreview | null>(null);
const [goalsForm, setGoalsForm] = useState<UserDailyGoals>({});
const [reminderTitle, setReminderTitle] = useState('');
const [reminderTime, setReminderTime] = useState('09:00');
const [reminderType, setReminderType] = useState(DietReminderType.Water);
const [editingReminderId, setEditingReminderId] = useState<number | null>(null);
const [mealSort, setMealSort] = useState<{ column: MealLogSortColumn; direction: SortDirection }>({
column: 'name',
direction: 'asc',
});
const [drinkSort, setDrinkSort] = useState<{ column: DrinkLogSortColumn; direction: SortDirection }>({
column: 'name',
direction: 'asc',
});
const [reminderSort, setReminderSort] = useState<{ column: ReminderSortColumn; direction: SortDirection }>({
column: 'time',
direction: 'asc',
});
const summaryQuery = useQuery({
queryKey: ['diet', 'summary', date],
queryFn: () => fetchDailySummary(date),
});
const suggestionsQuery = useQuery({
queryKey: ['diet', 'suggestions', date],
queryFn: () => fetchMealSuggestions(date),
enabled: tab === 'today' && !!summaryQuery.data,
});
const catalogQuery = useQuery({
queryKey: ['diet', 'catalog'],
queryFn: fetchDrinkCatalog,
});
const ingredientItemsQuery = useQuery({
queryKey: ['ingredient-catalog', 'all'],
queryFn: () => fetchIngredientCatalogItems(),
});
const recipesQuery = useQuery({
queryKey: ['recipes', 'diet', recipeSearch],
queryFn: () => fetchRecipes({ search: recipeSearch }),
enabled: recipeSearch.trim().length >= 2,
});
const goalsQuery = useQuery({
queryKey: ['diet', 'goals'],
queryFn: fetchGoals,
enabled: tab === 'goals',
});
const remindersQuery = useQuery({
queryKey: ['diet', 'reminders'],
queryFn: fetchReminders,
enabled: tab === 'reminders',
});
const recentsQuery = useQuery({
queryKey: ['diet', 'recents'],
queryFn: () => fetchRecentMeals(8),
enabled: tab === 'today',
});
const favoriteRecipesQuery = useQuery({
queryKey: ['diet', 'favorites', 'recipes'],
queryFn: fetchFavoriteRecipes,
enabled: tab === 'today',
});
const favoriteCatalogQuery = useQuery({
queryKey: ['diet', 'favorites', 'catalog'],
queryFn: fetchFavoriteCatalogItems,
enabled: tab === 'today',
});
const historyRangeValues = historyRange(14);
const historyQuery = useQuery({
queryKey: ['diet', 'history', historyRangeValues.from, historyRangeValues.to],
queryFn: () => fetchHistory(historyRangeValues.from, historyRangeValues.to),
enabled: tab === 'history',
});
useEffect(() => {
if (goalsQuery.data) setGoalsForm(goalsQuery.data);
}, [goalsQuery.data]);
const invalidate = () => void queryClient.invalidateQueries({ queryKey: ['diet'] });
const resetMealForm = () => {
setRecipeSearch('');
setSelectedRecipeId(null);
setCustomMealName('');
setCatalogItemId(null);
setPortions('1');
setGrams('150');
setMealPreview(null);
};
const buildMealPayload = useCallback((): LogMealInput | null => {
if (selectedRecipeId) {
return {
recipeId: selectedRecipeId,
mealCategory,
logDate: date,
portions: Number(portions) || 1,
};
}
const gramsNum = Number(grams);
if (catalogItemId && gramsNum > 0) {
return { catalogItemId, grams: gramsNum, mealCategory, logDate: date };
}
if (customMealName.trim()) {
return { name: customMealName.trim(), mealCategory, logDate: date };
}
return null;
}, [catalogItemId, customMealName, date, grams, mealCategory, portions, selectedRecipeId]);
useEffect(() => {
const payload = buildMealPayload();
if (!payload) {
setMealPreview(null);
return;
}
const timer = setTimeout(() => {
void previewMeal(payload, date)
.then(setMealPreview)
.catch(() => setMealPreview(null));
}, 250);
return () => clearTimeout(timer);
}, [buildMealPayload, date]);
const logMealMutation = useMutation({
mutationFn: logMeal,
onSuccess: () => {
resetMealForm();
invalidate();
},
});
const logDrinkMutation = useMutation({ mutationFn: logDrink, onSuccess: invalidate });
const saveGoalsMutation = useMutation({ mutationFn: saveGoals, onSuccess: invalidate });
const createReminderMutation = useMutation({
mutationFn: createReminder,
onSuccess: () => {
setReminderTitle('');
setEditingReminderId(null);
void queryClient.invalidateQueries({ queryKey: ['diet', 'reminders'] });
},
});
const updateReminderMutation = useMutation({
mutationFn: ({ id, input }: { id: number; input: Parameters<typeof updateReminder>[1] }) => updateReminder(id, input),
onSuccess: () => {
setReminderTitle('');
setEditingReminderId(null);
void queryClient.invalidateQueries({ queryKey: ['diet', 'reminders'] });
},
});
const copyYesterdayMutation = useMutation({
mutationFn: () => copyYesterdayMeals(date),
onSuccess: () => invalidate(),
});
const deleteMealMutation = useMutation({ mutationFn: deleteMeal, onSuccess: invalidate });
const deleteDrinkMutation = useMutation({ mutationFn: deleteDrink, onSuccess: invalidate });
const deleteReminderMutation = useMutation({
mutationFn: deleteReminder,
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['diet', 'reminders'] }),
});
const tabs: { id: Tab; label: string }[] = [
{ id: 'today', label: t('diet.tabToday') },
{ id: 'history', label: t('diet.tabHistory') },
{ id: 'goals', label: t('diet.tabGoals') },
{ id: 'reminders', label: t('diet.tabReminders') },
];
const summary = summaryQuery.data;
const suggestions = suggestionsQuery.data ?? [];
const ingredientItems = ingredientItemsQuery.data ?? [];
const suggestionName = (s: DietSuggestion) =>
i18n.language.startsWith('pl') && s.namePl ? s.namePl : s.name;
const canLogMeal = buildMealPayload() != null;
const sortedMeals = useMemo(() => {
if (!summary) return [];
const { column, direction } = mealSort;
return sortBy(summary.meals, direction, (meal) => (column === 'name' ? meal.name : meal.calories));
}, [summary, mealSort]);
const sortedDrinks = useMemo(() => {
if (!summary) return [];
const { column, direction } = drinkSort;
return sortBy(summary.drinks, direction, (drink) =>
column === 'name' ? drink.name : drink.volumeMl,
);
}, [summary, drinkSort]);
const sortedReminders = useMemo(() => {
const { column, direction } = reminderSort;
return sortBy(remindersQuery.data ?? [], direction, (reminder) =>
column === 'title' ? reminder.title : reminder.timeOfDay,
);
}, [remindersQuery.data, reminderSort]);
return (
<Screen title={t('diet.title')} subtitle={t('diet.subtitle')}>
<ScrollView contentContainerStyle={styles.scroll} keyboardShouldPersistTaps="handled">
<DateNavigator date={date} onChange={setDate} />
<View style={styles.tabs}>
{tabs.map(({ id, label }) => (
<Pressable
key={id}
onPress={() => setTab(id)}
style={[
styles.tab,
{
backgroundColor: tab === id ? colors.brand : colors.surface,
borderColor: tab === id ? colors.brand : colors.border,
},
]}
>
<Text style={{ color: tab === id ? '#fff' : colors.text, fontWeight: '600', fontSize: 13 }}>
{label}
</Text>
</Pressable>
))}
</View>
{tab === 'today' && (
<>
{summaryQuery.isLoading ? (
<Text style={{ color: colors.textMuted }}>{t('common.loading')}</Text>
) : summaryQuery.isError ? (
<ErrorView
message={extractApiError(summaryQuery.error, t('errors.loadDietFailed'))}
onRetry={() => summaryQuery.refetch()}
/>
) : summary ? (
<>
<ProgressCard
label={t('diet.calories')}
consumed={summary.calories.consumed}
goal={summary.calories.goal}
remaining={summary.calories.remaining}
unit=" kcal"
remainingLabel={t('diet.remaining')}
/>
<ProgressCard
label={t('diet.water')}
consumed={summary.waterMl.consumed}
goal={summary.waterMl.goal}
remaining={summary.waterMl.remaining}
unit="ml"
remainingLabel={t('diet.remaining')}
/>
<ProgressCard
label={t('recipes.protein')}
consumed={Number(summary.proteinG.consumed)}
goal={summary.proteinG.goal != null ? Number(summary.proteinG.goal) : null}
remaining={summary.proteinG.remaining != null ? Number(summary.proteinG.remaining) : null}
unit="g"
remainingLabel={t('diet.remaining')}
/>
<ProgressCard
label={t('recipes.fat')}
consumed={Number(summary.fatG.consumed)}
goal={summary.fatG.goal != null ? Number(summary.fatG.goal) : null}
remaining={summary.fatG.remaining != null ? Number(summary.fatG.remaining) : null}
unit="g"
remainingLabel={t('diet.remaining')}
/>
<ProgressCard
label={t('recipes.carbs')}
consumed={Number(summary.carbsG.consumed)}
goal={summary.carbsG.goal != null ? Number(summary.carbsG.goal) : null}
remaining={summary.carbsG.remaining != null ? Number(summary.carbsG.remaining) : null}
unit="g"
remainingLabel={t('diet.remaining')}
/>
<Text style={[styles.section, { color: colors.text }]}>{t('diet.quickLog')}</Text>
<Button
title={t('diet.copyYesterday')}
variant="secondary"
loading={copyYesterdayMutation.isPending}
onPress={() => copyYesterdayMutation.mutate()}
/>
{(recentsQuery.data?.length ?? 0) > 0 ? (
<>
<Text style={[styles.label, { color: colors.text }]}>{t('diet.recents')}</Text>
<View style={styles.chips}>
{recentsQuery.data!.map((meal, idx) => (
<Pressable
key={`${meal.name}-${idx}`}
disabled={logMealMutation.isPending}
onPress={() => {
const payload: LogMealInput = { mealCategory: meal.mealCategory, logDate: date, portions: Number(meal.portions) || 1 };
if (meal.recipeId) payload.recipeId = meal.recipeId;
else if (meal.catalogItemId && meal.grams) {
payload.catalogItemId = meal.catalogItemId;
payload.grams = Number(meal.grams);
} else payload.name = meal.name;
logMealMutation.mutate(payload);
}}
style={[styles.chip, { borderColor: colors.border, backgroundColor: colors.surface }]}
>
<Text style={{ color: colors.text, fontSize: 12 }}>{meal.name}</Text>
</Pressable>
))}
</View>
</>
) : null}
{(favoriteRecipesQuery.data?.length ?? 0) > 0 ? (
<>
<Text style={[styles.label, { color: colors.text }]}>{t('diet.favoriteRecipes')}</Text>
<View style={styles.chips}>
{favoriteRecipesQuery.data!.map((fav) => (
<Pressable
key={fav.recipeId}
disabled={logMealMutation.isPending}
onPress={() =>
logMealMutation.mutate({ recipeId: fav.recipeId, mealCategory, logDate: date, portions: 1 })
}
style={[styles.chip, { borderColor: colors.border, backgroundColor: colors.surface }]}
>
<Text style={{ color: colors.text, fontSize: 12 }}>{fav.name}</Text>
</Pressable>
))}
</View>
</>
) : null}
{(favoriteCatalogQuery.data?.length ?? 0) > 0 ? (
<>
<Text style={[styles.label, { color: colors.text }]}>{t('diet.favoriteCatalog')}</Text>
<View style={styles.chips}>
{favoriteCatalogQuery.data!.map((fav) => (
<Pressable
key={fav.catalogItemId}
disabled={logMealMutation.isPending}
onPress={() =>
logMealMutation.mutate({
catalogItemId: fav.catalogItemId,
grams: 150,
mealCategory,
logDate: date,
})
}
style={[styles.chip, { borderColor: colors.border, backgroundColor: colors.surface }]}
>
<Text style={{ color: colors.text, fontSize: 12 }}>
{i18n.language.startsWith('pl') && fav.namePl ? fav.namePl : fav.name}
</Text>
</Pressable>
))}
</View>
</>
) : null}
{suggestions.length > 0 ? (
<>
<Text style={[styles.section, { color: colors.text }]}>{t('diet.suggestionsTitle')}</Text>
<Text style={{ color: colors.textMuted, fontSize: 13 }}>{t('diet.suggestionsHint')}</Text>
{suggestions.map((s) => (
<View
key={s.catalogItemId}
style={[styles.suggestionRow, { borderColor: colors.border, backgroundColor: colors.surface }]}
>
<View style={{ flex: 1 }}>
<Text style={{ color: colors.text, fontWeight: '600' }}>{suggestionName(s)}</Text>
<Text style={{ color: colors.textMuted, fontSize: 12 }}>
{s.suggestedGrams}g · {s.calories ?? '?'} kcal · P {Number(s.proteinG ?? 0).toFixed(1)}g ·{' '}
{t(`diet.suggestionReasons.${s.reasonKey}`)}
</Text>
</View>
<Button
title={`+ ${t('diet.logSuggestion')}`}
variant="secondary"
loading={logMealMutation.isPending}
onPress={() =>
logMealMutation.mutate({
catalogItemId: s.catalogItemId,
grams: s.suggestedGrams,
mealCategory,
logDate: date,
})
}
/>
</View>
))}
</>
) : null}
<Text style={[styles.section, { color: colors.text }]}>{t('diet.logDrink')}</Text>
<View style={styles.chips}>
{catalogQuery.data?.map((item) => (
<Pressable
key={item.id}
disabled={logDrinkMutation.isPending}
onPress={() =>
logDrinkMutation.mutate({
drinkCatalogId: item.id,
volumeMl: item.defaultVolumeMl,
logDate: date,
})
}
style={[styles.chip, { borderColor: colors.border, backgroundColor: colors.surface }]}
>
<Text style={{ color: colors.text }}>
{item.iconEmoji ?? '🥤'} {item.name} +{item.defaultVolumeMl}ml
</Text>
</Pressable>
))}
</View>
<Text style={[styles.section, { color: colors.text }]}>{t('diet.logMeal')}</Text>
<Text style={[styles.label, { color: colors.text }]}>{t('common.category')}</Text>
<View style={styles.chips}>
{CONSUMPTION_CATEGORIES.map((c) => (
<Pressable
key={c.value}
onPress={() => setMealCategory(c.value)}
style={[
styles.chip,
{
backgroundColor: mealCategory === c.value ? colors.brand : colors.surface,
borderColor: mealCategory === c.value ? colors.brand : colors.border,
},
]}
>
<Text style={{ color: mealCategory === c.value ? '#fff' : colors.text, fontSize: 12 }}>
{t(c.labelKey)}
</Text>
</Pressable>
))}
</View>
<Input
placeholder={t('recipes.searchRecipes')}
value={recipeSearch}
onChangeText={(v) => {
setRecipeSearch(v);
setSelectedRecipeId(null);
setCatalogItemId(null);
}}
/>
{recipesQuery.data?.map((r) => (
<Pressable
key={r.id}
onPress={() => {
setSelectedRecipeId(r.id);
setCustomMealName('');
setCatalogItemId(null);
}}
style={[
styles.recipePick,
{
backgroundColor: selectedRecipeId === r.id ? colors.brand : colors.surface,
borderColor: colors.border,
},
]}
>
<Text style={{ color: selectedRecipeId === r.id ? '#fff' : colors.text }}>{r.name}</Text>
</Pressable>
))}
<Input
label={t('diet.portions')}
keyboardType="decimal-pad"
value={portions}
onChangeText={setPortions}
/>
<Text style={[styles.label, { color: colors.text }]}>{t('diet.fromCatalog')}</Text>
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.chips}>
<Pressable
onPress={() => setCatalogItemId(null)}
style={[
styles.chip,
{
backgroundColor: catalogItemId == null ? colors.brand : colors.surface,
borderColor: catalogItemId == null ? colors.brand : colors.border,
},
]}
>
<Text style={{ color: catalogItemId == null ? '#fff' : colors.text, fontSize: 12 }}>
{t('diet.noCatalogItem')}
</Text>
</Pressable>
{ingredientItems.map((item) => (
<Pressable
key={item.id}
onPress={() => {
setCatalogItemId(item.id);
setSelectedRecipeId(null);
setCustomMealName('');
}}
style={[
styles.chip,
{
backgroundColor: catalogItemId === item.id ? colors.brand : colors.surface,
borderColor: catalogItemId === item.id ? colors.brand : colors.border,
},
]}
>
<Text style={{ color: catalogItemId === item.id ? '#fff' : colors.text, fontSize: 12 }}>
{ingredientDisplayName(item, i18n.language)}
</Text>
</Pressable>
))}
</ScrollView>
{catalogItemId ? (
<Input
label={t('diet.grams')}
keyboardType="numeric"
value={grams}
onChangeText={setGrams}
/>
) : null}
<Input
label={t('diet.orCustomMeal')}
value={customMealName}
onChangeText={(v) => {
setCustomMealName(v);
setSelectedRecipeId(null);
setCatalogItemId(null);
}}
/>
{mealPreview ? (
<View style={[styles.previewBox, { borderColor: colors.brand, backgroundColor: colors.surface }]}>
<Text style={{ color: colors.text, fontWeight: '700' }}>
{t('diet.mealPreview')}: {mealPreview.meal.name}
</Text>
<Text style={{ color: colors.text, marginTop: 6, fontSize: 13 }}>
{mealPreview.meal.calories ?? '?'} kcal · P {Number(mealPreview.meal.proteinG ?? 0).toFixed(1)}g · F{' '}
{Number(mealPreview.meal.fatG ?? 0).toFixed(1)}g · C {Number(mealPreview.meal.carbsG ?? 0).toFixed(1)}g
</Text>
<Text style={{ color: colors.textMuted, marginTop: 6, fontSize: 13 }}>
{t('diet.remainingAfter')}: {mealPreview.remainingAfter.calories ?? '?'} kcal, P{' '}
{mealPreview.remainingAfter.proteinG ?? '?'}g, F {mealPreview.remainingAfter.fatG ?? '?'}g, C{' '}
{mealPreview.remainingAfter.carbsG ?? '?'}g
</Text>
</View>
) : null}
<Button
title={t('diet.markEaten')}
loading={logMealMutation.isPending}
disabled={!canLogMeal}
onPress={() => {
const payload = buildMealPayload();
if (payload) logMealMutation.mutate(payload);
}}
/>
<Text style={[styles.section, { color: colors.text }]}>{t('diet.mealsToday')}</Text>
{summary.meals.length > 0 ? (
<View style={styles.sortRow}>
<SortChip
label={`${t('ingredientCatalog.columns.name')}${sortIndicator(mealSort, 'name')}`}
active={mealSort.column === 'name'}
onPress={() => setMealSort((current) => nextSort(current, 'name'))}
/>
<SortChip
label={`${t('recipes.calories')}${sortIndicator(mealSort, 'calories')}`}
active={mealSort.column === 'calories'}
onPress={() => setMealSort((current) => nextSort(current, 'calories'))}
/>
</View>
) : null}
{summary.meals.length === 0 ? (
<Text style={{ color: colors.textMuted }}>{t('diet.nothingLogged')}</Text>
) : (
sortedMeals.map((m) => (
<View key={m.id} style={[styles.logRow, { borderColor: colors.border }]}>
<View style={{ flex: 1 }}>
<Text style={{ color: colors.text, fontWeight: '600' }}>{m.name}</Text>
<Text style={{ color: colors.textMuted, fontSize: 12 }}>
{mealMacroLine(m) || t('diet.noMacros')}
</Text>
</View>
<Button title="✕" variant="ghost" onPress={() => deleteMealMutation.mutate(m.id)} />
</View>
))
)}
<Text style={[styles.section, { color: colors.text }]}>{t('diet.drinksToday')}</Text>
{summary.drinks.length > 0 ? (
<View style={styles.sortRow}>
<SortChip
label={`${t('ingredientCatalog.columns.name')}${sortIndicator(drinkSort, 'name')}`}
active={drinkSort.column === 'name'}
onPress={() => setDrinkSort((current) => nextSort(current, 'name'))}
/>
<SortChip
label={`${t('diet.water')}${sortIndicator(drinkSort, 'volume')}`}
active={drinkSort.column === 'volume'}
onPress={() => setDrinkSort((current) => nextSort(current, 'volume'))}
/>
</View>
) : null}
{summary.drinks.length === 0 ? (
<Text style={{ color: colors.textMuted }}>{t('diet.nothingLogged')}</Text>
) : (
sortedDrinks.map((d) => (
<View key={d.id} style={[styles.logRow, { borderColor: colors.border }]}>
<View style={{ flex: 1 }}>
<Text style={{ color: colors.text, fontWeight: '600' }}>{d.name}</Text>
<Text style={{ color: colors.textMuted, fontSize: 12 }}>{d.volumeMl} ml</Text>
</View>
<Button title="✕" variant="ghost" onPress={() => deleteDrinkMutation.mutate(d.id)} />
</View>
))
)}
</>
) : null}
</>
)}
{tab === 'goals' && (
<>
<Text style={{ color: colors.textMuted, marginBottom: 8 }}>{t('diet.goalsHint')}</Text>
{(
[
['calorieGoal', 'diet.calorieGoal'],
['proteinGoalG', 'manage.proteinG'],
['fatGoalG', 'manage.fatG'],
['carbsGoalG', 'manage.carbsG'],
['waterGoalMl', 'diet.waterGoal'],
] as const
).map(([key, labelKey]) => (
<Input
key={key}
label={t(labelKey)}
keyboardType="numeric"
value={goalsForm[key]?.toString() ?? ''}
onChangeText={(v) =>
setGoalsForm((prev) => ({
...prev,
[key]: v === '' ? null : Number(v),
}))
}
/>
))}
<Button
title={t('diet.saveGoals')}
loading={saveGoalsMutation.isPending}
onPress={() => saveGoalsMutation.mutate(goalsForm)}
/>
</>
)}
{tab === 'history' && (
<>
{historyQuery.isLoading ? (
<Text style={{ color: colors.textMuted }}>{t('common.loading')}</Text>
) : historyQuery.isError ? (
<ErrorView message={t('errors.loadHistoryFailed')} onRetry={() => historyQuery.refetch()} />
) : (
<>
<Text style={{ color: colors.textMuted, fontSize: 13 }}>{t('diet.historyHint')}</Text>
<SimpleBarChart
unit=" kcal"
data={(historyQuery.data ?? []).map((day) => ({
label: day.date.slice(5),
value: day.calories,
}))}
/>
{(historyQuery.data ?? []).map((day) => (
<View key={day.date} style={[styles.logRow, { borderColor: colors.border }]}>
<View style={{ flex: 1 }}>
<Text style={{ color: colors.text, fontWeight: '600' }}>{day.date}</Text>
<Text style={{ color: colors.textMuted, fontSize: 12 }}>
{day.calories} kcal · P {Number(day.proteinG).toFixed(0)}g · {day.mealCount} {t('diet.mealsShort')}
</Text>
</View>
</View>
))}
</>
)}
</>
)}
{tab === 'reminders' && (
<>
<Input
label={t('diet.reminderTitle')}
value={reminderTitle}
onChangeText={setReminderTitle}
placeholder={t('diet.reminderTitlePlaceholder')}
/>
<Input label={t('diet.reminderTime')} value={reminderTime} onChangeText={setReminderTime} />
<Button
title={editingReminderId ? t('diet.updateReminder') : t('diet.saveReminder')}
loading={createReminderMutation.isPending || updateReminderMutation.isPending}
disabled={!reminderTitle.trim()}
onPress={() => {
const payload = {
reminderType,
title: reminderTitle.trim(),
timeOfDay: reminderTime,
daysOfWeekMask: REMINDER_DAYS_MASK_ALL,
};
if (editingReminderId) {
updateReminderMutation.mutate({ id: editingReminderId, input: payload });
} else {
createReminderMutation.mutate(payload);
}
}}
/>
{editingReminderId ? (
<Button
title={t('common.cancel')}
variant="ghost"
onPress={() => {
setEditingReminderId(null);
setReminderTitle('');
setReminderTime('09:00');
}}
/>
) : null}
<Text style={[styles.section, { color: colors.text }]}>{t('diet.yourReminders')}</Text>
{(remindersQuery.data?.length ?? 0) > 0 ? (
<View style={styles.sortRow}>
<SortChip
label={`${t('diet.reminderTitle')}${sortIndicator(reminderSort, 'title')}`}
active={reminderSort.column === 'title'}
onPress={() => setReminderSort((current) => nextSort(current, 'title'))}
/>
<SortChip
label={`${t('diet.reminderTime')}${sortIndicator(reminderSort, 'time')}`}
active={reminderSort.column === 'time'}
onPress={() => setReminderSort((current) => nextSort(current, 'time'))}
/>
</View>
) : null}
{remindersQuery.data?.length === 0 ? (
<Text style={{ color: colors.textMuted }}>{t('diet.noReminders')}</Text>
) : (
sortedReminders.map((r) => (
<View key={r.id} style={[styles.logRow, { borderColor: colors.border }]}>
<Pressable
style={{ flex: 1 }}
onPress={() => {
setEditingReminderId(r.id);
setReminderTitle(r.title);
setReminderTime(r.timeOfDay.slice(0, 5));
setReminderType(r.reminderType);
}}
>
<Text style={{ color: colors.text, fontWeight: '600' }}>{r.title}</Text>
<Text style={{ color: colors.textMuted, fontSize: 12 }}>{r.timeOfDay.slice(0, 5)}</Text>
</Pressable>
<Button title={t('common.edit')} variant="ghost" onPress={() => {
setEditingReminderId(r.id);
setReminderTitle(r.title);
setReminderTime(r.timeOfDay.slice(0, 5));
setReminderType(r.reminderType);
}} />
<Button title="✕" variant="ghost" onPress={() => deleteReminderMutation.mutate(r.id)} />
</View>
))
)}
</>
)}
</ScrollView>
</Screen>
);
}
function SortChip({ label, active, onPress }: { label: string; active: boolean; onPress: () => void }) {
const { colors } = useAppearance();
return (
<Pressable
onPress={onPress}
style={[
styles.sortChip,
{
backgroundColor: active ? colors.brand : colors.surface,
borderColor: active ? colors.brand : colors.border,
},
]}
>
<Text style={{ color: active ? '#fff' : colors.text, fontWeight: '600', fontSize: 12 }}>{label}</Text>
</Pressable>
);
}
const styles = StyleSheet.create({
scroll: { gap: 12, paddingBottom: 32 },
tabs: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
tab: { borderWidth: 1, borderRadius: 999, paddingHorizontal: 14, paddingVertical: 8 },
section: { fontSize: 17, fontWeight: '700', marginTop: 8 },
label: { fontSize: 14, fontWeight: '600' },
chips: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
chip: { borderWidth: 1, borderRadius: 12, paddingHorizontal: 10, paddingVertical: 8 },
recipePick: { borderWidth: 1, borderRadius: 10, padding: 10 },
suggestionRow: {
flexDirection: 'row',
alignItems: 'center',
borderWidth: 1,
borderRadius: 12,
padding: 10,
gap: 8,
},
previewBox: { borderWidth: 1, borderRadius: 12, padding: 12 },
sortRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
sortChip: { borderWidth: 1, borderRadius: 999, paddingHorizontal: 12, paddingVertical: 6 },
logRow: {
flexDirection: 'row',
alignItems: 'center',
borderWidth: 1,
borderRadius: 12,
padding: 10,
gap: 8,
},
progressCard: { borderWidth: 1, borderRadius: 14, padding: 14, gap: 6 },
progressLabel: { fontSize: 13, fontWeight: '600' },
progressValue: { fontSize: 22, fontWeight: '700' },
progressTrack: { height: 8, borderRadius: 999, overflow: 'hidden', marginTop: 4 },
progressFill: { height: '100%', borderRadius: 999 },
});

View File

@@ -0,0 +1,198 @@
import { router } from 'expo-router';
import { ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { fetchCategories } from '@/src/api/recipes.api';
import { ErrorView } from '@/src/components/ErrorView';
import { Screen } from '@/src/components/Screen';
import { useAuth } from '@/src/context/AuthContext';
import { useAppearance } from '@/src/context/AppearanceContext';
import { MealCategory } from '@/src/types/recipe';
import { categoryLabel } from '@/src/utils/recipe';
const CATEGORY_EMOJI: Record<MealCategory, string> = {
[MealCategory.Breakfast]: '🍳',
[MealCategory.SecondBreakfast]: '🥐',
[MealCategory.Lunch]: '🍲',
[MealCategory.Dinner]: '🌙',
};
export default function DashboardScreen() {
const { t } = useTranslation();
const { user } = useAuth();
const { colors } = useAppearance();
const categoriesQuery = useQuery({
queryKey: ['categories'],
queryFn: fetchCategories,
});
const welcome = user?.userName
? t('dashboard.welcomeNamed', { name: user.userName })
: t('dashboard.welcome');
return (
<Screen title={welcome} subtitle={t('dashboard.subtitle')}>
{categoriesQuery.isLoading ? (
<View style={styles.loading}>
<ActivityIndicator size="large" color={colors.brand} />
</View>
) : categoriesQuery.isError ? (
<ErrorView
message={t('errors.loadCategoriesFailed')}
onRetry={() => categoriesQuery.refetch()}
/>
) : (
<ScrollView contentContainerStyle={styles.list}>
<Pressable
onPress={() => router.push('/(main)/(tabs)/meals')}
style={({ pressed }) => [
styles.allCard,
{
backgroundColor: colors.brand,
opacity: pressed ? 0.9 : 1,
},
]}
>
<Text style={styles.allTitle}>{t('nav.allMeals')}</Text>
<Text style={styles.allSubtitle}>{t('dashboard.viewRecipes')}</Text>
</Pressable>
<Pressable
onPress={() => router.push('/(main)/ingredients')}
style={({ pressed }) => [
styles.card,
{
backgroundColor: colors.surface,
borderColor: colors.border,
opacity: pressed ? 0.9 : 1,
},
]}
>
<Text style={styles.emoji}>🥬</Text>
<View style={styles.cardBody}>
<Text style={[styles.cardTitle, { color: colors.text }]}>{t('nav.ingredientCatalog')}</Text>
<Text style={[styles.cardCount, { color: colors.textMuted }]}>
{t('ingredientCatalog.subtitle')}
</Text>
</View>
</Pressable>
<Pressable
onPress={() => router.push('/(main)/diet-generator')}
style={({ pressed }) => [
styles.card,
{
backgroundColor: colors.surface,
borderColor: colors.border,
opacity: pressed ? 0.9 : 1,
},
]}
>
<Text style={styles.emoji}></Text>
<View style={styles.cardBody}>
<Text style={[styles.cardTitle, { color: colors.text }]}>{t('nav.dietGenerator')}</Text>
<Text style={[styles.cardCount, { color: colors.textMuted }]}>
{t('generators.diet.subtitle')}
</Text>
</View>
</Pressable>
<Pressable
onPress={() => router.push('/(main)/recipe-generator')}
style={({ pressed }) => [
styles.card,
{
backgroundColor: colors.surface,
borderColor: colors.border,
opacity: pressed ? 0.9 : 1,
},
]}
>
<Text style={styles.emoji}>🤖</Text>
<View style={styles.cardBody}>
<Text style={[styles.cardTitle, { color: colors.text }]}>{t('nav.recipeGenerator')}</Text>
<Text style={[styles.cardCount, { color: colors.textMuted }]}>
{t('generators.recipe.subtitle')}
</Text>
</View>
</Pressable>
{(categoriesQuery.data ?? []).map((item) => (
<Pressable
key={item.category}
onPress={() => router.push(`/(main)/category/${item.category}`)}
style={({ pressed }) => [
styles.card,
{
backgroundColor: colors.surface,
borderColor: colors.border,
opacity: pressed ? 0.9 : 1,
},
]}
>
<Text style={styles.emoji}>{CATEGORY_EMOJI[item.category as MealCategory] ?? '🍽️'}</Text>
<View style={styles.cardBody}>
<Text style={[styles.cardTitle, { color: colors.text }]}>
{categoryLabel(item.category, t)}
</Text>
<Text style={[styles.cardCount, { color: colors.textMuted }]}>
{t('common.recipeCount', { count: item.count })}
</Text>
</View>
</Pressable>
))}
</ScrollView>
)}
</Screen>
);
}
const styles = StyleSheet.create({
list: {
gap: 12,
paddingBottom: 24,
},
allCard: {
borderRadius: 16,
padding: 18,
gap: 4,
},
allTitle: {
color: '#fff',
fontSize: 20,
fontWeight: '700',
},
allSubtitle: {
color: 'rgba(255,255,255,0.9)',
fontSize: 14,
},
card: {
borderWidth: 1,
borderRadius: 16,
padding: 16,
flexDirection: 'row',
alignItems: 'center',
gap: 14,
},
emoji: {
fontSize: 28,
},
cardBody: {
flex: 1,
gap: 2,
},
cardTitle: {
fontSize: 18,
fontWeight: '600',
},
cardCount: {
fontSize: 14,
},
loading: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 40,
},
});

View File

@@ -0,0 +1,362 @@
import { useState } from 'react';
import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { fetchIngredientCatalogItems } from '@/src/api/ingredient-catalog.api';
import { createRecipe } from '@/src/api/recipes.api';
import { Button } from '@/src/components/Button';
import { ExcelImportPanel } from '@/src/components/ExcelImportPanel';
import { Input } from '@/src/components/Input';
import { Screen } from '@/src/components/Screen';
import { useAppearance } from '@/src/context/AppearanceContext';
import { MealCategory } from '@/src/types/recipe';
import { ALL_CATEGORIES, categoryLabel, parseOptionalNumber } from '@/src/utils/recipe';
import { extractApiError } from '@/src/utils/apiError';
import { ingredientDisplayName } from '@/src/utils/ingredient-catalog.util';
type ManageTab = 'manual' | 'import';
interface IngredientRow {
name: string;
amountGrams: string;
unit: string;
catalogItemId: number | null;
}
interface StepRow {
description: string;
}
export default function ManageScreen() {
const { t, i18n } = useTranslation();
const { colors } = useAppearance();
const queryClient = useQueryClient();
const [manageTab, setManageTab] = useState<ManageTab>('manual');
const catalogQuery = useQuery({
queryKey: ['ingredient-catalog', 'all'],
queryFn: () => fetchIngredientCatalogItems(),
});
const [name, setName] = useState('');
const [category, setCategory] = useState<MealCategory>(MealCategory.Lunch);
const [prepTime, setPrepTime] = useState('');
const [calories, setCalories] = useState('');
const [protein, setProtein] = useState('');
const [fat, setFat] = useState('');
const [carbs, setCarbs] = useState('');
const [ingredients, setIngredients] = useState<IngredientRow[]>([
{ name: '', amountGrams: '', unit: '', catalogItemId: null },
]);
const [steps, setSteps] = useState<StepRow[]>([{ description: '' }]);
const [error, setError] = useState<string | null>(null);
const saveMutation = useMutation({
mutationFn: createRecipe,
onSuccess: (recipe) => {
queryClient.invalidateQueries({ queryKey: ['recipes'] });
queryClient.invalidateQueries({ queryKey: ['categories'] });
Alert.alert(t('manage.title'), t('manage.savedSuccess', { name: recipe.name }));
setName('');
setPrepTime('');
setCalories('');
setProtein('');
setFat('');
setCarbs('');
setIngredients([{ name: '', amountGrams: '', unit: '', catalogItemId: null }]);
setSteps([{ description: '' }]);
setError(null);
},
onError: (err) => {
setError(extractApiError(err, t('errors.saveRecipeFailed')));
},
});
function validate(): string | null {
if (!name.trim()) return t('validation.recipeNameRequired');
const validSteps = steps.filter((s) => s.description.trim());
if (validSteps.length === 0) return t('validation.stepsMin');
return null;
}
function handleSave() {
const validationError = validate();
if (validationError) {
setError(validationError);
return;
}
const filteredIngredients = ingredients
.filter((i) => i.name.trim())
.map((i, index) => ({
name: i.name.trim(),
amountGrams: parseOptionalNumber(i.amountGrams),
unit: i.unit,
catalogItemId: i.catalogItemId,
sortOrder: index,
}));
const filteredSteps = steps
.filter((s) => s.description.trim())
.map((s, index) => ({
stepNumber: index + 1,
description: s.description.trim(),
}));
saveMutation.mutate({
name: name.trim(),
mealCategory: category,
calories: parseOptionalNumber(calories),
protein: parseOptionalNumber(protein),
fat: parseOptionalNumber(fat),
carbs: parseOptionalNumber(carbs),
prepTimeMinutes: parseOptionalNumber(prepTime),
ingredients: filteredIngredients,
steps: filteredSteps,
});
}
const catalogItems = catalogQuery.data ?? [];
return (
<Screen title={t('manage.title')} subtitle={t('manage.subtitle')}>
<View style={styles.tabs}>
{(['manual', 'import'] as ManageTab[]).map((tab) => (
<Pressable
key={tab}
onPress={() => setManageTab(tab)}
style={[styles.tab, { backgroundColor: manageTab === tab ? colors.brand : colors.surface, borderColor: manageTab === tab ? colors.brand : colors.border }]}
>
<Text style={{ color: manageTab === tab ? '#fff' : colors.text, fontWeight: '600', fontSize: 13 }}>
{t(tab === 'manual' ? 'manage.tabManual' : 'manage.tabImport')}
</Text>
</Pressable>
))}
</View>
{manageTab === 'import' ? (
<ExcelImportPanel onImported={() => queryClient.invalidateQueries({ queryKey: ['recipes'] })} />
) : (
<ScrollView contentContainerStyle={styles.scroll} keyboardShouldPersistTaps="handled">
<Text style={[styles.section, { color: colors.text }]}>{t('manage.basicInfo')}</Text>
<Input
label={t('manage.recipeName')}
placeholder={t('manage.recipeNamePlaceholder')}
value={name}
onChangeText={setName}
/>
<Text style={[styles.label, { color: colors.text }]}>{t('common.category')}</Text>
<View style={styles.chips}>
{ALL_CATEGORIES.map((cat) => {
const selected = category === cat;
return (
<Pressable
key={cat}
onPress={() => setCategory(cat)}
style={[
styles.chip,
{
backgroundColor: selected ? colors.brand : colors.surface,
borderColor: selected ? colors.brand : colors.border,
},
]}
>
<Text style={{ color: selected ? '#fff' : colors.text, fontSize: 13 }}>
{categoryLabel(cat, t)}
</Text>
</Pressable>
);
})}
</View>
<View style={styles.row}>
<View style={styles.half}>
<Input
label={t('manage.prepTimeMin')}
keyboardType="numeric"
value={prepTime}
onChangeText={setPrepTime}
/>
</View>
<View style={styles.half}>
<Input
label={t('manage.caloriesKcal')}
keyboardType="numeric"
value={calories}
onChangeText={setCalories}
/>
</View>
</View>
<View style={styles.row}>
<View style={styles.third}>
<Input label={t('manage.proteinG')} keyboardType="numeric" value={protein} onChangeText={setProtein} />
</View>
<View style={styles.third}>
<Input label={t('manage.fatG')} keyboardType="numeric" value={fat} onChangeText={setFat} />
</View>
<View style={styles.third}>
<Input label={t('manage.carbsG')} keyboardType="numeric" value={carbs} onChangeText={setCarbs} />
</View>
</View>
<Text style={[styles.section, { color: colors.text }]}>{t('recipes.ingredients')}</Text>
{ingredients.map((ing, index) => (
<View key={index} style={styles.block}>
<Input
label={t('manage.ingredientName')}
value={ing.name}
onChangeText={(value) => {
const next = [...ingredients];
next[index] = { ...next[index], name: value };
setIngredients(next);
}}
/>
<Text style={[styles.label, { color: colors.text }]}>{t('manage.catalogItem')}</Text>
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.chips}>
<Pressable
onPress={() => {
const next = [...ingredients];
next[index] = { ...next[index], catalogItemId: null };
setIngredients(next);
}}
style={[styles.chip, { backgroundColor: ing.catalogItemId == null ? colors.brand : colors.surface, borderColor: ing.catalogItemId == null ? colors.brand : colors.border }]}
>
<Text style={{ color: ing.catalogItemId == null ? '#fff' : colors.text, fontSize: 12 }}>{t('diet.noCatalogItem')}</Text>
</Pressable>
{catalogItems.map((item) => (
<Pressable
key={item.id}
onPress={() => {
const next = [...ingredients];
next[index] = { ...next[index], catalogItemId: item.id, name: ingredientDisplayName(item, i18n.language) };
setIngredients(next);
}}
style={[styles.chip, { backgroundColor: ing.catalogItemId === item.id ? colors.brand : colors.surface, borderColor: ing.catalogItemId === item.id ? colors.brand : colors.border }]}
>
<Text style={{ color: ing.catalogItemId === item.id ? '#fff' : colors.text, fontSize: 12 }}>{ingredientDisplayName(item, i18n.language)}</Text>
</Pressable>
))}
</ScrollView>
<View style={styles.row}>
<View style={styles.half}>
<Input
label={t('manage.ingredientGrams')}
keyboardType="numeric"
value={ing.amountGrams}
onChangeText={(value) => {
const next = [...ingredients];
next[index] = { ...next[index], amountGrams: value };
setIngredients(next);
}}
/>
</View>
<View style={styles.half}>
<Input
label={t('manage.ingredientUnit')}
value={ing.unit}
onChangeText={(value) => {
const next = [...ingredients];
next[index] = { ...next[index], unit: value };
setIngredients(next);
}}
/>
</View>
</View>
{ingredients.length > 1 ? (
<Button
title={t('manage.removeIngredient')}
variant="ghost"
onPress={() => setIngredients(ingredients.filter((_, i) => i !== index))}
/>
) : null}
</View>
))}
<Button
title={t('common.add')}
variant="secondary"
onPress={() => setIngredients([...ingredients, { name: '', amountGrams: '', unit: '', catalogItemId: null }])}
/>
<Text style={[styles.section, { color: colors.text }]}>{t('manage.preparationSteps')}</Text>
{steps.map((step, index) => (
<View key={index} style={styles.block}>
<Input
label={`${index + 1}.`}
placeholder={t('manage.stepPlaceholder')}
value={step.description}
onChangeText={(value) => {
const next = [...steps];
next[index] = { description: value };
setSteps(next);
}}
multiline
/>
{steps.length > 1 ? (
<Button
title={t('manage.removeStep')}
variant="ghost"
onPress={() => setSteps(steps.filter((_, i) => i !== index))}
/>
) : null}
</View>
))}
<Button title={t('manage.addStep')} variant="secondary" onPress={() => setSteps([...steps, { description: '' }])} />
{error ? <Text style={[styles.error, { color: colors.danger }]}>{error}</Text> : null}
<Button
title={saveMutation.isPending ? t('manage.saving') : t('manage.saveRecipe')}
loading={saveMutation.isPending}
onPress={handleSave}
/>
</ScrollView>
)}
</Screen>
);
}
const styles = StyleSheet.create({
scroll: {
gap: 12,
paddingBottom: 32,
},
tabs: { flexDirection: 'row', gap: 8, marginBottom: 8 },
tab: { borderWidth: 1, borderRadius: 999, paddingHorizontal: 14, paddingVertical: 8 },
section: {
fontSize: 18,
fontWeight: '700',
marginTop: 4,
},
label: {
fontSize: 14,
fontWeight: '600',
},
chips: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
},
chip: {
borderWidth: 1,
borderRadius: 999,
paddingHorizontal: 12,
paddingVertical: 8,
},
row: {
flexDirection: 'row',
gap: 10,
},
half: {
flex: 1,
},
third: {
flex: 1,
},
block: {
gap: 8,
},
error: {
fontSize: 14,
},
});

View File

@@ -0,0 +1,163 @@
import { router } from 'expo-router';
import { useMemo, useState } from 'react';
import { ActivityIndicator, FlatList, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { fetchRecipes } from '@/src/api/recipes.api';
import { EmptyState, RecipeCard } from '@/src/components/RecipeCard';
import { ErrorView } from '@/src/components/ErrorView';
import { Input } from '@/src/components/Input';
import { Screen } from '@/src/components/Screen';
import { useAppearance } from '@/src/context/AppearanceContext';
import { nextSort, sortBy, sortIndicator, type SortDirection } from '@/src/utils/sort';
type RecipeSortColumn = 'name' | 'calories' | 'prepTime';
const RECIPE_SORT_COLUMNS: { id: RecipeSortColumn; labelKey: string }[] = [
{ id: 'name', labelKey: 'manage.recipeName' },
{ id: 'calories', labelKey: 'recipes.calories' },
{ id: 'prepTime', labelKey: 'manage.prepTimeMin' },
];
export default function MealsScreen() {
const { t } = useTranslation();
const { colors } = useAppearance();
const [search, setSearch] = useState('');
const [ingredient, setIngredient] = useState('');
const [recipeSort, setRecipeSort] = useState<{ column: RecipeSortColumn; direction: SortDirection }>({
column: 'name',
direction: 'asc',
});
const recipesQuery = useQuery({
queryKey: ['recipes', search, ingredient],
queryFn: () =>
fetchRecipes({
search: search.trim() || undefined,
ingredient: ingredient.trim() || undefined,
}),
});
const emptyMessage = useMemo(() => {
if (ingredient.trim()) return t('recipes.noMatchingIngredient');
return t('recipes.noMatchingFilter');
}, [ingredient, t]);
const sortedRecipes = useMemo(() => {
const list = recipesQuery.data ?? [];
const { column, direction } = recipeSort;
return sortBy(list, direction, (recipe) => {
switch (column) {
case 'name':
return recipe.name;
case 'calories':
return recipe.calories;
case 'prepTime':
return recipe.prepTimeMinutes;
}
});
}, [recipesQuery.data, recipeSort]);
return (
<Screen title={t('recipes.allMealsTitle')} subtitle={t('recipes.allMealsSubtitle')}>
<View style={styles.filters}>
<Input
placeholder={t('recipes.searchRecipes')}
value={search}
onChangeText={setSearch}
autoCapitalize="none"
/>
<Input
placeholder={t('recipes.searchByIngredient')}
value={ingredient}
onChangeText={setIngredient}
autoCapitalize="none"
/>
</View>
{(recipesQuery.data?.length ?? 0) > 0 ? (
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.sortRow}>
{RECIPE_SORT_COLUMNS.map(({ id, labelKey }) => (
<SortChip
key={id}
label={`${t(labelKey)}${sortIndicator(recipeSort, id)}`}
active={recipeSort.column === id}
onPress={() => setRecipeSort((current) => nextSort(current, id))}
/>
))}
</ScrollView>
) : null}
{recipesQuery.isLoading ? (
<View style={styles.loading}>
<ActivityIndicator size="large" color={colors.brand} />
</View>
) : recipesQuery.isError ? (
<ErrorView
message={t('errors.loadRecipesFailed')}
onRetry={() => recipesQuery.refetch()}
/>
) : (
<FlatList
data={sortedRecipes}
keyExtractor={(item) => String(item.id)}
contentContainerStyle={styles.list}
ListEmptyComponent={
<EmptyState title={t('recipes.noMatchingTitle')} description={emptyMessage} />
}
renderItem={({ item }) => (
<RecipeCard
recipe={item}
onPress={() => router.push(`/(main)/recipe/${item.id}`)}
/>
)}
/>
)}
</Screen>
);
}
function SortChip({ label, active, onPress }: { label: string; active: boolean; onPress: () => void }) {
const { colors } = useAppearance();
return (
<Pressable
onPress={onPress}
style={[
styles.sortChip,
{
backgroundColor: active ? colors.brand : colors.surface,
borderColor: active ? colors.brand : colors.border,
},
]}
>
<Text style={{ color: active ? '#fff' : colors.text, fontWeight: '600', fontSize: 12 }}>{label}</Text>
</Pressable>
);
}
const styles = StyleSheet.create({
filters: {
gap: 10,
},
sortRow: {
gap: 8,
paddingBottom: 8,
},
sortChip: {
borderWidth: 1,
borderRadius: 999,
paddingHorizontal: 12,
paddingVertical: 6,
},
list: {
gap: 10,
paddingBottom: 24,
flexGrow: 1,
},
loading: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 40,
},
});

View File

@@ -0,0 +1,348 @@
import { useMemo, useState } from 'react';
import { Alert, Modal, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { router } from 'expo-router';
import { useTranslation } from 'react-i18next';
import { deleteMealPlanEntry, fetchMealPlanEntries, upsertMealPlanEntry } from '@/src/api/meal-plan.api';
import { fetchGoals } from '@/src/api/diet.api';
import { fetchRecipes } from '@/src/api/recipes.api';
import { Button } from '@/src/components/Button';
import { ErrorView } from '@/src/components/ErrorView';
import { Input } from '@/src/components/Input';
import { Screen } from '@/src/components/Screen';
import { useAppearance } from '@/src/context/AppearanceContext';
import { CONSUMPTION_CATEGORIES } from '@/src/types/diet';
import type { MealPlanEntry } from '@/src/types/meal-plan';
import { MealCategory } from '@/src/types/recipe';
import { addDays, formatDisplayDate, weekDays, weekRange } from '@/src/utils/date';
import { categoryLabel } from '@/src/utils/recipe';
import {
computeDayMealPlanning,
estimateEntryCalories,
rankRecipeSuggestions,
} from '@/src/utils/mealPlannerSuggestions';
const PLANNER_CATEGORIES = CONSUMPTION_CATEGORIES.filter((c) => c.value <= MealCategory.Dinner);
export default function PlannerScreen() {
const { t, i18n } = useTranslation();
const { colors } = useAppearance();
const queryClient = useQueryClient();
const [weekAnchor, setWeekAnchor] = useState(() => weekRange().from);
const { from, to } = useMemo(() => weekRange(weekAnchor), [weekAnchor]);
const days = useMemo(() => weekDays(from), [from]);
const [picker, setPicker] = useState<{ date: string; mealCategory: number; entry?: MealPlanEntry } | null>(null);
const [recipeSearch, setRecipeSearch] = useState('');
const [portions, setPortions] = useState('1');
const [dailyTargets, setDailyTargets] = useState<Record<string, number>>({});
const [selectedRecipeId, setSelectedRecipeId] = useState<number | null>(null);
const entriesQuery = useQuery({
queryKey: ['meal-plan', from, to],
queryFn: () => fetchMealPlanEntries(from, to),
});
const goalsQuery = useQuery({
queryKey: ['diet-goals'],
queryFn: fetchGoals,
});
const recipesQuery = useQuery({
queryKey: ['recipes', 'planner-search', recipeSearch],
queryFn: () => fetchRecipes({ search: recipeSearch }),
enabled: !!picker && recipeSearch.trim().length >= 2,
});
const suggestionsQuery = useQuery({
queryKey: ['recipes', 'planner-suggestions', picker?.mealCategory],
queryFn: () => fetchRecipes({ category: picker!.mealCategory as MealCategory }),
enabled: !!picker,
});
const entries = entriesQuery.data ?? [];
const dailyTarget = picker
? (dailyTargets[picker.date] ?? goalsQuery.data?.calorieGoal ?? 0)
: 0;
const selectedRecipe = useMemo(() => {
if (!selectedRecipeId) return null;
return (
recipesQuery.data?.find((r) => r.id === selectedRecipeId) ??
suggestionsQuery.data?.find((r) => r.id === selectedRecipeId) ??
null
);
}, [selectedRecipeId, recipesQuery.data, suggestionsQuery.data]);
const estimatedMealCalories = estimateEntryCalories(selectedRecipe, Number(portions) || 1);
const dayPlanning = useMemo(() => {
if (!picker) return null;
const pending =
estimatedMealCalories != null && estimatedMealCalories > 0
? { category: picker.mealCategory, calories: estimatedMealCalories }
: null;
return computeDayMealPlanning(picker.date, picker.mealCategory, entries, dailyTarget, pending);
}, [picker, entries, dailyTarget, estimatedMealCalories]);
const suggestions = useMemo(() => {
if (!picker || !dayPlanning || !suggestionsQuery.data) return [];
return rankRecipeSuggestions(suggestionsQuery.data, picker.mealCategory, dayPlanning.slotTarget);
}, [picker, dayPlanning, suggestionsQuery.data]);
const upsertMutation = useMutation({
mutationFn: upsertMealPlanEntry,
onSuccess: () => {
setPicker(null);
setRecipeSearch('');
setPortions('1');
setSelectedRecipeId(null);
void queryClient.invalidateQueries({ queryKey: ['meal-plan'] });
},
});
const deleteMutation = useMutation({
mutationFn: deleteMealPlanEntry,
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['meal-plan'] }),
});
function selectFromSearch(recipeId: number) {
setSelectedRecipeId(recipeId);
setRecipeSearch('');
}
function selectFromSuggestion(recipeId: number) {
setSelectedRecipeId(recipeId);
}
function clearSelection() {
setSelectedRecipeId(null);
}
function entryFor(date: string, mealCategory: number): MealPlanEntry | undefined {
return entriesQuery.data?.find((e) => e.planDate === date && e.mealCategory === mealCategory);
}
return (
<Screen title={t('planner.title')} subtitle={t('planner.subtitle')}>
<View style={styles.header}>
<Pressable
onPress={() => setWeekAnchor(addDays(from, -7))}
style={[styles.navBtn, { borderColor: colors.border, backgroundColor: colors.surface }]}
>
<Text style={{ color: colors.text }}></Text>
</Pressable>
<Text style={{ color: colors.text, fontWeight: '700', flex: 1, textAlign: 'center' }}>
{formatDisplayDate(from, i18n.language)} {formatDisplayDate(to, i18n.language)}
</Text>
<Pressable
onPress={() => setWeekAnchor(addDays(from, 7))}
style={[styles.navBtn, { borderColor: colors.border, backgroundColor: colors.surface }]}
>
<Text style={{ color: colors.text }}></Text>
</Pressable>
</View>
<Button title={t('planner.openShopping')} variant="secondary" onPress={() => router.push('/(main)/(tabs)/shopping')} />
{entriesQuery.isLoading ? (
<Text style={{ color: colors.textMuted }}>{t('common.loading')}</Text>
) : entriesQuery.isError ? (
<ErrorView message={t('errors.loadPlannerFailed')} onRetry={() => entriesQuery.refetch()} />
) : (
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View>
<View style={styles.row}>
<View style={styles.corner} />
{days.map((day) => (
<View key={day} style={[styles.dayHead, { borderColor: colors.border }]}>
<Text style={{ color: colors.textMuted, fontSize: 11, fontWeight: '600' }}>
{formatDisplayDate(day, i18n.language)}
</Text>
</View>
))}
</View>
{PLANNER_CATEGORIES.map((cat) => (
<View key={cat.value} style={styles.row}>
<View style={[styles.catLabel, { borderColor: colors.border, backgroundColor: colors.surface }]}>
<Text style={{ color: colors.text, fontSize: 11, fontWeight: '600' }}>{t(cat.labelKey)}</Text>
</View>
{days.map((day) => {
const entry = entryFor(day, cat.value);
return (
<Pressable
key={`${day}-${cat.value}`}
onPress={() => {
setPicker({ date: day, mealCategory: cat.value, entry });
setRecipeSearch('');
setPortions('1');
setSelectedRecipeId(null);
}}
onLongPress={() => {
if (!entry) return;
Alert.alert(t('planner.removeEntry'), entry.recipeName, [
{ text: t('common.cancel'), style: 'cancel' },
{ text: t('common.delete'), style: 'destructive', onPress: () => deleteMutation.mutate(entry.id) },
]);
}}
style={[styles.cell, { borderColor: colors.border, backgroundColor: colors.surface }]}
>
<Text style={{ color: entry ? colors.text : colors.textMuted, fontSize: 11 }} numberOfLines={2}>
{entry?.recipeName ?? '+'}
</Text>
</Pressable>
);
})}
</View>
))}
</View>
</ScrollView>
)}
<Modal visible={!!picker} transparent animationType="slide" onRequestClose={() => setPicker(null)}>
<View style={styles.modalBackdrop}>
<View style={[styles.modal, { backgroundColor: colors.background }]}>
<Text style={[styles.modalTitle, { color: colors.text }]}>{t('planner.assignRecipe')}</Text>
{picker ? (
<Text style={{ color: colors.textMuted, marginBottom: 8 }}>
{formatDisplayDate(picker.date, i18n.language)} · {categoryLabel(picker.mealCategory as MealCategory, t)}
</Text>
) : null}
<Input
label={t('planner.dailyTarget')}
keyboardType="number-pad"
value={String(dailyTarget || '')}
onChangeText={(text) => {
if (!picker) return;
const value = Number(text);
setDailyTargets((prev) => ({
...prev,
[picker.date]: Number.isFinite(value) ? value : 0,
}));
}}
/>
{dayPlanning && dailyTarget > 0 ? (
<View style={[styles.summaryBox, { backgroundColor: colors.surface, borderColor: colors.border }]}>
<Text style={{ color: colors.text, fontSize: 13 }}>
{t('planner.daySummary', { logged: dayPlanning.logged, target: dayPlanning.dailyTarget })}
</Text>
<Text style={{ color: colors.textMuted, fontSize: 11, marginTop: 4 }}>
{t('planner.remaining', { count: Math.max(0, dayPlanning.remaining) })}
{dayPlanning.slotTarget > 0
? ` · ${t('planner.slotHint', { count: dayPlanning.slotTarget })}`
: ''}
{dayPlanning.pendingCalories > 0
? ` · ${t('planner.includingSelection', { count: dayPlanning.pendingCalories })}`
: ''}
</Text>
</View>
) : (
<Text style={{ color: colors.textMuted, fontSize: 12 }}>{t('planner.noSuggestions')}</Text>
)}
{suggestions.length > 0 ? (
<View>
<Text style={{ color: colors.text, fontWeight: '600', marginBottom: 6 }}>
{t('planner.suggestionsTitle')}
</Text>
<ScrollView style={{ maxHeight: 160 }}>
{suggestions.map(({ recipe, diff }) => (
<Pressable
key={recipe.id}
onPress={() => selectFromSuggestion(recipe.id)}
style={[
styles.recipePick,
{
borderColor: selectedRecipeId === recipe.id ? colors.brand : colors.border,
borderWidth: selectedRecipeId === recipe.id ? 2 : 1,
backgroundColor: colors.surface,
},
]}
>
<Text style={{ color: colors.text }}>{recipe.name}</Text>
<Text style={{ color: colors.textMuted, fontSize: 11 }}>
{recipe.calories} kcal{diff > 0 ? ` · ±${diff}` : ''}
</Text>
</Pressable>
))}
</ScrollView>
</View>
) : null}
<Input placeholder={t('recipes.searchRecipes')} value={recipeSearch} onChangeText={setRecipeSearch} />
{recipeSearch.trim().length >= 2 && !selectedRecipeId ? (
<ScrollView style={{ maxHeight: 200 }}>
{recipesQuery.data?.map((r) => (
<Pressable
key={r.id}
onPress={() => selectFromSearch(r.id)}
style={[styles.recipePick, { borderColor: colors.border, backgroundColor: colors.surface }]}
>
<Text style={{ color: colors.text }}>{r.name}</Text>
{r.calories != null ? (
<Text style={{ color: colors.textMuted, fontSize: 11 }}>{r.calories} kcal</Text>
) : null}
</Pressable>
))}
{recipesQuery.isFetched && !recipesQuery.isLoading && recipesQuery.data?.length === 0 ? (
<Text style={{ color: colors.textMuted, fontSize: 12 }}>{t('recipes.noMatchingTitle')}</Text>
) : null}
</ScrollView>
) : null}
{selectedRecipe ? (
<View style={[styles.summaryBox, { backgroundColor: colors.surface, borderColor: colors.brand }]}>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', gap: 8 }}>
<Text style={{ color: colors.text, fontSize: 13, flex: 1 }}>
{t('planner.selectedRecipe', { name: selectedRecipe.name })}
{estimatedMealCalories != null
? ` · ${estimatedMealCalories} kcal`
: selectedRecipe.calories != null
? ` · ${selectedRecipe.calories} kcal`
: ''}
</Text>
<Pressable onPress={clearSelection} hitSlop={8}>
<Text style={{ color: colors.textMuted }}></Text>
</Pressable>
</View>
</View>
) : null}
<Input label={t('diet.portions')} keyboardType="decimal-pad" value={portions} onChangeText={setPortions} />
{estimatedMealCalories != null ? (
<Text style={{ color: colors.textMuted, fontSize: 12 }}>
{t('planner.estimatedMeal', { count: estimatedMealCalories })}
</Text>
) : null}
<Button
title={t('common.save')}
disabled={!selectedRecipeId || upsertMutation.isPending}
onPress={() => {
if (!picker || !selectedRecipeId) return;
upsertMutation.mutate({
planDate: picker.date,
mealCategory: picker.mealCategory,
recipeId: selectedRecipeId,
portions: Number(portions) || 1,
});
}}
/>
<Button title={t('common.cancel')} variant="ghost" onPress={() => setPicker(null)} />
</View>
</View>
</Modal>
</Screen>
);
}
const styles = StyleSheet.create({
header: { flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 8 },
navBtn: { borderWidth: 1, borderRadius: 8, width: 36, height: 36, alignItems: 'center', justifyContent: 'center' },
row: { flexDirection: 'row' },
corner: { width: 72 },
dayHead: { width: 88, padding: 6, borderWidth: 1, alignItems: 'center' },
catLabel: { width: 72, padding: 6, borderWidth: 1, justifyContent: 'center' },
cell: { width: 88, height: 56, padding: 6, borderWidth: 1, justifyContent: 'center' },
modalBackdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.45)', justifyContent: 'flex-end' },
modal: { borderTopLeftRadius: 16, borderTopRightRadius: 16, padding: 16, gap: 10, maxHeight: '80%' },
modalTitle: { fontSize: 18, fontWeight: '700' },
summaryBox: { borderWidth: 1, borderRadius: 10, padding: 10 },
recipePick: { borderWidth: 1, borderRadius: 8, padding: 10, marginBottom: 6 },
});

View File

@@ -0,0 +1,154 @@
import { router } from 'expo-router';
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { useTranslation } from 'react-i18next';
import { Button } from '@/src/components/Button';
import { Screen } from '@/src/components/Screen';
import { useAuth } from '@/src/context/AuthContext';
import { useAppearance } from '@/src/context/AppearanceContext';
import { APPEARANCE_OPTIONS } from '@/src/theme/appearance';
import { setAppLanguage } from '@/src/i18n';
export default function SettingsScreen() {
const { t, i18n } = useTranslation();
const { user, logout } = useAuth();
const { colors, appearanceId, setAppearance } = useAppearance();
return (
<Screen title={t('settings.title')}>
<ScrollView contentContainerStyle={styles.scroll}>
<Text style={[styles.section, { color: colors.text }]}>{t('settings.account')}</Text>
<View style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.border }]}>
<Text style={[styles.label, { color: colors.textMuted }]}>{t('auth.email')}</Text>
<Text style={[styles.value, { color: colors.text }]}>{user?.email ?? '—'}</Text>
{user?.userName ? (
<>
<Text style={[styles.label, { color: colors.textMuted, marginTop: 8 }]}>
{t('auth.displayName')}
</Text>
<Text style={[styles.value, { color: colors.text }]}>{user.userName}</Text>
</>
) : null}
</View>
<Text style={[styles.section, { color: colors.text }]}>{t('settings.preferences')}</Text>
<Text style={[styles.subsection, { color: colors.text }]}>{t('language.label')}</Text>
<View style={styles.row}>
{(['en', 'pl'] as const).map((code) => {
const selected = i18n.language.startsWith(code);
return (
<Pressable
key={code}
onPress={() => setAppLanguage(code)}
style={[
styles.chip,
{
backgroundColor: selected ? colors.brand : colors.surface,
borderColor: selected ? colors.brand : colors.border,
},
]}
>
<Text style={{ color: selected ? '#fff' : colors.text }}>
{t(`language.${code}`)}
</Text>
</Pressable>
);
})}
</View>
<Text style={[styles.subsection, { color: colors.text }]}>{t('appearance.label')}</Text>
{APPEARANCE_OPTIONS.map((option) => {
const selected = appearanceId === option.id;
return (
<Pressable
key={option.id}
onPress={() => setAppearance(option.id)}
style={[
styles.appearanceRow,
{
backgroundColor: colors.surface,
borderColor: selected ? colors.brand : colors.border,
},
]}
>
<View style={[styles.swatch, { backgroundColor: option.swatch }]} />
<View style={styles.appearanceText}>
<Text style={[styles.appearanceTitle, { color: colors.text }]}>
{t(option.labelKey)}
</Text>
<Text style={{ color: colors.textMuted }}>{t(option.descriptionKey)}</Text>
</View>
</Pressable>
);
})}
<Button
title={t('nav.logout')}
variant="danger"
onPress={async () => {
await logout();
router.replace('/(auth)/login');
}}
/>
</ScrollView>
</Screen>
);
}
const styles = StyleSheet.create({
scroll: {
gap: 12,
paddingBottom: 32,
},
section: {
fontSize: 18,
fontWeight: '700',
marginTop: 4,
},
subsection: {
fontSize: 15,
fontWeight: '600',
},
card: {
borderWidth: 1,
borderRadius: 14,
padding: 14,
},
label: {
fontSize: 13,
},
value: {
fontSize: 16,
fontWeight: '500',
},
row: {
flexDirection: 'row',
gap: 10,
},
chip: {
borderWidth: 1,
borderRadius: 999,
paddingHorizontal: 16,
paddingVertical: 10,
},
appearanceRow: {
borderWidth: 1,
borderRadius: 14,
padding: 12,
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
swatch: {
width: 28,
height: 28,
borderRadius: 14,
},
appearanceText: {
flex: 1,
gap: 2,
},
appearanceTitle: {
fontWeight: '600',
fontSize: 15,
},
});

View File

@@ -0,0 +1,126 @@
import { useMemo, useState } from 'react';
import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { useQuery } from '@tanstack/react-query';
import * as Print from 'expo-print';
import * as Sharing from 'expo-sharing';
import { useTranslation } from 'react-i18next';
import { fetchShoppingList } from '@/src/api/meal-plan.api';
import { Button } from '@/src/components/Button';
import { ErrorView } from '@/src/components/ErrorView';
import { Screen } from '@/src/components/Screen';
import { useAppearance } from '@/src/context/AppearanceContext';
import { addDays, formatDisplayDate, weekRange } from '@/src/utils/date';
import { buildShoppingListHtml, formatShoppingQuantity } from '@/src/utils/pdfExport';
export default function ShoppingScreen() {
const { t, i18n } = useTranslation();
const { colors } = useAppearance();
const [weekAnchor, setWeekAnchor] = useState(() => weekRange().from);
const { from, to } = useMemo(() => weekRange(weekAnchor), [weekAnchor]);
const [checked, setChecked] = useState<Record<string, boolean>>({});
const [exporting, setExporting] = useState(false);
const listQuery = useQuery({
queryKey: ['shopping-list', from, to],
queryFn: () => fetchShoppingList(from, to),
});
async function handleExportPdf() {
const items = listQuery.data ?? [];
if (items.length === 0) {
Alert.alert(t('errors.pdfNothingToExport'));
return;
}
setExporting(true);
try {
const html = buildShoppingListHtml(
items,
t('pdf.shoppingList'),
t('pdf.generatedOn', { date: new Date().toLocaleDateString(), count: items.length }),
t('pdf.toTaste'),
);
const { uri } = await Print.printToFileAsync({ html });
if (await Sharing.isAvailableAsync()) {
await Sharing.shareAsync(uri, { UTI: '.pdf', mimeType: 'application/pdf' });
}
} catch {
Alert.alert(t('errors.pdfGenerateFailed'));
} finally {
setExporting(false);
}
}
return (
<Screen title={t('shopping.title')} subtitle={t('shopping.subtitle')}>
<View style={styles.header}>
<Pressable
onPress={() => setWeekAnchor(addDays(from, -7))}
style={[styles.navBtn, { borderColor: colors.border, backgroundColor: colors.surface }]}
>
<Text style={{ color: colors.text }}></Text>
</Pressable>
<Text style={{ color: colors.text, fontWeight: '700', flex: 1, textAlign: 'center' }}>
{formatDisplayDate(from, i18n.language)} {formatDisplayDate(to, i18n.language)}
</Text>
<Pressable
onPress={() => setWeekAnchor(addDays(from, 7))}
style={[styles.navBtn, { borderColor: colors.border, backgroundColor: colors.surface }]}
>
<Text style={{ color: colors.text }}></Text>
</Pressable>
</View>
<Button
title={exporting ? t('pdf.generating') : t('pdf.generate')}
variant="secondary"
loading={exporting}
onPress={() => void handleExportPdf()}
/>
{listQuery.isLoading ? (
<Text style={{ color: colors.textMuted }}>{t('common.loading')}</Text>
) : listQuery.isError ? (
<ErrorView message={t('errors.loadShoppingFailed')} onRetry={() => listQuery.refetch()} />
) : (listQuery.data?.length ?? 0) === 0 ? (
<Text style={{ color: colors.textMuted }}>{t('shopping.empty')}</Text>
) : (
<ScrollView contentContainerStyle={styles.list}>
{listQuery.data!.map((item) => {
const key = item.name;
const isChecked = checked[key] ?? false;
return (
<Pressable
key={key}
onPress={() => setChecked((prev) => ({ ...prev, [key]: !isChecked }))}
style={[styles.row, { borderColor: colors.border, backgroundColor: colors.surface }]}
>
<View style={[styles.checkbox, { borderColor: colors.brand, backgroundColor: isChecked ? colors.brand : 'transparent' }]}>
{isChecked ? <Text style={{ color: '#fff', fontSize: 12 }}></Text> : null}
</View>
<View style={{ flex: 1 }}>
<Text style={{ color: isChecked ? colors.textMuted : colors.text, textDecorationLine: isChecked ? 'line-through' : 'none', fontWeight: '600' }}>
{item.name}
</Text>
{item.breakdown.length > 0 ? (
<Text style={{ color: colors.textMuted, fontSize: 11 }}>{item.breakdown.join(' · ')}</Text>
) : null}
</View>
<Text style={{ color: colors.textMuted, fontSize: 13 }}>
{formatShoppingQuantity(item, t('pdf.toTaste'))}
</Text>
</Pressable>
);
})}
</ScrollView>
)}
</Screen>
);
}
const styles = StyleSheet.create({
header: { flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 8 },
navBtn: { borderWidth: 1, borderRadius: 8, width: 36, height: 36, alignItems: 'center', justifyContent: 'center' },
list: { gap: 8, paddingBottom: 24 },
row: { flexDirection: 'row', alignItems: 'center', gap: 10, borderWidth: 1, borderRadius: 12, padding: 12 },
checkbox: { width: 22, height: 22, borderRadius: 6, borderWidth: 2, alignItems: 'center', justifyContent: 'center' },
});

View File

@@ -0,0 +1,38 @@
import { Redirect, Stack } from 'expo-router';
import { useTranslation } from 'react-i18next';
import { useAuth } from '@/src/context/AuthContext';
import { useAppearance } from '@/src/context/AppearanceContext';
import { Screen } from '@/src/components/Screen';
export default function MainLayout() {
const { isAuthenticated, isLoading } = useAuth();
const { colors } = useAppearance();
const { t } = useTranslation();
if (isLoading) {
return <Screen loading />;
}
if (!isAuthenticated) {
return <Redirect href="/(auth)/login" />;
}
return (
<Stack
screenOptions={{
headerStyle: { backgroundColor: colors.surface },
headerTintColor: colors.brand,
headerTitleStyle: { color: colors.text },
contentStyle: { backgroundColor: colors.background },
}}
>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="recipe/[id]" options={{ title: t('recipes.allMealsTitle') }} />
<Stack.Screen name="recipe/edit/[id]" options={{ title: t('manage.editRecipe') }} />
<Stack.Screen name="ingredients" options={{ title: t('nav.ingredientCatalog') }} />
<Stack.Screen name="diet-generator" options={{ title: t('nav.dietGenerator') }} />
<Stack.Screen name="recipe-generator" options={{ title: t('nav.recipeGenerator') }} />
<Stack.Screen name="category/[category]" />
</Stack>
);
}

View File

@@ -0,0 +1,152 @@
import { Stack, useLocalSearchParams, router } from 'expo-router';
import { useMemo, useState } from 'react';
import { ActivityIndicator, FlatList, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { fetchRecipes } from '@/src/api/recipes.api';
import { EmptyState, RecipeCard } from '@/src/components/RecipeCard';
import { ErrorView } from '@/src/components/ErrorView';
import { Screen } from '@/src/components/Screen';
import { useAppearance } from '@/src/context/AppearanceContext';
import { categoryLabel, parseCategoryParam } from '@/src/utils/recipe';
import { nextSort, sortBy, sortIndicator, type SortDirection } from '@/src/utils/sort';
type RecipeSortColumn = 'name' | 'calories' | 'prepTime';
const RECIPE_SORT_COLUMNS: { id: RecipeSortColumn; labelKey: string }[] = [
{ id: 'name', labelKey: 'manage.recipeName' },
{ id: 'calories', labelKey: 'recipes.calories' },
{ id: 'prepTime', labelKey: 'manage.prepTimeMin' },
];
export default function CategoryScreen() {
const { category: categoryParam } = useLocalSearchParams<{ category: string }>();
const category = parseCategoryParam(categoryParam ?? '');
const { t } = useTranslation();
const { colors } = useAppearance();
const [recipeSort, setRecipeSort] = useState<{ column: RecipeSortColumn; direction: SortDirection }>({
column: 'name',
direction: 'asc',
});
const recipesQuery = useQuery({
queryKey: ['recipes', 'category', category],
queryFn: () => fetchRecipes({ category: category! }),
enabled: category != null,
});
const title = category != null ? categoryLabel(category, t) : t('categories.unknown');
const sortedRecipes = useMemo(() => {
const list = recipesQuery.data ?? [];
const { column, direction } = recipeSort;
return sortBy(list, direction, (recipe) => {
switch (column) {
case 'name':
return recipe.name;
case 'calories':
return recipe.calories;
case 'prepTime':
return recipe.prepTimeMinutes;
}
});
}, [recipesQuery.data, recipeSort]);
if (category == null) {
return (
<Screen>
<ErrorView message={t('errors.loadRecipesFailed')} />
</Screen>
);
}
return (
<>
<Stack.Screen options={{ title, headerShown: true }} />
<Screen subtitle={t('common.recipeCount', { count: recipesQuery.data?.length ?? 0 })}>
{(recipesQuery.data?.length ?? 0) > 0 ? (
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.sortRow}>
{RECIPE_SORT_COLUMNS.map(({ id, labelKey }) => (
<SortChip
key={id}
label={`${t(labelKey)}${sortIndicator(recipeSort, id)}`}
active={recipeSort.column === id}
onPress={() => setRecipeSort((current) => nextSort(current, id))}
/>
))}
</ScrollView>
) : null}
{recipesQuery.isLoading ? (
<View style={styles.loading}>
<ActivityIndicator size="large" color={colors.brand} />
</View>
) : recipesQuery.isError ? (
<ErrorView
message={t('errors.loadRecipesFailed')}
onRetry={() => recipesQuery.refetch()}
/>
) : (
<FlatList
data={sortedRecipes}
keyExtractor={(item) => String(item.id)}
contentContainerStyle={styles.list}
ListEmptyComponent={
<EmptyState
title={t('recipes.emptyCategoryTitle', { category: title })}
description={t('recipes.emptyCategoryDescription')}
/>
}
renderItem={({ item }) => (
<RecipeCard
recipe={item}
onPress={() => router.push(`/(main)/recipe/${item.id}`)}
/>
)}
/>
)}
</Screen>
</>
);
}
function SortChip({ label, active, onPress }: { label: string; active: boolean; onPress: () => void }) {
const { colors } = useAppearance();
return (
<Pressable
onPress={onPress}
style={[
styles.sortChip,
{
backgroundColor: active ? colors.brand : colors.surface,
borderColor: active ? colors.brand : colors.border,
},
]}
>
<Text style={{ color: active ? '#fff' : colors.text, fontWeight: '600', fontSize: 12 }}>{label}</Text>
</Pressable>
);
}
const styles = StyleSheet.create({
sortRow: {
gap: 8,
paddingBottom: 8,
},
sortChip: {
borderWidth: 1,
borderRadius: 999,
paddingHorizontal: 12,
paddingVertical: 6,
},
list: {
gap: 10,
paddingBottom: 24,
flexGrow: 1,
},
loading: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 40,
},
});

View File

@@ -0,0 +1,479 @@
import { useEffect, useMemo, useState } from 'react';
import { Modal, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { router } from 'expo-router';
import { useTranslation } from 'react-i18next';
import {
acceptDietMacros,
commitDietPlan,
createDietSession,
fetchDietProfile,
generateDietPlan,
proposeDietMacros,
regenerateDietMeal,
saveDietProfile,
toggleDietMealLock,
} from '@/src/api/diet-generator.api';
import { Button } from '@/src/components/Button';
import { Input } from '@/src/components/Input';
import { Screen } from '@/src/components/Screen';
import { useAppearance } from '@/src/context/AppearanceContext';
import {
DEFAULT_DIET_PROFILE,
type DietGeneratorDraftMeal,
type DietGeneratorSession,
type DietUserProfile,
} from '@/src/types/generator';
import { MealCategory } from '@/src/types/recipe';
import { ApiError, extractApiError } from '@/src/utils/apiError';
import { categoryLabel } from '@/src/utils/recipe';
type Step = 'profile' | 'macros' | 'plan' | 'done';
function formatDraftMealMacros(meal: DietGeneratorDraftMeal): string {
const parts = [`${meal.calories ?? '?'} kcal`];
if (meal.proteinG != null) parts.push(`P ${meal.proteinG}g`);
if (meal.fatG != null) parts.push(`F ${meal.fatG}g`);
if (meal.carbsG != null) parts.push(`C ${meal.carbsG}g`);
return parts.join(' · ');
}
export default function DietGeneratorScreen() {
const { t } = useTranslation();
const { colors } = useAppearance();
const [step, setStep] = useState<Step>('profile');
const [profile, setProfile] = useState<DietUserProfile>({ ...DEFAULT_DIET_PROFILE });
const [sessionId, setSessionId] = useState<number | null>(null);
const [session, setSession] = useState<DietGeneratorSession | null>(null);
const [macros, setMacros] = useState({
calories: 2000,
proteinG: 150,
fatG: 65,
carbsG: 200,
rationale: '',
});
const [commitResult, setCommitResult] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [info, setInfo] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [previewMeal, setPreviewMeal] = useState<DietGeneratorDraftMeal | null>(null);
useEffect(() => {
void fetchDietProfile().then((p) => {
if (p) setProfile({ ...DEFAULT_DIET_PROFILE, ...p });
});
}, []);
const mealsByDate = useMemo(() => {
const map = new Map<string, DietGeneratorSession['draftMeals']>();
for (const meal of session?.draftMeals ?? []) {
const list = map.get(meal.planDate) ?? [];
list.push(meal);
map.set(meal.planDate, list);
}
return [...map.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([date, meals]) => ({
date,
meals,
summary: session?.daySummaries.find((d) => d.planDate === date),
}));
}, [session]);
async function startSession() {
setError(null);
setLoading(true);
try {
await saveDietProfile(profile);
const created = await createDietSession({ planDayCount: 7, calorieToleranceKcal: 10 });
setSessionId(created.id);
if (created.proposedMacros) applyMacros(created);
const refined = await proposeDietMacros(created.id);
if (refined.proposedMacros) applyMacros(refined);
setStep('macros');
} catch (e) {
setError(extractApiError(e, t('generators.diet.error')));
} finally {
setLoading(false);
}
}
async function acceptAndGenerate() {
if (!sessionId) return;
setError(null);
setLoading(true);
try {
await acceptDietMacros(sessionId, macros);
const plan = await generateDietPlan(sessionId);
setSession(plan);
setStep('plan');
} catch (e) {
setError(extractApiError(e, t('generators.diet.error')));
} finally {
setLoading(false);
}
}
async function toggleLock(mealId: number, isLocked: boolean) {
if (!sessionId) return;
try {
const updated = await toggleDietMealLock(sessionId, mealId, isLocked);
setSession(updated);
} catch (e) {
setError(extractApiError(e, t('generators.diet.error')));
}
}
async function regenMeal(planDate: string, mealCategory: number) {
if (!sessionId) return;
try {
const updated = await regenerateDietMeal(sessionId, planDate, mealCategory);
setSession(updated);
setError(null);
setInfo(null);
} catch (e) {
if (e instanceof ApiError && e.status === 400) {
setInfo(t('generators.diet.noAlternativeMeals'));
setError(null);
} else {
setError(extractApiError(e, t('generators.diet.error')));
setInfo(null);
}
}
}
async function commitPlan() {
if (!sessionId) return;
setLoading(true);
try {
const result = await commitDietPlan(sessionId);
setCommitResult(`${result.planStartDate}${result.planEndDate}`);
setStep('done');
} catch (e) {
setError(extractApiError(e, t('generators.diet.error')));
} finally {
setLoading(false);
}
}
function applyMacros(s: DietGeneratorSession) {
if (!s.proposedMacros) return;
setMacros({
calories: s.proposedMacros.calories,
proteinG: s.proposedMacros.proteinG,
fatG: s.proposedMacros.fatG,
carbsG: s.proposedMacros.carbsG,
rationale: s.proposedMacros.rationale ?? '',
});
}
function reset() {
setStep('profile');
setSessionId(null);
setSession(null);
setCommitResult(null);
setError(null);
setInfo(null);
}
return (
<Screen title={t('generators.diet.title')} subtitle={t('generators.diet.subtitle')}>
<ScrollView contentContainerStyle={styles.scroll}>
{info ? (
<View style={[styles.infoBox, { backgroundColor: colors.brand + '22' }]}>
<Text style={{ color: colors.brand }}>{info}</Text>
</View>
) : null}
{error ? (
<View style={[styles.errorBox, { backgroundColor: colors.danger + '22' }]}>
<Text style={{ color: colors.danger }}>{error}</Text>
</View>
) : null}
{step === 'profile' ? (
<View style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.border }]}>
<Text style={[styles.sectionTitle, { color: colors.text }]}>
{t('generators.diet.profileTitle')}
</Text>
<Input
label={t('generators.diet.heightCm')}
keyboardType="numeric"
value={String(profile.heightCm)}
onChangeText={(v) => setProfile({ ...profile, heightCm: Number(v) || 0 })}
/>
<Input
label={t('generators.diet.weightKg')}
keyboardType="numeric"
value={String(profile.weightKg)}
onChangeText={(v) => setProfile({ ...profile, weightKg: Number(v) || 0 })}
/>
<Input
label={t('generators.diet.age')}
keyboardType="numeric"
value={String(profile.age)}
onChangeText={(v) => setProfile({ ...profile, age: Number(v) || 0 })}
/>
<Text style={[styles.label, { color: colors.text }]}>{t('generators.diet.sex')}</Text>
<View style={styles.row}>
{(['male', 'female'] as const).map((sex) => (
<Pressable
key={sex}
onPress={() => setProfile({ ...profile, sex })}
style={[
styles.chip,
{
backgroundColor: profile.sex === sex ? colors.brand : colors.surface,
borderColor: profile.sex === sex ? colors.brand : colors.border,
},
]}
>
<Text style={{ color: profile.sex === sex ? '#fff' : colors.text }}>
{t(`generators.diet.${sex === 'male' ? 'male' : 'female'}`)}
</Text>
</Pressable>
))}
</View>
<Text style={[styles.label, { color: colors.text }]}>{t('generators.diet.activity')}</Text>
<View style={styles.wrapRow}>
{(['sedentary', 'light', 'moderate', 'active', 'very_active'] as const).map((level) => (
<Pressable
key={level}
onPress={() => setProfile({ ...profile, activityLevel: level })}
style={[
styles.chip,
{
backgroundColor: profile.activityLevel === level ? colors.brand : colors.surface,
borderColor: profile.activityLevel === level ? colors.brand : colors.border,
},
]}
>
<Text style={{ color: profile.activityLevel === level ? '#fff' : colors.text, fontSize: 12 }}>
{t(`generators.diet.${level === 'very_active' ? 'veryActive' : level}`)}
</Text>
</Pressable>
))}
</View>
<Text style={[styles.label, { color: colors.text }]}>{t('generators.diet.goal')}</Text>
<View style={styles.wrapRow}>
{(['lose_weight', 'maintain', 'gain_muscle', 'recomp'] as const).map((goal) => (
<Pressable
key={goal}
onPress={() => setProfile({ ...profile, goal })}
style={[
styles.chip,
{
backgroundColor: profile.goal === goal ? colors.brand : colors.surface,
borderColor: profile.goal === goal ? colors.brand : colors.border,
},
]}
>
<Text style={{ color: profile.goal === goal ? '#fff' : colors.text, fontSize: 12 }}>
{t(
`generators.diet.${
goal === 'lose_weight'
? 'loseWeight'
: goal === 'gain_muscle'
? 'gainMuscle'
: goal
}`,
)}
</Text>
</Pressable>
))}
</View>
<Input
label={t('generators.diet.allergies')}
multiline
value={profile.allergiesNotes ?? ''}
onChangeText={(v) => setProfile({ ...profile, allergiesNotes: v })}
/>
<Button title={t('generators.diet.continue')} loading={loading} onPress={() => void startSession()} />
</View>
) : null}
{step === 'macros' ? (
<View style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.border }]}>
<Text style={[styles.sectionTitle, { color: colors.text }]}>
{t('generators.diet.macrosTitle')}
</Text>
{macros.rationale ? (
<Text style={{ color: colors.textMuted, marginBottom: 8 }}>{macros.rationale}</Text>
) : null}
<Input
label={t('manage.caloriesKcal')}
keyboardType="numeric"
value={String(macros.calories)}
onChangeText={(v) => setMacros({ ...macros, calories: Number(v) || 0 })}
/>
<Input
label={t('manage.proteinG')}
keyboardType="numeric"
value={String(macros.proteinG)}
onChangeText={(v) => setMacros({ ...macros, proteinG: Number(v) || 0 })}
/>
<Input
label={t('manage.fatG')}
keyboardType="numeric"
value={String(macros.fatG)}
onChangeText={(v) => setMacros({ ...macros, fatG: Number(v) || 0 })}
/>
<Input
label={t('manage.carbsG')}
keyboardType="numeric"
value={String(macros.carbsG)}
onChangeText={(v) => setMacros({ ...macros, carbsG: Number(v) || 0 })}
/>
<View style={styles.buttonRow}>
<Button title={t('common.back')} variant="secondary" onPress={() => setStep('profile')} />
<Button
title={t('generators.diet.acceptAndGenerate')}
loading={loading}
onPress={() => void acceptAndGenerate()}
/>
</View>
</View>
) : null}
{step === 'plan' && session ? (
<View style={styles.gap}>
<Text style={{ color: colors.textMuted }}>{t('generators.diet.planHint')}</Text>
{mealsByDate.map(({ date, meals, summary }) => (
<View
key={date}
style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.border }]}
>
<View style={styles.dayHeader}>
<Text style={[styles.sectionTitle, { color: colors.text }]}>{date}</Text>
<Text style={{ color: summary?.withinTolerance ? colors.brand : '#b45309' }}>
{summary?.totalCalories ?? 0} / {summary?.targetCalories ?? 0} kcal
{summary?.withinTolerance ? ' ✓' : ''}
</Text>
</View>
{meals.map((meal) => (
<View key={meal.id} style={[styles.mealRow, { borderColor: colors.border }]}>
<View style={{ flex: 1 }}>
<Text style={{ color: colors.textMuted, fontSize: 11, textTransform: 'uppercase' }}>
{categoryLabel(meal.mealCategory as MealCategory, t)}
</Text>
<Text style={{ color: colors.text, fontWeight: '600' }}>{meal.recipeName}</Text>
<Text style={{ color: colors.textMuted, fontSize: 12 }}>
{formatDraftMealMacros(meal)}
</Text>
</View>
<View style={styles.mealActions}>
<Button
title={t('generators.diet.viewRecipe')}
variant="secondary"
onPress={() => setPreviewMeal(meal)}
style={styles.smallBtn}
/>
<Button
title={meal.isLocked ? t('generators.diet.unlock') : t('generators.diet.lock')}
variant="secondary"
onPress={() => void toggleLock(meal.id, !meal.isLocked)}
style={styles.smallBtn}
/>
<Button
title="↻"
variant="secondary"
disabled={meal.isLocked}
onPress={() => void regenMeal(meal.planDate, meal.mealCategory)}
style={styles.smallBtn}
/>
</View>
</View>
))}
</View>
))}
<Button title={t('generators.diet.commit')} loading={loading} onPress={() => void commitPlan()} />
</View>
) : null}
{step === 'done' ? (
<View style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.border }]}>
<Text style={[styles.sectionTitle, { color: colors.brand }]}>{t('generators.diet.done')}</Text>
{commitResult ? <Text style={{ color: colors.textMuted }}>{commitResult}</Text> : null}
<Button title={t('nav.planner')} onPress={() => router.push('/(main)/(tabs)/planner')} />
<Button title={t('generators.diet.newPlan')} variant="secondary" onPress={reset} />
</View>
) : null}
</ScrollView>
<Modal visible={!!previewMeal} transparent animationType="slide" onRequestClose={() => setPreviewMeal(null)}>
<View style={styles.modalBackdrop}>
<View style={[styles.modalCard, { backgroundColor: colors.surface, borderColor: colors.border }]}>
<ScrollView contentContainerStyle={styles.modalScroll}>
{previewMeal ? (
<>
<Text style={[styles.sectionTitle, { color: colors.text }]}>{previewMeal.recipeName}</Text>
{previewMeal.calories != null ? (
<Text style={{ color: colors.textMuted, marginBottom: 8 }}>
{previewMeal.calories} kcal · P {previewMeal.proteinG ?? 0}g · F {previewMeal.fatG ?? 0}g · C{' '}
{previewMeal.carbsG ?? 0}g
</Text>
) : null}
<Text style={[styles.label, { color: colors.text }]}>{t('recipes.ingredients')}</Text>
{previewMeal.ingredients.map((ing) => (
<Text key={ing.id} style={{ color: colors.text, marginBottom: 4 }}>
{ing.name}
{ing.amountGrams != null ? `${ing.amountGrams}g` : ''}
{ing.unit ? ` ${ing.unit}` : ''}
</Text>
))}
<Text style={[styles.label, { color: colors.text, marginTop: 12 }]}>
{t('recipes.preparation')}
</Text>
{previewMeal.steps.map((step) => (
<Text key={step.id} style={{ color: colors.text, marginBottom: 4 }}>
{step.stepNumber}. {step.description}
</Text>
))}
</>
) : null}
</ScrollView>
<Button title={t('common.back')} variant="secondary" onPress={() => setPreviewMeal(null)} />
</View>
</View>
</Modal>
</Screen>
);
}
const styles = StyleSheet.create({
scroll: { gap: 16, paddingBottom: 32 },
card: { borderWidth: 1, borderRadius: 16, padding: 16, gap: 12 },
sectionTitle: { fontSize: 18, fontWeight: '700' },
label: { fontSize: 14, fontWeight: '600' },
row: { flexDirection: 'row', gap: 8 },
wrapRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
chip: { borderWidth: 1, borderRadius: 999, paddingHorizontal: 12, paddingVertical: 8 },
buttonRow: { gap: 10 },
gap: { gap: 12 },
dayHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', gap: 8 },
mealRow: {
borderTopWidth: 1,
paddingTop: 10,
marginTop: 10,
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
mealActions: { gap: 6 },
smallBtn: { minHeight: 36, paddingHorizontal: 10 },
errorBox: { borderRadius: 12, padding: 12 },
infoBox: { borderRadius: 12, padding: 12 },
modalBackdrop: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.5)',
justifyContent: 'flex-end',
padding: 16,
},
modalCard: {
borderWidth: 1,
borderRadius: 16,
padding: 16,
maxHeight: '85%',
gap: 12,
},
modalScroll: { gap: 4, paddingBottom: 8 },
});

View File

@@ -0,0 +1,434 @@
import { useEffect, useMemo, useState } from 'react';
import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import {
createIngredientCatalogItem,
deleteIngredientCatalogItem,
fetchIngredientCatalogItems,
fetchIngredientCategories,
updateIngredientCatalogItem,
} from '@/src/api/ingredient-catalog.api';
import { Button } from '@/src/components/Button';
import { ErrorView } from '@/src/components/ErrorView';
import { Input } from '@/src/components/Input';
import { Screen } from '@/src/components/Screen';
import { useAppearance } from '@/src/context/AppearanceContext';
import type {
IngredientCategory,
IngredientNutritionItem,
UpsertIngredientNutritionItemInput,
} from '@/src/types/ingredient-catalog';
import { extractApiError } from '@/src/utils/apiError';
import { ingredientDisplayName } from '@/src/utils/ingredient-catalog.util';
import { nextSort, sortBy, sortIndicator, type SortDirection } from '@/src/utils/sort';
type IngredientSortColumn = 'name' | 'category' | 'calories' | 'protein' | 'fat' | 'carbs' | 'fiber';
const EMPTY_FORM: UpsertIngredientNutritionItemInput = {
categoryId: 0,
name: '',
namePl: '',
caloriesPer100G: 0,
proteinGPer100G: 0,
fatGPer100G: 0,
carbsGPer100G: 0,
fiberGPer100G: null,
sortOrder: 0,
isActive: true,
};
export default function IngredientsScreen() {
const { t, i18n } = useTranslation();
const { colors } = useAppearance();
const queryClient = useQueryClient();
const [category, setCategory] = useState('');
const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [form, setForm] = useState<UpsertIngredientNutritionItemInput>({ ...EMPTY_FORM });
const [formError, setFormError] = useState<string | null>(null);
const [sort, setSort] = useState<{ column: IngredientSortColumn; direction: SortDirection }>({
column: 'name',
direction: 'asc',
});
useEffect(() => {
const timer = setTimeout(() => setDebouncedSearch(search.trim()), 300);
return () => clearTimeout(timer);
}, [search]);
const categoriesQuery = useQuery({
queryKey: ['ingredient-categories'],
queryFn: fetchIngredientCategories,
});
const itemsQuery = useQuery({
queryKey: ['ingredient-catalog', category, debouncedSearch],
queryFn: () =>
fetchIngredientCatalogItems({
category: category || undefined,
search: debouncedSearch || undefined,
}),
});
const saveMutation = useMutation({
mutationFn: async () => {
const payload = {
...form,
name: form.name.trim(),
namePl: form.namePl?.trim() || null,
fiberGPer100G: form.fiberGPer100G ?? null,
};
if (editingId) return updateIngredientCatalogItem(editingId, payload);
return createIngredientCatalogItem(payload);
},
onSuccess: () => {
setShowForm(false);
setEditingId(null);
setFormError(null);
void queryClient.invalidateQueries({ queryKey: ['ingredient-categories'] });
void queryClient.invalidateQueries({ queryKey: ['ingredient-catalog'] });
},
onError: (err) => {
setFormError(extractApiError(err, t('errors.saveIngredientFailed')));
},
});
const deleteMutation = useMutation({
mutationFn: deleteIngredientCatalogItem,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['ingredient-categories'] });
void queryClient.invalidateQueries({ queryKey: ['ingredient-catalog'] });
},
});
const categoryLabel = (code: string, fallback: string) =>
t(`ingredientCatalog.categories.${code}`, { defaultValue: fallback });
const totalCount = useMemo(
() => (categoriesQuery.data ?? []).reduce((sum, c) => sum + c.itemCount, 0),
[categoriesQuery.data],
);
const openCreateForm = () => {
setEditingId(null);
setForm({ ...EMPTY_FORM, categoryId: categoriesQuery.data?.[0]?.id ?? 0 });
setFormError(null);
setShowForm(true);
};
const openEditForm = (item: IngredientNutritionItem) => {
setEditingId(item.id);
setForm({
categoryId: item.categoryId,
name: item.name,
namePl: item.namePl ?? '',
caloriesPer100G: item.caloriesPer100G,
proteinGPer100G: item.proteinGPer100G,
fatGPer100G: item.fatGPer100G,
carbsGPer100G: item.carbsGPer100G,
fiberGPer100G: item.fiberGPer100G,
sortOrder: 0,
isActive: true,
});
setFormError(null);
setShowForm(true);
};
const saveForm = () => {
if (!form.categoryId || !form.name.trim()) {
setFormError(t('ingredientCatalog.formInvalid'));
return;
}
saveMutation.mutate();
};
const deleteItem = (id: number) => {
Alert.alert(t('common.delete'), t('ingredientCatalog.deleteConfirm'), [
{ text: t('common.cancel'), style: 'cancel' },
{ text: t('common.delete'), style: 'destructive', onPress: () => deleteMutation.mutate(id) },
]);
};
const loading = categoriesQuery.isLoading || itemsQuery.isLoading;
const error = categoriesQuery.error ?? itemsQuery.error;
const items = itemsQuery.data ?? [];
const sortedItems = useMemo(() => {
const { column, direction } = sort;
return sortBy(items, direction, (item) => {
switch (column) {
case 'name':
return ingredientDisplayName(item, i18n.language);
case 'category':
return item.categoryCode;
case 'calories':
return item.caloriesPer100G;
case 'protein':
return Number(item.proteinGPer100G);
case 'fat':
return Number(item.fatGPer100G);
case 'carbs':
return Number(item.carbsGPer100G);
case 'fiber':
return item.fiberGPer100G != null ? Number(item.fiberGPer100G) : null;
}
});
}, [items, sort, i18n.language]);
const toggleSort = (column: IngredientSortColumn) => {
setSort((current) => nextSort(current, column));
};
return (
<Screen
title={t('ingredientCatalog.title')}
subtitle={t('ingredientCatalog.subtitle')}
loading={loading}
>
<Button title={`+ ${t('ingredientCatalog.addIngredient')}`} variant="secondary" onPress={openCreateForm} />
{showForm ? (
<View style={[styles.formBox, { borderColor: colors.border, backgroundColor: colors.surface }]}>
<Text style={[styles.formTitle, { color: colors.text }]}>
{editingId ? t('ingredientCatalog.editIngredient') : t('ingredientCatalog.addIngredient')}
</Text>
{formError ? <Text style={{ color: '#dc2626', marginBottom: 8 }}>{formError}</Text> : null}
<Text style={[styles.label, { color: colors.text }]}>{t('ingredientCatalog.columns.category')}</Text>
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.chips}>
{(categoriesQuery.data ?? []).map((cat: IngredientCategory) => (
<Chip
key={cat.id}
label={categoryLabel(cat.code, cat.name)}
active={form.categoryId === cat.id}
onPress={() => setForm((prev) => ({ ...prev, categoryId: cat.id }))}
/>
))}
</ScrollView>
<Input
label={t('ingredientCatalog.nameEn')}
value={form.name}
onChangeText={(v) => setForm((prev) => ({ ...prev, name: v }))}
/>
<Input
label={t('ingredientCatalog.namePlLabel')}
value={form.namePl ?? ''}
onChangeText={(v) => setForm((prev) => ({ ...prev, namePl: v }))}
/>
{(
[
['caloriesPer100G', 'ingredientCatalog.columns.calories'],
['proteinGPer100G', 'ingredientCatalog.columns.protein'],
['fatGPer100G', 'ingredientCatalog.columns.fat'],
['carbsGPer100G', 'ingredientCatalog.columns.carbs'],
['fiberGPer100G', 'ingredientCatalog.columns.fiber'],
] as const
).map(([key, labelKey]) => (
<Input
key={key}
label={t(labelKey)}
keyboardType="decimal-pad"
value={form[key]?.toString() ?? ''}
onChangeText={(v) =>
setForm((prev) => ({
...prev,
[key]: v === '' ? null : Number(v),
}))
}
/>
))}
<View style={styles.formActions}>
<Button title={t('common.save')} loading={saveMutation.isPending} onPress={saveForm} />
<Button title={t('common.cancel')} variant="secondary" onPress={() => setShowForm(false)} />
</View>
</View>
) : null}
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.chips}>
<Chip
label={`${t('ingredientCatalog.allCategories')} (${totalCount})`}
active={category === ''}
onPress={() => setCategory('')}
/>
{(categoriesQuery.data ?? []).map((cat: IngredientCategory) => (
<Chip
key={cat.code}
label={`${categoryLabel(cat.code, cat.name)} (${cat.itemCount})`}
active={category === cat.code}
onPress={() => setCategory(cat.code)}
/>
))}
</ScrollView>
<Input
label={t('common.search')}
value={search}
onChangeText={setSearch}
placeholder={t('ingredientCatalog.searchPlaceholder')}
/>
{(itemsQuery.data ?? []).length > 0 ? (
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.sortRow}>
{(
[
['name', 'ingredientCatalog.columns.name'],
['category', 'ingredientCatalog.columns.category'],
['calories', 'ingredientCatalog.columns.calories'],
['protein', 'ingredientCatalog.columns.protein'],
['fat', 'ingredientCatalog.columns.fat'],
['carbs', 'ingredientCatalog.columns.carbs'],
['fiber', 'ingredientCatalog.columns.fiber'],
] as const
).map(([column, labelKey]) => (
<SortChip
key={column}
label={`${t(labelKey)}${sortIndicator(sort, column)}`}
active={sort.column === column}
onPress={() => toggleSort(column)}
/>
))}
</ScrollView>
) : null}
{error ? (
<ErrorView
message={extractApiError(error, t('errors.loadIngredientCatalogFailed'))}
onRetry={() => {
categoriesQuery.refetch();
itemsQuery.refetch();
}}
/>
) : (itemsQuery.data ?? []).length === 0 ? (
<View style={styles.empty}>
<Text style={[styles.emptyTitle, { color: colors.text }]}>{t('ingredientCatalog.noResultsTitle')}</Text>
<Text style={{ color: colors.textMuted }}>{t('ingredientCatalog.noResultsDescription')}</Text>
</View>
) : (
<ScrollView contentContainerStyle={styles.list}>
{sortedItems.map((item) => (
<View
key={item.id}
style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.border }]}
>
<View style={styles.cardHeader}>
<View style={{ flex: 1 }}>
<Text style={[styles.cardTitle, { color: colors.text }]}>
{ingredientDisplayName(item, i18n.language)}
</Text>
<Text style={{ color: colors.textMuted, marginBottom: 8 }}>
{categoryLabel(item.categoryCode, item.categoryName)}
</Text>
</View>
<View style={styles.cardActions}>
<Pressable onPress={() => openEditForm(item)}>
<Text style={{ color: colors.brand, fontWeight: '600' }}>{t('common.edit')}</Text>
</Pressable>
<Pressable onPress={() => deleteItem(item.id)}>
<Text style={{ color: '#dc2626', fontWeight: '600' }}>{t('common.delete')}</Text>
</Pressable>
</View>
</View>
<View style={styles.macros}>
<Macro label={t('ingredientCatalog.columns.calories')} value={`${item.caloriesPer100G}`} />
<Macro label={t('ingredientCatalog.columns.protein')} value={formatNum(item.proteinGPer100G)} />
<Macro label={t('ingredientCatalog.columns.fat')} value={formatNum(item.fatGPer100G)} />
<Macro label={t('ingredientCatalog.columns.carbs')} value={formatNum(item.carbsGPer100G)} />
{item.fiberGPer100G != null ? (
<Macro label={t('ingredientCatalog.columns.fiber')} value={formatNum(item.fiberGPer100G)} />
) : null}
</View>
</View>
))}
<Text style={[styles.note, { color: colors.textMuted }]}>{t('ingredientCatalog.per100gNote')}</Text>
</ScrollView>
)}
</Screen>
);
}
function SortChip({ label, active, onPress }: { label: string; active: boolean; onPress: () => void }) {
const { colors } = useAppearance();
return (
<Pressable
onPress={onPress}
style={[
styles.sortChip,
{
backgroundColor: active ? colors.brand : colors.surface,
borderColor: active ? colors.brand : colors.border,
},
]}
>
<Text style={{ color: active ? '#fff' : colors.text, fontWeight: '600', fontSize: 12 }}>{label}</Text>
</Pressable>
);
}
function Chip({ label, active, onPress }: { label: string; active: boolean; onPress: () => void }) {
const { colors } = useAppearance();
return (
<Pressable
onPress={onPress}
style={[
styles.chip,
{
backgroundColor: active ? colors.brand : colors.surface,
borderColor: active ? colors.brand : colors.border,
},
]}
>
<Text style={{ color: active ? '#fff' : colors.text, fontWeight: '600', fontSize: 13 }}>{label}</Text>
</Pressable>
);
}
function Macro({ label, value }: { label: string; value: string }) {
const { colors } = useAppearance();
return (
<View style={styles.macro}>
<Text style={{ color: colors.textMuted, fontSize: 11 }}>{label}</Text>
<Text style={{ color: colors.text, fontWeight: '600' }}>{value}</Text>
</View>
);
}
function formatNum(value: number): string {
return Number(value).toFixed(1);
}
const styles = StyleSheet.create({
chips: { gap: 8, paddingBottom: 12 },
sortRow: { gap: 8, paddingBottom: 8 },
sortChip: {
borderWidth: 1,
borderRadius: 999,
paddingHorizontal: 12,
paddingVertical: 6,
},
chip: {
borderWidth: 1,
borderRadius: 999,
paddingHorizontal: 14,
paddingVertical: 8,
},
formBox: { borderWidth: 1, borderRadius: 16, padding: 16, gap: 8, marginBottom: 8 },
formTitle: { fontSize: 17, fontWeight: '700', marginBottom: 4 },
formActions: { flexDirection: 'row', gap: 8, marginTop: 4 },
label: { fontSize: 14, fontWeight: '600' },
list: { gap: 12, paddingBottom: 24 },
card: {
borderWidth: 1,
borderRadius: 16,
padding: 16,
},
cardHeader: { flexDirection: 'row', alignItems: 'flex-start', gap: 8 },
cardActions: { gap: 8, alignItems: 'flex-end' },
cardTitle: { fontSize: 16, fontWeight: '700', marginBottom: 2 },
macros: { flexDirection: 'row', flexWrap: 'wrap', gap: 12 },
macro: { minWidth: 72 },
note: { fontSize: 12, textAlign: 'center', marginTop: 8 },
empty: { paddingVertical: 32, alignItems: 'center', gap: 8 },
emptyTitle: { fontSize: 16, fontWeight: '700' },
});

View File

@@ -0,0 +1,376 @@
import { useMemo, useState } from 'react';
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { router } from 'expo-router';
import { useTranslation } from 'react-i18next';
import {
commitRecipes,
createRecipeSession,
generateRecipeDrafts,
regenerateRecipePart,
removeRecipeDraft,
} from '@/src/api/recipe-generator.api';
import { Button } from '@/src/components/Button';
import { Input } from '@/src/components/Input';
import { Screen } from '@/src/components/Screen';
import { useAppearance } from '@/src/context/AppearanceContext';
import type { CommitRecipeResult, GeneratedRecipeDraft, RecipeGeneratorConstraints } from '@/src/types/generator';
import { MealCategory } from '@/src/types/recipe';
import { extractApiError } from '@/src/utils/apiError';
import { ALL_CATEGORIES, categoryLabel } from '@/src/utils/recipe';
type Step = 'constraints' | 'review' | 'done';
export default function RecipeGeneratorScreen() {
const { t } = useTranslation();
const { colors } = useAppearance();
const [step, setStep] = useState<Step>('constraints');
const [sessionId, setSessionId] = useState<number | null>(null);
const [drafts, setDrafts] = useState<GeneratedRecipeDraft[]>([]);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [savedRecipes, setSavedRecipes] = useState<CommitRecipeResult[]>([]);
const [recipeCount, setRecipeCount] = useState('3');
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const [constraints, setConstraints] = useState<RecipeGeneratorConstraints>({
prompt: '',
mealCategory: MealCategory.Breakfast,
targetCalories: null,
calorieToleranceKcal: 10,
maxPrepTimeMinutes: 30,
cuisineStyle: '',
dietStyle: '',
exclusions: '',
});
const calorieTolerance = constraints.calorieToleranceKcal ?? 10;
function isWithinCalorieTarget(calories?: number | null): boolean {
if (!constraints.targetCalories || calories == null) return true;
return Math.abs(calories - constraints.targetCalories) <= calorieTolerance;
}
const allSelected = useMemo(
() => drafts.length > 0 && drafts.every((d) => selectedIds.has(d.draftId)),
[drafts, selectedIds],
);
function syncDrafts(next: GeneratedRecipeDraft[]) {
setDrafts(next);
setSelectedIds(new Set(next.map((d) => d.draftId)));
}
async function runGenerate() {
setError(null);
setPending(true);
const count = Math.min(8, Math.max(1, Number(recipeCount) || 3));
try {
let session;
if (sessionId) {
session = await generateRecipeDrafts(sessionId, count);
} else {
const created = await createRecipeSession(constraints);
setSessionId(created.id);
session = await generateRecipeDrafts(created.id, count);
}
syncDrafts(session.drafts ?? []);
setStep('review');
} catch (e) {
setError(extractApiError(e, t('generators.recipe.error')));
} finally {
setPending(false);
}
}
async function regen(draftId: string, mode: 'ingredients' | 'steps' | 'full') {
if (!sessionId) return;
setPending(true);
setError(null);
try {
const session = await regenerateRecipePart(sessionId, draftId, mode, constraints.prompt);
syncDrafts(session.drafts ?? []);
} catch (e) {
setError(extractApiError(e, t('generators.recipe.error')));
} finally {
setPending(false);
}
}
async function removeDraft(draftId: string) {
if (!sessionId) return;
setPending(true);
setError(null);
try {
const session = await removeRecipeDraft(sessionId, draftId);
const next = session.drafts ?? [];
syncDrafts(next);
if (next.length === 0) setStep('constraints');
} catch (e) {
setError(extractApiError(e, t('generators.recipe.error')));
} finally {
setPending(false);
}
}
async function saveSelected() {
if (!sessionId || selectedIds.size === 0) return;
setPending(true);
setError(null);
try {
const result = await commitRecipes(sessionId, [...selectedIds]);
setSavedRecipes(result.saved);
const remaining = drafts.filter((d) => !selectedIds.has(d.draftId));
if (remaining.length === 0) setStep('done');
else syncDrafts(remaining);
} catch (e) {
setError(extractApiError(e, t('generators.recipe.error')));
} finally {
setPending(false);
}
}
function toggleSelected(draftId: string) {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(draftId)) next.delete(draftId);
else next.add(draftId);
return next;
});
}
function toggleAll() {
setSelectedIds(allSelected ? new Set() : new Set(drafts.map((d) => d.draftId)));
}
return (
<Screen title={t('generators.recipe.title')} subtitle={t('generators.recipe.subtitle')}>
<ScrollView contentContainerStyle={styles.scroll}>
{error ? (
<View style={[styles.errorBox, { backgroundColor: colors.danger + '22' }]}>
<Text style={{ color: colors.danger }}>{error}</Text>
</View>
) : null}
{step === 'constraints' ? (
<View style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.border }]}>
<Text style={[styles.sectionTitle, { color: colors.text }]}>
{t('generators.recipe.constraintsTitle')}
</Text>
<Input
label={t('generators.recipe.prompt')}
multiline
placeholder={t('generators.recipe.promptPlaceholder')}
value={constraints.prompt}
onChangeText={(v) => setConstraints({ ...constraints, prompt: v })}
/>
<Input
label={t('generators.recipe.targetCalories')}
keyboardType="numeric"
placeholder={t('generators.recipe.targetCaloriesOptional')}
value={constraints.targetCalories != null ? String(constraints.targetCalories) : ''}
onChangeText={(v) =>
setConstraints({
...constraints,
targetCalories: v.trim() ? Number(v) || null : null,
})
}
/>
<Text style={{ color: colors.textMuted, fontSize: 12, marginBottom: 12 }}>
{t('generators.recipe.targetCaloriesHint')}
</Text>
<Input
label={t('generators.recipe.recipeCount')}
keyboardType="numeric"
value={recipeCount}
onChangeText={setRecipeCount}
/>
<Text style={[styles.label, { color: colors.text }]}>{t('common.category')}</Text>
<View style={styles.wrapRow}>
{ALL_CATEGORIES.map((cat) => (
<Pressable
key={cat}
onPress={() => setConstraints({ ...constraints, mealCategory: cat })}
style={[
styles.chip,
{
backgroundColor: constraints.mealCategory === cat ? colors.brand : colors.surface,
borderColor: constraints.mealCategory === cat ? colors.brand : colors.border,
},
]}
>
<Text style={{ color: constraints.mealCategory === cat ? '#fff' : colors.text, fontSize: 12 }}>
{categoryLabel(cat, t)}
</Text>
</Pressable>
))}
</View>
<Input
label={t('generators.recipe.maxPrep')}
keyboardType="numeric"
value={String(constraints.maxPrepTimeMinutes ?? '')}
onChangeText={(v) =>
setConstraints({ ...constraints, maxPrepTimeMinutes: Number(v) || undefined })
}
/>
<Input
label={t('generators.recipe.exclusions')}
value={constraints.exclusions ?? ''}
onChangeText={(v) => setConstraints({ ...constraints, exclusions: v })}
/>
<Button
title={t('generators.recipe.generate')}
loading={pending}
disabled={!constraints.prompt.trim()}
onPress={() => void runGenerate()}
/>
</View>
) : null}
{step === 'review' && drafts.length > 0 ? (
<View style={styles.gap}>
<View style={styles.rowBetween}>
<Text style={{ color: colors.textMuted, flex: 1 }}>
{t('generators.recipe.reviewHint', { count: drafts.length })}
</Text>
<Pressable onPress={toggleAll} style={styles.selectAll}>
<Text style={{ color: colors.brand, fontWeight: '600' }}>
{t('generators.recipe.selectAll')}
</Text>
</Pressable>
</View>
{drafts.map((draft) => (
<View
key={draft.draftId}
style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.border }]}
>
<View style={styles.rowBetween}>
<Pressable onPress={() => toggleSelected(draft.draftId)} style={styles.checkRow}>
<View
style={[
styles.checkbox,
{
borderColor: selectedIds.has(draft.draftId) ? colors.brand : colors.border,
backgroundColor: selectedIds.has(draft.draftId) ? colors.brand : 'transparent',
},
]}
/>
<View style={{ flex: 1 }}>
<Text style={[styles.sectionTitle, { color: colors.text }]}>{draft.name}</Text>
{draft.calories != null ? (
<Text
style={{
color:
constraints.targetCalories && !isWithinCalorieTarget(draft.calories)
? '#b45309'
: colors.textMuted,
fontSize: 13,
}}
>
{constraints.targetCalories
? t('generators.recipe.caloriesWithTarget', {
actual: draft.calories,
target: constraints.targetCalories,
})
: `${draft.calories} kcal`}
{' · '}
P {draft.protein ?? 0}g · F {draft.fat ?? 0}g · C {draft.carbs ?? 0}g
</Text>
) : null}
</View>
</Pressable>
</View>
{draft.unlinkedIngredientCount > 0 ? (
<Text style={{ color: '#b45309' }}>
{t('generators.recipe.unlinkedWarning', { count: draft.unlinkedIngredientCount })}
</Text>
) : null}
<Text style={[styles.label, { color: colors.text }]}>{t('recipes.ingredients')}</Text>
{draft.ingredients.map((ing, i) => (
<Text
key={i}
style={{ color: !ing.isLinked && ing.amountGrams ? '#b45309' : colors.text, fontSize: 14 }}
>
{ing.name} {ing.amountGrams ?? '?'}g {ing.unit ?? ''}
</Text>
))}
<Text style={[styles.label, { color: colors.text, marginTop: 8 }]}>
{t('manage.preparationSteps')}
</Text>
{draft.steps.map((s) => (
<Text key={s.stepNumber} style={{ color: colors.text, fontSize: 14 }}>
{s.stepNumber}. {s.description}
</Text>
))}
<View style={styles.gap}>
<Button
title={t('generators.recipe.regenIngredients')}
variant="secondary"
loading={pending}
onPress={() => void regen(draft.draftId, 'ingredients')}
/>
<Button
title={t('generators.recipe.remove')}
variant="danger"
disabled={pending}
onPress={() => void removeDraft(draft.draftId)}
/>
</View>
</View>
))}
<Button
title={t('generators.recipe.saveSelected', { count: selectedIds.size })}
loading={pending}
disabled={selectedIds.size === 0}
onPress={() => void saveSelected()}
/>
<Button title={t('common.back')} variant="secondary" onPress={() => setStep('constraints')} />
</View>
) : null}
{step === 'done' && savedRecipes.length > 0 ? (
<View style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.border }]}>
<Text style={[styles.sectionTitle, { color: colors.text }]}>
{t('generators.recipe.doneMultiple', { count: savedRecipes.length })}
</Text>
{savedRecipes.map((r) => (
<Pressable key={r.recipeId} onPress={() => router.push(`/(main)/recipe/${r.recipeId}`)}>
<Text style={{ color: colors.brand, fontSize: 16, marginBottom: 8 }}>{r.recipeName}</Text>
</Pressable>
))}
<Button
title={t('generators.recipe.generateMore')}
variant="secondary"
onPress={() => {
setStep('constraints');
setDrafts([]);
setSelectedIds(new Set());
setSavedRecipes([]);
setSessionId(null);
}}
/>
</View>
) : null}
</ScrollView>
</Screen>
);
}
const styles = StyleSheet.create({
scroll: { gap: 16, paddingBottom: 32 },
card: { borderWidth: 1, borderRadius: 16, padding: 16, gap: 12 },
sectionTitle: { fontSize: 18, fontWeight: '700' },
label: { fontSize: 14, fontWeight: '600' },
wrapRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
chip: { borderWidth: 1, borderRadius: 999, paddingHorizontal: 12, paddingVertical: 8 },
gap: { gap: 10 },
rowBetween: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 8 },
checkRow: { flexDirection: 'row', alignItems: 'flex-start', gap: 10, flex: 1 },
checkbox: { width: 22, height: 22, borderRadius: 6, borderWidth: 2, marginTop: 2 },
selectAll: { paddingVertical: 4, paddingHorizontal: 8 },
errorBox: { borderRadius: 12, padding: 12 },
});

View File

@@ -0,0 +1,205 @@
import { Stack, useLocalSearchParams, router } from 'expo-router';
import { useMemo, useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { fetchRecipeById } from '@/src/api/recipes.api';
import { Button } from '@/src/components/Button';
import { ErrorView } from '@/src/components/ErrorView';
import { Screen } from '@/src/components/Screen';
import { useAppearance } from '@/src/context/AppearanceContext';
import { categoryLabel } from '@/src/utils/recipe';
import { nextSort, sortBy, sortIndicator, type SortDirection } from '@/src/utils/sort';
type IngredientSortColumn = 'name' | 'amount';
export default function RecipeDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const recipeId = Number(id);
const { t } = useTranslation();
const { colors } = useAppearance();
const [ingredientSort, setIngredientSort] = useState<{
column: IngredientSortColumn;
direction: SortDirection;
}>({ column: 'name', direction: 'asc' });
const recipeQuery = useQuery({
queryKey: ['recipe', recipeId],
queryFn: () => fetchRecipeById(recipeId),
enabled: Number.isFinite(recipeId),
});
const sortedIngredients = useMemo(() => {
if (!recipeQuery.data) return [];
const { column, direction } = ingredientSort;
return sortBy(recipeQuery.data.ingredients, direction, (ing) =>
column === 'name' ? ing.name : ing.amountGrams,
);
}, [recipeQuery.data, ingredientSort]);
if (!Number.isFinite(recipeId)) {
return (
<Screen>
<ErrorView message={t('errors.loadRecipeFailed')} />
</Screen>
);
}
if (recipeQuery.isLoading) {
return <Screen loading />;
}
if (recipeQuery.isError || !recipeQuery.data) {
return (
<Screen>
<ErrorView
message={t('errors.loadRecipeFailed')}
onRetry={() => recipeQuery.refetch()}
/>
</Screen>
);
}
const recipe = recipeQuery.data;
return (
<>
<Stack.Screen options={{ title: recipe.name }} />
<Screen>
<Button title={t('common.edit')} variant="secondary" onPress={() => router.push(`/(main)/recipe/edit/${recipeId}`)} />
<View style={[styles.metaCard, { backgroundColor: colors.surface, borderColor: colors.border }]}>
<Text style={[styles.category, { color: colors.brand }]}>
{categoryLabel(recipe.mealCategory, t)}
</Text>
<View style={styles.metaRow}>
{recipe.calories != null ? (
<Text style={{ color: colors.textMuted }}>
{t('recipes.calories')}: {t('recipes.kcal', { count: recipe.calories })}
</Text>
) : null}
{recipe.prepTimeMinutes != null ? (
<Text style={{ color: colors.textMuted }}>
{t('recipes.minutes', { count: recipe.prepTimeMinutes })}
</Text>
) : null}
</View>
<View style={styles.macros}>
{recipe.protein != null ? (
<Text style={{ color: colors.text }}>{t('recipes.protein')}: {recipe.protein}g</Text>
) : null}
{recipe.fat != null ? (
<Text style={{ color: colors.text }}>{t('recipes.fat')}: {recipe.fat}g</Text>
) : null}
{recipe.carbs != null ? (
<Text style={{ color: colors.text }}>{t('recipes.carbs')}: {recipe.carbs}g</Text>
) : null}
</View>
</View>
<Text style={[styles.section, { color: colors.text }]}>{t('recipes.ingredients')}</Text>
{recipe.ingredients.length === 0 ? (
<Text style={{ color: colors.textMuted }}>{t('recipes.noIngredients')}</Text>
) : (
<>
<View style={styles.sortRow}>
<SortChip
label={`${t('ingredientCatalog.columns.name')}${sortIndicator(ingredientSort, 'name')}`}
active={ingredientSort.column === 'name'}
onPress={() => setIngredientSort((current) => nextSort(current, 'name'))}
/>
<SortChip
label={`${t('diet.grams')}${sortIndicator(ingredientSort, 'amount')}`}
active={ingredientSort.column === 'amount'}
onPress={() => setIngredientSort((current) => nextSort(current, 'amount'))}
/>
</View>
{sortedIngredients.map((ing) => (
<Text key={ing.id} style={[styles.line, { color: colors.text }]}>
{ing.name}
{ing.amountGrams != null ? `${ing.amountGrams}g` : ''}
{ing.unit ? ` ${ing.unit}` : ''}
</Text>
))}
</>
)}
<Text style={[styles.section, { color: colors.text }]}>{t('recipes.preparation')}</Text>
{recipe.steps.length === 0 ? (
<Text style={{ color: colors.textMuted }}>{t('recipes.noSteps')}</Text>
) : (
recipe.steps.map((step) => (
<Text key={step.id} style={[styles.step, { color: colors.text }]}>
{step.stepNumber}. {step.description}
</Text>
))
)}
</Screen>
</>
);
}
function SortChip({ label, active, onPress }: { label: string; active: boolean; onPress: () => void }) {
const { colors } = useAppearance();
return (
<Pressable
onPress={onPress}
style={[
styles.sortChip,
{
backgroundColor: active ? colors.brand : colors.surface,
borderColor: active ? colors.brand : colors.border,
},
]}
>
<Text style={{ color: active ? '#fff' : colors.text, fontWeight: '600', fontSize: 12 }}>{label}</Text>
</Pressable>
);
}
const styles = StyleSheet.create({
metaCard: {
borderWidth: 1,
borderRadius: 14,
padding: 14,
gap: 8,
},
category: {
fontSize: 14,
fontWeight: '700',
textTransform: 'uppercase',
},
metaRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 12,
},
macros: {
gap: 4,
},
section: {
fontSize: 18,
fontWeight: '700',
marginTop: 8,
},
sortRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
marginBottom: 8,
},
sortChip: {
borderWidth: 1,
borderRadius: 999,
paddingHorizontal: 12,
paddingVertical: 6,
},
line: {
fontSize: 15,
lineHeight: 22,
},
step: {
fontSize: 15,
lineHeight: 22,
marginBottom: 6,
},
});

View File

@@ -0,0 +1,202 @@
import { Stack, useLocalSearchParams, router } from 'expo-router';
import { useEffect, useState } from 'react';
import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { fetchIngredientCatalogItems } from '@/src/api/ingredient-catalog.api';
import { updateRecipe } from '@/src/api/recipe-management.api';
import { fetchRecipeById } from '@/src/api/recipes.api';
import { Button } from '@/src/components/Button';
import { ErrorView } from '@/src/components/ErrorView';
import { Input } from '@/src/components/Input';
import { Screen } from '@/src/components/Screen';
import { useAppearance } from '@/src/context/AppearanceContext';
import { MealCategory } from '@/src/types/recipe';
import { extractApiError } from '@/src/utils/apiError';
import { ingredientDisplayName } from '@/src/utils/ingredient-catalog.util';
import { ALL_CATEGORIES, categoryLabel, parseOptionalNumber } from '@/src/utils/recipe';
interface IngredientRow {
name: string;
amountGrams: string;
unit: string;
catalogItemId: number | null;
}
interface StepRow {
description: string;
}
export default function EditRecipeScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const recipeId = Number(id);
const { t, i18n } = useTranslation();
const { colors } = useAppearance();
const queryClient = useQueryClient();
const recipeQuery = useQuery({
queryKey: ['recipe', recipeId],
queryFn: () => fetchRecipeById(recipeId),
enabled: Number.isFinite(recipeId),
});
const catalogQuery = useQuery({
queryKey: ['ingredient-catalog', 'all'],
queryFn: () => fetchIngredientCatalogItems(),
});
const [name, setName] = useState('');
const [category, setCategory] = useState<MealCategory>(MealCategory.Lunch);
const [prepTime, setPrepTime] = useState('');
const [calories, setCalories] = useState('');
const [protein, setProtein] = useState('');
const [fat, setFat] = useState('');
const [carbs, setCarbs] = useState('');
const [ingredients, setIngredients] = useState<IngredientRow[]>([]);
const [steps, setSteps] = useState<StepRow[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const recipe = recipeQuery.data;
if (!recipe) return;
setName(recipe.name);
setCategory(recipe.mealCategory);
setPrepTime(recipe.prepTimeMinutes?.toString() ?? '');
setCalories(recipe.calories?.toString() ?? '');
setProtein(recipe.protein?.toString() ?? '');
setFat(recipe.fat?.toString() ?? '');
setCarbs(recipe.carbs?.toString() ?? '');
setIngredients(
recipe.ingredients.length > 0
? recipe.ingredients.map((i) => ({
name: i.name,
amountGrams: i.amountGrams?.toString() ?? '',
unit: i.unit ?? '',
catalogItemId: i.catalogItemId ?? null,
}))
: [{ name: '', amountGrams: '', unit: '', catalogItemId: null }],
);
setSteps(
recipe.steps.length > 0
? recipe.steps.map((s) => ({ description: s.description }))
: [{ description: '' }],
);
}, [recipeQuery.data]);
const saveMutation = useMutation({
mutationFn: () => {
const filteredSteps = steps.filter((s) => s.description.trim());
return updateRecipe(recipeId, {
name: name.trim(),
mealCategory: category,
calories: parseOptionalNumber(calories),
protein: parseOptionalNumber(protein),
fat: parseOptionalNumber(fat),
carbs: parseOptionalNumber(carbs),
prepTimeMinutes: parseOptionalNumber(prepTime),
ingredients: ingredients
.filter((i) => i.name.trim())
.map((i, index) => ({
name: i.name.trim(),
amountGrams: parseOptionalNumber(i.amountGrams),
unit: i.unit,
catalogItemId: i.catalogItemId,
sortOrder: index,
})),
steps: filteredSteps.map((s, index) => ({ stepNumber: index + 1, description: s.description.trim() })),
});
},
onSuccess: (recipe) => {
queryClient.invalidateQueries({ queryKey: ['recipes'] });
queryClient.invalidateQueries({ queryKey: ['recipe', recipeId] });
Alert.alert(t('manage.title'), t('manage.updatedSuccess', { name: recipe.name }));
router.back();
},
onError: (err) => setError(extractApiError(err, t('errors.saveRecipeFailed'))),
});
if (!Number.isFinite(recipeId)) {
return (
<Screen>
<ErrorView message={t('errors.loadRecipeFailed')} />
</Screen>
);
}
if (recipeQuery.isLoading) return <Screen loading />;
if (recipeQuery.isError || !recipeQuery.data) {
return (
<Screen>
<ErrorView message={t('errors.loadRecipeFailed')} onRetry={() => recipeQuery.refetch()} />
</Screen>
);
}
const catalogItems = catalogQuery.data ?? [];
return (
<>
<Stack.Screen options={{ title: t('manage.editRecipe') }} />
<Screen title={t('manage.editRecipe')} subtitle={recipeQuery.data.name}>
<ScrollView contentContainerStyle={styles.scroll} keyboardShouldPersistTaps="handled">
<Input label={t('manage.recipeName')} value={name} onChangeText={setName} />
<Text style={[styles.label, { color: colors.text }]}>{t('common.category')}</Text>
<View style={styles.chips}>
{ALL_CATEGORIES.map((cat) => (
<Pressable
key={cat}
onPress={() => setCategory(cat)}
style={[styles.chip, { backgroundColor: category === cat ? colors.brand : colors.surface, borderColor: category === cat ? colors.brand : colors.border }]}
>
<Text style={{ color: category === cat ? '#fff' : colors.text, fontSize: 13 }}>{categoryLabel(cat, t)}</Text>
</Pressable>
))}
</View>
<View style={styles.row}>
<View style={styles.half}><Input label={t('manage.prepTimeMin')} keyboardType="numeric" value={prepTime} onChangeText={setPrepTime} /></View>
<View style={styles.half}><Input label={t('manage.caloriesKcal')} keyboardType="numeric" value={calories} onChangeText={setCalories} /></View>
</View>
<Text style={[styles.section, { color: colors.text }]}>{t('recipes.ingredients')}</Text>
{ingredients.map((ing, index) => (
<View key={index} style={styles.block}>
<Input label={t('manage.ingredientName')} value={ing.name} onChangeText={(v) => { const next = [...ingredients]; next[index] = { ...next[index], name: v }; setIngredients(next); }} />
<Text style={[styles.label, { color: colors.text }]}>{t('manage.catalogItem')}</Text>
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.chips}>
<Pressable onPress={() => { const next = [...ingredients]; next[index] = { ...next[index], catalogItemId: null }; setIngredients(next); }} style={[styles.chip, { backgroundColor: ing.catalogItemId == null ? colors.brand : colors.surface, borderColor: ing.catalogItemId == null ? colors.brand : colors.border }]}>
<Text style={{ color: ing.catalogItemId == null ? '#fff' : colors.text, fontSize: 12 }}>{t('diet.noCatalogItem')}</Text>
</Pressable>
{catalogItems.map((item) => (
<Pressable key={item.id} onPress={() => { const next = [...ingredients]; next[index] = { ...next[index], catalogItemId: item.id, name: ingredientDisplayName(item, i18n.language) }; setIngredients(next); }} style={[styles.chip, { backgroundColor: ing.catalogItemId === item.id ? colors.brand : colors.surface, borderColor: ing.catalogItemId === item.id ? colors.brand : colors.border }]}>
<Text style={{ color: ing.catalogItemId === item.id ? '#fff' : colors.text, fontSize: 12 }}>{ingredientDisplayName(item, i18n.language)}</Text>
</Pressable>
))}
</ScrollView>
<View style={styles.row}>
<View style={styles.half}><Input label={t('manage.ingredientGrams')} keyboardType="numeric" value={ing.amountGrams} onChangeText={(v) => { const next = [...ingredients]; next[index] = { ...next[index], amountGrams: v }; setIngredients(next); }} /></View>
<View style={styles.half}><Input label={t('manage.ingredientUnit')} value={ing.unit} onChangeText={(v) => { const next = [...ingredients]; next[index] = { ...next[index], unit: v }; setIngredients(next); }} /></View>
</View>
</View>
))}
<Text style={[styles.section, { color: colors.text }]}>{t('manage.preparationSteps')}</Text>
{steps.map((step, index) => (
<Input key={index} label={`${index + 1}.`} value={step.description} onChangeText={(v) => { const next = [...steps]; next[index] = { description: v }; setSteps(next); }} multiline />
))}
{error ? <Text style={{ color: colors.danger }}>{error}</Text> : null}
<Button title={t('common.save')} loading={saveMutation.isPending} onPress={() => saveMutation.mutate()} />
</ScrollView>
</Screen>
</>
);
}
const styles = StyleSheet.create({
scroll: { gap: 12, paddingBottom: 32 },
section: { fontSize: 18, fontWeight: '700' },
label: { fontSize: 14, fontWeight: '600' },
chips: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
chip: { borderWidth: 1, borderRadius: 999, paddingHorizontal: 12, paddingVertical: 8 },
row: { flexDirection: 'row', gap: 10 },
half: { flex: 1 },
block: { gap: 8 },
});

View File

@@ -0,0 +1,39 @@
import { ScrollViewStyleReset } from 'expo-router/html';
import type { ReactNode } from 'react';
// This file is web-only and used to configure the root HTML for every
// web page during static rendering.
// The contents of this function only run in Node.js environments and
// do not have access to the DOM or browser APIs.
export default function Root({ children }: { children: ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
{/*
Disable body scrolling on web. This makes ScrollView components work closer to how they do on native.
However, body scrolling is often nice to have for mobile web. If you want to enable it, remove this line.
*/}
<ScrollViewStyleReset />
{/* Using raw CSS styles as an escape-hatch to ensure the background color never flickers in dark-mode. */}
<style dangerouslySetInnerHTML={{ __html: responsiveBackground }} />
{/* Add any additional <head> elements that you want globally available on web... */}
</head>
<body>{children}</body>
</html>
);
}
const responsiveBackground = `
body {
background-color: #fff;
}
@media (prefers-color-scheme: dark) {
body {
background-color: #000;
}
}`;

View File

@@ -0,0 +1,47 @@
import { Link, Stack } from 'expo-router';
import { StyleSheet, Text, View } from 'react-native';
import { useTranslation } from 'react-i18next';
import { useAppearance } from '@/src/context/AppearanceContext';
export default function NotFoundScreen() {
const { t } = useTranslation();
const { colors } = useAppearance();
return (
<>
<Stack.Screen options={{ title: t('notFound.title') }} />
<View style={[styles.container, { backgroundColor: colors.background }]}>
<Text style={[styles.title, { color: colors.text }]}>{t('notFound.title')}</Text>
<Text style={[styles.description, { color: colors.textMuted }]}>
{t('notFound.description')}
</Text>
<Link href="/" style={[styles.link, { color: colors.brand }]}>
{t('notFound.backToDashboard')}
</Link>
</View>
</>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
padding: 20,
gap: 12,
},
title: {
fontSize: 22,
fontWeight: '700',
},
description: {
fontSize: 15,
textAlign: 'center',
},
link: {
marginTop: 8,
fontSize: 16,
fontWeight: '600',
},
});

View File

@@ -0,0 +1,70 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useFonts } from 'expo-font';
import { Stack } from 'expo-router';
import * as SplashScreen from 'expo-splash-screen';
import { useEffect, useState } from 'react';
import { I18nextProvider } from 'react-i18next';
import 'react-native-reanimated';
import { AuthProvider } from '@/src/context/AuthContext';
import { AppearanceProvider } from '@/src/context/AppearanceContext';
import { ReminderNotificationBridge } from '@/src/components/ReminderNotificationBridge';
import i18n, { initI18n } from '@/src/i18n';
export { ErrorBoundary } from 'expo-router';
SplashScreen.preventAutoHideAsync();
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
staleTime: 30_000,
},
},
});
export default function RootLayout() {
const [ready, setReady] = useState(false);
const [loaded, error] = useFonts({
SpaceMono: require('../assets/fonts/SpaceMono-Regular.ttf'),
});
useEffect(() => {
if (error) throw error;
}, [error]);
useEffect(() => {
(async () => {
await initI18n();
setReady(true);
})();
}, []);
useEffect(() => {
if (loaded && ready) {
SplashScreen.hideAsync();
}
}, [loaded, ready]);
if (!loaded || !ready) {
return null;
}
return (
<I18nextProvider i18n={i18n}>
<QueryClientProvider client={queryClient}>
<AppearanceProvider>
<AuthProvider>
<ReminderNotificationBridge />
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="index" />
<Stack.Screen name="(auth)" />
<Stack.Screen name="(main)" />
</Stack>
</AuthProvider>
</AppearanceProvider>
</QueryClientProvider>
</I18nextProvider>
);
}

View File

@@ -0,0 +1,17 @@
import { Redirect } from 'expo-router';
import { useAuth } from '@/src/context/AuthContext';
import { Screen } from '@/src/components/Screen';
export default function Index() {
const { isAuthenticated, isLoading } = useAuth();
if (isLoading) {
return <Screen loading />;
}
if (isAuthenticated) {
return <Redirect href="/(main)/(tabs)" />;
}
return <Redirect href="/(auth)/login" />;
}