378 lines
18 KiB
TypeScript
378 lines
18 KiB
TypeScript
import React from 'react';
|
|
import { MoreVertical, X, CheckSquare, ChevronUp, ChevronDown, Scale, Dumbbell, Plus, Activity, Timer as TimerIcon, ArrowRight, ArrowUp, CheckCircle, Edit, Trash2 } from 'lucide-react';
|
|
import { ExerciseType, Language, WorkoutSet } from '../../types';
|
|
import { t } from '../../services/i18n';
|
|
import FilledInput from '../FilledInput';
|
|
import ExerciseModal from '../ExerciseModal';
|
|
import { useTracker } from './useTracker';
|
|
import SetLogger from './SetLogger';
|
|
import { formatSetMetrics } from '../../utils/setFormatting';
|
|
import { useAuth } from '../../context/AuthContext';
|
|
import { api } from '../../services/api';
|
|
import RestTimerFAB from '../ui/RestTimerFAB';
|
|
import EditSetModal from '../EditSetModal';
|
|
|
|
|
|
interface ActiveSessionViewProps {
|
|
tracker: ReturnType<typeof useTracker>;
|
|
activeSession: any; // Using any to avoid strict type issues with the complex session object for now, but ideally should be WorkoutSession
|
|
lang: Language;
|
|
onSessionEnd: () => void;
|
|
onSessionQuit: () => void;
|
|
onRemoveSet: (setId: string) => void;
|
|
}
|
|
|
|
const ActiveSessionView: React.FC<ActiveSessionViewProps> = ({ tracker, activeSession, lang, onSessionEnd, onSessionQuit, onRemoveSet }) => {
|
|
const {
|
|
elapsedTime,
|
|
showFinishConfirm,
|
|
setShowFinishConfirm,
|
|
showQuitConfirm,
|
|
setShowQuitConfirm,
|
|
showMenu,
|
|
setShowMenu,
|
|
activePlan, // This comes from useTracker props but we might need to pass it explicitly if not in hook return
|
|
currentStepIndex,
|
|
showPlanList,
|
|
setShowPlanList,
|
|
jumpToStep,
|
|
searchQuery,
|
|
setSearchQuery,
|
|
setShowSuggestions,
|
|
showSuggestions,
|
|
filteredExercises,
|
|
setSelectedExercise,
|
|
selectedExercise,
|
|
weight,
|
|
setWeight,
|
|
reps,
|
|
setReps,
|
|
duration,
|
|
setDuration,
|
|
distance,
|
|
setDistance,
|
|
height,
|
|
setHeight,
|
|
handleAddSet,
|
|
editingSetId,
|
|
editWeight,
|
|
setEditWeight,
|
|
editReps,
|
|
setEditReps,
|
|
editDuration,
|
|
setEditDuration,
|
|
editDistance,
|
|
setEditDistance,
|
|
editHeight,
|
|
setEditHeight,
|
|
editSide,
|
|
setEditSide,
|
|
handleCancelEdit,
|
|
handleSaveEdit,
|
|
handleEditSet,
|
|
isCreating,
|
|
setIsCreating,
|
|
handleCreateExercise,
|
|
exercises
|
|
} = tracker;
|
|
|
|
const { currentUser, updateUser } = useAuth();
|
|
|
|
// Timer Logic is now managed in useTracker to persist across re-renders/step changes
|
|
const { timer } = tracker;
|
|
|
|
const [editingSet, setEditingSet] = React.useState<WorkoutSet | null>(null);
|
|
|
|
const handleSaveSetFromModal = async (updatedSet: WorkoutSet) => {
|
|
if (tracker.updateSet) {
|
|
tracker.updateSet(updatedSet);
|
|
}
|
|
setEditingSet(null);
|
|
};
|
|
|
|
const handleLogSet = async () => {
|
|
await handleAddSet();
|
|
|
|
// Determine next rest time
|
|
let nextTime = currentUser?.profile?.restTimerDefault || 120;
|
|
|
|
if (activePlan) {
|
|
// Logic: activePlan set just added. We are moving to next step?
|
|
// Tracker's handleAddSet calls addSet -> which calls ActiveWorkoutContext's addSet -> which increments currentStepIndex (logic inside context)
|
|
// But state update might be async or we might need to look at current index before update?
|
|
// Usually we want the rest time AFTER the set we just did.
|
|
// The user just configured the set for the *current* step index.
|
|
// So we look at activePlan.steps[currentStepIndex].restTime.
|
|
// BUT, if the user just finished step 0, and step 0 says "Rest 60s", then we rest 60s.
|
|
// If fallback, use default.
|
|
|
|
// Note: currentStepIndex might update immediately or after render.
|
|
// In a real app, we might get the next set's target time? No, rest is usually associated with the fatigue of the set just done.
|
|
// Requirement: "rest time after this set".
|
|
// So we use currentStepIndex (which likely points to the set we just logged, assuming UI hasn't advanced yet?
|
|
// Actually, handleAddSet likely appends set. Context might auto-advance.
|
|
// Let's assume we use the restTime of the step that matches the set just logged.
|
|
|
|
const currentStep = activePlan.steps[currentStepIndex];
|
|
if (currentStep && currentStep.restTimeSeconds) {
|
|
nextTime = currentStep.restTimeSeconds;
|
|
}
|
|
}
|
|
|
|
if (timer.status !== 'RUNNING') {
|
|
timer.reset(nextTime);
|
|
// timer.start(); // Removed per user request: disable auto-start
|
|
}
|
|
};
|
|
|
|
const handleDurationChange = async (newVal: number) => {
|
|
// Update user profile
|
|
try {
|
|
await api.patch('/auth/profile', { restTimerDefault: newVal });
|
|
if (currentUser) {
|
|
updateUser({
|
|
...currentUser,
|
|
profile: { ...currentUser.profile, restTimerDefault: newVal }
|
|
});
|
|
}
|
|
} catch (e) {
|
|
console.error("Failed to update default timer", e);
|
|
}
|
|
};
|
|
|
|
|
|
|
|
const isPlanFinished = activePlan && currentStepIndex >= activePlan.steps.length;
|
|
|
|
return (
|
|
<div className="flex flex-col h-full max-h-full overflow-hidden relative bg-surface">
|
|
<div className="px-4 py-3 bg-surface-container shadow-elevation-1 z-20 flex justify-between items-center">
|
|
<div className="flex flex-col">
|
|
<h2 className="text-title-medium text-on-surface flex items-center gap-2 font-medium">
|
|
<span className="w-2 h-2 rounded-full bg-error animate-pulse" />
|
|
{activePlan ? activePlan.name : t('free_workout', lang)}
|
|
</h2>
|
|
<span className="text-xs text-on-surface-variant font-mono mt-0.5 flex items-center gap-2">
|
|
<span className="bg-surface-container-high px-1.5 py-0.5 rounded text-on-surface font-bold">{elapsedTime}</span>
|
|
{activeSession.userBodyWeight ? ` • ${activeSession.userBodyWeight}kg` : ''}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2 relative">
|
|
<button
|
|
onClick={() => setShowFinishConfirm(true)}
|
|
className="px-5 py-2 rounded-full bg-error-container text-on-error-container text-sm font-medium hover:opacity-90 transition-opacity"
|
|
>
|
|
{t('finish', lang)}
|
|
</button>
|
|
<button
|
|
onClick={() => setShowMenu(!showMenu)}
|
|
className="p-2 rounded-full bg-surface-container-high text-on-surface hover:bg-surface-container-highest transition-colors"
|
|
aria-label="Options"
|
|
>
|
|
<MoreVertical size={20} />
|
|
</button>
|
|
{showMenu && (
|
|
<>
|
|
<div
|
|
className="fixed inset-0 z-30"
|
|
onClick={() => setShowMenu(false)}
|
|
/>
|
|
<div className="absolute right-0 top-full mt-2 bg-surface-container rounded-xl shadow-elevation-3 overflow-hidden z-40 min-w-[200px]">
|
|
<button
|
|
onClick={() => {
|
|
setShowMenu(false);
|
|
setShowQuitConfirm(true);
|
|
}}
|
|
className="w-full px-4 py-3 text-left text-error hover:bg-error-container/20 transition-colors flex items-center gap-2"
|
|
>
|
|
<X size={18} />
|
|
{t('quit_no_save', lang)}
|
|
</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{activePlan && (
|
|
<div className="bg-surface-container-low border-b border-outline-variant">
|
|
<button
|
|
onClick={() => setShowPlanList(!showPlanList)}
|
|
className="w-full px-4 py-3 flex justify-between items-center"
|
|
>
|
|
<div className="flex flex-col items-start">
|
|
{isPlanFinished ? (
|
|
<div className="flex items-center gap-2 text-primary">
|
|
<CheckSquare size={18} />
|
|
<span className="font-bold">{t('plan_completed', lang)}</span>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<span className="text-[10px] text-primary font-medium tracking-wider">{t('step', lang)} {currentStepIndex + 1} {t('of', lang)} {activePlan.steps.length}</span>
|
|
<div className="font-medium text-on-surface flex items-center gap-2">
|
|
{activePlan.steps[currentStepIndex].exerciseName}
|
|
{activePlan.steps[currentStepIndex].isWeighted && <Scale size={12} className="text-primary" />}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
{showPlanList ? <ChevronUp size={20} className="text-on-surface-variant" /> : <ChevronDown size={20} className="text-on-surface-variant" />}
|
|
</button>
|
|
|
|
{showPlanList && (
|
|
<div className="max-h-48 overflow-y-auto bg-surface-container-high p-2 space-y-1 animate-in slide-in-from-top-2">
|
|
{activePlan.steps.map((step, idx) => (
|
|
<button
|
|
key={step.id}
|
|
onClick={() => jumpToStep(idx)}
|
|
className={`w-full text-left px-4 py-3 rounded-full text-sm flex items-center justify-between transition-colors ${idx === currentStepIndex
|
|
? 'bg-primary-container text-on-primary-container font-medium'
|
|
: idx < currentStepIndex
|
|
? 'text-on-surface-variant opacity-50'
|
|
: 'text-on-surface hover:bg-white/5'
|
|
}`}
|
|
>
|
|
<span>{idx + 1}. {step.exerciseName}</span>
|
|
{step.isWeighted && <Scale size={14} />}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex-1 overflow-y-auto p-4 pb-32 space-y-6">
|
|
|
|
<SetLogger
|
|
tracker={tracker}
|
|
lang={lang}
|
|
onLogSet={handleLogSet}
|
|
/>
|
|
|
|
{activeSession.sets.length > 0 && (
|
|
<div className="pt-4">
|
|
<h3 className="text-sm text-primary font-medium px-2 mb-3 tracking-wide">{t('history_section', lang)}</h3>
|
|
<div className="flex flex-col gap-2">
|
|
{[...activeSession.sets].reverse().map((set: WorkoutSet, idx: number) => {
|
|
const setNumber = activeSession.sets.length - idx;
|
|
return (
|
|
<div key={set.id} className="flex justify-between items-center p-4 bg-surface-container rounded-xl shadow-elevation-1 animate-in fade-in slide-in-from-bottom-2">
|
|
<div className="flex items-center gap-4 flex-1">
|
|
<div className="w-8 h-8 rounded-full bg-secondary-container text-on-secondary-container flex items-center justify-center text-xs font-bold">
|
|
{setNumber}
|
|
</div>
|
|
<div>
|
|
<div className="text-base font-medium text-on-surface">{set.exerciseName}{set.side && <span className="ml-2 text-xs font-medium text-on-surface-variant">{t(set.side.toLowerCase() as any, lang)}</span>}</div>
|
|
<div className="text-sm text-on-surface-variant">
|
|
{formatSetMetrics(set, lang)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<button
|
|
onClick={() => setEditingSet(set)}
|
|
className="p-2 text-on-surface-variant hover:text-primary hover:bg-primary-container/20 rounded-full transition-colors"
|
|
aria-label={t('edit', lang)}
|
|
>
|
|
<Edit size={20} />
|
|
</button>
|
|
<button
|
|
onClick={() => onRemoveSet(set.id)}
|
|
className="p-2 text-on-surface-variant hover:text-error hover:bg-error-container/10 rounded-full transition-colors"
|
|
aria-label={t('delete', lang)}
|
|
>
|
|
<Trash2 size={20} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{isCreating && (
|
|
<ExerciseModal
|
|
isOpen={isCreating}
|
|
onClose={() => setIsCreating(false)}
|
|
onSave={handleCreateExercise}
|
|
lang={lang}
|
|
existingExercises={exercises}
|
|
initialName={tracker.searchQuery}
|
|
/>
|
|
)}
|
|
|
|
{/* Finish Confirmation Dialog */}
|
|
{showFinishConfirm && (
|
|
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-6 backdrop-blur-sm">
|
|
<div className="w-full max-w-sm bg-surface-container rounded-[28px] p-6 shadow-elevation-3">
|
|
<h3 className="text-2xl font-normal text-on-surface mb-2">{t('finish_confirm_title', lang)}</h3>
|
|
<p className="text-on-surface-variant text-sm mb-8">{t('finish_confirm_msg', lang)}</p>
|
|
<div className="flex justify-end gap-2">
|
|
<button
|
|
onClick={() => setShowFinishConfirm(false)}
|
|
className="px-6 py-2.5 rounded-full text-primary font-medium hover:bg-white/5"
|
|
>
|
|
{t('cancel', lang)}
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
setShowFinishConfirm(false);
|
|
onSessionEnd();
|
|
}}
|
|
className="px-6 py-2.5 rounded-full bg-green-600 text-white font-medium hover:bg-green-700"
|
|
>
|
|
{t('confirm', lang)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Quit Without Saving Confirmation Dialog */}
|
|
{showQuitConfirm && (
|
|
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-6 backdrop-blur-sm">
|
|
<div className="w-full max-w-sm bg-surface-container rounded-[28px] p-6 shadow-elevation-3">
|
|
<h3 className="text-2xl font-normal text-error mb-2">{t('quit_confirm_title', lang)}</h3>
|
|
<p className="text-on-surface-variant text-sm mb-8">{t('quit_confirm_msg', lang)}</p>
|
|
<div className="flex justify-end gap-2">
|
|
<button
|
|
onClick={() => setShowQuitConfirm(false)}
|
|
className="px-6 py-2.5 rounded-full text-primary font-medium hover:bg-white/5"
|
|
>
|
|
{t('cancel', lang)}
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
setShowQuitConfirm(false);
|
|
onSessionQuit();
|
|
}}
|
|
className="px-6 py-2.5 rounded-full bg-green-600 text-white font-medium hover:bg-green-700"
|
|
>
|
|
{t('confirm', lang)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Edit Set Modal */}
|
|
{editingSet && (
|
|
<EditSetModal
|
|
isOpen={!!editingSet}
|
|
onClose={() => setEditingSet(null)}
|
|
set={editingSet}
|
|
exerciseDef={tracker.exercises.find(e => e.id === editingSet.exerciseId) || tracker.exercises.find(e => e.name === editingSet.exerciseName)}
|
|
onSave={handleSaveSetFromModal}
|
|
lang={lang}
|
|
/>
|
|
)}
|
|
|
|
<RestTimerFAB timer={timer} onDurationChange={handleDurationChange} />
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ActiveSessionView;
|