* Extended functionalities
* Added different UIs
This commit is contained in:
376
meal-plan-frontend-mobile/app/(main)/recipe-generator.tsx
Normal file
376
meal-plan-frontend-mobile/app/(main)/recipe-generator.tsx
Normal file
@@ -0,0 +1,376 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||
import { router } from 'expo-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
commitRecipes,
|
||||
createRecipeSession,
|
||||
generateRecipeDrafts,
|
||||
regenerateRecipePart,
|
||||
removeRecipeDraft,
|
||||
} from '@/src/api/recipe-generator.api';
|
||||
import { Button } from '@/src/components/Button';
|
||||
import { Input } from '@/src/components/Input';
|
||||
import { Screen } from '@/src/components/Screen';
|
||||
import { useAppearance } from '@/src/context/AppearanceContext';
|
||||
import type { CommitRecipeResult, GeneratedRecipeDraft, RecipeGeneratorConstraints } from '@/src/types/generator';
|
||||
import { MealCategory } from '@/src/types/recipe';
|
||||
import { extractApiError } from '@/src/utils/apiError';
|
||||
import { ALL_CATEGORIES, categoryLabel } from '@/src/utils/recipe';
|
||||
|
||||
type Step = 'constraints' | 'review' | 'done';
|
||||
|
||||
export default function RecipeGeneratorScreen() {
|
||||
const { t } = useTranslation();
|
||||
const { colors } = useAppearance();
|
||||
const [step, setStep] = useState<Step>('constraints');
|
||||
const [sessionId, setSessionId] = useState<number | null>(null);
|
||||
const [drafts, setDrafts] = useState<GeneratedRecipeDraft[]>([]);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [savedRecipes, setSavedRecipes] = useState<CommitRecipeResult[]>([]);
|
||||
const [recipeCount, setRecipeCount] = useState('3');
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [constraints, setConstraints] = useState<RecipeGeneratorConstraints>({
|
||||
prompt: '',
|
||||
mealCategory: MealCategory.Breakfast,
|
||||
targetCalories: null,
|
||||
calorieToleranceKcal: 10,
|
||||
maxPrepTimeMinutes: 30,
|
||||
cuisineStyle: '',
|
||||
dietStyle: '',
|
||||
exclusions: '',
|
||||
});
|
||||
|
||||
const calorieTolerance = constraints.calorieToleranceKcal ?? 10;
|
||||
|
||||
function isWithinCalorieTarget(calories?: number | null): boolean {
|
||||
if (!constraints.targetCalories || calories == null) return true;
|
||||
return Math.abs(calories - constraints.targetCalories) <= calorieTolerance;
|
||||
}
|
||||
|
||||
const allSelected = useMemo(
|
||||
() => drafts.length > 0 && drafts.every((d) => selectedIds.has(d.draftId)),
|
||||
[drafts, selectedIds],
|
||||
);
|
||||
|
||||
function syncDrafts(next: GeneratedRecipeDraft[]) {
|
||||
setDrafts(next);
|
||||
setSelectedIds(new Set(next.map((d) => d.draftId)));
|
||||
}
|
||||
|
||||
async function runGenerate() {
|
||||
setError(null);
|
||||
setPending(true);
|
||||
const count = Math.min(8, Math.max(1, Number(recipeCount) || 3));
|
||||
try {
|
||||
let session;
|
||||
if (sessionId) {
|
||||
session = await generateRecipeDrafts(sessionId, count);
|
||||
} else {
|
||||
const created = await createRecipeSession(constraints);
|
||||
setSessionId(created.id);
|
||||
session = await generateRecipeDrafts(created.id, count);
|
||||
}
|
||||
syncDrafts(session.drafts ?? []);
|
||||
setStep('review');
|
||||
} catch (e) {
|
||||
setError(extractApiError(e, t('generators.recipe.error')));
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function regen(draftId: string, mode: 'ingredients' | 'steps' | 'full') {
|
||||
if (!sessionId) return;
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
const session = await regenerateRecipePart(sessionId, draftId, mode, constraints.prompt);
|
||||
syncDrafts(session.drafts ?? []);
|
||||
} catch (e) {
|
||||
setError(extractApiError(e, t('generators.recipe.error')));
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeDraft(draftId: string) {
|
||||
if (!sessionId) return;
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
const session = await removeRecipeDraft(sessionId, draftId);
|
||||
const next = session.drafts ?? [];
|
||||
syncDrafts(next);
|
||||
if (next.length === 0) setStep('constraints');
|
||||
} catch (e) {
|
||||
setError(extractApiError(e, t('generators.recipe.error')));
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSelected() {
|
||||
if (!sessionId || selectedIds.size === 0) return;
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await commitRecipes(sessionId, [...selectedIds]);
|
||||
setSavedRecipes(result.saved);
|
||||
const remaining = drafts.filter((d) => !selectedIds.has(d.draftId));
|
||||
if (remaining.length === 0) setStep('done');
|
||||
else syncDrafts(remaining);
|
||||
} catch (e) {
|
||||
setError(extractApiError(e, t('generators.recipe.error')));
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSelected(draftId: string) {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(draftId)) next.delete(draftId);
|
||||
else next.add(draftId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function toggleAll() {
|
||||
setSelectedIds(allSelected ? new Set() : new Set(drafts.map((d) => d.draftId)));
|
||||
}
|
||||
|
||||
return (
|
||||
<Screen title={t('generators.recipe.title')} subtitle={t('generators.recipe.subtitle')}>
|
||||
<ScrollView contentContainerStyle={styles.scroll}>
|
||||
{error ? (
|
||||
<View style={[styles.errorBox, { backgroundColor: colors.danger + '22' }]}>
|
||||
<Text style={{ color: colors.danger }}>{error}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{step === 'constraints' ? (
|
||||
<View style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.border }]}>
|
||||
<Text style={[styles.sectionTitle, { color: colors.text }]}>
|
||||
{t('generators.recipe.constraintsTitle')}
|
||||
</Text>
|
||||
<Input
|
||||
label={t('generators.recipe.prompt')}
|
||||
multiline
|
||||
placeholder={t('generators.recipe.promptPlaceholder')}
|
||||
value={constraints.prompt}
|
||||
onChangeText={(v) => setConstraints({ ...constraints, prompt: v })}
|
||||
/>
|
||||
<Input
|
||||
label={t('generators.recipe.targetCalories')}
|
||||
keyboardType="numeric"
|
||||
placeholder={t('generators.recipe.targetCaloriesOptional')}
|
||||
value={constraints.targetCalories != null ? String(constraints.targetCalories) : ''}
|
||||
onChangeText={(v) =>
|
||||
setConstraints({
|
||||
...constraints,
|
||||
targetCalories: v.trim() ? Number(v) || null : null,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Text style={{ color: colors.textMuted, fontSize: 12, marginBottom: 12 }}>
|
||||
{t('generators.recipe.targetCaloriesHint')}
|
||||
</Text>
|
||||
<Input
|
||||
label={t('generators.recipe.recipeCount')}
|
||||
keyboardType="numeric"
|
||||
value={recipeCount}
|
||||
onChangeText={setRecipeCount}
|
||||
/>
|
||||
<Text style={[styles.label, { color: colors.text }]}>{t('common.category')}</Text>
|
||||
<View style={styles.wrapRow}>
|
||||
{ALL_CATEGORIES.map((cat) => (
|
||||
<Pressable
|
||||
key={cat}
|
||||
onPress={() => setConstraints({ ...constraints, mealCategory: cat })}
|
||||
style={[
|
||||
styles.chip,
|
||||
{
|
||||
backgroundColor: constraints.mealCategory === cat ? colors.brand : colors.surface,
|
||||
borderColor: constraints.mealCategory === cat ? colors.brand : colors.border,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text style={{ color: constraints.mealCategory === cat ? '#fff' : colors.text, fontSize: 12 }}>
|
||||
{categoryLabel(cat, t)}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
<Input
|
||||
label={t('generators.recipe.maxPrep')}
|
||||
keyboardType="numeric"
|
||||
value={String(constraints.maxPrepTimeMinutes ?? '')}
|
||||
onChangeText={(v) =>
|
||||
setConstraints({ ...constraints, maxPrepTimeMinutes: Number(v) || undefined })
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
label={t('generators.recipe.exclusions')}
|
||||
value={constraints.exclusions ?? ''}
|
||||
onChangeText={(v) => setConstraints({ ...constraints, exclusions: v })}
|
||||
/>
|
||||
<Button
|
||||
title={t('generators.recipe.generate')}
|
||||
loading={pending}
|
||||
disabled={!constraints.prompt.trim()}
|
||||
onPress={() => void runGenerate()}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{step === 'review' && drafts.length > 0 ? (
|
||||
<View style={styles.gap}>
|
||||
<View style={styles.rowBetween}>
|
||||
<Text style={{ color: colors.textMuted, flex: 1 }}>
|
||||
{t('generators.recipe.reviewHint', { count: drafts.length })}
|
||||
</Text>
|
||||
<Pressable onPress={toggleAll} style={styles.selectAll}>
|
||||
<Text style={{ color: colors.brand, fontWeight: '600' }}>
|
||||
{t('generators.recipe.selectAll')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{drafts.map((draft) => (
|
||||
<View
|
||||
key={draft.draftId}
|
||||
style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.border }]}
|
||||
>
|
||||
<View style={styles.rowBetween}>
|
||||
<Pressable onPress={() => toggleSelected(draft.draftId)} style={styles.checkRow}>
|
||||
<View
|
||||
style={[
|
||||
styles.checkbox,
|
||||
{
|
||||
borderColor: selectedIds.has(draft.draftId) ? colors.brand : colors.border,
|
||||
backgroundColor: selectedIds.has(draft.draftId) ? colors.brand : 'transparent',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={[styles.sectionTitle, { color: colors.text }]}>{draft.name}</Text>
|
||||
{draft.calories != null ? (
|
||||
<Text
|
||||
style={{
|
||||
color:
|
||||
constraints.targetCalories && !isWithinCalorieTarget(draft.calories)
|
||||
? '#b45309'
|
||||
: colors.textMuted,
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{constraints.targetCalories
|
||||
? t('generators.recipe.caloriesWithTarget', {
|
||||
actual: draft.calories,
|
||||
target: constraints.targetCalories,
|
||||
})
|
||||
: `${draft.calories} kcal`}
|
||||
{' · '}
|
||||
P {draft.protein ?? 0}g · F {draft.fat ?? 0}g · C {draft.carbs ?? 0}g
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{draft.unlinkedIngredientCount > 0 ? (
|
||||
<Text style={{ color: '#b45309' }}>
|
||||
{t('generators.recipe.unlinkedWarning', { count: draft.unlinkedIngredientCount })}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<Text style={[styles.label, { color: colors.text }]}>{t('recipes.ingredients')}</Text>
|
||||
{draft.ingredients.map((ing, i) => (
|
||||
<Text
|
||||
key={i}
|
||||
style={{ color: !ing.isLinked && ing.amountGrams ? '#b45309' : colors.text, fontSize: 14 }}
|
||||
>
|
||||
{ing.name} — {ing.amountGrams ?? '?'}g {ing.unit ?? ''}
|
||||
</Text>
|
||||
))}
|
||||
|
||||
<Text style={[styles.label, { color: colors.text, marginTop: 8 }]}>
|
||||
{t('manage.preparationSteps')}
|
||||
</Text>
|
||||
{draft.steps.map((s) => (
|
||||
<Text key={s.stepNumber} style={{ color: colors.text, fontSize: 14 }}>
|
||||
{s.stepNumber}. {s.description}
|
||||
</Text>
|
||||
))}
|
||||
|
||||
<View style={styles.gap}>
|
||||
<Button
|
||||
title={t('generators.recipe.regenIngredients')}
|
||||
variant="secondary"
|
||||
loading={pending}
|
||||
onPress={() => void regen(draft.draftId, 'ingredients')}
|
||||
/>
|
||||
<Button
|
||||
title={t('generators.recipe.remove')}
|
||||
variant="danger"
|
||||
disabled={pending}
|
||||
onPress={() => void removeDraft(draft.draftId)}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
|
||||
<Button
|
||||
title={t('generators.recipe.saveSelected', { count: selectedIds.size })}
|
||||
loading={pending}
|
||||
disabled={selectedIds.size === 0}
|
||||
onPress={() => void saveSelected()}
|
||||
/>
|
||||
<Button title={t('common.back')} variant="secondary" onPress={() => setStep('constraints')} />
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{step === 'done' && savedRecipes.length > 0 ? (
|
||||
<View style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.border }]}>
|
||||
<Text style={[styles.sectionTitle, { color: colors.text }]}>
|
||||
{t('generators.recipe.doneMultiple', { count: savedRecipes.length })}
|
||||
</Text>
|
||||
{savedRecipes.map((r) => (
|
||||
<Pressable key={r.recipeId} onPress={() => router.push(`/(main)/recipe/${r.recipeId}`)}>
|
||||
<Text style={{ color: colors.brand, fontSize: 16, marginBottom: 8 }}>{r.recipeName}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
<Button
|
||||
title={t('generators.recipe.generateMore')}
|
||||
variant="secondary"
|
||||
onPress={() => {
|
||||
setStep('constraints');
|
||||
setDrafts([]);
|
||||
setSelectedIds(new Set());
|
||||
setSavedRecipes([]);
|
||||
setSessionId(null);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
</ScrollView>
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
scroll: { gap: 16, paddingBottom: 32 },
|
||||
card: { borderWidth: 1, borderRadius: 16, padding: 16, gap: 12 },
|
||||
sectionTitle: { fontSize: 18, fontWeight: '700' },
|
||||
label: { fontSize: 14, fontWeight: '600' },
|
||||
wrapRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
|
||||
chip: { borderWidth: 1, borderRadius: 999, paddingHorizontal: 12, paddingVertical: 8 },
|
||||
gap: { gap: 10 },
|
||||
rowBetween: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 8 },
|
||||
checkRow: { flexDirection: 'row', alignItems: 'flex-start', gap: 10, flex: 1 },
|
||||
checkbox: { width: 22, height: 22, borderRadius: 6, borderWidth: 2, marginTop: 2 },
|
||||
selectAll: { paddingVertical: 4, paddingHorizontal: 8 },
|
||||
errorBox: { borderRadius: 12, padding: 12 },
|
||||
});
|
||||
Reference in New Issue
Block a user