988 lines
36 KiB
TypeScript
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; |