Compare commits
15
Commits
4c3d1a118c
..
v1.0.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a5b5a68e4 | ||
|
|
87e3f59ee1 | ||
|
|
80663f7d39 | ||
|
|
6969e79029 | ||
|
|
df97937948 | ||
|
|
ed8b406b7d | ||
|
|
e07d28d6b1 | ||
|
|
ab26148a67 | ||
|
|
56e6225dd1 | ||
|
|
bdc6562116 | ||
|
|
9312783793 | ||
|
|
b78ada0865 | ||
|
|
be2f77dbf1 | ||
|
|
2ca23e276f | ||
|
|
f3bcd78e49 |
@@ -0,0 +1,38 @@
|
||||
{"_e":"root:init","_t":1787490823136,"format":"v0-jsonl","version":"57.0.12"}
|
||||
{"_e":"env:mode","_t":1787490823267,"nodeEnv":"production","babelEnv":"production","mode":"production"}
|
||||
{"_e":"env:load","_t":1787490823269,"mode":"production","files":[],"env":{}}
|
||||
{"_e":"devserver:start","_t":1787490823283,"mode":"production","web":true,"baseUrl":"","asyncRoutes":false,"routerRoot":"../../../app","serverComponents":false,"serverActions":false,"serverRendering":false,"apiRoutes":false,"exporting":true}
|
||||
{"_e":"metro:config","_t":1787490823513,"serverRoot":"../../..","projectRoot":"../../..","exporting":true,"flags":{"autolinkingModuleResolution":false,"serverActions":false,"serverComponents":false,"reactCompiler":false,"optimizeGraph":false,"treeshaking":false,"logbox":false}}
|
||||
{"_e":"metro:instantiate","_t":1787490823624,"atlas":false,"workers":10,"host":null,"port":null}
|
||||
{"_e":"metro:bundling:started","_t":1787490824135,"id":"1","platform":"web","environment":null,"entry":"node_modules/expo-router/entry.js"}
|
||||
{"_e":"root:init","_t":1787490834893,"format":"v0-jsonl","version":"57.0.12"}
|
||||
{"_e":"env:mode","_t":1787490834983,"nodeEnv":"production","babelEnv":"production","mode":"production"}
|
||||
{"_e":"env:load","_t":1787490834985,"mode":"production","files":[],"env":{}}
|
||||
{"_e":"devserver:start","_t":1787490834998,"mode":"production","web":true,"baseUrl":"","asyncRoutes":false,"routerRoot":"../../../app","serverComponents":false,"serverActions":false,"serverRendering":false,"apiRoutes":false,"exporting":true}
|
||||
{"_e":"metro:config","_t":1787490835165,"serverRoot":"../../..","projectRoot":"../../..","exporting":true,"flags":{"autolinkingModuleResolution":false,"serverActions":false,"serverComponents":false,"reactCompiler":false,"optimizeGraph":false,"treeshaking":false,"logbox":false}}
|
||||
{"_e":"metro:instantiate","_t":1787490835236,"atlas":false,"workers":10,"host":null,"port":null}
|
||||
{"_e":"metro:bundling:started","_t":1787490835782,"id":"1","platform":"web","environment":null,"entry":"node_modules/expo-router/entry.js"}
|
||||
{"_e":"root:init","_t":1787490897618,"format":"v0-jsonl","version":"57.0.12"}
|
||||
{"_e":"env:mode","_t":1787490897716,"nodeEnv":"production","babelEnv":"production","mode":"production"}
|
||||
{"_e":"env:load","_t":1787490897718,"mode":"production","files":[],"env":{}}
|
||||
{"_e":"devserver:start","_t":1787490897732,"mode":"production","web":true,"baseUrl":"","asyncRoutes":false,"routerRoot":"../../../app","serverComponents":false,"serverActions":false,"serverRendering":false,"apiRoutes":false,"exporting":true}
|
||||
{"_e":"metro:config","_t":1787490897904,"serverRoot":"../../..","projectRoot":"../../..","exporting":true,"flags":{"autolinkingModuleResolution":false,"serverActions":false,"serverComponents":false,"reactCompiler":false,"optimizeGraph":false,"treeshaking":false,"logbox":false}}
|
||||
{"_e":"metro:instantiate","_t":1787490897978,"atlas":false,"workers":10,"host":null,"port":null}
|
||||
{"_e":"metro:bundling:started","_t":1787490898645,"id":"1","platform":"web","environment":null,"entry":"node_modules/expo-router/entry.js"}
|
||||
{"_e":"metro:bundling:done","_t":1787490904477,"id":"1","total":743,"ms":5831.675237}
|
||||
{"_e":"root:init","_t":1787490907952,"format":"v0-jsonl","version":"57.0.12"}
|
||||
{"_e":"env:mode","_t":1787490908055,"nodeEnv":"production","babelEnv":"production","mode":"production"}
|
||||
{"_e":"env:load","_t":1787490908056,"mode":"production","files":[],"env":{}}
|
||||
{"_e":"devserver:start","_t":1787490908069,"mode":"production","web":true,"baseUrl":"","asyncRoutes":false,"routerRoot":"../../../app","serverComponents":false,"serverActions":false,"serverRendering":false,"apiRoutes":false,"exporting":true}
|
||||
{"_e":"metro:config","_t":1787490908244,"serverRoot":"../../..","projectRoot":"../../..","exporting":true,"flags":{"autolinkingModuleResolution":false,"serverActions":false,"serverComponents":false,"reactCompiler":false,"optimizeGraph":false,"treeshaking":false,"logbox":false}}
|
||||
{"_e":"metro:instantiate","_t":1787490908323,"atlas":false,"workers":10,"host":null,"port":null}
|
||||
{"_e":"metro:bundling:started","_t":1787490908955,"id":"1","platform":"web","environment":null,"entry":"node_modules/expo-router/entry.js"}
|
||||
{"_e":"metro:bundling:done","_t":1787490909421,"id":"1","total":731,"ms":465.745099}
|
||||
{"_e":"root:init","_t":1787490971753,"format":"v0-jsonl","version":"57.0.12"}
|
||||
{"_e":"env:mode","_t":1787490972145,"nodeEnv":"production","babelEnv":"production","mode":"production"}
|
||||
{"_e":"env:load","_t":1787490972146,"mode":"production","files":[],"env":{}}
|
||||
{"_e":"devserver:start","_t":1787490972158,"mode":"production","web":true,"baseUrl":"","asyncRoutes":false,"routerRoot":"../../../app","serverComponents":false,"serverActions":false,"serverRendering":false,"apiRoutes":false,"exporting":true}
|
||||
{"_e":"metro:config","_t":1787490972351,"serverRoot":"../../..","projectRoot":"../../..","exporting":true,"flags":{"autolinkingModuleResolution":false,"serverActions":false,"serverComponents":false,"reactCompiler":false,"optimizeGraph":false,"treeshaking":false,"logbox":false}}
|
||||
{"_e":"metro:instantiate","_t":1787490972454,"atlas":false,"workers":10,"host":null,"port":null}
|
||||
{"_e":"metro:bundling:started","_t":1787490973074,"id":"1","platform":"web","environment":null,"entry":"node_modules/expo-router/entry.js"}
|
||||
{"_e":"metro:bundling:done","_t":1787490973517,"id":"1","total":743,"ms":441.956684}
|
||||
@@ -29,15 +29,22 @@ jobs:
|
||||
|
||||
- name: Install Android SDK
|
||||
run: |
|
||||
mkdir -p "$ANDROID_HOME/cmdline-tools"
|
||||
wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -O /tmp/cmdtools.zip
|
||||
unzip -q /tmp/cmdtools.zip -d "$ANDROID_HOME/cmdline-tools"
|
||||
mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest"
|
||||
yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses > /dev/null
|
||||
if [ -x "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" ]; then
|
||||
echo "Android SDK already present in cache"
|
||||
else
|
||||
rm -rf "$ANDROID_HOME/cmdline-tools"
|
||||
mkdir -p "$ANDROID_HOME/cmdline-tools"
|
||||
wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -O /tmp/cmdtools.zip
|
||||
unzip -q /tmp/cmdtools.zip -d "$ANDROID_HOME/cmdline-tools"
|
||||
mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest"
|
||||
fi
|
||||
printf 'y\n' | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses > /dev/null
|
||||
"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" \
|
||||
"platform-tools" \
|
||||
"platforms;android-35" \
|
||||
"platforms;android-36" \
|
||||
"build-tools;35.0.0" \
|
||||
"build-tools;36.0.0" \
|
||||
"ndk;27.1.12297006"
|
||||
|
||||
- name: Install JS dependencies
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
name: Build Desktop (deb)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install JS dependencies
|
||||
working-directory: carry-your-live
|
||||
run: npm ci
|
||||
|
||||
- name: Export web bundle
|
||||
working-directory: carry-your-live
|
||||
run: npx expo export --platform web
|
||||
|
||||
- name: Build deb package
|
||||
working-directory: carry-your-live
|
||||
run: npx electron-builder --linux deb --config electron-builder.config.js
|
||||
|
||||
- name: Upload deb
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: carry-your-live-desktop-deb
|
||||
path: carry-your-live/desktop-build/*.deb
|
||||
@@ -0,0 +1,56 @@
|
||||
name: release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Compute next version
|
||||
id: version
|
||||
run: |
|
||||
latest=$(git tag --list 'v[0-9]*' --sort=-v:refname | head -n 1)
|
||||
if [ -z "$latest" ]; then
|
||||
next="v1.0.0"
|
||||
else
|
||||
latest=${latest#v}
|
||||
IFS='.' read -r major minor patch <<< "$latest"
|
||||
next="v${major}.${minor}.$((patch + 1))"
|
||||
fi
|
||||
echo "next=${next}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build changelog
|
||||
id: changelog
|
||||
run: |
|
||||
prev_tag=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
|
||||
if [ -z "$prev_tag" ]; then
|
||||
log=$(git log --oneline --no-merges main | head -n 40)
|
||||
else
|
||||
log=$(git log --oneline --no-merges "$prev_tag"..main | head -n 40)
|
||||
fi
|
||||
{
|
||||
echo "body<<EOF"
|
||||
echo "## What's new in this release"
|
||||
echo ""
|
||||
echo "$log" | sed 's/^/- /'
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ steps.version.outputs.next }}
|
||||
name: ${{ steps.version.outputs.next }}
|
||||
body: ${{ steps.changelog.outputs.body }}
|
||||
generate_release_notes: false
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
# Carry Your Live — Roadmap & Feature List
|
||||
|
||||
Status as of Aug 2026. Based on code audit of frontend (`carry-your-live/`), backend (`backend/`), and `API_SPEC.md`.
|
||||
|
||||
## Legend
|
||||
- ✅ Implemented
|
||||
- 🟡 Partial / stubbed
|
||||
- ❌ Missing
|
||||
|
||||
---
|
||||
|
||||
## Current State (Feature List)
|
||||
|
||||
### Core Task Management
|
||||
- ✅ Tasks CRUD with title, description, category, priority, due date/time, all-day
|
||||
- ✅ Nested subtasks (sub-subtask trees), convert task↔subtask, re-parent, reorder
|
||||
- ✅ Categories/tags with colors (task color derives from category)
|
||||
- ✅ Priorities (None → Critical) with badges
|
||||
- ✅ Repeating/recurring tasks: repeat profiles, series IDs, this/future/all delete scopes, undo-complete
|
||||
- ✅ Overdue / due-today highlighting
|
||||
|
||||
### Screens & Navigation
|
||||
- ✅ Tabs: Todo, Calendar, Stats, Settings (+ Add Task, Day View, Task Detail, Subtask Detail stack screens)
|
||||
- ✅ Category filter chips, swipe actions, long-press multi-select, drag-to-reorder
|
||||
- ✅ Quick-add bar, calendar date strip, analog clock widget
|
||||
- ✅ Stats screen (client-side `useStats`)
|
||||
|
||||
### Sync & Accounts
|
||||
- ✅ Offline-first WatermelonDB (schema v21) + full sync engine (tombstones, LWW)
|
||||
- ✅ Backend: Express + Drizzle + Postgres — auth (register/login/me/forgot/reset), sync pull/push, tasks, subtasks, categories, repeat profiles, user settings, user search
|
||||
- ✅ Login/register/sign-out UI (`SyncModal`)
|
||||
|
||||
### Social
|
||||
- ✅ Friends: request/accept/decline/remove, user search, full backend routes
|
||||
- ✅ Assignee field on tasks/subtasks (schema v7)
|
||||
|
||||
### Settings (wired up)
|
||||
- ✅ Default tag, sort order, show-completed, calendar days-ahead, accent color picker, server URL override
|
||||
|
||||
---
|
||||
|
||||
## Gaps (what's broken or stubbed)
|
||||
|
||||
| Item | Status | Where |
|
||||
|---|---|---|
|
||||
| Light mode / theme toggle | ❌ Dark-only, hard-coded palette; backend has `darkMode` column unused | `src/theme.tsx:92` |
|
||||
| Notifications toggle | 🟡 Stored but never read; toggling off doesn't disable reminders | `settings.tsx:148`, `src/services/notifications.ts` |
|
||||
| Global reminder preference | 🟡 Stored but never applied when scheduling | `src/theme.tsx:180` |
|
||||
| Forgot/reset password UI | 🟡 Backend done, no client UI | `backend/src/routes/auth.ts:149` |
|
||||
| Pull-to-refresh | 🟡 Reloads local queries only, doesn't trigger server sync | `TaskList.tsx:129` |
|
||||
| Client settings sync | 🟡 All settings AsyncStorage-only; `PATCH /users/me/settings` never called | — |
|
||||
| Reminder enum mismatch | 🟡 Spec uses minutes (`'15'`,`'30'`…), client uses `'5m'`,`'1h'` style | `API_SPEC.md` vs client |
|
||||
| Webhooks / rate-limit headers | 🟡 In spec, not built | `API_SPEC.md` |
|
||||
|
||||
---
|
||||
|
||||
## Roadmap (by release)
|
||||
|
||||
> Last released: **v1.0.0** (2026-08-05). The `Unreleased` changelog section already contains the friends feature, assignee field, and various fixes.
|
||||
|
||||
---
|
||||
|
||||
### v1.1.0 — Friends & Assignees (staged; ship current `Unreleased` + stub fixes)
|
||||
|
||||
Ships what's already merged plus fixes to half-wired features so nothing in the release is a dead toggle.
|
||||
|
||||
- 📦 Release existing `Unreleased` changes: friends feature, assignee field (schema v7), QuickAddBar on overview, sync fixes for repeat/reminders
|
||||
- Fix notifications toggle so it's actually respected (`src/services/notifications.ts`); wire up `rescheduleAllReminders()`
|
||||
- Apply global reminder preference when scheduling task reminders
|
||||
- Make pull-to-refresh run a real server sync cycle (`TaskList.tsx:129`)
|
||||
- Add forgot/reset password UI (backend routes already exist: `backend/src/routes/auth.ts:149`)
|
||||
- Unify reminder offset enums between client, `API_SPEC.md`, and backend
|
||||
|
||||
**Theme:** "Everything you can toggle does something."
|
||||
|
||||
---
|
||||
|
||||
### v1.2.0 — Theming & Findability
|
||||
|
||||
- Light mode + follow-system-theme option; theme-mode setting synced via `/users/me/settings` (backend `darkMode` column already exists)
|
||||
- Sync all client settings to `PATCH /users/me/settings` instead of AsyncStorage-only
|
||||
- Task search: filter-by-text in list + global search screen
|
||||
- Friend-request badges/indicators in main UI (polling-based)
|
||||
- Resolve API error-format drift between spec and auth routes
|
||||
|
||||
**Theme:** "Look good, find anything."
|
||||
|
||||
---
|
||||
|
||||
### v1.3.0 — Stats & Insights
|
||||
|
||||
- Persisted stats snapshots for history/trends over time (current stats are per-local-data only)
|
||||
- Productivity trends: completion rates by category/priority, streaks
|
||||
- Export stats (CSV/image share)
|
||||
|
||||
**Theme:** "Show me how I'm doing."
|
||||
|
||||
---
|
||||
|
||||
### v1.4.0 — Quick Capture & Calendar UX
|
||||
|
||||
Client-side only; no backend changes required.
|
||||
|
||||
- Natural language quick-add ("pay rent tomorrow 5pm !finance" → parsed fields) in QuickAddBar
|
||||
- Snooze/postpone gesture on overdue tasks
|
||||
- Drag-and-drop rescheduling between days in calendar
|
||||
- Month grid view alongside the date strip
|
||||
- "Today at a glance" dashboard combining overdue, today, and stats
|
||||
- Time estimates + daily planned-vs-free-time indicator
|
||||
|
||||
**Theme:** "Capture in seconds."
|
||||
|
||||
---
|
||||
|
||||
### v1.5.0 — Power Tools & Personalization
|
||||
|
||||
- Task templates / saved routines (builds on repeat profiles)
|
||||
- Soft-archive with search (archive instead of delete)
|
||||
- Full local JSON export/import backup
|
||||
- Attachments/links/photos on tasks
|
||||
- Location-based reminders (expo-location)
|
||||
- Markdown support in descriptions
|
||||
- Deep links (`carryyourlive://task/<id>`) for shortcuts and notifications
|
||||
- Undo toast for all destructive actions (extend existing complete-undo)
|
||||
- Biometric lock / privacy blur on app switch
|
||||
- Haptics settings + reduced-motion mode
|
||||
- Custom app icon / theme presets beyond accent color
|
||||
- Onboarding flow + sample data seeding
|
||||
- Android home-screen widget / week agenda widget
|
||||
- import calendar from other apps
|
||||
|
||||
**Theme:** "Make it yours, make it fast."
|
||||
|
||||
---
|
||||
|
||||
### v2.0.0 — Collaboration
|
||||
|
||||
Breaking-ish social features; requires backend schema changes.
|
||||
|
||||
- Real task assignment delivery: shared-list or assignment mechanism (rows are currently strictly user-scoped — assigneeId goes nowhere)
|
||||
- Shared lists / collaborative categories with permissions
|
||||
- Server push notifications (FCM/APNs): cross-device reminders, friend requests, assignments
|
||||
- Comments/activity feed on assigned tasks
|
||||
- Shared category templates ("family shopping list")
|
||||
- Reactions/acknowledgments on assignments
|
||||
|
||||
**Theme:** "Carry it together."
|
||||
|
||||
---
|
||||
|
||||
### v2.1.0 — Platform & Ops
|
||||
|
||||
- Webhooks (`POST /webhooks/sync`) + documented rate limiting per `API_SPEC.md`
|
||||
- Account management: email field, account deletion
|
||||
- Release hardening: OTA update flow validation, EAS builds for iOS/Android
|
||||
|
||||
**Theme:** "Production-grade."
|
||||
Generated
+176
-2
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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`),
|
||||
});
|
||||
|
||||
@@ -44,6 +46,7 @@ export const tasks = pgTable('tasks', {
|
||||
id: text('id').primaryKey(),
|
||||
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||
categoryId: text('category_id').references(() => categories.id, { onDelete: 'cascade' }),
|
||||
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'),
|
||||
@@ -84,6 +87,8 @@ export const subtasks = pgTable('subtasks', {
|
||||
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||
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'),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
@@ -82,6 +82,8 @@ router.post('/task/:taskId', asyncHandler(async (req: Request, res: Response) =>
|
||||
userId,
|
||||
taskId: req.params.taskId,
|
||||
parentSubtaskId,
|
||||
categoryId: data.categoryId ?? null,
|
||||
tags: data.tags ?? '',
|
||||
title: data.title,
|
||||
description: data.description ?? '',
|
||||
priority: data.priority ?? 'none',
|
||||
|
||||
+36
-46
@@ -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();
|
||||
|
||||
@@ -235,6 +222,7 @@ router.post('/push', asyncHandler(async (req: Request, res: Response) => {
|
||||
title: task.title,
|
||||
description: task.description,
|
||||
categoryId: task.categoryId,
|
||||
tags: task.tags ?? '',
|
||||
priority: task.priority,
|
||||
completed: task.completed,
|
||||
dueDate: task.dueDate,
|
||||
@@ -290,6 +278,8 @@ router.post('/push', asyncHandler(async (req: Request, res: Response) => {
|
||||
.set({
|
||||
taskId: sub.taskId,
|
||||
parentSubtaskId: sub.parentSubtaskId ?? null,
|
||||
categoryId: sub.categoryId ?? null,
|
||||
tags: sub.tags ?? '',
|
||||
title: sub.title,
|
||||
description: sub.description ?? '',
|
||||
priority: sub.priority ?? 'none',
|
||||
|
||||
@@ -135,6 +135,7 @@ router.post('/', asyncHandler(async (req: Request, res: Response) => {
|
||||
id: taskId,
|
||||
userId,
|
||||
categoryId: data.categoryId,
|
||||
tags: data.tags ?? '',
|
||||
title: data.title,
|
||||
description: data.description ?? '',
|
||||
priority: data.priority ?? 'none',
|
||||
@@ -159,6 +160,8 @@ router.post('/', asyncHandler(async (req: Request, res: Response) => {
|
||||
userId,
|
||||
taskId,
|
||||
title: st.title,
|
||||
categoryId: (st as any).categoryId ?? null,
|
||||
tags: (st as any).tags ?? '',
|
||||
completed: false,
|
||||
order: index,
|
||||
createdAt: now,
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
const EXPO_PUSH_URL = 'https://exp.host/--/api/v2/push/send';
|
||||
|
||||
export interface PushNotificationPayload {
|
||||
to: string;
|
||||
title: string;
|
||||
body: string;
|
||||
data?: Record<string, any>;
|
||||
sound?: string;
|
||||
badge?: number;
|
||||
priority?: 'default' | 'normal' | 'high';
|
||||
channelId?: string;
|
||||
}
|
||||
|
||||
export interface PushTicket {
|
||||
id: string;
|
||||
status: 'ok' | 'error';
|
||||
message?: string;
|
||||
details?: {
|
||||
error?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PushReceipt {
|
||||
status: 'ok' | 'error';
|
||||
message?: string;
|
||||
details?: {
|
||||
error?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ExpoPushResponse {
|
||||
data?: PushTicket[] | Record<string, PushReceipt>;
|
||||
}
|
||||
|
||||
export async function sendPushNotification(payload: PushNotificationPayload): Promise<PushTicket> {
|
||||
const response = await fetch(EXPO_PUSH_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const result = (await response.json()) as ExpoPushResponse;
|
||||
if (result.data && Array.isArray(result.data)) {
|
||||
return result.data[0] ?? { id: '', status: 'error', message: 'No ticket returned' };
|
||||
}
|
||||
return { id: '', status: 'error', message: 'No ticket returned' };
|
||||
}
|
||||
|
||||
export async function sendPushNotifications(payloads: PushNotificationPayload[]): Promise<PushTicket[]> {
|
||||
const chunks: PushNotificationPayload[][] = [];
|
||||
for (let i = 0; i < payloads.length; i += 100) {
|
||||
chunks.push(payloads.slice(i, i + 100));
|
||||
}
|
||||
|
||||
const tickets: PushTicket[] = [];
|
||||
for (const chunk of chunks) {
|
||||
const response = await fetch(EXPO_PUSH_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify(chunk),
|
||||
});
|
||||
const result = (await response.json()) as ExpoPushResponse;
|
||||
if (result.data && Array.isArray(result.data)) {
|
||||
tickets.push(...result.data);
|
||||
}
|
||||
}
|
||||
return tickets;
|
||||
}
|
||||
|
||||
export async function getPushReceipts(ticketIds: string[]): Promise<PushReceipt[]> {
|
||||
if (ticketIds.length === 0) return [];
|
||||
|
||||
const response = await fetch(`${EXPO_PUSH_URL}/receipts`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ ids: ticketIds }),
|
||||
});
|
||||
|
||||
const result = (await response.json()) as ExpoPushResponse;
|
||||
const receipts: PushReceipt[] = [];
|
||||
if (result.data && !Array.isArray(result.data)) {
|
||||
for (const id of ticketIds) {
|
||||
receipts.push(result.data[id] ?? { status: 'error', message: 'No receipt found' });
|
||||
}
|
||||
}
|
||||
return receipts;
|
||||
}
|
||||
|
||||
export function isExpoPushToken(token: string): boolean {
|
||||
return token.startsWith('ExponentPushToken[') || token.startsWith('ExpoPushToken[');
|
||||
}
|
||||
|
||||
export function createTaskReminderPayload(
|
||||
pushToken: string,
|
||||
taskTitle: string,
|
||||
dueTime: string,
|
||||
taskId: string,
|
||||
repeat?: string
|
||||
): PushNotificationPayload {
|
||||
return {
|
||||
to: pushToken,
|
||||
title: taskTitle,
|
||||
body: `Due ${dueTime}${repeat && repeat !== 'none' ? ' · repeating' : ''}`,
|
||||
data: { type: 'task_reminder', taskId },
|
||||
sound: 'default',
|
||||
priority: 'high',
|
||||
channelId: 'task-reminders',
|
||||
};
|
||||
}
|
||||
|
||||
export function createFriendRequestPayload(
|
||||
pushToken: string,
|
||||
fromUsername: string,
|
||||
friendshipId: string
|
||||
): PushNotificationPayload {
|
||||
return {
|
||||
to: pushToken,
|
||||
title: 'New Friend Request',
|
||||
body: `${fromUsername} wants to be your friend`,
|
||||
data: { type: 'friend_request', friendshipId },
|
||||
sound: 'default',
|
||||
priority: 'high',
|
||||
channelId: 'friend-requests',
|
||||
};
|
||||
}
|
||||
|
||||
export function createTaskAssignmentPayload(
|
||||
pushToken: string,
|
||||
taskTitle: string,
|
||||
fromUsername: string,
|
||||
taskId: string
|
||||
): PushNotificationPayload {
|
||||
return {
|
||||
to: pushToken,
|
||||
title: 'Task Assigned',
|
||||
body: `${fromUsername} assigned you "${taskTitle}"`,
|
||||
data: { type: 'task_assignment', taskId },
|
||||
sound: 'default',
|
||||
priority: 'high',
|
||||
channelId: 'task-assignments',
|
||||
};
|
||||
}
|
||||
@@ -39,6 +39,7 @@ export const taskCreateSchema = z.object({
|
||||
title: z.string().min(1).max(100),
|
||||
description: z.string().max(1000).optional(),
|
||||
categoryId: z.string().max(100).transform((v) => (v === '' ? null : v)).nullable(),
|
||||
tags: z.string().max(500).optional(),
|
||||
priority: z.enum(['none', 'low', 'medium', 'high', 'critical']).optional(),
|
||||
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 ?? ''),
|
||||
@@ -52,13 +53,14 @@ export const taskCreateSchema = z.object({
|
||||
reminders: z.string().max(100).optional(),
|
||||
assigneeId: z.string().nullable().optional(),
|
||||
completedAt: z.number().int().min(0).nullable().optional(),
|
||||
subtasks: z.array(z.object({ title: z.string().min(1).max(100) })).optional(),
|
||||
subtasks: z.array(z.object({ title: z.string().min(1).max(100), categoryId: z.string().max(100).nullable().optional().transform((v) => (v === '' ? null : v)) })).optional(),
|
||||
});
|
||||
|
||||
export const taskUpdateSchema = z.object({
|
||||
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(),
|
||||
@@ -72,6 +74,8 @@ export const taskUpdateSchema = z.object({
|
||||
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 ?? ''),
|
||||
@@ -91,6 +95,8 @@ export const subtaskCreateSchema = z.object({
|
||||
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(),
|
||||
|
||||
@@ -7,6 +7,9 @@ node_modules/
|
||||
.expo/
|
||||
dist/
|
||||
web-build/
|
||||
|
||||
# Electron
|
||||
desktop-build/
|
||||
expo-env.d.ts
|
||||
|
||||
# Native
|
||||
|
||||
@@ -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() {
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
tabBarActiveTintColor: theme.accent,
|
||||
tabBarInactiveTintColor: theme.text,
|
||||
tabBarInactiveTintColor: theme.textSecondary,
|
||||
tabBarStyle: {
|
||||
backgroundColor: theme.tabBarBg,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: theme.border,
|
||||
height: 68 + insets.bottom,
|
||||
borderTopWidth: 0,
|
||||
height: 84 + insets.bottom,
|
||||
paddingBottom: insets.bottom,
|
||||
paddingTop: 6,
|
||||
elevation: 8,
|
||||
paddingTop: 12,
|
||||
paddingHorizontal: 16,
|
||||
elevation: 0,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: -2 },
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 8,
|
||||
shadowOffset: { width: 0, height: -4 },
|
||||
shadowOpacity: 0.12,
|
||||
shadowRadius: 20,
|
||||
},
|
||||
tabBarItemStyle: {
|
||||
paddingVertical: 0,
|
||||
@@ -33,10 +34,54 @@ export default function TabLayout() {
|
||||
tabBarLabelStyle: {
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
marginTop: 4,
|
||||
},
|
||||
headerShown: false,
|
||||
sceneStyle: { backgroundColor: theme.background },
|
||||
}}
|
||||
tabBar={(props) => {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.tabBarContainer,
|
||||
{
|
||||
backgroundColor: theme.tabBarBg,
|
||||
paddingBottom: Math.max(props.insets.bottom, 8),
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: -4 },
|
||||
shadowOpacity: 0.12,
|
||||
shadowRadius: 20,
|
||||
elevation: 8,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{props.navigation.getState().routes.map((route) => {
|
||||
const focused = props.navigation.getState().index === props.navigation.getState().routes.indexOf(route);
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={route.name}
|
||||
style={[
|
||||
styles.tabItem,
|
||||
focused && styles.tabItemActive,
|
||||
{ backgroundColor: focused ? theme.accentSoft : 'transparent' },
|
||||
]}
|
||||
onPress={() => props.navigation.navigate(route.name as never)}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={route.name === 'index' ? 'ToDo' : route.name.charAt(0).toUpperCase() + route.name.slice(1)}
|
||||
accessibilityState={{ selected: focused }}
|
||||
>
|
||||
<TabBarIcon
|
||||
name={route.name === 'index' ? 'checklist' : route.name === 'calendar' ? 'calendar' : route.name === 'stats' ? 'stats' : 'gear'}
|
||||
focused={focused}
|
||||
color={focused ? theme.accent : theme.textSecondary}
|
||||
size={24}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}}
|
||||
screenListeners={({ route }) => ({
|
||||
focus: () => {
|
||||
if (route.name !== 'settings') {
|
||||
@@ -45,42 +90,35 @@ export default function TabLayout() {
|
||||
},
|
||||
})}
|
||||
>
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: 'ToDo',
|
||||
tabBarIcon: ({ color, focused }) => (
|
||||
<TabBarIcon name="checklist" focused={focused} color={color} size={26} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="calendar"
|
||||
options={{
|
||||
title: 'Calendar',
|
||||
tabBarIcon: ({ color, focused }) => (
|
||||
<TabBarIcon name="calendar" focused={focused} color={color} size={26} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="stats"
|
||||
options={{
|
||||
title: 'Stats',
|
||||
tabBarIcon: ({ color, focused }) => (
|
||||
<TabBarIcon name="stats" focused={focused} color={color} size={26} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="settings"
|
||||
options={{
|
||||
title: 'Settings',
|
||||
tabBarIcon: ({ color, focused }) => (
|
||||
<TabBarIcon name="gear" focused={focused} color={color} size={26} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen name="index" />
|
||||
<Tabs.Screen name="calendar" />
|
||||
<Tabs.Screen name="stats" />
|
||||
<Tabs.Screen name="settings" />
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
tabBarContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-around',
|
||||
paddingTop: 10,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
tabItem: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 8,
|
||||
borderRadius: 16,
|
||||
minWidth: 70,
|
||||
},
|
||||
tabItemActive: {
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 8,
|
||||
elevation: 4,
|
||||
},
|
||||
});
|
||||
@@ -1,19 +1,18 @@
|
||||
import React, { useMemo, useRef, useState, useCallback, useEffect } from 'react';
|
||||
import { View, Text, StyleSheet, SafeAreaView, ScrollView, TouchableOpacity, Animated, Dimensions, BackHandler, KeyboardAvoidingView } from 'react-native';
|
||||
import { View, Text, StyleSheet, SafeAreaView, ScrollView, TouchableOpacity, Animated, Dimensions, KeyboardAvoidingView } from 'react-native';
|
||||
import { useRouter, useFocusEffect } from 'expo-router';
|
||||
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
||||
import { Header } from '@/components/Header';
|
||||
import { useTasksByDate, useTasksInMonth } from '@/hooks/useTasks';
|
||||
import { useSubtasks } from '@/hooks/useSubtasks';
|
||||
import { useTaskModals } from '@/hooks/useTaskModals';
|
||||
import { useSettings } from '@/theme';
|
||||
import { useCategories, useDatabase } from '@/hooks/useDatabase';
|
||||
import { toggleTaskComplete } from '@/utils/taskActions';
|
||||
import { useCategories } from '@/hooks/useDatabase';
|
||||
import { confirmAndToggleTaskComplete } from '@/utils/taskActions';
|
||||
import { QuickAddBar } from '@/components/QuickAddBar';
|
||||
import { OptionPickerModal } from '@/components/OptionPickerModal';
|
||||
import { SubtaskData } from '@/types';
|
||||
import Task from '@/models/Task';
|
||||
import { format, addMonths, addDays, startOfMonth, isSameDay, isSameMonth, isToday } from 'date-fns';
|
||||
import { Q } from '@nozbe/watermelondb';
|
||||
import Svg, { Path, Circle } from 'react-native-svg';
|
||||
import { desaturate } from '@/theme';
|
||||
import type { ThemeColors } from '@/theme';
|
||||
@@ -28,7 +27,7 @@ const MONTH_NAMES = [
|
||||
export default function CalendarScreen() {
|
||||
const router = useRouter();
|
||||
const { theme } = useSettings();
|
||||
const { collections } = useDatabase();
|
||||
const styles = React.useMemo(() => createStyles(theme), [theme]);
|
||||
const categories = useCategories();
|
||||
const { modals, openTaskEdit } = useTaskModals();
|
||||
|
||||
@@ -36,26 +35,33 @@ export default function CalendarScreen() {
|
||||
const [selectedDate, setSelectedDate] = useState(() => new Date());
|
||||
const [monthPickerVisible, setMonthPickerVisible] = useState(false);
|
||||
const [yearPickerVisible, setYearPickerVisible] = useState(false);
|
||||
const [subtasksMap, setSubtasksMap] = useState<Record<string, SubtaskData[]>>({});
|
||||
const { map: subtasksByTask, refresh: refreshSubtasks } = useSubtasks();
|
||||
|
||||
const visibleMonthRef = useRef(visibleMonth);
|
||||
visibleMonthRef.current = visibleMonth;
|
||||
const selectedDateRef = useRef(selectedDate);
|
||||
selectedDateRef.current = selectedDate;
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
const sub = BackHandler.addEventListener('hardwareBackPress', () => true);
|
||||
return () => sub.remove();
|
||||
}, [])
|
||||
);
|
||||
|
||||
const translateX = useRef(new Animated.Value(0)).current;
|
||||
const translateX = useRef<Animated.Value>(new Animated.Value(0));
|
||||
const animatingRef = useRef(false);
|
||||
const gridWidthRef = useRef<number>(WIDTH);
|
||||
|
||||
const { tasks: selectedDayTasks } = useTasksByDate(selectedDate);
|
||||
useEffect(() => {
|
||||
visibleMonthRef.current = visibleMonth;
|
||||
}, [visibleMonth]);
|
||||
|
||||
useEffect(() => {
|
||||
selectedDateRef.current = selectedDate;
|
||||
}, [selectedDate]);
|
||||
|
||||
const { tasks: selectedDayTasks, refresh: refreshDayTasks } = useTasksByDate(selectedDate);
|
||||
const monthTasks = useTasksInMonth(visibleMonth);
|
||||
const refreshMonthTasks = monthTasks.refresh;
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
refreshDayTasks();
|
||||
refreshMonthTasks();
|
||||
refreshSubtasks();
|
||||
}, [refreshDayTasks, refreshMonthTasks, refreshSubtasks])
|
||||
);
|
||||
|
||||
const weeks = useMemo(() => {
|
||||
const first = startOfMonth(visibleMonth);
|
||||
@@ -67,70 +73,25 @@ export default function CalendarScreen() {
|
||||
return rows;
|
||||
}, [visibleMonth]);
|
||||
|
||||
const loadSubtasks = useCallback(
|
||||
async (taskId: string): Promise<[string, SubtaskData[]]> => {
|
||||
const subs = await collections.subtasks
|
||||
.query(Q.where('task_id', taskId), Q.where('parent_subtask_id', null), Q.sortBy('order', 'asc'))
|
||||
.fetch();
|
||||
const mapped: SubtaskData[] = subs.map((s: any) => ({
|
||||
id: s.id,
|
||||
taskId: s.taskId,
|
||||
parentSubtaskId: s.parentSubtaskId || null,
|
||||
title: s.title,
|
||||
description: s.description || '',
|
||||
priority: (s.priority || 'none') as SubtaskData['priority'],
|
||||
completed: s.completed,
|
||||
dueDate: s.dueDate || 0,
|
||||
dueTime: s.dueTime || '',
|
||||
endTime: s.endTime || '',
|
||||
allDay: s.allDay ?? false,
|
||||
repeat: (s.repeat || 'none') as SubtaskData['repeat'],
|
||||
repeatInterval: s.repeatInterval ?? 1,
|
||||
repeatDays: s.repeatDays || '',
|
||||
seriesId: s.seriesId || '',
|
||||
reminder: (s.reminder || 'none') as SubtaskData['reminder'],
|
||||
assigneeId: s.assigneeId ?? null,
|
||||
order: s.order,
|
||||
subtasks: [],
|
||||
}));
|
||||
return [taskId, mapped];
|
||||
},
|
||||
[collections.subtasks]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setSubtasksMap({});
|
||||
Promise.all(selectedDayTasks.map((t) => loadSubtasks(t.id))).then((entries) => {
|
||||
if (!active) return;
|
||||
setSubtasksMap(Object.fromEntries(entries));
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [selectedDayTasks, loadSubtasks]);
|
||||
|
||||
const handleDayPress = useCallback((day: Date) => {
|
||||
setSelectedDate(day);
|
||||
if (!isSameMonth(day, visibleMonthRef.current)) {
|
||||
setVisibleMonth(day);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const transitionTo = useCallback(
|
||||
(dir: 1 | -1) => {
|
||||
(dir: 1 | -1, animate = true) => {
|
||||
if (animatingRef.current) return;
|
||||
const next = addMonths(visibleMonthRef.current, dir);
|
||||
if (!isSameMonth(selectedDateRef.current, next)) {
|
||||
setSelectedDate(startOfMonth(next));
|
||||
}
|
||||
if (!animate) {
|
||||
setVisibleMonth(next);
|
||||
translateX.current.setValue(0);
|
||||
return;
|
||||
}
|
||||
animatingRef.current = true;
|
||||
const w = gridWidthRef.current || WIDTH;
|
||||
const target = dir === 1 ? -w : w;
|
||||
Animated.timing(translateX, { toValue: target, duration: 220, useNativeDriver: false }).start(() => {
|
||||
const next = addMonths(visibleMonthRef.current, dir);
|
||||
if (!isSameMonth(selectedDateRef.current, next)) {
|
||||
setSelectedDate(startOfMonth(next));
|
||||
}
|
||||
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;
|
||||
});
|
||||
});
|
||||
@@ -138,27 +99,41 @@ export default function CalendarScreen() {
|
||||
[translateX]
|
||||
);
|
||||
|
||||
const handleDayPress = useCallback(
|
||||
(day: Date) => {
|
||||
setSelectedDate(day);
|
||||
if (!isSameMonth(day, visibleMonthRef.current)) {
|
||||
transitionTo(day > visibleMonthRef.current ? 1 : -1, true);
|
||||
} else {
|
||||
router.push({ pathname: '/day-view', params: { date: day.toISOString() } });
|
||||
}
|
||||
},
|
||||
[router, transitionTo]
|
||||
);
|
||||
|
||||
const pan = useMemo(
|
||||
() =>
|
||||
Gesture.Pan()
|
||||
.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();
|
||||
transitionTo(1);
|
||||
translateX.current.stopAnimation();
|
||||
transitionTo(1, false);
|
||||
} else if (dx >= w / 4) {
|
||||
translateX.stopAnimation();
|
||||
transitionTo(-1);
|
||||
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]
|
||||
@@ -183,8 +158,12 @@ export default function CalendarScreen() {
|
||||
}, [currentYear]);
|
||||
|
||||
const handleToggleComplete = useCallback(async (taskId: string) => {
|
||||
await toggleTaskComplete(taskId);
|
||||
}, []);
|
||||
await confirmAndToggleTaskComplete(taskId, () => {
|
||||
refreshDayTasks();
|
||||
refreshMonthTasks();
|
||||
refreshSubtasks();
|
||||
});
|
||||
}, [refreshDayTasks, refreshMonthTasks, refreshSubtasks]);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
|
||||
@@ -197,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 }] }]}
|
||||
>
|
||||
<View style={styles.monthRow}>
|
||||
<TouchableOpacity
|
||||
@@ -217,11 +197,12 @@ export default function CalendarScreen() {
|
||||
<Path d="M6 9l6 6 6-6" stroke={theme.textSecondary} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
</Svg>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={() => setYearPickerVisible(true)} activeOpacity={0.7} style={styles.yearButton}>
|
||||
<Text style={[styles.yearLabel, { color: theme.textMuted }]}>{format(visibleMonth, 'yyyy')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity onPress={() => setYearPickerVisible(true)} activeOpacity={0.7} style={styles.yearButton}>
|
||||
<Text style={[styles.yearLabel, { color: theme.textMuted }]}>{format(visibleMonth, 'yyyy')}</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={() => transitionTo(1)}
|
||||
style={[styles.monthNav, { borderColor: theme.borderStrong, backgroundColor: theme.card }]}
|
||||
@@ -248,7 +229,7 @@ export default function CalendarScreen() {
|
||||
key={day.toISOString()}
|
||||
day={day}
|
||||
label={cellTitle(byDayOf(monthTasks.byDay, day))}
|
||||
dotColor={cellColor(byDayOf(monthTasks.byDay, day), categories)}
|
||||
dotColor={cellColor(byDayOf(monthTasks.byDay, day), categories, theme)}
|
||||
selected={isSameDay(day, selectedDate)}
|
||||
today={isToday(day)}
|
||||
inMonth={isSameMonth(day, visibleMonth)}
|
||||
@@ -316,15 +297,10 @@ export default function CalendarScreen() {
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{(subtasksMap[task.id] ?? []).length > 0 && (
|
||||
{(subtasksByTask.get(task.id) ?? []).length > 0 && (
|
||||
<View style={styles.bullets}>
|
||||
{(subtasksMap[task.id] ?? []).map((sub) => (
|
||||
<TouchableOpacity
|
||||
key={sub.id}
|
||||
style={styles.bulletRow}
|
||||
onPress={() => router.push({ pathname: '/subtask-detail', params: { id: sub.id } })}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{(subtasksByTask.get(task.id) ?? []).map((sub) => (
|
||||
<View key={sub.id} style={styles.bulletRow}>
|
||||
<Svg width={5} height={5} viewBox="0 0 6 6" style={styles.bulletDot as any}>
|
||||
<Circle cx={3} cy={3} r={3} fill={theme.textMuted} />
|
||||
</Svg>
|
||||
@@ -334,7 +310,7 @@ export default function CalendarScreen() {
|
||||
>
|
||||
{sub.title}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
@@ -343,7 +319,10 @@ export default function CalendarScreen() {
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
<QuickAddBar dueDate={selectedDate.getTime()} placeholder={`Add event for ${format(selectedDate, 'MMM d')}`} />
|
||||
<QuickAddBar
|
||||
dueDate={selectedDate.getTime()}
|
||||
placeholder={`Add event for ${format(selectedDate, 'MMM d')}`}
|
||||
/>
|
||||
</KeyboardAvoidingView>
|
||||
|
||||
{modals(() => {})}
|
||||
@@ -378,10 +357,10 @@ function cellTitle(tasks: Task[]): string | null {
|
||||
return `${tasks[0].title} +${tasks.length - 1}`;
|
||||
}
|
||||
|
||||
function cellColor(tasks: Task[], categories: { id: string; color: string }[]): string {
|
||||
if (tasks.length === 0) return '#8E8E8E';
|
||||
function cellColor(tasks: Task[], categories: { id: string; color: string }[], theme: ThemeColors): string {
|
||||
if (tasks.length === 0) return theme.textMuted;
|
||||
const cat = categories.find((c) => c.id === tasks[0].categoryId);
|
||||
return cat ? desaturate(cat.color, 0.3) : '#8E8E8E';
|
||||
return cat ? desaturate(cat.color, 0.3) : theme.textMuted;
|
||||
}
|
||||
|
||||
interface DayCellProps {
|
||||
@@ -396,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 (
|
||||
<TouchableOpacity
|
||||
style={[styles.dayCell, selected && { backgroundColor: theme.accentSoft, borderColor: theme.accentBorder }]}
|
||||
@@ -426,7 +406,7 @@ const DayCell = React.memo(function DayCell({ day, label, dotColor, selected, to
|
||||
);
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
const createStyles = (theme: ThemeColors) => StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
@@ -461,7 +441,8 @@ const styles = StyleSheet.create({
|
||||
justifyContent: 'center',
|
||||
},
|
||||
monthSelectorGroup: {
|
||||
alignItems: 'center',
|
||||
alignItems: 'flex-start',
|
||||
flex: 1,
|
||||
},
|
||||
monthButton: {
|
||||
flexDirection: 'row',
|
||||
@@ -476,10 +457,11 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
yearButton: {
|
||||
marginTop: -2,
|
||||
paddingHorizontal: 6,
|
||||
},
|
||||
yearLabel: {
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
fontSize: 17,
|
||||
fontWeight: '700',
|
||||
},
|
||||
weekdayRow: {
|
||||
flexDirection: 'row',
|
||||
@@ -512,7 +494,7 @@ const styles = StyleSheet.create({
|
||||
fontWeight: '600',
|
||||
},
|
||||
dayNumberSelected: {
|
||||
color: '#FFFFFF',
|
||||
color: theme.text,
|
||||
},
|
||||
chip: {
|
||||
marginTop: 3,
|
||||
@@ -585,7 +567,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
eventCompleted: {
|
||||
textDecorationLine: 'line-through',
|
||||
color: '#9E9E9E',
|
||||
color: theme.textMuted,
|
||||
},
|
||||
menuButton: {
|
||||
padding: 4,
|
||||
@@ -612,6 +594,6 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
bulletCompleted: {
|
||||
textDecorationLine: 'line-through',
|
||||
color: '#6E6E6E',
|
||||
color: theme.textMuted,
|
||||
},
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { View, Text, StyleSheet, SafeAreaView, BackHandler, KeyboardAvoidingView } from 'react-native';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
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';
|
||||
@@ -7,15 +7,18 @@ import { TaskList } from '@/components/TaskList';
|
||||
import { QuickAddBar } from '@/components/QuickAddBar';
|
||||
import { useDatabase } from '@/hooks/useDatabase';
|
||||
import { useSettings } from '@/theme';
|
||||
import Svg, { Path, Circle } from 'react-native-svg';
|
||||
|
||||
export default function TasksScreen() {
|
||||
const { isReady } = useDatabase();
|
||||
const { theme } = useSettings();
|
||||
const [selectedCategory, setSelectedCategory] = React.useState<string>('all');
|
||||
const { theme, showCompleted, setShowCompleted } = useSettings();
|
||||
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
|
||||
|
||||
const categoryIds = useMemo(() => selectedCategories, [selectedCategories]);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
const sub = BackHandler.addEventListener('hardwareBackPress', () => true);
|
||||
const sub = BackHandler.addEventListener('hardwareBackPress', () => false);
|
||||
return () => sub.remove();
|
||||
}, [])
|
||||
);
|
||||
@@ -32,13 +35,41 @@ export default function TasksScreen() {
|
||||
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
|
||||
<Header title="ToDo" showLogo={false} />
|
||||
<View style={styles.categoryFilterWrapper}>
|
||||
<CategoryFilter selected={selectedCategory} onSelect={setSelectedCategory} />
|
||||
<CategoryFilter selected={selectedCategories} onSelect={setSelectedCategories} />
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.completedToggle,
|
||||
{ backgroundColor: theme.card, borderColor: showCompleted ? theme.accent : theme.borderStrong },
|
||||
]}
|
||||
onPress={() => setShowCompleted(!showCompleted)}
|
||||
activeOpacity={0.8}
|
||||
accessibilityRole="switch"
|
||||
accessibilityLabel="Show completed tasks"
|
||||
accessibilityState={{ checked: showCompleted }}
|
||||
>
|
||||
<Svg width={14} height={14} viewBox="0 0 24 24">
|
||||
<Circle
|
||||
cx={12}
|
||||
cy={12}
|
||||
r={9}
|
||||
stroke={showCompleted ? theme.accent : theme.textMuted}
|
||||
strokeWidth={2}
|
||||
fill="none"
|
||||
/>
|
||||
{showCompleted && (
|
||||
<Path d="M7 12.5l3.5 3.5 6.5-7" stroke={theme.accent} strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
)}
|
||||
</Svg>
|
||||
<Text style={[styles.completedToggleText, { color: showCompleted ? theme.text : theme.textMuted }]}>
|
||||
Completed
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<KeyboardAvoidingView
|
||||
style={styles.kbAvoid}
|
||||
behavior="padding"
|
||||
>
|
||||
<TaskList categoryId={selectedCategory} />
|
||||
<TaskList categoryIds={categoryIds} showCompleted={showCompleted} />
|
||||
<QuickAddBar />
|
||||
</KeyboardAvoidingView>
|
||||
</SafeAreaView>
|
||||
@@ -51,13 +82,25 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
categoryFilterWrapper: {
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingRight: 12,
|
||||
},
|
||||
completedToggle: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 5,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 18,
|
||||
borderWidth: 1.5,
|
||||
alignSelf: 'center',
|
||||
},
|
||||
completedToggleText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
},
|
||||
kbAvoid: {
|
||||
flex: 1,
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
});
|
||||
@@ -22,7 +22,7 @@ import { getLastVisitedTab, tabHref } from '@/utils/tabHistory';
|
||||
|
||||
export default function SettingsScreen() {
|
||||
const router = useRouter();
|
||||
const { theme, notifications, setNotifications, defaultCategoryId, setDefaultCategoryId, sortBy, setSortBy, reminderPreference, setReminderPreference, apiUrl, accentColor, setAccentColor, todoAheadDays, setTodoAheadDays } = useSettings();
|
||||
const { theme, notifications, setNotifications, defaultCategoryId, setDefaultCategoryId, sortBy, setSortBy, reminderPreference, setReminderPreference, apiUrl, accentColor, setAccentColor, todoAheadDays, setTodoAheadDays, showCompleted, setShowCompleted } = useSettings();
|
||||
const categories = useCategories();
|
||||
const [picker, setPicker] = useState<null | 'category' | 'sort' | 'reminder' | 'todoAhead'>(null);
|
||||
const [editingCategory, setEditingCategory] = useState<Category | null | 'new'>(null);
|
||||
@@ -119,12 +119,12 @@ export default function SettingsScreen() {
|
||||
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
|
||||
<Header title="Settings" showLogo={false} />
|
||||
<ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.textMuted }]}>Categories</Text>
|
||||
<Text style={[styles.sectionTitle, { color: theme.textMuted }]}>Tags</Text>
|
||||
{categories.map((category) => (
|
||||
<ListItem
|
||||
key={category.id}
|
||||
title={category.name}
|
||||
subtitle={category.id === defaultCategoryId ? 'Default category' : undefined}
|
||||
subtitle={category.id === defaultCategoryId ? 'Default tag' : undefined}
|
||||
leftElement={
|
||||
<View style={[styles.categoryDot, { backgroundColor: category.color }]} />
|
||||
}
|
||||
@@ -142,7 +142,7 @@ export default function SettingsScreen() {
|
||||
<Path d="M12 5v14M5 12h14" stroke={theme.accentText} strokeWidth={2} strokeLinecap="round" />
|
||||
</Svg>
|
||||
</View>
|
||||
<Text style={[styles.addCategoryText, { color: theme.accent }]}>Add Category</Text>
|
||||
<Text style={[styles.addCategoryText, { color: theme.accent }]}>Add Tag</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={[styles.sectionTitle, { color: theme.textMuted }]}>Notifications</Text>
|
||||
<ListItem
|
||||
@@ -165,7 +165,7 @@ export default function SettingsScreen() {
|
||||
/>
|
||||
<Text style={[styles.sectionTitle, { color: theme.textMuted }]}>Tasks</Text>
|
||||
<ListItem
|
||||
title="Default Category"
|
||||
title="Default Tag"
|
||||
subtitle={defaultCategoryLabel}
|
||||
onPress={() => setPicker('category')}
|
||||
showChevron
|
||||
@@ -176,6 +176,18 @@ export default function SettingsScreen() {
|
||||
onPress={() => setPicker('sort')}
|
||||
showChevron
|
||||
/>
|
||||
<ListItem
|
||||
title="Show Completed Tasks"
|
||||
subtitle="Hide or show finished tasks in the Todo list"
|
||||
rightElement={
|
||||
<Switch
|
||||
value={showCompleted}
|
||||
onValueChange={setShowCompleted}
|
||||
thumbColor="#FFFFFF"
|
||||
trackColor={{ false: theme.borderStrong, true: theme.accent }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ListItem
|
||||
title="Show Calendar Tasks"
|
||||
subtitle={todoAheadDays === 0 ? 'Only today' : `Up to ${todoAheadDays} days ahead`}
|
||||
@@ -257,8 +269,8 @@ export default function SettingsScreen() {
|
||||
|
||||
<OptionPickerModal
|
||||
visible={picker === 'category'}
|
||||
title="Default Category"
|
||||
options={[{ value: '', label: 'None', color: '#9E9E9E' }, ...categories.map((c) => ({ 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)}
|
||||
|
||||
@@ -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() {
|
||||
</View>
|
||||
|
||||
<View style={[styles.card, { backgroundColor: theme.card }]}>
|
||||
<Text style={[styles.cardTitle, { color: theme.textMuted }]}>By category</Text>
|
||||
<Text style={[styles.cardTitle, { color: theme.textMuted }]}>By tags</Text>
|
||||
{stats.byCategory.length === 0 ? (
|
||||
<Text style={[styles.emptyText, { color: theme.textFaint }]}>No completed tasks yet</Text>
|
||||
) : (
|
||||
@@ -98,7 +97,7 @@ export default function StatsScreen() {
|
||||
) : (
|
||||
stats.byPriority.map((p) => (
|
||||
<View key={p.priority} style={styles.row}>
|
||||
<View style={[styles.dot, { backgroundColor: priorityColor(p.priority) }]} />
|
||||
<View style={[styles.dot, { backgroundColor: priorityColor(theme, p.priority) }]} />
|
||||
<Text style={[styles.rowLabel, { color: theme.text }]}>{p.label}</Text>
|
||||
<Text style={[styles.rowValue, { color: theme.textMuted }]}>{p.count}</Text>
|
||||
</View>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, any> | undefined;
|
||||
if (!data) return;
|
||||
|
||||
switch (data.type) {
|
||||
case 'task_reminder':
|
||||
case 'task_assignment':
|
||||
if (data.taskId) {
|
||||
router.push({ pathname: '/task-detail', params: { id: data.taskId } });
|
||||
}
|
||||
break;
|
||||
case 'friend_request':
|
||||
if (data.friendshipId) {
|
||||
router.push({ pathname: '/(tabs)/settings' });
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
const handleUrl = (url: string | null) => {
|
||||
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]);
|
||||
|
||||
@@ -33,6 +70,7 @@ function RootNavigator() {
|
||||
<Stack.Screen name="(tabs)" />
|
||||
<Stack.Screen name="add-task" />
|
||||
<Stack.Screen name="task-detail" />
|
||||
<Stack.Screen name="day-view" />
|
||||
</Stack>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
|
||||
@@ -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';
|
||||
@@ -17,15 +17,17 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useDatabase } from '@/hooks/useDatabase';
|
||||
import { database, collections } from '@/database';
|
||||
import { TaskFormData } from '@/types';
|
||||
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({
|
||||
title: z.string().trim().min(1, 'Task 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(),
|
||||
@@ -47,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<ScrollView>(null);
|
||||
|
||||
const methods = useForm<TaskFormData>({
|
||||
resolver: zodResolver(taskSchema),
|
||||
@@ -60,6 +64,7 @@ export default function AddTaskScreen() {
|
||||
title: '',
|
||||
description: '',
|
||||
categoryId: initialCategory,
|
||||
tags: initialCategory ? [initialCategory] : [],
|
||||
priority: 'none',
|
||||
dueDate: initialDate,
|
||||
dueTime: '',
|
||||
@@ -83,7 +88,7 @@ export default function AddTaskScreen() {
|
||||
formState: { errors },
|
||||
} = methods;
|
||||
|
||||
const categoryId = watch('categoryId');
|
||||
const tags = watch('tags') ?? [];
|
||||
const priority = watch('priority');
|
||||
const repeat = watch('repeat');
|
||||
const repeatInterval = watch('repeatInterval') ?? 1;
|
||||
@@ -94,20 +99,23 @@ export default function AddTaskScreen() {
|
||||
const assigneeId = watch('assigneeId');
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!categoryId && initialCategory) {
|
||||
setValue('categoryId', initialCategory);
|
||||
if (tags.length === 0 && initialCategory) {
|
||||
setValue('tags', [initialCategory]);
|
||||
}
|
||||
}, [initialCategory, categoryId, setValue]);
|
||||
}, [initialCategory, tags, setValue]);
|
||||
|
||||
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'
|
||||
? `series_${Math.random().toString(36).slice(2, 10)}${Date.now().toString(36)}`
|
||||
: '';
|
||||
const resolvedCategoryId = data.categoryId || '';
|
||||
const resolvedCategoryId = (data.tags && data.tags[0]) || '';
|
||||
|
||||
let createdTask: any = null;
|
||||
|
||||
@@ -116,6 +124,7 @@ export default function AddTaskScreen() {
|
||||
t.title = data.title.trim();
|
||||
t.description = data.description || '';
|
||||
t.categoryId = resolvedCategoryId;
|
||||
t.tags = tagsToString(data.tags || []);
|
||||
t.priority = data.priority;
|
||||
t.completed = false;
|
||||
t.dueDate = dueDateTimestamp;
|
||||
@@ -157,7 +166,7 @@ export default function AddTaskScreen() {
|
||||
}
|
||||
});
|
||||
|
||||
await scheduleTaskReminder(createdTask);
|
||||
await scheduleTaskReminder(createdTask, notificationsEnabled, reminderPreference);
|
||||
|
||||
router.back();
|
||||
};
|
||||
@@ -176,16 +185,19 @@ export default function AddTaskScreen() {
|
||||
<FormProvider {...methods}>
|
||||
<KeyboardAvoidingView
|
||||
style={styles.keyboardAvoiding}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 0}
|
||||
>
|
||||
<ScrollView
|
||||
ref={scrollViewRef}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
keyboardDismissMode="interactive"
|
||||
>
|
||||
<CategorySelector
|
||||
value={categoryId}
|
||||
onChange={(value) => setValue('categoryId', value)}
|
||||
value={tags}
|
||||
onChange={(value) => setValue('tags', value)}
|
||||
error={errors.categoryId?.message}
|
||||
/>
|
||||
<Controller
|
||||
@@ -260,7 +272,7 @@ const styles = StyleSheet.create({
|
||||
scrollContent: {
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: 12,
|
||||
paddingBottom: 120,
|
||||
paddingBottom: 100,
|
||||
gap: 20,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { View, Text, StyleSheet, SafeAreaView, ScrollView, TouchableOpacity, KeyboardAvoidingView, BackHandler } from 'react-native';
|
||||
import { useRouter, useLocalSearchParams, useFocusEffect } from 'expo-router';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { Header } from '@/components/Header';
|
||||
import { useTasksByDate } from '@/hooks/useTasks';
|
||||
import { useSubtasks } from '@/hooks/useSubtasks';
|
||||
import { useTaskModals } from '@/hooks/useTaskModals';
|
||||
import { useSettings } from '@/theme';
|
||||
import { useCategories } from '@/hooks/useDatabase';
|
||||
import { confirmAndToggleTaskComplete, toggleSubtaskComplete } from '@/utils/taskActions';
|
||||
import { QuickAddBar } from '@/components/QuickAddBar';
|
||||
import { SubtaskData } from '@/types';
|
||||
import { format, startOfDay } from 'date-fns';
|
||||
import Svg, { Path, Circle } from 'react-native-svg';
|
||||
import { desaturate, type ThemeColors } from '@/theme';
|
||||
|
||||
function isDayMatch(timestamp: number, day: Date): boolean {
|
||||
const d = new Date(timestamp);
|
||||
return d.getFullYear() === day.getFullYear() && d.getMonth() === day.getMonth() && d.getDate() === day.getDate();
|
||||
}
|
||||
|
||||
export default function DayViewScreen() {
|
||||
const router = useRouter();
|
||||
const { theme } = useSettings();
|
||||
const insets = useSafeAreaInsets();
|
||||
const styles = useMemo(() => createStyles(theme), [theme]);
|
||||
const categories = useCategories();
|
||||
const { modals, openTaskEdit } = useTaskModals();
|
||||
const { date: dateParam } = useLocalSearchParams<{ date?: string }>();
|
||||
|
||||
const day = useMemo(() => {
|
||||
const parsed = dateParam ? new Date(dateParam) : new Date();
|
||||
return Number.isNaN(parsed.getTime()) ? new Date() : parsed;
|
||||
}, [dateParam]);
|
||||
const dayStart = useMemo(() => startOfDay(day), [day]);
|
||||
|
||||
const { map: subtasksByTask, refresh: refreshSubtasks } = useSubtasks();
|
||||
const { tasks: dayTasks, loading, refresh } = useTasksByDate(day);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
const sub = BackHandler.addEventListener('hardwareBackPress', () => false);
|
||||
refreshSubtasks();
|
||||
refresh();
|
||||
return () => sub.remove();
|
||||
}, [refreshSubtasks, refresh])
|
||||
);
|
||||
|
||||
const subtasksOnDay = useMemo(() => {
|
||||
const map = new Map<string, SubtaskData[]>();
|
||||
for (const [taskId, roots] of subtasksByTask) {
|
||||
const due = roots.filter((s) => s.dueDate && isDayMatch(s.dueDate, day));
|
||||
if (due.length > 0) map.set(taskId, due);
|
||||
}
|
||||
return map;
|
||||
}, [subtasksByTask, day]);
|
||||
|
||||
const handleToggleComplete = useCallback(async (taskId: string) => {
|
||||
await confirmAndToggleTaskComplete(taskId, () => {
|
||||
refresh();
|
||||
refreshSubtasks();
|
||||
});
|
||||
}, [refresh, refreshSubtasks]);
|
||||
|
||||
const handleToggleSubtask = useCallback(async (subtaskId: string) => {
|
||||
await toggleSubtaskComplete(subtaskId);
|
||||
refreshSubtasks();
|
||||
}, [refreshSubtasks]);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={[styles.container, { backgroundColor: theme.background, paddingBottom: insets.bottom }]}>
|
||||
<Header title={format(day, 'EEEE, MMM d')} showLogo={false} />
|
||||
<KeyboardAvoidingView style={styles.kbAvoid} behavior="padding">
|
||||
<ScrollView style={styles.scrollBody} contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}>
|
||||
{loading ? (
|
||||
<View style={styles.emptyState}>
|
||||
<Text style={[styles.emptyText, { color: theme.textSecondary }]}>Loading...</Text>
|
||||
</View>
|
||||
) : dayTasks.length === 0 && subtasksOnDay.size === 0 ? (
|
||||
<View style={styles.emptyState}>
|
||||
<Text style={[styles.emptyText, { color: theme.textSecondary }]}>No tasks on this day</Text>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
{dayTasks.map((task) => {
|
||||
const subs = subtasksOnDay.get(task.id) ?? [];
|
||||
const openCount = subs.filter((s) => !s.completed).length;
|
||||
const cat = categories.find((c) => c.id === task.categoryId);
|
||||
return (
|
||||
<View key={task.id} style={[styles.eventCard, { backgroundColor: theme.card, borderColor: theme.border }]}>
|
||||
<View style={styles.eventRow}>
|
||||
<TouchableOpacity
|
||||
style={styles.checkCircle}
|
||||
onPress={() => handleToggleComplete(task.id)}
|
||||
activeOpacity={0.7}
|
||||
accessibilityRole="checkbox"
|
||||
accessibilityState={{ checked: task.completed }}
|
||||
>
|
||||
<Svg width={30} height={30} viewBox="0 0 24 24">
|
||||
{task.completed ? (
|
||||
<>
|
||||
<Circle cx={12} cy={12} r={10} fill={theme.accent} />
|
||||
<Path d="M7 12l3 3 7-7" stroke="#FFFFFF" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
</>
|
||||
) : (
|
||||
<Circle cx={12} cy={12} r={10} stroke={theme.textSecondary} strokeWidth={2.5} fill="none" />
|
||||
)}
|
||||
</Svg>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.eventTouch}
|
||||
onPress={() => router.push({ pathname: '/task-detail', params: { id: task.id } })}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text
|
||||
style={[styles.eventTitle, { color: theme.text }, task.completed && styles.eventCompleted]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{task.title}
|
||||
</Text>
|
||||
{openCount > 0 && (
|
||||
<Text style={[styles.eventSub, { color: theme.textMuted }]} numberOfLines={1}>
|
||||
{openCount} open subtask{openCount === 1 ? '' : 's'}
|
||||
</Text>
|
||||
)}
|
||||
{!task.completed && (
|
||||
<View style={styles.metaRow}>
|
||||
{cat && (
|
||||
<View style={[styles.tagChip, { backgroundColor: desaturate(cat.color, 0.3) }]}>
|
||||
<Text style={styles.tagText} numberOfLines={1}>{cat.name}</Text>
|
||||
</View>
|
||||
)}
|
||||
{task.dueTime ? (
|
||||
<Text style={[styles.timeText, { color: theme.textSecondary }]}>{task.dueTime}{task.endTime ? `–${task.endTime}` : ''}</Text>
|
||||
) : task.dueDate ? (
|
||||
<Text style={[styles.timeText, { color: theme.textSecondary }]}>{format(task.dueDate, 'HH:mm')}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.menuButton}
|
||||
onPress={() => openTaskEdit(task.id)}
|
||||
activeOpacity={0.7}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Edit ${task.title}`}
|
||||
>
|
||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||
<Path d="M9 6l6 6-6 6" stroke={theme.textSecondary} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
</Svg>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
{Array.from(subtasksOnDay.entries()).flatMap(([taskId, subs]) => {
|
||||
const parent = dayTasks.find((t) => t.id === taskId);
|
||||
if (parent) return [];
|
||||
return subs.map((sub) => (
|
||||
<View key={sub.id} style={[styles.eventCard, { backgroundColor: theme.card, borderColor: theme.border }]}>
|
||||
<View style={styles.eventRow}>
|
||||
<TouchableOpacity
|
||||
style={styles.checkCircle}
|
||||
onPress={() => handleToggleSubtask(sub.id)}
|
||||
activeOpacity={0.7}
|
||||
accessibilityRole="checkbox"
|
||||
accessibilityState={{ checked: sub.completed }}
|
||||
>
|
||||
<Svg width={30} height={30} viewBox="0 0 24 24">
|
||||
{sub.completed ? (
|
||||
<>
|
||||
<Circle cx={12} cy={12} r={10} fill={theme.accent} />
|
||||
<Path d="M7 12l3 3 7-7" stroke="#FFFFFF" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
</>
|
||||
) : (
|
||||
<Circle cx={12} cy={12} r={10} stroke={theme.textSecondary} strokeWidth={2.5} fill="none" />
|
||||
)}
|
||||
</Svg>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={styles.eventTouch}
|
||||
onPress={() => router.push({ pathname: '/subtask-detail', params: { id: sub.id } })}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text
|
||||
style={[styles.eventTitle, { color: theme.text }, sub.completed && styles.eventCompleted]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{sub.title}
|
||||
</Text>
|
||||
{sub.dueTime ? (
|
||||
<Text style={[styles.timeText, { color: theme.textSecondary }]}>{sub.dueTime}{sub.endTime ? `–${sub.endTime}` : ''}</Text>
|
||||
) : null}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
));
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</ScrollView>
|
||||
<QuickAddBar dueDate={dayStart.getTime()} placeholder={`Add event for ${format(day, 'MMM d')}`} />
|
||||
</KeyboardAvoidingView>
|
||||
{modals(() => {})}
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const createStyles = (theme: ThemeColors) => StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
kbAvoid: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollBody: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingTop: 12,
|
||||
paddingBottom: 16,
|
||||
},
|
||||
emptyState: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: 64,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
},
|
||||
eventCard: {
|
||||
marginHorizontal: 16,
|
||||
marginBottom: 8,
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
eventRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
checkCircle: {
|
||||
width: 30,
|
||||
marginRight: 12,
|
||||
},
|
||||
eventTouch: {
|
||||
flex: 1,
|
||||
},
|
||||
eventTitle: {
|
||||
fontSize: 15,
|
||||
fontWeight: '500',
|
||||
},
|
||||
eventCompleted: {
|
||||
textDecorationLine: 'line-through',
|
||||
color: theme.textMuted,
|
||||
},
|
||||
eventSub: {
|
||||
fontSize: 13,
|
||||
marginTop: 2,
|
||||
},
|
||||
metaRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
marginTop: 4,
|
||||
},
|
||||
tagChip: {
|
||||
paddingHorizontal: 7,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 6,
|
||||
maxWidth: 140,
|
||||
},
|
||||
tagText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 10,
|
||||
fontWeight: '600',
|
||||
},
|
||||
timeText: {
|
||||
fontSize: 12.5,
|
||||
fontWeight: '500',
|
||||
},
|
||||
menuButton: {
|
||||
padding: 4,
|
||||
marginLeft: 6,
|
||||
},
|
||||
});
|
||||
@@ -15,15 +15,17 @@ 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';
|
||||
import { useFriends } from '@/hooks/useFriends';
|
||||
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(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
priority: z.enum(['none', 'low', 'medium', 'high', 'critical']),
|
||||
dueDate: z.date().nullable().optional(),
|
||||
dueTime: z.string().optional(),
|
||||
@@ -51,6 +53,7 @@ export default function SubtaskDetailScreen() {
|
||||
defaultValues: {
|
||||
title: '',
|
||||
description: '',
|
||||
tags: [],
|
||||
priority: 'none',
|
||||
dueDate: null,
|
||||
dueTime: '',
|
||||
@@ -68,6 +71,7 @@ export default function SubtaskDetailScreen() {
|
||||
const { handleSubmit, watch, setValue, control, reset, formState: { errors } } = methods;
|
||||
|
||||
const priority = watch('priority');
|
||||
const tags = watch('tags') ?? [];
|
||||
const repeat = watch('repeat');
|
||||
const repeatInterval = watch('repeatInterval') ?? 1;
|
||||
const repeatDays = watch('repeatDays') ?? [];
|
||||
@@ -80,13 +84,13 @@ export default function SubtaskDetailScreen() {
|
||||
if (!id || !isReady) return;
|
||||
let mounted = true;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const subtask = await collections.subtasks.find(id);
|
||||
const subscription = collections.subtasks.findAndObserve(id).subscribe({
|
||||
next: (subtask: any) => {
|
||||
if (!mounted) return;
|
||||
reset({
|
||||
title: subtask.title,
|
||||
description: subtask.description,
|
||||
tags: parseTaskTags(subtask.tags, subtask.categoryId),
|
||||
priority: subtask.priority,
|
||||
dueDate: subtask.dueDate ? new Date(subtask.dueDate) : null,
|
||||
dueTime: subtask.dueTime,
|
||||
@@ -94,18 +98,25 @@ export default function SubtaskDetailScreen() {
|
||||
allDay: subtask.allDay ?? false,
|
||||
repeat: subtask.repeat,
|
||||
repeatInterval: subtask.repeatInterval || 1,
|
||||
repeatDays: (subtask.repeatDays || '').split(',').map(Number).filter((d) => !Number.isNaN(d)),
|
||||
repeatDays: ((subtask.repeatDays || '') as string).split(',').map(Number).filter((d) => !Number.isNaN(d)),
|
||||
reminder: (subtask.reminder || 'none') as Reminder,
|
||||
reminders: subtask.reminders || '',
|
||||
assigneeId: subtask.assigneeId ?? null,
|
||||
});
|
||||
setLoaded(true);
|
||||
} catch {
|
||||
},
|
||||
error: () => {
|
||||
if (mounted) setNotFound(true);
|
||||
}
|
||||
})();
|
||||
},
|
||||
complete: () => {
|
||||
if (mounted) setNotFound(true);
|
||||
},
|
||||
});
|
||||
|
||||
return () => { mounted = false; };
|
||||
return () => {
|
||||
mounted = false;
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, [id, isReady, reset]);
|
||||
|
||||
const onSubmit = async (data: SubtaskFormData) => {
|
||||
@@ -114,6 +125,8 @@ export default function SubtaskDetailScreen() {
|
||||
await updateSubtask(id, {
|
||||
title: data.title,
|
||||
description: data.description || '',
|
||||
categoryId: (data.tags && data.tags[0]) || '',
|
||||
tags: tagsToString(data.tags || []),
|
||||
priority: data.priority,
|
||||
dueDate: data.dueDate ? data.dueDate.getTime() : 0,
|
||||
dueTime: data.dueTime || '',
|
||||
@@ -187,6 +200,10 @@ export default function SubtaskDetailScreen() {
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<CategorySelector
|
||||
value={tags}
|
||||
onChange={(value) => setValue('tags', value)}
|
||||
/>
|
||||
<PrioritySelector
|
||||
value={priority}
|
||||
onChange={(value) => setValue('priority', value)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, SafeAreaView, ScrollView, KeyboardAvoidingView, Platform, TouchableOpacity } from 'react-native';
|
||||
import React, { useRef } from 'react';
|
||||
import { View, Text, StyleSheet, SafeAreaView, ScrollView, KeyboardAvoidingView, Platform, TouchableOpacity, Keyboard } from 'react-native';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { Header } from '@/components/Header';
|
||||
import { CategorySelector } from '@/components/CategorySelector';
|
||||
@@ -19,10 +19,12 @@ import { z } from 'zod';
|
||||
import { useDatabase } from '@/hooks/useDatabase';
|
||||
import { database, collections } from '@/database';
|
||||
import { Q } from '@nozbe/watermelondb';
|
||||
import { TaskFormData } from '@/types';
|
||||
import { TaskFormData, parseTaskTags, tagsToString } from '@/types';
|
||||
import { useSettings } from '@/theme';
|
||||
import { deleteTaskOccurrences } from '@/utils/taskActions';
|
||||
import { scheduleTaskReminder } from '@/services/notifications';
|
||||
import { ReminderPreference } from '@/types';
|
||||
import { recordTombstonesInBatch } from '@/database/tombstones';
|
||||
import { useFriends } from '@/hooks/useFriends';
|
||||
import Svg, { Path, Circle } from 'react-native-svg';
|
||||
|
||||
@@ -30,6 +32,7 @@ const taskSchema = z.object({
|
||||
title: z.string().trim().min(1, 'Task name is required').max(100),
|
||||
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(),
|
||||
@@ -77,8 +80,10 @@ export default function TaskDetailScreen() {
|
||||
const router = useRouter();
|
||||
const { theme } = useSettings();
|
||||
const { friends } = useFriends();
|
||||
const { notifications: notificationsEnabled, reminderPreference } = useSettings();
|
||||
const [loaded, setLoaded] = React.useState(false);
|
||||
const [notFound, setNotFound] = React.useState(false);
|
||||
const scrollViewRef = useRef<ScrollView>(null);
|
||||
|
||||
const methods = useForm<TaskFormData>({
|
||||
resolver: zodResolver(taskSchema),
|
||||
@@ -86,6 +91,7 @@ export default function TaskDetailScreen() {
|
||||
title: '',
|
||||
description: '',
|
||||
categoryId: '',
|
||||
tags: [],
|
||||
priority: 'none',
|
||||
dueDate: null,
|
||||
dueTime: '',
|
||||
@@ -102,7 +108,7 @@ export default function TaskDetailScreen() {
|
||||
|
||||
const { handleSubmit, watch, setValue, control, reset, formState: { errors } } = methods;
|
||||
|
||||
const categoryId = watch('categoryId');
|
||||
const tags = watch('tags') ?? [];
|
||||
const priority = watch('priority');
|
||||
const repeat = watch('repeat');
|
||||
const repeatInterval = watch('repeatInterval') ?? 1;
|
||||
@@ -123,10 +129,13 @@ export default function TaskDetailScreen() {
|
||||
const subtasks = await collections.subtasks.query(Q.where('task_id', id)).fetch();
|
||||
|
||||
if (!mounted) return;
|
||||
const remindersValue = task.reminders || (task.reminder && task.reminder !== 'none' ? task.reminder : '');
|
||||
|
||||
reset({
|
||||
title: task.title,
|
||||
description: task.description,
|
||||
categoryId: task.categoryId,
|
||||
tags: parseTaskTags(task.tags, task.categoryId),
|
||||
priority: task.priority,
|
||||
dueDate: task.dueDate ? new Date(task.dueDate) : null,
|
||||
dueTime: task.dueTime,
|
||||
@@ -136,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 })),
|
||||
});
|
||||
@@ -151,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;
|
||||
|
||||
@@ -160,14 +173,12 @@ export default function TaskDetailScreen() {
|
||||
const task = await collections.tasks.find(id);
|
||||
savedTask = task;
|
||||
const existingSubtasks = await collections.subtasks.query(Q.where('task_id', id)).fetch();
|
||||
for (const subtask of existingSubtasks) {
|
||||
await subtask.destroyPermanently();
|
||||
}
|
||||
|
||||
await task.update((t) => {
|
||||
t.title = data.title.trim();
|
||||
t.description = data.description || '';
|
||||
t.categoryId = data.categoryId || task.categoryId || '';
|
||||
t.categoryId = (data.tags && data.tags[0]) || task.categoryId || '';
|
||||
t.tags = tagsToString(data.tags || []);
|
||||
t.priority = data.priority;
|
||||
t.dueDate = dueDateTimestamp;
|
||||
t.dueTime = data.dueTime || '';
|
||||
@@ -177,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)}`;
|
||||
@@ -184,36 +196,69 @@ export default function TaskDetailScreen() {
|
||||
t.updatedAt = now;
|
||||
});
|
||||
|
||||
if (data.subtasks && data.subtasks.length > 0) {
|
||||
for (let i = 0; i < data.subtasks.length; i++) {
|
||||
const subtask = data.subtasks[i];
|
||||
if (subtask.title.trim()) {
|
||||
await collections.subtasks.create((s) => {
|
||||
s.taskId = task.id;
|
||||
s.title = subtask.title.trim();
|
||||
s.description = '';
|
||||
s.priority = 'none';
|
||||
s.completed = false;
|
||||
s.dueDate = 0;
|
||||
s.dueTime = '';
|
||||
s.endTime = '';
|
||||
s.allDay = false;
|
||||
s.repeat = 'none';
|
||||
s.repeatInterval = 1;
|
||||
s.repeatDays = '';
|
||||
s.seriesId = '';
|
||||
s.reminder = 'none';
|
||||
s.assigneeId = null;
|
||||
s.order = i;
|
||||
s.createdAt = now;
|
||||
const keptIds = new Set<string>();
|
||||
let order = 0;
|
||||
for (const formItem of data.subtasks ?? []) {
|
||||
const trimmed = formItem.title.trim();
|
||||
if (!trimmed) continue;
|
||||
if (formItem._key) {
|
||||
const existing = existingSubtasks.find((s) => s.id === formItem._key && !s.parentSubtaskId);
|
||||
if (existing) {
|
||||
keptIds.add(existing.id);
|
||||
await existing.update((s) => {
|
||||
s.title = trimmed;
|
||||
s.order = order;
|
||||
s.updatedAt = now;
|
||||
});
|
||||
order++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
await collections.subtasks.create((s) => {
|
||||
s.taskId = task.id;
|
||||
s.categoryId = task.categoryId || '';
|
||||
s.tags = task.tags || '';
|
||||
s.title = trimmed;
|
||||
s.description = '';
|
||||
s.priority = 'none';
|
||||
s.completed = false;
|
||||
s.dueDate = 0;
|
||||
s.dueTime = '';
|
||||
s.endTime = '';
|
||||
s.allDay = false;
|
||||
s.repeat = 'none';
|
||||
s.repeatInterval = 1;
|
||||
s.repeatDays = '';
|
||||
s.seriesId = '';
|
||||
s.reminder = 'none';
|
||||
s.reminders = '';
|
||||
s.assigneeId = null;
|
||||
s.order = order++;
|
||||
s.createdAt = now;
|
||||
s.updatedAt = now;
|
||||
});
|
||||
}
|
||||
|
||||
const removedIds: string[] = [];
|
||||
for (const existing of existingSubtasks) {
|
||||
if (existing.parentSubtaskId) continue;
|
||||
if (keptIds.has(existing.id)) continue;
|
||||
removedIds.push(existing.id);
|
||||
const children = await collections.subtasks.query(Q.where('parent_subtask_id', existing.id)).fetch();
|
||||
for (const child of children) {
|
||||
await child.update((c) => {
|
||||
c.parentSubtaskId = null;
|
||||
c.updatedAt = now;
|
||||
});
|
||||
}
|
||||
await existing.destroyPermanently();
|
||||
}
|
||||
if (removedIds.length > 0) {
|
||||
await recordTombstonesInBatch('subtasks', removedIds);
|
||||
}
|
||||
});
|
||||
|
||||
await scheduleTaskReminder(savedTask);
|
||||
await scheduleTaskReminder(savedTask, notificationsEnabled, reminderPreference);
|
||||
|
||||
router.back();
|
||||
};
|
||||
@@ -253,16 +298,19 @@ export default function TaskDetailScreen() {
|
||||
<FormProvider {...methods}>
|
||||
<KeyboardAvoidingView
|
||||
style={styles.keyboardAvoiding}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 0}
|
||||
>
|
||||
<ScrollView
|
||||
ref={scrollViewRef}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
keyboardDismissMode="interactive"
|
||||
>
|
||||
<CategorySelector
|
||||
value={categoryId}
|
||||
onChange={(value) => setValue('categoryId', value)}
|
||||
value={tags}
|
||||
onChange={(value) => setValue('tags', value)}
|
||||
error={errors.categoryId?.message}
|
||||
/>
|
||||
<Controller
|
||||
@@ -397,7 +445,7 @@ const styles = StyleSheet.create({
|
||||
scrollContent: {
|
||||
paddingHorizontal: 12,
|
||||
paddingTop: 8,
|
||||
paddingBottom: 80,
|
||||
paddingBottom: 60,
|
||||
gap: 12,
|
||||
},
|
||||
section: {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 384 KiB |
@@ -0,0 +1,46 @@
|
||||
module.exports = {
|
||||
appId: 'com.carryyourlive.desktop',
|
||||
productName: 'Carry Your Live',
|
||||
copyright: 'Copyright © 2026',
|
||||
|
||||
directories: {
|
||||
output: 'desktop-build',
|
||||
buildResources: 'build',
|
||||
},
|
||||
|
||||
// Override the entry point in the packaged app (repo keeps expo-router/entry
|
||||
// as `main` so `expo start` keeps working).
|
||||
extraMetadata: {
|
||||
main: 'electron/main.js',
|
||||
},
|
||||
|
||||
// The Electron shell needs zero runtime dependencies: main/preload use Node
|
||||
// built-ins only, and the renderer is fully bundled by Metro into dist/.
|
||||
// Returning false tells electron-builder that node_modules are handled
|
||||
// externally — i.e. don't bundle production deps (expo, react-native, ...)
|
||||
// into the package. This keeps the deb ~100x smaller.
|
||||
// NOTE: do not set npmRebuild:false here — installAppDependencies()
|
||||
// short-circuits on it *before* evaluating beforeBuild, which would
|
||||
// re-enable bundling of node_modules.
|
||||
beforeBuild: async () => false,
|
||||
// Sanity check validates the ORIGINAL package.json `main` (expo-router/entry)
|
||||
// against the asar, which no longer exists once deps are excluded.
|
||||
// The real entry point is set via extraMetadata.main above.
|
||||
disableSanityCheckAsar: true,
|
||||
|
||||
files: ['dist/**/*', 'electron/**/*', 'assets/icon.png', 'package.json'],
|
||||
|
||||
// Icons are generated from build/icon.png (1024x1024) for each platform.
|
||||
mac: {
|
||||
category: 'public.app-category.productivity',
|
||||
target: ['dmg', 'zip'],
|
||||
},
|
||||
win: {
|
||||
target: ['nsis'],
|
||||
},
|
||||
linux: {
|
||||
category: 'Productivity',
|
||||
maintainer: 'carry-your-live',
|
||||
target: ['deb', 'AppImage'],
|
||||
},
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
/* global __dirname */
|
||||
const { app, BrowserWindow } = require('electron')
|
||||
const { app, BrowserWindow, ipcMain } = require('electron')
|
||||
const path = require('path')
|
||||
|
||||
const DEV_SERVER_URL = process.env.ELECTRON_START_URL
|
||||
|
||||
function createWindow() {
|
||||
const win = new BrowserWindow({
|
||||
width: 1200,
|
||||
@@ -11,24 +13,39 @@ function createWindow() {
|
||||
webPreferences: {
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
sandbox: true,
|
||||
webSecurity: true,
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
},
|
||||
icon: path.join(__dirname, '../assets/icon.png'),
|
||||
titleBarStyle: 'default',
|
||||
show: false,
|
||||
})
|
||||
|
||||
win.loadFile(path.join(__dirname, '../dist/index.html'))
|
||||
if (DEV_SERVER_URL) {
|
||||
win.loadURL(DEV_SERVER_URL)
|
||||
} else {
|
||||
win.loadFile(path.join(__dirname, '../dist/index.html'))
|
||||
}
|
||||
|
||||
win.once('ready-to-show', () => {
|
||||
win.show()
|
||||
})
|
||||
|
||||
win.on('closed', () => {
|
||||
app.quit()
|
||||
// In dev, reload instead of quitting when the renderer crashes / navigates away
|
||||
win.webContents.on('didFailLoad', (_event, code, desc) => {
|
||||
console.error(`[electron] failed to load: ${code} ${desc}`)
|
||||
})
|
||||
|
||||
return win
|
||||
}
|
||||
|
||||
ipcMain.handle('app:get-info', () => ({
|
||||
version: app.getVersion(),
|
||||
name: app.getName(),
|
||||
platform: process.platform,
|
||||
}))
|
||||
|
||||
app.whenReady().then(createWindow)
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
@@ -41,4 +58,4 @@ app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
const { contextBridge, ipcRenderer } = require('electron')
|
||||
|
||||
contextBridge.exposeInMainWorld('electron', {
|
||||
isElectron: true,
|
||||
getAppInfo: () => ipcRenderer.invoke('app:get-info'),
|
||||
})
|
||||
@@ -1,21 +0,0 @@
|
||||
server {
|
||||
listen 8081;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /api {
|
||||
proxy_pass http://backend:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
}
|
||||
Generated
+2758
-67
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@
|
||||
"name": "carry-your-live",
|
||||
"version": "1.0.0",
|
||||
"main": "expo-router/entry",
|
||||
"homepage": "https://gitea.techmos.org/tech08mag/carry-your-live",
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.3.4",
|
||||
"@nozbe/watermelondb": "^0.28.1-0",
|
||||
@@ -9,8 +10,10 @@
|
||||
"@react-native-community/datetimepicker": "9.1.0",
|
||||
"date-fns": "^3.6.0",
|
||||
"expo": "~57.0.10",
|
||||
"expo-blur": "~57.0.2",
|
||||
"expo-constants": "~57.0.9",
|
||||
"expo-dev-client": "~57.0.1",
|
||||
"expo-device": "^57.0.1",
|
||||
"expo-linking": "~57.0.5",
|
||||
"expo-notifications": "~57.0.8",
|
||||
"expo-router": "~57.0.10",
|
||||
@@ -33,6 +36,9 @@
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.20.0",
|
||||
"@types/react": "~19.2.2",
|
||||
"babel-preset-expo": "~57.0.0",
|
||||
"electron": "^41.7.1",
|
||||
"electron-builder": "^26.15.3",
|
||||
"eslint": "^9.0.0",
|
||||
"eslint-config-expo": "~57.0.1",
|
||||
"patch-package": "^8.0.1",
|
||||
@@ -47,6 +53,11 @@
|
||||
"lint": "expo lint",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"format": "prettier --write \"**/*.{ts,tsx,json,md}\"",
|
||||
"export:web": "expo export --platform web",
|
||||
"desktop": "electron electron/main.js",
|
||||
"desktop:dev": "ELECTRON_START_URL=http://localhost:8081 electron electron/main.js",
|
||||
"desktop:dist": "npm run export:web && electron-builder --config electron-builder.config.js",
|
||||
"desktop:dir": "npm run export:web && electron-builder --dir --config electron-builder.config.js",
|
||||
"build:ios": "eas build --platform ios",
|
||||
"build:android": "eas build --platform android",
|
||||
"build:web": "eas build --platform web",
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Modal, View, Text, StyleSheet, TextInput, TouchableOpacity, ScrollView, KeyboardAvoidingView, Platform, Pressable } from 'react-native';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Modal, View, Text, StyleSheet, TextInput, TouchableOpacity, ScrollView, KeyboardAvoidingView, Platform, Pressable, useWindowDimensions } from 'react-native';
|
||||
import Category from '@/models/Category';
|
||||
import { useSettings } from '@/theme';
|
||||
import { CATEGORY_COLORS } from '@/constants';
|
||||
import { createCategory, updateCategory, deleteCategory } from '@/utils/categoryActions';
|
||||
import { confirmAction } from '@/utils/confirm';
|
||||
import ColorPickerInput from '@/components/ColorPickerInput';
|
||||
import Svg, { Path } from 'react-native-svg';
|
||||
import Svg, { Path, Circle } from 'react-native-svg';
|
||||
|
||||
interface CategoryEditorModalProps {
|
||||
visible: boolean;
|
||||
@@ -20,6 +20,7 @@ export function CategoryEditorModal({ visible, category, categoryCount, onClose
|
||||
const [name, setName] = useState('');
|
||||
const [color, setColor] = useState(CATEGORY_COLORS[0]);
|
||||
const [prevVisible, setPrevVisible] = useState(visible);
|
||||
const { height: screenHeight } = useWindowDimensions();
|
||||
|
||||
if (prevVisible !== visible) {
|
||||
setPrevVisible(visible);
|
||||
@@ -45,8 +46,8 @@ export function CategoryEditorModal({ visible, category, categoryCount, onClose
|
||||
const handleDelete = () => {
|
||||
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
|
||||
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
||||
<KeyboardAvoidingView
|
||||
style={styles.fill}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
keyboardVerticalOffset={0}
|
||||
>
|
||||
<Pressable
|
||||
style={styles.overlay}
|
||||
@@ -74,14 +76,16 @@ export function CategoryEditorModal({ visible, category, categoryCount, onClose
|
||||
<ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<Text style={[styles.title, { color: theme.text }]}>
|
||||
{category ? 'Edit Category' : 'New Category'}
|
||||
{category ? 'Edit Tag' : 'New Tag'}
|
||||
</Text>
|
||||
<TouchableOpacity onPress={onClose} style={styles.closeButton} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} accessibilityRole="button" accessibilityLabel="Close category editor">
|
||||
<Svg width={18} height={18} viewBox="0 0 24 24">
|
||||
<Path d="M18 6L6 18M6 6l12 12" stroke={theme.textSecondary} strokeWidth={2.2} strokeLinecap="round" />
|
||||
<TouchableOpacity onPress={onClose} style={styles.closeButton} hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }} accessibilityRole="button" accessibilityLabel="Close tag editor">
|
||||
<Svg width={22} height={22} viewBox="0 0 24 24">
|
||||
<Circle cx={12} cy={12} r={10} fill={theme.cardAlt} />
|
||||
<Path d="M18 6L6 18M6 6l12 12" stroke={theme.textSecondary} strokeWidth={2.5} strokeLinecap="round" />
|
||||
</Svg>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
@@ -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,
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
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';
|
||||
import Svg, { Path, Circle } from 'react-native-svg';
|
||||
|
||||
interface CategoryFilterProps {
|
||||
selected: string;
|
||||
onSelect: (categoryId: string) => void;
|
||||
selected: string[];
|
||||
onSelect: (categoryIds: string[]) => void;
|
||||
}
|
||||
|
||||
export function CategoryFilter({ selected, onSelect }: CategoryFilterProps) {
|
||||
@@ -15,6 +15,29 @@ export function CategoryFilter({ selected, onSelect }: CategoryFilterProps) {
|
||||
const { theme } = useSettings();
|
||||
const router = useRouter();
|
||||
|
||||
const selectedSet = new Set(selected);
|
||||
const isAnythingSelected = selected.length > 0;
|
||||
const [scaleAnim] = useState(new Animated.Value(1));
|
||||
|
||||
const toggle = (id: string) => {
|
||||
const next = new Set(selectedSet);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
onSelect(Array.from(next));
|
||||
};
|
||||
|
||||
const animatePress = (id: string, isPressing: boolean) => {
|
||||
Animated.spring(scaleAnim, {
|
||||
toValue: isPressing ? 0.92 : 1,
|
||||
friction: 8,
|
||||
tension: 100,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
horizontal
|
||||
@@ -25,10 +48,14 @@ export function CategoryFilter({ selected, onSelect }: CategoryFilterProps) {
|
||||
<CategoryButton
|
||||
id="all"
|
||||
name="All"
|
||||
color="#9E9E9E"
|
||||
selected={selected === 'all'}
|
||||
onPress={() => onSelect('all')}
|
||||
color={theme.textMuted}
|
||||
selected={!isAnythingSelected}
|
||||
onPress={() => onSelect([])}
|
||||
onPressIn={() => animatePress('all', true)}
|
||||
onPressOut={() => animatePress('all', false)}
|
||||
theme={theme}
|
||||
isAllCategory={true}
|
||||
scaleAnim={scaleAnim}
|
||||
/>
|
||||
{categories.map((category) => (
|
||||
<CategoryButton
|
||||
@@ -36,9 +63,12 @@ export function CategoryFilter({ selected, onSelect }: CategoryFilterProps) {
|
||||
id={category.id}
|
||||
name={category.name}
|
||||
color={category.color}
|
||||
selected={selected === category.id}
|
||||
onPress={() => onSelect(category.id)}
|
||||
selected={selectedSet.has(category.id)}
|
||||
onPress={() => toggle(category.id)}
|
||||
onPressIn={() => animatePress(category.id, true)}
|
||||
onPressOut={() => animatePress(category.id, false)}
|
||||
theme={theme}
|
||||
scaleAnim={scaleAnim}
|
||||
/>
|
||||
))}
|
||||
<TouchableOpacity
|
||||
@@ -46,7 +76,7 @@ export function CategoryFilter({ selected, onSelect }: CategoryFilterProps) {
|
||||
onPress={() => router.push('/settings')}
|
||||
activeOpacity={0.7}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Manage categories"
|
||||
accessibilityLabel="Manage tags"
|
||||
hitSlop={{ top: 8, bottom: 8, left: 4, right: 4 }}
|
||||
>
|
||||
<Svg width={14} height={14} viewBox="0 0 24 24">
|
||||
@@ -67,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 (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.button,
|
||||
{ backgroundColor: theme.card, borderColor: theme.borderStrong },
|
||||
selected && { borderColor: theme.accent, backgroundColor: theme.accentSoft },
|
||||
{ backgroundColor: color },
|
||||
selected && styles.buttonSelected,
|
||||
animatedStyle,
|
||||
]}
|
||||
onPress={onPress}
|
||||
activeOpacity={0.8}
|
||||
hitSlop={{ top: 6, bottom: 6, left: 4, right: 4 }}
|
||||
onPressIn={onPressIn}
|
||||
onPressOut={onPressOut}
|
||||
activeOpacity={1}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 8, right: 8 }}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.colorDot,
|
||||
{ backgroundColor: color },
|
||||
]}
|
||||
/>
|
||||
<Text style={[
|
||||
styles.buttonText,
|
||||
{ color: theme.textSecondary },
|
||||
selected && { color: theme.text, fontWeight: '600' },
|
||||
]}>
|
||||
{name}
|
||||
</Text>
|
||||
{isAllCategory ? (
|
||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||
<Circle cx={12} cy={12} r={9} stroke="#FFFFFF" strokeWidth={2.5} fill="none" opacity={0.9} />
|
||||
<Path d="M6 12l4 4 8-8" stroke="#FFFFFF" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" opacity={0.9} />
|
||||
</Svg>
|
||||
) : null}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
@@ -109,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',
|
||||
|
||||
@@ -5,8 +5,8 @@ import { useSettings } from '@/theme';
|
||||
import Svg, { Path } from 'react-native-svg';
|
||||
|
||||
interface CategorySelectorProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
value: string[];
|
||||
onChange: (value: string[]) => void;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@@ -15,11 +15,30 @@ export function CategorySelector({ value, onChange, error }: CategorySelectorPro
|
||||
const { theme } = useSettings();
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
|
||||
const selectedCategory = categories.find(c => c.id === value);
|
||||
const selected = new Set(value);
|
||||
const selectedCategories = categories.filter((c) => selected.has(c.id));
|
||||
|
||||
const toggle = (id: string) => {
|
||||
const next = new Set(selected);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
onChange(Array.from(next));
|
||||
};
|
||||
|
||||
const summary =
|
||||
selectedCategories.length === 0
|
||||
? 'None'
|
||||
: selectedCategories
|
||||
.slice(0, 2)
|
||||
.map((c) => c.name)
|
||||
.join(', ') + (selectedCategories.length > 2 ? ` +${selectedCategories.length - 2}` : '');
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={[styles.label, { color: theme.text }]}>Category</Text>
|
||||
<Text style={[styles.label, { color: theme.text }]}>Tags</Text>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.selectorButton,
|
||||
@@ -28,20 +47,28 @@ export function CategorySelector({ value, onChange, error }: CategorySelectorPro
|
||||
onPress={() => setShowModal(true)}
|
||||
activeOpacity={0.8}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Select category"
|
||||
accessibilityHint="Opens a list of categories to choose from"
|
||||
accessibilityLabel="Select tags"
|
||||
accessibilityHint="Opens a list of tags to choose from"
|
||||
>
|
||||
<View style={styles.selectorContent}>
|
||||
<View style={styles.selectorRow}>
|
||||
<View style={[styles.colorCircle, { backgroundColor: selectedCategory?.color || '#9E9E9E' }]} />
|
||||
<Text style={[styles.selectorValue, { color: theme.text }]}>{selectedCategory?.name || 'None'}</Text>
|
||||
</View>
|
||||
{selectedCategories.length > 0 ? (
|
||||
<View style={styles.chipRow}>
|
||||
{selectedCategories.slice(0, 3).map((c) => (
|
||||
<View key={c.id} style={[styles.chip, { backgroundColor: theme.cardAlt, borderColor: theme.border }]}>
|
||||
<View style={[styles.colorCircle, { backgroundColor: c.color }]} />
|
||||
<Text style={[styles.chipText, { color: theme.textSecondary }]} numberOfLines={1}>{c.name}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
) : (
|
||||
<Text style={[styles.selectorValue, { color: theme.textMuted }]}>None</Text>
|
||||
)}
|
||||
</View>
|
||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||
<Path d="M9 18l6-6-6-6" stroke={theme.textSecondary} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
</Svg>
|
||||
</TouchableOpacity>
|
||||
{error && <Text style={[styles.errorText, { color: '#E53935' }]}>{error}</Text>}
|
||||
{error && <Text style={[styles.errorText, { color: theme.dangerStrong }]}>{error}</Text>}
|
||||
|
||||
<Modal visible={showModal} transparent animationType="fade" onRequestClose={() => setShowModal(false)}>
|
||||
<KeyboardAvoidingView
|
||||
@@ -51,67 +78,62 @@ export function CategorySelector({ value, onChange, error }: CategorySelectorPro
|
||||
<Pressable style={styles.modalOverlay} onPress={() => setShowModal(false)}>
|
||||
<Pressable style={[styles.modalSheet, { backgroundColor: theme.sheetBg }]}>
|
||||
<View style={styles.modalHeader}>
|
||||
<Text style={[styles.modalTitle, { color: theme.text }]}>Select Category</Text>
|
||||
<TouchableOpacity onPress={() => setShowModal(false)} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} accessibilityRole="button" accessibilityLabel="Close category picker">
|
||||
<Text style={[styles.closeText, { color: theme.textMuted }]}>✕</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={[styles.modalTitle, { color: theme.text }]}>Select Tags</Text>
|
||||
<View style={styles.modalHeaderRight}>
|
||||
<TouchableOpacity
|
||||
onPress={() => { onChange([]); setShowModal(false); }}
|
||||
activeOpacity={0.7}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Clear all tags"
|
||||
>
|
||||
<Text style={[styles.clearText, { color: theme.textMuted }]}>Clear</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={() => setShowModal(false)}
|
||||
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Done selecting tags"
|
||||
>
|
||||
<Text style={[styles.doneText, { color: theme.accent }]}>Done</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
<ScrollView contentContainerStyle={styles.modalContent}>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.modalOption,
|
||||
{ backgroundColor: theme.card, borderColor: theme.borderStrong },
|
||||
!value && { borderColor: theme.accent, backgroundColor: theme.accentSoft, borderWidth: 2 },
|
||||
]}
|
||||
onPress={() => { onChange(''); setShowModal(false); }}
|
||||
activeOpacity={0.8}
|
||||
accessibilityRole="radio"
|
||||
accessibilityLabel="No category"
|
||||
accessibilityState={{ selected: !value }}
|
||||
>
|
||||
<View style={[styles.colorCircle, { backgroundColor: '#9E9E9E' }, !value && styles.colorCircleSelected]} />
|
||||
<Text style={[
|
||||
styles.categoryName,
|
||||
{ color: theme.textSecondary },
|
||||
!value && { color: theme.accentStrong, fontWeight: '600' },
|
||||
]}>
|
||||
None
|
||||
</Text>
|
||||
{!value && (
|
||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||
<Path d="M5 12l5 5 9-10" stroke={theme.accent} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
</Svg>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
{categories.map((category) => (
|
||||
<TouchableOpacity
|
||||
key={category.id}
|
||||
style={[
|
||||
styles.modalOption,
|
||||
{ backgroundColor: theme.card, borderColor: theme.borderStrong },
|
||||
value === category.id && { borderColor: theme.accent, backgroundColor: theme.accentSoft, borderWidth: 2 },
|
||||
]}
|
||||
onPress={() => { onChange(category.id); setShowModal(false); }}
|
||||
activeOpacity={0.8}
|
||||
accessibilityRole="radio"
|
||||
accessibilityLabel={`Category ${category.name}`}
|
||||
accessibilityState={{ selected: value === category.id }}
|
||||
>
|
||||
<View style={[styles.colorCircle, { backgroundColor: category.color }, value === category.id && styles.colorCircleSelected]} />
|
||||
<Text style={[
|
||||
styles.categoryName,
|
||||
{ color: theme.textSecondary },
|
||||
value === category.id && { color: theme.accentStrong, fontWeight: '600' },
|
||||
]}>
|
||||
{category.name}
|
||||
</Text>
|
||||
{value === category.id && (
|
||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||
<Path d="M5 12l5 5 9-10" stroke={theme.accent} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
</Svg>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
<Text style={[styles.modalHint, { color: theme.textMuted }]}>
|
||||
A task can have multiple tags. Selected: {selected.size}
|
||||
</Text>
|
||||
{categories.map((category) => {
|
||||
const isSelected = selected.has(category.id);
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={category.id}
|
||||
style={[
|
||||
styles.modalOption,
|
||||
{ backgroundColor: theme.card, borderColor: theme.borderStrong },
|
||||
isSelected && { borderColor: theme.accent, backgroundColor: theme.accentSoft, borderWidth: 2 },
|
||||
]}
|
||||
onPress={() => toggle(category.id)}
|
||||
activeOpacity={0.8}
|
||||
accessibilityRole="checkbox"
|
||||
accessibilityLabel={`Tag ${category.name}`}
|
||||
accessibilityState={{ checked: isSelected }}
|
||||
>
|
||||
<View style={[styles.colorCircle, { backgroundColor: category.color }, isSelected && styles.colorCircleSelected]} />
|
||||
<Text style={[
|
||||
styles.categoryName,
|
||||
{ color: theme.textSecondary },
|
||||
isSelected && { color: theme.text, fontWeight: '600' },
|
||||
]}>
|
||||
{category.name}
|
||||
</Text>
|
||||
{isSelected && (
|
||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||
<Path d="M5 12l5 5 9-10" stroke={theme.accent} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
</Svg>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
@@ -141,11 +163,25 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
selectorContent: {
|
||||
flex: 1,
|
||||
paddingRight: 8,
|
||||
},
|
||||
selectorRow: {
|
||||
chipRow: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 6,
|
||||
},
|
||||
chip: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
gap: 6,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
},
|
||||
chipText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
},
|
||||
colorCircle: {
|
||||
width: 12,
|
||||
@@ -185,13 +221,27 @@ const styles = StyleSheet.create({
|
||||
padding: 20,
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
modalHeaderRight: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 16,
|
||||
},
|
||||
modalTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
},
|
||||
closeText: {
|
||||
fontSize: 22,
|
||||
fontWeight: '300',
|
||||
clearText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
},
|
||||
doneText: {
|
||||
fontSize: 15,
|
||||
fontWeight: '700',
|
||||
},
|
||||
modalHint: {
|
||||
fontSize: 12,
|
||||
paddingHorizontal: 4,
|
||||
paddingBottom: 4,
|
||||
},
|
||||
modalContent: {
|
||||
padding: 12,
|
||||
|
||||
@@ -49,7 +49,7 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
|
||||
styles.pickerButton,
|
||||
{ backgroundColor: theme.inputBg, borderColor: theme.borderStrong },
|
||||
field.value && { borderColor: theme.accent, backgroundColor: theme.accentSoft },
|
||||
fieldName === 'endTime' && endTimeInvalid && { borderColor: '#E53935', backgroundColor: theme.accentSoft },
|
||||
fieldName === 'endTime' && endTimeInvalid && { borderColor: theme.dangerStrong, backgroundColor: theme.accentSoft },
|
||||
]}
|
||||
onPress={onPress}
|
||||
activeOpacity={0.8}
|
||||
@@ -150,6 +150,28 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={[styles.calendarButton, { backgroundColor: theme.inputBg, borderColor: theme.borderStrong }]}
|
||||
onPress={() => setPicker('date')}
|
||||
activeOpacity={0.8}
|
||||
accessibilityLabel="Open calendar"
|
||||
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
||||
>
|
||||
<Svg width={22} height={22} viewBox="0 0 24 24">
|
||||
<Path
|
||||
d="M8 2v4M16 2v4M3 10h18M3 18h18"
|
||||
stroke={theme.textSecondary}
|
||||
strokeWidth={1.8}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<Path
|
||||
d="M10 2H14a2 2 0 0 1 2 2v16a2 2 0 0 1-2 2H10a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2z"
|
||||
stroke={theme.textSecondary}
|
||||
strokeWidth={1.8}
|
||||
fill="none"
|
||||
/>
|
||||
</Svg>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<Controller
|
||||
control={control}
|
||||
@@ -181,12 +203,12 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
|
||||
</View>
|
||||
|
||||
{endTimeInvalid && (
|
||||
<View style={[styles.warningRow, { backgroundColor: theme.accentSoft, borderColor: '#E53935' }]}>
|
||||
<View style={[styles.warningRow, { backgroundColor: theme.accentSoft, borderColor: theme.dangerStrong }]}>
|
||||
<Svg width={14} height={14} viewBox="0 0 24 24">
|
||||
<Circle cx={12} cy={12} r={10} stroke="#EF5350" strokeWidth={1.8} fill="none" />
|
||||
<Path d="M12 8v5M12 16.5v.5" stroke="#E53935" strokeWidth={1.8} strokeLinecap="round" />
|
||||
<Circle cx={12} cy={12} r={10} stroke={theme.dangerStrong} strokeWidth={1.8} fill="none" />
|
||||
<Path d="M12 8v5M12 16.5v.5" stroke={theme.dangerStrong} strokeWidth={1.8} strokeLinecap="round" />
|
||||
</Svg>
|
||||
<Text style={[styles.warningText, { color: '#E53935' }]}>
|
||||
<Text style={[styles.warningText, { color: theme.dangerStrong }]}>
|
||||
{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',
|
||||
|
||||
@@ -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 (
|
||||
<View style={styles.container}>
|
||||
@@ -35,7 +36,7 @@ export function DescriptionInput({ error, ...props }: DescriptionInputProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
const createStyles = (theme: ThemeColors) => StyleSheet.create({
|
||||
container: {
|
||||
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,
|
||||
},
|
||||
});
|
||||
@@ -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() {
|
||||
>
|
||||
<Svg width={24} height={24} viewBox="0 0 24 24">
|
||||
<Path
|
||||
d="M12 5v14M5 12h14"
|
||||
d="M12 19V5M12 5l-5 5M12 5l5 5"
|
||||
stroke={theme.accentText}
|
||||
strokeWidth={2.5}
|
||||
strokeLinecap="round"
|
||||
@@ -79,6 +79,9 @@ export function FloatingActionButton() {
|
||||
</Svg>
|
||||
</Animated.View>
|
||||
</TouchableOpacity>
|
||||
<View style={styles.labelContainer}>
|
||||
<Text style={[styles.label, { color: theme.accentText }]}>Add a Task</Text>
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,279 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
KeyboardAvoidingView,
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import { useSettings } from '@/theme';
|
||||
import { forgotPassword, resetPassword } from '@/services/auth';
|
||||
|
||||
interface ForgotPasswordModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type ForgotPasswordStep = 'enterUsername' | 'enterResetCode';
|
||||
|
||||
export function ForgotPasswordModal({ visible, onClose }: ForgotPasswordModalProps) {
|
||||
const { theme } = useSettings();
|
||||
const [step, setStep] = useState<ForgotPasswordStep>('enterUsername');
|
||||
const [username, setUsername] = useState('');
|
||||
const [resetToken, setResetToken] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
const handleRequestReset = async () => {
|
||||
if (busy) return;
|
||||
if (!username.trim()) {
|
||||
setError('Username is required');
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await forgotPassword(username.trim());
|
||||
if (result.success) {
|
||||
if (result.resetToken) {
|
||||
Alert.alert('Reset Token (Dev Mode)', `Your reset token is: ${result.resetToken}\n\nEnter this token on the next screen.`, [
|
||||
{ text: 'OK', onPress: () => setStep('enterResetCode') },
|
||||
]);
|
||||
} else {
|
||||
Alert.alert('Reset Email Sent', 'If the account exists, a password reset link has been sent.', [
|
||||
{ text: 'OK', onPress: () => setStep('enterResetCode') },
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
setError('Failed to request password reset');
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err?.message ?? 'Failed to request password reset');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetPassword = async () => {
|
||||
if (busy) return;
|
||||
if (!resetToken.trim()) {
|
||||
setError('Reset token is required');
|
||||
return;
|
||||
}
|
||||
if (newPassword.length < 8) {
|
||||
setError('Password must be at least 8 characters');
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await resetPassword(resetToken.trim(), newPassword);
|
||||
if (result.success) {
|
||||
Alert.alert('Password Reset', 'Your password has been reset successfully.', [
|
||||
{ text: 'OK', onPress: onClose },
|
||||
]);
|
||||
} else {
|
||||
setError('Failed to reset password');
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err?.message ?? 'Failed to reset password. Token may be invalid or expired.');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
if (step === 'enterResetCode') {
|
||||
setStep('enterUsername');
|
||||
setResetToken('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
setError(null);
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const renderEnterUsername = () => (
|
||||
<View style={styles.body}>
|
||||
<Text style={[styles.subtitle, { color: theme.textSecondary }]}>
|
||||
Enter your username to receive a password reset link.
|
||||
</Text>
|
||||
<TextInput
|
||||
style={[styles.input, { backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text }]}
|
||||
placeholder="Username"
|
||||
placeholderTextColor={theme.textMuted}
|
||||
value={username}
|
||||
onChangeText={setUsername}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
{error && <Text style={[styles.errorText, { color: theme.dangerStrong }]}>{error}</Text>}
|
||||
<TouchableOpacity
|
||||
style={[styles.primaryButton, { backgroundColor: theme.accent }]}
|
||||
onPress={handleRequestReset}
|
||||
disabled={busy}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
{busy ? (
|
||||
<ActivityIndicator color={theme.accentText} />
|
||||
) : (
|
||||
<Text style={[styles.primaryButtonText, { color: theme.accentText }]}>Send Reset Link</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
|
||||
const renderEnterResetCode = () => (
|
||||
<View style={styles.body}>
|
||||
<Text style={[styles.subtitle, { color: theme.textSecondary }]}>
|
||||
Enter the reset token and your new password.
|
||||
</Text>
|
||||
<TextInput
|
||||
style={[styles.input, { backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text }]}
|
||||
placeholder="Reset Token"
|
||||
placeholderTextColor={theme.textMuted}
|
||||
value={resetToken}
|
||||
onChangeText={setResetToken}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
<TextInput
|
||||
style={[styles.input, { backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text }]}
|
||||
placeholder="New Password (min 8 chars)"
|
||||
placeholderTextColor={theme.textMuted}
|
||||
value={newPassword}
|
||||
onChangeText={setNewPassword}
|
||||
secureTextEntry
|
||||
/>
|
||||
<TextInput
|
||||
style={[styles.input, { backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text }]}
|
||||
placeholder="Confirm New Password"
|
||||
placeholderTextColor={theme.textMuted}
|
||||
value={confirmPassword}
|
||||
onChangeText={setConfirmPassword}
|
||||
secureTextEntry
|
||||
/>
|
||||
{error && <Text style={[styles.errorText, { color: theme.dangerStrong }]}>{error}</Text>}
|
||||
<TouchableOpacity
|
||||
style={[styles.primaryButton, { backgroundColor: theme.accent }]}
|
||||
onPress={handleResetPassword}
|
||||
disabled={busy}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
{busy ? (
|
||||
<ActivityIndicator color={theme.accentText} />
|
||||
) : (
|
||||
<Text style={[styles.primaryButtonText, { color: theme.accentText }]}>Reset Password</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="fade" onRequestClose={handleBack}>
|
||||
<KeyboardAvoidingView
|
||||
style={styles.overlay}
|
||||
behavior="padding"
|
||||
>
|
||||
<TouchableOpacity style={styles.backdrop} activeOpacity={1} onPress={handleBack} />
|
||||
<View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}>
|
||||
<View style={[styles.handle, { backgroundColor: theme.borderStrong }]} />
|
||||
<Text style={[styles.title, { color: theme.text }]}>Reset Password</Text>
|
||||
|
||||
{step === 'enterUsername' ? renderEnterUsername() : renderEnterResetCode()}
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.secondaryButton, { borderColor: theme.borderStrong }]}
|
||||
onPress={handleBack}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={[styles.secondaryButtonText, { color: theme.textSecondary }]}>Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
backdrop: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0,0,0,0.4)',
|
||||
},
|
||||
sheet: {
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
paddingTop: 8,
|
||||
paddingHorizontal: 20,
|
||||
paddingBottom: 32,
|
||||
},
|
||||
handle: {
|
||||
width: 40,
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
alignSelf: 'center',
|
||||
marginBottom: 12,
|
||||
},
|
||||
title: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
textAlign: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
body: {
|
||||
gap: 12,
|
||||
marginBottom: 16,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 14,
|
||||
},
|
||||
input: {
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 12,
|
||||
fontSize: 15,
|
||||
},
|
||||
primaryButton: {
|
||||
paddingVertical: 14,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: 50,
|
||||
},
|
||||
primaryButtonText: {
|
||||
fontSize: 15,
|
||||
fontWeight: '700',
|
||||
},
|
||||
secondaryButton: {
|
||||
paddingVertical: 12,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
alignItems: 'center',
|
||||
},
|
||||
secondaryButtonText: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
},
|
||||
});
|
||||
@@ -1,9 +1,11 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { View, StyleSheet, TextInput, TouchableOpacity, Keyboard, Text } from 'react-native';
|
||||
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
||||
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';
|
||||
import { OptionPickerModal } from '@/components/OptionPickerModal';
|
||||
import { tagsToString } from '@/types';
|
||||
import Svg, { Path } from 'react-native-svg';
|
||||
import { subscribeToQuickAdd } from '@/utils/quickAddFocus';
|
||||
|
||||
@@ -12,6 +14,21 @@ interface QuickAddBarProps {
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
const BlurContainer = ({ children, style, theme }: { children: React.ReactNode; style: any; theme: any }) => {
|
||||
if (Platform.OS === 'web') {
|
||||
return (
|
||||
<View style={[style, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }]}>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<BlurView style={style} intensity={80} tint="dark">
|
||||
{children}
|
||||
</BlurView>
|
||||
);
|
||||
};
|
||||
|
||||
export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
|
||||
const { theme, defaultCategoryId } = useSettings();
|
||||
const categories = useCategories();
|
||||
@@ -20,7 +37,11 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
|
||||
const [categoryPickerVisible, setCategoryPickerVisible] = useState(false);
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
|
||||
const categoryColor = categories.find((c) => c.id === categoryId)?.color ?? '#9E9E9E';
|
||||
const visibleCategories = useMemo(
|
||||
() => categories.filter((c) => c.name.toLowerCase() !== 'calendar'),
|
||||
[categories]
|
||||
);
|
||||
const categoryColor = visibleCategories.find((c) => c.id === categoryId)?.color ?? theme.textMuted;
|
||||
|
||||
useEffect(() => {
|
||||
return subscribeToQuickAdd(() => {
|
||||
@@ -40,6 +61,7 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
|
||||
t.title = trimmed;
|
||||
t.description = '';
|
||||
t.categoryId = categoryId || '';
|
||||
t.tags = tagsToString(categoryId ? [categoryId] : []);
|
||||
t.priority = 'none';
|
||||
t.completed = false;
|
||||
t.dueDate = dueDate;
|
||||
@@ -60,58 +82,57 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
|
||||
|
||||
return (
|
||||
<View style={styles.wrapper}>
|
||||
<View style={[styles.bar, { backgroundColor: theme.card, borderColor: theme.border }]}>
|
||||
<TouchableOpacity
|
||||
style={[styles.categoryButton, { borderColor: theme.borderStrong, backgroundColor: theme.cardAlt }]}
|
||||
onPress={() => setCategoryPickerVisible(true)}
|
||||
activeOpacity={0.7}
|
||||
accessibilityLabel="Select category"
|
||||
>
|
||||
<View style={[styles.categoryButtonDot, { backgroundColor: categoryColor }]} />
|
||||
<Svg width={12} height={12} viewBox="0 0 24 24">
|
||||
<Path d="M6 9l6 6 6-6" stroke={theme.textSecondary} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
</Svg>
|
||||
</TouchableOpacity>
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
style={[styles.input, { color: theme.text }]}
|
||||
placeholder={placeholder ?? 'Add a task'}
|
||||
placeholderTextColor={theme.textMuted}
|
||||
value={title}
|
||||
onChangeText={setTitle}
|
||||
onSubmitEditing={handleAdd}
|
||||
returnKeyType="done"
|
||||
accessibilityLabel="Quick add task"
|
||||
accessibilityHint="Enter a task name and press the add button"
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={[styles.submit, { backgroundColor: theme.accent }, title.trim() ? {} : styles.submitDisabled]}
|
||||
onPress={title.trim() ? handleAdd : undefined}
|
||||
disabled={!title.trim()}
|
||||
activeOpacity={0.8}
|
||||
accessibilityLabel="Add task"
|
||||
accessibilityHint="Adds the entered task to the list"
|
||||
accessibilityState={{ disabled: !title.trim() }}
|
||||
>
|
||||
<View style={styles.submitLabelRow}>
|
||||
<Text style={[styles.submitText, { color: theme.accentText }]}>Add a Task</Text>
|
||||
<Svg width={18} height={18} viewBox="0 0 24 24">
|
||||
<BlurContainer theme={theme} style={styles.blurContainer}>
|
||||
<View style={[styles.bar, { backgroundColor: 'rgba(255,255,255,0.05)', borderColor: 'rgba(255,255,255,0.1)' }]}>
|
||||
<TouchableOpacity
|
||||
style={[styles.categoryButton, { borderColor: theme.borderStrong, backgroundColor: theme.cardAlt }]}
|
||||
onPress={() => setCategoryPickerVisible(true)}
|
||||
activeOpacity={0.7}
|
||||
accessibilityLabel="Select tags"
|
||||
>
|
||||
<View style={[styles.categoryButtonDot, { backgroundColor: categoryColor }]} />
|
||||
<Svg width={12} height={12} viewBox="0 0 24 24">
|
||||
<Path d="M6 9l6 6 6-6" stroke={theme.textSecondary} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
</Svg>
|
||||
</TouchableOpacity>
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
style={[styles.input, { color: theme.text }]}
|
||||
placeholder={placeholder ?? 'Add a task'}
|
||||
placeholderTextColor={theme.textMuted}
|
||||
value={title}
|
||||
onChangeText={setTitle}
|
||||
onSubmitEditing={handleAdd}
|
||||
returnKeyType="done"
|
||||
accessibilityLabel="Quick add task"
|
||||
accessibilityHint="Enter a task name and press the add button"
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={[styles.submit, { backgroundColor: theme.accent }, title.trim() ? {} : styles.submitDisabled]}
|
||||
onPress={title.trim() ? handleAdd : undefined}
|
||||
disabled={!title.trim()}
|
||||
activeOpacity={0.8}
|
||||
accessibilityLabel="Add task"
|
||||
accessibilityHint="Adds the entered task to the list"
|
||||
accessibilityState={{ disabled: !title.trim() }}
|
||||
>
|
||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||
<Path
|
||||
d="M12 5v14M12 5l-5 5M12 5l5 5"
|
||||
d="M12 19V5M12 5l-5 5M12 5l5 5"
|
||||
stroke={theme.accentText}
|
||||
strokeWidth={2.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</Svg>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</BlurContainer>
|
||||
|
||||
<OptionPickerModal
|
||||
visible={categoryPickerVisible}
|
||||
title="Select Category"
|
||||
options={[{ value: '', label: 'None', color: '#9E9E9E' }, ...categories.map((c) => ({ 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)}
|
||||
@@ -124,6 +145,11 @@ const styles = StyleSheet.create({
|
||||
wrapper: {
|
||||
paddingHorizontal: 8,
|
||||
paddingTop: 4,
|
||||
paddingBottom: 8,
|
||||
},
|
||||
blurContainer: {
|
||||
borderRadius: 14,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
bar: {
|
||||
flexDirection: 'row',
|
||||
@@ -139,6 +165,7 @@ const styles = StyleSheet.create({
|
||||
shadowOpacity: 0.10,
|
||||
shadowRadius: 6,
|
||||
elevation: 6,
|
||||
minHeight: 56,
|
||||
},
|
||||
categoryButton: {
|
||||
flexDirection: 'row',
|
||||
|
||||
@@ -2,7 +2,6 @@ import React, { useState, useEffect } from 'react';
|
||||
import { View, StyleSheet } from 'react-native';
|
||||
import { SubtaskData } from '@/types';
|
||||
import { TaskItem } from './TaskItem';
|
||||
import { useSettings } from '@/theme';
|
||||
|
||||
interface SubtaskItemProps {
|
||||
subtask: SubtaskData;
|
||||
@@ -19,6 +18,7 @@ interface SubtaskItemProps {
|
||||
onDragEnd?: (absoluteY: number) => void;
|
||||
depth?: number;
|
||||
categoryColor?: string;
|
||||
categoryColorResolver?: (categoryId: string | undefined) => string | undefined;
|
||||
registerRef?: (subtaskId: string, parentTaskId: string, ref: View | null) => void;
|
||||
hoveredId?: string | null;
|
||||
}
|
||||
@@ -38,13 +38,16 @@ export const SubtaskItem = React.memo(function SubtaskItem({
|
||||
onDragEnd,
|
||||
depth = 1,
|
||||
categoryColor,
|
||||
categoryColorResolver,
|
||||
registerRef,
|
||||
hoveredId,
|
||||
}: SubtaskItemProps) {
|
||||
const { theme } = useSettings();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const hasChildren = subtask.subtasks && subtask.subtasks.length > 0;
|
||||
const hovered = hoveredId === subtask.id;
|
||||
const effectiveColor = subtask.categoryId
|
||||
? categoryColorResolver?.(subtask.categoryId) ?? categoryColor
|
||||
: categoryColor;
|
||||
|
||||
const handleExpand = () => setExpanded(!expanded);
|
||||
|
||||
@@ -75,7 +78,7 @@ export const SubtaskItem = React.memo(function SubtaskItem({
|
||||
onDragStart={() => onDragStart?.(subtask)}
|
||||
onDragUpdate={onDragUpdate}
|
||||
onDragEnd={onDragEnd}
|
||||
categoryColor={categoryColor}
|
||||
categoryColor={effectiveColor}
|
||||
/>
|
||||
</View>
|
||||
{hasChildren && expanded && (
|
||||
@@ -99,7 +102,8 @@ export const SubtaskItem = React.memo(function SubtaskItem({
|
||||
onDragUpdate={onDragUpdate}
|
||||
onDragEnd={onDragEnd}
|
||||
depth={depth + 1}
|
||||
categoryColor={categoryColor}
|
||||
categoryColor={effectiveColor}
|
||||
categoryColorResolver={categoryColorResolver}
|
||||
registerRef={registerRef}
|
||||
hoveredId={hoveredId}
|
||||
/>
|
||||
|
||||
@@ -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<number | null>(null);
|
||||
const [result, setResult] = useState<SyncResult | null>(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) {
|
||||
</TouchableOpacity>
|
||||
|
||||
{status === 'success' && result && (
|
||||
<Text style={[styles.successText, { color: '#43A047' }]}>
|
||||
<Text style={[styles.successText, { color: theme.successStrong }]}>
|
||||
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) {
|
||||
</Text>
|
||||
)}
|
||||
{status === 'error' && error && (
|
||||
<Text style={[styles.errorText, { color: '#E53935' }]}>{error}</Text>
|
||||
<Text style={[styles.errorText, { color: theme.dangerStrong }]}>{error}</Text>
|
||||
)}
|
||||
|
||||
<TouchableOpacity
|
||||
@@ -176,7 +181,7 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
|
||||
onPress={handleSignOut}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={[styles.secondaryButtonText, { color: '#E53935' }]}>Sign Out</Text>
|
||||
<Text style={[styles.secondaryButtonText, { color: theme.dangerStrong }]}>Sign Out</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : (
|
||||
@@ -234,7 +239,17 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
|
||||
secureTextEntry
|
||||
/>
|
||||
|
||||
{error && <Text style={[styles.errorText, { color: '#E53935' }]}>{error}</Text>}
|
||||
{mode === 'login' && (
|
||||
<TouchableOpacity
|
||||
style={styles.forgotPasswordLink}
|
||||
onPress={() => setShowForgotPassword(true)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={[styles.forgotPasswordText, { color: theme.accent }]}>Forgot Password?</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{error && <Text style={[styles.errorText, { color: theme.dangerStrong }]}>{error}</Text>}
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.primaryButton, { backgroundColor: theme.accent }]}
|
||||
@@ -253,6 +268,8 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
|
||||
</View>
|
||||
)}
|
||||
|
||||
<ForgotPasswordModal visible={showForgotPassword} onClose={() => setShowForgotPassword(false)} />
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.secondaryButton, { borderColor: theme.borderStrong }]}
|
||||
onPress={onClose}
|
||||
@@ -374,4 +391,13 @@ const styles = StyleSheet.create({
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
},
|
||||
forgotPasswordLink: {
|
||||
alignSelf: 'flex-end',
|
||||
marginTop: -4,
|
||||
marginBottom: 8,
|
||||
},
|
||||
forgotPasswordText: {
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -66,14 +66,14 @@ export default function SyncStatus({ compact = false }: SyncStatusProps) {
|
||||
case 'success':
|
||||
return (
|
||||
<Svg width={16} height={16} viewBox="0 0 24 24">
|
||||
<Path d="M5 12l5 5 9-10" stroke="#66BB6A" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
<Path d="M5 12l5 5 9-10" stroke={theme.success} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
</Svg>
|
||||
);
|
||||
case 'error':
|
||||
return (
|
||||
<Svg width={16} height={16} viewBox="0 0 24 24">
|
||||
<Circle cx={12} cy={12} r={10} stroke="#EF5350" strokeWidth={2} fill="none" />
|
||||
<Path d="M12 8v4M12 16h.01" stroke="#EF5350" strokeWidth={2} strokeLinecap="round" />
|
||||
<Circle cx={12} cy={12} r={10} stroke={theme.danger} strokeWidth={2} fill="none" />
|
||||
<Path d="M12 8v4M12 16h.01" stroke={theme.danger} strokeWidth={2} strokeLinecap="round" />
|
||||
</Svg>
|
||||
);
|
||||
default:
|
||||
@@ -128,7 +128,7 @@ export default function SyncStatus({ compact = false }: SyncStatusProps) {
|
||||
</View>
|
||||
{error && (
|
||||
<View style={styles.error}>
|
||||
<Text style={[styles.errorText, { color: '#E53935' }]}>{error}</Text>
|
||||
<Text style={[styles.errorText, { color: theme.dangerStrong }]}>{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
<Text style={[styles.lastSync, { color: theme.textFaint }]}>
|
||||
|
||||
@@ -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 = {
|
||||
@@ -42,10 +43,12 @@ interface TaskItemProps {
|
||||
indented?: boolean;
|
||||
depth?: number;
|
||||
categoryColor?: string;
|
||||
categoryColors?: (string | undefined)[];
|
||||
}
|
||||
|
||||
export const TaskItem = React.memo(function TaskItem({ task, onToggle, onDelete, onPress, onLongPress, onMenuOpen, selected, selectionMode, completedSection, draggable, hovered, onDragStart, onDragUpdate, onDragEnd, onReorderStart, onReorderUpdate, onReorderEnd, expanded, indented, depth = 0, categoryColor }: TaskItemProps) {
|
||||
export const TaskItem = React.memo(function TaskItem({ task, onToggle, onDelete, onPress, onLongPress, onMenuOpen, selected, selectionMode, completedSection, draggable, hovered, onDragStart, onDragUpdate, onDragEnd, onReorderStart, onReorderUpdate, onReorderEnd, expanded, indented, depth = 0, categoryColor, categoryColors }: TaskItemProps) {
|
||||
const { theme } = useSettings();
|
||||
const 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));
|
||||
@@ -215,9 +218,9 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) =>
|
||||
<View style={styles.content}>
|
||||
<View style={styles.titleRow}>
|
||||
<View style={styles.categoryDotSlot}>
|
||||
{categoryColor ? (
|
||||
{categoryColor && (
|
||||
<View style={[styles.categoryDot, { backgroundColor: categoryColor }]} />
|
||||
) : null}
|
||||
)}
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={styles.checkCircle}
|
||||
@@ -234,7 +237,7 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) =>
|
||||
<Path d="M7 12l3 3 7-7" stroke="#FFFFFF" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
</>
|
||||
) : (
|
||||
<Circle cx={12} cy={12} r={10} stroke={isOverdue ? '#FF8A80' : theme.textSecondary} strokeWidth={2.5} fill="none" />
|
||||
<Circle cx={12} cy={12} r={10} stroke={isOverdue ? theme.danger : theme.textSecondary} strokeWidth={2.5} fill="none" />
|
||||
)}
|
||||
</Svg>
|
||||
</TouchableOpacity>
|
||||
@@ -395,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',
|
||||
@@ -418,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',
|
||||
@@ -430,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,
|
||||
@@ -468,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,
|
||||
@@ -480,7 +483,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
titleCompleted: {
|
||||
textDecorationLine: 'line-through',
|
||||
color: '#9E9E9E',
|
||||
color: theme.textMuted,
|
||||
},
|
||||
repeatIcon: {
|
||||
marginLeft: 8,
|
||||
@@ -504,7 +507,7 @@ const styles = StyleSheet.create({
|
||||
justifyContent: 'center',
|
||||
},
|
||||
titleOverdue: {
|
||||
color: '#E57373',
|
||||
color: theme.danger,
|
||||
},
|
||||
priorityBadge: {
|
||||
paddingHorizontal: 6,
|
||||
@@ -522,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',
|
||||
@@ -540,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: {
|
||||
@@ -559,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,
|
||||
@@ -581,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,
|
||||
},
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import React, { useState, useCallback, useMemo, useRef, useEffect } from 'react';
|
||||
import React, { useState, useCallback, useMemo, useRef } from 'react';
|
||||
import { View, Text, StyleSheet, Animated, RefreshControl, Alert, TouchableOpacity } from 'react-native';
|
||||
import { useTasks } from '@/hooks/useTasks';
|
||||
import { useSubtasks } from '@/hooks/useSubtasks';
|
||||
import { useCategories } from '@/hooks/useDatabase';
|
||||
import { useTaskModals } from '@/hooks/useTaskModals';
|
||||
import { useFocusEffect } from 'expo-router';
|
||||
import { TaskItem } from './TaskItem';
|
||||
import { SubtaskItem } from './SubtaskItem';
|
||||
import { TaskData, SubtaskData } from '@/types';
|
||||
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,
|
||||
@@ -17,12 +20,12 @@ import {
|
||||
moveSubtaskToTask,
|
||||
setSubtaskParent,
|
||||
toggleSubtaskComplete,
|
||||
fetchSubtaskTree,
|
||||
} from '@/utils/taskActions';
|
||||
import Svg, { Path, Rect } from 'react-native-svg';
|
||||
import Svg, { Path } from 'react-native-svg';
|
||||
|
||||
interface TaskListProps {
|
||||
categoryId?: string;
|
||||
categoryIds?: string[];
|
||||
showCompleted?: boolean;
|
||||
onSelectionChange?: (active: boolean) => void;
|
||||
}
|
||||
|
||||
@@ -39,10 +42,10 @@ const DropIndicator = ({ theme }: { theme: any }) => (
|
||||
</View>
|
||||
);
|
||||
|
||||
export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProps) {
|
||||
export function TaskList({ categoryIds = [], showCompleted = false, onSelectionChange }: TaskListProps) {
|
||||
const { theme, sortBy, todoAheadDays } = useSettings();
|
||||
|
||||
const { tasks, loading } = useTasks(categoryId, 'all', todoAheadDays);
|
||||
const { tasks, loading, refresh: refreshTasks } = useTasks(categoryIds, showCompleted ? 'all' : false, todoAheadDays);
|
||||
|
||||
const categories = useCategories();
|
||||
const categoryColors = useMemo(() => {
|
||||
@@ -58,7 +61,7 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [hoverTaskId, setHoverTaskId] = useState<string | null>(null);
|
||||
const [expandedTasks, setExpandedTasks] = useState<Set<string>>(new Set());
|
||||
const [subtasksMap, setSubtasksMap] = useState<Map<string, SubtaskData[]>>(new Map());
|
||||
const { map: subtasksMap, refresh: refreshSubtasks } = useSubtasks();
|
||||
const [dropIndicator, setDropIndicator] = useState<{ targetId: string | null; position: 'above' | 'below' } | null>(null);
|
||||
const itemRefs = useRef<Map<string, View>>(new Map());
|
||||
const subtaskRefs = useRef<Map<string, { ref: View; parentTaskId: string }>>(new Map());
|
||||
@@ -88,6 +91,11 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
|
||||
}
|
||||
}, []);
|
||||
|
||||
const categoryColorResolver = useCallback(
|
||||
(categoryId: string | undefined) => (categoryId ? categoryColors.get(categoryId) : undefined),
|
||||
[categoryColors]
|
||||
);
|
||||
|
||||
const sortedTasks = useMemo(() => {
|
||||
const sorted = [...tasks];
|
||||
switch (sortBy) {
|
||||
@@ -104,13 +112,32 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
|
||||
sorted.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
|
||||
break;
|
||||
}
|
||||
return sorted;
|
||||
const active: typeof sorted = [];
|
||||
const done: typeof sorted = [];
|
||||
for (const t of sorted) {
|
||||
(t.completed ? done : active).push(t);
|
||||
}
|
||||
return [...active, ...done];
|
||||
}, [tasks, sortBy]);
|
||||
|
||||
const onRefresh = useCallback(() => {
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
refreshTasks();
|
||||
refreshSubtasks();
|
||||
}, [refreshTasks, refreshSubtasks])
|
||||
);
|
||||
|
||||
const onRefresh = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
await runSyncGuarded();
|
||||
} catch (error) {
|
||||
if (__DEV__) console.warn('Sync failed:', error);
|
||||
}
|
||||
refreshTasks();
|
||||
refreshSubtasks();
|
||||
setTimeout(() => setRefreshing(false), 600);
|
||||
}, []);
|
||||
}, [refreshTasks, refreshSubtasks]);
|
||||
|
||||
const exitSelection = useCallback(() => {
|
||||
setSelectionMode(false);
|
||||
@@ -140,22 +167,9 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
|
||||
});
|
||||
}, [onSelectionChange]);
|
||||
|
||||
const fetchSubtasks = useCallback(async (taskId: string) => {
|
||||
const withNested = await fetchSubtaskTree(taskId);
|
||||
setSubtasksMap((prev) => new Map(prev).set(taskId, withNested));
|
||||
return withNested;
|
||||
}, []);
|
||||
|
||||
const refreshAll = useCallback(() => {
|
||||
for (const taskId of expandedTasks) {
|
||||
fetchSubtasks(taskId);
|
||||
}
|
||||
}, [expandedTasks, fetchSubtasks]);
|
||||
|
||||
const handleToggle = useCallback(async (taskId: string) => {
|
||||
await toggleTaskComplete(taskId);
|
||||
refreshAll();
|
||||
}, [refreshAll]);
|
||||
await confirmAndToggleTaskComplete(taskId, () => refreshTasks());
|
||||
}, [refreshTasks]);
|
||||
|
||||
const toggleExpand = useCallback(async (taskId: string) => {
|
||||
setExpandedTasks((prev) => {
|
||||
@@ -167,23 +181,13 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const isCurrentlyExpanded = expandedTasks.has(taskId);
|
||||
if (isCurrentlyExpanded) {
|
||||
setSubtasksMap((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(taskId);
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
await fetchSubtasks(taskId);
|
||||
}
|
||||
}, [expandedTasks, fetchSubtasks]);
|
||||
|
||||
const handleSubtaskToggle = useCallback(async (subtaskId: string, taskId: string) => {
|
||||
const handleSubtaskToggle = useCallback(async (subtaskId: string) => {
|
||||
await toggleSubtaskComplete(subtaskId);
|
||||
await fetchSubtasks(taskId);
|
||||
}, [fetchSubtasks]);
|
||||
refreshTasks();
|
||||
refreshSubtasks();
|
||||
}, [refreshTasks, refreshSubtasks]);
|
||||
|
||||
const handleBulkDelete = useCallback(() => {
|
||||
Alert.alert(`Delete ${selectedIds.size} task${selectedIds.size > 1 ? 's' : ''}?`, 'This cannot be undone.', [
|
||||
@@ -194,17 +198,15 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
|
||||
onPress: async () => {
|
||||
await Promise.all(Array.from(selectedIds).map(taskId => deleteTask(taskId)));
|
||||
exitSelection();
|
||||
refreshAll();
|
||||
},
|
||||
},
|
||||
]);
|
||||
}, [selectedIds, exitSelection, refreshAll]);
|
||||
}, [selectedIds, exitSelection]);
|
||||
|
||||
const handleBulkComplete = useCallback(async () => {
|
||||
await Promise.all(Array.from(selectedIds).map(taskId => setTaskCompleted(taskId, true)));
|
||||
exitSelection();
|
||||
refreshAll();
|
||||
}, [selectedIds, exitSelection, refreshAll]);
|
||||
}, [selectedIds, exitSelection]);
|
||||
|
||||
const measureItems = useCallback(async () => {
|
||||
const positions: Record<string, { top: number; bottom: number }> = {};
|
||||
@@ -323,17 +325,14 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
|
||||
const parentTaskId = subtaskRefs.current.get(target)?.parentTaskId ?? state.taskId;
|
||||
(async () => {
|
||||
await convertTaskToSubtask(state.taskId, parentTaskId, target);
|
||||
await fetchSubtasks(parentTaskId);
|
||||
refreshAll();
|
||||
})();
|
||||
} else {
|
||||
(async () => {
|
||||
await convertTaskToSubtask(state.taskId, target);
|
||||
refreshAll();
|
||||
})();
|
||||
}
|
||||
}
|
||||
}, [findHoverTarget, refreshAll, fetchSubtasks]);
|
||||
}, [findHoverTarget]);
|
||||
|
||||
const handleSubtaskDragStart = useCallback(async (subtaskId: string, parentTaskId: string) => {
|
||||
subtaskDragRef.current = { subtaskId, parentTaskId, ...(await measureAll()) };
|
||||
@@ -377,14 +376,11 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
|
||||
await setSubtaskParent(state.subtaskId, target);
|
||||
} else if (target !== state.parentTaskId) {
|
||||
await moveSubtaskToTask(state.subtaskId, target);
|
||||
await fetchSubtasks(target);
|
||||
}
|
||||
} else {
|
||||
await convertSubtaskToTask(state.subtaskId);
|
||||
}
|
||||
await fetchSubtasks(state.parentTaskId);
|
||||
refreshAll();
|
||||
}, [findHoverTarget, fetchSubtasks, refreshAll]);
|
||||
}, [findHoverTarget]);
|
||||
|
||||
const renderItem = useCallback(
|
||||
({ item, index }: { item: Task; index: number }) => {
|
||||
@@ -392,6 +388,7 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
|
||||
const itemSubtasks = subtasksMap.get(item.id) ?? [];
|
||||
const showDropAbove = dropIndicator?.targetId === item.id && dropIndicator?.position === 'above';
|
||||
const showDropBelow = dropIndicator?.targetId === item.id && dropIndicator?.position === 'below';
|
||||
const tagColors = parseTaskTags(item.tags, item.categoryId).map((id) => categoryColors.get(id));
|
||||
return (
|
||||
<View>
|
||||
{showDropAbove && <DropIndicator theme={theme} />}
|
||||
@@ -425,6 +422,8 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
|
||||
onReorderEnd={handleDragEnd}
|
||||
selectedIds={selectedIds}
|
||||
categoryColor={categoryColors.get(item.categoryId)}
|
||||
categoryColors={tagColors}
|
||||
categoryColorResolver={categoryColorResolver}
|
||||
/>
|
||||
{showDropBelow && <DropIndicator theme={theme} />}
|
||||
</View>
|
||||
@@ -454,6 +453,8 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
|
||||
handleSubtaskDragStart,
|
||||
handleSubtaskDragUpdate,
|
||||
handleSubtaskDragEnd,
|
||||
registerSubtaskRef,
|
||||
categoryColorResolver,
|
||||
categoryColors,
|
||||
]
|
||||
);
|
||||
@@ -503,7 +504,7 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
|
||||
contentContainerStyle={styles.listContent}
|
||||
/>
|
||||
|
||||
{modals(refreshAll)}
|
||||
{modals(() => {})}
|
||||
|
||||
{selectionMode && (
|
||||
<View style={[styles.selectionBar, { backgroundColor: theme.sheetBg, borderTopColor: theme.border }]}>
|
||||
@@ -515,8 +516,8 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
|
||||
</TouchableOpacity>
|
||||
<Text style={[styles.selectionCount, { color: theme.text }]}>{selectedIds.size} selected</Text>
|
||||
<View style={styles.selectionActions}>
|
||||
<SelectionButton label="Complete" color="#43A047" onPress={handleBulkComplete} />
|
||||
<SelectionButton label="Delete" color="#E53935" onPress={handleBulkDelete} />
|
||||
<SelectionButton label="Complete" color={theme.successStrong} onPress={handleBulkComplete} />
|
||||
<SelectionButton label="Delete" color={theme.dangerStrong} onPress={handleBulkDelete} />
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
@@ -566,6 +567,8 @@ interface TaskRowProps {
|
||||
onReorderEnd: (absoluteY: number, translationY: number) => void;
|
||||
selectedIds: Set<string>;
|
||||
categoryColor?: string;
|
||||
categoryColors?: (string | undefined)[];
|
||||
categoryColorResolver?: (categoryId: string | undefined) => string | undefined;
|
||||
}
|
||||
|
||||
const TaskRow = React.memo(function TaskRow({
|
||||
@@ -598,6 +601,8 @@ const TaskRow = React.memo(function TaskRow({
|
||||
onReorderEnd,
|
||||
selectedIds,
|
||||
categoryColor,
|
||||
categoryColors,
|
||||
categoryColorResolver,
|
||||
}: TaskRowProps) {
|
||||
const sortedSubtasks = useMemo(
|
||||
() => subtasks.slice().sort((a, b) => a.order - b.order),
|
||||
@@ -627,6 +632,7 @@ const TaskRow = React.memo(function TaskRow({
|
||||
onReorderUpdate={onReorderUpdate}
|
||||
onReorderEnd={onReorderEnd}
|
||||
categoryColor={categoryColor}
|
||||
categoryColors={categoryColors}
|
||||
/>
|
||||
{expanded && subtasks.length > 0 && (
|
||||
<View style={styles.subtaskList}>
|
||||
@@ -636,16 +642,17 @@ const TaskRow = React.memo(function TaskRow({
|
||||
subtask={sub}
|
||||
hoveredId={hoverTaskId}
|
||||
registerRef={registerSubtaskRef}
|
||||
onToggle={() => onSubtaskToggle(sub.id, task.id)}
|
||||
onToggle={(sub) => onSubtaskToggle(sub.id, task.id)}
|
||||
onDelete={() => onSubtaskDelete(sub)}
|
||||
onMenuOpen={() => onSubtaskMenuOpen(sub)}
|
||||
selected={selectedIds.has(sub.id)}
|
||||
selectionMode={selectionMode}
|
||||
draggable
|
||||
onDragStart={() => onSubtaskDragStart(sub.id, task.id)}
|
||||
onDragStart={(sub) => onSubtaskDragStart(sub.id, task.id)}
|
||||
onDragUpdate={onSubtaskDragUpdate}
|
||||
onDragEnd={onSubtaskDragEnd}
|
||||
categoryColor={categoryColor}
|
||||
categoryColorResolver={categoryColorResolver}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
|
||||
@@ -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 (
|
||||
<View style={styles.container}>
|
||||
@@ -34,7 +35,7 @@ export function TaskNameInput({ error, ...props }: TaskNameInputProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
const createStyles = (theme: ThemeColors) => StyleSheet.create({
|
||||
container: {
|
||||
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,
|
||||
},
|
||||
});
|
||||
@@ -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: <Svg width={20} height={20} viewBox="0 0 24 24"><Circle2 /></Svg>,
|
||||
onPress: onChangeCategory ?? (() => {}),
|
||||
});
|
||||
@@ -84,7 +86,7 @@ export function TaskOverflowMenu({
|
||||
key: 'delete',
|
||||
label: 'Delete',
|
||||
destructive: true,
|
||||
icon: <Svg width={20} height={20} viewBox="0 0 24 24"><Path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13" stroke="#E57373" strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" fill="none" /></Svg>,
|
||||
icon: <Svg width={20} height={20} viewBox="0 0 24 24"><Path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13" stroke={theme.danger} strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" fill="none" /></Svg>,
|
||||
onPress: onDelete,
|
||||
},
|
||||
);
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -193,5 +193,32 @@ export const migrations = schemaMigrations({
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
toVersion: 18,
|
||||
steps: [
|
||||
addColumns({
|
||||
table: 'tasks',
|
||||
columns: [{ name: 'tags', type: 'string', isOptional: true }],
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
toVersion: 19,
|
||||
steps: [
|
||||
addColumns({
|
||||
table: 'subtasks',
|
||||
columns: [{ name: 'category_id', type: 'string', isOptional: true }],
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
toVersion: 20,
|
||||
steps: [
|
||||
addColumns({
|
||||
table: 'subtasks',
|
||||
columns: [{ name: 'tags', type: 'string', isOptional: true }],
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { appSchema, tableSchema } from '@nozbe/watermelondb';
|
||||
|
||||
export const schema = appSchema({
|
||||
version: 17,
|
||||
version: 21,
|
||||
tables: [
|
||||
tableSchema({
|
||||
name: 'categories',
|
||||
@@ -19,6 +19,7 @@ export const schema = appSchema({
|
||||
{ name: 'title', type: 'string' },
|
||||
{ name: 'description', type: 'string' },
|
||||
{ name: 'category_id', type: 'string', isIndexed: true },
|
||||
{ name: 'tags', type: 'string', isOptional: true },
|
||||
{ name: 'priority', type: 'string' },
|
||||
{ name: 'completed', type: 'boolean', isIndexed: true },
|
||||
{ name: 'completed_at', type: 'number', isOptional: true },
|
||||
@@ -44,6 +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', 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 },
|
||||
|
||||
@@ -11,8 +11,10 @@ import RepeatProfile from '@/models/RepeatProfile';
|
||||
import Friendship from '@/models/Friendship';
|
||||
import { TombstoneEntity } from '@/models/Tombstone';
|
||||
import { fetchPendingTombstones, removeTombstonesInBatch } from './tombstones';
|
||||
import { useOnline } from '@/hooks/useOnline';
|
||||
|
||||
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 +57,24 @@ async function setLastPulledAt(timestamp: number): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function getLastPushedAt(): Promise<number> {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(LAST_PUSHED_AT_KEY);
|
||||
const value = raw ? Number(raw) : 0;
|
||||
return Number.isFinite(value) ? value : 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
async function setLastPushedAt(timestamp: number): Promise<void> {
|
||||
try {
|
||||
await AsyncStorage.setItem(LAST_PUSHED_AT_KEY, String(timestamp));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export async function getLastSyncTime(): Promise<number | null> {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(LAST_RUN_AT_KEY);
|
||||
@@ -75,6 +95,21 @@ export async function runSync(): Promise<SyncResult> {
|
||||
throw new Error('NOT_SIGNED_IN');
|
||||
}
|
||||
|
||||
// If offline, mark timestamps and return early - changes will sync on next foreground launch
|
||||
const isCurrentlyOnline = await useOnline();
|
||||
if (!isCurrentlyOnline) {
|
||||
const now = Date.now();
|
||||
await setLastPulledAt(now);
|
||||
await setLastPushedAt(now);
|
||||
const result: SyncResult = {
|
||||
pushed: { categories: 0, tasks: 0, subtasks: 0, repeatProfiles: 0, friendships: 0 },
|
||||
pulled: { categories: 0, tasks: 0, subtasks: 0, repeatProfiles: 0, friendships: 0 },
|
||||
conflicts: 0,
|
||||
timestamp: now,
|
||||
};
|
||||
return result;
|
||||
}
|
||||
|
||||
const result: SyncResult = {
|
||||
pushed: { categories: 0, tasks: 0, subtasks: 0, repeatProfiles: 0, friendships: 0 },
|
||||
pulled: { categories: 0, tasks: 0, subtasks: 0, repeatProfiles: 0, friendships: 0 },
|
||||
@@ -89,9 +124,23 @@ export async function runSync(): Promise<SyncResult> {
|
||||
|
||||
await pruneSyncedTombstones(conflicts, pushedDeletions);
|
||||
|
||||
const lastPulledAt = await getLastPulledAt();
|
||||
const response = await apiFetch(`/sync?since=${lastPulledAt}`);
|
||||
const data = await response.json();
|
||||
// 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();
|
||||
let data: any;
|
||||
try {
|
||||
const response = await apiFetch(`/sync?since=${pullSince}`);
|
||||
data = await response.json();
|
||||
} catch (error: any) {
|
||||
if (error.offline) {
|
||||
result.timestamp = Date.now();
|
||||
await setLastPulledAt(Date.now());
|
||||
return result;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const pulledCounts = await applyServerChanges(data);
|
||||
result.pulled = pulledCounts;
|
||||
@@ -103,13 +152,13 @@ export async function runSync(): Promise<SyncResult> {
|
||||
}
|
||||
|
||||
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) => ({
|
||||
@@ -117,6 +166,7 @@ async function pushChanges(): Promise<{ conflicts: PushConflict[]; pushedDeletio
|
||||
title: t.title,
|
||||
description: t.description,
|
||||
categoryId: t.categoryId,
|
||||
tags: t.tags || '',
|
||||
priority: t.priority,
|
||||
completed: t.completed,
|
||||
dueDate: t.dueDate,
|
||||
@@ -150,6 +200,8 @@ async function pushChanges(): Promise<{ conflicts: PushConflict[]; pushedDeletio
|
||||
id: s.id,
|
||||
taskId: s.taskId,
|
||||
parentSubtaskId: s.parentSubtaskId || null,
|
||||
categoryId: s.categoryId || '',
|
||||
tags: s.tags || '',
|
||||
title: s.title,
|
||||
description: s.description ?? '',
|
||||
priority: s.priority ?? 'none',
|
||||
@@ -191,12 +243,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();
|
||||
|
||||
@@ -228,7 +280,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,
|
||||
@@ -257,7 +309,7 @@ async function pushChanges(): Promise<{ conflicts: PushConflict[]; pushedDeletio
|
||||
friendships: changedFriendships,
|
||||
},
|
||||
deleted: changedDeletions,
|
||||
lastPulledAt,
|
||||
lastPulledAt: await getLastPulledAt(),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -451,6 +503,7 @@ async function upsertTask(row: ServerRow, force: boolean): Promise<boolean> {
|
||||
t.title = String(row.title ?? '');
|
||||
t.description = String(row.description ?? '');
|
||||
t.categoryId = String(row.categoryId ?? '');
|
||||
t.tags = String(row.tags ?? '');
|
||||
t.priority = row.priority ?? 'none';
|
||||
t.completed = Boolean(row.completed);
|
||||
t.dueDate = Number(row.dueDate ?? 0);
|
||||
@@ -476,6 +529,7 @@ async function upsertTask(row: ServerRow, force: boolean): Promise<boolean> {
|
||||
t.title = String(row.title ?? t.title);
|
||||
t.description = String(row.description ?? t.description);
|
||||
t.categoryId = String(row.categoryId ?? t.categoryId);
|
||||
t.tags = String(row.tags ?? t.tags ?? '');
|
||||
t.priority = row.priority ?? t.priority;
|
||||
t.completed = Boolean(row.completed ?? t.completed);
|
||||
t.dueDate = Number(row.dueDate ?? t.dueDate);
|
||||
@@ -529,6 +583,8 @@ async function upsertSubtask(row: ServerRow, force: boolean): Promise<boolean> {
|
||||
await collections.subtasks.create((s) => {
|
||||
s.taskId = String(row.taskId ?? '');
|
||||
s.parentSubtaskId = row.parentSubtaskId == null ? null : String(row.parentSubtaskId);
|
||||
s.categoryId = row.categoryId ? String(row.categoryId) : '';
|
||||
s.tags = String(row.tags ?? '');
|
||||
s.title = String(row.title ?? '');
|
||||
s.description = String(row.description ?? '');
|
||||
s.priority = row.priority ?? 'none';
|
||||
@@ -555,6 +611,8 @@ async function upsertSubtask(row: ServerRow, force: boolean): Promise<boolean> {
|
||||
await local.update((s) => {
|
||||
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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function useOnline(): boolean {
|
||||
const [isOnline, setIsOnline] = useState(navigator.onLine);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => setIsOnline(navigator.onLine);
|
||||
window.addEventListener('online', handler);
|
||||
window.addEventListener('offline', handler);
|
||||
return () => {
|
||||
window.removeEventListener('online', handler);
|
||||
window.removeEventListener('offline', handler);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return isOnline;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useDatabase } from './useDatabase';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { SubtaskData } from '@/types';
|
||||
function mapRow(s: any): SubtaskData {
|
||||
return {
|
||||
id: s.id,
|
||||
taskId: s.taskId,
|
||||
parentSubtaskId: s.parentSubtaskId || null,
|
||||
categoryId: s.categoryId || '',
|
||||
tags: s.tags || '',
|
||||
title: s.title,
|
||||
description: s.description || '',
|
||||
priority: (s.priority || 'none') as SubtaskData['priority'],
|
||||
completed: s.completed,
|
||||
dueDate: s.dueDate || 0,
|
||||
dueTime: s.dueTime || '',
|
||||
endTime: s.endTime || '',
|
||||
allDay: s.allDay ?? false,
|
||||
repeat: (s.repeat || 'none') as SubtaskData['repeat'],
|
||||
repeatInterval: s.repeatInterval ?? 1,
|
||||
repeatDays: s.repeatDays || '',
|
||||
seriesId: s.seriesId || '',
|
||||
reminder: (s.reminder || 'none') as SubtaskData['reminder'],
|
||||
assigneeId: s.assigneeId ?? null,
|
||||
order: s.order,
|
||||
subtasks: [],
|
||||
};
|
||||
}
|
||||
|
||||
function buildTrees(rows: any[]): Map<string, SubtaskData[]> {
|
||||
const nodes = new Map<string, SubtaskData>();
|
||||
for (const row of rows) {
|
||||
nodes.set(row.id, mapRow(row));
|
||||
}
|
||||
const rootsByTask = new Map<string, SubtaskData[]>();
|
||||
for (const node of nodes.values()) {
|
||||
if (node.parentSubtaskId && nodes.has(node.parentSubtaskId)) {
|
||||
const parent = nodes.get(node.parentSubtaskId)!;
|
||||
parent.subtasks.push(node);
|
||||
} else {
|
||||
const list = rootsByTask.get(node.taskId) ?? [];
|
||||
list.push(node);
|
||||
rootsByTask.set(node.taskId, list);
|
||||
}
|
||||
}
|
||||
for (const list of rootsByTask.values()) {
|
||||
list.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||
}
|
||||
const sortDeep = (list: SubtaskData[]) => {
|
||||
for (const node of list) {
|
||||
node.subtasks.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||
sortDeep(node.subtasks);
|
||||
}
|
||||
};
|
||||
for (const list of rootsByTask.values()) {
|
||||
sortDeep(list);
|
||||
}
|
||||
return rootsByTask;
|
||||
}
|
||||
|
||||
export function useSubtasks(): { map: Map<string, SubtaskData[]>; refresh: () => void } {
|
||||
const { collections } = useDatabase();
|
||||
const [rows, setRows] = useState<any[]>([]);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const refresh = useCallback(() => setRefreshKey((k) => k + 1), []);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
const subscription = collections.subtasks
|
||||
.query()
|
||||
.observe()
|
||||
.subscribe({
|
||||
next: (result) => {
|
||||
if (mounted) setRows(result);
|
||||
},
|
||||
error: () => {},
|
||||
});
|
||||
return () => {
|
||||
mounted = false;
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, [collections.subtasks, refreshKey]);
|
||||
|
||||
const map = useMemo(() => buildTrees(rows), [rows]);
|
||||
|
||||
return { map, refresh };
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
<OptionPickerModal
|
||||
visible={picker?.type === 'category'}
|
||||
title="Change Category"
|
||||
title="Change Tags"
|
||||
options={[{ value: '', label: 'None', color: '#9E9E9E' }, ...categories.map((c) => ({ value: c.id, label: c.name, color: c.color }))]}
|
||||
selectedValue={menuTask?.categoryId}
|
||||
onSelect={handleSingleCategory}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useDatabase } from './useDatabase';
|
||||
import { Q } from '@nozbe/watermelondb';
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import { useEffect, useState, useMemo, useCallback } from 'react';
|
||||
import Task from '../models/Task';
|
||||
import { startOfMonth, endOfMonth } from 'date-fns';
|
||||
|
||||
@@ -8,6 +8,8 @@ export function useTasksInMonth(monthDate: Date) {
|
||||
const { collections } = useDatabase();
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const refresh = useCallback(() => setRefreshKey((k) => k + 1), []);
|
||||
|
||||
const range = useMemo(() => {
|
||||
const s = startOfMonth(monthDate);
|
||||
@@ -42,7 +44,7 @@ export function useTasksInMonth(monthDate: Date) {
|
||||
mounted = false;
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, [collections, range.start, range.end]);
|
||||
}, [collections, range.start, range.end, refreshKey]);
|
||||
|
||||
const byDay = useMemo(() => {
|
||||
const map: Record<number, Task[]> = {};
|
||||
@@ -55,13 +57,15 @@ export function useTasksInMonth(monthDate: Date) {
|
||||
return map;
|
||||
}, [tasks]);
|
||||
|
||||
return { tasks, byDay, loading };
|
||||
return { tasks, byDay, loading, refresh };
|
||||
}
|
||||
|
||||
export function useTasks(categoryId?: string, showCompleted: boolean | 'all' = false, maxAheadDays?: number) {
|
||||
export function useTasks(categoryIds: string[] = [], showCompleted: boolean | 'all' = false, maxAheadDays?: number) {
|
||||
const { collections } = useDatabase();
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const refresh = useCallback(() => setRefreshKey((k) => k + 1), []);
|
||||
|
||||
const cutoff = useMemo(() => {
|
||||
if (maxAheadDays === undefined) return null;
|
||||
@@ -75,8 +79,14 @@ export function useTasks(categoryId?: string, showCompleted: boolean | 'all' = f
|
||||
let mounted = true;
|
||||
const conditions: any[] = [];
|
||||
|
||||
if (categoryId && categoryId !== 'all') {
|
||||
conditions.push(Q.where('category_id', categoryId));
|
||||
if (categoryIds.length > 0) {
|
||||
const tagConditions = categoryIds.map((id) =>
|
||||
Q.or(
|
||||
Q.where('tags', Q.like(`%,${id},%`)),
|
||||
Q.where('category_id', id)
|
||||
)
|
||||
);
|
||||
conditions.push(tagConditions.length === 1 ? tagConditions[0] : Q.or(...tagConditions));
|
||||
}
|
||||
|
||||
if (showCompleted === true) {
|
||||
@@ -111,15 +121,17 @@ export function useTasks(categoryId?: string, showCompleted: boolean | 'all' = f
|
||||
mounted = false;
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, [collections, categoryId, showCompleted, cutoff]);
|
||||
}, [collections, categoryIds, showCompleted, cutoff, refreshKey]);
|
||||
|
||||
return { tasks, loading };
|
||||
return { tasks, loading, refresh };
|
||||
}
|
||||
|
||||
export function useTasksByDate(date: Date) {
|
||||
const { collections } = useDatabase();
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const refresh = useCallback(() => setRefreshKey((k) => k + 1), []);
|
||||
|
||||
const startOfDay = useMemo(() => {
|
||||
const d = new Date(date);
|
||||
@@ -156,7 +168,7 @@ export function useTasksByDate(date: Date) {
|
||||
mounted = false;
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, [collections, startOfDay, endOfDay]);
|
||||
}, [collections, startOfDay, endOfDay, refreshKey]);
|
||||
|
||||
return { tasks, loading };
|
||||
return { tasks, loading, refresh };
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ export default class Subtask extends Model {
|
||||
|
||||
@field('task_id') taskId!: string;
|
||||
@field('parent_subtask_id') parentSubtaskId!: string | null;
|
||||
@field('category_id') categoryId!: string;
|
||||
@field('tags') tags!: string;
|
||||
@field('title') title!: string;
|
||||
@field('description') description!: string;
|
||||
@field('priority') priority!: Priority;
|
||||
|
||||
@@ -13,6 +13,7 @@ export default class Task extends Model {
|
||||
@field('title') title!: string;
|
||||
@field('description') description!: string;
|
||||
@field('category_id') categoryId!: string;
|
||||
@field('tags') tags!: string;
|
||||
@field('priority') priority!: Priority;
|
||||
@field('completed') completed!: boolean;
|
||||
@field('completed_at') completedAt!: number | null;
|
||||
|
||||
@@ -96,10 +96,19 @@ export async function apiFetch(path: string, options: RequestInit = {}): Promise
|
||||
}
|
||||
const response = await fetch(`${apiBaseUrl}${path}`, { ...options, headers });
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
// If it's a 401 or network error, try to get a fresh token and retry once
|
||||
if (response.status === 404 || response.status === 0) {
|
||||
// Not found or network error - don't throw, return the response for caller to handle
|
||||
const error = new Error(text || `Request failed (${response.status})`);
|
||||
(error as any).status = response.status;
|
||||
(error as any).offline = true;
|
||||
throw error;
|
||||
}
|
||||
let message = `Request failed (${response.status})`;
|
||||
let details: any = null;
|
||||
try {
|
||||
const body = await response.json();
|
||||
const body = JSON.parse(text);
|
||||
if (body && typeof body.message === 'string') {
|
||||
message = body.message;
|
||||
}
|
||||
@@ -136,3 +145,38 @@ export async function register(username: string, password: string): Promise<Auth
|
||||
await setAuth(data.token, data.user);
|
||||
return data.user as AuthUser;
|
||||
}
|
||||
|
||||
export async function forgotPassword(username: string): Promise<{ success: boolean; resetToken?: string }> {
|
||||
const response = await apiFetch('/auth/forgot-password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username }),
|
||||
});
|
||||
const data = await response.json();
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function resetPassword(token: string, password: string): Promise<{ success: boolean }> {
|
||||
const response = await apiFetch('/auth/reset-password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ token, password }),
|
||||
});
|
||||
const data = await response.json();
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function registerPushToken(pushToken: string): Promise<{ success: boolean }> {
|
||||
const response = await apiFetch('/push/push-token', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ pushToken }),
|
||||
});
|
||||
const data = await response.json();
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function unregisterPushToken(): Promise<{ success: boolean }> {
|
||||
const response = await apiFetch('/push/push-token', {
|
||||
method: 'DELETE',
|
||||
});
|
||||
const data = await response.json();
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
function reminderPreferenceToReminder(pref: ReminderPreference): Reminder {
|
||||
switch (pref) {
|
||||
case 'none':
|
||||
return 'none';
|
||||
case 'at_time':
|
||||
return 'at_time';
|
||||
case '15':
|
||||
return '15';
|
||||
case '30':
|
||||
return '30';
|
||||
case '60':
|
||||
return '60';
|
||||
case '120':
|
||||
return '120';
|
||||
case '1440':
|
||||
return '1440';
|
||||
default:
|
||||
return 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function getRemindersForTask(task: any, globalPreference: ReminderPreference): 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<void> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function rescheduleAllReminders(tasks: any[]): Promise<void> {
|
||||
export async function cancelAllReminders(): Promise<void> {
|
||||
const notifications = await getNotificationsModule();
|
||||
if (!notifications) return;
|
||||
try {
|
||||
await notifications.cancelAllScheduledNotificationsAsync();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
const idMap: Record<string, string[]> = {};
|
||||
await writeIdMap(idMap);
|
||||
}
|
||||
|
||||
export async function rescheduleAllReminders(tasks: any[], notificationsEnabled: boolean = true, globalReminderPreference: ReminderPreference = '15'): Promise<void> {
|
||||
if (!notificationsEnabled) {
|
||||
await cancelAllReminders();
|
||||
return;
|
||||
}
|
||||
|
||||
const notifications = await getNotificationsModule();
|
||||
if (!notifications) return;
|
||||
try {
|
||||
@@ -145,11 +199,7 @@ export async function rescheduleAllReminders(tasks: any[]): Promise<void> {
|
||||
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;
|
||||
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { Platform } from 'react-native';
|
||||
import * as Device from 'expo-device';
|
||||
import * as Notifications from 'expo-notifications';
|
||||
import Constants from 'expo-constants';
|
||||
import { registerPushToken, unregisterPushToken } from './auth';
|
||||
|
||||
export async function registerForPushNotificationsAsync(): Promise<string | null> {
|
||||
if (!Device.isDevice) {
|
||||
if (__DEV__) console.warn('Push notifications require a physical device');
|
||||
return null;
|
||||
}
|
||||
|
||||
const { status: existingStatus } = await Notifications.getPermissionsAsync();
|
||||
let finalStatus = existingStatus;
|
||||
if (existingStatus !== 'granted') {
|
||||
const { status } = await Notifications.requestPermissionsAsync({
|
||||
ios: {
|
||||
allowAlert: true,
|
||||
allowBadge: true,
|
||||
allowSound: true,
|
||||
allowCriticalAlerts: true,
|
||||
},
|
||||
});
|
||||
finalStatus = status;
|
||||
}
|
||||
if (finalStatus !== 'granted') {
|
||||
if (__DEV__) console.warn('Push notification permission not granted');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const projectId = (Constants.expoConfig as any)?.extra?.eas?.projectId ?? (Constants.expoConfig as any)?.projectId;
|
||||
const tokenData = await Notifications.getExpoPushTokenAsync({
|
||||
projectId,
|
||||
});
|
||||
const pushToken = tokenData.data;
|
||||
|
||||
await registerPushToken(pushToken);
|
||||
if (__DEV__) console.log('Push token registered:', pushToken);
|
||||
return pushToken;
|
||||
} catch (error) {
|
||||
if (__DEV__) console.error('Failed to get push token:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function unregisterForPushNotificationsAsync(): Promise<void> {
|
||||
await unregisterPushToken();
|
||||
}
|
||||
|
||||
export function setupNotificationChannels(): void {
|
||||
if (Platform.OS === 'android') {
|
||||
Notifications.setNotificationChannelAsync('task-reminders', {
|
||||
name: 'Task Reminders',
|
||||
importance: Notifications.AndroidImportance.HIGH,
|
||||
vibrationPattern: [0, 250, 250, 250],
|
||||
lightColor: '#EF5350',
|
||||
sound: 'default',
|
||||
enableVibrate: true,
|
||||
enableLights: true,
|
||||
});
|
||||
|
||||
Notifications.setNotificationChannelAsync('friend-requests', {
|
||||
name: 'Friend Requests',
|
||||
importance: Notifications.AndroidImportance.HIGH,
|
||||
vibrationPattern: [0, 250, 250, 250],
|
||||
lightColor: '#29B6F6',
|
||||
sound: 'default',
|
||||
enableVibrate: true,
|
||||
});
|
||||
|
||||
Notifications.setNotificationChannelAsync('task-assignments', {
|
||||
name: 'Task Assignments',
|
||||
importance: Notifications.AndroidImportance.HIGH,
|
||||
vibrationPattern: [0, 250, 250, 250],
|
||||
lightColor: '#66BB6A',
|
||||
sound: 'default',
|
||||
enableVibrate: true,
|
||||
});
|
||||
|
||||
Notifications.setNotificationChannelAsync('default', {
|
||||
name: 'Default',
|
||||
importance: Notifications.AndroidImportance.DEFAULT,
|
||||
sound: 'default',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function addNotificationReceivedListener(
|
||||
listener: (notification: Notifications.Notification) => void
|
||||
): Notifications.EventSubscription {
|
||||
return Notifications.addNotificationReceivedListener(listener);
|
||||
}
|
||||
|
||||
export function addNotificationResponseReceivedListener(
|
||||
listener: (response: Notifications.NotificationResponse) => void
|
||||
): Notifications.EventSubscription {
|
||||
return Notifications.addNotificationResponseReceivedListener(listener);
|
||||
}
|
||||
|
||||
export function removeNotificationSubscription(subscription: Notifications.EventSubscription): void {
|
||||
subscription.remove();
|
||||
}
|
||||
|
||||
export async function getBadgeCount(): Promise<number> {
|
||||
try {
|
||||
return await Notifications.getBadgeCountAsync();
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export async function setBadgeCount(count: number): Promise<void> {
|
||||
try {
|
||||
await Notifications.setBadgeCountAsync(count);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function handleNotificationResponse(response: Notifications.NotificationResponse): void {
|
||||
const data = response.notification.request.content.data as Record<string, any> | undefined;
|
||||
if (!data) return;
|
||||
|
||||
switch (data.type) {
|
||||
case 'task_reminder':
|
||||
case 'task_assignment':
|
||||
if (data.taskId) {
|
||||
// Navigation will be handled by the app router
|
||||
console.log('Navigate to task:', data.taskId);
|
||||
}
|
||||
break;
|
||||
case 'friend_request':
|
||||
if (data.friendshipId) {
|
||||
console.log('Navigate to friend request:', data.friendshipId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -128,6 +144,8 @@ interface SettingsContextType {
|
||||
setAccentColor: (value: string) => void;
|
||||
todoAheadDays: number;
|
||||
setTodoAheadDays: (value: number) => void;
|
||||
showCompleted: boolean;
|
||||
setShowCompleted: (value: boolean) => void;
|
||||
theme: ThemeColors;
|
||||
}
|
||||
|
||||
@@ -140,6 +158,7 @@ const STORAGE_KEYS = {
|
||||
reminderPreference: 'settings:reminderPreference',
|
||||
accentColor: 'settings:accentColor',
|
||||
todoAheadDays: 'settings:todoAheadDays',
|
||||
showCompleted: 'settings:showCompleted',
|
||||
};
|
||||
|
||||
const SettingsContext = createContext<SettingsContextType | null>(null);
|
||||
@@ -176,11 +195,12 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
|
||||
const [sortBy, setSortBy] = useStoredSetting<SortBy>(STORAGE_KEYS.sortBy, 'date');
|
||||
const [reminderPreference, setReminderPreference] = useStoredSetting<ReminderPreference>(
|
||||
STORAGE_KEYS.reminderPreference,
|
||||
'15m',
|
||||
'15',
|
||||
);
|
||||
const [apiUrl, setApiUrl] = useStoredSetting<string>(API_URL_KEY, DEFAULT_API_BASE_URL);
|
||||
const [accentColor, setAccentColor] = useStoredSetting<string>(STORAGE_KEYS.accentColor, DEFAULT_ACCENT);
|
||||
const [todoAheadDays, setTodoAheadDays] = useStoredSetting<number>(STORAGE_KEYS.todoAheadDays, DEFAULT_TODO_AHEAD_DAYS);
|
||||
const [showCompleted, setShowCompleted] = useStoredSetting<boolean>(STORAGE_KEYS.showCompleted, false);
|
||||
|
||||
const theme = useMemo(() => colors(accentColor), [accentColor]);
|
||||
|
||||
@@ -200,6 +220,8 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
|
||||
setAccentColor,
|
||||
todoAheadDays,
|
||||
setTodoAheadDays,
|
||||
showCompleted,
|
||||
setShowCompleted,
|
||||
theme,
|
||||
}),
|
||||
[
|
||||
@@ -217,6 +239,8 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
|
||||
setAccentColor,
|
||||
todoAheadDays,
|
||||
setTodoAheadDays,
|
||||
showCompleted,
|
||||
setShowCompleted,
|
||||
theme,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -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 }[] = [
|
||||
@@ -99,11 +101,30 @@ export interface CategoryData {
|
||||
order: number;
|
||||
}
|
||||
|
||||
export function tagsToString(tags: string[]): string {
|
||||
return `,${tags.filter(Boolean).join(',')},`;
|
||||
}
|
||||
|
||||
export function tagsFromString(raw: string | null | undefined): string[] {
|
||||
if (!raw) return [];
|
||||
return raw
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function parseTaskTags(tagsRaw: string | null | undefined, categoryId: string): string[] {
|
||||
const parsed = tagsFromString(tagsRaw);
|
||||
if (parsed.length > 0) return parsed;
|
||||
return categoryId ? [categoryId] : [];
|
||||
}
|
||||
|
||||
export interface TaskData {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
categoryId: string;
|
||||
tags?: string;
|
||||
priority: Priority;
|
||||
completed: boolean;
|
||||
dueDate: number;
|
||||
@@ -125,6 +146,8 @@ export interface SubtaskData {
|
||||
id: string;
|
||||
taskId: string;
|
||||
parentSubtaskId: string | null;
|
||||
categoryId: string;
|
||||
tags: string;
|
||||
title: string;
|
||||
description: string;
|
||||
priority: Priority;
|
||||
@@ -154,6 +177,7 @@ export interface TaskFormData {
|
||||
title: string;
|
||||
description?: string;
|
||||
categoryId: string;
|
||||
tags?: string[];
|
||||
priority: Priority;
|
||||
dueDate: Date | null;
|
||||
dueTime?: string;
|
||||
@@ -171,6 +195,8 @@ export interface TaskFormData {
|
||||
export interface SubtaskFormData {
|
||||
title: string;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
categoryId?: string;
|
||||
priority: Priority;
|
||||
dueDate: Date | null;
|
||||
dueTime?: string;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { database, collections } from '@/database';
|
||||
import { Q } from '@nozbe/watermelondb';
|
||||
import { recordTombstonesInBatch } from '@/database/tombstones';
|
||||
import { tagsFromString, tagsToString, parseTaskTags } from '@/types';
|
||||
|
||||
export async function createCategory(name: string, color: string): Promise<void> {
|
||||
await database.write(async () => {
|
||||
@@ -36,7 +37,18 @@ export async function deleteCategory(categoryId: string): Promise<void> {
|
||||
const tasks = await collections.tasks.query(Q.where('category_id', categoryId)).fetch();
|
||||
for (const task of tasks) {
|
||||
await task.update((t) => {
|
||||
t.categoryId = fallback?.id ?? '';
|
||||
const tags = parseTaskTags(t.tags, t.categoryId).filter((id) => id !== categoryId);
|
||||
t.categoryId = fallback?.id ?? tags[0] ?? '';
|
||||
t.tags = tagsToString(tags);
|
||||
t.updatedAt = new Date();
|
||||
});
|
||||
}
|
||||
|
||||
const tagReferencedTasks = await collections.tasks.query(Q.where('tags', Q.like(`%,${categoryId},%`))).fetch();
|
||||
for (const task of tagReferencedTasks) {
|
||||
const tags = parseTaskTags(task.tags, task.categoryId).filter((id) => id !== categoryId);
|
||||
await task.update((t) => {
|
||||
t.tags = tagsToString(tags);
|
||||
t.updatedAt = new Date();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { database, collections } from '@/database';
|
||||
import { Q } from '@nozbe/watermelondb';
|
||||
import { Priority, Repeat, Reminder, SubtaskData } from '@/types';
|
||||
import { Alert } from 'react-native';
|
||||
import { Priority, Repeat, Reminder, SubtaskData, tagsToString, parseTaskTags } from '@/types';
|
||||
import { scheduleTaskReminder, cancelTaskReminder } from '@/services/notifications';
|
||||
import { recordTombstonesInBatch } from '@/database/tombstones';
|
||||
|
||||
@@ -9,6 +10,8 @@ function mapSubtaskRow(s: any, taskId?: string): SubtaskData {
|
||||
id: s.id,
|
||||
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,
|
||||
@@ -215,6 +218,51 @@ export async function toggleTaskComplete(taskId: string): Promise<string | null>
|
||||
return nextOccurrence ? nextOccurrence.id : null;
|
||||
}
|
||||
|
||||
export async function countOpenSubtasks(taskId: string): Promise<number> {
|
||||
const open = await collections.subtasks.query(
|
||||
Q.where('task_id', taskId),
|
||||
Q.where('completed', false)
|
||||
).fetch();
|
||||
return open.length;
|
||||
}
|
||||
|
||||
export async function confirmAndToggleTaskComplete(
|
||||
taskId: string,
|
||||
onCompleted?: () => void
|
||||
): Promise<void> {
|
||||
let task: any;
|
||||
try {
|
||||
task = await collections.tasks.find(taskId);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const finish = async () => {
|
||||
await toggleTaskComplete(taskId);
|
||||
onCompleted?.();
|
||||
};
|
||||
|
||||
if (task.completed) {
|
||||
await finish();
|
||||
return;
|
||||
}
|
||||
|
||||
const openCount = await countOpenSubtasks(taskId);
|
||||
if (openCount === 0) {
|
||||
await finish();
|
||||
return;
|
||||
}
|
||||
|
||||
Alert.alert(
|
||||
'Unfinished subtasks',
|
||||
`"${task.title}" still has ${openCount} open subtask${openCount === 1 ? '' : 's'}. Mark it as done anyway?`,
|
||||
[
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{ text: 'Mark done anyway', style: 'destructive', onPress: finish },
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
export async function toggleSubtaskComplete(subtaskId: string): Promise<void> {
|
||||
await database.write(async () => {
|
||||
const subtask = await collections.subtasks.find(subtaskId);
|
||||
@@ -235,6 +283,8 @@ export async function toggleSubtaskComplete(subtaskId: string): Promise<void> {
|
||||
export interface SubtaskUpdateData {
|
||||
title: string;
|
||||
description?: string;
|
||||
categoryId?: string;
|
||||
tags?: string;
|
||||
priority: Priority;
|
||||
dueDate: number;
|
||||
dueTime?: string;
|
||||
@@ -253,6 +303,8 @@ export async function updateSubtask(subtaskId: string, data: SubtaskUpdateData):
|
||||
await subtask.update((s) => {
|
||||
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 || '';
|
||||
@@ -314,6 +366,8 @@ export async function duplicateSubtask(subtaskId: string): Promise<void> {
|
||||
|
||||
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;
|
||||
@@ -407,6 +461,7 @@ export async function convertTaskToSubtask(taskId: string, parentTaskId: string,
|
||||
const created = await collections.subtasks.create((s) => {
|
||||
s.taskId = parentTaskId;
|
||||
s.parentSubtaskId = parentSubtaskId;
|
||||
s.categoryId = task.categoryId || '';
|
||||
s.title = task.title;
|
||||
s.description = task.description;
|
||||
s.priority = task.priority;
|
||||
@@ -430,6 +485,7 @@ export async function convertTaskToSubtask(taskId: string, parentTaskId: string,
|
||||
await collections.subtasks.create((s) => {
|
||||
s.taskId = parentTaskId;
|
||||
s.parentSubtaskId = created.id;
|
||||
s.categoryId = subtask.categoryId || '';
|
||||
s.title = subtask.title;
|
||||
s.description = subtask.description;
|
||||
s.priority = subtask.priority;
|
||||
@@ -466,11 +522,13 @@ export async function convertSubtaskToTask(subtaskId: string): Promise<void> {
|
||||
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 = parent.categoryId;
|
||||
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;
|
||||
@@ -621,6 +679,7 @@ async function createNextOccurrence(task: any, seriesId: string): Promise<any> {
|
||||
t.title = task.title;
|
||||
t.description = task.description;
|
||||
t.categoryId = task.categoryId;
|
||||
t.tags = task.tags || '';
|
||||
t.priority = task.priority;
|
||||
t.completed = false;
|
||||
t.dueDate = nextDate.getTime();
|
||||
@@ -642,6 +701,8 @@ async function createNextOccurrence(task: any, seriesId: string): Promise<any> {
|
||||
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;
|
||||
@@ -664,6 +725,7 @@ export async function setTaskCategory(taskId: string, categoryId: string): Promi
|
||||
const task = await collections.tasks.find(taskId);
|
||||
await task.update((t) => {
|
||||
t.categoryId = categoryId;
|
||||
t.tags = tagsToString(categoryId ? [categoryId] : []);
|
||||
t.updatedAt = new Date();
|
||||
});
|
||||
});
|
||||
@@ -690,6 +752,7 @@ export async function duplicateTask(taskId: string): Promise<void> {
|
||||
t.title = task.title;
|
||||
t.description = task.description;
|
||||
t.categoryId = task.categoryId;
|
||||
t.tags = task.tags || '';
|
||||
t.priority = task.priority;
|
||||
t.completed = false;
|
||||
t.dueDate = task.dueDate;
|
||||
@@ -744,6 +807,8 @@ export async function reorderTasks(taskIds: string[]): Promise<void> {
|
||||
export interface CreateSubtaskData {
|
||||
taskId: string;
|
||||
parentSubtaskId?: string | null;
|
||||
categoryId?: string;
|
||||
tags?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
priority?: Priority;
|
||||
@@ -770,6 +835,8 @@ export async function createSubtask(data: CreateSubtaskData): Promise<string> {
|
||||
const subtask = await collections.subtasks.create((s) => {
|
||||
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';
|
||||
|
||||
@@ -1,6 +1,2 @@
|
||||
tech
|
||||
osuhhgoshghgjs
|
||||
|
||||
|
||||
test
|
||||
SFGHDHTHHDHRERTHEH
|
||||
tech08mag
|
||||
7BXtaqyvwVB15W
|
||||
|
||||
File diff suppressed because one or more lines are too long
Vendored
+14
@@ -0,0 +1,14 @@
|
||||
x64:
|
||||
firstOrDefaultFilePatterns:
|
||||
- '**/*'
|
||||
- '!**/node_modules/**'
|
||||
- '!build{,/**/*}'
|
||||
- '!dist{,/**/*}'
|
||||
- '!**/*.{iml,hprof,orig,pyc,pyo,rbc,swp,csproj,sln,suo,xproj,cc,d.ts,mk,a,o,obj,forge-meta,pdb}'
|
||||
- '!**/._*'
|
||||
- '!**/electron-builder.{yaml,yml,json,json5,toml,ts}'
|
||||
- '!**/{.git,.hg,.svn,CVS,RCS,SCCS,__pycache__,.DS_Store,thumbs.db,.gitignore,.gitkeep,.gitattributes,.npmignore,.idea,.vs,.flowconfig,.jshintrc,.eslintrc,.circleci,.yarn-integrity,.yarn-metadata.json,yarn-error.log,yarn.lock,package-lock.json,npm-debug.log,pnpm-lock.yaml,bun.lock,bun.lockb,appveyor.yml,.travis.yml,circle.yml,.nyc_output,.husky,.github,electron-builder.env}'
|
||||
- '!.yarn{,/**/*}'
|
||||
- '!.editorconfig'
|
||||
- '!.yarnrc.yml'
|
||||
nodeModuleFilePatterns: []
|
||||
Vendored
+37
@@ -0,0 +1,37 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
|
||||
<title>carry-your-live</title>
|
||||
<!-- The `react-native-web` recommended style reset: https://necolas.github.io/react-native-web/docs/setup/#root-element -->
|
||||
<style id="expo-reset">
|
||||
/* These styles make the body full-height */
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
/* These styles disable body scrolling if you are using <ScrollView> */
|
||||
body {
|
||||
overflow: hidden;
|
||||
}
|
||||
/* These styles make the root element full-height */
|
||||
#root {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- Use static rendering with Expo Router to support running without JavaScript. -->
|
||||
<noscript>
|
||||
You need to enable JavaScript to run this app.
|
||||
</noscript>
|
||||
<!-- The root element for your Expo app. -->
|
||||
<div id="root"></div>
|
||||
<script src="/_expo/static/js/web/entry-c9e0f94bf4e223fd6879dadc2f9426cb.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
Copyright (c) Electron contributors
|
||||
Copyright (c) 2013-2020 GitHub Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of this software and associated documentation files (the
|
||||
"Software"), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish,
|
||||
distribute, sublicense, and/or sell copies of the Software, and to
|
||||
permit persons to whom the Software is furnished to do so, subject to
|
||||
the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
||||
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
+343352
File diff suppressed because it is too large
Load Diff
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user