Files
Piotr Kus f15bb7a916 * Extended functionalities
* Added different UIs
2026-06-24 21:43:40 +02:00

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