* 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,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 },
});