480 lines
19 KiB
TypeScript
480 lines
19 KiB
TypeScript
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 },
|
|
});
|