* 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,205 @@
import { Stack, useLocalSearchParams, router } from 'expo-router';
import { useMemo, useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { fetchRecipeById } from '@/src/api/recipes.api';
import { Button } from '@/src/components/Button';
import { ErrorView } from '@/src/components/ErrorView';
import { Screen } from '@/src/components/Screen';
import { useAppearance } from '@/src/context/AppearanceContext';
import { categoryLabel } from '@/src/utils/recipe';
import { nextSort, sortBy, sortIndicator, type SortDirection } from '@/src/utils/sort';
type IngredientSortColumn = 'name' | 'amount';
export default function RecipeDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const recipeId = Number(id);
const { t } = useTranslation();
const { colors } = useAppearance();
const [ingredientSort, setIngredientSort] = useState<{
column: IngredientSortColumn;
direction: SortDirection;
}>({ column: 'name', direction: 'asc' });
const recipeQuery = useQuery({
queryKey: ['recipe', recipeId],
queryFn: () => fetchRecipeById(recipeId),
enabled: Number.isFinite(recipeId),
});
const sortedIngredients = useMemo(() => {
if (!recipeQuery.data) return [];
const { column, direction } = ingredientSort;
return sortBy(recipeQuery.data.ingredients, direction, (ing) =>
column === 'name' ? ing.name : ing.amountGrams,
);
}, [recipeQuery.data, ingredientSort]);
if (!Number.isFinite(recipeId)) {
return (
<Screen>
<ErrorView message={t('errors.loadRecipeFailed')} />
</Screen>
);
}
if (recipeQuery.isLoading) {
return <Screen loading />;
}
if (recipeQuery.isError || !recipeQuery.data) {
return (
<Screen>
<ErrorView
message={t('errors.loadRecipeFailed')}
onRetry={() => recipeQuery.refetch()}
/>
</Screen>
);
}
const recipe = recipeQuery.data;
return (
<>
<Stack.Screen options={{ title: recipe.name }} />
<Screen>
<Button title={t('common.edit')} variant="secondary" onPress={() => router.push(`/(main)/recipe/edit/${recipeId}`)} />
<View style={[styles.metaCard, { backgroundColor: colors.surface, borderColor: colors.border }]}>
<Text style={[styles.category, { color: colors.brand }]}>
{categoryLabel(recipe.mealCategory, t)}
</Text>
<View style={styles.metaRow}>
{recipe.calories != null ? (
<Text style={{ color: colors.textMuted }}>
{t('recipes.calories')}: {t('recipes.kcal', { count: recipe.calories })}
</Text>
) : null}
{recipe.prepTimeMinutes != null ? (
<Text style={{ color: colors.textMuted }}>
{t('recipes.minutes', { count: recipe.prepTimeMinutes })}
</Text>
) : null}
</View>
<View style={styles.macros}>
{recipe.protein != null ? (
<Text style={{ color: colors.text }}>{t('recipes.protein')}: {recipe.protein}g</Text>
) : null}
{recipe.fat != null ? (
<Text style={{ color: colors.text }}>{t('recipes.fat')}: {recipe.fat}g</Text>
) : null}
{recipe.carbs != null ? (
<Text style={{ color: colors.text }}>{t('recipes.carbs')}: {recipe.carbs}g</Text>
) : null}
</View>
</View>
<Text style={[styles.section, { color: colors.text }]}>{t('recipes.ingredients')}</Text>
{recipe.ingredients.length === 0 ? (
<Text style={{ color: colors.textMuted }}>{t('recipes.noIngredients')}</Text>
) : (
<>
<View style={styles.sortRow}>
<SortChip
label={`${t('ingredientCatalog.columns.name')}${sortIndicator(ingredientSort, 'name')}`}
active={ingredientSort.column === 'name'}
onPress={() => setIngredientSort((current) => nextSort(current, 'name'))}
/>
<SortChip
label={`${t('diet.grams')}${sortIndicator(ingredientSort, 'amount')}`}
active={ingredientSort.column === 'amount'}
onPress={() => setIngredientSort((current) => nextSort(current, 'amount'))}
/>
</View>
{sortedIngredients.map((ing) => (
<Text key={ing.id} style={[styles.line, { color: colors.text }]}>
{ing.name}
{ing.amountGrams != null ? `${ing.amountGrams}g` : ''}
{ing.unit ? ` ${ing.unit}` : ''}
</Text>
))}
</>
)}
<Text style={[styles.section, { color: colors.text }]}>{t('recipes.preparation')}</Text>
{recipe.steps.length === 0 ? (
<Text style={{ color: colors.textMuted }}>{t('recipes.noSteps')}</Text>
) : (
recipe.steps.map((step) => (
<Text key={step.id} style={[styles.step, { color: colors.text }]}>
{step.stepNumber}. {step.description}
</Text>
))
)}
</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({
metaCard: {
borderWidth: 1,
borderRadius: 14,
padding: 14,
gap: 8,
},
category: {
fontSize: 14,
fontWeight: '700',
textTransform: 'uppercase',
},
metaRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 12,
},
macros: {
gap: 4,
},
section: {
fontSize: 18,
fontWeight: '700',
marginTop: 8,
},
sortRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
marginBottom: 8,
},
sortChip: {
borderWidth: 1,
borderRadius: 999,
paddingHorizontal: 12,
paddingVertical: 6,
},
line: {
fontSize: 15,
lineHeight: 22,
},
step: {
fontSize: 15,
lineHeight: 22,
marginBottom: 6,
},
});

View File

@@ -0,0 +1,202 @@
import { Stack, useLocalSearchParams, router } from 'expo-router';
import { useEffect, useState } from 'react';
import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { fetchIngredientCatalogItems } from '@/src/api/ingredient-catalog.api';
import { updateRecipe } from '@/src/api/recipe-management.api';
import { fetchRecipeById } 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 { MealCategory } from '@/src/types/recipe';
import { extractApiError } from '@/src/utils/apiError';
import { ingredientDisplayName } from '@/src/utils/ingredient-catalog.util';
import { ALL_CATEGORIES, categoryLabel, parseOptionalNumber } from '@/src/utils/recipe';
interface IngredientRow {
name: string;
amountGrams: string;
unit: string;
catalogItemId: number | null;
}
interface StepRow {
description: string;
}
export default function EditRecipeScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const recipeId = Number(id);
const { t, i18n } = useTranslation();
const { colors } = useAppearance();
const queryClient = useQueryClient();
const recipeQuery = useQuery({
queryKey: ['recipe', recipeId],
queryFn: () => fetchRecipeById(recipeId),
enabled: Number.isFinite(recipeId),
});
const catalogQuery = useQuery({
queryKey: ['ingredient-catalog', 'all'],
queryFn: () => fetchIngredientCatalogItems(),
});
const [name, setName] = useState('');
const [category, setCategory] = useState<MealCategory>(MealCategory.Lunch);
const [prepTime, setPrepTime] = useState('');
const [calories, setCalories] = useState('');
const [protein, setProtein] = useState('');
const [fat, setFat] = useState('');
const [carbs, setCarbs] = useState('');
const [ingredients, setIngredients] = useState<IngredientRow[]>([]);
const [steps, setSteps] = useState<StepRow[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const recipe = recipeQuery.data;
if (!recipe) return;
setName(recipe.name);
setCategory(recipe.mealCategory);
setPrepTime(recipe.prepTimeMinutes?.toString() ?? '');
setCalories(recipe.calories?.toString() ?? '');
setProtein(recipe.protein?.toString() ?? '');
setFat(recipe.fat?.toString() ?? '');
setCarbs(recipe.carbs?.toString() ?? '');
setIngredients(
recipe.ingredients.length > 0
? recipe.ingredients.map((i) => ({
name: i.name,
amountGrams: i.amountGrams?.toString() ?? '',
unit: i.unit ?? '',
catalogItemId: i.catalogItemId ?? null,
}))
: [{ name: '', amountGrams: '', unit: '', catalogItemId: null }],
);
setSteps(
recipe.steps.length > 0
? recipe.steps.map((s) => ({ description: s.description }))
: [{ description: '' }],
);
}, [recipeQuery.data]);
const saveMutation = useMutation({
mutationFn: () => {
const filteredSteps = steps.filter((s) => s.description.trim());
return updateRecipe(recipeId, {
name: name.trim(),
mealCategory: category,
calories: parseOptionalNumber(calories),
protein: parseOptionalNumber(protein),
fat: parseOptionalNumber(fat),
carbs: parseOptionalNumber(carbs),
prepTimeMinutes: parseOptionalNumber(prepTime),
ingredients: ingredients
.filter((i) => i.name.trim())
.map((i, index) => ({
name: i.name.trim(),
amountGrams: parseOptionalNumber(i.amountGrams),
unit: i.unit,
catalogItemId: i.catalogItemId,
sortOrder: index,
})),
steps: filteredSteps.map((s, index) => ({ stepNumber: index + 1, description: s.description.trim() })),
});
},
onSuccess: (recipe) => {
queryClient.invalidateQueries({ queryKey: ['recipes'] });
queryClient.invalidateQueries({ queryKey: ['recipe', recipeId] });
Alert.alert(t('manage.title'), t('manage.updatedSuccess', { name: recipe.name }));
router.back();
},
onError: (err) => setError(extractApiError(err, t('errors.saveRecipeFailed'))),
});
if (!Number.isFinite(recipeId)) {
return (
<Screen>
<ErrorView message={t('errors.loadRecipeFailed')} />
</Screen>
);
}
if (recipeQuery.isLoading) return <Screen loading />;
if (recipeQuery.isError || !recipeQuery.data) {
return (
<Screen>
<ErrorView message={t('errors.loadRecipeFailed')} onRetry={() => recipeQuery.refetch()} />
</Screen>
);
}
const catalogItems = catalogQuery.data ?? [];
return (
<>
<Stack.Screen options={{ title: t('manage.editRecipe') }} />
<Screen title={t('manage.editRecipe')} subtitle={recipeQuery.data.name}>
<ScrollView contentContainerStyle={styles.scroll} keyboardShouldPersistTaps="handled">
<Input label={t('manage.recipeName')} value={name} onChangeText={setName} />
<Text style={[styles.label, { color: colors.text }]}>{t('common.category')}</Text>
<View style={styles.chips}>
{ALL_CATEGORIES.map((cat) => (
<Pressable
key={cat}
onPress={() => setCategory(cat)}
style={[styles.chip, { backgroundColor: category === cat ? colors.brand : colors.surface, borderColor: category === cat ? colors.brand : colors.border }]}
>
<Text style={{ color: category === cat ? '#fff' : colors.text, fontSize: 13 }}>{categoryLabel(cat, t)}</Text>
</Pressable>
))}
</View>
<View style={styles.row}>
<View style={styles.half}><Input label={t('manage.prepTimeMin')} keyboardType="numeric" value={prepTime} onChangeText={setPrepTime} /></View>
<View style={styles.half}><Input label={t('manage.caloriesKcal')} keyboardType="numeric" value={calories} onChangeText={setCalories} /></View>
</View>
<Text style={[styles.section, { color: colors.text }]}>{t('recipes.ingredients')}</Text>
{ingredients.map((ing, index) => (
<View key={index} style={styles.block}>
<Input label={t('manage.ingredientName')} value={ing.name} onChangeText={(v) => { const next = [...ingredients]; next[index] = { ...next[index], name: v }; setIngredients(next); }} />
<Text style={[styles.label, { color: colors.text }]}>{t('manage.catalogItem')}</Text>
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.chips}>
<Pressable onPress={() => { const next = [...ingredients]; next[index] = { ...next[index], catalogItemId: null }; setIngredients(next); }} style={[styles.chip, { backgroundColor: ing.catalogItemId == null ? colors.brand : colors.surface, borderColor: ing.catalogItemId == null ? colors.brand : colors.border }]}>
<Text style={{ color: ing.catalogItemId == null ? '#fff' : colors.text, fontSize: 12 }}>{t('diet.noCatalogItem')}</Text>
</Pressable>
{catalogItems.map((item) => (
<Pressable key={item.id} onPress={() => { const next = [...ingredients]; next[index] = { ...next[index], catalogItemId: item.id, name: ingredientDisplayName(item, i18n.language) }; setIngredients(next); }} style={[styles.chip, { backgroundColor: ing.catalogItemId === item.id ? colors.brand : colors.surface, borderColor: ing.catalogItemId === item.id ? colors.brand : colors.border }]}>
<Text style={{ color: ing.catalogItemId === item.id ? '#fff' : colors.text, fontSize: 12 }}>{ingredientDisplayName(item, i18n.language)}</Text>
</Pressable>
))}
</ScrollView>
<View style={styles.row}>
<View style={styles.half}><Input label={t('manage.ingredientGrams')} keyboardType="numeric" value={ing.amountGrams} onChangeText={(v) => { const next = [...ingredients]; next[index] = { ...next[index], amountGrams: v }; setIngredients(next); }} /></View>
<View style={styles.half}><Input label={t('manage.ingredientUnit')} value={ing.unit} onChangeText={(v) => { const next = [...ingredients]; next[index] = { ...next[index], unit: v }; setIngredients(next); }} /></View>
</View>
</View>
))}
<Text style={[styles.section, { color: colors.text }]}>{t('manage.preparationSteps')}</Text>
{steps.map((step, index) => (
<Input key={index} label={`${index + 1}.`} value={step.description} onChangeText={(v) => { const next = [...steps]; next[index] = { description: v }; setSteps(next); }} multiline />
))}
{error ? <Text style={{ color: colors.danger }}>{error}</Text> : null}
<Button title={t('common.save')} loading={saveMutation.isPending} onPress={() => saveMutation.mutate()} />
</ScrollView>
</Screen>
</>
);
}
const styles = StyleSheet.create({
scroll: { gap: 12, paddingBottom: 32 },
section: { fontSize: 18, fontWeight: '700' },
label: { fontSize: 14, fontWeight: '600' },
chips: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
chip: { borderWidth: 1, borderRadius: 999, paddingHorizontal: 12, paddingVertical: 8 },
row: { flexDirection: 'row', gap: 10 },
half: { flex: 1 },
block: { gap: 8 },
});