@wandelbots/wandelbots-js-react-components 2.34.2 → 2.35.0-pr.feature-replace-forwardref.376.d3a302b
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/README.md +1 -1
- package/dist/components/AppHeader.d.ts +34 -0
- package/dist/components/AppHeader.d.ts.map +1 -0
- package/dist/components/CopyableText.d.ts +3 -2
- package/dist/components/CopyableText.d.ts.map +1 -1
- package/dist/components/CycleTimer.d.ts +33 -16
- package/dist/components/CycleTimer.d.ts.map +1 -1
- package/dist/components/DataGrid.d.ts +66 -0
- package/dist/components/DataGrid.d.ts.map +1 -0
- package/dist/components/LogPanel.d.ts +38 -0
- package/dist/components/LogPanel.d.ts.map +1 -0
- package/dist/components/LogStore.d.ts +12 -0
- package/dist/components/LogStore.d.ts.map +1 -0
- package/dist/components/LogViewer.d.ts +46 -0
- package/dist/components/LogViewer.d.ts.map +1 -0
- package/dist/components/ProgramControl.d.ts +8 -2
- package/dist/components/ProgramControl.d.ts.map +1 -1
- package/dist/components/ProgramStateIndicator.d.ts +1 -1
- package/dist/components/ProgramStateIndicator.d.ts.map +1 -1
- package/dist/components/RobotCard.d.ts +103 -0
- package/dist/components/RobotCard.d.ts.map +1 -0
- package/dist/components/RobotListItem.d.ts +34 -0
- package/dist/components/RobotListItem.d.ts.map +1 -0
- package/dist/components/RobotSetupReadinessIndicator.d.ts +31 -0
- package/dist/components/RobotSetupReadinessIndicator.d.ts.map +1 -0
- package/dist/components/RobotSetupReadinessIndicator.test.d.ts +2 -0
- package/dist/components/RobotSetupReadinessIndicator.test.d.ts.map +1 -0
- package/dist/components/SelectableFab.d.ts +2 -1
- package/dist/components/SelectableFab.d.ts.map +1 -1
- package/dist/components/TabBar.d.ts +32 -0
- package/dist/components/TabBar.d.ts.map +1 -0
- package/dist/components/robots/Robot.d.ts +3 -2
- package/dist/components/robots/Robot.d.ts.map +1 -1
- package/dist/components/robots/manufacturerHomePositions.d.ts +21 -0
- package/dist/components/robots/manufacturerHomePositions.d.ts.map +1 -0
- package/dist/icons/DropdownArrowIcon.d.ts +3 -0
- package/dist/icons/DropdownArrowIcon.d.ts.map +1 -0
- package/dist/icons/index.d.ts +1 -0
- package/dist/icons/index.d.ts.map +1 -1
- package/dist/index.cjs +50 -50
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11381 -9522
- package/dist/index.js.map +1 -1
- package/dist/themes/createDarkTheme.d.ts.map +1 -1
- package/package.json +6 -5
- package/src/components/AppHeader.md +84 -0
- package/src/components/AppHeader.tsx +199 -0
- package/src/components/CopyableText.tsx +70 -73
- package/src/components/CycleTimer.tsx +384 -159
- package/src/components/DataGrid.tsx +659 -0
- package/src/components/LogPanel.tsx +69 -0
- package/src/components/LogStore.ts +44 -0
- package/src/components/LogViewer.tsx +370 -0
- package/src/components/ProgramControl.tsx +27 -12
- package/src/components/ProgramStateIndicator.tsx +25 -8
- package/src/components/RobotCard.tsx +568 -0
- package/src/components/RobotListItem.tsx +150 -0
- package/src/components/RobotSetupReadinessIndicator.test.tsx +60 -0
- package/src/components/RobotSetupReadinessIndicator.tsx +124 -0
- package/src/components/SelectableFab.tsx +14 -15
- package/src/components/TabBar.tsx +147 -0
- package/src/components/robots/Robot.tsx +5 -2
- package/src/components/robots/manufacturerHomePositions.ts +76 -0
- package/src/i18n/locales/de/translations.json +8 -1
- package/src/i18n/locales/en/translations.json +8 -1
- package/src/icons/DropdownArrowIcon.tsx +13 -0
- package/src/icons/chevronDown.svg +3 -0
- package/src/icons/index.ts +1 -0
- package/src/index.ts +14 -0
- package/src/themes/createDarkTheme.ts +75 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Box, Typography, useTheme } from "@mui/material"
|
|
1
|
+
import { Box, Fade, Typography, useTheme } from "@mui/material"
|
|
2
2
|
import { Gauge } from "@mui/x-charts/Gauge"
|
|
3
3
|
import { observer } from "mobx-react-lite"
|
|
4
4
|
import { useCallback, useEffect, useRef, useState } from "react"
|
|
@@ -9,13 +9,13 @@ import { useInterpolation } from "./utils/interpolation"
|
|
|
9
9
|
export interface CycleTimerProps {
|
|
10
10
|
/**
|
|
11
11
|
* Callback that receives the timer control functions:
|
|
12
|
-
* - `startNewCycle(maxTimeSeconds
|
|
12
|
+
* - `startNewCycle(maxTimeSeconds?, elapsedSeconds?)` - Start a new timer cycle (if maxTimeSeconds is omitted, runs as count-up timer)
|
|
13
13
|
* - `pause()` - Pause the countdown while preserving remaining time
|
|
14
14
|
* - `resume()` - Resume countdown from where it was paused
|
|
15
15
|
* - `isPaused()` - Check current pause state
|
|
16
16
|
*/
|
|
17
17
|
onCycleComplete: (controls: {
|
|
18
|
-
startNewCycle: (maxTimeSeconds
|
|
18
|
+
startNewCycle: (maxTimeSeconds?: number, elapsedSeconds?: number) => void
|
|
19
19
|
pause: () => void
|
|
20
20
|
resume: () => void
|
|
21
21
|
isPaused: () => boolean
|
|
@@ -30,18 +30,23 @@ export interface CycleTimerProps {
|
|
|
30
30
|
compact?: boolean
|
|
31
31
|
/** Additional CSS classes */
|
|
32
32
|
className?: string
|
|
33
|
+
/** Whether the timer is in an error state (pauses timer and shows error styling) */
|
|
34
|
+
hasError?: boolean
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
/**
|
|
36
|
-
* A circular gauge timer component that shows the remaining time of a cycle
|
|
38
|
+
* A circular gauge timer component that shows the remaining time of a cycle or counts up
|
|
37
39
|
*
|
|
38
40
|
* Features:
|
|
39
41
|
* - Circular gauge with 264px diameter and 40px thickness
|
|
40
|
-
* -
|
|
41
|
-
* -
|
|
42
|
-
* -
|
|
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)
|
|
43
47
|
* - Full timer control: start, pause, resume functionality
|
|
44
48
|
* - Support for starting with elapsed time (resume mid-cycle)
|
|
49
|
+
* - Error state support: pauses timer and shows error styling (red color)
|
|
45
50
|
* - Smooth spring-based progress animations for all state transitions
|
|
46
51
|
* - Fully localized with i18next
|
|
47
52
|
* - Material-UI theming integration
|
|
@@ -53,32 +58,44 @@ export interface CycleTimerProps {
|
|
|
53
58
|
* @param variant - Visual variant: "default" (large gauge) or "small" (animated icon with text)
|
|
54
59
|
* @param compact - For small variant: whether to hide remaining time details
|
|
55
60
|
* @param className - Additional CSS classes
|
|
61
|
+
* @param hasError - Whether the timer is in an error state (pauses timer and shows error styling)
|
|
56
62
|
*
|
|
57
63
|
* Usage:
|
|
58
64
|
* ```tsx
|
|
65
|
+
* // Count-down timer (with max time)
|
|
59
66
|
* <CycleTimer
|
|
60
67
|
* onCycleComplete={(controls) => {
|
|
61
|
-
* // Start a 5-minute cycle
|
|
68
|
+
* // Start a 5-minute countdown cycle
|
|
62
69
|
* controls.startNewCycle(300)
|
|
63
70
|
*
|
|
64
71
|
* // Or start a 5-minute cycle with 2 minutes already elapsed
|
|
65
72
|
* controls.startNewCycle(300, 120)
|
|
73
|
+
* }}
|
|
74
|
+
* onCycleEnd={() => console.log('Cycle completed!')}
|
|
75
|
+
* />
|
|
66
76
|
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
77
|
+
* // Count-up timer (no max time)
|
|
78
|
+
* <CycleTimer
|
|
79
|
+
* onCycleComplete={(controls) => {
|
|
80
|
+
* // Start count-up timer
|
|
81
|
+
* controls.startNewCycle()
|
|
69
82
|
*
|
|
70
|
-
* //
|
|
71
|
-
* controls.
|
|
83
|
+
* // Or start count-up timer with some elapsed time
|
|
84
|
+
* controls.startNewCycle(undefined, 120)
|
|
85
|
+
* }}
|
|
86
|
+
* />
|
|
72
87
|
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
88
|
+
* // Timer with error state
|
|
89
|
+
* <CycleTimer
|
|
90
|
+
* hasError={errorCondition}
|
|
91
|
+
* onCycleComplete={(controls) => {
|
|
92
|
+
* controls.startNewCycle(300)
|
|
75
93
|
* }}
|
|
76
|
-
* onCycleEnd={() => console.log('Cycle completed!')}
|
|
77
94
|
* />
|
|
78
95
|
* ```
|
|
79
96
|
*
|
|
80
97
|
* Control Functions:
|
|
81
|
-
* - `startNewCycle(maxTimeSeconds
|
|
98
|
+
* - `startNewCycle(maxTimeSeconds?, elapsedSeconds?)` - Start a new timer cycle (omit maxTimeSeconds for count-up mode)
|
|
82
99
|
* - `pause()` - Pause the countdown while preserving remaining time
|
|
83
100
|
* - `resume()` - Resume countdown from where it was paused
|
|
84
101
|
* - `isPaused()` - Check current pause state
|
|
@@ -92,17 +109,29 @@ export const CycleTimer = externalizeComponent(
|
|
|
92
109
|
variant = "default",
|
|
93
110
|
compact = false,
|
|
94
111
|
className,
|
|
112
|
+
hasError = false,
|
|
95
113
|
}: CycleTimerProps) => {
|
|
96
114
|
const theme = useTheme()
|
|
97
115
|
const { t } = useTranslation()
|
|
98
116
|
const [remainingTime, setRemainingTime] = useState(0)
|
|
99
|
-
const [maxTime, setMaxTime] = useState(
|
|
117
|
+
const [maxTime, setMaxTime] = useState<number | null>(null)
|
|
100
118
|
const [isRunning, setIsRunning] = useState(false)
|
|
101
119
|
const [isPausedState, setIsPausedState] = useState(false)
|
|
102
120
|
const [currentProgress, setCurrentProgress] = useState(0)
|
|
103
121
|
const animationRef = useRef<number | null>(null)
|
|
104
122
|
const startTimeRef = useRef<number | null>(null)
|
|
105
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)
|
|
106
135
|
|
|
107
136
|
// Spring-based interpolator for smooth gauge progress animations
|
|
108
137
|
// Uses physics simulation to create natural, smooth transitions between progress values
|
|
@@ -114,37 +143,78 @@ export const CycleTimer = externalizeComponent(
|
|
|
114
143
|
},
|
|
115
144
|
})
|
|
116
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
|
+
|
|
117
168
|
const startNewCycle = useCallback(
|
|
118
|
-
(maxTimeSeconds
|
|
119
|
-
setMaxTime(maxTimeSeconds)
|
|
120
|
-
const remainingSeconds = Math.max(0, maxTimeSeconds - elapsedSeconds)
|
|
121
|
-
setRemainingTime(remainingSeconds)
|
|
169
|
+
(maxTimeSeconds?: number, elapsedSeconds: number = 0) => {
|
|
170
|
+
setMaxTime(maxTimeSeconds ?? null)
|
|
122
171
|
setIsPausedState(false)
|
|
123
172
|
pausedTimeRef.current = 0
|
|
124
173
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
+
}
|
|
132
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
|
|
133
210
|
progressInterpolator.setTarget([initialProgress])
|
|
134
|
-
}
|
|
135
211
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
setTimeout(() => onCycleEnd(), 0)
|
|
212
|
+
if (autoStart) {
|
|
213
|
+
startTimeRef.current = Date.now() - elapsedSeconds * 1000
|
|
214
|
+
setIsRunning(true)
|
|
215
|
+
} else {
|
|
216
|
+
startTimeRef.current = null
|
|
142
217
|
}
|
|
143
|
-
} else if (autoStart) {
|
|
144
|
-
startTimeRef.current = Date.now() - elapsedSeconds * 1000
|
|
145
|
-
setIsRunning(true)
|
|
146
|
-
} else {
|
|
147
|
-
startTimeRef.current = null
|
|
148
218
|
}
|
|
149
219
|
},
|
|
150
220
|
[autoStart, onCycleEnd, progressInterpolator],
|
|
@@ -159,11 +229,32 @@ export const CycleTimer = externalizeComponent(
|
|
|
159
229
|
// Calculate exact progress position and smoothly animate to it when pausing
|
|
160
230
|
// This ensures the visual progress matches the actual elapsed time
|
|
161
231
|
const totalElapsed = pausedTimeRef.current / 1000
|
|
162
|
-
|
|
163
|
-
|
|
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
|
+
}
|
|
164
242
|
}
|
|
165
243
|
setIsRunning(false)
|
|
166
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
|
|
167
258
|
}, [isRunning, maxTime, progressInterpolator])
|
|
168
259
|
|
|
169
260
|
const resume = useCallback(() => {
|
|
@@ -178,6 +269,49 @@ export const CycleTimer = externalizeComponent(
|
|
|
178
269
|
return isPausedState
|
|
179
270
|
}, [isPausedState])
|
|
180
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
|
+
|
|
181
315
|
// Call onCycleComplete immediately to provide the timer control functions
|
|
182
316
|
useEffect(() => {
|
|
183
317
|
let isMounted = true
|
|
@@ -202,30 +336,39 @@ export const CycleTimer = externalizeComponent(
|
|
|
202
336
|
if (isRunning) {
|
|
203
337
|
// Single animation frame loop that handles both time updates and progress
|
|
204
338
|
const updateTimer = () => {
|
|
205
|
-
if (startTimeRef.current
|
|
339
|
+
if (startTimeRef.current) {
|
|
206
340
|
const now = Date.now()
|
|
207
341
|
const elapsed =
|
|
208
342
|
(now - startTimeRef.current + pausedTimeRef.current) / 1000
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
|
227
364
|
}
|
|
228
|
-
|
|
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])
|
|
229
372
|
}
|
|
230
373
|
|
|
231
374
|
// Continue animation loop while running
|
|
@@ -269,12 +412,31 @@ export const CycleTimer = externalizeComponent(
|
|
|
269
412
|
}
|
|
270
413
|
}, [progressInterpolator])
|
|
271
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
|
+
|
|
272
427
|
// Keep interpolator synchronized with static progress when timer is stopped
|
|
273
428
|
// Ensures correct visual state when component initializes or timer stops
|
|
274
429
|
useEffect(() => {
|
|
275
|
-
if (!isRunning && !isPausedState
|
|
276
|
-
|
|
277
|
-
|
|
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
|
+
}
|
|
278
440
|
}
|
|
279
441
|
}, [
|
|
280
442
|
isRunning,
|
|
@@ -301,92 +463,92 @@ export const CycleTimer = externalizeComponent(
|
|
|
301
463
|
sx={{
|
|
302
464
|
display: "flex",
|
|
303
465
|
alignItems: "center",
|
|
304
|
-
|
|
466
|
+
m: 0,
|
|
467
|
+
gap: 1, // 8px gap between circle and text
|
|
305
468
|
}}
|
|
306
469
|
>
|
|
307
|
-
{/* Animated progress
|
|
470
|
+
{/* Animated progress ring icon */}
|
|
308
471
|
<Box
|
|
309
472
|
sx={{
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
height: 40,
|
|
473
|
+
width: 20,
|
|
474
|
+
height: 20,
|
|
313
475
|
display: "flex",
|
|
314
476
|
alignItems: "center",
|
|
315
477
|
justifyContent: "center",
|
|
316
|
-
|
|
317
|
-
|
|
478
|
+
opacity: showPauseAnimation || showErrorAnimation ? 0.6 : 1,
|
|
479
|
+
transition: "opacity 0.5s ease-out",
|
|
318
480
|
}}
|
|
319
481
|
>
|
|
320
|
-
<
|
|
321
|
-
width=
|
|
322
|
-
height=
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
left: "50%",
|
|
367
|
-
transform: "translate(-50%, -50%)",
|
|
368
|
-
width: 13,
|
|
369
|
-
height: 13,
|
|
370
|
-
borderRadius: "50%",
|
|
371
|
-
backgroundColor: theme.palette.background?.paper || "white",
|
|
372
|
-
pointerEvents: "none",
|
|
373
|
-
}}
|
|
374
|
-
/>
|
|
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>
|
|
375
528
|
</Box>
|
|
376
529
|
|
|
377
530
|
{/* Timer text display */}
|
|
378
531
|
<Typography
|
|
379
532
|
variant="body2"
|
|
380
533
|
sx={{
|
|
381
|
-
color:
|
|
534
|
+
color: hasError
|
|
535
|
+
? theme.palette.error.light
|
|
536
|
+
: theme.palette.text.primary,
|
|
382
537
|
fontSize: "14px",
|
|
538
|
+
transition: "color 0.5s ease-out",
|
|
383
539
|
}}
|
|
384
540
|
>
|
|
385
|
-
{
|
|
386
|
-
?
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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)}
|
|
390
552
|
</Typography>
|
|
391
553
|
</Box>
|
|
392
554
|
)
|
|
@@ -415,10 +577,13 @@ export const CycleTimer = externalizeComponent(
|
|
|
415
577
|
outerRadius="90%"
|
|
416
578
|
skipAnimation={true}
|
|
417
579
|
sx={{
|
|
418
|
-
opacity:
|
|
419
|
-
transition: "opacity 0.
|
|
580
|
+
opacity: showPauseAnimation || showErrorAnimation ? 0.6 : 1,
|
|
581
|
+
transition: "opacity 0.5s ease-out",
|
|
420
582
|
[`& .MuiGauge-valueArc`]: {
|
|
421
|
-
fill:
|
|
583
|
+
fill: hasError
|
|
584
|
+
? theme.palette.error.light
|
|
585
|
+
: theme.palette.success.main,
|
|
586
|
+
transition: "fill 0.5s ease-out",
|
|
422
587
|
},
|
|
423
588
|
[`& .MuiGauge-referenceArc`]: {
|
|
424
589
|
fill: "white",
|
|
@@ -446,42 +611,102 @@ export const CycleTimer = externalizeComponent(
|
|
|
446
611
|
gap: 1,
|
|
447
612
|
}}
|
|
448
613
|
>
|
|
449
|
-
{/* "remaining time" label */}
|
|
450
|
-
<
|
|
451
|
-
variant="body2"
|
|
614
|
+
{/* "remaining time" label - always reserves space to prevent layout shift */}
|
|
615
|
+
<Box
|
|
452
616
|
sx={{
|
|
453
|
-
|
|
454
|
-
|
|
617
|
+
height: "16px", // Fixed height to prevent layout shift
|
|
618
|
+
display: "flex",
|
|
619
|
+
alignItems: "center",
|
|
620
|
+
justifyContent: "center",
|
|
455
621
|
marginBottom: 0.5,
|
|
456
622
|
}}
|
|
457
623
|
>
|
|
458
|
-
|
|
459
|
-
|
|
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>
|
|
460
639
|
|
|
461
|
-
{/* Main timer display */}
|
|
462
|
-
<
|
|
463
|
-
variant="h1"
|
|
640
|
+
{/* Main timer display with error state transition */}
|
|
641
|
+
<Box
|
|
464
642
|
sx={{
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
643
|
+
position: "relative",
|
|
644
|
+
height: "48px", // Fixed height to prevent layout shift
|
|
645
|
+
display: "flex",
|
|
646
|
+
alignItems: "center",
|
|
647
|
+
justifyContent: "center",
|
|
469
648
|
marginBottom: 0.5,
|
|
470
649
|
}}
|
|
471
650
|
>
|
|
472
|
-
{
|
|
473
|
-
|
|
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>
|
|
474
683
|
|
|
475
|
-
{/* Total time display */}
|
|
476
|
-
<
|
|
477
|
-
variant="body2"
|
|
684
|
+
{/* Total time display - always reserves space to prevent layout shift */}
|
|
685
|
+
<Box
|
|
478
686
|
sx={{
|
|
479
|
-
|
|
480
|
-
|
|
687
|
+
height: "16px", // Fixed height to prevent layout shift
|
|
688
|
+
display: "flex",
|
|
689
|
+
alignItems: "center",
|
|
690
|
+
justifyContent: "center",
|
|
481
691
|
}}
|
|
482
692
|
>
|
|
483
|
-
|
|
484
|
-
|
|
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>
|
|
485
710
|
</Box>
|
|
486
711
|
</Box>
|
|
487
712
|
)
|