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

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,232 +51,131 @@ export default function Subjects() {
}, [session])
);
const DeleteSubject = async (sId: string) => {
Alert.alert(
'Delete Subject',
'Are you sure you want to delete this subject?',
[
{
text: 'Cancel',
style: 'cancel',
},
{
text: 'Delete',
style: 'destructive',
onPress: async () => {
const { error } = await supabase
.from('subjects')
.delete()
.eq('sId', sId);
if (error) {
Alert.alert('Subject could not be deleted, please try again');
return;
}
Alert.alert('Subject deleted successfully!');
GetSubjects();
},
},
]
);
};
return (
<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()}
>
<Text className="text-sm font-semibold text-text-secondary">
Logout
</Text>
</Pressable>
</View>
<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="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
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="mb-6 h-14 items-center justify-center rounded-2xl bg-accent"
onPress={() => router.push('/subject/createSubject')}
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;
/*
Har bare skrevet timeren som en egen tab til å begynne med.
Planen er at når bruker starter en task så vil de få opp denne timeren
som viser TaskName og Description der tallene står nå
Kanskje en animert figur hvis vi får tid
*/
export default function App() {
const scrollX = React.useRef(new Animated.Value(0)).current;
const [duration, setDuration] = React.useState(timers[0])
const timerAnimation = React.useRef(new Animated.Value(height)).current
const buttonAnimation = React.useRef(new Animated.Value(0)).current
const animation = React.useCallback(() => {
Animated.sequence([
Animated.timing(buttonAnimation, {
toValue: 1,
duration: 300,
useNativeDriver: true
}),
Animated.timing(timerAnimation, {
toValue: 0,
duration: 300,
useNativeDriver: true
}),
Animated.timing(timerAnimation, {
toValue: height,
duration: duration * 1000,
useNativeDriver: true
}),
]) .start(() => {
Animated.timing(buttonAnimation, {
toValue: 0,
duration: 300,
useNativeDriver: true
}).start()
})
}, [duration])
const placeholderTask = {
name: 'Read chapter 4',
description: 'Focus on the summary questions and write down anything unclear.',
};
function formatTime(totalSeconds: number) {
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
}
export default function TimerScreen() {
const [containerHeight, setContainerHeight] = React.useState(0);
const [duration, setDuration] = React.useState(TIMER_OPTIONS[0]);
const [timerIsRunning, setIsRunning] = React.useState(false);
const [timeRemaining, setTimeRemaining] = React.useState(0);
const scrollX = React.useRef(new Animated.Value(0)).current;
const timerAnimation = React.useRef(new Animated.Value(0)).current;
const buttonAnimation = React.useRef(new Animated.Value(0)).current;
const taskDetailsAnimation = React.useRef(new Animated.Value(0)).current;
const countdownAnimation = React.useRef(new Animated.Value(0)).current;
const cancelButtonAnimation = React.useRef(new Animated.Value(0)).current;
const pressedButtonAnimation = React.useRef(new Animated.Value(0)).current;
const focusModeAnimation = React.useRef(new Animated.Value(0)).current;
const cancelOverlayAnimation = React.useRef(new Animated.Value(0)).current;
const countdownRef = React.useRef<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,
}),
Animated.timing(cancelButtonAnimation, {
toValue: 1,
duration: 300,
useNativeDriver: true,
}),
Animated.timing(countdownAnimation, {
toValue: 1,
duration: 300,
useNativeDriver: true,
}),
Animated.timing(timerAnimation, {
toValue: 0,
duration: 300,
useNativeDriver: true,
}),
]),
Animated.timing(focusModeAnimation, {
toValue: 1,
duration: 450,
useNativeDriver: true,
}),
Animated.timing(taskDetailsAnimation, {
toValue: 1,
duration: 500,
useNativeDriver: true,
}),
]);
runningAnimationRef.current = runningAnimation;
runningAnimation.start(({ finished }) => {
runningAnimationRef.current = null;
if (!finished) {
return;
}
startProgressAnimation(0);
});
}, [
buttonAnimation,
cancelButtonAnimation,
countdownAnimation,
focusModeAnimation,
startProgressAnimation,
taskDetailsAnimation,
timerAnimation,
]);
const startCountdown = React.useCallback(
(totalSeconds: number) => {
setTimeRemaining(totalSeconds);
clearCountdownInterval();
countdownRef.current = setInterval(() => {
setTimeRemaining((currentTime) => {
if (currentTime <= 1) {
clearCountdownInterval();
return 0;
}
return currentTime - 1;
});
}, 1000);
},
[clearCountdownInterval]
);
const startTimerSession = React.useCallback(() => {
if (timerIsRunning || containerHeight === 0) {
return;
}
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
setIsRunning(true);
taskDetailsAnimation.setValue(0);
countdownAnimation.setValue(0);
cancelOverlayAnimation.setValue(0);
const totalSeconds = duration * TIMER_UNIT_IN_SECONDS;
sessionStartedAtRef.current = Date.now();
sessionDurationMsRef.current = totalSeconds * 1000;
startCountdown(totalSeconds);
runStartSequence();
}, [
cancelOverlayAnimation,
containerHeight,
countdownAnimation,
duration,
runStartSequence,
startCountdown,
taskDetailsAnimation,
timerIsRunning,
]);
const cancelTimer = React.useCallback(() => {
if (!timerIsRunning) {
return;
}
clearCountdownInterval();
clearCancelHoldTimeouts();
stopRunningAnimations();
Animated.parallel([
Animated.timing(cancelButtonAnimation, {
toValue: 0,
duration: 180,
useNativeDriver: true,
}),
Animated.timing(taskDetailsAnimation, {
toValue: 0,
duration: 220,
useNativeDriver: true,
}),
Animated.timing(focusModeAnimation, {
toValue: 0,
duration: 250,
useNativeDriver: true,
}),
Animated.timing(countdownAnimation, {
toValue: 0,
duration: 180,
useNativeDriver: true,
}),
Animated.timing(timerAnimation, {
toValue: containerHeight,
duration: 300,
useNativeDriver: true,
}),
Animated.timing(cancelOverlayAnimation, {
toValue: 0,
duration: 120,
useNativeDriver: true,
}),
]).start(() => {
Animated.timing(buttonAnimation, {
toValue: 0,
duration: 220,
useNativeDriver: true,
}).start(() => {
resetSessionValues();
});
});
}, [
buttonAnimation,
cancelButtonAnimation,
cancelOverlayAnimation,
clearCancelHoldTimeouts,
clearCountdownInterval,
containerHeight,
countdownAnimation,
focusModeAnimation,
resetSessionValues,
stopRunningAnimations,
taskDetailsAnimation,
timerAnimation,
timerIsRunning,
]);
const handleCancelHoldStart = React.useCallback(() => {
animateButtonPress(true);
cancelHoldIdRef.current += 1;
const cancelHoldId = cancelHoldIdRef.current;
cancelHoldActiveRef.current = true;
cancelHoldStartedAtRef.current = Date.now();
cancelAccelStartedRef.current = false;
cancelHoldAnimationDelayRef.current = setTimeout(() => {
cancelHoldAnimationDelayRef.current = null;
if (!cancelHoldActiveRef.current || cancelHoldIdRef.current !== cancelHoldId) {
return;
}
// The hold starts with normal button feedback. After a short delay, we
// begin the accelerated red overlay preview so quick taps do not cause a
// jolt, while long holds still clearly show that cancel is about to fire.
cancelAccelStartedRef.current = true;
cancelOverlayAnimation.setValue(0);
const elapsedHoldMs = Date.now() - cancelHoldStartedAtRef.current;
const remainingHoldMs = Math.max(1, HOLD_TO_CANCEL_MS - elapsedHoldMs);
const sessionStartedAt = sessionStartedAtRef.current ?? Date.now();
const elapsedAtCancelMs = Date.now() + remainingHoldMs - sessionStartedAt;
const expectedProgress = elapsedAtCancelMs / sessionDurationMsRef.current;
const clampedProgress = Math.max(0, Math.min(expectedProgress, 1));
const expectedYAtCancel = containerHeight * clampedProgress;
const cancelOffset = Math.max(0, containerHeight - expectedYAtCancel);
Animated.timing(cancelOverlayAnimation, {
toValue: cancelOffset,
duration: remainingHoldMs,
easing: Easing.in(Easing.quad),
useNativeDriver: true,
}).start();
}, CANCEL_ANIMATION_DELAY_MS);
cancelHoldTimeoutRef.current = setTimeout(() => {
cancelHoldActiveRef.current = false;
cancelHoldIdRef.current += 1;
cancelAccelStartedRef.current = false;
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning);
cancelTimer();
cancelHoldTimeoutRef.current = null;
}, HOLD_TO_CANCEL_MS);
}, [animateButtonPress, cancelOverlayAnimation, cancelTimer, containerHeight]);
const handleCancelHoldEnd = React.useCallback(() => {
animateButtonPress(false);
cancelHoldActiveRef.current = false;
cancelHoldIdRef.current += 1;
clearCancelHoldTimeouts();
if (!cancelAccelStartedRef.current) {
return;
}
cancelAccelStartedRef.current = false;
cancelOverlayAnimation.stopAnimation((currentOffset) => {
cancelOverlayAnimation.setValue(currentOffset);
Animated.timing(cancelOverlayAnimation, {
toValue: 0,
duration: 750,
easing: Easing.in(Easing.bounce),
useNativeDriver: true,
}).start();
});
}, [animateButtonPress, cancelOverlayAnimation, clearCancelHoldTimeouts]);
const handleTimerPickerMomentumEnd = React.useCallback(
(event: { nativeEvent: { contentOffset: { x: number } } }) => {
if (timerIsRunning) {
return;
}
const index = Math.round(event.nativeEvent.contentOffset.x / ITEM_SIZE);
const clampedIndex = Math.max(0, Math.min(index, TIMER_OPTIONS.length - 1));
setDuration(TIMER_OPTIONS[clampedIndex]);
},
[timerIsRunning]
);
const renderTimerItem = React.useCallback(
({ item, index }: { item: number; index: number }) => {
const inputRange = [
(index - 1) * ITEM_SIZE,
index * ITEM_SIZE,
(index + 1) * ITEM_SIZE,
];
const baseOpacity = scrollX.interpolate({
inputRange,
outputRange: [0.4, 1, 0.4],
});
const opacity = Animated.multiply(baseOpacity, pickerOpacity);
const scale = scrollX.interpolate({
inputRange,
outputRange: [0.7, 1, 0.7],
});
return (
<View style={styles.timerOptionItem}>
<Animated.Text
style={[
styles.text,
{
opacity,
transform: [{ scale }],
},
]}
>
{item}
</Animated.Text>
</View>
);
},
[pickerOpacity, scrollX]
);
const opacity = buttonAnimation.interpolate({
inputRange: [0, 1],
outputRange: [1, 0]
})
const translateY = buttonAnimation.interpolate({
inputRange: [0, 1],
outputRange: [0, 200]
})
return (
<View style={styles.container}>
<View
style={styles.container}
onLayout={(event) => {
setContainerHeight(event.nativeEvent.layout.height);
}}
>
<StatusBar hidden />
<Animated.View
style={[StyleSheet.absoluteFillObject, {
height,
width,
backgroundColor: colors.red,
transform: [{
translateY: timerAnimation
}]
}]}
/>
<Animated.View
style={[
StyleSheet.absoluteFillObject,
styles.timerOverlay,
{
justifyContent: 'flex-end',
alignItems: 'center',
paddingBottom: 100,
opacity,
transform: [{
translateY
}]
height: containerHeight,
width,
transform: [{ translateY: timerOverlayTranslateY }],
},
]}>
]}
/>
<Animated.View
style={[
StyleSheet.absoluteFillObject,
styles.startButtonContainer,
{
opacity: startButtonOpacity,
transform: [{ translateY: startButtonTranslateY }],
},
]}
>
<TouchableOpacity
onPress={animation}>
<View
style={styles.roundButton}
/>
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={{
position: 'absolute',
top: height / 3,
left: 0,
right: 0,
flex: 1,
}}>
<Animated.FlatList
data={timers}
keyExtractor={item => item.toString()}
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}
)}
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]);
}}
onMomentumScrollEnd={handleTimerPickerMomentumEnd}
snapToInterval={ITEM_SIZE}
decelerationRate={"fast"}
style={{flexGrow: 0}}
contentContainerStyle={{
paddingHorizontal: ITEM_SPACING
}}
renderItem={({item, index}) => {
const inputRange = [
(index - 1) * ITEM_SIZE,
index * ITEM_SIZE,
(index + 1) * ITEM_SIZE,
]
decelerationRate="fast"
style={styles.timerPickerList}
contentContainerStyle={styles.timerPickerContent}
renderItem={renderTimerItem}
/>
</View>
const opacity = scrollX.interpolate({
inputRange,
outputRange: [.4, 1, .4]
})
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
}]
}]}>
{item}
</Animated.Text>
</View>
}
}
/>
</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,28 +19,74 @@ 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);
if (deadlineReminder <= new Date()) return null;
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 },
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 CreateAssignment = async () => {
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);
}
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);
}
try {
await CheckSubjectCompletion(subjectId);
} catch {
Alert.alert('Failed to update subject status');
}
if (sId) {
try {
await CheckSubjectCompletion(sId);
} catch {
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,16 +33,41 @@ 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,115 +165,311 @@ 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 className="flex-1 bg-app-bg px-5 pt-6">
<Stack.Screen
options={{
title: 'Details',
}}
/>
<View
className="rounded-3xl bg-app-surface p-5"
style={{
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 style={defaultStyles.container}>
<View className="flex-1 bg-app-bg">
<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: '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>
),
}}
/>
{!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
style={{
width: "100%",
height: 12,
backgroundColor: "#D9D9D9",
borderRadius: 999,
overflow: "hidden",
}}
>
<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={{
width: `${progress}%`,
height: "100%",
backgroundColor: "#4CAF50",
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}%`,
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>
<Text className="mt-4 text-sm text-text-muted">
Last changed: {formatDateTime(assignment.lastChanged)}
</Text>
</View>
</View>
<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>
<Button title="Edit" onPress={() => router.push({pathname: "/assignment/editAssignment", params: { aId: assignment.aId }})} />
<Button testID = "delete-assignment-button" title="Delete" onPress={() => DeleteAssignment(assignment.aId)} />
<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 style={defaultStyles.buttonContainer}>
<Button title="Create Task" onPress={() => router.push({pathname: "/task/createTask", params: { aId: assignment.aId }})} />
<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;
<SectionList
sections={taskSections}
keyExtractor={(item) => item.tId}
renderSectionHeader={({ section: { title } }) => <Text style={defaultStyles.subtitle}>{title}</Text>}
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>
return (
<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 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>
{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>
)}
</View>
);
}}
renderSectionFooter={({ section }) =>
section.data.length === 0 ? (
<View style={defaultStyles.container}>
<Text style={defaultStyles.body}>{section.emptyMessage}</Text>
<View style={defaultStyles.separator} />
</View>
) : (
<View style={defaultStyles.separator} />
)
}
/>
</View>
)}
)}
</View>
);
}}
renderSectionFooter={({ section }) =>
section.data.length === 0 ? (
<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 className="mb-2" />
)
}
/>
</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"}>
<TouchableWithoutFeedback onPress={Keyboard.dismiss}>
<View style={defaultStyles.container}>
<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,
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"
value={email}
onChangeText={SetEmail}
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
style={defaultStyles.inputText}
placeholder="Enter Password"
className={inputClassName}
placeholder="Create a password"
placeholderTextColor="#9CA3AF"
secureTextEntry
value={password}
onChangeText={SetPassword}
secureTextEntry
/>
<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>
</View>
</TouchableWithoutFeedback>
</KeyboardAvoidingView>
</View>
</View>
)
<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 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"}>
<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("/")} />
</View>
</TouchableWithoutFeedback>
</KeyboardAvoidingView>
</View>
</View>
)
return (
<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,
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 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,180 +86,378 @@ 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 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 className="flex-1 bg-app-bg px-5 pt-6">
<Stack.Screen
options={{
title: 'Subject Details',
}}
/>
<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>
<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 progress = assignments.length === 0 ? 0 : Math.round((assignments.filter(assignment => assignment.isCompleted).length / assignments.length) * 100);
const colorKey: SubjectColor = subject.color ?? 'slate';
const colorSet = SUBJECT_COLORS[colorKey];
const firstLetter = subject.title?.trim().charAt(0).toUpperCase() || 'S';
return (
<View style={defaultStyles.container}>
<View className="flex-1 bg-app-bg">
<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',
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>
),
}}
/>
{!subject && (
<View style={defaultStyles.container}>
<Text style={defaultStyles.title}>Subject not found</Text>
</View>
)}
{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",
}}
>
<SectionList
className="flex-1"
contentContainerStyle={{
paddingHorizontal: 20,
paddingTop: 20,
paddingBottom: 32,
}}
sections={assignmentSections}
keyExtractor={(item) => item.aId}
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
style={{
width: `${progress}%`,
height: "100%",
backgroundColor: "#4CAF50",
}}
/>
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>
<Button title="Edit" onPress={() => router.push({pathname: "/subject/editSubject", params: { sId: subject.sId }})} />
<Button testID = "delete-subject-button" title="Delete" onPress={() => DeleteSubject(subject.sId)} />
<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 style={defaultStyles.buttonContainer}>
<Button title="Create Assignment" onPress={() => router.push({pathname: "/assignment/createAssignment", params: { sId: subject.sId }})} />
<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;
<SectionList
sections={assignmentSections}
keyExtractor={(item) => item.aId}
renderSectionHeader={({ section: { title } }) => <Text style={defaultStyles.subtitle}>{title}</Text>}
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>
return (
<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 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>
{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>
)}
</View>
);
}}
renderSectionFooter={({ section }) =>
section.data.length === 0 ? (
<View style={defaultStyles.container}>
<Text style={defaultStyles.body}>{section.emptyMessage}</Text>
<View style={defaultStyles.separator} />
</View>
) : (
<View style={defaultStyles.separator} />
)
}
/>
</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">
Assignments for this subject will show up here.
</Text>
</View>
) : (
<View className="mb-2" />
)
}
/>
</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);
} catch {
Alert.alert("Failed to update assignment completion state");
}
try {
await CheckAssignmentCompletion(assignmentId);
} catch {
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,36 +1,88 @@
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(() => {
@@ -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 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,
}}
>
<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 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>
)}
{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="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>
<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="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>
)}
<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>
<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>
);
}
}