* Extended functionalities
* Added different UIs
This commit is contained in:
479
meal-plan-frontend-mobile/app/(main)/diet-generator.tsx
Normal file
479
meal-plan-frontend-mobile/app/(main)/diet-generator.tsx
Normal file
@@ -0,0 +1,479 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Modal, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||
import { router } from 'expo-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
acceptDietMacros,
|
||||
commitDietPlan,
|
||||
createDietSession,
|
||||
fetchDietProfile,
|
||||
generateDietPlan,
|
||||
proposeDietMacros,
|
||||
regenerateDietMeal,
|
||||
saveDietProfile,
|
||||
toggleDietMealLock,
|
||||
} from '@/src/api/diet-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 {
|
||||
DEFAULT_DIET_PROFILE,
|
||||
type DietGeneratorDraftMeal,
|
||||
type DietGeneratorSession,
|
||||
type DietUserProfile,
|
||||
} from '@/src/types/generator';
|
||||
import { MealCategory } from '@/src/types/recipe';
|
||||
import { ApiError, extractApiError } from '@/src/utils/apiError';
|
||||
import { categoryLabel } from '@/src/utils/recipe';
|
||||
|
||||
type Step = 'profile' | 'macros' | 'plan' | 'done';
|
||||
|
||||
function formatDraftMealMacros(meal: DietGeneratorDraftMeal): string {
|
||||
const parts = [`${meal.calories ?? '?'} kcal`];
|
||||
if (meal.proteinG != null) parts.push(`P ${meal.proteinG}g`);
|
||||
if (meal.fatG != null) parts.push(`F ${meal.fatG}g`);
|
||||
if (meal.carbsG != null) parts.push(`C ${meal.carbsG}g`);
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
export default function DietGeneratorScreen() {
|
||||
const { t } = useTranslation();
|
||||
const { colors } = useAppearance();
|
||||
const [step, setStep] = useState<Step>('profile');
|
||||
const [profile, setProfile] = useState<DietUserProfile>({ ...DEFAULT_DIET_PROFILE });
|
||||
const [sessionId, setSessionId] = useState<number | null>(null);
|
||||
const [session, setSession] = useState<DietGeneratorSession | null>(null);
|
||||
const [macros, setMacros] = useState({
|
||||
calories: 2000,
|
||||
proteinG: 150,
|
||||
fatG: 65,
|
||||
carbsG: 200,
|
||||
rationale: '',
|
||||
});
|
||||
const [commitResult, setCommitResult] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [info, setInfo] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [previewMeal, setPreviewMeal] = useState<DietGeneratorDraftMeal | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchDietProfile().then((p) => {
|
||||
if (p) setProfile({ ...DEFAULT_DIET_PROFILE, ...p });
|
||||
});
|
||||
}, []);
|
||||
|
||||
const mealsByDate = useMemo(() => {
|
||||
const map = new Map<string, DietGeneratorSession['draftMeals']>();
|
||||
for (const meal of session?.draftMeals ?? []) {
|
||||
const list = map.get(meal.planDate) ?? [];
|
||||
list.push(meal);
|
||||
map.set(meal.planDate, list);
|
||||
}
|
||||
return [...map.entries()]
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([date, meals]) => ({
|
||||
date,
|
||||
meals,
|
||||
summary: session?.daySummaries.find((d) => d.planDate === date),
|
||||
}));
|
||||
}, [session]);
|
||||
|
||||
async function startSession() {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
await saveDietProfile(profile);
|
||||
const created = await createDietSession({ planDayCount: 7, calorieToleranceKcal: 10 });
|
||||
setSessionId(created.id);
|
||||
if (created.proposedMacros) applyMacros(created);
|
||||
const refined = await proposeDietMacros(created.id);
|
||||
if (refined.proposedMacros) applyMacros(refined);
|
||||
setStep('macros');
|
||||
} catch (e) {
|
||||
setError(extractApiError(e, t('generators.diet.error')));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function acceptAndGenerate() {
|
||||
if (!sessionId) return;
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
await acceptDietMacros(sessionId, macros);
|
||||
const plan = await generateDietPlan(sessionId);
|
||||
setSession(plan);
|
||||
setStep('plan');
|
||||
} catch (e) {
|
||||
setError(extractApiError(e, t('generators.diet.error')));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleLock(mealId: number, isLocked: boolean) {
|
||||
if (!sessionId) return;
|
||||
try {
|
||||
const updated = await toggleDietMealLock(sessionId, mealId, isLocked);
|
||||
setSession(updated);
|
||||
} catch (e) {
|
||||
setError(extractApiError(e, t('generators.diet.error')));
|
||||
}
|
||||
}
|
||||
|
||||
async function regenMeal(planDate: string, mealCategory: number) {
|
||||
if (!sessionId) return;
|
||||
try {
|
||||
const updated = await regenerateDietMeal(sessionId, planDate, mealCategory);
|
||||
setSession(updated);
|
||||
setError(null);
|
||||
setInfo(null);
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 400) {
|
||||
setInfo(t('generators.diet.noAlternativeMeals'));
|
||||
setError(null);
|
||||
} else {
|
||||
setError(extractApiError(e, t('generators.diet.error')));
|
||||
setInfo(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function commitPlan() {
|
||||
if (!sessionId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await commitDietPlan(sessionId);
|
||||
setCommitResult(`${result.planStartDate} → ${result.planEndDate}`);
|
||||
setStep('done');
|
||||
} catch (e) {
|
||||
setError(extractApiError(e, t('generators.diet.error')));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function applyMacros(s: DietGeneratorSession) {
|
||||
if (!s.proposedMacros) return;
|
||||
setMacros({
|
||||
calories: s.proposedMacros.calories,
|
||||
proteinG: s.proposedMacros.proteinG,
|
||||
fatG: s.proposedMacros.fatG,
|
||||
carbsG: s.proposedMacros.carbsG,
|
||||
rationale: s.proposedMacros.rationale ?? '',
|
||||
});
|
||||
}
|
||||
|
||||
function reset() {
|
||||
setStep('profile');
|
||||
setSessionId(null);
|
||||
setSession(null);
|
||||
setCommitResult(null);
|
||||
setError(null);
|
||||
setInfo(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<Screen title={t('generators.diet.title')} subtitle={t('generators.diet.subtitle')}>
|
||||
<ScrollView contentContainerStyle={styles.scroll}>
|
||||
{info ? (
|
||||
<View style={[styles.infoBox, { backgroundColor: colors.brand + '22' }]}>
|
||||
<Text style={{ color: colors.brand }}>{info}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<View style={[styles.errorBox, { backgroundColor: colors.danger + '22' }]}>
|
||||
<Text style={{ color: colors.danger }}>{error}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{step === 'profile' ? (
|
||||
<View style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.border }]}>
|
||||
<Text style={[styles.sectionTitle, { color: colors.text }]}>
|
||||
{t('generators.diet.profileTitle')}
|
||||
</Text>
|
||||
<Input
|
||||
label={t('generators.diet.heightCm')}
|
||||
keyboardType="numeric"
|
||||
value={String(profile.heightCm)}
|
||||
onChangeText={(v) => setProfile({ ...profile, heightCm: Number(v) || 0 })}
|
||||
/>
|
||||
<Input
|
||||
label={t('generators.diet.weightKg')}
|
||||
keyboardType="numeric"
|
||||
value={String(profile.weightKg)}
|
||||
onChangeText={(v) => setProfile({ ...profile, weightKg: Number(v) || 0 })}
|
||||
/>
|
||||
<Input
|
||||
label={t('generators.diet.age')}
|
||||
keyboardType="numeric"
|
||||
value={String(profile.age)}
|
||||
onChangeText={(v) => setProfile({ ...profile, age: Number(v) || 0 })}
|
||||
/>
|
||||
<Text style={[styles.label, { color: colors.text }]}>{t('generators.diet.sex')}</Text>
|
||||
<View style={styles.row}>
|
||||
{(['male', 'female'] as const).map((sex) => (
|
||||
<Pressable
|
||||
key={sex}
|
||||
onPress={() => setProfile({ ...profile, sex })}
|
||||
style={[
|
||||
styles.chip,
|
||||
{
|
||||
backgroundColor: profile.sex === sex ? colors.brand : colors.surface,
|
||||
borderColor: profile.sex === sex ? colors.brand : colors.border,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text style={{ color: profile.sex === sex ? '#fff' : colors.text }}>
|
||||
{t(`generators.diet.${sex === 'male' ? 'male' : 'female'}`)}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
<Text style={[styles.label, { color: colors.text }]}>{t('generators.diet.activity')}</Text>
|
||||
<View style={styles.wrapRow}>
|
||||
{(['sedentary', 'light', 'moderate', 'active', 'very_active'] as const).map((level) => (
|
||||
<Pressable
|
||||
key={level}
|
||||
onPress={() => setProfile({ ...profile, activityLevel: level })}
|
||||
style={[
|
||||
styles.chip,
|
||||
{
|
||||
backgroundColor: profile.activityLevel === level ? colors.brand : colors.surface,
|
||||
borderColor: profile.activityLevel === level ? colors.brand : colors.border,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text style={{ color: profile.activityLevel === level ? '#fff' : colors.text, fontSize: 12 }}>
|
||||
{t(`generators.diet.${level === 'very_active' ? 'veryActive' : level}`)}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
<Text style={[styles.label, { color: colors.text }]}>{t('generators.diet.goal')}</Text>
|
||||
<View style={styles.wrapRow}>
|
||||
{(['lose_weight', 'maintain', 'gain_muscle', 'recomp'] as const).map((goal) => (
|
||||
<Pressable
|
||||
key={goal}
|
||||
onPress={() => setProfile({ ...profile, goal })}
|
||||
style={[
|
||||
styles.chip,
|
||||
{
|
||||
backgroundColor: profile.goal === goal ? colors.brand : colors.surface,
|
||||
borderColor: profile.goal === goal ? colors.brand : colors.border,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text style={{ color: profile.goal === goal ? '#fff' : colors.text, fontSize: 12 }}>
|
||||
{t(
|
||||
`generators.diet.${
|
||||
goal === 'lose_weight'
|
||||
? 'loseWeight'
|
||||
: goal === 'gain_muscle'
|
||||
? 'gainMuscle'
|
||||
: goal
|
||||
}`,
|
||||
)}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
<Input
|
||||
label={t('generators.diet.allergies')}
|
||||
multiline
|
||||
value={profile.allergiesNotes ?? ''}
|
||||
onChangeText={(v) => setProfile({ ...profile, allergiesNotes: v })}
|
||||
/>
|
||||
<Button title={t('generators.diet.continue')} loading={loading} onPress={() => void startSession()} />
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{step === 'macros' ? (
|
||||
<View style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.border }]}>
|
||||
<Text style={[styles.sectionTitle, { color: colors.text }]}>
|
||||
{t('generators.diet.macrosTitle')}
|
||||
</Text>
|
||||
{macros.rationale ? (
|
||||
<Text style={{ color: colors.textMuted, marginBottom: 8 }}>{macros.rationale}</Text>
|
||||
) : null}
|
||||
<Input
|
||||
label={t('manage.caloriesKcal')}
|
||||
keyboardType="numeric"
|
||||
value={String(macros.calories)}
|
||||
onChangeText={(v) => setMacros({ ...macros, calories: Number(v) || 0 })}
|
||||
/>
|
||||
<Input
|
||||
label={t('manage.proteinG')}
|
||||
keyboardType="numeric"
|
||||
value={String(macros.proteinG)}
|
||||
onChangeText={(v) => setMacros({ ...macros, proteinG: Number(v) || 0 })}
|
||||
/>
|
||||
<Input
|
||||
label={t('manage.fatG')}
|
||||
keyboardType="numeric"
|
||||
value={String(macros.fatG)}
|
||||
onChangeText={(v) => setMacros({ ...macros, fatG: Number(v) || 0 })}
|
||||
/>
|
||||
<Input
|
||||
label={t('manage.carbsG')}
|
||||
keyboardType="numeric"
|
||||
value={String(macros.carbsG)}
|
||||
onChangeText={(v) => setMacros({ ...macros, carbsG: Number(v) || 0 })}
|
||||
/>
|
||||
<View style={styles.buttonRow}>
|
||||
<Button title={t('common.back')} variant="secondary" onPress={() => setStep('profile')} />
|
||||
<Button
|
||||
title={t('generators.diet.acceptAndGenerate')}
|
||||
loading={loading}
|
||||
onPress={() => void acceptAndGenerate()}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{step === 'plan' && session ? (
|
||||
<View style={styles.gap}>
|
||||
<Text style={{ color: colors.textMuted }}>{t('generators.diet.planHint')}</Text>
|
||||
{mealsByDate.map(({ date, meals, summary }) => (
|
||||
<View
|
||||
key={date}
|
||||
style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.border }]}
|
||||
>
|
||||
<View style={styles.dayHeader}>
|
||||
<Text style={[styles.sectionTitle, { color: colors.text }]}>{date}</Text>
|
||||
<Text style={{ color: summary?.withinTolerance ? colors.brand : '#b45309' }}>
|
||||
{summary?.totalCalories ?? 0} / {summary?.targetCalories ?? 0} kcal
|
||||
{summary?.withinTolerance ? ' ✓' : ''}
|
||||
</Text>
|
||||
</View>
|
||||
{meals.map((meal) => (
|
||||
<View key={meal.id} style={[styles.mealRow, { borderColor: colors.border }]}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={{ color: colors.textMuted, fontSize: 11, textTransform: 'uppercase' }}>
|
||||
{categoryLabel(meal.mealCategory as MealCategory, t)}
|
||||
</Text>
|
||||
<Text style={{ color: colors.text, fontWeight: '600' }}>{meal.recipeName}</Text>
|
||||
<Text style={{ color: colors.textMuted, fontSize: 12 }}>
|
||||
{formatDraftMealMacros(meal)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.mealActions}>
|
||||
<Button
|
||||
title={t('generators.diet.viewRecipe')}
|
||||
variant="secondary"
|
||||
onPress={() => setPreviewMeal(meal)}
|
||||
style={styles.smallBtn}
|
||||
/>
|
||||
<Button
|
||||
title={meal.isLocked ? t('generators.diet.unlock') : t('generators.diet.lock')}
|
||||
variant="secondary"
|
||||
onPress={() => void toggleLock(meal.id, !meal.isLocked)}
|
||||
style={styles.smallBtn}
|
||||
/>
|
||||
<Button
|
||||
title="↻"
|
||||
variant="secondary"
|
||||
disabled={meal.isLocked}
|
||||
onPress={() => void regenMeal(meal.planDate, meal.mealCategory)}
|
||||
style={styles.smallBtn}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
))}
|
||||
<Button title={t('generators.diet.commit')} loading={loading} onPress={() => void commitPlan()} />
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{step === 'done' ? (
|
||||
<View style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.border }]}>
|
||||
<Text style={[styles.sectionTitle, { color: colors.brand }]}>{t('generators.diet.done')}</Text>
|
||||
{commitResult ? <Text style={{ color: colors.textMuted }}>{commitResult}</Text> : null}
|
||||
<Button title={t('nav.planner')} onPress={() => router.push('/(main)/(tabs)/planner')} />
|
||||
<Button title={t('generators.diet.newPlan')} variant="secondary" onPress={reset} />
|
||||
</View>
|
||||
) : null}
|
||||
</ScrollView>
|
||||
|
||||
<Modal visible={!!previewMeal} transparent animationType="slide" onRequestClose={() => setPreviewMeal(null)}>
|
||||
<View style={styles.modalBackdrop}>
|
||||
<View style={[styles.modalCard, { backgroundColor: colors.surface, borderColor: colors.border }]}>
|
||||
<ScrollView contentContainerStyle={styles.modalScroll}>
|
||||
{previewMeal ? (
|
||||
<>
|
||||
<Text style={[styles.sectionTitle, { color: colors.text }]}>{previewMeal.recipeName}</Text>
|
||||
{previewMeal.calories != null ? (
|
||||
<Text style={{ color: colors.textMuted, marginBottom: 8 }}>
|
||||
{previewMeal.calories} kcal · P {previewMeal.proteinG ?? 0}g · F {previewMeal.fatG ?? 0}g · C{' '}
|
||||
{previewMeal.carbsG ?? 0}g
|
||||
</Text>
|
||||
) : null}
|
||||
<Text style={[styles.label, { color: colors.text }]}>{t('recipes.ingredients')}</Text>
|
||||
{previewMeal.ingredients.map((ing) => (
|
||||
<Text key={ing.id} style={{ color: colors.text, marginBottom: 4 }}>
|
||||
• {ing.name}
|
||||
{ing.amountGrams != null ? ` — ${ing.amountGrams}g` : ''}
|
||||
{ing.unit ? ` ${ing.unit}` : ''}
|
||||
</Text>
|
||||
))}
|
||||
<Text style={[styles.label, { color: colors.text, marginTop: 12 }]}>
|
||||
{t('recipes.preparation')}
|
||||
</Text>
|
||||
{previewMeal.steps.map((step) => (
|
||||
<Text key={step.id} style={{ color: colors.text, marginBottom: 4 }}>
|
||||
{step.stepNumber}. {step.description}
|
||||
</Text>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
</ScrollView>
|
||||
<Button title={t('common.back')} variant="secondary" onPress={() => setPreviewMeal(null)} />
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
</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' },
|
||||
row: { flexDirection: 'row', gap: 8 },
|
||||
wrapRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
|
||||
chip: { borderWidth: 1, borderRadius: 999, paddingHorizontal: 12, paddingVertical: 8 },
|
||||
buttonRow: { gap: 10 },
|
||||
gap: { gap: 12 },
|
||||
dayHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', gap: 8 },
|
||||
mealRow: {
|
||||
borderTopWidth: 1,
|
||||
paddingTop: 10,
|
||||
marginTop: 10,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
mealActions: { gap: 6 },
|
||||
smallBtn: { minHeight: 36, paddingHorizontal: 10 },
|
||||
errorBox: { borderRadius: 12, padding: 12 },
|
||||
infoBox: { borderRadius: 12, padding: 12 },
|
||||
modalBackdrop: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
justifyContent: 'flex-end',
|
||||
padding: 16,
|
||||
},
|
||||
modalCard: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 16,
|
||||
padding: 16,
|
||||
maxHeight: '85%',
|
||||
gap: 12,
|
||||
},
|
||||
modalScroll: { gap: 4, paddingBottom: 8 },
|
||||
});
|
||||
Reference in New Issue
Block a user