* Extended functionalities

* Added different UIs
This commit is contained in:
2026-06-24 21:43:40 +02:00
parent 282f4864c4
commit f15bb7a916
348 changed files with 59057 additions and 498 deletions

View 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 },
});