From c74062c84c971f89758e0c13ff5d911e2194123a Mon Sep 17 00:00:00 2001 From: Chris Sanden Date: Fri, 1 May 2026 21:37:58 +0200 Subject: [PATCH] Implemented timer into task details, uploaded example images for app and centred headers on all screens --- app/(tabs)/_layout.tsx | 1 - app/(tabs)/index.tsx | 1 + app/(tabs)/subjects.tsx | 1 + app/assignment/upsertAssignment.tsx | 1 + app/assignment/viewDetailsAssignment.tsx | 1 + app/subject/upsertSubject.tsx | 3 +- app/subject/viewDetailsSubject.tsx | 1 + app/task/_layout.tsx | 1 + app/{(tabs) => task}/timer.tsx | 327 ++++++++++++------ app/task/upsertTask.tsx | 1 + app/task/viewDetailsTask.tsx | 14 +- assets/images/android-icon-background.png | Bin 17549 -> 1816 bytes assets/images/android-icon-foreground.png | Bin 78796 -> 165466 bytes assets/images/android-icon-monochrome.png | Bin 4140 -> 12305 bytes assets/images/favicon.png | Bin 1129 -> 3802 bytes assets/images/icon.png | Bin 393493 -> 564277 bytes .../master.png | Bin assets/images/splash-icon.png | Bin 17547 -> 564277 bytes .../android-icon-background.png | Bin 1816 -> 0 bytes .../android-icon-foreground.png | Bin 165466 -> 0 bytes .../android-icon-monochrome.png | Bin 12305 -> 0 bytes assets/study-sprint-image-pack/favicon.png | Bin 3802 -> 0 bytes assets/study-sprint-image-pack/icon.png | Bin 564277 -> 0 bytes .../study-sprint-image-pack/splash-icon.png | Bin 564277 -> 0 bytes lib/asyncStorage.ts | 27 +- 25 files changed, 263 insertions(+), 116 deletions(-) rename app/{(tabs) => task}/timer.tsx (75%) rename assets/{study-sprint-image-pack => images}/master.png (100%) delete mode 100644 assets/study-sprint-image-pack/android-icon-background.png delete mode 100644 assets/study-sprint-image-pack/android-icon-foreground.png delete mode 100644 assets/study-sprint-image-pack/android-icon-monochrome.png delete mode 100644 assets/study-sprint-image-pack/favicon.png delete mode 100644 assets/study-sprint-image-pack/icon.png delete mode 100644 assets/study-sprint-image-pack/splash-icon.png diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index d9a261e..c02acbd 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -66,7 +66,6 @@ export default function TabLayout() { }}> - ); } \ No newline at end of file diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 528f1e9..29084d6 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -34,6 +34,7 @@ export default function HomeScreen() { { return ( diff --git a/app/(tabs)/subjects.tsx b/app/(tabs)/subjects.tsx index 86564b8..af2aad8 100644 --- a/app/(tabs)/subjects.tsx +++ b/app/(tabs)/subjects.tsx @@ -56,6 +56,7 @@ export default function Subjects() { ( diff --git a/app/assignment/viewDetailsAssignment.tsx b/app/assignment/viewDetailsAssignment.tsx index e22cb61..cf8ce24 100644 --- a/app/assignment/viewDetailsAssignment.tsx +++ b/app/assignment/viewDetailsAssignment.tsx @@ -182,6 +182,7 @@ export default function ViewDetailsAssignment() { diff --git a/app/subject/upsertSubject.tsx b/app/subject/upsertSubject.tsx index b3bef3f..ae10dde 100644 --- a/app/subject/upsertSubject.tsx +++ b/app/subject/upsertSubject.tsx @@ -94,7 +94,7 @@ export default function UpsertSubject() { if(result.error) { Alert.alert( - isEditMode + isEditMode ? 'Subject could not be updated, please try again' : 'Subject could not be created, please try again' ); @@ -129,6 +129,7 @@ export default function UpsertSubject() { options= {{ title: isEditMode ? 'Edit Subject' : 'Create Subject', headerTitleStyle: defaultStyles.title, + headerTitleAlign: 'center', }} /> diff --git a/app/subject/viewDetailsSubject.tsx b/app/subject/viewDetailsSubject.tsx index 8dee4f8..7ec88d3 100644 --- a/app/subject/viewDetailsSubject.tsx +++ b/app/subject/viewDetailsSubject.tsx @@ -173,6 +173,7 @@ export default function ViewDetailsSubject() { diff --git a/app/task/_layout.tsx b/app/task/_layout.tsx index 06a3159..df39d7d 100644 --- a/app/task/_layout.tsx +++ b/app/task/_layout.tsx @@ -5,6 +5,7 @@ export default function TaskLayout() { + ); } \ No newline at end of file diff --git a/app/(tabs)/timer.tsx b/app/task/timer.tsx similarity index 75% rename from app/(tabs)/timer.tsx rename to app/task/timer.tsx index 12857f2..933f431 100644 --- a/app/(tabs)/timer.tsx +++ b/app/task/timer.tsx @@ -1,4 +1,12 @@ +import { + GetActiveSprint, + RemoveActiveSprint, + SaveActiveSprint, +} from '@/lib/asyncStorage'; +import { supabase } from '@/lib/supabase'; +import type { Task } from '@/lib/types'; import * as Haptics from 'expo-haptics'; +import { Stack, useLocalSearchParams } from 'expo-router'; import * as React from 'react'; import { Animated, @@ -8,7 +16,7 @@ import { StyleSheet, Text, TouchableOpacity, - View, + View } from 'react-native'; const { width, height } = Dimensions.get('window'); @@ -40,11 +48,6 @@ 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.', -}; - function formatTime(totalSeconds: number) { const minutes = Math.floor(totalSeconds / 60); const seconds = totalSeconds % 60; @@ -56,7 +59,9 @@ 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 [timerOverlayVisible, setTimerOverlayVisible] = React.useState(false); const [timeRemaining, setTimeRemaining] = React.useState(0); + const [task, setTask] = React.useState(null); const scrollX = React.useRef(new Animated.Value(0)).current; const timerAnimation = React.useRef(new Animated.Value(0)).current; @@ -76,15 +81,41 @@ export default function TimerScreen() { const sessionStartedAtRef = React.useRef(null); const sessionDurationMsRef = React.useRef(0); const cancelAccelStartedRef = React.useRef(false); + const cancelHoldCompletedRef = React.useRef(false); const cancelHoldActiveRef = React.useRef(false); const cancelHoldIdRef = React.useRef(0); const cancelHoldStartedAtRef = React.useRef(0); + const { tId } = useLocalSearchParams<{ tId?: string}>(); + const timerOverlayHeight = Math.max(containerHeight, 1); + const timerOverlayOffscreenY = timerOverlayHeight + 1000; + React.useEffect(() => { if (containerHeight > 0 && !timerIsRunning) { - timerAnimation.setValue(containerHeight); + timerAnimation.setValue(timerOverlayOffscreenY); } - }, [containerHeight, timerIsRunning, timerAnimation]); + }, [containerHeight, timerIsRunning, timerAnimation, timerOverlayOffscreenY]); + + React.useEffect(() => { + if (!tId) { + setTask(null); + return; + } + + const fetchTask = async () => { + const {data, error} = await supabase + .from('tasks') + .select('*') + .eq('tId', tId) + .single() + + + if (!error && data) { + setTask(data); + } + }; + fetchTask(); +}, [tId]) const pressedButtonScale = pressedButtonAnimation.interpolate({ inputRange: [0, 1], @@ -102,11 +133,7 @@ export default function TimerScreen() { 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], @@ -115,7 +142,7 @@ export default function TimerScreen() { const countdownTranslateY = focusModeAnimation.interpolate({ inputRange: [0, 1], - outputRange: [0, -containerHeight * 0.35], + outputRange: [0, -height * 0.35], }); const countdownScale = focusModeAnimation.interpolate({ @@ -201,15 +228,18 @@ export default function TimerScreen() { sessionDurationMsRef.current = 0; cancelHoldActiveRef.current = false; cancelAccelStartedRef.current = false; + cancelHoldCompletedRef.current = false; - timerAnimation.setValue(containerHeight); + timerAnimation.setValue(timerOverlayOffscreenY); cancelOverlayAnimation.setValue(0); + setTimerOverlayVisible(false); setTimeRemaining(0); setIsRunning(false); - }, [cancelOverlayAnimation, containerHeight, timerAnimation]); + }, [cancelOverlayAnimation, timerAnimation, timerOverlayOffscreenY]); const finishTimer = React.useCallback(() => { clearCountdownInterval(); + void RemoveActiveSprint(); Animated.parallel([ Animated.timing(countdownAnimation, { @@ -263,14 +293,14 @@ export default function TimerScreen() { // runs it to the bottom over the remaining session time. const startProgressAnimation = React.useCallback( (fromY: number) => { - const elapsedRatio = fromY / containerHeight; + const elapsedRatio = fromY / timerOverlayHeight; const remainingMs = sessionDurationMsRef.current * (1 - elapsedRatio); sessionStartedAtRef.current = Date.now() - sessionDurationMsRef.current * elapsedRatio; timerAnimation.setValue(fromY); const progressAnimation = Animated.timing(timerAnimation, { - toValue: containerHeight, + toValue: timerOverlayHeight, duration: remainingMs, useNativeDriver: true, }); @@ -286,33 +316,18 @@ export default function TimerScreen() { finishTimer(); }); }, - [containerHeight, finishTimer, timerAnimation] + [finishTimer, timerAnimation, timerOverlayHeight] ); 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, - }), - ]), + buttonAnimation.setValue(1); + cancelButtonAnimation.setValue(1); + countdownAnimation.setValue(1); + timerAnimation.setValue(0); + + startProgressAnimation(0); + + const focusAnimation = Animated.parallel([ Animated.timing(focusModeAnimation, { toValue: 1, duration: 450, @@ -325,15 +340,9 @@ export default function TimerScreen() { }), ]); - runningAnimationRef.current = runningAnimation; - runningAnimation.start(({ finished }) => { + runningAnimationRef.current = focusAnimation; + focusAnimation.start(() => { runningAnimationRef.current = null; - - if (!finished) { - return; - } - - startProgressAnimation(0); }); }, [ buttonAnimation, @@ -346,41 +355,109 @@ export default function TimerScreen() { ]); const startCountdown = React.useCallback( - (totalSeconds: number) => { - setTimeRemaining(totalSeconds); + (endTime: number) => { clearCountdownInterval(); - countdownRef.current = setInterval(() => { - setTimeRemaining((currentTime) => { - if (currentTime <= 1) { - clearCountdownInterval(); - return 0; - } + const updateRemainingTime = () => { + const remainingSeconds = Math.max( + 0, + Math.ceil((endTime - Date.now()) / 1000) + ); - return currentTime - 1; - }); - }, 1000); + setTimeRemaining(remainingSeconds); + + if (remainingSeconds <= 0) { + clearCountdownInterval(); + } + }; + + updateRemainingTime(); + countdownRef.current = setInterval(updateRemainingTime, 1000); }, [clearCountdownInterval] ); + React.useEffect(() => { + if (!tId || timerIsRunning || containerHeight === 0) { + return; + } + + const restoreSprint = async () => { + const activeSprint = await GetActiveSprint(); + + if (!activeSprint || activeSprint.taskId !== tId) { + return; + } + + const remainingMs = activeSprint.endTime - Date.now(); + + if (remainingMs <= 0) { + await RemoveActiveSprint(); + return; + } + + const totalMs = activeSprint.durationSeconds * 1000; + const elapsedMs = totalMs - remainingMs; + const elapsedRatio = Math.max(0, Math.min(elapsedMs / totalMs, 1)); + const restoredY = timerOverlayHeight * elapsedRatio; + + setIsRunning(true); + setTimerOverlayVisible(true); + sessionStartedAtRef.current = Date.now() - elapsedMs; + sessionDurationMsRef.current = totalMs; + + buttonAnimation.setValue(1); + cancelButtonAnimation.setValue(1); + countdownAnimation.setValue(1); + focusModeAnimation.setValue(1); + taskDetailsAnimation.setValue(1); + cancelOverlayAnimation.setValue(0); + + startCountdown(activeSprint.endTime); + startProgressAnimation(restoredY); + }; + + void restoreSprint(); + }, [ + buttonAnimation, + cancelButtonAnimation, + cancelOverlayAnimation, + containerHeight, + countdownAnimation, + focusModeAnimation, + startCountdown, + startProgressAnimation, + taskDetailsAnimation, + tId, + timerOverlayHeight, + timerIsRunning, + ]); + const startTimerSession = React.useCallback(() => { - if (timerIsRunning || containerHeight === 0) { + if (!tId || timerIsRunning || containerHeight === 0) { return; } Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); setIsRunning(true); + setTimerOverlayVisible(true); taskDetailsAnimation.setValue(0); countdownAnimation.setValue(0); cancelOverlayAnimation.setValue(0); const totalSeconds = duration * TIMER_UNIT_IN_SECONDS; + const endTime = Date.now() + totalSeconds * 1000; sessionStartedAtRef.current = Date.now(); sessionDurationMsRef.current = totalSeconds * 1000; - startCountdown(totalSeconds); + void SaveActiveSprint({ + taskId: tId, + durationSeconds: totalSeconds, + endTime, + }); + + startCountdown(endTime); runStartSequence(); }, [ cancelOverlayAnimation, @@ -390,6 +467,7 @@ export default function TimerScreen() { runStartSequence, startCountdown, taskDetailsAnimation, + tId, timerIsRunning, ]); @@ -400,46 +478,50 @@ export default function TimerScreen() { clearCountdownInterval(); clearCancelHoldTimeouts(); - stopRunningAnimations(); + void RemoveActiveSprint(); - Animated.parallel([ - Animated.timing(cancelButtonAnimation, { - toValue: 0, - duration: 180, - useNativeDriver: true, - }), - Animated.timing(taskDetailsAnimation, { - toValue: 0, - duration: 220, - useNativeDriver: true, - }), - Animated.timing(focusModeAnimation, { - toValue: 0, - duration: 250, - useNativeDriver: true, - }), - Animated.timing(countdownAnimation, { - toValue: 0, - duration: 180, - useNativeDriver: true, - }), - Animated.timing(timerAnimation, { - toValue: containerHeight, - duration: 300, - useNativeDriver: true, - }), - Animated.timing(cancelOverlayAnimation, { - toValue: 0, - duration: 120, - useNativeDriver: true, - }), - ]).start(() => { - Animated.timing(buttonAnimation, { - toValue: 0, - duration: 220, - useNativeDriver: true, - }).start(() => { - resetSessionValues(); + runningAnimationRef.current?.stop(); + runningAnimationRef.current = null; + + progressAnimationRef.current?.stop(); + progressAnimationRef.current = null; + + timerAnimation.stopAnimation(() => { + cancelOverlayAnimation.stopAnimation(() => { + timerAnimation.setValue(timerOverlayOffscreenY); + cancelOverlayAnimation.setValue(0); + setTimerOverlayVisible(false); + + Animated.parallel([ + Animated.timing(cancelButtonAnimation, { + toValue: 0, + duration: 180, + useNativeDriver: true, + }), + Animated.timing(taskDetailsAnimation, { + toValue: 0, + duration: 220, + useNativeDriver: true, + }), + Animated.timing(focusModeAnimation, { + toValue: 0, + duration: 250, + useNativeDriver: true, + }), + Animated.timing(countdownAnimation, { + toValue: 0, + duration: 180, + useNativeDriver: true, + }), + ]).start(() => { + Animated.timing(buttonAnimation, { + toValue: 0, + duration: 220, + useNativeDriver: true, + }).start(() => { + resetSessionValues(); + }); + }); }); }); }, [ @@ -448,13 +530,12 @@ export default function TimerScreen() { cancelOverlayAnimation, clearCancelHoldTimeouts, clearCountdownInterval, - containerHeight, countdownAnimation, focusModeAnimation, resetSessionValues, - stopRunningAnimations, taskDetailsAnimation, timerAnimation, + timerOverlayOffscreenY, timerIsRunning, ]); @@ -466,6 +547,7 @@ export default function TimerScreen() { cancelHoldActiveRef.current = true; cancelHoldStartedAtRef.current = Date.now(); cancelAccelStartedRef.current = false; + cancelHoldCompletedRef.current = false; cancelHoldAnimationDelayRef.current = setTimeout(() => { cancelHoldAnimationDelayRef.current = null; @@ -486,8 +568,8 @@ export default function TimerScreen() { const elapsedAtCancelMs = Date.now() + remainingHoldMs - sessionStartedAt; const expectedProgress = elapsedAtCancelMs / sessionDurationMsRef.current; const clampedProgress = Math.max(0, Math.min(expectedProgress, 1)); - const expectedYAtCancel = containerHeight * clampedProgress; - const cancelOffset = Math.max(0, containerHeight - expectedYAtCancel); + const expectedYAtCancel = timerOverlayHeight * clampedProgress; + const cancelOffset = Math.max(0, timerOverlayHeight - expectedYAtCancel); Animated.timing(cancelOverlayAnimation, { toValue: cancelOffset, @@ -501,12 +583,13 @@ export default function TimerScreen() { cancelHoldActiveRef.current = false; cancelHoldIdRef.current += 1; cancelAccelStartedRef.current = false; + cancelHoldCompletedRef.current = true; Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning); cancelTimer(); cancelHoldTimeoutRef.current = null; }, HOLD_TO_CANCEL_MS); - }, [animateButtonPress, cancelOverlayAnimation, cancelTimer, containerHeight]); + }, [animateButtonPress, cancelOverlayAnimation, cancelTimer, timerOverlayHeight]); const handleCancelHoldEnd = React.useCallback(() => { animateButtonPress(false); @@ -515,6 +598,10 @@ export default function TimerScreen() { clearCancelHoldTimeouts(); + if (cancelHoldCompletedRef.current) { + return; + } + if (!cancelAccelStartedRef.current) { return; } @@ -590,13 +677,24 @@ export default function TimerScreen() { }} >