AI Plan Generation. Clear button sets focus

This commit is contained in:
AG
2025-12-15 22:46:04 +02:00
parent 854eda98d2
commit c275804fbc
9 changed files with 542 additions and 9 deletions

View File

@@ -1,5 +1,6 @@
import React, { useState, useEffect } from 'react';
import { Plus, Trash2, PlayCircle, Dumbbell, Save, X, List, ArrowUp, Pencil, User, Flame, Timer as TimerIcon, Ruler, Footprints, Percent, CheckCircle, GripVertical } from 'lucide-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 } from 'lucide-react';
import {
DndContext,
closestCenter,
@@ -35,6 +36,7 @@ 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;
@@ -215,6 +217,42 @@ const Plans: React.FC<PlansProps> = ({ lang }) => {
// 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'>('full_gym');
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);
@@ -367,6 +405,101 @@ const Plans: React.FC<PlansProps> = ({ lang }) => {
}
};
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);
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('full_gym');
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">
@@ -582,14 +715,194 @@ const Plans: React.FC<PlansProps> = ({ lang }) => {
)}
</div>
{/* FAB */}
<button
onClick={handleCreateNew}
aria-label="Create Plan"
className="absolute bottom-6 right-6 w-14 h-14 bg-primary-container text-on-primary-container rounded-[16px] shadow-elevation-3 flex items-center justify-center hover:bg-primary hover:text-on-primary transition-colors z-20"
{/* 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 rounded-[16px] shadow-elevation-3 flex items-center justify-center hover:bg-primary hover:text-on-primary transition-all ${fabMenuOpen ? 'rotate-45' : ''}`}
>
<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"
>
<Plus size={28} />
</button>
<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>
{/* 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 && (