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",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@google/genai": "^1.30.0",
|
||||
"lucide-react": "^0.554.0",
|
||||
"npm-run-all": "^4.1.5",
|
||||
@@ -353,6 +356,59 @@
|
||||
"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": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
||||
@@ -7260,7 +7316,6 @@
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true,
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/type-fest": {
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
"test:full": "npm-run-all --parallel dev server:test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@google/genai": "^1.30.0",
|
||||
"lucide-react": "^0.554.0",
|
||||
"npm-run-all": "^4.1.5",
|
||||
|
||||
@@ -1,5 +1,24 @@
|
||||
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 { getExercises, saveExercise } from '../services/storage';
|
||||
import { t } from '../services/i18n';
|
||||
@@ -21,6 +40,87 @@ 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 = ({ 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 { currentUser } = useAuth();
|
||||
const userId = currentUser?.id || '';
|
||||
@@ -34,14 +134,25 @@ const Plans: React.FC<PlansProps> = ({ lang }) => {
|
||||
const [description, setDescription] = useState('');
|
||||
const [steps, setSteps] = useState<PlannedSet[]>([]);
|
||||
|
||||
const [availableExercises, setAvailableExercises] = useState<ExerciseDef[]>([]);
|
||||
const [showExerciseSelector, setShowExerciseSelector] = useState(false);
|
||||
|
||||
// Drag and Drop Refs
|
||||
const dragItem = React.useRef<number | null>(null);
|
||||
const [draggingIndex, setDraggingIndex] = useState<number | null>(null);
|
||||
// Dnd Sensors
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor), // Handle mouse and basic pointer events
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
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
|
||||
const [availableExercises, setAvailableExercises] = useState<ExerciseDef[]>([]);
|
||||
const [showExerciseSelector, setShowExerciseSelector] = useState(false);
|
||||
const [isCreatingExercise, setIsCreatingExercise] = useState(false);
|
||||
|
||||
// Preparation Modal State
|
||||
@@ -143,29 +254,16 @@ const Plans: React.FC<PlansProps> = ({ lang }) => {
|
||||
setSteps(steps.filter(s => s.id !== stepId));
|
||||
};
|
||||
|
||||
const onDragStart = (index: number) => {
|
||||
console.log('Drag Start:', index);
|
||||
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 handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
const newSteps = [...steps];
|
||||
const draggedItemContent = newSteps.splice(dragItem.current, 1)[0];
|
||||
newSteps.splice(index, 0, draggedItemContent);
|
||||
|
||||
setSteps(newSteps);
|
||||
dragItem.current = index;
|
||||
setDraggingIndex(index);
|
||||
console.log(`Swapped ${draggedItemContent.id} to ${index}`);
|
||||
};
|
||||
|
||||
const onDragEnd = () => {
|
||||
dragItem.current = null;
|
||||
setDraggingIndex(null);
|
||||
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);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (isEditing) {
|
||||
@@ -206,61 +304,28 @@ const Plans: React.FC<PlansProps> = ({ lang }) => {
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{steps.map((step, idx) => (
|
||||
<Card
|
||||
key={step.id}
|
||||
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' : ''}`}
|
||||
draggable
|
||||
onDragStart={() => onDragStart(idx)}
|
||||
onDragEnter={() => onDragEnter(idx)}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDragEnd={onDragEnd}
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className={`text-on-surface-variant p-1 ${draggingIndex === idx ? 'cursor-grabbing' : 'cursor-grab'}`}>
|
||||
<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">
|
||||
{idx + 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"
|
||||
<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}
|
||||
/>
|
||||
<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>
|
||||
|
||||
<Button
|
||||
@@ -275,6 +340,37 @@ const Plans: React.FC<PlansProps> = ({ lang }) => {
|
||||
</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)}
|
||||
|
||||
@@ -180,7 +180,7 @@ const SetLogger: React.FC<SetLoggerProps> = ({ tracker, lang, onLogSet, isSporad
|
||||
: '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>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -10,14 +10,91 @@ interface UseRestTimerProps {
|
||||
}
|
||||
|
||||
export const useRestTimer = ({ defaultTime, onFinish }: UseRestTimerProps) => {
|
||||
const [timeLeft, setTimeLeft] = useState(defaultTime);
|
||||
const [status, setStatus] = useState<TimerStatus>('IDLE');
|
||||
const [duration, setDuration] = useState(defaultTime); // The set duration to reset to
|
||||
// Initial state function to restore from localStorage if available
|
||||
const getInitialState = () => {
|
||||
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 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
|
||||
useEffect(() => {
|
||||
if (prevDefaultTimeRef.current !== defaultTime) {
|
||||
@@ -30,34 +107,27 @@ export const useRestTimer = ({ defaultTime, onFinish }: UseRestTimerProps) => {
|
||||
}
|
||||
}, [defaultTime, status]);
|
||||
|
||||
// Manage RAF based on status
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (rafRef.current) cancelAnimationFrame(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 {
|
||||
if (status === 'RUNNING') {
|
||||
if (!rafRef.current) {
|
||||
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(() => {
|
||||
if (status === 'RUNNING') return;
|
||||
@@ -67,24 +137,23 @@ export const useRestTimer = ({ defaultTime, onFinish }: UseRestTimerProps) => {
|
||||
endTimeRef.current = Date.now() + targetSeconds * 1000;
|
||||
|
||||
setStatus('RUNNING');
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
}, [status, timeLeft, duration, tick]);
|
||||
// Effect will trigger tick
|
||||
}, [status, timeLeft, duration]);
|
||||
|
||||
const pause = useCallback(() => {
|
||||
if (status !== 'RUNNING') return;
|
||||
setStatus('PAUSED');
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||
// Effect calls cancelAnimationFrame
|
||||
endTimeRef.current = null;
|
||||
}, [status]);
|
||||
|
||||
const reset = useCallback((newDuration?: number) => {
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||
|
||||
const nextDuration = newDuration !== undefined ? newDuration : duration;
|
||||
setDuration(nextDuration);
|
||||
setTimeLeft(nextDuration);
|
||||
setStatus('IDLE');
|
||||
endTimeRef.current = null;
|
||||
// Effect calls cancelAnimationFrame (since status becomes IDLE)
|
||||
}, [duration]);
|
||||
|
||||
const addTime = useCallback((seconds: number) => {
|
||||
|
||||
Reference in New Issue
Block a user