implemented last few features and refactored for readability

This commit is contained in:
Chris Sanden
2026-04-24 22:31:01 +02:00
parent 0aa0c18bb5
commit b191a1eced
2 changed files with 721 additions and 291 deletions

View File

@@ -1,7 +1,9 @@
import * as Haptics from 'expo-haptics';
import * as React from 'react';
import {
Animated,
Dimensions,
Easing,
StatusBar,
StyleSheet,
Text,
@@ -19,7 +21,13 @@ const colors = {
const timers = [...Array(13).keys()].map((i) => (i === 0 ? 1 : i * 5));
const ITEM_SIZE = width * 0.38;
const ITEM_SPACING = (width - ITEM_SIZE) / 2;
const TIMER_UNIT_IN_SECONDS = 1; // Set to 60 for timer value to represent minutes
const TIMER_UNIT_IN_SECONDS = 60; // Set to 60 for timer value to represent minutes
const HOLD_TO_CANCEL_MS = 2000;
const CANCEL_ANIMATION_DELAY_MS = 250;
const CANCEL_RELEASE_MS = 750;
const START_TRANSITION_MS = 300;
const TIMER_RESET_MS = 300;
const COUNTDOWN_FADE_MS = 180;
const placeholderTask = {
name: 'Read chapter 4',
description: 'Focus on the summary questions and write down anything unclear.',
@@ -33,184 +41,174 @@ function formatTime(totalSeconds: number) {
seconds.toString().padStart(2, '0')}`;
}
function getCancelOverlayTarget({
containerHeight,
holdStartedAt,
sessionStartedAt,
sessionDurationMs,
}: {
containerHeight: number;
holdStartedAt: number;
sessionStartedAt: number;
sessionDurationMs: number;
}) {
const now = Date.now();
const elapsedHoldMs = now - holdStartedAt;
const remainingHoldMs = Math.max(1, HOLD_TO_CANCEL_MS - elapsedHoldMs);
const elapsedAtCancelMs = now + remainingHoldMs - sessionStartedAt;
const expectedProgress = elapsedAtCancelMs / sessionDurationMs;
const clampedProgress = Math.max(0, Math.min(expectedProgress, 1));
const expectedYAtCancel = containerHeight * clampedProgress;
const cancelOffset = Math.max(0, containerHeight - expectedYAtCancel);
return { cancelOffset, remainingHoldMs };
}
export default function App() {
const [containerHeight, setContainerHeight] = React.useState(0)
const [duration, setDuration] = React.useState(timers[0])
const [timerIsRunning, setIsRunning] = React.useState(false)
const [timeRemaining, setTimeRemaining] = React.useState(0)
const [selectedIndex, setSelectedIndex] = React.useState(0)
const [showCountdownText, setShowCountdownText] = React.useState(false)
const [containerHeight, setContainerHeight] = React.useState(0);
const [duration, setDuration] = React.useState(timers[0]);
const [timerIsRunning, setIsRunning] = React.useState(false);
const [timeRemaining, setTimeRemaining] = React.useState(0);
// Animated values
const scrollX = React.useRef(new Animated.Value(0)).current;
const timerAnimation = React.useRef(new Animated.Value(0)).current;
const buttonAnimation = React.useRef(new Animated.Value(0)).current;
const taskDetailsAnimation = React.useRef(new Animated.Value(0)).current;
const countdownAnimation = React.useRef(new Animated.Value(0)).current;
const cancelButtonAnimation = React.useRef(new Animated.Value(0)).current;
const pressedButtonAnimation = React.useRef(new Animated.Value(0)).current;
const focusModeAnimation = React.useRef(new Animated.Value(0)).current; // 0 = timer inactive, 1 = timer active
const timerOverlayOpacity = React.useRef(new Animated.Value(1)).current;
const cancelOverlayAnimation = React.useRef(new Animated.Value(0)).current;
// Timer/session refs
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 (timerIsRunning || containerHeight === 0) {
return;
}
setIsRunning(true);
setShowCountdownText(true);
taskDetailsAnimation.setValue(0);
countdownAnimation.setValue(0);
cancelProgressAnimation.setValue(0);
const progressAnimationRef = React.useRef<Animated.CompositeAnimation | null>(null);
const sessionStartedAtRef = React.useRef<number | null>(null);
const sessionDurationMsRef = React.useRef(0);
const totalSeconds = duration * TIMER_UNIT_IN_SECONDS;
setTimeRemaining(totalSeconds);
if (countdownRef.current) {
clearInterval(countdownRef.current);
countdownRef.current = null;
}
countdownRef.current = setInterval(() => {
setTimeRemaining((currentTime) => {
if (currentTime <= 1) {
if (countdownRef.current) {
clearInterval(countdownRef.current);
countdownRef.current = null;
}
return 0;
}
return currentTime -1;
});
}, 1000);
const runningAnimation = Animated.sequence([
Animated.parallel([
Animated.timing(buttonAnimation, {
toValue: 1,
duration: 300,
useNativeDriver: true
}),
Animated.timing(cancelButtonAnimation, {
toValue: 1,
duration: 300,
useNativeDriver: true
}),
Animated.timing(taskDetailsAnimation, {
toValue: 1,
duration: 500,
useNativeDriver: true
}),
Animated.timing(countdownAnimation, {
toValue: 1,
duration: 300,
useNativeDriver: true
}),
]),
Animated.timing(timerAnimation, {
toValue: 0,
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;
runningAnimation.start(({ finished }) => {
runningAnimationRef.current = null;
if (!finished) {
return;
}
Animated.timing(countdownAnimation, {
toValue: 0,
duration: 200,
useNativeDriver: true
}).start(() => {
setShowCountdownText(false);
Animated.parallel([
Animated.timing(buttonAnimation, {
toValue: 0,
duration: 300,
useNativeDriver: true
}),
Animated.timing(taskDetailsAnimation, {
toValue: 0,
duration: 300,
useNativeDriver: true
}),
Animated.timing(cancelButtonAnimation, {
toValue: 0,
duration: 300,
useNativeDriver: true
}),
]).start(() => {
setIsRunning(false);
/* TODO
Implement store and send of ellapsed time value in seconds to DB
for total time spent statistic
*/
})
})
});
}, [cancelProgressAnimation, cancelButtonAnimation, countdownAnimation,
buttonAnimation, containerHeight, duration, timerIsRunning, taskDetailsAnimation,
timerAnimation]);
// Cancel-hold refs
const cancelHoldTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const cancelHoldAnimationDelayRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const cancelAccelStartedRef = React.useRef(false);
const cancelHoldActiveRef = React.useRef(false);
const cancelHoldIdRef = React.useRef(0);
const cancelHoldStartedAtRef = React.useRef(0);
React.useEffect(() => {
if (containerHeight > 0 && !timerIsRunning) {
timerAnimation.setValue(containerHeight);
}
}, [containerHeight, timerIsRunning, timerAnimation])
if (containerHeight > 0 && !timerIsRunning) {
timerAnimation.setValue(containerHeight);
}
}, [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 (!timerIsRunning){
return;
}
if (countdownRef.current) {
clearInterval(countdownRef.current);
countdownRef.current = null;
}
runningAnimationRef.current?.stop();
runningAnimationRef.current = null;
const cancelButtonOpacity = cancelButtonAnimation;
Animated.parallel([
const pressedButtonScale = pressedButtonAnimation.interpolate({
inputRange: [0, 1],
outputRange: [1, 0.90],
});
const cancelButtonTranslateY = cancelButtonAnimation.interpolate({
inputRange: [0, 1],
outputRange: [16, 0],
});
const timerOverlayTranslateY = Animated.add(
timerAnimation,
cancelOverlayAnimation
).interpolate({
inputRange: [0, Math.max(containerHeight, 1)],
outputRange: [0, Math.max(containerHeight, 1)],
extrapolate: 'clamp',
});
const countdownTranslateX = focusModeAnimation.interpolate({
inputRange: [0, 1],
outputRange: [0, -width * 0.30],
});
const countdownTranslateY = focusModeAnimation.interpolate({
inputRange: [0, 1],
outputRange: [0, -containerHeight * 0.35],
});
const countdownScale = focusModeAnimation.interpolate({
inputRange: [0, 1],
outputRange: [1, 0.55],
});
const startButtonOpacity = buttonAnimation.interpolate({
inputRange: [0, 1],
outputRange: [1, 0]
});
const startButtonTranslateY = 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 clearCountdown = React.useCallback(() => {
if (countdownRef.current) {
clearInterval(countdownRef.current);
countdownRef.current = null;
}
}, []);
const clearCancelHoldTimers = React.useCallback(() => {
if (cancelHoldTimeoutRef.current) {
clearTimeout(cancelHoldTimeoutRef.current);
cancelHoldTimeoutRef.current = null;
}
if (cancelHoldAnimationDelayRef.current) {
clearTimeout(cancelHoldAnimationDelayRef.current);
cancelHoldAnimationDelayRef.current = null;
}
}, []);
const stopTimerAnimations = React.useCallback(() => {
runningAnimationRef.current?.stop();
runningAnimationRef.current = null;
progressAnimationRef.current?.stop();
progressAnimationRef.current = null;
cancelOverlayAnimation.stopAnimation();
}, [cancelOverlayAnimation]);
const animateButtonPress = React.useCallback((pressed: boolean) => {
Animated.timing(pressedButtonAnimation, {
toValue: pressed ? 1 : 0,
duration: pressed ? 80 : 140,
useNativeDriver: true
}).start();
}, [pressedButtonAnimation]);
const cancelTimer = React.useCallback(() => {
if (!timerIsRunning) {
return;
}
clearCountdown();
stopTimerAnimations();
Animated.parallel([
Animated.timing(cancelButtonAnimation, {
toValue: 0,
duration: 180,
@@ -221,57 +219,273 @@ export default function App() {
duration: 220,
useNativeDriver: true
}),
Animated.timing(focusModeAnimation, {
toValue: 0,
duration: 250,
useNativeDriver: true
}),
Animated.timing(countdownAnimation, {
toValue: 0,
duration: 180,
duration: COUNTDOWN_FADE_MS,
useNativeDriver: true
}),
Animated.timing(timerAnimation, {
toValue: containerHeight,
duration: 300,
duration: TIMER_RESET_MS,
useNativeDriver: true
}),
Animated.timing(timerOverlayOpacity, {
toValue: 0,
duration: 120,
useNativeDriver: true,
}),
Animated.timing(cancelOverlayAnimation, {
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);
});
.start(() => {
timerAnimation.setValue(containerHeight);
cancelOverlayAnimation.setValue(0);
timerOverlayOpacity.setValue(1);
setTimeRemaining(0);
setIsRunning(false);
});
})
}, [timerOverlayOpacity, cancelProgressAnimation, buttonAnimation, cancelButtonAnimation,
containerHeight, countdownAnimation, timerAnimation, timerIsRunning, taskDetailsAnimation,]);
return (
<View style={styles.container}
onLayout={(event) => {
setContainerHeight(event.nativeEvent.layout.height);
}}>
<StatusBar hidden />
<Animated.View
style={[StyleSheet.absoluteFillObject, {
height: containerHeight,
width,
backgroundColor: colors.red,
transform: [{
translateY: timerAnimation
}]
}]}
}, [timerOverlayOpacity, buttonAnimation, cancelButtonAnimation,
cancelOverlayAnimation, clearCountdown, containerHeight, countdownAnimation,
focusModeAnimation, stopTimerAnimations, taskDetailsAnimation,
timerAnimation, timerIsRunning]);
const startCancelHold = React.useCallback(() => {
animateButtonPress(true);
cancelHoldIdRef.current += 1;
const cancelHoldId = cancelHoldIdRef.current;
cancelHoldActiveRef.current = true;
cancelHoldStartedAtRef.current = Date.now();
cancelAccelStartedRef.current = false;
cancelHoldAnimationDelayRef.current = setTimeout(() => {
cancelHoldAnimationDelayRef.current = null;
if (!cancelHoldActiveRef.current || cancelHoldIdRef.current !== cancelHoldId) {
return;
}
cancelAccelStartedRef.current = true;
cancelOverlayAnimation.setValue(0);
const { cancelOffset, remainingHoldMs } = getCancelOverlayTarget({
containerHeight,
holdStartedAt: cancelHoldStartedAtRef.current,
sessionStartedAt: sessionStartedAtRef.current ?? Date.now(),
sessionDurationMs: sessionDurationMsRef.current,
});
Animated.timing(cancelOverlayAnimation, {
toValue: cancelOffset,
duration: remainingHoldMs,
easing: Easing.in(Easing.quad),
useNativeDriver: true
}).start(({ finished }) => {
if (!finished || cancelHoldIdRef.current !== cancelHoldId) {
return;
}
});
}, CANCEL_ANIMATION_DELAY_MS);
cancelHoldTimeoutRef.current = setTimeout(() => {
cancelHoldActiveRef.current = false;
cancelHoldIdRef.current += 1;
cancelAccelStartedRef.current = false;
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning);
cancelTimer();
cancelHoldTimeoutRef.current = null;
}, HOLD_TO_CANCEL_MS);
}, [animateButtonPress, cancelOverlayAnimation, cancelTimer, containerHeight]);
const finishTimer = React.useCallback(() => {
Animated.parallel([
Animated.timing(countdownAnimation, {
toValue: 0,
duration: 200,
useNativeDriver: true
}),
Animated.timing(focusModeAnimation, {
toValue: 0,
duration: 250,
useNativeDriver: true
}),
Animated.timing(taskDetailsAnimation, {
toValue: 0,
duration: 300,
useNativeDriver: true
})
]).start(() => {
Animated.parallel([
Animated.timing(buttonAnimation, {
toValue: 0,
duration: START_TRANSITION_MS,
useNativeDriver: true
}),
Animated.timing(cancelButtonAnimation, {
toValue: 0,
duration: START_TRANSITION_MS,
useNativeDriver: true
}),
]).start(() => {
setIsRunning(false);
/* TODO
Implement store and send of ellapsed time value in seconds to DB
for total time spent statistic
*/
})
})
}, [countdownAnimation, focusModeAnimation, taskDetailsAnimation, buttonAnimation, cancelButtonAnimation]);
const startProgressAnimation = React.useCallback((fromY: number) => {
const elapsedRatio = fromY / containerHeight;
const remainingMs = sessionDurationMsRef.current * (1 - elapsedRatio);
sessionStartedAtRef.current = Date.now() - sessionDurationMsRef.current * elapsedRatio;
timerAnimation.setValue(fromY);
const progressAnimation = Animated.timing(timerAnimation, {
toValue: containerHeight,
duration: remainingMs,
useNativeDriver: true
});
progressAnimationRef.current = progressAnimation;
progressAnimation.start(({ finished }) => {
progressAnimationRef.current = null;
if (!finished) {
return;
}
finishTimer();
});
}, [finishTimer, containerHeight, timerAnimation]);
const stopCancelHold = React.useCallback(() => {
animateButtonPress(false);
cancelHoldActiveRef.current = false;
cancelHoldIdRef.current += 1;
clearCancelHoldTimers();
if (!cancelAccelStartedRef.current) {
return;
}
cancelAccelStartedRef.current = false;
cancelOverlayAnimation.stopAnimation((currentOffset) => {
cancelOverlayAnimation.setValue(currentOffset);
Animated.timing(cancelOverlayAnimation, {
toValue: 0,
duration: CANCEL_RELEASE_MS,
easing: Easing.in(Easing.bounce),
useNativeDriver: true
}).start();
});
}, [animateButtonPress, cancelOverlayAnimation, clearCancelHoldTimers]);
const startTimer = React.useCallback(() => {
if (timerIsRunning || containerHeight === 0) {
return;
}
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
setIsRunning(true);
taskDetailsAnimation.setValue(0);
countdownAnimation.setValue(0);
cancelOverlayAnimation.setValue(0);
const totalSeconds = duration * TIMER_UNIT_IN_SECONDS;
setTimeRemaining(totalSeconds);
sessionStartedAtRef.current = Date.now();
sessionDurationMsRef.current = totalSeconds * 1000;
clearCountdown();
countdownRef.current = setInterval(() => {
setTimeRemaining((currentTime) => {
if (currentTime <= 1) {
clearCountdown();
return 0;
}
return currentTime - 1;
});
}, 1000);
const runningAnimation = Animated.sequence([
Animated.parallel([
Animated.timing(buttonAnimation, {
toValue: 1,
duration: START_TRANSITION_MS,
useNativeDriver: true
}),
Animated.timing(cancelButtonAnimation, {
toValue: 1,
duration: START_TRANSITION_MS,
useNativeDriver: true
}),
Animated.timing(countdownAnimation, {
toValue: 1,
duration: START_TRANSITION_MS,
useNativeDriver: true
}),
Animated.timing(timerAnimation, {
toValue: 0,
duration: START_TRANSITION_MS,
useNativeDriver: true
}),
]),
Animated.timing(focusModeAnimation, {
toValue: 1,
duration: 450,
useNativeDriver: true
}),
Animated.timing(taskDetailsAnimation, {
toValue: 1,
duration: 500,
useNativeDriver: true
}),
]);
runningAnimationRef.current = runningAnimation;
runningAnimation.start(({ finished }) => {
runningAnimationRef.current = null;
if (!finished) {
return;
}
startProgressAnimation(0);
});
}, [cancelButtonAnimation, countdownAnimation,
buttonAnimation, cancelOverlayAnimation, clearCountdown, taskDetailsAnimation,
timerAnimation, focusModeAnimation, duration, timerIsRunning, containerHeight, startProgressAnimation]);
const renderTimerOverlay = () => (
<Animated.View
style={[StyleSheet.absoluteFillObject, {
height: containerHeight,
width,
backgroundColor: colors.red,
transform: [{ translateY: timerOverlayTranslateY }]
}]}
/>
);
const renderStartButton = () => (
<Animated.View
style={[
StyleSheet.absoluteFillObject,
@@ -279,46 +493,71 @@ return (
justifyContent: 'flex-end',
alignItems: 'center',
paddingBottom: 100,
opacity,
transform: [{
translateY
}]
opacity: startButtonOpacity,
transform: [{ translateY: startButtonTranslateY }]
},
]}>
<TouchableOpacity
disabled={timerIsRunning}
onPress={animation}>
<View style={styles.roundButton}>
onPress={startTimer}
onPressIn={() => animateButtonPress(true)}
onPressOut={() => animateButtonPress(false)}>
<Animated.View style={[
styles.roundButton,
{
transform: [{ scale: pressedButtonScale }],
},
]}>
<Text className='text-text-main text-xl'>Start</Text>
<Text className='text-text-main text-xl'>Sprint</Text>
</View>
</Animated.View>
</TouchableOpacity>
</Animated.View>
);
const renderCancelButton = () => (
<Animated.View
pointerEvents={timerIsRunning? 'auto' : 'none'}
style={[
styles.cancelButtonContainer,
{
opacity: cancelButtonOpacity,
transform: [{translateY: cancelButtonTranslateY}],
},
]}>
<TouchableOpacity onPress={cancelTimer}>
<View style={styles.cancelButton}>
<Animated.View style={[
styles.cancelButtonFill, {
width: cancelProgressAnimation.interpolate({
inputRange: [0, 1],
outputRange: ['0%', '100%'],
}),
},
]}
/>
<Text className='text-text-main text-xl'>Cancel</Text>
</View>
pointerEvents={timerIsRunning? 'auto' : 'none'}
style={[
styles.cancelButtonContainer,
{
opacity: cancelButtonOpacity,
transform: [{translateY: cancelButtonTranslateY}],
},
]}>
<TouchableOpacity
onPressIn={startCancelHold}
onPressOut={stopCancelHold}>
<Animated.View style={[
styles.cancelButton,
{
transform: [{ scale: pressedButtonScale }],
},
]}>
<Text className='text-text-main text-xl'>Hold to end sprint</Text>
</Animated.View>
</TouchableOpacity>
</Animated.View>
);
const renderCountdownOverlay = () => (
<Animated.View
pointerEvents="none"
style={[
styles.countdownOverlay, {
opacity: countdownAnimation,
transform: [
{translateX: countdownTranslateX},
{translateY: countdownTranslateY},
{scale: countdownScale},
],
},
]}>
<Text style={styles.countdownText}>{formatTime(timeRemaining)}</Text>
</Animated.View>
);
const renderDurationPicker = () => (
<View
style={{
position: 'absolute',
@@ -327,7 +566,7 @@ return (
right: 0,
flex: 1,
}}>
<Animated.FlatList
<Animated.FlatList
data={timers}
scrollEnabled={!timerIsRunning}
keyExtractor={item => item.toString()}
@@ -342,74 +581,79 @@ return (
if (timerIsRunning) {
return;
}
const index = Math.round(ev.nativeEvent.contentOffset.x / ITEM_SIZE)
setSelectedIndex(index);
setDuration(timers[index]);
const index = Math.round(ev.nativeEvent.contentOffset.x / ITEM_SIZE);
const clampedIndex = Math.max(0, Math.min(index, timers.length - 1));
setDuration(timers[clampedIndex]);
}}
snapToInterval={ITEM_SIZE}
decelerationRate={"fast"}
decelerationRate="fast"
style={{flexGrow: 0}}
contentContainerStyle={{
paddingHorizontal: ITEM_SPACING
}}
renderItem={({item, index}) => {
const isSelected = index === selectedIndex;
const timerText = showCountdownText && isSelected ? formatTime(timeRemaining) : item;
const inputRange = [
(index - 1) * ITEM_SIZE,
index * ITEM_SIZE,
(index + 1) * ITEM_SIZE,
]
(index - 1) * ITEM_SIZE,
index * ITEM_SIZE,
(index + 1) * ITEM_SIZE,
];
const normalOpacity = scrollX.interpolate({
inputRange,
outputRange: [.4, 1, .4]
})
const selectedOpacity = scrollX.interpolate({
inputRange,
outputRange: [0, 1, 0],
extrapolate: 'clamp'
})
const opacity = Animated.add(
Animated.multiply(normalOpacity, inactiveTimerNumberOpacity),
Animated.multiply(selectedOpacity, countdownAnimation)
)
inputRange,
outputRange: [.4, 1, .4]
});
const timerTextOpacity = Animated.multiply(normalOpacity, inactiveTimerNumberOpacity);
const scale = scrollX.interpolate({
inputRange,
outputRange: [.7, 1, .7]
})
return <View style={{width: ITEM_SIZE, justifyContent: 'center', alignItems: 'center'}}>
<Animated.Text style={[showCountdownText && isSelected ? styles.countdownText : styles.text, {
opacity,
transform: [{
scale
}]
inputRange,
outputRange: [.7, 1, .7]
});
}]}>
{timerText}
return (
<View style={styles.timerItem}>
<Animated.Text style={[
styles.text,
{
opacity: timerTextOpacity,
transform: [{ scale }]
}
]}>
{item}
</Animated.Text>
</View>
}
);
}}
/>
</View>
);
const renderTaskDetails = () => (
<Animated.View
pointerEvents="none"
style={[
styles.taskDetails,
{
opacity: taskDetailsOpacity,
transform: [{ translateY: taskDetailsTranslateY }]
}
/>
</View>
<Animated.View
pointerEvents="none"
style={[
styles.taskDetails,
{
opacity: taskDetailsOpacity,
transform: [{
translateY: taskDetailsTranslateY
}]
}
]}>
<Text style={styles.taskName}>{placeholderTask.name}</Text>
<Text style={styles.taskDescription}>{placeholderTask.description}</Text>
</Animated.View>
]}>
<Text style={styles.taskName}>{placeholderTask.name}</Text>
<Text style={styles.taskDescription}>{placeholderTask.description}</Text>
</Animated.View>
);
return (
<View
style={styles.container}
onLayout={(event) => {
setContainerHeight(event.nativeEvent.layout.height);
}}>
<StatusBar hidden />
{renderTimerOverlay()}
{renderStartButton()}
{renderCancelButton()}
{renderCountdownOverlay()}
{renderDurationPicker()}
{renderTaskDetails()}
</View>
);
}
@@ -427,31 +671,35 @@ const styles = StyleSheet.create({
alignItems: 'center',
justifyContent: 'center',
},
text: {
fontSize: ITEM_SIZE * 0.8,
fontFamily: 'Menlo',
color: colors.text,
fontWeight: '900',
},
timerItem: {
width: ITEM_SIZE,
justifyContent: 'center',
alignItems: 'center',
},
taskDetails: {
position: 'absolute',
top: height * 0.56,
top: height * 0.34,
left: 32,
right: 32,
alignItems: 'center',
},
taskName: {
color: colors.text,
fontSize: 24,
fontSize: 32,
fontWeight: '800',
textAlign: 'center',
},
taskDescription: {
color: colors.text,
fontSize: 16,
lineHeight: 22,
marginTop: 10,
fontSize: 24,
lineHeight: 32,
marginTop: 20,
textAlign: 'center',
},
countdownText: {
@@ -459,6 +707,7 @@ const styles = StyleSheet.create({
fontFamily: 'Menlo',
color: colors.text,
fontWeight: '900',
textAlign: 'center',
},
cancelButtonContainer: {
position: 'absolute',
@@ -481,11 +730,11 @@ const styles = StyleSheet.create({
position: 'relative',
overflow: 'hidden',
},
cancelButtonFill: {
countdownOverlay: {
position: 'absolute',
top: height / 3,
left: 0,
bottom: 0,
top: 0,
backgroundColor: 'rgb(184, 80, 80)',
}
right: 0,
alignItems: 'center',
},
});

View File

@@ -0,0 +1,181 @@
# Timer Focus Mode and Hold-Cancel Work Report
## #Overview
Today the standalone timer screen was reworked further with a focus on the active sprint layout, countdown ownership, and the hold-to-cancel interaction.
The main direction was to make the running timer feel more like a focused study state instead of a duration picker that happens to count down. The countdown was moved toward a separate overlay, the task details were given more visual emphasis, and the cancel interaction was changed from a simple button press into a deliberate hold action.
---
## #ImplementedFeatures
### #CountdownOverlay
Moved the active countdown away from the duration picker:
- Removed the old selected-picker countdown state
- Added a separate countdown overlay using `countdownAnimation`
- Added `focusModeAnimation` so the countdown can move from the central timer area toward the upper-left area
- Kept the picker responsible for duration values only
This separates two responsibilities that had previously been mixed together: the picker selects a duration, while the overlay shows active countdown time.
---
### #FocusModeLayout
Adjusted the active timer layout to put more attention on the task:
- Moved task details higher and closer to the center of the running screen
- Increased the task title and description size
- Kept task details animated through `taskDetailsAnimation`
- Continued using the red screen overlay as the main visual timer-progress element
The intent is for the active state to feel more like a study-session spotlight, where the selected task becomes the main focus and the countdown becomes supporting information.
---
### #HoldToCancel
Changed the cancel action into a hold interaction:
- Added `HOLD_TO_CANCEL_MS`
- Added `cancelHoldTimeoutRef`
- Added a hold-completion haptic warning
- Kept the cancel button scale feedback during press
- Changed the label to `Hold to end sprint`
This makes cancellation more deliberate and reduces the chance of accidentally ending a sprint with a single tap.
---
### #CancelAccelerationExperiment
Implemented the red timer overlay as cancel feedback:
- Added delayed cancel acceleration through `CANCEL_ANIMATION_DELAY_MS`
- Added `cancelHoldAnimationDelayRef`
- Added `cancelAccelStartedRef` to distinguish quick taps from actual hold acceleration
- Split normal timer progress into `progressAnimationRef`
- Added `startProgressAnimation(fromY)` so progress can start or resume from a specific overlay position
- Added `cancelOverlayAnimation` as a temporary visual offset on top of the real timer progress
- Added `getCancelOverlayTarget(...)` to calculate how far the cancel preview should move
- Added a release handoff animation so the cancel offset eases back into the real timer position
- Added clamping so the visual overlay does not move past the finished timer position
- Added easing constants for the cancel delay, release handoff, and timer reset timings
The goal was for the red overlay to speed toward the finished position during a hold, then return smoothly to the real timer progress if the user releases before the cancel completes. The important change is that cancel preview motion is now layered on top of the real progress instead of directly taking over the main timer animation.
---
### #DurationPickerCleanup
Cleaned up the duration picker after moving countdown ownership out of it:
- Removed selected countdown rendering from the picker item
- Kept picker items rendering plain timer values
- Kept picker values fading out during active timer mode
- Added index clamping when reading the selected duration from `onMomentumScrollEnd`
- Restored `duration` as a dependency of the start callback so the selected picker value is used correctly
This fixed the earlier issue where the timer could behave as if the selected duration was still the initial value.
---
### #TimerCodeCleanup
Cleaned up the timer screen structure after the interaction behavior was stabilized:
- Renamed the old `animation` callback to `startTimer`
- Renamed unclear animated values like `opacity` and `translateY` to `startButtonOpacity` and `startButtonTranslateY`
- Grouped refs by purpose: animated values, timer/session refs, and cancel-hold refs
- Extracted `clearCountdown`, `clearCancelHoldTimers`, and `stopTimerAnimations`
- Extracted the cancel overlay target calculation into `getCancelOverlayTarget(...)`
- Split the render section into local render helpers for the overlay, start button, cancel button, countdown, duration picker, and task details
- Moved the timer item layout into `styles.timerItem`
This did not change the screen into a separate hook or split the timer into multiple files. The cleanup stayed local to `timer.tsx` so the current animation work remains easy to inspect.
---
## #LearningNotes
### #AnimationOwnership
The main lesson today was that an `Animated.Value` should have one clear owner at a time.
The red overlay now combines two animated values:
- normal timer progress
- hold-to-cancel visual offset
The normal timer progress is controlled by `timerAnimation`, while cancel preview motion is controlled by `cancelOverlayAnimation`. This avoids stopping the real timer progress just to show the cancel speed-up effect.
---
### #RefsAsMutableState
Several refs were added to track animation and timer ownership:
- `progressAnimationRef` tracks the long-running red overlay progress animation
- `sessionStartedAtRef` tracks the progress timeline used for recovery calculations
- `sessionDurationMsRef` stores the current timer duration in milliseconds
- `cancelHoldTimeoutRef` tracks when hold cancellation should complete
- `cancelHoldAnimationDelayRef` tracks when cancel acceleration should begin
- `cancelAccelStartedRef` tracks whether the red overlay acceleration actually started
- `cancelHoldActiveRef` and `cancelHoldIdRef` prevent stale delayed hold callbacks from taking over after release
The important distinction is that assigning to `.current` is allowed even when the ref variable itself is declared with `const`.
---
### #CancelOffsetHandoff
The release recovery logic was changed to avoid rewriting the real timer progress:
- keep `timerAnimation` running as the source of real timer progress
- add `cancelOverlayAnimation` on top of it while the cancel button is held
- animate only the cancel offset back to `0` when the hold is released
- keep the visible overlay clamped to the screen height
- tune the release handoff timing with `CANCEL_RELEASE_MS`
This makes the visual red overlay return to the countdown's real timer position without forcing the main timer animation to stop and restart.
---
## #CurrentState
The hold-cancel red overlay interaction has been reworked so the cancel preview no longer directly mutates the real timer progress.
The current implementation:
- keeps the countdown and real timer progress owned by `timerAnimation`
- uses `cancelOverlayAnimation` as a temporary visual offset during hold-to-cancel
- invalidates stale hold callbacks with `cancelHoldIdRef`
- eases the cancel offset back to `0` on release
- keeps the cancel-completion path separate from normal timer completion
This should make the red overlay speed-up feel connected to the cancel hold while still keeping the timer progress visually aligned with the countdown after release.
---
## #Verification
Current static checks pass:
```text
npm run lint
exited successfully
```
```text
npx tsc --noEmit
exited successfully
```
The hold-cancel handoff was also adjusted based on runtime feedback so the cancel offset eases back more smoothly into the real timer progress.
---
## #FilesChanged
Main file worked on:
```text
app/(tabs)/timer.tsx
```
New note added:
```text
notes/work-report-timer-2026-04-24.md
```
---
## #Conclusion
The timer screen moved further toward a focused active-sprint experience. The countdown is now separated from the duration picker, task details have more visual weight, and cancel is treated as a deliberate hold action rather than a normal tap.
The main animation change is that hold-to-cancel now keeps the real timer progress separate from the temporary cancel speed-up effect. The code was also cleaned up so the timer flow is easier to read and continue working on.