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

349 lines
16 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useMemo, useState } from 'react';
import { Alert, Modal, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { router } from 'expo-router';
import { useTranslation } from 'react-i18next';
import { deleteMealPlanEntry, fetchMealPlanEntries, upsertMealPlanEntry } from '@/src/api/meal-plan.api';
import { fetchGoals } from '@/src/api/diet.api';
import { fetchRecipes } from '@/src/api/recipes.api';
import { Button } from '@/src/components/Button';
import { ErrorView } from '@/src/components/ErrorView';
import { Input } from '@/src/components/Input';
import { Screen } from '@/src/components/Screen';
import { useAppearance } from '@/src/context/AppearanceContext';
import { CONSUMPTION_CATEGORIES } from '@/src/types/diet';
import type { MealPlanEntry } from '@/src/types/meal-plan';
import { MealCategory } from '@/src/types/recipe';
import { addDays, formatDisplayDate, weekDays, weekRange } from '@/src/utils/date';
import { categoryLabel } from '@/src/utils/recipe';
import {
computeDayMealPlanning,
estimateEntryCalories,
rankRecipeSuggestions,
} from '@/src/utils/mealPlannerSuggestions';
const PLANNER_CATEGORIES = CONSUMPTION_CATEGORIES.filter((c) => c.value <= MealCategory.Dinner);
export default function PlannerScreen() {
const { t, i18n } = useTranslation();
const { colors } = useAppearance();
const queryClient = useQueryClient();
const [weekAnchor, setWeekAnchor] = useState(() => weekRange().from);
const { from, to } = useMemo(() => weekRange(weekAnchor), [weekAnchor]);
const days = useMemo(() => weekDays(from), [from]);
const [picker, setPicker] = useState<{ date: string; mealCategory: number; entry?: MealPlanEntry } | null>(null);
const [recipeSearch, setRecipeSearch] = useState('');
const [portions, setPortions] = useState('1');
const [dailyTargets, setDailyTargets] = useState<Record<string, number>>({});
const [selectedRecipeId, setSelectedRecipeId] = useState<number | null>(null);
const entriesQuery = useQuery({
queryKey: ['meal-plan', from, to],
queryFn: () => fetchMealPlanEntries(from, to),
});
const goalsQuery = useQuery({
queryKey: ['diet-goals'],
queryFn: fetchGoals,
});
const recipesQuery = useQuery({
queryKey: ['recipes', 'planner-search', recipeSearch],
queryFn: () => fetchRecipes({ search: recipeSearch }),
enabled: !!picker && recipeSearch.trim().length >= 2,
});
const suggestionsQuery = useQuery({
queryKey: ['recipes', 'planner-suggestions', picker?.mealCategory],
queryFn: () => fetchRecipes({ category: picker!.mealCategory as MealCategory }),
enabled: !!picker,
});
const entries = entriesQuery.data ?? [];
const dailyTarget = picker
? (dailyTargets[picker.date] ?? goalsQuery.data?.calorieGoal ?? 0)
: 0;
const selectedRecipe = useMemo(() => {
if (!selectedRecipeId) return null;
return (
recipesQuery.data?.find((r) => r.id === selectedRecipeId) ??
suggestionsQuery.data?.find((r) => r.id === selectedRecipeId) ??
null
);
}, [selectedRecipeId, recipesQuery.data, suggestionsQuery.data]);
const estimatedMealCalories = estimateEntryCalories(selectedRecipe, Number(portions) || 1);
const dayPlanning = useMemo(() => {
if (!picker) return null;
const pending =
estimatedMealCalories != null && estimatedMealCalories > 0
? { category: picker.mealCategory, calories: estimatedMealCalories }
: null;
return computeDayMealPlanning(picker.date, picker.mealCategory, entries, dailyTarget, pending);
}, [picker, entries, dailyTarget, estimatedMealCalories]);
const suggestions = useMemo(() => {
if (!picker || !dayPlanning || !suggestionsQuery.data) return [];
return rankRecipeSuggestions(suggestionsQuery.data, picker.mealCategory, dayPlanning.slotTarget);
}, [picker, dayPlanning, suggestionsQuery.data]);
const upsertMutation = useMutation({
mutationFn: upsertMealPlanEntry,
onSuccess: () => {
setPicker(null);
setRecipeSearch('');
setPortions('1');
setSelectedRecipeId(null);
void queryClient.invalidateQueries({ queryKey: ['meal-plan'] });
},
});
const deleteMutation = useMutation({
mutationFn: deleteMealPlanEntry,
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['meal-plan'] }),
});
function selectFromSearch(recipeId: number) {
setSelectedRecipeId(recipeId);
setRecipeSearch('');
}
function selectFromSuggestion(recipeId: number) {
setSelectedRecipeId(recipeId);
}
function clearSelection() {
setSelectedRecipeId(null);
}
function entryFor(date: string, mealCategory: number): MealPlanEntry | undefined {
return entriesQuery.data?.find((e) => e.planDate === date && e.mealCategory === mealCategory);
}
return (
<Screen title={t('planner.title')} subtitle={t('planner.subtitle')}>
<View style={styles.header}>
<Pressable
onPress={() => setWeekAnchor(addDays(from, -7))}
style={[styles.navBtn, { borderColor: colors.border, backgroundColor: colors.surface }]}
>
<Text style={{ color: colors.text }}></Text>
</Pressable>
<Text style={{ color: colors.text, fontWeight: '700', flex: 1, textAlign: 'center' }}>
{formatDisplayDate(from, i18n.language)} {formatDisplayDate(to, i18n.language)}
</Text>
<Pressable
onPress={() => setWeekAnchor(addDays(from, 7))}
style={[styles.navBtn, { borderColor: colors.border, backgroundColor: colors.surface }]}
>
<Text style={{ color: colors.text }}></Text>
</Pressable>
</View>
<Button title={t('planner.openShopping')} variant="secondary" onPress={() => router.push('/(main)/(tabs)/shopping')} />
{entriesQuery.isLoading ? (
<Text style={{ color: colors.textMuted }}>{t('common.loading')}</Text>
) : entriesQuery.isError ? (
<ErrorView message={t('errors.loadPlannerFailed')} onRetry={() => entriesQuery.refetch()} />
) : (
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View>
<View style={styles.row}>
<View style={styles.corner} />
{days.map((day) => (
<View key={day} style={[styles.dayHead, { borderColor: colors.border }]}>
<Text style={{ color: colors.textMuted, fontSize: 11, fontWeight: '600' }}>
{formatDisplayDate(day, i18n.language)}
</Text>
</View>
))}
</View>
{PLANNER_CATEGORIES.map((cat) => (
<View key={cat.value} style={styles.row}>
<View style={[styles.catLabel, { borderColor: colors.border, backgroundColor: colors.surface }]}>
<Text style={{ color: colors.text, fontSize: 11, fontWeight: '600' }}>{t(cat.labelKey)}</Text>
</View>
{days.map((day) => {
const entry = entryFor(day, cat.value);
return (
<Pressable
key={`${day}-${cat.value}`}
onPress={() => {
setPicker({ date: day, mealCategory: cat.value, entry });
setRecipeSearch('');
setPortions('1');
setSelectedRecipeId(null);
}}
onLongPress={() => {
if (!entry) return;
Alert.alert(t('planner.removeEntry'), entry.recipeName, [
{ text: t('common.cancel'), style: 'cancel' },
{ text: t('common.delete'), style: 'destructive', onPress: () => deleteMutation.mutate(entry.id) },
]);
}}
style={[styles.cell, { borderColor: colors.border, backgroundColor: colors.surface }]}
>
<Text style={{ color: entry ? colors.text : colors.textMuted, fontSize: 11 }} numberOfLines={2}>
{entry?.recipeName ?? '+'}
</Text>
</Pressable>
);
})}
</View>
))}
</View>
</ScrollView>
)}
<Modal visible={!!picker} transparent animationType="slide" onRequestClose={() => setPicker(null)}>
<View style={styles.modalBackdrop}>
<View style={[styles.modal, { backgroundColor: colors.background }]}>
<Text style={[styles.modalTitle, { color: colors.text }]}>{t('planner.assignRecipe')}</Text>
{picker ? (
<Text style={{ color: colors.textMuted, marginBottom: 8 }}>
{formatDisplayDate(picker.date, i18n.language)} · {categoryLabel(picker.mealCategory as MealCategory, t)}
</Text>
) : null}
<Input
label={t('planner.dailyTarget')}
keyboardType="number-pad"
value={String(dailyTarget || '')}
onChangeText={(text) => {
if (!picker) return;
const value = Number(text);
setDailyTargets((prev) => ({
...prev,
[picker.date]: Number.isFinite(value) ? value : 0,
}));
}}
/>
{dayPlanning && dailyTarget > 0 ? (
<View style={[styles.summaryBox, { backgroundColor: colors.surface, borderColor: colors.border }]}>
<Text style={{ color: colors.text, fontSize: 13 }}>
{t('planner.daySummary', { logged: dayPlanning.logged, target: dayPlanning.dailyTarget })}
</Text>
<Text style={{ color: colors.textMuted, fontSize: 11, marginTop: 4 }}>
{t('planner.remaining', { count: Math.max(0, dayPlanning.remaining) })}
{dayPlanning.slotTarget > 0
? ` · ${t('planner.slotHint', { count: dayPlanning.slotTarget })}`
: ''}
{dayPlanning.pendingCalories > 0
? ` · ${t('planner.includingSelection', { count: dayPlanning.pendingCalories })}`
: ''}
</Text>
</View>
) : (
<Text style={{ color: colors.textMuted, fontSize: 12 }}>{t('planner.noSuggestions')}</Text>
)}
{suggestions.length > 0 ? (
<View>
<Text style={{ color: colors.text, fontWeight: '600', marginBottom: 6 }}>
{t('planner.suggestionsTitle')}
</Text>
<ScrollView style={{ maxHeight: 160 }}>
{suggestions.map(({ recipe, diff }) => (
<Pressable
key={recipe.id}
onPress={() => selectFromSuggestion(recipe.id)}
style={[
styles.recipePick,
{
borderColor: selectedRecipeId === recipe.id ? colors.brand : colors.border,
borderWidth: selectedRecipeId === recipe.id ? 2 : 1,
backgroundColor: colors.surface,
},
]}
>
<Text style={{ color: colors.text }}>{recipe.name}</Text>
<Text style={{ color: colors.textMuted, fontSize: 11 }}>
{recipe.calories} kcal{diff > 0 ? ` · ±${diff}` : ''}
</Text>
</Pressable>
))}
</ScrollView>
</View>
) : null}
<Input placeholder={t('recipes.searchRecipes')} value={recipeSearch} onChangeText={setRecipeSearch} />
{recipeSearch.trim().length >= 2 && !selectedRecipeId ? (
<ScrollView style={{ maxHeight: 200 }}>
{recipesQuery.data?.map((r) => (
<Pressable
key={r.id}
onPress={() => selectFromSearch(r.id)}
style={[styles.recipePick, { borderColor: colors.border, backgroundColor: colors.surface }]}
>
<Text style={{ color: colors.text }}>{r.name}</Text>
{r.calories != null ? (
<Text style={{ color: colors.textMuted, fontSize: 11 }}>{r.calories} kcal</Text>
) : null}
</Pressable>
))}
{recipesQuery.isFetched && !recipesQuery.isLoading && recipesQuery.data?.length === 0 ? (
<Text style={{ color: colors.textMuted, fontSize: 12 }}>{t('recipes.noMatchingTitle')}</Text>
) : null}
</ScrollView>
) : null}
{selectedRecipe ? (
<View style={[styles.summaryBox, { backgroundColor: colors.surface, borderColor: colors.brand }]}>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', gap: 8 }}>
<Text style={{ color: colors.text, fontSize: 13, flex: 1 }}>
{t('planner.selectedRecipe', { name: selectedRecipe.name })}
{estimatedMealCalories != null
? ` · ${estimatedMealCalories} kcal`
: selectedRecipe.calories != null
? ` · ${selectedRecipe.calories} kcal`
: ''}
</Text>
<Pressable onPress={clearSelection} hitSlop={8}>
<Text style={{ color: colors.textMuted }}></Text>
</Pressable>
</View>
</View>
) : null}
<Input label={t('diet.portions')} keyboardType="decimal-pad" value={portions} onChangeText={setPortions} />
{estimatedMealCalories != null ? (
<Text style={{ color: colors.textMuted, fontSize: 12 }}>
{t('planner.estimatedMeal', { count: estimatedMealCalories })}
</Text>
) : null}
<Button
title={t('common.save')}
disabled={!selectedRecipeId || upsertMutation.isPending}
onPress={() => {
if (!picker || !selectedRecipeId) return;
upsertMutation.mutate({
planDate: picker.date,
mealCategory: picker.mealCategory,
recipeId: selectedRecipeId,
portions: Number(portions) || 1,
});
}}
/>
<Button title={t('common.cancel')} variant="ghost" onPress={() => setPicker(null)} />
</View>
</View>
</Modal>
</Screen>
);
}
const styles = StyleSheet.create({
header: { flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 8 },
navBtn: { borderWidth: 1, borderRadius: 8, width: 36, height: 36, alignItems: 'center', justifyContent: 'center' },
row: { flexDirection: 'row' },
corner: { width: 72 },
dayHead: { width: 88, padding: 6, borderWidth: 1, alignItems: 'center' },
catLabel: { width: 72, padding: 6, borderWidth: 1, justifyContent: 'center' },
cell: { width: 88, height: 56, padding: 6, borderWidth: 1, justifyContent: 'center' },
modalBackdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.45)', justifyContent: 'flex-end' },
modal: { borderTopLeftRadius: 16, borderTopRightRadius: 16, padding: 16, gap: 10, maxHeight: '80%' },
modalTitle: { fontSize: 18, fontWeight: '700' },
summaryBox: { borderWidth: 1, borderRadius: 10, padding: 10 },
recipePick: { borderWidth: 1, borderRadius: 8, padding: 10, marginBottom: 6 },
});