* Extended functionalities
* Added different UIs
This commit is contained in:
202
meal-plan-frontend-mobile/app/(main)/recipe/edit/[id].tsx
Normal file
202
meal-plan-frontend-mobile/app/(main)/recipe/edit/[id].tsx
Normal 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 },
|
||||
});
|
||||
Reference in New Issue
Block a user