Files
gymflow/src/components/Plans.tsx

988 lines
36 KiB
TypeScript

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<SortablePlanStepProps> = ({ 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 (
<div ref={setNodeRef} style={style} {...attributes}>
<Card
className={`flex items-center gap-3 transition-all hover:bg-surface-container-high ${isDragging ? 'bg-surface-container-high shadow-elevation-3' : ''}`}
>
<div
className="text-on-surface-variant p-1 cursor-grab touch-none"
{...listeners}
onPointerDown={handlePointerDown}
>
<GripVertical size={20} />
</div>
<div className="w-8 h-8 rounded-full bg-primary-container text-on-primary-container flex items-center justify-center text-xs font-bold shrink-0">
{index + 1}
</div>
<div className="flex-1">
<div className="text-base font-medium text-on-surface">{step.exerciseName}</div>
<div className="flex items-center gap-4 mt-1">
<label className="flex items-center gap-2 cursor-pointer w-fit">
<div className={`w-4 h-4 border rounded flex items-center justify-center ${step.isWeighted ? 'bg-primary border-primary' : 'border-outline'}`}>
{step.isWeighted && <Dumbbell size={10} className="text-on-primary" />}
</div>
<input
type="checkbox"
checked={step.isWeighted}
onChange={() => toggleWeighted(step.id)}
className="hidden"
/>
<span className="text-xs text-on-surface-variant">{t('weighted', lang)}</span>
</label>
<div className="flex items-center gap-2">
<TimerIcon size={14} className="text-on-surface-variant" />
<input
type="number"
placeholder="Rest (s)"
className="w-16 bg-transparent border-b border-outline-variant text-xs text-on-surface focus:border-primary focus:outline-none text-center"
value={step.restTimeSeconds || ''}
onChange={(e) => {
const val = parseInt(e.target.value);
updateRestTime(step.id, isNaN(val) ? undefined : val);
}}
/>
<span className="text-xs text-on-surface-variant">s</span>
</div>
</div>
</div>
<Button onClick={() => removeStep(step.id)} variant="ghost" size="icon" className="text-on-surface-variant hover:text-error hover:bg-error/10">
<Trash2 size={20} />
</Button>
</Card>
</div>
);
};
const Plans: React.FC<PlansProps> = ({ 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<string | null>(null);
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [steps, setSteps] = useState<PlannedSet[]>([]);
// 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<ExerciseDef[]>([]);
const [showExerciseSelector, setShowExerciseSelector] = useState(false);
const [isCreatingExercise, setIsCreatingExercise] = useState(false);
// Preparation Modal State
const [showPlanPrep, setShowPlanPrep] = useState<WorkoutPlan | null>(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<string | null>(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<string, ExerciseType> = {
'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 (
<div className="h-full flex flex-col bg-surface">
<TopBar
title={t('plan_editor', lang)}
actions={
<div className="flex items-center gap-1">
<Button
onClick={() => {
setIsEditing(false);
localStorage.removeItem('gymflow_plan_draft');
}}
variant="ghost" size="icon">
<X size={20} />
</Button>
<Button onClick={handleSave} variant="ghost" className="text-primary font-medium hover:bg-primary-container/10">
{t('save', lang)}
</Button>
</div>
}
/>
<div className="flex-1 overflow-y-auto p-4 space-y-6">
<FilledInput
label={t('ex_name', lang)}
value={name}
onChange={(e: any) => setName(e.target.value)}
type="text"
autocapitalize="words"
onBlur={() => setName(toTitleCase(name))}
/>
<FilledInput
label={t('prep_title', lang)}
value={description}
onChange={(e: any) => setDescription(e.target.value)}
multiline
rows={3}
type="text"
/>
<div className="space-y-3">
<div className="flex justify-between items-center px-2">
<label className="text-sm text-primary font-medium">{t('exercises_list', lang)}</label>
</div>
<div className="space-y-2">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={steps.map(s => s.id)}
strategy={verticalListSortingStrategy}
>
{steps.map((step, idx) => (
<SortablePlanStep
key={step.id}
step={step}
index={idx}
toggleWeighted={toggleWeighted}
updateRestTime={updateRestTime}
removeStep={removeStep}
lang={lang}
/>
))}
</SortableContext>
</DndContext>
</div>
<Button
onClick={() => setShowExerciseSelector(true)}
variant="outline"
fullWidth
className="py-6 rounded-full border border-outline text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary-container/10 transition-all h-auto"
>
<Plus size={20} />
{t('add_exercise', lang)}
</Button>
</div>
</div>
<SideSheet
isOpen={showExerciseSelector}
onClose={() => setShowExerciseSelector(false)}
title={t('select_exercise', lang)}
width="lg"
>
<div className="flex flex-col h-[60vh]">
<div className="flex justify-end mb-2">
<Button onClick={() => setIsCreatingExercise(true)} variant="ghost" className="text-primary hover:bg-primary-container/20 flex gap-2">
<Plus size={18} /> {t('create_exercise', lang)}
</Button>
</div>
<div className="flex-1 overflow-y-auto -mx-6 px-6">
{availableExercises
.slice()
.sort((a, b) => a.name.localeCompare(b.name))
.map(ex => (
<button
key={ex.id}
onClick={() => addStep(ex)}
className="w-full text-left p-4 border-b border-outline-variant hover:bg-surface-container-high text-on-surface flex justify-between group"
>
<span className="group-hover:text-primary transition-colors">{ex.name}</span>
<span className="text-xs bg-secondary-container text-on-secondary-container px-2 py-1 rounded-full">{ex.type}</span>
</button>
))}
</div>
</div>
</SideSheet>
<SideSheet
isOpen={showExerciseSelector}
onClose={() => setShowExerciseSelector(false)}
title={t('select_exercise', lang)}
width="lg"
>
<div className="flex flex-col h-[60vh]">
<div className="flex justify-end mb-2">
<Button onClick={() => setIsCreatingExercise(true)} variant="ghost" className="text-primary hover:bg-primary-container/20 flex gap-2">
<Plus size={18} /> {t('create_exercise', lang)}
</Button>
</div>
<div className="flex-1 overflow-y-auto -mx-6 px-6">
{availableExercises
.slice()
.sort((a, b) => a.name.localeCompare(b.name))
.map(ex => (
<button
key={ex.id}
onClick={() => addStep(ex)}
className="w-full text-left p-4 border-b border-outline-variant hover:bg-surface-container-high text-on-surface flex justify-between group"
>
<span className="group-hover:text-primary transition-colors">{ex.name}</span>
<span className="text-xs bg-secondary-container text-on-secondary-container px-2 py-1 rounded-full">{ex.type}</span>
</button>
))}
</div>
</div>
</SideSheet>
<ExerciseModal
isOpen={isCreatingExercise}
onClose={() => setIsCreatingExercise(false)}
onSave={handleSaveNewExercise}
lang={lang}
existingExercises={availableExercises}
/>
</div>
);
}
return (
<div className="h-full flex flex-col bg-surface relative">
<TopBar title={t('my_plans', lang)} icon={ClipboardList} />
<div className="flex-1 p-4 overflow-y-auto pb-24">
{plans.length === 0 ? (
<div className="text-center text-on-surface-variant mt-20 flex flex-col items-center">
<div className="w-16 h-16 bg-surface-container-high rounded-full flex items-center justify-center mb-4">
<List size={32} />
</div>
<p className="text-headline-sm">{t('plans_empty', lang)}</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{plans.map(plan => (
<Card key={plan.id} className="relative overflow-hidden group">
<div className="flex justify-between items-start mb-2">
<h3 className="text-title-lg font-normal text-on-surface">{plan.name}</h3>
<div className="flex gap-1">
<Button
onClick={(e) => { e.stopPropagation(); handleEdit(plan); }}
variant="ghost"
size="icon"
aria-label="Edit Plan"
className="text-on-surface-variant hover:text-primary hover:bg-surface-container-high"
>
<Pencil size={20} />
</Button>
<Button
onClick={(e) => handleDelete(plan.id, e)}
variant="ghost"
size="icon"
aria-label="Delete Plan"
className="text-on-surface-variant hover:text-error hover:bg-error-container/10"
>
<Trash2 size={20} />
</Button>
</div>
</div>
<p className="text-on-surface-variant text-body-md line-clamp-2 mb-4 min-h-[1.5rem]">
{plan.description || t('prep_no_instructions', lang)}
</p>
<div className="flex items-center justify-between">
<div className="text-label-md font-medium text-primary bg-primary-container/20 px-3 py-1 rounded-full">
{plan.steps.length} {t('exercises_count', lang)}
</div>
<Button
onClick={() => handleStart(plan)}
className="flex items-center gap-2"
>
<PlayCircle size={18} />
{t('start', lang)}
</Button>
</div>
</Card>
))}
</div>
)}
</div>
{/* FAB Menu */}
<div className="absolute bottom-6 right-6 z-20">
{/* Menu Options - shown when expanded */}
{fabMenuOpen && (
<div className="absolute bottom-16 right-0 flex flex-col gap-2 items-end animate-in slide-in-from-bottom-2 duration-200">
<button
onClick={() => { setFabMenuOpen(false); handleCreateNew(); }}
className="flex items-center gap-2 px-4 py-2 bg-surface-container-high text-on-surface rounded-full shadow-elevation-2 hover:bg-surface-container-highest transition-colors"
>
<Pencil size={18} />
<span className="font-medium text-sm whitespace-nowrap">{t('create_manually', lang)}</span>
</button>
<button
onClick={() => { setFabMenuOpen(false); setShowAISheet(true); }}
className="flex items-center gap-2 px-4 py-2 bg-secondary-container text-on-secondary-container rounded-full shadow-elevation-2 hover:opacity-90 transition-opacity"
>
<Bot size={18} />
<span className="font-medium text-sm whitespace-nowrap">{t('create_with_ai', lang)}</span>
</button>
</div>
)}
{/* Main FAB */}
<button
onClick={() => setFabMenuOpen(!fabMenuOpen)}
aria-label="Create Plan"
className={`w-14 h-14 bg-primary-container text-on-primary-container shadow-elevation-3 flex items-center justify-center hover:bg-primary hover:text-on-primary transition-all duration-200 ${fabMenuOpen ? 'rotate-45 rounded-[28px]' : 'rotate-0 rounded-[16px]'}`}
>
<Plus size={28} />
</button>
</div>
{/* Backdrop for FAB Menu */}
{fabMenuOpen && (
<div
className="fixed inset-0 z-10"
onClick={() => setFabMenuOpen(false)}
/>
)}
{/* AI Plan Creation Side Sheet */}
<SideSheet
isOpen={showAISheet}
onClose={() => {
setShowAISheet(false);
setAIPrompt('');
setAIError(null);
setGeneratedPlanPreview(null);
setAIDuration(60);
setAIEquipment('full_gym');
}}
title={t('ai_plan_prompt_title', lang)}
width="lg"
>
<div className="space-y-6">
{/* Duration Slider */}
<div className="space-y-2">
<label className="text-sm font-medium text-on-surface flex items-center justify-between">
<span>{t('duration', lang)}</span>
<span className="text-primary font-bold">
{aiDuration >= 120 ? t('duration_hours_plus', lang) : `${aiDuration} ${t('duration_minutes', lang)}`}
</span>
</label>
<input
type="range"
min="5"
max="120"
step="5"
value={aiDuration}
onChange={(e) => setAIDuration(Number(e.target.value))}
className="w-full h-2 bg-surface-container-highest rounded-lg appearance-none cursor-pointer accent-primary"
disabled={aiLoading}
/>
<div className="flex justify-between text-xs text-on-surface-variant">
<span>5 {t('duration_minutes', lang)}</span>
<span>{t('duration_hours_plus', lang)}</span>
</div>
</div>
{/* Equipment Selector */}
<div className="space-y-2">
<label className="text-sm font-medium text-on-surface">{t('equipment', lang)}</label>
<div className="grid grid-cols-2 gap-2">
{(['none', 'essentials', 'free_weights', 'full_gym'] as const).map((level) => (
<button
key={level}
onClick={() => setAIEquipment(level)}
disabled={aiLoading}
className={`p-3 rounded-xl text-sm font-medium transition-all ${aiEquipment === level
? 'bg-primary text-on-primary'
: 'bg-surface-container-high text-on-surface hover:bg-surface-container-highest'
}`}
>
{t(`equipment_${level}` as any, lang)}
</button>
))}
</div>
</div>
{/* Level Selector */}
<div className="space-y-2">
<label className="text-sm font-medium text-on-surface">{t('level', lang)}</label>
<div className="flex gap-2">
{(['beginner', 'intermediate', 'advanced'] as const).map((lvl) => (
<button
key={lvl}
onClick={() => setAILevel(lvl)}
disabled={aiLoading}
className={`flex-1 p-2 rounded-xl text-sm font-medium transition-all ${aiLevel === lvl
? 'bg-primary text-on-primary'
: 'bg-surface-container-high text-on-surface hover:bg-surface-container-highest'
}`}
>
{t(`level_${lvl}` as any, lang)}
</button>
))}
</div>
</div>
{/* Intensity Selector */}
<div className="space-y-2">
<label className="text-sm font-medium text-on-surface">{t('intensity', lang)}</label>
<div className="flex gap-2">
{(['low', 'moderate', 'high'] as const).map((int) => (
<button
key={int}
onClick={() => setAIIntensity(int)}
disabled={aiLoading}
className={`flex-1 p-2 rounded-xl text-sm font-medium transition-all ${aiIntensity === int
? 'bg-primary text-on-primary'
: 'bg-surface-container-high text-on-surface hover:bg-surface-container-highest'
}`}
>
{t(`intensity_${int}` as any, lang)}
</button>
))}
</div>
</div>
{/* Additional Requirements */}
<div className="space-y-2">
<label className="text-sm font-medium text-on-surface">
{lang === 'ru' ? 'Дополнительные требования' : 'Additional requirements'}
</label>
<textarea
className="w-full h-20 p-3 bg-surface-container-high rounded-xl text-on-surface resize-none focus:outline-none focus:ring-2 focus:ring-primary text-sm"
placeholder={t('ai_plan_prompt_placeholder', lang)}
value={aiPrompt}
onChange={(e) => setAIPrompt(e.target.value)}
disabled={aiLoading}
/>
</div>
{/* Error Display */}
{aiError && (
<div className="p-3 bg-error-container text-on-error-container rounded-lg text-sm">
{aiError}
</div>
)}
{/* Generated Plan Preview */}
{generatedPlanPreview && (
<div className="space-y-3 p-4 bg-surface-container rounded-xl border border-outline-variant">
<div className="text-xs font-bold text-primary">{t('ai_generated_plan', lang)}</div>
<h3 className="text-lg font-medium text-on-surface">{generatedPlanPreview.name}</h3>
{generatedPlanPreview.description && (
<p className="text-sm text-on-surface-variant">{generatedPlanPreview.description}</p>
)}
<div className="space-y-1">
{generatedPlanPreview.exercises.map((ex, idx) => (
<div key={idx} className="flex items-center gap-2 text-sm py-1 border-b border-outline-variant/30 last:border-0">
<span className="w-6 h-6 flex items-center justify-center text-xs font-bold bg-primary-container text-on-primary-container rounded-full">{idx + 1}</span>
<span className="text-on-surface">{ex.name}</span>
{ex.isWeighted && <Dumbbell size={14} className="text-on-surface-variant" />}
</div>
))}
</div>
</div>
)}
{/* Action Buttons */}
<div className="flex justify-end gap-2 pt-2">
{!generatedPlanPreview ? (
<>
<Button
onClick={() => {
setShowAISheet(false);
setAIPrompt('');
setAIError(null);
setGeneratedPlanPreview(null);
}}
variant="ghost"
disabled={aiLoading}
>
{t('cancel', lang)}
</Button>
<Button
onClick={handleGenerateAIPlan}
disabled={aiLoading}
className="flex items-center gap-2"
>
{aiLoading && <Loader2 size={16} className="animate-spin" />}
{t('generate', lang)}
</Button>
</>
) : (
<>
<Button
onClick={handleGenerateAIPlan}
variant="ghost"
disabled={aiLoading}
className="flex items-center gap-2"
>
{aiLoading && <Loader2 size={16} className="animate-spin" />}
{t('generate', lang)}
</Button>
<Button
onClick={handleSaveAIPlan}
disabled={aiLoading}
className="flex items-center gap-2"
>
{aiLoading && <Loader2 size={16} className="animate-spin" />}
{t('save_plan', lang)}
</Button>
</>
)}
</div>
</div>
</SideSheet>
{/* Preparation Modal */}
{showPlanPrep && (
<SideSheet
isOpen={!!showPlanPrep}
onClose={() => setShowPlanPrep(null)}
title={showPlanPrep.name}
width="md"
>
<div className="space-y-8">
<div className="bg-surface-container-high p-4 rounded-xl text-on-surface-variant text-sm">
<div className="text-xs font-bold text-primary mb-2">{t('prep_title', lang)}</div>
{showPlanPrep.description || t('prep_no_instructions', lang)}
</div>
<div className="flex justify-end gap-2">
<Button onClick={() => setShowPlanPrep(null)} variant="ghost">{t('cancel', lang)}</Button>
<Button onClick={confirmPlanStart}>{t('start', lang)}</Button>
</div>
</div>
</SideSheet>
)}
</div>
);
};
export default Plans;