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