* Extended functionalities
* Added different UIs
This commit is contained in:
362
meal-plan-frontend-mobile/app/(main)/(tabs)/manage.tsx
Normal file
362
meal-plan-frontend-mobile/app/(main)/(tabs)/manage.tsx
Normal file
@@ -0,0 +1,362 @@
|
||||
import { 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 { createRecipe } from '@/src/api/recipes.api';
|
||||
import { Button } from '@/src/components/Button';
|
||||
import { ExcelImportPanel } from '@/src/components/ExcelImportPanel';
|
||||
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 { ALL_CATEGORIES, categoryLabel, parseOptionalNumber } from '@/src/utils/recipe';
|
||||
import { extractApiError } from '@/src/utils/apiError';
|
||||
import { ingredientDisplayName } from '@/src/utils/ingredient-catalog.util';
|
||||
|
||||
type ManageTab = 'manual' | 'import';
|
||||
|
||||
interface IngredientRow {
|
||||
name: string;
|
||||
amountGrams: string;
|
||||
unit: string;
|
||||
catalogItemId: number | null;
|
||||
}
|
||||
|
||||
interface StepRow {
|
||||
description: string;
|
||||
}
|
||||
|
||||
export default function ManageScreen() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { colors } = useAppearance();
|
||||
const queryClient = useQueryClient();
|
||||
const [manageTab, setManageTab] = useState<ManageTab>('manual');
|
||||
|
||||
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[]>([
|
||||
{ name: '', amountGrams: '', unit: '', catalogItemId: null },
|
||||
]);
|
||||
const [steps, setSteps] = useState<StepRow[]>([{ description: '' }]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: createRecipe,
|
||||
onSuccess: (recipe) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['recipes'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['categories'] });
|
||||
Alert.alert(t('manage.title'), t('manage.savedSuccess', { name: recipe.name }));
|
||||
setName('');
|
||||
setPrepTime('');
|
||||
setCalories('');
|
||||
setProtein('');
|
||||
setFat('');
|
||||
setCarbs('');
|
||||
setIngredients([{ name: '', amountGrams: '', unit: '', catalogItemId: null }]);
|
||||
setSteps([{ description: '' }]);
|
||||
setError(null);
|
||||
},
|
||||
onError: (err) => {
|
||||
setError(extractApiError(err, t('errors.saveRecipeFailed')));
|
||||
},
|
||||
});
|
||||
|
||||
function validate(): string | null {
|
||||
if (!name.trim()) return t('validation.recipeNameRequired');
|
||||
const validSteps = steps.filter((s) => s.description.trim());
|
||||
if (validSteps.length === 0) return t('validation.stepsMin');
|
||||
return null;
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
const validationError = validate();
|
||||
if (validationError) {
|
||||
setError(validationError);
|
||||
return;
|
||||
}
|
||||
|
||||
const filteredIngredients = 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,
|
||||
}));
|
||||
|
||||
const filteredSteps = steps
|
||||
.filter((s) => s.description.trim())
|
||||
.map((s, index) => ({
|
||||
stepNumber: index + 1,
|
||||
description: s.description.trim(),
|
||||
}));
|
||||
|
||||
saveMutation.mutate({
|
||||
name: name.trim(),
|
||||
mealCategory: category,
|
||||
calories: parseOptionalNumber(calories),
|
||||
protein: parseOptionalNumber(protein),
|
||||
fat: parseOptionalNumber(fat),
|
||||
carbs: parseOptionalNumber(carbs),
|
||||
prepTimeMinutes: parseOptionalNumber(prepTime),
|
||||
ingredients: filteredIngredients,
|
||||
steps: filteredSteps,
|
||||
});
|
||||
}
|
||||
|
||||
const catalogItems = catalogQuery.data ?? [];
|
||||
|
||||
return (
|
||||
<Screen title={t('manage.title')} subtitle={t('manage.subtitle')}>
|
||||
<View style={styles.tabs}>
|
||||
{(['manual', 'import'] as ManageTab[]).map((tab) => (
|
||||
<Pressable
|
||||
key={tab}
|
||||
onPress={() => setManageTab(tab)}
|
||||
style={[styles.tab, { backgroundColor: manageTab === tab ? colors.brand : colors.surface, borderColor: manageTab === tab ? colors.brand : colors.border }]}
|
||||
>
|
||||
<Text style={{ color: manageTab === tab ? '#fff' : colors.text, fontWeight: '600', fontSize: 13 }}>
|
||||
{t(tab === 'manual' ? 'manage.tabManual' : 'manage.tabImport')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{manageTab === 'import' ? (
|
||||
<ExcelImportPanel onImported={() => queryClient.invalidateQueries({ queryKey: ['recipes'] })} />
|
||||
) : (
|
||||
<ScrollView contentContainerStyle={styles.scroll} keyboardShouldPersistTaps="handled">
|
||||
<Text style={[styles.section, { color: colors.text }]}>{t('manage.basicInfo')}</Text>
|
||||
<Input
|
||||
label={t('manage.recipeName')}
|
||||
placeholder={t('manage.recipeNamePlaceholder')}
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
/>
|
||||
|
||||
<Text style={[styles.label, { color: colors.text }]}>{t('common.category')}</Text>
|
||||
<View style={styles.chips}>
|
||||
{ALL_CATEGORIES.map((cat) => {
|
||||
const selected = category === cat;
|
||||
return (
|
||||
<Pressable
|
||||
key={cat}
|
||||
onPress={() => setCategory(cat)}
|
||||
style={[
|
||||
styles.chip,
|
||||
{
|
||||
backgroundColor: selected ? colors.brand : colors.surface,
|
||||
borderColor: selected ? colors.brand : colors.border,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text style={{ color: selected ? '#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>
|
||||
|
||||
<View style={styles.row}>
|
||||
<View style={styles.third}>
|
||||
<Input label={t('manage.proteinG')} keyboardType="numeric" value={protein} onChangeText={setProtein} />
|
||||
</View>
|
||||
<View style={styles.third}>
|
||||
<Input label={t('manage.fatG')} keyboardType="numeric" value={fat} onChangeText={setFat} />
|
||||
</View>
|
||||
<View style={styles.third}>
|
||||
<Input label={t('manage.carbsG')} keyboardType="numeric" value={carbs} onChangeText={setCarbs} />
|
||||
</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={(value) => {
|
||||
const next = [...ingredients];
|
||||
next[index] = { ...next[index], name: value };
|
||||
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={(value) => {
|
||||
const next = [...ingredients];
|
||||
next[index] = { ...next[index], amountGrams: value };
|
||||
setIngredients(next);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.half}>
|
||||
<Input
|
||||
label={t('manage.ingredientUnit')}
|
||||
value={ing.unit}
|
||||
onChangeText={(value) => {
|
||||
const next = [...ingredients];
|
||||
next[index] = { ...next[index], unit: value };
|
||||
setIngredients(next);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
{ingredients.length > 1 ? (
|
||||
<Button
|
||||
title={t('manage.removeIngredient')}
|
||||
variant="ghost"
|
||||
onPress={() => setIngredients(ingredients.filter((_, i) => i !== index))}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
))}
|
||||
<Button
|
||||
title={t('common.add')}
|
||||
variant="secondary"
|
||||
onPress={() => setIngredients([...ingredients, { name: '', amountGrams: '', unit: '', catalogItemId: null }])}
|
||||
/>
|
||||
|
||||
<Text style={[styles.section, { color: colors.text }]}>{t('manage.preparationSteps')}</Text>
|
||||
{steps.map((step, index) => (
|
||||
<View key={index} style={styles.block}>
|
||||
<Input
|
||||
label={`${index + 1}.`}
|
||||
placeholder={t('manage.stepPlaceholder')}
|
||||
value={step.description}
|
||||
onChangeText={(value) => {
|
||||
const next = [...steps];
|
||||
next[index] = { description: value };
|
||||
setSteps(next);
|
||||
}}
|
||||
multiline
|
||||
/>
|
||||
{steps.length > 1 ? (
|
||||
<Button
|
||||
title={t('manage.removeStep')}
|
||||
variant="ghost"
|
||||
onPress={() => setSteps(steps.filter((_, i) => i !== index))}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
))}
|
||||
<Button title={t('manage.addStep')} variant="secondary" onPress={() => setSteps([...steps, { description: '' }])} />
|
||||
|
||||
{error ? <Text style={[styles.error, { color: colors.danger }]}>{error}</Text> : null}
|
||||
<Button
|
||||
title={saveMutation.isPending ? t('manage.saving') : t('manage.saveRecipe')}
|
||||
loading={saveMutation.isPending}
|
||||
onPress={handleSave}
|
||||
/>
|
||||
</ScrollView>
|
||||
)}
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
scroll: {
|
||||
gap: 12,
|
||||
paddingBottom: 32,
|
||||
},
|
||||
tabs: { flexDirection: 'row', gap: 8, marginBottom: 8 },
|
||||
tab: { borderWidth: 1, borderRadius: 999, paddingHorizontal: 14, paddingVertical: 8 },
|
||||
section: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
marginTop: 4,
|
||||
},
|
||||
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,
|
||||
},
|
||||
third: {
|
||||
flex: 1,
|
||||
},
|
||||
block: {
|
||||
gap: 8,
|
||||
},
|
||||
error: {
|
||||
fontSize: 14,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user