@wandelbots/wandelbots-js-react-components 2.36.0 → 2.37.0-pr.feature-states-for-cycle-timer.379.4ca80c1

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.
Files changed (44) hide show
  1. package/dist/components/CycleTimer/CycleTimer.d.ts +3 -0
  2. package/dist/components/CycleTimer/CycleTimer.d.ts.map +1 -0
  3. package/dist/components/CycleTimer/DefaultVariant.d.ts +10 -0
  4. package/dist/components/CycleTimer/DefaultVariant.d.ts.map +1 -0
  5. package/dist/components/CycleTimer/SmallVariant.d.ts +11 -0
  6. package/dist/components/CycleTimer/SmallVariant.d.ts.map +1 -0
  7. package/dist/components/CycleTimer/index.d.ts +28 -0
  8. package/dist/components/CycleTimer/index.d.ts.map +1 -0
  9. package/dist/components/CycleTimer/types.d.ts +49 -0
  10. package/dist/components/CycleTimer/types.d.ts.map +1 -0
  11. package/dist/components/CycleTimer/useAnimations.d.ts +13 -0
  12. package/dist/components/CycleTimer/useAnimations.d.ts.map +1 -0
  13. package/dist/components/CycleTimer/useTimerLogic.d.ts +26 -0
  14. package/dist/components/CycleTimer/useTimerLogic.d.ts.map +1 -0
  15. package/dist/components/CycleTimer/utils.d.ts +13 -0
  16. package/dist/components/CycleTimer/utils.d.ts.map +1 -0
  17. package/dist/components/CycleTimer.d.ts +2 -96
  18. package/dist/components/CycleTimer.d.ts.map +1 -1
  19. package/dist/components/TabBar.d.ts.map +1 -1
  20. package/dist/components/jogging/PoseCartesianValues.d.ts +8 -4
  21. package/dist/components/jogging/PoseCartesianValues.d.ts.map +1 -1
  22. package/dist/components/jogging/PoseJointValues.d.ts +8 -4
  23. package/dist/components/jogging/PoseJointValues.d.ts.map +1 -1
  24. package/dist/index.cjs +50 -50
  25. package/dist/index.cjs.map +1 -1
  26. package/dist/index.js +9215 -8857
  27. package/dist/index.js.map +1 -1
  28. package/package.json +1 -1
  29. package/src/components/AppHeader.tsx +1 -1
  30. package/src/components/CycleTimer/CycleTimer.ts +6 -0
  31. package/src/components/CycleTimer/DefaultVariant.tsx +272 -0
  32. package/src/components/CycleTimer/SmallVariant.tsx +190 -0
  33. package/src/components/CycleTimer/index.tsx +143 -0
  34. package/src/components/CycleTimer/types.ts +58 -0
  35. package/src/components/CycleTimer/useAnimations.ts +154 -0
  36. package/src/components/CycleTimer/useTimerLogic.ts +377 -0
  37. package/src/components/CycleTimer/utils.ts +53 -0
  38. package/src/components/CycleTimer.tsx +6 -715
  39. package/src/components/ProgramControl.tsx +4 -4
  40. package/src/components/TabBar.tsx +8 -10
  41. package/src/components/jogging/PoseCartesianValues.tsx +67 -7
  42. package/src/components/jogging/PoseJointValues.tsx +68 -8
  43. package/src/i18n/locales/de/translations.json +4 -0
  44. package/src/i18n/locales/en/translations.json +4 -0
@@ -1,715 +1,6 @@
1
- import { Box, Fade, Typography, useTheme } from "@mui/material"
2
- import { Gauge } from "@mui/x-charts/Gauge"
3
- import { observer } from "mobx-react-lite"
4
- import { useCallback, useEffect, useRef, useState } from "react"
5
- import { useTranslation } from "react-i18next"
6
- import { externalizeComponent } from "../externalizeComponent"
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"