Final push before system formatting
196
AppDev/ikt205_2026_18_study_sprint/source/.gitignore
vendored
Normal 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/
|
||||
105
AppDev/ikt205_2026_18_study_sprint/source/README.md
Normal 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.
|
||||
29
AppDev/ikt205_2026_18_study_sprint/source/Study-Sprint.sln
Normal 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
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
57
AppDev/ikt205_2026_18_study_sprint/source/app.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
118
AppDev/ikt205_2026_18_study_sprint/source/app/(tabs)/_layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1028
AppDev/ikt205_2026_18_study_sprint/source/app/(tabs)/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
AppDev/ikt205_2026_18_study_sprint/source/app/_layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
213
AppDev/ikt205_2026_18_study_sprint/source/app/createUser.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
189
AppDev/ikt205_2026_18_study_sprint/source/app/login.tsx
Normal 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't have an account? Sign up
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</TouchableWithoutFeedback>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
359
AppDev/ikt205_2026_18_study_sprint/source/app/setup.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
1483
AppDev/ikt205_2026_18_study_sprint/source/app/task/timer.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 162 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
BIN
AppDev/ikt205_2026_18_study_sprint/source/assets/images/icon.png
Normal file
|
After Width: | Height: | Size: 551 KiB |
|
After Width: | Height: | Size: 551 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 551 KiB |
10
AppDev/ikt205_2026_18_study_sprint/source/babel.config.js
Normal file
@@ -0,0 +1,10 @@
|
||||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
|
||||
return {
|
||||
presets: [
|
||||
['babel-preset-expo', { jsxImportSource: 'nativewind' }],
|
||||
'nativewind/babel',
|
||||
],
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
53
AppDev/ikt205_2026_18_study_sprint/source/constants/theme.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
@@ -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>
|
||||
21
AppDev/ikt205_2026_18_study_sprint/source/eas.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
10
AppDev/ikt205_2026_18_study_sprint/source/eslint.config.js
Normal 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/*'],
|
||||
},
|
||||
]);
|
||||
4
AppDev/ikt205_2026_18_study_sprint/source/global.css
Normal file
@@ -0,0 +1,4 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { useColorScheme } from 'react-native';
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
4
AppDev/ikt205_2026_18_study_sprint/source/jest.setup.js
Normal file
@@ -0,0 +1,4 @@
|
||||
jest.mock(
|
||||
"@react-native-async-storage/async-storage",
|
||||
() => require("@react-native-async-storage/async-storage/jest/async-storage-mock")
|
||||
);
|
||||
@@ -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');
|
||||
}
|
||||
29
AppDev/ikt205_2026_18_study_sprint/source/lib/date.ts
Normal 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',
|
||||
});
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
15
AppDev/ikt205_2026_18_study_sprint/source/lib/progress.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
84
AppDev/ikt205_2026_18_study_sprint/source/lib/setupStatus.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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];
|
||||
};
|
||||
36
AppDev/ikt205_2026_18_study_sprint/source/lib/supabase.ts
Normal 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,
|
||||
},
|
||||
})
|
||||
|
||||
35
AppDev/ikt205_2026_18_study_sprint/source/lib/types.ts
Normal 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;
|
||||
};
|
||||
@@ -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',
|
||||
});
|
||||
3
AppDev/ikt205_2026_18_study_sprint/source/nativewind-env.d.ts
vendored
Normal 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.
|
||||
17164
AppDev/ikt205_2026_18_study_sprint/source/package-lock.json
generated
Normal file
71
AppDev/ikt205_2026_18_study_sprint/source/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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`;
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
);
|
||||
73
AppDev/ikt205_2026_18_study_sprint/source/tailwind.config.js
Normal 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: [],
|
||||
};
|
||||
17
AppDev/ikt205_2026_18_study_sprint/source/tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
||||