diff --git a/.gitignore b/.gitignore index f31da9b..c9e4ffb 100644 --- a/.gitignore +++ b/.gitignore @@ -192,4 +192,5 @@ google-services.json # Misc # --------------------------- *.orig.* -app-example \ No newline at end of file +app-example +newDeps/ \ No newline at end of file diff --git a/app/(tabs)/timer.tsx b/app/(tabs)/timer.tsx index db553c1..89d3f69 100644 --- a/app/(tabs)/timer.tsx +++ b/app/(tabs)/timer.tsx @@ -8,8 +8,9 @@ import { StyleSheet, Text, TouchableOpacity, - View + View, } from 'react-native'; + const { width, height } = Dimensions.get('window'); const colors = { @@ -18,12 +19,15 @@ const colors = { text: '#ffffff', }; -const timers = [...Array(13).keys()].map((i) => (i === 0 ? 1 : i * 5)); +const TIMER_OPTIONS = [...Array(13).keys()].map((index) => (index === 0 ? 1 : index * 5)); const ITEM_SIZE = width * 0.38; const ITEM_SPACING = (width - ITEM_SIZE) / 2; -const TIMER_UNIT_IN_SECONDS = 60; // Set to 60 for timer value to represent minutes +const TIMER_UNIT_IN_SECONDS = 60; const HOLD_TO_CANCEL_MS = 2000; -const CANCEL_ANIMATION_DELAY_MS = 250 +const CANCEL_ANIMATION_DELAY_MS = 250; +const BUTTON_PRESS_IN_MS = 80; +const BUTTON_PRESS_OUT_MS = 140; + const placeholderTask = { name: 'Read chapter 4', description: 'Focus on the summary questions and write down anything unclear.', @@ -33,49 +37,46 @@ function formatTime(totalSeconds: number) { const minutes = Math.floor(totalSeconds / 60); const seconds = totalSeconds % 60; - return `${minutes.toString().padStart(2, '0')}:${ - seconds.toString().padStart(2, '0')}`; + return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; } -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) +export default function TimerScreen() { + const [containerHeight, setContainerHeight] = React.useState(0); + const [duration, setDuration] = React.useState(TIMER_OPTIONS[0]); + const [timerIsRunning, setIsRunning] = React.useState(false); + const [timeRemaining, setTimeRemaining] = React.useState(0); + 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; + const cancelOverlayAnimation = React.useRef(new Animated.Value(0)).current; + const countdownRef = React.useRef | null>(null); - const cancelHoldTimeoutRef = React.useRef | null>(null); + const cancelHoldTimeoutRef = React.useRef | null>(null); + const cancelHoldAnimationDelayRef = React.useRef | null>(null); const runningAnimationRef = React.useRef(null); const progressAnimationRef = React.useRef(null); const sessionStartedAtRef = React.useRef(null); const sessionDurationMsRef = React.useRef(0); - 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 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); - const cancelOverlayAnimation = React.useRef(new Animated.Value(0)).current; React.useEffect(() => { - if (containerHeight > 0 && !timerIsRunning) { - timerAnimation.setValue(containerHeight); - } - }, [containerHeight, timerIsRunning, timerAnimation]); - - const timerOverlayOpacity = React.useRef(new Animated.Value(1)).current; - - const cancelButtonOpacity = cancelButtonAnimation; + if (containerHeight > 0 && !timerIsRunning) { + timerAnimation.setValue(containerHeight); + } + }, [containerHeight, timerIsRunning, timerAnimation]); const pressedButtonScale = pressedButtonAnimation.interpolate({ - inputRange: [0, 1], - outputRange: [1, 0.90], + inputRange: [0, 1], + outputRange: [1, 0.9], }); const cancelButtonTranslateY = cancelButtonAnimation.interpolate({ @@ -83,6 +84,9 @@ export default function App() { outputRange: [16, 0], }); + // Real timer progress comes from timerAnimation. The cancel hold adds a + // temporary visual offset on top so release/cancel logic does not fight the + // underlying progress animation. const timerOverlayTranslateY = Animated.add( timerAnimation, cancelOverlayAnimation @@ -94,7 +98,7 @@ export default function App() { const countdownTranslateX = focusModeAnimation.interpolate({ inputRange: [0, 1], - outputRange: [0, -width * 0.30], + outputRange: [0, -width * 0.3], }); const countdownTranslateY = focusModeAnimation.interpolate({ @@ -107,111 +111,345 @@ export default function App() { outputRange: [1, 0.55], }); - const opacity = buttonAnimation.interpolate({ - inputRange: [0, 1], - outputRange: [1, 0] + const startButtonOpacity = buttonAnimation.interpolate({ + inputRange: [0, 1], + outputRange: [1, 0], }); - const translateY = buttonAnimation.interpolate({ - inputRange: [0, 1], - outputRange: [0, 200] + const startButtonTranslateY = buttonAnimation.interpolate({ + inputRange: [0, 1], + outputRange: [0, 200], }); - const inactiveTimerNumberOpacity = buttonAnimation.interpolate({ - inputRange: [0, 1], - outputRange: [1, 0] + const pickerOpacity = buttonAnimation.interpolate({ + inputRange: [0, 1], + outputRange: [1, 0], }); const taskDetailsOpacity = taskDetailsAnimation.interpolate({ - inputRange: [0, 1], - outputRange: [0, 1] + inputRange: [0, 1], + outputRange: [0, 1], }); const taskDetailsTranslateY = taskDetailsAnimation.interpolate({ - inputRange: [0, 1], - outputRange: [20, 0] + inputRange: [0, 1], + outputRange: [20, 0], }); - 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; - } + const clearCountdownInterval = React.useCallback(() => { if (countdownRef.current) { clearInterval(countdownRef.current); countdownRef.current = null; } + }, []); + + const clearCancelHoldTimeouts = React.useCallback(() => { + if (cancelHoldTimeoutRef.current) { + clearTimeout(cancelHoldTimeoutRef.current); + cancelHoldTimeoutRef.current = null; + } + + if (cancelHoldAnimationDelayRef.current) { + clearTimeout(cancelHoldAnimationDelayRef.current); + cancelHoldAnimationDelayRef.current = null; + } + }, []); + + const stopRunningAnimations = React.useCallback(() => { runningAnimationRef.current?.stop(); runningAnimationRef.current = null; progressAnimationRef.current?.stop(); progressAnimationRef.current = null; + cancelOverlayAnimation.stopAnimation(); + }, [cancelOverlayAnimation]); + + React.useEffect(() => { + return () => { + clearCountdownInterval(); + clearCancelHoldTimeouts(); + stopRunningAnimations(); + }; + }, [clearCancelHoldTimeouts, clearCountdownInterval, stopRunningAnimations]); + + const animateButtonPress = React.useCallback( + (pressed: boolean) => { + Animated.timing(pressedButtonAnimation, { + toValue: pressed ? 1 : 0, + duration: pressed ? BUTTON_PRESS_IN_MS : BUTTON_PRESS_OUT_MS, + useNativeDriver: true, + }).start(); + }, + [pressedButtonAnimation] + ); + + const resetSessionValues = React.useCallback(() => { + sessionStartedAtRef.current = null; + sessionDurationMsRef.current = 0; + cancelHoldActiveRef.current = false; + cancelAccelStartedRef.current = false; + + timerAnimation.setValue(containerHeight); + cancelOverlayAnimation.setValue(0); + setTimeRemaining(0); + setIsRunning(false); + }, [cancelOverlayAnimation, containerHeight, timerAnimation]); + + const finishTimer = React.useCallback(() => { + clearCountdownInterval(); + + 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: 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 + */ + + resetSessionValues(); + }); + }); + }, [ + buttonAnimation, + cancelButtonAnimation, + clearCountdownInterval, + countdownAnimation, + focusModeAnimation, + resetSessionValues, + taskDetailsAnimation, + ]); + + // This picks up the timer overlay animation from the current Y position and + // runs it to the bottom over the remaining session time. + 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(); + }); + }, + [containerHeight, finishTimer, timerAnimation] + ); + + const runStartSequence = React.useCallback(() => { + 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(countdownAnimation, { + toValue: 1, + duration: 300, + useNativeDriver: true, + }), + Animated.timing(timerAnimation, { + toValue: 0, + duration: 300, + 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); + }); + }, [ + buttonAnimation, + cancelButtonAnimation, + countdownAnimation, + focusModeAnimation, + startProgressAnimation, + taskDetailsAnimation, + timerAnimation, + ]); + + const startCountdown = React.useCallback( + (totalSeconds: number) => { + setTimeRemaining(totalSeconds); + clearCountdownInterval(); + + countdownRef.current = setInterval(() => { + setTimeRemaining((currentTime) => { + if (currentTime <= 1) { + clearCountdownInterval(); + return 0; + } + + return currentTime - 1; + }); + }, 1000); + }, + [clearCountdownInterval] + ); + + const startTimerSession = 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; + sessionStartedAtRef.current = Date.now(); + sessionDurationMsRef.current = totalSeconds * 1000; + + startCountdown(totalSeconds); + runStartSequence(); + }, [ + cancelOverlayAnimation, + containerHeight, + countdownAnimation, + duration, + runStartSequence, + startCountdown, + taskDetailsAnimation, + timerIsRunning, + ]); + + const cancelTimer = React.useCallback(() => { + if (!timerIsRunning) { + return; + } + + clearCountdownInterval(); + clearCancelHoldTimeouts(); + stopRunningAnimations(); Animated.parallel([ Animated.timing(cancelButtonAnimation, { toValue: 0, duration: 180, - useNativeDriver: true + useNativeDriver: true, }), Animated.timing(taskDetailsAnimation, { toValue: 0, duration: 220, - useNativeDriver: true + useNativeDriver: true, }), Animated.timing(focusModeAnimation, { - toValue: 0, - duration: 250, - useNativeDriver: true - }), + toValue: 0, + duration: 250, + useNativeDriver: true, + }), Animated.timing(countdownAnimation, { toValue: 0, duration: 180, - useNativeDriver: true + useNativeDriver: true, }), Animated.timing(timerAnimation, { toValue: containerHeight, duration: 300, - useNativeDriver: true - }), - Animated.timing(timerOverlayOpacity, { - toValue: 0, - duration: 120, useNativeDriver: true, }), Animated.timing(cancelOverlayAnimation, { toValue: 0, duration: 120, useNativeDriver: true, - }) - ]).start(() => { - Animated.timing(buttonAnimation, { - toValue: 0, - duration: 220, - useNativeDriver: true - }) - .start(() => { - timerAnimation.setValue(containerHeight) - cancelOverlayAnimation.setValue(0); - timerOverlayOpacity.setValue(1); - setTimeRemaining(0); - setIsRunning(false); - }); - }) - }, [timerOverlayOpacity, buttonAnimation, cancelButtonAnimation, - cancelOverlayAnimation, containerHeight, countdownAnimation, timerAnimation, - timerIsRunning, taskDetailsAnimation, focusModeAnimation]); + }), + ]).start(() => { + Animated.timing(buttonAnimation, { + toValue: 0, + duration: 220, + useNativeDriver: true, + }).start(() => { + resetSessionValues(); + }); + }); + }, [ + buttonAnimation, + cancelButtonAnimation, + cancelOverlayAnimation, + clearCancelHoldTimeouts, + clearCountdownInterval, + containerHeight, + countdownAnimation, + focusModeAnimation, + resetSessionValues, + stopRunningAnimations, + taskDetailsAnimation, + timerAnimation, + timerIsRunning, + ]); - const startCancelHold = React.useCallback(() => { + const handleCancelHoldStart = React.useCallback(() => { animateButtonPress(true); cancelHoldIdRef.current += 1; + const cancelHoldId = cancelHoldIdRef.current; cancelHoldActiveRef.current = true; cancelHoldStartedAtRef.current = Date.now(); @@ -224,6 +462,9 @@ export default function App() { return; } + // The hold starts with normal button feedback. After a short delay, we + // begin the accelerated red overlay preview so quick taps do not cause a + // jolt, while long holds still clearly show that cancel is about to fire. cancelAccelStartedRef.current = true; cancelOverlayAnimation.setValue(0); @@ -240,103 +481,27 @@ export default function App() { toValue: cancelOffset, duration: remainingHoldMs, easing: Easing.in(Easing.quad), - useNativeDriver: true - }).start(({ finished }) => { - if (!finished || cancelHoldIdRef.current !== cancelHoldId) { - return; - } - }); + useNativeDriver: true, + }).start(); }, CANCEL_ANIMATION_DELAY_MS); cancelHoldTimeoutRef.current = setTimeout(() => { cancelHoldActiveRef.current = false; cancelHoldIdRef.current += 1; cancelAccelStartedRef.current = false; - Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning) + + 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: 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 - */ - }) - }) - }, [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(() => { + const handleCancelHoldEnd = React.useCallback(() => { animateButtonPress(false); cancelHoldActiveRef.current = false; cancelHoldIdRef.current += 1; - if (cancelHoldTimeoutRef.current) { - clearTimeout(cancelHoldTimeoutRef.current); - cancelHoldTimeoutRef.current = null; - } - - if (cancelHoldAnimationDelayRef.current) { - clearTimeout(cancelHoldAnimationDelayRef.current); - cancelHoldAnimationDelayRef.current = null; - } + clearCancelHoldTimeouts(); if (!cancelAccelStartedRef.current) { return; @@ -349,252 +514,191 @@ export default function App() { toValue: 0, duration: 750, easing: Easing.in(Easing.bounce), - useNativeDriver: true + useNativeDriver: true, }).start(); }); - }, [animateButtonPress, cancelOverlayAnimation]); + }, [animateButtonPress, cancelOverlayAnimation, clearCancelHoldTimeouts]); - const animation = 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; - - 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(countdownAnimation, { - toValue: 1, - duration: 300, - useNativeDriver: true - }), - Animated.timing(timerAnimation, { - toValue: 0, - duration: 300, - 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) { + const handleTimerPickerMomentumEnd = React.useCallback( + (event: { nativeEvent: { contentOffset: { x: number } } }) => { + if (timerIsRunning) { return; } - startProgressAnimation(0); - }); - }, [cancelButtonAnimation, countdownAnimation, - buttonAnimation, cancelOverlayAnimation, taskDetailsAnimation, - timerAnimation, focusModeAnimation, duration, timerIsRunning, containerHeight, startProgressAnimation]); -return ( - { - setContainerHeight(event.nativeEvent.layout.height); - }}> -