* Extended functionalities

* Added different UIs
This commit is contained in:
2026-06-24 21:43:40 +02:00
parent 282f4864c4
commit f15bb7a916
348 changed files with 59057 additions and 498 deletions

View File

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