From 931278379351926a110f55616d10c83f2e3ff535 Mon Sep 17 00:00:00 2001 From: tech08mag Date: Mon, 10 Aug 2026 12:26:09 +0200 Subject: [PATCH 1/2] ci: trigger release on any merge to main (works with squash/rebase merges) --- .gitea/workflows/release.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 4108171..f45714e 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -11,7 +11,6 @@ permissions: jobs: release: runs-on: ubuntu-latest - if: github.event_name == 'push' && startsWith(github.event.head_commit.message, 'Merge pull request') steps: - name: Checkout uses: actions/checkout@v4 From bdc6562116e7f3dc56a0de021dbf9e01f5d9cfed Mon Sep 17 00:00:00 2001 From: tech08mag Date: Sun, 23 Aug 2026 11:36:17 +0200 Subject: [PATCH 2/2] working push notifiations --- ROADMAP.md | 156 ++++++++++ backend/package-lock.json | 178 ++++++++++- backend/package.json | 2 + backend/src/db/schema.ts | 3 + backend/src/index.ts | 2 + backend/src/routes/pushNotifications.ts | 97 ++++++ backend/src/routes/subtasks.ts | 1 + backend/src/routes/sync.ts | 80 +++-- backend/src/routes/tasks.ts | 1 + backend/src/services/pushNotifications.ts | 153 ++++++++++ backend/src/utils/validation.ts | 2 + carry-your-live/app/(tabs)/_layout.tsx | 130 +++++--- carry-your-live/app/(tabs)/calendar.tsx | 73 +++-- carry-your-live/app/(tabs)/index.tsx | 4 +- carry-your-live/app/(tabs)/settings.tsx | 12 +- carry-your-live/app/(tabs)/stats.tsx | 24 +- carry-your-live/app/_layout.tsx | 37 +++ carry-your-live/app/add-task.tsx | 19 +- carry-your-live/app/day-view.tsx | 20 +- carry-your-live/app/subtask-detail.tsx | 17 +- carry-your-live/app/task-detail.tsx | 24 +- carry-your-live/package-lock.json | 51 ++++ carry-your-live/package.json | 2 + .../src/components/CategoryEditorModal.tsx | 38 ++- .../src/components/CategoryFilter.tsx | 103 ++++--- .../src/components/CategorySelector.tsx | 2 +- .../src/components/DateTimePicker.tsx | 40 ++- .../src/components/DescriptionInput.tsx | 11 +- .../src/components/FloatingActionButton.tsx | 20 +- .../src/components/ForgotPasswordModal.tsx | 279 ++++++++++++++++++ .../src/components/QuickAddBar.tsx | 109 ++++--- carry-your-live/src/components/SyncModal.tsx | 34 ++- carry-your-live/src/components/SyncStatus.tsx | 8 +- carry-your-live/src/components/TaskItem.tsx | 55 ++-- carry-your-live/src/components/TaskList.tsx | 17 +- .../src/components/TaskNameInput.tsx | 11 +- .../src/components/TaskOverflowMenu.tsx | 10 +- carry-your-live/src/database/schema.ts | 5 +- carry-your-live/src/database/sync.ts | 46 ++- carry-your-live/src/hooks/useDatabase.tsx | 6 - carry-your-live/src/hooks/useTaskModals.tsx | 7 +- carry-your-live/src/services/auth.ts | 35 +++ carry-your-live/src/services/notifications.ts | 76 ++++- .../src/services/pushNotifications.ts | 139 +++++++++ carry-your-live/src/theme.tsx | 32 +- carry-your-live/src/types/index.ts | 3 + carry-your-live/src/utils/taskActions.ts | 61 +++- creds.txt | 8 +- todo.md | 34 --- 49 files changed, 1858 insertions(+), 419 deletions(-) create mode 100644 ROADMAP.md create mode 100644 backend/src/routes/pushNotifications.ts create mode 100644 backend/src/services/pushNotifications.ts create mode 100644 carry-your-live/src/components/ForgotPasswordModal.tsx create mode 100644 carry-your-live/src/services/pushNotifications.ts delete mode 100644 todo.md diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..fb4d859 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,156 @@ +# 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/`) 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 + +**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." diff --git a/backend/package-lock.json b/backend/package-lock.json index 5467141..3c5d1e5 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -8,6 +8,7 @@ "name": "carry-your-live-backend", "version": "1.0.0", "dependencies": { + "@types/node-fetch": "^2.6.13", "bcryptjs": "^3.0.3", "cors": "^2.8.5", "dotenv": "^16.4.5", @@ -15,6 +16,7 @@ "express": "^4.21.0", "express-rate-limit": "^8.6.2", "jsonwebtoken": "^9.0.2", + "node-fetch": "^3.3.2", "pg": "^8.13.0", "zod": "^3.23.8" }, @@ -1015,12 +1017,21 @@ "version": "22.20.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", - "devOptional": true, "license": "MIT", "dependencies": { "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": { "version": "8.20.4", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.4.tgz", @@ -1099,6 +1110,12 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "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": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", @@ -1198,6 +1215,18 @@ "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": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -1251,6 +1280,15 @@ "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": { "version": "4.4.3", "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": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -1504,6 +1551,21 @@ "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": { "version": "0.19.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.12.tgz", @@ -1651,6 +1713,29 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "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": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", @@ -1684,6 +1769,34 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "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": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -1800,6 +1913,21 @@ "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": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", @@ -2037,6 +2165,44 @@ "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": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -2977,7 +3143,6 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "devOptional": true, "license": "MIT" }, "node_modules/unpipe": { @@ -3007,6 +3172,15 @@ "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": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/backend/package.json b/backend/package.json index 0386db9..b6705b0 100644 --- a/backend/package.json +++ b/backend/package.json @@ -13,6 +13,7 @@ "db:studio": "drizzle-kit studio" }, "dependencies": { + "@types/node-fetch": "^2.6.13", "bcryptjs": "^3.0.3", "cors": "^2.8.5", "dotenv": "^16.4.5", @@ -20,6 +21,7 @@ "express": "^4.21.0", "express-rate-limit": "^8.6.2", "jsonwebtoken": "^9.0.2", + "node-fetch": "^3.3.2", "pg": "^8.13.0", "zod": "^3.23.8" }, diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 0bede9b..14de09e 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -10,6 +10,8 @@ export const users = pgTable('users', { passwordHash: text('password_hash').notNull(), resetToken: text('reset_token'), 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`), }); @@ -86,6 +88,7 @@ export const subtasks = pgTable('subtasks', { taskId: text('task_id').notNull().references(() => tasks.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(), description: text('description').notNull().default(''), priority: text('priority', { enum: ['none', 'low', 'medium', 'high', 'critical'] }).notNull().default('none'), diff --git a/backend/src/index.ts b/backend/src/index.ts index 08d47d9..13e5031 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -9,6 +9,7 @@ import userRoutes from './routes/users'; import friendRoutes from './routes/friends'; import repeatProfileRoutes from './routes/repeatProfiles'; import syncRoutes from './routes/sync'; +import pushNotificationRoutes from './routes/pushNotifications'; import { errorHandler, notFoundHandler } from './middleware/errorHandler'; const app = express(); @@ -34,6 +35,7 @@ app.use('/api/users', userRoutes); app.use('/api/repeat-profiles', repeatProfileRoutes); app.use('/api/sync', syncRoutes); app.use('/api/friends', friendRoutes); +app.use('/api/push', pushNotificationRoutes); // 404 handler app.use(notFoundHandler); diff --git a/backend/src/routes/pushNotifications.ts b/backend/src/routes/pushNotifications.ts new file mode 100644 index 0000000..89bdc2c --- /dev/null +++ b/backend/src/routes/pushNotifications.ts @@ -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; \ No newline at end of file diff --git a/backend/src/routes/subtasks.ts b/backend/src/routes/subtasks.ts index 4eeb034..71787cb 100644 --- a/backend/src/routes/subtasks.ts +++ b/backend/src/routes/subtasks.ts @@ -83,6 +83,7 @@ router.post('/task/:taskId', asyncHandler(async (req: Request, res: Response) => taskId: req.params.taskId, parentSubtaskId, categoryId: data.categoryId ?? null, + tags: data.tags ?? '', title: data.title, description: data.description ?? '', priority: data.priority ?? 'none', diff --git a/backend/src/routes/sync.ts b/backend/src/routes/sync.ts index c4a3472..3b5681d 100644 --- a/backend/src/routes/sync.ts +++ b/backend/src/routes/sync.ts @@ -17,60 +17,47 @@ router.get('/', asyncHandler(async (req: Request, res: Response) => { const sinceDate = since; - // Fetch categories changed since timestamp - const changedCategories = await db - .select() - .from(categories) - .where(and(eq(categories.userId, userId), gte(categories.updatedAt, sinceDate))) - .orderBy(asc(categories.updatedAt)); + // Fetch all changed entities in parallel + const [changedCategories, changedTasks, changedSubtasks, changedRepeatProfiles, changedFriendships, changedTombstones] = await Promise.all([ + db + .select() + .from(categories) + .where(and(eq(categories.userId, userId), gte(categories.updatedAt, sinceDate))) + .orderBy(asc(categories.updatedAt)), - // Fetch tasks changed since timestamp - const changedTasks = await db - .select() - .from(tasks) - .where(and(eq(tasks.userId, userId), gte(tasks.updatedAt, sinceDate))) - .orderBy(asc(tasks.updatedAt)); + db + .select() + .from(tasks) + .where(and(eq(tasks.userId, userId), gte(tasks.updatedAt, sinceDate))) + .orderBy(asc(tasks.updatedAt)), - // Fetch subtasks changed since timestamp - let changedSubtasks: any[] = []; - if (changedTasks.length > 0) { - changedSubtasks = await db + db .select() .from(subtasks) .where(and(eq(subtasks.userId, userId), gte(subtasks.updatedAt, sinceDate))) - .orderBy(asc(subtasks.updatedAt)); - } else { - // Also fetch subtasks for tasks that might have been deleted - changedSubtasks = await db + .orderBy(asc(subtasks.updatedAt)), + + db .select() - .from(subtasks) - .where(and(eq(subtasks.userId, userId), gte(subtasks.updatedAt, sinceDate))) - .orderBy(asc(subtasks.updatedAt)); - } + .from(repeatProfiles) + .where(and(eq(repeatProfiles.userId, userId), gte(repeatProfiles.updatedAt, sinceDate))) + .orderBy(asc(repeatProfiles.updatedAt)), - // Fetch repeat profiles changed since timestamp - const changedRepeatProfiles = await db - .select() - .from(repeatProfiles) - .where(and(eq(repeatProfiles.userId, userId), gte(repeatProfiles.updatedAt, sinceDate))) - .orderBy(asc(repeatProfiles.updatedAt)); + db + .select() + .from(friendships) + .where(and( + or(eq(friendships.userId, userId), eq(friendships.friendId, userId)), + gte(friendships.updatedAt, sinceDate) + )) + .orderBy(asc(friendships.updatedAt)), - // Fetch friendships changed since timestamp - const changedFriendships = await db - .select() - .from(friendships) - .where(and( - 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)); + db + .select() + .from(tombstones) + .where(and(eq(tombstones.userId, userId), gte(tombstones.updatedAt, sinceDate))) + .orderBy(asc(tombstones.updatedAt)), + ]); const timestamp = Date.now(); @@ -292,6 +279,7 @@ router.post('/push', asyncHandler(async (req: Request, res: Response) => { taskId: sub.taskId, parentSubtaskId: sub.parentSubtaskId ?? null, categoryId: sub.categoryId ?? null, + tags: sub.tags ?? '', title: sub.title, description: sub.description ?? '', priority: sub.priority ?? 'none', diff --git a/backend/src/routes/tasks.ts b/backend/src/routes/tasks.ts index 795987d..c80bc01 100644 --- a/backend/src/routes/tasks.ts +++ b/backend/src/routes/tasks.ts @@ -161,6 +161,7 @@ router.post('/', asyncHandler(async (req: Request, res: Response) => { taskId, title: st.title, categoryId: (st as any).categoryId ?? null, + tags: (st as any).tags ?? '', completed: false, order: index, createdAt: now, diff --git a/backend/src/services/pushNotifications.ts b/backend/src/services/pushNotifications.ts new file mode 100644 index 0000000..555cb75 --- /dev/null +++ b/backend/src/services/pushNotifications.ts @@ -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; + 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; +} + +export async function sendPushNotification(payload: PushNotificationPayload): Promise { + 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 { + 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 { + 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', + }; +} \ No newline at end of file diff --git a/backend/src/utils/validation.ts b/backend/src/utils/validation.ts index 9aa68e3..50504b5 100644 --- a/backend/src/utils/validation.ts +++ b/backend/src/utils/validation.ts @@ -75,6 +75,7 @@ export const subtaskCreateSchema = z.object({ title: z.string().min(1).max(100), 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(), 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 ?? ''), @@ -95,6 +96,7 @@ export const subtaskUpdateSchema = z.object({ title: z.string().min(1).max(100).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(), completed: z.boolean().optional(), dueDate: z.number().int().min(0).optional(), diff --git a/carry-your-live/app/(tabs)/_layout.tsx b/carry-your-live/app/(tabs)/_layout.tsx index 2203f44..8219f70 100644 --- a/carry-your-live/app/(tabs)/_layout.tsx +++ b/carry-your-live/app/(tabs)/_layout.tsx @@ -1,6 +1,7 @@ import { Tabs } from 'expo-router'; import React from 'react'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { View, StyleSheet, TouchableOpacity } from 'react-native'; import { TabBarIcon } from '@/components/TabBarIcon'; import { useSettings } from '@/theme'; import { setLastVisitedTab } from '@/utils/tabHistory'; @@ -13,19 +14,19 @@ export default function TabLayout() { { + return ( + + {props.navigation.getState().routes.map((route) => { + const focused = props.navigation.getState().index === props.navigation.getState().routes.indexOf(route); + return ( + 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 }} + > + + + ); + })} + + ); + }} screenListeners={({ route }) => ({ focus: () => { if (route.name !== 'settings') { @@ -45,42 +90,35 @@ export default function TabLayout() { }, })} > - ( - - ), - }} - /> - ( - - ), - }} - /> - ( - - ), - }} - /> - ( - - ), - }} - /> + + + + ); -} \ No newline at end of file +} + +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, + }, +}); \ No newline at end of file diff --git a/carry-your-live/app/(tabs)/calendar.tsx b/carry-your-live/app/(tabs)/calendar.tsx index db87f50..8e92759 100644 --- a/carry-your-live/app/(tabs)/calendar.tsx +++ b/carry-your-live/app/(tabs)/calendar.tsx @@ -1,5 +1,5 @@ -import React, { useMemo, useRef, useState, useCallback } from 'react'; -import { View, Text, StyleSheet, SafeAreaView, ScrollView, TouchableOpacity, Animated, Dimensions, BackHandler, KeyboardAvoidingView } from 'react-native'; +import React, { useMemo, useRef, useState, useCallback, useEffect } from 'react'; +import { View, Text, StyleSheet, SafeAreaView, ScrollView, TouchableOpacity, Animated, Dimensions, KeyboardAvoidingView } from 'react-native'; import { useRouter, useFocusEffect } from 'expo-router'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import { Header } from '@/components/Header'; @@ -8,7 +8,7 @@ import { useSubtasks } from '@/hooks/useSubtasks'; import { useTaskModals } from '@/hooks/useTaskModals'; import { useSettings } from '@/theme'; import { useCategories } from '@/hooks/useDatabase'; -import { toggleTaskComplete } from '@/utils/taskActions'; +import { confirmAndToggleTaskComplete } from '@/utils/taskActions'; import { QuickAddBar } from '@/components/QuickAddBar'; import { OptionPickerModal } from '@/components/OptionPickerModal'; import Task from '@/models/Task'; @@ -27,6 +27,7 @@ const MONTH_NAMES = [ export default function CalendarScreen() { const router = useRouter(); const { theme } = useSettings(); + const styles = React.useMemo(() => createStyles(theme), [theme]); const categories = useCategories(); const { modals, openTaskEdit } = useTaskModals(); @@ -37,9 +38,18 @@ export default function CalendarScreen() { const { map: subtasksByTask, refresh: refreshSubtasks } = useSubtasks(); const visibleMonthRef = useRef(visibleMonth); - visibleMonthRef.current = visibleMonth; const selectedDateRef = useRef(selectedDate); - selectedDateRef.current = selectedDate; + const translateX = useRef(new Animated.Value(0)); + const animatingRef = useRef(false); + const gridWidthRef = useRef(WIDTH); + + useEffect(() => { + visibleMonthRef.current = visibleMonth; + }, [visibleMonth]); + + useEffect(() => { + selectedDateRef.current = selectedDate; + }, [selectedDate]); const { tasks: selectedDayTasks, refresh: refreshDayTasks } = useTasksByDate(selectedDate); const monthTasks = useTasksInMonth(visibleMonth); @@ -47,18 +57,12 @@ export default function CalendarScreen() { useFocusEffect( useCallback(() => { - const sub = BackHandler.addEventListener('hardwareBackPress', () => true); refreshDayTasks(); refreshMonthTasks(); refreshSubtasks(); - return () => sub.remove(); }, [refreshDayTasks, refreshMonthTasks, refreshSubtasks]) ); - const translateX = useRef(new Animated.Value(0)).current; - const animatingRef = useRef(false); - const gridWidthRef = useRef(WIDTH); - const weeks = useMemo(() => { const first = startOfMonth(visibleMonth); const offset = (first.getDay() + 6) % 7; // week starts Monday @@ -78,16 +82,16 @@ export default function CalendarScreen() { } if (!animate) { setVisibleMonth(next); - translateX.setValue(0); + translateX.current.setValue(0); return; } animatingRef.current = true; const w = gridWidthRef.current || WIDTH; 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(() => { setVisibleMonth(next); - translateX.setValue(-target); - Animated.timing(translateX, { toValue: 0, duration: 180, useNativeDriver: false }).start(() => { + translateX.current.setValue(-target); + Animated.timing(translateX.current, { toValue: 0, duration: 180, useNativeDriver: false }).start(() => { animatingRef.current = false; }); }); @@ -113,21 +117,23 @@ export default function CalendarScreen() { .activeOffsetX([-16, 16]) .minDistance(6) .runOnJS(true) + // eslint-disable-next-line react-hooks/refs .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) => { if (animatingRef.current) return; const w = gridWidthRef.current || WIDTH; const dx = e.translationX; if (dx <= -w / 4) { - translateX.stopAnimation(); + translateX.current.stopAnimation(); transitionTo(1, false); } else if (dx >= w / 4) { - translateX.stopAnimation(); + translateX.current.stopAnimation(); transitionTo(-1, false); } else { - Animated.spring(translateX, { toValue: 0, useNativeDriver: false }).start(); + Animated.spring(translateX.current, { toValue: 0, useNativeDriver: false }).start(); } }), [translateX, transitionTo] @@ -152,10 +158,11 @@ export default function CalendarScreen() { }, [currentYear]); const handleToggleComplete = useCallback(async (taskId: string) => { - await toggleTaskComplete(taskId); - refreshDayTasks(); - refreshMonthTasks(); - refreshSubtasks(); + await confirmAndToggleTaskComplete(taskId, () => { + refreshDayTasks(); + refreshMonthTasks(); + refreshSubtasks(); + }); }, [refreshDayTasks, refreshMonthTasks, refreshSubtasks]); return ( @@ -169,7 +176,8 @@ export default function CalendarScreen() { onLayout={(e) => { 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 }] }]} > c.id === tasks[0].categoryId); - return cat ? desaturate(cat.color, 0.3) : '#8E8E8E'; + return cat ? desaturate(cat.color, 0.3) : theme.textMuted; } interface DayCellProps { @@ -367,6 +375,7 @@ interface DayCellProps { } const DayCell = React.memo(function DayCell({ day, label, dotColor, selected, today, inMonth, theme, onPress }: DayCellProps) { + const styles = React.useMemo(() => createStyles(theme), [theme]); return ( StyleSheet.create({ container: { flex: 1, }, @@ -485,7 +494,7 @@ const styles = StyleSheet.create({ fontWeight: '600', }, dayNumberSelected: { - color: '#FFFFFF', + color: theme.text, }, chip: { marginTop: 3, @@ -558,7 +567,7 @@ const styles = StyleSheet.create({ }, eventCompleted: { textDecorationLine: 'line-through', - color: '#9E9E9E', + color: theme.textMuted, }, menuButton: { padding: 4, @@ -585,6 +594,6 @@ const styles = StyleSheet.create({ }, bulletCompleted: { textDecorationLine: 'line-through', - color: '#6E6E6E', + color: theme.textMuted, }, }); \ No newline at end of file diff --git a/carry-your-live/app/(tabs)/index.tsx b/carry-your-live/app/(tabs)/index.tsx index f34b912..4d00993 100644 --- a/carry-your-live/app/(tabs)/index.tsx +++ b/carry-your-live/app/(tabs)/index.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useMemo, useState } from 'react'; -import { View, Text, StyleSheet, SafeAreaView, BackHandler, KeyboardAvoidingView, TouchableOpacity } from 'react-native'; +import { View, Text, StyleSheet, SafeAreaView, KeyboardAvoidingView, TouchableOpacity, BackHandler } from 'react-native'; import { useFocusEffect } from 'expo-router'; import { Header } from '@/components/Header'; import { CategoryFilter } from '@/components/CategoryFilter'; @@ -18,7 +18,7 @@ export default function TasksScreen() { useFocusEffect( useCallback(() => { - const sub = BackHandler.addEventListener('hardwareBackPress', () => true); + const sub = BackHandler.addEventListener('hardwareBackPress', () => false); return () => sub.remove(); }, []) ); diff --git a/carry-your-live/app/(tabs)/settings.tsx b/carry-your-live/app/(tabs)/settings.tsx index 9d135fb..e55b055 100644 --- a/carry-your-live/app/(tabs)/settings.tsx +++ b/carry-your-live/app/(tabs)/settings.tsx @@ -119,12 +119,12 @@ export default function SettingsScreen() {
- Categories + Tags {categories.map((category) => ( } @@ -142,7 +142,7 @@ export default function SettingsScreen() { - Add Category + Add Tag Notifications Tasks setPicker('category')} showChevron @@ -269,8 +269,8 @@ export default function SettingsScreen() { ({ value: c.id, label: c.name, color: c.color }))]} + title="Default Tag" + options={[{ value: '', label: 'None', color: theme.textMuted }, ...categories.map((c) => ({ value: c.id, label: c.name, color: c.color }))]} selectedValue={defaultCategoryId} onSelect={handleDefaultCategory} onClose={() => setPicker(null)} diff --git a/carry-your-live/app/(tabs)/stats.tsx b/carry-your-live/app/(tabs)/stats.tsx index 480aad7..33ff95f 100644 --- a/carry-your-live/app/(tabs)/stats.tsx +++ b/carry-your-live/app/(tabs)/stats.tsx @@ -1,9 +1,9 @@ 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 { Header } from '@/components/Header'; import { useStats } from '@/hooks/useStats'; -import { useSettings } from '@/theme'; +import { useSettings, type ThemeColors } from '@/theme'; export default function StatsScreen() { const { theme } = useSettings(); @@ -11,8 +11,7 @@ export default function StatsScreen() { useFocusEffect( useCallback(() => { - const sub = BackHandler.addEventListener('hardwareBackPress', () => true); - return () => sub.remove(); + return () => {}; }, []) ); @@ -77,7 +76,7 @@ export default function StatsScreen() { - By category + By tags {stats.byCategory.length === 0 ? ( No completed tasks yet ) : ( @@ -98,7 +97,7 @@ export default function StatsScreen() { ) : ( stats.byPriority.map((p) => ( - + {p.label} {p.count} @@ -112,16 +111,17 @@ export default function StatsScreen() { const BASE_HEIGHT = 90; -function priorityColor(priority: string): string { +function priorityColor(theme: ThemeColors, priority: string): string { switch (priority) { + case 'critical': + return theme.dangerStrong; case 'high': - return '#EF5350'; - case 'medium': - return '#FFA726'; + return theme.warning; case 'low': - return '#66BB6A'; + case 'medium': + return theme.success; default: - return '#9E9E9E'; + return theme.textMuted; } } diff --git a/carry-your-live/app/_layout.tsx b/carry-your-live/app/_layout.tsx index 45e5b73..70ff8b8 100644 --- a/carry-your-live/app/_layout.tsx +++ b/carry-your-live/app/_layout.tsx @@ -8,11 +8,46 @@ import { UpdateNotifier } from '@/components/UpdateNotifier'; import { Linking } from 'react-native'; import { useEffect } from 'react'; 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() { const router = useRouter(); useEffect(() => { + setupNotificationChannels(); + registerForPushNotificationsAsync().catch(() => {}); + + const responseListener = addNotificationResponseReceivedListener((response) => { + const data = response.notification.request.content.data as Record | 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) => { if (!url || !url.includes('://quick-add')) return; router.replace('/(tabs)'); @@ -21,8 +56,10 @@ function RootNavigator() { Linking.getInitialURL().then(handleUrl).catch(() => {}); const subscription = Linking.addEventListener('url', ({ url }) => handleUrl(url)); + return () => { subscription.remove(); + removeNotificationSubscription(responseListener); }; }, [router]); diff --git a/carry-your-live/app/add-task.tsx b/carry-your-live/app/add-task.tsx index 4e599ae..58f2589 100644 --- a/carry-your-live/app/add-task.tsx +++ b/carry-your-live/app/add-task.tsx @@ -1,5 +1,5 @@ -import React, { useMemo } from 'react'; -import { View, Text, StyleSheet, SafeAreaView, ScrollView, KeyboardAvoidingView, Platform } from 'react-native'; +import React, { useMemo, useRef } from 'react'; +import { View, Text, StyleSheet, SafeAreaView, ScrollView, KeyboardAvoidingView, Platform, Keyboard } from 'react-native'; import { Header } from '@/components/Header'; import { CategorySelector } from '@/components/CategorySelector'; import { TaskNameInput } from '@/components/TaskNameInput'; @@ -20,6 +20,7 @@ import { database, collections } from '@/database'; import { TaskFormData, tagsToString } from '@/types'; import { useSettings } from '@/theme'; import { scheduleTaskReminder } from '@/services/notifications'; +import { ReminderPreference } from '@/types'; import { useFriends } from '@/hooks/useFriends'; const taskSchema = z.object({ @@ -48,12 +49,14 @@ export default function AddTaskScreen() { const { theme } = useSettings(); const { date: dateParam } = useLocalSearchParams<{ date?: string }>(); const { friends } = useFriends(); + const { notifications: notificationsEnabled, reminderPreference } = useSettings(); const initialCategory = defaultCategoryId || ''; const initialDate = useMemo(() => { if (!dateParam) return null; const parsed = new Date(Array.isArray(dateParam) ? dateParam[0] : dateParam); return Number.isNaN(parsed.getTime()) ? null : parsed; }, [dateParam]); + const scrollViewRef = useRef(null); const methods = useForm({ resolver: zodResolver(taskSchema), @@ -104,6 +107,9 @@ export default function AddTaskScreen() { const onSubmit = async (data: TaskFormData) => { if (!isReady) return; + Keyboard.dismiss(); + scrollViewRef.current?.scrollTo({ y: 0, animated: true }); + const now = new Date(); const dueDateTimestamp = data.dueDate ? data.dueDate.getTime() : 0; const seriesId = data.repeat !== 'none' @@ -160,7 +166,7 @@ export default function AddTaskScreen() { } }); - await scheduleTaskReminder(createdTask); + await scheduleTaskReminder(createdTask, notificationsEnabled, reminderPreference); router.back(); }; @@ -179,12 +185,15 @@ export default function AddTaskScreen() { createStyles(theme), [theme]); const categories = useCategories(); const { modals, openTaskEdit } = useTaskModals(); const { date: dateParam } = useLocalSearchParams<{ date?: string }>(); @@ -54,9 +57,10 @@ export default function DayViewScreen() { }, [subtasksByTask, day]); const handleToggleComplete = useCallback(async (taskId: string) => { - await toggleTaskComplete(taskId); - refresh(); - refreshSubtasks(); + await confirmAndToggleTaskComplete(taskId, () => { + refresh(); + refreshSubtasks(); + }); }, [refresh, refreshSubtasks]); const handleToggleSubtask = useCallback(async (subtaskId: string) => { @@ -65,7 +69,7 @@ export default function DayViewScreen() { }, [refreshSubtasks]); return ( - +
@@ -205,7 +209,7 @@ export default function DayViewScreen() { ); } -const styles = StyleSheet.create({ +const createStyles = (theme: ThemeColors) => StyleSheet.create({ container: { flex: 1, }, @@ -252,7 +256,7 @@ const styles = StyleSheet.create({ }, eventCompleted: { textDecorationLine: 'line-through', - color: '#9E9E9E', + color: theme.textMuted, }, eventSub: { fontSize: 13, diff --git a/carry-your-live/app/subtask-detail.tsx b/carry-your-live/app/subtask-detail.tsx index 0d1f434..da6fc89 100644 --- a/carry-your-live/app/subtask-detail.tsx +++ b/carry-your-live/app/subtask-detail.tsx @@ -15,7 +15,7 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import { useDatabase } from '@/hooks/useDatabase'; 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 { updateSubtask, deleteSubtask } from '@/utils/taskActions'; import { CategorySelector } from '@/components/CategorySelector'; @@ -25,7 +25,7 @@ import Svg, { Path } from 'react-native-svg'; const subtaskSchema = z.object({ title: z.string().trim().min(1, 'Subtask name is required').max(100), description: z.string().max(1000).optional(), - categoryId: z.string().optional(), + tags: z.array(z.string()).optional(), priority: z.enum(['none', 'low', 'medium', 'high', 'critical']), dueDate: z.date().nullable().optional(), dueTime: z.string().optional(), @@ -53,7 +53,7 @@ export default function SubtaskDetailScreen() { defaultValues: { title: '', description: '', - categoryId: '', + tags: [], priority: 'none', dueDate: null, dueTime: '', @@ -71,7 +71,7 @@ export default function SubtaskDetailScreen() { const { handleSubmit, watch, setValue, control, reset, formState: { errors } } = methods; const priority = watch('priority'); - const categoryId = watch('categoryId'); + const tags = watch('tags') ?? []; const repeat = watch('repeat'); const repeatInterval = watch('repeatInterval') ?? 1; const repeatDays = watch('repeatDays') ?? []; @@ -90,7 +90,7 @@ export default function SubtaskDetailScreen() { reset({ title: subtask.title, description: subtask.description, - categoryId: subtask.categoryId || '', + tags: parseTaskTags(subtask.tags, subtask.categoryId), priority: subtask.priority, dueDate: subtask.dueDate ? new Date(subtask.dueDate) : null, dueTime: subtask.dueTime, @@ -125,7 +125,8 @@ export default function SubtaskDetailScreen() { await updateSubtask(id, { title: data.title, description: data.description || '', - categoryId: data.categoryId || '', + categoryId: (data.tags && data.tags[0]) || '', + tags: tagsToString(data.tags || []), priority: data.priority, dueDate: data.dueDate ? data.dueDate.getTime() : 0, dueTime: data.dueTime || '', @@ -200,8 +201,8 @@ export default function SubtaskDetailScreen() { )} /> setValue('categoryId', value[0] ?? '')} + value={tags} + onChange={(value) => setValue('tags', value)} /> (null); const methods = useForm({ resolver: zodResolver(taskSchema), @@ -126,6 +129,8 @@ export default function TaskDetailScreen() { const subtasks = await collections.subtasks.query(Q.where('task_id', id)).fetch(); if (!mounted) return; + const remindersValue = task.reminders || (task.reminder && task.reminder !== 'none' ? task.reminder : ''); + reset({ title: task.title, description: task.description, @@ -140,6 +145,7 @@ export default function TaskDetailScreen() { repeatInterval: task.repeatInterval || 1, repeatDays: (task.repeatDays || '').split(',').map(Number).filter((d) => !Number.isNaN(d)), reminder: (task.reminder || 'none') as TaskFormData['reminder'], + reminders: remindersValue, assigneeId: task.assigneeId ?? null, subtasks: subtasks.map((s) => ({ title: s.title, _key: s.id })), }); @@ -155,6 +161,9 @@ export default function TaskDetailScreen() { const onSubmit = async (data: TaskFormData) => { if (!id || !isReady) return; + Keyboard.dismiss(); + scrollViewRef.current?.scrollTo({ y: 0, animated: true }); + const now = new Date(); const dueDateTimestamp = data.dueDate ? data.dueDate.getTime() : 0; @@ -179,6 +188,7 @@ export default function TaskDetailScreen() { t.repeatInterval = data.repeatInterval || 1; t.repeatDays = (data.repeatDays || []).join(','); t.reminder = data.reminder || 'none'; + t.reminders = data.reminders || ''; t.assigneeId = data.assigneeId ?? null; if (data.repeat !== 'none' && !t.seriesId) { t.seriesId = `series_${Math.random().toString(36).slice(2, 10)}${Date.now().toString(36)}`; @@ -207,6 +217,7 @@ export default function TaskDetailScreen() { 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'; @@ -247,7 +258,7 @@ export default function TaskDetailScreen() { } }); - await scheduleTaskReminder(savedTask); + await scheduleTaskReminder(savedTask, notificationsEnabled, reminderPreference); router.back(); }; @@ -287,12 +298,15 @@ export default function TaskDetailScreen() { { if (!category || !canDelete) return; confirmAction({ - title: 'Delete Category', - message: `Tasks in "${category.name}" will be moved to another category.`, + title: 'Delete Tag', + message: `Tasks in "${category.name}" will be moved to another tag.`, confirmLabel: 'Delete', destructive: true, onConfirm: async () => { @@ -60,7 +61,8 @@ export function CategoryEditorModal({ visible, category, categoryCount, onClose - {category ? 'Edit Category' : 'New Category'} + {category ? 'Edit Tag' : 'New Tag'} - - - + + + + @@ -92,7 +96,7 @@ export function CategoryEditorModal({ visible, category, categoryCount, onClose styles.input, { backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text }, ]} - placeholder="Category name" + placeholder="Tag name" placeholderTextColor={theme.textMuted} value={name} onChangeText={setName} @@ -171,10 +175,18 @@ const styles = StyleSheet.create({ sheet: { width: '100%', maxWidth: 380, - maxHeight: '100%', + maxHeight: '90%', borderRadius: 16, padding: 20, gap: 8, + shadowColor: '#000', + shadowOffset: { width: 0, height: 8 }, + shadowOpacity: 0.25, + shadowRadius: 16, + elevation: 12, + }, + scrollContent: { + minHeight: 200, }, header: { flexDirection: 'row', @@ -188,6 +200,8 @@ const styles = StyleSheet.create({ }, closeButton: { padding: 4, + borderRadius: 12, + backgroundColor: 'transparent', }, label: { fontSize: 13, diff --git a/carry-your-live/src/components/CategoryFilter.tsx b/carry-your-live/src/components/CategoryFilter.tsx index 3cacd63..cee8796 100644 --- a/carry-your-live/src/components/CategoryFilter.tsx +++ b/carry-your-live/src/components/CategoryFilter.tsx @@ -1,5 +1,5 @@ -import React from 'react'; -import { View, Text, StyleSheet, ScrollView, TouchableOpacity } from 'react-native'; +import React, { useState } from 'react'; +import { Text, StyleSheet, ScrollView, TouchableOpacity, Animated } from 'react-native'; import { useRouter } from 'expo-router'; import { useUniqueCategories } from '@/hooks/useDatabase'; import { useSettings, ThemeColors } from '@/theme'; @@ -17,6 +17,7 @@ export function CategoryFilter({ selected, onSelect }: CategoryFilterProps) { 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); @@ -28,6 +29,15 @@ export function CategoryFilter({ selected, onSelect }: CategoryFilterProps) { 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 ( onSelect([])} + onPressIn={() => animatePress('all', true)} + onPressOut={() => animatePress('all', false)} theme={theme} + isAllCategory={true} + scaleAnim={scaleAnim} /> {categories.map((category) => ( toggle(category.id)} + onPressIn={() => animatePress(category.id, true)} + onPressOut={() => animatePress(category.id, false)} theme={theme} + scaleAnim={scaleAnim} /> ))} router.push('/settings')} activeOpacity={0.7} accessibilityRole="button" - accessibilityLabel="Manage categories" + accessibilityLabel="Manage tags" hitSlop={{ top: 8, bottom: 8, left: 4, right: 4 }} > @@ -80,34 +97,43 @@ interface CategoryButtonProps { color: string; selected: boolean; onPress: () => void; + onPressIn: () => void; + onPressOut: () => void; 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 ( - - - {name} - + {isAllCategory ? ( + + + + + ) : null} ); } @@ -122,24 +148,27 @@ const styles = StyleSheet.create({ alignItems: 'center', }, button: { - flexDirection: 'row', + width: 44, + height: 44, + borderRadius: 22, + borderWidth: 2, + borderColor: 'transparent', alignItems: 'center', - paddingHorizontal: 12, - paddingVertical: 10, - borderRadius: 18, - borderWidth: 1.5, - minHeight: 38, justifyContent: 'center', - gap: 8, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 2, }, - colorDot: { - width: 8, - height: 8, - borderRadius: 4, - }, - buttonText: { - fontSize: 12, - fontWeight: '500', + buttonSelected: { + borderColor: '#FFFFFF', + borderWidth: 3, + shadowColor: '#000', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.25, + shadowRadius: 8, + elevation: 6, }, manageButton: { alignSelf: 'center', diff --git a/carry-your-live/src/components/CategorySelector.tsx b/carry-your-live/src/components/CategorySelector.tsx index 0fd2b17..c3d35ee 100644 --- a/carry-your-live/src/components/CategorySelector.tsx +++ b/carry-your-live/src/components/CategorySelector.tsx @@ -68,7 +68,7 @@ export function CategorySelector({ value, onChange, error }: CategorySelectorPro - {error && {error}} + {error && {error}} setShowModal(false)}> + setPicker('date')} + activeOpacity={0.8} + accessibilityLabel="Open calendar" + hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} + > + + + + + {endTimeInvalid && ( - + - - + + - + {endTime === startTime ? 'The end time is the same as the start time.' : 'The end time is before the start time.'} @@ -299,6 +321,14 @@ const styles = StyleSheet.create({ borderRadius: 10, borderWidth: 1, }, + calendarButton: { + width: 52, + height: 52, + borderRadius: 12, + borderWidth: 1, + alignItems: 'center', + justifyContent: 'center', + }, warningText: { fontSize: 13, fontWeight: '500', diff --git a/carry-your-live/src/components/DescriptionInput.tsx b/carry-your-live/src/components/DescriptionInput.tsx index 57bd3b0..8d8e75e 100644 --- a/carry-your-live/src/components/DescriptionInput.tsx +++ b/carry-your-live/src/components/DescriptionInput.tsx @@ -1,6 +1,6 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import { View, Text, StyleSheet, TextInput , TextInputProps } from 'react-native'; -import { useSettings } from '@/theme'; +import { useSettings, type ThemeColors } from '@/theme'; interface DescriptionInputProps extends TextInputProps { error?: string; @@ -8,6 +8,7 @@ interface DescriptionInputProps extends TextInputProps { export function DescriptionInput({ error, ...props }: DescriptionInputProps) { const { theme } = useSettings(); + const styles = useMemo(() => createStyles(theme), [theme]); return ( @@ -35,7 +36,7 @@ export function DescriptionInput({ error, ...props }: DescriptionInputProps) { ); } -const styles = StyleSheet.create({ +const createStyles = (theme: ThemeColors) => StyleSheet.create({ container: { gap: 5, }, @@ -51,7 +52,7 @@ const styles = StyleSheet.create({ fontSize: 14, }, inputError: { - borderColor: '#E53935', + borderColor: theme.dangerStrong, borderWidth: 1.5, }, charCount: { @@ -61,7 +62,7 @@ const styles = StyleSheet.create({ }, errorText: { fontSize: 11, - color: '#E53935', + color: theme.dangerStrong, marginLeft: 4, }, }); \ No newline at end of file diff --git a/carry-your-live/src/components/FloatingActionButton.tsx b/carry-your-live/src/components/FloatingActionButton.tsx index f0e9217..1778a41 100644 --- a/carry-your-live/src/components/FloatingActionButton.tsx +++ b/carry-your-live/src/components/FloatingActionButton.tsx @@ -1,5 +1,5 @@ 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 Svg, { Path } from 'react-native-svg'; import { useSettings } from '@/theme'; @@ -70,7 +70,7 @@ export function FloatingActionButton() { > + + Add a Task + ); } @@ -92,6 +95,7 @@ const styles = StyleSheet.create({ shadowOpacity: 0.3, shadowRadius: 12, elevation: 6, + alignItems: 'flex-end', }, button: { width: 56, @@ -100,4 +104,16 @@ const styles = StyleSheet.create({ alignItems: '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', + }, }); \ No newline at end of file diff --git a/carry-your-live/src/components/ForgotPasswordModal.tsx b/carry-your-live/src/components/ForgotPasswordModal.tsx new file mode 100644 index 0000000..99eca16 --- /dev/null +++ b/carry-your-live/src/components/ForgotPasswordModal.tsx @@ -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('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(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 = () => ( + + + Enter your username to receive a password reset link. + + + {error && {error}} + + {busy ? ( + + ) : ( + Send Reset Link + )} + + + ); + + const renderEnterResetCode = () => ( + + + Enter the reset token and your new password. + + + + + {error && {error}} + + {busy ? ( + + ) : ( + Reset Password + )} + + + ); + + return ( + + + + + + Reset Password + + {step === 'enterUsername' ? renderEnterUsername() : renderEnterResetCode()} + + + Cancel + + + + + ); +} + +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', + }, +}); \ No newline at end of file diff --git a/carry-your-live/src/components/QuickAddBar.tsx b/carry-your-live/src/components/QuickAddBar.tsx index 15e5c53..2ed6e37 100644 --- a/carry-your-live/src/components/QuickAddBar.tsx +++ b/carry-your-live/src/components/QuickAddBar.tsx @@ -1,5 +1,6 @@ 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 { useCategories } from '@/hooks/useDatabase'; import { useSettings } from '@/theme'; @@ -13,6 +14,21 @@ interface QuickAddBarProps { placeholder?: string; } +const BlurContainer = ({ children, style, theme }: { children: React.ReactNode; style: any; theme: any }) => { + if (Platform.OS === 'web') { + return ( + + {children} + + ); + } + return ( + + {children} + + ); +}; + export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) { const { theme, defaultCategoryId } = useSettings(); const categories = useCategories(); @@ -25,7 +41,7 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) { () => categories.filter((c) => c.name.toLowerCase() !== 'calendar'), [categories] ); - const categoryColor = visibleCategories.find((c) => c.id === categoryId)?.color ?? '#9E9E9E'; + const categoryColor = visibleCategories.find((c) => c.id === categoryId)?.color ?? theme.textMuted; useEffect(() => { return subscribeToQuickAdd(() => { @@ -66,58 +82,57 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) { return ( - - setCategoryPickerVisible(true)} - activeOpacity={0.7} - accessibilityLabel="Select category" - > - - - - - - - - - Add a Task - + + + setCategoryPickerVisible(true)} + activeOpacity={0.7} + accessibilityLabel="Select tags" + > + + + + + + + + - - - + + + ({ value: c.id, label: c.name, color: c.color }))]} + title="Select Tags" + options={[{ value: '', label: 'None', color: theme.textMuted }, ...visibleCategories.map((c) => ({ value: c.id, label: c.name, color: c.color }))]} selectedValue={categoryId} onSelect={(value) => setCategoryId(Array.isArray(value) ? value[0] : value)} onClose={() => setCategoryPickerVisible(false)} @@ -130,6 +145,11 @@ const styles = StyleSheet.create({ wrapper: { paddingHorizontal: 8, paddingTop: 4, + paddingBottom: 8, + }, + blurContainer: { + borderRadius: 14, + overflow: 'hidden', }, bar: { flexDirection: 'row', @@ -145,6 +165,7 @@ const styles = StyleSheet.create({ shadowOpacity: 0.10, shadowRadius: 6, elevation: 6, + minHeight: 56, }, categoryButton: { flexDirection: 'row', diff --git a/carry-your-live/src/components/SyncModal.tsx b/carry-your-live/src/components/SyncModal.tsx index 15da8a6..93c0546 100644 --- a/carry-your-live/src/components/SyncModal.tsx +++ b/carry-your-live/src/components/SyncModal.tsx @@ -12,6 +12,8 @@ import { import { useSettings } from '@/theme'; import { AuthUser, getAuthToken, getAuthUser, login, register, signOutAuth } from '@/services/auth'; import { runSyncGuarded, startAutoSync, stopAutoSync, getLastSyncTime, SyncResult } from '@/database/sync'; +import { ForgotPasswordModal } from './ForgotPasswordModal'; +import { registerForPushNotificationsAsync, unregisterForPushNotificationsAsync } from '@/services/pushNotifications'; interface SyncModalProps { visible: boolean; @@ -34,6 +36,7 @@ export function SyncModal({ visible, onClose }: SyncModalProps) { const [lastSync, setLastSync] = useState(null); const [result, setResult] = useState(null); const [prevVisible, setPrevVisible] = useState(visible); + const [showForgotPassword, setShowForgotPassword] = useState(false); if (prevVisible !== visible) { setPrevVisible(visible); @@ -83,6 +86,7 @@ export function SyncModal({ visible, onClose }: SyncModalProps) { : await login(username.trim(), password); setUser(authedUser); startAutoSync(); + registerForPushNotificationsAsync().catch(() => {}); } catch (err: any) { setError(err?.message ?? 'Sign in failed'); } finally { @@ -115,6 +119,7 @@ export function SyncModal({ visible, onClose }: SyncModalProps) { const handleSignOut = async () => { await signOutAuth(); stopAutoSync(); + unregisterForPushNotificationsAsync().catch(() => {}); setUser(null); setError(null); setStatus('idle'); @@ -159,7 +164,7 @@ export function SyncModal({ visible, onClose }: SyncModalProps) { {status === 'success' && result && ( - + Synced! {result.pushed.tasks + result.pulled.tasks} tasks,{' '} {result.pushed.categories + result.pulled.categories} categories,{' '} {result.pushed.subtasks + result.pulled.subtasks} subtasks,{' '} @@ -168,7 +173,7 @@ export function SyncModal({ visible, onClose }: SyncModalProps) { )} {status === 'error' && error && ( - {error} + {error} )} - Sign Out + Sign Out ) : ( @@ -234,7 +239,17 @@ export function SyncModal({ visible, onClose }: SyncModalProps) { secureTextEntry /> - {error && {error}} + {mode === 'login' && ( + setShowForgotPassword(true)} + activeOpacity={0.7} + > + Forgot Password? + + )} + + {error && {error}} )} + setShowForgotPassword(false)} /> + - + ); case 'error': return ( - - + + ); default: @@ -128,7 +128,7 @@ export default function SyncStatus({ compact = false }: SyncStatusProps) { {error && ( - {error} + {error} )} diff --git a/carry-your-live/src/components/TaskItem.tsx b/carry-your-live/src/components/TaskItem.tsx index a79ff49..e71bd1e 100644 --- a/carry-your-live/src/components/TaskItem.tsx +++ b/carry-your-live/src/components/TaskItem.tsx @@ -3,7 +3,8 @@ import { View, Text, StyleSheet, TouchableOpacity, Animated, Platform, Dimension import { Swipeable, Gesture, GestureDetector, PanGestureHandler } from 'react-native-gesture-handler'; import { TaskData, SubtaskData, Priority, Repeat, Reminder } from '@/types'; 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'; export type TaskLike = { @@ -47,6 +48,7 @@ interface 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 styles = React.useMemo(() => createStyles(theme), [theme]); const [opacityAnim] = React.useState(new Animated.Value(task.completed ? 0.5 : 1)); const [dragTranslateX] = React.useState(new Animated.Value(0)); const [dragTranslateY] = React.useState(new Animated.Value(0)); @@ -216,16 +218,9 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation) => - {(categoryColors ?? (categoryColor ? [categoryColor] : [])).slice(0, 3).map((color, i) => ( - 0 && { marginLeft: -6 }, - ]} - /> - ))} + {categoryColor && ( + + )} ) => ) : ( - + )} @@ -403,7 +398,7 @@ function formatTime12(time: string): string { return `${h}:${String(minutes).padStart(2, '0')} ${period}`; } -const styles = StyleSheet.create({ +const createStyles = (theme: ThemeColors) => StyleSheet.create({ container: { flexDirection: 'row', alignItems: 'center', @@ -426,10 +421,10 @@ const styles = StyleSheet.create({ marginBottom: 4, }, taskOverdue: { - borderColor: '#573431', + borderColor: theme.dangerBorder, }, taskDueToday: { - borderColor: '#2C4766', + borderColor: mixHex(theme.info, theme.background, 0.55), }, taskSelected: { shadowColor: '#000', @@ -438,8 +433,8 @@ const styles = StyleSheet.create({ elevation: 2, }, taskHovered: { - borderColor: '#2C4766', - backgroundColor: 'rgba(44, 71, 102, 0.15)', + borderColor: mixHex(theme.info, theme.background, 0.55), + backgroundColor: mixHex(theme.info, theme.background, 0.85), }, dragLifted: { zIndex: 100, @@ -476,9 +471,9 @@ const styles = StyleSheet.create({ marginRight: 8, }, categoryDot: { - width: 10, - height: 10, - borderRadius: 5, + width: 12, + height: 12, + borderRadius: 6, }, title: { fontSize: 17, @@ -488,7 +483,7 @@ const styles = StyleSheet.create({ }, titleCompleted: { textDecorationLine: 'line-through', - color: '#9E9E9E', + color: theme.textMuted, }, repeatIcon: { marginLeft: 8, @@ -512,7 +507,7 @@ const styles = StyleSheet.create({ justifyContent: 'center', }, titleOverdue: { - color: '#E57373', + color: theme.danger, }, priorityBadge: { paddingHorizontal: 6, @@ -530,14 +525,14 @@ const styles = StyleSheet.create({ width: 20, height: 20, borderRadius: 10, - backgroundColor: '#43302E', + backgroundColor: theme.dangerSoft, alignItems: 'center', justifyContent: 'center', }, assigneeText: { fontSize: 9, fontWeight: '700', - color: '#FF9C8F', + color: theme.danger, }, dueRow: { flexDirection: 'row', @@ -548,11 +543,11 @@ const styles = StyleSheet.create({ fontSize: 13, }, dueTextOverdue: { - color: '#E57373', + color: theme.danger, fontWeight: '600', }, dueTextDueToday: { - color: '#64B5F6', + color: theme.info, fontWeight: '600', }, menuButton: { @@ -567,12 +562,12 @@ const styles = StyleSheet.create({ paddingHorizontal: 6, paddingVertical: 2, borderRadius: 8, - backgroundColor: 'rgba(100, 181, 246, 0.15)', + backgroundColor: mixHex(theme.info, theme.background, 0.85), }, allDayText: { fontSize: 11, fontWeight: '600', - color: '#64B5F6', + color: theme.info, }, checkCircle: { width: 30, @@ -589,12 +584,12 @@ const styles = StyleSheet.create({ gap: 4, }, swipeDelete: { - backgroundColor: '#B8504A', + backgroundColor: theme.dangerStrong, borderTopRightRadius: 16, borderBottomRightRadius: 16, }, swipeComplete: { - backgroundColor: '#3D7A4A', + backgroundColor: theme.successStrong, borderTopLeftRadius: 16, borderBottomLeftRadius: 16, }, diff --git a/carry-your-live/src/components/TaskList.tsx b/carry-your-live/src/components/TaskList.tsx index 814fc35..c0c5a94 100644 --- a/carry-your-live/src/components/TaskList.tsx +++ b/carry-your-live/src/components/TaskList.tsx @@ -10,8 +10,9 @@ import { SubtaskItem } from './SubtaskItem'; import { TaskData, SubtaskData, parseTaskTags } from '@/types'; import Task from '@/models/Task'; import { useSettings } from '@/theme'; +import { runSyncGuarded } from '@/database/sync'; import { - toggleTaskComplete, + confirmAndToggleTaskComplete, deleteTask, setTaskCompleted, convertTaskToSubtask, @@ -126,8 +127,13 @@ export function TaskList({ categoryIds = [], showCompleted = false, onSelectionC }, [refreshTasks, refreshSubtasks]) ); - const onRefresh = useCallback(() => { + const onRefresh = useCallback(async () => { setRefreshing(true); + try { + await runSyncGuarded(); + } catch (error) { + if (__DEV__) console.warn('Sync failed:', error); + } refreshTasks(); refreshSubtasks(); setTimeout(() => setRefreshing(false), 600); @@ -162,8 +168,7 @@ export function TaskList({ categoryIds = [], showCompleted = false, onSelectionC }, [onSelectionChange]); const handleToggle = useCallback(async (taskId: string) => { - await toggleTaskComplete(taskId); - refreshTasks(); + await confirmAndToggleTaskComplete(taskId, () => refreshTasks()); }, [refreshTasks]); const toggleExpand = useCallback(async (taskId: string) => { @@ -511,8 +516,8 @@ export function TaskList({ categoryIds = [], showCompleted = false, onSelectionC {selectedIds.size} selected - - + + )} diff --git a/carry-your-live/src/components/TaskNameInput.tsx b/carry-your-live/src/components/TaskNameInput.tsx index 668663f..2404def 100644 --- a/carry-your-live/src/components/TaskNameInput.tsx +++ b/carry-your-live/src/components/TaskNameInput.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { View, Text, StyleSheet, TextInput , TextInputProps } from 'react-native'; -import { useSettings } from '@/theme'; +import { useSettings, type ThemeColors } from '@/theme'; interface TaskNameInputProps extends TextInputProps { error?: string; @@ -8,6 +8,7 @@ interface TaskNameInputProps extends TextInputProps { export function TaskNameInput({ error, ...props }: TaskNameInputProps) { const { theme } = useSettings(); + const styles = React.useMemo(() => createStyles(theme), [theme]); return ( @@ -34,7 +35,7 @@ export function TaskNameInput({ error, ...props }: TaskNameInputProps) { ); } -const styles = StyleSheet.create({ +const createStyles = (theme: ThemeColors) => StyleSheet.create({ container: { gap: 5, }, @@ -47,7 +48,7 @@ const styles = StyleSheet.create({ fontWeight: '600', }, required: { - color: '#E53935', + color: theme.dangerStrong, fontSize: 13, fontWeight: '600', }, @@ -59,12 +60,12 @@ const styles = StyleSheet.create({ fontSize: 15, }, inputError: { - borderColor: '#E53935', + borderColor: theme.dangerStrong, borderWidth: 1.5, }, errorText: { fontSize: 11, - color: '#E53935', + color: theme.dangerStrong, marginLeft: 4, }, }); \ No newline at end of file diff --git a/carry-your-live/src/components/TaskOverflowMenu.tsx b/carry-your-live/src/components/TaskOverflowMenu.tsx index d86dd92..3491579 100644 --- a/carry-your-live/src/components/TaskOverflowMenu.tsx +++ b/carry-your-live/src/components/TaskOverflowMenu.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { View, Text, StyleSheet, Modal, TouchableOpacity } from 'react-native'; import { useSettings } from '@/theme'; +import type { ThemeColors } from '@/theme'; import { TaskData, SubtaskData } from '@/types'; import Svg, { Path } from 'react-native-svg'; @@ -40,6 +41,7 @@ export function TaskOverflowMenu({ onAddSubtask, }: TaskOverflowMenuProps) { const { theme } = useSettings(); + const styles = React.useMemo(() => createStyles(theme), [theme]); if (!task) return null; @@ -67,7 +69,7 @@ export function TaskOverflowMenu({ if (!subtask) { actions.push({ key: 'category', - label: 'Change Category', + label: 'Change Tags', icon: , onPress: onChangeCategory ?? (() => {}), }); @@ -84,7 +86,7 @@ export function TaskOverflowMenu({ key: 'delete', label: 'Delete', destructive: true, - icon: , + icon: , onPress: onDelete, }, ); @@ -144,7 +146,7 @@ function Circle2() { ); } -const styles = StyleSheet.create({ +const createStyles = (theme: ThemeColors) => StyleSheet.create({ overlay: { flex: 1, justifyContent: 'flex-end', @@ -185,7 +187,7 @@ const styles = StyleSheet.create({ fontWeight: '500', }, destructiveText: { - color: '#E53935', + color: theme.dangerStrong, fontWeight: '600', }, }); diff --git a/carry-your-live/src/database/schema.ts b/carry-your-live/src/database/schema.ts index d5e3212..8278a26 100644 --- a/carry-your-live/src/database/schema.ts +++ b/carry-your-live/src/database/schema.ts @@ -1,7 +1,7 @@ import { appSchema, tableSchema } from '@nozbe/watermelondb'; export const schema = appSchema({ - version: 20, + version: 21, tables: [ tableSchema({ name: 'categories', @@ -45,9 +45,8 @@ export const schema = appSchema({ columns: [ { name: 'task_id', type: 'string', isIndexed: true }, { name: 'parent_subtask_id', type: 'string', isIndexed: true, isOptional: true }, - { name: 'category_id', type: 'string', isOptional: true }, - { name: 'tags', type: 'string', isOptional: true }, { name: 'category_id', type: 'string', isIndexed: true, isOptional: true }, + { name: 'tags', type: 'string', isOptional: true }, { name: 'title', type: 'string' }, { name: 'description', type: 'string', isOptional: true }, { name: 'priority', type: 'string', isOptional: true }, diff --git a/carry-your-live/src/database/sync.ts b/carry-your-live/src/database/sync.ts index 2292b53..c8f6f94 100644 --- a/carry-your-live/src/database/sync.ts +++ b/carry-your-live/src/database/sync.ts @@ -13,6 +13,7 @@ import { TombstoneEntity } from '@/models/Tombstone'; import { fetchPendingTombstones, removeTombstonesInBatch } from './tombstones'; const LAST_PULLED_AT_KEY = 'sync:lastPulledAt'; +const LAST_PUSHED_AT_KEY = 'sync:lastPushedAt'; const LAST_RUN_AT_KEY = 'sync:lastRunAt'; export interface SyncResult { @@ -55,6 +56,24 @@ async function setLastPulledAt(timestamp: number): Promise { } } +async function getLastPushedAt(): Promise { + 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 { + try { + await AsyncStorage.setItem(LAST_PUSHED_AT_KEY, String(timestamp)); + } catch { + // ignore + } +} + export async function getLastSyncTime(): Promise { try { const raw = await AsyncStorage.getItem(LAST_RUN_AT_KEY); @@ -89,8 +108,12 @@ export async function runSync(): Promise { await pruneSyncedTombstones(conflicts, pushedDeletions); - const lastPulledAt = await getLastPulledAt(); - const response = await apiFetch(`/sync?since=${lastPulledAt}`); + // Update last pushed timestamp to avoid re-pushing same changes + await setLastPushedAt(Date.now()); + + // Use current time as pull cutoff to avoid re-pulling what we just pushed + const pullSince = Date.now(); + const response = await apiFetch(`/sync?since=${pullSince}`); const data = await response.json(); const pulledCounts = await applyServerChanges(data); @@ -103,13 +126,13 @@ export async function runSync(): Promise { } 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([ - collections.tasks.query(Q.where('updated_at', Q.gt(lastPulledAt))).fetch(), - collections.subtasks.query(Q.where('updated_at', Q.gt(lastPulledAt))).fetch(), - collections.repeatProfiles.query(Q.where('updated_at', Q.gt(lastPulledAt))).fetch(), - collections.friendships.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(lastPushedAt))).fetch(), + collections.repeatProfiles.query(Q.where('updated_at', Q.gt(lastPushedAt))).fetch(), + collections.friendships.query(Q.where('updated_at', Q.gt(lastPushedAt))).fetch(), ]); const taskPayload = (t: Task) => ({ @@ -194,12 +217,12 @@ async function pushChanges(): Promise<{ conflicts: PushConflict[]; pushedDeletio if (referencedCategoryIds.size > 0) { categoryQuery = collections.categories.query( Q.or( - Q.where('updated_at', Q.gt(lastPulledAt)), + Q.where('updated_at', Q.gt(lastPushedAt)), Q.where('id', Q.oneOf(Array.from(referencedCategoryIds))) ) ); } 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(); @@ -231,7 +254,7 @@ async function pushChanges(): Promise<{ conflicts: PushConflict[]; pushedDeletio updatedAt: f.updatedAt.getTime(), })); - const pendingTombstones = await fetchPendingTombstones(lastPulledAt); + const pendingTombstones = await fetchPendingTombstones(lastPushedAt); const changedDeletions = pendingTombstones.map((t) => ({ entity: t.entity, id: t.entityId, @@ -260,7 +283,7 @@ async function pushChanges(): Promise<{ conflicts: PushConflict[]; pushedDeletio friendships: changedFriendships, }, deleted: changedDeletions, - lastPulledAt, + lastPulledAt: await getLastPulledAt(), }), }); @@ -563,6 +586,7 @@ async function upsertSubtask(row: ServerRow, force: boolean): Promise { s.taskId = String(row.taskId ?? s.taskId); 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.description = String(row.description ?? s.description); s.priority = row.priority ?? s.priority; diff --git a/carry-your-live/src/hooks/useDatabase.tsx b/carry-your-live/src/hooks/useDatabase.tsx index 138da63..1dd7973 100644 --- a/carry-your-live/src/hooks/useDatabase.tsx +++ b/carry-your-live/src/hooks/useDatabase.tsx @@ -46,12 +46,6 @@ export function DatabaseProvider({ children }: { children: ReactNode }) { for (const task of tasksToUpdate) { 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 await dup.destroyPermanently(); } diff --git a/carry-your-live/src/hooks/useTaskModals.tsx b/carry-your-live/src/hooks/useTaskModals.tsx index 7d5f71b..a2064b8 100644 --- a/carry-your-live/src/hooks/useTaskModals.tsx +++ b/carry-your-live/src/hooks/useTaskModals.tsx @@ -9,7 +9,6 @@ import { TaskOverflowMenu } from '@/components/TaskOverflowMenu'; import { TaskDeleteModal } from '@/components/TaskDeleteModal'; import { OptionPickerModal } from '@/components/OptionPickerModal'; import { - toggleTaskComplete, deleteTaskOccurrences, duplicateTask, setTaskCategory, @@ -20,6 +19,7 @@ import { setSubtaskPriority, createSubtask, TaskDeleteScope, + confirmAndToggleTaskComplete, } from '@/utils/taskActions'; 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) => { - await toggleTaskComplete(taskId); - refreshRef.current(); + await confirmAndToggleTaskComplete(taskId, () => refreshRef.current()); }, []); const handleSubtaskToggle = useCallback(async (subtask: SubtaskData) => { @@ -303,7 +302,7 @@ export function useTaskModals() { ({ value: c.id, label: c.name, color: c.color }))]} selectedValue={menuTask?.categoryId} onSelect={handleSingleCategory} diff --git a/carry-your-live/src/services/auth.ts b/carry-your-live/src/services/auth.ts index b1c8b17..211d31d 100644 --- a/carry-your-live/src/services/auth.ts +++ b/carry-your-live/src/services/auth.ts @@ -136,3 +136,38 @@ export async function register(username: string, password: string): Promise { + 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; +} diff --git a/carry-your-live/src/services/notifications.ts b/carry-your-live/src/services/notifications.ts index 50fff8c..e17baed 100644 --- a/carry-your-live/src/services/notifications.ts +++ b/carry-your-live/src/services/notifications.ts @@ -1,6 +1,6 @@ import { Platform } from 'react-native'; import AsyncStorage from '@react-native-async-storage/async-storage'; -import { Reminder, reminderToMinutes, parseReminders } from '@/types'; +import { Reminder, reminderToMinutes, parseReminders, ReminderPreference } from '@/types'; const ID_MAP_KEY = 'notifications:taskIdToNotificationIds'; @@ -30,11 +30,52 @@ function getNotificationsModule(): any { } } -export async function scheduleTaskReminder(task: any): Promise { +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): 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 globalReminder = reminderPreferenceToReminder(globalPreference); + if (globalReminder !== 'none') { + return [globalReminder]; + } + + return []; +} + +export async function scheduleTaskReminder(task: any, notificationsEnabled: boolean = true, globalReminderPreference: ReminderPreference = '15'): Promise { const notifications = await getNotificationsModule(); const dueDate = task.dueDate ? new Date(task.dueDate) : null; if (!notifications) return; + if (!notificationsEnabled) return; const idMap = await readIdMap(); @@ -51,11 +92,7 @@ export async function scheduleTaskReminder(task: any): Promise { await writeIdMap(idMap); // Determine the set of reminders to schedule - const reminders: Reminder[] = task.reminders - ? parseReminders(task.reminders) - : task.reminder && task.reminder !== 'none' - ? [task.reminder as Reminder] - : []; + const reminders = getRemindersForTask(task, globalReminderPreference); if (reminders.length === 0 || !dueDate || !task.dueTime) return; @@ -132,7 +169,24 @@ export async function requestNotificationPermission(): Promise { } } -export async function rescheduleAllReminders(tasks: any[]): Promise { +export async function cancelAllReminders(): Promise { + const notifications = await getNotificationsModule(); + if (!notifications) return; + try { + await notifications.cancelAllScheduledNotificationsAsync(); + } catch { + // ignore + } + const idMap: Record = {}; + await writeIdMap(idMap); +} + +export async function rescheduleAllReminders(tasks: any[], notificationsEnabled: boolean = true, globalReminderPreference: ReminderPreference = '15'): Promise { + if (!notificationsEnabled) { + await cancelAllReminders(); + return; + } + const notifications = await getNotificationsModule(); if (!notifications) return; try { @@ -145,11 +199,7 @@ export async function rescheduleAllReminders(tasks: any[]): Promise { for (const task of tasks) { const dueDate = task.dueDate ? new Date(task.dueDate) : null; - const reminders: Reminder[] = task.reminders - ? parseReminders(task.reminders) - : task.reminder && task.reminder !== 'none' - ? [task.reminder as Reminder] - : []; + const reminders = getRemindersForTask(task, globalReminderPreference); if (reminders.length === 0 || !dueDate || !task.dueTime) continue; diff --git a/carry-your-live/src/services/pushNotifications.ts b/carry-your-live/src/services/pushNotifications.ts new file mode 100644 index 0000000..6a9b52e --- /dev/null +++ b/carry-your-live/src/services/pushNotifications.ts @@ -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 { + 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 { + 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 { + try { + return await Notifications.getBadgeCountAsync(); + } catch { + return 0; + } +} + +export async function setBadgeCount(count: number): Promise { + try { + await Notifications.setBadgeCountAsync(count); + } catch { + // ignore + } +} + +export function handleNotificationResponse(response: Notifications.NotificationResponse): void { + const data = response.notification.request.content.data as Record | 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; + } +} \ No newline at end of file diff --git a/carry-your-live/src/theme.tsx b/carry-your-live/src/theme.tsx index d485dd6..e9ed76c 100644 --- a/carry-your-live/src/theme.tsx +++ b/carry-your-live/src/theme.tsx @@ -11,16 +11,16 @@ export const SORT_OPTIONS: { value: SortBy; label: string }[] = [ { 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 }[] = [ { value: 'none', label: 'No reminder' }, - { value: 'atTime', label: 'At due time' }, - { value: '5m', label: '5 minutes before' }, - { value: '15m', label: '15 minutes before' }, - { value: '30m', label: '30 minutes before' }, - { value: '1h', label: '1 hour before' }, - { value: '1d', label: '1 day before' }, + { value: 'at_time', label: 'At due time' }, + { value: '15', label: '15 minutes before' }, + { value: '30', label: '30 minutes before' }, + { value: '60', label: '1 hour before' }, + { value: '120', label: '2 hours before' }, + { value: '1440', label: '1 day before' }, ]; export interface ThemeColors { @@ -42,6 +42,14 @@ export interface ThemeColors { overlay: string; sheetBg: string; tabBarBg: string; + danger: string; + dangerStrong: string; + dangerSoft: string; + dangerBorder: string; + success: string; + successStrong: string; + warning: string; + info: string; } export const DEFAULT_ACCENT = '#EF5350'; @@ -110,6 +118,14 @@ export function colors(accent: string = DEFAULT_ACCENT): ThemeColors { overlay: 'rgba(0,0,0,0.6)', sheetBg: '#26282D', tabBarBg: '#16171A', + danger: '#E57373', + dangerStrong: '#E53935', + dangerSoft: '#43302E', + dangerBorder: '#573431', + success: '#66BB6A', + successStrong: '#43A047', + warning: '#FFA726', + info: '#64B5F6', }; } @@ -179,7 +195,7 @@ export function SettingsProvider({ children }: { children: ReactNode }) { const [sortBy, setSortBy] = useStoredSetting(STORAGE_KEYS.sortBy, 'date'); const [reminderPreference, setReminderPreference] = useStoredSetting( STORAGE_KEYS.reminderPreference, - '15m', + '15', ); const [apiUrl, setApiUrl] = useStoredSetting(API_URL_KEY, DEFAULT_API_BASE_URL); const [accentColor, setAccentColor] = useStoredSetting(STORAGE_KEYS.accentColor, DEFAULT_ACCENT); diff --git a/carry-your-live/src/types/index.ts b/carry-your-live/src/types/index.ts index d47df85..f5a2f77 100644 --- a/carry-your-live/src/types/index.ts +++ b/carry-your-live/src/types/index.ts @@ -1,5 +1,7 @@ export type Priority = 'none' | 'low' | 'medium' | 'high' | 'critical'; +export type { ReminderPreference } from '../theme'; + export type Repeat = 'none' | 'daily' | 'weekly' | 'monthly' | 'custom'; export const REPEAT_OPTIONS: { value: Repeat; label: string }[] = [ @@ -193,6 +195,7 @@ export interface TaskFormData { export interface SubtaskFormData { title: string; description?: string; + tags?: string[]; categoryId?: string; priority: Priority; dueDate: Date | null; diff --git a/carry-your-live/src/utils/taskActions.ts b/carry-your-live/src/utils/taskActions.ts index 5f34c92..6ae0a7f 100644 --- a/carry-your-live/src/utils/taskActions.ts +++ b/carry-your-live/src/utils/taskActions.ts @@ -1,6 +1,7 @@ import { database, collections } from '@/database'; import { Q } from '@nozbe/watermelondb'; -import { Priority, Repeat, Reminder, SubtaskData, tagsToString } from '@/types'; +import { Alert } from 'react-native'; +import { Priority, Repeat, Reminder, SubtaskData, tagsToString, parseTaskTags } from '@/types'; import { scheduleTaskReminder, cancelTaskReminder } from '@/services/notifications'; import { recordTombstonesInBatch } from '@/database/tombstones'; @@ -10,6 +11,7 @@ function mapSubtaskRow(s: any, taskId?: string): SubtaskData { taskId: s.taskId ?? taskId ?? '', parentSubtaskId: s.parentSubtaskId || null, categoryId: s.categoryId || '', + tags: s.tags || '', title: s.title, description: s.description || '', priority: (s.priority || 'none') as Priority, @@ -216,6 +218,51 @@ export async function toggleTaskComplete(taskId: string): Promise return nextOccurrence ? nextOccurrence.id : null; } +export async function countOpenSubtasks(taskId: string): Promise { + 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 { + 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 { await database.write(async () => { const subtask = await collections.subtasks.find(subtaskId); @@ -237,6 +284,7 @@ export interface SubtaskUpdateData { title: string; description?: string; categoryId?: string; + tags?: string; priority: Priority; dueDate: number; dueTime?: string; @@ -256,6 +304,7 @@ export async function updateSubtask(subtaskId: string, data: SubtaskUpdateData): s.title = data.title.trim(); 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.dueDate = data.dueDate; s.dueTime = data.dueTime || ''; @@ -318,6 +367,7 @@ export async function duplicateSubtask(subtaskId: string): Promise { clone = await collections.subtasks.create((s) => { s.taskId = subtask.taskId; s.categoryId = subtask.categoryId || ''; + s.tags = subtask.tags || ''; s.title = subtask.title; s.description = subtask.description; s.priority = subtask.priority; @@ -472,12 +522,13 @@ export async function convertSubtaskToTask(subtaskId: string): Promise { const subtask = await collections.subtasks.find(subtaskId); const parent = await collections.tasks.find(subtask.taskId); const now = new Date(); + const subTags = parseTaskTags(subtask.tags, subtask.categoryId); const task = await collections.tasks.create((t) => { t.title = subtask.title; t.description = subtask.description || ''; - t.categoryId = subtask.categoryId || parent.categoryId || ''; - t.tags = tagsToString([subtask.categoryId || parent.categoryId].filter(Boolean)); + t.categoryId = subTags[0] || parent.categoryId || ''; + t.tags = tagsToString(subTags.length > 0 ? subTags : (parent.categoryId ? [parent.categoryId] : [])); t.priority = subtask.priority; t.completed = subtask.completed; t.dueDate = subtask.dueDate; @@ -650,6 +701,8 @@ async function createNextOccurrence(task: any, seriesId: string): Promise { s.taskId = next.id; s.title = subtask.title; s.description = subtask.description; + s.categoryId = subtask.categoryId || ''; + s.tags = subtask.tags || ''; s.priority = subtask.priority; s.completed = false; s.dueDate = subtask.dueDate; @@ -755,6 +808,7 @@ export interface CreateSubtaskData { taskId: string; parentSubtaskId?: string | null; categoryId?: string; + tags?: string; title: string; description?: string; priority?: Priority; @@ -782,6 +836,7 @@ export async function createSubtask(data: CreateSubtaskData): Promise { s.taskId = data.taskId; s.parentSubtaskId = data.parentSubtaskId || null; s.categoryId = data.categoryId || ''; + s.tags = data.tags || ''; s.title = data.title.trim(); s.description = data.description || ''; s.priority = data.priority || 'none'; diff --git a/creds.txt b/creds.txt index f733e01..fc085e5 100644 --- a/creds.txt +++ b/creds.txt @@ -1,6 +1,2 @@ -tech -osuhhgoshghgjs - - -test -SFGHDHTHHDHRERTHEH \ No newline at end of file +tech08mag +7BXtaqyvwVB15W diff --git a/todo.md b/todo.md deleted file mode 100644 index 045c2f7..0000000 --- a/todo.md +++ /dev/null @@ -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)