Timer is persistent with Local Storage. Drag&Drop of planned sets fixed on mobile.
This commit is contained in:
57
package-lock.json
generated
57
package-lock.json
generated
@@ -8,6 +8,9 @@
|
|||||||
"name": "gymflow-ai",
|
"name": "gymflow-ai",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@google/genai": "^1.30.0",
|
"@google/genai": "^1.30.0",
|
||||||
"lucide-react": "^0.554.0",
|
"lucide-react": "^0.554.0",
|
||||||
"npm-run-all": "^4.1.5",
|
"npm-run-all": "^4.1.5",
|
||||||
@@ -353,6 +356,59 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@dnd-kit/accessibility": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/core": {
|
||||||
|
"version": "6.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||||
|
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/accessibility": "^3.1.1",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0",
|
||||||
|
"react-dom": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/sortable": {
|
||||||
|
"version": "10.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
|
||||||
|
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.0",
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/utilities": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.25.12",
|
"version": "0.25.12",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
||||||
@@ -7260,7 +7316,6 @@
|
|||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"dev": true,
|
|
||||||
"license": "0BSD"
|
"license": "0BSD"
|
||||||
},
|
},
|
||||||
"node_modules/type-fest": {
|
"node_modules/type-fest": {
|
||||||
|
|||||||
@@ -15,6 +15,9 @@
|
|||||||
"test:full": "npm-run-all --parallel dev server:test"
|
"test:full": "npm-run-all --parallel dev server:test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@google/genai": "^1.30.0",
|
"@google/genai": "^1.30.0",
|
||||||
"lucide-react": "^0.554.0",
|
"lucide-react": "^0.554.0",
|
||||||
"npm-run-all": "^4.1.5",
|
"npm-run-all": "^4.1.5",
|
||||||
|
|||||||
@@ -1,5 +1,24 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Plus, Trash2, PlayCircle, Dumbbell, Save, X, ChevronRight, List, ArrowUp, ArrowDown, Pencil, User, Flame, Timer as TimerIcon, Ruler, Footprints, Activity, Percent, CheckCircle, GripVertical, Scale, Search } from 'lucide-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 {
|
||||||
|
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 { WorkoutPlan, ExerciseDef, PlannedSet, Language, ExerciseType } from '../types';
|
||||||
import { getExercises, saveExercise } from '../services/storage';
|
import { getExercises, saveExercise } from '../services/storage';
|
||||||
import { t } from '../services/i18n';
|
import { t } from '../services/i18n';
|
||||||
@@ -21,6 +40,87 @@ interface PlansProps {
|
|||||||
lang: Language;
|
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 = ({ step, index, toggleWeighted, updateRestTime, removeStep, lang }: SortablePlanStepProps) => {
|
||||||
|
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',
|
||||||
|
};
|
||||||
|
|
||||||
|
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}>
|
||||||
|
<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 Plans: React.FC<PlansProps> = ({ lang }) => {
|
||||||
const { currentUser } = useAuth();
|
const { currentUser } = useAuth();
|
||||||
const userId = currentUser?.id || '';
|
const userId = currentUser?.id || '';
|
||||||
@@ -34,14 +134,25 @@ const Plans: React.FC<PlansProps> = ({ lang }) => {
|
|||||||
const [description, setDescription] = useState('');
|
const [description, setDescription] = useState('');
|
||||||
const [steps, setSteps] = useState<PlannedSet[]>([]);
|
const [steps, setSteps] = useState<PlannedSet[]>([]);
|
||||||
|
|
||||||
const [availableExercises, setAvailableExercises] = useState<ExerciseDef[]>([]);
|
// Dnd Sensors
|
||||||
const [showExerciseSelector, setShowExerciseSelector] = useState(false);
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor), // Handle mouse and basic pointer events
|
||||||
// Drag and Drop Refs
|
useSensor(KeyboardSensor, {
|
||||||
const dragItem = React.useRef<number | null>(null);
|
coordinateGetter: sortableKeyboardCoordinates,
|
||||||
const [draggingIndex, setDraggingIndex] = useState<number | null>(null);
|
}),
|
||||||
|
useSensor(TouchSensor, {
|
||||||
|
// Small delay or tolerance can help distinguish scrolling from dragging,
|
||||||
|
// but usually for a handle drag, instant is fine or defaults work.
|
||||||
|
// Let's add a small activation constraint to prevent accidental drags while scrolling if picking by handle
|
||||||
|
activationConstraint: {
|
||||||
|
distance: 5,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
// Create Exercise State
|
// Create Exercise State
|
||||||
|
const [availableExercises, setAvailableExercises] = useState<ExerciseDef[]>([]);
|
||||||
|
const [showExerciseSelector, setShowExerciseSelector] = useState(false);
|
||||||
const [isCreatingExercise, setIsCreatingExercise] = useState(false);
|
const [isCreatingExercise, setIsCreatingExercise] = useState(false);
|
||||||
|
|
||||||
// Preparation Modal State
|
// Preparation Modal State
|
||||||
@@ -143,29 +254,16 @@ const Plans: React.FC<PlansProps> = ({ lang }) => {
|
|||||||
setSteps(steps.filter(s => s.id !== stepId));
|
setSteps(steps.filter(s => s.id !== stepId));
|
||||||
};
|
};
|
||||||
|
|
||||||
const onDragStart = (index: number) => {
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
console.log('Drag Start:', index);
|
const { active, over } = event;
|
||||||
dragItem.current = index;
|
|
||||||
setDraggingIndex(index);
|
|
||||||
};
|
|
||||||
const onDragEnter = (index: number) => {
|
|
||||||
console.log('Drag Enter:', index);
|
|
||||||
if (dragItem.current === null) return;
|
|
||||||
if (dragItem.current === index) return;
|
|
||||||
|
|
||||||
const newSteps = [...steps];
|
if (active.id !== over?.id) {
|
||||||
const draggedItemContent = newSteps.splice(dragItem.current, 1)[0];
|
setSteps((items) => {
|
||||||
newSteps.splice(index, 0, draggedItemContent);
|
const oldIndex = items.findIndex((item) => item.id === active.id);
|
||||||
|
const newIndex = items.findIndex((item) => item.id === over?.id);
|
||||||
setSteps(newSteps);
|
return arrayMove(items, oldIndex, newIndex);
|
||||||
dragItem.current = index;
|
});
|
||||||
setDraggingIndex(index);
|
}
|
||||||
console.log(`Swapped ${draggedItemContent.id} to ${index}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDragEnd = () => {
|
|
||||||
dragItem.current = null;
|
|
||||||
setDraggingIndex(null);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isEditing) {
|
if (isEditing) {
|
||||||
@@ -206,61 +304,28 @@ const Plans: React.FC<PlansProps> = ({ lang }) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{steps.map((step, idx) => (
|
<DndContext
|
||||||
<Card
|
sensors={sensors}
|
||||||
key={step.id}
|
collisionDetection={closestCenter}
|
||||||
className={`flex items-center gap-3 transition-all hover:bg-surface-container-high ${draggingIndex === idx ? 'opacity-50 ring-2 ring-primary bg-surface-container-high' : ''}`}
|
onDragEnd={handleDragEnd}
|
||||||
draggable
|
|
||||||
onDragStart={() => onDragStart(idx)}
|
|
||||||
onDragEnter={() => onDragEnter(idx)}
|
|
||||||
onDragOver={(e) => e.preventDefault()}
|
|
||||||
onDragEnd={onDragEnd}
|
|
||||||
>
|
>
|
||||||
<div className={`text-on-surface-variant p-1 ${draggingIndex === idx ? 'cursor-grabbing' : 'cursor-grab'}`}>
|
<SortableContext
|
||||||
<GripVertical size={20} />
|
items={steps.map(s => s.id)}
|
||||||
</div>
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
<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">
|
{steps.map((step, idx) => (
|
||||||
{idx + 1}
|
<SortablePlanStep
|
||||||
</div>
|
key={step.id}
|
||||||
|
step={step}
|
||||||
<div className="flex-1">
|
index={idx}
|
||||||
<div className="text-base font-medium text-on-surface">{step.exerciseName}</div>
|
toggleWeighted={toggleWeighted}
|
||||||
<div className="flex items-center gap-4 mt-1">
|
updateRestTime={updateRestTime}
|
||||||
<label className="flex items-center gap-2 cursor-pointer w-fit">
|
removeStep={removeStep}
|
||||||
<div className={`w-4 h-4 border rounded flex items-center justify-center ${step.isWeighted ? 'bg-primary border-primary' : 'border-outline'}`}>
|
lang={lang}
|
||||||
{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">
|
|
||||||
<X size={20} />
|
|
||||||
</Button>
|
|
||||||
</Card>
|
|
||||||
))}
|
))}
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@@ -275,6 +340,37 @@ const Plans: React.FC<PlansProps> = ({ lang }) => {
|
|||||||
</div>
|
</div>
|
||||||
</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
|
<SideSheet
|
||||||
isOpen={showExerciseSelector}
|
isOpen={showExerciseSelector}
|
||||||
onClose={() => setShowExerciseSelector(false)}
|
onClose={() => setShowExerciseSelector(false)}
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ const SetLogger: React.FC<SetLoggerProps> = ({ tracker, lang, onLogSet, isSporad
|
|||||||
: 'bg-primary-container text-on-primary-container'
|
: 'bg-primary-container text-on-primary-container'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{isSporadic && sporadicSuccess ? <CheckCircle size={24} /> : (isSporadic ? <Plus size={24} /> : <CheckCircle size={24} />)}
|
{isSporadic && sporadicSuccess ? <CheckCircle size={24} /> : <Plus size={24} />}
|
||||||
<span>{isSporadic && sporadicSuccess ? t('saved', lang) : t('log_set', lang)}</span>
|
<span>{isSporadic && sporadicSuccess ? t('saved', lang) : t('log_set', lang)}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,14 +10,91 @@ interface UseRestTimerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const useRestTimer = ({ defaultTime, onFinish }: UseRestTimerProps) => {
|
export const useRestTimer = ({ defaultTime, onFinish }: UseRestTimerProps) => {
|
||||||
const [timeLeft, setTimeLeft] = useState(defaultTime);
|
// Initial state function to restore from localStorage if available
|
||||||
const [status, setStatus] = useState<TimerStatus>('IDLE');
|
const getInitialState = () => {
|
||||||
const [duration, setDuration] = useState(defaultTime); // The set duration to reset to
|
try {
|
||||||
|
const saved = localStorage.getItem('gymflow_rest_timer');
|
||||||
|
if (saved) {
|
||||||
|
const parsed = JSON.parse(saved);
|
||||||
|
// Validate parsed data structure lightly
|
||||||
|
if (parsed && typeof parsed.timeLeft === 'number') {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to parse saved timer", e);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
const endTimeRef = useRef<number | null>(null);
|
const savedState = getInitialState();
|
||||||
|
|
||||||
|
// If we have a saved running timer, we need to recalculate time left
|
||||||
|
let initialTimeLeft = defaultTime;
|
||||||
|
let initialStatus: TimerStatus = 'IDLE';
|
||||||
|
let initialDuration = defaultTime;
|
||||||
|
|
||||||
|
if (savedState) {
|
||||||
|
initialDuration = savedState.duration || defaultTime;
|
||||||
|
initialStatus = savedState.status;
|
||||||
|
initialTimeLeft = savedState.timeLeft;
|
||||||
|
|
||||||
|
if (initialStatus === 'RUNNING' && savedState.endTime) {
|
||||||
|
const now = Date.now();
|
||||||
|
const remaining = Math.max(0, Math.ceil((savedState.endTime - now) / 1000));
|
||||||
|
if (remaining > 0) {
|
||||||
|
initialTimeLeft = remaining;
|
||||||
|
} else {
|
||||||
|
initialStatus = 'FINISHED'; // It finished while we were away
|
||||||
|
initialTimeLeft = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [timeLeft, setTimeLeft] = useState(initialTimeLeft);
|
||||||
|
const [status, setStatus] = useState<TimerStatus>(initialStatus);
|
||||||
|
const [duration, setDuration] = useState(initialDuration);
|
||||||
|
|
||||||
|
const endTimeRef = useRef<number | null>(savedState?.endTime || null);
|
||||||
const rafRef = useRef<number | null>(null);
|
const rafRef = useRef<number | null>(null);
|
||||||
const prevDefaultTimeRef = useRef(defaultTime);
|
const prevDefaultTimeRef = useRef(defaultTime);
|
||||||
|
|
||||||
|
// Tick function - defined before effects
|
||||||
|
const tick = useCallback(() => {
|
||||||
|
if (!endTimeRef.current) return;
|
||||||
|
const now = Date.now();
|
||||||
|
const remaining = Math.max(0, Math.ceil((endTimeRef.current - now) / 1000));
|
||||||
|
|
||||||
|
setTimeLeft(remaining);
|
||||||
|
|
||||||
|
if (remaining <= 0) {
|
||||||
|
setStatus('FINISHED');
|
||||||
|
playTimeUpSignal();
|
||||||
|
if (onFinish) onFinish();
|
||||||
|
|
||||||
|
endTimeRef.current = null; // Clear end time
|
||||||
|
|
||||||
|
// Auto-reset visuals after 3 seconds of "FINISHED" state?
|
||||||
|
setTimeout(() => {
|
||||||
|
setStatus(prev => prev === 'FINISHED' ? 'IDLE' : prev);
|
||||||
|
setTimeLeft(prev => prev === 0 ? duration : prev);
|
||||||
|
}, 3000);
|
||||||
|
} else {
|
||||||
|
rafRef.current = requestAnimationFrame(tick);
|
||||||
|
}
|
||||||
|
}, [duration, onFinish]);
|
||||||
|
|
||||||
|
// Save to localStorage whenever relevant state changes
|
||||||
|
useEffect(() => {
|
||||||
|
const stateToSave = {
|
||||||
|
status,
|
||||||
|
timeLeft,
|
||||||
|
duration,
|
||||||
|
endTime: endTimeRef.current
|
||||||
|
};
|
||||||
|
localStorage.setItem('gymflow_rest_timer', JSON.stringify(stateToSave));
|
||||||
|
}, [status, timeLeft, duration]);
|
||||||
|
|
||||||
// Update internal duration when defaultTime changes
|
// Update internal duration when defaultTime changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (prevDefaultTimeRef.current !== defaultTime) {
|
if (prevDefaultTimeRef.current !== defaultTime) {
|
||||||
@@ -30,34 +107,27 @@ export const useRestTimer = ({ defaultTime, onFinish }: UseRestTimerProps) => {
|
|||||||
}
|
}
|
||||||
}, [defaultTime, status]);
|
}, [defaultTime, status]);
|
||||||
|
|
||||||
|
// Manage RAF based on status
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
if (status === 'RUNNING') {
|
||||||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
if (!rafRef.current) {
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const tick = useCallback(() => {
|
|
||||||
if (!endTimeRef.current) return;
|
|
||||||
const now = Date.now();
|
|
||||||
const remaining = Math.max(0, Math.ceil((endTimeRef.current - now) / 1000));
|
|
||||||
|
|
||||||
setTimeLeft(remaining);
|
|
||||||
|
|
||||||
if (remaining <= 0) {
|
|
||||||
setStatus('FINISHED');
|
|
||||||
playTimeUpSignal();
|
|
||||||
if (onFinish) onFinish();
|
|
||||||
|
|
||||||
// Auto-reset visuals after 3 seconds of "FINISHED" state?
|
|
||||||
// Requirement says: "The FAB must first change color to red for 3 seconds, and then return to the idle state"
|
|
||||||
// So the hook stays in FINISHED.
|
|
||||||
setTimeout(() => {
|
|
||||||
reset();
|
|
||||||
}, 3000);
|
|
||||||
} else {
|
|
||||||
rafRef.current = requestAnimationFrame(tick);
|
rafRef.current = requestAnimationFrame(tick);
|
||||||
}
|
}
|
||||||
}, [onFinish]);
|
} else {
|
||||||
|
if (rafRef.current) {
|
||||||
|
cancelAnimationFrame(rafRef.current);
|
||||||
|
rafRef.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (rafRef.current) {
|
||||||
|
cancelAnimationFrame(rafRef.current);
|
||||||
|
rafRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [status, tick]);
|
||||||
|
|
||||||
|
|
||||||
const start = useCallback(() => {
|
const start = useCallback(() => {
|
||||||
if (status === 'RUNNING') return;
|
if (status === 'RUNNING') return;
|
||||||
@@ -67,24 +137,23 @@ export const useRestTimer = ({ defaultTime, onFinish }: UseRestTimerProps) => {
|
|||||||
endTimeRef.current = Date.now() + targetSeconds * 1000;
|
endTimeRef.current = Date.now() + targetSeconds * 1000;
|
||||||
|
|
||||||
setStatus('RUNNING');
|
setStatus('RUNNING');
|
||||||
rafRef.current = requestAnimationFrame(tick);
|
// Effect will trigger tick
|
||||||
}, [status, timeLeft, duration, tick]);
|
}, [status, timeLeft, duration]);
|
||||||
|
|
||||||
const pause = useCallback(() => {
|
const pause = useCallback(() => {
|
||||||
if (status !== 'RUNNING') return;
|
if (status !== 'RUNNING') return;
|
||||||
setStatus('PAUSED');
|
setStatus('PAUSED');
|
||||||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
// Effect calls cancelAnimationFrame
|
||||||
endTimeRef.current = null;
|
endTimeRef.current = null;
|
||||||
}, [status]);
|
}, [status]);
|
||||||
|
|
||||||
const reset = useCallback((newDuration?: number) => {
|
const reset = useCallback((newDuration?: number) => {
|
||||||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
|
||||||
|
|
||||||
const nextDuration = newDuration !== undefined ? newDuration : duration;
|
const nextDuration = newDuration !== undefined ? newDuration : duration;
|
||||||
setDuration(nextDuration);
|
setDuration(nextDuration);
|
||||||
setTimeLeft(nextDuration);
|
setTimeLeft(nextDuration);
|
||||||
setStatus('IDLE');
|
setStatus('IDLE');
|
||||||
endTimeRef.current = null;
|
endTimeRef.current = null;
|
||||||
|
// Effect calls cancelAnimationFrame (since status becomes IDLE)
|
||||||
}, [duration]);
|
}, [duration]);
|
||||||
|
|
||||||
const addTime = useCallback((seconds: number) => {
|
const addTime = useCallback((seconds: number) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user