206 lines
6.4 KiB
TypeScript
206 lines
6.4 KiB
TypeScript
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,
|
|
},
|
|
});
|