Final push before system formatting

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

View File

@@ -0,0 +1,196 @@
# ---------------------------
# Node / Expo / React Native
# ---------------------------
node_modules/
.expo/
dist/
web-build/
expo-env.d.ts
.metro-health-check*
*.tsbuildinfo
# Local env files
.env*.local
.env
# Logs
*.log
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
# ---------------------------
# Android / React Native native
# Keep /android and /ios ONLY if you commit native code
# Remove these two lines if you want generated native folders ignored
# ---------------------------
.gradle/
build/
local.properties
captures/
.externalNativeBuild/
.cxx/
*.aab
*.apk
output-metadata.json
*.hprof
.kotlin/
# ---------------------------
# IntelliJ / Android Studio / VS Code
# ---------------------------
*.iml
.idea/
.vscode/
misc.xml
deploymentTargetDropDown.xml
render.experimental.xml
# ---------------------------
# Secrets / signing / platform keys
# ---------------------------
*.jks
*.keystore
*.p8
*.p12
*.key
*.pem
*.mobileprovision
google-services.json
# ---------------------------
# iOS / Android generated native folders
# Ignore these only for Expo managed/prebuild workflow
# Comment them out if you keep native code in repo
# ---------------------------
/android
/ios
# ---------------------------
# .NET / ASP.NET Core Web API
# ---------------------------
**/bin/
**/obj/
**/.vs/
*.user
*.rsuser
*.suo
# App settings / local secrets
**/appsettings.Development.json
**/secrets.json
# EF Core / local DB artifacts
*.db
*.db-shm
*.db-wal
*.sqlite
*.sqlite3
# ---------------------------
# Misc
# ---------------------------
*.orig.*
app-example
# ---------------------------
# Node / Expo / React Native
# ---------------------------
node_modules/
.expo/
dist/
web-build/
expo-env.d.ts
.metro-health-check*
*.tsbuildinfo
# Local env files
.env*.local
.env
# Logs
*.log
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
# ---------------------------
# Android / React Native native
# Keep /android and /ios ONLY if you commit native code
# Remove these two lines if you want generated native folders ignored
# ---------------------------
.gradle/
build/
local.properties
captures/
.externalNativeBuild/
.cxx/
*.aab
*.apk
output-metadata.json
*.hprof
.kotlin/
# ---------------------------
# IntelliJ / Android Studio / VS Code
# ---------------------------
*.iml
.idea/
.vscode/
misc.xml
deploymentTargetDropDown.xml
render.experimental.xml
# ---------------------------
# Secrets / signing / platform keys
# ---------------------------
*.jks
*.keystore
*.p8
*.p12
*.key
*.pem
*.mobileprovision
google-services.json
# ---------------------------
# iOS / Android generated native folders
# Ignore these only for Expo managed/prebuild workflow
# Comment them out if you keep native code in repo
# ---------------------------
/android
/ios
# ---------------------------
# .NET / ASP.NET Core Web API
# ---------------------------
**/bin/
**/obj/
**/.vs/
*.user
*.rsuser
*.suo
# App settings / local secrets
**/appsettings.Development.json
**/secrets.json
# EF Core / local DB artifacts
*.db
*.db-shm
*.db-wal
*.sqlite
*.sqlite3
# ---------------------------
# Misc
# ---------------------------
*.orig.*
app-example
newDeps/

View File

@@ -0,0 +1,105 @@
# Study Sprint
Study Sprint is a React Native mobile application built with Expo Router. The app helps students organize study work into subjects, assignments, tasks, and timed focus sessions. Users can create an account, structure their course work, start study sprints from individual tasks, take breaks between sessions, and follow progress from the dashboard.
The application uses Supabase for authentication and persistent data, while Expo/React Native handles the mobile client, navigation, notifications, and local session state.
## Main Features
- Email/password sign up and login with Supabase Auth.
- Subject, assignment, and task management.
- Task-based sprint timer with focus sessions and breaks.
- Dashboard for current progress, active sprint state, and upcoming work.
- Local persistence for active session state.
- Jest tests for route guarding and CRUD behavior around subjects, assignments, and tasks.
## Tech Stack
- Expo SDK 54
- React Native 0.81
- Expo Router
- TypeScript
- Supabase
- NativeWind / Tailwind CSS
- Jest with `jest-expo`
## Requirements
Install these before running the project locally:
- Node.js 20.19.4 or newer
- npm
- Android Studio with an Android emulator, or a physical Android device with USB debugging
- Expo CLI through `npx expo`
## Install Dependencies
From the project root:
```bash
npm install
```
The project uses `patch-package`, so `npm install` also applies the local patch in `patches/`.
## Run Locally With Expo
Start the Expo development server:
```bash
npx expo start
```
Then choose one of the Expo options:
- Press `a` to open the app in an Android emulator.
- Scan the QR code with Expo Go on a physical device.
- Press `w` to run the web version for quick UI checks.
The Android emulator should already be running before pressing `a`.
## Test and Quality Checks
Run the Jest test suite:
```bash
npm test
```
Run Expo linting:
```bash
npm run lint
```
Run TypeScript checking:
```bash
npx tsc --noEmit
```
These commands are the expected local checks before delivery.
## Project Structure
```text
app/ Expo Router screens and navigation layouts
components/ Shared UI components
constants/ Shared styling and theme constants
hooks/ Shared React hooks
lib/ Supabase client, session lifecycle, progress, storage, and utilities
__tests__/ Jest test files
assets/ App icons, splash assets, and images
patches/ patch-package fixes applied after install
```
## Delivery Notes
For local assessment, the recommended flow is:
1. Add the required Supabase environment variables.
2. Run `npm install`.
3. Run `npm test`, `npm run lint`, and `npx tsc --noEmit`.
4. Start the app with `npx expo start` and pressing `a` to open the app with your Android Emulator.
The app is configured as an Expo managed project with generated native folders ignored, so Android/iOS native folders do not need to be committed for normal Expo development.

View File

@@ -0,0 +1,29 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.5.2.0
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "backend", "backend", "{1AE8ACA6-933B-BF2A-3671-3E2EAC007D16}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "study-sprint-api", "backend\study-sprint-api\study-sprint-api.csproj", "{1003D4A4-D46B-F75C-EC68-321C2ED62795}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{1003D4A4-D46B-F75C-EC68-321C2ED62795}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1003D4A4-D46B-F75C-EC68-321C2ED62795}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1003D4A4-D46B-F75C-EC68-321C2ED62795}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1003D4A4-D46B-F75C-EC68-321C2ED62795}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{1003D4A4-D46B-F75C-EC68-321C2ED62795} = {1AE8ACA6-933B-BF2A-3671-3E2EAC007D16}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {2870106D-D84D-4FC9-A7C9-41F972CCDF07}
EndGlobalSection
EndGlobal

View File

@@ -0,0 +1,79 @@
import UpsertAssignment from "@/app/assignment/upsertAssignment";
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/asyncStorage", () => ({
GetAssignmentNotificationId: jest.fn(() => Promise.resolve()),
SaveAssignmentNotificationId: jest.fn(() => Promise.resolve()),
RemoveAssignmentNotificationId: 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",
uId: "user-123",
},
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(mockSelect).toHaveBeenCalled();
expect(mockSingle).toHaveBeenCalled();
expect(router.back).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,141 @@
import ViewDetailsAssignment from "@/app/assignment/viewDetailsAssignment";
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/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 an assignment 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];
expect(alertButtons).toBeDefined();
const confirmDeleteButton = alertButtons?.[1];
expect(confirmDeleteButton?.onPress).toBeDefined();
if (!confirmDeleteButton?.onPress) {
throw new Error("Delete confirmation button missing");
}
await confirmDeleteButton.onPress();
await waitFor(() => {
expect(supabase.from).toHaveBeenCalledWith("assignments");
expect(mockAssignmentDelete).toHaveBeenCalled();
expect(mockAssignmentDeleteEq).toHaveBeenCalledWith("aId", "assignment-123");
expect(router.back).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,96 @@
import UpsertAssignment from "@/app/assignment/upsertAssignment";
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",
sId: "subject-123",
}),
useFocusEffect: (callback: () => void) => callback(),
}));
jest.mock("@/lib/asyncStorage", () => ({
GetAssignmentNotificationId: jest.fn(() => Promise.resolve(null)),
SaveAssignmentNotificationId: jest.fn(() => Promise.resolve()),
RemoveAssignmentNotificationId: 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(() => ({
select: mockSelect,
update: mockUpdate,
})),
},
}));
test("updates an assignment and navigates back", async () => {
mockSingle.mockResolvedValue({
data: {
aId: "assignment-123",
title: "create a simple test",
uId: "user-123",
sId: "subject-123",
},
error: null,
});
mockUpdateSingle.mockResolvedValue({
data: {
aId: "assignment-123",
title: "create a harder test",
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",
sId: "subject-123",
})
);
expect(mockUpdateEq).toHaveBeenCalledWith("aId", "assignment-123");
expect(mockUpdateSingle).toHaveBeenCalled();
expect(router.back).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,92 @@
import TabLayout from "@/app/(tabs)/_layout";
import { getSetupStatus } from "@/lib/setupStatus";
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/setupStatus", () => ({
getSetupStatus: jest.fn(),
}));
jest.mock("@/lib/supabase", () => ({
supabase: {
auth: {
getSession: jest.fn(),
onAuthStateChange: jest.fn(() => ({
data: {
subscription: {
unsubscribe: jest.fn(),
},
},
})),
},
},
}));
beforeEach(() => {
jest.clearAllMocks();
(getSetupStatus as jest.Mock).mockResolvedValue({
subjectId: "subject-123",
assignmentId: "assignment-123",
taskId: "task-123",
completedFocusSessions: 1,
currentStep: "sprint",
isSetupComplete: true,
});
});
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,63 @@
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 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: () => ({}),
}));
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 () => {
mockSingle.mockResolvedValue({
data: {
sId: "subject-123",
title: "ikt205g26v",
uId: "user-123",
},
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(mockSelect).toHaveBeenCalled();
expect(mockSingle).toHaveBeenCalled();
expect(router.back).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,122 @@
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];
expect(alertButtons).toBeDefined();
const confirmDeleteButton = alertButtons?.[1];
expect(confirmDeleteButton?.onPress).toBeDefined();
if (!confirmDeleteButton?.onPress) {
throw new Error("Delete confirmation button missing");
}
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,73 @@
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 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: () => ({
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 () => {
mockSingle.mockResolvedValue({
data: {
tId: "task-123",
title: "Read chapter 4",
uId: "user-123",
aId: "assignment-123",
},
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(mockSelect).toHaveBeenCalled();
expect(mockSingle).toHaveBeenCalled();
expect(CheckAssignmentCompletion).toHaveBeenCalledWith("assignment-123");
expect(router.back).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,169 @@
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 }));
const mockSprintSessionsEqCompleted = jest.fn();
const mockSprintSessionsEqSessionType = jest.fn(() => ({ eq: mockSprintSessionsEqCompleted }));
const mockSprintSessionsEqUser = jest.fn(() => ({ eq: mockSprintSessionsEqSessionType }));
const mockSprintSessionsEqTask = jest.fn(() => ({ eq: mockSprintSessionsEqUser }));
const mockSprintSessionsSelect = jest.fn(() => ({ eq: mockSprintSessionsEqTask }));
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,
};
}
if (table === "sprint_sessions") {
return {
select: mockSprintSessionsSelect,
};
}
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,
});
mockSprintSessionsEqCompleted.mockResolvedValue({ count: 0, error: null });
mockTaskDeleteEq.mockResolvedValue({ error: null, });
const screen = render(<ViewDetailsTask />);
await screen.findByText("Read chapter 4");
await screen.findByText("ikt205g26v");
fireEvent.press(await screen.findByText("Delete"));
expect(alertSpy).toHaveBeenCalledWith(
"Delete Task",
"Are you sure you want to delete this task?",
expect.any(Array),
);
const alertButtons = alertSpy.mock.calls[0]?.[2];
expect(alertButtons).toBeDefined();
const confirmDeleteButton = alertButtons?.[1];
expect(confirmDeleteButton?.onPress).toBeDefined();
if (!confirmDeleteButton?.onPress) {
throw new Error("Delete confirmation button missing");
}
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

@@ -0,0 +1,57 @@
{
"expo": {
"name": "Study Sprint",
"slug": "Study-Sprint",
"owner": "ikt205g26v-g18",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "studysprint",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"ios": {
"supportsTablet": true
},
"android": {
"adaptiveIcon": {
"backgroundColor": "#E6F4FE",
"foregroundImage": "./assets/images/android-icon-foreground.png",
"backgroundImage": "./assets/images/android-icon-background.png",
"monochromeImage": "./assets/images/android-icon-monochrome.png"
},
"edgeToEdgeEnabled": true,
"predictiveBackGestureEnabled": false,
"package": "com.softsand.studysprint"
},
"web": {
"output": "static",
"favicon": "./assets/images/favicon.png"
},
"plugins": [
"expo-router",
[
"expo-splash-screen",
{
"image": "./assets/images/splash-icon.png",
"imageWidth": 200,
"resizeMode": "contain",
"backgroundColor": "#ffffff",
"dark": {
"backgroundColor": "#000000"
}
}
],
"expo-secure-store"
],
"experiments": {
"typedRoutes": true,
"reactCompiler": true
},
"extra": {
"router": {},
"eas": {
"projectId": "2b2ec99b-a2ea-4991-8694-93f9e3d042a3"
}
}
}
}

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,378 @@
import { defaultStyles } from '@/constants/defaultStyles';
import * as AsyncStorage from '@/lib/asyncStorage';
import { supabase } from '@/lib/supabase';
import * as Notifications from 'expo-notifications';
import { router, Stack, useLocalSearchParams } from 'expo-router';
import { useEffect, useState } from 'react';
import {
ActivityIndicator,
Alert,
Keyboard,
KeyboardAvoidingView,
Platform,
Pressable,
ScrollView,
Text,
TextInput,
TouchableWithoutFeedback,
View,
} from 'react-native';
export default function UpsertAssignment() {
const { aId, sId: routeSId, flow } = useLocalSearchParams<{
aId?: string;
sId?: string;
flow?: string;
}>();
const isEditMode = Boolean(aId);
const isSetupFlow = flow === 'setup';
const [title, SetTitle] = useState('');
const [description, SetDescription] = useState('');
const [deadline, SetDeadline] = useState('');
const [isCompleted, SetIsCompleted] = useState(false);
const [subjectId, SetSubjectId] = useState<string | null>(routeSId ?? null);
const [isLoading, SetIsLoading] = useState(isEditMode);
const [isSaving, SetIsSaving] = useState(false);
useEffect(() => {
if (!isEditMode || !aId) {
SetIsLoading(false);
return;
}
const loadAssignment = async () => {
SetIsLoading(true);
const { data, error } = await supabase
.from('assignments')
.select('*')
.eq('aId', aId)
.single();
SetIsLoading(false);
if (error || !data) {
Alert.alert('Assignment could not be loaded, please try again');
router.back();
return;
}
SetTitle(data.title ?? '');
SetDescription(data.description ?? '');
SetDeadline(data.deadline ?? '');
SetIsCompleted(data.isCompleted ?? false);
SetSubjectId(data.sId ?? routeSId ?? null);
};
loadAssignment();
}, [aId, isEditMode, routeSId]);
const ScheduleDeadlineReminder = async (
assignmentId: string,
assignmentTitle: string,
assignmentDeadline: string
) => {
const dl = new Date(assignmentDeadline);
if (Number.isNaN(dl.getTime())) return null;
const deadlineReminder = new Date(dl.getTime() - 24 * 60 * 60 * 1000);
if (deadlineReminder <= new Date()) return null;
const nId = await Notifications.scheduleNotificationAsync({
content: {
title: 'Assignment deadline coming up',
body: `${assignmentTitle} is due in 24 hours.`,
data: { aId: assignmentId },
},
trigger: {
type: Notifications.SchedulableTriggerInputTypes.DATE,
date: deadlineReminder,
},
});
return nId;
};
const updateDeadlineReminder = async (
assignmentId: string,
assignmentTitle: string,
assignmentDeadline: string,
completed: boolean
) => {
const existingNotificationId =
await AsyncStorage.GetAssignmentNotificationId(assignmentId);
if (existingNotificationId) {
try {
await Notifications.cancelScheduledNotificationAsync(
existingNotificationId
);
} catch {}
await AsyncStorage.RemoveAssignmentNotificationId(assignmentId);
}
if (completed) return;
const nId = await ScheduleDeadlineReminder(
assignmentId,
assignmentTitle,
assignmentDeadline
);
if (nId) {
await AsyncStorage.SaveAssignmentNotificationId(assignmentId, nId);
}
};
const handleSubmit = async () => {
if (title.trim() === '') {
Alert.alert('Title is required!');
return;
}
const { data: userData, error: userError } = await supabase.auth.getUser();
if (userError || !userData.user) {
router.replace('/login');
return;
}
if (!subjectId) {
Alert.alert('Missing subject', 'This assignment is not linked to a subject.');
return;
}
SetIsSaving(true);
const payload = {
title: title.trim(),
description: description.trim(),
deadline: deadline.trim(),
isCompleted,
lastChanged: new Date().toISOString(),
uId: userData.user.id,
sId: subjectId,
};
const result =
isEditMode && aId
? await supabase
.from('assignments')
.update(payload)
.eq('aId', aId)
.select()
.single()
: await supabase.from('assignments').insert(payload).select().single();
if (result.error || !result.data) {
SetIsSaving(false);
Alert.alert(
isEditMode
? 'Assignment could not be updated, please try again'
: 'Assignment could not be created, please try again'
);
return;
}
const savedAssignment = result.data;
await updateDeadlineReminder(
savedAssignment.aId,
savedAssignment.title,
savedAssignment.deadline,
savedAssignment.isCompleted
);
SetIsSaving(false);
if (!isEditMode && isSetupFlow) {
router.replace({
pathname: '/task/upsertTask',
params: {
aId: savedAssignment.aId,
flow: 'setup',
},
});
return;
}
Alert.alert(
isEditMode
? 'Assignment successfully updated!'
: 'Assignment successfully created!'
);
router.back();
};
const inputClassName =
'rounded-2xl border border-app-border bg-app-subtle px-4 py-3 text-base text-text-main';
const labelClassName = 'mb-2 text-sm font-semibold text-text-secondary';
if (isLoading) {
return (
<View className="flex-1 items-center justify-center bg-app-bg">
<ActivityIndicator size="large" />
</View>
);
}
return (
<>
<Stack.Screen
options={{
title: isEditMode ? 'Edit Assignment' : 'Create Assignment',
headerTitleStyle: defaultStyles.title,
headerTitleAlign: 'center',
}}
/>
<KeyboardAvoidingView
className="flex-1 bg-app-bg"
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<TouchableWithoutFeedback onPress={Keyboard.dismiss}>
<ScrollView
className="flex-1"
keyboardShouldPersistTaps="handled"
contentContainerStyle={{
flexGrow: 1,
justifyContent: 'center',
paddingHorizontal: 20,
paddingVertical: 32,
}}
>
<View className="mb-6">
<Text className="text-3xl font-bold text-text-main">
{isEditMode ? 'Edit Assignment' : 'Create Assignment'}
</Text>
<Text className="mt-2 text-base leading-6 text-text-secondary">
{isEditMode
? 'Update this assignment and keep your subject organized.'
: 'Add a new assignment to keep your subject organized.'}
</Text>
</View>
<View className="rounded-3xl border border-app-border bg-app-surface p-5 shadow-sm">
<View className="mb-5">
<Text className={labelClassName}>Title</Text>
<TextInput
testID = "assignment-title-input"
className={inputClassName}
placeholder={
isSetupFlow ? 'e.g. Weekly problem set 3' : 'Enter assignment title'
}
placeholderTextColor="#9CA3AF"
value={title}
onChangeText={SetTitle}
returnKeyType="next"
/>
</View>
<View className="mb-5">
<Text className={labelClassName}>Description</Text>
<TextInput
className={`${inputClassName} min-h-28`}
placeholder={
isSetupFlow
? 'e.g. Finish the next exercise set before Friday'
: 'Add a short description'
}
placeholderTextColor="#9CA3AF"
value={description}
onChangeText={SetDescription}
multiline
textAlignVertical="top"
/>
</View>
<View className="mb-5">
<Text className={labelClassName}>Deadline</Text>
<TextInput
className={inputClassName}
placeholder={isSetupFlow ? 'e.g. 2026-05-14' : 'YYYY-MM-DD'}
placeholderTextColor="#9CA3AF"
value={deadline}
onChangeText={SetDeadline}
autoCapitalize="none"
autoCorrect={false}
/>
</View>
<Pressable
className={`mb-6 flex-row items-center rounded-2xl border p-4 ${
isCompleted
? 'border-accent bg-accent-soft'
: 'border-app-border bg-app-subtle'
}`}
onPress={() => SetIsCompleted((current) => !current)}
disabled={isSaving}
>
<View
className={`mr-3 h-6 w-6 items-center justify-center rounded-md border-2 ${
isCompleted
? 'border-accent bg-accent'
: 'border-app-border bg-app-surface'
}`}
>
{isCompleted && (
<Text className="text-sm font-bold text-text-inverse">
</Text>
)}
</View>
<View className="flex-1">
<Text className="text-base font-semibold text-text-main">
Mark as completed
</Text>
<Text className="mt-1 text-sm text-text-muted">
You can change this later.
</Text>
</View>
</Pressable>
<Pressable
testID = "upsert-assignment-button"
className={`h-14 items-center justify-center rounded-2xl ${
isSaving ? 'bg-accent-disabled' : 'bg-accent'
}`}
onPress={handleSubmit}
disabled={isSaving}
>
{isSaving ? (
<View className="flex-row items-center">
<ActivityIndicator size="small" />
<Text className="ml-3 text-base font-bold text-text-inverse">
{isEditMode ? 'Saving...' : 'Creating...'}
</Text>
</View>
) : (
<Text className="text-base font-bold text-text-inverse">
{isEditMode ? 'Save Changes' : 'Create Assignment'}
</Text>
)}
</Pressable>
<Pressable
className="mt-3 h-14 items-center justify-center rounded-2xl border border-app-border bg-app-subtle"
onPress={() => router.back()}
disabled={isSaving}
>
<Text className="text-base font-semibold text-text-secondary">
Cancel
</Text>
</Pressable>
</View>
</ScrollView>
</TouchableWithoutFeedback>
</KeyboardAvoidingView>
</>
);
}

View File

@@ -0,0 +1,521 @@
import { formatDate, formatDateTime } from '@/lib/date';
import { CheckAssignmentCompletion } from '@/lib/progress';
import { getSubjectColorSet, type SubjectColor } from '@/lib/subjectColors';
import { supabase } from '@/lib/supabase';
import type { Assignment, Task } from '@/lib/types';
import { Session } from '@supabase/supabase-js';
import { router, Stack, useFocusEffect, useLocalSearchParams } from 'expo-router';
import { useCallback, useEffect, useState } from 'react';
import { ActivityIndicator, Alert, Pressable, SectionList, Text, View } from "react-native";
export default function ViewDetailsAssignment() {
const { aId } = useLocalSearchParams<{ aId: string }>();
const [assignment, SetAssignment] = useState<Assignment | null>(null);
const [tasks, SetTasks] = useState<Task[]>([]);
const [session, SetSession] = useState<Session | null>(null);
const [isLoading, SetIsLoading] = useState(false);
const [subjectMeta, setSubjectMeta] = useState({
title: 'No Subject',
color: 'slate' as SubjectColor,
});
const taskSections = [
{ title: "Upcoming Tasks", data: tasks.filter((task) => !task.isCompleted), emptyMessage: "No upcoming tasks" },
{ title: "Completed Tasks", data: tasks.filter((task) => task.isCompleted), emptyMessage: "No completed tasks" },
];
useEffect(() => {
supabase.auth.getSession().then(({ data }) => SetSession(data.session ?? null))
const { data: sub } = supabase.auth.onAuthStateChange((_event, newSession) => {
SetSession(newSession)
})
return () => sub.subscription.unsubscribe()
},
[])
const GetAssignment = async (assignmentId: string) => {
SetIsLoading(true);
const { data, error } = await supabase
.from('assignments')
.select('*')
.eq('aId', assignmentId)
.single();
SetIsLoading(false);
if (error || !data) {
Alert.alert('Assignment could not be fetched, please try again');
return;
}
SetAssignment(data);
if (data.sId) {
SetIsLoading(true);
const { data: subjectData, error: subjectError } = await supabase
.from('subjects')
.select('title, color')
.eq('sId', data.sId)
.single();
SetIsLoading(false);
if (subjectError || !subjectData) {
setSubjectMeta({
title: 'Unknown Subject',
color: 'slate'
});
return;
}
setSubjectMeta({
title: subjectData.title ?? 'Unknown Subject',
color: (subjectData.color as SubjectColor | undefined) ?? 'slate'
});
}
};
const GetTasks = async (aId: string) => {
SetIsLoading(true);
const { data, error } = await supabase.from("tasks").select("*").eq("aId", aId);
SetIsLoading(false);
if (error) {
Alert.alert("Tasks could not be fetched, please try again");
return;
}
SetTasks(data ?? []);
}
useFocusEffect(
useCallback(() => {
if (session && aId) {
GetAssignment(aId);
GetTasks(aId);
}
}, [session, aId])
);
const DeleteAssignment = async (aId: string) => {
Alert.alert(
"Delete Assignment",
"Are you sure you want to delete this assignment?",
[
{
text: "Cancel",
style: "cancel"
},
{
text: "Delete",
style: "destructive",
onPress: async () => {
const { error } = await supabase.from("assignments").delete().eq("aId", aId);
if (error) {
Alert.alert("Assignment could not be deleted, please try again");
return;
}
Alert.alert("Assignment deleted successfully!");
router.back();
}
}
]
)
}
const DeleteTask = async (tId: string, aId: string) => {
Alert.alert(
"Delete Task",
"Are you sure you want to delete this task?",
[
{
text: "Cancel",
style: "cancel"
},
{
text: "Delete",
style: "destructive",
onPress: async () => {
const { error } = await supabase.from("tasks").delete().eq("tId", tId);
if (error) {
Alert.alert("Task could not be deleted, please try again");
return;
}
Alert.alert("Task deleted successfully!");
if (aId) {
try {
await CheckAssignmentCompletion(aId);
} catch {
Alert.alert("Failed to update assignment completion state");
}
}
GetTasks(aId);
}
}
]
)
}
const ToggleTaskCompletion = async (task: Task) => {
const nextIsCompleted = !task.isCompleted;
const { error } = await supabase
.from("tasks")
.update({
isCompleted: nextIsCompleted,
lastChanged: new Date().toISOString(),
})
.eq("tId", task.tId);
if (error) {
Alert.alert("Task could not be updated, please try again");
return;
}
try {
await CheckAssignmentCompletion(task.aId);
} catch {
Alert.alert("Failed to update assignment completion state");
}
await GetTasks(task.aId);
await GetAssignment(task.aId);
}
const colorSet = getSubjectColorSet(subjectMeta.color);
const completedTasks = tasks.filter((task) => task.isCompleted).length;
const totalTasks = tasks.length;
const remainingTasks = totalTasks - completedTasks;
const progress =
totalTasks === 0
? 0
: Math.round((completedTasks / totalTasks) * 100);
if (isLoading) {
return (
<View className="flex-1 items-center justify-center bg-app-bg">
<ActivityIndicator size="large" />
</View>
);
}
if (!assignment) {
return (
<View className="flex-1 bg-app-bg px-5 pt-6">
<Stack.Screen
options={{
title: 'Details',
headerTitleAlign: 'center',
}}
/>
<View
className="rounded-3xl bg-app-surface p-5"
style={{
borderWidth: 1,
borderColor: colorSet.strong,
}}
>
<Text className="text-2xl font-bold text-text-main">
Assignment not found
</Text>
<Text className="mt-2 text-base text-text-secondary">
The assignment could not be loaded.
</Text>
<Pressable
className="mt-5 h-12 items-center justify-center rounded-2xl bg-accent"
onPress={() => router.back()}
>
<Text className="text-base font-bold text-text-inverse">Go back</Text>
</Pressable>
</View>
</View>
);
}
return (
<View className="flex-1 bg-app-bg">
<Stack.Screen
options={{
title: 'Assignment Details',
headerRight: () => (
<Pressable
className="rounded-full bg-app-subtle px-4 py-2"
onPress={async () => await supabase.auth.signOut()}
>
<Text className="text-sm font-semibold text-text-secondary">
Logout
</Text>
</Pressable>
),
}}
/>
<SectionList
className="flex-1"
contentContainerStyle={{ paddingHorizontal: 20, paddingTop: 20, paddingBottom: 32 }}
sections={totalTasks === 0 ? [] : taskSections}
keyExtractor={(item) => item.tId}
showsVerticalScrollIndicator={false}
stickySectionHeadersEnabled={false}
ListHeaderComponent={
<View>
<View
className="rounded-3xl bg-app-surface p-5"
style={{
borderWidth: 1,
borderColor: colorSet.strong,
}}
>
<View className="flex-row items-start">
<View className="flex-1">
<Text className="text-2xl font-bold text-text-main">
{assignment.title}
</Text>
{assignment.description ? (
<Text className="mt-2 text-base leading-6 text-text-secondary">
{assignment.description}
</Text>
) : null}
<View className="mt-4 flex-row flex-wrap">
<View
className="mr-2 mb-2 rounded-full px-3 py-1"
style={{ backgroundColor: colorSet.soft }}
>
<Text
className="text-xs font-semibold"
style={{ color: colorSet.strong }}
>
{subjectMeta.title}
</Text>
</View>
<View className="mr-2 mb-2 rounded-full bg-app-subtle px-3 py-1">
<Text className="text-xs font-semibold text-text-secondary">
Deadline: {formatDate(assignment.deadline) || 'No deadline'}
</Text>
</View>
</View>
<View className="mt-5">
<View className="mb-2 flex-row items-center justify-between">
<Text className="text-sm font-semibold text-text-secondary">
Tasks completed
</Text>
<Text className="text-sm font-bold text-text-main">
{completedTasks}/{totalTasks}
</Text>
</View>
<View className="h-3 overflow-hidden rounded-full bg-app-subtle">
<View
className="h-full rounded-full"
style={{
width: `${progress}%`,
backgroundColor: colorSet.strong,
}}
/>
</View>
<Text className="mt-2 text-xs font-medium text-text-secondary">
{remainingTasks === 0
? 'All tasks complete'
: `${remainingTasks} task${remainingTasks === 1 ? '' : 's'} remaining`}
</Text>
<Text className="mt-1 text-xs text-text-muted">
Based only on completed tasks in this assignment.
</Text>
</View>
<Text className="mt-4 text-sm text-text-muted">
Last changed: {formatDateTime(assignment.lastChanged)}
</Text>
</View>
</View>
<View className="mt-5 flex-row border-t border-app-border pt-5">
<Pressable
className="mr-3 flex-1 items-center justify-center rounded-2xl border border-app-border bg-app-subtle py-3"
onPress={() =>
router.push({
pathname: '../assignment/upsertAssignment',
params: { aId: assignment.aId },
})
}
>
<Text className="text-sm font-bold text-text-secondary">Edit</Text>
</Pressable>
<Pressable
testID="delete-assignment-button"
className="flex-1 items-center justify-center rounded-2xl border border-app-border bg-app-surface py-3"
onPress={() => DeleteAssignment(assignment.aId)}
>
<Text className="text-sm font-bold text-status-danger">
Delete
</Text>
</Pressable>
</View>
</View>
<Pressable
className="mb-6 mt-5 h-14 items-center justify-center rounded-2xl bg-accent"
onPress={() =>
router.push({
pathname: '../task/upsertTask',
params: { aId: assignment.aId },
})
}
>
<Text className="text-base font-bold text-text-inverse">
Create Task
</Text>
</Pressable>
</View>
}
renderSectionHeader={({ section: { title, data } }) => (
<View className="mb-3 mt-2 flex-row items-center justify-between">
<Text className="text-lg font-bold text-text-main">{title}</Text>
<View className="rounded-full bg-app-subtle px-3 py-1">
<Text className="text-xs font-semibold text-text-muted">
{data.length}
</Text>
</View>
</View>
)}
renderItem={({ item }) => {
const isOwner = session?.user.id === item.uId;
return (
<View
className="mb-4 rounded-3xl bg-app-surface p-4"
style={{
borderWidth: 1,
borderColor: colorSet.strong,
}}
>
<Pressable
onPress={() =>
router.push({
pathname: '/task/viewDetailsTask',
params: { tId: item.tId },
})
}
>
<View className="flex-row items-start">
<View className="flex-1">
<Text
className={`text-base font-bold ${
item.isCompleted ? 'text-text-secondary' : 'text-text-main'
}`}
>
{item.title}
</Text>
{item.description ? (
<Text
className="mt-1 text-sm leading-5 text-text-muted"
numberOfLines={2}
>
{item.description}
</Text>
) : null}
</View>
</View>
</Pressable>
{isOwner && (
<View className="mt-4 flex-row border-t border-app-border pt-4">
<Pressable
className="mr-3 flex-1 items-center justify-center rounded-2xl py-3"
style={{ backgroundColor: item.isCompleted ? '#EFEBE3' : colorSet.soft }}
onPress={() => ToggleTaskCompletion(item)}
>
<Text
className="text-sm font-bold"
style={{ color: colorSet.strong }}
>
{item.isCompleted ? 'Reopen' : 'Complete'}
</Text>
</Pressable>
<Pressable
className="mr-3 flex-1 items-center justify-center rounded-2xl border border-app-border bg-app-subtle py-3"
onPress={() =>
router.push({
pathname: '../task/upsertTask',
params: { tId: item.tId },
})
}
>
<Text className="text-sm font-bold text-text-secondary">Edit</Text>
</Pressable>
<Pressable
className="flex-1 items-center justify-center rounded-2xl border border-app-border bg-app-surface py-3"
onPress={() => DeleteTask(item.tId, item.aId)}
>
<Text className="text-sm font-bold text-status-danger">
Delete
</Text>
</Pressable>
</View>
)}
</View>
);
}}
ListEmptyComponent={
<View
className="mb-6 rounded-3xl border border-app-border bg-app-surface p-5"
style={{ borderColor: colorSet.strong }}
>
<Text className="text-center text-base font-semibold text-text-secondary">
No tasks needed yet
</Text>
<Text className="mt-1 text-center text-sm text-text-muted">
Add tasks if this assignment needs smaller steps.
</Text>
</View>
}
renderSectionFooter={({ section }) =>
section.data.length === 0 ? (
<View className="mb-6 rounded-3xl border border-app-border bg-app-surface p-5" style={{ borderColor: colorSet.strong }}>
<Text className="text-center text-base font-semibold text-text-secondary">
{section.emptyMessage}
</Text>
<Text className="mt-1 text-center text-sm text-text-muted">
{tasks.length === 0
? 'Create the first task so this assignment turns into one clear next action.'
: 'Tasks for this assignment will show up here.'}
</Text>
</View>
) : (
<View className="mb-2" />
)
}
/>
</View>
);
}

View File

@@ -0,0 +1,213 @@
import { supabase } from '@/lib/supabase';
import { router } from 'expo-router';
import { useEffect, useRef, useState } from 'react';
import {
Alert,
Animated,
Keyboard,
KeyboardAvoidingView,
KeyboardEvent,
Platform,
Pressable,
ScrollView,
Text,
TextInput,
TouchableWithoutFeedback,
View,
} from 'react-native';
export default function CreateUser() {
const [email, SetEmail] = useState('');
const [password, SetPassword] = useState('');
const [isLoading, SetIsLoading] = useState(false);
const [isKeyboardVisible, setIsKeyboardVisible] = useState(false);
const scrollViewRef = useRef<ScrollView>(null);
const cardLift = useRef(new Animated.Value(0)).current;
useEffect(() => {
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
const handleKeyboardShow = (event: KeyboardEvent) => {
setIsKeyboardVisible(true);
const keyboardHeight = event.endCoordinates.height;
const liftAmount = Math.min(
Platform.OS === 'ios' ? keyboardHeight * 0.5 : keyboardHeight * 0.6,
260
);
Animated.timing(cardLift, {
toValue: -liftAmount,
duration: event.duration ?? 220,
useNativeDriver: true,
}).start();
};
const handleKeyboardHide = () => {
setIsKeyboardVisible(false);
Animated.timing(cardLift, {
toValue: 0,
duration: 220,
useNativeDriver: true,
}).start();
};
const showSubscription = Keyboard.addListener(showEvent, handleKeyboardShow);
const hideSubscription = Keyboard.addListener(hideEvent, handleKeyboardHide);
return () => {
showSubscription.remove();
hideSubscription.remove();
};
}, [cardLift]);
const SignUp = async () => {
if (email.trim() === '' || password.trim() === '') {
Alert.alert('All fields are required!');
return;
}
SetIsLoading(true);
const { data, error } = await supabase.auth.signUp({
email: email.trim(),
password,
});
SetIsLoading(false);
if (error) {
Alert.alert(error.message, 'User could not be created, please try again');
return;
}
if (!data.session) {
Alert.alert(
'Check your email',
'Your account was created. Please confirm your email before signing in.'
);
router.replace('/login');
return;
}
router.replace('/setup');
};
const inputClassName =
'rounded-2xl border border-app-border bg-app-subtle px-4 py-3 text-base text-text-main';
return (
<KeyboardAvoidingView
className="flex-1 bg-app-bg"
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 24}
>
<TouchableWithoutFeedback onPress={Keyboard.dismiss}>
<ScrollView
ref={scrollViewRef}
className="flex-1"
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
automaticallyAdjustKeyboardInsets={Platform.OS === 'ios'}
contentContainerStyle={{
flexGrow: 1,
justifyContent: isKeyboardVisible ? 'flex-start' : 'center',
paddingHorizontal: 20,
paddingTop: isKeyboardVisible ? 24 : 64,
paddingBottom: isKeyboardVisible ? 96 : 32,
}}
>
<Animated.View style={{ transform: [{ translateY: cardLift }] }}>
<View className="mb-10">
<Text className="mt-5 text-4xl font-bold text-text-main">
Study Sprint
</Text>
<Text className="mt-3 text-base leading-6 text-text-secondary">
Organize subjects, assignments, and tasks in one calm workflow.
</Text>
</View>
<View className="rounded-3xl border border-app-border bg-app-surface p-5">
<Text className="text-2xl font-bold text-text-main">
Create account
</Text>
<Text className="mt-2 text-sm leading-5 text-text-secondary">
Start your next study sprint.
</Text>
<View className="mt-5 rounded-2xl border border-app-border bg-app-subtle p-4">
<Text className="text-sm font-bold text-text-main">
What this app does
</Text>
<Text className="mt-1 text-sm leading-5 text-text-secondary">
Study Sprint helps you move from subject to assignment to task,
then into a focused sprint.
</Text>
<Text className="mt-3 text-sm font-bold text-text-main">
Why an account exists
</Text>
<Text className="mt-1 text-sm leading-5 text-text-secondary">
Your account keeps that structure and your tracked study
progress attached to you.
</Text>
</View>
<View className="mt-6 mb-5">
<Text className="mb-2 text-sm font-semibold text-text-secondary">
Email
</Text>
<TextInput
className={inputClassName}
placeholder="you@example.com"
placeholderTextColor="#9CA3AF"
keyboardType="email-address"
autoCapitalize="none"
autoCorrect={false}
value={email}
onChangeText={SetEmail}
/>
</View>
<View className="mb-6">
<Text className="mb-2 text-sm font-semibold text-text-secondary">
Password
</Text>
<TextInput
className={inputClassName}
placeholder="Create a password so your progress follows you"
placeholderTextColor="#9CA3AF"
secureTextEntry
value={password}
onChangeText={SetPassword}
/>
</View>
<Pressable
className={`h-14 items-center justify-center rounded-2xl ${
isLoading ? 'bg-accent-disabled' : 'bg-accent'
}`}
onPress={SignUp}
disabled={isLoading}
>
<Text className="text-base font-bold text-text-inverse">
{isLoading ? 'Creating account...' : 'Create account'}
</Text>
</Pressable>
<Pressable
className="mt-4 h-12 items-center justify-center rounded-2xl border border-app-border bg-app-subtle"
onPress={() => router.push('/login')}
>
<Text className="text-sm font-semibold text-text-secondary">
Already have an account? Log in
</Text>
</Pressable>
</View>
</Animated.View>
</ScrollView>
</TouchableWithoutFeedback>
</KeyboardAvoidingView>
);
}

View File

@@ -0,0 +1,189 @@
import { getSetupStatus } from "@/lib/setupStatus";
import { supabase } from "@/lib/supabase";
import { router } from "expo-router";
import { useEffect, useRef, useState } from "react";
import { Alert, Keyboard, KeyboardAvoidingView, KeyboardEvent, Platform, Pressable, ScrollView, Text, TextInput, TouchableWithoutFeedback, View } from "react-native";
export default function Login() {
const [email, SetEmail] = useState('');
const [password, SetPassword] = useState('');
const [isLoading, SetIsLoading] = useState(false);
const [isKeyboardVisible, setIsKeyboardVisible] = useState(false);
const scrollViewRef = useRef<ScrollView>(null);
useEffect(() => {
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
const handleKeyboardShow = (event: KeyboardEvent) => {
setIsKeyboardVisible(true);
const keyboardHeight = event.endCoordinates.height;
const offsetBaseline = Platform.OS === 'ios' ? 180 : 140;
const nextScrollOffset = Math.max(0, keyboardHeight - offsetBaseline);
scrollViewRef.current?.scrollTo({
y: nextScrollOffset,
animated: true,
});
};
const handleKeyboardHide = () => {
setIsKeyboardVisible(false);
scrollViewRef.current?.scrollTo({ y: 0, animated: true });
};
const showSubscription = Keyboard.addListener(showEvent, handleKeyboardShow);
const hideSubscription = Keyboard.addListener(hideEvent, handleKeyboardHide);
return () => {
showSubscription.remove();
hideSubscription.remove();
};
}, []);
const login = async () => {
if(email.trim() === '' || password.trim() === '') {
Alert.alert("All fields are required!");
return;
}
SetIsLoading(true);
const { data, error } = await supabase.auth.signInWithPassword({
email,
password
});
SetIsLoading(false);
if (error) {
Alert.alert("Login failed, please check your credentials and try again");
return;
}
if (!data.user?.id) {
Alert.alert("Login failed, missing user session after sign-in");
return;
}
try {
const setupStatus = await getSetupStatus(data.user.id);
router.replace(setupStatus.isSetupComplete ? "/" : "/setup");
} catch {
router.replace("/setup");
}
}
const inputClassName =
'rounded-2xl border border-app-border bg-app-subtle px-4 py-3 text-base text-text-main'
return (
<KeyboardAvoidingView
className="flex-1 bg-app-bg"
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 24}
>
<TouchableWithoutFeedback onPress={Keyboard.dismiss}>
<ScrollView
ref={scrollViewRef}
className="flex-1"
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
automaticallyAdjustKeyboardInsets={Platform.OS === 'ios'}
contentContainerStyle={{
flexGrow: 1,
justifyContent: isKeyboardVisible ? 'flex-start' : 'center',
paddingHorizontal: 20,
paddingTop: isKeyboardVisible ? 24 : 64,
paddingBottom: isKeyboardVisible ? 96 : 32,
}}
>
<View className="mb-10">
<Text className="text-4xl font-bold text-text-main">
Study Sprint
</Text>
<Text className="mt-3 text-base leading-6 text-text-secondary">
Pick up where you left off.
</Text>
</View>
<View className="rounded-3xl border border-app-border bg-app-surface p-5">
<Text className="text-2xl font-bold text-text-main">
Log in
</Text>
<Text className="mt-2 text-sm leading-5 text-text-secondary">
Continue your study workflow.
</Text>
<View className="mt-5 rounded-2xl border border-app-border bg-app-subtle p-4">
<Text className="text-sm font-bold text-text-main">
Your study path stays with your account
</Text>
<Text className="mt-1 text-sm leading-5 text-text-secondary">
Subjects, assignments, tasks, and tracked sprint progress follow
you after you sign in.
</Text>
</View>
<View className="mb-5 mt-6">
<Text className="mb-2 text-sm font-semibold text-text-secondary">
Email
</Text>
<TextInput
className={inputClassName}
placeholder="you@example.com"
placeholderTextColor="#9CA3AF"
keyboardType="email-address"
autoCapitalize="none"
autoCorrect={false}
value={email}
onChangeText={SetEmail}
/>
</View>
<View className="mb-6">
<Text className="mb-2 text-sm font-semibold text-text-secondary">
Password
</Text>
<TextInput
className={inputClassName}
placeholder="Enter your password"
placeholderTextColor="#9CA3AF"
secureTextEntry
value={password}
onChangeText={SetPassword}
onFocus={() => {
scrollViewRef.current?.scrollToEnd({ animated: true });
}}
/>
</View>
<Pressable
className={`h-14 items-center justify-center rounded-2xl ${
isLoading ? 'bg-accent-disabled' : 'bg-accent'
}`}
onPress={login}
disabled={isLoading}
>
<Text className="text-base font-bold text-text-inverse">
{isLoading ? 'Logging in...' : 'Log in'}
</Text>
</Pressable>
<Pressable
className="mt-4 h-12 items-center justify-center rounded-2xl border border-app-border bg-app-subtle"
onPress={() => router.push('/createUser')}
>
<Text className="text-sm font-semibold text-text-secondary">
Don&apos;t have an account? Sign up
</Text>
</Pressable>
</View>
</ScrollView>
</TouchableWithoutFeedback>
</KeyboardAvoidingView>
);
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,540 @@
import { formatDate, formatDateTime } from '@/lib/date';
import { SUBJECT_COLORS, type SubjectColor } from '@/lib/subjectColors';
import { supabase } from '@/lib/supabase';
import type { Assignment } from '@/lib/types';
import { Session } from '@supabase/supabase-js';
import { router, Stack, useFocusEffect, useLocalSearchParams } from 'expo-router';
import { useCallback, useEffect, useState } from 'react';
import { ActivityIndicator, Alert, Pressable, SectionList, Text, View } from 'react-native';
export type Subject = {
sId: string;
title: string;
description: string;
isActive: boolean;
lastChanged: string;
uId: string;
color: SubjectColor;
};
export default function ViewDetailsSubject() {
const { sId } = useLocalSearchParams<{ sId: string }>();
const [subject, SetSubject] = useState<Subject | null>(null);
const [assignments, SetAssignments] = useState<Assignment[]>([]);
const [session, SetSession] = useState<Session | null>(null);
const [isLoading, SetIsLoading] = useState(false);
const assignmentSections = [
{
title: 'Active Assignments',
data: assignments.filter((assignment) => !assignment.isCompleted),
emptyMessage: 'No active assignments',
},
{
title: 'Completed Assignments',
data: assignments.filter((assignment) => assignment.isCompleted),
emptyMessage: 'No completed assignments',
},
];
useEffect(() => {
supabase.auth.getSession().then(({ data }) => SetSession(data.session ?? null));
const { data: sub } = supabase.auth.onAuthStateChange((_event, newSession) => {
SetSession(newSession);
});
return () => sub.subscription.unsubscribe();
}, []);
const GetSubject = async (subjectId: string) => {
SetIsLoading(true);
const { data, error } = await supabase
.from('subjects')
.select('*')
.eq('sId', subjectId)
.single();
SetIsLoading(false);
if (error) {
Alert.alert('Subject could not be fetched, please try again');
return;
}
SetSubject((data as Subject) ?? null);
};
const GetAssignments = async (subjectId: string) => {
SetIsLoading(true);
const { data, error } = await supabase
.from('assignments')
.select('*')
.eq('sId', subjectId)
.order('deadline', { ascending: true });
SetIsLoading(false);
if (error) {
Alert.alert('Assignments could not be fetched, please try again');
return;
}
SetAssignments(data ?? []);
};
const ToggleAssignmentCompletion = async (assignment: Assignment) => {
const nextIsCompleted = !assignment.isCompleted;
const { error } = await supabase
.from('assignments')
.update({
isCompleted: nextIsCompleted,
lastChanged: new Date().toISOString(),
})
.eq('aId', assignment.aId);
if (error) {
Alert.alert('Assignment could not be updated, please try again');
return;
}
await GetAssignments(assignment.sId);
await GetSubject(assignment.sId);
};
useFocusEffect(
useCallback(() => {
if (!session || !sId) {
return;
}
SetIsLoading(true);
SetSubject(null);
Promise.all([GetSubject(sId), GetAssignments(sId)]).finally(() => {
SetIsLoading(false);
});
}, [session, sId])
);
const DeleteSubject = async (subjectId: string) => {
Alert.alert(
'Delete Subject',
'Are you sure you want to delete this subject?',
[
{
text: 'Cancel',
style: 'cancel',
},
{
text: 'Delete',
style: 'destructive',
onPress: async () => {
const { error } = await supabase
.from('subjects')
.delete()
.eq('sId', subjectId);
if (error) {
Alert.alert('Subject could not be deleted, please try again');
return;
}
Alert.alert('Subject deleted successfully!');
router.back();
},
},
]
);
};
const DeleteAssignment = async (assignmentId: string, subjectId: string) => {
Alert.alert(
'Delete Assignment',
'Are you sure you want to delete this assignment?',
[
{
text: 'Cancel',
style: 'cancel',
},
{
text: 'Delete',
style: 'destructive',
onPress: async () => {
const { error } = await supabase
.from('assignments')
.delete()
.eq('aId', assignmentId);
if (error) {
Alert.alert('Assignment could not be deleted, please try again');
return;
}
await GetAssignments(subjectId);
await GetSubject(subjectId);
Alert.alert('Assignment deleted successfully!');
},
},
]
);
};
const completedAssignments = assignments.filter((assignment) => assignment.isCompleted).length;
const totalAssignments = assignments.length;
const remainingAssignments = totalAssignments - completedAssignments;
const progress =
assignments.length === 0
? 0
: Math.round((completedAssignments / totalAssignments) * 100);
if (isLoading) {
return (
<View className="flex-1 bg-app-bg px-5 pt-6">
<Stack.Screen
options={{
title: 'Subject Details',
}}
/>
<View className="flex-1 items-center justify-center">
<ActivityIndicator size="large" color="#2563eb" />
<Text className="mt-4 text-base font-semibold text-text-secondary">
Loading subject...
</Text>
</View>
</View>
);
}
if (!subject) {
return (
<View className="flex-1 bg-app-bg px-5 pt-6">
<Stack.Screen
options={{
title: 'Subject Details',
headerTitleAlign: 'center',
}}
/>
<View className="rounded-3xl border border-app-border bg-app-surface p-5">
<Text className="text-2xl font-bold text-text-main">
Subject not found
</Text>
<Text className="mt-2 text-base text-text-secondary">
The subject could not be loaded.
</Text>
<Pressable
className="mt-5 h-12 items-center justify-center rounded-2xl bg-accent"
onPress={() => router.back()}
>
<Text className="text-base font-bold text-text-inverse">
Go back
</Text>
</Pressable>
</View>
</View>
);
}
const colorKey: SubjectColor = subject.color ?? 'slate';
const colorSet = SUBJECT_COLORS[colorKey];
const firstLetter = subject.title?.trim().charAt(0).toUpperCase() || 'S';
return (
<View className="flex-1 bg-app-bg">
<Stack.Screen
options={{
title: 'Subject Details',
headerRight: () => (
<Pressable
className="rounded-full bg-app-subtle px-4 py-2"
onPress={async () => await supabase.auth.signOut()}
>
<Text className="text-sm font-semibold text-text-secondary">
Logout
</Text>
</Pressable>
),
}}
/>
<SectionList
className="flex-1"
contentContainerStyle={{
paddingHorizontal: 20,
paddingTop: 20,
paddingBottom: 32,
}}
sections={totalAssignments === 0 ? [] : assignmentSections}
keyExtractor={(item) => item.aId}
showsVerticalScrollIndicator={false}
stickySectionHeadersEnabled={false}
ListHeaderComponent={
<View>
<View
className="rounded-3xl bg-app-surface p-5"
style={{
borderWidth: 1,
borderColor: colorSet.strong,
}}
>
<View className="flex-row items-center">
<View
className="mr-3 h-12 w-12 items-center justify-center rounded-2xl"
style={{ backgroundColor: colorSet.soft }}
>
<Text
className="text-base font-bold"
style={{ color: colorSet.strong }}
>
{firstLetter}
</Text>
</View>
<View className="flex-1">
<Text className="text-2xl font-bold text-text-main">
{subject.title}
</Text>
{subject.description ? (
<Text className="mt-1 text-sm leading-5 text-text-secondary">
{subject.description}
</Text>
) : (
<Text className="mt-1 text-sm leading-5 text-text-muted">
No description added.
</Text>
)}
</View>
<View className="ml-3">
<View
className="rounded-full px-3 py-1"
style={{ backgroundColor: colorSet.soft }}
>
<Text
className="text-xs font-semibold"
style={{ color: colorSet.strong }}
>
{subject.isActive ? 'Active' : 'Inactive'}
</Text>
</View>
</View>
</View>
<View className="mt-5">
<View className="mb-2 flex-row items-center justify-between">
<Text className="text-sm font-semibold text-text-secondary">
Assignment Progress
</Text>
<Text className="text-sm font-bold text-text-main">
{completedAssignments}/{totalAssignments}
</Text>
</View>
<View className="h-3 overflow-hidden rounded-full bg-app-subtle">
<View
className="h-full rounded-full"
style={{
width: `${progress}%`,
backgroundColor: colorSet.strong,
}}
/>
</View>
<Text className="mt-2 text-xs font-medium text-text-secondary">
{remainingAssignments === 0
? 'All assignments complete'
: `${remainingAssignments} assignment${
remainingAssignments === 1 ? '' : 's'
} remaining`}
</Text>
<Text className="mt-1 text-xs text-text-muted">
Based only on completed assignments in this subject.
</Text>
</View>
<Text className="mt-4 text-sm text-text-muted">
Last changed: {formatDateTime(subject.lastChanged)}
</Text>
<View className="mt-5 flex-row border-t border-app-border pt-5">
<Pressable
className="mr-3 flex-1 items-center justify-center rounded-2xl border border-app-border bg-app-subtle py-3"
onPress={() =>
router.push({
pathname: '../subject/upsertSubject',
params: { sId: subject.sId },
})
}
>
<Text className="text-sm font-bold text-text-secondary">
Edit
</Text>
</Pressable>
<Pressable
testID="delete-subject-button"
className="flex-1 items-center justify-center rounded-2xl border border-app-border bg-app-surface py-3"
onPress={() => DeleteSubject(subject.sId)}
>
<Text className="text-sm font-bold text-status-danger">
Delete
</Text>
</Pressable>
</View>
</View>
<Pressable
className="mb-6 mt-5 h-14 items-center justify-center rounded-2xl bg-accent"
onPress={() =>
router.push({
pathname: '../assignment/upsertAssignment',
params: { sId: subject.sId },
})
}
>
<Text className="text-base font-bold text-text-inverse">
Add Assignment
</Text>
</Pressable>
</View>
}
renderSectionHeader={({ section: { title, data } }) => (
<View className="mb-3 mt-2 flex-row items-center justify-between">
<Text className="text-lg font-bold text-text-main">{title}</Text>
<View className="rounded-full bg-app-subtle px-3 py-1">
<Text className="text-xs font-semibold text-text-muted">
{data.length}
</Text>
</View>
</View>
)}
renderItem={({ item }) => {
const isOwner = session?.user.id === item.uId;
return (
<View
className="mb-4 rounded-3xl border border-app-border bg-app-surface p-4"
style={{
borderColor: colorSet.strong,
}}
>
<View className="flex-row items-center">
<Pressable
className="flex-1"
onPress={() =>
router.push({
pathname: '/assignment/viewDetailsAssignment',
params: { aId: item.aId },
})
}
>
<View className="flex-1">
<Text
className={`text-base font-bold ${
item.isCompleted ? 'text-text-secondary' : 'text-text-main'
}`}
>
{item.title}
</Text>
{item.description ? (
<Text
className="mt-1 text-sm leading-5 text-text-muted"
numberOfLines={2}
>
{item.description}
</Text>
) : null}
<Text className="mt-2 text-sm text-text-secondary">
Deadline: {formatDate(item.deadline)}
</Text>
</View>
</Pressable>
</View>
{isOwner && (
<View className="mt-4 flex-row border-t border-app-border pt-4">
<Pressable
className="mr-3 flex-1 items-center justify-center rounded-2xl py-3"
style={{ backgroundColor: item.isCompleted ? '#EFEBE3' : colorSet.soft }}
onPress={() => ToggleAssignmentCompletion(item)}
>
<Text
className="text-sm font-bold"
style={{ color: colorSet.strong }}
>
{item.isCompleted ? 'Reopen' : 'Complete'}
</Text>
</Pressable>
<Pressable
className="mr-3 flex-1 items-center justify-center rounded-2xl border border-app-border bg-app-subtle py-3"
onPress={() =>
router.push({
pathname: '../assignment/upsertAssignment',
params: { aId: item.aId },
})
}
>
<Text className="text-sm font-bold text-text-secondary">
Edit
</Text>
</Pressable>
<Pressable
className="flex-1 items-center justify-center rounded-2xl border border-app-border bg-app-surface py-3"
onPress={() => DeleteAssignment(item.aId, item.sId)}
>
<Text className="text-sm font-bold text-status-danger">
Delete
</Text>
</Pressable>
</View>
)}
</View>
);
}}
ListEmptyComponent={
<View className="mb-6 rounded-3xl border border-app-border bg-app-surface p-5">
<Text className="text-center text-base font-semibold text-text-secondary">
No assignments yet
</Text>
<Text className="mt-1 text-center text-sm text-text-muted">
Add one when this subject has work to track.
</Text>
</View>
}
renderSectionFooter={({ section }) =>
section.data.length === 0 ? (
<View className="mb-6 rounded-3xl border border-app-border bg-app-surface p-5">
<Text className="text-center text-base font-semibold text-text-secondary">
{section.emptyMessage}
</Text>
<Text className="mt-1 text-center text-sm text-text-muted">
{assignments.length === 0
? 'Create the first assignment to give this subject a real study path.'
: 'Assignments for this subject will show up here.'}
</Text>
</View>
) : (
<View className="mb-2" />
)
}
/>
</View>
);
}

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,298 @@
import { defaultStyles } from '@/constants/defaultStyles';
import { SaveSetupSprintDemoUsed } from '@/lib/asyncStorage';
import { CheckAssignmentCompletion } from '@/lib/progress';
import { supabase } from '@/lib/supabase';
import type { Task } from '@/lib/types';
import { router, Stack, useLocalSearchParams } from 'expo-router';
import { useEffect, useState } from 'react';
import {
ActivityIndicator,
Alert,
Keyboard,
KeyboardAvoidingView,
Platform,
Pressable,
ScrollView,
Text,
TextInput,
TouchableWithoutFeedback,
View,
} from 'react-native';
export default function UpsertTask() {
const { tId, aId: routeAId, flow } = useLocalSearchParams<{
tId?: string;
aId?: string;
flow?: string;
}>();
const isEditMode = Boolean(tId);
const isSetupFlow = flow === 'setup';
const [title, SetTitle] = useState('');
const [description, SetDescription] = useState('');
const [isCompleted, SetIsCompleted] = useState(false);
const [assignmentId, SetAssignmentId] = useState<string | null>(routeAId ?? null);
const [isLoading, SetIsLoading] = useState(isEditMode);
const [isSaving, SetIsSaving] = useState(false);
useEffect(() => {
if (!isEditMode || !tId) {
SetIsLoading(false);
return;
}
const loadTask = async () => {
SetIsLoading(true);
const { data, error } = await supabase
.from('tasks')
.select('*')
.eq('tId', tId)
.single();
SetIsLoading(false);
if (error || !data) {
Alert.alert('Task could not be loaded, please try again');
router.back();
return;
}
const task = data as Task;
SetTitle(task.title ?? '');
SetDescription(task.description ?? '');
SetIsCompleted(task.isCompleted ?? false);
SetAssignmentId(task.aId ?? routeAId ?? null);
};
loadTask();
}, [isEditMode, tId, routeAId]);
const handleSubmit = async () => {
if (title.trim() === '') {
Alert.alert('Title is required!');
return;
}
const { data, error: userError } = await supabase.auth.getUser();
if (userError || !data.user) {
router.replace('/login');
return;
}
if (!assignmentId) {
Alert.alert('Missing assignment', 'This task is not linked to an assignment.');
return;
}
SetIsSaving(true);
const payload = {
title: title.trim(),
description: description.trim(),
isCompleted,
lastChanged: new Date().toISOString(),
uId: data.user.id,
aId: assignmentId,
};
const result =
isEditMode && tId
? await supabase.from('tasks').update(payload).eq('tId', tId)
: await supabase.from('tasks').insert(payload).select().single();
if (result.error) {
SetIsSaving(false);
Alert.alert(
isEditMode
? 'Task could not be updated, please try again'
: 'Task could not be created, please try again'
);
return;
}
try {
await CheckAssignmentCompletion(assignmentId);
} catch {
SetIsSaving(false);
Alert.alert('Failed to update assignment completion state');
return;
}
SetIsSaving(false);
if (!isEditMode && isSetupFlow && result.data?.tId) {
await SaveSetupSprintDemoUsed(data.user.id);
router.replace({
pathname: '/task/timer',
params: {
tId: result.data.tId,
durationSeconds: '5',
onboardingDemo: 'true',
},
});
return;
}
Alert.alert(
isEditMode ? 'Task successfully updated!' : 'Task successfully created!'
);
router.back();
};
const inputClassName =
'rounded-2xl border border-app-border bg-app-subtle px-4 py-3 text-base text-text-main';
const labelClassName = 'mb-2 text-sm font-semibold text-text-secondary';
if (isLoading) {
return (
<View className="flex-1 items-center justify-center bg-app-bg">
<ActivityIndicator size="large" />
</View>
);
}
return (
<>
<Stack.Screen
options={{
title: isEditMode ? 'Edit Task' : 'Create Task',
headerTitleStyle: defaultStyles.title,
headerTitleAlign: 'center',
}}
/>
<KeyboardAvoidingView
className="flex-1 bg-app-bg"
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<TouchableWithoutFeedback onPress={Keyboard.dismiss}>
<ScrollView
className="flex-1"
keyboardShouldPersistTaps="handled"
contentContainerStyle={{
flexGrow: 1,
justifyContent: 'center',
paddingHorizontal: 20,
paddingVertical: 32,
}}
>
<View className="mb-6">
<Text className="text-3xl font-bold text-text-main">
{isEditMode ? 'Edit Task' : 'Create Task'}
</Text>
<Text className="mt-2 text-base leading-6 text-text-secondary">
{isEditMode
? 'Update this task and keep your assignment moving forward.'
: 'Add a small step to move this assignment forward.'}
</Text>
</View>
<View className="rounded-3xl border border-app-border bg-app-surface p-5 shadow-sm">
<View className="mb-5">
<Text className={labelClassName}>Title</Text>
<TextInput
testID="task-title-input"
className={inputClassName}
placeholder={isSetupFlow ? 'e.g. Solve questions 1-3' : 'Enter task title'}
placeholderTextColor="#9CA3AF"
value={title}
onChangeText={SetTitle}
returnKeyType="next"
/>
</View>
<View className="mb-5">
<Text className={labelClassName}>Description</Text>
<TextInput
className={`${inputClassName} min-h-28`}
placeholder={
isSetupFlow
? 'e.g. Work through the first three tasks without notes'
: 'Add a short description'
}
placeholderTextColor="#9CA3AF"
value={description}
onChangeText={SetDescription}
multiline
textAlignVertical="top"
/>
</View>
<Pressable
onPress={() => SetIsCompleted((state) => !state)}
disabled={isSaving}
className={`mb-6 flex-row items-center rounded-2xl border p-4 ${
isCompleted
? 'border-accent bg-accent-soft'
: 'border-app-border bg-app-subtle'
}`}
>
<View
className={`mr-3 h-6 w-6 items-center justify-center rounded-md border-2 ${
isCompleted
? 'border-accent bg-accent'
: 'border-app-border bg-app-surface'
}`}
>
{isCompleted && (
<Text className="text-sm font-bold text-text-inverse">
</Text>
)}
</View>
<View className="flex-1">
<Text className="text-base font-semibold text-text-main">
Mark as completed
</Text>
<Text className="mt-1 text-sm text-text-muted">
You can change this later.
</Text>
</View>
</Pressable>
<Pressable
testID="upsert-task-button"
className={`h-14 items-center justify-center rounded-2xl ${
isSaving ? 'bg-accent-disabled' : 'bg-accent'
}`}
onPress={handleSubmit}
disabled={isSaving}
>
{isSaving ? (
<View className="flex-row items-center">
<ActivityIndicator size="small" />
<Text className="ml-3 text-base font-bold text-text-inverse">
{isEditMode ? 'Saving...' : 'Creating...'}
</Text>
</View>
) : (
<Text className="text-base font-bold text-text-inverse">
{isEditMode ? 'Save Changes' : 'Create Task'}
</Text>
)}
</Pressable>
<Pressable
className="mt-3 h-14 items-center justify-center rounded-2xl border border-app-border bg-app-subtle"
onPress={() => router.back()}
disabled={isSaving}
>
<Text className="text-base font-semibold text-text-secondary">
Cancel
</Text>
</Pressable>
</View>
</ScrollView>
</TouchableWithoutFeedback>
</KeyboardAvoidingView>
</>
);
}

View File

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

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: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 551 KiB

View File

@@ -0,0 +1,10 @@
module.exports = function (api) {
api.cache(true);
return {
presets: [
['babel-preset-expo', { jsxImportSource: 'nativewind' }],
'nativewind/babel',
],
};
};

View File

@@ -0,0 +1,25 @@
import { Href, Link } from 'expo-router';
import { openBrowserAsync, WebBrowserPresentationStyle } from 'expo-web-browser';
import { type ComponentProps } from 'react';
type Props = Omit<ComponentProps<typeof Link>, 'href'> & { href: Href & string };
export function ExternalLink({ href, ...rest }: Props) {
return (
<Link
target="_blank"
{...rest}
href={href}
onPress={async (event) => {
if (process.env.EXPO_OS !== 'web') {
// Prevent the default behavior of linking to the default browser on native.
event.preventDefault();
// Open the link in an in-app browser.
await openBrowserAsync(href, {
presentationStyle: WebBrowserPresentationStyle.AUTOMATIC,
});
}
}}
/>
);
}

View File

@@ -0,0 +1,18 @@
import { BottomTabBarButtonProps } from '@react-navigation/bottom-tabs';
import { PlatformPressable } from '@react-navigation/elements';
import * as Haptics from 'expo-haptics';
export function HapticTab(props: BottomTabBarButtonProps) {
return (
<PlatformPressable
{...props}
onPressIn={(ev) => {
if (process.env.EXPO_OS === 'ios') {
// Add a soft haptic feedback when pressing down on the tabs.
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}
props.onPressIn?.(ev);
}}
/>
);
}

View File

@@ -0,0 +1,19 @@
import Animated from 'react-native-reanimated';
export function HelloWave() {
return (
<Animated.Text
style={{
fontSize: 28,
lineHeight: 32,
marginTop: -6,
animationName: {
'50%': { transform: [{ rotate: '25deg' }] },
},
animationIterationCount: 4,
animationDuration: '300ms',
}}>
👋
</Animated.Text>
);
}

View File

@@ -0,0 +1,79 @@
import type { PropsWithChildren, ReactElement } from 'react';
import { StyleSheet } from 'react-native';
import Animated, {
interpolate,
useAnimatedRef,
useAnimatedStyle,
useScrollOffset,
} from 'react-native-reanimated';
import { ThemedView } from '@/components/themed-view';
import { useColorScheme } from '@/hooks/use-color-scheme';
import { useThemeColor } from '@/hooks/use-theme-color';
const HEADER_HEIGHT = 250;
type Props = PropsWithChildren<{
headerImage: ReactElement;
headerBackgroundColor: { dark: string; light: string };
}>;
export default function ParallaxScrollView({
children,
headerImage,
headerBackgroundColor,
}: Props) {
const backgroundColor = useThemeColor({}, 'background');
const colorScheme = useColorScheme() ?? 'light';
const scrollRef = useAnimatedRef<Animated.ScrollView>();
const scrollOffset = useScrollOffset(scrollRef);
const headerAnimatedStyle = useAnimatedStyle(() => {
return {
transform: [
{
translateY: interpolate(
scrollOffset.value,
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
[-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75]
),
},
{
scale: interpolate(scrollOffset.value, [-HEADER_HEIGHT, 0, HEADER_HEIGHT], [2, 1, 1]),
},
],
};
});
return (
<Animated.ScrollView
ref={scrollRef}
style={{ backgroundColor, flex: 1 }}
scrollEventThrottle={16}>
<Animated.View
style={[
styles.header,
{ backgroundColor: headerBackgroundColor[colorScheme] },
headerAnimatedStyle,
]}>
{headerImage}
</Animated.View>
<ThemedView style={styles.content}>{children}</ThemedView>
</Animated.ScrollView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
header: {
height: HEADER_HEIGHT,
overflow: 'hidden',
},
content: {
flex: 1,
padding: 32,
gap: 16,
overflow: 'hidden',
},
});

View File

@@ -0,0 +1,60 @@
import { StyleSheet, Text, type TextProps } from 'react-native';
import { useThemeColor } from '@/hooks/use-theme-color';
export type ThemedTextProps = TextProps & {
lightColor?: string;
darkColor?: string;
type?: 'default' | 'title' | 'defaultSemiBold' | 'subtitle' | 'link';
};
export function ThemedText({
style,
lightColor,
darkColor,
type = 'default',
...rest
}: ThemedTextProps) {
const color = useThemeColor({ light: lightColor, dark: darkColor }, 'text');
return (
<Text
style={[
{ color },
type === 'default' ? styles.default : undefined,
type === 'title' ? styles.title : undefined,
type === 'defaultSemiBold' ? styles.defaultSemiBold : undefined,
type === 'subtitle' ? styles.subtitle : undefined,
type === 'link' ? styles.link : undefined,
style,
]}
{...rest}
/>
);
}
const styles = StyleSheet.create({
default: {
fontSize: 16,
lineHeight: 24,
},
defaultSemiBold: {
fontSize: 16,
lineHeight: 24,
fontWeight: '600',
},
title: {
fontSize: 32,
fontWeight: 'bold',
lineHeight: 32,
},
subtitle: {
fontSize: 20,
fontWeight: 'bold',
},
link: {
lineHeight: 30,
fontSize: 16,
color: '#0a7ea4',
},
});

View File

@@ -0,0 +1,14 @@
import { View, type ViewProps } from 'react-native';
import { useThemeColor } from '@/hooks/use-theme-color';
export type ThemedViewProps = ViewProps & {
lightColor?: string;
darkColor?: string;
};
export function ThemedView({ style, lightColor, darkColor, ...otherProps }: ThemedViewProps) {
const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background');
return <View style={[{ backgroundColor }, style]} {...otherProps} />;
}

View File

@@ -0,0 +1,45 @@
import { PropsWithChildren, useState } from 'react';
import { StyleSheet, TouchableOpacity } from 'react-native';
import { ThemedText } from '@/components/themed-text';
import { ThemedView } from '@/components/themed-view';
import { IconSymbol } from '@/components/ui/icon-symbol';
import { Colors } from '@/constants/theme';
import { useColorScheme } from '@/hooks/use-color-scheme';
export function Collapsible({ children, title }: PropsWithChildren & { title: string }) {
const [isOpen, setIsOpen] = useState(false);
const theme = useColorScheme() ?? 'light';
return (
<ThemedView>
<TouchableOpacity
style={styles.heading}
onPress={() => setIsOpen((value) => !value)}
activeOpacity={0.8}>
<IconSymbol
name="chevron.right"
size={18}
weight="medium"
color={theme === 'light' ? Colors.light.icon : Colors.dark.icon}
style={{ transform: [{ rotate: isOpen ? '90deg' : '0deg' }] }}
/>
<ThemedText type="defaultSemiBold">{title}</ThemedText>
</TouchableOpacity>
{isOpen && <ThemedView style={styles.content}>{children}</ThemedView>}
</ThemedView>
);
}
const styles = StyleSheet.create({
heading: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
content: {
marginTop: 6,
marginLeft: 24,
},
});

View File

@@ -0,0 +1,32 @@
import { SymbolView, SymbolViewProps, SymbolWeight } from 'expo-symbols';
import { StyleProp, ViewStyle } from 'react-native';
export function IconSymbol({
name,
size = 24,
color,
style,
weight = 'regular',
}: {
name: SymbolViewProps['name'];
size?: number;
color: string;
style?: StyleProp<ViewStyle>;
weight?: SymbolWeight;
}) {
return (
<SymbolView
weight={weight}
tintColor={color}
resizeMode="scaleAspectFit"
name={name}
style={[
{
width: size,
height: size,
},
style,
]}
/>
);
}

View File

@@ -0,0 +1,41 @@
// Fallback for using MaterialIcons on Android and web.
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import { SymbolWeight, SymbolViewProps } from 'expo-symbols';
import { ComponentProps } from 'react';
import { OpaqueColorValue, type StyleProp, type TextStyle } from 'react-native';
type IconMapping = Record<SymbolViewProps['name'], ComponentProps<typeof MaterialIcons>['name']>;
type IconSymbolName = keyof typeof MAPPING;
/**
* Add your SF Symbols to Material Icons mappings here.
* - see Material Icons in the [Icons Directory](https://icons.expo.fyi).
* - see SF Symbols in the [SF Symbols](https://developer.apple.com/sf-symbols/) app.
*/
const MAPPING = {
'house.fill': 'home',
'paperplane.fill': 'send',
'chevron.left.forwardslash.chevron.right': 'code',
'chevron.right': 'chevron-right',
} as IconMapping;
/**
* An icon component that uses native SF Symbols on iOS, and Material Icons on Android and web.
* This ensures a consistent look across platforms, and optimal resource usage.
* Icon `name`s are based on SF Symbols and require manual mapping to Material Icons.
*/
export function IconSymbol({
name,
size = 24,
color,
style,
}: {
name: IconSymbolName;
size?: number;
color: string | OpaqueColorValue;
style?: StyleProp<TextStyle>;
weight?: SymbolWeight;
}) {
return <MaterialIcons color={color} size={size} name={MAPPING[name]} style={style} />;
}

View File

@@ -0,0 +1,105 @@
import { StyleSheet } from "react-native";
export const defaultStyles = StyleSheet.create({
container: {
flex: 1,
padding: 24,
marginVertical: 4,
marginHorizontal: 4,
},
buttonContainer: {
flexDirection: "row",
gap: 8,
justifyContent: "center",
marginHorizontal: 4,
marginVertical: 4,
},
title: {
fontSize: 24,
fontWeight: '700',
textAlign: 'center',
marginVertical: 4,
marginHorizontal: 4,
},
subtitle: {
fontSize: 20,
fontWeight: '600',
textAlign: 'center',
marginVertical: 4,
marginHorizontal: 4,
},
body: {
fontSize: 16,
fontWeight: '400',
textAlign: 'left',
marginVertical: 4,
marginHorizontal: 4,
},
separator: {
height: 1,
backgroundColor: '#000000',
marginVertical: 4,
marginHorizontal: 4,
},
circularButton: {
width: 40,
height: 40,
borderRadius: 20,
alignItems: 'center',
justifyContent: 'center',
marginVertical: 4,
marginHorizontal: 4,
},
linkText: {
color: "blue",
textDecorationLine: "underline",
textAlign: "center",
marginVertical: 4,
marginHorizontal: 4,
},
inputText: {
borderWidth: 1,
borderColor: "#ccc",
borderRadius: 6,
fontSize: 16,
fontWeight: '400',
textAlign: 'left',
marginVertical: 4,
marginHorizontal: 4,
},
checkboxContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
marginVertical: 4,
marginHorizontal: 4,
},
checkbox: {
width: 24,
height: 24,
borderWidth: 2,
borderColor: '#666',
borderRadius: 4,
alignItems: 'center',
justifyContent: 'center',
marginVertical: 4,
marginHorizontal: 4,
},
checkboxMark: {
fontSize: 14,
fontWeight: '700',
},
checkboxLabel: {
fontSize: 16,
color: '#111',
marginVertical: 4,
marginHorizontal: 4,
},
boldBody: {
fontSize: 16,
fontWeight: 'bold',
textAlign: 'left',
marginVertical: 4,
marginHorizontal: 4,
},
});

View File

@@ -0,0 +1,53 @@
/**
* Below are the colors that are used in the app. The colors are defined in the light and dark mode.
* There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc.
*/
import { Platform } from 'react-native';
const tintColorLight = '#0a7ea4';
const tintColorDark = '#fff';
export const Colors = {
light: {
text: '#11181C',
background: '#fff',
tint: tintColorLight,
icon: '#687076',
tabIconDefault: '#687076',
tabIconSelected: tintColorLight,
},
dark: {
text: '#ECEDEE',
background: '#151718',
tint: tintColorDark,
icon: '#9BA1A6',
tabIconDefault: '#9BA1A6',
tabIconSelected: tintColorDark,
},
};
export const Fonts = Platform.select({
ios: {
/** iOS `UIFontDescriptorSystemDesignDefault` */
sans: 'system-ui',
/** iOS `UIFontDescriptorSystemDesignSerif` */
serif: 'ui-serif',
/** iOS `UIFontDescriptorSystemDesignRounded` */
rounded: 'ui-rounded',
/** iOS `UIFontDescriptorSystemDesignMonospaced` */
mono: 'ui-monospace',
},
default: {
sans: 'normal',
serif: 'serif',
rounded: 'normal',
mono: 'monospace',
},
web: {
sans: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
serif: "Georgia, 'Times New Roman', serif",
rounded: "'SF Pro Rounded', 'Hiragino Maru Gothic ProN', Meiryo, 'MS PGothic', sans-serif",
mono: "SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
},
});

View File

@@ -0,0 +1,21 @@
# Signup Confirmation Page
This serves a very small static confirmation page with `nginx`.
## Run
```bash
docker compose up -d
```
It will be available on port `8080` on the VPS.
## Files
- `docker-compose.yml`: starts `nginx:alpine`
- `site/index.html`: the page shown after email confirmation
## Notes
- If you already have a reverse proxy on the VPS, point your domain or subdomain to `http://localhost:8080`.
- If you want this container to bind directly to port `80`, change `8080:80` to `80:80` in `docker-compose.yml`.

View File

@@ -0,0 +1,14 @@
networks:
caddy_shared:
external: true
services:
signup-confirmation:
image: nginx:alpine
container_name: study-sprint-signup-confirmation
restart: always
expose:
- "80"
networks:
- caddy_shared
volumes:
- ./site:/usr/share/nginx/html:ro

View File

@@ -0,0 +1,75 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Study Sprint</title>
<style>
:root {
color-scheme: light;
--bg: #f4f7fb;
--card: #ffffff;
--text: #1f2937;
--muted: #5b6472;
--accent: #3b82f6;
--border: #d9e2ec;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
padding: 24px;
background:
radial-gradient(circle at top, #e6f1ff 0%, transparent 45%),
linear-gradient(180deg, var(--bg) 0%, #eef3f8 100%);
font-family: Arial, sans-serif;
color: var(--text);
}
.card {
width: min(100%, 560px);
padding: 40px 28px;
border: 1px solid var(--border);
border-radius: 24px;
background: var(--card);
box-shadow: 0 24px 60px rgba(15, 23, 42, 0.08);
text-align: center;
}
.eyebrow {
margin: 0 0 12px;
font-size: 0.85rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--accent);
}
h1 {
margin: 0;
font-size: clamp(2rem, 5vw, 2.7rem);
line-height: 1.1;
}
p {
margin: 16px 0 0;
font-size: 1rem;
line-height: 1.6;
color: var(--muted);
}
</style>
</head>
<body>
<main class="card">
<p class="eyebrow">Study Sprint</p>
<h1>Thank you for signing up.</h1>
<p>Your email has been confirmed. You can now sign in to your account in the Study Sprint app.</p>
</main>
</body>
</html>

View File

@@ -0,0 +1,21 @@
{
"cli": {
"version": ">= 18.8.0",
"appVersionSource": "remote"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal"
},
"preview": {
"distribution": "internal"
},
"production": {
"autoIncrement": true
}
},
"submit": {
"production": {}
}
}

View File

@@ -0,0 +1,10 @@
// https://docs.expo.dev/guides/using-eslint/
const { defineConfig } = require('eslint/config');
const expoConfig = require('eslint-config-expo/flat');
module.exports = defineConfig([
expoConfig,
{
ignores: ['dist/*'],
},
]);

View File

@@ -0,0 +1,4 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@@ -0,0 +1 @@
export { useColorScheme } from 'react-native';

View File

@@ -0,0 +1,21 @@
import { useEffect, useState } from 'react';
import { useColorScheme as useRNColorScheme } from 'react-native';
/**
* To support static rendering, this value needs to be re-calculated on the client side for web
*/
export function useColorScheme() {
const [hasHydrated, setHasHydrated] = useState(false);
useEffect(() => {
setHasHydrated(true);
}, []);
const colorScheme = useRNColorScheme();
if (hasHydrated) {
return colorScheme;
}
return 'light';
}

View File

@@ -0,0 +1,21 @@
/**
* Learn more about light and dark modes:
* https://docs.expo.dev/guides/color-schemes/
*/
import { Colors } from '@/constants/theme';
import { useColorScheme } from '@/hooks/use-color-scheme';
export function useThemeColor(
props: { light?: string; dark?: string },
colorName: keyof typeof Colors.light & keyof typeof Colors.dark
) {
const theme = useColorScheme() ?? 'light';
const colorFromProps = props[theme];
if (colorFromProps) {
return colorFromProps;
} else {
return Colors[theme][colorName];
}
}

View File

@@ -0,0 +1,4 @@
jest.mock(
"@react-native-async-storage/async-storage",
() => require("@react-native-async-storage/async-storage/jest/async-storage-mock")
);

View File

@@ -0,0 +1,80 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import type { SessionType } from '@/lib/types';
const notificationKey = (aId: string) => `assignment_notification_${aId}`;
const setupSprintDemoKey = (userId: string) => `setup_sprint_demo_${userId}`;
const activeSprintKey = 'active_sprint';
const studyCycleKey = 'study_cycle';
export type ActiveSession = {
sessionId: string;
sessionType: SessionType;
taskId: string | null;
returnTaskId?: string | null;
durationSeconds: number;
endTime: number;
};
export type StudyCycle = {
taskId: string;
completedFocusSessions: number;
lastCompletedSessionType: SessionType;
lastCompletedAt: number;
};
export async function SaveAssignmentNotificationId(aId: string, notificationId: string) {
await AsyncStorage.setItem(notificationKey(aId), notificationId);
}
export async function GetAssignmentNotificationId(aId: string) {
return await AsyncStorage.getItem(notificationKey(aId));
}
export async function RemoveAssignmentNotificationId(aId: string) {
await AsyncStorage.removeItem(notificationKey(aId));
}
export async function SaveActiveSession(activeSession: ActiveSession) {
await AsyncStorage.setItem(activeSprintKey, JSON.stringify(activeSession));
}
export async function GetActiveSession() {
const activeSession = await AsyncStorage.getItem(activeSprintKey);
if (!activeSession) {
return null;
}
return JSON.parse(activeSession) as ActiveSession;
}
export async function RemoveActiveSession() {
await AsyncStorage.removeItem(activeSprintKey);
}
export async function SaveStudyCycle(studyCycle: StudyCycle) {
await AsyncStorage.setItem(studyCycleKey, JSON.stringify(studyCycle));
}
export async function GetStudyCycle() {
const studyCycle = await AsyncStorage.getItem(studyCycleKey);
if (!studyCycle) {
return null;
}
return JSON.parse(studyCycle) as StudyCycle;
}
export async function RemoveStudyCycle() {
await AsyncStorage.removeItem(studyCycleKey);
}
export async function GetSetupSprintDemoUsed(userId: string) {
const value = await AsyncStorage.getItem(setupSprintDemoKey(userId));
return value === 'true';
}
export async function SaveSetupSprintDemoUsed(userId: string) {
await AsyncStorage.setItem(setupSprintDemoKey(userId), 'true');
}

View File

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

View File

@@ -0,0 +1,39 @@
import * as Notifications from 'expo-notifications';
import { Platform } from 'react-native';
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldPlaySound: true,
shouldSetBadge: true,
shouldShowBanner: true,
shouldShowList: true,
}),
});
function HandleRegistrationError(errorMessage: string) {
alert(errorMessage);
throw new Error(errorMessage);
}
export async function RegisterForLocalNotificationsAsync() {
if (Platform.OS === 'android') {
await Notifications.setNotificationChannelAsync('default', {
name: 'default',
importance: Notifications.AndroidImportance.MAX
});
}
const { status: existingStatus } = await Notifications.getPermissionsAsync();
let finalStatus = existingStatus;
if (existingStatus !== 'granted') {
const { status } = await Notifications.requestPermissionsAsync();
finalStatus = status;
}
if (finalStatus !== 'granted') {
HandleRegistrationError('Permission not granted for local notifications');
return;
}
}

View File

@@ -0,0 +1,15 @@
import { supabase } from '@/lib/supabase';
export async function CheckAssignmentCompletion(aId: string) {
const { data, error } = await supabase.from("tasks").select("tId, isCompleted").eq("aId", aId);
if (error) throw error;
const tasks = data ?? [];
const allCompleted = tasks.length > 0 && tasks.every((task) => task.isCompleted === true);
const { error: updateError } = await supabase.from("assignments").update({ isCompleted: allCompleted, lastChanged: new Date().toISOString()}).eq("aId", aId);
if (updateError) throw updateError;
}

View File

@@ -0,0 +1,5 @@
export const DEFAULT_FOCUS_DURATION_MINUTES = 25;
export const DEFAULT_SHORT_BREAK_DURATION_MINUTES = 5;
export const DEFAULT_LONG_BREAK_DURATION_MINUTES = 15;
export const FOCUS_SESSIONS_PER_LONG_BREAK = 4;
export const STUDY_CYCLE_IDLE_RESET_MINUTES = 120;

View File

@@ -0,0 +1,37 @@
import {
GetActiveSession,
RemoveActiveSession,
RemoveStudyCycle,
type ActiveSession,
} from '@/lib/asyncStorage';
import { supabase } from '@/lib/supabase';
export type FinalSessionStatus = 'completed' | 'cancelled' | 'expired';
export async function finalizeStoredSession(
finalStatus: FinalSessionStatus,
activeSessionOverride?: ActiveSession | null
) {
const activeSession = activeSessionOverride ?? await GetActiveSession();
if (!activeSession) {
return null;
}
await RemoveActiveSession();
if (finalStatus !== 'completed') {
await RemoveStudyCycle();
}
const { error } = await supabase.rpc('finalize_sprint_session', {
p_session_id: activeSession.sessionId,
p_final_status: finalStatus,
p_ended_at: new Date().toISOString(),
});
return {
activeSession,
error,
};
}

View File

@@ -0,0 +1,84 @@
import { supabase } from '@/lib/supabase';
export type SetupStepKey = 'subject' | 'assignment' | 'task' | 'sprint';
export type SetupStatus = {
subjectId: string | null;
assignmentId: string | null;
taskId: string | null;
completedFocusSessions: number;
currentStep: SetupStepKey;
isSetupComplete: boolean;
};
export async function getSetupStatus(userId: string): Promise<SetupStatus> {
const [subjectResult, assignmentResult, taskResult, focusSessionResult] = await Promise.all([
supabase
.from('subjects')
.select('sId')
.eq('uId', userId)
.order('lastChanged', { ascending: false })
.limit(1)
.maybeSingle(),
supabase
.from('assignments')
.select('aId')
.eq('uId', userId)
.order('lastChanged', { ascending: false })
.limit(1)
.maybeSingle(),
supabase
.from('tasks')
.select('tId')
.eq('uId', userId)
.order('lastChanged', { ascending: false })
.limit(1)
.maybeSingle(),
supabase
.from('sprint_sessions')
.select('sessionId', { count: 'exact', head: true })
.eq('userId', userId)
.eq('sessionType', 'focus')
.eq('status', 'completed'),
]);
if (subjectResult.error) {
throw subjectResult.error;
}
if (assignmentResult.error) {
throw assignmentResult.error;
}
if (taskResult.error) {
throw taskResult.error;
}
if (focusSessionResult.error) {
throw focusSessionResult.error;
}
const subjectId = subjectResult.data?.sId ?? null;
const assignmentId = assignmentResult.data?.aId ?? null;
const taskId = taskResult.data?.tId ?? null;
const completedFocusSessions = focusSessionResult.count ?? 0;
let currentStep: SetupStepKey = 'sprint';
if (!subjectId) {
currentStep = 'subject';
} else if (!assignmentId) {
currentStep = 'assignment';
} else if (!taskId) {
currentStep = 'task';
}
return {
subjectId,
assignmentId,
taskId,
completedFocusSessions,
currentStep,
isSetupComplete: taskId !== null && completedFocusSessions > 0,
};
}

View File

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

View File

@@ -0,0 +1,36 @@
import { createClient } from '@supabase/supabase-js';
import * as SecureStore from 'expo-secure-store';
import 'react-native-url-polyfill/auto';
const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL!
const supabaseKey = process.env.EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY!
if (!supabaseUrl) {
throw new Error('Missing EXPO_PUBLIC_SUPABASE_URL');
}
if (!supabaseKey) {
throw new Error('Missing EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY');
}
const SecureStoreAdapter = {
getItem: async (key: string) => {
return await SecureStore.getItemAsync(key);
},
setItem: async (key: string, value: string) => {
await SecureStore.setItemAsync(key, value);
},
removeItem: async (key: string) => {
await SecureStore.deleteItemAsync(key);
},
};
export const supabase = createClient(supabaseUrl, supabaseKey, {
auth: {
storage: SecureStoreAdapter,
autoRefreshToken: true,
persistSession: true,
detectSessionInUrl: false,
},
})

View File

@@ -0,0 +1,35 @@
import type { SubjectColor } from '@/lib/subjectColors';
export type SessionType = 'focus' | 'short_break' | 'long_break';
export type Task = {
tId: string;
title: string;
description: string;
isCompleted: boolean;
lastChanged: string;
uId: string;
aId: string;
totalTimeInSeconds: number;
};
export type Assignment = {
aId: string;
title: string;
description: string;
deadline: string;
isCompleted: boolean;
lastChanged: string;
uId: string;
sId: string;
};
export type Subject = {
sId: string;
title: string;
description: string;
isActive: boolean;
lastChanged: string;
uId: string;
color?: SubjectColor;
};

View File

@@ -0,0 +1,8 @@
const { getDefaultConfig } = require('expo/metro-config');
const { withNativeWind } = require('nativewind/metro');
const config = getDefaultConfig(__dirname);
module.exports = withNativeWind(config, {
input: './global.css',
});

View File

@@ -0,0 +1,3 @@
/// <reference types="nativewind/types" />
// NOTE: This file should not be edited and should be committed with your source code. It is generated by NativeWind.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,71 @@
{
"name": "study-sprint",
"main": "expo-router/entry",
"version": "1.0.0",
"scripts": {
"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",
"test": "jest"
},
"dependencies": {
"@expo/vector-icons": "^15.0.3",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8",
"@supabase/supabase-js": "^2.103.1",
"expo": "~54.0.33",
"expo-constants": "~18.0.13",
"expo-dev-client": "~6.0.20",
"expo-font": "~14.0.11",
"expo-haptics": "~15.0.8",
"expo-image": "~3.0.11",
"expo-linking": "~8.0.11",
"expo-notifications": "~0.32.16",
"expo-router": "~6.0.23",
"expo-secure-store": "~15.0.8",
"expo-splash-screen": "~31.0.13",
"expo-status-bar": "~3.0.9",
"expo-symbols": "~1.0.8",
"expo-system-ui": "~6.0.9",
"expo-web-browser": "~15.0.10",
"nativewind": "^4.2.3",
"patch-package": "^8.0.1",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0",
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0",
"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",
"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,
"jest": {
"preset": "jest-expo",
"setupFiles": [
"<rootDir>/jest.setup.js"
]
}
}

View File

@@ -0,0 +1,28 @@
diff --git a/node_modules/metro-config/src/loadConfig.js b/node_modules/metro-config/src/loadConfig.js
index 7ac9d88..4a424c3 100644
--- a/node_modules/metro-config/src/loadConfig.js
+++ b/node_modules/metro-config/src/loadConfig.js
@@ -76,6 +76,9 @@ const resolve = (filePath) => {
const possiblePath = path.resolve(process.cwd(), filePath);
return isFile(possiblePath) ? possiblePath : filePath;
};
+
+const { pathToFileURL } = require("url");
+
async function resolveConfig(filePath, cwd) {
const configPath =
filePath != null
@@ -289,7 +292,12 @@ async function loadConfigFile(absolutePath) {
}
} catch (e) {
try {
- const configModule = await import(absolutePath);
+ const importPath =
+ process.platform === "win32"
+ ? pathToFileURL(absolutePath).href
+ : absolutePath;
+
+ const configModule = await import(importPath);
config = await configModule.default;
} catch (error) {
let prefix = `Error loading Metro config at: ${absolutePath}\n`;

View File

@@ -0,0 +1,112 @@
#!/usr/bin/env node
/**
* This script is used to reset the project to a blank state.
* It deletes or moves the /app, /components, /hooks, /scripts, and /constants directories to /app-example based on user input and creates a new /app directory with an index.tsx and _layout.tsx file.
* You can remove the `reset-project` script from package.json and safely delete this file after running it.
*/
const fs = require("fs");
const path = require("path");
const readline = require("readline");
const root = process.cwd();
const oldDirs = ["app", "components", "hooks", "constants", "scripts"];
const exampleDir = "app-example";
const newAppDir = "app";
const exampleDirPath = path.join(root, exampleDir);
const indexContent = `import { Text, View } from "react-native";
export default function Index() {
return (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
}}
>
<Text>Edit app/index.tsx to edit this screen.</Text>
</View>
);
}
`;
const layoutContent = `import { Stack } from "expo-router";
export default function RootLayout() {
return <Stack />;
}
`;
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const moveDirectories = async (userInput) => {
try {
if (userInput === "y") {
// Create the app-example directory
await fs.promises.mkdir(exampleDirPath, { recursive: true });
console.log(`📁 /${exampleDir} directory created.`);
}
// Move old directories to new app-example directory or delete them
for (const dir of oldDirs) {
const oldDirPath = path.join(root, dir);
if (fs.existsSync(oldDirPath)) {
if (userInput === "y") {
const newDirPath = path.join(root, exampleDir, dir);
await fs.promises.rename(oldDirPath, newDirPath);
console.log(`➡️ /${dir} moved to /${exampleDir}/${dir}.`);
} else {
await fs.promises.rm(oldDirPath, { recursive: true, force: true });
console.log(`❌ /${dir} deleted.`);
}
} else {
console.log(`➡️ /${dir} does not exist, skipping.`);
}
}
// Create new /app directory
const newAppDirPath = path.join(root, newAppDir);
await fs.promises.mkdir(newAppDirPath, { recursive: true });
console.log("\n📁 New /app directory created.");
// Create index.tsx
const indexPath = path.join(newAppDirPath, "index.tsx");
await fs.promises.writeFile(indexPath, indexContent);
console.log("📄 app/index.tsx created.");
// Create _layout.tsx
const layoutPath = path.join(newAppDirPath, "_layout.tsx");
await fs.promises.writeFile(layoutPath, layoutContent);
console.log("📄 app/_layout.tsx created.");
console.log("\n✅ Project reset complete. Next steps:");
console.log(
`1. Run \`npx expo start\` to start a development server.\n2. Edit app/index.tsx to edit the main screen.${
userInput === "y"
? `\n3. Delete the /${exampleDir} directory when you're done referencing it.`
: ""
}`
);
} catch (error) {
console.error(`❌ Error during script execution: ${error.message}`);
}
};
rl.question(
"Do you want to move existing files to /app-example instead of deleting them? (Y/n): ",
(answer) => {
const userInput = answer.trim().toLowerCase() || "y";
if (userInput === "y" || userInput === "n") {
moveDirectories(userInput).finally(() => rl.close());
} else {
console.log("❌ Invalid input. Please enter 'Y' or 'N'.");
rl.close();
}
}
);

View File

@@ -0,0 +1,73 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./app/**/*.{js,jsx,ts,tsx}',
'./components/**/*.{js,jsx,ts,tsx}',
'./constants/**/*.{js,jsx,ts,tsx}',
],
presets: [require('nativewind/preset')],
theme: {
extend: {
colors: {
app: {
bg: '#F7F5EF',
surface: '#FFFFFF',
subtle: '#EFEBE3',
border: '#DDD6C8',
},
text: {
main: '#1F2933',
secondary: '#52616B',
muted: '#9AA6B2',
inverse: '#FFFFFF',
},
accent: {
DEFAULT: '#3B82A0',
soft: '#DCEFF5',
hover: '#2F6F88',
disabled: '#9CC7D6',
},
status: {
success: '#15803D',
warning: '#B7791F',
danger: '#B91C1C',
},
subject: {
blue: {
bg: '#DCEFF5',
text: '#2F6F88',
},
emerald: {
bg: '#DDEFE5',
text: '#2F7D55',
},
amber: {
bg: '#F6E8C6',
text: '#9A6A16',
},
violet: {
bg: '#E9E2F5',
text: '#6D4BA3',
},
cyan: {
bg: '#DDF0EF',
text: '#287C7A',
},
rose: {
bg: '#F4E1DF',
text: '#9B4A43',
},
slate: {
bg: '#E8E4DA',
text: '#52616B',
},
},
},
},
},
plugins: [],
};

View File

@@ -0,0 +1,17 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"jsx": "react-native",
"paths": {
"@/*": ["./*"]
}
},
"include": [
"**/*.ts",
"**/*.tsx",
".expo/types/**/*.ts",
"expo-env.d.ts",
"nativewind-env.d.ts"
]
}