Merge branch 'main' into tailwind

This commit is contained in:
Teodor Salvesen
2026-05-05 14:31:02 +02:00
committed by GitHub
40 changed files with 5882 additions and 769 deletions

1
.gitignore vendored
View File

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

View File

@@ -0,0 +1,82 @@
import UpsertAssignment from "@/app/assignment/upsertAssignment";
import { CheckSubjectCompletion } from "@/lib/progress";
import { supabase } from "@/lib/supabase";
import { fireEvent, render, waitFor } from "@testing-library/react-native";
import { router } from "expo-router";
const mockSingle = jest.fn();
const mockSelect = jest.fn(() => ({ single: mockSingle, }));
const mockInsert = jest.fn(() => ({ select: mockSelect, }));
jest.mock("expo-router", () => ({
router: {
back: jest.fn(),
replace: jest.fn(),
},
Stack: {
Screen: () => null,
},
useLocalSearchParams: () => ({
sId: "subject-123",
}),
}));
jest.mock("@/lib/progress", () => ({
CheckSubjectCompletion: jest.fn(() => Promise.resolve()),
}));
jest.mock("@/lib/asyncStorage", () => ({
GetAssignmentNotificationId: jest.fn(() => Promise.resolve()),
SaveAssignmentNotificationId: jest.fn(() => Promise.resolve()),
}));
jest.mock("expo-notifications", () => ({
scheduleNotificationAsync: jest.fn(() => Promise.resolve("notification-123")),
SchedulableTriggerInputTypes: {
DATE: "date",
},
}));
jest.mock("@/lib/supabase", () => ({
supabase: {
auth: {
getUser: jest.fn(() =>
Promise.resolve({
data: { user: { id: "user-123" } },
error: null,
})
),
},
from: jest.fn(() => ({
insert: mockInsert,
})),
},
}));
test("creates an assignment and navigates back", async () => {
mockSingle.mockResolvedValue({
data: {
aId: "assignment-123",
title: "create a simple test",
deadline: "",
},
error: null,
});
const screen = render(<UpsertAssignment />);
fireEvent.changeText(screen.getByTestId("assignment-title-input"), "create a simple test");
fireEvent.press(screen.getByTestId("upsert-assignment-button"));
await waitFor(() => {
expect(supabase.from).toHaveBeenCalledWith("assignments");
expect(mockInsert).toHaveBeenCalledWith(
expect.objectContaining({
title: "create a simple test",
uId: "user-123",
sId: "subject-123",
})
);
expect(CheckSubjectCompletion).toHaveBeenCalledWith("subject-123");
expect(router.back).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,141 @@
import ViewDetailsAssignment from "@/app/assignment/viewDetailsAssignment";
import { CheckSubjectCompletion } from "@/lib/progress";
import { supabase } from "@/lib/supabase";
import { fireEvent, render, waitFor } from "@testing-library/react-native";
import { router } from "expo-router";
import { Alert } from "react-native";
const mockAssignmentSingle = jest.fn();
const mockAssignmentSelectEq = jest.fn(() => ({ single: mockAssignmentSingle, }));
const mockAssignmentSelect = jest.fn(() => ({ eq: mockAssignmentSelectEq, }));
const mockAssignmentDeleteEq = jest.fn();
const mockAssignmentDelete = jest.fn(() => ({ eq: mockAssignmentDeleteEq, }));
const mockTasksSelectEq = jest.fn();
const mockTasksSelect = jest.fn(() => ({ eq: mockTasksSelectEq }));
const mockSubjectSingle = jest.fn();
const mockSubjectSelectEq = jest.fn(() => ({ single: mockSubjectSingle }));
const mockSubjectSelect = jest.fn(() => ({ eq: mockSubjectSelectEq }));
jest.mock("expo-router", () => ({
router: {
back: jest.fn(),
replace: jest.fn(),
},
Stack: {
Screen: () => null,
},
useLocalSearchParams: () => ({
aId: "assignment-123",
}),
useFocusEffect: (callback: () => void) => {
const React = require("react");
React.useEffect(callback, [callback]);
},
}));
jest.mock("@/lib/progress", () => ({
CheckSubjectCompletion: jest.fn(() => Promise.resolve()),
}));
jest.mock("@/lib/supabase", () => ({
supabase: {
auth: {
getUser: jest.fn(() =>
Promise.resolve({
data: { user: { id: "user-123" } },
error: null,
})
),
getSession: jest.fn(() =>
Promise.resolve({
data: {
session: {
user: { id: "user-123" },
},
},
})
),
onAuthStateChange: jest.fn(() => ({
data: {
subscription: {
unsubscribe: jest.fn(),
},
},
})),
},
from: jest.fn((table: string) => {
if (table === "assignments") {
return {
select: mockAssignmentSelect,
delete: mockAssignmentDelete,
};
}
if (table === "tasks") {
return {
select: mockTasksSelect,
};
}
if (table === "subjects") {
return {
select: mockSubjectSelect,
};
}
return {};
}),
},
}));
const alertSpy = jest.spyOn(Alert, "alert");
test("deletes a task and navigates back", async () => {
mockAssignmentSingle.mockResolvedValue({
data: {
aId: "assignment-123",
title: "create a simple test",
uId: "user-123",
sId: "subject-123"
},
error: null,
});
mockTasksSelectEq.mockResolvedValue({ data: [], error: null, })
mockSubjectSingle.mockResolvedValue({
data: {
sId: "subject-123",
title: "ikt205g26v",
color: "blue",
},
error: null,
});
mockAssignmentDeleteEq.mockResolvedValue({ error: null, });
const screen = render(<ViewDetailsAssignment />);
await screen.findByText("create a simple test");
await screen.findByText("ikt205g26v");
fireEvent.press(await screen.findByTestId("delete-assignment-button"));
expect(alertSpy).toHaveBeenCalledWith(
"Delete Assignment",
"Are you sure you want to delete this assignment?",
expect.any(Array),
);
const alertButtons = alertSpy.mock.calls[0][2];
const confirmDeleteButton = alertButtons[1];
await confirmDeleteButton.onPress();
await waitFor(() => {
expect(supabase.from).toHaveBeenCalledWith("assignments");
expect(mockAssignmentDelete).toHaveBeenCalled();
expect(mockAssignmentDeleteEq).toHaveBeenCalledWith("aId", "assignment-123");
expect(CheckSubjectCompletion).toHaveBeenCalledWith("subject-123");
expect(router.back).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,100 @@
import UpsertAssignment from "@/app/assignment/upsertAssignment";
import { CheckSubjectCompletion } from "@/lib/progress";
import { supabase } from "@/lib/supabase";
import { fireEvent, render, waitFor } from "@testing-library/react-native";
import { router } from "expo-router";
const mockUpdateSingle = jest.fn();
const mockUpdateSelect = jest.fn(() => ({ single: mockUpdateSingle, }));
const mockUpdateEq = jest.fn(() => ({ select: mockUpdateSelect, }));
const mockUpdate = jest.fn(() => ({ eq: mockUpdateEq, }));
const mockSingle = jest.fn();
const mockSelectEq = jest.fn(() => ({ single: mockSingle, }));
const mockSelect = jest.fn(() => ({ eq: mockSelectEq, }));
jest.mock("expo-router", () => ({
router: {
back: jest.fn(),
replace: jest.fn(),
},
Stack: {
Screen: () => null,
},
useLocalSearchParams: () => ({
aId: "assignment-123",
}),
useFocusEffect: (callback: () => void) => callback(),
}));
jest.mock("@/lib/progress", () => ({
CheckSubjectCompletion: jest.fn(() => Promise.resolve()),
}));
jest.mock("@/lib/asyncStorage", () => ({
GetAssignmentNotificationId: jest.fn(() => Promise.resolve(null)),
}));
jest.mock("expo-notifications", () => ({
scheduleNotificationAsync: jest.fn(() => Promise.resolve("notification-123")),
SchedulableTriggerInputTypes: {
DATE: "date",
},
}));
jest.mock("@/lib/supabase", () => ({
supabase: {
auth: {
getUser: jest.fn(() =>
Promise.resolve({
data: { user: { id: "user-123" } },
error: null,
})
),
},
from: jest.fn(() => ({
select: mockSelect,
update: mockUpdate,
})),
},
}));
test("updates an assignment and navigates back", async () => {
mockSingle.mockResolvedValue({
data: {
aId: "assignment-123",
title: "create a simple test",
deadline: "2026-04-25",
uId: "user-123",
sId: "subject-123",
},
error: null,
});
mockUpdateSingle.mockResolvedValue({
data: {
aId: "assignment-123",
title: "create a harder test",
deadline: "2026-04-25",
uId: "user-123",
},
error: null,
});
const screen = render(<UpsertAssignment />);
fireEvent.changeText(await screen.findByTestId("assignment-title-input"), "create a harder test");
fireEvent.press(screen.getByTestId("upsert-assignment-button"));
await waitFor(() => {
expect(supabase.from).toHaveBeenCalledWith("assignments");
expect(mockSelect).toHaveBeenCalled();
expect(mockUpdate).toHaveBeenCalledWith(
expect.objectContaining({
title: "create a harder test",
uId: "user-123",
deadline: "2026-04-25",
})
);
expect(mockUpdateSingle).toHaveBeenCalled();
expect(CheckSubjectCompletion).toHaveBeenCalledWith("subject-123");
expect(router.back).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,79 @@
import TabLayout from "@/app/(tabs)/_layout";
import { supabase } from "@/lib/supabase";
import { render, waitFor } from "@testing-library/react-native";
jest.mock("expo-router", () => {
const React = require("react");
const { Text, View } = require("react-native");
const MockTabs = ({ children }: { children?: React.ReactNode }) => (
<View>
<Text>tabs</Text>
{children}
</View>
);
MockTabs.Screen = () => null;
return {
Redirect: ({ href }: { href: string }) => <Text>redirect:{href}</Text>,
Tabs: MockTabs,
router: {
push: jest.fn(),
},
};
});
jest.mock("expo-notifications", () => ({
getLastNotificationResponse: jest.fn(() => null),
addNotificationResponseReceivedListener: jest.fn(() => ({
remove: jest.fn(),
})),
}));
jest.mock("@/lib/supabase", () => ({
supabase: {
auth: {
getSession: jest.fn(),
onAuthStateChange: jest.fn(() => ({
data: {
subscription: {
unsubscribe: jest.fn(),
},
},
})),
},
},
}));
beforeEach(() => {
jest.clearAllMocks();
});
test("redirects to login if there is no session", async () => {
(supabase.auth.getSession as jest.Mock).mockResolvedValue({
data: { session: null },
});
const screen = render(<TabLayout />);
await waitFor(() => {
expect(screen.getByText("redirect:/login")).toBeTruthy();
});
});
test("renders tabs when session exists", async () => {
(supabase.auth.getSession as jest.Mock).mockResolvedValue({
data: {
session: {
user: { id: "user-123" },
},
},
});
const screen = render(<TabLayout />);
await waitFor(() => {
expect(screen.getByText("tabs")).toBeTruthy();
});
});

View File

@@ -0,0 +1,52 @@
import UpsertSubject from "@/app/subject/upsertSubject";
import { supabase } from "@/lib/supabase";
import { fireEvent, render, waitFor } from "@testing-library/react-native";
import { router } from "expo-router";
const mockInsert = jest.fn();
jest.mock("expo-router", () => ({
router: {
back: jest.fn(),
replace: jest.fn(),
},
Stack: {
Screen: () => null,
},
useLocalSearchParams: () => ({}),
}));
jest.mock("@/lib/supabase", () => ({
supabase: {
auth: {
getUser: jest.fn(() =>
Promise.resolve({
data: { user: { id: "user-123" } },
error: null,
})
),
},
from: jest.fn(() => ({
insert: mockInsert,
})),
},
}));
test("creates a subject and navigates back", async () => {
mockInsert.mockResolvedValue({ error: null });
const screen = render(<UpsertSubject />);
fireEvent.changeText(screen.getByTestId("subject-title-input"), "ikt205g26v");
fireEvent.press(screen.getByTestId("upsert-subject-button"));
await waitFor(() => {
expect(supabase.from).toHaveBeenCalledWith("subjects");
expect(mockInsert).toHaveBeenCalledWith(
expect.objectContaining({
title: "ikt205g26v",
uId: "user-123",
})
);
expect(router.back).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,116 @@
import ViewDetailsSubject from "@/app/subject/viewDetailsSubject";
import { supabase } from "@/lib/supabase";
import { fireEvent, render, waitFor } from "@testing-library/react-native";
import { router } from "expo-router";
import { Alert } from "react-native";
const mockSubjectSingle = jest.fn();
const mockSubjectSelectEq = jest.fn(() => ({ single: mockSubjectSingle }));
const mockSubjectSelect = jest.fn(() => ({ eq: mockSubjectSelectEq }));
const mockSubjectDeleteEq = jest.fn();
const mockSubjectDelete = jest.fn(() => ({ eq: mockSubjectDeleteEq }));
const mockAssignmentsOrder = jest.fn();
const mockAssignmentsEq = jest.fn(() => ({ order: mockAssignmentsOrder }));
const mockAssignmentsSelect = jest.fn(() => ({ eq: mockAssignmentsEq }));
jest.mock("expo-router", () => ({
router: {
back: jest.fn(),
replace: jest.fn(),
},
Stack: {
Screen: () => null,
},
useLocalSearchParams: () => ({
sId: "subject-123",
}),
useFocusEffect: (callback: () => void) => {
const React = require("react");
React.useEffect(callback, [callback]);
},
}));
jest.mock("@/lib/supabase", () => ({
supabase: {
auth: {
getUser: jest.fn(() =>
Promise.resolve({
data: { user: { id: "user-123" } },
error: null,
})
),
getSession: jest.fn(() =>
Promise.resolve({
data: {
session: {
user: { id: "user-123" },
},
},
})
),
onAuthStateChange: jest.fn(() => ({
data: {
subscription: {
unsubscribe: jest.fn(),
},
},
})),
},
from: jest.fn((table) => {
if (table === "subjects") {
return {
select: mockSubjectSelect,
delete: mockSubjectDelete,
};
}
if (table === "assignments") {
return {
select: mockAssignmentsSelect,
};
}
return {};
}),
},
}));
const alertSpy = jest.spyOn(Alert, "alert");
test("deletes a subject and navigates back", async () => {
mockSubjectSingle.mockResolvedValue({
data: {
sId: "subject-123",
title: "ikt205g26v",
uId: "user-123",
},
error: null,
});
mockAssignmentsOrder.mockResolvedValue({ data: [], error: null, })
mockSubjectDeleteEq.mockResolvedValue({ error: null, });
const screen = render(<ViewDetailsSubject />);
await screen.findByText("ikt205g26v");
fireEvent.press(await screen.findByTestId("delete-subject-button"));
expect(alertSpy).toHaveBeenCalledWith(
"Delete Subject",
"Are you sure you want to delete this subject?",
expect.any(Array),
);
const alertButtons = alertSpy.mock.calls[0][2];
const confirmDeleteButton = alertButtons[1];
await confirmDeleteButton.onPress();
await waitFor(() => {
expect(supabase.from).toHaveBeenCalledWith("subjects");
expect(mockSubjectDelete).toHaveBeenCalled();
expect(mockSubjectDeleteEq).toHaveBeenCalledWith("sId", "subject-123");
expect(router.back).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,70 @@
import UpsertSubject from "@/app/subject/upsertSubject";
import { supabase } from "@/lib/supabase";
import { fireEvent, render, waitFor } from "@testing-library/react-native";
import { router } from "expo-router";
const mockUpdateEq = jest.fn();
const mockUpdate = jest.fn(() => ({ eq: mockUpdateEq, }));
const mockSingle = jest.fn();
const mockSelectEq = jest.fn(() => ({ single: mockSingle, }));
const mockSelect = jest.fn(() => ({ eq: mockSelectEq, }));
jest.mock("expo-router", () => ({
router: {
back: jest.fn(),
replace: jest.fn(),
},
Stack: {
Screen: () => null,
},
useLocalSearchParams: () => ({
sId: "subject-123",
}),
useFocusEffect: (callback: () => void) => callback(),
}));
jest.mock("@/lib/supabase", () => ({
supabase: {
auth: {
getUser: jest.fn(() =>
Promise.resolve({
data: { user: { id: "user-123" } },
error: null,
})
),
},
from: jest.fn(() => ({
select: mockSelect,
update: mockUpdate,
})),
},
}));
test("updates a subject and navigates back", async () => {
mockSingle.mockResolvedValue({
data: {
sId: "subject-123",
title: "ikt205g26v",
uId: "user-123",
},
error: null,
});
mockUpdateEq.mockResolvedValue({ error: null, });
const screen = render(<UpsertSubject />);
fireEvent.changeText(await screen.findByTestId("subject-title-input"), "ikt206g26v");
fireEvent.press(screen.getByTestId("upsert-subject-button"));
await waitFor(() => {
expect(supabase.from).toHaveBeenCalledWith("subjects");
expect(mockSelect).toHaveBeenCalled();
expect(mockUpdate).toHaveBeenCalledWith(
expect.objectContaining({
title: "ikt206g26v",
uId: "user-123",
})
);
expect(mockUpdateEq).toHaveBeenCalledWith("sId", "subject-123");
expect(router.back).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,61 @@
import UpsertTask from "@/app/task/upsertTask";
import { CheckAssignmentCompletion } from "@/lib/progress";
import { supabase } from "@/lib/supabase";
import { fireEvent, render, waitFor } from "@testing-library/react-native";
import { router } from "expo-router";
const mockInsert = jest.fn();
jest.mock("expo-router", () => ({
router: {
back: jest.fn(),
replace: jest.fn(),
},
Stack: {
Screen: () => null,
},
useLocalSearchParams: () => ({
aId: "assignment-123",
}),
}));
jest.mock("@/lib/progress", () => ({
CheckAssignmentCompletion: jest.fn(() => Promise.resolve()),
}));
jest.mock("@/lib/supabase", () => ({
supabase: {
auth: {
getUser: jest.fn(() =>
Promise.resolve({
data: { user: { id: "user-123" } },
error: null,
})
),
},
from: jest.fn(() => ({
insert: mockInsert,
})),
},
}));
test("creates a task and navigates back", async () => {
mockInsert.mockResolvedValue({ error: null });
const screen = render(<UpsertTask />);
fireEvent.changeText(screen.getByTestId("task-title-input"), "Read chapter 4");
fireEvent.press(screen.getByTestId("upsert-task-button"));
await waitFor(() => {
expect(supabase.from).toHaveBeenCalledWith("tasks");
expect(mockInsert).toHaveBeenCalledWith(
expect.objectContaining({
title: "Read chapter 4",
uId: "user-123",
aId: "assignment-123",
})
);
expect(CheckAssignmentCompletion).toHaveBeenCalledWith("assignment-123");
expect(router.back).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,150 @@
import ViewDetailsTask from "@/app/task/viewDetailsTask";
import { CheckAssignmentCompletion } from "@/lib/progress";
import { supabase } from "@/lib/supabase";
import { fireEvent, render, waitFor } from "@testing-library/react-native";
import { router } from "expo-router";
import { Alert } from "react-native";
const mockTaskSingle = jest.fn();
const mockTaskSelectEq = jest.fn(() => ({ single: mockTaskSingle }));
const mockTaskSelect = jest.fn(() => ({ eq: mockTaskSelectEq }));
const mockTaskDeleteEq = jest.fn();
const mockTaskDelete = jest.fn(() => ({ eq: mockTaskDeleteEq }));
const mockAssignmentSingle = jest.fn();
const mockAssignmentSelectEq = jest.fn(() => ({ single: mockAssignmentSingle }));
const mockAssignmentSelect = jest.fn(() => ({ eq: mockAssignmentSelectEq }));
const mockSubjectSingle = jest.fn();
const mockSubjectSelectEq = jest.fn(() => ({ single: mockSubjectSingle }));
const mockSubjectSelect = jest.fn(() => ({ eq: mockSubjectSelectEq }));
jest.mock("expo-router", () => ({
router: {
back: jest.fn(),
replace: jest.fn(),
},
Stack: {
Screen: () => null,
},
useLocalSearchParams: () => ({
tId: "task-123",
}),
useFocusEffect: (callback: () => void) => {
const React = require("react");
React.useEffect(callback, [callback]);
},
}));
jest.mock("@/lib/progress", () => ({
CheckAssignmentCompletion: jest.fn(() => Promise.resolve()),
}));
jest.mock("@/lib/supabase", () => ({
supabase: {
auth: {
getUser: jest.fn(() =>
Promise.resolve({
data: { user: { id: "user-123" } },
error: null,
})
),
getSession: jest.fn(() =>
Promise.resolve({
data: {
session: {
user: { id: "user-123" },
},
},
})
),
onAuthStateChange: jest.fn(() => ({
data: {
subscription: {
unsubscribe: jest.fn(),
},
},
})),
},
from: jest.fn((table: string) => {
if (table === "tasks") {
return {
select: mockTaskSelect,
delete: mockTaskDelete,
};
}
if (table === "assignments") {
return {
select: mockAssignmentSelect,
};
}
if (table === "subjects") {
return {
select: mockSubjectSelect,
};
}
return {};
}),
},
}));
const alertSpy = jest.spyOn(Alert, "alert");
test("deletes a task and navigates back", async () => {
mockTaskSingle.mockResolvedValue({
data: {
tId: "task-123",
title: "Read chapter 4",
uId: "user-123",
aId: "assignment-123",
},
error: null,
});
mockAssignmentSingle.mockResolvedValue({
data: {
aId: "assignment-123",
title: "create a simple test",
uId: "user-123",
sId: "subject-123",
},
error: null,
});
mockSubjectSingle.mockResolvedValue({
data: {
sId: "subject-123",
title: "ikt205g26v",
color: "blue",
},
error: null,
});
mockTaskDeleteEq.mockResolvedValue({ error: null, });
const screen = render(<ViewDetailsTask />);
await screen.findByText("Read chapter 4");
await screen.findByText("ikt205g26v");
fireEvent.press(await screen.findByTestId("delete-task-button"));
expect(alertSpy).toHaveBeenCalledWith(
"Delete Task",
"Are you sure you want to delete this task?",
expect.any(Array),
);
const alertButtons = alertSpy.mock.calls[0][2];
const confirmDeleteButton = alertButtons[1];
await confirmDeleteButton.onPress();
await waitFor(() => {
expect(supabase.from).toHaveBeenCalledWith("tasks");
expect(mockTaskDelete).toHaveBeenCalled();
expect(mockTaskDeleteEq).toHaveBeenCalledWith("tId", "task-123");
expect(CheckAssignmentCompletion).toHaveBeenCalledWith("assignment-123");
expect(router.back).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,78 @@
import UpsertTask from "@/app/task/upsertTask";
import { CheckAssignmentCompletion } from "@/lib/progress";
import { supabase } from "@/lib/supabase";
import { fireEvent, render, waitFor } from "@testing-library/react-native";
import { router } from "expo-router";
const mockUpdateEq = jest.fn();
const mockUpdate = jest.fn(() => ({ eq: mockUpdateEq, }));
const mockSingle = jest.fn();
const mockSelectEq = jest.fn(() => ({ single: mockSingle, }));
const mockSelect = jest.fn(() => ({ eq: mockSelectEq, }));
jest.mock("expo-router", () => ({
router: {
back: jest.fn(),
replace: jest.fn(),
},
Stack: {
Screen: () => null,
},
useLocalSearchParams: () => ({
tId: "task-123",
}),
useFocusEffect: (callback: () => void) => callback(),
}));
jest.mock("@/lib/progress", () => ({
CheckAssignmentCompletion: jest.fn(() => Promise.resolve()),
}));
jest.mock("@/lib/supabase", () => ({
supabase: {
auth: {
getUser: jest.fn(() =>
Promise.resolve({
data: { user: { id: "user-123" } },
error: null,
})
),
},
from: jest.fn(() => ({
select: mockSelect,
update: mockUpdate,
})),
},
}));
test("updates a task and navigates back", async () => {
mockSingle.mockResolvedValue({
data: {
tId: "task-123",
title: "Read chapter 4",
uId: "user-123",
aId: "assignment-123",
},
error: null,
});
mockUpdateEq.mockResolvedValue({ error: null, });
const screen = render(<UpsertTask />);
fireEvent.changeText(await screen.findByTestId("task-title-input"), "Read chapter 5");
fireEvent.press(screen.getByTestId("upsert-task-button"));
await waitFor(() => {
expect(supabase.from).toHaveBeenCalledWith("tasks");
expect(mockSelect).toHaveBeenCalled();
expect(mockUpdate).toHaveBeenCalledWith(
expect.objectContaining({
title: "Read chapter 5",
uId: "user-123",
aId: "assignment-123",
})
);
expect(mockUpdateEq).toHaveBeenCalledWith("tId", "task-123");
expect(CheckAssignmentCompletion).toHaveBeenCalledWith("assignment-123");
expect(router.back).toHaveBeenCalled();
});
});

View File

@@ -1,6 +1,6 @@
{
"expo": {
"name": "Study-Sprint",
"name": "Study Sprint",
"slug": "Study-Sprint",
"owner": "ikt205g26v-g18",
"version": "1.0.0",

View File

@@ -4,14 +4,9 @@ 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 {
ActivityIndicator,
Alert,
Pressable,
ScrollView,
Text,
View,
} from 'react-native';
import { ActivityIndicator, Alert, Pressable, ScrollView, Text, View } from 'react-native';
import type { SubjectColor } from '@/lib/subjectColors';
export default function Subjects() {
const [subjects, SetSubjects] = useState<Subject[]>([]);
@@ -43,12 +38,16 @@ export default function Subjects() {
SetIsLoading(true);
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');
SetIsLoading(false);
@@ -133,6 +132,13 @@ export default function Subjects() {
</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">

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,701 @@ const colors = {
text: '#ffffff',
};
const timers = [...Array(13).keys()].map((i) => (i === 0 ? 1 : i * 5));
const ITEM_SIZE = width * 0.38;
const ITEM_SPACING = (width - ITEM_SIZE) / 2;
/*
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
TODO
Make timer count down even when app is un-focused or closed.
Set const endTime = Date.now() + duration and save that to the task, maybe?
Then trigger notif when endTime == Date.now()?
Then fetch endTime from DB -> if null then timer is inactive
if !null then set timer to endTime - Date.now() and start
Might have to save duration as well in DB to preserve timer animation persistance
*/
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 opacity = buttonAnimation.interpolate({
inputRange: [0, 1],
outputRange: [1, 0]
})
const translateY = buttonAnimation.interpolate({
inputRange: [0, 1],
outputRange: [0, 200]
})
const TIMER_OPTIONS = [...Array(13).keys()].map((index) => (index === 0 ? 1 : index * 5));
const ITEM_SIZE = width * 0.38;
const ITEM_SPACING = (width - ITEM_SIZE) / 2;
const TIMER_UNIT_IN_SECONDS = 60;
const HOLD_TO_CANCEL_MS = 2000;
const CANCEL_ANIMATION_DELAY_MS = 250;
const BUTTON_PRESS_IN_MS = 80;
const BUTTON_PRESS_OUT_MS = 140;
const placeholderTask = {
name: 'Read chapter 4',
description: 'Focus on the summary questions and write down anything unclear.',
};
function formatTime(totalSeconds: number) {
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
}
export default function TimerScreen() {
const [containerHeight, setContainerHeight] = React.useState(0);
const [duration, setDuration] = React.useState(TIMER_OPTIONS[0]);
const [timerIsRunning, setIsRunning] = React.useState(false);
const [timeRemaining, setTimeRemaining] = React.useState(0);
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]
);
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 +723,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

@@ -256,6 +256,7 @@ export default function UpsertAssignment() {
<View className="mb-5">
<Text className={labelClassName}>Title</Text>
<TextInput
testID = "assignment-title-input"
className={inputClassName}
placeholder="Enter assignment title"
placeholderTextColor="#9CA3AF"
@@ -325,6 +326,7 @@ export default function UpsertAssignment() {
</Pressable>
<Pressable
testID = "upsert-assignment-button"
className={`h-14 items-center justify-center rounded-2xl ${
isSaving ? 'bg-accent-disabled' : 'bg-accent'
}`}

View File

@@ -6,7 +6,7 @@ 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, Pressable, SectionList, Text, View } from "react-native";
import { ActivityIndicator, Alert, Pressable, SectionList, Text, View } from "react-native";
export default function ViewDetailsAssignment() {
@@ -14,6 +14,7 @@ export default function ViewDetailsAssignment() {
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,
@@ -34,14 +35,17 @@ export default function ViewDetailsAssignment() {
[])
const GetAssignment = async (assignmentId: string) => {
const { data, error } = await supabase
.from('assignments')
.select('*')
.eq('aId', assignmentId)
.single();
SetIsLoading(true);
const { data, error } = await supabase
.from('assignments')
.select('*')
.eq('aId', assignmentId)
.single();
SetIsLoading(false);
if (error || !data) {
console.log('GetAssignment error:', error);
Alert.alert('Assignment could not be fetched, please try again');
return;
}
@@ -49,14 +53,17 @@ export default function ViewDetailsAssignment() {
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) {
console.log('GetSubjectMeta error:', subjectError);
setSubjectMeta({
title: 'Unknown Subject',
color: 'slate'
@@ -72,8 +79,12 @@ export default function ViewDetailsAssignment() {
};
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;
@@ -204,6 +215,14 @@ export default function ViewDetailsAssignment() {
? 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">
@@ -348,7 +367,7 @@ export default function ViewDetailsAssignment() {
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',
pathname: '../assignment/upsertAssignment',
params: { aId: assignment.aId },
})
}
@@ -357,6 +376,7 @@ export default function ViewDetailsAssignment() {
</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)}
>
@@ -371,7 +391,7 @@ export default function ViewDetailsAssignment() {
className="mb-6 mt-5 h-14 items-center justify-center rounded-2xl bg-accent"
onPress={() =>
router.push({
pathname: '/task/createTask',
pathname: '../task/upsertTask',
params: { aId: assignment.aId },
})
}
@@ -453,7 +473,7 @@ export default function ViewDetailsAssignment() {
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',
pathname: '../task/upsertTask',
params: { tId: item.tId },
})
}

View File

@@ -161,6 +161,7 @@ export default function UpsertSubject() {
<View className="mb-5">
<Text className={labelClassName}>Title</Text>
<TextInput className={inputClassName}
testID = "subject-title-input"
placeholder="Enter subject title"
placeholderTextColor="#9CA3AF"
value={title}
@@ -311,6 +312,7 @@ export default function UpsertSubject() {
</Pressable>
<Pressable
testID = "upsert-subject-button"
className={`h-14 items-center justify-center rounded-2xl ${
isSaving
? 'bg-accent-disabled'

View File

@@ -23,7 +23,7 @@ export default function ViewDetailsSubject() {
const [subject, SetSubject] = useState<Subject | null>(null);
const [assignments, SetAssignments] = useState<Assignment[]>([]);
const [session, SetSession] = useState<Session | null>(null);
const [isLoading, SetIsLoading] = useState(true);
const [isLoading, SetIsLoading] = useState(false);
const assignmentSections = [
{
@@ -49,12 +49,16 @@ export default function ViewDetailsSubject() {
}, []);
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;
@@ -64,12 +68,16 @@ export default function ViewDetailsSubject() {
};
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;
@@ -119,20 +127,6 @@ export default function ViewDetailsSubject() {
}, [session, sId])
);
useEffect(() => {
const test = async () => {
try {
const { data, error } = await supabase.from('subjects').select('*').limit(1);
console.log('test data:', data);
console.log('test error:', error);
} catch (err) {
console.log('test crashed:', err);
}
};
test();
}, []);
const DeleteSubject = async (subjectId: string) => {
Alert.alert(
'Delete Subject',
@@ -391,7 +385,7 @@ export default function ViewDetailsSubject() {
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',
pathname: '../subject/upsertSubject',
params: { sId: subject.sId },
})
}
@@ -402,6 +396,7 @@ export default function ViewDetailsSubject() {
</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)}
>
@@ -416,7 +411,7 @@ export default function ViewDetailsSubject() {
className="mb-6 mt-5 h-14 items-center justify-center rounded-2xl bg-accent"
onPress={() =>
router.push({
pathname: '/assignment/upsertAssignment',
pathname: '../assignment/upsertAssignment',
params: { sId: subject.sId },
})
}
@@ -502,7 +497,7 @@ export default function ViewDetailsSubject() {
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',
pathname: '../assignment/upsertAssignment',
params: { aId: item.aId },
})
}

View File

@@ -3,9 +3,7 @@ import { Stack } from "expo-router";
export default function TaskLayout() {
return (
<Stack>
<Stack.Screen name="tasks" options={{ title: 'Tasks' }} />
<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,255 +0,0 @@
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,
Keyboard,
KeyboardAvoidingView,
Platform,
Pressable,
ScrollView,
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;
}
if (task.aId) {
try {
await CheckAssignmentCompletion(task.aId);
} catch {
Alert.alert("Failed to update assignment completion state");
}
}
Alert.alert("Task successfully edited!");
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: 'Edit Task',
}}
/>
{!task ? (
<View className="flex-1 bg-app-bg px-5 pt-6">
<View className="rounded-3xl border border-app-border bg-app-surface p-5">
<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>
) : (
<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">
Edit Task
</Text>
<Text className="mt-2 text-base leading-6 text-text-secondary">
Update the task details and completion state.
</Text>
</View>
<View className="rounded-3xl border border-app-border bg-app-surface p-5">
<View className="mb-5">
<Text className={labelClassName}>Title</Text>
<TextInput
className={inputClassName}
placeholder="Enter task title"
placeholderTextColor="#9CA3AF"
value={task.title}
onChangeText={(text) =>
SetTask((prev) => (prev ? { ...prev, title: text } : prev))
}
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={task.description}
onChangeText={(text) =>
SetTask((prev) =>
prev ? { ...prev, description: text } : prev
)
}
multiline
textAlignVertical="top"
/>
</View>
<Pressable
onPress={() =>
SetTask((prev) =>
prev ? { ...prev, isCompleted: !prev.isCompleted } : prev
)
}
disabled={isSaving}
className={`mb-6 flex-row items-center rounded-2xl border p-4 ${
task.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 ${
task.isCompleted
? 'border-accent bg-accent'
: 'border-app-border bg-app-surface'
}`}
>
{task.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 again later.
</Text>
</View>
</Pressable>
<Pressable
className={`h-14 items-center justify-center rounded-2xl ${
isSaving ? 'bg-accent-disabled' : 'bg-accent'
}`}
onPress={EditTask}
disabled={isSaving}
>
{isSaving ? (
<View className="flex-row items-center">
<ActivityIndicator size="small" />
<Text className="ml-3 text-base font-bold text-text-inverse">
Saving...
</Text>
</View>
) : (
<Text className="text-base font-bold text-text-inverse">
Save Changes
</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,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,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>
@@ -115,8 +181,10 @@ export default function CreateTask() {
<View className="mb-5">
<Text className={labelClassName}>Title</Text>
<TextInput
testID="task-title-input"
className={inputClassName}
placeholder="Enter task title"
placeholderTextColor="#9CA3AF"
value={title}
onChangeText={SetTitle}
returnKeyType="next"
@@ -128,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
@@ -169,22 +238,23 @@ export default function CreateTask() {
</Pressable>
<Pressable
testID="upsert-task-button"
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

@@ -6,13 +6,14 @@ 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, Pressable, Text, View } from 'react-native';
import { ActivityIndicator, 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 [isLoading, SetIsLoading] = useState(false);
const [contextMeta, setContextMeta] = useState({
subjectTitle: 'No Subject',
assignmentTitle: 'No Assignment',
@@ -30,14 +31,17 @@ export default function ViewDetailsTask() {
}, []);
const GetTask = async (taskId: string) => {
SetIsLoading(true);
const { data, error } = await supabase
.from('tasks')
.select('*')
.eq('tId', taskId)
.single();
SetIsLoading(false);
if (error || !data) {
console.log('GetTask error:', error);
Alert.alert('Task could not be fetched, please try again');
return;
}
@@ -45,14 +49,17 @@ export default function ViewDetailsTask() {
SetTask(data);
if (data.aId) {
SetIsLoading(true);
const { data: assignmentData, error: assignmentError } = await supabase
.from('assignments')
.select('title, sId')
.eq('aId', data.aId)
.single();
SetIsLoading(false);
if (assignmentError || !assignmentData) {
console.log('GetTaskAssignment error:', assignmentError);
setContextMeta({
subjectTitle: 'Unknown Subject',
assignmentTitle: 'Unknown Assignment',
@@ -62,14 +69,17 @@ export default function ViewDetailsTask() {
}
if (assignmentData.sId) {
SetIsLoading(true);
const { data: subjectData, error: subjectError } = await supabase
.from('subjects')
.select('title, color')
.eq('sId', assignmentData.sId)
.single();
SetIsLoading(false);
if (subjectError || !subjectData) {
console.log('GetTaskSubject error:', subjectError);
setContextMeta({
subjectTitle: 'Unknown Subject',
assignmentTitle: assignmentData.title ?? 'Unknown Assignment',
@@ -138,6 +148,14 @@ export default function ViewDetailsTask() {
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">
@@ -263,7 +281,7 @@ export default function ViewDetailsTask() {
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',
pathname: '../task/upsertTask',
params: { tId: task.tId },
})
}
@@ -274,6 +292,7 @@ export default function ViewDetailsTask() {
</Pressable>
<Pressable
testID="delete-task-button"
className="flex-1 items-center justify-center rounded-2xl border border-app-border bg-app-surface py-3"
onPress={() => DeleteTask(task.tId)}
>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 551 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 551 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 551 KiB

View File

@@ -0,0 +1,71 @@
# CRUD Testing Summary (React Native + Jest + Supabase)
## What these tests are about
Tests verify **app behavior**, not Supabase itself.
They check:
- User interaction works
- Correct database functions are called
- Navigation happens after actions
---
## CRUD Breakdown
### CREATE
- User inputs data
- `insert()` is called
- App navigates back
Flow:
User → type → press create → insert() → router.back()
---
### READ
- Data is fetched (`select().eq().single()`)
- State updates
- UI renders correct content
---
### UPDATE
- Existing data is loaded
- User edits input
- `update().eq()` is called with correct values
- Navigation happens
---
### DELETE
- User presses delete
- `Alert.alert()` is triggered
- Confirm button (`onPress`) is manually called in test
- `delete().eq()` runs
- Navigation happens
---
## Why mocking is used
- No real database calls
- Faster tests
- Full control over success/error cases
- No side effects (no real data created/deleted)
---
## Mock rule
The mock must match the real call chain:
Real:
from → update → eq → select → single
Mock:
from() → update() → eq() → select() → single()
If not → errors like:
".select is not a function"
---
https://oss.callstack.com/react-native-testing-library/

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,191 @@
# Timer Focus Mode and Hold-Cancel Work Report
## #Overview
Today the standalone timer screen was reworked further with a focus on the active sprint layout, countdown ownership, and the hold-to-cancel interaction.
The main direction was to make the running timer feel more like a focused study state instead of a duration picker that happens to count down. The countdown was moved toward a separate overlay, the task details were given more visual emphasis, and the cancel interaction was changed from a simple button press into a deliberate hold action.
---
## #ImplementedFeatures
### #CountdownOverlay
Moved the active countdown away from the duration picker:
- Removed the old selected-picker countdown state
- Added a separate countdown overlay using `countdownAnimation`
- Added `focusModeAnimation` so the countdown can move from the central timer area toward the upper-left area
- Kept the picker responsible for duration values only
This separates two responsibilities that had previously been mixed together: the picker selects a duration, while the overlay shows active countdown time.
---
### #FocusModeLayout
Adjusted the active timer layout to put more attention on the task:
- Moved task details higher and closer to the center of the running screen
- Increased the task title and description size
- Kept task details animated through `taskDetailsAnimation`
- Continued using the red screen overlay as the main visual timer-progress element
The intent is for the active state to feel more like a study-session spotlight, where the selected task becomes the main focus and the countdown becomes supporting information.
---
### #HoldToCancel
Changed the cancel action into a hold interaction:
- Added `HOLD_TO_CANCEL_MS`
- Added `cancelHoldTimeoutRef`
- Added a hold-completion haptic warning
- Kept the cancel button scale feedback during press
- Changed the label to `Hold to end sprint`
This makes cancellation more deliberate and reduces the chance of accidentally ending a sprint with a single tap.
---
### #CancelAccelerationExperiment
Implemented the red timer overlay as cancel feedback:
- Added delayed cancel acceleration through `CANCEL_ANIMATION_DELAY_MS`
- Added `cancelHoldAnimationDelayRef`
- Added `cancelAccelStartedRef` to distinguish quick taps from actual hold acceleration
- Split normal timer progress into `progressAnimationRef`
- Added `startProgressAnimation(fromY)` so progress can start or resume from a specific overlay position
- Added `cancelOverlayAnimation` as a temporary visual offset on top of the real timer progress
- Added `getCancelOverlayTarget(...)` to calculate how far the cancel preview should move
- Added a release handoff animation so the cancel offset eases back into the real timer position
- Added clamping so the visual overlay does not move past the finished timer position
- Added easing constants for the cancel delay, release handoff, and timer reset timings
The goal was for the red overlay to speed toward the finished position during a hold, then return smoothly to the real timer progress if the user releases before the cancel completes. The important change is that cancel preview motion is now layered on top of the real progress instead of directly taking over the main timer animation.
---
### #DurationPickerCleanup
Cleaned up the duration picker after moving countdown ownership out of it:
- Removed selected countdown rendering from the picker item
- Kept picker items rendering plain timer values
- Kept picker values fading out during active timer mode
- Added index clamping when reading the selected duration from `onMomentumScrollEnd`
- Restored `duration` as a dependency of the start callback so the selected picker value is used correctly
This fixed the earlier issue where the timer could behave as if the selected duration was still the initial value.
---
### #TimerCodeCleanup
Cleaned up the timer screen structure after the interaction behavior was stabilized:
- Renamed the old `animation` callback to `startTimer`
- Renamed unclear animated values like `opacity` and `translateY` to `startButtonOpacity` and `startButtonTranslateY`
- Grouped refs by purpose: animated values, timer/session refs, and cancel-hold refs
- Extracted `clearCountdown`, `clearCancelHoldTimers`, and `stopTimerAnimations`
- Extracted the cancel overlay target calculation into `getCancelOverlayTarget(...)`
- Split the render section into local render helpers for the overlay, start button, cancel button, countdown, duration picker, and task details
- Moved the timer item layout into `styles.timerItem`
This did not change the screen into a separate hook or split the timer into multiple files. The cleanup stayed local to `timer.tsx` so the current animation work remains easy to inspect.
---
## #LearningNotes
### #AnimationOwnership
The main lesson today was that an `Animated.Value` should have one clear owner at a time.
The red overlay now combines two animated values:
- normal timer progress
- hold-to-cancel visual offset
The normal timer progress is controlled by `timerAnimation`, while cancel preview motion is controlled by `cancelOverlayAnimation`. This avoids stopping the real timer progress just to show the cancel speed-up effect.
---
### #RefsAsMutableState
Several refs were added to track animation and timer ownership:
- `progressAnimationRef` tracks the long-running red overlay progress animation
- `sessionStartedAtRef` tracks the progress timeline used for recovery calculations
- `sessionDurationMsRef` stores the current timer duration in milliseconds
- `cancelHoldTimeoutRef` tracks when hold cancellation should complete
- `cancelHoldAnimationDelayRef` tracks when cancel acceleration should begin
- `cancelAccelStartedRef` tracks whether the red overlay acceleration actually started
- `cancelHoldActiveRef` and `cancelHoldIdRef` prevent stale delayed hold callbacks from taking over after release
The important distinction is that assigning to `.current` is allowed even when the ref variable itself is declared with `const`.
---
### #CancelOffsetHandoff
The release recovery logic was changed to avoid rewriting the real timer progress:
- keep `timerAnimation` running as the source of real timer progress
- add `cancelOverlayAnimation` on top of it while the cancel button is held
- animate only the cancel offset back to `0` when the hold is released
- keep the visible overlay clamped to the screen height
- tune the release handoff timing with `CANCEL_RELEASE_MS`
This makes the visual red overlay return to the countdown's real timer position without forcing the main timer animation to stop and restart.
---
## #CurrentState
The hold-cancel red overlay interaction has been reworked so the cancel preview no longer directly mutates the real timer progress.
The current implementation:
- keeps the countdown and real timer progress owned by `timerAnimation`
- uses `cancelOverlayAnimation` as a temporary visual offset during hold-to-cancel
- invalidates stale hold callbacks with `cancelHoldIdRef`
- eases the cancel offset back to `0` on release
- keeps the cancel-completion path separate from normal timer completion
This should make the red overlay speed-up feel connected to the cancel hold while still keeping the timer progress visually aligned with the countdown after release.
---
## #Verification
Current static checks pass:
```text
npm run lint
exited successfully
```
```text
npx tsc --noEmit
exited successfully
```
The hold-cancel handoff was also adjusted based on runtime feedback so the cancel offset eases back more smoothly into the real timer progress.
---
## #FilesChanged
Main file worked on:
```text
app/(tabs)/timer.tsx
```
New note added:
```text
notes/work-report-timer-2026-04-24.md
```
---
## #Conclusion
The timer screen moved further toward a focused active-sprint experience. The countdown is now separated from the duration picker, task details have more visual weight, and cancel is treated as a deliberate hold action rather than a normal tap.
The main animation change is that hold-to-cancel now keeps the real timer progress separate from the temporary cancel speed-up effect. The code was also cleaned up so the timer flow is easier to read and continue working on.
## Problems occuring after writing conclusion
Tried to implement sound by installing expo-audio. This caused the dependency list to update. The diff was massive, and something in the diff caused the entire timer page to break. Logic, animations - the lot. Have reverted back to last known working dependency list, as well as un-refactored a lot of code in an attempt to revert to a functioning state before figuring out that the culprit was dependencies. Need to figure our what is causing the critical failure in the new list.
## Todo
- Re-refactor to make code cleaner, more readable and easier to maintain.
- Figure out the dependency issues of later dependency lists
## Conclusion of dependecy saga
There was a mismatch in the nativewind dependency, with my one being ^4.2.3 and the other list being ^4.1.23. This cause my entire timer screen to fail. Animations got borked, buttons not working properly, duration picker only showing 2 indexes... the works. Solution - keepp nativewind dependency to ^4.2.3

View File

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

3196
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,12 +3,14 @@
"main": "expo-router/entry",
"version": "1.0.0",
"scripts": {
"postinstall": "patch-package",
"start": "expo start",
"reset-project": "node ./scripts/reset-project.js",
"postinstall": "patch-package",
"android": "expo run:android",
"ios": "expo run:ios",
"web": "expo start --web",
"lint": "expo lint"
"lint": "expo lint",
"test": "jest"
},
"dependencies": {
"@expo/vector-icons": "^15.0.3",
@@ -32,7 +34,8 @@
"expo-symbols": "~1.0.8",
"expo-system-ui": "~6.0.9",
"expo-web-browser": "~15.0.10",
"nativewind": "^4.1.23",
"nativewind": "^4.2.3",
"patch-package": "^8.0.1",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "0.81.5",
@@ -42,15 +45,24 @@
"react-native-screens": "~4.16.0",
"react-native-url-polyfill": "^3.0.0",
"react-native-web": "~0.21.0",
"react-native-worklets": "0.5.1"
"react-native-worklets": "0.5.1",
"tailwindcss": "^3.4.19"
},
"devDependencies": {
"@testing-library/react-native": "^13.3.3",
"@types/jest": "^30.0.0",
"@types/react": "~19.1.0",
"eslint": "^9.25.0",
"eslint-config-expo": "~10.0.0",
"jest": "^29.7.0",
"jest-expo": "^55.0.16",
"patch-package": "^8.0.1",
"react-test-renderer": "19.1.0",
"tailwindcss": "^3.4.19",
"typescript": "~5.9.2"
},
"private": true
"private": true,
"jest": {
"preset": "jest-expo"
}
}