* Extended functionalities
* Added different UIs
This commit is contained in:
80
meal-plan-frontend-mobile/app/(main)/(tabs)/_layout.tsx
Normal file
80
meal-plan-frontend-mobile/app/(main)/(tabs)/_layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
945
meal-plan-frontend-mobile/app/(main)/(tabs)/diet.tsx
Normal file
945
meal-plan-frontend-mobile/app/(main)/(tabs)/diet.tsx
Normal 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 },
|
||||
});
|
||||
198
meal-plan-frontend-mobile/app/(main)/(tabs)/index.tsx
Normal file
198
meal-plan-frontend-mobile/app/(main)/(tabs)/index.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
362
meal-plan-frontend-mobile/app/(main)/(tabs)/manage.tsx
Normal file
362
meal-plan-frontend-mobile/app/(main)/(tabs)/manage.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
163
meal-plan-frontend-mobile/app/(main)/(tabs)/meals.tsx
Normal file
163
meal-plan-frontend-mobile/app/(main)/(tabs)/meals.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
348
meal-plan-frontend-mobile/app/(main)/(tabs)/planner.tsx
Normal file
348
meal-plan-frontend-mobile/app/(main)/(tabs)/planner.tsx
Normal 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 },
|
||||
});
|
||||
154
meal-plan-frontend-mobile/app/(main)/(tabs)/settings.tsx
Normal file
154
meal-plan-frontend-mobile/app/(main)/(tabs)/settings.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
126
meal-plan-frontend-mobile/app/(main)/(tabs)/shopping.tsx
Normal file
126
meal-plan-frontend-mobile/app/(main)/(tabs)/shopping.tsx
Normal 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' },
|
||||
});
|
||||
38
meal-plan-frontend-mobile/app/(main)/_layout.tsx
Normal file
38
meal-plan-frontend-mobile/app/(main)/_layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
152
meal-plan-frontend-mobile/app/(main)/category/[category].tsx
Normal file
152
meal-plan-frontend-mobile/app/(main)/category/[category].tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
479
meal-plan-frontend-mobile/app/(main)/diet-generator.tsx
Normal file
479
meal-plan-frontend-mobile/app/(main)/diet-generator.tsx
Normal 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 },
|
||||
});
|
||||
434
meal-plan-frontend-mobile/app/(main)/ingredients.tsx
Normal file
434
meal-plan-frontend-mobile/app/(main)/ingredients.tsx
Normal 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' },
|
||||
});
|
||||
376
meal-plan-frontend-mobile/app/(main)/recipe-generator.tsx
Normal file
376
meal-plan-frontend-mobile/app/(main)/recipe-generator.tsx
Normal 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 },
|
||||
});
|
||||
205
meal-plan-frontend-mobile/app/(main)/recipe/[id].tsx
Normal file
205
meal-plan-frontend-mobile/app/(main)/recipe/[id].tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
202
meal-plan-frontend-mobile/app/(main)/recipe/edit/[id].tsx
Normal file
202
meal-plan-frontend-mobile/app/(main)/recipe/edit/[id].tsx
Normal 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 },
|
||||
});
|
||||
Reference in New Issue
Block a user