import React, { useState, useEffect } from 'react'; import { useSearchParams } from 'react-router-dom'; import { Plus, Trash2, PlayCircle, Dumbbell, Save, X, List, ArrowUp, Pencil, User, Flame, Timer as TimerIcon, Ruler, Footprints, Percent, CheckCircle, GripVertical, Bot, Loader2, ClipboardList } from 'lucide-react'; import { TopBar } from './ui/TopBar'; import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, DragEndEvent, TouchSensor, MouseSensor } from '@dnd-kit/core'; import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, useSortable, } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { WorkoutPlan, ExerciseDef, PlannedSet, Language, ExerciseType } from '../types'; import { getExercises, saveExercise } from '../services/storage'; import { t } from '../services/i18n'; import { generateId } from '../utils/uuid'; import { useAuth } from '../context/AuthContext'; import { useSession } from '../context/SessionContext'; import { useActiveWorkout } from '../context/ActiveWorkoutContext'; import FilledInput from './FilledInput'; import { toTitleCase } from '../utils/text'; import { Button } from './ui/Button'; import { Card } from './ui/Card'; import { Modal } from './ui/Modal'; import { SideSheet } from './ui/SideSheet'; import { Checkbox } from './ui/Checkbox'; import ExerciseModal from './ExerciseModal'; import { generateWorkoutPlan } from '../services/geminiService'; interface PlansProps { lang: Language; } // Sortable Item Component interface SortablePlanStepProps { step: PlannedSet; index: number; toggleWeighted: (id: string) => void; updateRestTime: (id: string, val: number | undefined) => void; removeStep: (id: string) => void; lang: Language; } const SortablePlanStep: React.FC = ({ step, index, toggleWeighted, updateRestTime, removeStep, lang }) => { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: step.id }); const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1, zIndex: isDragging ? 1000 : 1, position: 'relative' as 'relative', }; const handlePointerDown = (e: React.PointerEvent) => { listeners?.onPointerDown?.(e); // Only trigger vibration for touch input (long press logic) if (e.pointerType === 'touch') { const startTime = Date.now(); // Use pattern [0, 300, 50] to vibrate after 300ms delay, triggered synchronously by user gesture // This works around Firefox Android blocking async vibrate calls if (typeof navigator !== 'undefined' && navigator.vibrate) { try { navigator.vibrate([0, 300, 50]); } catch (err) { // Ignore potential errors if vibrate is blocked or invalid } } // Cleanup / Cancel logic const cancelVibration = () => { // Only cancel if less than 300ms has passed (meaning we aborted the long press) // If > 300ms, the vibration (50ms) is either playing or done, we let it finish. if (Date.now() - startTime < 300) { if (typeof navigator !== 'undefined' && navigator.vibrate) { navigator.vibrate(0); } } cleanup(); }; const startX = e.clientX; const startY = e.clientY; const onMove = (me: PointerEvent) => { const diff = Math.hypot(me.clientX - startX, me.clientY - startY); if (diff > 10) { // 10px tolerance cancelVibration(); } }; const cleanup = () => { window.removeEventListener('pointermove', onMove); window.removeEventListener('pointerup', cancelVibration); window.removeEventListener('pointercancel', cancelVibration); }; window.addEventListener('pointermove', onMove); window.addEventListener('pointerup', cancelVibration); window.addEventListener('pointercancel', cancelVibration); } }; return (
{index + 1}
{step.exerciseName}
{ const val = parseInt(e.target.value); updateRestTime(step.id, isNaN(val) ? undefined : val); }} /> s
); }; const Plans: React.FC = ({ lang }) => { const { currentUser } = useAuth(); const userId = currentUser?.id || ''; const { plans, sessions, savePlan, deletePlan, refreshData } = useSession(); const { startSession } = useActiveWorkout(); const [isEditing, setIsEditing] = useState(false); const [editId, setEditId] = useState(null); const [name, setName] = useState(''); const [description, setDescription] = useState(''); const [steps, setSteps] = useState([]); // Dnd Sensors const sensors = useSensors( useSensor(MouseSensor, { activationConstraint: { distance: 10, }, }), useSensor(TouchSensor, { activationConstraint: { delay: 300, tolerance: 5, }, }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates, }) ); // Create Exercise State const [availableExercises, setAvailableExercises] = useState([]); const [showExerciseSelector, setShowExerciseSelector] = useState(false); const [isCreatingExercise, setIsCreatingExercise] = useState(false); // Preparation Modal State const [showPlanPrep, setShowPlanPrep] = useState(null); // FAB Menu State const [fabMenuOpen, setFabMenuOpen] = useState(false); // AI Plan Creation State const [showAISheet, setShowAISheet] = useState(false); const [aiPrompt, setAIPrompt] = useState(''); const [aiLoading, setAILoading] = useState(false); const [aiError, setAIError] = useState(null); const [aiDuration, setAIDuration] = useState(60); // Default 1 hour in minutes const [aiEquipment, setAIEquipment] = useState<'none' | 'essentials' | 'free_weights' | 'full_gym'>('none'); const [aiLevel, setAILevel] = useState<'beginner' | 'intermediate' | 'advanced'>('intermediate'); const [aiIntensity, setAIIntensity] = useState<'low' | 'moderate' | 'high'>('moderate'); const [generatedPlanPreview, setGeneratedPlanPreview] = useState<{ name: string; description: string; exercises: Array<{ name: string; isWeighted: boolean; restTimeSeconds: number; type?: string; unilateral?: boolean; }>; } | null>(null); // URL params handling const [searchParams, setSearchParams] = useSearchParams(); useEffect(() => { if (searchParams.get('create') === 'true') { handleCreateNew(); setSearchParams({}); } if (searchParams.get('aiPrompt') === 'true') { setShowAISheet(true); setSearchParams({}); } }, [searchParams, setSearchParams]); const handleStart = (plan: WorkoutPlan) => { if (plan.description && plan.description.trim().length > 0) { setShowPlanPrep(plan); } else { startSession(plan, currentUser?.profile?.weight); } }; const confirmPlanStart = () => { if (showPlanPrep) { startSession(showPlanPrep, currentUser?.profile?.weight); setShowPlanPrep(null); } }; useEffect(() => { const loadData = async () => { refreshData(); const fetchedExercises = await getExercises(userId); // Filter out archived exercises if (Array.isArray(fetchedExercises)) { setAvailableExercises(fetchedExercises.filter(e => !e.isArchived)); } else { setAvailableExercises([]); } }; if (userId) loadData(); }, [userId, refreshData]); const handleCreateNew = () => { setEditId(generateId()); setName(''); setDescription(''); setSteps([]); setIsEditing(true); }; const handleEdit = (plan: WorkoutPlan) => { setEditId(plan.id); setName(plan.name); setDescription(plan.description || ''); setSteps(plan.steps); setIsEditing(true); }; // Persist draft to localStorage useEffect(() => { if (isEditing) { const draft = { editId, name, description, steps }; localStorage.setItem('gymflow_plan_draft', JSON.stringify(draft)); } }, [isEditing, editId, name, description, steps]); // Restore draft on mount useEffect(() => { const draftJson = localStorage.getItem('gymflow_plan_draft'); if (draftJson) { try { const draft = JSON.parse(draftJson); // Only restore if we have valid data if (draft.editId) { setEditId(draft.editId); setName(draft.name || ''); setDescription(draft.description || ''); setSteps(draft.steps || []); setIsEditing(true); } } catch (e) { console.error("Failed to parse plan draft", e); } } }, []); const handleSave = async () => { if (!name.trim() || !editId) return; const newPlan: WorkoutPlan = { id: editId, name, description, steps }; await savePlan(newPlan); localStorage.removeItem('gymflow_plan_draft'); setIsEditing(false); }; const handleDelete = async (id: string, e: React.MouseEvent) => { e.stopPropagation(); if (confirm(t('delete_confirm', lang))) { await deletePlan(id); } }; const addStep = (ex: ExerciseDef) => { const newStep: PlannedSet = { id: generateId(), exerciseId: ex.id, exerciseName: ex.name, exerciseType: ex.type, isWeighted: false, restTimeSeconds: 120 // Default new step to 120s? Or leave undefined to use profile default? // Requirement: "fallback to user default". So maybe undefined/null is better for "inherit". // But UI needs a value. Let's start with 120 or empty. }; setSteps([...steps, newStep]); setShowExerciseSelector(false); }; const handleSaveNewExercise = async (newEx: ExerciseDef) => { await saveExercise(userId, newEx); const exList = await getExercises(userId); setAvailableExercises(exList.filter(e => !e.isArchived)); // Automatically add the new exercise to the plan addStep(newEx); setIsCreatingExercise(false); }; const toggleWeighted = (stepId: string) => { setSteps(steps.map(s => s.id === stepId ? { ...s, isWeighted: !s.isWeighted } : s)); }; const updateRestTime = (stepId: string, seconds: number | undefined) => { setSteps(steps.map(s => s.id === stepId ? { ...s, restTimeSeconds: seconds } : s)); }; const removeStep = (stepId: string) => { setSteps(steps.filter(s => s.id !== stepId)); }; /* Vibration handled in SortablePlanStep locally for better touch support */ /* const handleDragStart = () => { console.log('handleDragStart called'); if (typeof navigator !== 'undefined' && navigator.vibrate) { navigator.vibrate(50); } }; */ const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event; if (active.id !== over?.id) { setSteps((items) => { const oldIndex = items.findIndex((item) => item.id === active.id); const newIndex = items.findIndex((item) => item.id === over?.id); return arrayMove(items, oldIndex, newIndex); }); } }; const handleGenerateAIPlan = async () => { if (aiLoading) return; setAILoading(true); setAIError(null); setGeneratedPlanPreview(null); try { const availableNames = availableExercises.map(e => e.name); const prompt = aiPrompt.trim() ? aiPrompt : (lang === 'ru' ? 'Создай план тренировки' : 'Create a workout plan'); const aiPlan = await generateWorkoutPlan( prompt, availableNames, lang, aiDuration, aiEquipment, aiLevel, aiIntensity, sessions, currentUser?.profile ); setGeneratedPlanPreview(aiPlan); } catch (err: any) { console.error('AI plan generation error:', err); setAIError(err.message || 'Failed to generate plan'); } finally { setAILoading(false); } }; const handleSaveAIPlan = async () => { if (!generatedPlanPreview || aiLoading) return; setAILoading(true); setAIError(null); try { // Build plan steps, creating new exercises as needed const planSteps: PlannedSet[] = []; for (const aiEx of generatedPlanPreview.exercises) { let existingEx = availableExercises.find( e => e.name.toLowerCase() === aiEx.name.toLowerCase() ); if (!existingEx) { // Create new exercise - map AI type to ExerciseType enum const typeMap: Record = { 'STRENGTH': ExerciseType.STRENGTH, 'BODYWEIGHT': ExerciseType.BODYWEIGHT, 'CARDIO': ExerciseType.CARDIO, 'STATIC': ExerciseType.STATIC, 'PLYOMETRIC': ExerciseType.PLYOMETRIC, 'HIGH_JUMP': ExerciseType.HIGH_JUMP, 'LONG_JUMP': ExerciseType.LONG_JUMP, }; const mappedType = typeMap[aiEx.type?.toUpperCase() || 'STRENGTH'] || ExerciseType.STRENGTH; const newEx: ExerciseDef = { id: generateId(), name: aiEx.name, type: mappedType, isUnilateral: aiEx.unilateral || false, bodyWeightPercentage: undefined, }; await saveExercise(userId, newEx); existingEx = newEx; // Add to local list setAvailableExercises(prev => [...prev, newEx]); } planSteps.push({ id: generateId(), exerciseId: existingEx.id, exerciseName: existingEx.name, exerciseType: existingEx.type, isWeighted: aiEx.isWeighted || false, restTimeSeconds: aiEx.restTimeSeconds || 120, }); } // Save the plan const newPlan: WorkoutPlan = { id: generateId(), name: generatedPlanPreview.name, description: generatedPlanPreview.description, steps: planSteps, }; await savePlan(newPlan); // Reset state and close setAIPrompt(''); setAIDuration(60); setAIEquipment('none'); setAILevel('intermediate'); setAIIntensity('moderate'); setGeneratedPlanPreview(null); setShowAISheet(false); setFabMenuOpen(false); } catch (err: any) { console.error('AI plan save error:', err); setAIError(err.message || 'Failed to save plan'); } finally { setAILoading(false); } }; if (isEditing) { return (
} />
setName(e.target.value)} type="text" autocapitalize="words" onBlur={() => setName(toTitleCase(name))} /> setDescription(e.target.value)} multiline rows={3} type="text" />
s.id)} strategy={verticalListSortingStrategy} > {steps.map((step, idx) => ( ))}
setShowExerciseSelector(false)} title={t('select_exercise', lang)} width="lg" >
{availableExercises .slice() .sort((a, b) => a.name.localeCompare(b.name)) .map(ex => ( ))}
setShowExerciseSelector(false)} title={t('select_exercise', lang)} width="lg" >
{availableExercises .slice() .sort((a, b) => a.name.localeCompare(b.name)) .map(ex => ( ))}
setIsCreatingExercise(false)} onSave={handleSaveNewExercise} lang={lang} existingExercises={availableExercises} /> ); } return (
{plans.length === 0 ? (

{t('plans_empty', lang)}

) : (
{plans.map(plan => (

{plan.name}

{plan.description || t('prep_no_instructions', lang)}

{plan.steps.length} {t('exercises_count', lang)}
))}
)}
{/* FAB Menu */}
{/* Menu Options - shown when expanded */} {fabMenuOpen && (
)} {/* Main FAB */}
{/* Backdrop for FAB Menu */} {fabMenuOpen && (
setFabMenuOpen(false)} /> )} {/* AI Plan Creation Side Sheet */} { setShowAISheet(false); setAIPrompt(''); setAIError(null); setGeneratedPlanPreview(null); setAIDuration(60); setAIEquipment('full_gym'); }} title={t('ai_plan_prompt_title', lang)} width="lg" >
{/* Duration Slider */}
setAIDuration(Number(e.target.value))} className="w-full h-2 bg-surface-container-highest rounded-lg appearance-none cursor-pointer accent-primary" disabled={aiLoading} />
5 {t('duration_minutes', lang)} {t('duration_hours_plus', lang)}
{/* Equipment Selector */}
{(['none', 'essentials', 'free_weights', 'full_gym'] as const).map((level) => ( ))}
{/* Level Selector */}
{(['beginner', 'intermediate', 'advanced'] as const).map((lvl) => ( ))}
{/* Intensity Selector */}
{(['low', 'moderate', 'high'] as const).map((int) => ( ))}
{/* Additional Requirements */}