Merge branch 'main' of github.com:Fhj0607/Study-Sprint

This commit is contained in:
Teodor
2026-04-30 17:16:33 +02:00
39 changed files with 3937 additions and 2364 deletions

1
.gitignore vendored
View File

@@ -193,3 +193,4 @@ google-services.json
# ---------------------------
*.orig.*
app-example
newDeps/

View File

@@ -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"
}
}

View File

@@ -56,14 +56,15 @@ export default function TabLayout() {
}
if (!session) {
return <Redirect href="/createUser" />;
return <Redirect href="/login" />;
}
return (
<Tabs>
<Tabs.Screen name="index" options={{title: "Index"}} />
<Tabs.Screen name="tasks" options={{title: "Tasks"}} />
<Tabs.Screen name="assignments" options={{title: "Assignments"}} />
<Tabs
screenOptions={{
headerShown: true,
}}>
<Tabs.Screen name="index" options={{title: 'Dashboard', tabBarLabel: 'Dashboard', }} />
<Tabs.Screen name="subjects" options={{title: "Subjects"}} />
<Tabs.Screen name="timer" options={{title: "Timer"}} />
</Tabs>

View File

@@ -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<Assignment[]>([]);
const [tasksByAssignment, SetTasksByAssignment] = useState<Record<string, Task[]>>({});
const [session, SetSession] = useState<Session | null>(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<string, Task[]> = {};
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 (
<View className="flex-1 bg-app-bg">
<Stack.Screen
options={{
title: 'Assignments',
headerTitleStyle: defaultStyles.title,
headerRight: () => (
<View className="flex-row items-center">
<Pressable
className="mr-3 h-10 w-10 items-center justify-center rounded-full border border-app-border bg-app-surface"
onPress={GetAssignments}
>
<Ionicons name="refresh" size={20} color="#333" />
</Pressable>
<Pressable
className="rounded-full bg-app-subtle px-4 py-2"
onPress={async () => await supabase.auth.signOut()}
>
<Text className="text-sm font-semibold text-text-secondary">
Logout
</Text>
</Pressable>
</View>
),
}}
/>
<View className="flex-1 px-5 pt-5">
<View className="mb-6">
<Text className="text-3xl font-bold text-text-main">
Assignments
</Text>
<Text className="mt-2 text-base leading-6 text-text-secondary">
Track what is coming up and what you have already finished.
</Text>
</View>
<Pressable
className="mb-6 h-14 items-center justify-center rounded-2xl bg-accent"
onPress={() => router.push('/assignment/createAssignment')}
>
<Text className="text-base font-bold text-text-inverse">
Create Assignment
</Text>
</Pressable>
<SectionList
sections={assignmentSections}
keyExtractor={(item) => item.aId}
showsVerticalScrollIndicator={false}
stickySectionHeadersEnabled={false}
contentContainerStyle={{
paddingBottom: 32,
}}
renderSectionHeader={({ section: { title, data } }) => (
<View className="mb-3 mt-2 flex-row items-center justify-between">
<Text className="text-lg font-bold text-text-main">
{title}
</Text>
<View className="rounded-full bg-app-subtle px-3 py-1">
<Text className="text-xs font-semibold text-text-muted">
{data.length}
</Text>
</View>
</View>
)}
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 (
<View className="mb-4 rounded-3xl border border-app-border bg-app-surface p-4 shadow-sm">
<Pressable
onPress={() =>
router.push({
pathname: '/assignment/viewDetailsAssignment',
params: { aId: item.aId },
})
}
>
<View className="flex-row items-start">
<View
className={`mr-3 mt-1 h-6 w-6 items-center justify-center rounded-md border-2 ${
item.isCompleted
? 'border-accent bg-accent'
: 'border-app-border bg-app-subtle'
}`}
>
{item.isCompleted && (
<Text className="text-sm font-bold text-text-inverse">
</Text>
)}
</View>
<View className="flex-1">
<Text
className={`text-base font-bold ${
item.isCompleted
? 'text-text-secondary'
: 'text-text-main'
}`}
>
{item.title}
</Text>
{item.description ? (
<Text
className="mt-1 text-sm leading-5 text-text-muted"
numberOfLines={2}
>
{item.description}
</Text>
) : null}
<View className="mt-3 self-start rounded-full bg-app-subtle px-3 py-1">
<Text className="text-xs font-semibold text-text-secondary">
Deadline: {item.deadline || 'No deadline'}
</Text>
</View>
<View style={{ marginTop: 10 }}>
<Text style={{ marginBottom: 4 }}>{progress}%</Text>
<View
style={{
width: "100%",
height: 12,
backgroundColor: "#D9D9D9",
borderRadius: 999,
overflow: "hidden",
}}
>
<View
style={{
width: `${progress}%`,
height: "100%",
backgroundColor: "#4CAF50",
}}
/>
</View>
</View>
</View>
</View>
</Pressable>
{isOwner && (
<View className="mt-4 flex-row border-t border-app-border pt-4">
<Pressable
className="mr-3 flex-1 items-center justify-center rounded-2xl border border-app-border bg-app-subtle py-3"
onPress={() =>
router.push({
pathname: '/assignment/editAssignment',
params: { aId: item.aId },
})
}
>
<Text className="text-sm font-bold text-text-secondary">
Edit
</Text>
</Pressable>
<Pressable
className="flex-1 items-center justify-center rounded-2xl border border-app-border bg-app-surface py-3"
onPress={() => DeleteAssignment(item.aId, item.sId)}
>
<Text className="text-sm font-bold text-status-danger">
Delete
</Text>
</Pressable>
</View>
)}
</View>
);
}}
renderSectionFooter={({ section }) =>
section.data.length === 0 ? (
<View className="mb-6 rounded-3xl border border-app-border bg-app-surface p-5">
<Text className="text-center text-base font-semibold text-text-secondary">
{section.emptyMessage}
</Text>
<Text className="mt-1 text-center text-sm text-text-muted">
New assignments will show up here.
</Text>
</View>
) : (
<View className="mb-2" />
)
}
/>
</View>
</View>
);
}

View File

@@ -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<Subject[]>([]);
const [assignmentsBySubject, SetAssignmentsBySubject] = useState<Record<string, Assignment[]>>({});
const [session, SetSession] = useState<Session | null>(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<string, Assignment[]> = {};
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,52 +51,12 @@ 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 (
<View className="flex-1 bg-app-bg">
<Stack.Screen
options={{
title: 'Subjects',
headerTitleStyle: defaultStyles.title,
headerRight: () => (
<View className="flex-row items-center">
<Pressable
className="mr-3 h-10 w-10 items-center justify-center rounded-full border border-app-border bg-app-surface"
onPress={GetSubjects}
>
<Ionicons name="refresh" size={20} color="#333" />
</Pressable>
<Pressable
className="rounded-full bg-app-subtle px-4 py-2"
onPress={async () => await supabase.auth.signOut()}
@@ -151,178 +65,117 @@ export default function Subjects() {
Logout
</Text>
</Pressable>
</View>
),
}}
/>
<View className="flex-1 px-5 pt-5">
<ScrollView
className="flex-1"
contentContainerStyle={{
paddingHorizontal: 20,
paddingTop: 20,
paddingBottom: 32,
}}
showsVerticalScrollIndicator={false}
>
<View className="mb-6">
<Text className="text-3xl font-bold text-text-main">
Subjects
</Text>
<Text className="text-3xl font-bold text-text-main">Subjects</Text>
<Text className="mt-2 text-base leading-6 text-text-secondary">
Organize your study work by subject, then break it into assignments
and tasks.
Pick a subject to manage assignments and tasks.
</Text>
</View>
{subjects.length === 0 ? (
<View className="rounded-3xl border border-app-border bg-app-surface p-5">
<Text className="text-center text-base font-semibold text-text-secondary">
No subjects yet
</Text>
<Text className="mt-1 text-center text-sm text-text-muted">
Create your first subject to get started.
</Text>
</View>
) : (
<View>
{subjects.map((subject) => {
const colorKey: SubjectColor = subject.color ?? 'slate';
const colorSet = SUBJECT_COLORS[colorKey];
const firstLetter =
subject.title?.trim().charAt(0).toUpperCase() || '?';
return (
<Pressable
className="mb-6 h-14 items-center justify-center rounded-2xl bg-accent"
onPress={() => router.push('/subject/createSubject')}
key={subject.sId}
className="mb-4 rounded-3xl bg-app-surface p-4"
style={{
borderWidth: 1,
borderColor: colorSet.strong,
}}
onPress={() =>
router.push({
pathname: '/subject/viewDetailsSubject',
params: { sId: subject.sId },
})
}
>
<View className="flex-row items-center">
<View
className="mr-3 h-12 w-12 items-center justify-center rounded-2xl"
style={{ backgroundColor: colorSet.soft }}
>
<Text
className="text-base font-bold"
style={{ color: colorSet.strong }}
>
{firstLetter}
</Text>
</View>
<View className="flex-1">
<Text
className="text-base font-bold text-text-main"
numberOfLines={1}
>
{subject.title}
</Text>
<Text
className="mt-1 text-sm leading-5 text-text-secondary"
numberOfLines={2}
>
{subject.description || 'No description added.'}
</Text>
</View>
<View className="ml-3">
<View
className="rounded-full px-3 py-1"
style={{ backgroundColor: colorSet.soft }}
>
<Text
className="text-xs font-semibold"
style={{ color: colorSet.strong }}
>
{subject.isActive ? 'Active' : 'Inactive'}
</Text>
</View>
</View>
</View>
</Pressable>
);
})}
</View>
)}
<Pressable
className="mt-2 h-14 items-center justify-center rounded-2xl bg-accent"
onPress={() => router.push('/subject/upsertSubject')}
>
<Text className="text-base font-bold text-text-inverse">
Create Subject
</Text>
</Pressable>
<SectionList
sections={subjectSections}
keyExtractor={(item) => item.sId}
showsVerticalScrollIndicator={false}
stickySectionHeadersEnabled={false}
contentContainerStyle={{
paddingBottom: 32,
}}
renderSectionHeader={({ section: { title, data } }) => (
<View className="mb-3 mt-2 flex-row items-center justify-between">
<Text className="text-lg font-bold text-text-main">
{title}
</Text>
<View className="rounded-full bg-app-subtle px-3 py-1">
<Text className="text-xs font-semibold text-text-muted">
{data.length}
</Text>
</View>
</View>
)}
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 (
<View className="mb-4 rounded-3xl border border-app-border bg-app-surface p-4 shadow-sm">
<Pressable
onPress={() =>
router.push({
pathname: '/subject/viewDetailsSubject',
params: { sId: item.sId },
})
}
>
<View className="flex-row items-start">
<View
className={`mr-3 mt-1 h-6 w-6 items-center justify-center rounded-md border-2 ${
item.isActive
? 'border-accent bg-accent'
: 'border-app-border bg-app-subtle'
}`}
>
{item.isActive && (
<Text className="text-sm font-bold text-text-inverse">
</Text>
)}
</View>
<View className="flex-1">
<Text
className={`text-base font-bold ${
item.isActive
? 'text-text-main'
: 'text-text-secondary'
}`}
>
{item.title}
</Text>
{item.description ? (
<Text
className="mt-1 text-sm leading-5 text-text-muted"
numberOfLines={2}
>
{item.description}
</Text>
) : null}
<View className="mt-3 self-start rounded-full bg-app-subtle px-3 py-1">
<Text className="text-xs font-semibold text-text-secondary">
{item.isActive ? 'Active' : 'Inactive'}
</Text>
</View>
<View style={{ marginTop: 10 }}>
<Text style={{ marginBottom: 4 }}>{progress}%</Text>
<View
style={{
width: "100%",
height: 12,
backgroundColor: "#D9D9D9",
borderRadius: 999,
overflow: "hidden",
}}
>
<View
style={{
width: `${progress}%`,
height: "100%",
backgroundColor: "#4CAF50",
}}
/>
</View>
</View>
</View>
</View>
</Pressable>
{isOwner && (
<View className="mt-4 flex-row border-t border-app-border pt-4">
<Pressable
className="mr-3 flex-1 items-center justify-center rounded-2xl border border-app-border bg-app-subtle py-3"
onPress={() =>
router.push({
pathname: '/subject/editSubject',
params: { sId: item.sId },
})
}
>
<Text className="text-sm font-bold text-text-secondary">
Edit
</Text>
</Pressable>
<Pressable
className="flex-1 items-center justify-center rounded-2xl border border-app-border bg-app-surface py-3"
onPress={() => DeleteSubject(item.sId)}
>
<Text className="text-sm font-bold text-status-danger">
Delete
</Text>
</Pressable>
</View>
)}
</View>
);
}}
renderSectionFooter={({ section }) =>
section.data.length === 0 ? (
<View className="mb-6 rounded-3xl border border-app-border bg-app-surface p-5">
<Text className="text-center text-base font-semibold text-text-secondary">
{section.emptyMessage}
</Text>
<Text className="mt-1 text-center text-sm text-text-muted">
Subjects you create will show up here.
</Text>
</View>
) : (
<View className="mb-2" />
)
}
/>
</View>
</ScrollView>
</View>
);
}

View File

@@ -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<Task[]>([]);
const [session, SetSession] = useState<Session | null>(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 (
<View className="flex-1 bg-app-bg">
<Stack.Screen
options={{
title: 'Tasks',
headerTitleStyle: defaultStyles.title,
headerRight: () => (
<View className="flex-row items-center">
<Pressable
className="mr-3 h-10 w-10 items-center justify-center rounded-full border border-app-border bg-app-surface"
onPress={GetTasks}
>
<Ionicons name="refresh" size={20} color="#333" />
</Pressable>
<Pressable
className="rounded-full bg-app-subtle px-4 py-2"
onPress={async () => await supabase.auth.signOut()}
>
<Text className="text-sm font-semibold text-text-secondary">
Logout
</Text>
</Pressable>
</View>
),
}}
/>
<View className="flex-1 px-5 pt-5">
<View className="mb-6">
<Text className="text-3xl font-bold text-text-main">
Tasks
</Text>
<Text className="mt-2 text-base leading-6 text-text-secondary">
Break assignments into small steps and keep your progress clear.
</Text>
</View>
<Pressable
className="mb-6 h-14 items-center justify-center rounded-2xl bg-accent"
onPress={() => router.push('/task/createTask')}
>
<Text className="text-base font-bold text-text-inverse">
Create Task
</Text>
</Pressable>
<SectionList
sections={taskSections}
keyExtractor={(item) => item.tId}
showsVerticalScrollIndicator={false}
stickySectionHeadersEnabled={false}
contentContainerStyle={{
paddingBottom: 32,
}}
renderSectionHeader={({ section: { title, data } }) => (
<View className="mb-3 mt-2 flex-row items-center justify-between">
<Text className="text-lg font-bold text-text-main">
{title}
</Text>
<View className="rounded-full bg-app-subtle px-3 py-1">
<Text className="text-xs font-semibold text-text-muted">
{data.length}
</Text>
</View>
</View>
)}
renderItem={({ item }) => {
const isOwner = session?.user.id === item.uId;
return (
<View className="mb-4 rounded-3xl border border-app-border bg-app-surface p-4 shadow-sm">
<Pressable
onPress={() =>
router.push({
pathname: '/task/viewDetailsTask',
params: { tId: item.tId },
})
}
>
<View className="flex-row items-start">
<View
className={`mr-3 mt-1 h-6 w-6 items-center justify-center rounded-md border-2 ${
item.isCompleted
? 'border-accent bg-accent'
: 'border-app-border bg-app-subtle'
}`}
>
{item.isCompleted && (
<Text className="text-sm font-bold text-text-inverse">
</Text>
)}
</View>
<View className="flex-1">
<Text
className={`text-base font-bold ${
item.isCompleted
? 'text-text-secondary'
: 'text-text-main'
}`}
>
{item.title}
</Text>
{item.description ? (
<Text
className="mt-1 text-sm leading-5 text-text-muted"
numberOfLines={2}
>
{item.description}
</Text>
) : null}
<View className="mt-3 self-start rounded-full bg-app-subtle px-3 py-1">
<Text className="text-xs font-semibold text-text-secondary">
{item.isCompleted ? 'Completed' : 'In progress'}
</Text>
</View>
</View>
</View>
</Pressable>
{isOwner && (
<View className="mt-4 flex-row border-t border-app-border pt-4">
<Pressable
className="mr-3 flex-1 items-center justify-center rounded-2xl border border-app-border bg-app-subtle py-3"
onPress={() =>
router.push({
pathname: '/task/editTask',
params: { tId: item.tId },
})
}
>
<Text className="text-sm font-bold text-text-secondary">
Edit
</Text>
</Pressable>
<Pressable
className="flex-1 items-center justify-center rounded-2xl border border-app-border bg-app-surface py-3"
onPress={() => DeleteTask(item.tId, item.aId)}
>
<Text className="text-sm font-bold text-status-danger">
Delete
</Text>
</Pressable>
</View>
)}
</View>
);
}}
renderSectionFooter={({ section }) =>
section.data.length === 0 ? (
<View className="mb-6 rounded-3xl border border-app-border bg-app-surface p-5">
<Text className="text-center text-base font-semibold text-text-secondary">
{section.emptyMessage}
</Text>
<Text className="mt-1 text-center text-sm text-text-muted">
Tasks for this assignment will show up here.
</Text>
</View>
) : (
<View className="mb-2" />
)
}
/>
</View>
</View>
);
}

View File

@@ -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;
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);
/*
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([
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<ReturnType<typeof setInterval> | null>(null);
const cancelHoldTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const cancelHoldAnimationDelayRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const runningAnimationRef = React.useRef<Animated.CompositeAnimation | null>(null);
const progressAnimationRef = React.useRef<Animated.CompositeAnimation | null>(null);
const sessionStartedAtRef = React.useRef<number | null>(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
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
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: height,
duration: duration * 1000,
useNativeDriver: true
toValue: containerHeight,
duration: 300,
useNativeDriver: true,
}),
]) .start(() => {
Animated.timing(cancelOverlayAnimation, {
toValue: 0,
duration: 120,
useNativeDriver: true,
}),
]).start(() => {
Animated.timing(buttonAnimation, {
toValue: 0,
duration: 300,
useNativeDriver: true
}).start()
})
}, [duration])
duration: 220,
useNativeDriver: true,
}).start(() => {
resetSessionValues();
});
});
}, [
buttonAnimation,
cancelButtonAnimation,
cancelOverlayAnimation,
clearCancelHoldTimeouts,
clearCountdownInterval,
containerHeight,
countdownAnimation,
focusModeAnimation,
resetSessionValues,
stopRunningAnimations,
taskDetailsAnimation,
timerAnimation,
timerIsRunning,
]);
const opacity = buttonAnimation.interpolate({
inputRange: [0, 1],
outputRange: [1, 0]
})
const translateY = buttonAnimation.interpolate({
inputRange: [0, 1],
outputRange: [0, 200]
})
const handleCancelHoldStart = React.useCallback(() => {
animateButtonPress(true);
cancelHoldIdRef.current += 1;
return (
<View style={styles.container}>
<StatusBar hidden />
<Animated.View
style={[StyleSheet.absoluteFillObject, {
height,
width,
backgroundColor: colors.red,
transform: [{
translateY: timerAnimation
}]
}]}
/>
<Animated.View
style={[
StyleSheet.absoluteFillObject,
{
justifyContent: 'flex-end',
alignItems: 'center',
paddingBottom: 100,
opacity,
transform: [{
translateY
}]
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]);
},
]}>
<TouchableOpacity
onPress={animation}>
<View
style={styles.roundButton}
/>
</TouchableOpacity>
</Animated.View>
<View
style={{
position: 'absolute',
top: height / 3,
left: 0,
right: 0,
flex: 1,
}}>
<Animated.FlatList
data={timers}
keyExtractor={item => item.toString()}
horizontal
bounces={false}
onScroll={Animated.event(
[{nativeEvent: {contentOffset: {x: scrollX}}}],
{ useNativeDriver: true}
)}
showsHorizontalScrollIndicator={false}
onMomentumScrollEnd={ev => {
const index = Math.round(ev.nativeEvent.contentOffset.x / ITEM_SIZE)
setDuration(timers[index]);
}}
snapToInterval={ITEM_SIZE}
decelerationRate={"fast"}
style={{flexGrow: 0}}
contentContainerStyle={{
paddingHorizontal: ITEM_SPACING
}}
renderItem={({item, index}) => {
[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 opacity = scrollX.interpolate({
const baseOpacity = scrollX.interpolate({
inputRange,
outputRange: [.4, 1, .4]
})
outputRange: [0.4, 1, 0.4],
});
const opacity = Animated.multiply(baseOpacity, pickerOpacity);
const scale = scrollX.interpolate({
inputRange,
outputRange: [.7, 1, .7]
})
return <View style={{width: ITEM_SIZE, justifyContent: 'center', alignItems: 'center'}}>
<Animated.Text style={[styles.text, {
opacity,
transform: [{
scale
}]
outputRange: [0.7, 1, 0.7],
});
}]}>
return (
<View style={styles.timerOptionItem}>
<Animated.Text
style={[
styles.text,
{
opacity,
transform: [{ scale }],
},
]}
>
{item}
</Animated.Text>
</View>
}
}
);
},
[pickerOpacity, scrollX]
);
return (
<View
style={styles.container}
onLayout={(event) => {
setContainerHeight(event.nativeEvent.layout.height);
}}
>
<StatusBar hidden />
<Animated.View
style={[
StyleSheet.absoluteFillObject,
styles.timerOverlay,
{
height: containerHeight,
width,
transform: [{ translateY: timerOverlayTranslateY }],
},
]}
/>
<Animated.View
style={[
StyleSheet.absoluteFillObject,
styles.startButtonContainer,
{
opacity: startButtonOpacity,
transform: [{ translateY: startButtonTranslateY }],
},
]}
>
<TouchableOpacity
disabled={timerIsRunning}
onPress={startTimerSession}
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>
</Animated.View>
</TouchableOpacity>
</Animated.View>
<Animated.View
pointerEvents={timerIsRunning ? 'auto' : 'none'}
style={[
styles.cancelButtonContainer,
{
opacity: cancelButtonAnimation,
transform: [{ translateY: cancelButtonTranslateY }],
},
]}
>
<TouchableOpacity onPressIn={handleCancelHoldStart} onPressOut={handleCancelHoldEnd}>
<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>
<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>
<View
style={[
styles.timerPickerWrapper,
{
top: containerHeight / 3,
},
]}
>
<Animated.FlatList
data={TIMER_OPTIONS}
scrollEnabled={!timerIsRunning}
keyExtractor={(item) => item.toString()}
horizontal
bounces={false}
onScroll={Animated.event([{ nativeEvent: { contentOffset: { x: scrollX } } }], {
useNativeDriver: true,
})}
showsHorizontalScrollIndicator={false}
onMomentumScrollEnd={handleTimerPickerMomentumEnd}
snapToInterval={ITEM_SIZE}
decelerationRate="fast"
style={styles.timerPickerList}
contentContainerStyle={styles.timerPickerContent}
renderItem={renderTimerItem}
/>
</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>
</View>
);
}
@@ -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',
}
},
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',
},
});

View File

@@ -3,8 +3,7 @@ import { Stack } from "expo-router";
export default function AssignmentLayout() {
return (
<Stack>
<Stack.Screen name="createAssignment" options={{ title: "Create Assignment" }} />
<Stack.Screen name="editAssignment" options={{ title: "Edit Assignment" }} />
<Stack.Screen name="upsertAssignment" options={{ title: 'Create/Edit Assignment' }} />
<Stack.Screen name="viewDetailsAssignment" options={{ title: "Assignment Details" }} />
</Stack>
);

View File

@@ -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<Assignment | null>(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 (
<View style={defaultStyles.container}>
<Stack.Screen
options={{
title: "Edit Assignment",
headerTitleStyle: defaultStyles.title
}}
/>
{!assignment && (
<View style={defaultStyles.container}>
<Text style={defaultStyles.title}>Assignment not found</Text>
</View>
)}
{assignment && (
<View style={defaultStyles.container}>
<Text style={defaultStyles.title}>Edit Assignment</Text>
<KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === "ios" ? "padding" : "height"}>
<TouchableWithoutFeedback onPress={Keyboard.dismiss}>
<View style={defaultStyles.container}>
<TextInput
testID="assignment-title-input"
style={defaultStyles.inputText}
placeholder="Title"
value={assignment.title}
onChangeText={(text) => SetAssignment(prev => prev ? { ...prev, title: text } : prev)}
/>
<TextInput
style={defaultStyles.inputText}
placeholder="Text"
value={assignment.description}
onChangeText={(text) => SetAssignment(prev => prev ? { ...prev, description: text } : prev)}
/>
<TextInput
style={defaultStyles.inputText}
placeholder="Text"
value={assignment.deadline}
onChangeText={(text) => SetAssignment(prev => prev ? { ...prev, deadline: text } : prev)}
/>
<Pressable
onPress={() => SetAssignment(prev => prev ? { ...prev, isCompleted: !prev.isCompleted } : prev)}
style={defaultStyles.checkboxContainer}
>
<View style={defaultStyles.checkbox}>
{assignment.isCompleted && <Text style={defaultStyles.checkboxMark}></Text>}
</View>
<Text style={defaultStyles.checkboxLabel}>{assignment.isCompleted ? 'Completed' : 'Not Completed'}</Text>
</Pressable>
<Button testID="edit-assignment-button" title={isSaving ? "Saving..." : "Save"} onPress={EditAssignment} disabled={isSaving} />
{isSaving && (
<ActivityIndicator size="large" />
)}
<Button title="Cancel" onPress={() => router.back()} />
</View>
</TouchableWithoutFeedback>
</KeyboardAvoidingView>
</View>
)}
</View>
)
}

View File

@@ -4,7 +4,7 @@ import { CheckSubjectCompletion } from '@/lib/progress';
import { supabase } from '@/lib/supabase';
import * as Notifications from 'expo-notifications';
import { router, Stack, useLocalSearchParams } from 'expo-router';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import {
ActivityIndicator,
Alert,
@@ -19,18 +19,64 @@ import {
View,
} from 'react-native';
export default function CreateAssignment() {
const sId = (useLocalSearchParams().sId as string) ?? null;
export default function UpsertAssignment() {
const { aId, sId: routeSId } = useLocalSearchParams<{
aId?: string;
sId?: string;
}>();
const isEditMode = Boolean(aId);
const [title, SetTitle] = useState('');
const [description, SetDescription] = useState('');
const [deadline, SetDeadline] = useState('');
const [isCompleted, SetIsCompleted] = useState(false);
const [subjectId, SetSubjectId] = useState<string | null>(routeSId ?? null);
const [isLoading, SetIsLoading] = useState(isEditMode);
const [isSaving, SetIsSaving] = useState(false);
const ScheduleDeadlineReminder = async (aId: string, title: string, deadline: string) => {
const dl = new Date(deadline);
useEffect(() => {
if (!isEditMode || !aId) {
SetIsLoading(false);
return;
}
if (isNaN(dl.getTime())) return null;
const loadAssignment = async () => {
SetIsLoading(true);
const { data, error } = await supabase
.from('assignments')
.select('*')
.eq('aId', aId)
.single();
SetIsLoading(false);
if (error || !data) {
Alert.alert('Assignment could not be loaded, please try again');
router.back();
return;
}
SetTitle(data.title ?? '');
SetDescription(data.description ?? '');
SetDeadline(data.deadline ?? '');
SetIsCompleted(data.isCompleted ?? false);
SetSubjectId(data.sId ?? routeSId ?? null);
};
loadAssignment();
}, [aId, isEditMode, routeSId]);
const ScheduleDeadlineReminder = async (
assignmentId: string,
assignmentTitle: string,
assignmentDeadline: string
) => {
const dl = new Date(assignmentDeadline);
if (Number.isNaN(dl.getTime())) return null;
const deadlineReminder = new Date(dl.getTime() - 24 * 60 * 60 * 1000);
@@ -39,8 +85,8 @@ export default function CreateAssignment() {
const nId = await Notifications.scheduleNotificationAsync({
content: {
title: 'Assignment deadline coming up',
body: `${title} is due in 24 hours.`,
data: { aId },
body: `${assignmentTitle} is due in 24 hours.`,
data: { aId: assignmentId },
},
trigger: {
type: Notifications.SchedulableTriggerInputTypes.DATE,
@@ -49,9 +95,40 @@ export default function CreateAssignment() {
});
return nId;
};
const updateDeadlineReminder = async (
assignmentId: string,
assignmentTitle: string,
assignmentDeadline: string,
completed: boolean
) => {
const existingNotificationId =
await AsyncStorage.GetAssignmentNotificationId(assignmentId);
if (existingNotificationId) {
try {
await Notifications.cancelScheduledNotificationAsync(
existingNotificationId
);
} catch {}
await AsyncStorage.RemoveAssignmentNotificationId(assignmentId);
}
const CreateAssignment = async () => {
if (completed) return;
const nId = await ScheduleDeadlineReminder(
assignmentId,
assignmentTitle,
assignmentDeadline
);
if (nId) {
await AsyncStorage.SaveAssignmentNotificationId(assignmentId, nId);
}
};
const handleSubmit = async () => {
if (title.trim() === '') {
Alert.alert('Title is required!');
return;
@@ -60,54 +137,70 @@ export default function CreateAssignment() {
const { data: userData, error: userError } = await supabase.auth.getUser();
if (userError || !userData.user) {
router.replace('../createUser');
router.replace('/login');
return;
}
if (!subjectId) {
Alert.alert('Missing subject', 'This assignment is not linked to a subject.');
return;
}
SetIsSaving(true);
const { data: assignmentData, error: dbError } = await supabase.from('assignments').insert({
const payload = {
title: title.trim(),
description: description.trim(),
deadline: deadline.trim(),
isCompleted,
lastChanged: new Date().toISOString(),
uId: userData.user.id,
sId,
})
.select()
.single();
sId: subjectId,
};
if (dbError) {
const result =
isEditMode && aId
? await supabase
.from('assignments')
.update(payload)
.eq('aId', aId)
.select()
.single()
: await supabase.from('assignments').insert(payload).select().single();
if (result.error || !result.data) {
SetIsSaving(false);
Alert.alert('Assignment could not be created, please try again');
Alert.alert(
isEditMode
? 'Assignment could not be updated, please try again'
: 'Assignment could not be created, please try again'
);
return;
}
Alert.alert('Assignment successfully created!');
const savedAssignment = result.data;
if (!isCompleted && assignmentData) {
const nId = await ScheduleDeadlineReminder(assignmentData.aId, assignmentData.title, assignmentData.deadline);
await updateDeadlineReminder(
savedAssignment.aId,
savedAssignment.title,
savedAssignment.deadline,
savedAssignment.isCompleted
);
if (nId) {
await AsyncStorage.SaveAssignmentNotificationId(assignmentData.aId, nId);
}
}
if (sId) {
try {
await CheckSubjectCompletion(sId);
await CheckSubjectCompletion(subjectId);
} catch {
Alert.alert("Failed to update subject status");
}
Alert.alert('Failed to update subject status');
}
SetTitle('');
SetDescription('');
SetDeadline('');
SetIsCompleted(false);
SetIsSaving(false);
Alert.alert(
isEditMode
? 'Assignment successfully updated!'
: 'Assignment successfully created!'
);
router.back();
};
@@ -116,11 +209,19 @@ export default function CreateAssignment() {
const labelClassName = 'mb-2 text-sm font-semibold text-text-secondary';
if (isLoading) {
return (
<View className="flex-1 items-center justify-center bg-app-bg">
<ActivityIndicator size="large" />
</View>
);
}
return (
<>
<Stack.Screen
options={{
title: 'Create Assignment',
title: isEditMode ? 'Edit Assignment' : 'Create Assignment',
headerTitleStyle: defaultStyles.title,
}}
/>
@@ -142,10 +243,12 @@ export default function CreateAssignment() {
>
<View className="mb-6">
<Text className="text-3xl font-bold text-text-main">
Create Assignment
{isEditMode ? 'Edit Assignment' : 'Create Assignment'}
</Text>
<Text className="mt-2 text-base leading-6 text-text-secondary">
Add a new assignment to keep your subject organized.
{isEditMode
? 'Update this assignment and keep your subject organized.'
: 'Add a new assignment to keep your subject organized.'}
</Text>
</View>
@@ -156,6 +259,7 @@ export default function CreateAssignment() {
testID = "assignment-title-input"
className={inputClassName}
placeholder="Enter assignment title"
placeholderTextColor="#9CA3AF"
value={title}
onChangeText={SetTitle}
returnKeyType="next"
@@ -167,6 +271,7 @@ export default function CreateAssignment() {
<TextInput
className={`${inputClassName} min-h-28`}
placeholder="Add a short description"
placeholderTextColor="#9CA3AF"
value={description}
onChangeText={SetDescription}
multiline
@@ -179,6 +284,7 @@ export default function CreateAssignment() {
<TextInput
className={inputClassName}
placeholder="YYYY-MM-DD"
placeholderTextColor="#9CA3AF"
value={deadline}
onChangeText={SetDeadline}
autoCapitalize="none"
@@ -224,19 +330,19 @@ export default function CreateAssignment() {
className={`h-14 items-center justify-center rounded-2xl ${
isSaving ? 'bg-accent-disabled' : 'bg-accent'
}`}
onPress={CreateAssignment}
onPress={handleSubmit}
disabled={isSaving}
>
{isSaving ? (
<View className="flex-row items-center">
<ActivityIndicator size="small" />
<Text className="ml-3 text-base font-bold text-text-inverse">
Creating...
{isEditMode ? 'Saving...' : 'Creating...'}
</Text>
</View>
) : (
<Text className="text-base font-bold text-text-inverse">
Create Assignment
{isEditMode ? 'Save Changes' : 'Create Assignment'}
</Text>
)}
</Pressable>

View File

@@ -1,17 +1,23 @@
import { defaultStyles } from '@/constants/defaultStyles';
import { formatDate, formatDateTime } from '@/lib/date';
import { CheckAssignmentCompletion, CheckSubjectCompletion } from '@/lib/progress';
import { getSubjectColorSet, type SubjectColor } from '@/lib/subjectColors';
import { supabase } from '@/lib/supabase';
import type { Assignment, Task } from '@/lib/types';
import { Session } from '@supabase/supabase-js';
import { router, Stack, useFocusEffect, useLocalSearchParams } from 'expo-router';
import { useCallback, useEffect, useState } from 'react';
import { Alert, Button, Pressable, SectionList, Text, View } from "react-native";
import { Alert, Pressable, SectionList, Text, View } from "react-native";
export default function ViewDetailsAssignment() {
const { aId } = useLocalSearchParams<{ aId: string }>();
const [assignment, SetAssignment] = useState<Assignment | null>(null)
const [tasks, SetTasks] = useState<Task[]>([])
const [session, SetSession] = useState<Session | null>(null)
const [assignment, SetAssignment] = useState<Assignment | null>(null);
const [tasks, SetTasks] = useState<Task[]>([]);
const [session, SetSession] = useState<Session | null>(null);
const [subjectMeta, setSubjectMeta] = useState({
title: 'No Subject',
color: 'slate' as SubjectColor,
});
const taskSections = [
{ title: "Upcoming Tasks", data: tasks.filter((task) => !task.isCompleted), emptyMessage: "No upcoming tasks" },
@@ -27,17 +33,42 @@ export default function ViewDetailsAssignment() {
},
[])
const GetAssignment = async (aId: string) => {
const { data, error } = await supabase.from("assignments").select("*").eq("aId", aId).single();
const GetAssignment = async (assignmentId: string) => {
const { data, error } = await supabase
.from('assignments')
.select('*')
.eq('aId', assignmentId)
.single();
if (error) {
Alert.alert("Assignment could not be fetched, please try again");
if (error || !data) {
Alert.alert('Assignment could not be fetched, please try again');
return;
}
SetAssignment(data ?? null);
SetAssignment(data);
if (data.sId) {
const { data: subjectData, error: subjectError } = await supabase
.from('subjects')
.select('title, color')
.eq('sId', data.sId)
.single();
if (subjectError || !subjectData) {
setSubjectMeta({
title: 'Unknown Subject',
color: 'slate'
});
return;
}
setSubjectMeta({
title: subjectData.title ?? 'Unknown Subject',
color: (subjectData.color as SubjectColor | undefined) ?? 'slate'
});
}
};
const GetTasks = async (aId: string) => {
const { data, error } = await supabase.from("tasks").select("*").eq("aId", aId);
@@ -134,97 +165,291 @@ export default function ViewDetailsAssignment() {
)
}
const progress = tasks.length === 0 ? 0 : Math.round((tasks.filter(task => task.isCompleted).length / tasks.length) * 100);
const colorSet = getSubjectColorSet(subjectMeta.color);
const completedTasks = tasks.filter((task) => task.isCompleted).length;
const totalTasks = tasks.length;
const remainingTasks = totalTasks - completedTasks;
const progress =
totalTasks === 0
? 0
: Math.round((completedTasks / totalTasks) * 100);
if (!assignment) {
return (
<View style={defaultStyles.container}>
<View className="flex-1 bg-app-bg px-5 pt-6">
<Stack.Screen
options={{
title: "Details",
headerTitleStyle: defaultStyles.title,
headerLeft: () => {
return (
<View style={defaultStyles.buttonContainer}>
<Button title="Back" onPress={router.back} />
</View>
)
},
headerRight: () => {
return (
<View style={defaultStyles.buttonContainer}>
<Button title="Logout" onPress={async () => await supabase.auth.signOut()} />
</View>
)
},
title: 'Details',
}}
/>
{!assignment && (
<View style={defaultStyles.container}>
<Text style={defaultStyles.title}>Assignment not found</Text>
</View>
)}
{assignment && (
<View style={defaultStyles.container}>
<View style={defaultStyles.container}>
<Text style={defaultStyles.title}>{assignment.title}</Text>
<Text style={defaultStyles.body}>{assignment.description}</Text>
<Text style={defaultStyles.body}>{assignment.deadline}</Text>
<View style={defaultStyles.checkbox}>
{assignment.isCompleted && <Text style={defaultStyles.checkboxMark}></Text>}
</View>
<Text style={defaultStyles.body}>{assignment.lastChanged}</Text>
<View style={{ marginTop: 10 }}>
<Text style={{ marginBottom: 4 }}>{progress}%</Text>
<View
className="rounded-3xl bg-app-surface p-5"
style={{
width: "100%",
height: 12,
backgroundColor: "#D9D9D9",
borderRadius: 999,
overflow: "hidden",
borderWidth: 1,
borderColor: colorSet.strong,
}}
>
<Text className="text-2xl font-bold text-text-main">
Assignment not found
</Text>
<Text className="mt-2 text-base text-text-secondary">
The assignment could not be loaded.
</Text>
<Pressable
className="mt-5 h-12 items-center justify-center rounded-2xl bg-accent"
onPress={() => router.back()}
>
<Text className="text-base font-bold text-text-inverse">Go back</Text>
</Pressable>
</View>
</View>
);
}
return (
<View className="flex-1 bg-app-bg">
<Stack.Screen
options={{
title: 'Assignment Details',
headerRight: () => (
<Pressable
className="rounded-full bg-app-subtle px-4 py-2"
onPress={async () => await supabase.auth.signOut()}
>
<Text className="text-sm font-semibold text-text-secondary">
Logout
</Text>
</Pressable>
),
}}
/>
<SectionList
className="flex-1"
contentContainerStyle={{ paddingHorizontal: 20, paddingTop: 20, paddingBottom: 32 }}
sections={taskSections}
keyExtractor={(item) => item.tId}
showsVerticalScrollIndicator={false}
stickySectionHeadersEnabled={false}
ListHeaderComponent={
<View>
<View
className="rounded-3xl bg-app-surface p-5"
style={{
borderWidth: 1,
borderColor: colorSet.strong,
}}
>
<View className="flex-row items-start">
<View
className="mr-3 mt-1 h-6 w-6 items-center justify-center rounded-md border-2"
style={{
borderColor: assignment.isCompleted ? colorSet.strong : '#DDD6C8',
backgroundColor: assignment.isCompleted ? colorSet.strong : '#EFEBE3',
}}
>
{assignment.isCompleted && (
<Text className="text-sm font-bold text-text-inverse"></Text>
)}
</View>
<View className="flex-1">
<Text className="text-2xl font-bold text-text-main">
{assignment.title}
</Text>
{assignment.description ? (
<Text className="mt-2 text-base leading-6 text-text-secondary">
{assignment.description}
</Text>
) : null}
<View className="mt-4 flex-row flex-wrap">
<View
className="mr-2 mb-2 rounded-full px-3 py-1"
style={{ backgroundColor: colorSet.soft }}
>
<Text
className="text-xs font-semibold"
style={{ color: colorSet.strong }}
>
{subjectMeta.title}
</Text>
</View>
<View className="mr-2 mb-2 rounded-full bg-app-subtle px-3 py-1">
<Text className="text-xs font-semibold text-text-secondary">
Deadline: {formatDate(assignment.deadline) || 'No deadline'}
</Text>
</View>
</View>
<View className="mt-5">
<View className="mb-2 flex-row items-center justify-between">
<Text className="text-sm font-semibold text-text-secondary">
Task Progress
</Text>
<Text className="text-sm font-bold text-text-main">
{completedTasks}/{totalTasks}
</Text>
</View>
<View className="h-3 overflow-hidden rounded-full bg-app-subtle">
<View
className="h-full rounded-full"
style={{
width: `${progress}%`,
height: "100%",
backgroundColor: "#4CAF50",
backgroundColor: colorSet.strong,
}}
/>
</View>
<Text className="mt-2 text-xs font-medium text-text-secondary">
{remainingTasks === 0
? 'All tasks complete'
: `${remainingTasks} task${remainingTasks === 1 ? '' : 's'} remaining`}
</Text>
</View>
<Button title="Edit" onPress={() => router.push({pathname: "/assignment/editAssignment", params: { aId: assignment.aId }})} />
<Button testID = "delete-assignment-button" title="Delete" onPress={() => DeleteAssignment(assignment.aId)} />
<Text className="mt-4 text-sm text-text-muted">
Last changed: {formatDateTime(assignment.lastChanged)}
</Text>
</View>
</View>
<View style={defaultStyles.buttonContainer}>
<Button title="Create Task" onPress={() => router.push({pathname: "/task/createTask", params: { aId: assignment.aId }})} />
<View className="mt-5 flex-row border-t border-app-border pt-5">
<Pressable
className="mr-3 flex-1 items-center justify-center rounded-2xl border border-app-border bg-app-subtle py-3"
onPress={() =>
router.push({
pathname: '/assignment/upsertAssignment',
params: { aId: assignment.aId },
})
}
>
<Text className="text-sm font-bold text-text-secondary">Edit</Text>
</Pressable>
<Pressable
className="flex-1 items-center justify-center rounded-2xl border border-app-border bg-app-surface py-3"
onPress={() => DeleteAssignment(assignment.aId)}
>
<Text className="text-sm font-bold text-status-danger">
Delete
</Text>
</Pressable>
</View>
</View>
<SectionList
sections={taskSections}
keyExtractor={(item) => item.tId}
renderSectionHeader={({ section: { title } }) => <Text style={defaultStyles.subtitle}>{title}</Text>}
<Pressable
className="mb-6 mt-5 h-14 items-center justify-center rounded-2xl bg-accent"
onPress={() =>
router.push({
pathname: '/task/upsertTask',
params: { aId: assignment.aId },
})
}
>
<Text className="text-base font-bold text-text-inverse">
Create Task
</Text>
</Pressable>
</View>
}
renderSectionHeader={({ section: { title, data } }) => (
<View className="mb-3 mt-2 flex-row items-center justify-between">
<Text className="text-lg font-bold text-text-main">{title}</Text>
<View className="rounded-full bg-app-subtle px-3 py-1">
<Text className="text-xs font-semibold text-text-muted">
{data.length}
</Text>
</View>
</View>
)}
renderItem={({ item }) => {
const isOwner = session?.user.id === item.uId;
return (
<View style={defaultStyles.container}>
<Pressable style={defaultStyles.container} onPress={() => router.push({pathname: "/task/viewDetailsTask", params: { tId: item.tId }})}>
<Text style={defaultStyles.boldBody}>{item.title}</Text>
<View style={defaultStyles.checkbox}>
{item.isCompleted && <Text style={defaultStyles.checkboxMark}></Text>}
<View
className="mb-4 rounded-3xl bg-app-surface p-4"
style={{
borderWidth: 1,
borderColor: colorSet.strong,
}}
>
<Pressable
onPress={() =>
router.push({
pathname: '/task/viewDetailsTask',
params: { tId: item.tId },
})
}
>
<View className="flex-row items-start">
<View
className="mr-3 mt-1 h-6 w-6 items-center justify-center rounded-md border-2"
style={{
borderColor: item.isCompleted ? colorSet.strong : '#DDD6C8',
backgroundColor: item.isCompleted ? colorSet.strong : '#EFEBE3',
}}
>
{item.isCompleted && (
<Text className="text-sm font-bold text-text-inverse"></Text>
)}
</View>
<View className="flex-1">
<Text
className={`text-base font-bold ${
item.isCompleted ? 'text-text-secondary' : 'text-text-main'
}`}
>
{item.title}
</Text>
{item.description ? (
<Text
className="mt-1 text-sm leading-5 text-text-muted"
numberOfLines={2}
>
{item.description}
</Text>
) : null}
</View>
</View>
</Pressable>
{isOwner && (
<View style={defaultStyles.buttonContainer}>
<Button title="Edit" onPress={() => router.push({pathname: "/task/editTask", params: { tId: item.tId }})} />
<Button title="Delete" onPress={() => DeleteTask(item.tId, item.aId)} />
<View className="mt-4 flex-row border-t border-app-border pt-4">
<Pressable
className="mr-3 flex-1 items-center justify-center rounded-2xl border border-app-border bg-app-subtle py-3"
onPress={() =>
router.push({
pathname: '/task/upsertTask',
params: { tId: item.tId },
})
}
>
<Text className="text-sm font-bold text-text-secondary">Edit</Text>
</Pressable>
<Pressable
className="flex-1 items-center justify-center rounded-2xl border border-app-border bg-app-surface py-3"
onPress={() => DeleteTask(item.tId, item.aId)}
>
<Text className="text-sm font-bold text-status-danger">
Delete
</Text>
</Pressable>
</View>
)}
</View>
@@ -232,17 +457,19 @@ export default function ViewDetailsAssignment() {
}}
renderSectionFooter={({ section }) =>
section.data.length === 0 ? (
<View style={defaultStyles.container}>
<Text style={defaultStyles.body}>{section.emptyMessage}</Text>
<View style={defaultStyles.separator} />
<View className="mb-6 rounded-3xl border border-app-border bg-app-surface p-5" style={{ borderColor: colorSet.strong }}>
<Text className="text-center text-base font-semibold text-text-secondary">
{section.emptyMessage}
</Text>
<Text className="mt-1 text-center text-sm text-text-muted">
Tasks for this assignment will show up here.
</Text>
</View>
) : (
<View style={defaultStyles.separator} />
<View className="mb-2" />
)
}
/>
</View>
)}
</View>
);
}

View File

@@ -1,69 +1,146 @@
import { defaultStyles } from '@/constants/defaultStyles';
import { supabase } from '@/lib/supabase';
import { router, Stack } from 'expo-router';
import { router } from 'expo-router';
import { useState } from 'react';
import { Alert, Button, Keyboard, KeyboardAvoidingView, Platform, Pressable, Text, TextInput, TouchableWithoutFeedback, View } from 'react-native';
import {
Alert,
Keyboard,
KeyboardAvoidingView,
Platform,
Pressable,
ScrollView,
Text,
TextInput,
TouchableWithoutFeedback,
View,
} from 'react-native';
export default function CreateUser() {
const [email, SetEmail] = useState('');
const [password, SetPassword] = useState('');
const [isLoading, SetIsLoading] = useState(false);
const SignUp = async () => {
if(email.trim() === '' || password.trim() === '') {
Alert.alert("All fields are required!");
if (email.trim() === '' || password.trim() === '') {
Alert.alert('All fields are required!');
return;
}
const {error} = await supabase.auth.signUp({
email: email,
password: password,
SetIsLoading(true);
const { data, error } = await supabase.auth.signUp({
email: email.trim(),
password,
});
SetIsLoading(false);
if (error) {
Alert.alert(error.message, "User could not be created, please try again");
Alert.alert(error.message, 'User could not be created, please try again');
return;
}
router.replace("/");
if (!data.session) {
Alert.alert(
'Check your email',
'Your account was created. Please confirm your email before signing in.'
);
router.replace('/login');
return;
}
router.replace('/');
};
const inputClassName =
'rounded-2xl border border-app-border bg-app-subtle px-4 py-3 text-base text-text-main';
return (
<View style={defaultStyles.container}>
<Stack.Screen
options={{
title: "Create User",
headerTitleStyle: defaultStyles.title,
}}
/>
<View style={defaultStyles.container}>
<Text style={defaultStyles.title}>Create User</Text>
<KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === "ios" ? "padding" : "height"}>
<KeyboardAvoidingView
className="flex-1 bg-app-bg"
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<TouchableWithoutFeedback onPress={Keyboard.dismiss}>
<View style={defaultStyles.container}>
<ScrollView
className="flex-1"
keyboardShouldPersistTaps="handled"
contentContainerStyle={{
flexGrow: 1,
justifyContent: 'center',
paddingHorizontal: 20,
paddingTop: 64,
paddingBottom: 32,
}}
>
<View className="mb-10">
<Text className="mt-5 text-4xl font-bold text-text-main">
Study Sprint
</Text>
<Text className="mt-3 text-base leading-6 text-text-secondary">
Organize subjects, assignments, and tasks in one calm workflow.
</Text>
</View>
<View className="rounded-3xl border border-app-border bg-app-surface p-5">
<Text className="text-2xl font-bold text-text-main">
Create account
</Text>
<Text className="mt-2 text-sm leading-5 text-text-secondary">
Start your next study sprint.
</Text>
<View className="mt-6 mb-5">
<Text className="mb-2 text-sm font-semibold text-text-secondary">
Email
</Text>
<TextInput
style={defaultStyles.inputText}
placeholder="Enter Email"
className={inputClassName}
placeholder="you@example.com"
placeholderTextColor="#9CA3AF"
keyboardType="email-address"
autoCapitalize="none"
autoCorrect={false}
value={email}
onChangeText={SetEmail}
autoCapitalize="none"
/>
</View>
<View className="mb-6">
<Text className="mb-2 text-sm font-semibold text-text-secondary">
Password
</Text>
<TextInput
style={defaultStyles.inputText}
placeholder="Enter Password"
className={inputClassName}
placeholder="Create a password"
placeholderTextColor="#9CA3AF"
secureTextEntry
value={password}
onChangeText={SetPassword}
secureTextEntry
/>
</View>
<Button title="Save" onPress={SignUp} />
<Button title="Cancel" onPress={() => router.back()} />
<Pressable onPress={() => router.push("/login")} style={defaultStyles.buttonContainer}>
<Text style={defaultStyles.linkText}>Already have an Account? login here</Text>
<Pressable
className={`h-14 items-center justify-center rounded-2xl ${
isLoading ? 'bg-accent-disabled' : 'bg-accent'
}`}
onPress={SignUp}
disabled={isLoading}
>
<Text className="text-base font-bold text-text-inverse">
{isLoading ? 'Creating account...' : 'Create account'}
</Text>
</Pressable>
<Pressable
className="mt-4 h-12 items-center justify-center rounded-2xl border border-app-border bg-app-subtle"
onPress={() => router.push('/login')}
>
<Text className="text-sm font-semibold text-text-secondary">
Already have an account? Log in
</Text>
</Pressable>
</View>
</ScrollView>
</TouchableWithoutFeedback>
</KeyboardAvoidingView>
</View>
</View>
)
);
}

View File

@@ -1,12 +1,12 @@
import { defaultStyles } from "@/constants/defaultStyles";
import { supabase } from "@/lib/supabase";
import { router, Stack } from "expo-router";
import { router } from "expo-router";
import { useState } from "react";
import { Alert, Button, Keyboard, KeyboardAvoidingView, Platform, Text, TextInput, TouchableWithoutFeedback, View } from "react-native";
import { Alert, Keyboard, KeyboardAvoidingView, Platform, Pressable, ScrollView, Text, TextInput, TouchableWithoutFeedback, View } from "react-native";
export default function Login() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [email, SetEmail] = useState('');
const [password, SetPassword] = useState('');
const [isLoading, SetIsLoading] = useState(false);
const login = async () => {
if(email.trim() === '' || password.trim() === '') {
@@ -14,11 +14,15 @@ export default function Login() {
return;
}
SetIsLoading(true);
const { error } = await supabase.auth.signInWithPassword({
email,
password
});
SetIsLoading(false);
if (error) {
Alert.alert("Login failed, please check your credentials and try again");
return;
@@ -27,40 +31,98 @@ export default function Login() {
router.replace("/");
}
return (
<View style={defaultStyles.container}>
<Stack.Screen
options={{
title: "Login",
headerTitleStyle: defaultStyles.title
}}
/>
const inputClassName =
'rounded-2xl border border-app-border bg-app-subtle px-4 py-3 text-base text-text-main'
<View style={defaultStyles.container}>
<Text style={defaultStyles.title}>Login</Text>
<KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === "ios" ? "padding" : "height"}>
return (
<KeyboardAvoidingView
className="flex-1 bg-app-bg"
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<TouchableWithoutFeedback onPress={Keyboard.dismiss}>
<View style={defaultStyles.container}>
<TextInput
style={defaultStyles.inputText}
placeholder="Enter Email"
value={email}
onChangeText={setEmail}
autoCapitalize="none"
/>
<TextInput
style={defaultStyles.inputText}
placeholder="Enter Password"
value={password}
onChangeText={setPassword}
secureTextEntry
/>
<Button title="Login" onPress={login} />
<Button title="Cancel" onPress={() => router.push("/")} />
<ScrollView
className="flex-1"
keyboardShouldPersistTaps="handled"
contentContainerStyle={{
flexGrow: 1,
justifyContent: 'center',
paddingHorizontal: 20,
paddingTop: 64,
paddingBottom: 32,
}}
>
<View className="mb-10">
<Text className="text-4xl font-bold text-text-main">
Study Sprint
</Text>
<Text className="mt-3 text-base leading-6 text-text-secondary">
Pick up where you left off.
</Text>
</View>
<View className="rounded-3xl border border-app-border bg-app-surface p-5">
<Text className="text-2xl font-bold text-text-main">
Log in
</Text>
<Text className="mt-2 text-sm leading-5 text-text-secondary">
Continue your study workflow.
</Text>
<View className="mb-5 mt-6">
<Text className="mb-2 text-sm font-semibold text-text-secondary">
Email
</Text>
<TextInput
className={inputClassName}
placeholder="you@example.com"
placeholderTextColor="#9CA3AF"
keyboardType="email-address"
autoCapitalize="none"
autoCorrect={false}
value={email}
onChangeText={SetEmail}
/>
</View>
<View className="mb-6">
<Text className="mb-2 text-sm font-semibold text-text-secondary">
Password
</Text>
<TextInput
className={inputClassName}
placeholder="Enter your password"
placeholderTextColor="#9CA3AF"
secureTextEntry
value={password}
onChangeText={SetPassword}
/>
</View>
<Pressable
className={`h-14 items-center justify-center rounded-2xl ${
isLoading ? 'bg-accent-disabled' : 'bg-accent'
}`}
onPress={login}
disabled={isLoading}
>
<Text className="text-base font-bold text-text-inverse">
{isLoading ? 'Logging in...' : 'Log in'}
</Text>
</Pressable>
<Pressable
className="mt-4 h-12 items-center justify-center rounded-2xl border border-app-border bg-app-subtle"
onPress={() => router.push('/createUser')}
>
<Text className="text-sm font-semibold text-text-secondary">
Don&apos;t have an account? Sign up
</Text>
</Pressable>
</View>
</ScrollView>
</TouchableWithoutFeedback>
</KeyboardAvoidingView>
</View>
</View>
)
);
}

View File

@@ -3,8 +3,7 @@ import { Stack } from "expo-router";
export default function SubjectLayout() {
return (
<Stack>
<Stack.Screen name="createSubject" options={{ title: "Create Subject" }} />
<Stack.Screen name="editSubject" options={{ title: "Edit Subject" }} />
<Stack.Screen name="upsertSubject" />
<Stack.Screen name="viewDetailsSubject" options={{ title: "Subject Details" }} />
</Stack>
);

View File

@@ -1,197 +0,0 @@
import { defaultStyles } from '@/constants/defaultStyles';
import { supabase } from '@/lib/supabase';
import { router, Stack } from 'expo-router';
import { useState } from 'react';
import {
ActivityIndicator,
Alert,
Keyboard,
KeyboardAvoidingView,
Platform,
Pressable,
ScrollView,
Text,
TextInput,
TouchableWithoutFeedback,
View,
} from 'react-native';
export default function CreateSubject() {
const [title, SetTitle] = useState('');
const [description, SetDescription] = useState('');
const [isActive, SetIsActive] = useState(true);
const [isSaving, SetIsSaving] = useState(false);
const CreateSubject = async () => {
if (title.trim() === '') {
Alert.alert('Title is required!');
return;
}
const { data, error: userError } = await supabase.auth.getUser();
if (userError || !data.user) {
router.replace('../createUser');
return;
}
SetIsSaving(true);
const { error: dbError } = await supabase.from('subjects').insert({
title: title.trim(),
description: description.trim(),
isActive,
lastChanged: new Date().toISOString(),
uId: data.user.id,
});
if (dbError) {
SetIsSaving(false);
Alert.alert('Subject could not be created, please try again');
return;
}
Alert.alert('Subject successfully created!');
SetTitle('');
SetDescription('');
SetIsActive(true);
SetIsSaving(false);
router.back();
};
const inputClassName =
'rounded-2xl border border-app-border bg-app-subtle px-4 py-3 text-base text-text-main';
const labelClassName = 'mb-2 text-sm font-semibold text-text-secondary';
return (
<>
<Stack.Screen
options={{
title: 'Create Subject',
headerTitleStyle: defaultStyles.title,
}}
/>
<KeyboardAvoidingView
className="flex-1 bg-app-bg"
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<TouchableWithoutFeedback onPress={Keyboard.dismiss}>
<ScrollView
className="flex-1"
keyboardShouldPersistTaps="handled"
contentContainerStyle={{
flexGrow: 1,
justifyContent: 'center',
paddingHorizontal: 20,
paddingVertical: 32,
}}
>
<View className="mb-6">
<Text className="text-3xl font-bold text-text-main">
Create Subject
</Text>
<Text className="mt-2 text-base leading-6 text-text-secondary">
Add a subject to organize your assignments and study tasks.
</Text>
</View>
<View className="rounded-3xl border border-app-border bg-app-surface p-5 shadow-sm">
<View className="mb-5">
<Text className={labelClassName}>Title</Text>
<TextInput
testID="subject-title-input"
className={inputClassName}
placeholder="Enter subject title"
value={title}
onChangeText={SetTitle}
returnKeyType="next"
/>
</View>
<View className="mb-5">
<Text className={labelClassName}>Description</Text>
<TextInput
className={`${inputClassName} min-h-28`}
placeholder="Add a short description"
value={description}
onChangeText={SetDescription}
multiline
textAlignVertical="top"
/>
</View>
<Pressable
onPress={() => SetIsActive((state) => !state)}
disabled={isSaving}
className={`mb-6 flex-row items-center rounded-2xl border p-4 ${
isActive
? 'border-accent bg-accent-soft'
: 'border-app-border bg-app-subtle'
}`}
>
<View
className={`mr-3 h-6 w-6 items-center justify-center rounded-md border-2 ${
isActive
? 'border-accent bg-accent'
: 'border-app-border bg-app-surface'
}`}
>
{isActive && (
<Text className="text-sm font-bold text-text-inverse">
</Text>
)}
</View>
<View className="flex-1">
<Text className="text-base font-semibold text-text-main">
Active subject
</Text>
<Text className="mt-1 text-sm text-text-muted">
Active subjects appear in your main study workflow.
</Text>
</View>
</Pressable>
<Pressable
testID="create-subject-button"
className={`h-14 items-center justify-center rounded-2xl ${
isSaving ? 'bg-accent-disabled' : 'bg-accent'
}`}
onPress={CreateSubject}
disabled={isSaving}
>
{isSaving ? (
<View className="flex-row items-center">
<ActivityIndicator size="small" />
<Text className="ml-3 text-base font-bold text-text-inverse">
Creating...
</Text>
</View>
) : (
<Text className="text-base font-bold text-text-inverse">
Create Subject
</Text>
)}
</Pressable>
<Pressable
className="mt-3 h-14 items-center justify-center rounded-2xl border border-app-border bg-app-subtle"
onPress={() => router.back()}
disabled={isSaving}
>
<Text className="text-base font-semibold text-text-secondary">
Cancel
</Text>
</Pressable>
</View>
</ScrollView>
</TouchableWithoutFeedback>
</KeyboardAvoidingView>
</>
);
}

View File

@@ -1,126 +0,0 @@
import { defaultStyles } from '@/constants/defaultStyles';
import { supabase } from '@/lib/supabase';
import type { Subject } from '@/lib/types';
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 EditSubject() {
const { sId } = useLocalSearchParams<{ sId: string }>();
const [subject, SetSubject] = useState<Subject | null>(null)
const [isSaving, SetIsSaving] = useState(false);
const GetSubject = async (sId: string) => {
const { data, error } = await supabase.from("subjects").select("*").eq("sId", sId).single();
if (error) {
Alert.alert("Subject could not be fetched, please try again");
return;
}
SetSubject(data ?? null);
}
useFocusEffect(
useCallback(() => {
if (sId) {
GetSubject(sId);
}
}, [sId])
);
const EditSubject = async () => {
if (!subject) return;
if(subject.title.trim() === '') {
Alert.alert("Title is required!");
return;
}
const { data, error: userError } = await supabase.auth.getUser();
if(userError || !data.user) {
router.replace("../createUser");
return;
}
SetIsSaving(true);
const { error: dbError } = await supabase.from("subjects").update({
title: subject.title,
description: subject.description,
isActive: subject.isActive,
lastChanged: new Date().toISOString(),
uId: data.user.id,
}).eq("sId", sId);
SetIsSaving(false);
if (dbError) {
Alert.alert("Subject could not be edited, please try again");
return;
}
Alert.alert("Subject successfully edited!");
router.back();
}
return (
<View style={defaultStyles.container}>
<Stack.Screen
options={{
title: "Edit Subject",
headerTitleStyle: defaultStyles.title
}}
/>
{!subject && (
<View style={defaultStyles.container}>
<Text style={defaultStyles.title}>Subject not found</Text>
</View>
)}
{subject && (
<View style={defaultStyles.container}>
<Text style={defaultStyles.title}>Edit Subject</Text>
<KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === "ios" ? "padding" : "height"}>
<TouchableWithoutFeedback onPress={Keyboard.dismiss}>
<View style={defaultStyles.container}>
<TextInput
testID="subject-title-input"
style={defaultStyles.inputText}
placeholder="Title"
value={subject.title}
onChangeText={(text) => SetSubject(prev => prev ? { ...prev, title: text } : prev)}
/>
<TextInput
style={defaultStyles.inputText}
placeholder="Text"
value={subject.description}
onChangeText={(text) => SetSubject(prev => prev ? { ...prev, description: text } : prev)}
/>
<Pressable
onPress={() => SetSubject(prev => prev ? { ...prev, isActive: !prev.isActive } : prev)}
style={defaultStyles.checkboxContainer}
>
<View style={defaultStyles.checkbox}>
{subject.isActive && <Text style={defaultStyles.checkboxMark}></Text>}
</View>
<Text style={defaultStyles.checkboxLabel}>{subject.isActive ? 'Active' : 'inactive'}</Text>
</Pressable>
<Button testID="edit-subject-button" title={isSaving ? "Saving..." : "Save"} onPress={EditSubject} disabled={isSaving} />
{isSaving && (
<ActivityIndicator size="large" />
)}
<Button title="Cancel" onPress={() => router.back()} />
</View>
</TouchableWithoutFeedback>
</KeyboardAvoidingView>
</View>
)}
</View>
)
}

View File

@@ -0,0 +1,351 @@
import { defaultStyles } from '@/constants/defaultStyles';
import { SUBJECT_COLOR_KEYS, SUBJECT_COLORS, type SubjectColor } from '@/lib/subjectColors';
import { supabase } from '@/lib/supabase';
import type { Subject } from '@/lib/types';
import { router, Stack, useLocalSearchParams } from 'expo-router';
import { useEffect, useMemo, useState } from 'react';
import {
ActivityIndicator,
Alert,
Keyboard,
KeyboardAvoidingView,
Platform,
Pressable,
ScrollView,
Text,
TextInput,
TouchableWithoutFeedback,
View
} from 'react-native';
export default function UpsertSubject() {
const { sId } = useLocalSearchParams<{ sId?: string }>();
const isEditMode = Boolean(sId);
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [isActive, setIsActive] = useState(true);
const [color, setColor] = useState<SubjectColor>('blue');
const [isLoading, setIsLoading] = useState(isEditMode);
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
if (!isEditMode || !sId) return;
const loadSubject = async () => {
setIsLoading(true);
const { data, error } = await supabase
.from('subjects')
.select('*')
.eq('sId', sId)
.single();
setIsLoading(false);
if (error || !data ) {
Alert.alert('Subject could not be loaded, please try again');
router.back();
return;
}
const subject = data as Subject;
setTitle(subject.title ?? '');
setDescription(subject.description ?? '');
setIsActive(subject.isActive ?? true);
setColor(subject.color ?? 'blue');
};
loadSubject();
}, [isEditMode, sId]);
const handleSubmit = async () => {
if (title.trim() === '') {
Alert.alert('Title is required!');
return;
}
const { data, error: userError } = await supabase.auth.getUser();
if (userError || !data.user) {
router.replace('/login');
return;
}
setIsSaving(true);
const payload = {
title: title.trim(),
description : description.trim(),
isActive,
color,
lastChanged: new Date().toISOString(),
uId: data.user.id,
};
const result = isEditMode && sId
? await supabase.from('subjects').update(payload).eq('sId', sId)
: await supabase.from('subjects').insert(payload);
setIsSaving(false);
if(result.error) {
Alert.alert(
isEditMode
? 'Subject could not be updated, please try again'
: 'Subject could not be created, please try again'
);
return;
}
Alert.alert(
isEditMode ? 'Subject updated successfully!' : 'Subject created successfully!'
);
router.back();
};
const inputClassName =
'rounded-2xl border border-app-border bg-app-subtle px-4 py-3 text-base text-text-main';
const labelClassName = 'mb-2 text-sm font-semibold text-text-secondary';
const selectedColor = useMemo(() => SUBJECT_COLORS[color], [color]);
if (isLoading) {
return (
<View className="flex-1 items-center justify-center bg-app-bg">
<ActivityIndicator size="large" />
</View>
);
}
return (
<>
<Stack.Screen
options= {{
title: isEditMode ? 'Edit Subject' : 'Create Subject',
headerTitleStyle: defaultStyles.title,
}}
/>
<KeyboardAvoidingView
className="flex-1 bg-app-bg"
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<TouchableWithoutFeedback onPress={Keyboard.dismiss}>
<ScrollView
className="flex-1"
keyboardShouldPersistTaps="handled"
contentContainerStyle={{
flexGrow: 1,
justifyContent: 'center',
paddingHorizontal: 20,
paddingVertical: 32,
}}
>
<View className="mb-6">
<Text className="text-3xl font-bold text-text-main">
{isEditMode ? 'Edit Subject' : 'Create Subject'}
</Text>
<Text className="mt-2 text-base leading-6 text-text-secondary">
{isEditMode? ' Update this subject and keep your study structure organized.'
: 'Add a subject to organize your assignments and studyt tasks.'}
</Text>
</View>
<View className="rounded-3xl border border-app-border bg-app-surface p-5 shadow-sm">
<View className="mb-5">
<Text className={labelClassName}>Title</Text>
<TextInput className={inputClassName}
placeholder="Enter subject title"
placeholderTextColor="#9CA3AF"
value={title}
onChangeText={setTitle}
returnKeyType="next"
/>
</View>
<View className ="mb-5">
<Text className={labelClassName}>Description</Text>
<TextInput
className={`${inputClassName} min-h-28`}
placeholder="Add a short description"
placeholderTextColor="#9CA3AF"
value={description}
onChangeText={setDescription}
multiline
textAlignVertical="top"
/>
</View>
<View className="mb-6">
<Text className={labelClassName}>Color</Text>
<View className="mb-4">
<Text className={labelClassName}>Preview</Text>
<View
className="rounded-3xl bg-app-surface p-4"
style={{
borderWidth: 1,
borderColor: selectedColor.strong,
}}
>
<View className="flex-row items-center">
<View
className="mr-3 h-12 w-12 items-center justify-center rounded-2xl"
style={{ backgroundColor: selectedColor.soft }}
>
<Text
className="text-base font-bold"
style={{ color: selectedColor.strong }}
>
{title.trim().charAt(0).toUpperCase() || 'S'}
</Text>
</View>
<View className="flex-1">
<Text
className="text-base font-bold text-text-main"
numberOfLines={1}
>
{title.trim() || 'Subject Preview'}
</Text>
<Text
className="mt-1 text-sm leading-5 text-text-secondary"
numberOfLines={2}
>
{description.trim() || 'This color will be used as the subject card accent.'}
</Text>
</View>
<View className="ml-3">
<View
className="rounded-full px-3 py-1"
style={{ backgroundColor: selectedColor.soft }}
>
<Text
className="text-xs font-semibold"
style={{ color: selectedColor.strong }}
>
{isActive ? 'Active' : 'Inactive'}
</Text>
</View>
</View>
</View>
</View>
</View>
<View className="flex-row flex-wrap">
{SUBJECT_COLOR_KEYS.map((colorKey) => {
const colorOption = SUBJECT_COLORS[colorKey];
const isSelected = color === colorKey;
return (
<Pressable
key={colorKey}
onPress={() => setColor(colorKey)}
className="mr-3 mb-3 rounded-2xl border border-app-border bg-app--surface p-2"
style={{
borderColor: isSelected
? colorOption.strong
: '#FFFFFF',
}}
>
<View className="flex-row items-center">
<View
className="mr-2 h-8 w-8 rounded-xl"
style={{ backgroundColor: colorOption.strong }}
/>
<Text
className="text-sm font-semibold"
style={{
color: isSelected
? colorOption.strong
: '#52616B',
}}
>
{colorOption.label}
</Text>
</View>
</Pressable>
);
})}
</View>
</View>
<Pressable
onPress={() => setIsActive((state) => !state)}
disabled={isSaving}
className={`mb-6 flex-row items-center rounded-2xl border p-4 ${
isActive
? 'border-accent bg-accent-soft'
: 'border-app-border bg-app-subtle'
}`}
>
<View
className={`mr-3 h-6 w-6 items-center justify-center rounded-md border-2 ${
isActive
? 'border-accent bg-accent'
: 'border-app-border bg-app-surface'
}`}
>
{isActive && (
<Text className="text-sm font-bold text-text-inverse"></Text>
)}
</View>
<View className="flex-1">
<Text className="text-base font-semibold text-text-main">
Active subject
</Text>
<Text className="mt-1 text-sm text-text-muted">
Active subjects appear in your main study workflow.
</Text>
</View>
</Pressable>
<Pressable
className={`h-14 items-center justify-center rounded-2xl ${
isSaving
? 'bg-accent-disabled'
: 'bg-accent'
}`}
onPress={handleSubmit}
disabled={isSaving}
>
{isSaving ? (
<View className="flex-row items-center">
<ActivityIndicator size="small" />
<Text className="ml-3 text-base font-bold-text-text-inverse">
{isEditMode ? 'Saving...' : 'Creating...'}
</Text>
</View>
) : (
<Text className="text-base font-bold text-text-inverse">
{isEditMode ? 'Save Changes' : 'Create Subject'}
</Text>
)}
</Pressable>
<Pressable
className="mt-3 h-14 items-center justify-center rounded-2xl border border-app-border bg-app-subtle"
onPress={() => router.back()}
disabled={isSaving}
>
<Text className="text-base font-semibold text-text-secondary">
Cancel
</Text>
</Pressable>
</View>
</ScrollView>
</TouchableWithoutFeedback>
</KeyboardAvoidingView>
</>
);
}

View File

@@ -1,53 +1,81 @@
import { defaultStyles } from '@/constants/defaultStyles';
import { formatDate, formatDateTime } from '@/lib/date';
import { CheckSubjectCompletion } from '@/lib/progress';
import { SUBJECT_COLORS, type SubjectColor } from '@/lib/subjectColors';
import { supabase } from '@/lib/supabase';
import type { Assignment, Subject } from '@/lib/types';
import type { Assignment } from '@/lib/types';
import { Session } from '@supabase/supabase-js';
import { router, Stack, useFocusEffect, useLocalSearchParams } from 'expo-router';
import { useCallback, useEffect, useState } from 'react';
import { Alert, Button, Pressable, SectionList, Text, View } from "react-native";
import { Alert, Pressable, SectionList, Text, View } from 'react-native';
export type Subject = {
sId: string;
title: string;
description: string;
isActive: boolean;
lastChanged: string;
uId: string;
color: SubjectColor;
};
export default function ViewDetailsSubject() {
const { sId } = useLocalSearchParams<{ sId: string }>();
const [subject, SetSubject] = useState<Subject | null>(null)
const [assignments, SetAssignments] = useState<Assignment[]>([])
const [session, SetSession] = useState<Session | null>(null)
const [subject, SetSubject] = useState<Subject | null>(null);
const [assignments, SetAssignments] = useState<Assignment[]>([]);
const [session, SetSession] = useState<Session | null>(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" },
{
title: 'Active Assignments',
data: assignments.filter((assignment) => !assignment.isCompleted),
emptyMessage: 'No active 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()
},
[])
supabase.auth.getSession().then(({ data }) => SetSession(data.session ?? null));
const GetSubject = async (sId: string) => {
const { data, error } = await supabase.from("subjects").select("*").eq("sId", sId).single();
const { data: sub } = supabase.auth.onAuthStateChange((_event, newSession) => {
SetSession(newSession);
});
return () => sub.subscription.unsubscribe();
}, []);
const GetSubject = async (subjectId: string) => {
const { data, error } = await supabase
.from('subjects')
.select('*')
.eq('sId', subjectId)
.single();
if (error) {
Alert.alert("Subject could not be fetched, please try again");
Alert.alert('Subject could not be fetched, please try again');
return;
}
SetSubject(data ?? null);
}
SetSubject((data as Subject) ?? null);
};
const GetAssignments = async (sId: string) => {
const { data, error } = await supabase.from("assignments").select("*").eq("sId", sId).order("deadline", { ascending: true });
const GetAssignments = async (subjectId: string) => {
const { data, error } = await supabase
.from('assignments')
.select('*')
.eq('sId', subjectId)
.order('deadline', { ascending: true });
if (error) {
Alert.alert("Assignments could not be fetched, please try again");
Alert.alert('Assignments could not be fetched, please try again');
return;
}
SetAssignments(data ?? []);
}
};
useFocusEffect(
useCallback(() => {
@@ -58,162 +86,358 @@ export default function ViewDetailsSubject() {
}, [session, sId])
);
const DeleteSubject = async (sId: string) => {
const DeleteSubject = async (subjectId: string) => {
Alert.alert(
"Delete Subject",
"Are you sure you want to delete this subject?",
'Delete Subject',
'Are you sure you want to delete this subject?',
[
{
text: "Cancel",
style: "cancel"
text: 'Cancel',
style: 'cancel',
},
{
text: "Delete",
style: "destructive",
text: 'Delete',
style: 'destructive',
onPress: async () => {
const { error } = await supabase.from("subjects").delete().eq("sId", sId);
const { error } = await supabase
.from('subjects')
.delete()
.eq('sId', subjectId);
if (error) {
Alert.alert("Subject could not be deleted, please try again");
Alert.alert('Subject could not be deleted, please try again');
return;
}
Alert.alert("Subject deleted successfully!");
Alert.alert('Subject deleted successfully!');
router.back();
}
}
},
},
]
)
}
);
};
const DeleteAssignment = async (aId: string, sId: string) => {
const DeleteAssignment = async (assignmentId: string, subjectId: string) => {
Alert.alert(
"Delete Assignment",
"Are you sure you want to delete this assignment?",
'Delete Assignment',
'Are you sure you want to delete this assignment?',
[
{
text: "Cancel",
style: "cancel"
text: 'Cancel',
style: 'cancel',
},
{
text: "Delete",
style: "destructive",
text: 'Delete',
style: 'destructive',
onPress: async () => {
const { error } = await supabase.from("assignments").delete().eq("aId", aId);
const { error } = await supabase
.from('assignments')
.delete()
.eq('aId', assignmentId);
if (error) {
Alert.alert("Assignment could not be deleted, please try again");
Alert.alert('Assignment could not be deleted, please try again');
return;
}
Alert.alert("Assignment deleted successfully!");
if (sId) {
if (subjectId) {
try {
await CheckSubjectCompletion(sId);
await CheckSubjectCompletion(subjectId);
} catch {
Alert.alert("Failed to update subject status");
Alert.alert('Failed to update subject status');
}
}
GetAssignments(sId);
}
}
await GetAssignments(subjectId);
await GetSubject(subjectId);
Alert.alert('Assignment deleted successfully!');
},
},
]
)
}
);
};
const progress = assignments.length === 0 ? 0 : Math.round((assignments.filter(assignment => assignment.isCompleted).length / assignments.length) * 100);
const completedAssignments = assignments.filter((assignment) => assignment.isCompleted).length;
const totalAssignments = assignments.length;
const remainingAssignments = totalAssignments - completedAssignments;
const progress =
assignments.length === 0
? 0
: Math.round((completedAssignments / totalAssignments) * 100);
if (!subject) {
return (
<View style={defaultStyles.container}>
<View className="flex-1 bg-app-bg px-5 pt-6">
<Stack.Screen
options={{
title: "Details",
headerTitleStyle: defaultStyles.title,
headerLeft: () => {
return (
<View style={defaultStyles.buttonContainer}>
<Button title="Back" onPress={router.back} />
</View>
)
},
headerRight: () => {
return (
<View style={defaultStyles.buttonContainer}>
<Button title="Logout" onPress={async () => await supabase.auth.signOut()} />
</View>
)
},
title: 'Subject Details',
}}
/>
{!subject && (
<View style={defaultStyles.container}>
<Text style={defaultStyles.title}>Subject not found</Text>
</View>
)}
<View className="rounded-3xl border border-app-border bg-app-surface p-5">
<Text className="text-2xl font-bold text-text-main">
Subject not found
</Text>
<Text className="mt-2 text-base text-text-secondary">
The subject could not be loaded.
</Text>
{subject && (
<View style={defaultStyles.container}>
<View style={defaultStyles.container}>
<Text style={defaultStyles.title}>{subject.title}</Text>
<Text style={defaultStyles.body}>{subject.description}</Text>
<View style={defaultStyles.checkbox}>
{subject.isActive && <Text style={defaultStyles.checkboxMark}></Text>}
</View>
<Text style={defaultStyles.body}>{subject.lastChanged}</Text>
<View style={{ marginTop: 10 }}>
<Text style={{ marginBottom: 4 }}>{progress}%</Text>
<View
style={{
width: "100%",
height: 12,
backgroundColor: "#D9D9D9",
borderRadius: 999,
overflow: "hidden",
}}
<Pressable
className="mt-5 h-12 items-center justify-center rounded-2xl bg-accent"
onPress={() => router.back()}
>
<View
style={{
width: `${progress}%`,
height: "100%",
backgroundColor: "#4CAF50",
<Text className="text-base font-bold text-text-inverse">
Go back
</Text>
</Pressable>
</View>
</View>
);
}
const colorKey: SubjectColor = subject.color ?? 'slate';
const colorSet = SUBJECT_COLORS[colorKey];
const firstLetter = subject.title?.trim().charAt(0).toUpperCase() || 'S';
return (
<View className="flex-1 bg-app-bg">
<Stack.Screen
options={{
title: 'Subject Details',
headerRight: () => (
<Pressable
className="rounded-full bg-app-subtle px-4 py-2"
onPress={async () => await supabase.auth.signOut()}
>
<Text className="text-sm font-semibold text-text-secondary">
Logout
</Text>
</Pressable>
),
}}
/>
</View>
</View>
<Button title="Edit" onPress={() => router.push({pathname: "/subject/editSubject", params: { sId: subject.sId }})} />
<Button testID = "delete-subject-button" title="Delete" onPress={() => DeleteSubject(subject.sId)} />
<View style={defaultStyles.buttonContainer}>
<Button title="Create Assignment" onPress={() => router.push({pathname: "/assignment/createAssignment", params: { sId: subject.sId }})} />
</View>
</View>
<SectionList
className="flex-1"
contentContainerStyle={{
paddingHorizontal: 20,
paddingTop: 20,
paddingBottom: 32,
}}
sections={assignmentSections}
keyExtractor={(item) => item.aId}
renderSectionHeader={({ section: { title } }) => <Text style={defaultStyles.subtitle}>{title}</Text>}
showsVerticalScrollIndicator={false}
stickySectionHeadersEnabled={false}
ListHeaderComponent={
<View>
<View
className="rounded-3xl bg-app-surface p-5"
style={{
borderWidth: 1,
borderColor: colorSet.strong,
}}
>
<View className="flex-row items-center">
<View
className="mr-3 h-12 w-12 items-center justify-center rounded-2xl"
style={{ backgroundColor: colorSet.soft }}
>
<Text
className="text-base font-bold"
style={{ color: colorSet.strong }}
>
{firstLetter}
</Text>
</View>
<View className="flex-1">
<Text className="text-2xl font-bold text-text-main">
{subject.title}
</Text>
{subject.description ? (
<Text className="mt-1 text-sm leading-5 text-text-secondary">
{subject.description}
</Text>
) : (
<Text className="mt-1 text-sm leading-5 text-text-muted">
No description added.
</Text>
)}
</View>
<View className="ml-3">
<View
className="rounded-full px-3 py-1"
style={{ backgroundColor: colorSet.soft }}
>
<Text
className="text-xs font-semibold"
style={{ color: colorSet.strong }}
>
{subject.isActive ? 'Active' : 'Inactive'}
</Text>
</View>
</View>
</View>
<View className="mt-5">
<View className="mb-2 flex-row items-center justify-between">
<Text className="text-sm font-semibold text-text-secondary">
Assignment Progress
</Text>
<Text className="text-sm font-bold text-text-main">
{completedAssignments}/{totalAssignments}
</Text>
</View>
<View className="h-3 overflow-hidden rounded-full bg-app-subtle">
<View
className="h-full rounded-full"
style={{
width: `${progress}%`,
backgroundColor: colorSet.strong,
}}
/>
</View>
<Text className="mt-2 text-xs font-medium text-text-secondary">
{remainingAssignments === 0
? 'All assignments complete'
: `${remainingAssignments} assignment${
remainingAssignments === 1 ? '' : 's'
} remaining`}
</Text>
</View>
<Text className="mt-4 text-sm text-text-muted">
Last changed: {formatDateTime(subject.lastChanged)}
</Text>
<View className="mt-5 flex-row border-t border-app-border pt-5">
<Pressable
className="mr-3 flex-1 items-center justify-center rounded-2xl border border-app-border bg-app-subtle py-3"
onPress={() =>
router.push({
pathname: '/subject/upsertSubject',
params: { sId: subject.sId },
})
}
>
<Text className="text-sm font-bold text-text-secondary">
Edit
</Text>
</Pressable>
<Pressable
className="flex-1 items-center justify-center rounded-2xl border border-app-border bg-app-surface py-3"
onPress={() => DeleteSubject(subject.sId)}
>
<Text className="text-sm font-bold text-status-danger">
Delete
</Text>
</Pressable>
</View>
</View>
<Pressable
className="mb-6 mt-5 h-14 items-center justify-center rounded-2xl bg-accent"
onPress={() =>
router.push({
pathname: '/assignment/upsertAssignment',
params: { sId: subject.sId },
})
}
>
<Text className="text-base font-bold text-text-inverse">
Create Assignment
</Text>
</Pressable>
</View>
}
renderSectionHeader={({ section: { title, data } }) => (
<View className="mb-3 mt-2 flex-row items-center justify-between">
<Text className="text-lg font-bold text-text-main">{title}</Text>
<View className="rounded-full bg-app-subtle px-3 py-1">
<Text className="text-xs font-semibold text-text-muted">
{data.length}
</Text>
</View>
</View>
)}
renderItem={({ item }) => {
const isOwner = session?.user.id === item.uId;
return (
<View style={defaultStyles.container}>
<Pressable style={defaultStyles.container} onPress={() => router.push({pathname: "/assignment/viewDetailsAssignment", params: { aId: item.aId }})}>
<Text style={defaultStyles.boldBody}>{item.title}</Text>
<Text style={defaultStyles.body}>{item.deadline}</Text>
<View style={defaultStyles.checkbox}>
{item.isCompleted && <Text style={defaultStyles.checkboxMark}></Text>}
<View
className="mb-4 rounded-3xl border border-app-border bg-app-surface p-4"
style={{
borderColor: colorSet.strong,
}}
>
<Pressable
onPress={() =>
router.push({
pathname: '/assignment/viewDetailsAssignment',
params: { aId: item.aId },
})
}
>
<View className="flex-row items-center">
<View className="flex-1">
<Text
className={`text-base font-bold ${
item.isCompleted ? 'text-text-secondary' : 'text-text-main'
}`}
>
{item.title}
</Text>
{item.description ? (
<Text
className="mt-1 text-sm leading-5 text-text-muted"
numberOfLines={2}
>
{item.description}
</Text>
) : null}
<Text className="mt-2 text-sm text-text-secondary">
Deadline: {formatDate(item.deadline)}
</Text>
</View>
</View>
</Pressable>
{isOwner && (
<View style={defaultStyles.buttonContainer}>
<Button title="Edit" onPress={() => router.push({pathname: "/assignment/editAssignment", params: { aId: item.aId }})} />
<Button title="Delete" onPress={() => DeleteAssignment(item.aId, item.sId)} />
<View className="mt-4 flex-row border-t border-app-border pt-4">
<Pressable
className="mr-3 flex-1 items-center justify-center rounded-2xl border border-app-border bg-app-subtle py-3"
onPress={() =>
router.push({
pathname: '/assignment/upsertAssignment',
params: { aId: item.aId },
})
}
>
<Text className="text-sm font-bold text-text-secondary">
Edit
</Text>
</Pressable>
<Pressable
className="flex-1 items-center justify-center rounded-2xl border border-app-border bg-app-surface py-3"
onPress={() => DeleteAssignment(item.aId, item.sId)}
>
<Text className="text-sm font-bold text-status-danger">
Delete
</Text>
</Pressable>
</View>
)}
</View>
@@ -221,17 +445,19 @@ export default function ViewDetailsSubject() {
}}
renderSectionFooter={({ section }) =>
section.data.length === 0 ? (
<View style={defaultStyles.container}>
<Text style={defaultStyles.body}>{section.emptyMessage}</Text>
<View style={defaultStyles.separator} />
<View className="mb-6 rounded-3xl border border-app-border bg-app-surface p-5">
<Text className="text-center text-base font-semibold text-text-secondary">
{section.emptyMessage}
</Text>
<Text className="mt-1 text-center text-sm text-text-muted">
Assignments for this subject will show up here.
</Text>
</View>
) : (
<View style={defaultStyles.separator} />
<View className="mb-2" />
)
}
/>
</View>
)}
</View>
);
}

View File

@@ -3,8 +3,7 @@ import { Stack } from "expo-router";
export default function TaskLayout() {
return (
<Stack>
<Stack.Screen name="createTask" options={{ title: "Create Task" }} />
<Stack.Screen name="editTask" options={{ title: "Edit Task" }} />
<Stack.Screen name="upsertTask" options={{ title: "Create Task" }} />
<Stack.Screen name="viewDetailsTask" options={{ title: "Task Details" }} />
</Stack>
);

View File

@@ -1,136 +0,0 @@
import { defaultStyles } from '@/constants/defaultStyles';
import { CheckAssignmentCompletion } from '@/lib/progress';
import { supabase } from '@/lib/supabase';
import type { Task } from '@/lib/types';
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 EditTask() {
const { tId } = useLocalSearchParams<{ tId: string }>();
const [task, SetTask] = useState<Task | null>(null)
const [isSaving, SetIsSaving] = useState(false);
const GetTask = async (tId: string) => {
const { data, error } = await supabase.from("tasks").select("*").eq("tId", tId).single();
if (error) {
Alert.alert("Task could not be fetched, please try again");
return;
}
SetTask(data ?? null);
}
useFocusEffect(
useCallback(() => {
if (tId) {
GetTask(tId);
}
}, [tId])
);
const EditTask = async () => {
if (!task) return;
if(task.title.trim() === '') {
Alert.alert("Title is required!");
return;
}
const { data, error: userError } = await supabase.auth.getUser();
if(userError || !data.user) {
router.replace("../createUser");
return;
}
SetIsSaving(true);
const { error: dbError } = await supabase.from("tasks").update({
title: task.title,
description: task.description,
isCompleted: task.isCompleted,
lastChanged: new Date().toISOString(),
uId: data.user.id,
aId: task.aId,
}).eq("tId", tId);
SetIsSaving(false);
if (dbError) {
Alert.alert("Task could not be edited, please try again");
return;
}
Alert.alert("Task successfully edited!");
if (task.aId) {
try {
await CheckAssignmentCompletion(task.aId);
} catch {
Alert.alert("Failed to update assignment completion state");
}
}
router.back();
}
return (
<View style={defaultStyles.container}>
<Stack.Screen
options={{
title: "Edit Task",
headerTitleStyle: defaultStyles.title
}}
/>
{!task && (
<View style={defaultStyles.container}>
<Text style={defaultStyles.title}>Task not found</Text>
</View>
)}
{task && (
<View style={defaultStyles.container}>
<Text style={defaultStyles.title}>Edit Task</Text>
<KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === "ios" ? "padding" : "height"}>
<TouchableWithoutFeedback onPress={Keyboard.dismiss}>
<View style={defaultStyles.container}>
<TextInput
testID="task-title-input"
style={defaultStyles.inputText}
placeholder="Title"
value={task.title}
onChangeText={(text) => SetTask(prev => prev ? { ...prev, title: text } : prev)}
/>
<TextInput
style={defaultStyles.inputText}
placeholder="Text"
value={task.description}
onChangeText={(text) => SetTask(prev => prev ? { ...prev, description: text } : prev)}
/>
<Pressable
onPress={() => SetTask(prev => prev ? { ...prev, isCompleted: !prev.isCompleted } : prev)}
style={defaultStyles.checkboxContainer}
>
<View style={defaultStyles.checkbox}>
{task.isCompleted && <Text style={defaultStyles.checkboxMark}></Text>}
</View>
<Text style={defaultStyles.checkboxLabel}>{task.isCompleted ? 'Completed' : 'Not Completed'}</Text>
</Pressable>
<Button testID="edit-task-button" title={isSaving ? "Saving..." : "Save"} onPress={EditTask} disabled={isSaving} />
{isSaving && (
<ActivityIndicator size="large" />
)}
<Button title="Cancel" onPress={() => router.back()} />
</View>
</TouchableWithoutFeedback>
</KeyboardAvoidingView>
</View>
)}
</View>
)
}

View File

@@ -1,8 +1,9 @@
import { defaultStyles } from '@/constants/defaultStyles';
import { CheckAssignmentCompletion } from '@/lib/progress';
import { supabase } from '@/lib/supabase';
import type { Task } from '@/lib/types';
import { router, Stack, useLocalSearchParams } from 'expo-router';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import {
ActivityIndicator,
Alert,
@@ -17,15 +18,57 @@ import {
View,
} from 'react-native';
export default function CreateTask() {
const aId = (useLocalSearchParams().aId as string) ?? null;
export default function UpsertTask() {
const { tId, aId: routeAId } = useLocalSearchParams<{
tId?: string;
aId?: string;
}>();
const isEditMode = Boolean(tId);
const [title, SetTitle] = useState('');
const [description, SetDescription] = useState('');
const [isCompleted, SetIsCompleted] = useState(false);
const [assignmentId, SetAssignmentId] = useState<string | null>(routeAId ?? null);
const [isLoading, SetIsLoading] = useState(isEditMode);
const [isSaving, SetIsSaving] = useState(false);
const CreateTask = async () => {
useEffect(() => {
if (!isEditMode || !tId) {
SetIsLoading(false);
return;
}
const loadTask = async () => {
SetIsLoading(true);
const { data, error } = await supabase
.from('tasks')
.select('*')
.eq('tId', tId)
.single();
SetIsLoading(false);
if (error || !data) {
Alert.alert('Task could not be loaded, please try again');
router.back();
return;
}
const task = data as Task;
SetTitle(task.title ?? '');
SetDescription(task.description ?? '');
SetIsCompleted(task.isCompleted ?? false);
SetAssignmentId(task.aId ?? routeAId ?? null);
};
loadTask();
}, [isEditMode, tId, routeAId]);
const handleSubmit = async () => {
if (title.trim() === '') {
Alert.alert('Title is required!');
return;
@@ -34,42 +77,55 @@ export default function CreateTask() {
const { data, error: userError } = await supabase.auth.getUser();
if (userError || !data.user) {
router.replace('../createUser');
router.replace('/login');
return;
}
if (!assignmentId) {
Alert.alert('Missing assignment', 'This task is not linked to an assignment.');
return;
}
SetIsSaving(true);
const { error: dbError } = await supabase.from('tasks').insert({
const payload = {
title: title.trim(),
description: description.trim(),
isCompleted,
lastChanged: new Date().toISOString(),
uId: data.user.id,
aId,
});
aId: assignmentId,
};
if (dbError) {
const result =
isEditMode && tId
? await supabase.from('tasks').update(payload).eq('tId', tId)
: await supabase.from('tasks').insert(payload);
if (result.error) {
SetIsSaving(false);
Alert.alert('Task could not be created, please try again');
Alert.alert(
isEditMode
? 'Task could not be updated, please try again'
: 'Task could not be created, please try again'
);
return;
}
Alert.alert('Task successfully created!');
if (aId) {
try {
await CheckAssignmentCompletion(aId);
await CheckAssignmentCompletion(assignmentId);
} catch {
Alert.alert("Failed to update assignment completion state");
}
SetIsSaving(false);
Alert.alert('Failed to update assignment completion state');
return;
}
SetTitle('');
SetDescription('');
SetIsCompleted(false);
SetIsSaving(false);
Alert.alert(
isEditMode ? 'Task successfully updated!' : 'Task successfully created!'
);
router.back();
};
@@ -78,11 +134,19 @@ export default function CreateTask() {
const labelClassName = 'mb-2 text-sm font-semibold text-text-secondary';
if (isLoading) {
return (
<View className="flex-1 items-center justify-center bg-app-bg">
<ActivityIndicator size="large" />
</View>
);
}
return (
<>
<Stack.Screen
options={{
title: 'Create Task',
title: isEditMode ? 'Edit Task' : 'Create Task',
headerTitleStyle: defaultStyles.title,
}}
/>
@@ -104,10 +168,12 @@ export default function CreateTask() {
>
<View className="mb-6">
<Text className="text-3xl font-bold text-text-main">
Create Task
{isEditMode ? 'Edit Task' : 'Create Task'}
</Text>
<Text className="mt-2 text-base leading-6 text-text-secondary">
Add a small step to move this assignment forward.
{isEditMode
? 'Update this task and keep your assignment moving forward.'
: 'Add a small step to move this assignment forward.'}
</Text>
</View>
@@ -118,6 +184,7 @@ export default function CreateTask() {
testID="task-title-input"
className={inputClassName}
placeholder="Enter task title"
placeholderTextColor="#9CA3AF"
value={title}
onChangeText={SetTitle}
returnKeyType="next"
@@ -129,6 +196,7 @@ export default function CreateTask() {
<TextInput
className={`${inputClassName} min-h-28`}
placeholder="Add a short description"
placeholderTextColor="#9CA3AF"
value={description}
onChangeText={SetDescription}
multiline
@@ -174,19 +242,19 @@ export default function CreateTask() {
className={`h-14 items-center justify-center rounded-2xl ${
isSaving ? 'bg-accent-disabled' : 'bg-accent'
}`}
onPress={CreateTask}
onPress={handleSubmit}
disabled={isSaving}
>
{isSaving ? (
<View className="flex-row items-center">
<ActivityIndicator size="small" />
<Text className="ml-3 text-base font-bold text-text-inverse">
Creating...
{isEditMode ? 'Saving...' : 'Creating...'}
</Text>
</View>
) : (
<Text className="text-base font-bold text-text-inverse">
Create Task
{isEditMode ? 'Save Changes' : 'Create Task'}
</Text>
)}
</Pressable>

View File

@@ -1,37 +1,89 @@
import { defaultStyles } from '@/constants/defaultStyles';
import { formatDateTime } from '@/lib/date';
import { CheckAssignmentCompletion } from '@/lib/progress';
import { getSubjectColorSet, type SubjectColor } from '@/lib/subjectColors';
import { supabase } from '@/lib/supabase';
import type { Task } from '@/lib/types';
import { Session } from '@supabase/supabase-js';
import { router, Stack, useFocusEffect, useLocalSearchParams } from 'expo-router';
import { useCallback, useEffect, useState } from 'react';
import { Alert, Button, Text, View } from "react-native";
import { Alert, Pressable, Text, View } from 'react-native';
export default function ViewDetailsTask() {
const { tId } = useLocalSearchParams<{ tId: string }>();
const [task, SetTask] = useState<Task | null>(null)
const [session, SetSession] = useState<Session | null>(null)
const [task, SetTask] = useState<Task | null>(null);
const [session, SetSession] = useState<Session | null>(null);
const [contextMeta, setContextMeta] = useState({
subjectTitle: 'No Subject',
assignmentTitle: 'No Assignment',
subjectColor: 'slate' as SubjectColor,
});
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) => {
SetSession(newSession)
})
return () => sub.subscription.unsubscribe()
},
[])
SetSession(newSession);
});
const GetTask = async (tId: string) => {
const { data, error } = await supabase.from("tasks").select("*").eq("tId", tId).single();
return () => sub.subscription.unsubscribe();
}, []);
if (error) {
Alert.alert("Task could not be fetched, please try again");
const GetTask = async (taskId: string) => {
const { data, error } = await supabase
.from('tasks')
.select('*')
.eq('tId', taskId)
.single();
if (error || !data) {
Alert.alert('Task could not be fetched, please try again');
return;
}
SetTask(data ?? null);
SetTask(data);
if (data.aId) {
const { data: assignmentData, error: assignmentError } = await supabase
.from('assignments')
.select('title, sId')
.eq('aId', data.aId)
.single();
if (assignmentError || !assignmentData) {
setContextMeta({
subjectTitle: 'Unknown Subject',
assignmentTitle: 'Unknown Assignment',
subjectColor: 'slate',
});
return;
}
if (assignmentData.sId) {
const { data: subjectData, error: subjectError } = await supabase
.from('subjects')
.select('title, color')
.eq('sId', assignmentData.sId)
.single();
if (subjectError || !subjectData) {
setContextMeta({
subjectTitle: 'Unknown Subject',
assignmentTitle: assignmentData.title ?? 'Unknown Assignment',
subjectColor: 'slate',
});
return;
}
setContextMeta({
subjectTitle: subjectData.title ?? 'Unknown Subject',
assignmentTitle: assignmentData.title ?? 'Unknown Assignment',
subjectColor: (subjectData.color as SubjectColor | undefined) ?? 'slate',
});
}
}
};
useFocusEffect(
useCallback(() => {
if (session && tId) {
@@ -40,89 +92,207 @@ export default function ViewDetailsTask() {
}, [session, tId])
);
const DeleteTask = async (tId: string) => {
const DeleteTask = async (taskId: string) => {
Alert.alert(
"Delete Task",
"Are you sure you want to delete this task?",
'Delete Task',
'Are you sure you want to delete this task?',
[
{
text: "Cancel",
style: "cancel"
text: 'Cancel',
style: 'cancel',
},
{
text: "Delete",
style: "destructive",
text: 'Delete',
style: 'destructive',
onPress: async () => {
const { error } = await supabase.from("tasks").delete().eq("tId", tId);
const { error } = await supabase
.from('tasks')
.delete()
.eq('tId', taskId);
if (error) {
Alert.alert("Task could not be deleted, please try again");
Alert.alert('Task could not be deleted, please try again');
return;
}
Alert.alert("Task deleted successfully!");
const aId = task?.aId;
if (aId) {
try {
await CheckAssignmentCompletion(aId);
} catch {
Alert.alert("Failed to update assignment completion state");
Alert.alert('Failed to update assignment completion state');
}
}
Alert.alert('Task deleted successfully!');
router.back();
}
}
},
},
]
)
}
);
};
const colorSet = getSubjectColorSet(contextMeta.subjectColor);
if (!task) {
return (
<View style={defaultStyles.container}>
<View className="flex-1 bg-app-bg px-5 pt-6">
<Stack.Screen
options={{
title: "Details",
headerTitleStyle: defaultStyles.title,
headerLeft: () => {
return (
<View style={defaultStyles.buttonContainer}>
<Button title="Back" onPress={router.back} />
</View>
)
},
headerRight: () => {
return (
<View style={defaultStyles.buttonContainer}>
<Button title="Logout" onPress={async () => await supabase.auth.signOut()} />
</View>
)
},
title: 'Task Details',
headerRight: () => (
<Pressable
className="rounded-full bg-app-subtle px-4 py-2"
onPress={async () => await supabase.auth.signOut()}
>
<Text className="text-sm font-semibold text-text-secondary">
Logout
</Text>
</Pressable>
),
}}
/>
{!task && (
<View style={defaultStyles.container}>
<Text style={defaultStyles.title}>Task not found</Text>
<View
className="rounded-3xl bg-app-surface p-5"
style={{
borderWidth: 1,
borderColor: colorSet.strong,
}}
>
<Text className="text-2xl font-bold text-text-main">
Task not found
</Text>
<Text className="mt-2 text-base text-text-secondary">
The task could not be loaded.
</Text>
<Pressable
className="mt-5 h-12 items-center justify-center rounded-2xl bg-accent"
onPress={() => router.back()}
>
<Text className="text-base font-bold text-text-inverse">
Go back
</Text>
</Pressable>
</View>
</View>
);
}
const isOwner = session?.user.id === task.uId;
return (
<View className="flex-1 bg-app-bg px-5 pt-6">
<Stack.Screen
options={{
title: 'Task Details',
headerRight: () => (
<Pressable
className="rounded-full bg-app-subtle px-4 py-2"
onPress={async () => await supabase.auth.signOut()}
>
<Text className="text-sm font-semibold text-text-secondary">
Logout
</Text>
</Pressable>
),
}}
/>
<View
className="rounded-3xl bg-app-surface p-5"
style={{
borderWidth: 1,
borderColor: colorSet.strong,
}}
>
<View className="flex-row items-start">
<View
className="mr-3 mt-1 h-6 w-6 items-center justify-center rounded-md border-2"
style={{
borderColor: task.isCompleted ? colorSet.strong : '#DDD6C8',
backgroundColor: task.isCompleted ? colorSet.strong : '#EFEBE3',
}}
>
{task.isCompleted && (
<Text className="text-sm font-bold text-text-inverse"></Text>
)}
</View>
<View className="flex-1">
<Text
className={`text-2xl font-bold ${
task.isCompleted ? 'text-text-secondary' : 'text-text-main'
}`}
>
{task.title}
</Text>
{task.description ? (
<Text className="mt-3 text-base leading-6 text-text-secondary">
{task.description}
</Text>
) : (
<Text className="mt-3 text-base text-text-muted">
No description added.
</Text>
)}
{task && (
<View style={defaultStyles.container}>
<Text style={defaultStyles.title}>{task.title}</Text>
<Text style={defaultStyles.body}>{task.description}</Text>
<View style={defaultStyles.checkbox}>
{task.isCompleted && <Text style={defaultStyles.checkboxMark}></Text>}
<View className="mt-4 flex-row flex-wrap">
<View
className="mr-2 mb-2 rounded-full px-3 py-1"
style={{ backgroundColor: colorSet.soft }}
>
<Text
className="text-xs font-semibold"
style={{ color: colorSet.strong }}
>
{contextMeta.subjectTitle}
</Text>
</View>
<Text style={defaultStyles.body}>{task.lastChanged}</Text>
<View style={defaultStyles.buttonContainer}>
<Button title="Edit" onPress={() => router.push({pathname: "/task/editTask", params: { tId: task.tId }})} />
<Button testID = "delete-task-button" title="Delete" onPress={() => DeleteTask(task.tId)} />
<View className="mr-2 mb-2 rounded-full bg-app-subtle px-3 py-1">
<Text className="text-xs font-semibold text-text-secondary">
{contextMeta.assignmentTitle}
</Text>
</View>
</View>
<Text className="mt-2 text-sm text-text-muted">
Last changed: {formatDateTime(task.lastChanged)}
</Text>
</View>
</View>
{isOwner && (
<View className="mt-5 flex-row border-t border-app-border pt-5">
<Pressable
className="mr-3 flex-1 items-center justify-center rounded-2xl border border-app-border bg-app-subtle py-3"
onPress={() =>
router.push({
pathname: '/task/upsertTask',
params: { tId: task.tId },
})
}
>
<Text className="text-sm font-bold text-text-secondary">
Edit
</Text>
</Pressable>
<Pressable
className="flex-1 items-center justify-center rounded-2xl border border-app-border bg-app-surface py-3"
onPress={() => DeleteTask(task.tId)}
>
<Text className="text-sm font-bold text-status-danger">
Delete
</Text>
</Pressable>
</View>
)}
</View>
</View>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 551 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 551 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 551 KiB

29
lib/date.ts Normal file
View File

@@ -0,0 +1,29 @@
export const formatDate = (value?: string | null) => {
if (!value) return 'No date';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
});
};
export const formatDateTime = (value?: string | null) => {
if (!value) return 'Unknown';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
};

58
lib/subjectColors.ts Normal file
View File

@@ -0,0 +1,58 @@
export type SubjectColor =
| 'blue'
| 'emerald'
| 'amber'
| 'violet'
| 'cyan'
| 'rose'
| 'slate';
export const SUBJECT_COLORS: Record<
SubjectColor,
{ soft: string; strong: string; label: string }
> = {
blue: {
soft: '#DCEFF5',
strong: '#2F6F88',
label: 'Blue',
},
emerald: {
soft: '#DDEFE5',
strong: '#2F7D55',
label: 'Emerald',
},
amber: {
soft: '#F6E8C6',
strong: '#9A6A16',
label: 'Amber',
},
violet: {
soft: '#E9E2F5',
strong: '#6D4BA3',
label: 'Violet',
},
cyan: {
soft: '#DDF0EF',
strong: '#287C7A',
label: 'Cyan',
},
rose: {
soft: '#F4E1DF',
strong: '#9B4A43',
label: 'Rose',
},
slate: {
soft: '#E8E4DA',
strong: '#52616B',
label: 'Slate',
},
};
export const SUBJECT_COLOR_KEYS = Object.keys(
SUBJECT_COLORS
) as SubjectColor[];
export const getSubjectColorSet = (color?: SubjectColor) => {
const colorKey: SubjectColor = color ?? 'slate';
return SUBJECT_COLORS[colorKey];
};

View File

@@ -1,3 +1,5 @@
import type { SubjectColor } from '@/lib/subjectColors';
export type Task = {
tId: string;
title: string;
@@ -26,4 +28,5 @@ export type Subject = {
isActive: boolean;
lastChanged: string;
uId: string;
color?: SubjectColor;
};

View File

@@ -0,0 +1,92 @@
# Timer Element Work Report
## #Overview
This note documents the timer work completed by **Chris Sanden** in the Study-Sprint project.
The git history shows a dedicated timer commit:
- Commit: `d50301cb04837b196110cea43ff15c0493c5fac2`
- Short hash: `d50301c`
- Author: `Chris Sanden <c.sanden@outlook.com>`
- Date: `2026-04-21`
- Message: `First draft of timer element`
- File added: `app/(tabs)/timer.tsx`
- Branch references at inspection time: `timer`, `origin/timer`
---
## #ImplementedFeatures
### #TimerTab
Created the first draft of a standalone timer screen:
- Added `app/(tabs)/timer.tsx`
- Implemented the timer as its own tab while the final task-start flow is still planned
- Used React Native and Expo tab routing conventions already present in the project
---
### #DurationSelector
Implemented a horizontal animated selector for timer durations:
- Uses `Animated.FlatList`
- Supports snap scrolling with `snapToInterval`
- Shows selectable durations from `1` to `60`
- Uses scaled and faded text animation so the centered duration is emphasized
- Updates the selected duration when scrolling ends
---
### #TimerAnimation
Implemented the first timer start animation:
- Added a circular start button
- Button fades and moves down after the timer starts
- Timer overlay animates into view
- Timer overlay then animates out based on the selected duration
- Uses `Animated.sequence` and `useNativeDriver`
---
## #UserInterface
The timer screen includes:
- Full-screen dark background
- Red timer overlay
- Large centered duration numbers
- Circular red start button near the bottom of the screen
- Hidden status bar for a focused timer view
The visual direction is a simple first draft intended to make the timer interaction testable before deeper integration with tasks.
---
## #PlannedIntegration
The in-code note describes the intended next step:
- Keep the timer as a separate tab initially
- Later open the timer when a user starts a task
- Replace the current duration-number area with task information such as:
- Task name
- Task description
- Potentially add an animated character or visual element if time allows
---
## #GitEvidence
The work attributed to Chris is supported by this git log entry:
```text
d50301c Chris Sanden 2026-04-21 First draft of timer element
```
The commit added one new file:
```text
A app/(tabs)/timer.tsx
```
The file was later also touched in commit `cb6368a` by `Teodor` on `2026-04-22` as part of broader UI and routing fixes. The original timer implementation documented here is the `d50301c` commit authored by Chris.
---
## #Conclusion
Chris implemented the first functional timer draft for the application. The work established a standalone timer tab, duration selection, animated start behavior, and a clear path for later connecting the timer to task-start workflows.

View File

@@ -0,0 +1,151 @@
# Timer UI and Countdown Work Report
## #Overview
Today the standalone timer screen was developed further before wiring it into the task system.
The main focus was improving the timer interaction and learning how the React Native animation flow works. The timer is still being treated as its own tab for now, with placeholder task data used in place of real task integration.
---
## #ImplementedFeatures
### #TaskInformationPlaceholder
Added placeholder task information to the timer screen:
- Placeholder task name
- Placeholder task description
- Fade-in animation when the timer starts
- Fade-out animation when the timer finishes
This prepares the timer UI for the later task integration, where the placeholder values can be replaced by real task data.
---
### #AdjacentTimerFade
Updated the timer duration selector so adjacent numbers fade away when the timer starts:
- The centered selected value remains visible
- Neighboring values fade out during the active timer state
- Neighboring values are intended to fade back in after the timer finishes
This was implemented by separating the normal picker opacity from the active timer opacity and combining them with `Animated.add` and `Animated.multiply`.
---
### #MeasuredTimerHeight
Started adjusting the timer overlay to use the measured screen/container height:
- Added `containerHeight`
- Added `onLayout` to measure the actual timer screen area
- Updated timer overlay movement to use the measured container height
This was done because the full window height does not always match the visible tab screen area when headers, tab bars, or safe areas are involved.
---
### #CountdownDisplay
Added countdown display logic:
- Added `timeRemaining`
- Added `selectedIndex`
- Added `formatTime(totalSeconds)`
- Converted the selected timer value into a `MM:SS` display while running
- Added `TIMER_UNIT_IN_SECONDS` so timer values can behave as seconds during development and minutes later
Current development behavior:
- `TIMER_UNIT_IN_SECONDS = 1`
- Selecting `5` means a 5-second timer
Planned production behavior:
- `TIMER_UNIT_IN_SECONDS = 60`
- Selecting `5` means a 5-minute timer
---
### #CountdownFadeControl
Started separating countdown visibility from the rest of the timer UI:
- Added `countdownAnimation`
- Added `showCountdownText`
- Began separating the `MM:SS` countdown fade from the button and picker fade
- Fixed the nested animation callback syntax after adding the countdown fade-out flow
The goal is for the countdown text to fade out first, then for the button and adjacent timer values to fade back in after the countdown is gone.
---
## #LearningNotes
### #ReactState
Worked with several pieces of state:
- `duration` stores the selected timer value
- `isRunning` tracks whether the timer is active
- `timeRemaining` stores the countdown value
- `selectedIndex` identifies which duration is selected
- `showCountdownText` controls whether the selected item renders as `MM:SS`
- `containerHeight` stores the measured height of the timer screen
Important distinction:
- State values trigger re-renders when changed
- Animated values drive smooth visual changes without normal React state updates on every animation frame
---
### #Hooks
Clarified where hooks are allowed:
- `useState`, `useRef`, `useEffect`, and `useCallback` must be called inside the component
- Hooks must not be placed inside callbacks, conditionals, loops, or event handlers
- `useEffect` dependency arrays must be inside the `useEffect(...)` call
One key bug came from an effect without a proper dependency array. Because the countdown updates state every second, the effect ran every second and reset the red overlay position.
---
### #AnimationFlow
The timer now uses multiple animated values:
- `timerAnimation` controls the red overlay movement
- `buttonAnimation` controls the start button and inactive timer value visibility
- `taskDetailsAnimation` controls the placeholder task information
- `countdownAnimation` controls the `MM:SS` countdown visibility
The main lesson was that one animation value should not control too many unrelated visual states. Separate animation values make it easier to control the order of fade-out and fade-in transitions.
---
## #Verification
The timer file syntax issue around the end of the `animation` callback was fixed.
Current lint result:
```text
npm run lint
exited successfully
```
The previous parse error was caused by mismatched closing braces/parentheses near the nested `.start(...)` callbacks at the end of the animation sequence.
The remaining behavior to confirm is the final transition order:
- `MM:SS` countdown should fade out
- selected text should switch back to the normal timer value while hidden
- adjacent timer values should fade back in
- start button should fade back in
---
## #FilesChanged
Main file worked on:
```text
app/(tabs)/timer.tsx
```
New note added:
```text
notes/work-report-timer-2026-04-22.md
```
---
## #Conclusion
The timer UI moved from a basic animated duration selector toward a more complete timer experience. It now has placeholder task information, a `MM:SS` countdown concept, measured layout support, and separate animation values for different UI elements.
The syntax error at the end of the animation callback has been fixed and lint now passes. The remaining immediate work is to finish confirming the final fade-out/fade-in ordering so the countdown disappears cleanly before the picker and start button return.

View File

@@ -0,0 +1,141 @@
# Timer Interaction and Cancel Flow Work Report
## #Overview
Today the standalone timer screen was developed further with a focus on the cancel interaction, countdown reset order, and a progress cue inside the cancel button.
The main work was not just adding UI pieces, but understanding how the existing React Native `Animated` flow behaves when a timer is started, cancelled, or allowed to finish naturally. The timer is still being treated as its own tab with placeholder task information, but the interaction model is now closer to the intended study-session behavior.
---
## #ImplementedFeatures
### #CancelButton
Added a dedicated cancel control for the active timer state:
- Added a separate cancel button animation value
- Added a bottom-positioned cancel button that appears only during the running state
- Added reverse handling so the button can be dismissed again when cancelling manually or when the timer finishes
The main goal was to keep the original large start control as the primary entry point, while giving the active timer state its own secondary exit action.
---
### #CancelProgressCue
Started adding a progress cue directly inside the cancel button:
- Added a separate `cancelProgressAnimation`
- Added an inner animated fill layer inside the cancel button
- Changed the progress direction to move left-to-right inside the button instead of using a full-button opacity fade
This was done to match the visual language of the main red timer overlay while keeping the progress indicator smaller and more local to the cancel action.
---
### #DurationLocking
Updated the duration selector to stay fixed while the timer is running:
- Added `scrollEnabled={!timerIsRunning}` to the horizontal timer picker
- Added an early return inside `onMomentumScrollEnd`
- Prevented the selected timer duration from changing once a session has started
This keeps the timer state consistent after the session begins and avoids the picker drifting into a visually different value while the countdown is active.
---
### #CountdownOwnership
Clarified how the countdown interval should be owned and reset:
- Added `countdownRef`
- Added interval clearing before starting a new countdown
- Used the ref-based interval handle so cancel and finish logic can target the active countdown
This work was needed because countdown behavior becomes unreliable if the code starts new intervals without keeping a consistent reference to the currently running one.
---
### #CancelFlowSequencing
Worked on the ordering of reverse animations during manual cancel:
- Tested separating countdown fade-out from the picker/start-button return
- Investigated why adjacent numbers were reappearing before the countdown text had fully finished reversing
- Traced the problem to both animation timing and the `showCountdownText` render condition
The important lesson here was that hiding the countdown visually and switching the rendered text back to the normal timer value are related, but not identical, events.
---
## #LearningNotes
### #AnimatedValueResponsibilities
Today reinforced that each `Animated.Value` should have one clear responsibility:
- `timerAnimation` controls the red overlay position
- `buttonAnimation` controls start-button disappearance and inactive picker return
- `countdownAnimation` controls countdown visibility
- `cancelButtonAnimation` controls the cancel button itself
- `cancelProgressAnimation` controls the left-to-right fill inside the cancel button
Several visual bugs came from trying to make one animated value carry two different meanings at the same time.
---
### #RenderStateVsAnimationState
A key distinction became clearer during the cancel-flow debugging:
- Animated values control motion and opacity
- Regular React state controls what text/content is actually rendered
One important example is `showCountdownText`:
- Even if the countdown has visually faded out, the selected timer item still renders `MM:SS` while `showCountdownText` remains `true`
- This means the UI can still appear to be in “countdown mode” even after part of the reverse animation has already completed
This is why some cancel-order issues were not purely animation problems.
---
### #SequenceVsParallel
The timer work also clarified when `Animated.sequence([...])` and `Animated.parallel([...])` should be used:
- `sequence` is for strict order
- `parallel` is for animations that should run at the same time
One mistake that surfaced during the progress-button work was placing the long progress-fill animation in a sequence after the main timer animation, which caused the fill to begin only after the timer had already ended.
---
## #CurrentIssue
The current timer screen still has remaining cancel-flow polish issues around visual timing and overlay cleanup.
The main issue currently under investigation is the reset order during manual cancel:
- the red timer overlay can still produce a visible flash/jump when the running animation is interrupted
- the adjacent picker numbers and selected countdown text are sensitive to both animation order and `showCountdownText`
- the current implementation needs further refinement so cancel feels deliberate instead of visually noisy
Current lint result:
```text
npm run lint
completed with 1 warning
```
Current warning:
- unnecessary `showCountdownText` dependency in one `useCallback`
There are no current lint errors, but the cancel interaction is not yet considered visually finished.
---
## #FilesChanged
Main file worked on:
```text
app/(tabs)/timer.tsx
```
New note added:
```text
notes/work-report-timer-2026-04-23.md
```
---
## #Conclusion
The timer screen moved further toward a complete active-session interaction today. It now has a dedicated cancel control, a left-to-right progress cue inside that control, a locked duration picker while running, and a clearer separation between countdown ownership and animation ownership.
The main remaining work is not basic feature addition, but interaction polish. In particular, the cancel sequence still needs refinement so the red overlay, countdown text, and adjacent timer values return in a clean and intentional order.

View File

@@ -0,0 +1,191 @@
# 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.
## Problems occuring after writing conclusion
Tried to implement sound by installing expo-audio. This caused the dependency list to update. The diff was massive, and something in the diff caused the entire timer page to break. Logic, animations - the lot. Have reverted back to last known working dependency list, as well as un-refactored a lot of code in an attempt to revert to a functioning state before figuring out that the culprit was dependencies. Need to figure our what is causing the critical failure in the new list.
## Todo
- Re-refactor to make code cleaner, more readable and easier to maintain.
- Figure out the dependency issues of later dependency lists
## Conclusion of dependecy saga
There was a mismatch in the nativewind dependency, with my one being ^4.2.3 and the other list being ^4.1.23. This cause my entire timer screen to fail. Animations got borked, buttons not working properly, duration picker only showing 2 indexes... the works. Solution - keepp nativewind dependency to ^4.2.3

View File

@@ -0,0 +1,163 @@
# Timer Refactor and Verification Work Report
## #Overview
Today the timer screen was worked on with a narrower goal than yesterday: not new interaction features, but cleanup, readability, and making the existing timer flow easier to understand and maintain.
This follows directly from yesterday's state. The April 24 note ended with two follow-up items:
- re-refactor the timer code so it becomes easier to read and work on
- keep the dependency situation stable after the NativeWind version mismatch had broken the screen
Today's work focused on the first of those. The interaction model was kept the same, but the internal structure of `timer.tsx` was cleaned up so the current hold-to-cancel and focus-mode behavior is easier to inspect without splitting the code into hooks or separate files.
---
## #ImplementedFeatures
### #TimerCodeRefactor
Refactored the timer screen structure inside `app/(tabs)/timer.tsx`:
- renamed the component from `App` to `TimerScreen`
- renamed unclear callbacks such as the old generic start-animation function into `startTimerSession`
- grouped the file more clearly into constants, animated values, refs, derived values, actions, render helpers, and JSX
- renamed vague animated/interpolated values to clearer names such as `startButtonOpacity`, `startButtonTranslateY`, and `pickerOpacity`
This did not change the screen architecture into multiple files. The cleanup stayed local to the timer file so the animation flow is still easy to inspect in one place.
---
### #CleanupHelpers
Extracted repeated timer cleanup work into small local helpers:
- added `clearCountdownInterval()`
- added `clearCancelHoldTimeouts()`
- added `stopRunningAnimations()`
- added `resetSessionValues()`
Before this, the same interval, timeout, and animation-reset work was spread across multiple callbacks. Pulling it into helpers makes it easier to follow what happens when a session starts, finishes, or is cancelled.
---
### #RenderStructureCleanup
Cleaned up the render section so it is easier to read:
- moved repeated inline layout styles into named `StyleSheet` entries
- extracted the timer picker item rendering into a local `renderTimerItem(...)` helper
- kept the JSX order aligned with the visible screen layers: overlay, start button, cancel button, countdown, duration picker, and task details
This mainly improves scanning. The old file worked, but the render section made you jump between inline style objects and animation expressions to understand each layer.
---
### #CommentAndNamingPass
Added a small number of comments only where the code was genuinely hard to follow:
- clarified that `timerAnimation` owns real timer progress
- clarified that `cancelOverlayAnimation` is only a temporary visual offset during hold-to-cancel
- clarified that `startProgressAnimation(fromY)` resumes overlay progress from the current Y position
- clarified why cancel acceleration starts after a short delay
The aim was not to comment every line, but to explain the parts that are hard to infer just by reading the code.
---
### #StateResetTightening
Made the session cleanup more explicit:
- reset `sessionStartedAtRef` and `sessionDurationMsRef` when a session ends
- reset cancel-hold flags during session cleanup
- made `finishTimer()` explicitly clear the countdown interval before running exit animations
- kept the existing unmount cleanup so intervals, timeouts, and running animations are not left behind if the screen disappears mid-session
These are small changes, but they make the timer lifecycle more predictable and reduce the amount of stale mutable state left around after finish or cancel paths.
---
## #LearningNotes
### #ReadableCodeVsNewFeatures
Today's timer work was a good reminder that "more maintainable" does not always mean "more abstract."
For this screen, the right cleanup level was:
- better names
- smaller local helpers
- clearer grouping
- a few targeted comments
The wrong cleanup level for the current stage would have been moving the logic into extra hooks or files too early, because that would make it harder to inspect the animation flow while the interaction is still being tuned.
---
### #MutableRefOwnership
The timer file still relies heavily on refs because several parts of the interaction are long-lived and imperative:
- active countdown interval
- running start animation
- running progress animation
- delayed cancel-preview start
- hold-to-cancel completion timeout
The cleanup made this easier to see by separating refs that hold animated values from refs that track mutable timer/session ownership.
---
## #CurrentState
Compared with yesterday, the timer interaction model is mostly the same, but the code behind it is more structured.
The current implementation:
- keeps the red overlay model used yesterday
- keeps `timerAnimation` as the real progress owner
- keeps `cancelOverlayAnimation` as the temporary hold-preview layer
- keeps the delayed hold acceleration and release recovery flow
- keeps all timer logic local to `timer.tsx`
- is now easier to read because repeated cleanup and render logic have been extracted into named local pieces
This means today's work was mainly a recovery and consolidation pass after yesterday's interaction-heavy changes and the earlier dependency-related breakage.
---
## #Verification
Today's static checks passed after the refactor:
```text
npm run lint
exited successfully
```
```text
npx tsc --noEmit
exited successfully
```
```text
git diff --check -- 'app/(tabs)/timer.tsx'
exited successfully
```
There was no new timer commit for today at the time of writing this note. The summary above is based on:
- the current working-tree diff for `app/(tabs)/timer.tsx`
- the verification commands run after the refactor
- yesterday's note and timer history for context
I did not do a live Expo interaction test inside this note workflow, so runtime behavior is verified statically plus by code review rather than by manually pressing through the UI.
---
## #FilesChanged
Main file worked on:
```text
app/(tabs)/timer.tsx
```
New note added:
```text
notes/work-report-timer-2026-04-25.md
```
---
## #Conclusion
The main timer work today was not adding new features, but making yesterday's feature-rich timer implementation is easier to continue working on.
The result is a timer file that keeps the same focus-mode and hold-to-cancel behavior, while being more readable, more structured, and easier to maintain. The biggest improvement is that the important ideas in the file now have clearer names, clearer ownership, and clearer cleanup paths.
The timer is now considered finished and ready to implement into the rest of the project.

678
package-lock.json generated
View File

@@ -30,7 +30,8 @@
"expo-symbols": "~1.0.8",
"expo-system-ui": "~6.0.9",
"expo-web-browser": "~15.0.10",
"nativewind": "^4.1.23",
"nativewind": "^4.2.3",
"patch-package": "^8.0.1",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "0.81.5",
@@ -40,7 +41,8 @@
"react-native-screens": "~4.16.0",
"react-native-url-polyfill": "^3.0.0",
"react-native-web": "~0.21.0",
"react-native-worklets": "0.5.1"
"react-native-worklets": "0.5.1",
"tailwindcss": "^3.4.19"
},
"devDependencies": {
"@testing-library/react-native": "^13.3.3",
@@ -1777,6 +1779,198 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@expo/cli": {
"version": "54.0.23",
"resolved": "https://registry.npmjs.org/@expo/cli/-/cli-54.0.23.tgz",
"integrity": "sha512-km0h72SFfQCmVycH/JtPFTVy69w6Lx1cHNDmfLfQqgKFYeeHTjx7LVDP4POHCtNxFP2UeRazrygJhlh4zz498g==",
"license": "MIT",
"dependencies": {
"@0no-co/graphql.web": "^1.0.8",
"@expo/code-signing-certificates": "^0.0.6",
"@expo/config": "~12.0.13",
"@expo/config-plugins": "~54.0.4",
"@expo/devcert": "^1.2.1",
"@expo/env": "~2.0.8",
"@expo/image-utils": "^0.8.8",
"@expo/json-file": "^10.0.8",
"@expo/metro": "~54.2.0",
"@expo/metro-config": "~54.0.14",
"@expo/osascript": "^2.3.8",
"@expo/package-manager": "^1.9.10",
"@expo/plist": "^0.4.8",
"@expo/prebuild-config": "^54.0.8",
"@expo/schema-utils": "^0.1.8",
"@expo/spawn-async": "^1.7.2",
"@expo/ws-tunnel": "^1.0.1",
"@expo/xcpretty": "^4.3.0",
"@react-native/dev-middleware": "0.81.5",
"@urql/core": "^5.0.6",
"@urql/exchange-retry": "^1.3.0",
"accepts": "^1.3.8",
"arg": "^5.0.2",
"better-opn": "~3.0.2",
"bplist-creator": "0.1.0",
"bplist-parser": "^0.3.1",
"chalk": "^4.0.0",
"ci-info": "^3.3.0",
"compression": "^1.7.4",
"connect": "^3.7.0",
"debug": "^4.3.4",
"env-editor": "^0.4.1",
"expo-server": "^1.0.5",
"freeport-async": "^2.0.0",
"getenv": "^2.0.0",
"glob": "^13.0.0",
"lan-network": "^0.1.6",
"minimatch": "^9.0.0",
"node-forge": "^1.3.3",
"npm-package-arg": "^11.0.0",
"ora": "^3.4.0",
"picomatch": "^3.0.1",
"pretty-bytes": "^5.6.0",
"pretty-format": "^29.7.0",
"progress": "^2.0.3",
"prompts": "^2.3.2",
"qrcode-terminal": "0.11.0",
"require-from-string": "^2.0.2",
"requireg": "^0.2.2",
"resolve": "^1.22.2",
"resolve-from": "^5.0.0",
"resolve.exports": "^2.0.3",
"semver": "^7.6.0",
"send": "^0.19.0",
"slugify": "^1.3.4",
"source-map-support": "~0.5.21",
"stacktrace-parser": "^0.1.10",
"structured-headers": "^0.4.1",
"tar": "^7.5.2",
"terminal-link": "^2.1.1",
"undici": "^6.18.2",
"wrap-ansi": "^7.0.0",
"ws": "^8.12.1"
},
"bin": {
"expo-internal": "build/bin/cli"
},
"peerDependencies": {
"expo": "*",
"expo-router": "*",
"react-native": "*"
},
"peerDependenciesMeta": {
"expo-router": {
"optional": true
},
"react-native": {
"optional": true
}
}
},
"node_modules/@expo/cli/node_modules/brace-expansion": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/@expo/cli/node_modules/ci-info": {
"version": "3.9.0",
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz",
"integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/sibiraj-s"
}
],
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/@expo/cli/node_modules/minimatch": {
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.2"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@expo/cli/node_modules/picomatch": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-3.0.2.tgz",
"integrity": "sha512-cfDHL6LStTEKlNilboNtobT/kEa30PtAf2Q1OgszfrG/rpVl1xaFWT9ktfkS306GmHgmnad1Sw4wabhlvFtsTw==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/@expo/cli/node_modules/resolve": {
"version": "1.22.12",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
"integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"is-core-module": "^2.16.1",
"path-parse": "^1.0.7",
"supports-preserve-symlinks-flag": "^1.0.0"
},
"bin": {
"resolve": "bin/resolve"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/@expo/cli/node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@expo/cli/node_modules/ws": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/@expo/code-signing-certificates": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@expo/code-signing-certificates/-/code-signing-certificates-0.0.6.tgz",
@@ -4718,9 +4912,9 @@
}
},
"node_modules/@xmldom/xmldom": {
"version": "0.8.12",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.12.tgz",
"integrity": "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg==",
"version": "0.8.13",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz",
"integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
@@ -4730,7 +4924,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz",
"integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==",
"dev": true,
"license": "BSD-2-Clause"
},
"node_modules/abab": {
@@ -8040,198 +8233,6 @@
"react-native": "*"
}
},
"node_modules/expo/node_modules/@expo/cli": {
"version": "54.0.23",
"resolved": "https://registry.npmjs.org/@expo/cli/-/cli-54.0.23.tgz",
"integrity": "sha512-km0h72SFfQCmVycH/JtPFTVy69w6Lx1cHNDmfLfQqgKFYeeHTjx7LVDP4POHCtNxFP2UeRazrygJhlh4zz498g==",
"license": "MIT",
"dependencies": {
"@0no-co/graphql.web": "^1.0.8",
"@expo/code-signing-certificates": "^0.0.6",
"@expo/config": "~12.0.13",
"@expo/config-plugins": "~54.0.4",
"@expo/devcert": "^1.2.1",
"@expo/env": "~2.0.8",
"@expo/image-utils": "^0.8.8",
"@expo/json-file": "^10.0.8",
"@expo/metro": "~54.2.0",
"@expo/metro-config": "~54.0.14",
"@expo/osascript": "^2.3.8",
"@expo/package-manager": "^1.9.10",
"@expo/plist": "^0.4.8",
"@expo/prebuild-config": "^54.0.8",
"@expo/schema-utils": "^0.1.8",
"@expo/spawn-async": "^1.7.2",
"@expo/ws-tunnel": "^1.0.1",
"@expo/xcpretty": "^4.3.0",
"@react-native/dev-middleware": "0.81.5",
"@urql/core": "^5.0.6",
"@urql/exchange-retry": "^1.3.0",
"accepts": "^1.3.8",
"arg": "^5.0.2",
"better-opn": "~3.0.2",
"bplist-creator": "0.1.0",
"bplist-parser": "^0.3.1",
"chalk": "^4.0.0",
"ci-info": "^3.3.0",
"compression": "^1.7.4",
"connect": "^3.7.0",
"debug": "^4.3.4",
"env-editor": "^0.4.1",
"expo-server": "^1.0.5",
"freeport-async": "^2.0.0",
"getenv": "^2.0.0",
"glob": "^13.0.0",
"lan-network": "^0.1.6",
"minimatch": "^9.0.0",
"node-forge": "^1.3.3",
"npm-package-arg": "^11.0.0",
"ora": "^3.4.0",
"picomatch": "^3.0.1",
"pretty-bytes": "^5.6.0",
"pretty-format": "^29.7.0",
"progress": "^2.0.3",
"prompts": "^2.3.2",
"qrcode-terminal": "0.11.0",
"require-from-string": "^2.0.2",
"requireg": "^0.2.2",
"resolve": "^1.22.2",
"resolve-from": "^5.0.0",
"resolve.exports": "^2.0.3",
"semver": "^7.6.0",
"send": "^0.19.0",
"slugify": "^1.3.4",
"source-map-support": "~0.5.21",
"stacktrace-parser": "^0.1.10",
"structured-headers": "^0.4.1",
"tar": "^7.5.2",
"terminal-link": "^2.1.1",
"undici": "^6.18.2",
"wrap-ansi": "^7.0.0",
"ws": "^8.12.1"
},
"bin": {
"expo-internal": "build/bin/cli"
},
"peerDependencies": {
"expo": "*",
"expo-router": "*",
"react-native": "*"
},
"peerDependenciesMeta": {
"expo-router": {
"optional": true
},
"react-native": {
"optional": true
}
}
},
"node_modules/expo/node_modules/brace-expansion": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/expo/node_modules/ci-info": {
"version": "3.9.0",
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz",
"integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/sibiraj-s"
}
],
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/expo/node_modules/minimatch": {
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.2"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/expo/node_modules/picomatch": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-3.0.2.tgz",
"integrity": "sha512-cfDHL6LStTEKlNilboNtobT/kEa30PtAf2Q1OgszfrG/rpVl1xaFWT9ktfkS306GmHgmnad1Sw4wabhlvFtsTw==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/expo/node_modules/resolve": {
"version": "1.22.12",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
"integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"is-core-module": "^2.16.1",
"path-parse": "^1.0.7",
"supports-preserve-symlinks-flag": "^1.0.0"
},
"bin": {
"resolve": "bin/resolve"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/expo/node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/expo/node_modules/ws": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/exponential-backoff": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz",
@@ -8437,7 +8438,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz",
"integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"micromatch": "^4.0.2"
@@ -8530,7 +8530,6 @@
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
"integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
@@ -9816,7 +9815,6 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
"integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
"dev": true,
"license": "MIT"
},
"node_modules/isexe": {
@@ -11304,7 +11302,6 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz",
"integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind": "^1.0.8",
@@ -11343,7 +11340,6 @@
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
"integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"universalify": "^2.0.0"
@@ -11356,7 +11352,6 @@
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz",
"integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==",
"dev": true,
"license": "Public Domain",
"funding": {
"url": "https://github.com/sponsors/ljharb"
@@ -11392,7 +11387,6 @@
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz",
"integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.1.11"
@@ -12433,14 +12427,14 @@
}
},
"node_modules/nativewind": {
"version": "4.1.23",
"resolved": "https://registry.npmjs.org/nativewind/-/nativewind-4.1.23.tgz",
"integrity": "sha512-oLX3suGI6ojQqWxdQezOSM5GmJ4KvMnMtmaSMN9Ggb5j7ysFt4nHxb1xs8RDjZR7BWc+bsetNJU8IQdQMHqRpg==",
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/nativewind/-/nativewind-4.2.3.tgz",
"integrity": "sha512-HglF1v6A8CqBFpXWs0d3yf4qQGurrreLuyE8FTRI/VDH8b0npZa2SDG5tviTkLiBg0s5j09mQALZOjxuocgMLA==",
"license": "MIT",
"dependencies": {
"comment-json": "^4.2.5",
"debug": "^4.3.7",
"react-native-css-interop": "0.1.22"
"react-native-css-interop": "0.2.3"
},
"engines": {
"node": ">=16"
@@ -13063,7 +13057,6 @@
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.1.tgz",
"integrity": "sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@yarnpkg/lockfile": "^1.1.0",
@@ -13093,7 +13086,6 @@
"version": "3.9.0",
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz",
"integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==",
"dev": true,
"funding": [
{
"type": "github",
@@ -13109,7 +13101,6 @@
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -13122,7 +13113,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz",
"integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -13860,16 +13850,16 @@
}
},
"node_modules/react-native-css-interop": {
"version": "0.1.22",
"resolved": "https://registry.npmjs.org/react-native-css-interop/-/react-native-css-interop-0.1.22.tgz",
"integrity": "sha512-Mu01e+H9G+fxSWvwtgWlF5MJBJC4VszTCBXopIpeR171lbeBInHb8aHqoqRPxmJpi3xIHryzqKFOJYAdk7PBxg==",
"version": "0.2.3",
"resolved": "https://registry.npmjs.org/react-native-css-interop/-/react-native-css-interop-0.2.3.tgz",
"integrity": "sha512-wc+JI7iUfdFBqnE18HhMTtD0q9vkhuMczToA87UdHGWwMyxdT5sCcNy+i4KInPCE855IY0Ic8kLQqecAIBWz7w==",
"license": "MIT",
"dependencies": {
"@babel/helper-module-imports": "^7.22.15",
"@babel/traverse": "^7.23.0",
"@babel/types": "^7.23.0",
"debug": "^4.3.7",
"lightningcss": "^1.27.0",
"lightningcss": "~1.27.0",
"semver": "^7.6.3"
},
"engines": {
@@ -13890,6 +13880,258 @@
}
}
},
"node_modules/react-native-css-interop/node_modules/detect-libc": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
"integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==",
"license": "Apache-2.0",
"bin": {
"detect-libc": "bin/detect-libc.js"
},
"engines": {
"node": ">=0.10"
}
},
"node_modules/react-native-css-interop/node_modules/lightningcss": {
"version": "1.27.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.27.0.tgz",
"integrity": "sha512-8f7aNmS1+etYSLHht0fQApPc2kNO8qGRutifN5rVIc6Xo6ABsEbqOr758UwI7ALVbTt4x1fllKt0PYgzD9S3yQ==",
"license": "MPL-2.0",
"dependencies": {
"detect-libc": "^1.0.3"
},
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"lightningcss-darwin-arm64": "1.27.0",
"lightningcss-darwin-x64": "1.27.0",
"lightningcss-freebsd-x64": "1.27.0",
"lightningcss-linux-arm-gnueabihf": "1.27.0",
"lightningcss-linux-arm64-gnu": "1.27.0",
"lightningcss-linux-arm64-musl": "1.27.0",
"lightningcss-linux-x64-gnu": "1.27.0",
"lightningcss-linux-x64-musl": "1.27.0",
"lightningcss-win32-arm64-msvc": "1.27.0",
"lightningcss-win32-x64-msvc": "1.27.0"
}
},
"node_modules/react-native-css-interop/node_modules/lightningcss-darwin-arm64": {
"version": "1.27.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.27.0.tgz",
"integrity": "sha512-Gl/lqIXY+d+ySmMbgDf0pgaWSqrWYxVHoc88q+Vhf2YNzZ8DwoRzGt5NZDVqqIW5ScpSnmmjcgXP87Dn2ylSSQ==",
"cpu": [
"arm64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/react-native-css-interop/node_modules/lightningcss-darwin-x64": {
"version": "1.27.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.27.0.tgz",
"integrity": "sha512-0+mZa54IlcNAoQS9E0+niovhyjjQWEMrwW0p2sSdLRhLDc8LMQ/b67z7+B5q4VmjYCMSfnFi3djAAQFIDuj/Tg==",
"cpu": [
"x64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/react-native-css-interop/node_modules/lightningcss-freebsd-x64": {
"version": "1.27.0",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.27.0.tgz",
"integrity": "sha512-n1sEf85fePoU2aDN2PzYjoI8gbBqnmLGEhKq7q0DKLj0UTVmOTwDC7PtLcy/zFxzASTSBlVQYJUhwIStQMIpRA==",
"cpu": [
"x64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/react-native-css-interop/node_modules/lightningcss-linux-arm-gnueabihf": {
"version": "1.27.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.27.0.tgz",
"integrity": "sha512-MUMRmtdRkOkd5z3h986HOuNBD1c2lq2BSQA1Jg88d9I7bmPGx08bwGcnB75dvr17CwxjxD6XPi3Qh8ArmKFqCA==",
"cpu": [
"arm"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/react-native-css-interop/node_modules/lightningcss-linux-arm64-gnu": {
"version": "1.27.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.27.0.tgz",
"integrity": "sha512-cPsxo1QEWq2sfKkSq2Bq5feQDHdUEwgtA9KaB27J5AX22+l4l0ptgjMZZtYtUnteBofjee+0oW1wQ1guv04a7A==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/react-native-css-interop/node_modules/lightningcss-linux-arm64-musl": {
"version": "1.27.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.27.0.tgz",
"integrity": "sha512-rCGBm2ax7kQ9pBSeITfCW9XSVF69VX+fm5DIpvDZQl4NnQoMQyRwhZQm9pd59m8leZ1IesRqWk2v/DntMo26lg==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/react-native-css-interop/node_modules/lightningcss-linux-x64-gnu": {
"version": "1.27.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.27.0.tgz",
"integrity": "sha512-Dk/jovSI7qqhJDiUibvaikNKI2x6kWPN79AQiD/E/KeQWMjdGe9kw51RAgoWFDi0coP4jinaH14Nrt/J8z3U4A==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/react-native-css-interop/node_modules/lightningcss-linux-x64-musl": {
"version": "1.27.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.27.0.tgz",
"integrity": "sha512-QKjTxXm8A9s6v9Tg3Fk0gscCQA1t/HMoF7Woy1u68wCk5kS4fR+q3vXa1p3++REW784cRAtkYKrPy6JKibrEZA==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/react-native-css-interop/node_modules/lightningcss-win32-arm64-msvc": {
"version": "1.27.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.27.0.tgz",
"integrity": "sha512-/wXegPS1hnhkeG4OXQKEMQeJd48RDC3qdh+OA8pCuOPCyvnm/yEayrJdJVqzBsqpy1aJklRCVxscpFur80o6iQ==",
"cpu": [
"arm64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/react-native-css-interop/node_modules/lightningcss-win32-x64-msvc": {
"version": "1.27.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.27.0.tgz",
"integrity": "sha512-/OJLj94Zm/waZShL8nB5jsNj3CfNATLCTyFxZyouilfTmSoLDX7VlVAmhPHoZWVFp4vdmoiEbPEYC8HID3m6yw==",
"cpu": [
"x64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/react-native-css-interop/node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
@@ -15711,7 +15953,6 @@
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz",
"integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.14"
@@ -16054,7 +16295,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 10.0.0"

View File

@@ -3,10 +3,11 @@
"main": "expo-router/entry",
"version": "1.0.0",
"scripts": {
"postinstall": "patch-package",
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"reset-project": "node ./scripts/reset-project.js",
"postinstall": "patch-package",
"android": "expo run:android",
"ios": "expo run:ios",
"web": "expo start --web",
"lint": "expo lint",
"test": "jest"
@@ -33,7 +34,8 @@
"expo-symbols": "~1.0.8",
"expo-system-ui": "~6.0.9",
"expo-web-browser": "~15.0.10",
"nativewind": "^4.1.23",
"nativewind": "^4.2.3",
"patch-package": "^8.0.1",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "0.81.5",
@@ -43,7 +45,8 @@
"react-native-screens": "~4.16.0",
"react-native-url-polyfill": "^3.0.0",
"react-native-web": "~0.21.0",
"react-native-worklets": "0.5.1"
"react-native-worklets": "0.5.1",
"tailwindcss": "^3.4.19"
},
"devDependencies": {
"@testing-library/react-native": "^13.3.3",