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.json b/app.json index 38def58..deb766f 100644 --- a/app.json +++ b/app.json @@ -1,7 +1,8 @@ { "expo": { - "name": "Study-Sprint", - "slug": "study-sprint", + "name": "Study Sprint", + "slug": "Study-Sprint", + "owner": "ikt205g26v-g18", "version": "1.0.0", "orientation": "portrait", "icon": "./assets/images/icon.png", @@ -49,9 +50,8 @@ "extra": { "router": {}, "eas": { - "projectId": "d9e26d91-0f0c-4b97-b11a-20be2916e9f3" + "projectId": "2b2ec99b-a2ea-4991-8694-93f9e3d042a3" } - }, - "owner": "ikt205g26v-g18" + } } } diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index 5876d15..d9a261e 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -56,14 +56,15 @@ export default function TabLayout() { } if (!session) { - return ; + return ; } return ( - - - - + + diff --git a/app/(tabs)/assignments.tsx b/app/(tabs)/assignments.tsx deleted file mode 100644 index 8fb9f77..0000000 --- a/app/(tabs)/assignments.tsx +++ /dev/null @@ -1,336 +0,0 @@ -import { defaultStyles } from '@/constants/defaultStyles'; -import { CheckSubjectCompletion } from '@/lib/progress'; -import { supabase } from '@/lib/supabase'; -import type { Assignment, Task } from '@/lib/types'; -import { Ionicons } from '@expo/vector-icons'; -import { Session } from '@supabase/supabase-js'; -import { router, Stack, useFocusEffect } from 'expo-router'; -import { useCallback, useEffect, useState } from 'react'; -import { - Alert, - Pressable, - SectionList, - Text, - View, -} from 'react-native'; - -export default function Assignments() { - const [assignments, SetAssignments] = useState([]); - const [tasksByAssignment, SetTasksByAssignment] = useState>({}); - const [session, SetSession] = useState(null); - - const assignmentSections = [ - { - title: 'Upcoming Assignments', - data: assignments.filter((assignment) => !assignment.isCompleted), - emptyMessage: 'No upcoming assignments', - }, - { - title: 'Completed Assignments', - data: assignments.filter((assignment) => assignment.isCompleted), - emptyMessage: 'No completed assignments', - }, - ]; - - useEffect(() => { - supabase.auth - .getSession() - .then(({ data }) => SetSession(data.session ?? null)); - - const { data: sub } = supabase.auth.onAuthStateChange( - (_event, newSession) => { - SetSession(newSession); - } - ); - - return () => sub.subscription.unsubscribe(); - }, []); - - const GetAssignments = async () => { - const { data: assignmentsData, error: assignmentsError } = await supabase - .from('assignments') - .select('*') - .order('deadline', { ascending: false }); - - if (assignmentsError) { - Alert.alert('Assignments could not be fetched, please try again'); - return; - } - - const assignmentRows = assignmentsData ?? []; - SetAssignments(assignmentRows); - - if (assignmentRows.length === 0) { - SetTasksByAssignment({}); - return; - } - - const aIds = assignmentRows.map((assignment) => assignment.aId); - - const { data: tasksData, error: tasksError } = await supabase - .from('tasks') - .select('*') - .in('aId', aIds); - - if (tasksError) { - Alert.alert('Assignment tasks could not be fetched, please try again'); - SetTasksByAssignment({}); - return; - } - - const groupedTasks: Record = {}; - - for (const task of tasksData ?? []) { - if (!groupedTasks[task.aId]) { - groupedTasks[task.aId] = []; - } - groupedTasks[task.aId].push(task); - } - - SetTasksByAssignment(groupedTasks); - }; - - useFocusEffect( - useCallback(() => { - if (session) { - GetAssignments(); - } - }, [session]) - ); - - const DeleteAssignment = async (aId: string, sId: string) => { - Alert.alert( - 'Delete Assignment', - 'Are you sure you want to delete this assignment?', - [ - { - text: 'Cancel', - style: 'cancel', - }, - { - text: 'Delete', - style: 'destructive', - onPress: async () => { - const { error } = await supabase - .from('assignments') - .delete() - .eq('aId', aId); - - if (error) { - Alert.alert('Assignment could not be deleted, please try again'); - return; - } - - Alert.alert('Assignment deleted successfully!'); - - try { - await CheckSubjectCompletion(sId); - } catch { - Alert.alert("Failed to update subject status"); - } - - GetAssignments(); - }, - }, - ] - ); - }; - - return ( - - ( - - - - - - await supabase.auth.signOut()} - > - - Logout - - - - ), - }} - /> - - - - - Assignments - - - Track what is coming up and what you have already finished. - - - - router.push('/assignment/createAssignment')} - > - - Create Assignment - - - - item.aId} - showsVerticalScrollIndicator={false} - stickySectionHeadersEnabled={false} - contentContainerStyle={{ - paddingBottom: 32, - }} - renderSectionHeader={({ section: { title, data } }) => ( - - - {title} - - - - - {data.length} - - - - )} - renderItem={({ item }) => { - const isOwner = session?.user.id === item.uId; - - const assignmentTasks = tasksByAssignment[item.aId] ?? []; - const progress = assignmentTasks.length === 0 ? 0 : Math.round((assignmentTasks.filter(task => task.isCompleted).length / assignmentTasks.length) * 100); - - return ( - - - router.push({ - pathname: '/assignment/viewDetailsAssignment', - params: { aId: item.aId }, - }) - } - > - - - {item.isCompleted && ( - - ✓ - - )} - - - - - {item.title} - - - {item.description ? ( - - {item.description} - - ) : null} - - - - Deadline: {item.deadline || 'No deadline'} - - - - - {progress}% - - - - - - - - - - {isOwner && ( - - - router.push({ - pathname: '/assignment/editAssignment', - params: { aId: item.aId }, - }) - } - > - - Edit - - - - DeleteAssignment(item.aId, item.sId)} - > - - Delete - - - - )} - - ); - }} - renderSectionFooter={({ section }) => - section.data.length === 0 ? ( - - - {section.emptyMessage} - - - New assignments will show up here. - - - ) : ( - - ) - } - /> - - - ); -} \ No newline at end of file diff --git a/app/(tabs)/subjects.tsx b/app/(tabs)/subjects.tsx index f5f5f79..86564b8 100644 --- a/app/(tabs)/subjects.tsx +++ b/app/(tabs)/subjects.tsx @@ -1,40 +1,21 @@ -import { defaultStyles } from '@/constants/defaultStyles'; +import { SUBJECT_COLORS } from '@/lib/subjectColors'; import { supabase } from '@/lib/supabase'; -import type { Assignment, Subject } from '@/lib/types'; -import { Ionicons } from '@expo/vector-icons'; +import { Subject } from '@/lib/types'; import { Session } from '@supabase/supabase-js'; import { router, Stack, useFocusEffect } from 'expo-router'; import { useCallback, useEffect, useState } from 'react'; -import { - Alert, - Pressable, - SectionList, - Text, - View, -} from 'react-native'; +import { Alert, Pressable, ScrollView, Text, View } from 'react-native'; + +import type { SubjectColor } from '@/lib/subjectColors'; export default function Subjects() { const [subjects, SetSubjects] = useState([]); - const [assignmentsBySubject, SetAssignmentsBySubject] = useState>({}); const [session, SetSession] = useState(null); - const subjectSections = [ - { - title: 'Active Subjects', - data: subjects.filter((subject) => subject.isActive), - emptyMessage: 'No active subjects', - }, - { - title: 'Inactive Subjects', - data: subjects.filter((subject) => !subject.isActive), - emptyMessage: 'No inactive subjects', - }, - ]; - useEffect(() => { - supabase.auth - .getSession() - .then(({ data }) => SetSession(data.session ?? null)); + supabase.auth.getSession().then(({ data }) => { + SetSession(data.session ?? null); + }); const { data: sub } = supabase.auth.onAuthStateChange( (_event, newSession) => { @@ -46,47 +27,20 @@ export default function Subjects() { }, []); const GetSubjects = async () => { - const { data: subjectsData, error: subjectsError } = await supabase + if (!session?.user.id) return; + + const { data, error } = await supabase .from('subjects') .select('*') + .eq('uId', session.user.id) .order('lastChanged', { ascending: false }); - if (subjectsError) { + if (error) { Alert.alert('Subjects could not be fetched, please try again'); return; } - const subjectRows = subjectsData ?? []; - SetSubjects(subjectsData ?? []); - - if (subjectRows.length === 0) { - SetAssignmentsBySubject({}); - return; - } - - const sIds = subjectRows.map((subject) => subject.sId); - - const { data: assignmentsData, error: assignmentsError } = await supabase - .from('assignments') - .select('*') - .in('sId', sIds); - - if (assignmentsError) { - Alert.alert('Subject assignments could not be fetched, please try again'); - SetAssignmentsBySubject({}); - return; - } - - const groupedAssignments: Record = {}; - - for (const assignment of assignmentsData ?? []) { - if (!groupedAssignments[assignment.sId]) { - groupedAssignments[assignment.sId] = []; - } - groupedAssignments[assignment.sId].push(assignment); - } - - SetAssignmentsBySubject(groupedAssignments); + SetSubjects((data as Subject[]) ?? []); }; useFocusEffect( @@ -97,232 +51,131 @@ export default function Subjects() { }, [session]) ); - const DeleteSubject = async (sId: string) => { - Alert.alert( - 'Delete Subject', - 'Are you sure you want to delete this subject?', - [ - { - text: 'Cancel', - style: 'cancel', - }, - { - text: 'Delete', - style: 'destructive', - onPress: async () => { - const { error } = await supabase - .from('subjects') - .delete() - .eq('sId', sId); - - if (error) { - Alert.alert('Subject could not be deleted, please try again'); - return; - } - - Alert.alert('Subject deleted successfully!'); - GetSubjects(); - }, - }, - ] - ); - }; - return ( ( - - - - - - await supabase.auth.signOut()} - > - - Logout - - - + await supabase.auth.signOut()} + > + + Logout + + ), }} /> - + - - Subjects - + Subjects - Organize your study work by subject, then break it into assignments - and tasks. + Pick a subject to manage assignments and tasks. + {subjects.length === 0 ? ( + + + No subjects yet + + + Create your first subject to get started. + + + ) : ( + + {subjects.map((subject) => { + const colorKey: SubjectColor = subject.color ?? 'slate'; + const colorSet = SUBJECT_COLORS[colorKey]; + + const firstLetter = + subject.title?.trim().charAt(0).toUpperCase() || '?'; + + return ( + + router.push({ + pathname: '/subject/viewDetailsSubject', + params: { sId: subject.sId }, + }) + } + > + + + + {firstLetter} + + + + + + {subject.title} + + + + {subject.description || 'No description added.'} + + + + + + + {subject.isActive ? 'Active' : 'Inactive'} + + + + + + ); + })} + + )} + router.push('/subject/createSubject')} + className="mt-2 h-14 items-center justify-center rounded-2xl bg-accent" + onPress={() => router.push('/subject/upsertSubject')} > Create Subject - - item.sId} - showsVerticalScrollIndicator={false} - stickySectionHeadersEnabled={false} - contentContainerStyle={{ - paddingBottom: 32, - }} - renderSectionHeader={({ section: { title, data } }) => ( - - - {title} - - - - - {data.length} - - - - )} - renderItem={({ item }) => { - const isOwner = session?.user.id === item.uId; - - const subjectAssignments = assignmentsBySubject[item.sId] ?? []; - const progress = subjectAssignments.length === 0 ? 0 : Math.round((subjectAssignments.filter(assignment => assignment.isCompleted).length / subjectAssignments.length) * 100); - - return ( - - - router.push({ - pathname: '/subject/viewDetailsSubject', - params: { sId: item.sId }, - }) - } - > - - - {item.isActive && ( - - ✓ - - )} - - - - - {item.title} - - - {item.description ? ( - - {item.description} - - ) : null} - - - - {item.isActive ? 'Active' : 'Inactive'} - - - - {progress}% - - - - - - - - - - {isOwner && ( - - - router.push({ - pathname: '/subject/editSubject', - params: { sId: item.sId }, - }) - } - > - - Edit - - - - DeleteSubject(item.sId)} - > - - Delete - - - - )} - - ); - }} - renderSectionFooter={({ section }) => - section.data.length === 0 ? ( - - - {section.emptyMessage} - - - Subjects you create will show up here. - - - ) : ( - - ) - } - /> - + ); } \ No newline at end of file diff --git a/app/(tabs)/tasks.tsx b/app/(tabs)/tasks.tsx deleted file mode 100644 index 7e08755..0000000 --- a/app/(tabs)/tasks.tsx +++ /dev/null @@ -1,277 +0,0 @@ -import { defaultStyles } from '@/constants/defaultStyles'; -import { CheckAssignmentCompletion } from '@/lib/progress'; -import { supabase } from '@/lib/supabase'; -import type { Task } from '@/lib/types'; -import { Ionicons } from '@expo/vector-icons'; -import { Session } from '@supabase/supabase-js'; -import { router, Stack, useFocusEffect } from 'expo-router'; -import { useCallback, useEffect, useState } from 'react'; -import { - Alert, - Pressable, - SectionList, - Text, - View, -} from 'react-native'; - -export default function Tasks() { - const [tasks, SetTasks] = useState([]); - const [session, SetSession] = useState(null); - - const taskSections = [ - { - title: 'Upcoming Tasks', - data: tasks.filter((task) => !task.isCompleted), - emptyMessage: 'No upcoming tasks', - }, - { - title: 'Completed Tasks', - data: tasks.filter((task) => task.isCompleted), - emptyMessage: 'No completed tasks', - }, - ]; - - useEffect(() => { - supabase.auth - .getSession() - .then(({ data }) => SetSession(data.session ?? null)); - - const { data: sub } = supabase.auth.onAuthStateChange( - (_event, newSession) => { - SetSession(newSession); - } - ); - - return () => sub.subscription.unsubscribe(); - }, []); - - const GetTasks = async () => { - const { data, error } = await supabase.from('tasks').select('*'); - - if (error) { - Alert.alert('Tasks could not be fetched, please try again'); - return; - } - - SetTasks(data ?? []); - }; - - useFocusEffect( - useCallback(() => { - if (session) { - GetTasks(); - } - }, [session]) - ); - - const DeleteTask = async (tId: string, aId: string) => { - Alert.alert( - 'Delete Task', - 'Are you sure you want to delete this task?', - [ - { - text: 'Cancel', - style: 'cancel', - }, - { - text: 'Delete', - style: 'destructive', - onPress: async () => { - const { error } = await supabase - .from('tasks') - .delete() - .eq('tId', tId); - - if (error) { - Alert.alert('Task could not be deleted, please try again'); - return; - } - - Alert.alert('Task deleted successfully!'); - - try { - await CheckAssignmentCompletion(aId); - } catch { - Alert.alert("Failed to update assignment completion state"); - } - - GetTasks(); - }, - }, - ] - ); - }; - - return ( - - ( - - - - - - await supabase.auth.signOut()} - > - - Logout - - - - ), - }} - /> - - - - - Tasks - - - Break assignments into small steps and keep your progress clear. - - - - router.push('/task/createTask')} - > - - Create Task - - - - item.tId} - showsVerticalScrollIndicator={false} - stickySectionHeadersEnabled={false} - contentContainerStyle={{ - paddingBottom: 32, - }} - renderSectionHeader={({ section: { title, data } }) => ( - - - {title} - - - - - {data.length} - - - - )} - renderItem={({ item }) => { - const isOwner = session?.user.id === item.uId; - - return ( - - - router.push({ - pathname: '/task/viewDetailsTask', - params: { tId: item.tId }, - }) - } - > - - - {item.isCompleted && ( - - ✓ - - )} - - - - - {item.title} - - - {item.description ? ( - - {item.description} - - ) : null} - - - - {item.isCompleted ? 'Completed' : 'In progress'} - - - - - - - {isOwner && ( - - - router.push({ - pathname: '/task/editTask', - params: { tId: item.tId }, - }) - } - > - - Edit - - - - DeleteTask(item.tId, item.aId)} - > - - Delete - - - - )} - - ); - }} - renderSectionFooter={({ section }) => - section.data.length === 0 ? ( - - - {section.emptyMessage} - - - Tasks for this assignment will show up here. - - - ) : ( - - ) - } - /> - - - ); -} \ No newline at end of file diff --git a/app/(tabs)/timer.tsx b/app/(tabs)/timer.tsx index eacca7d..89d3f69 100644 --- a/app/(tabs)/timer.tsx +++ b/app/(tabs)/timer.tsx @@ -1,12 +1,16 @@ +import * as Haptics from 'expo-haptics'; import * as React from 'react'; import { Animated, Dimensions, + Easing, StatusBar, StyleSheet, + Text, TouchableOpacity, - View + View, } from 'react-native'; + const { width, height } = Dimensions.get('window'); const colors = { @@ -15,147 +19,689 @@ 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; +const HOLD_TO_CANCEL_MS = 2000; +const CANCEL_ANIMATION_DELAY_MS = 250; +const BUTTON_PRESS_IN_MS = 80; +const BUTTON_PRESS_OUT_MS = 140; -/* -Har bare skrevet timeren som en egen tab til å begynne med. -Planen er at når bruker starter en task så vil de få opp denne timeren -som viser TaskName og Description der tallene står nå -Kanskje en animert figur hvis vi får tid -*/ -export default function App() { - const scrollX = React.useRef(new Animated.Value(0)).current; - const [duration, setDuration] = React.useState(timers[0]) - const timerAnimation = React.useRef(new Animated.Value(height)).current - const buttonAnimation = React.useRef(new Animated.Value(0)).current - const animation = React.useCallback(() => { - Animated.sequence([ - Animated.timing(buttonAnimation, { - toValue: 1, - duration: 300, - useNativeDriver: true - }), - Animated.timing(timerAnimation, { - toValue: 0, - duration: 300, - useNativeDriver: true - }), - Animated.timing(timerAnimation, { - toValue: height, - duration: duration * 1000, - useNativeDriver: true - }), - ]) .start(() => { - Animated.timing(buttonAnimation, { - toValue: 0, - duration: 300, - useNativeDriver: true - }).start() - }) - }, [duration]) +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; + + return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '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 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 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]); + + const pressedButtonScale = pressedButtonAnimation.interpolate({ + inputRange: [0, 1], + outputRange: [1, 0.9], + }); + + const cancelButtonTranslateY = cancelButtonAnimation.interpolate({ + inputRange: [0, 1], + 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 + ).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.3], + }); + + 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 pickerOpacity = 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 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, + }), + 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(); + }); + }); + }, [ + buttonAnimation, + cancelButtonAnimation, + cancelOverlayAnimation, + clearCancelHoldTimeouts, + clearCountdownInterval, + containerHeight, + countdownAnimation, + focusModeAnimation, + resetSessionValues, + stopRunningAnimations, + taskDetailsAnimation, + timerAnimation, + timerIsRunning, + ]); + + const handleCancelHoldStart = 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; + } + + // 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); + + const elapsedHoldMs = Date.now() - cancelHoldStartedAtRef.current; + const remainingHoldMs = Math.max(1, HOLD_TO_CANCEL_MS - elapsedHoldMs); + const sessionStartedAt = sessionStartedAtRef.current ?? Date.now(); + 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); + + Animated.timing(cancelOverlayAnimation, { + toValue: cancelOffset, + duration: remainingHoldMs, + easing: Easing.in(Easing.quad), + useNativeDriver: true, + }).start(); + }, 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 handleCancelHoldEnd = React.useCallback(() => { + animateButtonPress(false); + cancelHoldActiveRef.current = false; + cancelHoldIdRef.current += 1; + + clearCancelHoldTimeouts(); + + if (!cancelAccelStartedRef.current) { + return; + } + + cancelAccelStartedRef.current = false; + cancelOverlayAnimation.stopAnimation((currentOffset) => { + cancelOverlayAnimation.setValue(currentOffset); + Animated.timing(cancelOverlayAnimation, { + toValue: 0, + duration: 750, + easing: Easing.in(Easing.bounce), + useNativeDriver: true, + }).start(); + }); + }, [animateButtonPress, cancelOverlayAnimation, clearCancelHoldTimeouts]); + + const handleTimerPickerMomentumEnd = React.useCallback( + (event: { nativeEvent: { contentOffset: { x: number } } }) => { + if (timerIsRunning) { + return; + } + + const index = Math.round(event.nativeEvent.contentOffset.x / ITEM_SIZE); + const clampedIndex = Math.max(0, Math.min(index, TIMER_OPTIONS.length - 1)); + setDuration(TIMER_OPTIONS[clampedIndex]); + }, + [timerIsRunning] + ); + + const renderTimerItem = React.useCallback( + ({ item, index }: { item: number; index: number }) => { + const inputRange = [ + (index - 1) * ITEM_SIZE, + index * ITEM_SIZE, + (index + 1) * ITEM_SIZE, + ]; + + const baseOpacity = scrollX.interpolate({ + inputRange, + outputRange: [0.4, 1, 0.4], + }); + + const opacity = Animated.multiply(baseOpacity, pickerOpacity); + const scale = scrollX.interpolate({ + inputRange, + outputRange: [0.7, 1, 0.7], + }); + + return ( + + + {item} + + + ); + }, + [pickerOpacity, scrollX] + ); - const opacity = buttonAnimation.interpolate({ - inputRange: [0, 1], - outputRange: [1, 0] - }) - const translateY = buttonAnimation.interpolate({ - inputRange: [0, 1], - outputRange: [0, 200] - }) - return ( - + { + setContainerHeight(event.nativeEvent.layout.height); + }} + > + + {placeholderTask.name} + {placeholderTask.description} + ); } @@ -165,16 +711,98 @@ const styles = StyleSheet.create({ flex: 1, backgroundColor: colors.black, }, + timerOverlay: { + backgroundColor: colors.red, + }, + startButtonContainer: { + justifyContent: 'flex-end', + alignItems: 'center', + paddingBottom: 100, + }, roundButton: { width: 80, height: 80, borderRadius: 80, - backgroundColor: colors.red, + backgroundColor: '#beb9a7', + alignItems: 'center', + justifyContent: 'center', + }, + timerPickerWrapper: { + position: 'absolute', + left: 0, + right: 0, + flex: 1, + }, + timerPickerList: { + flexGrow: 0, + }, + timerPickerContent: { + paddingHorizontal: ITEM_SPACING, + }, + timerOptionItem: { + width: ITEM_SIZE, + justifyContent: 'center', + alignItems: 'center', }, text: { fontSize: ITEM_SIZE * 0.8, fontFamily: 'Menlo', color: colors.text, fontWeight: '900', - } -}); \ No newline at end of file + }, + taskDetails: { + position: 'absolute', + top: height * 0.34, + left: 32, + right: 32, + alignItems: 'center', + }, + taskName: { + color: colors.text, + fontSize: 32, + fontWeight: '800', + textAlign: 'center', + }, + taskDescription: { + color: colors.text, + fontSize: 24, + lineHeight: 32, + marginTop: 20, + textAlign: 'center', + }, + countdownText: { + fontSize: ITEM_SIZE * 0.32, + fontFamily: 'Menlo', + color: colors.text, + fontWeight: '900', + textAlign: 'center', + }, + cancelButtonContainer: { + position: 'absolute', + left: 0, + right: 0, + bottom: 44, + alignItems: 'center', + zIndex: 2, + }, + cancelButton: { + minWidth: 112, + height: 44, + borderRadius: 22, + borderWidth: 1, + borderColor: 'rgba(155, 155, 155, 0.35)', + backgroundColor: '#beb9a7', + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: 22, + position: 'relative', + overflow: 'hidden', + }, + countdownOverlay: { + position: 'absolute', + top: height / 3, + left: 0, + right: 0, + alignItems: 'center', + }, +}); diff --git a/app/assignment/_layout.tsx b/app/assignment/_layout.tsx index 380a294..7fb96ef 100644 --- a/app/assignment/_layout.tsx +++ b/app/assignment/_layout.tsx @@ -3,8 +3,7 @@ import { Stack } from "expo-router"; export default function AssignmentLayout() { return ( - - + ); diff --git a/app/assignment/editAssignment.tsx b/app/assignment/editAssignment.tsx deleted file mode 100644 index 562033e..0000000 --- a/app/assignment/editAssignment.tsx +++ /dev/null @@ -1,193 +0,0 @@ -import { defaultStyles } from '@/constants/defaultStyles'; -import { GetAssignmentNotificationId, RemoveAssignmentNotificationId, SaveAssignmentNotificationId } from '@/lib/asyncStorage'; -import { CheckSubjectCompletion } from '@/lib/progress'; -import { supabase } from '@/lib/supabase'; -import type { Assignment } from '@/lib/types'; -import * as Notifications from 'expo-notifications'; -import { router, Stack, useFocusEffect, useLocalSearchParams } from 'expo-router'; -import { useCallback, useState } from 'react'; -import { ActivityIndicator, Alert, Button, Keyboard, KeyboardAvoidingView, Platform, Pressable, Text, TextInput, TouchableWithoutFeedback, View } from 'react-native'; - -export default function EditAssignment() { - const { aId } = useLocalSearchParams<{ aId: string }>(); - const [assignment, SetAssignment] = useState(null) - const [isSaving, SetIsSaving] = useState(false); - - const ScheduleDeadlineReminder = async (aId: string, title: string, deadline: string) => { - const dl = new Date(deadline); - - if (isNaN(dl.getTime())) return null; - - const deadlineReminder = new Date(dl.getTime() - 24 * 60 * 60 * 1000); - - if (deadlineReminder <= new Date()) return null; - - const nId = await Notifications.scheduleNotificationAsync({ - content: { - title: 'Assignment deadline coming up', - body: `${title} is due in 24 hours.`, - data: { aId }, - }, - trigger: { - type: Notifications.SchedulableTriggerInputTypes.DATE, - date: deadlineReminder, - }, - }); - - return nId; - } - - const CancelDeadlineReminder = async (aId: string) => { - const nId = await GetAssignmentNotificationId(aId); - - if (!nId) return; - - await Notifications.cancelScheduledNotificationAsync(nId); - await RemoveAssignmentNotificationId(aId); - } - - const GetAssignment = async (aId: string) => { - const { data, error } = await supabase.from("assignments").select("*").eq("aId", aId).single(); - - if (error) { - Alert.alert("Assignment could not be fetched, please try again"); - return; - } - - SetAssignment(data ?? null); - } - - useFocusEffect( - useCallback(() => { - if (aId) { - GetAssignment(aId); - } - }, [aId]) - ); - - const EditAssignment = async () => { - if (!assignment) return; - - if(assignment.title.trim() === '' || assignment.deadline.trim() === '') { - Alert.alert("Title and deadline are required!"); - return; - } - - const { data: userData, error: userError } = await supabase.auth.getUser(); - - if(userError || !userData.user) { - router.replace("../createUser"); - return; - } - - SetIsSaving(true); - - const { data: assignmentData, error: dbError } = await supabase.from("assignments").update({ - title: assignment.title, - description: assignment.description, - deadline: assignment.deadline, - isCompleted: assignment.isCompleted, - lastChanged: new Date().toISOString(), - uId: userData.user.id, - sId: assignment.sId, - }) - .eq("aId", aId) - .select() - .single(); - - SetIsSaving(false); - - if (dbError) { - Alert.alert("Assignment could not be edited, please try again"); - return; - } - - Alert.alert("Assignment successfully edited!"); - - if (assignmentData) { - await CancelDeadlineReminder(assignmentData.aId); - - if (!assignmentData.isCompleted) { - const nId = await ScheduleDeadlineReminder(assignmentData.aId, assignmentData.title, assignmentData.deadline); - - if (nId) { - await SaveAssignmentNotificationId(assignmentData.aId, nId); - } - } - } - - if (assignmentData.sId) { - try { - await CheckSubjectCompletion(assignmentData.sId); - } catch { - Alert.alert("Failed to update subject status"); - } - } - - router.back(); - } - - return ( - - - - {!assignment && ( - - Assignment not found - - )} - - {assignment && ( - - Edit Assignment - - - - SetAssignment(prev => prev ? { ...prev, title: text } : prev)} - /> - SetAssignment(prev => prev ? { ...prev, description: text } : prev)} - /> - SetAssignment(prev => prev ? { ...prev, deadline: text } : prev)} - /> - SetAssignment(prev => prev ? { ...prev, isCompleted: !prev.isCompleted } : prev)} - style={defaultStyles.checkboxContainer} - > - - {assignment.isCompleted && } - - {assignment.isCompleted ? 'Completed' : 'Not Completed'} - - -