Timer implemented. No working tests.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Plus, Trash2, PlayCircle, Dumbbell, Save, X, ChevronRight, List, ArrowUp, ArrowDown, Edit2, User, Flame, Timer as TimerIcon, Ruler, Footprints, Activity, Percent, CheckCircle, GripVertical, Scale } from 'lucide-react';
|
||||
import { Plus, Trash2, PlayCircle, Dumbbell, Save, X, ChevronRight, List, ArrowUp, ArrowDown, Edit2, User, Flame, Timer as TimerIcon, Ruler, Footprints, Activity, Percent, CheckCircle, GripVertical, Scale, Search } from 'lucide-react';
|
||||
import { WorkoutPlan, ExerciseDef, PlannedSet, Language, ExerciseType } from '../types';
|
||||
import { getExercises, saveExercise } from '../services/storage';
|
||||
import { t } from '../services/i18n';
|
||||
@@ -12,6 +12,7 @@ import FilledInput from './FilledInput';
|
||||
import { toTitleCase } from '../utils/text';
|
||||
import { Button } from './ui/Button';
|
||||
import { Card } from './ui/Card';
|
||||
import { Modal } from './ui/Modal';
|
||||
|
||||
interface PlansProps {
|
||||
lang: Language;
|
||||
@@ -93,7 +94,10 @@ const Plans: React.FC<PlansProps> = ({ lang }) => {
|
||||
exerciseId: ex.id,
|
||||
exerciseName: ex.name,
|
||||
exerciseType: ex.type,
|
||||
isWeighted: false
|
||||
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);
|
||||
@@ -134,6 +138,10 @@ const Plans: React.FC<PlansProps> = ({ lang }) => {
|
||||
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));
|
||||
};
|
||||
@@ -222,18 +230,35 @@ const Plans: React.FC<PlansProps> = ({ lang }) => {
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="text-base font-medium text-on-surface">{step.exerciseName}</div>
|
||||
<label className="flex items-center gap-2 mt-1 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 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>
|
||||
<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>
|
||||
</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} />
|
||||
@@ -254,20 +279,19 @@ const Plans: React.FC<PlansProps> = ({ lang }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showExerciseSelector && (
|
||||
<div className="fixed inset-0 bg-surface z-50 flex flex-col animate-in slide-in-from-bottom-full duration-200">
|
||||
<div className="px-4 py-3 border-b border-outline-variant flex justify-between items-center bg-surface-container shrink-0">
|
||||
<span className="font-medium text-on-surface">{t('select_exercise', lang)}</span>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={() => setIsCreatingExercise(true)} variant="ghost" size="icon" className="text-primary hover:bg-primary-container/20">
|
||||
<Plus size={20} />
|
||||
</Button>
|
||||
<Button onClick={() => setShowExerciseSelector(false)} variant="ghost" size="icon">
|
||||
<X size={20} />
|
||||
</Button>
|
||||
</div>
|
||||
<Modal
|
||||
isOpen={showExerciseSelector}
|
||||
onClose={() => setShowExerciseSelector(false)}
|
||||
title={t('select_exercise', lang)}
|
||||
maxWidth="md"
|
||||
>
|
||||
<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 p-2">
|
||||
<div className="flex-1 overflow-y-auto -mx-6 px-6">
|
||||
{availableExercises
|
||||
.slice()
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
@@ -275,84 +299,80 @@ const Plans: React.FC<PlansProps> = ({ lang }) => {
|
||||
<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"
|
||||
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>{ex.name}</span>
|
||||
<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>
|
||||
|
||||
{isCreatingExercise && (
|
||||
<div className="fixed inset-0 bg-surface z-[60] flex flex-col animate-in slide-in-from-bottom-full duration-200">
|
||||
<div className="px-4 py-3 border-b border-outline-variant flex justify-between items-center bg-surface-container shrink-0">
|
||||
<h3 className="text-title-medium font-medium text-on-surface">{t('create_exercise', lang)}</h3>
|
||||
<Button onClick={() => setIsCreatingExercise(false)} variant="ghost" size="icon" className="text-on-surface-variant hover:bg-white/5">
|
||||
<X size={20} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-6 overflow-y-auto flex-1">
|
||||
<FilledInput
|
||||
label={t('ex_name', lang)}
|
||||
value={newExName}
|
||||
onChange={(e: any) => setNewExName(e.target.value)}
|
||||
type="text"
|
||||
autoFocus
|
||||
autocapitalize="words"
|
||||
onBlur={() => setNewExName(toTitleCase(newExName))}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-on-surface-variant font-medium mb-3">{t('ex_type', lang)}</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[
|
||||
{ id: ExerciseType.STRENGTH, label: exerciseTypeLabels[ExerciseType.STRENGTH], icon: Dumbbell },
|
||||
{ id: ExerciseType.BODYWEIGHT, label: exerciseTypeLabels[ExerciseType.BODYWEIGHT], icon: User },
|
||||
{ id: ExerciseType.CARDIO, label: exerciseTypeLabels[ExerciseType.CARDIO], icon: Flame },
|
||||
{ id: ExerciseType.STATIC, label: exerciseTypeLabels[ExerciseType.STATIC], icon: TimerIcon },
|
||||
{ id: ExerciseType.HIGH_JUMP, label: exerciseTypeLabels[ExerciseType.HIGH_JUMP], icon: ArrowUp },
|
||||
{ id: ExerciseType.LONG_JUMP, label: exerciseTypeLabels[ExerciseType.LONG_JUMP], icon: Ruler },
|
||||
{ id: ExerciseType.PLYOMETRIC, label: exerciseTypeLabels[ExerciseType.PLYOMETRIC], icon: Footprints },
|
||||
].map((type) => (
|
||||
<button
|
||||
key={type.id}
|
||||
onClick={() => setNewExType(type.id)}
|
||||
className={`px-4 py-2 rounded-lg flex items-center gap-2 text-xs font-medium border transition-all ${newExType === type.id
|
||||
? 'bg-secondary-container text-on-secondary-container border-transparent'
|
||||
: 'bg-transparent text-on-surface-variant border-outline hover:border-on-surface-variant'
|
||||
}`}
|
||||
>
|
||||
<type.icon size={14} /> {type.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{newExType === ExerciseType.BODYWEIGHT && (
|
||||
<FilledInput
|
||||
label={t('body_weight_percent', lang)}
|
||||
value={newExBwPercentage}
|
||||
onChange={(e: any) => setNewExBwPercentage(e.target.value)}
|
||||
icon={<Percent size={12} />}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end mt-4">
|
||||
<Button
|
||||
onClick={handleCreateExercise}
|
||||
fullWidth
|
||||
size="lg"
|
||||
>
|
||||
<CheckCircle size={20} className="mr-2" />
|
||||
{t('create_btn', lang)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
isOpen={isCreatingExercise}
|
||||
onClose={() => setIsCreatingExercise(false)}
|
||||
title={t('create_exercise', lang)}
|
||||
maxWidth="md"
|
||||
>
|
||||
<div className="space-y-6 pt-2">
|
||||
<FilledInput
|
||||
label={t('ex_name', lang)}
|
||||
value={newExName}
|
||||
onChange={(e: any) => setNewExName(e.target.value)}
|
||||
type="text"
|
||||
autoFocus
|
||||
autocapitalize="words"
|
||||
onBlur={() => setNewExName(toTitleCase(newExName))}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-on-surface-variant font-medium mb-3">{t('ex_type', lang)}</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[
|
||||
{ id: ExerciseType.STRENGTH, label: exerciseTypeLabels[ExerciseType.STRENGTH], icon: Dumbbell },
|
||||
{ id: ExerciseType.BODYWEIGHT, label: exerciseTypeLabels[ExerciseType.BODYWEIGHT], icon: User },
|
||||
{ id: ExerciseType.CARDIO, label: exerciseTypeLabels[ExerciseType.CARDIO], icon: Flame },
|
||||
{ id: ExerciseType.STATIC, label: exerciseTypeLabels[ExerciseType.STATIC], icon: TimerIcon },
|
||||
{ id: ExerciseType.HIGH_JUMP, label: exerciseTypeLabels[ExerciseType.HIGH_JUMP], icon: ArrowUp },
|
||||
{ id: ExerciseType.LONG_JUMP, label: exerciseTypeLabels[ExerciseType.LONG_JUMP], icon: Ruler },
|
||||
{ id: ExerciseType.PLYOMETRIC, label: exerciseTypeLabels[ExerciseType.PLYOMETRIC], icon: Footprints },
|
||||
].map((type) => (
|
||||
<button
|
||||
key={type.id}
|
||||
onClick={() => setNewExType(type.id)}
|
||||
className={`px-4 py-2 rounded-lg flex items-center gap-2 text-xs font-medium border transition-all ${newExType === type.id
|
||||
? 'bg-secondary-container text-on-secondary-container border-transparent'
|
||||
: 'bg-transparent text-on-surface-variant border-outline hover:border-on-surface-variant'
|
||||
}`}
|
||||
>
|
||||
<type.icon size={14} /> {type.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{newExType === ExerciseType.BODYWEIGHT && (
|
||||
<FilledInput
|
||||
label={t('body_weight_percent', lang)}
|
||||
value={newExBwPercentage}
|
||||
onChange={(e: any) => setNewExBwPercentage(e.target.value)}
|
||||
icon={<Percent size={12} />}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end mt-6">
|
||||
<Button
|
||||
onClick={handleCreateExercise}
|
||||
fullWidth
|
||||
size="lg"
|
||||
>
|
||||
<CheckCircle size={20} className="mr-2" />
|
||||
{t('create_btn', lang)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ 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';
|
||||
|
||||
interface ActiveSessionViewProps {
|
||||
tracker: ReturnType<typeof useTracker>;
|
||||
@@ -71,6 +74,61 @@ const ActiveSessionView: React.FC<ActiveSessionViewProps> = ({ tracker, activeSe
|
||||
exercises
|
||||
} = tracker;
|
||||
|
||||
const { currentUser, updateUser } = useAuth();
|
||||
|
||||
// Timer Logic is now managed in useTracker to persist across re-renders/step changes
|
||||
const { timer } = tracker;
|
||||
|
||||
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();
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
@@ -177,7 +235,7 @@ const ActiveSessionView: React.FC<ActiveSessionViewProps> = ({ tracker, activeSe
|
||||
<SetLogger
|
||||
tracker={tracker}
|
||||
lang={lang}
|
||||
onLogSet={handleAddSet}
|
||||
onLogSet={handleLogSet}
|
||||
/>
|
||||
|
||||
{activeSession.sets.length > 0 && (
|
||||
@@ -397,6 +455,8 @@ const ActiveSessionView: React.FC<ActiveSessionViewProps> = ({ tracker, activeSe
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<RestTimerFAB timer={timer} onDurationChange={handleDurationChange} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,6 +6,10 @@ 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 { useRestTimer } from '../../hooks/useRestTimer'; // Not needed if using tracker.timer
|
||||
import RestTimerFAB from '../ui/RestTimerFAB';
|
||||
|
||||
interface SporadicViewProps {
|
||||
tracker: ReturnType<typeof useTracker>;
|
||||
@@ -26,6 +30,31 @@ const SporadicView: React.FC<SporadicViewProps> = ({ tracker, lang }) => {
|
||||
loadQuickLogSession
|
||||
} = tracker;
|
||||
|
||||
const { currentUser, updateUser } = useAuth();
|
||||
|
||||
// Timer Logic is now managed in useTracker
|
||||
const { timer } = tracker;
|
||||
|
||||
const handleLogSet = async () => {
|
||||
await handleLogSporadicSet();
|
||||
// Always usage default/current setting for sporadic
|
||||
timer.start();
|
||||
};
|
||||
|
||||
const handleDurationChange = async (newVal: number) => {
|
||||
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 [todaysSets, setTodaysSets] = useState<WorkoutSet[]>([]);
|
||||
const [editingSetId, setEditingSetId] = useState<string | null>(null);
|
||||
const [editingSet, setEditingSet] = useState<WorkoutSet | null>(null);
|
||||
@@ -72,7 +101,7 @@ const SporadicView: React.FC<SporadicViewProps> = ({ tracker, lang }) => {
|
||||
<SetLogger
|
||||
tracker={tracker}
|
||||
lang={lang}
|
||||
onLogSet={handleLogSporadicSet}
|
||||
onLogSet={handleLogSet}
|
||||
isSporadic={true}
|
||||
/>
|
||||
|
||||
@@ -301,6 +330,8 @@ const SporadicView: React.FC<SporadicViewProps> = ({ tracker, lang }) => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<RestTimerFAB timer={tracker.timer} onDurationChange={handleDurationChange} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ import { usePlanExecution } from '../../hooks/usePlanExecution';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { useActiveWorkout } from '../../context/ActiveWorkoutContext';
|
||||
import { useSession } from '../../context/SessionContext';
|
||||
import { useRestTimer } from '../../hooks/useRestTimer';
|
||||
|
||||
export const useTracker = (props: any) => { // Props ignored/removed
|
||||
const { currentUser } = useAuth();
|
||||
@@ -61,6 +62,21 @@ export const useTracker = (props: any) => { // Props ignored/removed
|
||||
const form = useWorkoutForm({ userId, onUpdateSet: handleUpdateSetWrapper });
|
||||
const planExec = usePlanExecution({ activeSession, activePlan, exercises });
|
||||
|
||||
// Rest Timer Logic (Moved from ActiveSessionView to persist state)
|
||||
const getTargetRestTime = () => {
|
||||
if (activePlan) {
|
||||
const currentStep = activePlan.steps[planExec.currentStepIndex];
|
||||
if (currentStep && currentStep.restTimeSeconds) {
|
||||
return currentStep.restTimeSeconds;
|
||||
}
|
||||
}
|
||||
return currentUser?.profile?.restTimerDefault || 120;
|
||||
};
|
||||
const targetRestTime = getTargetRestTime();
|
||||
const timer = useRestTimer({
|
||||
defaultTime: targetRestTime
|
||||
});
|
||||
|
||||
// Initial Data Load
|
||||
useEffect(() => {
|
||||
if (!userId) return;
|
||||
@@ -247,7 +263,8 @@ export const useTracker = (props: any) => { // Props ignored/removed
|
||||
onSessionEnd: endSession,
|
||||
onSessionQuit: quitSession,
|
||||
onRemoveSet: removeSet,
|
||||
activeSession // Need this in view
|
||||
activeSession, // Need this in view
|
||||
timer // Expose timer to views
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
211
src/components/ui/RestTimerFAB.tsx
Normal file
211
src/components/ui/RestTimerFAB.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Timer, Play, Pause, RotateCcw, Edit2, Plus, Minus, X, Check } from 'lucide-react';
|
||||
import { useRestTimer } from '../../hooks/useRestTimer';
|
||||
|
||||
interface RestTimerFABProps {
|
||||
timer: ReturnType<typeof useRestTimer>;
|
||||
onDurationChange?: (newDuration: number) => void;
|
||||
}
|
||||
|
||||
const RestTimerFAB: React.FC<RestTimerFABProps> = ({ timer, onDurationChange }) => {
|
||||
const { timeLeft, status, start, pause, reset, setDuration } = timer;
|
||||
|
||||
// Render Helpers (moved up for initial state calculation)
|
||||
const formatSeconds = (sec: number) => {
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = sec % 60;
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editValue, setEditValue] = useState(120);
|
||||
const [inputValue, setInputValue] = useState(formatSeconds(120));
|
||||
|
||||
// Auto-expand when running if not already expanded? No, requirement says "when time is running, show digits of the countdown on the enlarged timer FAB even if the menu is collapsed".
|
||||
// So the FAB itself grows.
|
||||
|
||||
// Effects
|
||||
useEffect(() => {
|
||||
if (status === 'FINISHED') {
|
||||
setIsExpanded(false);
|
||||
setIsEditing(false);
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
useEffect(() => {
|
||||
// Sync input value when editValue changes (externally or via +/- buttons)
|
||||
setInputValue(formatSeconds(editValue));
|
||||
}, [editValue]);
|
||||
|
||||
const handleToggle = () => {
|
||||
if (isEditing) return; // Don't toggle if editing
|
||||
setIsExpanded(!isExpanded);
|
||||
};
|
||||
|
||||
const handleStartPause = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (status === 'RUNNING') pause();
|
||||
else start();
|
||||
};
|
||||
|
||||
const handleReset = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
reset();
|
||||
};
|
||||
|
||||
const handleEdit = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const initialVal = timeLeft > 0 ? timeLeft : 120;
|
||||
setEditValue(initialVal);
|
||||
setInputValue(formatSeconds(initialVal));
|
||||
setIsEditing(true);
|
||||
setIsExpanded(true); // Keep expanded
|
||||
};
|
||||
|
||||
const saveEdit = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
// Ensure we capture any pending input value on save
|
||||
// (Though blur usually handles it, explicit save should too)
|
||||
let finalVal = editValue;
|
||||
|
||||
// Try parsing current input just in case
|
||||
if (inputValue.includes(':')) {
|
||||
const [m, s] = inputValue.split(':').map(Number);
|
||||
if (!isNaN(m) && !isNaN(s)) finalVal = m * 60 + s;
|
||||
} else if (/^\d+$/.test(inputValue)) {
|
||||
const v = parseInt(inputValue, 10);
|
||||
if (!isNaN(v)) finalVal = v;
|
||||
}
|
||||
|
||||
reset(finalVal);
|
||||
if (onDurationChange) onDurationChange(finalVal);
|
||||
setIsEditing(false);
|
||||
setIsExpanded(true); // Keep expanded per user request
|
||||
};
|
||||
|
||||
const adjustEdit = (curr: number, delta: number) => {
|
||||
return Math.max(0, curr + delta);
|
||||
};
|
||||
|
||||
const isRunningOrPaused = status === 'RUNNING' || status === 'PAUSED';
|
||||
const isFinished = status === 'FINISHED';
|
||||
|
||||
// Base classes
|
||||
const fabBaseClasses = `fixed bottom-24 right-6 shadow-elevation-3 transition-all duration-300 z-50 flex items-center justify-center font-medium`;
|
||||
|
||||
// Dimensions and Colors
|
||||
let fabClasses = fabBaseClasses;
|
||||
let content = null;
|
||||
|
||||
const handleInputBlur = () => {
|
||||
let val = 0;
|
||||
if (inputValue.includes(':')) {
|
||||
const [m, s] = inputValue.split(':').map(Number);
|
||||
if (!isNaN(m) && !isNaN(s)) val = m * 60 + s;
|
||||
} else {
|
||||
val = parseInt(inputValue, 10);
|
||||
}
|
||||
|
||||
if (!isNaN(val)) {
|
||||
setEditValue(val);
|
||||
setInputValue(formatSeconds(val)); // Re-format to standardized look
|
||||
} else {
|
||||
setInputValue(formatSeconds(editValue)); // Revert if invalid
|
||||
}
|
||||
};
|
||||
|
||||
if (isFinished) {
|
||||
fabClasses += ` w-14 h-14 rounded-[16px] bg-error text-on-error animate-pulse`;
|
||||
content = <Timer size={24} />;
|
||||
} else if (isRunningOrPaused && !isExpanded) {
|
||||
// Requirements: "when time is running, show digits of the countdown on the enlarged timer FAB even if the menu is collapsed"
|
||||
// So it should be wide enough to show digits.
|
||||
fabClasses += ` h-14 rounded-[16px] bg-primary-container text-on-primary-container px-4 min-w-[56px] cursor-pointer hover:brightness-95`;
|
||||
content = (
|
||||
<div className="flex items-center gap-2 pointer-events-none">
|
||||
<span className="font-mono text-lg font-bold">{formatSeconds(timeLeft)}</span>
|
||||
</div>
|
||||
);
|
||||
} else if (isExpanded) {
|
||||
// Expanded: No common background, just a column of buttons right-aligned (Speed Dial)
|
||||
fabClasses += ` w-auto bg-transparent flex-col-reverse items-end justify-end pb-0 overflow-visible`;
|
||||
|
||||
content = (
|
||||
<div className="flex flex-col items-end gap-3 pointer-events-auto">
|
||||
{/* Options List (Bottom to Top) */}
|
||||
{isEditing ? (
|
||||
<div className="flex flex-col items-end gap-2 animate-in slide-in-from-bottom-4 fade-in duration-200 mb-2 mr-1">
|
||||
<button onClick={(e) => setEditValue(v => adjustEdit(v, 5))} className="w-10 h-10 flex items-center justify-center bg-surface-container-high text-on-surface rounded-full shadow-elevation-2 hover:brightness-110"><Plus size={20} /></button>
|
||||
|
||||
{/* Manual Input Field */}
|
||||
<div className="bg-surface-container shadow-sm rounded px-2 py-1 my-1 flex items-center justify-center min-w-[60px]">
|
||||
<input
|
||||
type="text"
|
||||
className="bg-transparent text-on-surface font-mono font-bold text-lg text-center w-full focus:outline-none"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onFocus={(e) => e.target.select()}
|
||||
onBlur={handleInputBlur}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') e.currentTarget.blur();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button onClick={(e) => setEditValue(v => adjustEdit(v, -5))} className="w-10 h-10 flex items-center justify-center bg-surface-container-high text-on-surface rounded-full shadow-elevation-2 hover:brightness-110"><Minus size={20} /></button>
|
||||
<button onClick={saveEdit} className="w-10 h-10 flex items-center justify-center bg-primary text-on-primary rounded-full shadow-elevation-2 mt-1 hover:brightness-110"><Check size={20} /></button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-end gap-3 animate-in slide-in-from-bottom-4 fade-in duration-200 mb-4 mr-1">
|
||||
{/* Mini FABs */}
|
||||
<button onClick={handleEdit} className="w-10 h-10 flex items-center justify-center bg-surface-container-high text-on-surface hover:text-primary rounded-full shadow-elevation-2 hover:scale-110 transition-all" aria-label="Edit">
|
||||
<Edit2 size={18} />
|
||||
</button>
|
||||
|
||||
<button onClick={handleReset} className="w-10 h-10 flex items-center justify-center bg-surface-container-high text-on-surface hover:text-primary rounded-full shadow-elevation-2 hover:scale-110 transition-all" aria-label="Reset">
|
||||
<RotateCcw size={18} />
|
||||
</button>
|
||||
|
||||
<button onClick={handleStartPause} className="w-12 h-12 flex items-center justify-center bg-primary-container text-on-primary-container rounded-full shadow-elevation-2 hover:scale-110 transition-all" aria-label={status === 'RUNNING' ? 'Pause' : 'Start'}>
|
||||
{status === 'RUNNING' ? <Pause size={24} /> : <Play size={24} className="ml-1" />}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Toggle Button (Bottom) */}
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
className={`w-16 h-16 rounded-full flex items-center justify-center shadow-elevation-3 hover:scale-105 transition-all active:scale-95 ${isEditing
|
||||
? 'bg-error-container text-on-error-container hover:bg-error-container/80' // Light Red for cancel/close
|
||||
: 'bg-primary text-on-primary'
|
||||
}`}
|
||||
>
|
||||
{isEditing ? <X size={28} /> : <span className="font-mono text-sm font-bold">{formatSeconds(timeLeft)}</span>}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
// Idle state
|
||||
fabClasses += ` w-14 h-14 rounded-[16px] bg-secondary-container text-on-secondary-container hover:brightness-95 cursor-pointer`;
|
||||
content = (
|
||||
<div className="w-full h-full flex items-center justify-center pointer-events-none">
|
||||
<Timer size={24} />
|
||||
</div>
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
if (isExpanded) {
|
||||
// Override base classes for expanded state - remove shadow/bg from container
|
||||
fabClasses = `fixed bottom-24 right-6 z-50 flex flex-col items-end pointer-events-none`; // pointer-events-none prevents container from blocking clicks, children have pointer-events-auto
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={fabClasses} onClick={!isExpanded ? handleToggle : undefined}>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RestTimerFAB;
|
||||
120
src/hooks/useRestTimer.ts
Normal file
120
src/hooks/useRestTimer.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { playTimeUpSignal } from '../utils/audio';
|
||||
|
||||
export type TimerStatus = 'IDLE' | 'RUNNING' | 'PAUSED' | 'FINISHED';
|
||||
|
||||
interface UseRestTimerProps {
|
||||
defaultTime: number; // in seconds
|
||||
onFinish?: () => void;
|
||||
autoStart?: boolean;
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
const endTimeRef = useRef<number | null>(null);
|
||||
const rafRef = useRef<number | null>(null);
|
||||
const prevDefaultTimeRef = useRef(defaultTime);
|
||||
|
||||
// Update internal duration when defaultTime changes
|
||||
useEffect(() => {
|
||||
if (prevDefaultTimeRef.current !== defaultTime) {
|
||||
prevDefaultTimeRef.current = defaultTime;
|
||||
setDuration(defaultTime);
|
||||
// Only update visible time if IDLE. If running, it will apply on next reset.
|
||||
if (status === 'IDLE') {
|
||||
setTimeLeft(defaultTime);
|
||||
}
|
||||
}
|
||||
}, [defaultTime, 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 {
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
}
|
||||
}, [onFinish]);
|
||||
|
||||
const start = useCallback(() => {
|
||||
if (status === 'RUNNING') return;
|
||||
|
||||
// If starting from IDLE or PAUSED
|
||||
const targetSeconds = status === 'PAUSED' ? timeLeft : duration;
|
||||
endTimeRef.current = Date.now() + targetSeconds * 1000;
|
||||
|
||||
setStatus('RUNNING');
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
}, [status, timeLeft, duration, tick]);
|
||||
|
||||
const pause = useCallback(() => {
|
||||
if (status !== 'RUNNING') return;
|
||||
setStatus('PAUSED');
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||
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;
|
||||
}, [duration]);
|
||||
|
||||
const addTime = useCallback((seconds: number) => {
|
||||
setDuration(prev => prev + seconds);
|
||||
if (status === 'IDLE') {
|
||||
setTimeLeft(prev => prev + seconds);
|
||||
} else if (status === 'RUNNING') {
|
||||
// Add to current target
|
||||
if (endTimeRef.current) {
|
||||
endTimeRef.current += seconds * 1000;
|
||||
// Force immediate update to avoid flicker
|
||||
const now = Date.now();
|
||||
setTimeLeft(Math.max(0, Math.ceil((endTimeRef.current - now) / 1000)));
|
||||
}
|
||||
} else if (status === 'PAUSED') {
|
||||
setTimeLeft(prev => prev + seconds);
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
|
||||
return {
|
||||
timeLeft,
|
||||
status,
|
||||
start,
|
||||
pause,
|
||||
reset,
|
||||
addTime,
|
||||
setDuration: (val: number) => {
|
||||
setDuration(val);
|
||||
if (status === 'IDLE') setTimeLeft(val);
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -53,6 +53,7 @@ export interface PlannedSet {
|
||||
exerciseName: string; // Denormalized for easier display
|
||||
exerciseType: ExerciseType;
|
||||
isWeighted: boolean; // Prompt specifically asked for this flag
|
||||
restTimeSeconds?: number;
|
||||
}
|
||||
|
||||
export interface WorkoutPlan {
|
||||
@@ -74,6 +75,7 @@ export interface UserProfile {
|
||||
gender?: 'MALE' | 'FEMALE' | 'OTHER';
|
||||
birthDate?: number | string; // timestamp or ISO string
|
||||
language?: Language;
|
||||
restTimerDefault?: number;
|
||||
}
|
||||
|
||||
export interface BodyWeightRecord {
|
||||
|
||||
41
src/utils/audio.ts
Normal file
41
src/utils/audio.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Plays a beep sound using the Web Audio API.
|
||||
* @param duration Duration in milliseconds
|
||||
* @param frequency Frequency in Hz
|
||||
* @param volume Volume (0-1)
|
||||
*/
|
||||
export const playBeep = (duration = 200, frequency = 440, volume = 0.5) => {
|
||||
try {
|
||||
const AudioContext = window.AudioContext || (window as any).webkitAudioContext;
|
||||
if (!AudioContext) return;
|
||||
|
||||
const ctx = new AudioContext();
|
||||
const osc = ctx.createOscillator();
|
||||
const gainUrl = ctx.createGain();
|
||||
|
||||
osc.connect(gainUrl);
|
||||
gainUrl.connect(ctx.destination);
|
||||
|
||||
osc.type = 'sine';
|
||||
osc.frequency.value = frequency;
|
||||
gainUrl.gain.setValueAtTime(volume, ctx.currentTime);
|
||||
gainUrl.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + duration / 1000);
|
||||
|
||||
osc.start(ctx.currentTime);
|
||||
osc.stop(ctx.currentTime + duration / 1000);
|
||||
} catch (e) {
|
||||
console.error('Audio playback failed', e);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Plays a "Time Up" signal (3 beeps)
|
||||
*/
|
||||
export const playTimeUpSignal = async () => {
|
||||
// 3 beeps: High-Low-High? Or just 3 Highs.
|
||||
// Let's do 3 rapid beeps.
|
||||
const now = Date.now();
|
||||
playBeep(300, 880, 0.5);
|
||||
setTimeout(() => playBeep(300, 880, 0.5), 600);
|
||||
setTimeout(() => playBeep(600, 1200, 0.5), 1200);
|
||||
};
|
||||
Reference in New Issue
Block a user