timer basically feature-complete. polish remains
This commit is contained in:
@@ -36,7 +36,7 @@ function formatTime(totalSeconds: number) {
|
||||
export default function App() {
|
||||
const [containerHeight, setContainerHeight] = React.useState(0)
|
||||
const [duration, setDuration] = React.useState(timers[0])
|
||||
const [isRunning, setIsRunning] = React.useState(false)
|
||||
const [timerIsRunning, setIsRunning] = React.useState(false)
|
||||
const [timeRemaining, setTimeRemaining] = React.useState(0)
|
||||
const [selectedIndex, setSelectedIndex] = React.useState(0)
|
||||
const [showCountdownText, setShowCountdownText] = React.useState(false)
|
||||
@@ -48,16 +48,17 @@ export default function App() {
|
||||
const countdownRef = React.useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const runningAnimationRef = React.useRef<Animated.CompositeAnimation | null>(null);
|
||||
const cancelButtonAnimation = React.useRef(new Animated.Value(0)).current;
|
||||
const cancelProgressAnimation = React.useRef(new Animated.Value(0)).current;
|
||||
|
||||
const animation = React.useCallback(() => {
|
||||
if (isRunning || containerHeight === 0) {
|
||||
if (timerIsRunning || containerHeight === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsRunning(true);
|
||||
setShowCountdownText(true);
|
||||
taskDetailsAnimation.setValue(0);
|
||||
countdownAnimation.setValue(0);
|
||||
cancelProgressAnimation.setValue(0);
|
||||
|
||||
const totalSeconds = duration * TIMER_UNIT_IN_SECONDS;
|
||||
setTimeRemaining(totalSeconds);
|
||||
@@ -107,11 +108,18 @@ export default function App() {
|
||||
duration: 300,
|
||||
useNativeDriver: true
|
||||
}),
|
||||
Animated.parallel([
|
||||
Animated.timing(timerAnimation, {
|
||||
toValue: containerHeight,
|
||||
duration: totalSeconds * 1000,
|
||||
useNativeDriver: true
|
||||
})
|
||||
}),
|
||||
Animated.timing(cancelProgressAnimation, {
|
||||
toValue: 1,
|
||||
duration: totalSeconds * 1000,
|
||||
useNativeDriver: false,
|
||||
}),
|
||||
]),
|
||||
]);
|
||||
runningAnimationRef.current = runningAnimation;
|
||||
|
||||
@@ -154,43 +162,44 @@ export default function App() {
|
||||
})
|
||||
})
|
||||
});
|
||||
}, [cancelButtonAnimation, countdownAnimation, buttonAnimation, containerHeight, duration, isRunning, taskDetailsAnimation, timerAnimation]);
|
||||
}, [cancelProgressAnimation, cancelButtonAnimation, countdownAnimation,
|
||||
buttonAnimation, containerHeight, duration, timerIsRunning, taskDetailsAnimation,
|
||||
timerAnimation, showCountdownText]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (containerHeight > 0 && !isRunning) {
|
||||
if (containerHeight > 0 && !timerIsRunning) {
|
||||
timerAnimation.setValue(containerHeight);
|
||||
}
|
||||
}, [containerHeight, isRunning, timerAnimation])
|
||||
}, [containerHeight, timerIsRunning, timerAnimation])
|
||||
|
||||
const timerOverlayOpacity = React.useRef(new Animated.Value(1)).current;
|
||||
const cancelButtonOpacity = cancelButtonAnimation;
|
||||
|
||||
const cancelButtonTranslateY = cancelButtonAnimation.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [16, 0],
|
||||
})
|
||||
|
||||
});
|
||||
const opacity = buttonAnimation.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [1, 0]
|
||||
})
|
||||
});
|
||||
const translateY = buttonAnimation.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [0, 200]
|
||||
})
|
||||
});
|
||||
const inactiveTimerNumberOpacity = buttonAnimation.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [1, 0]
|
||||
})
|
||||
});
|
||||
const taskDetailsOpacity = taskDetailsAnimation.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [0, 1]
|
||||
})
|
||||
});
|
||||
const taskDetailsTranslateY = taskDetailsAnimation.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [20, 0]
|
||||
})
|
||||
});
|
||||
const cancelTimer = React.useCallback(() => {
|
||||
if (!isRunning){
|
||||
if (!timerIsRunning){
|
||||
return;
|
||||
}
|
||||
if (countdownRef.current) {
|
||||
@@ -200,17 +209,13 @@ export default function App() {
|
||||
runningAnimationRef.current?.stop();
|
||||
runningAnimationRef.current = null;
|
||||
|
||||
|
||||
Animated.parallel([
|
||||
Animated.timing(cancelButtonAnimation, {
|
||||
toValue: 0,
|
||||
duration: 180,
|
||||
useNativeDriver: true
|
||||
}),
|
||||
Animated.timing(buttonAnimation, {
|
||||
toValue: 0,
|
||||
duration: 220,
|
||||
useNativeDriver: true
|
||||
}),
|
||||
Animated.timing(taskDetailsAnimation, {
|
||||
toValue: 0,
|
||||
duration: 220,
|
||||
@@ -223,15 +228,33 @@ export default function App() {
|
||||
}),
|
||||
Animated.timing(timerAnimation, {
|
||||
toValue: containerHeight,
|
||||
duration: 220,
|
||||
duration: 300,
|
||||
useNativeDriver: true
|
||||
}),
|
||||
Animated.timing(timerOverlayOpacity, {
|
||||
toValue: 0,
|
||||
duration: 120,
|
||||
useNativeDriver: true,
|
||||
})
|
||||
]).start(() => {
|
||||
setShowCountdownText(false);
|
||||
|
||||
Animated.timing(buttonAnimation, {
|
||||
toValue: 0,
|
||||
duration: 220,
|
||||
useNativeDriver: true
|
||||
})
|
||||
.start(() => {
|
||||
timerAnimation.setValue(containerHeight)
|
||||
timerOverlayOpacity.setValue(1);
|
||||
cancelProgressAnimation.stopAnimation();
|
||||
cancelProgressAnimation.setValue(0);
|
||||
setTimeRemaining(0);
|
||||
setIsRunning(false);
|
||||
});
|
||||
}, [buttonAnimation, cancelButtonAnimation, containerHeight, countdownAnimation, timerAnimation, isRunning, taskDetailsAnimation,]);
|
||||
})
|
||||
}, [timerOverlayOpacity, cancelProgressAnimation, buttonAnimation, cancelButtonAnimation,
|
||||
containerHeight, countdownAnimation, timerAnimation, timerIsRunning, taskDetailsAnimation,]);
|
||||
|
||||
return (
|
||||
<View style={styles.container}
|
||||
@@ -264,15 +287,16 @@ return (
|
||||
]}>
|
||||
|
||||
<TouchableOpacity
|
||||
disabled={isRunning}
|
||||
disabled={timerIsRunning}
|
||||
onPress={animation}>
|
||||
<View style={styles.roundButton}>
|
||||
<Text className='text-text-main'>Start</Text>
|
||||
<Text className='text-text-main text-xl'>Start</Text>
|
||||
<Text className='text-text-main text-xl'>Sprint</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
<Animated.View
|
||||
pointerEvents={isRunning? 'auto' : 'none'}
|
||||
pointerEvents={timerIsRunning? 'auto' : 'none'}
|
||||
style={[
|
||||
styles.cancelButtonContainer,
|
||||
{
|
||||
@@ -280,9 +304,18 @@ return (
|
||||
transform: [{translateY: cancelButtonTranslateY}],
|
||||
},
|
||||
]}>
|
||||
<TouchableOpacity onPress={cancelTimer} activeOpacity={0.75}>
|
||||
<TouchableOpacity onPress={cancelTimer}>
|
||||
<View style={styles.cancelButton}>
|
||||
<Text className='text-text-main'>Cancel</Text>
|
||||
<Animated.View style={[
|
||||
styles.cancelButtonFill, {
|
||||
width: cancelProgressAnimation.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: ['0%', '100%'],
|
||||
}),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Text className='text-text-main text-xl'>Cancel</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
@@ -296,6 +329,7 @@ return (
|
||||
}}>
|
||||
<Animated.FlatList
|
||||
data={timers}
|
||||
scrollEnabled={!timerIsRunning}
|
||||
keyExtractor={item => item.toString()}
|
||||
horizontal
|
||||
bounces={false}
|
||||
@@ -305,6 +339,10 @@ return (
|
||||
)}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
onMomentumScrollEnd={ev => {
|
||||
if (timerIsRunning) {
|
||||
return;
|
||||
}
|
||||
|
||||
const index = Math.round(ev.nativeEvent.contentOffset.x / ITEM_SIZE)
|
||||
setSelectedIndex(index);
|
||||
setDuration(timers[index]);
|
||||
@@ -385,7 +423,7 @@ const styles = StyleSheet.create({
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 80,
|
||||
backgroundColor: colors.red,
|
||||
backgroundColor: '#beb9a7',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
@@ -435,10 +473,19 @@ const styles = StyleSheet.create({
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(155, 155, 155, 0.35',
|
||||
borderColor: 'rgba(155, 155, 155, 0.35)',
|
||||
backgroundColor: '#beb9a7',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 22,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
cancelButtonFill: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
top: 0,
|
||||
backgroundColor: 'rgb(184, 80, 80)',
|
||||
}
|
||||
});
|
||||
|
||||
141
notes/work-report-timer-2026-04-23.md
Normal file
141
notes/work-report-timer-2026-04-23.md
Normal file
@@ -0,0 +1,141 @@
|
||||
# Timer Interaction and Cancel Flow Work Report
|
||||
|
||||
## #Overview
|
||||
Today the standalone timer screen was developed further with a focus on the cancel interaction, countdown reset order, and a progress cue inside the cancel button.
|
||||
|
||||
The main work was not just adding UI pieces, but understanding how the existing React Native `Animated` flow behaves when a timer is started, cancelled, or allowed to finish naturally. The timer is still being treated as its own tab with placeholder task information, but the interaction model is now closer to the intended study-session behavior.
|
||||
|
||||
---
|
||||
|
||||
## #ImplementedFeatures
|
||||
|
||||
### #CancelButton
|
||||
Added a dedicated cancel control for the active timer state:
|
||||
- Added a separate cancel button animation value
|
||||
- Added a bottom-positioned cancel button that appears only during the running state
|
||||
- Added reverse handling so the button can be dismissed again when cancelling manually or when the timer finishes
|
||||
|
||||
The main goal was to keep the original large start control as the primary entry point, while giving the active timer state its own secondary exit action.
|
||||
|
||||
---
|
||||
|
||||
### #CancelProgressCue
|
||||
Started adding a progress cue directly inside the cancel button:
|
||||
- Added a separate `cancelProgressAnimation`
|
||||
- Added an inner animated fill layer inside the cancel button
|
||||
- Changed the progress direction to move left-to-right inside the button instead of using a full-button opacity fade
|
||||
|
||||
This was done to match the visual language of the main red timer overlay while keeping the progress indicator smaller and more local to the cancel action.
|
||||
|
||||
---
|
||||
|
||||
### #DurationLocking
|
||||
Updated the duration selector to stay fixed while the timer is running:
|
||||
- Added `scrollEnabled={!timerIsRunning}` to the horizontal timer picker
|
||||
- Added an early return inside `onMomentumScrollEnd`
|
||||
- Prevented the selected timer duration from changing once a session has started
|
||||
|
||||
This keeps the timer state consistent after the session begins and avoids the picker drifting into a visually different value while the countdown is active.
|
||||
|
||||
---
|
||||
|
||||
### #CountdownOwnership
|
||||
Clarified how the countdown interval should be owned and reset:
|
||||
- Added `countdownRef`
|
||||
- Added interval clearing before starting a new countdown
|
||||
- Used the ref-based interval handle so cancel and finish logic can target the active countdown
|
||||
|
||||
This work was needed because countdown behavior becomes unreliable if the code starts new intervals without keeping a consistent reference to the currently running one.
|
||||
|
||||
---
|
||||
|
||||
### #CancelFlowSequencing
|
||||
Worked on the ordering of reverse animations during manual cancel:
|
||||
- Tested separating countdown fade-out from the picker/start-button return
|
||||
- Investigated why adjacent numbers were reappearing before the countdown text had fully finished reversing
|
||||
- Traced the problem to both animation timing and the `showCountdownText` render condition
|
||||
|
||||
The important lesson here was that hiding the countdown visually and switching the rendered text back to the normal timer value are related, but not identical, events.
|
||||
|
||||
---
|
||||
|
||||
## #LearningNotes
|
||||
|
||||
### #AnimatedValueResponsibilities
|
||||
Today reinforced that each `Animated.Value` should have one clear responsibility:
|
||||
- `timerAnimation` controls the red overlay position
|
||||
- `buttonAnimation` controls start-button disappearance and inactive picker return
|
||||
- `countdownAnimation` controls countdown visibility
|
||||
- `cancelButtonAnimation` controls the cancel button itself
|
||||
- `cancelProgressAnimation` controls the left-to-right fill inside the cancel button
|
||||
|
||||
Several visual bugs came from trying to make one animated value carry two different meanings at the same time.
|
||||
|
||||
---
|
||||
|
||||
### #RenderStateVsAnimationState
|
||||
A key distinction became clearer during the cancel-flow debugging:
|
||||
- Animated values control motion and opacity
|
||||
- Regular React state controls what text/content is actually rendered
|
||||
|
||||
One important example is `showCountdownText`:
|
||||
- Even if the countdown has visually faded out, the selected timer item still renders `MM:SS` while `showCountdownText` remains `true`
|
||||
- This means the UI can still appear to be in “countdown mode” even after part of the reverse animation has already completed
|
||||
|
||||
This is why some cancel-order issues were not purely animation problems.
|
||||
|
||||
---
|
||||
|
||||
### #SequenceVsParallel
|
||||
The timer work also clarified when `Animated.sequence([...])` and `Animated.parallel([...])` should be used:
|
||||
- `sequence` is for strict order
|
||||
- `parallel` is for animations that should run at the same time
|
||||
|
||||
One mistake that surfaced during the progress-button work was placing the long progress-fill animation in a sequence after the main timer animation, which caused the fill to begin only after the timer had already ended.
|
||||
|
||||
---
|
||||
|
||||
## #CurrentIssue
|
||||
|
||||
The current timer screen still has remaining cancel-flow polish issues around visual timing and overlay cleanup.
|
||||
|
||||
The main issue currently under investigation is the reset order during manual cancel:
|
||||
- the red timer overlay can still produce a visible flash/jump when the running animation is interrupted
|
||||
- the adjacent picker numbers and selected countdown text are sensitive to both animation order and `showCountdownText`
|
||||
- the current implementation needs further refinement so cancel feels deliberate instead of visually noisy
|
||||
|
||||
Current lint result:
|
||||
|
||||
```text
|
||||
npm run lint
|
||||
completed with 1 warning
|
||||
```
|
||||
|
||||
Current warning:
|
||||
- unnecessary `showCountdownText` dependency in one `useCallback`
|
||||
|
||||
There are no current lint errors, but the cancel interaction is not yet considered visually finished.
|
||||
|
||||
---
|
||||
|
||||
## #FilesChanged
|
||||
|
||||
Main file worked on:
|
||||
|
||||
```text
|
||||
app/(tabs)/timer.tsx
|
||||
```
|
||||
|
||||
New note added:
|
||||
|
||||
```text
|
||||
notes/work-report-timer-2026-04-23.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## #Conclusion
|
||||
|
||||
The timer screen moved further toward a complete active-session interaction today. It now has a dedicated cancel control, a left-to-right progress cue inside that control, a locked duration picker while running, and a clearer separation between countdown ownership and animation ownership.
|
||||
|
||||
The main remaining work is not basic feature addition, but interaction polish. In particular, the cancel sequence still needs refinement so the red overlay, countdown text, and adjacent timer values return in a clean and intentional order.
|
||||
Reference in New Issue
Block a user