Final push before system formatting

This commit is contained in:
Chris Sanden
2026-05-31 14:05:22 +02:00
commit 5ece589fbe
178 changed files with 164198 additions and 0 deletions

View File

@@ -0,0 +1,118 @@
import { getSetupStatus } from "@/lib/setupStatus";
import { supabase } from "@/lib/supabase";
import MaterialIcons from "@expo/vector-icons/MaterialIcons";
import { Session } from "@supabase/supabase-js";
import * as Notifications from 'expo-notifications';
import { Redirect, router, Tabs } from "expo-router";
import { useEffect, useState } from "react";
function UseNotificationObserver() {
useEffect(() => {
function redirect(notification: Notifications.Notification) {
const aId = notification.request.content.data?.aId;
if (typeof aId === 'string') {
router.push({pathname: "/assignment/viewDetailsAssignment", params: { aId }});
}
}
const response = Notifications.getLastNotificationResponse();
if (response?.notification) {
redirect(response.notification);
}
const subscription = Notifications.addNotificationResponseReceivedListener(response => {
redirect(response.notification);
});
return () => {
subscription.remove();
};
}, []);
}
export default function TabLayout() {
const [session, SetSession] = useState<Session | null>(null)
const [loading, SetLoading] = useState(true);
const [setupChecked, setSetupChecked] = useState(false);
const [needsSetup, setNeedsSetup] = useState(false);
UseNotificationObserver();
useEffect(() => {
const loadSession = async () => {
const { data } = await supabase.auth.getSession();
SetSession(data.session ?? null);
SetLoading(false);
}
loadSession();
const { data: sub } = supabase.auth.onAuthStateChange((_event, newSession) => {
SetSession(newSession);
SetLoading(false);
});
return () => sub.subscription.unsubscribe();
}, []);
useEffect(() => {
const checkSetupStatus = async () => {
if (!session?.user.id) {
setNeedsSetup(false);
setSetupChecked(true);
return;
}
try {
const setupStatus = await getSetupStatus(session.user.id);
setNeedsSetup(!setupStatus.isSetupComplete);
} catch {
setNeedsSetup(true);
} finally {
setSetupChecked(true);
}
};
setSetupChecked(false);
void checkSetupStatus();
}, [session?.user.id]);
if (loading || !setupChecked) {
return null;
}
if (!session) {
return <Redirect href="/login" />;
}
if (needsSetup) {
return <Redirect href="/setup" />;
}
return (
<Tabs
screenOptions={{
headerShown: true,
}}>
<Tabs.Screen
name="index"
options={{
title: 'Dashboard',
tabBarLabel: 'Dashboard',
tabBarIcon: ({ color, size }) => (
<MaterialIcons name="dashboard" color={color} size={size} />
),
}}
/>
<Tabs.Screen
name="subjects"
options={{
title: "Subjects",
tabBarIcon: ({ color, size }) => (
<MaterialIcons name="menu-book" color={color} size={size} />
),
}}
/>
</Tabs>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,411 @@
import { getSetupStatus } from '@/lib/setupStatus';
import { SUBJECT_COLORS, type SubjectColor } from '@/lib/subjectColors';
import { supabase } from '@/lib/supabase';
import { Subject } from '@/lib/types';
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import { Session } from '@supabase/supabase-js';
import { Redirect, router, Stack, useFocusEffect } from 'expo-router';
import { useCallback, useEffect, useState } from 'react';
import { Alert, Modal, Pressable, ScrollView, Text, View, ActivityIndicator } from 'react-native';
const FLOW_STEPS = [
{
label: '1',
title: 'Subject',
description: 'Start with the broad area you are studying, like one course or one exam you are preparing for.',
},
{
label: '2',
title: 'Assignment',
description: 'Inside that, add the bigger piece of work you want to move forward, like a project, a problem set, or revision block.',
},
{
label: '3',
title: 'Task',
description: 'Then break it down into one task that feels concrete enough to begin without overthinking it.',
},
{
label: '4',
title: 'Sprint',
description: 'That task is what you bring into a focus session. After a sprint, you take a short pause, then come back to the same kind of focused work. After a few rounds, the app gives you a longer pause so the rhythm still feels sustainable.',
},
] as const;
export default function Subjects() {
const [subjects, SetSubjects] = useState<Subject[]>([]);
const [session, SetSession] = useState<Session | null>(null);
const [isFlowInfoVisible, setIsFlowInfoVisible] = useState(false);
const [needsSetup, setNeedsSetup] = useState<boolean | null>(null);
const [isLoading, SetIsLoading] = useState(true);
const activeSubjects = subjects.filter((subject) => subject.isActive);
const inactiveSubjects = subjects.filter((subject) => !subject.isActive);
useEffect(() => {
supabase.auth.getSession().then(({ data }) => {
SetSession(data.session ?? null);
});
const { data: sub } = supabase.auth.onAuthStateChange(
(_event, newSession) => {
SetSession(newSession);
}
);
return () => sub.subscription.unsubscribe();
}, []);
useEffect(() => {
const loadSetupGate = async () => {
if (!session?.user.id) {
setNeedsSetup(false);
return;
}
try {
const setupStatus = await getSetupStatus(session.user.id);
setNeedsSetup(!setupStatus.isSetupComplete);
} catch {
setNeedsSetup(true);
}
};
setNeedsSetup(null);
void loadSetupGate();
}, [session?.user.id]);
const GetSubjects = useCallback(async () => {
if (!session?.user.id) {
SetIsLoading(false);
return;
}
SetIsLoading(true);
const { data, error } = await supabase
.from('subjects')
.select('*')
.eq('uId', session.user.id)
.order('lastChanged', { ascending: false });
SetIsLoading(false);
if (error) {
Alert.alert('Subjects could not be fetched, please try again');
return;
}
SetSubjects((data as Subject[]) ?? []);
}, [session?.user.id]);
useFocusEffect(
useCallback(() => {
if (session) {
void GetSubjects();
}
}, [GetSubjects, session])
);
if (session && needsSetup === null) {
return null;
}
if (needsSetup) {
return <Redirect href="/setup" />;
}
const RenderSubjectCard = (subject: 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>
);
};
if (isLoading) {
return (
<View className="flex-1 items-center justify-center bg-app-bg">
<ActivityIndicator size="large" />
</View>
);
}
return (
<View className="flex-1 bg-app-bg">
<Stack.Screen
options={{
title: 'Subjects',
headerTitleAlign: 'center',
headerLeft: () => (
<View className="ml-3">
<Pressable
className="h-10.5 w-11 items-center justify-center rounded-full"
onPress={() => setIsFlowInfoVisible(true)}
>
<MaterialIcons name="help" size={36} color="#52606D" />
</Pressable>
</View>
),
headerRight: () => (
<View className="mr-3">
<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>
),
}}
/>
<Modal
animationType="fade"
transparent
visible={isFlowInfoVisible}
onRequestClose={() => setIsFlowInfoVisible(false)}
>
<View className="flex-1 justify-center bg-[rgba(15,23,42,0.42)] px-5">
<Pressable
className="absolute inset-0"
onPress={() => setIsFlowInfoVisible(false)}
/>
<View className="max-h-[80%] gap-4 rounded-[28px] bg-[#FCFDFE] p-5 shadow-lg">
<View className="flex-row items-start justify-between gap-3">
<View>
<Text className="text-xs font-bold uppercase tracking-[0.8px] text-[#7B8794]">
How work is organized
</Text>
<Text className="mt-1 text-[28px] font-extrabold text-[#1F2933]">
Study flow
</Text>
</View>
<Pressable
className="h-9 w-9 items-center justify-center rounded-full bg-[#EFF3F8]"
onPress={() => setIsFlowInfoVisible(false)}
>
<MaterialIcons name="close" size={18} color="#52606D" />
</Pressable>
</View>
<Text className="text-[15px] leading-[22px] text-[#52606D]">
The idea is to make getting started feel lighter. You decide what you are studying, narrow it down to one clear task, and then let the app carry you through a simple rhythm of focus and recovery.
</Text>
<ScrollView
className="max-h-80"
contentContainerStyle={{ gap: 4 }}
showsVerticalScrollIndicator={false}
>
{FLOW_STEPS.map((step, index) => (
<View key={step.title} className="flex-row gap-[14px]">
<View className="items-center">
<View className="h-8 w-8 items-center justify-center rounded-full bg-[#323F4E]">
<Text className="text-[13px] font-extrabold text-white">
{step.label}
</Text>
</View>
{index < FLOW_STEPS.length - 1 ? (
<View className="my-[6px] min-h-7 w-[2px] flex-1 bg-[#D5D9DF]" />
) : null}
</View>
<View className="flex-1 pb-[18px]">
<Text className="text-lg font-bold text-[#1F2933]">
{step.title}
</Text>
<Text className="mt-1 text-sm leading-[21px] text-[#52606D]">
{step.description}
</Text>
</View>
</View>
))}
</ScrollView>
<View className="rounded-[18px] bg-[#F1F5F9] px-4 py-[14px]">
<Text className="text-xs font-bold uppercase tracking-[0.8px] text-[#7B8794]">
Quick map
</Text>
<Text className="mt-[6px] text-base font-bold text-[#1F2933]">
{'Subject -> Assignment -> Task -> Sprint'}
</Text>
<Text className="mt-2 text-sm leading-[20px] text-[#52606D]">
In practice, that usually becomes focus session, short pause, focus session again, and eventually a longer pause when you have done a few solid rounds.
</Text>
</View>
<Pressable
className="min-h-12 items-center justify-center rounded-2xl bg-[#323F4E] px-4"
onPress={() => setIsFlowInfoVisible(false)}
>
<Text className="text-[15px] font-bold text-white">Close Guide</Text>
</Pressable>
</View>
</View>
</Modal>
<ScrollView
className="flex-1"
contentContainerStyle={{
paddingHorizontal: 20,
paddingTop: 20,
paddingBottom: 32,
}}
showsVerticalScrollIndicator={false}
>
{isLoading ? (
<View className="items-center justify-center rounded-3xl border border-app-border bg-app-surface p-5">
<ActivityIndicator size="large" color="#2563eb" />
<Text className="mt-4 text-center text-base font-semibold text-text-secondary">
Loading subjects...
</Text>
</View>
) : subjects.length === 0 ? (
<View className="rounded-3xl border border-app-border bg-app-surface p-5">
<Text className="text-center text-xl font-bold text-text-main">
No subjects yet
</Text>
<Text className="mt-2 text-center text-sm leading-5 text-text-secondary">
Start with one subject so the rest of your study path has a clear
place to live.
</Text>
<Pressable
className="mt-5 h-14 items-center justify-center rounded-2xl bg-accent"
onPress={() => router.push('/setup')}
>
<Text className="text-base font-bold text-text-inverse">
Start Guided Setup
</Text>
</Pressable>
</View>
) : (
<View>
<View className="mb-3 mt-2 flex-row items-center justify-between">
<Text className="text-lg font-bold text-text-main">
Active Subjects
</Text>
<View className="rounded-full bg-app-subtle px-3 py-1">
<Text className="text-xs font-semibold text-text-muted">
{activeSubjects.length}
</Text>
</View>
</View>
{activeSubjects.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">
No active subjects
</Text>
<Text className="mt-1 text-center text-sm text-text-muted">
Subjects with ongoing work will show up here.
</Text>
</View>
) : (
activeSubjects.map(RenderSubjectCard)
)}
<View className="mb-3 mt-2 flex-row items-center justify-between">
<Text className="text-lg font-bold text-text-main">
Inactive Subjects
</Text>
<View className="rounded-full bg-app-subtle px-3 py-1">
<Text className="text-xs font-semibold text-text-muted">
{inactiveSubjects.length}
</Text>
</View>
</View>
{inactiveSubjects.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">
No inactive subjects
</Text>
<Text className="mt-1 text-center text-sm text-text-muted">
Completed or paused subjects will show up here.
</Text>
</View>
) : (
inactiveSubjects.map(RenderSubjectCard)
)}
</View>
)}
{subjects.length > 0 ? (
<Pressable
className="mt-2 h-14 items-center justify-center rounded-2xl bg-accent"
onPress={() => router.push('/subject/upsertSubject')}
>
<Text className="text-base font-bold text-text-inverse">
Create Subject
</Text>
</Pressable>
) : null}
</ScrollView>
</View>
);
}

View File

@@ -0,0 +1,16 @@
import { Stack } from "expo-router";
import "../global.css";
export default function RootLayout() {
return (
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="setup" options={{ headerShown: true }} />
<Stack.Screen name="subject" options={{ headerShown: false }} />
<Stack.Screen name="assignment" options={{ headerShown: false }} />
<Stack.Screen name="task" options={{ headerShown: false }} />
<Stack.Screen name="createUser" options={{ headerShown: true, title: "" }} />
<Stack.Screen name="login" options={{ headerShown: false }} />
</Stack>
);
}

View File

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

View File

@@ -0,0 +1,378 @@
import { defaultStyles } from '@/constants/defaultStyles';
import * as AsyncStorage from '@/lib/asyncStorage';
import { supabase } from '@/lib/supabase';
import * as Notifications from 'expo-notifications';
import { router, Stack, useLocalSearchParams } from 'expo-router';
import { useEffect, useState } from 'react';
import {
ActivityIndicator,
Alert,
Keyboard,
KeyboardAvoidingView,
Platform,
Pressable,
ScrollView,
Text,
TextInput,
TouchableWithoutFeedback,
View,
} from 'react-native';
export default function UpsertAssignment() {
const { aId, sId: routeSId, flow } = useLocalSearchParams<{
aId?: string;
sId?: string;
flow?: string;
}>();
const isEditMode = Boolean(aId);
const isSetupFlow = flow === 'setup';
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);
useEffect(() => {
if (!isEditMode || !aId) {
SetIsLoading(false);
return;
}
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;
const nId = await Notifications.scheduleNotificationAsync({
content: {
title: 'Assignment deadline coming up',
body: `${assignmentTitle} is due in 24 hours.`,
data: { aId: assignmentId },
},
trigger: {
type: Notifications.SchedulableTriggerInputTypes.DATE,
date: deadlineReminder,
},
});
return nId;
};
const updateDeadlineReminder = async (
assignmentId: string,
assignmentTitle: string,
assignmentDeadline: string,
completed: boolean
) => {
const existingNotificationId =
await AsyncStorage.GetAssignmentNotificationId(assignmentId);
if (existingNotificationId) {
try {
await Notifications.cancelScheduledNotificationAsync(
existingNotificationId
);
} catch {}
await AsyncStorage.RemoveAssignmentNotificationId(assignmentId);
}
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;
}
const { data: userData, error: userError } = await supabase.auth.getUser();
if (userError || !userData.user) {
router.replace('/login');
return;
}
if (!subjectId) {
Alert.alert('Missing subject', 'This assignment is not linked to a subject.');
return;
}
SetIsSaving(true);
const payload = {
title: title.trim(),
description: description.trim(),
deadline: deadline.trim(),
isCompleted,
lastChanged: new Date().toISOString(),
uId: userData.user.id,
sId: subjectId,
};
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(
isEditMode
? 'Assignment could not be updated, please try again'
: 'Assignment could not be created, please try again'
);
return;
}
const savedAssignment = result.data;
await updateDeadlineReminder(
savedAssignment.aId,
savedAssignment.title,
savedAssignment.deadline,
savedAssignment.isCompleted
);
SetIsSaving(false);
if (!isEditMode && isSetupFlow) {
router.replace({
pathname: '/task/upsertTask',
params: {
aId: savedAssignment.aId,
flow: 'setup',
},
});
return;
}
Alert.alert(
isEditMode
? 'Assignment successfully updated!'
: 'Assignment successfully created!'
);
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';
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 Assignment' : 'Create Assignment',
headerTitleStyle: defaultStyles.title,
headerTitleAlign: 'center',
}}
/>
<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 Assignment' : 'Create Assignment'}
</Text>
<Text className="mt-2 text-base leading-6 text-text-secondary">
{isEditMode
? 'Update this assignment and keep your subject organized.'
: 'Add a new assignment to keep your subject organized.'}
</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 = "assignment-title-input"
className={inputClassName}
placeholder={
isSetupFlow ? 'e.g. Weekly problem set 3' : 'Enter assignment 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={
isSetupFlow
? 'e.g. Finish the next exercise set before Friday'
: 'Add a short description'
}
placeholderTextColor="#9CA3AF"
value={description}
onChangeText={SetDescription}
multiline
textAlignVertical="top"
/>
</View>
<View className="mb-5">
<Text className={labelClassName}>Deadline</Text>
<TextInput
className={inputClassName}
placeholder={isSetupFlow ? 'e.g. 2026-05-14' : 'YYYY-MM-DD'}
placeholderTextColor="#9CA3AF"
value={deadline}
onChangeText={SetDeadline}
autoCapitalize="none"
autoCorrect={false}
/>
</View>
<Pressable
className={`mb-6 flex-row items-center rounded-2xl border p-4 ${
isCompleted
? 'border-accent bg-accent-soft'
: 'border-app-border bg-app-subtle'
}`}
onPress={() => SetIsCompleted((current) => !current)}
disabled={isSaving}
>
<View
className={`mr-3 h-6 w-6 items-center justify-center rounded-md border-2 ${
isCompleted
? 'border-accent bg-accent'
: 'border-app-border bg-app-surface'
}`}
>
{isCompleted && (
<Text className="text-sm font-bold text-text-inverse">
</Text>
)}
</View>
<View className="flex-1">
<Text className="text-base font-semibold text-text-main">
Mark as completed
</Text>
<Text className="mt-1 text-sm text-text-muted">
You can change this later.
</Text>
</View>
</Pressable>
<Pressable
testID = "upsert-assignment-button"
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 Assignment'}
</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

@@ -0,0 +1,521 @@
import { formatDate, formatDateTime } from '@/lib/date';
import { CheckAssignmentCompletion } 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 { ActivityIndicator, 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 [isLoading, SetIsLoading] = useState(false);
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" },
{ 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 GetAssignment = async (assignmentId: string) => {
SetIsLoading(true);
const { data, error } = await supabase
.from('assignments')
.select('*')
.eq('aId', assignmentId)
.single();
SetIsLoading(false);
if (error || !data) {
Alert.alert('Assignment could not be fetched, please try again');
return;
}
SetAssignment(data);
if (data.sId) {
SetIsLoading(true);
const { data: subjectData, error: subjectError } = await supabase
.from('subjects')
.select('title, color')
.eq('sId', data.sId)
.single();
SetIsLoading(false);
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) => {
SetIsLoading(true);
const { data, error } = await supabase.from("tasks").select("*").eq("aId", aId);
SetIsLoading(false);
if (error) {
Alert.alert("Tasks could not be fetched, please try again");
return;
}
SetTasks(data ?? []);
}
useFocusEffect(
useCallback(() => {
if (session && aId) {
GetAssignment(aId);
GetTasks(aId);
}
}, [session, aId])
);
const DeleteAssignment = async (aId: 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!");
router.back();
}
}
]
)
}
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!");
if (aId) {
try {
await CheckAssignmentCompletion(aId);
} catch {
Alert.alert("Failed to update assignment completion state");
}
}
GetTasks(aId);
}
}
]
)
}
const ToggleTaskCompletion = async (task: Task) => {
const nextIsCompleted = !task.isCompleted;
const { error } = await supabase
.from("tasks")
.update({
isCompleted: nextIsCompleted,
lastChanged: new Date().toISOString(),
})
.eq("tId", task.tId);
if (error) {
Alert.alert("Task could not be updated, please try again");
return;
}
try {
await CheckAssignmentCompletion(task.aId);
} catch {
Alert.alert("Failed to update assignment completion state");
}
await GetTasks(task.aId);
await GetAssignment(task.aId);
}
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 (isLoading) {
return (
<View className="flex-1 items-center justify-center bg-app-bg">
<ActivityIndicator size="large" />
</View>
);
}
if (!assignment) {
return (
<View className="flex-1 bg-app-bg px-5 pt-6">
<Stack.Screen
options={{
title: 'Details',
headerTitleAlign: 'center',
}}
/>
<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 className="flex-1 bg-app-bg">
<Stack.Screen
options={{
title: 'Assignment Details',
headerRight: () => (
<Pressable
className="rounded-full bg-app-subtle px-4 py-2"
onPress={async () => await supabase.auth.signOut()}
>
<Text className="text-sm font-semibold text-text-secondary">
Logout
</Text>
</Pressable>
),
}}
/>
<SectionList
className="flex-1"
contentContainerStyle={{ paddingHorizontal: 20, paddingTop: 20, paddingBottom: 32 }}
sections={totalTasks === 0 ? [] : 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="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">
Tasks completed
</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>
<Text className="mt-1 text-xs text-text-muted">
Based only on completed tasks in this assignment.
</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
testID="delete-assignment-button"
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>
<Pressable
className="mb-6 mt-5 h-14 items-center justify-center rounded-2xl bg-accent"
onPress={() =>
router.push({
pathname: '../task/upsertTask',
params: { aId: assignment.aId },
})
}
>
<Text className="text-base font-bold text-text-inverse">
Create Task
</Text>
</Pressable>
</View>
}
renderSectionHeader={({ section: { title, data } }) => (
<View className="mb-3 mt-2 flex-row items-center justify-between">
<Text className="text-lg font-bold text-text-main">{title}</Text>
<View className="rounded-full bg-app-subtle px-3 py-1">
<Text className="text-xs font-semibold text-text-muted">
{data.length}
</Text>
</View>
</View>
)}
renderItem={({ item }) => {
const isOwner = session?.user.id === item.uId;
return (
<View
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="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 py-3"
style={{ backgroundColor: item.isCompleted ? '#EFEBE3' : colorSet.soft }}
onPress={() => ToggleTaskCompletion(item)}
>
<Text
className="text-sm font-bold"
style={{ color: colorSet.strong }}
>
{item.isCompleted ? 'Reopen' : 'Complete'}
</Text>
</Pressable>
<Pressable
className="mr-3 flex-1 items-center justify-center rounded-2xl border border-app-border bg-app-subtle py-3"
onPress={() =>
router.push({
pathname: '../task/upsertTask',
params: { tId: item.tId },
})
}
>
<Text className="text-sm font-bold text-text-secondary">Edit</Text>
</Pressable>
<Pressable
className="flex-1 items-center justify-center rounded-2xl border border-app-border bg-app-surface py-3"
onPress={() => DeleteTask(item.tId, item.aId)}
>
<Text className="text-sm font-bold text-status-danger">
Delete
</Text>
</Pressable>
</View>
)}
</View>
);
}}
ListEmptyComponent={
<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">
No tasks needed yet
</Text>
<Text className="mt-1 text-center text-sm text-text-muted">
Add tasks if this assignment needs smaller steps.
</Text>
</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.length === 0
? 'Create the first task so this assignment turns into one clear next action.'
: 'Tasks for this assignment will show up here.'}
</Text>
</View>
) : (
<View className="mb-2" />
)
}
/>
</View>
);
}

View File

@@ -0,0 +1,213 @@
import { supabase } from '@/lib/supabase';
import { router } from 'expo-router';
import { useEffect, useRef, useState } from 'react';
import {
Alert,
Animated,
Keyboard,
KeyboardAvoidingView,
KeyboardEvent,
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 [isKeyboardVisible, setIsKeyboardVisible] = useState(false);
const scrollViewRef = useRef<ScrollView>(null);
const cardLift = useRef(new Animated.Value(0)).current;
useEffect(() => {
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
const handleKeyboardShow = (event: KeyboardEvent) => {
setIsKeyboardVisible(true);
const keyboardHeight = event.endCoordinates.height;
const liftAmount = Math.min(
Platform.OS === 'ios' ? keyboardHeight * 0.5 : keyboardHeight * 0.6,
260
);
Animated.timing(cardLift, {
toValue: -liftAmount,
duration: event.duration ?? 220,
useNativeDriver: true,
}).start();
};
const handleKeyboardHide = () => {
setIsKeyboardVisible(false);
Animated.timing(cardLift, {
toValue: 0,
duration: 220,
useNativeDriver: true,
}).start();
};
const showSubscription = Keyboard.addListener(showEvent, handleKeyboardShow);
const hideSubscription = Keyboard.addListener(hideEvent, handleKeyboardHide);
return () => {
showSubscription.remove();
hideSubscription.remove();
};
}, [cardLift]);
const SignUp = async () => {
if (email.trim() === '' || password.trim() === '') {
Alert.alert('All fields are required!');
return;
}
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');
return;
}
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('/setup');
};
const inputClassName =
'rounded-2xl border border-app-border bg-app-subtle px-4 py-3 text-base text-text-main';
return (
<KeyboardAvoidingView
className="flex-1 bg-app-bg"
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 24}
>
<TouchableWithoutFeedback onPress={Keyboard.dismiss}>
<ScrollView
ref={scrollViewRef}
className="flex-1"
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
automaticallyAdjustKeyboardInsets={Platform.OS === 'ios'}
contentContainerStyle={{
flexGrow: 1,
justifyContent: isKeyboardVisible ? 'flex-start' : 'center',
paddingHorizontal: 20,
paddingTop: isKeyboardVisible ? 24 : 64,
paddingBottom: isKeyboardVisible ? 96 : 32,
}}
>
<Animated.View style={{ transform: [{ translateY: cardLift }] }}>
<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-5 rounded-2xl border border-app-border bg-app-subtle p-4">
<Text className="text-sm font-bold text-text-main">
What this app does
</Text>
<Text className="mt-1 text-sm leading-5 text-text-secondary">
Study Sprint helps you move from subject to assignment to task,
then into a focused sprint.
</Text>
<Text className="mt-3 text-sm font-bold text-text-main">
Why an account exists
</Text>
<Text className="mt-1 text-sm leading-5 text-text-secondary">
Your account keeps that structure and your tracked study
progress attached to you.
</Text>
</View>
<View className="mt-6 mb-5">
<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="Create a password so your progress follows you"
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={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>
</Animated.View>
</ScrollView>
</TouchableWithoutFeedback>
</KeyboardAvoidingView>
);
}

View File

@@ -0,0 +1,189 @@
import { getSetupStatus } from "@/lib/setupStatus";
import { supabase } from "@/lib/supabase";
import { router } from "expo-router";
import { useEffect, useRef, useState } from "react";
import { Alert, Keyboard, KeyboardAvoidingView, KeyboardEvent, Platform, Pressable, ScrollView, Text, TextInput, TouchableWithoutFeedback, View } from "react-native";
export default function Login() {
const [email, SetEmail] = useState('');
const [password, SetPassword] = useState('');
const [isLoading, SetIsLoading] = useState(false);
const [isKeyboardVisible, setIsKeyboardVisible] = useState(false);
const scrollViewRef = useRef<ScrollView>(null);
useEffect(() => {
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
const handleKeyboardShow = (event: KeyboardEvent) => {
setIsKeyboardVisible(true);
const keyboardHeight = event.endCoordinates.height;
const offsetBaseline = Platform.OS === 'ios' ? 180 : 140;
const nextScrollOffset = Math.max(0, keyboardHeight - offsetBaseline);
scrollViewRef.current?.scrollTo({
y: nextScrollOffset,
animated: true,
});
};
const handleKeyboardHide = () => {
setIsKeyboardVisible(false);
scrollViewRef.current?.scrollTo({ y: 0, animated: true });
};
const showSubscription = Keyboard.addListener(showEvent, handleKeyboardShow);
const hideSubscription = Keyboard.addListener(hideEvent, handleKeyboardHide);
return () => {
showSubscription.remove();
hideSubscription.remove();
};
}, []);
const login = async () => {
if(email.trim() === '' || password.trim() === '') {
Alert.alert("All fields are required!");
return;
}
SetIsLoading(true);
const { data, error } = await supabase.auth.signInWithPassword({
email,
password
});
SetIsLoading(false);
if (error) {
Alert.alert("Login failed, please check your credentials and try again");
return;
}
if (!data.user?.id) {
Alert.alert("Login failed, missing user session after sign-in");
return;
}
try {
const setupStatus = await getSetupStatus(data.user.id);
router.replace(setupStatus.isSetupComplete ? "/" : "/setup");
} catch {
router.replace("/setup");
}
}
const inputClassName =
'rounded-2xl border border-app-border bg-app-subtle px-4 py-3 text-base text-text-main'
return (
<KeyboardAvoidingView
className="flex-1 bg-app-bg"
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 24}
>
<TouchableWithoutFeedback onPress={Keyboard.dismiss}>
<ScrollView
ref={scrollViewRef}
className="flex-1"
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
automaticallyAdjustKeyboardInsets={Platform.OS === 'ios'}
contentContainerStyle={{
flexGrow: 1,
justifyContent: isKeyboardVisible ? 'flex-start' : 'center',
paddingHorizontal: 20,
paddingTop: isKeyboardVisible ? 24 : 64,
paddingBottom: isKeyboardVisible ? 96 : 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="mt-5 rounded-2xl border border-app-border bg-app-subtle p-4">
<Text className="text-sm font-bold text-text-main">
Your study path stays with your account
</Text>
<Text className="mt-1 text-sm leading-5 text-text-secondary">
Subjects, assignments, tasks, and tracked sprint progress follow
you after you sign in.
</Text>
</View>
<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}
onFocus={() => {
scrollViewRef.current?.scrollToEnd({ animated: true });
}}
/>
</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

@@ -0,0 +1,359 @@
import {
GetActiveSession,
GetSetupSprintDemoUsed,
SaveSetupSprintDemoUsed,
type ActiveSession,
} from '@/lib/asyncStorage';
import { DEFAULT_FOCUS_DURATION_MINUTES } from '@/lib/sessionDefaults';
import { getSetupStatus, type SetupStepKey } from '@/lib/setupStatus';
import { finalizeStoredSession } from '@/lib/sessionLifecycle';
import { supabase } from '@/lib/supabase';
import { Session } from '@supabase/supabase-js';
import { Redirect, Stack, router, useFocusEffect, useLocalSearchParams } from 'expo-router';
import { useCallback, useEffect, useState } from 'react';
import { Pressable, ScrollView, Text, View } from 'react-native';
type SetupState = {
subjectId: string | null;
assignmentId: string | null;
taskId: string | null;
completedFocusSessions: number;
};
const SETUP_STEPS = [
{
key: 'subject',
title: 'Create your first subject',
description:
'Start with one course or study area so the rest of the structure has a clear home.',
},
{
key: 'assignment',
title: 'Create your first assignment',
description:
'Add one project, exercise set, or exam-prep block inside that subject.',
},
{
key: 'task',
title: 'Create your first task',
description:
'Break the assignment into one concrete thing you can actually sit down and do.',
},
{
key: 'sprint',
title: 'Start your first sprint',
description:
'Begin one focused study session so the app immediately turns into action instead of setup.',
},
] as const;
export default function SetupScreen() {
const {
subjectId: subjectIdParam,
assignmentId: assignmentIdParam,
taskId: taskIdParam,
} = useLocalSearchParams<{
subjectId?: string;
assignmentId?: string;
taskId?: string;
}>();
const [session, setSession] = useState<Session | null>(null);
const [isAuthLoading, setIsAuthLoading] = useState(true);
const [setupState, setSetupState] = useState<SetupState>({
subjectId: subjectIdParam ?? null,
assignmentId: assignmentIdParam ?? null,
taskId: taskIdParam ?? null,
completedFocusSessions: 0,
});
const [activeSession, setActiveSession] = useState<ActiveSession | null>(null);
useEffect(() => {
supabase.auth.getSession().then(({ data }) => {
setSession(data.session ?? null);
setIsAuthLoading(false);
});
const { data: sub } = supabase.auth.onAuthStateChange((_event, newSession) => {
setSession(newSession);
setIsAuthLoading(false);
});
return () => sub.subscription.unsubscribe();
}, []);
const loadSetupState = useCallback(async () => {
if (!session?.user.id) {
setSetupState({
subjectId: null,
assignmentId: null,
taskId: null,
completedFocusSessions: 0,
});
setActiveSession(null);
return;
}
const [storedActiveSession, status] = await Promise.all([
GetActiveSession(),
getSetupStatus(session.user.id),
]);
if (storedActiveSession && storedActiveSession.endTime <= Date.now()) {
await finalizeStoredSession('expired', storedActiveSession);
setActiveSession(null);
} else {
setActiveSession(storedActiveSession);
}
setSetupState({
subjectId: subjectIdParam ?? status.subjectId,
assignmentId: assignmentIdParam ?? status.assignmentId,
taskId: taskIdParam ?? status.taskId,
completedFocusSessions: status.completedFocusSessions,
});
}, [assignmentIdParam, session?.user.id, subjectIdParam, taskIdParam]);
useFocusEffect(
useCallback(() => {
void loadSetupState();
}, [loadSetupState])
);
const currentStep: SetupStepKey = (() => {
if (!setupState.subjectId) {
return 'subject';
}
if (!setupState.assignmentId) {
return 'assignment';
}
if (!setupState.taskId) {
return 'task';
}
return 'sprint';
})();
const isSetupComplete =
setupState.taskId !== null && setupState.completedFocusSessions > 0;
const handlePrimaryAction = useCallback(async () => {
if (isSetupComplete) {
router.replace('/');
return;
}
if (currentStep === 'subject') {
router.push({
pathname: '/subject/upsertSubject',
params: { flow: 'setup' },
});
return;
}
if (currentStep === 'assignment' && setupState.subjectId) {
router.push({
pathname: '/assignment/upsertAssignment',
params: {
sId: setupState.subjectId,
flow: 'setup',
},
});
return;
}
if (currentStep === 'task' && setupState.assignmentId) {
router.push({
pathname: '/task/upsertTask',
params: {
aId: setupState.assignmentId,
flow: 'setup',
},
});
return;
}
if (!setupState.taskId) {
return;
}
const freshActiveSession = await GetActiveSession();
if (freshActiveSession && freshActiveSession.endTime > Date.now()) {
router.push({
pathname: '/task/timer',
params: freshActiveSession.taskId
? { tId: freshActiveSession.taskId }
: {
sessionType: freshActiveSession.sessionType,
durationMinutes: String(
Math.max(1, Math.round(freshActiveSession.durationSeconds / 60))
),
},
});
return;
}
if (freshActiveSession) {
await finalizeStoredSession('expired', freshActiveSession);
setActiveSession(null);
}
const shouldUseDemoSprint = session?.user.id
? !(await GetSetupSprintDemoUsed(session.user.id))
: false;
if (shouldUseDemoSprint && session?.user.id) {
await SaveSetupSprintDemoUsed(session.user.id);
}
router.push({
pathname: '/task/timer',
params: shouldUseDemoSprint
? {
tId: setupState.taskId,
durationSeconds: '5',
onboardingDemo: 'true',
}
: {
tId: setupState.taskId,
durationMinutes: String(DEFAULT_FOCUS_DURATION_MINUTES),
},
});
}, [currentStep, isSetupComplete, session?.user.id, setupState]);
const primaryLabel = isSetupComplete
? 'Go to dashboard'
: currentStep === 'subject'
? 'Create first subject'
: currentStep === 'assignment'
? 'Create first assignment'
: currentStep === 'task'
? 'Create first task'
: activeSession
? 'Open active sprint'
: 'Start first sprint';
if (isAuthLoading) {
return null;
}
if (!session) {
return <Redirect href="/login" />;
}
return (
<View className="flex-1 bg-app-bg">
<Stack.Screen
options={{
title: 'Guided Setup',
headerTitleAlign: 'center',
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>
),
}}
/>
<ScrollView
className="flex-1"
contentContainerStyle={{
paddingHorizontal: 20,
paddingTop: 20,
paddingBottom: 32,
}}
showsVerticalScrollIndicator={false}
>
<View className="rounded-3xl border border-app-border bg-app-surface p-5">
<Text className="text-xs font-bold uppercase tracking-[0.8px] text-text-muted">
First-time setup
</Text>
<Text className="mt-2 text-3xl font-bold text-text-main">
Build one simple study path
</Text>
<Text className="mt-3 text-base leading-6 text-text-secondary">
You only need one subject, one assignment, one task, and one sprint to
make the app useful.
</Text>
</View>
<View className="mt-6 gap-3">
{SETUP_STEPS.map((step, index) => {
const isDone =
step.key === 'subject'
? Boolean(setupState.subjectId)
: step.key === 'assignment'
? Boolean(setupState.assignmentId)
: step.key === 'task'
? Boolean(setupState.taskId)
: isSetupComplete;
const isCurrent = !isDone && currentStep === step.key;
return (
<View
key={step.key}
className={`rounded-3xl border p-4 ${
isCurrent
? 'border-accent bg-accent-soft'
: 'border-app-border bg-app-surface'
}`}
>
<View className="flex-row items-start">
<View
className={`mr-3 h-8 w-8 items-center justify-center rounded-full ${
isDone ? 'bg-accent' : isCurrent ? 'bg-text-main' : 'bg-app-subtle'
}`}
>
<Text
className={`text-sm font-bold ${
isDone || isCurrent ? 'text-text-inverse' : 'text-text-secondary'
}`}
>
{isDone ? '✓' : index + 1}
</Text>
</View>
<View className="flex-1">
<Text className="text-lg font-bold text-text-main">
{step.title}
</Text>
<Text className="mt-1 text-sm leading-5 text-text-secondary">
{step.description}
</Text>
</View>
</View>
</View>
);
})}
</View>
<View className="mt-6 rounded-3xl border border-app-border bg-app-surface p-5">
<Text className="text-sm font-semibold text-text-secondary">
{isSetupComplete
? 'You have already completed at least one focus sprint.'
: currentStep === 'sprint'
? 'The structure is ready. The next step is to actually begin a sprint.'
: 'Follow the next step below. The rest of the app will make more sense once that path exists.'}
</Text>
<Pressable
className="mt-5 h-14 items-center justify-center rounded-2xl bg-accent"
onPress={handlePrimaryAction}
>
<Text className="text-base font-bold text-text-inverse">
{primaryLabel}
</Text>
</Pressable>
</View>
</ScrollView>
</View>
);
}

View File

@@ -0,0 +1,10 @@
import { Stack } from "expo-router";
export default function SubjectLayout() {
return (
<Stack>
<Stack.Screen name="upsertSubject" />
<Stack.Screen name="viewDetailsSubject" options={{ title: "Subject Details" }} />
</Stack>
);
}

View File

@@ -0,0 +1,366 @@
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, flow } = useLocalSearchParams<{ sId?: string; flow?: string }>();
const isEditMode = Boolean(sId);
const isSetupFlow = flow === 'setup';
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).select().single();
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;
}
if (!isEditMode && isSetupFlow && result.data?.sId) {
router.replace({
pathname: '/assignment/upsertAssignment',
params: {
sId: result.data.sId,
flow: 'setup',
},
});
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,
headerTitleAlign: 'center',
}}
/>
<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 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 className={inputClassName}
placeholder={isSetupFlow ? 'e.g. Algorithms' : 'Enter subject title'}
testID = "subject-title-input"
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={isSetupFlow ? 'e.g. Lectures, problem sets, and exam prep' : '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
testID = "upsert-subject-button"
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

@@ -0,0 +1,540 @@
import { formatDate, formatDateTime } from '@/lib/date';
import { SUBJECT_COLORS, type SubjectColor } from '@/lib/subjectColors';
import { supabase } from '@/lib/supabase';
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 { ActivityIndicator, 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 [isLoading, SetIsLoading] = useState(false);
const assignmentSections = [
{
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();
}, []);
const GetSubject = async (subjectId: string) => {
SetIsLoading(true);
const { data, error } = await supabase
.from('subjects')
.select('*')
.eq('sId', subjectId)
.single();
SetIsLoading(false);
if (error) {
Alert.alert('Subject could not be fetched, please try again');
return;
}
SetSubject((data as Subject) ?? null);
};
const GetAssignments = async (subjectId: string) => {
SetIsLoading(true);
const { data, error } = await supabase
.from('assignments')
.select('*')
.eq('sId', subjectId)
.order('deadline', { ascending: true });
SetIsLoading(false);
if (error) {
Alert.alert('Assignments could not be fetched, please try again');
return;
}
SetAssignments(data ?? []);
};
const ToggleAssignmentCompletion = async (assignment: Assignment) => {
const nextIsCompleted = !assignment.isCompleted;
const { error } = await supabase
.from('assignments')
.update({
isCompleted: nextIsCompleted,
lastChanged: new Date().toISOString(),
})
.eq('aId', assignment.aId);
if (error) {
Alert.alert('Assignment could not be updated, please try again');
return;
}
await GetAssignments(assignment.sId);
await GetSubject(assignment.sId);
};
useFocusEffect(
useCallback(() => {
if (!session || !sId) {
return;
}
SetIsLoading(true);
SetSubject(null);
Promise.all([GetSubject(sId), GetAssignments(sId)]).finally(() => {
SetIsLoading(false);
});
}, [session, sId])
);
const DeleteSubject = async (subjectId: 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', subjectId);
if (error) {
Alert.alert('Subject could not be deleted, please try again');
return;
}
Alert.alert('Subject deleted successfully!');
router.back();
},
},
]
);
};
const DeleteAssignment = async (assignmentId: string, subjectId: 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', assignmentId);
if (error) {
Alert.alert('Assignment could not be deleted, please try again');
return;
}
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 (isLoading) {
return (
<View className="flex-1 bg-app-bg px-5 pt-6">
<Stack.Screen
options={{
title: 'Subject Details',
}}
/>
<View className="flex-1 items-center justify-center">
<ActivityIndicator size="large" color="#2563eb" />
<Text className="mt-4 text-base font-semibold text-text-secondary">
Loading subject...
</Text>
</View>
</View>
);
}
if (!subject) {
return (
<View className="flex-1 bg-app-bg px-5 pt-6">
<Stack.Screen
options={{
title: 'Subject Details',
headerTitleAlign: 'center',
}}
/>
<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 colorKey: SubjectColor = subject.color ?? 'slate';
const colorSet = SUBJECT_COLORS[colorKey];
const firstLetter = subject.title?.trim().charAt(0).toUpperCase() || 'S';
return (
<View className="flex-1 bg-app-bg">
<Stack.Screen
options={{
title: 'Subject Details',
headerRight: () => (
<Pressable
className="rounded-full bg-app-subtle px-4 py-2"
onPress={async () => await supabase.auth.signOut()}
>
<Text className="text-sm font-semibold text-text-secondary">
Logout
</Text>
</Pressable>
),
}}
/>
<SectionList
className="flex-1"
contentContainerStyle={{
paddingHorizontal: 20,
paddingTop: 20,
paddingBottom: 32,
}}
sections={totalAssignments === 0 ? [] : 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
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>
<Text className="mt-1 text-xs text-text-muted">
Based only on completed assignments in this subject.
</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
testID="delete-subject-button"
className="flex-1 items-center justify-center rounded-2xl border border-app-border bg-app-surface py-3"
onPress={() => DeleteSubject(subject.sId)}
>
<Text className="text-sm font-bold text-status-danger">
Delete
</Text>
</Pressable>
</View>
</View>
<Pressable
className="mb-6 mt-5 h-14 items-center justify-center rounded-2xl bg-accent"
onPress={() =>
router.push({
pathname: '../assignment/upsertAssignment',
params: { sId: subject.sId },
})
}
>
<Text className="text-base font-bold text-text-inverse">
Add Assignment
</Text>
</Pressable>
</View>
}
renderSectionHeader={({ section: { title, data } }) => (
<View className="mb-3 mt-2 flex-row items-center justify-between">
<Text className="text-lg font-bold text-text-main">{title}</Text>
<View className="rounded-full bg-app-subtle px-3 py-1">
<Text className="text-xs font-semibold text-text-muted">
{data.length}
</Text>
</View>
</View>
)}
renderItem={({ item }) => {
const isOwner = session?.user.id === item.uId;
return (
<View
className="mb-4 rounded-3xl border border-app-border bg-app-surface p-4"
style={{
borderColor: colorSet.strong,
}}
>
<View className="flex-row items-center">
<Pressable
className="flex-1"
onPress={() =>
router.push({
pathname: '/assignment/viewDetailsAssignment',
params: { aId: item.aId },
})
}
>
<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>
</Pressable>
</View>
{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 py-3"
style={{ backgroundColor: item.isCompleted ? '#EFEBE3' : colorSet.soft }}
onPress={() => ToggleAssignmentCompletion(item)}
>
<Text
className="text-sm font-bold"
style={{ color: colorSet.strong }}
>
{item.isCompleted ? 'Reopen' : 'Complete'}
</Text>
</Pressable>
<Pressable
className="mr-3 flex-1 items-center justify-center rounded-2xl border border-app-border bg-app-subtle py-3"
onPress={() =>
router.push({
pathname: '../assignment/upsertAssignment',
params: { aId: item.aId },
})
}
>
<Text className="text-sm font-bold text-text-secondary">
Edit
</Text>
</Pressable>
<Pressable
className="flex-1 items-center justify-center rounded-2xl border border-app-border bg-app-surface py-3"
onPress={() => DeleteAssignment(item.aId, item.sId)}
>
<Text className="text-sm font-bold text-status-danger">
Delete
</Text>
</Pressable>
</View>
)}
</View>
);
}}
ListEmptyComponent={
<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">
No assignments yet
</Text>
<Text className="mt-1 text-center text-sm text-text-muted">
Add one when this subject has work to track.
</Text>
</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.length === 0
? 'Create the first assignment to give this subject a real study path.'
: 'Assignments for this subject will show up here.'}
</Text>
</View>
) : (
<View className="mb-2" />
)
}
/>
</View>
);
}

View File

@@ -0,0 +1,11 @@
import { Stack } from "expo-router";
export default function TaskLayout() {
return (
<Stack>
<Stack.Screen name="upsertTask" options={{ title: "Create Task" }} />
<Stack.Screen name="viewDetailsTask" options={{ title: "Task Details" }} />
<Stack.Screen name='timer' options={{title: 'Sprint'}} />
</Stack>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,298 @@
import { defaultStyles } from '@/constants/defaultStyles';
import { SaveSetupSprintDemoUsed } from '@/lib/asyncStorage';
import { CheckAssignmentCompletion } from '@/lib/progress';
import { supabase } from '@/lib/supabase';
import type { Task } from '@/lib/types';
import { router, Stack, useLocalSearchParams } from 'expo-router';
import { useEffect, useState } from 'react';
import {
ActivityIndicator,
Alert,
Keyboard,
KeyboardAvoidingView,
Platform,
Pressable,
ScrollView,
Text,
TextInput,
TouchableWithoutFeedback,
View,
} from 'react-native';
export default function UpsertTask() {
const { tId, aId: routeAId, flow } = useLocalSearchParams<{
tId?: string;
aId?: string;
flow?: string;
}>();
const isEditMode = Boolean(tId);
const isSetupFlow = flow === 'setup';
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);
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;
}
const { data, error: userError } = await supabase.auth.getUser();
if (userError || !data.user) {
router.replace('/login');
return;
}
if (!assignmentId) {
Alert.alert('Missing assignment', 'This task is not linked to an assignment.');
return;
}
SetIsSaving(true);
const payload = {
title: title.trim(),
description: description.trim(),
isCompleted,
lastChanged: new Date().toISOString(),
uId: data.user.id,
aId: assignmentId,
};
const result =
isEditMode && tId
? await supabase.from('tasks').update(payload).eq('tId', tId)
: await supabase.from('tasks').insert(payload).select().single();
if (result.error) {
SetIsSaving(false);
Alert.alert(
isEditMode
? 'Task could not be updated, please try again'
: 'Task could not be created, please try again'
);
return;
}
try {
await CheckAssignmentCompletion(assignmentId);
} catch {
SetIsSaving(false);
Alert.alert('Failed to update assignment completion state');
return;
}
SetIsSaving(false);
if (!isEditMode && isSetupFlow && result.data?.tId) {
await SaveSetupSprintDemoUsed(data.user.id);
router.replace({
pathname: '/task/timer',
params: {
tId: result.data.tId,
durationSeconds: '5',
onboardingDemo: 'true',
},
});
return;
}
Alert.alert(
isEditMode ? 'Task successfully updated!' : 'Task successfully created!'
);
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';
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 Task' : 'Create Task',
headerTitleStyle: defaultStyles.title,
headerTitleAlign: 'center',
}}
/>
<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 Task' : 'Create Task'}
</Text>
<Text className="mt-2 text-base leading-6 text-text-secondary">
{isEditMode
? 'Update this task and keep your assignment moving forward.'
: 'Add a small step to move this assignment forward.'}
</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="task-title-input"
className={inputClassName}
placeholder={isSetupFlow ? 'e.g. Solve questions 1-3' : 'Enter task 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={
isSetupFlow
? 'e.g. Work through the first three tasks without notes'
: 'Add a short description'
}
placeholderTextColor="#9CA3AF"
value={description}
onChangeText={SetDescription}
multiline
textAlignVertical="top"
/>
</View>
<Pressable
onPress={() => SetIsCompleted((state) => !state)}
disabled={isSaving}
className={`mb-6 flex-row items-center rounded-2xl border p-4 ${
isCompleted
? '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 ${
isCompleted
? 'border-accent bg-accent'
: 'border-app-border bg-app-surface'
}`}
>
{isCompleted && (
<Text className="text-sm font-bold text-text-inverse">
</Text>
)}
</View>
<View className="flex-1">
<Text className="text-base font-semibold text-text-main">
Mark as completed
</Text>
<Text className="mt-1 text-sm text-text-muted">
You can change this later.
</Text>
</View>
</Pressable>
<Pressable
testID="upsert-task-button"
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 Task'}
</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

@@ -0,0 +1,468 @@
import { GetActiveSession } from '@/lib/asyncStorage';
import { formatDateTime } from '@/lib/date';
import { CheckAssignmentCompletion } from '@/lib/progress';
import { DEFAULT_FOCUS_DURATION_MINUTES } from '@/lib/sessionDefaults';
import { finalizeStoredSession } from '@/lib/sessionLifecycle';
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 { ActivityIndicator, Alert, Pressable, Text, View } from 'react-native';
function formatTrackedTime(totalSeconds: number) {
if (totalSeconds <= 0) {
return '0m';
}
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
if (hours === 0) {
return `${minutes}m`;
}
if (minutes === 0) {
return `${hours}h`;
}
return `${hours}h ${minutes}m`;
}
export default function ViewDetailsTask() {
const { tId } = useLocalSearchParams<{ tId: string }>();
const [task, SetTask] = useState<Task | null>(null);
const [session, SetSession] = useState<Session | null>(null);
const [isLoading, SetIsLoading] = useState(false);
const [completedFocusSessions, setCompletedFocusSessions] = useState(0);
const [contextMeta, setContextMeta] = useState({
subjectTitle: 'No Subject',
assignmentTitle: 'No Assignment',
subjectColor: 'slate' as SubjectColor,
});
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 loadTaskStudyActivity = useCallback(async (taskId: string, userId: string) => {
const { count, error } = await supabase
.from('sprint_sessions')
.select('sessionId', { count: 'exact', head: true })
.eq('taskId', taskId)
.eq('userId', userId)
.eq('sessionType', 'focus')
.eq('status', 'completed');
if (error) {
setCompletedFocusSessions(0);
return;
}
setCompletedFocusSessions(count ?? 0);
}, []);
const GetTask = useCallback(async (taskId: string) => {
SetIsLoading(true);
const { data, error } = await supabase
.from('tasks')
.select('*')
.eq('tId', taskId)
.single();
if (error || !data) {
SetTask(null);
setContextMeta({
subjectTitle: 'Unknown Subject',
assignmentTitle: 'Unknown Assignment',
subjectColor: 'slate',
});
setCompletedFocusSessions(0);
SetIsLoading(false);
Alert.alert('Task could not be fetched, please try again');
return;
}
SetTask(data);
await loadTaskStudyActivity(taskId, data.uId);
let nextContextMeta = {
subjectTitle: 'Unknown Subject',
assignmentTitle: 'Unknown Assignment',
subjectColor: 'slate' as SubjectColor,
};
if (data.aId) {
const { data: assignmentData, error: assignmentError } = await supabase
.from('assignments')
.select('title, sId')
.eq('aId', data.aId)
.single();
if (!assignmentError && assignmentData) {
nextContextMeta.assignmentTitle = assignmentData.title ?? 'Unknown Assignment';
if (assignmentData.sId) {
const { data: subjectData, error: subjectError } = await supabase
.from('subjects')
.select('title, color')
.eq('sId', assignmentData.sId)
.single();
if (!subjectError && subjectData) {
nextContextMeta = {
subjectTitle: subjectData.title ?? 'Unknown Subject',
assignmentTitle: assignmentData.title ?? 'Unknown Assignment',
subjectColor: (subjectData.color as SubjectColor | undefined) ?? 'slate',
};
}
}
}
}
setContextMeta(nextContextMeta);
SetIsLoading(false);
}, [loadTaskStudyActivity]);
useFocusEffect(
useCallback(() => {
if (session && tId) {
void GetTask(tId);
}
}, [GetTask, session, tId])
);
const handleSprintStart = async () => {
const activeSession = await GetActiveSession();
if (!activeSession) {
router.push({
pathname: '/task/timer',
params: {
tId: task?.tId,
durationMinutes: String(DEFAULT_FOCUS_DURATION_MINUTES),
},
});
return;
}
const secondsLeft = Math.ceil((activeSession.endTime - Date.now()) / 1000);
if (secondsLeft <= 0) {
await finalizeStoredSession('expired', activeSession);
router.push({
pathname: '/task/timer',
params: {
tId: task?.tId,
durationMinutes: String(DEFAULT_FOCUS_DURATION_MINUTES),
},
});
return;
}
if (activeSession.taskId === task?.tId) {
router.push({
pathname: '/task/timer',
params: {
tId: activeSession.taskId ?? undefined,
durationMinutes: String(DEFAULT_FOCUS_DURATION_MINUTES),
},
});
return;
}
Alert.alert(
'Active session in progress',
`End the current session and start a new ${DEFAULT_FOCUS_DURATION_MINUTES} minute sprint on this task?`,
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Start new sprint',
style: 'destructive',
onPress: async () => {
await finalizeStoredSession('cancelled', activeSession);
router.push({
pathname: '/task/timer',
params: {
tId: task?.tId,
durationMinutes: String(DEFAULT_FOCUS_DURATION_MINUTES),
},
});
},
},
]
);
};
const DeleteTask = async (taskId: 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', taskId);
if (error) {
Alert.alert('Task could not be deleted, please try again');
return;
}
const aId = task?.aId;
if (aId) {
try {
await CheckAssignmentCompletion(aId);
} catch {
Alert.alert('Failed to update assignment completion state');
}
}
Alert.alert('Task deleted successfully!');
router.back();
},
},
]
);
};
const colorSet = getSubjectColorSet(contextMeta.subjectColor);
if (isLoading) {
return (
<View className="flex-1 items-center justify-center bg-app-bg">
<ActivityIndicator size="large" />
</View>
);
}
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 className="flex-1 bg-app-bg px-5 pt-6">
<Stack.Screen
options={{
title: 'Task Details',
headerTitleAlign: 'center',
headerRight: () => (
<Pressable
className="rounded-full bg-app-subtle px-4 py-2"
onPress={async () => await supabase.auth.signOut()}
>
<Text className="text-sm font-semibold text-text-secondary">
Logout
</Text>
</Pressable>
),
}}
/>
<View
className="rounded-3xl bg-app-surface p-5"
style={{
borderWidth: 1,
borderColor: colorSet.strong,
}}
>
<View className="flex-row items-start">
<View
className="mr-3 mt-1 h-6 w-6 items-center justify-center rounded-md border-2"
style={{
borderColor: task.isCompleted ? colorSet.strong : '#DDD6C8',
backgroundColor: task.isCompleted ? colorSet.strong : '#EFEBE3',
}}
>
{task.isCompleted ? (
<Text className="text-sm font-bold text-text-inverse"></Text>
) : null}
</View>
<View className="flex-1">
<Text
className={`text-2xl font-bold ${
task.isCompleted ? 'text-text-secondary' : 'text-text-main'
}`}
>
{task.title}
</Text>
{task.description ? (
<Text className="mt-3 text-base leading-6 text-text-secondary">
{task.description}
</Text>
) : (
<Text className="mt-3 text-base text-text-muted">
No description added.
</Text>
)}
<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 className="mr-2 mb-2 rounded-full bg-app-subtle px-3 py-1">
<Text className="text-xs font-semibold text-text-secondary">
Status: {task.isCompleted ? 'Completed' : 'Not completed'}
</Text>
</View>
</View>
<View className="mt-5 rounded-2xl bg-app-subtle p-4">
<Text className="text-sm font-semibold text-text-secondary">
Study activity
</Text>
<Text className="mt-1 text-xs leading-5 text-text-muted">
This tracks focused work on the task separately from whether the task is marked completed.
</Text>
<View className="mt-4 flex-row gap-3">
<View className="flex-1 rounded-2xl bg-app-surface px-4 py-3">
<Text className="text-xs font-semibold uppercase tracking-[0.6px] text-text-muted">
Focus time
</Text>
<Text className="mt-1 text-lg font-bold text-text-main">
{formatTrackedTime(task.totalTimeInSeconds ?? 0)}
</Text>
</View>
<View className="flex-1 rounded-2xl bg-app-surface px-4 py-3">
<Text className="text-xs font-semibold uppercase tracking-[0.6px] text-text-muted">
Completed sessions
</Text>
<Text className="mt-1 text-lg font-bold text-text-main">
{completedFocusSessions}
</Text>
</View>
</View>
</View>
<Text className="mt-2 text-sm text-text-muted">
Last changed: {formatDateTime(task.lastChanged)}
</Text>
</View>
</View>
{isOwner ? (
<View className="mt-5 border-t border-app-border pt-5">
<Pressable
className="h-14 items-center justify-center rounded-2xl bg-accent"
onPress={handleSprintStart}
>
<Text className="text-base font-bold text-text-inverse">
Start Sprint
</Text>
</Pressable>
<Text className="mt-3 text-sm text-text-muted">
Starts a {DEFAULT_FOCUS_DURATION_MINUTES} minute focus sprint for this task.
</Text>
<View className="mt-4 flex-row">
<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>
) : null}
</View>
</View>
);
}