Files
Piotr Kus f15bb7a916 * Extended functionalities
* Added different UIs
2026-06-24 21:43:40 +02:00

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