377 lines
15 KiB
TypeScript
377 lines
15 KiB
TypeScript
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 },
|
|
});
|