946 lines
38 KiB
TypeScript
946 lines
38 KiB
TypeScript
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 },
|
|
});
|