* 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' },
|
||||
});
|
||||
Reference in New Issue
Block a user