Final push before system formatting

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

View File

@@ -0,0 +1,38 @@
# Deployment Section Note
The process of uploading the Android app bundle to Google Play Console and setting up an internal test track should generally **not** be described in the `Solution` section.
## Why it does not fit `Solution`
The `Solution` section should primarily explain:
- what the product is,
- which main components it consists of,
- how those components solve the user problem.
Google Play Console setup, app-bundle upload, signing, and internal testing are not really part of the product solution itself. They are part of the **delivery and distribution process**.
## Better placement
This material fits better in one of these sections:
- `Implementation`
- if the point is to describe the practical technical work required to prepare the app for Android delivery and real-device testing
- `Discussion`
- if the point is to reflect on deployment challenges, release-readiness, signing issues, package identity, or lessons learned
- `Appendix`
- if the point is simply to document that internal testing was configured and completed
## Recommended approach
If deployment readiness is important for the report, the cleanest solution is:
- keep `Solution` focused on the app as a product,
- mention Android deployment and internal testing in `Implementation`,
- mention any notable release problems or limitations in `Discussion` if relevant.
## Short report-friendly summary
If needed, a short version could be phrased like this:
> Preparation for Android deployment, including app-bundle generation and Google Play internal testing, was treated as part of the implementation and delivery process rather than as part of the product solution itself.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,564 @@
# Study Sprint - Architecture Redesign Notes
## Purpose of this note
This note documents the architectural and UI redesign work completed on the app documenting
- what changed
- why it changed
- how the strucutre improved
- which design decisions were intentional
---
## Initial app state
The app originally had a flatter and more fragmented structure than the actual data model supported.
The conceptual data hierarchy of the app is:
**Subject -> Assignment -> Task**
However, the earlier navigation and screen structure did not consistently reflect that hierarchy. In practice, this caused duplicated views, weak context, and screens that felt disconnected from their parent entities.
Main problems in the earlier version:
- top-level tabs included separate screens for **Subjects**, **Assignments**, and **Tasks**
- tasks existed as their own top-level area even though they are children of assignments
- assignments also felt partially detached from subjects
- repeated CRUD patterns created too many screens and too much UI duplication
- many screens relied on older `defaultStyles` patterns rather than a clearer component/card-based structure
- raw timestamps and brrittle date inputs created poor usability
- create/edit flows were separated even when both used the same form structure
---
## Core redesign goal
The redesign aimed to make the app follow its real conceptual model more closely
**Dashboard -> overview**
**Subjects -> actual content hierarchy entry point**
**Timer -> separate utility tool**
And inside the content structure:
**Subject -> Assignment -> Task**
The main design philosophy throughout the redesign was:
- calm
- intuitive
- minimal
- low visual noise
- predictable interaction patterns
- stronger hierarchy
- less redundancy
---
# 1. Navigation architecture redesign
## Previous navigation problem
The earlier tab setup exposed too many top-level destinations:
- Dashboard/Home
- Subjects
- Assignments
- Tasks
- Timer
THis was a problem because Tasks and Assignments were not truly top-level concepts in the product model. They belong to parent entities.
This caused:
- duplicated list views
- extra screens with weaker contextual meaning
- cognitive overload
- a flatter app structure than intended
## Navigation redesign decision
The top-level tabs were simplified to:
- **Dashboard**
- **Subjects**
- **Timer**
## Why this is better
This better matches the app structure:
- **Dashboard** = overview and corss-cutting information
- **Subjects** = main entry point into academic content
- **Timer** = standalone utility
Assignments and tasks were removed from the top-level tab bar and are now only accessed through the hierarchy:
- subject details contains assignments
- assignment details contain tasks
### Effect of this change
This reduced redundancy and made the app feel more coherent. It also aligned the user flow with the actual data relationships rather than exposing every model as its own first-class navigation area.
---
# 2. Content hierarchy redesign
## Original issue
Subjects, assignments, and tasks were partially treated like parallel entities rather than nested entities.
This weakened context:
- a task could appear without clearly indicating its assignment or subject
- an assignment could feel detached from its subject
- the hierarchy existed in the database but was not always communicated in the UI
## Redesign decision
The hierarchy was made explicit in the UI
- subjects page shows only subject cards
- subject detail page becomes the main hub fro the selected subject
- assignment detail page becomes the main hub for the selected assignment
- task detail page becomes the main hub for the selected task
### Result
The app now better communicates:
- where the user is
- what the current item belongs to
- how to move deeper into the structure
---
# 3. Subject list redesign
## Original issue
The subject list screen contained too much management UI directly in the list:
- edit buttons
- delete buttons
- progress bar
- cluttered card actions
This made the subject list feel like a control panel rather than a clean browsing screen.
## Redesign decision
The subject list was simplified into clean, tappable subject cards.
Each subject card now focuses on:
- subject title
- optional description
- active/inactive pill
- subject-specific color identity
- full-card tap to open subject details
### Removed from list cards
- inline edit button
- inline delete button
- progress bar
- management-heavy layout
### Why
The list screen should be for browsing and selecting. Management actions belong inside the detail screen for that entitiy
### Result
The subject list become calmer, easier to scan, and more aligned with the principle that cards should act as entry points rather than mini dashboards.
---
# 4. Subject detail screen redesign
## Original issue
The subject detail screenw as still using older styling patterns and did not fully behave as the subject "hub".
## Redesign decision
The subject detail screen was redesigned as the main management hub for the specific subject.
It now includes:
- a subject summary card
- subject status and metadata
- subject-specific color styling
- subject actions (edit/delete)
- create assignment action
- assignment sections below the subject summary
### Why this matters
This screen now clearly answers:
- what this subject is
- how active/complete it is
- what assignments belong to it
- what actions can be taken at the subject level
This better reflects the product structure.
---
# 5. Assignment detail screen redesign
### Original issue
Assignments were previously styled more generically and did not always preserve clear subject context.
## Redesign decision
The assignment detail screen was redesigned to:
- function as the hub for one assignment
- show clear metadata
- show progress through child tasks
- expose only assignment-relevant actions
- preserve visual inheritance from the parent subject
### New structure
The assignment detail screen now includes:
- assignment summary card
- subject context pill
- deadline metadata
- progress section showing task completion
- task list organized by completion state
- create task action
### Why
Assignments are not independent from subjects. The redesign makes that relationship visible without making the screen visually noisy.
---
# 6. Task detail screen redesign
## Original issue
Tasks were at risk of losing hierarchical context because they are the deepest level in the model.
## Redesign decision
The task detail screen was redesigned to preserve parent context explicitly.
It now includes:
- task summary card
- subject context pill
- assignment context pill
- task state and metadata
- parent-aware styling
### Why
Tasks are the easiest place for context loss. By surfacing both subject and assignment, the user always knows where the task belongs.
---
# 7. Upsert-based form architecture
## Original issue
The app originally used separate create and edit screens for the same entities, even when both screens used almost identical form fields and validation.
This created:
- duplicated UI code
- repeated logic
- more route clutter
- higher maintenance cost
## Redesign decision
Create/edit flows were consolidated into upsert-style screens where appropriate.
### Implemented
- `upsertSubject`
- `upsertAssignment`
- `upsertTask`
### Pattern used
The form checks whether an ID exists:
- if no ID exists -> create mode
- if an ID exists -> edit mode
The same screen handles:
- initial default values
- loading existing data when editing
- submit behavior for insert vs update
### Why this is better
This reduces duplication while keeping the form styling and validation consistent.
### Tradeoff
This introduces a little more branching inside a single screen, but the tradeoff is worth it because create and edit flows are structurally very similar for these entities.
---
# 8. Shared utility extraction
## Original issue
Small but important logic was duplicated across screens.
## Redesign decision
Shared helpers were moved into reusable modules.
### Added shared utilities
- `lib/date.ts`
- `lib/subjectColors.ts`
## Date formatting utility
A shared date formatting module was introduced to standardize:
- date-only display
- date-time display
This replaced raw timestamp rendering such as ISO strings.
### Why
Raw database timestamps were ugly and difficult to read. Formatting them centrally improves both UI quality and consistency.
## Subject color utility
A shared subject color configuration was introduced to centralize:
- available subject colors
- subject color type
- mapping from logical color key to visual values
- helper function for retrieving the correct color set
### Why
This prevents duplicated color logic and ensures consistent us of subject-specific accent colors across screens.
---
# 9. Subject color system and inheritance
## Original issue
The app initially relied mainly on global app accent colors, which made it harder to preserve identity across nested subject content.
## Redesign decision
Subjects were given their own user-selectable accent color.
### Color choice
The user chooses from a controlled palette instead of arbitrary colors.
### Why a controlled palette
This preserves:
- aesthetic consistency
- readability
- predictable contrast
- low visual noise
## How color is used
The subject color is used as a contextual accent, not a replacement for the whole theme.
Used on:
- subject card border
- subject preview card
- subject pills
- inherited borders and indicators in assignment/task screens
- progress bars and completion indicators where appropriate
## Important design rule
The subject color was not used for everything.
Primary action buttons such as:
- create
- save
- login
remain on the **global app accent**
### Why
This preserves a consistent interaction language:
- app accent = primary action
- subject color = content identity / context
This separation was an intentional design decision.
---
# 10. Card-based UI redesign
## Original issue
Several screens still relied on older layout/styling conventions that felt less coherent and more cluttered.
## Redesign decision
The redesign shifted toward a more consistent card-based interface using:
- bordered surface cards
- semantic Tailwind theme classes
- more restrained spacing
- contextual pills
- reduced action clutter
### Card design goals
- easier scanning
- stronger visual hierarchy
- fewer floating controls
- more predictable composition
### Result
The app feels more structured and less noisy.
---
# 11. Metadata and pill system
## Original issue
Status and metadata were previously shown in less consistent ways, including redundant indicators.
## Redesign decision
Metadata display was standardized using pill elements for small contextual information.
Examples include:
- subject name
- assignment parent
- deadline
- active/inactive state
## Important cleanup decision
Some pills were removed when they became redundant.
Example:
- completed/in-progress pill was removed in places where the same information was already communicated by a checkbox or progress structure
### Why
This reduced duplication and visual clutter.
---
# 12. Progress display redesign
## Original issue
Progress indicators were previously placed too aggressively in list views where they created clutter.
## Redesign decision
Progress bars were kept only where they make structural sense:
- subject detail
- assignment detail
### Why
These are hub screens where progress is meaningful.
Progress bars were intentionally removed from places like subject list cards where they overloaded a browsing view.
### Additional improvement
Progress is now shown with both:
- a percentage bar
- an `x / y` completed count
- remaining item count text
This makes progress more understandable than a bar alone.
---
# 13. Authentication-related debugging insight
During development, a major debugging issue turned out not to be screen architecture at all, but session/auth failure.
This surfaced as:
- fetch failures
- apparent data loading errors
- misleading “network request failed” behavior
### Takeaway
Auth state and session expiry can easily masquerade as architecture or fetch bugs.
This reinforced the importance of:
- clearer auth handling
- not assuming every fetch failure is a UI/data issue
- checking session state early when debugging
---
# 14. Time handling and dual-boot issue insight
A separate development issue was discovered related to system time mismatch in a Windows/Linux dual-boot environment.
Although not an app architecture feature directly, it affected development by causing:
- failed requests
- misleading network/auth behavior
### Development takeaway
System time correctness matters for:
- authentication
- HTTPS
- tokens
- scheduled features
This was important context during debugging and implementation.
---
# 15. Notifications and reminders
Assignment creation/updating included work around deadline reminders.
### Behavior
When an assignment has a valid future deadline:
- a reminder can be scheduled
- previous reminders are updated/cancelled when necessary
- notification IDs are stored through async storage helpers
### Why this matters architecturally
This means assignment upsert behavior is not only CRUD. It also coordinates:
- persistence
- reminder scheduling
- reminder cleanup
This is relevant for the final report because it shows that form flows have side effects beyond database writes.
---
# 16. General design principles used across the redesign
## Principle 1: reflect the true data hierarchy
The UI should match the conceptual model:
- subjects contain assignments
- assignments contain tasks
## Principle 2: remove redundant top-level structure
Not every data model deserves a top-level tab.
## Principle 3: keep list screens for browsing
Heavy management actions should live in detail screens.
## Principle 4: preserve context
The deeper the user goes, the more important parent context becomes.
## Principle 5: use color as identity, not decoration
Subject colors provide contextual identity without overwhelming the UI.
## Principle 6: keep primary actions globally consistent
App accent remains the primary action language.
## Principle 7: reduce duplication
Reusable upsert screens and shared utilities reduce maintenance cost.
## Principle 8: avoid visual noise
Redundant pills, repeated indicators, and crowded cards were intentionally reduced.
---
# 17. Current architecture summary
## Tabs
- Dashboard
- Subjects
- Timer
## Hierarchy
- Subject
- Assignment
- Task
## Reusable utilities
- date formatting
- subject color mapping
- progress checks
- async storage notification helpers
## Form strategy
- upsert-style forms for core entities
## Design system
- NativeWind
- semantic Tailwind tokens
- shared card/pill patterns
- subject color inheritance
---
# 18. Final outcome
The redesign changed the app from a flatter CRUD-style structure into a more coherent hierarchical study workflow.
The main improvements were:
- better navigation structure
- reduced redundancy
- clearer subject/assignment/task relationships
- stronger contextual design
- less cluttered list screens
- reusable form patterns
- centralized shared helpers
- more polished and consistent UI
Overall, the app now better reflects its intended purpose as a study productivity tool organized around the academic structure of:
**Subject → Assignment → Task**
---
# 19. Possible items to mention later in the final report
These are likely worth discussing explicitly:
- why Tasks and Assignments were removed as top-level tabs
- why subject detail and assignment detail were turned into hubs
- why create/edit were merged into upsert patterns
- why a controlled color palette was used instead of arbitrary colors
- why subject color was used for context, not for primary actions
- why duplicated metadata indicators were removed
- why shared date formatting and subject color utilities were extracted
- how preserving hierarchy improved usability
- how debugging/auth issues affected development decisions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,230 @@
# Task Timer Integration and App Polish Work Report
## #Overview
Today the timer work moved from being a standalone tab experiment into the actual task workflow.
The main commit used for this summary is:
```text
c74062c Implemented timer into task details, uploaded example images for app and centred headers on all screens
```
The work focused on connecting the sprint timer to individual tasks, preserving an active sprint locally, cleaning up routing, and polishing the surrounding app presentation. The timer is no longer exposed as a top-level tab. It now belongs to the task details flow where a sprint naturally starts from a selected task.
---
## #ImplementedFeatures
### #TimerRouteIntegration
Moved the timer route from the tab navigator into the task stack:
- removed the old timer tab from `app/(tabs)/_layout.tsx`
- added `timer` as a screen in `app/task/_layout.tsx`
- moved the timer implementation from `app/(tabs)/timer.tsx` to `app/task/timer.tsx`
- added a `Start Sprint` action from the task details screen
- passed the selected task id into the timer route using `tId`
This makes the timer part of the task workflow instead of a separate global screen.
---
### #TaskAwareTimer
Updated the timer screen so it can load and display the selected task:
- reads `tId` from route params
- fetches the matching task from Supabase
- shows the task title and description during the sprint
- falls back to generic sprint text if task data is missing
This replaces the earlier placeholder-task model and makes the sprint screen reflect the actual task being worked on.
---
### #ActiveSprintPersistence
Added local persistence for the current sprint in `lib/asyncStorage.ts`:
- added an `ActiveSprint` type
- added `SaveActiveSprint(...)`
- added `GetActiveSprint()`
- added `RemoveActiveSprint()`
- stores the active task id, sprint duration, and calculated end time
The timer now saves the sprint end time when a session starts. When the timer screen is reopened for the same task, it can restore the remaining sprint time instead of treating the session as gone.
---
### #TimeBasedCountdown
Changed the countdown ownership toward wall-clock time:
- calculates `endTime` when the sprint starts
- updates remaining time from `Date.now()`
- restores progress from elapsed time when an active sprint is found
- removes the active sprint when it expires or is cancelled
This is a step toward making the timer more robust when the app is backgrounded or the timer screen is reopened.
---
### #HoldCancelOverlayWork
Continued work on the red hold-to-cancel timer overlay:
- kept `timerAnimation` as the main timer-progress value
- kept `cancelOverlayAnimation` as the temporary hold-preview offset
- added measured overlay height handling through `containerHeight`
- added an offscreen reset position for the red overlay
- added `timerOverlayVisible` so the red overlay can be hidden immediately after manual cancel fires
The final direction was to stop relying only on moving the red overlay offscreen. The cancel path now also hides the overlay by opacity before the rest of the return animations run.
---
### #HeaderAlignmentPolish
Centered navigation titles across the main app screens:
- dashboard
- subjects
- subject create/edit
- subject details
- assignment create/edit
- assignment details
- task create/edit
- task details
- sprint timer
This was a small visual consistency pass, but it makes the app feel less uneven between screens.
---
### #ImageAssetUpdate
Updated the app image assets:
- replaced the main icon and splash image files under `assets/images/`
- moved `master.png` into `assets/images/`
- removed the older `assets/study-sprint-image-pack/` copies
This keeps the active image assets in the folder Expo expects instead of keeping a separate image-pack folder around.
---
## #ProblemsAndSetbacks
### #CancelOverlayBug
The main setback today was the red hold-to-cancel overlay still being visible after manual cancel.
Several possible causes were investigated:
- the overlay using `Dimensions.get('window')` instead of the measured container height
- the overlay being clamped to the screen height
- the overlay view being stretched by `StyleSheet.absoluteFillObject`
- the overlay not being moved far enough below the screen
The first fixes improved the logic but did not remove the visible red bar in runtime feedback. The latest approach adds explicit overlay visibility state so the red timer layer can be hidden directly when cancel completes.
---
### #ManualCancelVsNaturalFinish
Another important clarification was that the problematic path was manual cancel, not the natural "timer reached zero" flow.
The cancel path has extra moving parts:
- hold delay
- hold completion timeout
- `cancelOverlayAnimation`
- haptic warning
- return animations
That made the bug harder to reason about than the normal finish path.
---
### #RuntimeConfidence
Static checks passed, but the final visual fix still needs manual runtime confirmation on the device/emulator.
The timer animation issue is visual and interaction-dependent, so TypeScript and lint can confirm that the code is valid, but they cannot prove that the red overlay is gone in the actual UI.
---
## #CurrentState
The current unpushed commit has the timer integrated into the task flow.
The app now supports:
- starting a sprint from task details
- opening the timer as `/task/timer`
- loading the selected task into the sprint screen
- saving active sprint state locally
- restoring active sprint progress from stored end time
- cancelling and clearing active sprint state
- centered headers across the main screens
- updated Expo image assets
The remaining runtime risk is the red hold-to-cancel overlay. The newest implementation hides the overlay with explicit `timerOverlayVisible` state after manual cancel, but this still needs to be verified by pressing through the cancel flow in the app.
---
## #Verification
Static checks were run after the timer changes:
```text
npx tsc --noEmit
exited successfully
```
```text
npm run lint
exited successfully with one existing warning
```
The lint warning is unrelated to today's timer work:
```text
app/(tabs)/subjects.tsx
React Hook useCallback has a missing dependency: 'GetSubjects'
```
The summary above is based on the unpushed commit diff from:
```text
origin/timerTask..HEAD
```
---
## #FilesChanged
Main timer and task-flow files:
```text
app/task/timer.tsx
app/task/_layout.tsx
app/task/viewDetailsTask.tsx
lib/asyncStorage.ts
```
Navigation polish files:
```text
app/(tabs)/_layout.tsx
app/(tabs)/index.tsx
app/(tabs)/subjects.tsx
app/assignment/upsertAssignment.tsx
app/assignment/viewDetailsAssignment.tsx
app/subject/upsertSubject.tsx
app/subject/viewDetailsSubject.tsx
app/task/upsertTask.tsx
```
Image asset files:
```text
assets/images/
assets/study-sprint-image-pack/
```
New note added:
```text
notes/work-report-timer-2026-05-01.md
```
---
## #Conclusion
Today's work moved the timer from an isolated feature into the real task workflow.
The biggest progress was routing and ownership: a sprint now starts from a task, carries that task id into the timer, displays task details during the sprint, and stores active sprint state locally. The surrounding app also received a small consistency pass through centered headers and updated image assets.
The main setback was the manual hold-to-cancel red overlay bug. The implementation has gone through several attempts, and the current version now hides the overlay directly after cancel instead of relying only on moving it out of view. The next step is to verify that final visual behavior live in the app.

View File

@@ -0,0 +1,282 @@
# Timer Session Tracking and Dashboard Integration Work Report
## #Overview
Today the timer work moved beyond local in-memory behavior and into a more durable sprint-session model.
The main direction was to make sprint time count toward task progress in a safer way, while also surfacing that progress in the app UI. This meant extending the timer flow with database-backed sprint sessions, making task time visible on the task details screen, and continuing the dashboard integration so active or upcoming work is easier to reach.
The work stayed focused on the timer/task/dashboard path rather than broad app refactoring.
---
## #ImplementedFeatures
### #SprintSessionPersistence
Moved the timer session model toward a more robust database-backed structure:
- created a `sprint_sessions` table in Supabase
- added a `sessionId` field to the local `ActiveSprint` type in `lib/asyncStorage.ts`
- updated the timer start flow to create a sprint session in the database before entering the running timer state
- kept local `active_sprint` storage as the resume handle, but now tied it to a real database session instead of only a task id and end time
This changes the active sprint from being only a local timer state into a recordable session that can later be finalized safely.
---
### #TaskTimeTracking
Added task-level study time tracking:
- added `totalTimeInSeconds` to the task model in `lib/types.ts`
- verified that cancelling a sprint updates both `sprint_sessions` and the task total in the database
- verified that expired sessions also finalize correctly and contribute time as expected
This gives each task a running total of time spent, rather than leaving the timer as a standalone UI action with no durable result on the task itself.
---
### #FinalizeFlowRepair
Adjusted the timer finalize flow so session teardown and restore logic stop fighting each other:
- added a `finalizeSprintSession(...)` path in `app/task/timer.tsx`
- updated natural finish, cancel, and expired restore paths to use the database finalize flow
- removed the local active sprint before the finalize RPC completes so the restore effect does not immediately re-open a just-cancelled timer
- added alerts for sprint-session creation/finalization failures instead of silently leaving the screen in a half-running state
This fixed the case where cancelling the timer appeared to work visually, but then the sprint popped back open because restore logic still saw a locally active session.
---
### #TimerStartGuarding
Tightened the sprint-start path in the timer screen:
- delayed `setIsRunning(true)` until after the `start_sprint_session` RPC succeeds
- added handling for the returned session id before local sprint state is saved
- added fallback handling for session id shape differences in the RPC response
Before this, the timer UI could enter a partial running state if the database session failed to start, which made the header change without actually starting the timer animation flow.
---
### #TaskDetailsTimeDisplay
Made the recorded task time visible in the task details screen:
- added a local formatter for tracked time in `app/task/viewDetailsTask.tsx`
- displayed `Time spent: ...` under the existing metadata block on the task details screen
This is the first direct UI confirmation that the timer is affecting persistent task data rather than only changing temporary timer state.
---
### #DashboardSprintVisibility
Extended the dashboard so it reflects timer/task state more clearly:
- added dashboard support for reading and displaying the current active sprint from local storage
- showed the active sprint task title, description, and remaining time
- added an `Open Sprint` action that links directly back into the running timer
This gives the user a global way to get back to an already running sprint after navigating away from the timer screen.
---
### #UpcomingDeadlineCards
Added a deadline-based task section to the dashboard:
- added a `Tasks with upcoming deadlines` section to the dashboard state
- fetched active tasks together with their assignment and subject context
- sorted the tasks by assignment deadline in ascending order
- rendered clickable cards that open the relevant task details screen
- updated the metadata line at the bottom of each card to show subject, assignment, and deadline
This makes the dashboard more useful as a next-action screen instead of only a placeholder when no sprint is running.
---
### #DashboardTaskCompletion
Extended the dashboard task cards so they can directly affect task progress:
- added a `Mark as completed` action to each upcoming task card
- updated the action to write `isCompleted = true` back to the matching task row
- reused `CheckAssignmentCompletion(...)` so assignment completion status stays in sync with task completion
- removed the completed task from the dashboard list immediately after a successful update
- added a confirmation alert before the completion update runs
This gives the dashboard a lightweight task-management action instead of making it read-only.
---
### #DashboardAndSubjectsHelpFlow
Added a small help/info entry point to explain the app structure more clearly:
- added a help button in the dashboard header
- added the same help/info pattern to the subjects screen
- opened a compact modal that explains the app flow as `Subject -> Assignment -> Task -> Sprint`
- added a clear summary block and a closing action inside the modal
This gives new users a direct explanation of how the app model fits together without leaving the current screen.
---
### #HeaderAndStylingPolish
Continued local UI cleanup around the dashboard and subjects screens:
- aligned the header actions so help and logout use the same general layout pattern on both screens
- converted the dashboard screen from local `StyleSheet` usage to NativeWind/Tailwind class-based styling
- converted the subjects help/modal block away from `styles.*` references and into NativeWind classes
- kept the visual structure local to the affected screens rather than introducing shared abstractions
This keeps the dashboard and subjects screens stylistically closer to each other while staying within the current app structure.
---
### #ActiveSprintDashboardFix
Fixed a rendering bug in the dashboard state logic:
- the `Tasks with upcoming deadlines` section had been placed inside the `no active sprint` branch
- when a sprint was active, the upcoming task section disappeared entirely
- moved the upcoming task section out of that conditional so both the active sprint card and upcoming tasks render together
This keeps the dashboard useful while a sprint is already running instead of hiding the rest of the user's near-term work.
---
## #ProblemsAndSetbacks
### #QuotedColumnNames
The first major issue today came from the new sprint-session SQL functions using unquoted camelCase column names.
The database columns used names such as:
- `sessionId`
- `taskId`
- `startedAt`
- `elapsedSeconds`
Without quotes, Postgres treated these as lowercase names like `sessionid` and `taskid`, which caused RPC failures when starting or finalizing sprint sessions.
This had to be corrected in the SQL functions before the app-side timer integration could work.
---
### #RowLevelSecurity
The next blocker was row-level security on `sprint_sessions`.
Even after the SQL functions matched the correct columns, session creation still failed until the insert/select/update permissions allowed authenticated users to work with their own sprint-session rows.
This was a necessary database-layer fix before the new robust timer flow could be tested end to end.
---
### #CancelRestoreRace
Another significant bug showed up after the new finalize flow was wired in:
- the cancel animation ran
- the timer visually closed
- then the sprint reopened immediately
The cause was that the restore effect still found `active_sprint` in local storage while the cancel/finalize path was still finishing. Removing the local active sprint earlier in the finalize path fixed that race.
---
### #DashboardListInterpretation
There was also a dashboard-listing issue where the upcoming-deadlines section could appear to only show tasks from one subject.
The actual cause was not the subject join itself, but the fact that the list had been truncated after sorting. That made the section biased toward whichever subject owned the earliest deadlines in the current data.
---
### #UpcomingTasksVisibilityBug
Another dashboard bug appeared after the active sprint card had been added.
The issue was not in the Supabase query itself. The problem was that the upcoming-deadlines section was rendered only in the `no active sprint` branch of the dashboard conditional.
This meant:
- the active sprint card appeared correctly
- the upcoming task data was still loaded
- but the list was hidden whenever a sprint existed
The fix was to move the upcoming task section outside that conditional so the dashboard can show both at once.
---
## #CurrentState
The timer/task flow now goes further than yesterday's integration work.
The app now supports:
- creating a real sprint session in the database when a timer starts
- finalizing sprint sessions on cancel and expiry
- adding tracked session time into `tasks.totalTimeInSeconds`
- showing tracked task time on the task details screen
- reopening the active sprint from the dashboard
- showing upcoming deadline task cards even while a sprint is active
- marking upcoming dashboard tasks as completed with a confirmation step
- opening a help/info modal from both the dashboard and subjects headers
- using NativeWind/Tailwind styling for the dashboard screen and the subjects help modal block
At this point, the timer is no longer only integrated into the task route. It is now also contributing durable progress data back into the task model and exposing more of that state in surrounding screens.
---
## #Verification
During today's work, the following behaviors were verified manually through the app plus database inspection:
- sprint creation now succeeds after fixing quoted column names and RLS
- cancelling a sprint updates both `sprint_sessions` and `tasks.totalTimeInSeconds`
- expired sprint finalization also updates the database as expected
- the cancel flow no longer reopens the timer immediately after the close animation
Static checks were also run during the implementation work:
```text
npx tsc --noEmit
exited successfully
```
```text
npm run lint -- app/task/timer.tsx
exited successfully
```
```text
npm run lint -- app/task/viewDetailsTask.tsx
exited successfully
```
```text
npm run lint -- app/(tabs)/index.tsx
exited successfully
```
```text
npx eslint app/(tabs)/index.tsx
exited successfully
```
```text
npx eslint app/(tabs)/subjects.tsx
completed with one existing warning
```
The later dashboard/subjects polish work was verified with local static checks and code inspection. The summary above is based on today's working-tree changes plus the live runtime/database checks done while fixing the timer session flow.
---
## #FilesChanged
Main timer/session files:
```text
app/task/timer.tsx
lib/asyncStorage.ts
lib/types.ts
```
Task details and dashboard files:
```text
app/task/viewDetailsTask.tsx
app/(tabs)/index.tsx
app/(tabs)/subjects.tsx
lib/progress.ts
```
New note added:
```text
notes/work-report-timer-2026-05-02.md
```
---
## #Conclusion
Today's work turned the timer into something closer to a real task-tracking feature instead of only a screen-local countdown.
The biggest progress was introducing sprint sessions as database-backed records, finalizing them into tracked task time, and then surfacing that state back into the app through task details and the dashboard. The later dashboard follow-up made that surrounding UI more useful by keeping upcoming tasks visible during active sprints, allowing quick task completion from the dashboard itself, and adding a lightweight in-app explanation of the subject/assignment/task/sprint model.

View File

@@ -0,0 +1,392 @@
# Focus, Dashboard, And Progress Model Work Report
## #Overview
Today the timer work moved from a sprint-only model toward a more general session flow that can support both focused work and breaks.
The main goal was to start closing the vision gap around `focus -> break -> continue`, while keeping the implementation local to the existing timer route instead of introducing a larger navigation or state-management rewrite.
The work therefore covered both app-side session-model changes and the Supabase function updates needed to make the new flow actually start and finalize sessions correctly.
Later in the same work session, the scope also expanded into the dashboard and the progress presentation on the detail screens so the app better matches the remaining vision-gap plan.
The scope then expanded one step further into first-time-user friction, so the work also covered a guided onboarding path and clearer empty states for new accounts.
Later still, the work expanded beyond the app itself into the signup-confirmation path around account creation. That included auth-screen behavior fixes, a shorter guided-setup timer for quick verification, a minimal confirmation landing page for VPS deployment, Caddy routing, and a less boilerplate-looking confirmation email template.
---
## #ImplementedFeatures
### #GeneralSessionModel
Changed the local timer model from a sprint-specific structure into a more general session structure:
- added `SessionType` in `lib/types.ts`
- introduced the session types:
- `focus`
- `short_break`
- `long_break`
- replaced the old `ActiveSprint` shape with `ActiveSession` in `lib/asyncStorage.ts`
- stored `sessionType` together with `sessionId`, `taskId`, `durationSeconds`, and `endTime`
This means the active timer is no longer assumed to always be a task-linked focus sprint.
---
### #TimerSessionStartAndRestore
Updated the timer screen so it can start and restore different session types:
- replaced sprint-specific storage calls with `GetActiveSession(...)`, `SaveActiveSession(...)`, and `RemoveActiveSession(...)`
- generalized the timer start path into `startSession(...)`
- passed `p_session_type` into the Supabase `start_sprint_session(...)` RPC
- kept task linkage only for `focus` sessions
- updated the restore logic so a focus session restores by `tId`, while break sessions restore by `sessionType`
This gives the existing timer screen enough information to behave differently for focus sessions and break sessions without creating a second timer screen.
---
### #DashboardAndTaskIntegration
Updated the surrounding screens so they understand the new active-session shape:
- updated `app/task/viewDetailsTask.tsx` to read the new active session model
- updated `app/(tabs)/index.tsx` so the dashboard card can describe either a focus session or a break session
- made the dashboard open the timer with either a task id or a break-session configuration, depending on what is active
This keeps the rest of the app aligned with the timer change, instead of leaving the new session model isolated to one file.
---
### #DashboardProgressAndHistory
Extended the dashboard so it works more clearly as a study-activity overview:
- added a compact `Study progress` summary near the top of the dashboard
- showed:
- `Focus sessions today`
- `Minutes today`
- `Minutes this week`
- loaded the summary from `sprint_sessions` instead of from planning data
- added a `Recent sessions` section showing:
- task title when available
- session type
- duration
- final status
- date and time
- added a small `Recently completed tasks` section based on recent task completion updates
This moved the dashboard closer to the vision requirement that progress should reflect actual study behavior rather than only task structure.
---
### #DashboardLayoutRestructure
Reworked the order of the dashboard sections so the screen reads more clearly as a home surface:
- kept the active-session card at the top when relevant
- placed `Study progress` before the task lists
- moved `Tasks with upcoming deadlines` directly under the progress summary
- pushed `Recent sessions` and `Recently completed tasks` lower as secondary context
- made the lower history area work as a side-by-side layout when screen width allows it
- changed the dashboard body to a scrollable layout so the extra sections still fit without clipping
The result is a dashboard that moves from orientation, to next action, to history instead of feeling like a stacked report page.
---
### #ConsistentProgressModel
Aligned the progress language across the detail screens so each layer measures one clear thing:
- on the subject details screen, changed the progress label from `Assignment Progress` to `Assignments completed`
- added helper text clarifying that subject progress is based only on completed assignments
- on the assignment details screen, changed the progress label from `Task Progress` to `Tasks completed`
- added helper text clarifying that assignment progress is based only on completed tasks
- on the task details screen, separated completion state from study activity
- added a dedicated `Study activity` block showing:
- tracked focus time from `tasks.totalTimeInSeconds`
- completed focus-session count from `sprint_sessions`
- added an explicit task status label so completion state is not confused with study effort
This made the meaning of progress more consistent:
- `Subject` now reads as assignment completion
- `Assignment` now reads as task completion
- `Task` now reads as study effort plus completion state
- `Dashboard` now reads as recent study activity
---
### #FirstTimeSetupAndEmptyStates
Added the first guided setup flow so new users are pushed into one clear study path instead of landing in an empty app:
- added a dedicated `app/setup.tsx` route for first-time setup
- changed signup so a newly authenticated user is routed to setup instead of directly to the dashboard
- built the setup flow as a strict sequence:
- create first subject
- create first assignment
- create first task
- start first sprint
- updated the subject, assignment, and task creation screens so they can advance automatically to the next setup step
- removed the setup-breaking success popups between those guided creation steps
- added short auth-screen explanations describing:
- what the app does
- why an account exists
- that study structure and progress follow the user
- added clearer empty states on the dashboard and subjects screen that point the user into guided setup
- tightened the empty-state copy on subject and assignment details so each one points toward the next required object in the hierarchy
This closes a large part of the first-run friction gap without introducing a separate onboarding system or broader navigation rewrite.
---
### #AuthScreenKeyboardHandling
Adjusted the auth screens so text inputs do not stay buried behind the on-screen keyboard:
- updated `app/login.tsx` so the login content scrolls and shifts upward when the keyboard opens
- updated `app/createUser.tsx` so the entire create-account content block lifts upward with the keyboard instead of only trying to scroll one input into view
- kept the changes local to the auth screens instead of introducing a broader shared keyboard abstraction
This was aimed specifically at the real usability problem where the password field could end up hidden during login or signup.
---
### #SignupNavigationAndHeaderAlignment
Adjusted the signup screen navigation so it matches the rest of the app more closely:
- removed the temporary in-screen back button experiment from the signup page
- re-enabled the normal stack header for `createUser` in `app/_layout.tsx`
- kept signup navigation on the default app-style back arrow instead of a one-off local control
This kept the auth flow visually more consistent with the rest of the route stack.
---
### #GuidedSetupFiveSecondSprint
Changed guided setup so the first sprint can be tested almost immediately:
- updated `app/setup.tsx` so the setup flow opens the timer with a fixed `5` second duration
- extended `app/task/timer.tsx` so it can also accept an explicit `durationSeconds` route param
- kept the rest of the timer behavior unchanged, so the setup-specific shortcut still runs through the same session start, storage, and completion flow as normal timers
This made the first-run path quicker to test without changing the broader timer model back to a special-case setup implementation.
---
### #SignupConfirmationDeployment
Built the first deployable confirmation landing page outside the Expo app:
- added `deploy/signup-confirmation/site/index.html` as a minimal static confirmation page
- added `deploy/signup-confirmation/docker-compose.yml` so the page can be served with `nginx:alpine`
- added a small README for VPS deployment notes and port mapping
- verified the page deployment path together with the external VPS/domain setup already in use
This created a concrete destination URL for signup confirmation emails instead of leaving the email to resolve into a blank or undefined endpoint.
---
### #CaddyAndEmailConfirmationPolish
Finished the external confirmation flow around signup:
- corrected the Caddy reverse-proxy target from container port `8080` to `80` for the `nginx` confirmation container
- confirmed that the confirmation page then resolved correctly behind the existing Caddy-plus-Docker setup
- replaced the original bare confirmation email body with a cleaner branded HTML email using the existing `{{ .ConfirmationURL }}` placeholder
This moved the signup confirmation flow from a functional but rough setup into something that is both deployable and presentable.
---
### #PostSessionBreakFlow
Added the first real post-session flow in the timer UI:
- after a completed focus session, the timer now shows:
- `Start short break`
- `Skip break`
- starting the break reopens the same timer route in `short_break` mode
- after a completed short break, the timer now shows:
- `Continue with same task`
- `Back to dashboard`
- passed `returnTaskId` through the route so the timer can return the user to the original task after the break
This is the first implementation of an actual study loop rather than a timer that simply ends and disappears.
---
### #BreakTimerPresentation
Adjusted the timer UI so break sessions read more clearly:
- added a fixed-duration block for break sessions instead of showing the normal duration picker
- used a fixed 5-minute short-break duration for the first implementation
- kept the focus-session picker unchanged
- made the break start button match the existing `Start Sprint` button styling, but show only `Start`
- removed the bug where picker or pre-start break elements remained visible on top of the running break session
This keeps the first break flow minimal and visually consistent with the existing timer screen.
---
### #SupabaseFunctionAlignment
Adjusted the Supabase side so the new app flow could actually run:
- updated `start_sprint_session(...)` to accept `p_session_type`
- allowed break sessions to start with `taskId = null`
- aligned the SQL with the real table schema using:
- `sessionId`
- `taskId`
- `userId`
- `sessionType`
- `countedIntoTaskTotal`
- corrected function-return behavior so the app receives the created session id in the shape it expects
- kept finalize logic so only `focus` sessions contribute to `tasks.totalTimeInSeconds`
Without this database alignment, the app-side session model would compile but still fail when starting real sessions.
---
## #ProblemsAndSetbacks
### #SchemaMismatch
The main blocker today was that the first SQL version assumed table columns that did not exist in the real Supabase schema.
The actual `sprint_sessions` table already contained:
- `sessionId`
- `taskId`
- `userId`
- `plannedDuration`
- `startedAt`
- `endedAt`
- `elapsedSeconds`
- `status`
- `countedIntoTaskTotal`
- `sessionType`
But it did not contain `createdAt` or `updatedAt`, so the first function version failed at runtime.
---
### #FunctionReturnShape
Another blocker was the shape of the return value from `start_sprint_session(...)`.
Even after the insert worked, the app still showed:
- `Session could not be created.`
The issue was not the insert itself, but that the returned value shape did not match what `getSessionId(...)` was looking for on the app side.
This had to be corrected so the RPC returned the created session id in a directly readable object shape.
---
### #PauseUIScreenOverlap
The first version of the break UI had presentation bugs:
- the pause start button text looked cramped and awkward
- pre-start pause UI stayed visible after the break actually started
- picker or fixed-duration elements overlapped the running break session
This was corrected by hiding pre-start break UI while the timer is running and by reverting the pause start button back to the same visual model as the existing sprint start button.
---
### #ConfirmationRoutePortMismatch
The external signup-confirmation deployment initially failed behind Caddy with `HTTP ERROR 502`.
The actual issue was not the Docker network arrangement itself, but that the reverse proxy was targeting `signup-confirmation:8080` even though the `nginx` container listens internally on port `80`.
Changing the upstream target to the real container port fixed the route.
---
## #CurrentState
The timer flow now goes further than the previous sprint-only model.
The app now supports:
- starting a `focus` session tied to a task
- starting a `short_break` session with no task linkage
- storing and restoring the active session with its type
- showing a post-focus decision between taking a break or skipping it
- returning from a completed short break into the same task flow or back to the dashboard
- keeping break sessions out of task time totals
At this point, the app has the first working version of the focus-and-break loop described in the vision plan, even though the cycle logic and long-break offer are not implemented yet.
The dashboard also now gives a clearer answer to:
- `What have I done today?`
- `What should I work on next?`
And the detail screens now separate planning completion from study activity more explicitly, which makes the app easier to read without having to infer what each progress bar means.
For a brand-new user, the app also no longer drops straight into a generic empty state after account creation. There is now a clearer route from signup to:
- first subject
- first assignment
- first task
- first sprint
That makes the hierarchy feel more guided and less like a blank structure the user has to interpret alone.
The signup path also now has a more complete confirmation loop around it:
- the auth screens behave more safely when the mobile keyboard opens
- guided setup can launch a very short first sprint for fast verification
- the confirmation email can point to a real public landing page
- that landing page has a working Docker/Caddy deployment path on the VPS
- the email itself no longer looks like a raw boilerplate template
---
## #Verification
During today's work, the following behaviors were verified through implementation checks and runtime iteration:
- the new session model compiles across timer, dashboard, task details, and local storage
- `start_sprint_session(...)` now succeeds after the Supabase function updates
- the timer can start using the new session-based flow
- break sessions no longer leave the picker or fixed-duration setup visible on top of the running timer
- the dashboard compiles with the new progress-summary, recent-session, and recent-completion sections
- the task details screen compiles with a new `sprint_sessions`-based completed-session count
- the subject and assignment detail screens now label completion metrics more explicitly
- the new guided setup route compiles and links correctly with the subject, assignment, and task creation flow
- the login and signup screens compile after the keyboard-handling adjustments
- the guided setup route now opens the timer with an explicit 5-second fixed duration
- the deployable signup-confirmation page was brought up behind the VPS Caddy setup after correcting the upstream container port from `8080` to `80`
- the confirmation email template was updated to a cleaner HTML version while keeping `{{ .ConfirmationURL }}` as the actual confirmation link placeholder
Static verification also passed:
```text
npx tsc --noEmit
exited successfully
npm run lint
exited with existing warning only in:
- app/task/timer.tsx
```
I did not run a live interactive app test for the later dashboard and progress-model changes. That part of the verification is static rather than runtime-confirmed.
---
## #FilesChanged
Main app files worked on:
```text
app/task/timer.tsx
app/task/viewDetailsTask.tsx
app/(tabs)/index.tsx
app/(tabs)/subjects.tsx
app/setup.tsx
app/subject/viewDetailsSubject.tsx
app/subject/upsertSubject.tsx
app/assignment/viewDetailsAssignment.tsx
app/assignment/upsertAssignment.tsx
app/task/upsertTask.tsx
app/createUser.tsx
app/login.tsx
app/_layout.tsx
lib/asyncStorage.ts
lib/types.ts
deploy/signup-confirmation/docker-compose.yml
deploy/signup-confirmation/site/index.html
deploy/signup-confirmation/README.md
```
New note added:
```text
notes/work-report-timer-2026-05-03.md
```
---
## #Conclusion
The main result today was not just a timer change, but a broader step toward closing the remaining vision gaps around study flow and progress clarity.
The app now has:
- a session model that can represent both focused work and breaks
- the first concrete `focus -> break -> continue` path from the vision plan
- a dashboard that reflects recent study effort more directly
- detail screens that use more explicit and consistent progress meanings
- a first guided onboarding path that leads a new user from signup to their first workable sprint path
- more usable auth screens when entering credentials on mobile
- a complete basic signup-confirmation flow that now reaches a real deployed landing page and a cleaner confirmation email
The remaining work in this area is now less about inventing the model from scratch and more about extending, polishing, and live-validating the pieces that are already in place.

View File

@@ -0,0 +1,196 @@
# Main Flow Tightening and Timer Duration Picker Work Report
## #Overview
Today's work focused on the next concrete step in the vision-gap plan after the already completed sections.
The main goal was to reduce friction in the path from choosing work to actually starting a focus session. That meant tightening the task-level and dashboard-level sprint actions, introducing a consistent default focus duration, and making the timer screen feel faster to enter without removing the older duration-picker path entirely.
Later in the same work session, the scope narrowed further into the timer screen itself because the reintroduced picker flow behaved incorrectly. That led to a smaller follow-up fix focused specifically on stabilizing the picker state and preventing the screen from resetting while the user scrolls.
The scope also expanded into the help-flow modal on the dashboard and subjects screens so its explanation of the app structure matches the way the app now actually works.
After that, one more timer-flow bug surfaced in the post-session overlay itself. A completed break could still reuse the focus-session action menu, which incorrectly offered the user another break instead of only the actions that make sense after a break has ended.
---
## #ImplementedFeatures
### #DefaultFocusDuration
Introduced a shared default session-duration source for the low-friction focus flow:
- added `lib/sessionDefaults.ts`
- defined:
- `DEFAULT_FOCUS_DURATION_MINUTES`
- `DEFAULT_SHORT_BREAK_DURATION_MINUTES`
- reused those constants across the timer, task details, and dashboard paths
This removed the need to hardcode the same default duration in multiple places and made the main sprint path more consistent.
---
### #TaskDetailsPrimarySprintAction
Updated the task details screen so `Start Sprint` is the strongest action on the page:
- moved `Start Sprint` out of the lower row of equal-weight controls
- made it the primary full-width action above `Edit` and `Delete`
- added small helper text clarifying that the action starts a `25` minute focus sprint
- updated the task-details start flow so it passes the default focus duration into the timer route
- tightened the active-session replacement alert text so it clearly states what will happen before the current session is replaced
This makes the task screen push the user more directly toward real study work instead of presenting sprint start as only one option among several management actions.
---
### #DashboardDirectSprintStart
Reduced dashboard-to-timer friction for upcoming tasks:
- added a `Start Sprint` action directly on the `Tasks with upcoming deadlines` cards
- made that action open the timer immediately with the shared default focus duration
- handled the three relevant states:
- no active session
- an expired stored session
- an already running different session that must be explicitly replaced
- renamed the active-session dashboard button from `Open Session` to:
- `Resume Sprint`
- `Resume Break`
This removed one unnecessary detour where the user had to open task details first before reaching the timer.
---
### #TimerDefaultDurationFlow
Changed the timer entry flow so focus sessions no longer force the user through duration selection before they can begin:
- changed the default focus-session setup to show a fixed default duration first
- kept break sessions on a fixed-duration path as before
- made the start action use the default focus duration immediately unless the user actively chooses a custom one
This better matches the low-friction part of the vision plan, where starting work should feel immediate rather than configuration-heavy.
---
### #CustomDurationReturnPath
Reintroduced the old duration-picker flow as an explicit optional side path instead of the default:
- added a `Choose a different duration` button on the pre-start focus timer screen
- reopened the old picker presentation only when the route enters an explicit picker mode
This keeps the faster default path while still preserving the older manual-duration interaction for users who want it, without adding a second reversal action inside the picker itself.
---
### #PostSessionActionClarity
Adjusted the timer completion overlay so the focus-session exit path is more explicit:
- after a completed focus session, the overlay now offers:
- `Start short break`
- `Continue same task`
- `Back to dashboard`
- updated the explanation text so the available next actions are described directly in the overlay copy
This makes the post-session decision path closer to the plan's requirement that break, continue, and dashboard-return actions should be simple and explicit.
---
### #TimerPickerGlitchFix
Fixed the first version of the restored duration picker after it showed unstable behavior:
- the picker numbers could initially appear blank until the list was scrolled
- the selected duration could snap back incorrectly when scrolling ended
- the cause was that the picker route was being rewritten while the user interacted with the list
- changed picker selection to use local component state instead of route replacement on every scroll stop
- added explicit initial offset restoration on picker open so the visible selection matches the current duration immediately
- kept the route change only for entering or leaving picker mode, not for every intermediate selection
This made the picker usable again without undoing the lower-friction default entry flow.
---
### #HelpFlowAlignment
Updated the help modal so it matches the current app structure more closely:
- kept the main hierarchy as:
- `Subject`
- `Assignment`
- `Task`
- `Sprint`
- updated the `Sprint` explanation so it now reflects the real post-session flow:
- take a break
- continue the same task
- return to the dashboard
- changed the supporting copy so it explains that the work path now leads into both sprints and breaks instead of only into one focused work session
- added quick-map text clarifying the dashboard's current role:
- resume active session
- start next sprint
- review recent progress
- changed the help CTA on the dashboard from `Start with Subjects` to `Open Subjects`
- changed the help CTA on the subjects screen from `Start with Subjects` to `Close Guide`
This keeps the help flow aligned with the app's actual current behavior instead of leaving it stuck on an older sprint-only interpretation.
---
### #PostBreakMenuFix
Fixed a timer completion bug where a finished break could still produce the same action menu as a finished focus session:
- the post-session overlay had to know which session type actually just ended
- the previous flow could fall back to local screen state instead of the persisted active session
- this caused a break completion to sometimes be treated like a focus completion
- changed the completion flow so it reads the stored active session before building the post-session prompt
- reused that same session snapshot when finalizing the session in Supabase
This means the overlay now behaves correctly after a break finishes:
- it does not offer `Start short break` again
- it instead keeps the narrower break-finished path:
- continue with the same task
- go back to the dashboard
---
## #ProblemsAndSetbacks
### #PickerStateReset
The main issue during this work happened after the older picker screen was reintroduced as an optional path.
The first implementation reopened the picker route correctly, but it also updated the route params again when scrolling stopped. In practice this caused two visible problems:
- the initial number presentation was unstable
- the selected value could reset unexpectedly after momentum ended
The fix was to keep picker selection local to `app/task/timer.tsx` while the picker is open, and only use route params to decide whether the picker mode should be shown in the first place.
### #PostSessionTypeMismatch
Another issue appeared after the post-session focus actions were introduced.
The completion overlay already had separate UI for `focus` and `break` sessions, but the value used to choose between them was not robust enough. In practice, that made it possible for a finished break to reopen the focus-style menu and incorrectly offer another break.
The fix was to derive the completed session type from the persisted active session that had actually been running, rather than relying only on the screen's local state at the moment the animation finished.
---
## #CurrentState
The timer/task/dashboard flow now does more to push the user into focused work with fewer unnecessary steps.
The app now supports:
- a shared default focus duration for the main sprint path
- a stronger `Start Sprint` action on the task details screen
- direct sprint start from dashboard upcoming-task cards
- clearer `Resume Sprint` and `Resume Break` wording on the dashboard
- a fixed default-duration entry state on the timer screen
- an optional custom-duration picker path instead of a forced picker
- explicit post-focus next actions for break, continue, or dashboard return
- a stable picker implementation that keeps its selected value while the user scrolls
- a corrected break-finished overlay that no longer offers another pause when the completed session was already a break
- a help-flow explanation that now matches the real sprint, break, dashboard, and subjects flow more closely
At this point, the timer flow is more aligned with the vision requirement that starting work should feel fast, focused, and low-friction rather than like a chain of setup steps.
---
## #Verification
Static checks were run after the implementation work and after the picker bug fix:
An additional static check was also run after the post-break menu fix:
```text
npx tsc --noEmit
exited successfully
npm run lint
exited successfully
npx tsc --noEmit
exited successfully
```

View File

@@ -0,0 +1,307 @@
# Session Reliability and Break-Cycle Work Report
## #Overview
Today's work continued from the updated vision-gap plan, with the focus narrowed to the remaining timer and session-state gaps rather than broader feature expansion.
The first goal was to finish the last missing part of the focus-and-break loop by making the app distinguish between a short break and a long break in a way that matches an actual study cycle instead of using total historical session count.
After that, the scope shifted into reliability work because the remaining highest-risk issue was not missing UI, but the possibility that local active-session state and recorded session history could drift apart. That led to a review of how the dashboard, setup flow, task details screen, and timer screen each handled expired, cancelled, or replaced sessions.
Later in the same work session, the focus narrowed again into wording and flow polish on the timer screen. The break and sprint descriptions were rewritten so they better reflect the app's goal of supporting structured study behavior, and two runtime regressions reported after testing were fixed in the timer flow itself.
After that, the work shifted into the remaining first-time-user gap from the vision plan. The login and tab flows were tightened so incomplete users are routed into guided setup automatically, and the first guided sprint was changed into a short onboarding demo instead of dropping a new user straight into a normal 25-minute timer.
The final pass of the day was smaller, but still tied to the same product goal. The help modals on the dashboard and subjects screens were rewritten so they explain the focus-session and break rhythm in a more human way instead of sounding like a rigid step list.
After that, one final navigation-polish pass was added on the tabs layout itself. The bottom tabs were given explicit icons so the app's primary navigation reads faster at a glance and feels less unfinished.
After the branch work was merged back into `main`, one more cleanup pass was needed. That merge left multiple screen files in a syntactically broken state, so the final work shifted away from feature work and into restoring the current main branch to a usable state before report-focused delivery work.
---
## #ImplementedFeatures
### #LongBreakCycleCompletion
Finished the missing long-break part of the focus-and-break loop:
- extended the shared session defaults in `lib/sessionDefaults.ts`
- added:
- `DEFAULT_LONG_BREAK_DURATION_MINUTES`
- `FOCUS_SESSIONS_PER_LONG_BREAK`
- `STUDY_CYCLE_IDLE_RESET_MINUTES`
- updated the timer flow so the next break is chosen from a local study-cycle model instead of total historical session count
The long-break rule now follows a small continuous-cycle interpretation of study flow rather than incorrectly counting unrelated sessions from earlier in the day or from previous days.
---
### #StudyCycleState
Added a small local study-cycle state to support the new break behavior:
- extended `lib/asyncStorage.ts`
- added `StudyCycle`
- added:
- `SaveStudyCycle`
- `GetStudyCycle`
- `RemoveStudyCycle`
- tracked:
- the task tied to the current cycle
- how many focus sessions have been completed in that cycle
- the last completed session type
- the last completion timestamp
This keeps the long-break offer tied to the current study run instead of to the user's entire database history.
---
### #DynamicBreakPrompt
Updated the timer completion overlay so post-focus actions are based on the actual cycle state:
- expanded the post-session prompt model to include the next break type
- replaced the hardcoded short-break action with a dynamic break action
- updated the overlay text and button label so the user now sees:
- `Start short break`
- or `Start long break`
depending on the current cycle
This completes the missing loop from the plan where break behavior should feel intentional rather than unfinished.
---
### #SessionLifecycleConsistency
Introduced a shared finalization path for active sessions:
- added `lib/sessionLifecycle.ts`
- introduced `finalizeStoredSession(...)`
- moved repeated session-finalization behavior into one place:
- remove local active-session state
- clear study-cycle state when the final status is not `completed`
- finalize the same `sessionId` in Supabase with:
- `completed`
- `cancelled`
- `expired`
This reduced the risk that one screen would only clear local storage while another screen properly finalized the database record.
---
### #CrossScreenReliabilityFixes
Applied the shared finalization path across the screens that handle active-session recovery or replacement:
- `app/task/timer.tsx`
- `app/(tabs)/index.tsx`
- `app/task/viewDetailsTask.tsx`
- `app/setup.tsx`
The updated paths now explicitly finalize sessions when they are:
- expired on reopen
- expired while being observed from the dashboard
- cancelled because the user replaces one active sprint with another
This closes a real reliability gap where the app could previously lose the active local session while leaving the recorded session in the database unfinalized.
---
### #TimerAndBreakCopyPolish
Rewrote the timer and break descriptions so they better match the product's intended tone:
- updated the pre-start sprint description
- updated the pre-start break description
- updated the focus fallback description on the running timer
- updated the post-focus and post-break explanation copy
The new wording emphasizes that structured focus and intentional breaks matter for studying, instead of sounding like placeholder or utility-only text.
---
### #ReportedRegressionFixes
Fixed two runtime issues discovered during manual testing after the earlier session-cycle changes:
- after cancelling a focus session, the sprint-duration view could appear visually blank until the user manually dragged the picker
- after completing a long break, `Continue with same task` could route the user back to the dashboard instead of returning to the correct task flow
The fixes were:
- reinitializing the picker-offset path when the timer returns to a non-running state
- preserving `returnTaskId` inside the stored active-session shape so break sessions keep the correct task context all the way through completion
---
### #OnboardingRoutingGuard
Closed the remaining onboarding-routing gap so incomplete users are pushed into guided setup instead of being left in the dashboard tabs:
- added `lib/setupStatus.ts`
- moved the shared setup-completion rule into one place
- updated:
- `app/login.tsx`
- `app/(tabs)/_layout.tsx`
- `app/(tabs)/index.tsx`
- `app/(tabs)/subjects.tsx`
- setup completion is now checked from the same source in login, tab entry, dashboard, and subjects
This made the setup flow enforceable instead of depending on the user noticing the guided-setup card in the dashboard.
---
### #FirstSprintDemoFlow
Adjusted the first guided sprint so the first-time experience better matches the low-friction vision goal:
- extended `lib/asyncStorage.ts`
- added:
- `GetSetupSprintDemoUsed`
- `SaveSetupSprintDemoUsed`
- updated `app/task/upsertTask.tsx` and `app/setup.tsx` so the first setup sprint uses:
- `durationSeconds: '5'`
- `onboardingDemo: 'true'`
- updated `app/task/timer.tsx` so that onboarding-demo sprint completion:
- skips the normal session-complete modal
- routes directly to the dashboard
This keeps the first sprint short enough to demonstrate the flow without locking a new user into a full focus block, while still falling back to the normal focus-session duration after the demo has been used once.
---
### #HelpModalFlowCopy
Updated the help modals on the dashboard and subjects screens so they explain the intended study rhythm more naturally:
- updated:
- `app/(tabs)/index.tsx`
- `app/(tabs)/subjects.tsx`
- rewrote the flow-step descriptions so they feel less mechanical
- added clearer wording about the actual intended loop:
- focus session
- short pause
- focus session again
- longer pause after a few rounds
This better matches the app's tone and makes the focus/break cycle easier to understand from inside the product itself.
---
### #TabBarIconPolish
Added explicit icons to the bottom-tab navigation so the two primary surfaces are easier to scan:
- updated `app/(tabs)/_layout.tsx`
- reused the existing `MaterialIcons` set already used elsewhere in the app
- assigned:
- `dashboard` to the dashboard tab
- `menu-book` to the subjects tab
This was a small UI polish pass, but it improves immediate navigation clarity and makes the tab bar feel more intentional instead of placeholder-like.
---
### #MainBranchMergeCleanup
Repaired merge-related breakage after switching back to `main`:
- fixed `app/(tabs)/subjects.tsx`
- fixed `app/subject/viewDetailsSubject.tsx`
- rebuilt `app/task/viewDetailsTask.tsx` into a consistent working version
The merge had left these files with duplicated blocks, broken hook structure, and invalid JSX. The cleanup work focused on restoring the intended screen behavior rather than changing product scope.
The result was:
- subjects screen logic restored to a valid loading/setup/render flow
- subject details screen header and progress area reconstructed
- task details screen restored with working context, study activity, and sprint-start actions
This was not new feature work, but it was necessary delivery work because the main branch was no longer in a reliable edit/test state.
---
## #ProblemsAndSetbacks
### #SessionTruthDivergence
The main reliability issue uncovered today was not in the timer animation itself, but in how different screens treated expired or replaced sessions.
Several screens could detect that a stored session was no longer valid, but some of them only removed the local active-session entry instead of also finalizing the matching `sprint_sessions` row in Supabase. That created a risk where the UI and the database could tell different stories about the same session.
The fix was to stop duplicating that logic screen by screen and route those paths through a shared finalization helper instead.
### #PostChangeRuntimeRegressions
After the cycle and reliability changes landed, manual testing surfaced two smaller regressions in the timer screen:
- the duration screen could look empty after cancelling a focus session
- the break-return flow lost its task target after a long break
These were not architectural problems, but they were both important because they affected the user's immediate understanding of the timer flow after interacting with it.
### #OnboardingFlowMismatch
Manual testing later uncovered a smaller flow mismatch inside guided setup:
- the first task created in setup could still open the timer with the normal 25-minute focus default
- returning to the guided-setup screen afterwards could then launch a different 5-second demo path
The problem was that task creation in setup and the setup screen itself were using two different timer-entry paths. The fix was to make those paths share the same one-time onboarding-demo rule.
### #PostMergeCodeBreakage
The final setback of the day came after the branch merge itself rather than from the timer/session work.
Several files on `main` were left in a partially merged state with duplicated code fragments and broken JSX structure. That meant the next pass could not start from feature verification alone, because basic app screens were no longer parseable.
The practical fix was to repair those files directly first, then re-run targeted verification on the restored app files before deciding whether any real feature regressions were still present.
---
## #CurrentState
The timer and session model are now closer to the final intended behavior in the vision-gap plan.
The app now supports:
- a simple long-break rule tied to the current study cycle
- a local cycle model that avoids counting unrelated older sessions
- a post-focus overlay that correctly offers either a short break or a long break
- a shared session-finalization path used across timer, dashboard, setup, and task-details flows
- better consistency between active local session state and recorded session history
- more intentional sprint and break wording on the timer screen
- preserved task-return context across long-break completion
- corrected timer-screen recovery after cancelling a focus session
- automatic routing into guided setup for incomplete users after login and tab entry
- a one-time onboarding sprint demo that uses a 5-second timer
- direct dashboard routing after the onboarding demo completes, without the normal completion modal
- help modals that explain the study loop in a more natural way
- explicit tab icons that make dashboard and subjects easier to distinguish at a glance
- repaired `main`-branch versions of the subjects, subject-details, and task-details screens after merge corruption
At this point, the timer/session work is closer to a finished loop, and the first-time-user path is more in line with the intended product vision. The biggest remaining work is now less about feature gaps and more about making sure the final report and final app behavior stay aligned.
---
## #Verification
Static checks were run after the main implementation work and again after the regression fixes:
```text
npx tsc --noEmit
exited successfully
npm run lint
exited successfully
npx tsc --noEmit
exited successfully
npm run lint
exited successfully
npx tsc --noEmit
exited successfully
npm run lint
exited successfully
npx tsc --noEmit
exited successfully
npm run lint
exited successfully
```
Manual testing also confirmed most of the intended behavior from today's scope. Two regressions were found during that testing, both inside the timer flow, and both were fixed in the same work session:
- blank sprint-duration state after cancelling a focus session
- incorrect dashboard return after pressing `Continue with same task` following a long break
Later manual testing also validated the guided-setup flow after the onboarding fixes:
- incomplete users were routed into guided setup instead of landing in dashboard tabs
- the first setup sprint used the intended 5-second demo timer
- after the demo finished, the user was sent directly to the dashboard without seeing the normal session-complete modal
The final UI pass for the day was lighter and did not change behavior, but the resulting tabs-layout diff was reviewed directly and confirmed to be limited to navigation presentation:
- explicit `MaterialIcons` import in the tabs layout
- `dashboard` icon for the dashboard tab
- `menu-book` icon for the subjects tab
The final repair pass on `main` was verified separately:
- `npx eslint app/(tabs)/subjects.tsx app/subject/viewDetailsSubject.tsx app/task/viewDetailsTask.tsx`
- `git diff --check`
- no remaining merge markers were found in `app/`, `lib/`, `components/`, or `notes/`
One broader static check still failed afterwards:
- `npx tsc --noEmit`
That remaining failure was no longer caused by the repaired app screens. The reported errors were instead in the test setup under `__tests__/`, where Jest/testing-library types and modules were not currently configured in this branch.

View File

@@ -0,0 +1,71 @@
# DevelopmentBuildGuide
This project uses an **Expo development build** for features that are not fully supported in Expo Go, such as our notification setup.
---
## WhyWeUseThis
We do **not** use Expo Go for this project when testing notifications or other native features.
Reason:
- Expo Go has limitations for some native modules
- `expo-notifications` on Android requires a development build for reliable testing
- a development build works like a custom Expo Go app made specifically for this project
---
## #ImportantRule
You do **not** need to rebuild the APK for every code change.
### Rebuild is **not needed** for:
- changing React components
- changing screen layouts
- changing styles
- changing Supabase queries
- changing JS/TS functions
- changing form logic
- changing routing logic
- changing notification scheduling logic in JavaScript only
### Rebuild **is needed** for:
- adding a new native dependency
- removing a native dependency
- changing `app.json`
- changing Expo plugins
- changing Android/iOS permissions
- changing native notification config
- anything that affects the native app shell
---
## OneTimeSetup
Install EAS CLI globally if needed:
npm install -g eas-cli
Log in to Expo:
eas login
Install the Expo development client in the project:
npx expo install expo-dev-client
# BuildTheDevelopmentAPK
Run this command from the project root:
eas build --platform android --profile development
This sends the build to Expo's cloud build service.
When the build is finished:
open the build link
click Install
install the APK on your Android phone or emulator
Do not place the APK inside the project folder.
The APK is something you install on the device, not a source file.
# DailyWorkflow
After the development build APK is installed:
Start the project:
npx expo start

View File

@@ -0,0 +1,173 @@
# Task Management Mobile Application Project Summary
## Overview
This project is a mobile task management application developed using **React Native (Expo)** with **Supabase** as the backend. The application enables users to create, view, edit, and delete tasks while maintaining secure authentication and session management.
The system follows a full-stack architecture, integrating frontend UI components with backend database operations and authentication services.
---
## Technologies Used
### Frontend
- React Native (Expo)
- Expo Router (navigation)
- React Hooks (`useState`, `useEffect`, `useFocusEffect`)
### Backend
- Supabase (PostgreSQL database + authentication)
- Row Level Security (RLS) for data protection
### Storage & Security
- Expo SecureStore for persistent session storage
---
## Application Structure
### Navigation
The app uses **Expo Router** with a combination of:
- **Tab navigation** for main screens
- **Stack navigation** for individual pages
Main routes:
- `/` → Home (Today view)
- `/tasks` → Task list
- `/createTask` → Create new task
- `/editTask` → Edit existing task
- `/createUser` → Sign up
- `/login` → Login
---
## Authentication System
The application includes a complete authentication flow:
- **User Registration**
- Email and password-based signup
- **User Login**
- Credential-based authentication
- **Session Management**
- Persistent sessions using SecureStore
- **Protected Routes**
- Users are redirected if not authenticated
- **Logout**
- Ends session via Supabase
---
## Task Management Features
### Create Task
Users can:
- Enter title, description, and deadline
- Set completion status using a checkbox
- Save tasks to the database
### Read Tasks
- Tasks are fetched from Supabase
- Displayed using a `SectionList`
- Categorized into:
- Upcoming Tasks
- Completed Tasks
- Supports manual refresh and auto-refresh on screen focus
### Update Task
- Existing tasks can be edited
- Fields are pre-filled with current values
- Updates are sent to the database using task ID
### Delete Task
- Tasks can be deleted with confirmation
- List updates after deletion
---
## User Interface Design
A centralized styling system (`defaultStyles`) was implemented to ensure consistency across the application.
### Key UI Components
- Text inputs for forms
- Buttons and pressable elements
- Custom checkbox component
- Sectioned task list
- Activity indicators for loading states
### UX Features
- Keyboard handling with `KeyboardAvoidingView`
- Tap outside to dismiss keyboard
- Alerts for feedback (success/errors)
- Dynamic headers with actions (refresh, logout)
---
## State Management
The app uses local state via React Hooks:
- `useState` for form data and UI state
- `useEffect` for lifecycle events
- `useFocusEffect` for refreshing data when screens are focused
---
## Backend Integration
### Supabase Client
- Configured using environment variables
- Secure session persistence enabled
- Automatic token refresh
### Database Operations
- `INSERT` → create tasks
- `SELECT` → fetch tasks
- `UPDATE` → edit tasks
- `DELETE` → remove tasks
### Data Model (Tasks Table)
- `tId` (UUID)
- `title`
- `description`
- `deadline`
- `isCompleted`
- `lastChanged`
- `uId` (user reference)
---
## Security
- Row Level Security (RLS) ensures users can only access their own tasks
- Queries are filtered using `auth.uid() = uId`
- Update operations require a `WHERE` clause to prevent unintended changes
---
## Challenges and Solutions
### Routing Issues
- Fixed incorrect relative paths by using absolute routes and parameters
### Data Binding
- Ensured edit forms are pre-filled by fetching data using task ID
### Database Errors
- Resolved missing `WHERE` clause in update queries
- Handled invalid date formats
### UI Layout Problems
- Improved header layout by replacing default buttons with custom components
- Fixed spacing and alignment issues
---
## Conclusion
The project successfully demonstrates the development of a full-stack mobile application with:
- Secure authentication
- Persistent user sessions
- CRUD operations with a backend database
- Structured navigation and UI design
The application follows scalable patterns and provides a solid foundation for further enhancements such as improved UI design, additional features, and performance optimizations.

View File

@@ -0,0 +1,116 @@
## #Overview
Today I implemented a full **CRUD system** for the three core entities in the application:
- **Subjects**
- **Assignments**
- **Tasks**
This includes creating, editing, viewing details, and deleting records, as well as connecting all screens using Expo Router.
---
## #ImplementedFeatures
### #Subjects
Created full CRUD flow:
- `createSubject.tsx`
- `editSubject.tsx`
- `viewDetailsSubject.tsx`
**Functionality:**
- Create new subjects
- Edit existing subjects
- View subject details
- Delete subjects
**Relationships:**
- Subjects act as the top-level entity
- Assignments can optionally be linked to a subject via `sId`
- Subjects are displayed:
- globally (`subjects.tsx`)
- or standalone (`viewDetailsSubjects.tsx`)
---
### #Assignments
Created full CRUD flow:
- `createAssignment.tsx`
- `editAssignment.tsx`
- `viewDetailsAssignment.tsx`
**Functionality:**
- Create assignments (with optional `sId`)
- Edit assignments
- View assignment details
- Delete assignments
**Relationships:**
- Assignments can exist:
- linked to a subject (`sId`)
- or standalone (`sId = null`)
- Assignments are displayed:
- globally (`assignments.tsx`)
- or within a subject (`viewDetailsSubject.tsx`)
- or standalone (`viewDetailsAssignment.tsx`)
---
### #Tasks
Created full CRUD flow:
- `createTask.tsx`
- `editTask.tsx`
- `viewDetailsTask.tsx`
**Functionality:**
- Create tasks
- Edit tasks
- View task details
- Delete tasks
**Relationships:**
- Tasks are linked to assignments via `aId`
- Tasks are accessed through assignment detail pages
- Assignments are displayed:
- globally (`tasks.tsx`)
- or within an assignment (`viewDetailsAssignment.tsx`)
- or standalone (`viewDetailsTask.tsx`)
---
## #RoutingStructure
### #TopLevelScreens
- `subjects.tsx` → list of all subjects
- `assignments.tsx` → list of all assignments
- `tasks.tsx` → list of all tasks
- `index.tsx` → pre-existing home screen
---
## #DataModel
### #Subject
- `sId`
- `title`
- `description`
- `isActive`
- `lastChanged`
- `uId`
### #Assignment
- `aId`
- `title`
- `description`
- `deadline`
- `isCompleted`
- `lastChanged`
- `uId`
- `sId`
### #Task
- `tId`
- `title`
- `description`
- `isCompleted`
- `lastChanged`
- `uId`
- `aId`

View File

@@ -0,0 +1,38 @@
# Progress Tracking (Assignments & Subjects)
## What was done
- Implemented progress tracking for both assignments and subjects
- Used `Task.isCompleted` as the source of truth
- Synced `Assignment.isCompleted` based on task completion
## Logic implemented
- Created `CheckAssignmentCompletion(aId)`
- Assignment is marked completed only if all its tasks are completed
- Assignment remains incomplete if:
- Any task is incomplete
- No tasks exist
## Data handling
- Fetched assignments from Supabase
- Fetched all related tasks using assignment IDs
- Grouped tasks by `aId` into `tasksByAssignment`
- Used grouped data to calculate progress efficiently
## Progress calculation
- Assignment progress:
- completed tasks / total tasks
- Subject progress:
- completed tasks across all assignments / total tasks
## UI work
- Added progress bars to:
- Assignment cards
- Subject views
- Used basic inline styling for progress bars
- Fixed layout issues caused by incorrect placement inside `flex-row`
- Moved progress bar into content column to prevent UI breaking
## Result
- Progress updates dynamically based on task completion
- Assignment completion stays in sync with tasks
- UI correctly displays both assignment and subject progress

View File

@@ -0,0 +1,173 @@
## #Overview
Implemented a full local notification system for assignment deadlines using Expo Notifications, integrated with Supabase-backed assignment data.
---
## #CoreFeatures
### #LocalNotifications
* Integrated `expo-notifications` for scheduling local device notifications
* Configured notification handler for:
* banner display
* sound
* badge updates
* Notifications trigger even when:
* app is in background
* app is fully closed
---
### #DeadlineReminderLogic
* Implemented reminder scheduling based on assignment deadlines
* Default behavior:
* notify **24 hours before deadline**
* Prevented invalid scheduling:
* skip if reminder time is in the past
* validate deadline input before scheduling
---
### #AssignmentIntegration
* Notifications tied directly to assignment lifecycle:
#### On Create:
* Insert assignment into Supabase
* Retrieve inserted assignment (`aId`)
* Schedule reminder if not completed
#### On Edit:
* Cancel existing scheduled notification
* Update assignment in Supabase
* Schedule new reminder if still active
#### On Delete:
* Cancel scheduled notification
* Remove stored notification reference
---
### #NotificationPersistence
* Stored notification IDs locally using AsyncStorage
* Structure:
* `assignmentId → notificationId`
* Enables:
* precise cancellation
* avoiding duplicate notifications
---
### #NotificationCancellation
* Implemented cancellation flow using:
* `Notifications.cancelScheduledNotificationAsync(notificationId)`
* Ensures:
* no duplicate reminders on edit
* no orphan notifications after deletion
---
### #NotificationRouting
* Implemented navigation on notification tap
* Uses:
* `Notifications.addNotificationResponseReceivedListener`
* `Notifications.getLastNotificationResponse()`
#### Behavior:
* Works when:
* app is open
* app is in background
* app is launched from notification
#### Routing:
* Extract `aId` from `notification.content.data`
* Navigate using Expo Router:
```ts
router.push({
pathname: "/assignment/viewDetailsAssignment",
params: { aId }
});
```
---
### #AuthIntegration
* Notification observer only runs when user session exists
* Prevents routing into protected screens when unauthenticated
---
## #ArchitectureDecisions
### #LocalVsBackend
* Chose **local notifications only**
* No backend push notifications used
* Rationale:
* single-user reminders
* simpler implementation
* no need for push tokens or server logic
---
### #DataSeparation
* Supabase:
* stores assignment data (source of truth)
* Device (AsyncStorage):
* stores notification IDs (device-specific state)
---
### #RoutingApproach
* Used existing static route:
* `/assignment/viewDetailsAssignment`
* Passed `aId` via params instead of dynamic route `[aId].tsx`
* Keeps current structure intact
---
## #Summary
A complete local notification system has been implemented with:
* deadline-based scheduling
* lifecycle-aware updates (create/edit/delete)
* duplicate prevention
* device-level persistence
* deep-link style navigation on tap
This provides a solid, production-ready foundation for assignment reminders within the app.
Interesting sources:
https://docs.expo.dev/versions/latest/sdk/async-storage/
https://docs.expo.dev/versions/latest/sdk/securestore/
https://docs.expo.dev/versions/latest/sdk/notifications/

View File

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

View File

@@ -0,0 +1,23 @@
# Study Sprint Delivery
This delivery folder contains the final Study Sprint apk file, report, project vision, source code, demo video, and supporting appendices.
## Examiner Login Credentials
We have included `Appendices/examinerCredentials.txt` so the examiner can log in to the app without needing to register and confirm new accounts manually.
The file contains credentials for two prepared users:
- **Populated user**: an account with existing subjects, assignments, tasks, completed sprint sessions, and history. This lets the examiner quickly inspect how the app looks and behaves when it has already been used for study planning and focus sessions.
- **Fresh user**: a newly created account with only email confirmation completed. This lets the examiner inspect the first-time user experience without going through registration or email confirmation.
These accounts are included to make assessment faster and more reliable. The examiner can test both the empty starting state and a realistic in-use state of the application.
## Included Files
- `studysprint.apk`: Android application package.
- `report.pdf`: final project report.
- `projectVision.pdf`: project vision document.
- `source/`: application source code.
- `Appendices/`: appendix material including examiner credentials, work notes and commit history.
- `video.mp4`: Demo video of the app and its functions.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 551 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 551 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 551 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More