@wandelbots/wandelbots-js-react-components 2.36.0 → 2.37.0-pr.feature-states-for-cycle-timer.379.b99a9af
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/CycleTimer/CycleTimer.d.ts +3 -0
- package/dist/components/CycleTimer/CycleTimer.d.ts.map +1 -0
- package/dist/components/CycleTimer/DefaultVariant.d.ts +10 -0
- package/dist/components/CycleTimer/DefaultVariant.d.ts.map +1 -0
- package/dist/components/CycleTimer/SmallVariant.d.ts +11 -0
- package/dist/components/CycleTimer/SmallVariant.d.ts.map +1 -0
- package/dist/components/CycleTimer/index.d.ts +28 -0
- package/dist/components/CycleTimer/index.d.ts.map +1 -0
- package/dist/components/CycleTimer/types.d.ts +51 -0
- package/dist/components/CycleTimer/types.d.ts.map +1 -0
- package/dist/components/CycleTimer/useAnimations.d.ts +15 -0
- package/dist/components/CycleTimer/useAnimations.d.ts.map +1 -0
- package/dist/components/CycleTimer/useTimerLogic.d.ts +26 -0
- package/dist/components/CycleTimer/useTimerLogic.d.ts.map +1 -0
- package/dist/components/CycleTimer/utils.d.ts +13 -0
- package/dist/components/CycleTimer/utils.d.ts.map +1 -0
- package/dist/components/CycleTimer.d.ts +2 -96
- package/dist/components/CycleTimer.d.ts.map +1 -1
- package/dist/components/TabBar.d.ts.map +1 -1
- package/dist/components/jogging/PoseCartesianValues.d.ts +8 -4
- package/dist/components/jogging/PoseCartesianValues.d.ts.map +1 -1
- package/dist/components/jogging/PoseJointValues.d.ts +8 -4
- package/dist/components/jogging/PoseJointValues.d.ts.map +1 -1
- package/dist/index.cjs +50 -50
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +9290 -8813
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/AppHeader.tsx +1 -1
- package/src/components/CycleTimer/CycleTimer.ts +6 -0
- package/src/components/CycleTimer/DefaultVariant.tsx +327 -0
- package/src/components/CycleTimer/SmallVariant.tsx +230 -0
- package/src/components/CycleTimer/index.tsx +157 -0
- package/src/components/CycleTimer/types.ts +60 -0
- package/src/components/CycleTimer/useAnimations.ts +202 -0
- package/src/components/CycleTimer/useTimerLogic.ts +386 -0
- package/src/components/CycleTimer/utils.ts +53 -0
- package/src/components/CycleTimer.tsx +6 -715
- package/src/components/ProgramControl.tsx +4 -4
- package/src/components/TabBar.tsx +8 -10
- package/src/components/jogging/PoseCartesianValues.tsx +67 -7
- package/src/components/jogging/PoseJointValues.tsx +68 -8
- package/src/i18n/locales/de/translations.json +4 -0
- package/src/i18n/locales/en/translations.json +4 -0
|
@@ -1,715 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
import { useInterpolation } from "./utils/interpolation"
|
|
8
|
-
|
|
9
|
-
export interface CycleTimerProps {
|
|
10
|
-
/**
|
|
11
|
-
* Callback that receives the timer control functions:
|
|
12
|
-
* - `startNewCycle(maxTimeSeconds?, elapsedSeconds?)` - Start a new timer cycle (if maxTimeSeconds is omitted, runs as count-up timer)
|
|
13
|
-
* - `pause()` - Pause the countdown while preserving remaining time
|
|
14
|
-
* - `resume()` - Resume countdown from where it was paused
|
|
15
|
-
* - `isPaused()` - Check current pause state
|
|
16
|
-
*/
|
|
17
|
-
onCycleComplete: (controls: {
|
|
18
|
-
startNewCycle: (maxTimeSeconds?: number, elapsedSeconds?: number) => void
|
|
19
|
-
pause: () => void
|
|
20
|
-
resume: () => void
|
|
21
|
-
isPaused: () => boolean
|
|
22
|
-
}) => void
|
|
23
|
-
/** Callback fired when a cycle actually completes (reaches zero) */
|
|
24
|
-
onCycleEnd?: () => void
|
|
25
|
-
/** Whether the timer should start automatically when maxTime is set */
|
|
26
|
-
autoStart?: boolean
|
|
27
|
-
/** Visual variant of the timer */
|
|
28
|
-
variant?: "default" | "small"
|
|
29
|
-
/** For small variant: whether to show remaining time details (compact hides them) */
|
|
30
|
-
compact?: boolean
|
|
31
|
-
/** Additional CSS classes */
|
|
32
|
-
className?: string
|
|
33
|
-
/** Whether the timer is in an error state (pauses timer and shows error styling) */
|
|
34
|
-
hasError?: boolean
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* A circular gauge timer component that shows the remaining time of a cycle or counts up
|
|
39
|
-
*
|
|
40
|
-
* Features:
|
|
41
|
-
* - Circular gauge with 264px diameter and 40px thickness
|
|
42
|
-
* - Two modes: count-down (with max time) or count-up (without max time)
|
|
43
|
-
* - Count-down mode: shows remaining time prominently, counts down to zero
|
|
44
|
-
* - Count-up mode: shows elapsed time, gauge progresses in minute steps
|
|
45
|
-
* - Displays appropriate labels based on mode
|
|
46
|
-
* - Automatically counts down/up and triggers callback when reaching zero (count-down only)
|
|
47
|
-
* - Full timer control: start, pause, resume functionality
|
|
48
|
-
* - Support for starting with elapsed time (resume mid-cycle)
|
|
49
|
-
* - Error state support: pauses timer and shows error styling (red color)
|
|
50
|
-
* - Smooth spring-based progress animations for all state transitions
|
|
51
|
-
* - Fully localized with i18next
|
|
52
|
-
* - Material-UI theming integration
|
|
53
|
-
* - Small variant with animated progress icon (gauge border only) next to text
|
|
54
|
-
*
|
|
55
|
-
* @param onCycleComplete - Callback that receives timer control functions
|
|
56
|
-
* @param onCycleEnd - Optional callback fired when a cycle actually completes (reaches zero)
|
|
57
|
-
* @param autoStart - Whether to start timer automatically (default: true)
|
|
58
|
-
* @param variant - Visual variant: "default" (large gauge) or "small" (animated icon with text)
|
|
59
|
-
* @param compact - For small variant: whether to hide remaining time details
|
|
60
|
-
* @param className - Additional CSS classes
|
|
61
|
-
* @param hasError - Whether the timer is in an error state (pauses timer and shows error styling)
|
|
62
|
-
*
|
|
63
|
-
* Usage:
|
|
64
|
-
* ```tsx
|
|
65
|
-
* // Count-down timer (with max time)
|
|
66
|
-
* <CycleTimer
|
|
67
|
-
* onCycleComplete={(controls) => {
|
|
68
|
-
* // Start a 5-minute countdown cycle
|
|
69
|
-
* controls.startNewCycle(300)
|
|
70
|
-
*
|
|
71
|
-
* // Or start a 5-minute cycle with 2 minutes already elapsed
|
|
72
|
-
* controls.startNewCycle(300, 120)
|
|
73
|
-
* }}
|
|
74
|
-
* onCycleEnd={() => console.log('Cycle completed!')}
|
|
75
|
-
* />
|
|
76
|
-
*
|
|
77
|
-
* // Count-up timer (no max time)
|
|
78
|
-
* <CycleTimer
|
|
79
|
-
* onCycleComplete={(controls) => {
|
|
80
|
-
* // Start count-up timer
|
|
81
|
-
* controls.startNewCycle()
|
|
82
|
-
*
|
|
83
|
-
* // Or start count-up timer with some elapsed time
|
|
84
|
-
* controls.startNewCycle(undefined, 120)
|
|
85
|
-
* }}
|
|
86
|
-
* />
|
|
87
|
-
*
|
|
88
|
-
* // Timer with error state
|
|
89
|
-
* <CycleTimer
|
|
90
|
-
* hasError={errorCondition}
|
|
91
|
-
* onCycleComplete={(controls) => {
|
|
92
|
-
* controls.startNewCycle(300)
|
|
93
|
-
* }}
|
|
94
|
-
* />
|
|
95
|
-
* ```
|
|
96
|
-
*
|
|
97
|
-
* Control Functions:
|
|
98
|
-
* - `startNewCycle(maxTimeSeconds?, elapsedSeconds?)` - Start a new timer cycle (omit maxTimeSeconds for count-up mode)
|
|
99
|
-
* - `pause()` - Pause the countdown while preserving remaining time
|
|
100
|
-
* - `resume()` - Resume countdown from where it was paused
|
|
101
|
-
* - `isPaused()` - Check current pause state
|
|
102
|
-
*/
|
|
103
|
-
export const CycleTimer = externalizeComponent(
|
|
104
|
-
observer(
|
|
105
|
-
({
|
|
106
|
-
onCycleComplete,
|
|
107
|
-
onCycleEnd,
|
|
108
|
-
autoStart = true,
|
|
109
|
-
variant = "default",
|
|
110
|
-
compact = false,
|
|
111
|
-
className,
|
|
112
|
-
hasError = false,
|
|
113
|
-
}: CycleTimerProps) => {
|
|
114
|
-
const theme = useTheme()
|
|
115
|
-
const { t } = useTranslation()
|
|
116
|
-
const [remainingTime, setRemainingTime] = useState(0)
|
|
117
|
-
const [maxTime, setMaxTime] = useState<number | null>(null)
|
|
118
|
-
const [isRunning, setIsRunning] = useState(false)
|
|
119
|
-
const [isPausedState, setIsPausedState] = useState(false)
|
|
120
|
-
const [currentProgress, setCurrentProgress] = useState(0)
|
|
121
|
-
const animationRef = useRef<number | null>(null)
|
|
122
|
-
const startTimeRef = useRef<number | null>(null)
|
|
123
|
-
const pausedTimeRef = useRef<number>(0)
|
|
124
|
-
const [wasRunningBeforeError, setWasRunningBeforeError] = useState(false)
|
|
125
|
-
|
|
126
|
-
// Brief animation states for pause and error visual feedback
|
|
127
|
-
const [showPauseAnimation, setShowPauseAnimation] = useState(false)
|
|
128
|
-
const [showErrorAnimation, setShowErrorAnimation] = useState(false)
|
|
129
|
-
const pauseAnimationTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
|
130
|
-
const errorAnimationTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
|
131
|
-
|
|
132
|
-
// Track mode changes for fade transitions
|
|
133
|
-
const [showLabels, setShowLabels] = useState(true)
|
|
134
|
-
const prevMaxTimeRef = useRef<number | null | undefined>(undefined)
|
|
135
|
-
|
|
136
|
-
// Spring-based interpolator for smooth gauge progress animations
|
|
137
|
-
// Uses physics simulation to create natural, smooth transitions between progress values
|
|
138
|
-
const [progressInterpolator] = useInterpolation([0], {
|
|
139
|
-
tension: 80, // Higher values = faster, more responsive animations
|
|
140
|
-
friction: 18, // Higher values = more damping, less bouncy animations
|
|
141
|
-
onChange: ([progress]) => {
|
|
142
|
-
setCurrentProgress(progress)
|
|
143
|
-
},
|
|
144
|
-
})
|
|
145
|
-
|
|
146
|
-
// Handle mode changes with fade transitions for labels only
|
|
147
|
-
useEffect(() => {
|
|
148
|
-
const currentIsCountUp = maxTime === null
|
|
149
|
-
const prevMaxTime = prevMaxTimeRef.current
|
|
150
|
-
const prevIsCountUp = prevMaxTime === null
|
|
151
|
-
|
|
152
|
-
// Check if mode actually changed (not just first render)
|
|
153
|
-
if (
|
|
154
|
-
prevMaxTimeRef.current !== undefined &&
|
|
155
|
-
prevIsCountUp !== currentIsCountUp
|
|
156
|
-
) {
|
|
157
|
-
// Mode changed - labels will fade based on the Fade component conditions
|
|
158
|
-
// We just need to ensure showLabels is true so Fade can control visibility
|
|
159
|
-
setShowLabels(true)
|
|
160
|
-
} else {
|
|
161
|
-
// No mode change or first time - set initial state
|
|
162
|
-
setShowLabels(true)
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
prevMaxTimeRef.current = maxTime
|
|
166
|
-
}, [maxTime])
|
|
167
|
-
|
|
168
|
-
const startNewCycle = useCallback(
|
|
169
|
-
(maxTimeSeconds?: number, elapsedSeconds: number = 0) => {
|
|
170
|
-
setMaxTime(maxTimeSeconds ?? null)
|
|
171
|
-
setIsPausedState(false)
|
|
172
|
-
pausedTimeRef.current = 0
|
|
173
|
-
|
|
174
|
-
if (maxTimeSeconds !== undefined) {
|
|
175
|
-
// Count-down mode: set remaining time
|
|
176
|
-
const remainingSeconds = Math.max(
|
|
177
|
-
0,
|
|
178
|
-
maxTimeSeconds - elapsedSeconds,
|
|
179
|
-
)
|
|
180
|
-
setRemainingTime(remainingSeconds)
|
|
181
|
-
|
|
182
|
-
// Animate progress smoothly to starting position
|
|
183
|
-
const initialProgress =
|
|
184
|
-
elapsedSeconds > 0 ? (elapsedSeconds / maxTimeSeconds) * 100 : 0
|
|
185
|
-
if (elapsedSeconds === 0) {
|
|
186
|
-
progressInterpolator.setTarget([0])
|
|
187
|
-
} else {
|
|
188
|
-
progressInterpolator.setTarget([initialProgress])
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
if (remainingSeconds === 0) {
|
|
192
|
-
setIsRunning(false)
|
|
193
|
-
startTimeRef.current = null
|
|
194
|
-
// Trigger completion callback immediately if time is already up
|
|
195
|
-
if (onCycleEnd) {
|
|
196
|
-
setTimeout(() => onCycleEnd(), 0)
|
|
197
|
-
}
|
|
198
|
-
} else if (autoStart) {
|
|
199
|
-
startTimeRef.current = Date.now() - elapsedSeconds * 1000
|
|
200
|
-
setIsRunning(true)
|
|
201
|
-
} else {
|
|
202
|
-
startTimeRef.current = null
|
|
203
|
-
}
|
|
204
|
-
} else {
|
|
205
|
-
// Count-up mode: start from elapsed time
|
|
206
|
-
setRemainingTime(elapsedSeconds)
|
|
207
|
-
|
|
208
|
-
// For count-up mode, progress is based on minute steps
|
|
209
|
-
const initialProgress = ((elapsedSeconds / 60) % 1) * 100
|
|
210
|
-
progressInterpolator.setTarget([initialProgress])
|
|
211
|
-
|
|
212
|
-
if (autoStart) {
|
|
213
|
-
startTimeRef.current = Date.now() - elapsedSeconds * 1000
|
|
214
|
-
setIsRunning(true)
|
|
215
|
-
} else {
|
|
216
|
-
startTimeRef.current = null
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
},
|
|
220
|
-
[autoStart, onCycleEnd, progressInterpolator],
|
|
221
|
-
)
|
|
222
|
-
|
|
223
|
-
const pause = useCallback(() => {
|
|
224
|
-
if (startTimeRef.current && isRunning) {
|
|
225
|
-
const now = Date.now()
|
|
226
|
-
const additionalElapsed = now - startTimeRef.current
|
|
227
|
-
pausedTimeRef.current += additionalElapsed
|
|
228
|
-
|
|
229
|
-
// Calculate exact progress position and smoothly animate to it when pausing
|
|
230
|
-
// This ensures the visual progress matches the actual elapsed time
|
|
231
|
-
const totalElapsed = pausedTimeRef.current / 1000
|
|
232
|
-
|
|
233
|
-
if (maxTime !== null) {
|
|
234
|
-
// Count-down mode
|
|
235
|
-
const exactProgress = Math.min(100, (totalElapsed / maxTime) * 100)
|
|
236
|
-
progressInterpolator.setTarget([exactProgress])
|
|
237
|
-
} else {
|
|
238
|
-
// Count-up mode: progress based on minute steps
|
|
239
|
-
const exactProgress = ((totalElapsed / 60) % 1) * 100
|
|
240
|
-
progressInterpolator.setTarget([exactProgress])
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
setIsRunning(false)
|
|
244
|
-
setIsPausedState(true)
|
|
245
|
-
|
|
246
|
-
// Trigger brief pause animation
|
|
247
|
-
setShowPauseAnimation(true)
|
|
248
|
-
|
|
249
|
-
// Clear any existing timeout
|
|
250
|
-
if (pauseAnimationTimeoutRef.current) {
|
|
251
|
-
clearTimeout(pauseAnimationTimeoutRef.current)
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
// Reset animation after longer duration
|
|
255
|
-
pauseAnimationTimeoutRef.current = setTimeout(() => {
|
|
256
|
-
setShowPauseAnimation(false)
|
|
257
|
-
}, 800) // 800ms smooth animation
|
|
258
|
-
}, [isRunning, maxTime, progressInterpolator])
|
|
259
|
-
|
|
260
|
-
const resume = useCallback(() => {
|
|
261
|
-
if (isPausedState && remainingTime > 0) {
|
|
262
|
-
startTimeRef.current = Date.now()
|
|
263
|
-
setIsRunning(true)
|
|
264
|
-
setIsPausedState(false)
|
|
265
|
-
}
|
|
266
|
-
}, [isPausedState, remainingTime])
|
|
267
|
-
|
|
268
|
-
const isPaused = useCallback(() => {
|
|
269
|
-
return isPausedState
|
|
270
|
-
}, [isPausedState])
|
|
271
|
-
|
|
272
|
-
// Handle error state changes
|
|
273
|
-
useEffect(() => {
|
|
274
|
-
if (hasError) {
|
|
275
|
-
// Error occurred - pause timer if running and remember state
|
|
276
|
-
if (isRunning && !isPausedState) {
|
|
277
|
-
setWasRunningBeforeError(true)
|
|
278
|
-
pause()
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
// Trigger brief error animation
|
|
282
|
-
setShowErrorAnimation(true)
|
|
283
|
-
|
|
284
|
-
// Clear any existing timeout
|
|
285
|
-
if (errorAnimationTimeoutRef.current) {
|
|
286
|
-
clearTimeout(errorAnimationTimeoutRef.current)
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
// Reset animation after longer duration
|
|
290
|
-
errorAnimationTimeoutRef.current = setTimeout(() => {
|
|
291
|
-
setShowErrorAnimation(false)
|
|
292
|
-
}, 600) // 600ms smooth animation
|
|
293
|
-
} else {
|
|
294
|
-
// Error resolved - resume if was running before error
|
|
295
|
-
if (wasRunningBeforeError && isPausedState) {
|
|
296
|
-
setWasRunningBeforeError(false)
|
|
297
|
-
resume()
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
// Clear error animation if error is resolved
|
|
301
|
-
setShowErrorAnimation(false)
|
|
302
|
-
if (errorAnimationTimeoutRef.current) {
|
|
303
|
-
clearTimeout(errorAnimationTimeoutRef.current)
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
}, [
|
|
307
|
-
hasError,
|
|
308
|
-
isRunning,
|
|
309
|
-
isPausedState,
|
|
310
|
-
wasRunningBeforeError,
|
|
311
|
-
pause,
|
|
312
|
-
resume,
|
|
313
|
-
])
|
|
314
|
-
|
|
315
|
-
// Call onCycleComplete immediately to provide the timer control functions
|
|
316
|
-
useEffect(() => {
|
|
317
|
-
let isMounted = true
|
|
318
|
-
const timeoutId = setTimeout(() => {
|
|
319
|
-
if (isMounted) {
|
|
320
|
-
onCycleComplete({
|
|
321
|
-
startNewCycle,
|
|
322
|
-
pause,
|
|
323
|
-
resume,
|
|
324
|
-
isPaused,
|
|
325
|
-
})
|
|
326
|
-
}
|
|
327
|
-
}, 0)
|
|
328
|
-
|
|
329
|
-
return () => {
|
|
330
|
-
isMounted = false
|
|
331
|
-
clearTimeout(timeoutId)
|
|
332
|
-
}
|
|
333
|
-
}, [onCycleComplete, startNewCycle, pause, resume, isPaused])
|
|
334
|
-
|
|
335
|
-
useEffect(() => {
|
|
336
|
-
if (isRunning) {
|
|
337
|
-
// Single animation frame loop that handles both time updates and progress
|
|
338
|
-
const updateTimer = () => {
|
|
339
|
-
if (startTimeRef.current) {
|
|
340
|
-
const now = Date.now()
|
|
341
|
-
const elapsed =
|
|
342
|
-
(now - startTimeRef.current + pausedTimeRef.current) / 1000
|
|
343
|
-
|
|
344
|
-
if (maxTime !== null) {
|
|
345
|
-
// Count-down mode
|
|
346
|
-
const remaining = Math.max(0, maxTime - elapsed)
|
|
347
|
-
setRemainingTime(Math.ceil(remaining))
|
|
348
|
-
|
|
349
|
-
// Smoothly animate progress based on elapsed time for fluid visual feedback
|
|
350
|
-
const progress = Math.min(100, (elapsed / maxTime) * 100)
|
|
351
|
-
progressInterpolator.setTarget([progress])
|
|
352
|
-
|
|
353
|
-
if (remaining <= 0) {
|
|
354
|
-
setIsRunning(false)
|
|
355
|
-
startTimeRef.current = null
|
|
356
|
-
setRemainingTime(0)
|
|
357
|
-
// Animate to 100% completion with smooth spring transition
|
|
358
|
-
progressInterpolator.setTarget([100])
|
|
359
|
-
// Call onCycleEnd when timer reaches zero to notify about completion
|
|
360
|
-
if (onCycleEnd) {
|
|
361
|
-
setTimeout(() => onCycleEnd(), 0)
|
|
362
|
-
}
|
|
363
|
-
return
|
|
364
|
-
}
|
|
365
|
-
} else {
|
|
366
|
-
// Count-up mode
|
|
367
|
-
setRemainingTime(Math.floor(elapsed))
|
|
368
|
-
|
|
369
|
-
// For count-up mode, progress completes every minute (0-100% per minute)
|
|
370
|
-
const progress = ((elapsed / 60) % 1) * 100
|
|
371
|
-
progressInterpolator.setTarget([progress])
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
// Continue animation loop while running
|
|
375
|
-
if (isRunning) {
|
|
376
|
-
animationRef.current = requestAnimationFrame(updateTimer)
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
animationRef.current = requestAnimationFrame(updateTimer)
|
|
382
|
-
} else {
|
|
383
|
-
if (animationRef.current) {
|
|
384
|
-
cancelAnimationFrame(animationRef.current)
|
|
385
|
-
animationRef.current = null
|
|
386
|
-
}
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
return () => {
|
|
390
|
-
if (animationRef.current) {
|
|
391
|
-
cancelAnimationFrame(animationRef.current)
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
}, [isRunning, onCycleEnd, maxTime, progressInterpolator])
|
|
395
|
-
|
|
396
|
-
// Dedicated animation loop for spring physics interpolation
|
|
397
|
-
// Runs at 60fps to ensure smooth progress animations independent of timer updates
|
|
398
|
-
useEffect(() => {
|
|
399
|
-
let interpolationAnimationId: number | null = null
|
|
400
|
-
|
|
401
|
-
const animateInterpolation = () => {
|
|
402
|
-
progressInterpolator.update(1 / 60) // 60fps interpolation
|
|
403
|
-
interpolationAnimationId = requestAnimationFrame(animateInterpolation)
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
interpolationAnimationId = requestAnimationFrame(animateInterpolation)
|
|
407
|
-
|
|
408
|
-
return () => {
|
|
409
|
-
if (interpolationAnimationId) {
|
|
410
|
-
cancelAnimationFrame(interpolationAnimationId)
|
|
411
|
-
}
|
|
412
|
-
}
|
|
413
|
-
}, [progressInterpolator])
|
|
414
|
-
|
|
415
|
-
// Cleanup animation timeouts on unmount
|
|
416
|
-
useEffect(() => {
|
|
417
|
-
return () => {
|
|
418
|
-
if (pauseAnimationTimeoutRef.current) {
|
|
419
|
-
clearTimeout(pauseAnimationTimeoutRef.current)
|
|
420
|
-
}
|
|
421
|
-
if (errorAnimationTimeoutRef.current) {
|
|
422
|
-
clearTimeout(errorAnimationTimeoutRef.current)
|
|
423
|
-
}
|
|
424
|
-
}
|
|
425
|
-
}, [])
|
|
426
|
-
|
|
427
|
-
// Keep interpolator synchronized with static progress when timer is stopped
|
|
428
|
-
// Ensures correct visual state when component initializes or timer stops
|
|
429
|
-
useEffect(() => {
|
|
430
|
-
if (!isRunning && !isPausedState) {
|
|
431
|
-
if (maxTime !== null && maxTime > 0) {
|
|
432
|
-
// Count-down mode
|
|
433
|
-
const staticProgress = ((maxTime - remainingTime) / maxTime) * 100
|
|
434
|
-
progressInterpolator.setTarget([staticProgress])
|
|
435
|
-
} else if (maxTime === null) {
|
|
436
|
-
// Count-up mode
|
|
437
|
-
const staticProgress = ((remainingTime / 60) % 1) * 100
|
|
438
|
-
progressInterpolator.setTarget([staticProgress])
|
|
439
|
-
}
|
|
440
|
-
}
|
|
441
|
-
}, [
|
|
442
|
-
isRunning,
|
|
443
|
-
isPausedState,
|
|
444
|
-
maxTime,
|
|
445
|
-
remainingTime,
|
|
446
|
-
progressInterpolator,
|
|
447
|
-
])
|
|
448
|
-
|
|
449
|
-
const formatTime = (seconds: number): string => {
|
|
450
|
-
const minutes = Math.floor(seconds / 60)
|
|
451
|
-
const remainingSeconds = seconds % 60
|
|
452
|
-
return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
// Use interpolated progress value for smooth gauge animations
|
|
456
|
-
const progressValue = currentProgress
|
|
457
|
-
|
|
458
|
-
// Small variant: horizontal layout with gauge icon and text
|
|
459
|
-
if (variant === "small") {
|
|
460
|
-
return (
|
|
461
|
-
<Box
|
|
462
|
-
className={className}
|
|
463
|
-
sx={{
|
|
464
|
-
display: "flex",
|
|
465
|
-
alignItems: "center",
|
|
466
|
-
m: 0,
|
|
467
|
-
gap: 1, // 8px gap between circle and text
|
|
468
|
-
}}
|
|
469
|
-
>
|
|
470
|
-
{/* Animated progress ring icon */}
|
|
471
|
-
<Box
|
|
472
|
-
sx={{
|
|
473
|
-
width: 20,
|
|
474
|
-
height: 20,
|
|
475
|
-
display: "flex",
|
|
476
|
-
alignItems: "center",
|
|
477
|
-
justifyContent: "center",
|
|
478
|
-
opacity: showPauseAnimation || showErrorAnimation ? 0.6 : 1,
|
|
479
|
-
transition: "opacity 0.5s ease-out",
|
|
480
|
-
}}
|
|
481
|
-
>
|
|
482
|
-
<svg
|
|
483
|
-
width="20"
|
|
484
|
-
height="20"
|
|
485
|
-
viewBox="0 0 20 20"
|
|
486
|
-
style={{ transform: "rotate(-90deg)" }}
|
|
487
|
-
role="img"
|
|
488
|
-
aria-label="Timer progress"
|
|
489
|
-
>
|
|
490
|
-
{/* Background ring */}
|
|
491
|
-
<circle
|
|
492
|
-
cx="10"
|
|
493
|
-
cy="10"
|
|
494
|
-
r="8"
|
|
495
|
-
fill="none"
|
|
496
|
-
stroke={
|
|
497
|
-
hasError
|
|
498
|
-
? theme.palette.error.light
|
|
499
|
-
: theme.palette.success.main
|
|
500
|
-
}
|
|
501
|
-
strokeWidth="2"
|
|
502
|
-
opacity={0.3}
|
|
503
|
-
style={{
|
|
504
|
-
transition: "stroke 0.5s ease-out",
|
|
505
|
-
}}
|
|
506
|
-
/>
|
|
507
|
-
{/* Progress ring */}
|
|
508
|
-
<circle
|
|
509
|
-
cx="10"
|
|
510
|
-
cy="10"
|
|
511
|
-
r="8"
|
|
512
|
-
fill="none"
|
|
513
|
-
stroke={
|
|
514
|
-
hasError
|
|
515
|
-
? theme.palette.error.light
|
|
516
|
-
: theme.palette.success.main
|
|
517
|
-
}
|
|
518
|
-
strokeWidth="2"
|
|
519
|
-
strokeLinecap="round"
|
|
520
|
-
strokeDasharray={`${2 * Math.PI * 8}`}
|
|
521
|
-
strokeDashoffset={`${2 * Math.PI * 8 * (1 - progressValue / 100)}`}
|
|
522
|
-
style={{
|
|
523
|
-
transition:
|
|
524
|
-
"stroke-dashoffset 0.1s ease-out, stroke 0.5s ease-out",
|
|
525
|
-
}}
|
|
526
|
-
/>
|
|
527
|
-
</svg>
|
|
528
|
-
</Box>
|
|
529
|
-
|
|
530
|
-
{/* Timer text display */}
|
|
531
|
-
<Typography
|
|
532
|
-
variant="body2"
|
|
533
|
-
sx={{
|
|
534
|
-
color: hasError
|
|
535
|
-
? theme.palette.error.light
|
|
536
|
-
: theme.palette.text.primary,
|
|
537
|
-
fontSize: "14px",
|
|
538
|
-
transition: "color 0.5s ease-out",
|
|
539
|
-
}}
|
|
540
|
-
>
|
|
541
|
-
{hasError
|
|
542
|
-
? t("CycleTimer.Error.lb", "Error")
|
|
543
|
-
: maxTime !== null
|
|
544
|
-
? // Count-down mode: show remaining time
|
|
545
|
-
compact
|
|
546
|
-
? // Compact mode: show remaining time with "min." suffix
|
|
547
|
-
`${formatTime(remainingTime)} ${t("CycleTimer.Time.lb", { time: "" }).replace(/\s*$/, "")}`
|
|
548
|
-
: // Full mode: show "remaining / of total min." format
|
|
549
|
-
`${formatTime(remainingTime)} / ${t("CycleTimer.Time.lb", { time: formatTime(maxTime) })}`
|
|
550
|
-
: // Count-up mode: show elapsed time only
|
|
551
|
-
formatTime(remainingTime)}
|
|
552
|
-
</Typography>
|
|
553
|
-
</Box>
|
|
554
|
-
)
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
// Default variant: large circular gauge with centered content
|
|
558
|
-
return (
|
|
559
|
-
<Box
|
|
560
|
-
className={className}
|
|
561
|
-
sx={{
|
|
562
|
-
position: "relative",
|
|
563
|
-
width: 264,
|
|
564
|
-
height: 264,
|
|
565
|
-
display: "flex",
|
|
566
|
-
alignItems: "center",
|
|
567
|
-
justifyContent: "center",
|
|
568
|
-
}}
|
|
569
|
-
>
|
|
570
|
-
<Gauge
|
|
571
|
-
width={264}
|
|
572
|
-
height={264}
|
|
573
|
-
value={progressValue}
|
|
574
|
-
valueMin={0}
|
|
575
|
-
valueMax={100}
|
|
576
|
-
innerRadius="71%"
|
|
577
|
-
outerRadius="90%"
|
|
578
|
-
skipAnimation={true}
|
|
579
|
-
sx={{
|
|
580
|
-
opacity: showPauseAnimation || showErrorAnimation ? 0.6 : 1,
|
|
581
|
-
transition: "opacity 0.5s ease-out",
|
|
582
|
-
[`& .MuiGauge-valueArc`]: {
|
|
583
|
-
fill: hasError
|
|
584
|
-
? theme.palette.error.light
|
|
585
|
-
: theme.palette.success.main,
|
|
586
|
-
transition: "fill 0.5s ease-out",
|
|
587
|
-
},
|
|
588
|
-
[`& .MuiGauge-referenceArc`]: {
|
|
589
|
-
fill: "white",
|
|
590
|
-
stroke: "transparent",
|
|
591
|
-
},
|
|
592
|
-
}}
|
|
593
|
-
/>
|
|
594
|
-
|
|
595
|
-
{/* Center content overlay with timer information */}
|
|
596
|
-
<Box
|
|
597
|
-
sx={{
|
|
598
|
-
position: "absolute",
|
|
599
|
-
top: "50%",
|
|
600
|
-
left: "50%",
|
|
601
|
-
transform: "translate(-50%, -50%)",
|
|
602
|
-
width: 187, // 71% of 264 = ~187px inner radius
|
|
603
|
-
height: 187,
|
|
604
|
-
borderRadius: "50%",
|
|
605
|
-
backgroundColor: theme.palette.backgroundPaperElevation?.[8],
|
|
606
|
-
display: "flex",
|
|
607
|
-
flexDirection: "column",
|
|
608
|
-
alignItems: "center",
|
|
609
|
-
justifyContent: "center",
|
|
610
|
-
textAlign: "center",
|
|
611
|
-
gap: 1,
|
|
612
|
-
}}
|
|
613
|
-
>
|
|
614
|
-
{/* "remaining time" label - always reserves space to prevent layout shift */}
|
|
615
|
-
<Box
|
|
616
|
-
sx={{
|
|
617
|
-
height: "16px", // Fixed height to prevent layout shift
|
|
618
|
-
display: "flex",
|
|
619
|
-
alignItems: "center",
|
|
620
|
-
justifyContent: "center",
|
|
621
|
-
marginBottom: 0.5,
|
|
622
|
-
}}
|
|
623
|
-
>
|
|
624
|
-
<Fade
|
|
625
|
-
in={showLabels && maxTime !== null && !hasError}
|
|
626
|
-
timeout={300}
|
|
627
|
-
>
|
|
628
|
-
<Typography
|
|
629
|
-
variant="body2"
|
|
630
|
-
sx={{
|
|
631
|
-
fontSize: "12px",
|
|
632
|
-
color: theme.palette.text.secondary,
|
|
633
|
-
}}
|
|
634
|
-
>
|
|
635
|
-
{t("CycleTimer.RemainingTime.lb")}
|
|
636
|
-
</Typography>
|
|
637
|
-
</Fade>
|
|
638
|
-
</Box>
|
|
639
|
-
|
|
640
|
-
{/* Main timer display with error state transition */}
|
|
641
|
-
<Box
|
|
642
|
-
sx={{
|
|
643
|
-
position: "relative",
|
|
644
|
-
height: "48px", // Fixed height to prevent layout shift
|
|
645
|
-
display: "flex",
|
|
646
|
-
alignItems: "center",
|
|
647
|
-
justifyContent: "center",
|
|
648
|
-
marginBottom: 0.5,
|
|
649
|
-
}}
|
|
650
|
-
>
|
|
651
|
-
{/* Error text */}
|
|
652
|
-
<Fade in={hasError} timeout={500}>
|
|
653
|
-
<Typography
|
|
654
|
-
variant="h3"
|
|
655
|
-
sx={{
|
|
656
|
-
position: "absolute",
|
|
657
|
-
fontSize: "40px",
|
|
658
|
-
fontWeight: 400,
|
|
659
|
-
color: "#FFFFFF",
|
|
660
|
-
lineHeight: "116.7%",
|
|
661
|
-
}}
|
|
662
|
-
>
|
|
663
|
-
{t("CycleTimer.Error.lb", "Error")}
|
|
664
|
-
</Typography>
|
|
665
|
-
</Fade>
|
|
666
|
-
|
|
667
|
-
{/* Normal timer text */}
|
|
668
|
-
<Fade in={!hasError} timeout={500}>
|
|
669
|
-
<Typography
|
|
670
|
-
variant="h1"
|
|
671
|
-
sx={{
|
|
672
|
-
position: "absolute",
|
|
673
|
-
fontSize: "48px",
|
|
674
|
-
fontWeight: 500,
|
|
675
|
-
color: theme.palette.text.primary,
|
|
676
|
-
lineHeight: 1,
|
|
677
|
-
}}
|
|
678
|
-
>
|
|
679
|
-
{formatTime(remainingTime)}
|
|
680
|
-
</Typography>
|
|
681
|
-
</Fade>
|
|
682
|
-
</Box>
|
|
683
|
-
|
|
684
|
-
{/* Total time display - always reserves space to prevent layout shift */}
|
|
685
|
-
<Box
|
|
686
|
-
sx={{
|
|
687
|
-
height: "16px", // Fixed height to prevent layout shift
|
|
688
|
-
display: "flex",
|
|
689
|
-
alignItems: "center",
|
|
690
|
-
justifyContent: "center",
|
|
691
|
-
}}
|
|
692
|
-
>
|
|
693
|
-
<Fade
|
|
694
|
-
in={showLabels && maxTime !== null && !hasError}
|
|
695
|
-
timeout={300}
|
|
696
|
-
>
|
|
697
|
-
<Typography
|
|
698
|
-
variant="body2"
|
|
699
|
-
sx={{
|
|
700
|
-
fontSize: "12px",
|
|
701
|
-
color: theme.palette.text.secondary,
|
|
702
|
-
}}
|
|
703
|
-
>
|
|
704
|
-
{maxTime !== null
|
|
705
|
-
? t("CycleTimer.OfTime.lb", { time: formatTime(maxTime) })
|
|
706
|
-
: ""}
|
|
707
|
-
</Typography>
|
|
708
|
-
</Fade>
|
|
709
|
-
</Box>
|
|
710
|
-
</Box>
|
|
711
|
-
</Box>
|
|
712
|
-
)
|
|
713
|
-
},
|
|
714
|
-
),
|
|
715
|
-
)
|
|
1
|
+
export { CycleTimer } from "./CycleTimer/index"
|
|
2
|
+
export type {
|
|
3
|
+
CycleTimerControls,
|
|
4
|
+
CycleTimerProps,
|
|
5
|
+
CycleTimerState,
|
|
6
|
+
} from "./CycleTimer/types"
|