* Extended functionalities
* Added different UIs
This commit is contained in:
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 },
|
||||
});
|
||||
Reference in New Issue
Block a user