Compare commits

...
29 Commits
Author SHA1 Message Date
tech08mag 34bc152817 Merge pull request 'Dev merge main' (#9) from dev-merge-main into main
Build APK / build (push) Failing after 36s
Build Desktop (deb) / build (push) Failing after 5m5s
release / release (push) Successful in 7m42s
Reviewed-on: #9
2026-08-25 19:53:03 +00:00
tech08mag fc8c5976f8 Merge rewritten server history (content unchanged) 2026-08-25 21:41:17 +02:00
tech08mag 259d21c968 custom tags 2026-08-25 16:26:10 +02:00
tech08mag 21a2cffcc0 Merge branch 'dev' 2026-08-24 22:02:11 +02:00
tech08mag 865e0b3301 gitignore: add dist/ to reduce repo size 2026-08-24 21:45:15 +02:00
tech08mag 7fad4b5eec fix: create android/app directory before generating debug keystore
The keytool command fails without the directory existing first.
Add mkdir -p before the keytool genkeypair command.
2026-08-24 19:50:01 +02:00
tech08mag 3a5b5a68e4 Merge pull request 'fix: avoid SIGPIPE when accepting Android SDK licenses' (#6) from dev into main
Build Desktop (deb) / build (push) Successful in 11m47s
release / release (push) Successful in 12m16s
Build APK / build (push) Failing after 13m22s
Reviewed-on: #6
2026-08-24 17:34:53 +00:00
tech08mag 2773255b38 Merge pull request 'fix: avoid SIGPIPE when accepting Android SDK licenses' (#6) from dev into main
Reviewed-on: #6
2026-08-24 17:34:53 +00:00
tech08mag 85ef3dc38b fix: avoid SIGPIPE when accepting Android SDK licenses
Replace 'yes | sdkmanager --licenses' with 'printf y | sdkmanager --licenses'
to prevent exit code 141 (SIGPIPE) when the pipe closes after sdkmanager exits.
2026-08-24 19:33:27 +02:00
tech08mag 87e3f59ee1 fix: avoid SIGPIPE when accepting Android SDK licenses
Replace 'yes | sdkmanager --licenses' with 'printf y | sdkmanager --licenses'
to prevent exit code 141 (SIGPIPE) when the pipe closes after sdkmanager exits.
2026-08-24 19:33:27 +02:00
tech08mag 80663f7d39 Merge pull request 'fix(ci): install android-36 platform and build-tools for release build' (#5) from fix/ci-android-sdk-version into main
Build APK / build (push) Failing after 9m35s
Build Desktop (deb) / build (push) Failing after 14m56s
release / release (push) Successful in 6m16s
Reviewed-on: #5
2026-08-24 12:52:26 +00:00
tech08mag ab4ddb9f8b Merge pull request 'fix(ci): install android-36 platform and build-tools for release build' (#5) from fix/ci-android-sdk-version into main
Reviewed-on: #5
2026-08-24 12:52:26 +00:00
tech08mag 6969e79029 fix(ci): install android-36 platform and build-tools for release build 2026-08-24 14:46:15 +02:00
tech08mag 448279d98c fix(ci): install android-36 platform and build-tools for release build 2026-08-24 14:46:15 +02:00
tech08mag 7e224301cd Merge pull request 'fix(ci): add babel-preset-expo dep and make Android SDK setup idempotent' (#4) from feat/subtask-categories-refresh into main
Reviewed-on: #4
2026-08-23 14:05:26 +00:00
tech08mag df97937948 Merge pull request 'fix(ci): add babel-preset-expo dep and make Android SDK setup idempotent' (#4) from feat/subtask-categories-refresh into main
Build APK / build (push) Failing after 9m52s
Build Desktop (deb) / build (push) Failing after 14m48s
release / release (push) Successful in 6m0s
Reviewed-on: #4
2026-08-23 14:05:26 +00:00
tech08mag ed8b406b7d fix(ci): add babel-preset-expo dep and make Android SDK setup idempotent 2026-08-23 16:02:36 +02:00
tech08mag e87d31aded fix(ci): add babel-preset-expo dep and make Android SDK setup idempotent 2026-08-23 16:02:36 +02:00
tech08mag 964a4fe0f9 Merge pull request 'electron app' (#3) from feat/subtask-categories-refresh into main
Reviewed-on: #3
2026-08-23 13:39:14 +00:00
tech08mag e07d28d6b1 Merge pull request 'electron app' (#3) from feat/subtask-categories-refresh into main
Build APK / build (push) Failing after 10m21s
Build Desktop (deb) / build (push) Failing after 11m14s
release / release (push) Successful in 7m7s
Reviewed-on: #3
2026-08-23 13:39:14 +00:00
tech08mag ab26148a67 electron app 2026-08-23 15:26:52 +02:00
tech08mag fd1c473cb6 electron app 2026-08-23 15:26:52 +02:00
tech08mag 56e6225dd1 Merge pull request 'Feat/subtask categories refresh' (#2) from feat/subtask-categories-refresh into main
release / release (push) Successful in 3s
Build APK / build (push) Failing after 11s
Reviewed-on: #2
2026-08-23 09:37:20 +00:00
tech08mag bdc6562116 working push notifiations 2026-08-23 11:36:17 +02:00
tech08mag 9312783793 ci: trigger release on any merge to main (works with squash/rebase merges) 2026-08-10 12:26:09 +02:00
tech08mag b78ada0865 Merge pull request 'feat: subtask categories + live refresh fixes + release automation' (#1) from feat/subtask-categories-refresh into main
Build APK / build (push) Failing after 3m41s
release / release (push) Successful in 6s
Reviewed-on: #1
2026-08-10 09:56:09 +00:00
tech08mag be2f77dbf1 ci: auto-create app release on approved PR merge to main 2026-08-10 11:42:49 +02:00
tech08mag 2ca23e276f feat(app): subtask categories, live refresh on save, nested subtask fixes 2026-08-10 11:41:25 +02:00
tech08mag f3bcd78e49 feat(backend): subtask category support (schema, validation, sync, routes) 2026-08-10 11:41:25 +02:00
75 changed files with 13893 additions and 1038 deletions
+38
View File
@@ -0,0 +1,38 @@
{"_e":"root:init","_t":1787490823136,"format":"v0-jsonl","version":"57.0.12"}
{"_e":"env:mode","_t":1787490823267,"nodeEnv":"production","babelEnv":"production","mode":"production"}
{"_e":"env:load","_t":1787490823269,"mode":"production","files":[],"env":{}}
{"_e":"devserver:start","_t":1787490823283,"mode":"production","web":true,"baseUrl":"","asyncRoutes":false,"routerRoot":"../../../app","serverComponents":false,"serverActions":false,"serverRendering":false,"apiRoutes":false,"exporting":true}
{"_e":"metro:config","_t":1787490823513,"serverRoot":"../../..","projectRoot":"../../..","exporting":true,"flags":{"autolinkingModuleResolution":false,"serverActions":false,"serverComponents":false,"reactCompiler":false,"optimizeGraph":false,"treeshaking":false,"logbox":false}}
{"_e":"metro:instantiate","_t":1787490823624,"atlas":false,"workers":10,"host":null,"port":null}
{"_e":"metro:bundling:started","_t":1787490824135,"id":"1","platform":"web","environment":null,"entry":"node_modules/expo-router/entry.js"}
{"_e":"root:init","_t":1787490834893,"format":"v0-jsonl","version":"57.0.12"}
{"_e":"env:mode","_t":1787490834983,"nodeEnv":"production","babelEnv":"production","mode":"production"}
{"_e":"env:load","_t":1787490834985,"mode":"production","files":[],"env":{}}
{"_e":"devserver:start","_t":1787490834998,"mode":"production","web":true,"baseUrl":"","asyncRoutes":false,"routerRoot":"../../../app","serverComponents":false,"serverActions":false,"serverRendering":false,"apiRoutes":false,"exporting":true}
{"_e":"metro:config","_t":1787490835165,"serverRoot":"../../..","projectRoot":"../../..","exporting":true,"flags":{"autolinkingModuleResolution":false,"serverActions":false,"serverComponents":false,"reactCompiler":false,"optimizeGraph":false,"treeshaking":false,"logbox":false}}
{"_e":"metro:instantiate","_t":1787490835236,"atlas":false,"workers":10,"host":null,"port":null}
{"_e":"metro:bundling:started","_t":1787490835782,"id":"1","platform":"web","environment":null,"entry":"node_modules/expo-router/entry.js"}
{"_e":"root:init","_t":1787490897618,"format":"v0-jsonl","version":"57.0.12"}
{"_e":"env:mode","_t":1787490897716,"nodeEnv":"production","babelEnv":"production","mode":"production"}
{"_e":"env:load","_t":1787490897718,"mode":"production","files":[],"env":{}}
{"_e":"devserver:start","_t":1787490897732,"mode":"production","web":true,"baseUrl":"","asyncRoutes":false,"routerRoot":"../../../app","serverComponents":false,"serverActions":false,"serverRendering":false,"apiRoutes":false,"exporting":true}
{"_e":"metro:config","_t":1787490897904,"serverRoot":"../../..","projectRoot":"../../..","exporting":true,"flags":{"autolinkingModuleResolution":false,"serverActions":false,"serverComponents":false,"reactCompiler":false,"optimizeGraph":false,"treeshaking":false,"logbox":false}}
{"_e":"metro:instantiate","_t":1787490897978,"atlas":false,"workers":10,"host":null,"port":null}
{"_e":"metro:bundling:started","_t":1787490898645,"id":"1","platform":"web","environment":null,"entry":"node_modules/expo-router/entry.js"}
{"_e":"metro:bundling:done","_t":1787490904477,"id":"1","total":743,"ms":5831.675237}
{"_e":"root:init","_t":1787490907952,"format":"v0-jsonl","version":"57.0.12"}
{"_e":"env:mode","_t":1787490908055,"nodeEnv":"production","babelEnv":"production","mode":"production"}
{"_e":"env:load","_t":1787490908056,"mode":"production","files":[],"env":{}}
{"_e":"devserver:start","_t":1787490908069,"mode":"production","web":true,"baseUrl":"","asyncRoutes":false,"routerRoot":"../../../app","serverComponents":false,"serverActions":false,"serverRendering":false,"apiRoutes":false,"exporting":true}
{"_e":"metro:config","_t":1787490908244,"serverRoot":"../../..","projectRoot":"../../..","exporting":true,"flags":{"autolinkingModuleResolution":false,"serverActions":false,"serverComponents":false,"reactCompiler":false,"optimizeGraph":false,"treeshaking":false,"logbox":false}}
{"_e":"metro:instantiate","_t":1787490908323,"atlas":false,"workers":10,"host":null,"port":null}
{"_e":"metro:bundling:started","_t":1787490908955,"id":"1","platform":"web","environment":null,"entry":"node_modules/expo-router/entry.js"}
{"_e":"metro:bundling:done","_t":1787490909421,"id":"1","total":731,"ms":465.745099}
{"_e":"root:init","_t":1787490971753,"format":"v0-jsonl","version":"57.0.12"}
{"_e":"env:mode","_t":1787490972145,"nodeEnv":"production","babelEnv":"production","mode":"production"}
{"_e":"env:load","_t":1787490972146,"mode":"production","files":[],"env":{}}
{"_e":"devserver:start","_t":1787490972158,"mode":"production","web":true,"baseUrl":"","asyncRoutes":false,"routerRoot":"../../../app","serverComponents":false,"serverActions":false,"serverRendering":false,"apiRoutes":false,"exporting":true}
{"_e":"metro:config","_t":1787490972351,"serverRoot":"../../..","projectRoot":"../../..","exporting":true,"flags":{"autolinkingModuleResolution":false,"serverActions":false,"serverComponents":false,"reactCompiler":false,"optimizeGraph":false,"treeshaking":false,"logbox":false}}
{"_e":"metro:instantiate","_t":1787490972454,"atlas":false,"workers":10,"host":null,"port":null}
{"_e":"metro:bundling:started","_t":1787490973074,"id":"1","platform":"web","environment":null,"entry":"node_modules/expo-router/entry.js"}
{"_e":"metro:bundling:done","_t":1787490973517,"id":"1","total":743,"ms":441.956684}
+13 -5
View File
@@ -29,15 +29,22 @@ jobs:
- name: Install Android SDK - name: Install Android SDK
run: | run: |
mkdir -p "$ANDROID_HOME/cmdline-tools" if [ -x "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" ]; then
wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -O /tmp/cmdtools.zip echo "Android SDK already present in cache"
unzip -q /tmp/cmdtools.zip -d "$ANDROID_HOME/cmdline-tools" else
mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest" rm -rf "$ANDROID_HOME/cmdline-tools"
yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses > /dev/null mkdir -p "$ANDROID_HOME/cmdline-tools"
wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -O /tmp/cmdtools.zip
unzip -q /tmp/cmdtools.zip -d "$ANDROID_HOME/cmdline-tools"
mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest"
fi
printf 'y\n' | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses > /dev/null
"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" \ "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" \
"platform-tools" \ "platform-tools" \
"platforms;android-35" \ "platforms;android-35" \
"platforms;android-36" \
"build-tools;35.0.0" \ "build-tools;35.0.0" \
"build-tools;36.0.0" \
"ndk;27.1.12297006" "ndk;27.1.12297006"
- name: Install JS dependencies - name: Install JS dependencies
@@ -46,6 +53,7 @@ jobs:
- name: Generate debug keystore (signs the release APK) - name: Generate debug keystore (signs the release APK)
run: | run: |
mkdir -p carry-your-live/android/app
keytool -genkeypair -v \ keytool -genkeypair -v \
-keystore carry-your-live/android/app/debug.keystore \ -keystore carry-your-live/android/app/debug.keystore \
-alias androiddebugkey -storepass android -keypass android \ -alias androiddebugkey -storepass android -keypass android \
+36
View File
@@ -0,0 +1,36 @@
name: Build Desktop (deb)
on:
push:
branches: [main]
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 22
- name: Install JS dependencies
working-directory: carry-your-live
run: npm ci
- name: Export web bundle
working-directory: carry-your-live
run: npx expo export --platform web
- name: Build deb package
working-directory: carry-your-live
run: npx electron-builder --linux deb --config electron-builder.config.js
- name: Upload deb
uses: actions/upload-artifact@v4
with:
name: carry-your-live-desktop-deb
path: carry-your-live/desktop-build/*.deb
+56
View File
@@ -0,0 +1,56 @@
name: release
on:
push:
branches:
- main
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Compute next version
id: version
run: |
latest=$(git tag --list 'v[0-9]*' --sort=-v:refname | head -n 1)
if [ -z "$latest" ]; then
next="v1.0.0"
else
latest=${latest#v}
IFS='.' read -r major minor patch <<< "$latest"
next="v${major}.${minor}.$((patch + 1))"
fi
echo "next=${next}" >> "$GITHUB_OUTPUT"
- name: Build changelog
id: changelog
run: |
prev_tag=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
if [ -z "$prev_tag" ]; then
log=$(git log --oneline --no-merges main | head -n 40)
else
log=$(git log --oneline --no-merges "$prev_tag"..main | head -n 40)
fi
{
echo "body<<EOF"
echo "## What's new in this release"
echo ""
echo "$log" | sed 's/^/- /'
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Create release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.version.outputs.next }}
name: ${{ steps.version.outputs.next }}
body: ${{ steps.changelog.outputs.body }}
generate_release_notes: false
+2 -1
View File
@@ -1 +1,2 @@
node_modules node_modules/
dist/
+157
View File
@@ -0,0 +1,157 @@
# Carry Your Live — Roadmap & Feature List
Status as of Aug 2026. Based on code audit of frontend (`carry-your-live/`), backend (`backend/`), and `API_SPEC.md`.
## Legend
- ✅ Implemented
- 🟡 Partial / stubbed
- ❌ Missing
---
## Current State (Feature List)
### Core Task Management
- ✅ Tasks CRUD with title, description, category, priority, due date/time, all-day
- ✅ Nested subtasks (sub-subtask trees), convert task↔subtask, re-parent, reorder
- ✅ Categories/tags with colors (task color derives from category)
- ✅ Priorities (None → Critical) with badges
- ✅ Repeating/recurring tasks: repeat profiles, series IDs, this/future/all delete scopes, undo-complete
- ✅ Overdue / due-today highlighting
### Screens & Navigation
- ✅ Tabs: Todo, Calendar, Stats, Settings (+ Add Task, Day View, Task Detail, Subtask Detail stack screens)
- ✅ Category filter chips, swipe actions, long-press multi-select, drag-to-reorder
- ✅ Quick-add bar, calendar date strip, analog clock widget
- ✅ Stats screen (client-side `useStats`)
### Sync & Accounts
- ✅ Offline-first WatermelonDB (schema v21) + full sync engine (tombstones, LWW)
- ✅ Backend: Express + Drizzle + Postgres — auth (register/login/me/forgot/reset), sync pull/push, tasks, subtasks, categories, repeat profiles, user settings, user search
- ✅ Login/register/sign-out UI (`SyncModal`)
### Social
- ✅ Friends: request/accept/decline/remove, user search, full backend routes
- ✅ Assignee field on tasks/subtasks (schema v7)
### Settings (wired up)
- ✅ Default tag, sort order, show-completed, calendar days-ahead, accent color picker, server URL override
---
## Gaps (what's broken or stubbed)
| Item | Status | Where |
|---|---|---|
| Light mode / theme toggle | ❌ Dark-only, hard-coded palette; backend has `darkMode` column unused | `src/theme.tsx:92` |
| Notifications toggle | 🟡 Stored but never read; toggling off doesn't disable reminders | `settings.tsx:148`, `src/services/notifications.ts` |
| Global reminder preference | 🟡 Stored but never applied when scheduling | `src/theme.tsx:180` |
| Forgot/reset password UI | 🟡 Backend done, no client UI | `backend/src/routes/auth.ts:149` |
| Pull-to-refresh | 🟡 Reloads local queries only, doesn't trigger server sync | `TaskList.tsx:129` |
| Client settings sync | 🟡 All settings AsyncStorage-only; `PATCH /users/me/settings` never called | — |
| Reminder enum mismatch | 🟡 Spec uses minutes (`'15'`,`'30'`…), client uses `'5m'`,`'1h'` style | `API_SPEC.md` vs client |
| Webhooks / rate-limit headers | 🟡 In spec, not built | `API_SPEC.md` |
---
## Roadmap (by release)
> Last released: **v1.0.0** (2026-08-05). The `Unreleased` changelog section already contains the friends feature, assignee field, and various fixes.
---
### v1.1.0 — Friends & Assignees (staged; ship current `Unreleased` + stub fixes)
Ships what's already merged plus fixes to half-wired features so nothing in the release is a dead toggle.
- 📦 Release existing `Unreleased` changes: friends feature, assignee field (schema v7), QuickAddBar on overview, sync fixes for repeat/reminders
- Fix notifications toggle so it's actually respected (`src/services/notifications.ts`); wire up `rescheduleAllReminders()`
- Apply global reminder preference when scheduling task reminders
- Make pull-to-refresh run a real server sync cycle (`TaskList.tsx:129`)
- Add forgot/reset password UI (backend routes already exist: `backend/src/routes/auth.ts:149`)
- Unify reminder offset enums between client, `API_SPEC.md`, and backend
**Theme:** "Everything you can toggle does something."
---
### v1.2.0 — Theming & Findability
- Light mode + follow-system-theme option; theme-mode setting synced via `/users/me/settings` (backend `darkMode` column already exists)
- Sync all client settings to `PATCH /users/me/settings` instead of AsyncStorage-only
- Task search: filter-by-text in list + global search screen
- Friend-request badges/indicators in main UI (polling-based)
- Resolve API error-format drift between spec and auth routes
**Theme:** "Look good, find anything."
---
### v1.3.0 — Stats & Insights
- Persisted stats snapshots for history/trends over time (current stats are per-local-data only)
- Productivity trends: completion rates by category/priority, streaks
- Export stats (CSV/image share)
**Theme:** "Show me how I'm doing."
---
### v1.4.0 — Quick Capture & Calendar UX
Client-side only; no backend changes required.
- Natural language quick-add ("pay rent tomorrow 5pm !finance" → parsed fields) in QuickAddBar
- Snooze/postpone gesture on overdue tasks
- Drag-and-drop rescheduling between days in calendar
- Month grid view alongside the date strip
- "Today at a glance" dashboard combining overdue, today, and stats
- Time estimates + daily planned-vs-free-time indicator
**Theme:** "Capture in seconds."
---
### v1.5.0 — Power Tools & Personalization
- Task templates / saved routines (builds on repeat profiles)
- Soft-archive with search (archive instead of delete)
- Full local JSON export/import backup
- Attachments/links/photos on tasks
- Location-based reminders (expo-location)
- Markdown support in descriptions
- Deep links (`carryyourlive://task/<id>`) for shortcuts and notifications
- Undo toast for all destructive actions (extend existing complete-undo)
- Biometric lock / privacy blur on app switch
- Haptics settings + reduced-motion mode
- Custom app icon / theme presets beyond accent color
- Onboarding flow + sample data seeding
- Android home-screen widget / week agenda widget
- import calendar from other apps
**Theme:** "Make it yours, make it fast."
---
### v2.0.0 — Collaboration
Breaking-ish social features; requires backend schema changes.
- Real task assignment delivery: shared-list or assignment mechanism (rows are currently strictly user-scoped — assigneeId goes nowhere)
- Shared lists / collaborative categories with permissions
- Server push notifications (FCM/APNs): cross-device reminders, friend requests, assignments
- Comments/activity feed on assigned tasks
- Shared category templates ("family shopping list")
- Reactions/acknowledgments on assignments
**Theme:** "Carry it together."
---
### v2.1.0 — Platform & Ops
- Webhooks (`POST /webhooks/sync`) + documented rate limiting per `API_SPEC.md`
- Account management: email field, account deletion
- Release hardening: OTA update flow validation, EAS builds for iOS/Android
**Theme:** "Production-grade."
+176 -2
View File
@@ -8,6 +8,7 @@
"name": "carry-your-live-backend", "name": "carry-your-live-backend",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@types/node-fetch": "^2.6.13",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
@@ -15,6 +16,7 @@
"express": "^4.21.0", "express": "^4.21.0",
"express-rate-limit": "^8.6.2", "express-rate-limit": "^8.6.2",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"node-fetch": "^3.3.2",
"pg": "^8.13.0", "pg": "^8.13.0",
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
@@ -1015,12 +1017,21 @@
"version": "22.20.1", "version": "22.20.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz",
"integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
}, },
"node_modules/@types/node-fetch": {
"version": "2.6.13",
"resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz",
"integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==",
"license": "MIT",
"dependencies": {
"@types/node": "*",
"form-data": "^4.0.4"
}
},
"node_modules/@types/pg": { "node_modules/@types/pg": {
"version": "8.20.4", "version": "8.20.4",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.4.tgz", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.4.tgz",
@@ -1099,6 +1110,12 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/bcryptjs": { "node_modules/bcryptjs": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
@@ -1198,6 +1215,18 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/content-disposition": { "node_modules/content-disposition": {
"version": "0.5.4", "version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -1251,6 +1280,15 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/data-uri-to-buffer": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
"integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -1268,6 +1306,15 @@
} }
} }
}, },
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/depd": { "node_modules/depd": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -1504,6 +1551,21 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.19.12", "version": "0.19.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.12.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.12.tgz",
@@ -1651,6 +1713,29 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/fetch-blob": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
"integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/jimmywarting"
},
{
"type": "paypal",
"url": "https://paypal.me/jimmywarting"
}
],
"license": "MIT",
"dependencies": {
"node-domexception": "^1.0.0",
"web-streams-polyfill": "^3.0.3"
},
"engines": {
"node": "^12.20 || >= 14.13"
}
},
"node_modules/finalhandler": { "node_modules/finalhandler": {
"version": "1.3.2", "version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
@@ -1684,6 +1769,34 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/form-data": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.4",
"mime-types": "^2.1.35"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/formdata-polyfill": {
"version": "4.0.10",
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
"integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
"license": "MIT",
"dependencies": {
"fetch-blob": "^3.1.2"
},
"engines": {
"node": ">=12.20.0"
}
},
"node_modules/forwarded": { "node_modules/forwarded": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -1800,6 +1913,21 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": { "node_modules/hasown": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
@@ -2037,6 +2165,44 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/node-domexception": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
"integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
"deprecated": "Use your platform's native DOMException instead",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/jimmywarting"
},
{
"type": "github",
"url": "https://paypal.me/jimmywarting"
}
],
"license": "MIT",
"engines": {
"node": ">=10.5.0"
}
},
"node_modules/node-fetch": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
"license": "MIT",
"dependencies": {
"data-uri-to-buffer": "^4.0.0",
"fetch-blob": "^3.1.4",
"formdata-polyfill": "^4.0.10"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/node-fetch"
}
},
"node_modules/object-assign": { "node_modules/object-assign": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -2977,7 +3143,6 @@
"version": "6.21.0", "version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/unpipe": { "node_modules/unpipe": {
@@ -3007,6 +3172,15 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/web-streams-polyfill": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
"integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/xtend": { "node_modules/xtend": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+2
View File
@@ -13,6 +13,7 @@
"db:studio": "drizzle-kit studio" "db:studio": "drizzle-kit studio"
}, },
"dependencies": { "dependencies": {
"@types/node-fetch": "^2.6.13",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
@@ -20,6 +21,7 @@
"express": "^4.21.0", "express": "^4.21.0",
"express-rate-limit": "^8.6.2", "express-rate-limit": "^8.6.2",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"node-fetch": "^3.3.2",
"pg": "^8.13.0", "pg": "^8.13.0",
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
+5
View File
@@ -10,6 +10,8 @@ export const users = pgTable('users', {
passwordHash: text('password_hash').notNull(), passwordHash: text('password_hash').notNull(),
resetToken: text('reset_token'), resetToken: text('reset_token'),
resetTokenExpiry: bigint('reset_token_expiry', { mode: 'number' }), resetTokenExpiry: bigint('reset_token_expiry', { mode: 'number' }),
expoPushToken: text('expo_push_token'),
pushTokenUpdatedAt: bigint('push_token_updated_at', { mode: 'number' }),
createdAt: bigint('created_at', { mode: 'number' }).notNull().default(sql`EXTRACT(EPOCH FROM NOW()) * 1000`), createdAt: bigint('created_at', { mode: 'number' }).notNull().default(sql`EXTRACT(EPOCH FROM NOW()) * 1000`),
}); });
@@ -44,6 +46,7 @@ export const tasks = pgTable('tasks', {
id: text('id').primaryKey(), id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
categoryId: text('category_id').references(() => categories.id, { onDelete: 'cascade' }), categoryId: text('category_id').references(() => categories.id, { onDelete: 'cascade' }),
tags: text('tags').notNull().default(''),
title: text('title').notNull(), title: text('title').notNull(),
description: text('description').notNull().default(''), description: text('description').notNull().default(''),
priority: text('priority', { enum: ['none', 'low', 'medium', 'high', 'critical'] }).notNull().default('none'), priority: text('priority', { enum: ['none', 'low', 'medium', 'high', 'critical'] }).notNull().default('none'),
@@ -84,6 +87,8 @@ export const subtasks = pgTable('subtasks', {
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
taskId: text('task_id').notNull().references(() => tasks.id, { onDelete: 'cascade' }), taskId: text('task_id').notNull().references(() => tasks.id, { onDelete: 'cascade' }),
parentSubtaskId: text('parent_subtask_id').references((): AnyPgColumn => subtasks.id, { onDelete: 'cascade' }), parentSubtaskId: text('parent_subtask_id').references((): AnyPgColumn => subtasks.id, { onDelete: 'cascade' }),
categoryId: text('category_id').references(() => categories.id, { onDelete: 'set null' }),
tags: text('tags').notNull().default(''),
title: text('title').notNull(), title: text('title').notNull(),
description: text('description').notNull().default(''), description: text('description').notNull().default(''),
priority: text('priority', { enum: ['none', 'low', 'medium', 'high', 'critical'] }).notNull().default('none'), priority: text('priority', { enum: ['none', 'low', 'medium', 'high', 'critical'] }).notNull().default('none'),
+2
View File
@@ -9,6 +9,7 @@ import userRoutes from './routes/users';
import friendRoutes from './routes/friends'; import friendRoutes from './routes/friends';
import repeatProfileRoutes from './routes/repeatProfiles'; import repeatProfileRoutes from './routes/repeatProfiles';
import syncRoutes from './routes/sync'; import syncRoutes from './routes/sync';
import pushNotificationRoutes from './routes/pushNotifications';
import { errorHandler, notFoundHandler } from './middleware/errorHandler'; import { errorHandler, notFoundHandler } from './middleware/errorHandler';
const app = express(); const app = express();
@@ -34,6 +35,7 @@ app.use('/api/users', userRoutes);
app.use('/api/repeat-profiles', repeatProfileRoutes); app.use('/api/repeat-profiles', repeatProfileRoutes);
app.use('/api/sync', syncRoutes); app.use('/api/sync', syncRoutes);
app.use('/api/friends', friendRoutes); app.use('/api/friends', friendRoutes);
app.use('/api/push', pushNotificationRoutes);
// 404 handler // 404 handler
app.use(notFoundHandler); app.use(notFoundHandler);
+97
View File
@@ -0,0 +1,97 @@
import { Router, Request, Response } from 'express';
import { asyncHandler } from '../utils/asyncHandler';
import { db } from '../db';
import { users } from '../db/schema';
import { eq } from 'drizzle-orm';
import { AppError } from '../middleware/errorHandler';
import { z } from 'zod';
import { sendPushNotification, isExpoPushToken, createTaskReminderPayload } from '../services/pushNotifications';
const router = Router();
const registerPushTokenSchema = z.object({
pushToken: z.string().min(1),
});
router.post('/push-token', asyncHandler(async (req: Request, res: Response) => {
if (!req.user) {
throw new AppError('UNAUTHORIZED', 'Authentication required', 401);
}
const data = registerPushTokenSchema.parse(req.body);
if (!isExpoPushToken(data.pushToken)) {
throw new AppError('INVALID_PUSH_TOKEN', 'Invalid Expo push token format', 400);
}
await db
.update(users)
.set({
expoPushToken: data.pushToken,
pushTokenUpdatedAt: Date.now(),
})
.where(eq(users.id, req.user.userId));
res.json({ success: true });
}));
router.delete('/push-token', asyncHandler(async (req: Request, res: Response) => {
if (!req.user) {
throw new AppError('UNAUTHORIZED', 'Authentication required', 401);
}
await db
.update(users)
.set({
expoPushToken: null,
pushTokenUpdatedAt: Date.now(),
})
.where(eq(users.id, req.user.userId));
res.json({ success: true });
}));
router.post('/test', asyncHandler(async (req: Request, res: Response) => {
if (!req.user) {
throw new AppError('UNAUTHORIZED', 'Authentication required', 401);
}
const user = await db.select().from(users).where(eq(users.id, req.user.userId)).limit(1);
if (user.length === 0 || !user[0].expoPushToken) {
throw new AppError('NO_PUSH_TOKEN', 'No push token registered', 400);
}
const ticket = await sendPushNotification({
to: user[0].expoPushToken!,
title: 'Test Notification',
body: 'Push notifications are working!',
data: { type: 'test' },
sound: 'default',
});
res.json({ success: true, ticket });
}));
router.post('/send-reminder', asyncHandler(async (req: Request, res: Response) => {
if (!req.user) {
throw new AppError('UNAUTHORIZED', 'Authentication required', 401);
}
const { taskId, taskTitle, dueTime, repeat } = req.body;
if (!taskId || !taskTitle || !dueTime) {
throw new AppError('INVALID_PAYLOAD', 'Missing required fields', 400);
}
const user = await db.select().from(users).where(eq(users.id, req.user.userId)).limit(1);
if (user.length === 0 || !user[0].expoPushToken) {
throw new AppError('NO_PUSH_TOKEN', 'No push token registered', 400);
}
const ticket = await sendPushNotification(
createTaskReminderPayload(user[0].expoPushToken!, taskTitle, dueTime, taskId, repeat)
);
res.json({ success: true, ticket });
}));
export default router;
+2
View File
@@ -82,6 +82,8 @@ router.post('/task/:taskId', asyncHandler(async (req: Request, res: Response) =>
userId, userId,
taskId: req.params.taskId, taskId: req.params.taskId,
parentSubtaskId, parentSubtaskId,
categoryId: data.categoryId ?? null,
tags: data.tags ?? '',
title: data.title, title: data.title,
description: data.description ?? '', description: data.description ?? '',
priority: data.priority ?? 'none', priority: data.priority ?? 'none',
+36 -46
View File
@@ -17,60 +17,47 @@ router.get('/', asyncHandler(async (req: Request, res: Response) => {
const sinceDate = since; const sinceDate = since;
// Fetch categories changed since timestamp // Fetch all changed entities in parallel
const changedCategories = await db const [changedCategories, changedTasks, changedSubtasks, changedRepeatProfiles, changedFriendships, changedTombstones] = await Promise.all([
.select() db
.from(categories) .select()
.where(and(eq(categories.userId, userId), gte(categories.updatedAt, sinceDate))) .from(categories)
.orderBy(asc(categories.updatedAt)); .where(and(eq(categories.userId, userId), gte(categories.updatedAt, sinceDate)))
.orderBy(asc(categories.updatedAt)),
// Fetch tasks changed since timestamp db
const changedTasks = await db .select()
.select() .from(tasks)
.from(tasks) .where(and(eq(tasks.userId, userId), gte(tasks.updatedAt, sinceDate)))
.where(and(eq(tasks.userId, userId), gte(tasks.updatedAt, sinceDate))) .orderBy(asc(tasks.updatedAt)),
.orderBy(asc(tasks.updatedAt));
// Fetch subtasks changed since timestamp db
let changedSubtasks: any[] = [];
if (changedTasks.length > 0) {
changedSubtasks = await db
.select() .select()
.from(subtasks) .from(subtasks)
.where(and(eq(subtasks.userId, userId), gte(subtasks.updatedAt, sinceDate))) .where(and(eq(subtasks.userId, userId), gte(subtasks.updatedAt, sinceDate)))
.orderBy(asc(subtasks.updatedAt)); .orderBy(asc(subtasks.updatedAt)),
} else {
// Also fetch subtasks for tasks that might have been deleted db
changedSubtasks = await db
.select() .select()
.from(subtasks) .from(repeatProfiles)
.where(and(eq(subtasks.userId, userId), gte(subtasks.updatedAt, sinceDate))) .where(and(eq(repeatProfiles.userId, userId), gte(repeatProfiles.updatedAt, sinceDate)))
.orderBy(asc(subtasks.updatedAt)); .orderBy(asc(repeatProfiles.updatedAt)),
}
// Fetch repeat profiles changed since timestamp db
const changedRepeatProfiles = await db .select()
.select() .from(friendships)
.from(repeatProfiles) .where(and(
.where(and(eq(repeatProfiles.userId, userId), gte(repeatProfiles.updatedAt, sinceDate))) or(eq(friendships.userId, userId), eq(friendships.friendId, userId)),
.orderBy(asc(repeatProfiles.updatedAt)); gte(friendships.updatedAt, sinceDate)
))
.orderBy(asc(friendships.updatedAt)),
// Fetch friendships changed since timestamp db
const changedFriendships = await db .select()
.select() .from(tombstones)
.from(friendships) .where(and(eq(tombstones.userId, userId), gte(tombstones.updatedAt, sinceDate)))
.where(and( .orderBy(asc(tombstones.updatedAt)),
or(eq(friendships.userId, userId), eq(friendships.friendId, userId)), ]);
gte(friendships.updatedAt, sinceDate)
))
.orderBy(asc(friendships.updatedAt));
// Fetch tombstones (deletions) changed since timestamp
const changedTombstones = await db
.select()
.from(tombstones)
.where(and(eq(tombstones.userId, userId), gte(tombstones.updatedAt, sinceDate)))
.orderBy(asc(tombstones.updatedAt));
const timestamp = Date.now(); const timestamp = Date.now();
@@ -235,6 +222,7 @@ router.post('/push', asyncHandler(async (req: Request, res: Response) => {
title: task.title, title: task.title,
description: task.description, description: task.description,
categoryId: task.categoryId, categoryId: task.categoryId,
tags: task.tags ?? '',
priority: task.priority, priority: task.priority,
completed: task.completed, completed: task.completed,
dueDate: task.dueDate, dueDate: task.dueDate,
@@ -290,6 +278,8 @@ router.post('/push', asyncHandler(async (req: Request, res: Response) => {
.set({ .set({
taskId: sub.taskId, taskId: sub.taskId,
parentSubtaskId: sub.parentSubtaskId ?? null, parentSubtaskId: sub.parentSubtaskId ?? null,
categoryId: sub.categoryId ?? null,
tags: sub.tags ?? '',
title: sub.title, title: sub.title,
description: sub.description ?? '', description: sub.description ?? '',
priority: sub.priority ?? 'none', priority: sub.priority ?? 'none',
+3
View File
@@ -135,6 +135,7 @@ router.post('/', asyncHandler(async (req: Request, res: Response) => {
id: taskId, id: taskId,
userId, userId,
categoryId: data.categoryId, categoryId: data.categoryId,
tags: data.tags ?? '',
title: data.title, title: data.title,
description: data.description ?? '', description: data.description ?? '',
priority: data.priority ?? 'none', priority: data.priority ?? 'none',
@@ -159,6 +160,8 @@ router.post('/', asyncHandler(async (req: Request, res: Response) => {
userId, userId,
taskId, taskId,
title: st.title, title: st.title,
categoryId: (st as any).categoryId ?? null,
tags: (st as any).tags ?? '',
completed: false, completed: false,
order: index, order: index,
createdAt: now, createdAt: now,
+153
View File
@@ -0,0 +1,153 @@
import fetch from 'node-fetch';
const EXPO_PUSH_URL = 'https://exp.host/--/api/v2/push/send';
export interface PushNotificationPayload {
to: string;
title: string;
body: string;
data?: Record<string, any>;
sound?: string;
badge?: number;
priority?: 'default' | 'normal' | 'high';
channelId?: string;
}
export interface PushTicket {
id: string;
status: 'ok' | 'error';
message?: string;
details?: {
error?: string;
};
}
export interface PushReceipt {
status: 'ok' | 'error';
message?: string;
details?: {
error?: string;
};
}
interface ExpoPushResponse {
data?: PushTicket[] | Record<string, PushReceipt>;
}
export async function sendPushNotification(payload: PushNotificationPayload): Promise<PushTicket> {
const response = await fetch(EXPO_PUSH_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify(payload),
});
const result = (await response.json()) as ExpoPushResponse;
if (result.data && Array.isArray(result.data)) {
return result.data[0] ?? { id: '', status: 'error', message: 'No ticket returned' };
}
return { id: '', status: 'error', message: 'No ticket returned' };
}
export async function sendPushNotifications(payloads: PushNotificationPayload[]): Promise<PushTicket[]> {
const chunks: PushNotificationPayload[][] = [];
for (let i = 0; i < payloads.length; i += 100) {
chunks.push(payloads.slice(i, i + 100));
}
const tickets: PushTicket[] = [];
for (const chunk of chunks) {
const response = await fetch(EXPO_PUSH_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify(chunk),
});
const result = (await response.json()) as ExpoPushResponse;
if (result.data && Array.isArray(result.data)) {
tickets.push(...result.data);
}
}
return tickets;
}
export async function getPushReceipts(ticketIds: string[]): Promise<PushReceipt[]> {
if (ticketIds.length === 0) return [];
const response = await fetch(`${EXPO_PUSH_URL}/receipts`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({ ids: ticketIds }),
});
const result = (await response.json()) as ExpoPushResponse;
const receipts: PushReceipt[] = [];
if (result.data && !Array.isArray(result.data)) {
for (const id of ticketIds) {
receipts.push(result.data[id] ?? { status: 'error', message: 'No receipt found' });
}
}
return receipts;
}
export function isExpoPushToken(token: string): boolean {
return token.startsWith('ExponentPushToken[') || token.startsWith('ExpoPushToken[');
}
export function createTaskReminderPayload(
pushToken: string,
taskTitle: string,
dueTime: string,
taskId: string,
repeat?: string
): PushNotificationPayload {
return {
to: pushToken,
title: taskTitle,
body: `Due ${dueTime}${repeat && repeat !== 'none' ? ' · repeating' : ''}`,
data: { type: 'task_reminder', taskId },
sound: 'default',
priority: 'high',
channelId: 'task-reminders',
};
}
export function createFriendRequestPayload(
pushToken: string,
fromUsername: string,
friendshipId: string
): PushNotificationPayload {
return {
to: pushToken,
title: 'New Friend Request',
body: `${fromUsername} wants to be your friend`,
data: { type: 'friend_request', friendshipId },
sound: 'default',
priority: 'high',
channelId: 'friend-requests',
};
}
export function createTaskAssignmentPayload(
pushToken: string,
taskTitle: string,
fromUsername: string,
taskId: string
): PushNotificationPayload {
return {
to: pushToken,
title: 'Task Assigned',
body: `${fromUsername} assigned you "${taskTitle}"`,
data: { type: 'task_assignment', taskId },
sound: 'default',
priority: 'high',
channelId: 'task-assignments',
};
}
+7 -1
View File
@@ -39,6 +39,7 @@ export const taskCreateSchema = z.object({
title: z.string().min(1).max(100), title: z.string().min(1).max(100),
description: z.string().max(1000).optional(), description: z.string().max(1000).optional(),
categoryId: z.string().max(100).transform((v) => (v === '' ? null : v)).nullable(), categoryId: z.string().max(100).transform((v) => (v === '' ? null : v)).nullable(),
tags: z.string().max(500).optional(),
priority: z.enum(['none', 'low', 'medium', 'high', 'critical']).optional(), priority: z.enum(['none', 'low', 'medium', 'high', 'critical']).optional(),
dueDate: z.number().int().min(0).optional(), dueDate: z.number().int().min(0).optional(),
dueTime: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().or(z.literal('')).transform(v => v ?? ''), dueTime: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().or(z.literal('')).transform(v => v ?? ''),
@@ -52,13 +53,14 @@ export const taskCreateSchema = z.object({
reminders: z.string().max(100).optional(), reminders: z.string().max(100).optional(),
assigneeId: z.string().nullable().optional(), assigneeId: z.string().nullable().optional(),
completedAt: z.number().int().min(0).nullable().optional(), completedAt: z.number().int().min(0).nullable().optional(),
subtasks: z.array(z.object({ title: z.string().min(1).max(100) })).optional(), subtasks: z.array(z.object({ title: z.string().min(1).max(100), categoryId: z.string().max(100).nullable().optional().transform((v) => (v === '' ? null : v)) })).optional(),
}); });
export const taskUpdateSchema = z.object({ export const taskUpdateSchema = z.object({
title: z.string().min(1).max(100).optional(), title: z.string().min(1).max(100).optional(),
description: z.string().max(1000).optional(), description: z.string().max(1000).optional(),
categoryId: z.string().max(100).nullable().optional().transform((v) => (v === '' ? null : v)), categoryId: z.string().max(100).nullable().optional().transform((v) => (v === '' ? null : v)),
tags: z.string().max(500).optional(),
priority: z.enum(['none', 'low', 'medium', 'high', 'critical']).optional(), priority: z.enum(['none', 'low', 'medium', 'high', 'critical']).optional(),
completed: z.boolean().optional(), completed: z.boolean().optional(),
dueDate: z.number().int().min(0).optional(), dueDate: z.number().int().min(0).optional(),
@@ -72,6 +74,8 @@ export const taskUpdateSchema = z.object({
export const subtaskCreateSchema = z.object({ export const subtaskCreateSchema = z.object({
title: z.string().min(1).max(100), title: z.string().min(1).max(100),
description: z.string().max(1000).optional(), description: z.string().max(1000).optional(),
categoryId: z.string().max(100).nullable().optional().transform((v) => (v === '' ? null : v)),
tags: z.string().max(500).optional(),
priority: z.enum(['none', 'low', 'medium', 'high', 'critical']).optional(), priority: z.enum(['none', 'low', 'medium', 'high', 'critical']).optional(),
dueDate: z.number().int().min(0).optional(), dueDate: z.number().int().min(0).optional(),
dueTime: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().or(z.literal('')).transform(v => v ?? ''), dueTime: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().or(z.literal('')).transform(v => v ?? ''),
@@ -91,6 +95,8 @@ export const subtaskCreateSchema = z.object({
export const subtaskUpdateSchema = z.object({ export const subtaskUpdateSchema = z.object({
title: z.string().min(1).max(100).optional(), title: z.string().min(1).max(100).optional(),
description: z.string().max(1000).optional(), description: z.string().max(1000).optional(),
categoryId: z.string().max(100).nullable().optional().transform((v) => (v === '' ? null : v)),
tags: z.string().max(500).optional(),
priority: z.enum(['none', 'low', 'medium', 'high', 'critical']).optional(), priority: z.enum(['none', 'low', 'medium', 'high', 'critical']).optional(),
completed: z.boolean().optional(), completed: z.boolean().optional(),
dueDate: z.number().int().min(0).optional(), dueDate: z.number().int().min(0).optional(),
+3
View File
@@ -7,6 +7,9 @@ node_modules/
.expo/ .expo/
dist/ dist/
web-build/ web-build/
# Electron
desktop-build/
expo-env.d.ts expo-env.d.ts
# Native # Native
+84 -46
View File
@@ -1,6 +1,7 @@
import { Tabs } from 'expo-router'; import { Tabs } from 'expo-router';
import React from 'react'; import React from 'react';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { View, StyleSheet, TouchableOpacity } from 'react-native';
import { TabBarIcon } from '@/components/TabBarIcon'; import { TabBarIcon } from '@/components/TabBarIcon';
import { useSettings } from '@/theme'; import { useSettings } from '@/theme';
import { setLastVisitedTab } from '@/utils/tabHistory'; import { setLastVisitedTab } from '@/utils/tabHistory';
@@ -13,19 +14,19 @@ export default function TabLayout() {
<Tabs <Tabs
screenOptions={{ screenOptions={{
tabBarActiveTintColor: theme.accent, tabBarActiveTintColor: theme.accent,
tabBarInactiveTintColor: theme.text, tabBarInactiveTintColor: theme.textSecondary,
tabBarStyle: { tabBarStyle: {
backgroundColor: theme.tabBarBg, backgroundColor: theme.tabBarBg,
borderTopWidth: 1, borderTopWidth: 0,
borderTopColor: theme.border, height: 84 + insets.bottom,
height: 68 + insets.bottom,
paddingBottom: insets.bottom, paddingBottom: insets.bottom,
paddingTop: 6, paddingTop: 12,
elevation: 8, paddingHorizontal: 16,
elevation: 0,
shadowColor: '#000', shadowColor: '#000',
shadowOffset: { width: 0, height: -2 }, shadowOffset: { width: 0, height: -4 },
shadowOpacity: 0.08, shadowOpacity: 0.12,
shadowRadius: 8, shadowRadius: 20,
}, },
tabBarItemStyle: { tabBarItemStyle: {
paddingVertical: 0, paddingVertical: 0,
@@ -33,10 +34,54 @@ export default function TabLayout() {
tabBarLabelStyle: { tabBarLabelStyle: {
fontSize: 11, fontSize: 11,
fontWeight: '700', fontWeight: '700',
marginTop: 4,
}, },
headerShown: false, headerShown: false,
sceneStyle: { backgroundColor: theme.background }, sceneStyle: { backgroundColor: theme.background },
}} }}
tabBar={(props) => {
return (
<View
style={[
styles.tabBarContainer,
{
backgroundColor: theme.tabBarBg,
paddingBottom: Math.max(props.insets.bottom, 8),
shadowColor: '#000',
shadowOffset: { width: 0, height: -4 },
shadowOpacity: 0.12,
shadowRadius: 20,
elevation: 8,
},
]}
>
{props.navigation.getState().routes.map((route) => {
const focused = props.navigation.getState().index === props.navigation.getState().routes.indexOf(route);
return (
<TouchableOpacity
key={route.name}
style={[
styles.tabItem,
focused && styles.tabItemActive,
{ backgroundColor: focused ? theme.accentSoft : 'transparent' },
]}
onPress={() => props.navigation.navigate(route.name as never)}
accessibilityRole="button"
accessibilityLabel={route.name === 'index' ? 'ToDo' : route.name.charAt(0).toUpperCase() + route.name.slice(1)}
accessibilityState={{ selected: focused }}
>
<TabBarIcon
name={route.name === 'index' ? 'checklist' : route.name === 'calendar' ? 'calendar' : route.name === 'stats' ? 'stats' : 'gear'}
focused={focused}
color={focused ? theme.accent : theme.textSecondary}
size={24}
/>
</TouchableOpacity>
);
})}
</View>
);
}}
screenListeners={({ route }) => ({ screenListeners={({ route }) => ({
focus: () => { focus: () => {
if (route.name !== 'settings') { if (route.name !== 'settings') {
@@ -45,42 +90,35 @@ export default function TabLayout() {
}, },
})} })}
> >
<Tabs.Screen <Tabs.Screen name="index" />
name="index" <Tabs.Screen name="calendar" />
options={{ <Tabs.Screen name="stats" />
title: 'ToDo', <Tabs.Screen name="settings" />
tabBarIcon: ({ color, focused }) => (
<TabBarIcon name="checklist" focused={focused} color={color} size={26} />
),
}}
/>
<Tabs.Screen
name="calendar"
options={{
title: 'Calendar',
tabBarIcon: ({ color, focused }) => (
<TabBarIcon name="calendar" focused={focused} color={color} size={26} />
),
}}
/>
<Tabs.Screen
name="stats"
options={{
title: 'Stats',
tabBarIcon: ({ color, focused }) => (
<TabBarIcon name="stats" focused={focused} color={color} size={26} />
),
}}
/>
<Tabs.Screen
name="settings"
options={{
title: 'Settings',
tabBarIcon: ({ color, focused }) => (
<TabBarIcon name="gear" focused={focused} color={color} size={26} />
),
}}
/>
</Tabs> </Tabs>
); );
} }
const styles = StyleSheet.create({
tabBarContainer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-around',
paddingTop: 10,
paddingHorizontal: 16,
},
tabItem: {
flex: 1,
alignItems: 'center',
paddingVertical: 8,
paddingHorizontal: 8,
borderRadius: 16,
minWidth: 70,
},
tabItemActive: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 4,
},
});
+221 -217
View File
@@ -1,25 +1,28 @@
import React, { useMemo, useRef, useState, useCallback, useEffect } from 'react'; import React, { useMemo, useRef, useState, useCallback, useEffect } from 'react';
import { View, Text, StyleSheet, SafeAreaView, ScrollView, TouchableOpacity, Animated, Dimensions, BackHandler, KeyboardAvoidingView } from 'react-native'; import { View, Text, StyleSheet, SafeAreaView, ScrollView, TouchableOpacity, Animated, Dimensions, KeyboardAvoidingView } from 'react-native';
import { useRouter, useFocusEffect } from 'expo-router'; import { useRouter, useFocusEffect } from 'expo-router';
import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import { Header } from '@/components/Header'; import { Header } from '@/components/Header';
import { useTasksByDate, useTasksInMonth } from '@/hooks/useTasks'; import { useTasksByDate, useTasksInMonth } from '@/hooks/useTasks';
import { useSubtasks } from '@/hooks/useSubtasks';
import { useTaskModals } from '@/hooks/useTaskModals'; import { useTaskModals } from '@/hooks/useTaskModals';
import { useSettings } from '@/theme'; import { useSettings } from '@/theme';
import { useCategories, useDatabase } from '@/hooks/useDatabase'; import { useCategories } from '@/hooks/useDatabase';
import { toggleTaskComplete } from '@/utils/taskActions'; import { useICalImport } from '@/hooks/useICalImport';
import { confirmAndToggleTaskComplete, toggleSubtaskComplete } from '@/utils/taskActions';
import { QuickAddBar } from '@/components/QuickAddBar'; import { QuickAddBar } from '@/components/QuickAddBar';
import { OptionPickerModal } from '@/components/OptionPickerModal'; import { OptionPickerModal } from '@/components/OptionPickerModal';
import { SubtaskData } from '@/types'; import { TaskItem } from '@/components/TaskItem';
import { SubtaskItem } from '@/components/SubtaskItem';
import Task from '@/models/Task'; import Task from '@/models/Task';
import { format, addMonths, addDays, startOfMonth, isSameDay, isSameMonth, isToday } from 'date-fns'; import { format, addMonths, addDays, startOfMonth, isSameDay, isSameMonth, isToday } from 'date-fns';
import { Q } from '@nozbe/watermelondb'; import Svg, { Path } from 'react-native-svg';
import Svg, { Path, Circle } from 'react-native-svg';
import { desaturate } from '@/theme';
import type { ThemeColors } from '@/theme'; import type { ThemeColors } from '@/theme';
import type { TaskData, SubtaskData } from '@/types';
const WIDTH = Dimensions.get('window').width; const WIDTH = Dimensions.get('window').width;
const GERMAN_WEEKDAYS = ['mo', 'di', 'mi', 'do', 'fr', 'sa', 'so']; const GERMAN_WEEKDAYS = ['mo', 'di', 'mi', 'do', 'fr', 'sa', 'so'];
const PRIORITY_RANK: Record<string, number> = { none: 0, low: 1, medium: 2, high: 3, critical: 4 };
const MONTH_NAMES = [ const MONTH_NAMES = [
'January', 'February', 'March', 'April', 'May', 'June', 'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December', 'July', 'August', 'September', 'October', 'November', 'December',
@@ -27,35 +30,68 @@ const MONTH_NAMES = [
export default function CalendarScreen() { export default function CalendarScreen() {
const router = useRouter(); const router = useRouter();
const { theme } = useSettings(); const { theme, sortBy } = useSettings();
const { collections } = useDatabase(); const styles = React.useMemo(() => createStyles(theme), [theme]);
const categories = useCategories(); const categories = useCategories();
const categoryColors = useMemo(() => {
const map = new Map<string, string>();
for (const c of categories) {
map.set(c.id, c.color);
}
return map;
}, [categories]);
const { modals, openTaskEdit } = useTaskModals(); const { modals, openTaskEdit } = useTaskModals();
const [visibleMonth, setVisibleMonth] = useState(() => new Date()); const [visibleMonth, setVisibleMonth] = useState(() => new Date());
const [selectedDate, setSelectedDate] = useState(() => new Date()); const [selectedDate, setSelectedDate] = useState(() => new Date());
const [monthPickerVisible, setMonthPickerVisible] = useState(false); const [monthPickerVisible, setMonthPickerVisible] = useState(false);
const [yearPickerVisible, setYearPickerVisible] = useState(false); const [yearPickerVisible, setYearPickerVisible] = useState(false);
const [subtasksMap, setSubtasksMap] = useState<Record<string, SubtaskData[]>>({}); const { map: subtasksByTask, refresh: refreshSubtasks } = useSubtasks();
const visibleMonthRef = useRef(visibleMonth); const visibleMonthRef = useRef(visibleMonth);
visibleMonthRef.current = visibleMonth;
const selectedDateRef = useRef(selectedDate); const selectedDateRef = useRef(selectedDate);
selectedDateRef.current = selectedDate; const translateX = useRef<Animated.Value>(new Animated.Value(0));
useFocusEffect(
useCallback(() => {
const sub = BackHandler.addEventListener('hardwareBackPress', () => true);
return () => sub.remove();
}, [])
);
const translateX = useRef(new Animated.Value(0)).current;
const animatingRef = useRef(false); const animatingRef = useRef(false);
const gridWidthRef = useRef<number>(WIDTH); const gridWidthRef = useRef<number>(WIDTH);
const { tasks: selectedDayTasks } = useTasksByDate(selectedDate); useEffect(() => {
visibleMonthRef.current = visibleMonth;
}, [visibleMonth]);
useEffect(() => {
selectedDateRef.current = selectedDate;
}, [selectedDate]);
const { tasks: selectedDayTasks, refresh: refreshDayTasks } = useTasksByDate(selectedDate);
const monthTasks = useTasksInMonth(visibleMonth); const monthTasks = useTasksInMonth(visibleMonth);
const refreshMonthTasks = monthTasks.refresh;
const sortedDayTasks = useMemo(() => {
const sorted = [...selectedDayTasks];
switch (sortBy) {
case 'date':
sorted.sort((a, b) => (a.dueDate || Number.MAX_SAFE_INTEGER) - (b.dueDate || Number.MAX_SAFE_INTEGER));
break;
case 'priority':
sorted.sort((a, b) => PRIORITY_RANK[b.priority] - PRIORITY_RANK[a.priority]);
break;
case 'alpha':
sorted.sort((a, b) => a.title.localeCompare(b.title));
break;
case 'created':
sorted.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
break;
}
return sorted;
}, [selectedDayTasks, sortBy]);
useFocusEffect(
useCallback(() => {
refreshDayTasks();
refreshMonthTasks();
refreshSubtasks();
}, [refreshDayTasks, refreshMonthTasks, refreshSubtasks])
);
const weeks = useMemo(() => { const weeks = useMemo(() => {
const first = startOfMonth(visibleMonth); const first = startOfMonth(visibleMonth);
@@ -67,70 +103,25 @@ export default function CalendarScreen() {
return rows; return rows;
}, [visibleMonth]); }, [visibleMonth]);
const loadSubtasks = useCallback(
async (taskId: string): Promise<[string, SubtaskData[]]> => {
const subs = await collections.subtasks
.query(Q.where('task_id', taskId), Q.where('parent_subtask_id', null), Q.sortBy('order', 'asc'))
.fetch();
const mapped: SubtaskData[] = subs.map((s: any) => ({
id: s.id,
taskId: s.taskId,
parentSubtaskId: s.parentSubtaskId || null,
title: s.title,
description: s.description || '',
priority: (s.priority || 'none') as SubtaskData['priority'],
completed: s.completed,
dueDate: s.dueDate || 0,
dueTime: s.dueTime || '',
endTime: s.endTime || '',
allDay: s.allDay ?? false,
repeat: (s.repeat || 'none') as SubtaskData['repeat'],
repeatInterval: s.repeatInterval ?? 1,
repeatDays: s.repeatDays || '',
seriesId: s.seriesId || '',
reminder: (s.reminder || 'none') as SubtaskData['reminder'],
assigneeId: s.assigneeId ?? null,
order: s.order,
subtasks: [],
}));
return [taskId, mapped];
},
[collections.subtasks]
);
useEffect(() => {
let active = true;
setSubtasksMap({});
Promise.all(selectedDayTasks.map((t) => loadSubtasks(t.id))).then((entries) => {
if (!active) return;
setSubtasksMap(Object.fromEntries(entries));
});
return () => {
active = false;
};
}, [selectedDayTasks, loadSubtasks]);
const handleDayPress = useCallback((day: Date) => {
setSelectedDate(day);
if (!isSameMonth(day, visibleMonthRef.current)) {
setVisibleMonth(day);
}
}, []);
const transitionTo = useCallback( const transitionTo = useCallback(
(dir: 1 | -1) => { (dir: 1 | -1, animate = true) => {
if (animatingRef.current) return; if (animatingRef.current) return;
const next = addMonths(visibleMonthRef.current, dir);
if (!isSameMonth(selectedDateRef.current, next)) {
setSelectedDate(startOfMonth(next));
}
if (!animate) {
setVisibleMonth(next);
translateX.current.setValue(0);
return;
}
animatingRef.current = true; animatingRef.current = true;
const w = gridWidthRef.current || WIDTH; const w = gridWidthRef.current || WIDTH;
const target = dir === 1 ? -w : w; const target = dir === 1 ? -w : w;
Animated.timing(translateX, { toValue: target, duration: 220, useNativeDriver: false }).start(() => { Animated.timing(translateX.current, { toValue: target, duration: 220, useNativeDriver: false }).start(() => {
const next = addMonths(visibleMonthRef.current, dir);
if (!isSameMonth(selectedDateRef.current, next)) {
setSelectedDate(startOfMonth(next));
}
setVisibleMonth(next); setVisibleMonth(next);
translateX.setValue(-target); translateX.current.setValue(-target);
Animated.timing(translateX, { toValue: 0, duration: 180, useNativeDriver: false }).start(() => { Animated.timing(translateX.current, { toValue: 0, duration: 180, useNativeDriver: false }).start(() => {
animatingRef.current = false; animatingRef.current = false;
}); });
}); });
@@ -138,27 +129,41 @@ export default function CalendarScreen() {
[translateX] [translateX]
); );
const handleDayPress = useCallback(
(day: Date) => {
setSelectedDate(day);
if (!isSameMonth(day, visibleMonthRef.current)) {
transitionTo(day > visibleMonthRef.current ? 1 : -1, true);
} else {
router.push({ pathname: '/day-view', params: { date: day.toISOString() } });
}
},
[router, transitionTo]
);
const pan = useMemo( const pan = useMemo(
() => () =>
Gesture.Pan() Gesture.Pan()
.activeOffsetX([-16, 16]) .activeOffsetX([-16, 16])
.minDistance(6) .minDistance(6)
.runOnJS(true) .runOnJS(true)
// eslint-disable-next-line react-hooks/refs
.onUpdate((e) => { .onUpdate((e) => {
if (!animatingRef.current) translateX.setValue(e.translationX); if (!animatingRef.current) translateX.current.setValue(e.translationX);
}) })
// eslint-disable-next-line react-hooks/refs
.onEnd((e) => { .onEnd((e) => {
if (animatingRef.current) return; if (animatingRef.current) return;
const w = gridWidthRef.current || WIDTH; const w = gridWidthRef.current || WIDTH;
const dx = e.translationX; const dx = e.translationX;
if (dx <= -w / 4) { if (dx <= -w / 4) {
translateX.stopAnimation(); translateX.current.stopAnimation();
transitionTo(1); transitionTo(1, false);
} else if (dx >= w / 4) { } else if (dx >= w / 4) {
translateX.stopAnimation(); translateX.current.stopAnimation();
transitionTo(-1); transitionTo(-1, false);
} else { } else {
Animated.spring(translateX, { toValue: 0, useNativeDriver: false }).start(); Animated.spring(translateX.current, { toValue: 0, useNativeDriver: false }).start();
} }
}), }),
[translateX, transitionTo] [translateX, transitionTo]
@@ -183,9 +188,34 @@ export default function CalendarScreen() {
}, [currentYear]); }, [currentYear]);
const handleToggleComplete = useCallback(async (taskId: string) => { const handleToggleComplete = useCallback(async (taskId: string) => {
await toggleTaskComplete(taskId); await confirmAndToggleTaskComplete(taskId, () => {
refreshDayTasks();
refreshMonthTasks();
refreshSubtasks();
});
}, [refreshDayTasks, refreshMonthTasks, refreshSubtasks]);
const [expandedTasks, setExpandedTasks] = useState<Set<string>>(new Set());
const toggleExpand = useCallback((taskId: string) => {
setExpandedTasks((prev) => {
const next = new Set(prev);
if (next.has(taskId)) {
next.delete(taskId);
} else {
next.add(taskId);
}
return next;
});
}, []); }, []);
const handleSubtaskToggle = useCallback(async (subtaskId: string) => {
await toggleSubtaskComplete(subtaskId);
refreshDayTasks();
refreshMonthTasks();
refreshSubtasks();
}, [refreshDayTasks, refreshMonthTasks, refreshSubtasks]);
return ( return (
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}> <SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
<Header title="Calendar" showLogo={false} /> <Header title="Calendar" showLogo={false} />
@@ -197,7 +227,8 @@ export default function CalendarScreen() {
onLayout={(e) => { onLayout={(e) => {
gridWidthRef.current = e.nativeEvent.layout.width; gridWidthRef.current = e.nativeEvent.layout.width;
}} }}
style={[styles.calendarArea, { transform: [{ translateX }] }]} // eslint-disable-next-line react-hooks/refs
style={[styles.calendarArea, { transform: [{ translateX: translateX.current }] }]}
> >
<View style={styles.monthRow}> <View style={styles.monthRow}>
<TouchableOpacity <TouchableOpacity
@@ -217,11 +248,12 @@ export default function CalendarScreen() {
<Path d="M6 9l6 6 6-6" stroke={theme.textSecondary} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" /> <Path d="M6 9l6 6 6-6" stroke={theme.textSecondary} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
</Svg> </Svg>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity onPress={() => setYearPickerVisible(true)} activeOpacity={0.7} style={styles.yearButton}>
<Text style={[styles.yearLabel, { color: theme.textMuted }]}>{format(visibleMonth, 'yyyy')}</Text>
</TouchableOpacity>
</View> </View>
<TouchableOpacity onPress={() => setYearPickerVisible(true)} activeOpacity={0.7} style={styles.yearButton}>
<Text style={[styles.yearLabel, { color: theme.textMuted }]}>{format(visibleMonth, 'yyyy')}</Text>
</TouchableOpacity>
<TouchableOpacity <TouchableOpacity
onPress={() => transitionTo(1)} onPress={() => transitionTo(1)}
style={[styles.monthNav, { borderColor: theme.borderStrong, backgroundColor: theme.card }]} style={[styles.monthNav, { borderColor: theme.borderStrong, backgroundColor: theme.card }]}
@@ -248,7 +280,7 @@ export default function CalendarScreen() {
key={day.toISOString()} key={day.toISOString()}
day={day} day={day}
label={cellTitle(byDayOf(monthTasks.byDay, day))} label={cellTitle(byDayOf(monthTasks.byDay, day))}
dotColor={cellColor(byDayOf(monthTasks.byDay, day), categories)} dotColor={cellColor(byDayOf(monthTasks.byDay, day), categories, theme)}
selected={isSameDay(day, selectedDate)} selected={isSameDay(day, selectedDate)}
today={isToday(day)} today={isToday(day)}
inMonth={isSameMonth(day, visibleMonth)} inMonth={isSameMonth(day, visibleMonth)}
@@ -263,87 +295,48 @@ export default function CalendarScreen() {
<View style={styles.panelHeader}> <View style={styles.panelHeader}>
<Text style={[styles.panelDate, { color: theme.text }]}>{format(selectedDate, 'EEEE, MMMM d')}</Text> <Text style={[styles.panelDate, { color: theme.text }]}>{format(selectedDate, 'EEEE, MMMM d')}</Text>
{selectedDayTasks.length > 0 && ( {sortedDayTasks.length > 0 && (
<Text style={[styles.panelCount, { color: theme.textFaint }]}> <Text style={[styles.panelCount, { color: theme.textFaint }]}>
{selectedDayTasks.length} event{selectedDayTasks.length === 1 ? '' : 's'} {sortedDayTasks.length} event{sortedDayTasks.length === 1 ? '' : 's'}
</Text> </Text>
)} )}
</View> </View>
{selectedDayTasks.length === 0 ? ( {sortedDayTasks.length === 0 ? (
<View style={styles.emptyState}> <View style={styles.emptyState}>
<Text style={[styles.emptyText, { color: theme.textSecondary }]}>No events on this day</Text> <Text style={[styles.emptyText, { color: theme.textSecondary }]}>No events on this day</Text>
<Text style={[styles.emptySubtext, { color: theme.textMuted }]}>Tap a date or use the bar below</Text> <Text style={[styles.emptySubtext, { color: theme.textMuted }]}>Tap a date or use the bar below</Text>
</View> </View>
) : ( ) : (
selectedDayTasks.map((task) => ( sortedDayTasks.map((task) => {
<View key={task.id} style={[styles.eventCard, { backgroundColor: theme.card, borderColor: theme.border }]}> const isExpanded = expandedTasks.has(task.id);
<View style={styles.eventRow}> const itemSubtasks = subtasksByTask.get(task.id) ?? [];
<TouchableOpacity return (
style={styles.checkCircle} <View key={task.id} style={styles.eventCard}>
onPress={() => handleToggleComplete(task.id)} <CalendarTaskRow
activeOpacity={0.7} task={task}
> expanded={isExpanded}
<Svg width={30} height={30} viewBox="0 0 24 24"> subtasks={itemSubtasks}
{task.completed ? ( categoryColor={categoryColors.get(task.categoryId)}
<> onToggle={() => handleToggleComplete(task.id)}
<Circle cx={12} cy={12} r={10} fill={theme.accent} /> onPress={(id) =>
<Path d="M7 12l3 3 7-7" stroke="#FFFFFF" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" /> expandedTasks.has(id)
</> ? router.push({ pathname: '/task-detail', params: { id } })
) : ( : toggleExpand(id)
<Circle cx={12} cy={12} r={10} stroke={theme.textSecondary} strokeWidth={2.5} fill="none" /> }
)} onMenuOpen={(id) => openTaskEdit(id)}
</Svg> onSubtaskToggle={handleSubtaskToggle}
</TouchableOpacity> />
<TouchableOpacity
style={styles.eventTitleTouch}
onPress={() => router.push({ pathname: '/task-detail', params: { id: task.id } })}
activeOpacity={0.7}
>
<Text
style={[styles.eventTitle, { color: theme.text }, task.completed && styles.eventCompleted]}
numberOfLines={1}
>
{task.title}
</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.menuButton} onPress={() => openTaskEdit(task.id)} activeOpacity={0.7} accessibilityRole="button" accessibilityLabel={`Edit ${task.title}`}>
<Svg width={20} height={20} viewBox="0 0 24 24">
<Path d="M9 6l6 6-6 6" stroke={theme.textSecondary} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
</Svg>
</TouchableOpacity>
</View> </View>
);
{(subtasksMap[task.id] ?? []).length > 0 && ( })
<View style={styles.bullets}>
{(subtasksMap[task.id] ?? []).map((sub) => (
<TouchableOpacity
key={sub.id}
style={styles.bulletRow}
onPress={() => router.push({ pathname: '/subtask-detail', params: { id: sub.id } })}
activeOpacity={0.7}
>
<Svg width={5} height={5} viewBox="0 0 6 6" style={styles.bulletDot as any}>
<Circle cx={3} cy={3} r={3} fill={theme.textMuted} />
</Svg>
<Text
style={[styles.bulletText, { color: theme.textFaint }, sub.completed && styles.bulletCompleted]}
numberOfLines={1}
>
{sub.title}
</Text>
</TouchableOpacity>
))}
</View>
)}
</View>
))
)} )}
</ScrollView> </ScrollView>
<QuickAddBar dueDate={selectedDate.getTime()} placeholder={`Add event for ${format(selectedDate, 'MMM d')}`} /> <QuickAddBar
dueDate={selectedDate.getTime()}
placeholder={`Add event for ${format(selectedDate, 'MMM d')}`}
/>
</KeyboardAvoidingView> </KeyboardAvoidingView>
{modals(() => {})} {modals(() => {})}
@@ -372,16 +365,69 @@ function byDayOf(byDay: Record<number, Task[]>, day: Date): Task[] {
return byDay[day.getDate()] ?? []; return byDay[day.getDate()] ?? [];
} }
interface CalendarTaskRowProps {
task: Task;
expanded: boolean;
subtasks: SubtaskData[];
categoryColor?: string;
onToggle: (taskId: string) => void | Promise<void>;
onPress: (taskId: string) => void;
onMenuOpen: (taskId: string) => void;
onSubtaskToggle: (subtaskId: string) => void | Promise<void>;
}
const CalendarTaskRow = React.memo(function CalendarTaskRow({
task,
expanded,
subtasks,
categoryColor,
onToggle,
onPress,
onMenuOpen,
onSubtaskToggle,
}: CalendarTaskRowProps) {
const { theme } = useSettings();
const styles = React.useMemo(() => createStyles(theme), [theme]);
const sortedSubtasks = useMemo(
() => subtasks.slice().sort((a, b) => a.order - b.order),
[subtasks]
);
return (
<View>
<TaskItem
task={task as unknown as TaskData}
onToggle={() => onToggle(task.id)}
onPress={() => onPress(task.id)}
onMenuOpen={() => onMenuOpen(task.id)}
expanded={expanded}
categoryColor={categoryColor}
/>
{expanded && sortedSubtasks.length > 0 && (
<View style={styles.subtaskList}>
{sortedSubtasks.map((sub) => (
<SubtaskItem
key={sub.id}
subtask={sub}
depth={1}
categoryColor={categoryColor}
onToggle={() => onSubtaskToggle(sub.id)}
/>
))}
</View>
)}
</View>
);
});
function cellTitle(tasks: Task[]): string | null { function cellTitle(tasks: Task[]): string | null {
if (tasks.length === 0) return null; if (tasks.length === 0) return null;
if (tasks.length === 1) return tasks[0].title; if (tasks.length === 1) return tasks[0].title;
return `${tasks[0].title} +${tasks.length - 1}`; return `${tasks[0].title} +${tasks.length - 1}`;
} }
function cellColor(tasks: Task[], categories: { id: string; color: string }[]): string { function cellColor(tasks: Task[], categories: { id: string; color: string }[], theme: ThemeColors): string {
if (tasks.length === 0) return '#8E8E8E'; if (tasks.length === 0) return theme.textMuted;
const cat = categories.find((c) => c.id === tasks[0].categoryId); return theme.accent;
return cat ? desaturate(cat.color, 0.3) : '#8E8E8E';
} }
interface DayCellProps { interface DayCellProps {
@@ -396,6 +442,7 @@ interface DayCellProps {
} }
const DayCell = React.memo(function DayCell({ day, label, dotColor, selected, today, inMonth, theme, onPress }: DayCellProps) { const DayCell = React.memo(function DayCell({ day, label, dotColor, selected, today, inMonth, theme, onPress }: DayCellProps) {
const styles = React.useMemo(() => createStyles(theme), [theme]);
return ( return (
<TouchableOpacity <TouchableOpacity
style={[styles.dayCell, selected && { backgroundColor: theme.accentSoft, borderColor: theme.accentBorder }]} style={[styles.dayCell, selected && { backgroundColor: theme.accentSoft, borderColor: theme.accentBorder }]}
@@ -426,7 +473,7 @@ const DayCell = React.memo(function DayCell({ day, label, dotColor, selected, to
); );
}); });
const styles = StyleSheet.create({ const createStyles = (theme: ThemeColors) => StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
}, },
@@ -461,7 +508,8 @@ const styles = StyleSheet.create({
justifyContent: 'center', justifyContent: 'center',
}, },
monthSelectorGroup: { monthSelectorGroup: {
alignItems: 'center', alignItems: 'flex-start',
flex: 1,
}, },
monthButton: { monthButton: {
flexDirection: 'row', flexDirection: 'row',
@@ -476,10 +524,11 @@ const styles = StyleSheet.create({
}, },
yearButton: { yearButton: {
marginTop: -2, marginTop: -2,
paddingHorizontal: 6,
}, },
yearLabel: { yearLabel: {
fontSize: 13, fontSize: 17,
fontWeight: '500', fontWeight: '700',
}, },
weekdayRow: { weekdayRow: {
flexDirection: 'row', flexDirection: 'row',
@@ -512,7 +561,7 @@ const styles = StyleSheet.create({
fontWeight: '600', fontWeight: '600',
}, },
dayNumberSelected: { dayNumberSelected: {
color: '#FFFFFF', color: theme.text,
}, },
chip: { chip: {
marginTop: 3, marginTop: 3,
@@ -563,55 +612,10 @@ const styles = StyleSheet.create({
eventCard: { eventCard: {
marginHorizontal: 16, marginHorizontal: 16,
marginTop: 6, marginTop: 6,
borderRadius: 16,
borderWidth: 1,
paddingHorizontal: 14,
paddingVertical: 12,
}, },
eventRow: { subtaskList: {
flexDirection: 'row', paddingLeft: 8,
alignItems: 'center', paddingRight: 4,
}, paddingTop: 4,
checkCircle: {
width: 30,
marginRight: 12,
},
eventTitleTouch: {
flex: 1,
},
eventTitle: {
fontSize: 15,
fontWeight: '500',
},
eventCompleted: {
textDecorationLine: 'line-through',
color: '#9E9E9E',
},
menuButton: {
padding: 4,
marginLeft: 6,
},
bullets: {
marginTop: 8,
paddingTop: 8,
borderTopWidth: 1,
borderTopColor: 'rgba(255,255,255,0.06)',
gap: 6,
},
bulletRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
bulletDot: {
marginLeft: 8,
},
bulletText: {
flex: 1,
fontSize: 13,
},
bulletCompleted: {
textDecorationLine: 'line-through',
color: '#6E6E6E',
}, },
}); });
+55 -12
View File
@@ -1,5 +1,5 @@
import React, { useCallback } from 'react'; import React, { useCallback, useMemo, useState } from 'react';
import { View, Text, StyleSheet, SafeAreaView, BackHandler, KeyboardAvoidingView } from 'react-native'; import { View, Text, StyleSheet, SafeAreaView, KeyboardAvoidingView, TouchableOpacity, BackHandler } from 'react-native';
import { useFocusEffect } from 'expo-router'; import { useFocusEffect } from 'expo-router';
import { Header } from '@/components/Header'; import { Header } from '@/components/Header';
import { CategoryFilter } from '@/components/CategoryFilter'; import { CategoryFilter } from '@/components/CategoryFilter';
@@ -7,15 +7,18 @@ import { TaskList } from '@/components/TaskList';
import { QuickAddBar } from '@/components/QuickAddBar'; import { QuickAddBar } from '@/components/QuickAddBar';
import { useDatabase } from '@/hooks/useDatabase'; import { useDatabase } from '@/hooks/useDatabase';
import { useSettings } from '@/theme'; import { useSettings } from '@/theme';
import Svg, { Path, Circle } from 'react-native-svg';
export default function TasksScreen() { export default function TasksScreen() {
const { isReady } = useDatabase(); const { isReady } = useDatabase();
const { theme } = useSettings(); const { theme, showCompleted, setShowCompleted } = useSettings();
const [selectedCategory, setSelectedCategory] = React.useState<string>('all'); const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
const categoryIds = useMemo(() => selectedCategories, [selectedCategories]);
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
const sub = BackHandler.addEventListener('hardwareBackPress', () => true); const sub = BackHandler.addEventListener('hardwareBackPress', () => false);
return () => sub.remove(); return () => sub.remove();
}, []) }, [])
); );
@@ -32,13 +35,41 @@ export default function TasksScreen() {
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}> <SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
<Header title="ToDo" showLogo={false} /> <Header title="ToDo" showLogo={false} />
<View style={styles.categoryFilterWrapper}> <View style={styles.categoryFilterWrapper}>
<CategoryFilter selected={selectedCategory} onSelect={setSelectedCategory} /> <CategoryFilter selected={selectedCategories} onSelect={setSelectedCategories} />
<TouchableOpacity
style={[
styles.completedToggle,
{ backgroundColor: theme.card, borderColor: showCompleted ? theme.accent : theme.borderStrong },
]}
onPress={() => setShowCompleted(!showCompleted)}
activeOpacity={0.8}
accessibilityRole="switch"
accessibilityLabel="Show completed tasks"
accessibilityState={{ checked: showCompleted }}
>
<Svg width={14} height={14} viewBox="0 0 24 24">
<Circle
cx={12}
cy={12}
r={9}
stroke={showCompleted ? theme.accent : theme.textMuted}
strokeWidth={2}
fill="none"
/>
{showCompleted && (
<Path d="M7 12.5l3.5 3.5 6.5-7" stroke={theme.accent} strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" fill="none" />
)}
</Svg>
<Text style={[styles.completedToggleText, { color: showCompleted ? theme.text : theme.textMuted }]}>
Completed
</Text>
</TouchableOpacity>
</View> </View>
<KeyboardAvoidingView <KeyboardAvoidingView
style={styles.kbAvoid} style={styles.kbAvoid}
behavior="padding" behavior="padding"
> >
<TaskList categoryId={selectedCategory} /> <TaskList categoryIds={categoryIds} showCompleted={showCompleted} />
<QuickAddBar /> <QuickAddBar />
</KeyboardAvoidingView> </KeyboardAvoidingView>
</SafeAreaView> </SafeAreaView>
@@ -51,13 +82,25 @@ const styles = StyleSheet.create({
}, },
categoryFilterWrapper: { categoryFilterWrapper: {
justifyContent: 'center', justifyContent: 'center',
flexDirection: 'row',
alignItems: 'center',
paddingRight: 12,
},
completedToggle: {
flexDirection: 'row',
alignItems: 'center',
gap: 5,
paddingHorizontal: 10,
paddingVertical: 8,
borderRadius: 18,
borderWidth: 1.5,
alignSelf: 'center',
},
completedToggleText: {
fontSize: 12,
fontWeight: '600',
}, },
kbAvoid: { kbAvoid: {
flex: 1, flex: 1,
}, },
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
}); });
+82 -7
View File
@@ -15,6 +15,8 @@ import { useCategories } from '@/hooks/useDatabase';
import { getAuthUser, getAuthToken } from '@/services/auth'; import { getAuthUser, getAuthToken } from '@/services/auth';
import { checkForUpdates, getCurrentAppVersion } from '@/services/updates'; import { checkForUpdates, getCurrentAppVersion } from '@/services/updates';
import { getLastSyncTime } from '@/database/sync'; import { getLastSyncTime } from '@/database/sync';
import { rescheduleAllReminders } from '@/services/notifications';
import { collections } from '@/database';
import Category from '@/models/Category'; import Category from '@/models/Category';
import Svg, { Path } from 'react-native-svg'; import Svg, { Path } from 'react-native-svg';
import ColorWheel from '@/components/ColorWheel'; import ColorWheel from '@/components/ColorWheel';
@@ -22,9 +24,10 @@ import { getLastVisitedTab, tabHref } from '@/utils/tabHistory';
export default function SettingsScreen() { export default function SettingsScreen() {
const router = useRouter(); const router = useRouter();
const { theme, notifications, setNotifications, defaultCategoryId, setDefaultCategoryId, sortBy, setSortBy, reminderPreference, setReminderPreference, apiUrl, accentColor, setAccentColor, todoAheadDays, setTodoAheadDays } = useSettings(); const { theme, notifications, setNotifications, defaultCategoryId, setDefaultCategoryId, sortBy, setSortBy, reminderPreference, setReminderPreference, tagReminderPreferences, setTagReminderPreferences, apiUrl, accentColor, setAccentColor, todoAheadDays, setTodoAheadDays, showCompleted, setShowCompleted, deleteBySwipe, setDeleteBySwipe } = useSettings();
const categories = useCategories(); const categories = useCategories();
const [picker, setPicker] = useState<null | 'category' | 'sort' | 'reminder' | 'todoAhead'>(null); const [picker, setPicker] = useState<null | 'category' | 'sort' | 'reminder' | 'todoAhead'>(null);
const [reminderTagPicker, setReminderTagPicker] = useState<Category | null>(null);
const [editingCategory, setEditingCategory] = useState<Category | null | 'new'>(null); const [editingCategory, setEditingCategory] = useState<Category | null | 'new'>(null);
const [syncVisible, setSyncVisible] = useState(false); const [syncVisible, setSyncVisible] = useState(false);
const [friendsVisible, setFriendsVisible] = useState(false); const [friendsVisible, setFriendsVisible] = useState(false);
@@ -95,6 +98,14 @@ export default function SettingsScreen() {
const defaultCategoryLabel = defaultCategory?.name ?? 'None'; const defaultCategoryLabel = defaultCategory?.name ?? 'None';
const reminderLabel = REMINDER_OPTIONS.find((o) => o.value === reminderPreference)?.label ?? 'No reminder'; const reminderLabel = REMINDER_OPTIONS.find((o) => o.value === reminderPreference)?.label ?? 'No reminder';
const tagReminderLabel = (categoryId: string) => {
const selected = tagReminderPreferences[categoryId] ?? [];
if (selected.length === 0) return `Default (${reminderLabel})`;
return selected
.map((v) => REMINDER_OPTIONS.find((o) => o.value === v)?.label ?? v)
.join(', ');
};
const openCustomAccent = () => { const openCustomAccent = () => {
setDraftAccent(accentColor); setDraftAccent(accentColor);
setCustomAccentVisible(true); setCustomAccentVisible(true);
@@ -108,6 +119,20 @@ export default function SettingsScreen() {
setReminderPreference((Array.isArray(value) ? value[0] : value) as typeof reminderPreference); setReminderPreference((Array.isArray(value) ? value[0] : value) as typeof reminderPreference);
}; };
const handleTagReminderPreference = async (categoryId: string, value: string | string[]) => {
const selected = (Array.isArray(value) ? value : [value]).filter(
(v): v is typeof reminderPreference => v !== 'none'
);
setTagReminderPreferences({ ...tagReminderPreferences, [categoryId]: selected });
if (!notifications) return;
try {
const tasks = await collections.tasks.query().fetch();
await rescheduleAllReminders(tasks, notifications, reminderPreference);
} catch {
// ignore reschedule failures
}
};
const handleTodoAheadDays = (value: string | string[]) => { const handleTodoAheadDays = (value: string | string[]) => {
setTodoAheadDays(parseInt(Array.isArray(value) ? value[0] : value, 10)); setTodoAheadDays(parseInt(Array.isArray(value) ? value[0] : value, 10));
}; };
@@ -119,12 +144,12 @@ export default function SettingsScreen() {
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}> <SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
<Header title="Settings" showLogo={false} /> <Header title="Settings" showLogo={false} />
<ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}> <ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
<Text style={[styles.sectionTitle, { color: theme.textMuted }]}>Categories</Text> <Text style={[styles.sectionTitle, { color: theme.textMuted }]}>Tags</Text>
{categories.map((category) => ( {categories.map((category) => (
<ListItem <ListItem
key={category.id} key={category.id}
title={category.name} title={category.name}
subtitle={category.id === defaultCategoryId ? 'Default category' : undefined} subtitle={category.id === defaultCategoryId ? 'Default tag' : undefined}
leftElement={ leftElement={
<View style={[styles.categoryDot, { backgroundColor: category.color }]} /> <View style={[styles.categoryDot, { backgroundColor: category.color }]} />
} }
@@ -142,7 +167,7 @@ export default function SettingsScreen() {
<Path d="M12 5v14M5 12h14" stroke={theme.accentText} strokeWidth={2} strokeLinecap="round" /> <Path d="M12 5v14M5 12h14" stroke={theme.accentText} strokeWidth={2} strokeLinecap="round" />
</Svg> </Svg>
</View> </View>
<Text style={[styles.addCategoryText, { color: theme.accent }]}>Add Category</Text> <Text style={[styles.addCategoryText, { color: theme.accent }]}>Add Tag</Text>
</TouchableOpacity> </TouchableOpacity>
<Text style={[styles.sectionTitle, { color: theme.textMuted }]}>Notifications</Text> <Text style={[styles.sectionTitle, { color: theme.textMuted }]}>Notifications</Text>
<ListItem <ListItem
@@ -163,9 +188,21 @@ export default function SettingsScreen() {
onPress={() => notifications && setPicker('reminder')} onPress={() => notifications && setPicker('reminder')}
showChevron showChevron
/> />
{categories.map((category) => (
<ListItem
key={`tag-reminder-${category.id}`}
title={category.name}
subtitle={notifications ? tagReminderLabel(category.id) : 'Enable notifications first'}
leftElement={
<View style={[styles.categoryDot, { backgroundColor: category.color }]} />
}
onPress={() => notifications && setReminderTagPicker(category)}
showChevron
/>
))}
<Text style={[styles.sectionTitle, { color: theme.textMuted }]}>Tasks</Text> <Text style={[styles.sectionTitle, { color: theme.textMuted }]}>Tasks</Text>
<ListItem <ListItem
title="Default Category" title="Default Tag"
subtitle={defaultCategoryLabel} subtitle={defaultCategoryLabel}
onPress={() => setPicker('category')} onPress={() => setPicker('category')}
showChevron showChevron
@@ -176,6 +213,31 @@ export default function SettingsScreen() {
onPress={() => setPicker('sort')} onPress={() => setPicker('sort')}
showChevron showChevron
/> />
<ListItem
title="Show Completed Tasks"
subtitle="Hide or show finished tasks in the Todo list"
rightElement={
<Switch
value={showCompleted}
onValueChange={setShowCompleted}
thumbColor="#FFFFFF"
trackColor={{ false: theme.borderStrong, true: theme.accent }}
/>
}
/>
<ListItem
title="Delete by Swiping"
subtitle="Swipe right to delete task"
rightElement={
<Switch
value={deleteBySwipe}
onValueChange={setDeleteBySwipe}
thumbColor="#FFFFFF"
trackColor={{ false: theme.borderStrong, true: theme.accent }}
/>
}
showChevron
/>
<ListItem <ListItem
title="Show Calendar Tasks" title="Show Calendar Tasks"
subtitle={todoAheadDays === 0 ? 'Only today' : `Up to ${todoAheadDays} days ahead`} subtitle={todoAheadDays === 0 ? 'Only today' : `Up to ${todoAheadDays} days ahead`}
@@ -257,8 +319,8 @@ export default function SettingsScreen() {
<OptionPickerModal <OptionPickerModal
visible={picker === 'category'} visible={picker === 'category'}
title="Default Category" title="Default Tag"
options={[{ value: '', label: 'None', color: '#9E9E9E' }, ...categories.map((c) => ({ value: c.id, label: c.name, color: c.color }))]} options={[{ value: '', label: 'None', color: theme.textMuted }, ...categories.map((c) => ({ value: c.id, label: c.name, color: c.color }))]}
selectedValue={defaultCategoryId} selectedValue={defaultCategoryId}
onSelect={handleDefaultCategory} onSelect={handleDefaultCategory}
onClose={() => setPicker(null)} onClose={() => setPicker(null)}
@@ -282,6 +344,19 @@ export default function SettingsScreen() {
onClose={() => setPicker(null)} onClose={() => setPicker(null)}
/> />
{categories.map((category) => (
<OptionPickerModal
key={`tag-reminder-picker-${category.id}`}
visible={reminderTagPicker?.id === category.id}
title={`Reminders · ${category.name}`}
options={REMINDER_OPTIONS.filter((o) => o.value !== 'none')}
selectedValue={tagReminderPreferences[category.id] ?? []}
multiSelect
onSelect={(value) => handleTagReminderPreference(category.id, value)}
onClose={() => setReminderTagPicker(null)}
/>
))}
<OptionPickerModal <OptionPickerModal
visible={picker === 'todoAhead'} visible={picker === 'todoAhead'}
title="Calendar Tasks in Todo" title="Calendar Tasks in Todo"
+12 -12
View File
@@ -1,9 +1,9 @@
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import { View, Text, StyleSheet, SafeAreaView, ScrollView, BackHandler } from 'react-native'; import { View, Text, StyleSheet, SafeAreaView, ScrollView } from 'react-native';
import { useFocusEffect } from 'expo-router'; import { useFocusEffect } from 'expo-router';
import { Header } from '@/components/Header'; import { Header } from '@/components/Header';
import { useStats } from '@/hooks/useStats'; import { useStats } from '@/hooks/useStats';
import { useSettings } from '@/theme'; import { useSettings, type ThemeColors } from '@/theme';
export default function StatsScreen() { export default function StatsScreen() {
const { theme } = useSettings(); const { theme } = useSettings();
@@ -11,8 +11,7 @@ export default function StatsScreen() {
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
const sub = BackHandler.addEventListener('hardwareBackPress', () => true); return () => {};
return () => sub.remove();
}, []) }, [])
); );
@@ -77,7 +76,7 @@ export default function StatsScreen() {
</View> </View>
<View style={[styles.card, { backgroundColor: theme.card }]}> <View style={[styles.card, { backgroundColor: theme.card }]}>
<Text style={[styles.cardTitle, { color: theme.textMuted }]}>By category</Text> <Text style={[styles.cardTitle, { color: theme.textMuted }]}>By tags</Text>
{stats.byCategory.length === 0 ? ( {stats.byCategory.length === 0 ? (
<Text style={[styles.emptyText, { color: theme.textFaint }]}>No completed tasks yet</Text> <Text style={[styles.emptyText, { color: theme.textFaint }]}>No completed tasks yet</Text>
) : ( ) : (
@@ -98,7 +97,7 @@ export default function StatsScreen() {
) : ( ) : (
stats.byPriority.map((p) => ( stats.byPriority.map((p) => (
<View key={p.priority} style={styles.row}> <View key={p.priority} style={styles.row}>
<View style={[styles.dot, { backgroundColor: priorityColor(p.priority) }]} /> <View style={[styles.dot, { backgroundColor: priorityColor(theme, p.priority) }]} />
<Text style={[styles.rowLabel, { color: theme.text }]}>{p.label}</Text> <Text style={[styles.rowLabel, { color: theme.text }]}>{p.label}</Text>
<Text style={[styles.rowValue, { color: theme.textMuted }]}>{p.count}</Text> <Text style={[styles.rowValue, { color: theme.textMuted }]}>{p.count}</Text>
</View> </View>
@@ -112,16 +111,17 @@ export default function StatsScreen() {
const BASE_HEIGHT = 90; const BASE_HEIGHT = 90;
function priorityColor(priority: string): string { function priorityColor(theme: ThemeColors, priority: string): string {
switch (priority) { switch (priority) {
case 'critical':
return theme.dangerStrong;
case 'high': case 'high':
return '#EF5350'; return theme.warning;
case 'medium':
return '#FFA726';
case 'low': case 'low':
return '#66BB6A'; case 'medium':
return theme.success;
default: default:
return '#9E9E9E'; return theme.textMuted;
} }
} }
+38
View File
@@ -8,11 +8,46 @@ import { UpdateNotifier } from '@/components/UpdateNotifier';
import { Linking } from 'react-native'; import { Linking } from 'react-native';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { requestQuickAddFocus } from '@/utils/quickAddFocus'; import { requestQuickAddFocus } from '@/utils/quickAddFocus';
import { registerForPushNotificationsAsync, setupNotificationChannels, addNotificationResponseReceivedListener, removeNotificationSubscription } from '@/services/pushNotifications';
import * as Notifications from 'expo-notifications';
import { Platform } from 'react-native';
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: true,
shouldShowBanner: true,
shouldShowList: true,
}),
});
function RootNavigator() { function RootNavigator() {
const router = useRouter(); const router = useRouter();
useEffect(() => { useEffect(() => {
setupNotificationChannels();
registerForPushNotificationsAsync().catch(() => {});
const responseListener = addNotificationResponseReceivedListener((response) => {
const data = response.notification.request.content.data as Record<string, any> | undefined;
if (!data) return;
switch (data.type) {
case 'task_reminder':
case 'task_assignment':
if (data.taskId) {
router.push({ pathname: '/task-detail', params: { id: data.taskId } });
}
break;
case 'friend_request':
if (data.friendshipId) {
router.push({ pathname: '/(tabs)/settings' });
}
break;
}
});
const handleUrl = (url: string | null) => { const handleUrl = (url: string | null) => {
if (!url || !url.includes('://quick-add')) return; if (!url || !url.includes('://quick-add')) return;
router.replace('/(tabs)'); router.replace('/(tabs)');
@@ -21,8 +56,10 @@ function RootNavigator() {
Linking.getInitialURL().then(handleUrl).catch(() => {}); Linking.getInitialURL().then(handleUrl).catch(() => {});
const subscription = Linking.addEventListener('url', ({ url }) => handleUrl(url)); const subscription = Linking.addEventListener('url', ({ url }) => handleUrl(url));
return () => { return () => {
subscription.remove(); subscription.remove();
removeNotificationSubscription(responseListener);
}; };
}, [router]); }, [router]);
@@ -33,6 +70,7 @@ function RootNavigator() {
<Stack.Screen name="(tabs)" /> <Stack.Screen name="(tabs)" />
<Stack.Screen name="add-task" /> <Stack.Screen name="add-task" />
<Stack.Screen name="task-detail" /> <Stack.Screen name="task-detail" />
<Stack.Screen name="day-view" />
</Stack> </Stack>
</GestureHandlerRootView> </GestureHandlerRootView>
); );
+26 -13
View File
@@ -1,5 +1,5 @@
import React, { useMemo } from 'react'; import React, { useMemo, useRef } from 'react';
import { View, Text, StyleSheet, SafeAreaView, ScrollView, KeyboardAvoidingView, Platform } from 'react-native'; import { View, Text, StyleSheet, SafeAreaView, ScrollView, KeyboardAvoidingView, Platform, Keyboard } from 'react-native';
import { Header } from '@/components/Header'; import { Header } from '@/components/Header';
import { CategorySelector } from '@/components/CategorySelector'; import { CategorySelector } from '@/components/CategorySelector';
import { TaskNameInput } from '@/components/TaskNameInput'; import { TaskNameInput } from '@/components/TaskNameInput';
@@ -17,15 +17,17 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod'; import { z } from 'zod';
import { useDatabase } from '@/hooks/useDatabase'; import { useDatabase } from '@/hooks/useDatabase';
import { database, collections } from '@/database'; import { database, collections } from '@/database';
import { TaskFormData } from '@/types'; import { TaskFormData, tagsToString } from '@/types';
import { useSettings } from '@/theme'; import { useSettings } from '@/theme';
import { scheduleTaskReminder } from '@/services/notifications'; import { scheduleTaskReminder } from '@/services/notifications';
import { ReminderPreference } from '@/types';
import { useFriends } from '@/hooks/useFriends'; import { useFriends } from '@/hooks/useFriends';
const taskSchema = z.object({ const taskSchema = z.object({
title: z.string().trim().min(1, 'Task name is required').max(100), title: z.string().trim().min(1, 'Task name is required').max(100),
description: z.string().max(1000).optional(), description: z.string().max(1000).optional(),
categoryId: z.string().optional(), categoryId: z.string().optional(),
tags: z.array(z.string()).optional(),
priority: z.enum(['none', 'low', 'medium', 'high', 'critical']), priority: z.enum(['none', 'low', 'medium', 'high', 'critical']),
dueDate: z.date().nullable().optional(), dueDate: z.date().nullable().optional(),
dueTime: z.string().optional(), dueTime: z.string().optional(),
@@ -47,12 +49,14 @@ export default function AddTaskScreen() {
const { theme } = useSettings(); const { theme } = useSettings();
const { date: dateParam } = useLocalSearchParams<{ date?: string }>(); const { date: dateParam } = useLocalSearchParams<{ date?: string }>();
const { friends } = useFriends(); const { friends } = useFriends();
const { notifications: notificationsEnabled, reminderPreference } = useSettings();
const initialCategory = defaultCategoryId || ''; const initialCategory = defaultCategoryId || '';
const initialDate = useMemo(() => { const initialDate = useMemo(() => {
if (!dateParam) return null; if (!dateParam) return null;
const parsed = new Date(Array.isArray(dateParam) ? dateParam[0] : dateParam); const parsed = new Date(Array.isArray(dateParam) ? dateParam[0] : dateParam);
return Number.isNaN(parsed.getTime()) ? null : parsed; return Number.isNaN(parsed.getTime()) ? null : parsed;
}, [dateParam]); }, [dateParam]);
const scrollViewRef = useRef<ScrollView>(null);
const methods = useForm<TaskFormData>({ const methods = useForm<TaskFormData>({
resolver: zodResolver(taskSchema), resolver: zodResolver(taskSchema),
@@ -60,6 +64,7 @@ export default function AddTaskScreen() {
title: '', title: '',
description: '', description: '',
categoryId: initialCategory, categoryId: initialCategory,
tags: initialCategory ? [initialCategory] : [],
priority: 'none', priority: 'none',
dueDate: initialDate, dueDate: initialDate,
dueTime: '', dueTime: '',
@@ -83,7 +88,7 @@ export default function AddTaskScreen() {
formState: { errors }, formState: { errors },
} = methods; } = methods;
const categoryId = watch('categoryId'); const tags = watch('tags') ?? [];
const priority = watch('priority'); const priority = watch('priority');
const repeat = watch('repeat'); const repeat = watch('repeat');
const repeatInterval = watch('repeatInterval') ?? 1; const repeatInterval = watch('repeatInterval') ?? 1;
@@ -94,20 +99,23 @@ export default function AddTaskScreen() {
const assigneeId = watch('assigneeId'); const assigneeId = watch('assigneeId');
React.useEffect(() => { React.useEffect(() => {
if (!categoryId && initialCategory) { if (tags.length === 0 && initialCategory) {
setValue('categoryId', initialCategory); setValue('tags', [initialCategory]);
} }
}, [initialCategory, categoryId, setValue]); }, [initialCategory, tags, setValue]);
const onSubmit = async (data: TaskFormData) => { const onSubmit = async (data: TaskFormData) => {
if (!isReady) return; if (!isReady) return;
Keyboard.dismiss();
scrollViewRef.current?.scrollTo({ y: 0, animated: true });
const now = new Date(); const now = new Date();
const dueDateTimestamp = data.dueDate ? data.dueDate.getTime() : 0; const dueDateTimestamp = data.dueDate ? data.dueDate.getTime() : 0;
const seriesId = data.repeat !== 'none' const seriesId = data.repeat !== 'none'
? `series_${Math.random().toString(36).slice(2, 10)}${Date.now().toString(36)}` ? `series_${Math.random().toString(36).slice(2, 10)}${Date.now().toString(36)}`
: ''; : '';
const resolvedCategoryId = data.categoryId || ''; const resolvedCategoryId = (data.tags && data.tags[0]) || '';
let createdTask: any = null; let createdTask: any = null;
@@ -116,6 +124,7 @@ export default function AddTaskScreen() {
t.title = data.title.trim(); t.title = data.title.trim();
t.description = data.description || ''; t.description = data.description || '';
t.categoryId = resolvedCategoryId; t.categoryId = resolvedCategoryId;
t.tags = tagsToString(data.tags || []);
t.priority = data.priority; t.priority = data.priority;
t.completed = false; t.completed = false;
t.dueDate = dueDateTimestamp; t.dueDate = dueDateTimestamp;
@@ -157,7 +166,7 @@ export default function AddTaskScreen() {
} }
}); });
await scheduleTaskReminder(createdTask); await scheduleTaskReminder(createdTask, notificationsEnabled, reminderPreference);
router.back(); router.back();
}; };
@@ -176,16 +185,20 @@ export default function AddTaskScreen() {
<FormProvider {...methods}> <FormProvider {...methods}>
<KeyboardAvoidingView <KeyboardAvoidingView
style={styles.keyboardAvoiding} style={styles.keyboardAvoiding}
behavior={Platform.OS === 'ios' ? 'padding' : undefined} behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 0}
> >
<ScrollView <ScrollView
ref={scrollViewRef}
contentContainerStyle={styles.scrollContent} contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
keyboardDismissMode="interactive"
automaticallyAdjustContentInsets={false}
> >
<CategorySelector <CategorySelector
value={categoryId} value={tags}
onChange={(value) => setValue('categoryId', value)} onChange={(value) => setValue('tags', value)}
error={errors.categoryId?.message} error={errors.categoryId?.message}
/> />
<Controller <Controller
@@ -260,7 +273,7 @@ const styles = StyleSheet.create({
scrollContent: { scrollContent: {
paddingHorizontal: 16, paddingHorizontal: 16,
paddingTop: 12, paddingTop: 12,
paddingBottom: 120, paddingBottom: 100,
gap: 20, gap: 20,
}, },
}); });
+293
View File
@@ -0,0 +1,293 @@
import React, { useCallback, useMemo } from 'react';
import { View, Text, StyleSheet, SafeAreaView, ScrollView, TouchableOpacity, KeyboardAvoidingView, BackHandler } from 'react-native';
import { useRouter, useLocalSearchParams, useFocusEffect } from 'expo-router';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Header } from '@/components/Header';
import { useTasksByDate } from '@/hooks/useTasks';
import { useSubtasks } from '@/hooks/useSubtasks';
import { useTaskModals } from '@/hooks/useTaskModals';
import { useSettings } from '@/theme';
import { useCategories } from '@/hooks/useDatabase';
import { confirmAndToggleTaskComplete, toggleSubtaskComplete } from '@/utils/taskActions';
import { QuickAddBar } from '@/components/QuickAddBar';
import { SubtaskData } from '@/types';
import { format, startOfDay } from 'date-fns';
import Svg, { Path, Circle } from 'react-native-svg';
import { desaturate, type ThemeColors } from '@/theme';
function isDayMatch(timestamp: number, day: Date): boolean {
const d = new Date(timestamp);
return d.getFullYear() === day.getFullYear() && d.getMonth() === day.getMonth() && d.getDate() === day.getDate();
}
export default function DayViewScreen() {
const router = useRouter();
const { theme } = useSettings();
const insets = useSafeAreaInsets();
const styles = useMemo(() => createStyles(theme), [theme]);
const categories = useCategories();
const { modals, openTaskEdit } = useTaskModals();
const { date: dateParam } = useLocalSearchParams<{ date?: string }>();
const day = useMemo(() => {
const parsed = dateParam ? new Date(dateParam) : new Date();
return Number.isNaN(parsed.getTime()) ? new Date() : parsed;
}, [dateParam]);
const dayStart = useMemo(() => startOfDay(day), [day]);
const { map: subtasksByTask, refresh: refreshSubtasks } = useSubtasks();
const { tasks: dayTasks, loading, refresh } = useTasksByDate(day);
useFocusEffect(
useCallback(() => {
const sub = BackHandler.addEventListener('hardwareBackPress', () => {
router.back();
return true;
});
refreshSubtasks();
refresh();
return () => sub.remove();
}, [router, refreshSubtasks, refresh])
);
const subtasksOnDay = useMemo(() => {
const map = new Map<string, SubtaskData[]>();
for (const [taskId, roots] of subtasksByTask) {
const due = roots.filter((s) => s.dueDate && isDayMatch(s.dueDate, day));
if (due.length > 0) map.set(taskId, due);
}
return map;
}, [subtasksByTask, day]);
const handleToggleComplete = useCallback(async (taskId: string) => {
await confirmAndToggleTaskComplete(taskId, () => {
refresh();
refreshSubtasks();
});
}, [refresh, refreshSubtasks]);
const handleToggleSubtask = useCallback(async (subtaskId: string) => {
await toggleSubtaskComplete(subtaskId);
refreshSubtasks();
}, [refreshSubtasks]);
return (
<SafeAreaView style={[styles.container, { backgroundColor: theme.background, paddingBottom: insets.bottom }]}>
<Header title={format(day, 'EEEE, MMM d')} showLogo={false} rightAction="Back" />
<KeyboardAvoidingView style={styles.kbAvoid} behavior="padding">
<ScrollView style={styles.scrollBody} contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}>
{loading ? (
<View style={styles.emptyState}>
<Text style={[styles.emptyText, { color: theme.textSecondary }]}>Loading...</Text>
</View>
) : dayTasks.length === 0 && subtasksOnDay.size === 0 ? (
<View style={styles.emptyState}>
<Text style={[styles.emptyText, { color: theme.textSecondary }]}>No tasks on this day</Text>
</View>
) : (
<>
{dayTasks.map((task) => {
const subs = subtasksOnDay.get(task.id) ?? [];
const openCount = subs.filter((s) => !s.completed).length;
const cat = categories.find((c) => c.id === task.categoryId);
return (
<View key={task.id} style={[styles.eventCard, { backgroundColor: theme.card, borderColor: theme.border }]}>
<View style={styles.eventRow}>
<TouchableOpacity
style={styles.checkCircle}
onPress={() => handleToggleComplete(task.id)}
activeOpacity={0.7}
accessibilityRole="checkbox"
accessibilityState={{ checked: task.completed }}
>
<Svg width={30} height={30} viewBox="0 0 24 24">
{task.completed ? (
<>
<Circle cx={12} cy={12} r={10} fill={theme.accent} />
<Path d="M7 12l3 3 7-7" stroke="#FFFFFF" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
</>
) : (
<Circle cx={12} cy={12} r={10} stroke={theme.textSecondary} strokeWidth={2.5} fill="none" />
)}
</Svg>
</TouchableOpacity>
<TouchableOpacity
style={styles.eventTouch}
onPress={() => router.push({ pathname: '/task-detail', params: { id: task.id } })}
activeOpacity={0.7}
>
<Text
style={[styles.eventTitle, { color: theme.text }, task.completed && styles.eventCompleted]}
numberOfLines={1}
>
{task.title}
</Text>
{openCount > 0 && (
<Text style={[styles.eventSub, { color: theme.textMuted }]} numberOfLines={1}>
{openCount} open subtask{openCount === 1 ? '' : 's'}
</Text>
)}
{!task.completed && (
<View style={styles.metaRow}>
{cat && (
<View style={[styles.tagChip, { backgroundColor: desaturate(cat.color, 0.3) }]}>
<Text style={styles.tagText} numberOfLines={1}>{cat.name}</Text>
</View>
)}
{task.dueTime ? (
<Text style={[styles.timeText, { color: theme.textSecondary }]}>{task.dueTime}{task.endTime ? `–${task.endTime}` : ''}</Text>
) : task.dueDate ? (
<Text style={[styles.timeText, { color: theme.textSecondary }]}>{format(task.dueDate, 'HH:mm')}</Text>
) : null}
</View>
)}
</TouchableOpacity>
<TouchableOpacity
style={styles.menuButton}
onPress={() => openTaskEdit(task.id)}
activeOpacity={0.7}
accessibilityRole="button"
accessibilityLabel={`Edit ${task.title}`}
>
<Svg width={20} height={20} viewBox="0 0 24 24">
<Path d="M9 6l6 6-6 6" stroke={theme.textSecondary} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
</Svg>
</TouchableOpacity>
</View>
</View>
);
})}
{Array.from(subtasksOnDay.entries()).flatMap(([taskId, subs]) => {
const parent = dayTasks.find((t) => t.id === taskId);
if (parent) return [];
return subs.map((sub) => (
<View key={sub.id} style={[styles.eventCard, { backgroundColor: theme.card, borderColor: theme.border }]}>
<View style={styles.eventRow}>
<TouchableOpacity
style={styles.checkCircle}
onPress={() => handleToggleSubtask(sub.id)}
activeOpacity={0.7}
accessibilityRole="checkbox"
accessibilityState={{ checked: sub.completed }}
>
<Svg width={30} height={30} viewBox="0 0 24 24">
{sub.completed ? (
<>
<Circle cx={12} cy={12} r={10} fill={theme.accent} />
<Path d="M7 12l3 3 7-7" stroke="#FFFFFF" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
</>
) : (
<Circle cx={12} cy={12} r={10} stroke={theme.textSecondary} strokeWidth={2.5} fill="none" />
)}
</Svg>
</TouchableOpacity>
<TouchableOpacity
style={styles.eventTouch}
onPress={() => router.push({ pathname: '/subtask-detail', params: { id: sub.id } })}
activeOpacity={0.7}
>
<Text
style={[styles.eventTitle, { color: theme.text }, sub.completed && styles.eventCompleted]}
numberOfLines={1}
>
{sub.title}
</Text>
{sub.dueTime ? (
<Text style={[styles.timeText, { color: theme.textSecondary }]}>{sub.dueTime}{sub.endTime ? `–${sub.endTime}` : ''}</Text>
) : null}
</TouchableOpacity>
</View>
</View>
));
})}
</>
)}
</ScrollView>
<QuickAddBar dueDate={dayStart.getTime()} placeholder={`Add event for ${format(day, 'MMM d')}`} />
</KeyboardAvoidingView>
{modals(() => {})}
</SafeAreaView>
);
}
const createStyles = (theme: ThemeColors) => StyleSheet.create({
container: {
flex: 1,
},
kbAvoid: {
flex: 1,
},
scrollBody: {
flex: 1,
},
scrollContent: {
paddingTop: 12,
paddingBottom: 16,
},
emptyState: {
alignItems: 'center',
paddingVertical: 64,
},
emptyText: {
fontSize: 15,
fontWeight: '600',
},
eventCard: {
marginHorizontal: 16,
marginBottom: 8,
borderRadius: 16,
borderWidth: 1,
paddingHorizontal: 14,
paddingVertical: 12,
},
eventRow: {
flexDirection: 'row',
alignItems: 'center',
},
checkCircle: {
width: 30,
marginRight: 12,
},
eventTouch: {
flex: 1,
},
eventTitle: {
fontSize: 15,
fontWeight: '500',
},
eventCompleted: {
textDecorationLine: 'line-through',
color: theme.textMuted,
},
eventSub: {
fontSize: 13,
marginTop: 2,
},
metaRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
marginTop: 4,
},
tagChip: {
paddingHorizontal: 7,
paddingVertical: 2,
borderRadius: 6,
maxWidth: 140,
},
tagText: {
color: '#FFFFFF',
fontSize: 10,
fontWeight: '600',
},
timeText: {
fontSize: 12.5,
fontWeight: '500',
},
menuButton: {
padding: 4,
marginLeft: 6,
},
});
+27 -9
View File
@@ -15,15 +15,17 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod'; import { z } from 'zod';
import { useDatabase } from '@/hooks/useDatabase'; import { useDatabase } from '@/hooks/useDatabase';
import { collections } from '@/database'; import { collections } from '@/database';
import { SubtaskFormData, Reminder, parseReminders, toRemindersString } from '@/types'; import { SubtaskFormData, Reminder, parseReminders, toRemindersString, parseTaskTags, tagsToString } from '@/types';
import { useSettings } from '@/theme'; import { useSettings } from '@/theme';
import { updateSubtask, deleteSubtask } from '@/utils/taskActions'; import { updateSubtask, deleteSubtask } from '@/utils/taskActions';
import { CategorySelector } from '@/components/CategorySelector';
import { useFriends } from '@/hooks/useFriends'; import { useFriends } from '@/hooks/useFriends';
import Svg, { Path } from 'react-native-svg'; import Svg, { Path } from 'react-native-svg';
const subtaskSchema = z.object({ const subtaskSchema = z.object({
title: z.string().trim().min(1, 'Subtask name is required').max(100), title: z.string().trim().min(1, 'Subtask name is required').max(100),
description: z.string().max(1000).optional(), description: z.string().max(1000).optional(),
tags: z.array(z.string()).optional(),
priority: z.enum(['none', 'low', 'medium', 'high', 'critical']), priority: z.enum(['none', 'low', 'medium', 'high', 'critical']),
dueDate: z.date().nullable().optional(), dueDate: z.date().nullable().optional(),
dueTime: z.string().optional(), dueTime: z.string().optional(),
@@ -51,6 +53,7 @@ export default function SubtaskDetailScreen() {
defaultValues: { defaultValues: {
title: '', title: '',
description: '', description: '',
tags: [],
priority: 'none', priority: 'none',
dueDate: null, dueDate: null,
dueTime: '', dueTime: '',
@@ -68,6 +71,7 @@ export default function SubtaskDetailScreen() {
const { handleSubmit, watch, setValue, control, reset, formState: { errors } } = methods; const { handleSubmit, watch, setValue, control, reset, formState: { errors } } = methods;
const priority = watch('priority'); const priority = watch('priority');
const tags = watch('tags') ?? [];
const repeat = watch('repeat'); const repeat = watch('repeat');
const repeatInterval = watch('repeatInterval') ?? 1; const repeatInterval = watch('repeatInterval') ?? 1;
const repeatDays = watch('repeatDays') ?? []; const repeatDays = watch('repeatDays') ?? [];
@@ -80,13 +84,13 @@ export default function SubtaskDetailScreen() {
if (!id || !isReady) return; if (!id || !isReady) return;
let mounted = true; let mounted = true;
(async () => { const subscription = collections.subtasks.findAndObserve(id).subscribe({
try { next: (subtask: any) => {
const subtask = await collections.subtasks.find(id);
if (!mounted) return; if (!mounted) return;
reset({ reset({
title: subtask.title, title: subtask.title,
description: subtask.description, description: subtask.description,
tags: parseTaskTags(subtask.tags, subtask.categoryId),
priority: subtask.priority, priority: subtask.priority,
dueDate: subtask.dueDate ? new Date(subtask.dueDate) : null, dueDate: subtask.dueDate ? new Date(subtask.dueDate) : null,
dueTime: subtask.dueTime, dueTime: subtask.dueTime,
@@ -94,18 +98,25 @@ export default function SubtaskDetailScreen() {
allDay: subtask.allDay ?? false, allDay: subtask.allDay ?? false,
repeat: subtask.repeat, repeat: subtask.repeat,
repeatInterval: subtask.repeatInterval || 1, repeatInterval: subtask.repeatInterval || 1,
repeatDays: (subtask.repeatDays || '').split(',').map(Number).filter((d) => !Number.isNaN(d)), repeatDays: ((subtask.repeatDays || '') as string).split(',').map(Number).filter((d) => !Number.isNaN(d)),
reminder: (subtask.reminder || 'none') as Reminder, reminder: (subtask.reminder || 'none') as Reminder,
reminders: subtask.reminders || '', reminders: subtask.reminders || '',
assigneeId: subtask.assigneeId ?? null, assigneeId: subtask.assigneeId ?? null,
}); });
setLoaded(true); setLoaded(true);
} catch { },
error: () => {
if (mounted) setNotFound(true); if (mounted) setNotFound(true);
} },
})(); complete: () => {
if (mounted) setNotFound(true);
},
});
return () => { mounted = false; }; return () => {
mounted = false;
subscription.unsubscribe();
};
}, [id, isReady, reset]); }, [id, isReady, reset]);
const onSubmit = async (data: SubtaskFormData) => { const onSubmit = async (data: SubtaskFormData) => {
@@ -114,6 +125,8 @@ export default function SubtaskDetailScreen() {
await updateSubtask(id, { await updateSubtask(id, {
title: data.title, title: data.title,
description: data.description || '', description: data.description || '',
categoryId: (data.tags && data.tags[0]) || '',
tags: tagsToString(data.tags || []),
priority: data.priority, priority: data.priority,
dueDate: data.dueDate ? data.dueDate.getTime() : 0, dueDate: data.dueDate ? data.dueDate.getTime() : 0,
dueTime: data.dueTime || '', dueTime: data.dueTime || '',
@@ -174,6 +187,7 @@ export default function SubtaskDetailScreen() {
contentContainerStyle={styles.scrollContent} contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
keyboardDismissMode="interactive"
> >
<Controller <Controller
control={control} control={control}
@@ -187,6 +201,10 @@ export default function SubtaskDetailScreen() {
/> />
)} )}
/> />
<CategorySelector
value={tags}
onChange={(value) => setValue('tags', value)}
/>
<PrioritySelector <PrioritySelector
value={priority} value={priority}
onChange={(value) => setValue('priority', value)} onChange={(value) => setValue('priority', value)}
+83 -35
View File
@@ -1,5 +1,5 @@
import React from 'react'; import React, { useRef } from 'react';
import { View, Text, StyleSheet, SafeAreaView, ScrollView, KeyboardAvoidingView, Platform, TouchableOpacity } from 'react-native'; import { View, Text, StyleSheet, SafeAreaView, ScrollView, KeyboardAvoidingView, Platform, TouchableOpacity, Keyboard } from 'react-native';
import { useLocalSearchParams, useRouter } from 'expo-router'; import { useLocalSearchParams, useRouter } from 'expo-router';
import { Header } from '@/components/Header'; import { Header } from '@/components/Header';
import { CategorySelector } from '@/components/CategorySelector'; import { CategorySelector } from '@/components/CategorySelector';
@@ -19,10 +19,12 @@ import { z } from 'zod';
import { useDatabase } from '@/hooks/useDatabase'; import { useDatabase } from '@/hooks/useDatabase';
import { database, collections } from '@/database'; import { database, collections } from '@/database';
import { Q } from '@nozbe/watermelondb'; import { Q } from '@nozbe/watermelondb';
import { TaskFormData } from '@/types'; import { TaskFormData, parseTaskTags, tagsToString } from '@/types';
import { useSettings } from '@/theme'; import { useSettings } from '@/theme';
import { deleteTaskOccurrences } from '@/utils/taskActions'; import { deleteTaskOccurrences } from '@/utils/taskActions';
import { scheduleTaskReminder } from '@/services/notifications'; import { scheduleTaskReminder } from '@/services/notifications';
import { ReminderPreference } from '@/types';
import { recordTombstonesInBatch } from '@/database/tombstones';
import { useFriends } from '@/hooks/useFriends'; import { useFriends } from '@/hooks/useFriends';
import Svg, { Path, Circle } from 'react-native-svg'; import Svg, { Path, Circle } from 'react-native-svg';
@@ -30,6 +32,7 @@ const taskSchema = z.object({
title: z.string().trim().min(1, 'Task name is required').max(100), title: z.string().trim().min(1, 'Task name is required').max(100),
description: z.string().max(1000).optional(), description: z.string().max(1000).optional(),
categoryId: z.string().optional(), categoryId: z.string().optional(),
tags: z.array(z.string()).optional(),
priority: z.enum(['none', 'low', 'medium', 'high', 'critical']), priority: z.enum(['none', 'low', 'medium', 'high', 'critical']),
dueDate: z.date().nullable().optional(), dueDate: z.date().nullable().optional(),
dueTime: z.string().optional(), dueTime: z.string().optional(),
@@ -77,8 +80,10 @@ export default function TaskDetailScreen() {
const router = useRouter(); const router = useRouter();
const { theme } = useSettings(); const { theme } = useSettings();
const { friends } = useFriends(); const { friends } = useFriends();
const { notifications: notificationsEnabled, reminderPreference } = useSettings();
const [loaded, setLoaded] = React.useState(false); const [loaded, setLoaded] = React.useState(false);
const [notFound, setNotFound] = React.useState(false); const [notFound, setNotFound] = React.useState(false);
const scrollViewRef = useRef<ScrollView>(null);
const methods = useForm<TaskFormData>({ const methods = useForm<TaskFormData>({
resolver: zodResolver(taskSchema), resolver: zodResolver(taskSchema),
@@ -86,6 +91,7 @@ export default function TaskDetailScreen() {
title: '', title: '',
description: '', description: '',
categoryId: '', categoryId: '',
tags: [],
priority: 'none', priority: 'none',
dueDate: null, dueDate: null,
dueTime: '', dueTime: '',
@@ -102,7 +108,7 @@ export default function TaskDetailScreen() {
const { handleSubmit, watch, setValue, control, reset, formState: { errors } } = methods; const { handleSubmit, watch, setValue, control, reset, formState: { errors } } = methods;
const categoryId = watch('categoryId'); const tags = watch('tags') ?? [];
const priority = watch('priority'); const priority = watch('priority');
const repeat = watch('repeat'); const repeat = watch('repeat');
const repeatInterval = watch('repeatInterval') ?? 1; const repeatInterval = watch('repeatInterval') ?? 1;
@@ -123,10 +129,13 @@ export default function TaskDetailScreen() {
const subtasks = await collections.subtasks.query(Q.where('task_id', id)).fetch(); const subtasks = await collections.subtasks.query(Q.where('task_id', id)).fetch();
if (!mounted) return; if (!mounted) return;
const remindersValue = task.reminders || (task.reminder && task.reminder !== 'none' ? task.reminder : '');
reset({ reset({
title: task.title, title: task.title,
description: task.description, description: task.description,
categoryId: task.categoryId, categoryId: task.categoryId,
tags: parseTaskTags(task.tags, task.categoryId),
priority: task.priority, priority: task.priority,
dueDate: task.dueDate ? new Date(task.dueDate) : null, dueDate: task.dueDate ? new Date(task.dueDate) : null,
dueTime: task.dueTime, dueTime: task.dueTime,
@@ -136,6 +145,7 @@ export default function TaskDetailScreen() {
repeatInterval: task.repeatInterval || 1, repeatInterval: task.repeatInterval || 1,
repeatDays: (task.repeatDays || '').split(',').map(Number).filter((d) => !Number.isNaN(d)), repeatDays: (task.repeatDays || '').split(',').map(Number).filter((d) => !Number.isNaN(d)),
reminder: (task.reminder || 'none') as TaskFormData['reminder'], reminder: (task.reminder || 'none') as TaskFormData['reminder'],
reminders: remindersValue,
assigneeId: task.assigneeId ?? null, assigneeId: task.assigneeId ?? null,
subtasks: subtasks.map((s) => ({ title: s.title, _key: s.id })), subtasks: subtasks.map((s) => ({ title: s.title, _key: s.id })),
}); });
@@ -151,6 +161,9 @@ export default function TaskDetailScreen() {
const onSubmit = async (data: TaskFormData) => { const onSubmit = async (data: TaskFormData) => {
if (!id || !isReady) return; if (!id || !isReady) return;
Keyboard.dismiss();
scrollViewRef.current?.scrollTo({ y: 0, animated: true });
const now = new Date(); const now = new Date();
const dueDateTimestamp = data.dueDate ? data.dueDate.getTime() : 0; const dueDateTimestamp = data.dueDate ? data.dueDate.getTime() : 0;
@@ -160,14 +173,12 @@ export default function TaskDetailScreen() {
const task = await collections.tasks.find(id); const task = await collections.tasks.find(id);
savedTask = task; savedTask = task;
const existingSubtasks = await collections.subtasks.query(Q.where('task_id', id)).fetch(); const existingSubtasks = await collections.subtasks.query(Q.where('task_id', id)).fetch();
for (const subtask of existingSubtasks) {
await subtask.destroyPermanently();
}
await task.update((t) => { await task.update((t) => {
t.title = data.title.trim(); t.title = data.title.trim();
t.description = data.description || ''; t.description = data.description || '';
t.categoryId = data.categoryId || task.categoryId || ''; t.categoryId = (data.tags && data.tags[0]) || task.categoryId || '';
t.tags = tagsToString(data.tags || []);
t.priority = data.priority; t.priority = data.priority;
t.dueDate = dueDateTimestamp; t.dueDate = dueDateTimestamp;
t.dueTime = data.dueTime || ''; t.dueTime = data.dueTime || '';
@@ -177,6 +188,7 @@ export default function TaskDetailScreen() {
t.repeatInterval = data.repeatInterval || 1; t.repeatInterval = data.repeatInterval || 1;
t.repeatDays = (data.repeatDays || []).join(','); t.repeatDays = (data.repeatDays || []).join(',');
t.reminder = data.reminder || 'none'; t.reminder = data.reminder || 'none';
t.reminders = data.reminders || '';
t.assigneeId = data.assigneeId ?? null; t.assigneeId = data.assigneeId ?? null;
if (data.repeat !== 'none' && !t.seriesId) { if (data.repeat !== 'none' && !t.seriesId) {
t.seriesId = `series_${Math.random().toString(36).slice(2, 10)}${Date.now().toString(36)}`; t.seriesId = `series_${Math.random().toString(36).slice(2, 10)}${Date.now().toString(36)}`;
@@ -184,36 +196,69 @@ export default function TaskDetailScreen() {
t.updatedAt = now; t.updatedAt = now;
}); });
if (data.subtasks && data.subtasks.length > 0) { const keptIds = new Set<string>();
for (let i = 0; i < data.subtasks.length; i++) { let order = 0;
const subtask = data.subtasks[i]; for (const formItem of data.subtasks ?? []) {
if (subtask.title.trim()) { const trimmed = formItem.title.trim();
await collections.subtasks.create((s) => { if (!trimmed) continue;
s.taskId = task.id; if (formItem._key) {
s.title = subtask.title.trim(); const existing = existingSubtasks.find((s) => s.id === formItem._key && !s.parentSubtaskId);
s.description = ''; if (existing) {
s.priority = 'none'; keptIds.add(existing.id);
s.completed = false; await existing.update((s) => {
s.dueDate = 0; s.title = trimmed;
s.dueTime = ''; s.order = order;
s.endTime = '';
s.allDay = false;
s.repeat = 'none';
s.repeatInterval = 1;
s.repeatDays = '';
s.seriesId = '';
s.reminder = 'none';
s.assigneeId = null;
s.order = i;
s.createdAt = now;
s.updatedAt = now; s.updatedAt = now;
}); });
order++;
continue;
} }
} }
await collections.subtasks.create((s) => {
s.taskId = task.id;
s.categoryId = task.categoryId || '';
s.tags = task.tags || '';
s.title = trimmed;
s.description = '';
s.priority = 'none';
s.completed = false;
s.dueDate = 0;
s.dueTime = '';
s.endTime = '';
s.allDay = false;
s.repeat = 'none';
s.repeatInterval = 1;
s.repeatDays = '';
s.seriesId = '';
s.reminder = 'none';
s.reminders = '';
s.assigneeId = null;
s.order = order++;
s.createdAt = now;
s.updatedAt = now;
});
}
const removedIds: string[] = [];
for (const existing of existingSubtasks) {
if (existing.parentSubtaskId) continue;
if (keptIds.has(existing.id)) continue;
removedIds.push(existing.id);
const children = await collections.subtasks.query(Q.where('parent_subtask_id', existing.id)).fetch();
for (const child of children) {
await child.update((c) => {
c.parentSubtaskId = null;
c.updatedAt = now;
});
}
await existing.destroyPermanently();
}
if (removedIds.length > 0) {
await recordTombstonesInBatch('subtasks', removedIds);
} }
}); });
await scheduleTaskReminder(savedTask); await scheduleTaskReminder(savedTask, notificationsEnabled, reminderPreference);
router.back(); router.back();
}; };
@@ -253,16 +298,19 @@ export default function TaskDetailScreen() {
<FormProvider {...methods}> <FormProvider {...methods}>
<KeyboardAvoidingView <KeyboardAvoidingView
style={styles.keyboardAvoiding} style={styles.keyboardAvoiding}
behavior={Platform.OS === 'ios' ? 'padding' : undefined} behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 0}
> >
<ScrollView <ScrollView
ref={scrollViewRef}
contentContainerStyle={styles.scrollContent} contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
keyboardDismissMode="interactive"
> >
<CategorySelector <CategorySelector
value={categoryId} value={tags}
onChange={(value) => setValue('categoryId', value)} onChange={(value) => setValue('tags', value)}
error={errors.categoryId?.message} error={errors.categoryId?.message}
/> />
<Controller <Controller
@@ -397,7 +445,7 @@ const styles = StyleSheet.create({
scrollContent: { scrollContent: {
paddingHorizontal: 12, paddingHorizontal: 12,
paddingTop: 8, paddingTop: 8,
paddingBottom: 80, paddingBottom: 60,
gap: 12, gap: 12,
}, },
section: { section: {
Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

@@ -0,0 +1,46 @@
module.exports = {
appId: 'com.carryyourlive.desktop',
productName: 'Carry Your Live',
copyright: 'Copyright © 2026',
directories: {
output: 'desktop-build',
buildResources: 'build',
},
// Override the entry point in the packaged app (repo keeps expo-router/entry
// as `main` so `expo start` keeps working).
extraMetadata: {
main: 'electron/main.js',
},
// The Electron shell needs zero runtime dependencies: main/preload use Node
// built-ins only, and the renderer is fully bundled by Metro into dist/.
// Returning false tells electron-builder that node_modules are handled
// externally — i.e. don't bundle production deps (expo, react-native, ...)
// into the package. This keeps the deb ~100x smaller.
// NOTE: do not set npmRebuild:false here — installAppDependencies()
// short-circuits on it *before* evaluating beforeBuild, which would
// re-enable bundling of node_modules.
beforeBuild: async () => false,
// Sanity check validates the ORIGINAL package.json `main` (expo-router/entry)
// against the asar, which no longer exists once deps are excluded.
// The real entry point is set via extraMetadata.main above.
disableSanityCheckAsar: true,
files: ['dist/**/*', 'electron/**/*', 'assets/icon.png', 'package.json'],
// Icons are generated from build/icon.png (1024x1024) for each platform.
mac: {
category: 'public.app-category.productivity',
target: ['dmg', 'zip'],
},
win: {
target: ['nsis'],
},
linux: {
category: 'Productivity',
maintainer: 'carry-your-live',
target: ['deb', 'AppImage'],
},
}
+22 -5
View File
@@ -1,7 +1,9 @@
/* global __dirname */ /* global __dirname */
const { app, BrowserWindow } = require('electron') const { app, BrowserWindow, ipcMain } = require('electron')
const path = require('path') const path = require('path')
const DEV_SERVER_URL = process.env.ELECTRON_START_URL
function createWindow() { function createWindow() {
const win = new BrowserWindow({ const win = new BrowserWindow({
width: 1200, width: 1200,
@@ -11,24 +13,39 @@ function createWindow() {
webPreferences: { webPreferences: {
nodeIntegration: false, nodeIntegration: false,
contextIsolation: true, contextIsolation: true,
sandbox: true,
webSecurity: true, webSecurity: true,
preload: path.join(__dirname, 'preload.js'),
}, },
icon: path.join(__dirname, '../assets/icon.png'), icon: path.join(__dirname, '../assets/icon.png'),
titleBarStyle: 'default', titleBarStyle: 'default',
show: false, show: false,
}) })
win.loadFile(path.join(__dirname, '../dist/index.html')) if (DEV_SERVER_URL) {
win.loadURL(DEV_SERVER_URL)
} else {
win.loadFile(path.join(__dirname, '../dist/index.html'))
}
win.once('ready-to-show', () => { win.once('ready-to-show', () => {
win.show() win.show()
}) })
win.on('closed', () => { // In dev, reload instead of quitting when the renderer crashes / navigates away
app.quit() win.webContents.on('didFailLoad', (_event, code, desc) => {
console.error(`[electron] failed to load: ${code} ${desc}`)
}) })
return win
} }
ipcMain.handle('app:get-info', () => ({
version: app.getVersion(),
name: app.getName(),
platform: process.platform,
}))
app.whenReady().then(createWindow) app.whenReady().then(createWindow)
app.on('window-all-closed', () => { app.on('window-all-closed', () => {
@@ -41,4 +58,4 @@ app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) { if (BrowserWindow.getAllWindows().length === 0) {
createWindow() createWindow()
} }
}) })
+6
View File
@@ -0,0 +1,6 @@
const { contextBridge, ipcRenderer } = require('electron')
contextBridge.exposeInMainWorld('electron', {
isElectron: true,
getAppInfo: () => ipcRenderer.invoke('app:get-info'),
})
-21
View File
@@ -1,21 +0,0 @@
server {
listen 8081;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api {
proxy_pass http://backend:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
+2758 -67
View File
File diff suppressed because it is too large Load Diff
+11
View File
@@ -2,6 +2,7 @@
"name": "carry-your-live", "name": "carry-your-live",
"version": "1.0.0", "version": "1.0.0",
"main": "expo-router/entry", "main": "expo-router/entry",
"homepage": "https://gitea.techmos.org/tech08mag/carry-your-live",
"dependencies": { "dependencies": {
"@hookform/resolvers": "^3.3.4", "@hookform/resolvers": "^3.3.4",
"@nozbe/watermelondb": "^0.28.1-0", "@nozbe/watermelondb": "^0.28.1-0",
@@ -9,8 +10,10 @@
"@react-native-community/datetimepicker": "9.1.0", "@react-native-community/datetimepicker": "9.1.0",
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"expo": "~57.0.10", "expo": "~57.0.10",
"expo-blur": "~57.0.2",
"expo-constants": "~57.0.9", "expo-constants": "~57.0.9",
"expo-dev-client": "~57.0.1", "expo-dev-client": "~57.0.1",
"expo-device": "^57.0.1",
"expo-linking": "~57.0.5", "expo-linking": "~57.0.5",
"expo-notifications": "~57.0.8", "expo-notifications": "~57.0.8",
"expo-router": "~57.0.10", "expo-router": "~57.0.10",
@@ -33,6 +36,9 @@
"devDependencies": { "devDependencies": {
"@babel/core": "^7.20.0", "@babel/core": "^7.20.0",
"@types/react": "~19.2.2", "@types/react": "~19.2.2",
"babel-preset-expo": "~57.0.0",
"electron": "^41.7.1",
"electron-builder": "^26.15.3",
"eslint": "^9.0.0", "eslint": "^9.0.0",
"eslint-config-expo": "~57.0.1", "eslint-config-expo": "~57.0.1",
"patch-package": "^8.0.1", "patch-package": "^8.0.1",
@@ -47,6 +53,11 @@
"lint": "expo lint", "lint": "expo lint",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"format": "prettier --write \"**/*.{ts,tsx,json,md}\"", "format": "prettier --write \"**/*.{ts,tsx,json,md}\"",
"export:web": "expo export --platform web",
"desktop": "electron electron/main.js",
"desktop:dev": "ELECTRON_START_URL=http://localhost:8081 electron electron/main.js",
"desktop:dist": "npm run export:web && electron-builder --config electron-builder.config.js",
"desktop:dir": "npm run export:web && electron-builder --dir --config electron-builder.config.js",
"build:ios": "eas build --platform ios", "build:ios": "eas build --platform ios",
"build:android": "eas build --platform android", "build:android": "eas build --platform android",
"build:web": "eas build --platform web", "build:web": "eas build --platform web",
@@ -1,12 +1,12 @@
import React, { useState } from 'react'; import React, { useState, useEffect } from 'react';
import { Modal, View, Text, StyleSheet, TextInput, TouchableOpacity, ScrollView, KeyboardAvoidingView, Platform, Pressable } from 'react-native'; import { Modal, View, Text, StyleSheet, TextInput, TouchableOpacity, ScrollView, KeyboardAvoidingView, Platform, Pressable, useWindowDimensions } from 'react-native';
import Category from '@/models/Category'; import Category from '@/models/Category';
import { useSettings } from '@/theme'; import { useSettings } from '@/theme';
import { CATEGORY_COLORS } from '@/constants'; import { CATEGORY_COLORS } from '@/constants';
import { createCategory, updateCategory, deleteCategory } from '@/utils/categoryActions'; import { createCategory, updateCategory, deleteCategory } from '@/utils/categoryActions';
import { confirmAction } from '@/utils/confirm'; import { confirmAction } from '@/utils/confirm';
import ColorPickerInput from '@/components/ColorPickerInput'; import ColorWheel from '@/components/ColorWheel';
import Svg, { Path } from 'react-native-svg'; import Svg, { Path, Circle } from 'react-native-svg';
interface CategoryEditorModalProps { interface CategoryEditorModalProps {
visible: boolean; visible: boolean;
@@ -20,6 +20,7 @@ export function CategoryEditorModal({ visible, category, categoryCount, onClose
const [name, setName] = useState(''); const [name, setName] = useState('');
const [color, setColor] = useState(CATEGORY_COLORS[0]); const [color, setColor] = useState(CATEGORY_COLORS[0]);
const [prevVisible, setPrevVisible] = useState(visible); const [prevVisible, setPrevVisible] = useState(visible);
const { height: screenHeight } = useWindowDimensions();
if (prevVisible !== visible) { if (prevVisible !== visible) {
setPrevVisible(visible); setPrevVisible(visible);
@@ -45,8 +46,8 @@ export function CategoryEditorModal({ visible, category, categoryCount, onClose
const handleDelete = () => { const handleDelete = () => {
if (!category || !canDelete) return; if (!category || !canDelete) return;
confirmAction({ confirmAction({
title: 'Delete Category', title: 'Delete Tag',
message: `Tasks in "${category.name}" will be moved to another category.`, message: `Tasks in "${category.name}" will be moved to another tag.`,
confirmLabel: 'Delete', confirmLabel: 'Delete',
destructive: true, destructive: true,
onConfirm: async () => { onConfirm: async () => {
@@ -60,7 +61,8 @@ export function CategoryEditorModal({ visible, category, categoryCount, onClose
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}> <Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<KeyboardAvoidingView <KeyboardAvoidingView
style={styles.fill} style={styles.fill}
behavior={Platform.OS === 'ios' ? 'padding' : undefined} behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
keyboardVerticalOffset={0}
> >
<Pressable <Pressable
style={styles.overlay} style={styles.overlay}
@@ -74,14 +76,16 @@ export function CategoryEditorModal({ visible, category, categoryCount, onClose
<ScrollView <ScrollView
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
contentContainerStyle={styles.scrollContent}
> >
<View style={styles.header}> <View style={styles.header}>
<Text style={[styles.title, { color: theme.text }]}> <Text style={[styles.title, { color: theme.text }]}>
{category ? 'Edit Category' : 'New Category'} {category ? 'Edit Tag' : 'New Tag'}
</Text> </Text>
<TouchableOpacity onPress={onClose} style={styles.closeButton} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} accessibilityRole="button" accessibilityLabel="Close category editor"> <TouchableOpacity onPress={onClose} style={styles.closeButton} hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }} accessibilityRole="button" accessibilityLabel="Close tag editor">
<Svg width={18} height={18} viewBox="0 0 24 24"> <Svg width={22} height={22} viewBox="0 0 24 24">
<Path d="M18 6L6 18M6 6l12 12" stroke={theme.textSecondary} strokeWidth={2.2} strokeLinecap="round" /> <Circle cx={12} cy={12} r={10} fill={theme.cardAlt} />
<Path d="M18 6L6 18M6 6l12 12" stroke={theme.textSecondary} strokeWidth={2.5} strokeLinecap="round" />
</Svg> </Svg>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@@ -92,7 +96,7 @@ export function CategoryEditorModal({ visible, category, categoryCount, onClose
styles.input, styles.input,
{ backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text }, { backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text },
]} ]}
placeholder="Category name" placeholder="Tag name"
placeholderTextColor={theme.textMuted} placeholderTextColor={theme.textMuted}
value={name} value={name}
onChangeText={setName} onChangeText={setName}
@@ -125,7 +129,7 @@ export function CategoryEditorModal({ visible, category, categoryCount, onClose
</TouchableOpacity> </TouchableOpacity>
))} ))}
</View> </View>
<ColorPickerInput value={color} onChange={setColor} /> <ColorWheel color={color} onChange={setColor} />
<View style={styles.actions}> <View style={styles.actions}>
{category && ( {category && (
@@ -171,10 +175,18 @@ const styles = StyleSheet.create({
sheet: { sheet: {
width: '100%', width: '100%',
maxWidth: 380, maxWidth: 380,
maxHeight: '100%', maxHeight: '90%',
borderRadius: 16, borderRadius: 16,
padding: 20, padding: 20,
gap: 8, gap: 8,
shadowColor: '#000',
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.25,
shadowRadius: 16,
elevation: 12,
},
scrollContent: {
minHeight: 200,
}, },
header: { header: {
flexDirection: 'row', flexDirection: 'row',
@@ -188,6 +200,8 @@ const styles = StyleSheet.create({
}, },
closeButton: { closeButton: {
padding: 4, padding: 4,
borderRadius: 12,
backgroundColor: 'transparent',
}, },
label: { label: {
fontSize: 13, fontSize: 13,
@@ -1,13 +1,13 @@
import React from 'react'; import React, { useState } from 'react';
import { View, Text, StyleSheet, ScrollView, TouchableOpacity } from 'react-native'; import { Text, StyleSheet, ScrollView, TouchableOpacity, Animated } from 'react-native';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { useUniqueCategories } from '@/hooks/useDatabase'; import { useUniqueCategories } from '@/hooks/useDatabase';
import { useSettings, ThemeColors } from '@/theme'; import { useSettings, ThemeColors } from '@/theme';
import Svg, { Path, Circle } from 'react-native-svg'; import Svg, { Path, Circle } from 'react-native-svg';
interface CategoryFilterProps { interface CategoryFilterProps {
selected: string; selected: string[];
onSelect: (categoryId: string) => void; onSelect: (categoryIds: string[]) => void;
} }
export function CategoryFilter({ selected, onSelect }: CategoryFilterProps) { export function CategoryFilter({ selected, onSelect }: CategoryFilterProps) {
@@ -15,6 +15,29 @@ export function CategoryFilter({ selected, onSelect }: CategoryFilterProps) {
const { theme } = useSettings(); const { theme } = useSettings();
const router = useRouter(); const router = useRouter();
const selectedSet = new Set(selected);
const isAnythingSelected = selected.length > 0;
const [scaleAnim] = useState(new Animated.Value(1));
const toggle = (id: string) => {
const next = new Set(selectedSet);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
onSelect(Array.from(next));
};
const animatePress = (id: string, isPressing: boolean) => {
Animated.spring(scaleAnim, {
toValue: isPressing ? 0.92 : 1,
friction: 8,
tension: 100,
useNativeDriver: true,
}).start();
};
return ( return (
<ScrollView <ScrollView
horizontal horizontal
@@ -25,10 +48,14 @@ export function CategoryFilter({ selected, onSelect }: CategoryFilterProps) {
<CategoryButton <CategoryButton
id="all" id="all"
name="All" name="All"
color="#9E9E9E" color={theme.textMuted}
selected={selected === 'all'} selected={!isAnythingSelected}
onPress={() => onSelect('all')} onPress={() => onSelect([])}
onPressIn={() => animatePress('all', true)}
onPressOut={() => animatePress('all', false)}
theme={theme} theme={theme}
isAllCategory={true}
scaleAnim={scaleAnim}
/> />
{categories.map((category) => ( {categories.map((category) => (
<CategoryButton <CategoryButton
@@ -36,9 +63,12 @@ export function CategoryFilter({ selected, onSelect }: CategoryFilterProps) {
id={category.id} id={category.id}
name={category.name} name={category.name}
color={category.color} color={category.color}
selected={selected === category.id} selected={selectedSet.has(category.id)}
onPress={() => onSelect(category.id)} onPress={() => toggle(category.id)}
onPressIn={() => animatePress(category.id, true)}
onPressOut={() => animatePress(category.id, false)}
theme={theme} theme={theme}
scaleAnim={scaleAnim}
/> />
))} ))}
<TouchableOpacity <TouchableOpacity
@@ -46,7 +76,7 @@ export function CategoryFilter({ selected, onSelect }: CategoryFilterProps) {
onPress={() => router.push('/settings')} onPress={() => router.push('/settings')}
activeOpacity={0.7} activeOpacity={0.7}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="Manage categories" accessibilityLabel="Manage tags"
hitSlop={{ top: 8, bottom: 8, left: 4, right: 4 }} hitSlop={{ top: 8, bottom: 8, left: 4, right: 4 }}
> >
<Svg width={14} height={14} viewBox="0 0 24 24"> <Svg width={14} height={14} viewBox="0 0 24 24">
@@ -67,34 +97,43 @@ interface CategoryButtonProps {
color: string; color: string;
selected: boolean; selected: boolean;
onPress: () => void; onPress: () => void;
onPressIn: () => void;
onPressOut: () => void;
theme: ThemeColors; theme: ThemeColors;
isAllCategory?: boolean;
scaleAnim: Animated.Value;
} }
function CategoryButton({ name, color, selected, onPress, theme }: CategoryButtonProps) { function CategoryButton({ color, selected, onPress, onPressIn, onPressOut, theme, isAllCategory, scaleAnim }: CategoryButtonProps) {
const animatedStyle = {
transform: [{ scale: scaleAnim }],
shadowColor: selected ? theme.accent : '#000',
shadowOffset: { width: 0, height: selected ? 4 : 2 },
shadowOpacity: selected ? 0.25 : 0.1,
shadowRadius: selected ? 8 : 4,
elevation: selected ? 6 : 2,
};
return ( return (
<TouchableOpacity <TouchableOpacity
style={[ style={[
styles.button, styles.button,
{ backgroundColor: theme.card, borderColor: theme.borderStrong }, { backgroundColor: color },
selected && { borderColor: theme.accent, backgroundColor: theme.accentSoft }, selected && styles.buttonSelected,
animatedStyle,
]} ]}
onPress={onPress} onPress={onPress}
activeOpacity={0.8} onPressIn={onPressIn}
hitSlop={{ top: 6, bottom: 6, left: 4, right: 4 }} onPressOut={onPressOut}
activeOpacity={1}
hitSlop={{ top: 10, bottom: 10, left: 8, right: 8 }}
> >
<View {isAllCategory ? (
style={[ <Svg width={20} height={20} viewBox="0 0 24 24">
styles.colorDot, <Circle cx={12} cy={12} r={9} stroke="#FFFFFF" strokeWidth={2.5} fill="none" opacity={0.9} />
{ backgroundColor: color }, <Path d="M6 12l4 4 8-8" stroke="#FFFFFF" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" opacity={0.9} />
]} </Svg>
/> ) : null}
<Text style={[
styles.buttonText,
{ color: theme.textSecondary },
selected && { color: theme.text, fontWeight: '600' },
]}>
{name}
</Text>
</TouchableOpacity> </TouchableOpacity>
); );
} }
@@ -109,24 +148,27 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
}, },
button: { button: {
flexDirection: 'row', width: 44,
height: 44,
borderRadius: 22,
borderWidth: 2,
borderColor: 'transparent',
alignItems: 'center', alignItems: 'center',
paddingHorizontal: 12,
paddingVertical: 10,
borderRadius: 18,
borderWidth: 1.5,
minHeight: 38,
justifyContent: 'center', justifyContent: 'center',
gap: 8, shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 2,
}, },
colorDot: { buttonSelected: {
width: 8, borderColor: '#FFFFFF',
height: 8, borderWidth: 3,
borderRadius: 4, shadowColor: '#000',
}, shadowOffset: { width: 0, height: 4 },
buttonText: { shadowOpacity: 0.25,
fontSize: 12, shadowRadius: 8,
fontWeight: '500', elevation: 6,
}, },
manageButton: { manageButton: {
alignSelf: 'center', alignSelf: 'center',
@@ -5,8 +5,8 @@ import { useSettings } from '@/theme';
import Svg, { Path } from 'react-native-svg'; import Svg, { Path } from 'react-native-svg';
interface CategorySelectorProps { interface CategorySelectorProps {
value: string; value: string[];
onChange: (value: string) => void; onChange: (value: string[]) => void;
error?: string; error?: string;
} }
@@ -15,11 +15,30 @@ export function CategorySelector({ value, onChange, error }: CategorySelectorPro
const { theme } = useSettings(); const { theme } = useSettings();
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const selectedCategory = categories.find(c => c.id === value); const selected = new Set(value);
const selectedCategories = categories.filter((c) => selected.has(c.id));
const toggle = (id: string) => {
const next = new Set(selected);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
onChange(Array.from(next));
};
const summary =
selectedCategories.length === 0
? 'None'
: selectedCategories
.slice(0, 2)
.map((c) => c.name)
.join(', ') + (selectedCategories.length > 2 ? ` +${selectedCategories.length - 2}` : '');
return ( return (
<View style={styles.container}> <View style={styles.container}>
<Text style={[styles.label, { color: theme.text }]}>Category</Text> <Text style={[styles.label, { color: theme.text }]}>Tags</Text>
<TouchableOpacity <TouchableOpacity
style={[ style={[
styles.selectorButton, styles.selectorButton,
@@ -28,20 +47,28 @@ export function CategorySelector({ value, onChange, error }: CategorySelectorPro
onPress={() => setShowModal(true)} onPress={() => setShowModal(true)}
activeOpacity={0.8} activeOpacity={0.8}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="Select category" accessibilityLabel="Select tags"
accessibilityHint="Opens a list of categories to choose from" accessibilityHint="Opens a list of tags to choose from"
> >
<View style={styles.selectorContent}> <View style={styles.selectorContent}>
<View style={styles.selectorRow}> {selectedCategories.length > 0 ? (
<View style={[styles.colorCircle, { backgroundColor: selectedCategory?.color || '#9E9E9E' }]} /> <View style={styles.chipRow}>
<Text style={[styles.selectorValue, { color: theme.text }]}>{selectedCategory?.name || 'None'}</Text> {selectedCategories.slice(0, 3).map((c) => (
</View> <View key={c.id} style={[styles.chip, { backgroundColor: theme.cardAlt, borderColor: theme.border }]}>
<View style={[styles.colorCircle, { backgroundColor: c.color }]} />
<Text style={[styles.chipText, { color: theme.textSecondary }]} numberOfLines={1}>{c.name}</Text>
</View>
))}
</View>
) : (
<Text style={[styles.selectorValue, { color: theme.textMuted }]}>None</Text>
)}
</View> </View>
<Svg width={20} height={20} viewBox="0 0 24 24"> <Svg width={20} height={20} viewBox="0 0 24 24">
<Path d="M9 18l6-6-6-6" stroke={theme.textSecondary} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" /> <Path d="M9 18l6-6-6-6" stroke={theme.textSecondary} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
</Svg> </Svg>
</TouchableOpacity> </TouchableOpacity>
{error && <Text style={[styles.errorText, { color: '#E53935' }]}>{error}</Text>} {error && <Text style={[styles.errorText, { color: theme.dangerStrong }]}>{error}</Text>}
<Modal visible={showModal} transparent animationType="fade" onRequestClose={() => setShowModal(false)}> <Modal visible={showModal} transparent animationType="fade" onRequestClose={() => setShowModal(false)}>
<KeyboardAvoidingView <KeyboardAvoidingView
@@ -51,67 +78,62 @@ export function CategorySelector({ value, onChange, error }: CategorySelectorPro
<Pressable style={styles.modalOverlay} onPress={() => setShowModal(false)}> <Pressable style={styles.modalOverlay} onPress={() => setShowModal(false)}>
<Pressable style={[styles.modalSheet, { backgroundColor: theme.sheetBg }]}> <Pressable style={[styles.modalSheet, { backgroundColor: theme.sheetBg }]}>
<View style={styles.modalHeader}> <View style={styles.modalHeader}>
<Text style={[styles.modalTitle, { color: theme.text }]}>Select Category</Text> <Text style={[styles.modalTitle, { color: theme.text }]}>Select Tags</Text>
<TouchableOpacity onPress={() => setShowModal(false)} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} accessibilityRole="button" accessibilityLabel="Close category picker"> <View style={styles.modalHeaderRight}>
<Text style={[styles.closeText, { color: theme.textMuted }]}>✕</Text> <TouchableOpacity
</TouchableOpacity> onPress={() => { onChange([]); setShowModal(false); }}
activeOpacity={0.7}
accessibilityRole="button"
accessibilityLabel="Clear all tags"
>
<Text style={[styles.clearText, { color: theme.textMuted }]}>Clear</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => setShowModal(false)}
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
accessibilityRole="button"
accessibilityLabel="Done selecting tags"
>
<Text style={[styles.doneText, { color: theme.accent }]}>Done</Text>
</TouchableOpacity>
</View>
</View> </View>
<ScrollView contentContainerStyle={styles.modalContent}> <ScrollView contentContainerStyle={styles.modalContent}>
<TouchableOpacity <Text style={[styles.modalHint, { color: theme.textMuted }]}>
style={[ A task can have multiple tags. Selected: {selected.size}
styles.modalOption, </Text>
{ backgroundColor: theme.card, borderColor: theme.borderStrong }, {categories.map((category) => {
!value && { borderColor: theme.accent, backgroundColor: theme.accentSoft, borderWidth: 2 }, const isSelected = selected.has(category.id);
]} return (
onPress={() => { onChange(''); setShowModal(false); }} <TouchableOpacity
activeOpacity={0.8} key={category.id}
accessibilityRole="radio" style={[
accessibilityLabel="No category" styles.modalOption,
accessibilityState={{ selected: !value }} { backgroundColor: theme.card, borderColor: theme.borderStrong },
> isSelected && { borderColor: theme.accent, backgroundColor: theme.accentSoft, borderWidth: 2 },
<View style={[styles.colorCircle, { backgroundColor: '#9E9E9E' }, !value && styles.colorCircleSelected]} /> ]}
<Text style={[ onPress={() => toggle(category.id)}
styles.categoryName, activeOpacity={0.8}
{ color: theme.textSecondary }, accessibilityRole="checkbox"
!value && { color: theme.accentStrong, fontWeight: '600' }, accessibilityLabel={`Tag ${category.name}`}
]}> accessibilityState={{ checked: isSelected }}
None >
</Text> <View style={[styles.colorCircle, { backgroundColor: category.color }, isSelected && styles.colorCircleSelected]} />
{!value && ( <Text style={[
<Svg width={20} height={20} viewBox="0 0 24 24"> styles.categoryName,
<Path d="M5 12l5 5 9-10" stroke={theme.accent} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" /> { color: theme.textSecondary },
</Svg> isSelected && { color: theme.text, fontWeight: '600' },
)} ]}>
</TouchableOpacity> {category.name}
{categories.map((category) => ( </Text>
<TouchableOpacity {isSelected && (
key={category.id} <Svg width={20} height={20} viewBox="0 0 24 24">
style={[ <Path d="M5 12l5 5 9-10" stroke={theme.accent} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
styles.modalOption, </Svg>
{ backgroundColor: theme.card, borderColor: theme.borderStrong }, )}
value === category.id && { borderColor: theme.accent, backgroundColor: theme.accentSoft, borderWidth: 2 }, </TouchableOpacity>
]} );
onPress={() => { onChange(category.id); setShowModal(false); }} })}
activeOpacity={0.8}
accessibilityRole="radio"
accessibilityLabel={`Category ${category.name}`}
accessibilityState={{ selected: value === category.id }}
>
<View style={[styles.colorCircle, { backgroundColor: category.color }, value === category.id && styles.colorCircleSelected]} />
<Text style={[
styles.categoryName,
{ color: theme.textSecondary },
value === category.id && { color: theme.accentStrong, fontWeight: '600' },
]}>
{category.name}
</Text>
{value === category.id && (
<Svg width={20} height={20} viewBox="0 0 24 24">
<Path d="M5 12l5 5 9-10" stroke={theme.accent} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
</Svg>
)}
</TouchableOpacity>
))}
</ScrollView> </ScrollView>
</Pressable> </Pressable>
</Pressable> </Pressable>
@@ -141,11 +163,25 @@ const styles = StyleSheet.create({
}, },
selectorContent: { selectorContent: {
flex: 1, flex: 1,
paddingRight: 8,
}, },
selectorRow: { chipRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 6,
},
chip: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
gap: 10, gap: 6,
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 12,
borderWidth: 1,
},
chipText: {
fontSize: 12,
fontWeight: '600',
}, },
colorCircle: { colorCircle: {
width: 12, width: 12,
@@ -185,13 +221,27 @@ const styles = StyleSheet.create({
padding: 20, padding: 20,
borderBottomWidth: 1, borderBottomWidth: 1,
}, },
modalHeaderRight: {
flexDirection: 'row',
alignItems: 'center',
gap: 16,
},
modalTitle: { modalTitle: {
fontSize: 18, fontSize: 18,
fontWeight: '700', fontWeight: '700',
}, },
closeText: { clearText: {
fontSize: 22, fontSize: 14,
fontWeight: '300', fontWeight: '500',
},
doneText: {
fontSize: 15,
fontWeight: '700',
},
modalHint: {
fontSize: 12,
paddingHorizontal: 4,
paddingBottom: 4,
}, },
modalContent: { modalContent: {
padding: 12, padding: 12,
@@ -26,9 +26,12 @@ export default function NativeColorPickerInput({ value, onChange }: ColorPickerI
return ( return (
<View style={styles.row}> <View style={styles.row}>
<View style={[styles.preview, { backgroundColor: valid ? value : theme.borderStrong, borderColor: theme.borderStrong }]} /> <View style={[
<View style={[styles.inputWrap, { backgroundColor: theme.inputBg, borderColor: theme.borderStrong }]}> styles.preview,
<Text style={[styles.hash, { color: theme.textMuted }]}>#</Text> { backgroundColor: valid ? value : theme.accentSoft, borderColor: theme.accent },
]} />
<View style={styles.inputWrap}>
<Text style={styles.hash}>#{/**/}</Text>
<TextInput <TextInput
style={[styles.input, { color: theme.text }]} style={[styles.input, { color: theme.text }]}
value={hex} value={hex}
@@ -40,7 +43,7 @@ export default function NativeColorPickerInput({ value, onChange }: ColorPickerI
maxLength={6} maxLength={6}
/> />
</View> </View>
<Text style={[styles.hint, { color: theme.textMuted }]}>Hex code</Text> <Text style={styles.hint}>{/**/}Hex code</Text>
</View> </View>
); );
} }
@@ -28,13 +28,13 @@ export default function WebColorPickerInput({ value, onChange }: ColorPickerInpu
width: 44, width: 44,
height: 44, height: 44,
padding: 0, padding: 0,
border: `1px solid ${theme.borderStrong}`, border: `1px solid ${theme.accent}`,
borderRadius: 12, borderRadius: 12,
backgroundColor: 'transparent', backgroundColor: 'transparent',
cursor: 'pointer', cursor: 'pointer',
}, },
})} })}
<Text style={[styles.hexText, { color: theme.textSecondary }]}>{normalized}</Text> <Text style={[styles.hexText, { color: theme.accent }]}>{normalized}</Text>
</View> </View>
); );
} }
@@ -49,7 +49,7 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
styles.pickerButton, styles.pickerButton,
{ backgroundColor: theme.inputBg, borderColor: theme.borderStrong }, { backgroundColor: theme.inputBg, borderColor: theme.borderStrong },
field.value && { borderColor: theme.accent, backgroundColor: theme.accentSoft }, field.value && { borderColor: theme.accent, backgroundColor: theme.accentSoft },
fieldName === 'endTime' && endTimeInvalid && { borderColor: '#E53935', backgroundColor: theme.accentSoft }, fieldName === 'endTime' && endTimeInvalid && { borderColor: theme.dangerStrong, backgroundColor: theme.accentSoft },
]} ]}
onPress={onPress} onPress={onPress}
activeOpacity={0.8} activeOpacity={0.8}
@@ -150,6 +150,28 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
); );
}} }}
/> />
<TouchableOpacity
style={[styles.calendarButton, { backgroundColor: theme.inputBg, borderColor: theme.borderStrong }]}
onPress={() => setPicker('date')}
activeOpacity={0.8}
accessibilityLabel="Open calendar"
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
>
<Svg width={22} height={22} viewBox="0 0 24 24">
<Path
d="M8 2v4M16 2v4M3 10h18M3 18h18"
stroke={theme.textSecondary}
strokeWidth={1.8}
strokeLinecap="round"
/>
<Path
d="M10 2H14a2 2 0 0 1 2 2v16a2 2 0 0 1-2 2H10a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2z"
stroke={theme.textSecondary}
strokeWidth={1.8}
fill="none"
/>
</Svg>
</TouchableOpacity>
</View> </View>
<Controller <Controller
control={control} control={control}
@@ -181,12 +203,12 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
</View> </View>
{endTimeInvalid && ( {endTimeInvalid && (
<View style={[styles.warningRow, { backgroundColor: theme.accentSoft, borderColor: '#E53935' }]}> <View style={[styles.warningRow, { backgroundColor: theme.accentSoft, borderColor: theme.dangerStrong }]}>
<Svg width={14} height={14} viewBox="0 0 24 24"> <Svg width={14} height={14} viewBox="0 0 24 24">
<Circle cx={12} cy={12} r={10} stroke="#EF5350" strokeWidth={1.8} fill="none" /> <Circle cx={12} cy={12} r={10} stroke={theme.dangerStrong} strokeWidth={1.8} fill="none" />
<Path d="M12 8v5M12 16.5v.5" stroke="#E53935" strokeWidth={1.8} strokeLinecap="round" /> <Path d="M12 8v5M12 16.5v.5" stroke={theme.dangerStrong} strokeWidth={1.8} strokeLinecap="round" />
</Svg> </Svg>
<Text style={[styles.warningText, { color: '#E53935' }]}> <Text style={[styles.warningText, { color: theme.dangerStrong }]}>
{endTime === startTime {endTime === startTime
? 'The end time is the same as the start time.' ? 'The end time is the same as the start time.'
: 'The end time is before the start time.'} : 'The end time is before the start time.'}
@@ -299,6 +321,14 @@ const styles = StyleSheet.create({
borderRadius: 10, borderRadius: 10,
borderWidth: 1, borderWidth: 1,
}, },
calendarButton: {
width: 52,
height: 52,
borderRadius: 12,
borderWidth: 1,
alignItems: 'center',
justifyContent: 'center',
},
warningText: { warningText: {
fontSize: 13, fontSize: 13,
fontWeight: '500', fontWeight: '500',
@@ -1,6 +1,6 @@
import React from 'react'; import React, { useMemo } from 'react';
import { View, Text, StyleSheet, TextInput , TextInputProps } from 'react-native'; import { View, Text, StyleSheet, TextInput , TextInputProps } from 'react-native';
import { useSettings } from '@/theme'; import { useSettings, type ThemeColors } from '@/theme';
interface DescriptionInputProps extends TextInputProps { interface DescriptionInputProps extends TextInputProps {
error?: string; error?: string;
@@ -8,6 +8,7 @@ interface DescriptionInputProps extends TextInputProps {
export function DescriptionInput({ error, ...props }: DescriptionInputProps) { export function DescriptionInput({ error, ...props }: DescriptionInputProps) {
const { theme } = useSettings(); const { theme } = useSettings();
const styles = useMemo(() => createStyles(theme), [theme]);
return ( return (
<View style={styles.container}> <View style={styles.container}>
@@ -35,7 +36,7 @@ export function DescriptionInput({ error, ...props }: DescriptionInputProps) {
); );
} }
const styles = StyleSheet.create({ const createStyles = (theme: ThemeColors) => StyleSheet.create({
container: { container: {
gap: 5, gap: 5,
}, },
@@ -51,7 +52,7 @@ const styles = StyleSheet.create({
fontSize: 14, fontSize: 14,
}, },
inputError: { inputError: {
borderColor: '#E53935', borderColor: theme.dangerStrong,
borderWidth: 1.5, borderWidth: 1.5,
}, },
charCount: { charCount: {
@@ -61,7 +62,7 @@ const styles = StyleSheet.create({
}, },
errorText: { errorText: {
fontSize: 11, fontSize: 11,
color: '#E53935', color: theme.dangerStrong,
marginLeft: 4, marginLeft: 4,
}, },
}); });
@@ -1,5 +1,5 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { StyleSheet, TouchableOpacity, Animated, Easing } from 'react-native'; import { StyleSheet, TouchableOpacity, Animated, Easing, View, Text } from 'react-native';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import Svg, { Path } from 'react-native-svg'; import Svg, { Path } from 'react-native-svg';
import { useSettings } from '@/theme'; import { useSettings } from '@/theme';
@@ -70,7 +70,7 @@ export function FloatingActionButton() {
> >
<Svg width={24} height={24} viewBox="0 0 24 24"> <Svg width={24} height={24} viewBox="0 0 24 24">
<Path <Path
d="M12 5v14M5 12h14" d="M12 19V5M12 5l-5 5M12 5l5 5"
stroke={theme.accentText} stroke={theme.accentText}
strokeWidth={2.5} strokeWidth={2.5}
strokeLinecap="round" strokeLinecap="round"
@@ -79,6 +79,9 @@ export function FloatingActionButton() {
</Svg> </Svg>
</Animated.View> </Animated.View>
</TouchableOpacity> </TouchableOpacity>
<View style={styles.labelContainer}>
<Text style={[styles.label, { color: theme.accentText }]}>Add a Task</Text>
</View>
</Animated.View> </Animated.View>
); );
} }
@@ -92,6 +95,7 @@ const styles = StyleSheet.create({
shadowOpacity: 0.3, shadowOpacity: 0.3,
shadowRadius: 12, shadowRadius: 12,
elevation: 6, elevation: 6,
alignItems: 'flex-end',
}, },
button: { button: {
width: 56, width: 56,
@@ -100,4 +104,16 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
}, },
labelContainer: {
marginTop: 8,
paddingHorizontal: 12,
paddingVertical: 4,
borderRadius: 12,
backgroundColor: 'rgba(0,0,0,0.6)',
},
label: {
fontSize: 12,
fontWeight: '600',
textAlign: 'center',
},
}); });
@@ -0,0 +1,279 @@
import React, { useState } from 'react';
import {
Modal,
View,
Text,
StyleSheet,
TextInput,
TouchableOpacity,
KeyboardAvoidingView,
ActivityIndicator,
Alert,
} from 'react-native';
import { useSettings } from '@/theme';
import { forgotPassword, resetPassword } from '@/services/auth';
interface ForgotPasswordModalProps {
visible: boolean;
onClose: () => void;
}
type ForgotPasswordStep = 'enterUsername' | 'enterResetCode';
export function ForgotPasswordModal({ visible, onClose }: ForgotPasswordModalProps) {
const { theme } = useSettings();
const [step, setStep] = useState<ForgotPasswordStep>('enterUsername');
const [username, setUsername] = useState('');
const [resetToken, setResetToken] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
if (!visible) return null;
const handleRequestReset = async () => {
if (busy) return;
if (!username.trim()) {
setError('Username is required');
return;
}
setBusy(true);
setError(null);
try {
const result = await forgotPassword(username.trim());
if (result.success) {
if (result.resetToken) {
Alert.alert('Reset Token (Dev Mode)', `Your reset token is: ${result.resetToken}\n\nEnter this token on the next screen.`, [
{ text: 'OK', onPress: () => setStep('enterResetCode') },
]);
} else {
Alert.alert('Reset Email Sent', 'If the account exists, a password reset link has been sent.', [
{ text: 'OK', onPress: () => setStep('enterResetCode') },
]);
}
} else {
setError('Failed to request password reset');
}
} catch (err: any) {
setError(err?.message ?? 'Failed to request password reset');
} finally {
setBusy(false);
}
};
const handleResetPassword = async () => {
if (busy) return;
if (!resetToken.trim()) {
setError('Reset token is required');
return;
}
if (newPassword.length < 8) {
setError('Password must be at least 8 characters');
return;
}
if (newPassword !== confirmPassword) {
setError('Passwords do not match');
return;
}
setBusy(true);
setError(null);
try {
const result = await resetPassword(resetToken.trim(), newPassword);
if (result.success) {
Alert.alert('Password Reset', 'Your password has been reset successfully.', [
{ text: 'OK', onPress: onClose },
]);
} else {
setError('Failed to reset password');
}
} catch (err: any) {
setError(err?.message ?? 'Failed to reset password. Token may be invalid or expired.');
} finally {
setBusy(false);
}
};
const handleBack = () => {
if (step === 'enterResetCode') {
setStep('enterUsername');
setResetToken('');
setNewPassword('');
setConfirmPassword('');
setError(null);
} else {
onClose();
}
};
const renderEnterUsername = () => (
<View style={styles.body}>
<Text style={[styles.subtitle, { color: theme.textSecondary }]}>
Enter your username to receive a password reset link.
</Text>
<TextInput
style={[styles.input, { backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text }]}
placeholder="Username"
placeholderTextColor={theme.textMuted}
value={username}
onChangeText={setUsername}
autoCapitalize="none"
autoCorrect={false}
/>
{error && <Text style={[styles.errorText, { color: theme.dangerStrong }]}>{error}</Text>}
<TouchableOpacity
style={[styles.primaryButton, { backgroundColor: theme.accent }]}
onPress={handleRequestReset}
disabled={busy}
activeOpacity={0.8}
>
{busy ? (
<ActivityIndicator color={theme.accentText} />
) : (
<Text style={[styles.primaryButtonText, { color: theme.accentText }]}>Send Reset Link</Text>
)}
</TouchableOpacity>
</View>
);
const renderEnterResetCode = () => (
<View style={styles.body}>
<Text style={[styles.subtitle, { color: theme.textSecondary }]}>
Enter the reset token and your new password.
</Text>
<TextInput
style={[styles.input, { backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text }]}
placeholder="Reset Token"
placeholderTextColor={theme.textMuted}
value={resetToken}
onChangeText={setResetToken}
autoCapitalize="none"
autoCorrect={false}
/>
<TextInput
style={[styles.input, { backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text }]}
placeholder="New Password (min 8 chars)"
placeholderTextColor={theme.textMuted}
value={newPassword}
onChangeText={setNewPassword}
secureTextEntry
/>
<TextInput
style={[styles.input, { backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text }]}
placeholder="Confirm New Password"
placeholderTextColor={theme.textMuted}
value={confirmPassword}
onChangeText={setConfirmPassword}
secureTextEntry
/>
{error && <Text style={[styles.errorText, { color: theme.dangerStrong }]}>{error}</Text>}
<TouchableOpacity
style={[styles.primaryButton, { backgroundColor: theme.accent }]}
onPress={handleResetPassword}
disabled={busy}
activeOpacity={0.8}
>
{busy ? (
<ActivityIndicator color={theme.accentText} />
) : (
<Text style={[styles.primaryButtonText, { color: theme.accentText }]}>Reset Password</Text>
)}
</TouchableOpacity>
</View>
);
return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={handleBack}>
<KeyboardAvoidingView
style={styles.overlay}
behavior="padding"
>
<TouchableOpacity style={styles.backdrop} activeOpacity={1} onPress={handleBack} />
<View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}>
<View style={[styles.handle, { backgroundColor: theme.borderStrong }]} />
<Text style={[styles.title, { color: theme.text }]}>Reset Password</Text>
{step === 'enterUsername' ? renderEnterUsername() : renderEnterResetCode()}
<TouchableOpacity
style={[styles.secondaryButton, { borderColor: theme.borderStrong }]}
onPress={handleBack}
activeOpacity={0.7}
>
<Text style={[styles.secondaryButtonText, { color: theme.textSecondary }]}>Cancel</Text>
</TouchableOpacity>
</View>
</KeyboardAvoidingView>
</Modal>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
justifyContent: 'flex-end',
},
backdrop: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.4)',
},
sheet: {
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
paddingTop: 8,
paddingHorizontal: 20,
paddingBottom: 32,
},
handle: {
width: 40,
height: 4,
borderRadius: 2,
alignSelf: 'center',
marginBottom: 12,
},
title: {
fontSize: 18,
fontWeight: '700',
textAlign: 'center',
marginBottom: 16,
},
body: {
gap: 12,
marginBottom: 16,
},
subtitle: {
fontSize: 14,
},
input: {
borderRadius: 12,
borderWidth: 1,
paddingHorizontal: 14,
paddingVertical: 12,
fontSize: 15,
},
primaryButton: {
paddingVertical: 14,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
minHeight: 50,
},
primaryButtonText: {
fontSize: 15,
fontWeight: '700',
},
secondaryButton: {
paddingVertical: 12,
borderRadius: 12,
borderWidth: 1,
alignItems: 'center',
},
secondaryButtonText: {
fontSize: 15,
fontWeight: '600',
},
errorText: {
fontSize: 13,
fontWeight: '500',
},
});
+72 -45
View File
@@ -1,9 +1,11 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useMemo, useRef } from 'react';
import { View, StyleSheet, TextInput, TouchableOpacity, Keyboard, Text } from 'react-native'; import { View, StyleSheet, TextInput, TouchableOpacity, Keyboard, Text, Platform } from 'react-native';
import { BlurView } from 'expo-blur';
import { database, collections } from '@/database'; import { database, collections } from '@/database';
import { useCategories } from '@/hooks/useDatabase'; import { useCategories } from '@/hooks/useDatabase';
import { useSettings } from '@/theme'; import { useSettings } from '@/theme';
import { OptionPickerModal } from '@/components/OptionPickerModal'; import { OptionPickerModal } from '@/components/OptionPickerModal';
import { tagsToString } from '@/types';
import Svg, { Path } from 'react-native-svg'; import Svg, { Path } from 'react-native-svg';
import { subscribeToQuickAdd } from '@/utils/quickAddFocus'; import { subscribeToQuickAdd } from '@/utils/quickAddFocus';
@@ -12,6 +14,21 @@ interface QuickAddBarProps {
placeholder?: string; placeholder?: string;
} }
const BlurContainer = ({ children, style, theme }: { children: React.ReactNode; style: any; theme: any }) => {
if (Platform.OS === 'web') {
return (
<View style={[style, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }]}>
{children}
</View>
);
}
return (
<BlurView style={style} intensity={80} tint="dark">
{children}
</BlurView>
);
};
export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) { export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
const { theme, defaultCategoryId } = useSettings(); const { theme, defaultCategoryId } = useSettings();
const categories = useCategories(); const categories = useCategories();
@@ -20,7 +37,11 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
const [categoryPickerVisible, setCategoryPickerVisible] = useState(false); const [categoryPickerVisible, setCategoryPickerVisible] = useState(false);
const inputRef = useRef<TextInput>(null); const inputRef = useRef<TextInput>(null);
const categoryColor = categories.find((c) => c.id === categoryId)?.color ?? '#9E9E9E'; const visibleCategories = useMemo(
() => categories.filter((c) => c.name.toLowerCase() !== 'calendar'),
[categories]
);
const categoryColor = visibleCategories.find((c) => c.id === categoryId)?.color ?? theme.textMuted;
useEffect(() => { useEffect(() => {
return subscribeToQuickAdd(() => { return subscribeToQuickAdd(() => {
@@ -40,6 +61,7 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
t.title = trimmed; t.title = trimmed;
t.description = ''; t.description = '';
t.categoryId = categoryId || ''; t.categoryId = categoryId || '';
t.tags = tagsToString(categoryId ? [categoryId] : []);
t.priority = 'none'; t.priority = 'none';
t.completed = false; t.completed = false;
t.dueDate = dueDate; t.dueDate = dueDate;
@@ -60,58 +82,57 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
return ( return (
<View style={styles.wrapper}> <View style={styles.wrapper}>
<View style={[styles.bar, { backgroundColor: theme.card, borderColor: theme.border }]}> <BlurContainer theme={theme} style={styles.blurContainer}>
<TouchableOpacity <View style={[styles.bar, { backgroundColor: 'rgba(255,255,255,0.05)', borderColor: 'rgba(255,255,255,0.1)' }]}>
style={[styles.categoryButton, { borderColor: theme.borderStrong, backgroundColor: theme.cardAlt }]} <TouchableOpacity
onPress={() => setCategoryPickerVisible(true)} style={[styles.categoryButton, { borderColor: theme.borderStrong, backgroundColor: theme.cardAlt }]}
activeOpacity={0.7} onPress={() => setCategoryPickerVisible(true)}
accessibilityLabel="Select category" activeOpacity={0.7}
> accessibilityLabel="Select tags"
<View style={[styles.categoryButtonDot, { backgroundColor: categoryColor }]} /> >
<Svg width={12} height={12} viewBox="0 0 24 24"> <View style={[styles.categoryButtonDot, { backgroundColor: categoryColor }]} />
<Path d="M6 9l6 6 6-6" stroke={theme.textSecondary} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" /> <Svg width={12} height={12} viewBox="0 0 24 24">
</Svg> <Path d="M6 9l6 6 6-6" stroke={theme.textSecondary} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
</TouchableOpacity> </Svg>
<TextInput </TouchableOpacity>
ref={inputRef} <TextInput
style={[styles.input, { color: theme.text }]} ref={inputRef}
placeholder={placeholder ?? 'Add a task'} style={[styles.input, { color: theme.text }]}
placeholderTextColor={theme.textMuted} placeholder={placeholder ?? 'Add a task'}
value={title} placeholderTextColor={theme.textMuted}
onChangeText={setTitle} value={title}
onSubmitEditing={handleAdd} onChangeText={setTitle}
returnKeyType="done" onSubmitEditing={handleAdd}
accessibilityLabel="Quick add task" returnKeyType="done"
accessibilityHint="Enter a task name and press the add button" accessibilityLabel="Quick add task"
/> accessibilityHint="Enter a task name and press the add button"
<TouchableOpacity />
style={[styles.submit, { backgroundColor: theme.accent }, title.trim() ? {} : styles.submitDisabled]} <TouchableOpacity
onPress={title.trim() ? handleAdd : undefined} style={[styles.submit, { backgroundColor: theme.accent }, title.trim() ? {} : styles.submitDisabled]}
disabled={!title.trim()} onPress={title.trim() ? handleAdd : undefined}
activeOpacity={0.8} disabled={!title.trim()}
accessibilityLabel="Add task" activeOpacity={0.8}
accessibilityHint="Adds the entered task to the list" accessibilityLabel="Add task"
accessibilityState={{ disabled: !title.trim() }} accessibilityHint="Adds the entered task to the list"
> accessibilityState={{ disabled: !title.trim() }}
<View style={styles.submitLabelRow}> >
<Text style={[styles.submitText, { color: theme.accentText }]}>Add a Task</Text> <Svg width={20} height={20} viewBox="0 0 24 24">
<Svg width={18} height={18} viewBox="0 0 24 24">
<Path <Path
d="M12 5v14M12 5l-5 5M12 5l5 5" d="M12 19V5M12 5l-5 5M12 5l5 5"
stroke={theme.accentText} stroke={theme.accentText}
strokeWidth={2.5} strokeWidth={2.5}
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
/> />
</Svg> </Svg>
</View> </TouchableOpacity>
</TouchableOpacity> </View>
</View> </BlurContainer>
<OptionPickerModal <OptionPickerModal
visible={categoryPickerVisible} visible={categoryPickerVisible}
title="Select Category" title="Select Tags"
options={[{ value: '', label: 'None', color: '#9E9E9E' }, ...categories.map((c) => ({ value: c.id, label: c.name, color: c.color }))]} options={[{ value: '', label: 'None', color: theme.textMuted }, ...visibleCategories.map((c) => ({ value: c.id, label: c.name, color: c.color }))]}
selectedValue={categoryId} selectedValue={categoryId}
onSelect={(value) => setCategoryId(Array.isArray(value) ? value[0] : value)} onSelect={(value) => setCategoryId(Array.isArray(value) ? value[0] : value)}
onClose={() => setCategoryPickerVisible(false)} onClose={() => setCategoryPickerVisible(false)}
@@ -124,6 +145,11 @@ const styles = StyleSheet.create({
wrapper: { wrapper: {
paddingHorizontal: 8, paddingHorizontal: 8,
paddingTop: 4, paddingTop: 4,
paddingBottom: 8,
},
blurContainer: {
borderRadius: 14,
overflow: 'hidden',
}, },
bar: { bar: {
flexDirection: 'row', flexDirection: 'row',
@@ -139,6 +165,7 @@ const styles = StyleSheet.create({
shadowOpacity: 0.10, shadowOpacity: 0.10,
shadowRadius: 6, shadowRadius: 6,
elevation: 6, elevation: 6,
minHeight: 56,
}, },
categoryButton: { categoryButton: {
flexDirection: 'row', flexDirection: 'row',
@@ -2,7 +2,6 @@ import React, { useState, useEffect } from 'react';
import { View, StyleSheet } from 'react-native'; import { View, StyleSheet } from 'react-native';
import { SubtaskData } from '@/types'; import { SubtaskData } from '@/types';
import { TaskItem } from './TaskItem'; import { TaskItem } from './TaskItem';
import { useSettings } from '@/theme';
interface SubtaskItemProps { interface SubtaskItemProps {
subtask: SubtaskData; subtask: SubtaskData;
@@ -19,6 +18,7 @@ interface SubtaskItemProps {
onDragEnd?: (absoluteY: number) => void; onDragEnd?: (absoluteY: number) => void;
depth?: number; depth?: number;
categoryColor?: string; categoryColor?: string;
categoryColorResolver?: (categoryId: string | undefined) => string | undefined;
registerRef?: (subtaskId: string, parentTaskId: string, ref: View | null) => void; registerRef?: (subtaskId: string, parentTaskId: string, ref: View | null) => void;
hoveredId?: string | null; hoveredId?: string | null;
} }
@@ -38,13 +38,16 @@ export const SubtaskItem = React.memo(function SubtaskItem({
onDragEnd, onDragEnd,
depth = 1, depth = 1,
categoryColor, categoryColor,
categoryColorResolver,
registerRef, registerRef,
hoveredId, hoveredId,
}: SubtaskItemProps) { }: SubtaskItemProps) {
const { theme } = useSettings();
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const hasChildren = subtask.subtasks && subtask.subtasks.length > 0; const hasChildren = subtask.subtasks && subtask.subtasks.length > 0;
const hovered = hoveredId === subtask.id; const hovered = hoveredId === subtask.id;
const effectiveColor = subtask.categoryId
? categoryColorResolver?.(subtask.categoryId) ?? categoryColor
: categoryColor;
const handleExpand = () => setExpanded(!expanded); const handleExpand = () => setExpanded(!expanded);
@@ -75,7 +78,7 @@ export const SubtaskItem = React.memo(function SubtaskItem({
onDragStart={() => onDragStart?.(subtask)} onDragStart={() => onDragStart?.(subtask)}
onDragUpdate={onDragUpdate} onDragUpdate={onDragUpdate}
onDragEnd={onDragEnd} onDragEnd={onDragEnd}
categoryColor={categoryColor} categoryColor={effectiveColor}
/> />
</View> </View>
{hasChildren && expanded && ( {hasChildren && expanded && (
@@ -99,7 +102,8 @@ export const SubtaskItem = React.memo(function SubtaskItem({
onDragUpdate={onDragUpdate} onDragUpdate={onDragUpdate}
onDragEnd={onDragEnd} onDragEnd={onDragEnd}
depth={depth + 1} depth={depth + 1}
categoryColor={categoryColor} categoryColor={effectiveColor}
categoryColorResolver={categoryColorResolver}
registerRef={registerRef} registerRef={registerRef}
hoveredId={hoveredId} hoveredId={hoveredId}
/> />
+30 -4
View File
@@ -12,6 +12,8 @@ import {
import { useSettings } from '@/theme'; import { useSettings } from '@/theme';
import { AuthUser, getAuthToken, getAuthUser, login, register, signOutAuth } from '@/services/auth'; import { AuthUser, getAuthToken, getAuthUser, login, register, signOutAuth } from '@/services/auth';
import { runSyncGuarded, startAutoSync, stopAutoSync, getLastSyncTime, SyncResult } from '@/database/sync'; import { runSyncGuarded, startAutoSync, stopAutoSync, getLastSyncTime, SyncResult } from '@/database/sync';
import { ForgotPasswordModal } from './ForgotPasswordModal';
import { registerForPushNotificationsAsync, unregisterForPushNotificationsAsync } from '@/services/pushNotifications';
interface SyncModalProps { interface SyncModalProps {
visible: boolean; visible: boolean;
@@ -34,6 +36,7 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
const [lastSync, setLastSync] = useState<number | null>(null); const [lastSync, setLastSync] = useState<number | null>(null);
const [result, setResult] = useState<SyncResult | null>(null); const [result, setResult] = useState<SyncResult | null>(null);
const [prevVisible, setPrevVisible] = useState(visible); const [prevVisible, setPrevVisible] = useState(visible);
const [showForgotPassword, setShowForgotPassword] = useState(false);
if (prevVisible !== visible) { if (prevVisible !== visible) {
setPrevVisible(visible); setPrevVisible(visible);
@@ -83,6 +86,7 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
: await login(username.trim(), password); : await login(username.trim(), password);
setUser(authedUser); setUser(authedUser);
startAutoSync(); startAutoSync();
registerForPushNotificationsAsync().catch(() => {});
} catch (err: any) { } catch (err: any) {
setError(err?.message ?? 'Sign in failed'); setError(err?.message ?? 'Sign in failed');
} finally { } finally {
@@ -115,6 +119,7 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
const handleSignOut = async () => { const handleSignOut = async () => {
await signOutAuth(); await signOutAuth();
stopAutoSync(); stopAutoSync();
unregisterForPushNotificationsAsync().catch(() => {});
setUser(null); setUser(null);
setError(null); setError(null);
setStatus('idle'); setStatus('idle');
@@ -159,7 +164,7 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
</TouchableOpacity> </TouchableOpacity>
{status === 'success' && result && ( {status === 'success' && result && (
<Text style={[styles.successText, { color: '#43A047' }]}> <Text style={[styles.successText, { color: theme.successStrong }]}>
Synced! {result.pushed.tasks + result.pulled.tasks} tasks,{' '} Synced! {result.pushed.tasks + result.pulled.tasks} tasks,{' '}
{result.pushed.categories + result.pulled.categories} categories,{' '} {result.pushed.categories + result.pulled.categories} categories,{' '}
{result.pushed.subtasks + result.pulled.subtasks} subtasks,{' '} {result.pushed.subtasks + result.pulled.subtasks} subtasks,{' '}
@@ -168,7 +173,7 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
</Text> </Text>
)} )}
{status === 'error' && error && ( {status === 'error' && error && (
<Text style={[styles.errorText, { color: '#E53935' }]}>{error}</Text> <Text style={[styles.errorText, { color: theme.dangerStrong }]}>{error}</Text>
)} )}
<TouchableOpacity <TouchableOpacity
@@ -176,7 +181,7 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
onPress={handleSignOut} onPress={handleSignOut}
activeOpacity={0.7} activeOpacity={0.7}
> >
<Text style={[styles.secondaryButtonText, { color: '#E53935' }]}>Sign Out</Text> <Text style={[styles.secondaryButtonText, { color: theme.dangerStrong }]}>Sign Out</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
) : ( ) : (
@@ -234,7 +239,17 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
secureTextEntry secureTextEntry
/> />
{error && <Text style={[styles.errorText, { color: '#E53935' }]}>{error}</Text>} {mode === 'login' && (
<TouchableOpacity
style={styles.forgotPasswordLink}
onPress={() => setShowForgotPassword(true)}
activeOpacity={0.7}
>
<Text style={[styles.forgotPasswordText, { color: theme.accent }]}>Forgot Password?</Text>
</TouchableOpacity>
)}
{error && <Text style={[styles.errorText, { color: theme.dangerStrong }]}>{error}</Text>}
<TouchableOpacity <TouchableOpacity
style={[styles.primaryButton, { backgroundColor: theme.accent }]} style={[styles.primaryButton, { backgroundColor: theme.accent }]}
@@ -253,6 +268,8 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
</View> </View>
)} )}
<ForgotPasswordModal visible={showForgotPassword} onClose={() => setShowForgotPassword(false)} />
<TouchableOpacity <TouchableOpacity
style={[styles.secondaryButton, { borderColor: theme.borderStrong }]} style={[styles.secondaryButton, { borderColor: theme.borderStrong }]}
onPress={onClose} onPress={onClose}
@@ -374,4 +391,13 @@ const styles = StyleSheet.create({
fontSize: 13, fontSize: 13,
fontWeight: '500', fontWeight: '500',
}, },
forgotPasswordLink: {
alignSelf: 'flex-end',
marginTop: -4,
marginBottom: 8,
},
forgotPasswordText: {
fontSize: 13,
fontWeight: '500',
},
}); });
@@ -66,14 +66,14 @@ export default function SyncStatus({ compact = false }: SyncStatusProps) {
case 'success': case 'success':
return ( return (
<Svg width={16} height={16} viewBox="0 0 24 24"> <Svg width={16} height={16} viewBox="0 0 24 24">
<Path d="M5 12l5 5 9-10" stroke="#66BB6A" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" /> <Path d="M5 12l5 5 9-10" stroke={theme.success} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
</Svg> </Svg>
); );
case 'error': case 'error':
return ( return (
<Svg width={16} height={16} viewBox="0 0 24 24"> <Svg width={16} height={16} viewBox="0 0 24 24">
<Circle cx={12} cy={12} r={10} stroke="#EF5350" strokeWidth={2} fill="none" /> <Circle cx={12} cy={12} r={10} stroke={theme.danger} strokeWidth={2} fill="none" />
<Path d="M12 8v4M12 16h.01" stroke="#EF5350" strokeWidth={2} strokeLinecap="round" /> <Path d="M12 8v4M12 16h.01" stroke={theme.danger} strokeWidth={2} strokeLinecap="round" />
</Svg> </Svg>
); );
default: default:
@@ -128,7 +128,7 @@ export default function SyncStatus({ compact = false }: SyncStatusProps) {
</View> </View>
{error && ( {error && (
<View style={styles.error}> <View style={styles.error}>
<Text style={[styles.errorText, { color: '#E53935' }]}>{error}</Text> <Text style={[styles.errorText, { color: theme.dangerStrong }]}>{error}</Text>
</View> </View>
)} )}
<Text style={[styles.lastSync, { color: theme.textFaint }]}> <Text style={[styles.lastSync, { color: theme.textFaint }]}>
+30 -25
View File
@@ -3,7 +3,8 @@ import { View, Text, StyleSheet, TouchableOpacity, Animated, Platform, Dimension
import { Swipeable, Gesture, GestureDetector, PanGestureHandler } from 'react-native-gesture-handler'; import { Swipeable, Gesture, GestureDetector, PanGestureHandler } from 'react-native-gesture-handler';
import { TaskData, SubtaskData, Priority, Repeat, Reminder } from '@/types'; import { TaskData, SubtaskData, Priority, Repeat, Reminder } from '@/types';
import { PRIORITY_COLORS } from '@/constants'; import { PRIORITY_COLORS } from '@/constants';
import { useSettings } from '@/theme'; import { useSettings, mixHex } from '@/theme';
import type { ThemeColors } from '@/theme';
import Svg, { Path, Circle } from 'react-native-svg'; import Svg, { Path, Circle } from 'react-native-svg';
export type TaskLike = { export type TaskLike = {
@@ -42,10 +43,12 @@ interface TaskItemProps {
indented?: boolean; indented?: boolean;
depth?: number; depth?: number;
categoryColor?: string; categoryColor?: string;
categoryColors?: (string | undefined)[];
} }
export const TaskItem = React.memo(function TaskItem({ task, onToggle, onDelete, onPress, onLongPress, onMenuOpen, selected, selectionMode, completedSection, draggable, hovered, onDragStart, onDragUpdate, onDragEnd, onReorderStart, onReorderUpdate, onReorderEnd, expanded, indented, depth = 0, categoryColor }: TaskItemProps) { export const TaskItem = React.memo(function TaskItem({ task, onToggle, onDelete, onPress, onLongPress, onMenuOpen, selected, selectionMode, completedSection, draggable, hovered, onDragStart, onDragUpdate, onDragEnd, onReorderStart, onReorderUpdate, onReorderEnd, expanded, indented, depth = 0, categoryColor, categoryColors }: TaskItemProps) {
const { theme } = useSettings(); const { theme, deleteBySwipe } = useSettings();
const styles = React.useMemo(() => createStyles(theme), [theme]);
const [opacityAnim] = React.useState(new Animated.Value(task.completed ? 0.5 : 1)); const [opacityAnim] = React.useState(new Animated.Value(task.completed ? 0.5 : 1));
const [dragTranslateX] = React.useState(new Animated.Value(0)); const [dragTranslateX] = React.useState(new Animated.Value(0));
const [dragTranslateY] = React.useState(new Animated.Value(0)); const [dragTranslateY] = React.useState(new Animated.Value(0));
@@ -173,7 +176,9 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) =>
renderRightActions={renderRightActions} renderRightActions={renderRightActions}
renderLeftActions={renderLeftActions} renderLeftActions={renderLeftActions}
onSwipeableRightOpen={() => { onSwipeableRightOpen={() => {
onDelete?.(); if (deleteBySwipe) {
onDelete?.();
}
swipeableRef.current?.close(); swipeableRef.current?.close();
}} }}
onSwipeableLeftOpen={() => { onSwipeableLeftOpen={() => {
@@ -215,9 +220,9 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) =>
<View style={styles.content}> <View style={styles.content}>
<View style={styles.titleRow}> <View style={styles.titleRow}>
<View style={styles.categoryDotSlot}> <View style={styles.categoryDotSlot}>
{categoryColor ? ( {categoryColor && (
<View style={[styles.categoryDot, { backgroundColor: categoryColor }]} /> <View style={[styles.categoryDot, { backgroundColor: categoryColor }]} />
) : null} )}
</View> </View>
<TouchableOpacity <TouchableOpacity
style={styles.checkCircle} style={styles.checkCircle}
@@ -234,7 +239,7 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) =>
<Path d="M7 12l3 3 7-7" stroke="#FFFFFF" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" /> <Path d="M7 12l3 3 7-7" stroke="#FFFFFF" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
</> </>
) : ( ) : (
<Circle cx={12} cy={12} r={10} stroke={isOverdue ? '#FF8A80' : theme.textSecondary} strokeWidth={2.5} fill="none" /> <Circle cx={12} cy={12} r={10} stroke={isOverdue ? theme.danger : theme.textSecondary} strokeWidth={2.5} fill="none" />
)} )}
</Svg> </Svg>
</TouchableOpacity> </TouchableOpacity>
@@ -395,7 +400,7 @@ function formatTime12(time: string): string {
return `${h}:${String(minutes).padStart(2, '0')} ${period}`; return `${h}:${String(minutes).padStart(2, '0')} ${period}`;
} }
const styles = StyleSheet.create({ const createStyles = (theme: ThemeColors) => StyleSheet.create({
container: { container: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
@@ -418,10 +423,10 @@ const styles = StyleSheet.create({
marginBottom: 4, marginBottom: 4,
}, },
taskOverdue: { taskOverdue: {
borderColor: '#573431', borderColor: theme.dangerBorder,
}, },
taskDueToday: { taskDueToday: {
borderColor: '#2C4766', borderColor: mixHex(theme.info, theme.background, 0.55),
}, },
taskSelected: { taskSelected: {
shadowColor: '#000', shadowColor: '#000',
@@ -430,8 +435,8 @@ const styles = StyleSheet.create({
elevation: 2, elevation: 2,
}, },
taskHovered: { taskHovered: {
borderColor: '#2C4766', borderColor: mixHex(theme.info, theme.background, 0.55),
backgroundColor: 'rgba(44, 71, 102, 0.15)', backgroundColor: mixHex(theme.info, theme.background, 0.85),
}, },
dragLifted: { dragLifted: {
zIndex: 100, zIndex: 100,
@@ -468,9 +473,9 @@ const styles = StyleSheet.create({
marginRight: 8, marginRight: 8,
}, },
categoryDot: { categoryDot: {
width: 10, width: 12,
height: 10, height: 12,
borderRadius: 5, borderRadius: 6,
}, },
title: { title: {
fontSize: 17, fontSize: 17,
@@ -480,7 +485,7 @@ const styles = StyleSheet.create({
}, },
titleCompleted: { titleCompleted: {
textDecorationLine: 'line-through', textDecorationLine: 'line-through',
color: '#9E9E9E', color: theme.textMuted,
}, },
repeatIcon: { repeatIcon: {
marginLeft: 8, marginLeft: 8,
@@ -504,7 +509,7 @@ const styles = StyleSheet.create({
justifyContent: 'center', justifyContent: 'center',
}, },
titleOverdue: { titleOverdue: {
color: '#E57373', color: theme.danger,
}, },
priorityBadge: { priorityBadge: {
paddingHorizontal: 6, paddingHorizontal: 6,
@@ -522,14 +527,14 @@ const styles = StyleSheet.create({
width: 20, width: 20,
height: 20, height: 20,
borderRadius: 10, borderRadius: 10,
backgroundColor: '#43302E', backgroundColor: theme.dangerSoft,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
}, },
assigneeText: { assigneeText: {
fontSize: 9, fontSize: 9,
fontWeight: '700', fontWeight: '700',
color: '#FF9C8F', color: theme.danger,
}, },
dueRow: { dueRow: {
flexDirection: 'row', flexDirection: 'row',
@@ -540,11 +545,11 @@ const styles = StyleSheet.create({
fontSize: 13, fontSize: 13,
}, },
dueTextOverdue: { dueTextOverdue: {
color: '#E57373', color: theme.danger,
fontWeight: '600', fontWeight: '600',
}, },
dueTextDueToday: { dueTextDueToday: {
color: '#64B5F6', color: theme.info,
fontWeight: '600', fontWeight: '600',
}, },
menuButton: { menuButton: {
@@ -559,12 +564,12 @@ const styles = StyleSheet.create({
paddingHorizontal: 6, paddingHorizontal: 6,
paddingVertical: 2, paddingVertical: 2,
borderRadius: 8, borderRadius: 8,
backgroundColor: 'rgba(100, 181, 246, 0.15)', backgroundColor: mixHex(theme.info, theme.background, 0.85),
}, },
allDayText: { allDayText: {
fontSize: 11, fontSize: 11,
fontWeight: '600', fontWeight: '600',
color: '#64B5F6', color: theme.info,
}, },
checkCircle: { checkCircle: {
width: 30, width: 30,
@@ -581,12 +586,12 @@ const styles = StyleSheet.create({
gap: 4, gap: 4,
}, },
swipeDelete: { swipeDelete: {
backgroundColor: '#B8504A', backgroundColor: theme.dangerStrong,
borderTopRightRadius: 16, borderTopRightRadius: 16,
borderBottomRightRadius: 16, borderBottomRightRadius: 16,
}, },
swipeComplete: { swipeComplete: {
backgroundColor: '#3D7A4A', backgroundColor: theme.successStrong,
borderTopLeftRadius: 16, borderTopLeftRadius: 16,
borderBottomLeftRadius: 16, borderBottomLeftRadius: 16,
}, },
+76 -70
View File
@@ -1,15 +1,18 @@
import React, { useState, useCallback, useMemo, useRef, useEffect } from 'react'; import React, { useState, useCallback, useMemo, useRef } from 'react';
import { View, Text, StyleSheet, Animated, RefreshControl, Alert, TouchableOpacity } from 'react-native'; import { View, Text, StyleSheet, Animated, RefreshControl, Alert, TouchableOpacity } from 'react-native';
import { useTasks } from '@/hooks/useTasks'; import { useTasks } from '@/hooks/useTasks';
import { useSubtasks } from '@/hooks/useSubtasks';
import { useCategories } from '@/hooks/useDatabase'; import { useCategories } from '@/hooks/useDatabase';
import { useTaskModals } from '@/hooks/useTaskModals'; import { useTaskModals } from '@/hooks/useTaskModals';
import { useFocusEffect } from 'expo-router';
import { TaskItem } from './TaskItem'; import { TaskItem } from './TaskItem';
import { SubtaskItem } from './SubtaskItem'; import { SubtaskItem } from './SubtaskItem';
import { TaskData, SubtaskData } from '@/types'; import { TaskData, SubtaskData, parseTaskTags } from '@/types';
import Task from '@/models/Task'; import Task from '@/models/Task';
import { useSettings } from '@/theme'; import { useSettings } from '@/theme';
import { runSyncGuarded } from '@/database/sync';
import { import {
toggleTaskComplete, confirmAndToggleTaskComplete,
deleteTask, deleteTask,
setTaskCompleted, setTaskCompleted,
convertTaskToSubtask, convertTaskToSubtask,
@@ -17,12 +20,12 @@ import {
moveSubtaskToTask, moveSubtaskToTask,
setSubtaskParent, setSubtaskParent,
toggleSubtaskComplete, toggleSubtaskComplete,
fetchSubtaskTree,
} from '@/utils/taskActions'; } from '@/utils/taskActions';
import Svg, { Path, Rect } from 'react-native-svg'; import Svg, { Path } from 'react-native-svg';
interface TaskListProps { interface TaskListProps {
categoryId?: string; categoryIds?: string[];
showCompleted?: boolean;
onSelectionChange?: (active: boolean) => void; onSelectionChange?: (active: boolean) => void;
} }
@@ -39,10 +42,10 @@ const DropIndicator = ({ theme }: { theme: any }) => (
</View> </View>
); );
export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProps) { export function TaskList({ categoryIds = [], showCompleted = false, onSelectionChange }: TaskListProps) {
const { theme, sortBy, todoAheadDays } = useSettings(); const { theme, sortBy, todoAheadDays } = useSettings();
const { tasks, loading } = useTasks(categoryId, 'all', todoAheadDays); const { tasks, loading, refresh: refreshTasks } = useTasks(categoryIds, showCompleted ? 'all' : false, todoAheadDays);
const categories = useCategories(); const categories = useCategories();
const categoryColors = useMemo(() => { const categoryColors = useMemo(() => {
@@ -58,7 +61,7 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set()); const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [hoverTaskId, setHoverTaskId] = useState<string | null>(null); const [hoverTaskId, setHoverTaskId] = useState<string | null>(null);
const [expandedTasks, setExpandedTasks] = useState<Set<string>>(new Set()); const [expandedTasks, setExpandedTasks] = useState<Set<string>>(new Set());
const [subtasksMap, setSubtasksMap] = useState<Map<string, SubtaskData[]>>(new Map()); const { map: subtasksMap, refresh: refreshSubtasks } = useSubtasks();
const [dropIndicator, setDropIndicator] = useState<{ targetId: string | null; position: 'above' | 'below' } | null>(null); const [dropIndicator, setDropIndicator] = useState<{ targetId: string | null; position: 'above' | 'below' } | null>(null);
const itemRefs = useRef<Map<string, View>>(new Map()); const itemRefs = useRef<Map<string, View>>(new Map());
const subtaskRefs = useRef<Map<string, { ref: View; parentTaskId: string }>>(new Map()); const subtaskRefs = useRef<Map<string, { ref: View; parentTaskId: string }>>(new Map());
@@ -88,6 +91,11 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
} }
}, []); }, []);
const categoryColorResolver = useCallback(
(categoryId: string | undefined) => (categoryId ? categoryColors.get(categoryId) : undefined),
[categoryColors]
);
const sortedTasks = useMemo(() => { const sortedTasks = useMemo(() => {
const sorted = [...tasks]; const sorted = [...tasks];
switch (sortBy) { switch (sortBy) {
@@ -104,13 +112,32 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
sorted.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()); sorted.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
break; break;
} }
return sorted; const active: typeof sorted = [];
const done: typeof sorted = [];
for (const t of sorted) {
(t.completed ? done : active).push(t);
}
return [...active, ...done];
}, [tasks, sortBy]); }, [tasks, sortBy]);
const onRefresh = useCallback(() => { useFocusEffect(
useCallback(() => {
refreshTasks();
refreshSubtasks();
}, [refreshTasks, refreshSubtasks])
);
const onRefresh = useCallback(async () => {
setRefreshing(true); setRefreshing(true);
try {
await runSyncGuarded();
} catch (error) {
if (__DEV__) console.warn('Sync failed:', error);
}
refreshTasks();
refreshSubtasks();
setTimeout(() => setRefreshing(false), 600); setTimeout(() => setRefreshing(false), 600);
}, []); }, [refreshTasks, refreshSubtasks]);
const exitSelection = useCallback(() => { const exitSelection = useCallback(() => {
setSelectionMode(false); setSelectionMode(false);
@@ -140,22 +167,9 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
}); });
}, [onSelectionChange]); }, [onSelectionChange]);
const fetchSubtasks = useCallback(async (taskId: string) => {
const withNested = await fetchSubtaskTree(taskId);
setSubtasksMap((prev) => new Map(prev).set(taskId, withNested));
return withNested;
}, []);
const refreshAll = useCallback(() => {
for (const taskId of expandedTasks) {
fetchSubtasks(taskId);
}
}, [expandedTasks, fetchSubtasks]);
const handleToggle = useCallback(async (taskId: string) => { const handleToggle = useCallback(async (taskId: string) => {
await toggleTaskComplete(taskId); await confirmAndToggleTaskComplete(taskId, () => refreshTasks());
refreshAll(); }, [refreshTasks]);
}, [refreshAll]);
const toggleExpand = useCallback(async (taskId: string) => { const toggleExpand = useCallback(async (taskId: string) => {
setExpandedTasks((prev) => { setExpandedTasks((prev) => {
@@ -167,23 +181,13 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
} }
return next; return next;
}); });
}, []);
const isCurrentlyExpanded = expandedTasks.has(taskId); const handleSubtaskToggle = useCallback(async (subtaskId: string) => {
if (isCurrentlyExpanded) {
setSubtasksMap((prev) => {
const next = new Map(prev);
next.delete(taskId);
return next;
});
} else {
await fetchSubtasks(taskId);
}
}, [expandedTasks, fetchSubtasks]);
const handleSubtaskToggle = useCallback(async (subtaskId: string, taskId: string) => {
await toggleSubtaskComplete(subtaskId); await toggleSubtaskComplete(subtaskId);
await fetchSubtasks(taskId); refreshTasks();
}, [fetchSubtasks]); refreshSubtasks();
}, [refreshTasks, refreshSubtasks]);
const handleBulkDelete = useCallback(() => { const handleBulkDelete = useCallback(() => {
Alert.alert(`Delete ${selectedIds.size} task${selectedIds.size > 1 ? 's' : ''}?`, 'This cannot be undone.', [ Alert.alert(`Delete ${selectedIds.size} task${selectedIds.size > 1 ? 's' : ''}?`, 'This cannot be undone.', [
@@ -194,17 +198,15 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
onPress: async () => { onPress: async () => {
await Promise.all(Array.from(selectedIds).map(taskId => deleteTask(taskId))); await Promise.all(Array.from(selectedIds).map(taskId => deleteTask(taskId)));
exitSelection(); exitSelection();
refreshAll();
}, },
}, },
]); ]);
}, [selectedIds, exitSelection, refreshAll]); }, [selectedIds, exitSelection]);
const handleBulkComplete = useCallback(async () => { const handleBulkComplete = useCallback(async () => {
await Promise.all(Array.from(selectedIds).map(taskId => setTaskCompleted(taskId, true))); await Promise.all(Array.from(selectedIds).map(taskId => setTaskCompleted(taskId, true)));
exitSelection(); exitSelection();
refreshAll(); }, [selectedIds, exitSelection]);
}, [selectedIds, exitSelection, refreshAll]);
const measureItems = useCallback(async () => { const measureItems = useCallback(async () => {
const positions: Record<string, { top: number; bottom: number }> = {}; const positions: Record<string, { top: number; bottom: number }> = {};
@@ -323,20 +325,17 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
const parentTaskId = subtaskRefs.current.get(target)?.parentTaskId ?? state.taskId; const parentTaskId = subtaskRefs.current.get(target)?.parentTaskId ?? state.taskId;
(async () => { (async () => {
await convertTaskToSubtask(state.taskId, parentTaskId, target); await convertTaskToSubtask(state.taskId, parentTaskId, target);
await fetchSubtasks(parentTaskId);
refreshAll();
})(); })();
} else { } else {
(async () => { (async () => {
await convertTaskToSubtask(state.taskId, target); await convertTaskToSubtask(state.taskId, target);
refreshAll();
})(); })();
} }
} }
}, [findHoverTarget, refreshAll, fetchSubtasks]); }, [findHoverTarget]);
const handleSubtaskDragStart = useCallback(async (subtaskId: string, parentTaskId: string) => { const handleSubtaskDragStart = useCallback(async (subtaskId: string, parentTaskId: string, parentSubtaskId?: string) => {
subtaskDragRef.current = { subtaskId, parentTaskId, ...(await measureAll()) }; subtaskDragRef.current = { subtaskId, parentTaskId, parentSubtaskId, ...(await measureAll()) };
}, [measureAll]); }, [measureAll]);
const handleSubtaskDragUpdate = useCallback((absoluteY: number) => { const handleSubtaskDragUpdate = useCallback((absoluteY: number) => {
@@ -362,7 +361,7 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
} }
}, [findHoverTarget, calculateDropPosition, updateHoverTarget]); }, [findHoverTarget, calculateDropPosition, updateHoverTarget]);
const handleSubtaskDragEnd = useCallback(async (absoluteY: number) => { const handleSubtaskDragEnd = useCallback(async (absoluteY: number) => {
const state = subtaskDragRef.current; const state = subtaskDragRef.current;
subtaskDragRef.current = null; subtaskDragRef.current = null;
setHoverTaskId(null); setHoverTaskId(null);
@@ -372,19 +371,15 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
const target = findHoverTarget(absoluteY, state.subtaskId, state.positions, state.subPositions); const target = findHoverTarget(absoluteY, state.subtaskId, state.positions, state.subPositions);
if (target === state.subtaskId) return; if (target === state.subtaskId) return;
if (target) { // Only allow dropping on tasks (not subtasks) to match task behavior
if (state.subPositions[target]) { // Check if target exists in main task positions, not subtask positions
await setSubtaskParent(state.subtaskId, target); const isTaskTarget = state.positions && target in state.positions;
} else if (target !== state.parentTaskId) { if (target && isTaskTarget) {
await moveSubtaskToTask(state.subtaskId, target); // Move subtask to target task - makes it a subtask of that task
await fetchSubtasks(target); // If no target or target is a subtask: do nothing (matches task behavior)
} await moveSubtaskToTask(state.subtaskId, target);
} else {
await convertSubtaskToTask(state.subtaskId);
} }
await fetchSubtasks(state.parentTaskId); }, [findHoverTarget, moveSubtaskToTask]);
refreshAll();
}, [findHoverTarget, fetchSubtasks, refreshAll]);
const renderItem = useCallback( const renderItem = useCallback(
({ item, index }: { item: Task; index: number }) => { ({ item, index }: { item: Task; index: number }) => {
@@ -392,6 +387,7 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
const itemSubtasks = subtasksMap.get(item.id) ?? []; const itemSubtasks = subtasksMap.get(item.id) ?? [];
const showDropAbove = dropIndicator?.targetId === item.id && dropIndicator?.position === 'above'; const showDropAbove = dropIndicator?.targetId === item.id && dropIndicator?.position === 'above';
const showDropBelow = dropIndicator?.targetId === item.id && dropIndicator?.position === 'below'; const showDropBelow = dropIndicator?.targetId === item.id && dropIndicator?.position === 'below';
const tagColors = parseTaskTags(item.tags, item.categoryId).map((id) => categoryColors.get(id));
return ( return (
<View> <View>
{showDropAbove && <DropIndicator theme={theme} />} {showDropAbove && <DropIndicator theme={theme} />}
@@ -425,6 +421,8 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
onReorderEnd={handleDragEnd} onReorderEnd={handleDragEnd}
selectedIds={selectedIds} selectedIds={selectedIds}
categoryColor={categoryColors.get(item.categoryId)} categoryColor={categoryColors.get(item.categoryId)}
categoryColors={tagColors}
categoryColorResolver={categoryColorResolver}
/> />
{showDropBelow && <DropIndicator theme={theme} />} {showDropBelow && <DropIndicator theme={theme} />}
</View> </View>
@@ -454,6 +452,8 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
handleSubtaskDragStart, handleSubtaskDragStart,
handleSubtaskDragUpdate, handleSubtaskDragUpdate,
handleSubtaskDragEnd, handleSubtaskDragEnd,
registerSubtaskRef,
categoryColorResolver,
categoryColors, categoryColors,
] ]
); );
@@ -503,7 +503,7 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
contentContainerStyle={styles.listContent} contentContainerStyle={styles.listContent}
/> />
{modals(refreshAll)} {modals(() => {})}
{selectionMode && ( {selectionMode && (
<View style={[styles.selectionBar, { backgroundColor: theme.sheetBg, borderTopColor: theme.border }]}> <View style={[styles.selectionBar, { backgroundColor: theme.sheetBg, borderTopColor: theme.border }]}>
@@ -515,8 +515,8 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
</TouchableOpacity> </TouchableOpacity>
<Text style={[styles.selectionCount, { color: theme.text }]}>{selectedIds.size} selected</Text> <Text style={[styles.selectionCount, { color: theme.text }]}>{selectedIds.size} selected</Text>
<View style={styles.selectionActions}> <View style={styles.selectionActions}>
<SelectionButton label="Complete" color="#43A047" onPress={handleBulkComplete} /> <SelectionButton label="Complete" color={theme.successStrong} onPress={handleBulkComplete} />
<SelectionButton label="Delete" color="#E53935" onPress={handleBulkDelete} /> <SelectionButton label="Delete" color={theme.dangerStrong} onPress={handleBulkDelete} />
</View> </View>
</View> </View>
)} )}
@@ -566,6 +566,8 @@ interface TaskRowProps {
onReorderEnd: (absoluteY: number, translationY: number) => void; onReorderEnd: (absoluteY: number, translationY: number) => void;
selectedIds: Set<string>; selectedIds: Set<string>;
categoryColor?: string; categoryColor?: string;
categoryColors?: (string | undefined)[];
categoryColorResolver?: (categoryId: string | undefined) => string | undefined;
} }
const TaskRow = React.memo(function TaskRow({ const TaskRow = React.memo(function TaskRow({
@@ -598,6 +600,8 @@ const TaskRow = React.memo(function TaskRow({
onReorderEnd, onReorderEnd,
selectedIds, selectedIds,
categoryColor, categoryColor,
categoryColors,
categoryColorResolver,
}: TaskRowProps) { }: TaskRowProps) {
const sortedSubtasks = useMemo( const sortedSubtasks = useMemo(
() => subtasks.slice().sort((a, b) => a.order - b.order), () => subtasks.slice().sort((a, b) => a.order - b.order),
@@ -627,6 +631,7 @@ const TaskRow = React.memo(function TaskRow({
onReorderUpdate={onReorderUpdate} onReorderUpdate={onReorderUpdate}
onReorderEnd={onReorderEnd} onReorderEnd={onReorderEnd}
categoryColor={categoryColor} categoryColor={categoryColor}
categoryColors={categoryColors}
/> />
{expanded && subtasks.length > 0 && ( {expanded && subtasks.length > 0 && (
<View style={styles.subtaskList}> <View style={styles.subtaskList}>
@@ -636,16 +641,17 @@ const TaskRow = React.memo(function TaskRow({
subtask={sub} subtask={sub}
hoveredId={hoverTaskId} hoveredId={hoverTaskId}
registerRef={registerSubtaskRef} registerRef={registerSubtaskRef}
onToggle={() => onSubtaskToggle(sub.id, task.id)} onToggle={(sub) => onSubtaskToggle(sub.id, task.id)}
onDelete={() => onSubtaskDelete(sub)} onDelete={() => onSubtaskDelete(sub)}
onMenuOpen={() => onSubtaskMenuOpen(sub)} onMenuOpen={() => onSubtaskMenuOpen(sub)}
selected={selectedIds.has(sub.id)} selected={selectedIds.has(sub.id)}
selectionMode={selectionMode} selectionMode={selectionMode}
draggable draggable
onDragStart={() => onSubtaskDragStart(sub.id, task.id)} onDragStart={(sub) => onSubtaskDragStart(sub.id, task.id)}
onDragUpdate={onSubtaskDragUpdate} onDragUpdate={onSubtaskDragUpdate}
onDragEnd={onSubtaskDragEnd} onDragEnd={onSubtaskDragEnd}
categoryColor={categoryColor} categoryColor={categoryColor}
categoryColorResolver={categoryColorResolver}
/> />
))} ))}
</View> </View>
@@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { View, Text, StyleSheet, TextInput , TextInputProps } from 'react-native'; import { View, Text, StyleSheet, TextInput , TextInputProps } from 'react-native';
import { useSettings } from '@/theme'; import { useSettings, type ThemeColors } from '@/theme';
interface TaskNameInputProps extends TextInputProps { interface TaskNameInputProps extends TextInputProps {
error?: string; error?: string;
@@ -8,6 +8,7 @@ interface TaskNameInputProps extends TextInputProps {
export function TaskNameInput({ error, ...props }: TaskNameInputProps) { export function TaskNameInput({ error, ...props }: TaskNameInputProps) {
const { theme } = useSettings(); const { theme } = useSettings();
const styles = React.useMemo(() => createStyles(theme), [theme]);
return ( return (
<View style={styles.container}> <View style={styles.container}>
@@ -34,7 +35,7 @@ export function TaskNameInput({ error, ...props }: TaskNameInputProps) {
); );
} }
const styles = StyleSheet.create({ const createStyles = (theme: ThemeColors) => StyleSheet.create({
container: { container: {
gap: 5, gap: 5,
}, },
@@ -47,7 +48,7 @@ const styles = StyleSheet.create({
fontWeight: '600', fontWeight: '600',
}, },
required: { required: {
color: '#E53935', color: theme.dangerStrong,
fontSize: 13, fontSize: 13,
fontWeight: '600', fontWeight: '600',
}, },
@@ -59,12 +60,12 @@ const styles = StyleSheet.create({
fontSize: 15, fontSize: 15,
}, },
inputError: { inputError: {
borderColor: '#E53935', borderColor: theme.dangerStrong,
borderWidth: 1.5, borderWidth: 1.5,
}, },
errorText: { errorText: {
fontSize: 11, fontSize: 11,
color: '#E53935', color: theme.dangerStrong,
marginLeft: 4, marginLeft: 4,
}, },
}); });
@@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { View, Text, StyleSheet, Modal, TouchableOpacity } from 'react-native'; import { View, Text, StyleSheet, Modal, TouchableOpacity } from 'react-native';
import { useSettings } from '@/theme'; import { useSettings } from '@/theme';
import type { ThemeColors } from '@/theme';
import { TaskData, SubtaskData } from '@/types'; import { TaskData, SubtaskData } from '@/types';
import Svg, { Path } from 'react-native-svg'; import Svg, { Path } from 'react-native-svg';
@@ -40,6 +41,7 @@ export function TaskOverflowMenu({
onAddSubtask, onAddSubtask,
}: TaskOverflowMenuProps) { }: TaskOverflowMenuProps) {
const { theme } = useSettings(); const { theme } = useSettings();
const styles = React.useMemo(() => createStyles(theme), [theme]);
if (!task) return null; if (!task) return null;
@@ -67,7 +69,7 @@ export function TaskOverflowMenu({
if (!subtask) { if (!subtask) {
actions.push({ actions.push({
key: 'category', key: 'category',
label: 'Change Category', label: 'Change Tags',
icon: <Svg width={20} height={20} viewBox="0 0 24 24"><Circle2 /></Svg>, icon: <Svg width={20} height={20} viewBox="0 0 24 24"><Circle2 /></Svg>,
onPress: onChangeCategory ?? (() => {}), onPress: onChangeCategory ?? (() => {}),
}); });
@@ -84,7 +86,7 @@ export function TaskOverflowMenu({
key: 'delete', key: 'delete',
label: 'Delete', label: 'Delete',
destructive: true, destructive: true,
icon: <Svg width={20} height={20} viewBox="0 0 24 24"><Path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13" stroke="#E57373" strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" fill="none" /></Svg>, icon: <Svg width={20} height={20} viewBox="0 0 24 24"><Path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13" stroke={theme.danger} strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" fill="none" /></Svg>,
onPress: onDelete, onPress: onDelete,
}, },
); );
@@ -144,7 +146,7 @@ function Circle2() {
); );
} }
const styles = StyleSheet.create({ const createStyles = (theme: ThemeColors) => StyleSheet.create({
overlay: { overlay: {
flex: 1, flex: 1,
justifyContent: 'flex-end', justifyContent: 'flex-end',
@@ -185,7 +187,7 @@ const styles = StyleSheet.create({
fontWeight: '500', fontWeight: '500',
}, },
destructiveText: { destructiveText: {
color: '#E53935', color: theme.dangerStrong,
fontWeight: '600', fontWeight: '600',
}, },
}); });
@@ -193,5 +193,32 @@ export const migrations = schemaMigrations({
}), }),
], ],
}, },
{
toVersion: 18,
steps: [
addColumns({
table: 'tasks',
columns: [{ name: 'tags', type: 'string', isOptional: true }],
}),
],
},
{
toVersion: 19,
steps: [
addColumns({
table: 'subtasks',
columns: [{ name: 'category_id', type: 'string', isOptional: true }],
}),
],
},
{
toVersion: 20,
steps: [
addColumns({
table: 'subtasks',
columns: [{ name: 'tags', type: 'string', isOptional: true }],
}),
],
},
], ],
}); });
+4 -1
View File
@@ -1,7 +1,7 @@
import { appSchema, tableSchema } from '@nozbe/watermelondb'; import { appSchema, tableSchema } from '@nozbe/watermelondb';
export const schema = appSchema({ export const schema = appSchema({
version: 17, version: 21,
tables: [ tables: [
tableSchema({ tableSchema({
name: 'categories', name: 'categories',
@@ -19,6 +19,7 @@ export const schema = appSchema({
{ name: 'title', type: 'string' }, { name: 'title', type: 'string' },
{ name: 'description', type: 'string' }, { name: 'description', type: 'string' },
{ name: 'category_id', type: 'string', isIndexed: true }, { name: 'category_id', type: 'string', isIndexed: true },
{ name: 'tags', type: 'string', isOptional: true },
{ name: 'priority', type: 'string' }, { name: 'priority', type: 'string' },
{ name: 'completed', type: 'boolean', isIndexed: true }, { name: 'completed', type: 'boolean', isIndexed: true },
{ name: 'completed_at', type: 'number', isOptional: true }, { name: 'completed_at', type: 'number', isOptional: true },
@@ -44,6 +45,8 @@ export const schema = appSchema({
columns: [ columns: [
{ name: 'task_id', type: 'string', isIndexed: true }, { name: 'task_id', type: 'string', isIndexed: true },
{ name: 'parent_subtask_id', type: 'string', isIndexed: true, isOptional: true }, { name: 'parent_subtask_id', type: 'string', isIndexed: true, isOptional: true },
{ name: 'category_id', type: 'string', isIndexed: true, isOptional: true },
{ name: 'tags', type: 'string', isOptional: true },
{ name: 'title', type: 'string' }, { name: 'title', type: 'string' },
{ name: 'description', type: 'string', isOptional: true }, { name: 'description', type: 'string', isOptional: true },
{ name: 'priority', type: 'string', isOptional: true }, { name: 'priority', type: 'string', isOptional: true },
+70 -12
View File
@@ -11,8 +11,10 @@ import RepeatProfile from '@/models/RepeatProfile';
import Friendship from '@/models/Friendship'; import Friendship from '@/models/Friendship';
import { TombstoneEntity } from '@/models/Tombstone'; import { TombstoneEntity } from '@/models/Tombstone';
import { fetchPendingTombstones, removeTombstonesInBatch } from './tombstones'; import { fetchPendingTombstones, removeTombstonesInBatch } from './tombstones';
import { useOnline } from '@/hooks/useOnline';
const LAST_PULLED_AT_KEY = 'sync:lastPulledAt'; const LAST_PULLED_AT_KEY = 'sync:lastPulledAt';
const LAST_PUSHED_AT_KEY = 'sync:lastPushedAt';
const LAST_RUN_AT_KEY = 'sync:lastRunAt'; const LAST_RUN_AT_KEY = 'sync:lastRunAt';
export interface SyncResult { export interface SyncResult {
@@ -55,6 +57,24 @@ async function setLastPulledAt(timestamp: number): Promise<void> {
} }
} }
async function getLastPushedAt(): Promise<number> {
try {
const raw = await AsyncStorage.getItem(LAST_PUSHED_AT_KEY);
const value = raw ? Number(raw) : 0;
return Number.isFinite(value) ? value : 0;
} catch {
return 0;
}
}
async function setLastPushedAt(timestamp: number): Promise<void> {
try {
await AsyncStorage.setItem(LAST_PUSHED_AT_KEY, String(timestamp));
} catch {
// ignore
}
}
export async function getLastSyncTime(): Promise<number | null> { export async function getLastSyncTime(): Promise<number | null> {
try { try {
const raw = await AsyncStorage.getItem(LAST_RUN_AT_KEY); const raw = await AsyncStorage.getItem(LAST_RUN_AT_KEY);
@@ -75,6 +95,21 @@ export async function runSync(): Promise<SyncResult> {
throw new Error('NOT_SIGNED_IN'); throw new Error('NOT_SIGNED_IN');
} }
// If offline, mark timestamps and return early - changes will sync on next foreground launch
const isCurrentlyOnline = await useOnline();
if (!isCurrentlyOnline) {
const now = Date.now();
await setLastPulledAt(now);
await setLastPushedAt(now);
const result: SyncResult = {
pushed: { categories: 0, tasks: 0, subtasks: 0, repeatProfiles: 0, friendships: 0 },
pulled: { categories: 0, tasks: 0, subtasks: 0, repeatProfiles: 0, friendships: 0 },
conflicts: 0,
timestamp: now,
};
return result;
}
const result: SyncResult = { const result: SyncResult = {
pushed: { categories: 0, tasks: 0, subtasks: 0, repeatProfiles: 0, friendships: 0 }, pushed: { categories: 0, tasks: 0, subtasks: 0, repeatProfiles: 0, friendships: 0 },
pulled: { categories: 0, tasks: 0, subtasks: 0, repeatProfiles: 0, friendships: 0 }, pulled: { categories: 0, tasks: 0, subtasks: 0, repeatProfiles: 0, friendships: 0 },
@@ -89,9 +124,23 @@ export async function runSync(): Promise<SyncResult> {
await pruneSyncedTombstones(conflicts, pushedDeletions); await pruneSyncedTombstones(conflicts, pushedDeletions);
const lastPulledAt = await getLastPulledAt(); // Update last pushed timestamp to avoid re-pushing same changes
const response = await apiFetch(`/sync?since=${lastPulledAt}`); await setLastPushedAt(Date.now());
const data = await response.json();
// Use current time as pull cutoff to avoid re-pulling what we just pushed
const pullSince = Date.now();
let data: any;
try {
const response = await apiFetch(`/sync?since=${pullSince}`);
data = await response.json();
} catch (error: any) {
if (error.offline) {
result.timestamp = Date.now();
await setLastPulledAt(Date.now());
return result;
}
throw error;
}
const pulledCounts = await applyServerChanges(data); const pulledCounts = await applyServerChanges(data);
result.pulled = pulledCounts; result.pulled = pulledCounts;
@@ -103,13 +152,13 @@ export async function runSync(): Promise<SyncResult> {
} }
async function pushChanges(): Promise<{ conflicts: PushConflict[]; pushedDeletions: { entity: TombstoneEntity; id: string }[] }> { async function pushChanges(): Promise<{ conflicts: PushConflict[]; pushedDeletions: { entity: TombstoneEntity; id: string }[] }> {
const lastPulledAt = await getLastPulledAt(); const lastPushedAt = await getLastPushedAt();
const [tasks, subtasks, repeatProfiles, friendships] = await Promise.all([ const [tasks, subtasks, repeatProfiles, friendships] = await Promise.all([
collections.tasks.query(Q.where('updated_at', Q.gt(lastPulledAt))).fetch(), collections.tasks.query(Q.where('updated_at', Q.gt(lastPushedAt))).fetch(),
collections.subtasks.query(Q.where('updated_at', Q.gt(lastPulledAt))).fetch(), collections.subtasks.query(Q.where('updated_at', Q.gt(lastPushedAt))).fetch(),
collections.repeatProfiles.query(Q.where('updated_at', Q.gt(lastPulledAt))).fetch(), collections.repeatProfiles.query(Q.where('updated_at', Q.gt(lastPushedAt))).fetch(),
collections.friendships.query(Q.where('updated_at', Q.gt(lastPulledAt))).fetch(), collections.friendships.query(Q.where('updated_at', Q.gt(lastPushedAt))).fetch(),
]); ]);
const taskPayload = (t: Task) => ({ const taskPayload = (t: Task) => ({
@@ -117,6 +166,7 @@ async function pushChanges(): Promise<{ conflicts: PushConflict[]; pushedDeletio
title: t.title, title: t.title,
description: t.description, description: t.description,
categoryId: t.categoryId, categoryId: t.categoryId,
tags: t.tags || '',
priority: t.priority, priority: t.priority,
completed: t.completed, completed: t.completed,
dueDate: t.dueDate, dueDate: t.dueDate,
@@ -150,6 +200,8 @@ async function pushChanges(): Promise<{ conflicts: PushConflict[]; pushedDeletio
id: s.id, id: s.id,
taskId: s.taskId, taskId: s.taskId,
parentSubtaskId: s.parentSubtaskId || null, parentSubtaskId: s.parentSubtaskId || null,
categoryId: s.categoryId || '',
tags: s.tags || '',
title: s.title, title: s.title,
description: s.description ?? '', description: s.description ?? '',
priority: s.priority ?? 'none', priority: s.priority ?? 'none',
@@ -191,12 +243,12 @@ async function pushChanges(): Promise<{ conflicts: PushConflict[]; pushedDeletio
if (referencedCategoryIds.size > 0) { if (referencedCategoryIds.size > 0) {
categoryQuery = collections.categories.query( categoryQuery = collections.categories.query(
Q.or( Q.or(
Q.where('updated_at', Q.gt(lastPulledAt)), Q.where('updated_at', Q.gt(lastPushedAt)),
Q.where('id', Q.oneOf(Array.from(referencedCategoryIds))) Q.where('id', Q.oneOf(Array.from(referencedCategoryIds)))
) )
); );
} else { } else {
categoryQuery = collections.categories.query(Q.where('updated_at', Q.gt(lastPulledAt))); categoryQuery = collections.categories.query(Q.where('updated_at', Q.gt(lastPushedAt)));
} }
const categories = await categoryQuery.fetch(); const categories = await categoryQuery.fetch();
@@ -228,7 +280,7 @@ async function pushChanges(): Promise<{ conflicts: PushConflict[]; pushedDeletio
updatedAt: f.updatedAt.getTime(), updatedAt: f.updatedAt.getTime(),
})); }));
const pendingTombstones = await fetchPendingTombstones(lastPulledAt); const pendingTombstones = await fetchPendingTombstones(lastPushedAt);
const changedDeletions = pendingTombstones.map((t) => ({ const changedDeletions = pendingTombstones.map((t) => ({
entity: t.entity, entity: t.entity,
id: t.entityId, id: t.entityId,
@@ -257,7 +309,7 @@ async function pushChanges(): Promise<{ conflicts: PushConflict[]; pushedDeletio
friendships: changedFriendships, friendships: changedFriendships,
}, },
deleted: changedDeletions, deleted: changedDeletions,
lastPulledAt, lastPulledAt: await getLastPulledAt(),
}), }),
}); });
@@ -451,6 +503,7 @@ async function upsertTask(row: ServerRow, force: boolean): Promise<boolean> {
t.title = String(row.title ?? ''); t.title = String(row.title ?? '');
t.description = String(row.description ?? ''); t.description = String(row.description ?? '');
t.categoryId = String(row.categoryId ?? ''); t.categoryId = String(row.categoryId ?? '');
t.tags = String(row.tags ?? '');
t.priority = row.priority ?? 'none'; t.priority = row.priority ?? 'none';
t.completed = Boolean(row.completed); t.completed = Boolean(row.completed);
t.dueDate = Number(row.dueDate ?? 0); t.dueDate = Number(row.dueDate ?? 0);
@@ -476,6 +529,7 @@ async function upsertTask(row: ServerRow, force: boolean): Promise<boolean> {
t.title = String(row.title ?? t.title); t.title = String(row.title ?? t.title);
t.description = String(row.description ?? t.description); t.description = String(row.description ?? t.description);
t.categoryId = String(row.categoryId ?? t.categoryId); t.categoryId = String(row.categoryId ?? t.categoryId);
t.tags = String(row.tags ?? t.tags ?? '');
t.priority = row.priority ?? t.priority; t.priority = row.priority ?? t.priority;
t.completed = Boolean(row.completed ?? t.completed); t.completed = Boolean(row.completed ?? t.completed);
t.dueDate = Number(row.dueDate ?? t.dueDate); t.dueDate = Number(row.dueDate ?? t.dueDate);
@@ -529,6 +583,8 @@ async function upsertSubtask(row: ServerRow, force: boolean): Promise<boolean> {
await collections.subtasks.create((s) => { await collections.subtasks.create((s) => {
s.taskId = String(row.taskId ?? ''); s.taskId = String(row.taskId ?? '');
s.parentSubtaskId = row.parentSubtaskId == null ? null : String(row.parentSubtaskId); s.parentSubtaskId = row.parentSubtaskId == null ? null : String(row.parentSubtaskId);
s.categoryId = row.categoryId ? String(row.categoryId) : '';
s.tags = String(row.tags ?? '');
s.title = String(row.title ?? ''); s.title = String(row.title ?? '');
s.description = String(row.description ?? ''); s.description = String(row.description ?? '');
s.priority = row.priority ?? 'none'; s.priority = row.priority ?? 'none';
@@ -555,6 +611,8 @@ async function upsertSubtask(row: ServerRow, force: boolean): Promise<boolean> {
await local.update((s) => { await local.update((s) => {
s.taskId = String(row.taskId ?? s.taskId); s.taskId = String(row.taskId ?? s.taskId);
s.parentSubtaskId = row.parentSubtaskId == null ? null : String(row.parentSubtaskId); s.parentSubtaskId = row.parentSubtaskId == null ? null : String(row.parentSubtaskId);
s.categoryId = row.categoryId ? String(row.categoryId) : s.categoryId;
s.tags = row.tags != null ? String(row.tags) : s.tags;
s.title = String(row.title ?? s.title); s.title = String(row.title ?? s.title);
s.description = String(row.description ?? s.description); s.description = String(row.description ?? s.description);
s.priority = row.priority ?? s.priority; s.priority = row.priority ?? s.priority;
@@ -46,12 +46,6 @@ export function DatabaseProvider({ children }: { children: ReactNode }) {
for (const task of tasksToUpdate) { for (const task of tasksToUpdate) {
await task.update((t) => { t.categoryId = keep.id; }); await task.update((t) => { t.categoryId = keep.id; });
} }
// Reassign subtasks too (they're linked via task_id, so we update their parent tasks)
const subtasksToUpdate = await collections.subtasks.query(Q.where('task_id', dup.id)).fetch();
for (const sub of subtasksToUpdate) {
const parentTask = await collections.tasks.find(sub.taskId);
await parentTask.update((t) => { t.categoryId = keep.id; });
}
// Delete duplicate category // Delete duplicate category
await dup.destroyPermanently(); await dup.destroyPermanently();
} }
+194
View File
@@ -0,0 +1,194 @@
import { useDatabase } from './useDatabase';
import { Q } from '@nozbe/watermelondb';
import { useEffect, useState, useCallback } from 'react';
import Task from '../models/Task';
import parseICal from '../utils/icalParser';
import { startOfMonth, endOfMonth, addMonths, format } from 'date-fns';
function mapICalEventToTask(event: {
title: string;
description?: string;
start: Date;
end?: Date;
allDay?: boolean;
rrule?: string;
uid?: string;
priority?: number;
}): {
title: string;
description: string;
dueDate: number;
dueTime: string;
endTime: string;
allDay: boolean;
repeat: string;
repeatInterval: number;
repeatDays: string;
seriesId: string;
reminder: string;
reminders: string;
categoryId: string;
tags: string;
priority: string;
completed: boolean;
completedAt: number | null;
} {
const defaultCategoryId = 'cat_default'; // will be resolved later
const now = Date.now();
let dueDate = event.start.getTime();
let dueTime = '00:00';
let endTime = '00:00';
let allDay = event.allDay === true || !event.end;
if (event.allDay) {
dueDate = new Date(event.start.getFullYear(), event.start.getMonth(), event.start.getDate()).getTime();
dueTime = '00:00';
endTime = '23:59';
} else {
dueDate = event.start.getTime();
dueTime = `${event.start.getHours().toString().padStart(2, '0')}:${event.start.getMinutes().toString().padStart(2, '0')}`;
if (event.end) {
endTime = `${event.end.getHours().toString().padStart(2, '0')}:${event.end.getMinutes().toString().padStart(2, '0')}`;
}
}
let repeat = 'none';
let repeatInterval = 1;
let repeatDays = '';
let seriesId = event.uid || `${dueDate}-${Math.random().toString(36).substring(2, 9)}`;
let reminder = 'none';
let reminders = '';
if (event.rrule) {
repeat = 'custom';
// Simple RRULE parsing - extract FREQ and INTERVAL
const freqMatch = event.rrule.match(/FREQ=([^;:]+)/i);
if (freqMatch) {
const freq = freqMatch[1].toLowerCase();
if (freq === 'daily') {
repeat = 'daily';
repeatInterval = 1;
} else if (freq === 'weekly') {
repeat = 'weekly';
repeatInterval = 1;
} else if (freq === 'monthly') {
repeat = 'monthly';
repeatInterval = 1;
}
}
// Extract INTERVAL
const intervalMatch = event.rrule.match(/INTERVAL=(\d+)/i);
if (intervalMatch) {
repeatInterval = parseInt(intervalMatch[1], 10);
}
// Extract BYDAY for weekly/Monthly
const bydayMatch = event.rrule.match(/BYDAY=([^;,]+)/i);
if (bydayMatch) {
repeatDays = bydayMatch[1].replace(/,/g, ',');
}
}
// Map priority (iCal priority: -2=low, 0=normal, 2=high, 3-4=highest, 5=urgent)
let priorityValue: string = 'medium';
if (event.priority !== undefined) {
const p = event.priority;
if (p <= -2) priorityValue = 'low';
else if (p === 0) priorityValue = 'medium';
else if (p === 2) priorityValue = 'high';
else if (p >= 5) priorityValue = 'urgent';
else priorityValue = 'medium';
}
return {
title: event.title,
description: event.description || '',
dueDate,
dueTime,
endTime,
allDay,
repeat,
repeatInterval,
repeatDays,
seriesId,
reminder,
reminders,
categoryId: defaultCategoryId,
tags: '',
priority: priorityValue,
completed: false,
completedAt: null,
};
}
export function useICalImport() {
const { collections } = useDatabase();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const importICal = useCallback(async (icalContent: string) => {
setLoading(true);
setError(null);
try {
const events = parseICal(icalContent);
if (events.length === 0) {
throw new Error('No events found in the iCal data');
}
const taskData = events.map(mapICalEventToTask);
// Insert tasks into WatermelonDB
const subscription = collections.tasks
.query()
.splice(
0,
0,
...taskData.map((td) => ({
title: td.title,
description: td.description,
category_id: td.categoryId,
tags: td.tags,
priority: td.priority,
completed: td.completed,
completed_at: td.completedAt,
due_date: td.dueDate,
due_time: td.dueTime,
end_time: td.endTime,
all_day: td.allDay,
repeat: td.repeat,
repeat_interval: td.repeatInterval,
repeat_days: td.repeatDays,
color: '#9E9E9E',
series_id: td.seriesId,
order: 0,
reminder: td.reminder,
reminders: td.reminders,
assignee_id: null,
created_at: now,
updated_at: now,
}))
);
// Observe the changes
subscription.subscribe({
next: (result) => {
// Tasks have been imported
},
error: (err) => {
console.error('Import error:', err);
setError(err.message);
},
});
setLoading(false);
} catch (err: any) {
console.error('ICal import failed:', err);
setError(err.message || 'Import failed');
setLoading(false);
}
}, [collections]);
return { importICal, loading, error };
}
+17
View File
@@ -0,0 +1,17 @@
import { useEffect, useState } from 'react';
export function useOnline(): boolean {
const [isOnline, setIsOnline] = useState(navigator.onLine);
useEffect(() => {
const handler = () => setIsOnline(navigator.onLine);
window.addEventListener('online', handler);
window.addEventListener('offline', handler);
return () => {
window.removeEventListener('online', handler);
window.removeEventListener('offline', handler);
};
}, []);
return isOnline;
}
+87
View File
@@ -0,0 +1,87 @@
import { useDatabase } from './useDatabase';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { SubtaskData } from '@/types';
function mapRow(s: any): SubtaskData {
return {
id: s.id,
taskId: s.taskId,
parentSubtaskId: s.parentSubtaskId || null,
categoryId: s.categoryId || '',
tags: s.tags || '',
title: s.title,
description: s.description || '',
priority: (s.priority || 'none') as SubtaskData['priority'],
completed: s.completed,
dueDate: s.dueDate || 0,
dueTime: s.dueTime || '',
endTime: s.endTime || '',
allDay: s.allDay ?? false,
repeat: (s.repeat || 'none') as SubtaskData['repeat'],
repeatInterval: s.repeatInterval ?? 1,
repeatDays: s.repeatDays || '',
seriesId: s.seriesId || '',
reminder: (s.reminder || 'none') as SubtaskData['reminder'],
assigneeId: s.assigneeId ?? null,
order: s.order,
subtasks: [],
};
}
function buildTrees(rows: any[]): Map<string, SubtaskData[]> {
const nodes = new Map<string, SubtaskData>();
for (const row of rows) {
nodes.set(row.id, mapRow(row));
}
const rootsByTask = new Map<string, SubtaskData[]>();
for (const node of nodes.values()) {
if (node.parentSubtaskId && nodes.has(node.parentSubtaskId)) {
const parent = nodes.get(node.parentSubtaskId)!;
parent.subtasks.push(node);
} else {
const list = rootsByTask.get(node.taskId) ?? [];
list.push(node);
rootsByTask.set(node.taskId, list);
}
}
for (const list of rootsByTask.values()) {
list.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
}
const sortDeep = (list: SubtaskData[]) => {
for (const node of list) {
node.subtasks.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
sortDeep(node.subtasks);
}
};
for (const list of rootsByTask.values()) {
sortDeep(list);
}
return rootsByTask;
}
export function useSubtasks(): { map: Map<string, SubtaskData[]>; refresh: () => void } {
const { collections } = useDatabase();
const [rows, setRows] = useState<any[]>([]);
const [refreshKey, setRefreshKey] = useState(0);
const refresh = useCallback(() => setRefreshKey((k) => k + 1), []);
useEffect(() => {
let mounted = true;
const subscription = collections.subtasks
.query()
.observe()
.subscribe({
next: (result) => {
if (mounted) setRows(result);
},
error: () => {},
});
return () => {
mounted = false;
subscription.unsubscribe();
};
}, [collections.subtasks, refreshKey]);
const map = useMemo(() => buildTrees(rows), [rows]);
return { map, refresh };
}
+3 -4
View File
@@ -9,7 +9,6 @@ import { TaskOverflowMenu } from '@/components/TaskOverflowMenu';
import { TaskDeleteModal } from '@/components/TaskDeleteModal'; import { TaskDeleteModal } from '@/components/TaskDeleteModal';
import { OptionPickerModal } from '@/components/OptionPickerModal'; import { OptionPickerModal } from '@/components/OptionPickerModal';
import { import {
toggleTaskComplete,
deleteTaskOccurrences, deleteTaskOccurrences,
duplicateTask, duplicateTask,
setTaskCategory, setTaskCategory,
@@ -20,6 +19,7 @@ import {
setSubtaskPriority, setSubtaskPriority,
createSubtask, createSubtask,
TaskDeleteScope, TaskDeleteScope,
confirmAndToggleTaskComplete,
} from '@/utils/taskActions'; } from '@/utils/taskActions';
const PRIORITY_OPTIONS = Object.entries(PRIORITY_LABELS).map(([value, label]) => ({ value, label })); const PRIORITY_OPTIONS = Object.entries(PRIORITY_LABELS).map(([value, label]) => ({ value, label }));
@@ -179,8 +179,7 @@ export function useTaskModals() {
}, []); }, []);
const handleToggleComplete = useCallback(async (taskId: string) => { const handleToggleComplete = useCallback(async (taskId: string) => {
await toggleTaskComplete(taskId); await confirmAndToggleTaskComplete(taskId, () => refreshRef.current());
refreshRef.current();
}, []); }, []);
const handleSubtaskToggle = useCallback(async (subtask: SubtaskData) => { const handleSubtaskToggle = useCallback(async (subtask: SubtaskData) => {
@@ -303,7 +302,7 @@ export function useTaskModals() {
<OptionPickerModal <OptionPickerModal
visible={picker?.type === 'category'} visible={picker?.type === 'category'}
title="Change Category" title="Change Tags"
options={[{ value: '', label: 'None', color: '#9E9E9E' }, ...categories.map((c) => ({ value: c.id, label: c.name, color: c.color }))]} options={[{ value: '', label: 'None', color: '#9E9E9E' }, ...categories.map((c) => ({ value: c.id, label: c.name, color: c.color }))]}
selectedValue={menuTask?.categoryId} selectedValue={menuTask?.categoryId}
onSelect={handleSingleCategory} onSelect={handleSingleCategory}
+22 -10
View File
@@ -1,6 +1,6 @@
import { useDatabase } from './useDatabase'; import { useDatabase } from './useDatabase';
import { Q } from '@nozbe/watermelondb'; import { Q } from '@nozbe/watermelondb';
import { useEffect, useState, useMemo } from 'react'; import { useEffect, useState, useMemo, useCallback } from 'react';
import Task from '../models/Task'; import Task from '../models/Task';
import { startOfMonth, endOfMonth } from 'date-fns'; import { startOfMonth, endOfMonth } from 'date-fns';
@@ -8,6 +8,8 @@ export function useTasksInMonth(monthDate: Date) {
const { collections } = useDatabase(); const { collections } = useDatabase();
const [tasks, setTasks] = useState<Task[]>([]); const [tasks, setTasks] = useState<Task[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [refreshKey, setRefreshKey] = useState(0);
const refresh = useCallback(() => setRefreshKey((k) => k + 1), []);
const range = useMemo(() => { const range = useMemo(() => {
const s = startOfMonth(monthDate); const s = startOfMonth(monthDate);
@@ -42,7 +44,7 @@ export function useTasksInMonth(monthDate: Date) {
mounted = false; mounted = false;
subscription.unsubscribe(); subscription.unsubscribe();
}; };
}, [collections, range.start, range.end]); }, [collections, range.start, range.end, refreshKey]);
const byDay = useMemo(() => { const byDay = useMemo(() => {
const map: Record<number, Task[]> = {}; const map: Record<number, Task[]> = {};
@@ -55,13 +57,15 @@ export function useTasksInMonth(monthDate: Date) {
return map; return map;
}, [tasks]); }, [tasks]);
return { tasks, byDay, loading }; return { tasks, byDay, loading, refresh };
} }
export function useTasks(categoryId?: string, showCompleted: boolean | 'all' = false, maxAheadDays?: number) { export function useTasks(categoryIds: string[] = [], showCompleted: boolean | 'all' = false, maxAheadDays?: number) {
const { collections } = useDatabase(); const { collections } = useDatabase();
const [tasks, setTasks] = useState<Task[]>([]); const [tasks, setTasks] = useState<Task[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [refreshKey, setRefreshKey] = useState(0);
const refresh = useCallback(() => setRefreshKey((k) => k + 1), []);
const cutoff = useMemo(() => { const cutoff = useMemo(() => {
if (maxAheadDays === undefined) return null; if (maxAheadDays === undefined) return null;
@@ -75,8 +79,14 @@ export function useTasks(categoryId?: string, showCompleted: boolean | 'all' = f
let mounted = true; let mounted = true;
const conditions: any[] = []; const conditions: any[] = [];
if (categoryId && categoryId !== 'all') { if (categoryIds.length > 0) {
conditions.push(Q.where('category_id', categoryId)); const tagConditions = categoryIds.map((id) =>
Q.or(
Q.where('tags', Q.like(`%,${id},%`)),
Q.where('category_id', id)
)
);
conditions.push(tagConditions.length === 1 ? tagConditions[0] : Q.or(...tagConditions));
} }
if (showCompleted === true) { if (showCompleted === true) {
@@ -111,15 +121,17 @@ export function useTasks(categoryId?: string, showCompleted: boolean | 'all' = f
mounted = false; mounted = false;
subscription.unsubscribe(); subscription.unsubscribe();
}; };
}, [collections, categoryId, showCompleted, cutoff]); }, [collections, categoryIds, showCompleted, cutoff, refreshKey]);
return { tasks, loading }; return { tasks, loading, refresh };
} }
export function useTasksByDate(date: Date) { export function useTasksByDate(date: Date) {
const { collections } = useDatabase(); const { collections } = useDatabase();
const [tasks, setTasks] = useState<Task[]>([]); const [tasks, setTasks] = useState<Task[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [refreshKey, setRefreshKey] = useState(0);
const refresh = useCallback(() => setRefreshKey((k) => k + 1), []);
const startOfDay = useMemo(() => { const startOfDay = useMemo(() => {
const d = new Date(date); const d = new Date(date);
@@ -156,7 +168,7 @@ export function useTasksByDate(date: Date) {
mounted = false; mounted = false;
subscription.unsubscribe(); subscription.unsubscribe();
}; };
}, [collections, startOfDay, endOfDay]); }, [collections, startOfDay, endOfDay, refreshKey]);
return { tasks, loading }; return { tasks, loading, refresh };
} }
+2
View File
@@ -12,6 +12,8 @@ export default class Subtask extends Model {
@field('task_id') taskId!: string; @field('task_id') taskId!: string;
@field('parent_subtask_id') parentSubtaskId!: string | null; @field('parent_subtask_id') parentSubtaskId!: string | null;
@field('category_id') categoryId!: string;
@field('tags') tags!: string;
@field('title') title!: string; @field('title') title!: string;
@field('description') description!: string; @field('description') description!: string;
@field('priority') priority!: Priority; @field('priority') priority!: Priority;
+1
View File
@@ -13,6 +13,7 @@ export default class Task extends Model {
@field('title') title!: string; @field('title') title!: string;
@field('description') description!: string; @field('description') description!: string;
@field('category_id') categoryId!: string; @field('category_id') categoryId!: string;
@field('tags') tags!: string;
@field('priority') priority!: Priority; @field('priority') priority!: Priority;
@field('completed') completed!: boolean; @field('completed') completed!: boolean;
@field('completed_at') completedAt!: number | null; @field('completed_at') completedAt!: number | null;
+48 -2
View File
@@ -96,11 +96,22 @@ export async function apiFetch(path: string, options: RequestInit = {}): Promise
} }
const response = await fetch(`${apiBaseUrl}${path}`, { ...options, headers }); const response = await fetch(`${apiBaseUrl}${path}`, { ...options, headers });
if (!response.ok) { if (!response.ok) {
const text = await response.text();
// If it's a 401 or network error, try to get a fresh token and retry once
if (response.status === 404 || response.status === 0) {
// Not found or network error - don't throw, return the response for caller to handle
const error = new Error(text || `Request failed (${response.status})`);
(error as any).status = response.status;
(error as any).offline = true;
throw error;
}
let message = `Request failed (${response.status})`; let message = `Request failed (${response.status})`;
let details: any = null; let details: any = null;
try { try {
const body = await response.json(); const body = JSON.parse(text);
if (body && typeof body.message === 'string') { if (body && typeof body.error?.message === 'string') {
message = body.error.message;
} else if (body && typeof body.message === 'string') {
message = body.message; message = body.message;
} }
if (body && body.error && body.error.details) { if (body && body.error && body.error.details) {
@@ -136,3 +147,38 @@ export async function register(username: string, password: string): Promise<Auth
await setAuth(data.token, data.user); await setAuth(data.token, data.user);
return data.user as AuthUser; return data.user as AuthUser;
} }
export async function forgotPassword(username: string): Promise<{ success: boolean; resetToken?: string }> {
const response = await apiFetch('/auth/forgot-password', {
method: 'POST',
body: JSON.stringify({ username }),
});
const data = await response.json();
return data;
}
export async function resetPassword(token: string, password: string): Promise<{ success: boolean }> {
const response = await apiFetch('/auth/reset-password', {
method: 'POST',
body: JSON.stringify({ token, password }),
});
const data = await response.json();
return data;
}
export async function registerPushToken(pushToken: string): Promise<{ success: boolean }> {
const response = await apiFetch('/push/push-token', {
method: 'POST',
body: JSON.stringify({ pushToken }),
});
const data = await response.json();
return data;
}
export async function unregisterPushToken(): Promise<{ success: boolean }> {
const response = await apiFetch('/push/push-token', {
method: 'DELETE',
});
const data = await response.json();
return data;
}
+90 -13
View File
@@ -1,9 +1,19 @@
import { Platform } from 'react-native'; import { Platform } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import { Reminder, reminderToMinutes, parseReminders } from '@/types'; import { Reminder, reminderToMinutes, parseReminders, ReminderPreference, parseTaskTags } from '@/types';
import { TagReminderPreferences, TAG_REMINDER_PREFERENCES_KEY } from '@/theme';
const ID_MAP_KEY = 'notifications:taskIdToNotificationIds'; const ID_MAP_KEY = 'notifications:taskIdToNotificationIds';
async function loadTagPreferences(): Promise<TagReminderPreferences> {
try {
const raw = await AsyncStorage.getItem(TAG_REMINDER_PREFERENCES_KEY);
return raw ? (JSON.parse(raw) as TagReminderPreferences) : {};
} catch {
return {};
}
}
async function readIdMap(): Promise<Record<string, string[]>> { async function readIdMap(): Promise<Record<string, string[]>> {
try { try {
const raw = await AsyncStorage.getItem(ID_MAP_KEY); const raw = await AsyncStorage.getItem(ID_MAP_KEY);
@@ -30,11 +40,67 @@ function getNotificationsModule(): any {
} }
} }
export async function scheduleTaskReminder(task: any): Promise<void> { function reminderPreferenceToReminder(pref: ReminderPreference): Reminder {
switch (pref) {
case 'none':
return 'none';
case 'at_time':
return 'at_time';
case '15':
return '15';
case '30':
return '30';
case '60':
return '60';
case '120':
return '120';
case '1440':
return '1440';
default:
return 'none';
}
}
function getRemindersForTask(
task: any,
globalPreference: ReminderPreference,
tagPreferences: TagReminderPreferences = {}
): Reminder[] {
const taskReminders: Reminder[] = task.reminders
? parseReminders(task.reminders)
: task.reminder && task.reminder !== 'none'
? [task.reminder as Reminder]
: [];
if (taskReminders.length > 0) {
return taskReminders;
}
const tagReminders: Reminder[] = [];
for (const tagId of parseTaskTags(task.tags, task.categoryId)) {
for (const pref of tagPreferences[tagId] ?? []) {
if (pref === 'none') continue;
if (!tagReminders.includes(pref)) tagReminders.push(pref);
}
}
if (tagReminders.length > 0) {
return tagReminders;
}
const globalReminder = reminderPreferenceToReminder(globalPreference);
if (globalReminder !== 'none') {
return [globalReminder];
}
return [];
}
export async function scheduleTaskReminder(task: any, notificationsEnabled: boolean = true, globalReminderPreference: ReminderPreference = '15'): Promise<void> {
const notifications = await getNotificationsModule(); const notifications = await getNotificationsModule();
const dueDate = task.dueDate ? new Date(task.dueDate) : null; const dueDate = task.dueDate ? new Date(task.dueDate) : null;
if (!notifications) return; if (!notifications) return;
if (!notificationsEnabled) return;
const idMap = await readIdMap(); const idMap = await readIdMap();
@@ -51,11 +117,8 @@ export async function scheduleTaskReminder(task: any): Promise<void> {
await writeIdMap(idMap); await writeIdMap(idMap);
// Determine the set of reminders to schedule // Determine the set of reminders to schedule
const reminders: Reminder[] = task.reminders const tagPreferences = await loadTagPreferences();
? parseReminders(task.reminders) const reminders = getRemindersForTask(task, globalReminderPreference, tagPreferences);
: task.reminder && task.reminder !== 'none'
? [task.reminder as Reminder]
: [];
if (reminders.length === 0 || !dueDate || !task.dueTime) return; if (reminders.length === 0 || !dueDate || !task.dueTime) return;
@@ -132,7 +195,7 @@ export async function requestNotificationPermission(): Promise<boolean> {
} }
} }
export async function rescheduleAllReminders(tasks: any[]): Promise<void> { export async function cancelAllReminders(): Promise<void> {
const notifications = await getNotificationsModule(); const notifications = await getNotificationsModule();
if (!notifications) return; if (!notifications) return;
try { try {
@@ -141,15 +204,29 @@ export async function rescheduleAllReminders(tasks: any[]): Promise<void> {
// ignore // ignore
} }
const idMap: Record<string, string[]> = {}; const idMap: Record<string, string[]> = {};
await writeIdMap(idMap);
}
export async function rescheduleAllReminders(tasks: any[], notificationsEnabled: boolean = true, globalReminderPreference: ReminderPreference = '15'): Promise<void> {
if (!notificationsEnabled) {
await cancelAllReminders();
return;
}
const notifications = await getNotificationsModule();
if (!notifications) return;
try {
await notifications.cancelAllScheduledNotificationsAsync();
} catch {
// ignore
}
const idMap: Record<string, string[]> = {};
const tagPreferences = await loadTagPreferences();
for (const task of tasks) { for (const task of tasks) {
const dueDate = task.dueDate ? new Date(task.dueDate) : null; const dueDate = task.dueDate ? new Date(task.dueDate) : null;
const reminders: Reminder[] = task.reminders const reminders = getRemindersForTask(task, globalReminderPreference, tagPreferences);
? parseReminders(task.reminders)
: task.reminder && task.reminder !== 'none'
? [task.reminder as Reminder]
: [];
if (reminders.length === 0 || !dueDate || !task.dueTime) continue; if (reminders.length === 0 || !dueDate || !task.dueTime) continue;
@@ -0,0 +1,139 @@
import { Platform } from 'react-native';
import * as Device from 'expo-device';
import * as Notifications from 'expo-notifications';
import Constants from 'expo-constants';
import { registerPushToken, unregisterPushToken } from './auth';
export async function registerForPushNotificationsAsync(): Promise<string | null> {
if (!Device.isDevice) {
if (__DEV__) console.warn('Push notifications require a physical device');
return null;
}
const { status: existingStatus } = await Notifications.getPermissionsAsync();
let finalStatus = existingStatus;
if (existingStatus !== 'granted') {
const { status } = await Notifications.requestPermissionsAsync({
ios: {
allowAlert: true,
allowBadge: true,
allowSound: true,
allowCriticalAlerts: true,
},
});
finalStatus = status;
}
if (finalStatus !== 'granted') {
if (__DEV__) console.warn('Push notification permission not granted');
return null;
}
try {
const projectId = (Constants.expoConfig as any)?.extra?.eas?.projectId ?? (Constants.expoConfig as any)?.projectId;
const tokenData = await Notifications.getExpoPushTokenAsync({
projectId,
});
const pushToken = tokenData.data;
await registerPushToken(pushToken);
if (__DEV__) console.log('Push token registered:', pushToken);
return pushToken;
} catch (error) {
if (__DEV__) console.error('Failed to get push token:', error);
return null;
}
}
export async function unregisterForPushNotificationsAsync(): Promise<void> {
await unregisterPushToken();
}
export function setupNotificationChannels(): void {
if (Platform.OS === 'android') {
Notifications.setNotificationChannelAsync('task-reminders', {
name: 'Task Reminders',
importance: Notifications.AndroidImportance.HIGH,
vibrationPattern: [0, 250, 250, 250],
lightColor: '#EF5350',
sound: 'default',
enableVibrate: true,
enableLights: true,
});
Notifications.setNotificationChannelAsync('friend-requests', {
name: 'Friend Requests',
importance: Notifications.AndroidImportance.HIGH,
vibrationPattern: [0, 250, 250, 250],
lightColor: '#29B6F6',
sound: 'default',
enableVibrate: true,
});
Notifications.setNotificationChannelAsync('task-assignments', {
name: 'Task Assignments',
importance: Notifications.AndroidImportance.HIGH,
vibrationPattern: [0, 250, 250, 250],
lightColor: '#66BB6A',
sound: 'default',
enableVibrate: true,
});
Notifications.setNotificationChannelAsync('default', {
name: 'Default',
importance: Notifications.AndroidImportance.DEFAULT,
sound: 'default',
});
}
}
export function addNotificationReceivedListener(
listener: (notification: Notifications.Notification) => void
): Notifications.EventSubscription {
return Notifications.addNotificationReceivedListener(listener);
}
export function addNotificationResponseReceivedListener(
listener: (response: Notifications.NotificationResponse) => void
): Notifications.EventSubscription {
return Notifications.addNotificationResponseReceivedListener(listener);
}
export function removeNotificationSubscription(subscription: Notifications.EventSubscription): void {
subscription.remove();
}
export async function getBadgeCount(): Promise<number> {
try {
return await Notifications.getBadgeCountAsync();
} catch {
return 0;
}
}
export async function setBadgeCount(count: number): Promise<void> {
try {
await Notifications.setBadgeCountAsync(count);
} catch {
// ignore
}
}
export function handleNotificationResponse(response: Notifications.NotificationResponse): void {
const data = response.notification.request.content.data as Record<string, any> | undefined;
if (!data) return;
switch (data.type) {
case 'task_reminder':
case 'task_assignment':
if (data.taskId) {
// Navigation will be handled by the app router
console.log('Navigate to task:', data.taskId);
}
break;
case 'friend_request':
if (data.friendshipId) {
console.log('Navigate to friend request:', data.friendshipId);
}
break;
}
}
+53 -9
View File
@@ -11,18 +11,22 @@ export const SORT_OPTIONS: { value: SortBy; label: string }[] = [
{ value: 'created', label: 'Creation Date' }, { value: 'created', label: 'Creation Date' },
]; ];
export type ReminderPreference = 'none' | 'atTime' | '5m' | '15m' | '30m' | '1h' | '1d'; export type ReminderPreference = 'none' | 'at_time' | '15' | '30' | '60' | '120' | '1440';
export const REMINDER_OPTIONS: { value: ReminderPreference; label: string }[] = [ export const REMINDER_OPTIONS: { value: ReminderPreference; label: string }[] = [
{ value: 'none', label: 'No reminder' }, { value: 'none', label: 'No reminder' },
{ value: 'atTime', label: 'At due time' }, { value: 'at_time', label: 'At due time' },
{ value: '5m', label: '5 minutes before' }, { value: '15', label: '15 minutes before' },
{ value: '15m', label: '15 minutes before' }, { value: '30', label: '30 minutes before' },
{ value: '30m', label: '30 minutes before' }, { value: '60', label: '1 hour before' },
{ value: '1h', label: '1 hour before' }, { value: '120', label: '2 hours before' },
{ value: '1d', label: '1 day before' }, { value: '1440', label: '1 day before' },
]; ];
export type TagReminderPreferences = Record<string, ReminderPreference[]>;
export const TAG_REMINDER_PREFERENCES_KEY = 'settings:tagReminderPreferences';
export interface ThemeColors { export interface ThemeColors {
background: string; background: string;
card: string; card: string;
@@ -42,6 +46,14 @@ export interface ThemeColors {
overlay: string; overlay: string;
sheetBg: string; sheetBg: string;
tabBarBg: string; tabBarBg: string;
danger: string;
dangerStrong: string;
dangerSoft: string;
dangerBorder: string;
success: string;
successStrong: string;
warning: string;
info: string;
} }
export const DEFAULT_ACCENT = '#EF5350'; export const DEFAULT_ACCENT = '#EF5350';
@@ -110,6 +122,14 @@ export function colors(accent: string = DEFAULT_ACCENT): ThemeColors {
overlay: 'rgba(0,0,0,0.6)', overlay: 'rgba(0,0,0,0.6)',
sheetBg: '#26282D', sheetBg: '#26282D',
tabBarBg: '#16171A', tabBarBg: '#16171A',
danger: '#E57373',
dangerStrong: '#E53935',
dangerSoft: '#43302E',
dangerBorder: '#573431',
success: '#66BB6A',
successStrong: '#43A047',
warning: '#FFA726',
info: '#64B5F6',
}; };
} }
@@ -122,12 +142,18 @@ interface SettingsContextType {
setSortBy: (value: SortBy) => void; setSortBy: (value: SortBy) => void;
reminderPreference: ReminderPreference; reminderPreference: ReminderPreference;
setReminderPreference: (value: ReminderPreference) => void; setReminderPreference: (value: ReminderPreference) => void;
tagReminderPreferences: TagReminderPreferences;
setTagReminderPreferences: (value: TagReminderPreferences) => void;
apiUrl: string; apiUrl: string;
setApiUrl: (value: string) => void; setApiUrl: (value: string) => void;
accentColor: string; accentColor: string;
setAccentColor: (value: string) => void; setAccentColor: (value: string) => void;
todoAheadDays: number; todoAheadDays: number;
setTodoAheadDays: (value: number) => void; setTodoAheadDays: (value: number) => void;
showCompleted: boolean;
setShowCompleted: (value: boolean) => void;
deleteBySwipe: boolean;
setDeleteBySwipe: (value: boolean) => void;
theme: ThemeColors; theme: ThemeColors;
} }
@@ -138,8 +164,11 @@ const STORAGE_KEYS = {
defaultCategoryId: 'settings:defaultCategoryId', defaultCategoryId: 'settings:defaultCategoryId',
sortBy: 'settings:sortBy', sortBy: 'settings:sortBy',
reminderPreference: 'settings:reminderPreference', reminderPreference: 'settings:reminderPreference',
tagReminderPreferences: TAG_REMINDER_PREFERENCES_KEY,
accentColor: 'settings:accentColor', accentColor: 'settings:accentColor',
todoAheadDays: 'settings:todoAheadDays', todoAheadDays: 'settings:todoAheadDays',
showCompleted: 'settings:showCompleted',
deleteBySwipe: 'settings:deleteBySwipe',
}; };
const SettingsContext = createContext<SettingsContextType | null>(null); const SettingsContext = createContext<SettingsContextType | null>(null);
@@ -176,11 +205,17 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
const [sortBy, setSortBy] = useStoredSetting<SortBy>(STORAGE_KEYS.sortBy, 'date'); const [sortBy, setSortBy] = useStoredSetting<SortBy>(STORAGE_KEYS.sortBy, 'date');
const [reminderPreference, setReminderPreference] = useStoredSetting<ReminderPreference>( const [reminderPreference, setReminderPreference] = useStoredSetting<ReminderPreference>(
STORAGE_KEYS.reminderPreference, STORAGE_KEYS.reminderPreference,
'15m', '15',
);
const [tagReminderPreferences, setTagReminderPreferences] = useStoredSetting<TagReminderPreferences>(
STORAGE_KEYS.tagReminderPreferences,
{},
); );
const [apiUrl, setApiUrl] = useStoredSetting<string>(API_URL_KEY, DEFAULT_API_BASE_URL); const [apiUrl, setApiUrl] = useStoredSetting<string>(API_URL_KEY, DEFAULT_API_BASE_URL);
const [accentColor, setAccentColor] = useStoredSetting<string>(STORAGE_KEYS.accentColor, DEFAULT_ACCENT); const [accentColor, setAccentColor] = useStoredSetting<string>(STORAGE_KEYS.accentColor, DEFAULT_ACCENT);
const [todoAheadDays, setTodoAheadDays] = useStoredSetting<number>(STORAGE_KEYS.todoAheadDays, DEFAULT_TODO_AHEAD_DAYS); const [todoAheadDays, setTodoAheadDays] = useStoredSetting<number>(STORAGE_KEYS.todoAheadDays, DEFAULT_TODO_AHEAD_DAYS);
const [showCompleted, setShowCompleted] = useStoredSetting<boolean>(STORAGE_KEYS.showCompleted, false);
const [deleteBySwipe, setDeleteBySwipe] = useStoredSetting<boolean>(STORAGE_KEYS.deleteBySwipe, false);
const theme = useMemo(() => colors(accentColor), [accentColor]); const theme = useMemo(() => colors(accentColor), [accentColor]);
@@ -194,12 +229,18 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
setSortBy, setSortBy,
reminderPreference, reminderPreference,
setReminderPreference, setReminderPreference,
tagReminderPreferences,
setTagReminderPreferences,
apiUrl, apiUrl,
setApiUrl, setApiUrl,
accentColor, accentColor,
setAccentColor, setAccentColor,
todoAheadDays, todoAheadDays,
setTodoAheadDays, setTodoAheadDays,
showCompleted,
setShowCompleted,
deleteBySwipe,
setDeleteBySwipe,
theme, theme,
}), }),
[ [
@@ -211,13 +252,16 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
setSortBy, setSortBy,
reminderPreference, reminderPreference,
setReminderPreference, setReminderPreference,
tagReminderPreferences,
setTagReminderPreferences,
apiUrl, apiUrl,
setApiUrl, setApiUrl,
accentColor, accentColor,
setAccentColor, setAccentColor,
todoAheadDays, todoAheadDays,
setTodoAheadDays, setTodoAheadDays,
theme, showCompleted,
setShowCompleted,
] ]
); );
+26
View File
@@ -1,5 +1,7 @@
export type Priority = 'none' | 'low' | 'medium' | 'high' | 'critical'; export type Priority = 'none' | 'low' | 'medium' | 'high' | 'critical';
export type { ReminderPreference } from '../theme';
export type Repeat = 'none' | 'daily' | 'weekly' | 'monthly' | 'custom'; export type Repeat = 'none' | 'daily' | 'weekly' | 'monthly' | 'custom';
export const REPEAT_OPTIONS: { value: Repeat; label: string }[] = [ export const REPEAT_OPTIONS: { value: Repeat; label: string }[] = [
@@ -99,11 +101,30 @@ export interface CategoryData {
order: number; order: number;
} }
export function tagsToString(tags: string[]): string {
return `,${tags.filter(Boolean).join(',')},`;
}
export function tagsFromString(raw: string | null | undefined): string[] {
if (!raw) return [];
return raw
.split(',')
.map((t) => t.trim())
.filter(Boolean);
}
export function parseTaskTags(tagsRaw: string | null | undefined, categoryId: string): string[] {
const parsed = tagsFromString(tagsRaw);
if (parsed.length > 0) return parsed;
return categoryId ? [categoryId] : [];
}
export interface TaskData { export interface TaskData {
id: string; id: string;
title: string; title: string;
description: string; description: string;
categoryId: string; categoryId: string;
tags?: string;
priority: Priority; priority: Priority;
completed: boolean; completed: boolean;
dueDate: number; dueDate: number;
@@ -125,6 +146,8 @@ export interface SubtaskData {
id: string; id: string;
taskId: string; taskId: string;
parentSubtaskId: string | null; parentSubtaskId: string | null;
categoryId: string;
tags: string;
title: string; title: string;
description: string; description: string;
priority: Priority; priority: Priority;
@@ -154,6 +177,7 @@ export interface TaskFormData {
title: string; title: string;
description?: string; description?: string;
categoryId: string; categoryId: string;
tags?: string[];
priority: Priority; priority: Priority;
dueDate: Date | null; dueDate: Date | null;
dueTime?: string; dueTime?: string;
@@ -171,6 +195,8 @@ export interface TaskFormData {
export interface SubtaskFormData { export interface SubtaskFormData {
title: string; title: string;
description?: string; description?: string;
tags?: string[];
categoryId?: string;
priority: Priority; priority: Priority;
dueDate: Date | null; dueDate: Date | null;
dueTime?: string; dueTime?: string;
+13 -1
View File
@@ -1,6 +1,7 @@
import { database, collections } from '@/database'; import { database, collections } from '@/database';
import { Q } from '@nozbe/watermelondb'; import { Q } from '@nozbe/watermelondb';
import { recordTombstonesInBatch } from '@/database/tombstones'; import { recordTombstonesInBatch } from '@/database/tombstones';
import { tagsFromString, tagsToString, parseTaskTags } from '@/types';
export async function createCategory(name: string, color: string): Promise<void> { export async function createCategory(name: string, color: string): Promise<void> {
await database.write(async () => { await database.write(async () => {
@@ -36,7 +37,18 @@ export async function deleteCategory(categoryId: string): Promise<void> {
const tasks = await collections.tasks.query(Q.where('category_id', categoryId)).fetch(); const tasks = await collections.tasks.query(Q.where('category_id', categoryId)).fetch();
for (const task of tasks) { for (const task of tasks) {
await task.update((t) => { await task.update((t) => {
t.categoryId = fallback?.id ?? ''; const tags = parseTaskTags(t.tags, t.categoryId).filter((id) => id !== categoryId);
t.categoryId = fallback?.id ?? tags[0] ?? '';
t.tags = tagsToString(tags);
t.updatedAt = new Date();
});
}
const tagReferencedTasks = await collections.tasks.query(Q.where('tags', Q.like(`%,${categoryId},%`))).fetch();
for (const task of tagReferencedTasks) {
const tags = parseTaskTags(task.tags, task.categoryId).filter((id) => id !== categoryId);
await task.update((t) => {
t.tags = tagsToString(tags);
t.updatedAt = new Date(); t.updatedAt = new Date();
}); });
} }
+156
View File
@@ -0,0 +1,156 @@
function parseICal(content: string): Array<{
title: string;
description?: string;
start: Date;
end?: Date;
allDay?: boolean;
rrule?: string;
uid?: string;
priority?: number;
}> {
const events: Array<{
title: string;
description?: string;
start: Date;
end?: Date;
allDay?: boolean;
rrule?: string;
uid?: string;
priority?: number;
}> = [];
const lines = content.split('\n');
let i = 0;
while (i < lines.length) {
const line = lines[i].trim();
if (!line) {
i++;
continue;
}
if (line.startsWith('BEGIN:VEVENT')) {
const event: {
title: string;
description?: string;
start: Date;
end?: Date;
allDay?: boolean;
rrule?: string;
uid?: string;
priority?: number;
} = {
title: '',
start: new Date(0),
};
i++;
let inEvent = true;
while (i < lines.length && inEvent) {
const line = lines[i].trim();
i++;
if (line === 'END:VEVENT') {
inEvent = false;
continue;
}
if (line.startsWith('END:')) {
inEvent = false;
continue;
}
if (line.startsWith('SUMMARY:')) {
event.title = line.substring(8).trim();
} else if (line.startsWith('DESCRIPTION:')) {
event.description = line.substring(12).trim();
} else if (line.startsWith('DTSTART:')) {
const dateStr = line.substring(8).trim();
event.start = parseICalDateTime(dateStr);
} else if (line.startsWith('DTEND:')) {
const dateStr = line.substring(6).trim();
event.end = parseICalDateTime(dateStr);
} else if (line.startsWith('DTSTAMP:')) {
// ignore, we use start date
} else if (line.startsWith('UID:')) {
event.uid = line.substring(4).trim();
} else if (line.startsWith('PRIORITY:')) {
event.priority = parseInt(line.substring(9).trim(), 10);
} else if (line.startsWith('ALL-DAY:')) {
event.allDay = line.substring(10).trim().toLowerCase() === 'true';
} else if (line.startsWith('RRULE:')) {
event.rrule = line.substring(6).trim();
} else if (line.startsWith('BEGIN:VALARM')) {
// skip alarms
i++;
let inAlarm = true;
while (i < lines.length && inAlarm) {
const alarmLine = lines[i].trim();
i++;
if (alarmLine === 'END:VALARM') {
inAlarm = false;
}
}
} else {
// skip unrecognized lines
}
}
if (event.title) {
events.push(event);
}
} else {
i++;
}
}
return events;
}
function parseICalDateTime(dateStr: string): Date {
// Handle various iCal date/time formats
// Format 1: YYYYMMDDTHHMMSSZ (UTC)
// Format 2: YYYYMMDDTHHMMSS (local)
// Format 3: YYYYMMDD (date only)
// Format 4: TZID:... (with timezone)
let datePart = '';
let timePart = '';
let hasTime = false;
// Check for Z (UTC) indicator
const hasZ = dateStr.endsWith('Z');
const cleanStr = hasZ ? dateStr.slice(0, -1) : dateStr;
// Try to split at T
const tIndex = cleanStr.indexOf('T');
if (tIndex !== -1) {
datePart = cleanStr.substring(0, tIndex);
timePart = cleanStr.substring(tIndex + 1);
hasTime = true;
} else {
datePart = cleanStr;
}
// Parse date (YYYYMMDD)
if (datePart.length >= 8) {
const year = parseInt(datePart.substring(0, 4), 10);
const month = parseInt(datePart.substring(4, 6), 10) - 1; // 0-indexed
const day = parseInt(datePart.substring(6, 8), 10);
const date = new Date(year, month, day);
if (hasTime && timePart) {
const hour = parseInt(timePart.substring(0, 2), 10);
const minute = parseInt(timePart.substring(2, 4), 10);
date.setHours(hour, minute, 0, 0);
}
return date;
}
// Fallback: try Date.parse
return new Date(dateStr);
}
export default parseICal;
+82 -2
View File
@@ -1,6 +1,7 @@
import { database, collections } from '@/database'; import { database, collections } from '@/database';
import { Q } from '@nozbe/watermelondb'; import { Q } from '@nozbe/watermelondb';
import { Priority, Repeat, Reminder, SubtaskData } from '@/types'; import { Alert } from 'react-native';
import { Priority, Repeat, Reminder, SubtaskData, tagsToString, parseTaskTags } from '@/types';
import { scheduleTaskReminder, cancelTaskReminder } from '@/services/notifications'; import { scheduleTaskReminder, cancelTaskReminder } from '@/services/notifications';
import { recordTombstonesInBatch } from '@/database/tombstones'; import { recordTombstonesInBatch } from '@/database/tombstones';
@@ -9,6 +10,8 @@ function mapSubtaskRow(s: any, taskId?: string): SubtaskData {
id: s.id, id: s.id,
taskId: s.taskId ?? taskId ?? '', taskId: s.taskId ?? taskId ?? '',
parentSubtaskId: s.parentSubtaskId || null, parentSubtaskId: s.parentSubtaskId || null,
categoryId: s.categoryId || '',
tags: s.tags || '',
title: s.title, title: s.title,
description: s.description || '', description: s.description || '',
priority: (s.priority || 'none') as Priority, priority: (s.priority || 'none') as Priority,
@@ -215,6 +218,51 @@ export async function toggleTaskComplete(taskId: string): Promise<string | null>
return nextOccurrence ? nextOccurrence.id : null; return nextOccurrence ? nextOccurrence.id : null;
} }
export async function countOpenSubtasks(taskId: string): Promise<number> {
const open = await collections.subtasks.query(
Q.where('task_id', taskId),
Q.where('completed', false)
).fetch();
return open.length;
}
export async function confirmAndToggleTaskComplete(
taskId: string,
onCompleted?: () => void
): Promise<void> {
let task: any;
try {
task = await collections.tasks.find(taskId);
} catch {
return;
}
const finish = async () => {
await toggleTaskComplete(taskId);
onCompleted?.();
};
if (task.completed) {
await finish();
return;
}
const openCount = await countOpenSubtasks(taskId);
if (openCount === 0) {
await finish();
return;
}
Alert.alert(
'Unfinished subtasks',
`"${task.title}" still has ${openCount} open subtask${openCount === 1 ? '' : 's'}. Mark it as done anyway?`,
[
{ text: 'Cancel', style: 'cancel' },
{ text: 'Mark done anyway', style: 'destructive', onPress: finish },
]
);
}
export async function toggleSubtaskComplete(subtaskId: string): Promise<void> { export async function toggleSubtaskComplete(subtaskId: string): Promise<void> {
await database.write(async () => { await database.write(async () => {
const subtask = await collections.subtasks.find(subtaskId); const subtask = await collections.subtasks.find(subtaskId);
@@ -235,6 +283,8 @@ export async function toggleSubtaskComplete(subtaskId: string): Promise<void> {
export interface SubtaskUpdateData { export interface SubtaskUpdateData {
title: string; title: string;
description?: string; description?: string;
categoryId?: string;
tags?: string;
priority: Priority; priority: Priority;
dueDate: number; dueDate: number;
dueTime?: string; dueTime?: string;
@@ -253,6 +303,8 @@ export async function updateSubtask(subtaskId: string, data: SubtaskUpdateData):
await subtask.update((s) => { await subtask.update((s) => {
s.title = data.title.trim(); s.title = data.title.trim();
s.description = data.description || ''; s.description = data.description || '';
if (data.categoryId !== undefined) s.categoryId = data.categoryId;
if (data.tags !== undefined) s.tags = data.tags;
s.priority = data.priority; s.priority = data.priority;
s.dueDate = data.dueDate; s.dueDate = data.dueDate;
s.dueTime = data.dueTime || ''; s.dueTime = data.dueTime || '';
@@ -314,6 +366,8 @@ export async function duplicateSubtask(subtaskId: string): Promise<void> {
clone = await collections.subtasks.create((s) => { clone = await collections.subtasks.create((s) => {
s.taskId = subtask.taskId; s.taskId = subtask.taskId;
s.categoryId = subtask.categoryId || '';
s.tags = subtask.tags || '';
s.title = subtask.title; s.title = subtask.title;
s.description = subtask.description; s.description = subtask.description;
s.priority = subtask.priority; s.priority = subtask.priority;
@@ -407,6 +461,7 @@ export async function convertTaskToSubtask(taskId: string, parentTaskId: string,
const created = await collections.subtasks.create((s) => { const created = await collections.subtasks.create((s) => {
s.taskId = parentTaskId; s.taskId = parentTaskId;
s.parentSubtaskId = parentSubtaskId; s.parentSubtaskId = parentSubtaskId;
s.categoryId = task.categoryId || '';
s.title = task.title; s.title = task.title;
s.description = task.description; s.description = task.description;
s.priority = task.priority; s.priority = task.priority;
@@ -430,6 +485,7 @@ export async function convertTaskToSubtask(taskId: string, parentTaskId: string,
await collections.subtasks.create((s) => { await collections.subtasks.create((s) => {
s.taskId = parentTaskId; s.taskId = parentTaskId;
s.parentSubtaskId = created.id; s.parentSubtaskId = created.id;
s.categoryId = subtask.categoryId || '';
s.title = subtask.title; s.title = subtask.title;
s.description = subtask.description; s.description = subtask.description;
s.priority = subtask.priority; s.priority = subtask.priority;
@@ -466,11 +522,13 @@ export async function convertSubtaskToTask(subtaskId: string): Promise<void> {
const subtask = await collections.subtasks.find(subtaskId); const subtask = await collections.subtasks.find(subtaskId);
const parent = await collections.tasks.find(subtask.taskId); const parent = await collections.tasks.find(subtask.taskId);
const now = new Date(); const now = new Date();
const subTags = parseTaskTags(subtask.tags, subtask.categoryId);
const task = await collections.tasks.create((t) => { const task = await collections.tasks.create((t) => {
t.title = subtask.title; t.title = subtask.title;
t.description = subtask.description || ''; t.description = subtask.description || '';
t.categoryId = parent.categoryId; t.categoryId = subTags[0] || parent.categoryId || '';
t.tags = tagsToString(subTags.length > 0 ? subTags : (parent.categoryId ? [parent.categoryId] : []));
t.priority = subtask.priority; t.priority = subtask.priority;
t.completed = subtask.completed; t.completed = subtask.completed;
t.dueDate = subtask.dueDate; t.dueDate = subtask.dueDate;
@@ -621,6 +679,7 @@ async function createNextOccurrence(task: any, seriesId: string): Promise<any> {
t.title = task.title; t.title = task.title;
t.description = task.description; t.description = task.description;
t.categoryId = task.categoryId; t.categoryId = task.categoryId;
t.tags = task.tags || '';
t.priority = task.priority; t.priority = task.priority;
t.completed = false; t.completed = false;
t.dueDate = nextDate.getTime(); t.dueDate = nextDate.getTime();
@@ -642,6 +701,8 @@ async function createNextOccurrence(task: any, seriesId: string): Promise<any> {
s.taskId = next.id; s.taskId = next.id;
s.title = subtask.title; s.title = subtask.title;
s.description = subtask.description; s.description = subtask.description;
s.categoryId = subtask.categoryId || '';
s.tags = subtask.tags || '';
s.priority = subtask.priority; s.priority = subtask.priority;
s.completed = false; s.completed = false;
s.dueDate = subtask.dueDate; s.dueDate = subtask.dueDate;
@@ -664,6 +725,7 @@ export async function setTaskCategory(taskId: string, categoryId: string): Promi
const task = await collections.tasks.find(taskId); const task = await collections.tasks.find(taskId);
await task.update((t) => { await task.update((t) => {
t.categoryId = categoryId; t.categoryId = categoryId;
t.tags = tagsToString(categoryId ? [categoryId] : []);
t.updatedAt = new Date(); t.updatedAt = new Date();
}); });
}); });
@@ -690,6 +752,7 @@ export async function duplicateTask(taskId: string): Promise<void> {
t.title = task.title; t.title = task.title;
t.description = task.description; t.description = task.description;
t.categoryId = task.categoryId; t.categoryId = task.categoryId;
t.tags = task.tags || '';
t.priority = task.priority; t.priority = task.priority;
t.completed = false; t.completed = false;
t.dueDate = task.dueDate; t.dueDate = task.dueDate;
@@ -741,9 +804,24 @@ export async function reorderTasks(taskIds: string[]): Promise<void> {
}); });
} }
export async function reorderSubtasks(subtaskIds: string[]): Promise<void> {
await database.write(async () => {
const now = new Date();
for (let i = 0; i < subtaskIds.length; i++) {
const subtask = await collections.subtasks.find(subtaskIds[i]);
await subtask.update((s) => {
s.order = i;
s.updatedAt = now;
});
}
});
}
export interface CreateSubtaskData { export interface CreateSubtaskData {
taskId: string; taskId: string;
parentSubtaskId?: string | null; parentSubtaskId?: string | null;
categoryId?: string;
tags?: string;
title: string; title: string;
description?: string; description?: string;
priority?: Priority; priority?: Priority;
@@ -770,6 +848,8 @@ export async function createSubtask(data: CreateSubtaskData): Promise<string> {
const subtask = await collections.subtasks.create((s) => { const subtask = await collections.subtasks.create((s) => {
s.taskId = data.taskId; s.taskId = data.taskId;
s.parentSubtaskId = data.parentSubtaskId || null; s.parentSubtaskId = data.parentSubtaskId || null;
s.categoryId = data.categoryId || '';
s.tags = data.tags || '';
s.title = data.title.trim(); s.title = data.title.trim();
s.description = data.description || ''; s.description = data.description || '';
s.priority = data.priority || 'none'; s.priority = data.priority || 'none';
+2 -6
View File
@@ -1,6 +1,2 @@
tech tech08mag
osuhhgoshghgjs 7BXtaqyvwVB15W
test
SFGHDHTHHDHRERTHEH
+10
View File
@@ -0,0 +1,10 @@
// https://docs.expo.dev/guides/using-eslint/
const { defineConfig } = require('eslint/config');
const expoConfig = require("eslint-config-expo/flat");
module.exports = defineConfig([
expoConfig,
{
ignores: ["dist/*"],
}
]);
+7445 -127
View File
File diff suppressed because it is too large Load Diff
+33 -1
View File
@@ -1,4 +1,9 @@
{ {
"name": "carry-your-live",
"version": "1.0.0",
"description": "Carry Your Live - Desktop App",
"main": "expo-router/entry",
"homepage": "https://gitea.techmos.org/tech08mag/carry-your-live",
"dependencies": { "dependencies": {
"@hookform/resolvers": "^5.7.1", "@hookform/resolvers": "^5.7.1",
"@nozbe/watermelondb": "^0.28.0", "@nozbe/watermelondb": "^0.28.0",
@@ -14,9 +19,36 @@
"react-native-reanimated": "^4.5.3", "react-native-reanimated": "^4.5.3",
"react-native-safe-area-context": "^5.8.1", "react-native-safe-area-context": "^5.8.1",
"react-native-screens": "^4.26.2", "react-native-screens": "^4.26.2",
"react-native-web": "^0.21.2",
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
"electron": "^41.7.1",
"electron-builder": "^26.15.3",
"eslint": "^9.0.0",
"eslint-config-expo": "~57.0.1",
"patch-package": "^8.0.1",
"typescript": "~6.0.3" "typescript": "~6.0.3"
} },
"scripts": {
"start": "expo start",
"android": "expo run:android",
"ios": "expo run:ios",
"web": "expo start --web",
"lint": "expo lint",
"typecheck": "tsc --noEmit",
"format": "prettier --write \"**/*.{ts,tsx,json,md}\"",
"export:web": "expo export --platform web",
"desktop": "electron electron/main.js",
"desktop:dev": "ELECTRON_START_URL=http://localhost:8081 electron electron/main.js",
"desktop:dist": "npm run export:web && electron-builder",
"desktop:dir": "npm run export:web && electron-builder --dir",
"build:ios": "eas build --platform ios",
"build:android": "eas build --platform android",
"build:web": "eas build --platform web",
"submit:ios": "eas submit --platform ios",
"submit:android": "eas submit --platform android",
"postinstall": "patch-package"
},
"private": true
} }
-34
View File
@@ -1,34 +0,0 @@
- [x] Tasks should be renamed to ToDo
- [x] top of ToDo page should say ToDo instead of TODO
- [ ] Calendar rework
- [x] when task with subtasks marked as done it hides the whole task and the subtask are gone and can't be seen anymore (subtask should be visible after completion)
- [x] when dragging task to become subtask user should hold the task and not tap
because now it's very hard to scroll through tasks without randomly creating subtasks
- [ ] when creating new task there is a huge gap between the create task box and the keyboard
- [ ] after creating task keyboard should automatically be hidden again
now it's hard to get rid of the keyboard without clicking on other tasks
- [ ] ToDo page button for category: hitbox very weird hard to click
when clicked should be framed in accent color and not get bigger like the all
category now
- [ ] category all button doesn't have the correct spacing between the dot and the text
- [ ] no way to tell what category the task has -> should be a colored dot in the left of the task box replacing the nine random dots that don't anything
- [ ] task from the calendar page should only show up in the ToDo page if it is that day or x days in advance (x can be set in settings)
- [ ] clicking the three dots should open the "add new task" window instead of the window with the limited options it opens currently
- [ ] swiping task for deleting or completing
- [x] no category required by creating task
- [ ] calendar task arrow not centered
- [X] page names (up top) are not centered
- [X] tick icon in the top left needs to be removed/ replaced/ given a function
- [ ] settings -> categories -> new / change category window needs to be centered and fully visible
exit window button not visible or not existent
user should be able to click outside of the window to close it
- [ ] pressing the go back button android:
in settings go to previous page task/ calendar/ stats
in other (task/ calendar/ stats) stay on that page
at the moment calendar and stats go to tasks
- [ ] ToDo -> select category -> scroll to the right most -> button to go to categories in settings
- [x] accent color should be changeable
- [ ] bottom page buttons need to be reworked looking ass currently.
- [ ] add a task button should say "Add a Task" and (the button to add have an arrow pointing upwards instead of a cross) could look better
- [ ] space between add task button and bottom bar should be zero of the task below should be blurred currently task visible between the two looks weird
- [ ] Stats page rework (when time is right)