diff --git a/app/(tabs)/timer.tsx b/app/(tabs)/timer.tsx index d9e6499..ab0944d 100644 --- a/app/(tabs)/timer.tsx +++ b/app/(tabs)/timer.tsx @@ -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 | null>(null); const runningAnimationRef = React.useRef(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(null); + const sessionStartedAtRef = React.useRef(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 | null>(null); + const cancelHoldAnimationDelayRef = React.useRef | 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 ( - { - setContainerHeight(event.nativeEvent.layout.height); - }}> -