Compare commits
9
Commits
ec165beeff
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b78ada0865 | ||
|
|
be2f77dbf1 | ||
|
|
2ca23e276f | ||
|
|
f3bcd78e49 | ||
|
|
4c3d1a118c | ||
|
|
4b6e87c979 | ||
|
|
ed31f24b23 | ||
|
|
ecb7fbefb1 | ||
|
|
3d594670b0 |
@@ -0,0 +1,57 @@
|
|||||||
|
name: release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.event_name == 'push' && startsWith(github.event.head_commit.message, 'Merge pull request')
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
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
|
||||||
+55
@@ -57,6 +57,7 @@ GET /sync
|
|||||||
{
|
{
|
||||||
"id": "sub_1",
|
"id": "sub_1",
|
||||||
"taskId": "task_1",
|
"taskId": "task_1",
|
||||||
|
"parentSubtaskId": null,
|
||||||
"title": "Setup Expo project",
|
"title": "Setup Expo project",
|
||||||
"completed": true,
|
"completed": true,
|
||||||
"order": 0,
|
"order": 0,
|
||||||
@@ -64,10 +65,21 @@ GET /sync
|
|||||||
"updatedAt": 1699000000000
|
"updatedAt": 1699000000000
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"repeatProfiles": [],
|
||||||
|
"friendships": [],
|
||||||
|
"deleted": [
|
||||||
|
{
|
||||||
|
"entity": "tasks",
|
||||||
|
"id": "task_5",
|
||||||
|
"updatedAt": 1699150000000
|
||||||
|
}
|
||||||
|
],
|
||||||
"timestamp": 1699150000000
|
"timestamp": 1699150000000
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`deleted` contains every row tombstoned (deleted) since `since` — one entry per entity type/id. Clients must remove the row locally (and any related rows: a task deletion implies its subtasks, a category deletion implies its tasks and their subtasks, a parent subtask deletion implies its children).
|
||||||
|
|
||||||
**Error Responses**
|
**Error Responses**
|
||||||
| Status | Code | Message |
|
| Status | Code | Message |
|
||||||
|--------|------|---------|
|
|--------|------|---------|
|
||||||
@@ -114,6 +126,7 @@ POST /sync/push
|
|||||||
{
|
{
|
||||||
"id": "sub_new",
|
"id": "sub_new",
|
||||||
"taskId": "task_new",
|
"taskId": "task_new",
|
||||||
|
"parentSubtaskId": null,
|
||||||
"title": "Subtask 1",
|
"title": "Subtask 1",
|
||||||
"completed": false,
|
"completed": false,
|
||||||
"order": 0,
|
"order": 0,
|
||||||
@@ -122,10 +135,19 @@ POST /sync/push
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"deleted": [
|
||||||
|
{
|
||||||
|
"entity": "categories",
|
||||||
|
"id": "cat_gone",
|
||||||
|
"updatedAt": 1699150000000
|
||||||
|
}
|
||||||
|
],
|
||||||
"lastPulledAt": 1699100000000
|
"lastPulledAt": 1699100000000
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`deleted` is a list of tombstones the client recorded locally since its last successful sync. Deletions are resolved by last-writer-wins: if the server row's `updatedAt` is newer than the tombstone's `updatedAt`, the deletion is rejected and reported as a `server_wins` conflict (with the server row as `serverVersion`) so the client re-pulls it. Accepted deletions remove the rows server-side; cascaded rows (subtasks of a deleted task, tasks/subtasks of a deleted category, children of a deleted parent subtask) are tombstoned automatically so every device removes them.
|
||||||
|
|
||||||
**Response** (200 OK)
|
**Response** (200 OK)
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -568,6 +590,16 @@ interface Task {
|
|||||||
completed: boolean;
|
completed: boolean;
|
||||||
dueDate: number; // Unix timestamp (ms), 0 if not set
|
dueDate: number; // Unix timestamp (ms), 0 if not set
|
||||||
dueTime: string; // HH:MM format, empty if not set
|
dueTime: string; // HH:MM format, empty if not set
|
||||||
|
endTime: string; // HH:MM format, empty if not set
|
||||||
|
allDay: boolean;
|
||||||
|
repeat: 'none' | 'daily' | 'weekly' | 'monthly' | 'custom';
|
||||||
|
repeatInterval: number;
|
||||||
|
repeatDays: string; // comma separated weekday numbers, e.g. "0,2,4"
|
||||||
|
seriesId: string; // repeating-series id, empty if not part of a series
|
||||||
|
reminder: 'none' | 'at_time' | '15' | '30' | '60' | '120' | '1440';
|
||||||
|
reminders: string; // comma separated reminder values, empty if none
|
||||||
|
assigneeId: string | null;
|
||||||
|
completedAt: number | null; // Unix timestamp (ms) when completed
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
subtasks?: Subtask[];
|
subtasks?: Subtask[];
|
||||||
@@ -579,14 +611,37 @@ interface Task {
|
|||||||
interface Subtask {
|
interface Subtask {
|
||||||
id: string;
|
id: string;
|
||||||
taskId: string;
|
taskId: string;
|
||||||
|
parentSubtaskId: string | null; // id of the parent subtask, null for top-level
|
||||||
title: string;
|
title: string;
|
||||||
|
description: string;
|
||||||
|
priority: 'none' | 'low' | 'medium' | 'high' | 'critical';
|
||||||
completed: boolean;
|
completed: boolean;
|
||||||
|
dueDate: number;
|
||||||
|
dueTime: string;
|
||||||
|
endTime: string;
|
||||||
|
allDay: boolean;
|
||||||
|
repeat: 'none' | 'daily' | 'weekly' | 'monthly' | 'custom';
|
||||||
|
repeatInterval: number;
|
||||||
|
repeatDays: string;
|
||||||
|
seriesId: string;
|
||||||
|
reminder: 'none' | 'at_time' | '15' | '30' | '60' | '120' | '1440';
|
||||||
|
reminders: string;
|
||||||
|
assigneeId: string | null;
|
||||||
order: number;
|
order: number;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Tombstone
|
||||||
|
```typescript
|
||||||
|
interface Tombstone {
|
||||||
|
entity: 'categories' | 'tasks' | 'subtasks' | 'repeatProfiles' | 'friendships';
|
||||||
|
id: string; // id of the deleted row
|
||||||
|
updatedAt: number; // Unix timestamp (ms) of the deletion
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### UserSettings
|
### UserSettings
|
||||||
```typescript
|
```typescript
|
||||||
interface UserSettings {
|
interface UserSettings {
|
||||||
|
|||||||
@@ -76,7 +76,8 @@ A minimalist, offline-first task management app built with Expo, React Native, a
|
|||||||
| UI Components | React Native Paper + Custom SVG icons |
|
| UI Components | React Native Paper + Custom SVG icons |
|
||||||
| Animations | React Native Reanimated |
|
| Animations | React Native Reanimated |
|
||||||
| Date/Time | @react-native-community/datetimepicker + date-fns |
|
| Date/Time | @react-native-community/datetimepicker + date-fns |
|
||||||
| Build | Expo Dev Client / EAS Build |
|
| Build | EAS Build / Gradle (local APK) |
|
||||||
|
| CI/CD | Gitea Actions (`.gitea/workflows/build-apk.yml`) |
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
@@ -210,6 +211,8 @@ npx expo start --web
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Building
|
### Building
|
||||||
|
|
||||||
|
#### EAS Build (cloud)
|
||||||
```bash
|
```bash
|
||||||
# Install EAS CLI
|
# Install EAS CLI
|
||||||
npm install -g eas-cli
|
npm install -g eas-cli
|
||||||
@@ -222,6 +225,51 @@ eas build --platform ios
|
|||||||
eas build --platform android
|
eas build --platform android
|
||||||
eas build --platform web
|
eas build --platform web
|
||||||
```
|
```
|
||||||
|
Profiles are defined in `eas.json` (`development`, `preview`, `production`; production auto-increments version code).
|
||||||
|
|
||||||
|
#### Gradle (local Android APK)
|
||||||
|
The native Android project lives in `carry-your-live/android/`. Requires JDK 17+ and the Android SDK (platform 35, build-tools 35.0.0, NDK 27.1).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd carry-your-live/android
|
||||||
|
|
||||||
|
# Debug APK (unsigned)
|
||||||
|
./gradlew assembleDebug
|
||||||
|
|
||||||
|
# Release APK (currently signed with the debug keystore)
|
||||||
|
./gradlew assembleRelease
|
||||||
|
```
|
||||||
|
Output:
|
||||||
|
```
|
||||||
|
android/app/build/outputs/apk/debug/app-debug.apk
|
||||||
|
android/app/build/outputs/apk/release/app-release.apk
|
||||||
|
```
|
||||||
|
Key notes:
|
||||||
|
- The debug keystore (`android/app/debug.keystore`) is generated by the CI pipeline and must exist for release builds — create it locally with the same command used in the pipeline (see below) if it's missing.
|
||||||
|
- Release builds use `signingConfig signingConfigs.debug` until a production keystore is configured.
|
||||||
|
- Versions are set in `android/app/build.gradle` (`versionCode`, `versionName`).
|
||||||
|
|
||||||
|
## CI/CD Pipeline (Gitea Actions)
|
||||||
|
|
||||||
|
`.gitea/workflows/build-apk.yml` builds a release APK on every push to `main` (also manually triggerable via workflow_dispatch):
|
||||||
|
|
||||||
|
1. **Checkout** and setup Node 22, JDK 17 (Temurin)
|
||||||
|
2. **Android SDK**: installs cmdline-tools, licenses, platform-tools, `platforms;android-35`, `build-tools;35.0.0`, `ndk;27.1.12297006`
|
||||||
|
3. **JS deps**: `npm ci` in `carry-your-live/`
|
||||||
|
4. **Debug keystore**: generates `carry-your-live/android/app/debug.keystore` with `keytool` (alias `androiddebugkey`, passwords `android`)
|
||||||
|
5. **Build**: `./gradlew assembleRelease` in `carry-your-live/android/`
|
||||||
|
6. **Upload**: the APK is saved as the `carry-your-live-release` artifact (downloadable from the run's artifacts page)
|
||||||
|
|
||||||
|
To run the build locally exactly as CI does:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
keytool -genkeypair -v \
|
||||||
|
-keystore carry-your-live/android/app/debug.keystore \
|
||||||
|
-alias androiddebugkey -storepass android -keypass android \
|
||||||
|
-keyalg RSA -keysize 2048 -validity 10000 \
|
||||||
|
-dname "CN=Android Debug,O=Android,C=US"
|
||||||
|
cd carry-your-live/android && ./gradlew assembleRelease
|
||||||
|
```
|
||||||
|
|
||||||
## Sync API Specification
|
## Sync API Specification
|
||||||
|
|
||||||
|
|||||||
Vendored
+513
-136
@@ -1,3 +1,5 @@
|
|||||||
|
export declare const ENTITIES: readonly ["categories", "tasks", "subtasks", "repeatProfiles", "friendships"];
|
||||||
|
export type EntityName = (typeof ENTITIES)[number];
|
||||||
export declare const users: import("drizzle-orm/pg-core").PgTableWithColumns<{
|
export declare const users: import("drizzle-orm/pg-core").PgTableWithColumns<{
|
||||||
name: "users";
|
name: "users";
|
||||||
schema: undefined;
|
schema: undefined;
|
||||||
@@ -50,6 +52,38 @@ export declare const users: import("drizzle-orm/pg-core").PgTableWithColumns<{
|
|||||||
baseColumn: never;
|
baseColumn: never;
|
||||||
generated: undefined;
|
generated: undefined;
|
||||||
}, {}, {}>;
|
}, {}, {}>;
|
||||||
|
resetToken: import("drizzle-orm/pg-core").PgColumn<{
|
||||||
|
name: "reset_token";
|
||||||
|
tableName: "users";
|
||||||
|
dataType: "string";
|
||||||
|
columnType: "PgText";
|
||||||
|
data: string;
|
||||||
|
driverParam: string;
|
||||||
|
notNull: false;
|
||||||
|
hasDefault: false;
|
||||||
|
isPrimaryKey: false;
|
||||||
|
isAutoincrement: false;
|
||||||
|
hasRuntimeDefault: false;
|
||||||
|
enumValues: [string, ...string[]];
|
||||||
|
baseColumn: never;
|
||||||
|
generated: undefined;
|
||||||
|
}, {}, {}>;
|
||||||
|
resetTokenExpiry: import("drizzle-orm/pg-core").PgColumn<{
|
||||||
|
name: "reset_token_expiry";
|
||||||
|
tableName: "users";
|
||||||
|
dataType: "number";
|
||||||
|
columnType: "PgBigInt53";
|
||||||
|
data: number;
|
||||||
|
driverParam: string | number;
|
||||||
|
notNull: false;
|
||||||
|
hasDefault: false;
|
||||||
|
isPrimaryKey: false;
|
||||||
|
isAutoincrement: false;
|
||||||
|
hasRuntimeDefault: false;
|
||||||
|
enumValues: undefined;
|
||||||
|
baseColumn: never;
|
||||||
|
generated: undefined;
|
||||||
|
}, {}, {}>;
|
||||||
createdAt: import("drizzle-orm/pg-core").PgColumn<{
|
createdAt: import("drizzle-orm/pg-core").PgColumn<{
|
||||||
name: "created_at";
|
name: "created_at";
|
||||||
tableName: "users";
|
tableName: "users";
|
||||||
@@ -334,7 +368,7 @@ export declare const tasks: import("drizzle-orm/pg-core").PgTableWithColumns<{
|
|||||||
columnType: "PgText";
|
columnType: "PgText";
|
||||||
data: string;
|
data: string;
|
||||||
driverParam: string;
|
driverParam: string;
|
||||||
notNull: true;
|
notNull: false;
|
||||||
hasDefault: false;
|
hasDefault: false;
|
||||||
isPrimaryKey: false;
|
isPrimaryKey: false;
|
||||||
isAutoincrement: false;
|
isAutoincrement: false;
|
||||||
@@ -455,6 +489,22 @@ export declare const tasks: import("drizzle-orm/pg-core").PgTableWithColumns<{
|
|||||||
baseColumn: never;
|
baseColumn: never;
|
||||||
generated: undefined;
|
generated: undefined;
|
||||||
}, {}, {}>;
|
}, {}, {}>;
|
||||||
|
allDay: import("drizzle-orm/pg-core").PgColumn<{
|
||||||
|
name: "all_day";
|
||||||
|
tableName: "tasks";
|
||||||
|
dataType: "boolean";
|
||||||
|
columnType: "PgBoolean";
|
||||||
|
data: boolean;
|
||||||
|
driverParam: boolean;
|
||||||
|
notNull: true;
|
||||||
|
hasDefault: true;
|
||||||
|
isPrimaryKey: false;
|
||||||
|
isAutoincrement: false;
|
||||||
|
hasRuntimeDefault: false;
|
||||||
|
enumValues: undefined;
|
||||||
|
baseColumn: never;
|
||||||
|
generated: undefined;
|
||||||
|
}, {}, {}>;
|
||||||
repeat: import("drizzle-orm/pg-core").PgColumn<{
|
repeat: import("drizzle-orm/pg-core").PgColumn<{
|
||||||
name: "repeat";
|
name: "repeat";
|
||||||
tableName: "tasks";
|
tableName: "tasks";
|
||||||
@@ -551,6 +601,38 @@ export declare const tasks: import("drizzle-orm/pg-core").PgTableWithColumns<{
|
|||||||
baseColumn: never;
|
baseColumn: never;
|
||||||
generated: undefined;
|
generated: undefined;
|
||||||
}, {}, {}>;
|
}, {}, {}>;
|
||||||
|
reminders: import("drizzle-orm/pg-core").PgColumn<{
|
||||||
|
name: "reminders";
|
||||||
|
tableName: "tasks";
|
||||||
|
dataType: "string";
|
||||||
|
columnType: "PgText";
|
||||||
|
data: string;
|
||||||
|
driverParam: string;
|
||||||
|
notNull: true;
|
||||||
|
hasDefault: true;
|
||||||
|
isPrimaryKey: false;
|
||||||
|
isAutoincrement: false;
|
||||||
|
hasRuntimeDefault: false;
|
||||||
|
enumValues: [string, ...string[]];
|
||||||
|
baseColumn: never;
|
||||||
|
generated: undefined;
|
||||||
|
}, {}, {}>;
|
||||||
|
completedAt: import("drizzle-orm/pg-core").PgColumn<{
|
||||||
|
name: "completed_at";
|
||||||
|
tableName: "tasks";
|
||||||
|
dataType: "number";
|
||||||
|
columnType: "PgBigInt53";
|
||||||
|
data: number;
|
||||||
|
driverParam: string | number;
|
||||||
|
notNull: false;
|
||||||
|
hasDefault: false;
|
||||||
|
isPrimaryKey: false;
|
||||||
|
isAutoincrement: false;
|
||||||
|
hasRuntimeDefault: false;
|
||||||
|
enumValues: undefined;
|
||||||
|
baseColumn: never;
|
||||||
|
generated: undefined;
|
||||||
|
}, {}, {}>;
|
||||||
createdAt: import("drizzle-orm/pg-core").PgColumn<{
|
createdAt: import("drizzle-orm/pg-core").PgColumn<{
|
||||||
name: "created_at";
|
name: "created_at";
|
||||||
tableName: "tasks";
|
tableName: "tasks";
|
||||||
@@ -586,6 +668,436 @@ export declare const tasks: import("drizzle-orm/pg-core").PgTableWithColumns<{
|
|||||||
};
|
};
|
||||||
dialect: "pg";
|
dialect: "pg";
|
||||||
}>;
|
}>;
|
||||||
|
export declare const tombstones: import("drizzle-orm/pg-core").PgTableWithColumns<{
|
||||||
|
name: "tombstones";
|
||||||
|
schema: undefined;
|
||||||
|
columns: {
|
||||||
|
entity: import("drizzle-orm/pg-core").PgColumn<{
|
||||||
|
name: "entity";
|
||||||
|
tableName: "tombstones";
|
||||||
|
dataType: "string";
|
||||||
|
columnType: "PgText";
|
||||||
|
data: "categories" | "tasks" | "subtasks" | "repeatProfiles" | "friendships";
|
||||||
|
driverParam: string;
|
||||||
|
notNull: true;
|
||||||
|
hasDefault: false;
|
||||||
|
isPrimaryKey: false;
|
||||||
|
isAutoincrement: false;
|
||||||
|
hasRuntimeDefault: false;
|
||||||
|
enumValues: ["categories", "tasks", "subtasks", "repeatProfiles", "friendships"];
|
||||||
|
baseColumn: never;
|
||||||
|
generated: undefined;
|
||||||
|
}, {}, {}>;
|
||||||
|
entityId: import("drizzle-orm/pg-core").PgColumn<{
|
||||||
|
name: "entity_id";
|
||||||
|
tableName: "tombstones";
|
||||||
|
dataType: "string";
|
||||||
|
columnType: "PgText";
|
||||||
|
data: string;
|
||||||
|
driverParam: string;
|
||||||
|
notNull: true;
|
||||||
|
hasDefault: false;
|
||||||
|
isPrimaryKey: false;
|
||||||
|
isAutoincrement: false;
|
||||||
|
hasRuntimeDefault: false;
|
||||||
|
enumValues: [string, ...string[]];
|
||||||
|
baseColumn: never;
|
||||||
|
generated: undefined;
|
||||||
|
}, {}, {}>;
|
||||||
|
userId: import("drizzle-orm/pg-core").PgColumn<{
|
||||||
|
name: "user_id";
|
||||||
|
tableName: "tombstones";
|
||||||
|
dataType: "string";
|
||||||
|
columnType: "PgText";
|
||||||
|
data: string;
|
||||||
|
driverParam: string;
|
||||||
|
notNull: true;
|
||||||
|
hasDefault: false;
|
||||||
|
isPrimaryKey: false;
|
||||||
|
isAutoincrement: false;
|
||||||
|
hasRuntimeDefault: false;
|
||||||
|
enumValues: [string, ...string[]];
|
||||||
|
baseColumn: never;
|
||||||
|
generated: undefined;
|
||||||
|
}, {}, {}>;
|
||||||
|
updatedAt: import("drizzle-orm/pg-core").PgColumn<{
|
||||||
|
name: "updated_at";
|
||||||
|
tableName: "tombstones";
|
||||||
|
dataType: "number";
|
||||||
|
columnType: "PgBigInt53";
|
||||||
|
data: number;
|
||||||
|
driverParam: string | number;
|
||||||
|
notNull: true;
|
||||||
|
hasDefault: false;
|
||||||
|
isPrimaryKey: false;
|
||||||
|
isAutoincrement: false;
|
||||||
|
hasRuntimeDefault: false;
|
||||||
|
enumValues: undefined;
|
||||||
|
baseColumn: never;
|
||||||
|
generated: undefined;
|
||||||
|
}, {}, {}>;
|
||||||
|
};
|
||||||
|
dialect: "pg";
|
||||||
|
}>;
|
||||||
|
export declare const subtasks: import("drizzle-orm/pg-core").PgTableWithColumns<{
|
||||||
|
name: "subtasks";
|
||||||
|
schema: undefined;
|
||||||
|
columns: {
|
||||||
|
id: import("drizzle-orm/pg-core").PgColumn<{
|
||||||
|
name: "id";
|
||||||
|
tableName: "subtasks";
|
||||||
|
dataType: "string";
|
||||||
|
columnType: "PgText";
|
||||||
|
data: string;
|
||||||
|
driverParam: string;
|
||||||
|
notNull: true;
|
||||||
|
hasDefault: false;
|
||||||
|
isPrimaryKey: true;
|
||||||
|
isAutoincrement: false;
|
||||||
|
hasRuntimeDefault: false;
|
||||||
|
enumValues: [string, ...string[]];
|
||||||
|
baseColumn: never;
|
||||||
|
generated: undefined;
|
||||||
|
}, {}, {}>;
|
||||||
|
userId: import("drizzle-orm/pg-core").PgColumn<{
|
||||||
|
name: "user_id";
|
||||||
|
tableName: "subtasks";
|
||||||
|
dataType: "string";
|
||||||
|
columnType: "PgText";
|
||||||
|
data: string;
|
||||||
|
driverParam: string;
|
||||||
|
notNull: true;
|
||||||
|
hasDefault: false;
|
||||||
|
isPrimaryKey: false;
|
||||||
|
isAutoincrement: false;
|
||||||
|
hasRuntimeDefault: false;
|
||||||
|
enumValues: [string, ...string[]];
|
||||||
|
baseColumn: never;
|
||||||
|
generated: undefined;
|
||||||
|
}, {}, {}>;
|
||||||
|
taskId: import("drizzle-orm/pg-core").PgColumn<{
|
||||||
|
name: "task_id";
|
||||||
|
tableName: "subtasks";
|
||||||
|
dataType: "string";
|
||||||
|
columnType: "PgText";
|
||||||
|
data: string;
|
||||||
|
driverParam: string;
|
||||||
|
notNull: true;
|
||||||
|
hasDefault: false;
|
||||||
|
isPrimaryKey: false;
|
||||||
|
isAutoincrement: false;
|
||||||
|
hasRuntimeDefault: false;
|
||||||
|
enumValues: [string, ...string[]];
|
||||||
|
baseColumn: never;
|
||||||
|
generated: undefined;
|
||||||
|
}, {}, {}>;
|
||||||
|
parentSubtaskId: import("drizzle-orm/pg-core").PgColumn<{
|
||||||
|
name: "parent_subtask_id";
|
||||||
|
tableName: "subtasks";
|
||||||
|
dataType: "string";
|
||||||
|
columnType: "PgText";
|
||||||
|
data: string;
|
||||||
|
driverParam: string;
|
||||||
|
notNull: false;
|
||||||
|
hasDefault: false;
|
||||||
|
isPrimaryKey: false;
|
||||||
|
isAutoincrement: false;
|
||||||
|
hasRuntimeDefault: false;
|
||||||
|
enumValues: [string, ...string[]];
|
||||||
|
baseColumn: never;
|
||||||
|
generated: undefined;
|
||||||
|
}, {}, {}>;
|
||||||
|
title: import("drizzle-orm/pg-core").PgColumn<{
|
||||||
|
name: "title";
|
||||||
|
tableName: "subtasks";
|
||||||
|
dataType: "string";
|
||||||
|
columnType: "PgText";
|
||||||
|
data: string;
|
||||||
|
driverParam: string;
|
||||||
|
notNull: true;
|
||||||
|
hasDefault: false;
|
||||||
|
isPrimaryKey: false;
|
||||||
|
isAutoincrement: false;
|
||||||
|
hasRuntimeDefault: false;
|
||||||
|
enumValues: [string, ...string[]];
|
||||||
|
baseColumn: never;
|
||||||
|
generated: undefined;
|
||||||
|
}, {}, {}>;
|
||||||
|
description: import("drizzle-orm/pg-core").PgColumn<{
|
||||||
|
name: "description";
|
||||||
|
tableName: "subtasks";
|
||||||
|
dataType: "string";
|
||||||
|
columnType: "PgText";
|
||||||
|
data: string;
|
||||||
|
driverParam: string;
|
||||||
|
notNull: true;
|
||||||
|
hasDefault: true;
|
||||||
|
isPrimaryKey: false;
|
||||||
|
isAutoincrement: false;
|
||||||
|
hasRuntimeDefault: false;
|
||||||
|
enumValues: [string, ...string[]];
|
||||||
|
baseColumn: never;
|
||||||
|
generated: undefined;
|
||||||
|
}, {}, {}>;
|
||||||
|
priority: import("drizzle-orm/pg-core").PgColumn<{
|
||||||
|
name: "priority";
|
||||||
|
tableName: "subtasks";
|
||||||
|
dataType: "string";
|
||||||
|
columnType: "PgText";
|
||||||
|
data: "none" | "low" | "medium" | "high" | "critical";
|
||||||
|
driverParam: string;
|
||||||
|
notNull: true;
|
||||||
|
hasDefault: true;
|
||||||
|
isPrimaryKey: false;
|
||||||
|
isAutoincrement: false;
|
||||||
|
hasRuntimeDefault: false;
|
||||||
|
enumValues: ["none", "low", "medium", "high", "critical"];
|
||||||
|
baseColumn: never;
|
||||||
|
generated: undefined;
|
||||||
|
}, {}, {}>;
|
||||||
|
completed: import("drizzle-orm/pg-core").PgColumn<{
|
||||||
|
name: "completed";
|
||||||
|
tableName: "subtasks";
|
||||||
|
dataType: "boolean";
|
||||||
|
columnType: "PgBoolean";
|
||||||
|
data: boolean;
|
||||||
|
driverParam: boolean;
|
||||||
|
notNull: true;
|
||||||
|
hasDefault: true;
|
||||||
|
isPrimaryKey: false;
|
||||||
|
isAutoincrement: false;
|
||||||
|
hasRuntimeDefault: false;
|
||||||
|
enumValues: undefined;
|
||||||
|
baseColumn: never;
|
||||||
|
generated: undefined;
|
||||||
|
}, {}, {}>;
|
||||||
|
dueDate: import("drizzle-orm/pg-core").PgColumn<{
|
||||||
|
name: "due_date";
|
||||||
|
tableName: "subtasks";
|
||||||
|
dataType: "number";
|
||||||
|
columnType: "PgBigInt53";
|
||||||
|
data: number;
|
||||||
|
driverParam: string | number;
|
||||||
|
notNull: true;
|
||||||
|
hasDefault: true;
|
||||||
|
isPrimaryKey: false;
|
||||||
|
isAutoincrement: false;
|
||||||
|
hasRuntimeDefault: false;
|
||||||
|
enumValues: undefined;
|
||||||
|
baseColumn: never;
|
||||||
|
generated: undefined;
|
||||||
|
}, {}, {}>;
|
||||||
|
dueTime: import("drizzle-orm/pg-core").PgColumn<{
|
||||||
|
name: "due_time";
|
||||||
|
tableName: "subtasks";
|
||||||
|
dataType: "string";
|
||||||
|
columnType: "PgText";
|
||||||
|
data: string;
|
||||||
|
driverParam: string;
|
||||||
|
notNull: true;
|
||||||
|
hasDefault: true;
|
||||||
|
isPrimaryKey: false;
|
||||||
|
isAutoincrement: false;
|
||||||
|
hasRuntimeDefault: false;
|
||||||
|
enumValues: [string, ...string[]];
|
||||||
|
baseColumn: never;
|
||||||
|
generated: undefined;
|
||||||
|
}, {}, {}>;
|
||||||
|
endTime: import("drizzle-orm/pg-core").PgColumn<{
|
||||||
|
name: "end_time";
|
||||||
|
tableName: "subtasks";
|
||||||
|
dataType: "string";
|
||||||
|
columnType: "PgText";
|
||||||
|
data: string;
|
||||||
|
driverParam: string;
|
||||||
|
notNull: true;
|
||||||
|
hasDefault: true;
|
||||||
|
isPrimaryKey: false;
|
||||||
|
isAutoincrement: false;
|
||||||
|
hasRuntimeDefault: false;
|
||||||
|
enumValues: [string, ...string[]];
|
||||||
|
baseColumn: never;
|
||||||
|
generated: undefined;
|
||||||
|
}, {}, {}>;
|
||||||
|
allDay: import("drizzle-orm/pg-core").PgColumn<{
|
||||||
|
name: "all_day";
|
||||||
|
tableName: "subtasks";
|
||||||
|
dataType: "boolean";
|
||||||
|
columnType: "PgBoolean";
|
||||||
|
data: boolean;
|
||||||
|
driverParam: boolean;
|
||||||
|
notNull: true;
|
||||||
|
hasDefault: true;
|
||||||
|
isPrimaryKey: false;
|
||||||
|
isAutoincrement: false;
|
||||||
|
hasRuntimeDefault: false;
|
||||||
|
enumValues: undefined;
|
||||||
|
baseColumn: never;
|
||||||
|
generated: undefined;
|
||||||
|
}, {}, {}>;
|
||||||
|
repeat: import("drizzle-orm/pg-core").PgColumn<{
|
||||||
|
name: "repeat";
|
||||||
|
tableName: "subtasks";
|
||||||
|
dataType: "string";
|
||||||
|
columnType: "PgText";
|
||||||
|
data: "custom" | "none" | "daily" | "weekly" | "monthly";
|
||||||
|
driverParam: string;
|
||||||
|
notNull: true;
|
||||||
|
hasDefault: true;
|
||||||
|
isPrimaryKey: false;
|
||||||
|
isAutoincrement: false;
|
||||||
|
hasRuntimeDefault: false;
|
||||||
|
enumValues: ["none", "daily", "weekly", "monthly", "custom"];
|
||||||
|
baseColumn: never;
|
||||||
|
generated: undefined;
|
||||||
|
}, {}, {}>;
|
||||||
|
repeatInterval: import("drizzle-orm/pg-core").PgColumn<{
|
||||||
|
name: "repeat_interval";
|
||||||
|
tableName: "subtasks";
|
||||||
|
dataType: "number";
|
||||||
|
columnType: "PgInteger";
|
||||||
|
data: number;
|
||||||
|
driverParam: string | number;
|
||||||
|
notNull: true;
|
||||||
|
hasDefault: true;
|
||||||
|
isPrimaryKey: false;
|
||||||
|
isAutoincrement: false;
|
||||||
|
hasRuntimeDefault: false;
|
||||||
|
enumValues: undefined;
|
||||||
|
baseColumn: never;
|
||||||
|
generated: undefined;
|
||||||
|
}, {}, {}>;
|
||||||
|
repeatDays: import("drizzle-orm/pg-core").PgColumn<{
|
||||||
|
name: "repeat_days";
|
||||||
|
tableName: "subtasks";
|
||||||
|
dataType: "string";
|
||||||
|
columnType: "PgText";
|
||||||
|
data: string;
|
||||||
|
driverParam: string;
|
||||||
|
notNull: true;
|
||||||
|
hasDefault: true;
|
||||||
|
isPrimaryKey: false;
|
||||||
|
isAutoincrement: false;
|
||||||
|
hasRuntimeDefault: false;
|
||||||
|
enumValues: [string, ...string[]];
|
||||||
|
baseColumn: never;
|
||||||
|
generated: undefined;
|
||||||
|
}, {}, {}>;
|
||||||
|
seriesId: import("drizzle-orm/pg-core").PgColumn<{
|
||||||
|
name: "series_id";
|
||||||
|
tableName: "subtasks";
|
||||||
|
dataType: "string";
|
||||||
|
columnType: "PgText";
|
||||||
|
data: string;
|
||||||
|
driverParam: string;
|
||||||
|
notNull: true;
|
||||||
|
hasDefault: true;
|
||||||
|
isPrimaryKey: false;
|
||||||
|
isAutoincrement: false;
|
||||||
|
hasRuntimeDefault: false;
|
||||||
|
enumValues: [string, ...string[]];
|
||||||
|
baseColumn: never;
|
||||||
|
generated: undefined;
|
||||||
|
}, {}, {}>;
|
||||||
|
reminder: import("drizzle-orm/pg-core").PgColumn<{
|
||||||
|
name: "reminder";
|
||||||
|
tableName: "subtasks";
|
||||||
|
dataType: "string";
|
||||||
|
columnType: "PgText";
|
||||||
|
data: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440";
|
||||||
|
driverParam: string;
|
||||||
|
notNull: true;
|
||||||
|
hasDefault: true;
|
||||||
|
isPrimaryKey: false;
|
||||||
|
isAutoincrement: false;
|
||||||
|
hasRuntimeDefault: false;
|
||||||
|
enumValues: ["none", "at_time", "15", "30", "60", "120", "1440"];
|
||||||
|
baseColumn: never;
|
||||||
|
generated: undefined;
|
||||||
|
}, {}, {}>;
|
||||||
|
reminders: import("drizzle-orm/pg-core").PgColumn<{
|
||||||
|
name: "reminders";
|
||||||
|
tableName: "subtasks";
|
||||||
|
dataType: "string";
|
||||||
|
columnType: "PgText";
|
||||||
|
data: string;
|
||||||
|
driverParam: string;
|
||||||
|
notNull: true;
|
||||||
|
hasDefault: true;
|
||||||
|
isPrimaryKey: false;
|
||||||
|
isAutoincrement: false;
|
||||||
|
hasRuntimeDefault: false;
|
||||||
|
enumValues: [string, ...string[]];
|
||||||
|
baseColumn: never;
|
||||||
|
generated: undefined;
|
||||||
|
}, {}, {}>;
|
||||||
|
assigneeId: import("drizzle-orm/pg-core").PgColumn<{
|
||||||
|
name: "assignee_id";
|
||||||
|
tableName: "subtasks";
|
||||||
|
dataType: "string";
|
||||||
|
columnType: "PgText";
|
||||||
|
data: string;
|
||||||
|
driverParam: string;
|
||||||
|
notNull: false;
|
||||||
|
hasDefault: false;
|
||||||
|
isPrimaryKey: false;
|
||||||
|
isAutoincrement: false;
|
||||||
|
hasRuntimeDefault: false;
|
||||||
|
enumValues: [string, ...string[]];
|
||||||
|
baseColumn: never;
|
||||||
|
generated: undefined;
|
||||||
|
}, {}, {}>;
|
||||||
|
order: import("drizzle-orm/pg-core").PgColumn<{
|
||||||
|
name: "order";
|
||||||
|
tableName: "subtasks";
|
||||||
|
dataType: "number";
|
||||||
|
columnType: "PgInteger";
|
||||||
|
data: number;
|
||||||
|
driverParam: string | number;
|
||||||
|
notNull: true;
|
||||||
|
hasDefault: true;
|
||||||
|
isPrimaryKey: false;
|
||||||
|
isAutoincrement: false;
|
||||||
|
hasRuntimeDefault: false;
|
||||||
|
enumValues: undefined;
|
||||||
|
baseColumn: never;
|
||||||
|
generated: undefined;
|
||||||
|
}, {}, {}>;
|
||||||
|
createdAt: import("drizzle-orm/pg-core").PgColumn<{
|
||||||
|
name: "created_at";
|
||||||
|
tableName: "subtasks";
|
||||||
|
dataType: "number";
|
||||||
|
columnType: "PgBigInt53";
|
||||||
|
data: number;
|
||||||
|
driverParam: string | number;
|
||||||
|
notNull: true;
|
||||||
|
hasDefault: true;
|
||||||
|
isPrimaryKey: false;
|
||||||
|
isAutoincrement: false;
|
||||||
|
hasRuntimeDefault: false;
|
||||||
|
enumValues: undefined;
|
||||||
|
baseColumn: never;
|
||||||
|
generated: undefined;
|
||||||
|
}, {}, {}>;
|
||||||
|
updatedAt: import("drizzle-orm/pg-core").PgColumn<{
|
||||||
|
name: "updated_at";
|
||||||
|
tableName: "subtasks";
|
||||||
|
dataType: "number";
|
||||||
|
columnType: "PgBigInt53";
|
||||||
|
data: number;
|
||||||
|
driverParam: string | number;
|
||||||
|
notNull: true;
|
||||||
|
hasDefault: true;
|
||||||
|
isPrimaryKey: false;
|
||||||
|
isAutoincrement: false;
|
||||||
|
hasRuntimeDefault: false;
|
||||||
|
enumValues: undefined;
|
||||||
|
baseColumn: never;
|
||||||
|
generated: undefined;
|
||||||
|
}, {}, {}>;
|
||||||
|
};
|
||||||
|
dialect: "pg";
|
||||||
|
}>;
|
||||||
export declare const repeatProfiles: import("drizzle-orm/pg-core").PgTableWithColumns<{
|
export declare const repeatProfiles: import("drizzle-orm/pg-core").PgTableWithColumns<{
|
||||||
name: "repeat_profiles";
|
name: "repeat_profiles";
|
||||||
schema: undefined;
|
schema: undefined;
|
||||||
@@ -721,141 +1233,6 @@ export declare const repeatProfiles: import("drizzle-orm/pg-core").PgTableWithCo
|
|||||||
};
|
};
|
||||||
dialect: "pg";
|
dialect: "pg";
|
||||||
}>;
|
}>;
|
||||||
export declare const subtasks: import("drizzle-orm/pg-core").PgTableWithColumns<{
|
|
||||||
name: "subtasks";
|
|
||||||
schema: undefined;
|
|
||||||
columns: {
|
|
||||||
id: import("drizzle-orm/pg-core").PgColumn<{
|
|
||||||
name: "id";
|
|
||||||
tableName: "subtasks";
|
|
||||||
dataType: "string";
|
|
||||||
columnType: "PgText";
|
|
||||||
data: string;
|
|
||||||
driverParam: string;
|
|
||||||
notNull: true;
|
|
||||||
hasDefault: false;
|
|
||||||
isPrimaryKey: true;
|
|
||||||
isAutoincrement: false;
|
|
||||||
hasRuntimeDefault: false;
|
|
||||||
enumValues: [string, ...string[]];
|
|
||||||
baseColumn: never;
|
|
||||||
generated: undefined;
|
|
||||||
}, {}, {}>;
|
|
||||||
userId: import("drizzle-orm/pg-core").PgColumn<{
|
|
||||||
name: "user_id";
|
|
||||||
tableName: "subtasks";
|
|
||||||
dataType: "string";
|
|
||||||
columnType: "PgText";
|
|
||||||
data: string;
|
|
||||||
driverParam: string;
|
|
||||||
notNull: true;
|
|
||||||
hasDefault: false;
|
|
||||||
isPrimaryKey: false;
|
|
||||||
isAutoincrement: false;
|
|
||||||
hasRuntimeDefault: false;
|
|
||||||
enumValues: [string, ...string[]];
|
|
||||||
baseColumn: never;
|
|
||||||
generated: undefined;
|
|
||||||
}, {}, {}>;
|
|
||||||
taskId: import("drizzle-orm/pg-core").PgColumn<{
|
|
||||||
name: "task_id";
|
|
||||||
tableName: "subtasks";
|
|
||||||
dataType: "string";
|
|
||||||
columnType: "PgText";
|
|
||||||
data: string;
|
|
||||||
driverParam: string;
|
|
||||||
notNull: true;
|
|
||||||
hasDefault: false;
|
|
||||||
isPrimaryKey: false;
|
|
||||||
isAutoincrement: false;
|
|
||||||
hasRuntimeDefault: false;
|
|
||||||
enumValues: [string, ...string[]];
|
|
||||||
baseColumn: never;
|
|
||||||
generated: undefined;
|
|
||||||
}, {}, {}>;
|
|
||||||
title: import("drizzle-orm/pg-core").PgColumn<{
|
|
||||||
name: "title";
|
|
||||||
tableName: "subtasks";
|
|
||||||
dataType: "string";
|
|
||||||
columnType: "PgText";
|
|
||||||
data: string;
|
|
||||||
driverParam: string;
|
|
||||||
notNull: true;
|
|
||||||
hasDefault: false;
|
|
||||||
isPrimaryKey: false;
|
|
||||||
isAutoincrement: false;
|
|
||||||
hasRuntimeDefault: false;
|
|
||||||
enumValues: [string, ...string[]];
|
|
||||||
baseColumn: never;
|
|
||||||
generated: undefined;
|
|
||||||
}, {}, {}>;
|
|
||||||
completed: import("drizzle-orm/pg-core").PgColumn<{
|
|
||||||
name: "completed";
|
|
||||||
tableName: "subtasks";
|
|
||||||
dataType: "boolean";
|
|
||||||
columnType: "PgBoolean";
|
|
||||||
data: boolean;
|
|
||||||
driverParam: boolean;
|
|
||||||
notNull: true;
|
|
||||||
hasDefault: true;
|
|
||||||
isPrimaryKey: false;
|
|
||||||
isAutoincrement: false;
|
|
||||||
hasRuntimeDefault: false;
|
|
||||||
enumValues: undefined;
|
|
||||||
baseColumn: never;
|
|
||||||
generated: undefined;
|
|
||||||
}, {}, {}>;
|
|
||||||
order: import("drizzle-orm/pg-core").PgColumn<{
|
|
||||||
name: "order";
|
|
||||||
tableName: "subtasks";
|
|
||||||
dataType: "number";
|
|
||||||
columnType: "PgInteger";
|
|
||||||
data: number;
|
|
||||||
driverParam: string | number;
|
|
||||||
notNull: true;
|
|
||||||
hasDefault: true;
|
|
||||||
isPrimaryKey: false;
|
|
||||||
isAutoincrement: false;
|
|
||||||
hasRuntimeDefault: false;
|
|
||||||
enumValues: undefined;
|
|
||||||
baseColumn: never;
|
|
||||||
generated: undefined;
|
|
||||||
}, {}, {}>;
|
|
||||||
createdAt: import("drizzle-orm/pg-core").PgColumn<{
|
|
||||||
name: "created_at";
|
|
||||||
tableName: "subtasks";
|
|
||||||
dataType: "number";
|
|
||||||
columnType: "PgBigInt53";
|
|
||||||
data: number;
|
|
||||||
driverParam: string | number;
|
|
||||||
notNull: true;
|
|
||||||
hasDefault: true;
|
|
||||||
isPrimaryKey: false;
|
|
||||||
isAutoincrement: false;
|
|
||||||
hasRuntimeDefault: false;
|
|
||||||
enumValues: undefined;
|
|
||||||
baseColumn: never;
|
|
||||||
generated: undefined;
|
|
||||||
}, {}, {}>;
|
|
||||||
updatedAt: import("drizzle-orm/pg-core").PgColumn<{
|
|
||||||
name: "updated_at";
|
|
||||||
tableName: "subtasks";
|
|
||||||
dataType: "number";
|
|
||||||
columnType: "PgBigInt53";
|
|
||||||
data: number;
|
|
||||||
driverParam: string | number;
|
|
||||||
notNull: true;
|
|
||||||
hasDefault: true;
|
|
||||||
isPrimaryKey: false;
|
|
||||||
isAutoincrement: false;
|
|
||||||
hasRuntimeDefault: false;
|
|
||||||
enumValues: undefined;
|
|
||||||
baseColumn: never;
|
|
||||||
generated: undefined;
|
|
||||||
}, {}, {}>;
|
|
||||||
};
|
|
||||||
dialect: "pg";
|
|
||||||
}>;
|
|
||||||
export declare const userSettings: import("drizzle-orm/pg-core").PgTableWithColumns<{
|
export declare const userSettings: import("drizzle-orm/pg-core").PgTableWithColumns<{
|
||||||
name: "user_settings";
|
name: "user_settings";
|
||||||
schema: undefined;
|
schema: undefined;
|
||||||
|
|||||||
Vendored
+1
-1
@@ -1 +1 @@
|
|||||||
{"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../../src/db/schema.ts"],"names":[],"mappings":"AAGA,eAAO,MAAM,KAAK;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAKhB,CAAC;AAEH,eAAO,MAAM,WAAW;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAevB,CAAC;AAEF,eAAO,MAAM,UAAU;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAQrB,CAAC;AAEH,eAAO,MAAM,KAAK;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAmBhB,CAAC;AAEH,eAAO,MAAM,cAAc;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EASzB,CAAC;AAEH,eAAO,MAAM,QAAQ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EASnB,CAAC;AAEH,eAAO,MAAM,YAAY;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EASvB,CAAC"}
|
{"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../../src/db/schema.ts"],"names":[],"mappings":"AAGA,eAAO,MAAM,QAAQ,+EAAgF,CAAC;AACtG,MAAM,MAAM,UAAU,GAAG,CAAC,OAAO,QAAQ,CAAC,CAAC,MAAM,CAAC,CAAC;AAEnD,eAAO,MAAM,KAAK;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAOhB,CAAC;AAEH,eAAO,MAAM,WAAW;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAevB,CAAC;AAEF,eAAO,MAAM,UAAU;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAQrB,CAAC;AAEH,eAAO,MAAM,KAAK;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAsBhB,CAAC;AAEH,eAAO,MAAM,UAAU;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAYtB,CAAC;AAGF,eAAO,MAAM,QAAQ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EA2BlB,CAAC;AAEJ,eAAO,MAAM,cAAc;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EASzB,CAAC;AAEH,eAAO,MAAM,YAAY;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EASvB,CAAC"}
|
||||||
Vendored
+46
-12
@@ -1,12 +1,15 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
exports.userSettings = exports.subtasks = exports.repeatProfiles = exports.tasks = exports.categories = exports.friendships = exports.users = void 0;
|
exports.userSettings = exports.repeatProfiles = exports.subtasks = exports.tombstones = exports.tasks = exports.categories = exports.friendships = exports.users = exports.ENTITIES = void 0;
|
||||||
const pg_core_1 = require("drizzle-orm/pg-core");
|
const pg_core_1 = require("drizzle-orm/pg-core");
|
||||||
const drizzle_orm_1 = require("drizzle-orm");
|
const drizzle_orm_1 = require("drizzle-orm");
|
||||||
|
exports.ENTITIES = ['categories', 'tasks', 'subtasks', 'repeatProfiles', 'friendships'];
|
||||||
exports.users = (0, pg_core_1.pgTable)('users', {
|
exports.users = (0, pg_core_1.pgTable)('users', {
|
||||||
id: (0, pg_core_1.text)('id').primaryKey(),
|
id: (0, pg_core_1.text)('id').primaryKey(),
|
||||||
username: (0, pg_core_1.text)('username').notNull().unique(),
|
username: (0, pg_core_1.text)('username').notNull().unique(),
|
||||||
passwordHash: (0, pg_core_1.text)('password_hash').notNull(),
|
passwordHash: (0, pg_core_1.text)('password_hash').notNull(),
|
||||||
|
resetToken: (0, pg_core_1.text)('reset_token'),
|
||||||
|
resetTokenExpiry: (0, pg_core_1.bigint)('reset_token_expiry', { mode: 'number' }),
|
||||||
createdAt: (0, pg_core_1.bigint)('created_at', { mode: 'number' }).notNull().default((0, drizzle_orm_1.sql) `EXTRACT(EPOCH FROM NOW()) * 1000`),
|
createdAt: (0, pg_core_1.bigint)('created_at', { mode: 'number' }).notNull().default((0, drizzle_orm_1.sql) `EXTRACT(EPOCH FROM NOW()) * 1000`),
|
||||||
});
|
});
|
||||||
exports.friendships = (0, pg_core_1.pgTable)('friendships', {
|
exports.friendships = (0, pg_core_1.pgTable)('friendships', {
|
||||||
@@ -33,7 +36,7 @@ exports.categories = (0, pg_core_1.pgTable)('categories', {
|
|||||||
exports.tasks = (0, pg_core_1.pgTable)('tasks', {
|
exports.tasks = (0, pg_core_1.pgTable)('tasks', {
|
||||||
id: (0, pg_core_1.text)('id').primaryKey(),
|
id: (0, pg_core_1.text)('id').primaryKey(),
|
||||||
userId: (0, pg_core_1.text)('user_id').notNull().references(() => exports.users.id, { onDelete: 'cascade' }),
|
userId: (0, pg_core_1.text)('user_id').notNull().references(() => exports.users.id, { onDelete: 'cascade' }),
|
||||||
categoryId: (0, pg_core_1.text)('category_id').notNull().references(() => exports.categories.id, { onDelete: 'cascade' }),
|
categoryId: (0, pg_core_1.text)('category_id').references(() => exports.categories.id, { onDelete: 'cascade' }),
|
||||||
title: (0, pg_core_1.text)('title').notNull(),
|
title: (0, pg_core_1.text)('title').notNull(),
|
||||||
description: (0, pg_core_1.text)('description').notNull().default(''),
|
description: (0, pg_core_1.text)('description').notNull().default(''),
|
||||||
priority: (0, pg_core_1.text)('priority', { enum: ['none', 'low', 'medium', 'high', 'critical'] }).notNull().default('none'),
|
priority: (0, pg_core_1.text)('priority', { enum: ['none', 'low', 'medium', 'high', 'critical'] }).notNull().default('none'),
|
||||||
@@ -41,15 +44,56 @@ exports.tasks = (0, pg_core_1.pgTable)('tasks', {
|
|||||||
dueDate: (0, pg_core_1.bigint)('due_date', { mode: 'number' }).notNull().default(0),
|
dueDate: (0, pg_core_1.bigint)('due_date', { mode: 'number' }).notNull().default(0),
|
||||||
dueTime: (0, pg_core_1.text)('due_time').notNull().default(''),
|
dueTime: (0, pg_core_1.text)('due_time').notNull().default(''),
|
||||||
endTime: (0, pg_core_1.text)('end_time').notNull().default(''),
|
endTime: (0, pg_core_1.text)('end_time').notNull().default(''),
|
||||||
|
allDay: (0, pg_core_1.boolean)('all_day').notNull().default(false),
|
||||||
repeat: (0, pg_core_1.text)('repeat', { enum: ['none', 'daily', 'weekly', 'monthly', 'custom'] }).notNull().default('none'),
|
repeat: (0, pg_core_1.text)('repeat', { enum: ['none', 'daily', 'weekly', 'monthly', 'custom'] }).notNull().default('none'),
|
||||||
repeatInterval: (0, pg_core_1.integer)('repeat_interval').notNull().default(1),
|
repeatInterval: (0, pg_core_1.integer)('repeat_interval').notNull().default(1),
|
||||||
repeatDays: (0, pg_core_1.text)('repeat_days').notNull().default(''),
|
repeatDays: (0, pg_core_1.text)('repeat_days').notNull().default(''),
|
||||||
seriesId: (0, pg_core_1.text)('series_id').notNull().default(''),
|
seriesId: (0, pg_core_1.text)('series_id').notNull().default(''),
|
||||||
assigneeId: (0, pg_core_1.text)('assignee_id').references(() => exports.users.id, { onDelete: 'set null' }),
|
assigneeId: (0, pg_core_1.text)('assignee_id').references(() => exports.users.id, { onDelete: 'set null' }),
|
||||||
reminder: (0, pg_core_1.text)('reminder', { enum: ['none', 'at_time', '15', '30', '60', '120', '1440'] }).notNull().default('none'),
|
reminder: (0, pg_core_1.text)('reminder', { enum: ['none', 'at_time', '15', '30', '60', '120', '1440'] }).notNull().default('none'),
|
||||||
|
reminders: (0, pg_core_1.text)('reminders').notNull().default(''),
|
||||||
|
completedAt: (0, pg_core_1.bigint)('completed_at', { mode: 'number' }),
|
||||||
createdAt: (0, pg_core_1.bigint)('created_at', { mode: 'number' }).notNull().default((0, drizzle_orm_1.sql) `EXTRACT(EPOCH FROM NOW()) * 1000`),
|
createdAt: (0, pg_core_1.bigint)('created_at', { mode: 'number' }).notNull().default((0, drizzle_orm_1.sql) `EXTRACT(EPOCH FROM NOW()) * 1000`),
|
||||||
updatedAt: (0, pg_core_1.bigint)('updated_at', { mode: 'number' }).notNull().default((0, drizzle_orm_1.sql) `EXTRACT(EPOCH FROM NOW()) * 1000`),
|
updatedAt: (0, pg_core_1.bigint)('updated_at', { mode: 'number' }).notNull().default((0, drizzle_orm_1.sql) `EXTRACT(EPOCH FROM NOW()) * 1000`),
|
||||||
});
|
});
|
||||||
|
exports.tombstones = (0, pg_core_1.pgTable)('tombstones', {
|
||||||
|
entity: (0, pg_core_1.text)('entity', { enum: exports.ENTITIES }).notNull(),
|
||||||
|
entityId: (0, pg_core_1.text)('entity_id').notNull(),
|
||||||
|
userId: (0, pg_core_1.text)('user_id').notNull().references(() => exports.users.id, { onDelete: 'cascade' }),
|
||||||
|
updatedAt: (0, pg_core_1.bigint)('updated_at', { mode: 'number' }).notNull(),
|
||||||
|
}, (t) => ({
|
||||||
|
pk: (0, pg_core_1.primaryKey)({ columns: [t.entity, t.entityId] }),
|
||||||
|
userIdx: (0, pg_core_1.index)('tombstones_user_updated_idx').on(t.userId, t.updatedAt),
|
||||||
|
}));
|
||||||
|
// Define subtasks with explicit type to avoid circular reference
|
||||||
|
exports.subtasks = (0, pg_core_1.pgTable)('subtasks', {
|
||||||
|
id: (0, pg_core_1.text)('id').primaryKey(),
|
||||||
|
userId: (0, pg_core_1.text)('user_id').notNull().references(() => exports.users.id, { onDelete: 'cascade' }),
|
||||||
|
taskId: (0, pg_core_1.text)('task_id').notNull().references(() => exports.tasks.id, { onDelete: 'cascade' }),
|
||||||
|
parentSubtaskId: (0, pg_core_1.text)('parent_subtask_id').references(() => exports.subtasks.id, { onDelete: 'cascade' }),
|
||||||
|
title: (0, pg_core_1.text)('title').notNull(),
|
||||||
|
description: (0, pg_core_1.text)('description').notNull().default(''),
|
||||||
|
priority: (0, pg_core_1.text)('priority', { enum: ['none', 'low', 'medium', 'high', 'critical'] }).notNull().default('none'),
|
||||||
|
completed: (0, pg_core_1.boolean)('completed').notNull().default(false),
|
||||||
|
dueDate: (0, pg_core_1.bigint)('due_date', { mode: 'number' }).notNull().default(0),
|
||||||
|
dueTime: (0, pg_core_1.text)('due_time').notNull().default(''),
|
||||||
|
endTime: (0, pg_core_1.text)('end_time').notNull().default(''),
|
||||||
|
allDay: (0, pg_core_1.boolean)('all_day').notNull().default(false),
|
||||||
|
repeat: (0, pg_core_1.text)('repeat', { enum: ['none', 'daily', 'weekly', 'monthly', 'custom'] }).notNull().default('none'),
|
||||||
|
repeatInterval: (0, pg_core_1.integer)('repeat_interval').notNull().default(1),
|
||||||
|
repeatDays: (0, pg_core_1.text)('repeat_days').notNull().default(''),
|
||||||
|
seriesId: (0, pg_core_1.text)('series_id').notNull().default(''),
|
||||||
|
reminder: (0, pg_core_1.text)('reminder', { enum: ['none', 'at_time', '15', '30', '60', '120', '1440'] }).notNull().default('none'),
|
||||||
|
reminders: (0, pg_core_1.text)('reminders').notNull().default(''),
|
||||||
|
assigneeId: (0, pg_core_1.text)('assignee_id').references(() => exports.users.id, { onDelete: 'set null' }),
|
||||||
|
order: (0, pg_core_1.integer)('order').notNull().default(0),
|
||||||
|
createdAt: (0, pg_core_1.bigint)('created_at', { mode: 'number' }).notNull().default((0, drizzle_orm_1.sql) `EXTRACT(EPOCH FROM NOW()) * 1000`),
|
||||||
|
updatedAt: (0, pg_core_1.bigint)('updated_at', { mode: 'number' }).notNull().default((0, drizzle_orm_1.sql) `EXTRACT(EPOCH FROM NOW()) * 1000`),
|
||||||
|
}, (t) => ({
|
||||||
|
parentIdx: (0, pg_core_1.index)('subtasks_parent_idx').on(t.parentSubtaskId),
|
||||||
|
taskIdx: (0, pg_core_1.index)('subtasks_task_idx').on(t.taskId),
|
||||||
|
userIdx: (0, pg_core_1.index)('subtasks_user_idx').on(t.userId),
|
||||||
|
}));
|
||||||
exports.repeatProfiles = (0, pg_core_1.pgTable)('repeat_profiles', {
|
exports.repeatProfiles = (0, pg_core_1.pgTable)('repeat_profiles', {
|
||||||
id: (0, pg_core_1.text)('id').primaryKey(),
|
id: (0, pg_core_1.text)('id').primaryKey(),
|
||||||
userId: (0, pg_core_1.text)('user_id').notNull().references(() => exports.users.id, { onDelete: 'cascade' }),
|
userId: (0, pg_core_1.text)('user_id').notNull().references(() => exports.users.id, { onDelete: 'cascade' }),
|
||||||
@@ -60,16 +104,6 @@ exports.repeatProfiles = (0, pg_core_1.pgTable)('repeat_profiles', {
|
|||||||
createdAt: (0, pg_core_1.bigint)('created_at', { mode: 'number' }).notNull().default((0, drizzle_orm_1.sql) `EXTRACT(EPOCH FROM NOW()) * 1000`),
|
createdAt: (0, pg_core_1.bigint)('created_at', { mode: 'number' }).notNull().default((0, drizzle_orm_1.sql) `EXTRACT(EPOCH FROM NOW()) * 1000`),
|
||||||
updatedAt: (0, pg_core_1.bigint)('updated_at', { mode: 'number' }).notNull().default((0, drizzle_orm_1.sql) `EXTRACT(EPOCH FROM NOW()) * 1000`),
|
updatedAt: (0, pg_core_1.bigint)('updated_at', { mode: 'number' }).notNull().default((0, drizzle_orm_1.sql) `EXTRACT(EPOCH FROM NOW()) * 1000`),
|
||||||
});
|
});
|
||||||
exports.subtasks = (0, pg_core_1.pgTable)('subtasks', {
|
|
||||||
id: (0, pg_core_1.text)('id').primaryKey(),
|
|
||||||
userId: (0, pg_core_1.text)('user_id').notNull().references(() => exports.users.id, { onDelete: 'cascade' }),
|
|
||||||
taskId: (0, pg_core_1.text)('task_id').notNull().references(() => exports.tasks.id, { onDelete: 'cascade' }),
|
|
||||||
title: (0, pg_core_1.text)('title').notNull(),
|
|
||||||
completed: (0, pg_core_1.boolean)('completed').notNull().default(false),
|
|
||||||
order: (0, pg_core_1.integer)('order').notNull().default(0),
|
|
||||||
createdAt: (0, pg_core_1.bigint)('created_at', { mode: 'number' }).notNull().default((0, drizzle_orm_1.sql) `EXTRACT(EPOCH FROM NOW()) * 1000`),
|
|
||||||
updatedAt: (0, pg_core_1.bigint)('updated_at', { mode: 'number' }).notNull().default((0, drizzle_orm_1.sql) `EXTRACT(EPOCH FROM NOW()) * 1000`),
|
|
||||||
});
|
|
||||||
exports.userSettings = (0, pg_core_1.pgTable)('user_settings', {
|
exports.userSettings = (0, pg_core_1.pgTable)('user_settings', {
|
||||||
userId: (0, pg_core_1.text)('user_id').primaryKey().references(() => exports.users.id, { onDelete: 'cascade' }),
|
userId: (0, pg_core_1.text)('user_id').primaryKey().references(() => exports.users.id, { onDelete: 'cascade' }),
|
||||||
darkMode: (0, pg_core_1.boolean)('dark_mode').notNull().default(false),
|
darkMode: (0, pg_core_1.boolean)('dark_mode').notNull().default(false),
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -1 +1 @@
|
|||||||
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,eAAe,CAAC;AAYvB,QAAA,MAAM,GAAG,6CAAY,CAAC;AAkCtB,eAAe,GAAG,CAAC"}
|
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,eAAe,CAAC;AAavB,QAAA,MAAM,GAAG,6CAAY,CAAC;AAmCtB,eAAe,GAAG,CAAC"}
|
||||||
Vendored
+2
@@ -12,6 +12,7 @@ const tasks_1 = __importDefault(require("./routes/tasks"));
|
|||||||
const subtasks_1 = __importDefault(require("./routes/subtasks"));
|
const subtasks_1 = __importDefault(require("./routes/subtasks"));
|
||||||
const users_1 = __importDefault(require("./routes/users"));
|
const users_1 = __importDefault(require("./routes/users"));
|
||||||
const friends_1 = __importDefault(require("./routes/friends"));
|
const friends_1 = __importDefault(require("./routes/friends"));
|
||||||
|
const repeatProfiles_1 = __importDefault(require("./routes/repeatProfiles"));
|
||||||
const sync_1 = __importDefault(require("./routes/sync"));
|
const sync_1 = __importDefault(require("./routes/sync"));
|
||||||
const errorHandler_1 = require("./middleware/errorHandler");
|
const errorHandler_1 = require("./middleware/errorHandler");
|
||||||
const app = (0, express_1.default)();
|
const app = (0, express_1.default)();
|
||||||
@@ -31,6 +32,7 @@ app.use('/api/categories', categories_1.default);
|
|||||||
app.use('/api/tasks', tasks_1.default);
|
app.use('/api/tasks', tasks_1.default);
|
||||||
app.use('/api/subtasks', subtasks_1.default);
|
app.use('/api/subtasks', subtasks_1.default);
|
||||||
app.use('/api/users', users_1.default);
|
app.use('/api/users', users_1.default);
|
||||||
|
app.use('/api/repeat-profiles', repeatProfiles_1.default);
|
||||||
app.use('/api/sync', sync_1.default);
|
app.use('/api/sync', sync_1.default);
|
||||||
app.use('/api/friends', friends_1.default);
|
app.use('/api/friends', friends_1.default);
|
||||||
// 404 handler
|
// 404 handler
|
||||||
|
|||||||
Vendored
+1
-1
@@ -1 +1 @@
|
|||||||
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;AAAA,yBAAuB;AACvB,sDAA8B;AAC9B,gDAAwB;AACxB,yDAAuC;AACvC,qEAAiD;AACjD,2DAAwC;AACxC,iEAA8C;AAC9C,2DAAwC;AACxC,+DAA4C;AAC5C,yDAAuC;AACvC,4DAA0E;AAE1E,MAAM,GAAG,GAAG,IAAA,iBAAO,GAAE,CAAC;AACtB,MAAM,IAAI,GAAG,OAAO,CAAC,GAAG,CAAC,IAAI,IAAI,IAAI,CAAC;AAEtC,GAAG,CAAC,GAAG,CAAC,IAAA,cAAI,EAAC;IACX,MAAM,EAAE,IAAI;IACZ,WAAW,EAAE,IAAI;CAClB,CAAC,CAAC,CAAC;AACJ,GAAG,CAAC,GAAG,CAAC,iBAAO,CAAC,IAAI,EAAE,CAAC,CAAC;AAExB,eAAe;AACf,GAAG,CAAC,GAAG,CAAC,SAAS,EAAE,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE;IAC9B,GAAG,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC;AACpD,CAAC,CAAC,CAAC;AAEH,aAAa;AACb,GAAG,CAAC,GAAG,CAAC,WAAW,EAAE,cAAU,CAAC,CAAC;AACjC,GAAG,CAAC,GAAG,CAAC,iBAAiB,EAAE,oBAAc,CAAC,CAAC;AAC3C,GAAG,CAAC,GAAG,CAAC,YAAY,EAAE,eAAU,CAAC,CAAC;AAClC,GAAG,CAAC,GAAG,CAAC,eAAe,EAAE,kBAAa,CAAC,CAAC;AACxC,GAAG,CAAC,GAAG,CAAC,YAAY,EAAE,eAAU,CAAC,CAAC;AAClC,GAAG,CAAC,GAAG,CAAC,WAAW,EAAE,cAAU,CAAC,CAAC;AACjC,GAAG,CAAC,GAAG,CAAC,cAAc,EAAE,iBAAY,CAAC,CAAC;AAEtC,cAAc;AACd,GAAG,CAAC,GAAG,CAAC,8BAAe,CAAC,CAAC;AAEzB,gBAAgB;AAChB,GAAG,CAAC,GAAG,CAAC,2BAAY,CAAC,CAAC;AAEtB,GAAG,CAAC,MAAM,CAAC,IAAI,EAAE,GAAG,EAAE;IACpB,OAAO,CAAC,GAAG,CAAC,yCAAyC,IAAI,EAAE,CAAC,CAAC;IAC7D,OAAO,CAAC,GAAG,CAAC,wCAAwC,IAAI,MAAM,CAAC,CAAC;AAClE,CAAC,CAAC,CAAC;AAEH,kBAAe,GAAG,CAAC"}
|
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;AAAA,yBAAuB;AACvB,sDAA8B;AAC9B,gDAAwB;AACxB,yDAAuC;AACvC,qEAAiD;AACjD,2DAAwC;AACxC,iEAA8C;AAC9C,2DAAwC;AACxC,+DAA4C;AAC5C,6EAA0D;AAC1D,yDAAuC;AACvC,4DAA0E;AAE1E,MAAM,GAAG,GAAG,IAAA,iBAAO,GAAE,CAAC;AACtB,MAAM,IAAI,GAAG,OAAO,CAAC,GAAG,CAAC,IAAI,IAAI,IAAI,CAAC;AAEtC,GAAG,CAAC,GAAG,CAAC,IAAA,cAAI,EAAC;IACX,MAAM,EAAE,IAAI;IACZ,WAAW,EAAE,IAAI;CAClB,CAAC,CAAC,CAAC;AACJ,GAAG,CAAC,GAAG,CAAC,iBAAO,CAAC,IAAI,EAAE,CAAC,CAAC;AAExB,eAAe;AACf,GAAG,CAAC,GAAG,CAAC,SAAS,EAAE,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE;IAC9B,GAAG,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC;AACpD,CAAC,CAAC,CAAC;AAEH,aAAa;AACb,GAAG,CAAC,GAAG,CAAC,WAAW,EAAE,cAAU,CAAC,CAAC;AACjC,GAAG,CAAC,GAAG,CAAC,iBAAiB,EAAE,oBAAc,CAAC,CAAC;AAC3C,GAAG,CAAC,GAAG,CAAC,YAAY,EAAE,eAAU,CAAC,CAAC;AAClC,GAAG,CAAC,GAAG,CAAC,eAAe,EAAE,kBAAa,CAAC,CAAC;AACxC,GAAG,CAAC,GAAG,CAAC,YAAY,EAAE,eAAU,CAAC,CAAC;AAClC,GAAG,CAAC,GAAG,CAAC,sBAAsB,EAAE,wBAAmB,CAAC,CAAC;AACrD,GAAG,CAAC,GAAG,CAAC,WAAW,EAAE,cAAU,CAAC,CAAC;AACjC,GAAG,CAAC,GAAG,CAAC,cAAc,EAAE,iBAAY,CAAC,CAAC;AAEtC,cAAc;AACd,GAAG,CAAC,GAAG,CAAC,8BAAe,CAAC,CAAC;AAEzB,gBAAgB;AAChB,GAAG,CAAC,GAAG,CAAC,2BAAY,CAAC,CAAC;AAEtB,GAAG,CAAC,MAAM,CAAC,IAAI,EAAE,GAAG,EAAE;IACpB,OAAO,CAAC,GAAG,CAAC,yCAAyC,IAAI,EAAE,CAAC,CAAC;IAC7D,OAAO,CAAC,GAAG,CAAC,wCAAwC,IAAI,MAAM,CAAC,CAAC;AAClE,CAAC,CAAC,CAAC;AAEH,kBAAe,GAAG,CAAC"}
|
||||||
Vendored
+1
-1
@@ -1 +1 @@
|
|||||||
{"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../src/routes/auth.ts"],"names":[],"mappings":"AASA,QAAA,MAAM,MAAM,4CAAW,CAAC;AAmFxB,eAAe,MAAM,CAAC"}
|
{"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../src/routes/auth.ts"],"names":[],"mappings":"AAYA,QAAA,MAAM,MAAM,4CAAW,CAAC;AA0LxB,eAAe,MAAM,CAAC"}
|
||||||
Vendored
+101
-9
@@ -1,4 +1,7 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||||
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||||
|
};
|
||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
const express_1 = require("express");
|
const express_1 = require("express");
|
||||||
const asyncHandler_1 = require("../utils/asyncHandler");
|
const asyncHandler_1 = require("../utils/asyncHandler");
|
||||||
@@ -8,7 +11,47 @@ const drizzle_orm_1 = require("drizzle-orm");
|
|||||||
const auth_1 = require("../utils/auth");
|
const auth_1 = require("../utils/auth");
|
||||||
const errorHandler_1 = require("../middleware/errorHandler");
|
const errorHandler_1 = require("../middleware/errorHandler");
|
||||||
const zod_1 = require("zod");
|
const zod_1 = require("zod");
|
||||||
|
const bcryptjs_1 = __importDefault(require("bcryptjs"));
|
||||||
|
const express_rate_limit_1 = __importDefault(require("express-rate-limit"));
|
||||||
|
const crypto_1 = __importDefault(require("crypto"));
|
||||||
const router = (0, express_1.Router)();
|
const router = (0, express_1.Router)();
|
||||||
|
// Rate limiting for auth endpoints
|
||||||
|
const authLimiter = (0, express_rate_limit_1.default)({
|
||||||
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||||
|
max: 10, // limit each IP to 10 requests per windowMs
|
||||||
|
message: {
|
||||||
|
error: {
|
||||||
|
code: 'RATE_LIMITED',
|
||||||
|
message: 'Too many attempts. Please try again later.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
});
|
||||||
|
const loginLimiter = (0, express_rate_limit_1.default)({
|
||||||
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||||
|
max: 5, // stricter limit for login
|
||||||
|
message: {
|
||||||
|
error: {
|
||||||
|
code: 'RATE_LIMITED',
|
||||||
|
message: 'Too many login attempts. Please try again later.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
});
|
||||||
|
const passwordResetLimiter = (0, express_rate_limit_1.default)({
|
||||||
|
windowMs: 60 * 60 * 1000, // 1 hour
|
||||||
|
max: 3, // very strict for password reset
|
||||||
|
message: {
|
||||||
|
error: {
|
||||||
|
code: 'RATE_LIMITED',
|
||||||
|
message: 'Too many password reset requests. Please try again later.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
});
|
||||||
const usernameSchema = zod_1.z
|
const usernameSchema = zod_1.z
|
||||||
.string()
|
.string()
|
||||||
.min(3)
|
.min(3)
|
||||||
@@ -22,15 +65,24 @@ const loginSchema = zod_1.z.object({
|
|||||||
username: zod_1.z.string(),
|
username: zod_1.z.string(),
|
||||||
password: zod_1.z.string(),
|
password: zod_1.z.string(),
|
||||||
});
|
});
|
||||||
// In production, use bcrypt or argon2 for password hashing
|
const forgotPasswordSchema = zod_1.z.object({
|
||||||
function hashPassword(password) {
|
username: zod_1.z.string().min(1),
|
||||||
// Simple hash for demo - replace with bcrypt in production
|
});
|
||||||
return Buffer.from(password).toString('base64');
|
const resetPasswordSchema = zod_1.z.object({
|
||||||
|
token: zod_1.z.string().min(1),
|
||||||
|
password: zod_1.z.string().min(8),
|
||||||
|
});
|
||||||
|
const BCRYPT_ROUNDS = 12;
|
||||||
|
async function hashPassword(password) {
|
||||||
|
return bcryptjs_1.default.hash(password, BCRYPT_ROUNDS);
|
||||||
}
|
}
|
||||||
function verifyPassword(password, hash) {
|
async function verifyPassword(password, hash) {
|
||||||
return hashPassword(password) === hash;
|
return bcryptjs_1.default.compare(password, hash);
|
||||||
}
|
}
|
||||||
router.post('/register', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
|
function generateResetToken() {
|
||||||
|
return crypto_1.default.randomBytes(32).toString('hex');
|
||||||
|
}
|
||||||
|
router.post('/register', authLimiter, (0, asyncHandler_1.asyncHandler)(async (req, res) => {
|
||||||
const data = registerSchema.parse(req.body);
|
const data = registerSchema.parse(req.body);
|
||||||
const existing = await db_1.db.select().from(schema_1.users).where((0, drizzle_orm_1.eq)(schema_1.users.username, data.username)).limit(1);
|
const existing = await db_1.db.select().from(schema_1.users).where((0, drizzle_orm_1.eq)(schema_1.users.username, data.username)).limit(1);
|
||||||
if (existing.length > 0) {
|
if (existing.length > 0) {
|
||||||
@@ -41,7 +93,7 @@ router.post('/register', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
|
|||||||
await db_1.db.insert(schema_1.users).values({
|
await db_1.db.insert(schema_1.users).values({
|
||||||
id: userId,
|
id: userId,
|
||||||
username: data.username,
|
username: data.username,
|
||||||
passwordHash: hashPassword(data.password),
|
passwordHash: await hashPassword(data.password),
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
});
|
});
|
||||||
const token = (0, auth_1.generateToken)({ userId, username: data.username });
|
const token = (0, auth_1.generateToken)({ userId, username: data.username });
|
||||||
@@ -50,7 +102,7 @@ router.post('/register', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
|
|||||||
token,
|
token,
|
||||||
});
|
});
|
||||||
}));
|
}));
|
||||||
router.post('/login', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
|
router.post('/login', loginLimiter, (0, asyncHandler_1.asyncHandler)(async (req, res) => {
|
||||||
const data = loginSchema.parse(req.body);
|
const data = loginSchema.parse(req.body);
|
||||||
const user = await db_1.db.select().from(schema_1.users).where((0, drizzle_orm_1.eq)(schema_1.users.username, data.username)).limit(1);
|
const user = await db_1.db.select().from(schema_1.users).where((0, drizzle_orm_1.eq)(schema_1.users.username, data.username)).limit(1);
|
||||||
if (user.length === 0 || !verifyPassword(data.password, user[0].passwordHash)) {
|
if (user.length === 0 || !verifyPassword(data.password, user[0].passwordHash)) {
|
||||||
@@ -72,5 +124,45 @@ router.get('/me', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
|
|||||||
}
|
}
|
||||||
res.json({ id: user[0].id, username: user[0].username });
|
res.json({ id: user[0].id, username: user[0].username });
|
||||||
}));
|
}));
|
||||||
|
router.post('/forgot-password', passwordResetLimiter, (0, asyncHandler_1.asyncHandler)(async (req, res) => {
|
||||||
|
const data = forgotPasswordSchema.parse(req.body);
|
||||||
|
const user = await db_1.db.select().from(schema_1.users).where((0, drizzle_orm_1.eq)(schema_1.users.username, data.username)).limit(1);
|
||||||
|
// Always return success to prevent username enumeration
|
||||||
|
if (user.length === 0) {
|
||||||
|
res.json({ success: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const resetToken = generateResetToken();
|
||||||
|
const resetTokenExpiry = Date.now() + 3600000; // 1 hour
|
||||||
|
await db_1.db
|
||||||
|
.update(schema_1.users)
|
||||||
|
.set({ resetToken, resetTokenExpiry })
|
||||||
|
.where((0, drizzle_orm_1.eq)(schema_1.users.id, user[0].id));
|
||||||
|
// In production, send email with reset link
|
||||||
|
// For now, return token in response (dev only)
|
||||||
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
|
res.json({ success: true, resetToken });
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
res.json({ success: true });
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
router.post('/reset-password', passwordResetLimiter, (0, asyncHandler_1.asyncHandler)(async (req, res) => {
|
||||||
|
const data = resetPasswordSchema.parse(req.body);
|
||||||
|
const user = await db_1.db
|
||||||
|
.select()
|
||||||
|
.from(schema_1.users)
|
||||||
|
.where((0, drizzle_orm_1.eq)(schema_1.users.resetToken, data.token))
|
||||||
|
.limit(1);
|
||||||
|
if (user.length === 0 || !user[0].resetTokenExpiry || user[0].resetTokenExpiry < Date.now()) {
|
||||||
|
throw new errorHandler_1.AppError('INVALID_TOKEN', 'Invalid or expired reset token', 400);
|
||||||
|
}
|
||||||
|
const newPasswordHash = await hashPassword(data.password);
|
||||||
|
await db_1.db
|
||||||
|
.update(schema_1.users)
|
||||||
|
.set({ passwordHash: newPasswordHash, resetToken: null, resetTokenExpiry: null })
|
||||||
|
.where((0, drizzle_orm_1.eq)(schema_1.users.id, user[0].id));
|
||||||
|
res.json({ success: true });
|
||||||
|
}));
|
||||||
exports.default = router;
|
exports.default = router;
|
||||||
//# sourceMappingURL=auth.js.map
|
//# sourceMappingURL=auth.js.map
|
||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -1 +1 @@
|
|||||||
{"version":3,"file":"friends.d.ts","sourceRoot":"","sources":["../../src/routes/friends.ts"],"names":[],"mappings":"AAUA,QAAA,MAAM,MAAM,4CAAW,CAAC;AAsKxB,eAAe,MAAM,CAAC"}
|
{"version":3,"file":"friends.d.ts","sourceRoot":"","sources":["../../src/routes/friends.ts"],"names":[],"mappings":"AAUA,QAAA,MAAM,MAAM,4CAAW,CAAC;AAuLxB,eAAe,MAAM,CAAC"}
|
||||||
Vendored
+11
@@ -53,6 +53,17 @@ router.get('/', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
|
|||||||
}));
|
}));
|
||||||
res.json({ friends, incoming, outgoing });
|
res.json({ friends, incoming, outgoing });
|
||||||
}));
|
}));
|
||||||
|
router.get('/search', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
|
||||||
|
const { q } = validation_1.searchQuerySchema.parse(req.query);
|
||||||
|
const userId = req.user.userId;
|
||||||
|
const results = await db_1.db
|
||||||
|
.select({ id: schema_1.users.id, username: schema_1.users.username })
|
||||||
|
.from(schema_1.users)
|
||||||
|
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.ne)(schema_1.users.id, userId), (0, drizzle_orm_1.ilike)(schema_1.users.username, `%${q}%`)))
|
||||||
|
.orderBy((0, drizzle_orm_1.asc)(schema_1.users.username))
|
||||||
|
.limit(20);
|
||||||
|
res.json(results);
|
||||||
|
}));
|
||||||
router.post('/requests', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
|
router.post('/requests', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
|
||||||
const { username } = validation_1.friendRequestSchema.parse(req.body);
|
const { username } = validation_1.friendRequestSchema.parse(req.body);
|
||||||
const userId = req.user.userId;
|
const userId = req.user.userId;
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+3
@@ -0,0 +1,3 @@
|
|||||||
|
declare const router: import("express-serve-static-core").Router;
|
||||||
|
export default router;
|
||||||
|
//# sourceMappingURL=repeatProfiles.d.ts.map
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"repeatProfiles.d.ts","sourceRoot":"","sources":["../../src/routes/repeatProfiles.ts"],"names":[],"mappings":"AAUA,QAAA,MAAM,MAAM,4CAAW,CAAC;AA+ExB,eAAe,MAAM,CAAC"}
|
||||||
+74
@@ -0,0 +1,74 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
const express_1 = require("express");
|
||||||
|
const asyncHandler_1 = require("../utils/asyncHandler");
|
||||||
|
const db_1 = require("../db");
|
||||||
|
const schema_1 = require("../db/schema");
|
||||||
|
const drizzle_orm_1 = require("drizzle-orm");
|
||||||
|
const auth_1 = require("../utils/auth");
|
||||||
|
const auth_2 = require("../utils/auth");
|
||||||
|
const errorHandler_1 = require("../middleware/errorHandler");
|
||||||
|
const validation_1 = require("../utils/validation");
|
||||||
|
const router = (0, express_1.Router)();
|
||||||
|
router.use(auth_1.authMiddleware);
|
||||||
|
router.get('/', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
|
||||||
|
const profiles = await db_1.db
|
||||||
|
.select()
|
||||||
|
.from(schema_1.repeatProfiles)
|
||||||
|
.where((0, drizzle_orm_1.eq)(schema_1.repeatProfiles.userId, req.user.userId))
|
||||||
|
.orderBy((0, drizzle_orm_1.asc)(schema_1.repeatProfiles.createdAt));
|
||||||
|
res.json({ profiles });
|
||||||
|
}));
|
||||||
|
router.post('/', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
|
||||||
|
const data = validation_1.repeatProfileSchema.parse(req.body);
|
||||||
|
const userId = req.user.userId;
|
||||||
|
const now = Date.now();
|
||||||
|
const newProfile = {
|
||||||
|
id: (0, auth_2.generateId)('rp'),
|
||||||
|
userId,
|
||||||
|
name: data.name,
|
||||||
|
repeat: data.repeat,
|
||||||
|
repeatInterval: data.repeatInterval,
|
||||||
|
repeatDays: data.repeatDays,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
await db_1.db.insert(schema_1.repeatProfiles).values(newProfile);
|
||||||
|
res.status(201).json(newProfile);
|
||||||
|
}));
|
||||||
|
router.patch('/:id', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
|
||||||
|
const data = validation_1.repeatProfileUpdateSchema.parse(req.body);
|
||||||
|
const userId = req.user.userId;
|
||||||
|
const existing = await db_1.db
|
||||||
|
.select()
|
||||||
|
.from(schema_1.repeatProfiles)
|
||||||
|
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.repeatProfiles.id, req.params.id), (0, drizzle_orm_1.eq)(schema_1.repeatProfiles.userId, userId)))
|
||||||
|
.limit(1);
|
||||||
|
if (existing.length === 0) {
|
||||||
|
throw new errorHandler_1.AppError('NOT_FOUND', 'Repeat profile not found', 404);
|
||||||
|
}
|
||||||
|
const now = Date.now();
|
||||||
|
const updated = await db_1.db
|
||||||
|
.update(schema_1.repeatProfiles)
|
||||||
|
.set({ ...data, updatedAt: now })
|
||||||
|
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.repeatProfiles.id, req.params.id), (0, drizzle_orm_1.eq)(schema_1.repeatProfiles.userId, userId)))
|
||||||
|
.returning();
|
||||||
|
res.json(updated[0]);
|
||||||
|
}));
|
||||||
|
router.delete('/:id', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
|
||||||
|
const userId = req.user.userId;
|
||||||
|
const existing = await db_1.db
|
||||||
|
.select()
|
||||||
|
.from(schema_1.repeatProfiles)
|
||||||
|
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.repeatProfiles.id, req.params.id), (0, drizzle_orm_1.eq)(schema_1.repeatProfiles.userId, userId)))
|
||||||
|
.limit(1);
|
||||||
|
if (existing.length === 0) {
|
||||||
|
throw new errorHandler_1.AppError('NOT_FOUND', 'Repeat profile not found', 404);
|
||||||
|
}
|
||||||
|
await db_1.db
|
||||||
|
.delete(schema_1.repeatProfiles)
|
||||||
|
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.repeatProfiles.id, req.params.id), (0, drizzle_orm_1.eq)(schema_1.repeatProfiles.userId, userId)));
|
||||||
|
res.status(204).send();
|
||||||
|
}));
|
||||||
|
exports.default = router;
|
||||||
|
//# sourceMappingURL=repeatProfiles.js.map
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"repeatProfiles.js","sourceRoot":"","sources":["../../src/routes/repeatProfiles.ts"],"names":[],"mappings":";;AAAA,qCAAoD;AACpD,wDAAqD;AACrD,8BAA2B;AAC3B,yCAA8C;AAC9C,6CAA2C;AAC3C,wCAA+C;AAC/C,wCAA2C;AAC3C,6DAAsD;AACtD,oDAAqF;AAErF,MAAM,MAAM,GAAG,IAAA,gBAAM,GAAE,CAAC;AAExB,MAAM,CAAC,GAAG,CAAC,qBAAc,CAAC,CAAC;AAE3B,MAAM,CAAC,GAAG,CAAC,GAAG,EAAE,IAAA,2BAAY,EAAC,KAAK,EAAE,GAAY,EAAE,GAAa,EAAE,EAAE;IACjE,MAAM,QAAQ,GAAG,MAAM,OAAE;SACtB,MAAM,EAAE;SACR,IAAI,CAAC,uBAAc,CAAC;SACpB,KAAK,CAAC,IAAA,gBAAE,EAAC,uBAAc,CAAC,MAAM,EAAE,GAAG,CAAC,IAAK,CAAC,MAAM,CAAC,CAAC;SAClD,OAAO,CAAC,IAAA,iBAAG,EAAC,uBAAc,CAAC,SAAS,CAAC,CAAC,CAAC;IAE1C,GAAG,CAAC,IAAI,CAAC,EAAE,QAAQ,EAAE,CAAC,CAAC;AACzB,CAAC,CAAC,CAAC,CAAC;AAEJ,MAAM,CAAC,IAAI,CAAC,GAAG,EAAE,IAAA,2BAAY,EAAC,KAAK,EAAE,GAAY,EAAE,GAAa,EAAE,EAAE;IAClE,MAAM,IAAI,GAAG,gCAAmB,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;IACjD,MAAM,MAAM,GAAG,GAAG,CAAC,IAAK,CAAC,MAAM,CAAC;IAChC,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;IAEvB,MAAM,UAAU,GAAG;QACjB,EAAE,EAAE,IAAA,iBAAU,EAAC,IAAI,CAAC;QACpB,MAAM;QACN,IAAI,EAAE,IAAI,CAAC,IAAI;QACf,MAAM,EAAE,IAAI,CAAC,MAAM;QACnB,cAAc,EAAE,IAAI,CAAC,cAAc;QACnC,UAAU,EAAE,IAAI,CAAC,UAAU;QAC3B,SAAS,EAAE,GAAG;QACd,SAAS,EAAE,GAAG;KACf,CAAC;IAEF,MAAM,OAAE,CAAC,MAAM,CAAC,uBAAc,CAAC,CAAC,MAAM,CAAC,UAAU,CAAC,CAAC;IAEnD,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC;AACnC,CAAC,CAAC,CAAC,CAAC;AAEJ,MAAM,CAAC,KAAK,CAAC,MAAM,EAAE,IAAA,2BAAY,EAAC,KAAK,EAAE,GAAY,EAAE,GAAa,EAAE,EAAE;IACtE,MAAM,IAAI,GAAG,sCAAyB,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;IACvD,MAAM,MAAM,GAAG,GAAG,CAAC,IAAK,CAAC,MAAM,CAAC;IAEhC,MAAM,QAAQ,GAAG,MAAM,OAAE;SACtB,MAAM,EAAE;SACR,IAAI,CAAC,uBAAc,CAAC;SACpB,KAAK,CAAC,IAAA,iBAAG,EAAC,IAAA,gBAAE,EAAC,uBAAc,CAAC,EAAE,EAAE,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC,EAAE,IAAA,gBAAE,EAAC,uBAAc,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;SACnF,KAAK,CAAC,CAAC,CAAC,CAAC;IAEZ,IAAI,QAAQ,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAC1B,MAAM,IAAI,uBAAQ,CAAC,WAAW,EAAE,0BAA0B,EAAE,GAAG,CAAC,CAAC;IACnE,CAAC;IAED,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;IACvB,MAAM,OAAO,GAAG,MAAM,OAAE;SACrB,MAAM,CAAC,uBAAc,CAAC;SACtB,GAAG,CAAC,EAAE,GAAG,IAAI,EAAE,SAAS,EAAE,GAAG,EAAE,CAAC;SAChC,KAAK,CAAC,IAAA,iBAAG,EAAC,IAAA,gBAAE,EAAC,uBAAc,CAAC,EAAE,EAAE,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC,EAAE,IAAA,gBAAE,EAAC,uBAAc,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;SACnF,SAAS,EAAE,CAAC;IAEf,GAAG,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC;AACvB,CAAC,CAAC,CAAC,CAAC;AAEJ,MAAM,CAAC,MAAM,CAAC,MAAM,EAAE,IAAA,2BAAY,EAAC,KAAK,EAAE,GAAY,EAAE,GAAa,EAAE,EAAE;IACvE,MAAM,MAAM,GAAG,GAAG,CAAC,IAAK,CAAC,MAAM,CAAC;IAEhC,MAAM,QAAQ,GAAG,MAAM,OAAE;SACtB,MAAM,EAAE;SACR,IAAI,CAAC,uBAAc,CAAC;SACpB,KAAK,CAAC,IAAA,iBAAG,EAAC,IAAA,gBAAE,EAAC,uBAAc,CAAC,EAAE,EAAE,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC,EAAE,IAAA,gBAAE,EAAC,uBAAc,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;SACnF,KAAK,CAAC,CAAC,CAAC,CAAC;IAEZ,IAAI,QAAQ,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAC1B,MAAM,IAAI,uBAAQ,CAAC,WAAW,EAAE,0BAA0B,EAAE,GAAG,CAAC,CAAC;IACnE,CAAC;IAED,MAAM,OAAE;SACL,MAAM,CAAC,uBAAc,CAAC;SACtB,KAAK,CAAC,IAAA,iBAAG,EAAC,IAAA,gBAAE,EAAC,uBAAc,CAAC,EAAE,EAAE,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC,EAAE,IAAA,gBAAE,EAAC,uBAAc,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC,CAAC;IAEvF,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,CAAC;AACzB,CAAC,CAAC,CAAC,CAAC;AAEJ,kBAAe,MAAM,CAAC"}
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
{"version":3,"file":"subtasks.d.ts","sourceRoot":"","sources":["../../src/routes/subtasks.ts"],"names":[],"mappings":"AASA,QAAA,MAAM,MAAM,4CAAW,CAAC;AAmIxB,eAAe,MAAM,CAAC"}
|
{"version":3,"file":"subtasks.d.ts","sourceRoot":"","sources":["../../src/routes/subtasks.ts"],"names":[],"mappings":"AASA,QAAA,MAAM,MAAM,4CAAW,CAAC;AAoKxB,eAAe,MAAM,CAAC"}
|
||||||
Vendored
+28
-2
@@ -10,6 +10,16 @@ const errorHandler_1 = require("../middleware/errorHandler");
|
|||||||
const validation_1 = require("../utils/validation");
|
const validation_1 = require("../utils/validation");
|
||||||
const router = (0, express_1.Router)();
|
const router = (0, express_1.Router)();
|
||||||
router.use(auth_1.authMiddleware);
|
router.use(auth_1.authMiddleware);
|
||||||
|
// Helper to build nested subtask tree
|
||||||
|
const buildSubtaskTree = (allSubtasks, parentId = null) => {
|
||||||
|
return allSubtasks
|
||||||
|
.filter((s) => s.parentSubtaskId === parentId)
|
||||||
|
.sort((a, b) => a.order - b.order)
|
||||||
|
.map((s) => ({
|
||||||
|
...s,
|
||||||
|
subtasks: buildSubtaskTree(allSubtasks, s.id),
|
||||||
|
}));
|
||||||
|
};
|
||||||
router.get('/task/:taskId', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
|
router.get('/task/:taskId', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
|
||||||
const userId = req.user.userId;
|
const userId = req.user.userId;
|
||||||
// Verify task exists and belongs to user
|
// Verify task exists and belongs to user
|
||||||
@@ -26,7 +36,8 @@ router.get('/task/:taskId', (0, asyncHandler_1.asyncHandler)(async (req, res) =>
|
|||||||
.from(schema_1.subtasks)
|
.from(schema_1.subtasks)
|
||||||
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.subtasks.taskId, req.params.taskId), (0, drizzle_orm_1.eq)(schema_1.subtasks.userId, userId)))
|
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.subtasks.taskId, req.params.taskId), (0, drizzle_orm_1.eq)(schema_1.subtasks.userId, userId)))
|
||||||
.orderBy((0, drizzle_orm_1.asc)(schema_1.subtasks.order));
|
.orderBy((0, drizzle_orm_1.asc)(schema_1.subtasks.order));
|
||||||
res.json({ subtasks: taskSubtasks });
|
const nestedSubtasks = buildSubtaskTree(taskSubtasks);
|
||||||
|
res.json({ subtasks: nestedSubtasks });
|
||||||
}));
|
}));
|
||||||
router.post('/task/:taskId', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
|
router.post('/task/:taskId', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
|
||||||
const data = validation_1.subtaskCreateSchema.parse(req.body);
|
const data = validation_1.subtaskCreateSchema.parse(req.body);
|
||||||
@@ -40,10 +51,11 @@ router.post('/task/:taskId', (0, asyncHandler_1.asyncHandler)(async (req, res) =
|
|||||||
throw new errorHandler_1.AppError('NOT_FOUND', 'Task not found', 404);
|
throw new errorHandler_1.AppError('NOT_FOUND', 'Task not found', 404);
|
||||||
}
|
}
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
const parentSubtaskId = data.parentSubtaskId || null;
|
||||||
const maxOrder = await db_1.db
|
const maxOrder = await db_1.db
|
||||||
.select({ order: schema_1.subtasks.order })
|
.select({ order: schema_1.subtasks.order })
|
||||||
.from(schema_1.subtasks)
|
.from(schema_1.subtasks)
|
||||||
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.subtasks.taskId, req.params.taskId), (0, drizzle_orm_1.eq)(schema_1.subtasks.userId, userId)))
|
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.subtasks.taskId, req.params.taskId), (0, drizzle_orm_1.eq)(schema_1.subtasks.userId, userId), parentSubtaskId ? (0, drizzle_orm_1.eq)(schema_1.subtasks.parentSubtaskId, parentSubtaskId) : (0, drizzle_orm_1.isNull)(schema_1.subtasks.parentSubtaskId)))
|
||||||
.orderBy((0, drizzle_orm_1.desc)(schema_1.subtasks.order))
|
.orderBy((0, drizzle_orm_1.desc)(schema_1.subtasks.order))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
const subtaskId = `sub_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
const subtaskId = `sub_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||||
@@ -51,8 +63,22 @@ router.post('/task/:taskId', (0, asyncHandler_1.asyncHandler)(async (req, res) =
|
|||||||
id: subtaskId,
|
id: subtaskId,
|
||||||
userId,
|
userId,
|
||||||
taskId: req.params.taskId,
|
taskId: req.params.taskId,
|
||||||
|
parentSubtaskId,
|
||||||
title: data.title,
|
title: data.title,
|
||||||
|
description: data.description ?? '',
|
||||||
|
priority: data.priority ?? 'none',
|
||||||
completed: false,
|
completed: false,
|
||||||
|
dueDate: data.dueDate ?? 0,
|
||||||
|
dueTime: data.dueTime ?? '',
|
||||||
|
endTime: data.endTime ?? '',
|
||||||
|
allDay: data.allDay ?? false,
|
||||||
|
repeat: data.repeat ?? 'none',
|
||||||
|
repeatInterval: data.repeatInterval ?? 1,
|
||||||
|
repeatDays: data.repeatDays ?? '',
|
||||||
|
seriesId: data.seriesId ?? '',
|
||||||
|
reminder: data.reminder ?? 'none',
|
||||||
|
reminders: data.reminders ?? '',
|
||||||
|
assigneeId: data.assigneeId ?? null,
|
||||||
order: data.order ?? (maxOrder[0]?.order ?? -1) + 1,
|
order: data.order ?? (maxOrder[0]?.order ?? -1) + 1,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -1 +1 @@
|
|||||||
{"version":3,"file":"sync.d.ts","sourceRoot":"","sources":["../../src/routes/sync.ts"],"names":[],"mappings":"AASA,QAAA,MAAM,MAAM,4CAAW,CAAC;AA8SxB,eAAe,MAAM,CAAC"}
|
{"version":3,"file":"sync.d.ts","sourceRoot":"","sources":["../../src/routes/sync.ts"],"names":[],"mappings":"AASA,QAAA,MAAM,MAAM,4CAAW,CAAC;AA2lBxB,eAAe,MAAM,CAAC"}
|
||||||
Vendored
+281
-5
@@ -27,9 +27,8 @@ router.get('/', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
|
|||||||
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.tasks.userId, userId), (0, drizzle_orm_1.gte)(schema_1.tasks.updatedAt, sinceDate)))
|
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.tasks.userId, userId), (0, drizzle_orm_1.gte)(schema_1.tasks.updatedAt, sinceDate)))
|
||||||
.orderBy((0, drizzle_orm_1.asc)(schema_1.tasks.updatedAt));
|
.orderBy((0, drizzle_orm_1.asc)(schema_1.tasks.updatedAt));
|
||||||
// Fetch subtasks changed since timestamp
|
// Fetch subtasks changed since timestamp
|
||||||
const taskIds = changedTasks.map(t => t.id);
|
|
||||||
let changedSubtasks = [];
|
let changedSubtasks = [];
|
||||||
if (taskIds.length > 0) {
|
if (changedTasks.length > 0) {
|
||||||
changedSubtasks = await db_1.db
|
changedSubtasks = await db_1.db
|
||||||
.select()
|
.select()
|
||||||
.from(schema_1.subtasks)
|
.from(schema_1.subtasks)
|
||||||
@@ -37,7 +36,7 @@ router.get('/', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
|
|||||||
.orderBy((0, drizzle_orm_1.asc)(schema_1.subtasks.updatedAt));
|
.orderBy((0, drizzle_orm_1.asc)(schema_1.subtasks.updatedAt));
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
// Also fetch subtasks for tasks that might have been deleted (we track by updatedAt)
|
// Also fetch subtasks for tasks that might have been deleted
|
||||||
changedSubtasks = await db_1.db
|
changedSubtasks = await db_1.db
|
||||||
.select()
|
.select()
|
||||||
.from(schema_1.subtasks)
|
.from(schema_1.subtasks)
|
||||||
@@ -56,6 +55,12 @@ router.get('/', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
|
|||||||
.from(schema_1.friendships)
|
.from(schema_1.friendships)
|
||||||
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.or)((0, drizzle_orm_1.eq)(schema_1.friendships.userId, userId), (0, drizzle_orm_1.eq)(schema_1.friendships.friendId, userId)), (0, drizzle_orm_1.gte)(schema_1.friendships.updatedAt, sinceDate)))
|
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.or)((0, drizzle_orm_1.eq)(schema_1.friendships.userId, userId), (0, drizzle_orm_1.eq)(schema_1.friendships.friendId, userId)), (0, drizzle_orm_1.gte)(schema_1.friendships.updatedAt, sinceDate)))
|
||||||
.orderBy((0, drizzle_orm_1.asc)(schema_1.friendships.updatedAt));
|
.orderBy((0, drizzle_orm_1.asc)(schema_1.friendships.updatedAt));
|
||||||
|
// Fetch tombstones (deletions) changed since timestamp
|
||||||
|
const changedTombstones = await db_1.db
|
||||||
|
.select()
|
||||||
|
.from(schema_1.tombstones)
|
||||||
|
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.tombstones.userId, userId), (0, drizzle_orm_1.gte)(schema_1.tombstones.updatedAt, sinceDate)))
|
||||||
|
.orderBy((0, drizzle_orm_1.asc)(schema_1.tombstones.updatedAt));
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
res.json({
|
res.json({
|
||||||
categories: changedCategories,
|
categories: changedCategories,
|
||||||
@@ -63,6 +68,7 @@ router.get('/', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
|
|||||||
subtasks: changedSubtasks,
|
subtasks: changedSubtasks,
|
||||||
repeatProfiles: changedRepeatProfiles,
|
repeatProfiles: changedRepeatProfiles,
|
||||||
friendships: changedFriendships,
|
friendships: changedFriendships,
|
||||||
|
deleted: changedTombstones.map((t) => ({ entity: t.entity, id: t.entityId, updatedAt: t.updatedAt })),
|
||||||
timestamp,
|
timestamp,
|
||||||
});
|
});
|
||||||
}));
|
}));
|
||||||
@@ -73,6 +79,65 @@ router.post('/push', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
|
|||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
try {
|
try {
|
||||||
await db_1.db.transaction(async (tx) => {
|
await db_1.db.transaction(async (tx) => {
|
||||||
|
// Ensure every referenced category exists (FK integrity) so a stale or
|
||||||
|
// never-synced category reference cannot fail the entire push. Missing
|
||||||
|
// categories are recreated as a fallback and the client heals on pull.
|
||||||
|
const referencedCategoryIds = new Set();
|
||||||
|
for (const task of data.changes.tasks ?? []) {
|
||||||
|
if (task.categoryId)
|
||||||
|
referencedCategoryIds.add(task.categoryId);
|
||||||
|
}
|
||||||
|
for (const sub of data.changes.subtasks ?? []) {
|
||||||
|
const task = (data.changes.tasks ?? []).find((t) => t.id === sub.taskId);
|
||||||
|
if (task?.categoryId)
|
||||||
|
referencedCategoryIds.add(task.categoryId);
|
||||||
|
}
|
||||||
|
if (referencedCategoryIds.size > 0) {
|
||||||
|
const existing = await tx
|
||||||
|
.select({ id: schema_1.categories.id })
|
||||||
|
.from(schema_1.categories)
|
||||||
|
.where((0, drizzle_orm_1.inArray)(schema_1.categories.id, [...referencedCategoryIds]));
|
||||||
|
const existingIds = new Set(existing.map((c) => c.id));
|
||||||
|
const missing = [...referencedCategoryIds].filter((id) => !existingIds.has(id));
|
||||||
|
if (missing.length > 0) {
|
||||||
|
const rows = await tx
|
||||||
|
.select({ max: (0, drizzle_orm_1.sql) `max(${schema_1.categories.order})` })
|
||||||
|
.from(schema_1.categories)
|
||||||
|
.where((0, drizzle_orm_1.eq)(schema_1.categories.userId, userId));
|
||||||
|
const startOrder = (rows[0]?.max ?? -1) + 1;
|
||||||
|
await tx.insert(schema_1.categories).values(missing.map((id, i) => ({
|
||||||
|
id,
|
||||||
|
userId,
|
||||||
|
name: 'Default',
|
||||||
|
color: '#9E9E9E',
|
||||||
|
order: startOrder + i,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Sanitize assignee references: only real user ids may be stored (FK).
|
||||||
|
// Stale/unknown assignee ids are silently dropped to null instead of
|
||||||
|
// failing the whole push transaction.
|
||||||
|
const assigneeIds = new Set();
|
||||||
|
for (const task of data.changes.tasks ?? []) {
|
||||||
|
if (task.assigneeId)
|
||||||
|
assigneeIds.add(task.assigneeId);
|
||||||
|
}
|
||||||
|
for (const sub of data.changes.subtasks ?? []) {
|
||||||
|
if (sub.assigneeId)
|
||||||
|
assigneeIds.add(sub.assigneeId);
|
||||||
|
}
|
||||||
|
const validAssignees = new Set();
|
||||||
|
if (assigneeIds.size > 0) {
|
||||||
|
const rows = await tx
|
||||||
|
.select({ id: schema_1.users.id })
|
||||||
|
.from(schema_1.users)
|
||||||
|
.where((0, drizzle_orm_1.inArray)(schema_1.users.id, [...assigneeIds]));
|
||||||
|
for (const r of rows)
|
||||||
|
validAssignees.add(r.id);
|
||||||
|
}
|
||||||
|
const sanitizeAssignee = (a) => a && validAssignees.has(a) ? a : null;
|
||||||
// Process categories
|
// Process categories
|
||||||
if (data.changes.categories && data.changes.categories.length > 0) {
|
if (data.changes.categories && data.changes.categories.length > 0) {
|
||||||
for (const cat of data.changes.categories) {
|
for (const cat of data.changes.categories) {
|
||||||
@@ -131,6 +196,20 @@ router.post('/push', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
|
|||||||
});
|
});
|
||||||
continue; // Server wins
|
continue; // Server wins
|
||||||
}
|
}
|
||||||
|
// Prevent completing tasks with future due dates
|
||||||
|
if (task.completed === true) {
|
||||||
|
const effectiveDueDate = task.dueDate ?? existing[0].dueDate;
|
||||||
|
if (!(0, validation_1.canCompleteTask)(effectiveDueDate)) {
|
||||||
|
conflicts.push({
|
||||||
|
entity: 'tasks',
|
||||||
|
id: task.id,
|
||||||
|
serverVersion: existing[0],
|
||||||
|
clientVersion: task,
|
||||||
|
resolution: 'server_wins',
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
await tx
|
await tx
|
||||||
.update(schema_1.tasks)
|
.update(schema_1.tasks)
|
||||||
.set({
|
.set({
|
||||||
@@ -140,14 +219,17 @@ router.post('/push', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
|
|||||||
priority: task.priority,
|
priority: task.priority,
|
||||||
completed: task.completed,
|
completed: task.completed,
|
||||||
dueDate: task.dueDate,
|
dueDate: task.dueDate,
|
||||||
dueTime: task.dueTime,
|
dueTime: task.dueTime ?? '',
|
||||||
endTime: task.endTime ?? '',
|
endTime: task.endTime ?? '',
|
||||||
|
allDay: task.allDay ?? false,
|
||||||
repeat: task.repeat ?? 'none',
|
repeat: task.repeat ?? 'none',
|
||||||
repeatInterval: task.repeatInterval ?? 1,
|
repeatInterval: task.repeatInterval ?? 1,
|
||||||
repeatDays: task.repeatDays ?? '',
|
repeatDays: task.repeatDays ?? '',
|
||||||
seriesId: task.seriesId ?? '',
|
seriesId: task.seriesId ?? '',
|
||||||
reminder: task.reminder ?? 'none',
|
reminder: task.reminder ?? 'none',
|
||||||
assigneeId: task.assigneeId ?? null,
|
reminders: task.reminders ?? '',
|
||||||
|
assigneeId: sanitizeAssignee(task.assigneeId),
|
||||||
|
completedAt: task.completedAt ?? null,
|
||||||
updatedAt: task.updatedAt,
|
updatedAt: task.updatedAt,
|
||||||
})
|
})
|
||||||
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.tasks.id, task.id), (0, drizzle_orm_1.eq)(schema_1.tasks.userId, userId)));
|
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.tasks.id, task.id), (0, drizzle_orm_1.eq)(schema_1.tasks.userId, userId)));
|
||||||
@@ -155,6 +237,8 @@ router.post('/push', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
|
|||||||
else {
|
else {
|
||||||
await tx.insert(schema_1.tasks).values({
|
await tx.insert(schema_1.tasks).values({
|
||||||
...task,
|
...task,
|
||||||
|
assigneeId: sanitizeAssignee(task.assigneeId),
|
||||||
|
allDay: task.allDay ?? false,
|
||||||
userId,
|
userId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -184,8 +268,22 @@ router.post('/push', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
|
|||||||
.update(schema_1.subtasks)
|
.update(schema_1.subtasks)
|
||||||
.set({
|
.set({
|
||||||
taskId: sub.taskId,
|
taskId: sub.taskId,
|
||||||
|
parentSubtaskId: sub.parentSubtaskId ?? null,
|
||||||
title: sub.title,
|
title: sub.title,
|
||||||
|
description: sub.description ?? '',
|
||||||
|
priority: sub.priority ?? 'none',
|
||||||
completed: sub.completed,
|
completed: sub.completed,
|
||||||
|
dueDate: sub.dueDate ?? 0,
|
||||||
|
dueTime: sub.dueTime ?? '',
|
||||||
|
endTime: sub.endTime ?? '',
|
||||||
|
allDay: sub.allDay ?? false,
|
||||||
|
repeat: sub.repeat ?? 'none',
|
||||||
|
repeatInterval: sub.repeatInterval ?? 1,
|
||||||
|
repeatDays: sub.repeatDays ?? '',
|
||||||
|
seriesId: sub.seriesId ?? '',
|
||||||
|
reminder: sub.reminder ?? 'none',
|
||||||
|
reminders: sub.reminders ?? '',
|
||||||
|
assigneeId: sanitizeAssignee(sub.assigneeId),
|
||||||
order: sub.order,
|
order: sub.order,
|
||||||
updatedAt: sub.updatedAt,
|
updatedAt: sub.updatedAt,
|
||||||
})
|
})
|
||||||
@@ -194,6 +292,7 @@ router.post('/push', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
|
|||||||
else {
|
else {
|
||||||
await tx.insert(schema_1.subtasks).values({
|
await tx.insert(schema_1.subtasks).values({
|
||||||
...sub,
|
...sub,
|
||||||
|
assigneeId: sanitizeAssignee(sub.assigneeId),
|
||||||
userId,
|
userId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -273,6 +372,13 @@ router.post('/push', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Process deletions (tombstones) - last so they see the state produced
|
||||||
|
// by the upserts above and resolve by last-writer-wins.
|
||||||
|
if (data.deleted && data.deleted.length > 0) {
|
||||||
|
for (const deleted of data.deleted) {
|
||||||
|
await applyTombstone(tx, deleted.entity, deleted.id, deleted.updatedAt, userId, conflicts);
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
@@ -285,5 +391,175 @@ router.post('/push', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
|
|||||||
conflicts,
|
conflicts,
|
||||||
});
|
});
|
||||||
}));
|
}));
|
||||||
|
// Upsert a tombstone row, keeping the latest updatedAt.
|
||||||
|
async function upsertTombstone(tx, entity, entityId, updatedAt, userId) {
|
||||||
|
const existing = await tx
|
||||||
|
.select({ updatedAt: schema_1.tombstones.updatedAt })
|
||||||
|
.from(schema_1.tombstones)
|
||||||
|
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.tombstones.entity, entity), (0, drizzle_orm_1.eq)(schema_1.tombstones.entityId, entityId)))
|
||||||
|
.limit(1);
|
||||||
|
const merged = Math.max(existing[0]?.updatedAt ?? 0, updatedAt);
|
||||||
|
if (existing.length > 0) {
|
||||||
|
await tx
|
||||||
|
.update(schema_1.tombstones)
|
||||||
|
.set({ updatedAt: merged })
|
||||||
|
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.tombstones.entity, entity), (0, drizzle_orm_1.eq)(schema_1.tombstones.entityId, entityId)));
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
await tx.insert(schema_1.tombstones).values({ entity, entityId, userId, updatedAt: merged });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Apply a client deletion. LWW: if the server row is newer than the deletion
|
||||||
|
// timestamp, the deletion is rejected (server_wins conflict) so the client
|
||||||
|
// re-pulls the row. Accepted deletions cascade tombstones to every FK-cascaded
|
||||||
|
// child so all devices remove them too.
|
||||||
|
async function applyTombstone(tx, entity, id, deletedAt, userId, conflicts) {
|
||||||
|
const tombstoneOf = (e, ids) => ids.forEach((i) => upsertTombstone(tx, e, i, deletedAt, userId));
|
||||||
|
if (entity === 'tasks') {
|
||||||
|
const row = await tx
|
||||||
|
.select()
|
||||||
|
.from(schema_1.tasks)
|
||||||
|
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.tasks.id, id), (0, drizzle_orm_1.eq)(schema_1.tasks.userId, userId)))
|
||||||
|
.limit(1);
|
||||||
|
if (row.length === 0) {
|
||||||
|
await upsertTombstone(tx, entity, id, deletedAt, userId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (row[0].updatedAt > deletedAt) {
|
||||||
|
conflicts.push({
|
||||||
|
entity: 'tasks',
|
||||||
|
id,
|
||||||
|
serverVersion: row[0],
|
||||||
|
clientVersion: { id, updatedAt: deletedAt },
|
||||||
|
resolution: 'server_wins',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const children = await tx
|
||||||
|
.select({ id: schema_1.subtasks.id })
|
||||||
|
.from(schema_1.subtasks)
|
||||||
|
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.subtasks.taskId, id), (0, drizzle_orm_1.eq)(schema_1.subtasks.userId, userId)));
|
||||||
|
tombstoneOf('subtasks', children.map((c) => c.id));
|
||||||
|
await tx.delete(schema_1.tasks).where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.tasks.id, id), (0, drizzle_orm_1.eq)(schema_1.tasks.userId, userId)));
|
||||||
|
await upsertTombstone(tx, entity, id, deletedAt, userId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (entity === 'categories') {
|
||||||
|
const row = await tx
|
||||||
|
.select()
|
||||||
|
.from(schema_1.categories)
|
||||||
|
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.categories.id, id), (0, drizzle_orm_1.eq)(schema_1.categories.userId, userId)))
|
||||||
|
.limit(1);
|
||||||
|
if (row.length === 0) {
|
||||||
|
await upsertTombstone(tx, entity, id, deletedAt, userId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (row[0].updatedAt > deletedAt) {
|
||||||
|
conflicts.push({
|
||||||
|
entity: 'categories',
|
||||||
|
id,
|
||||||
|
serverVersion: row[0],
|
||||||
|
clientVersion: { id, updatedAt: deletedAt },
|
||||||
|
resolution: 'server_wins',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Deleting the category cascades its tasks (and their subtasks) -
|
||||||
|
// tombstone all of them so every client removes them.
|
||||||
|
const catTasks = await tx
|
||||||
|
.select()
|
||||||
|
.from(schema_1.tasks)
|
||||||
|
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.tasks.categoryId, id), (0, drizzle_orm_1.eq)(schema_1.tasks.userId, userId)));
|
||||||
|
for (const taskRow of catTasks) {
|
||||||
|
const subIds = await tx
|
||||||
|
.select({ id: schema_1.subtasks.id })
|
||||||
|
.from(schema_1.subtasks)
|
||||||
|
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.subtasks.taskId, taskRow.id), (0, drizzle_orm_1.eq)(schema_1.subtasks.userId, userId)));
|
||||||
|
tombstoneOf('subtasks', subIds.map((s) => s.id));
|
||||||
|
tombstoneOf('tasks', [taskRow.id]);
|
||||||
|
}
|
||||||
|
await tx.delete(schema_1.categories).where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.categories.id, id), (0, drizzle_orm_1.eq)(schema_1.categories.userId, userId)));
|
||||||
|
await upsertTombstone(tx, entity, id, deletedAt, userId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (entity === 'subtasks') {
|
||||||
|
const row = await tx
|
||||||
|
.select()
|
||||||
|
.from(schema_1.subtasks)
|
||||||
|
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.subtasks.id, id), (0, drizzle_orm_1.eq)(schema_1.subtasks.userId, userId)))
|
||||||
|
.limit(1);
|
||||||
|
if (row.length === 0) {
|
||||||
|
await upsertTombstone(tx, entity, id, deletedAt, userId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (row[0].updatedAt > deletedAt) {
|
||||||
|
conflicts.push({
|
||||||
|
entity: 'subtasks',
|
||||||
|
id,
|
||||||
|
serverVersion: row[0],
|
||||||
|
clientVersion: { id, updatedAt: deletedAt },
|
||||||
|
resolution: 'server_wins',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Deleting a parent subtask cascades its children in PG - tombstone them.
|
||||||
|
const children = await tx
|
||||||
|
.select({ id: schema_1.subtasks.id })
|
||||||
|
.from(schema_1.subtasks)
|
||||||
|
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.subtasks.parentSubtaskId, id), (0, drizzle_orm_1.eq)(schema_1.subtasks.userId, userId)));
|
||||||
|
tombstoneOf('subtasks', children.map((c) => c.id));
|
||||||
|
await tx.delete(schema_1.subtasks).where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.subtasks.id, id), (0, drizzle_orm_1.eq)(schema_1.subtasks.userId, userId)));
|
||||||
|
await upsertTombstone(tx, entity, id, deletedAt, userId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (entity === 'repeatProfiles') {
|
||||||
|
const row = await tx
|
||||||
|
.select()
|
||||||
|
.from(schema_1.repeatProfiles)
|
||||||
|
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.repeatProfiles.id, id), (0, drizzle_orm_1.eq)(schema_1.repeatProfiles.userId, userId)))
|
||||||
|
.limit(1);
|
||||||
|
if (row.length === 0) {
|
||||||
|
await upsertTombstone(tx, entity, id, deletedAt, userId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (row[0].updatedAt > deletedAt) {
|
||||||
|
conflicts.push({
|
||||||
|
entity: 'repeatProfiles',
|
||||||
|
id,
|
||||||
|
serverVersion: row[0],
|
||||||
|
clientVersion: { id, updatedAt: deletedAt },
|
||||||
|
resolution: 'server_wins',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await tx.delete(schema_1.repeatProfiles).where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.repeatProfiles.id, id), (0, drizzle_orm_1.eq)(schema_1.repeatProfiles.userId, userId)));
|
||||||
|
await upsertTombstone(tx, entity, id, deletedAt, userId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (entity === 'friendships') {
|
||||||
|
const row = await tx
|
||||||
|
.select()
|
||||||
|
.from(schema_1.friendships)
|
||||||
|
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.friendships.id, id), (0, drizzle_orm_1.or)((0, drizzle_orm_1.eq)(schema_1.friendships.userId, userId), (0, drizzle_orm_1.eq)(schema_1.friendships.friendId, userId))))
|
||||||
|
.limit(1);
|
||||||
|
if (row.length === 0) {
|
||||||
|
await upsertTombstone(tx, entity, id, deletedAt, userId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (row[0].updatedAt > deletedAt) {
|
||||||
|
conflicts.push({
|
||||||
|
entity: 'friendships',
|
||||||
|
id,
|
||||||
|
serverVersion: row[0],
|
||||||
|
clientVersion: { id, updatedAt: deletedAt },
|
||||||
|
resolution: 'server_wins',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await tx.delete(schema_1.friendships).where((0, drizzle_orm_1.eq)(schema_1.friendships.id, id));
|
||||||
|
await upsertTombstone(tx, entity, id, deletedAt, userId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
exports.default = router;
|
exports.default = router;
|
||||||
//# sourceMappingURL=sync.js.map
|
//# sourceMappingURL=sync.js.map
|
||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -1 +1 @@
|
|||||||
{"version":3,"file":"tasks.d.ts","sourceRoot":"","sources":["../../src/routes/tasks.ts"],"names":[],"mappings":"AAUA,QAAA,MAAM,MAAM,4CAAW,CAAC;AA8RxB,eAAe,MAAM,CAAC"}
|
{"version":3,"file":"tasks.d.ts","sourceRoot":"","sources":["../../src/routes/tasks.ts"],"names":[],"mappings":"AAUA,QAAA,MAAM,MAAM,4CAAW,CAAC;AA+TxB,eAAe,MAAM,CAAC"}
|
||||||
Vendored
+34
-1
@@ -97,7 +97,8 @@ router.post('/', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
|
|||||||
const data = validation_1.taskCreateSchema.parse(req.body);
|
const data = validation_1.taskCreateSchema.parse(req.body);
|
||||||
const userId = req.user.userId;
|
const userId = req.user.userId;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
// Verify category exists and belongs to user
|
// Verify category exists and belongs to user (optional - tasks may be uncategorized)
|
||||||
|
if (data.categoryId) {
|
||||||
const cat = await db_1.db
|
const cat = await db_1.db
|
||||||
.select()
|
.select()
|
||||||
.from(schema_1.categories)
|
.from(schema_1.categories)
|
||||||
@@ -106,6 +107,7 @@ router.post('/', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
|
|||||||
if (cat.length === 0) {
|
if (cat.length === 0) {
|
||||||
throw new errorHandler_1.AppError('NOT_FOUND', 'Category not found', 404);
|
throw new errorHandler_1.AppError('NOT_FOUND', 'Category not found', 404);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
const taskId = `task_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
const taskId = `task_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||||
const newTask = {
|
const newTask = {
|
||||||
id: taskId,
|
id: taskId,
|
||||||
@@ -118,7 +120,10 @@ router.post('/', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
|
|||||||
dueDate: data.dueDate ?? 0,
|
dueDate: data.dueDate ?? 0,
|
||||||
dueTime: data.dueTime ?? '',
|
dueTime: data.dueTime ?? '',
|
||||||
endTime: data.endTime ?? '',
|
endTime: data.endTime ?? '',
|
||||||
|
allDay: data.allDay ?? false,
|
||||||
assigneeId: data.assigneeId ?? null,
|
assigneeId: data.assigneeId ?? null,
|
||||||
|
reminder: data.reminder ?? 'none',
|
||||||
|
reminders: data.reminders ?? '',
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
@@ -166,6 +171,13 @@ router.patch('/:id', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
|
|||||||
throw new errorHandler_1.AppError('NOT_FOUND', 'Category not found', 404);
|
throw new errorHandler_1.AppError('NOT_FOUND', 'Category not found', 404);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Prevent completing tasks with future due dates
|
||||||
|
if (data.completed === true) {
|
||||||
|
const effectiveDueDate = data.dueDate ?? existing[0].dueDate;
|
||||||
|
if (!(0, validation_1.canCompleteTask)(effectiveDueDate)) {
|
||||||
|
throw new errorHandler_1.AppError('VALIDATION_ERROR', 'Cannot mark a task as completed if its due date is in the future', 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const updated = await db_1.db
|
const updated = await db_1.db
|
||||||
.update(schema_1.tasks)
|
.update(schema_1.tasks)
|
||||||
@@ -207,6 +219,7 @@ router.post('/batch', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
|
|||||||
for (const op of operations) {
|
for (const op of operations) {
|
||||||
try {
|
try {
|
||||||
if (op.type === 'create') {
|
if (op.type === 'create') {
|
||||||
|
if (op.data.categoryId) {
|
||||||
const cat = await db_1.db
|
const cat = await db_1.db
|
||||||
.select()
|
.select()
|
||||||
.from(schema_1.categories)
|
.from(schema_1.categories)
|
||||||
@@ -214,6 +227,7 @@ router.post('/batch', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
|
|||||||
.limit(1);
|
.limit(1);
|
||||||
if (cat.length === 0)
|
if (cat.length === 0)
|
||||||
throw new errorHandler_1.AppError('NOT_FOUND', 'Category not found', 404);
|
throw new errorHandler_1.AppError('NOT_FOUND', 'Category not found', 404);
|
||||||
|
}
|
||||||
const taskId = `task_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
const taskId = `task_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
await db_1.db.insert(schema_1.tasks).values({
|
await db_1.db.insert(schema_1.tasks).values({
|
||||||
@@ -227,6 +241,25 @@ router.post('/batch', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
|
|||||||
results.push({ id: taskId, success: true });
|
results.push({ id: taskId, success: true });
|
||||||
}
|
}
|
||||||
else if (op.type === 'update') {
|
else if (op.type === 'update') {
|
||||||
|
if (op.data.completed === true) {
|
||||||
|
let effectiveDueDate;
|
||||||
|
if (op.data.dueDate !== undefined) {
|
||||||
|
effectiveDueDate = op.data.dueDate;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const existingTask = await db_1.db
|
||||||
|
.select({ dueDate: schema_1.tasks.dueDate })
|
||||||
|
.from(schema_1.tasks)
|
||||||
|
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.tasks.id, op.id), (0, drizzle_orm_1.eq)(schema_1.tasks.userId, userId)))
|
||||||
|
.limit(1);
|
||||||
|
if (existingTask.length === 0)
|
||||||
|
throw new errorHandler_1.AppError('NOT_FOUND', 'Task not found', 404);
|
||||||
|
effectiveDueDate = existingTask[0].dueDate;
|
||||||
|
}
|
||||||
|
if (!(0, validation_1.canCompleteTask)(effectiveDueDate)) {
|
||||||
|
throw new errorHandler_1.AppError('VALIDATION_ERROR', 'Cannot mark a task as completed if its due date is in the future', 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
await db_1.db
|
await db_1.db
|
||||||
.update(schema_1.tasks)
|
.update(schema_1.tasks)
|
||||||
.set({ ...op.data, updatedAt: Date.now() })
|
.set({ ...op.data, updatedAt: Date.now() })
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -2,7 +2,7 @@ import { Request, Response, NextFunction } from 'express';
|
|||||||
import { AuthPayload } from '../types';
|
import { AuthPayload } from '../types';
|
||||||
export declare function generateToken(payload: AuthPayload): string;
|
export declare function generateToken(payload: AuthPayload): string;
|
||||||
export declare function verifyToken(token: string): AuthPayload | null;
|
export declare function verifyToken(token: string): AuthPayload | null;
|
||||||
export declare function authMiddleware(req: Request, res: Response, next: NextFunction): void;
|
export declare function authMiddleware(req: Request, res: Response, next: NextFunction): Promise<void>;
|
||||||
export declare function optionalAuthMiddleware(req: Request, res: Response, next: NextFunction): void;
|
export declare function optionalAuthMiddleware(req: Request, res: Response, next: NextFunction): void;
|
||||||
export declare function generateId(prefix?: string): string;
|
export declare function generateId(prefix?: string): string;
|
||||||
export declare function getCurrentTimestamp(): number;
|
export declare function getCurrentTimestamp(): number;
|
||||||
|
|||||||
Vendored
+1
-1
@@ -1 +1 @@
|
|||||||
{"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../src/utils/auth.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAE1D,OAAO,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AAKvC,wBAAgB,aAAa,CAAC,OAAO,EAAE,WAAW,GAAG,MAAM,CAE1D;AAED,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,WAAW,GAAG,IAAI,CAM7D;AAED,wBAAgB,cAAc,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,EAAE,IAAI,EAAE,YAAY,GAAG,IAAI,CA4BpF;AAED,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,EAAE,IAAI,EAAE,YAAY,GAAG,IAAI,CAW5F;AAED,wBAAgB,UAAU,CAAC,MAAM,GAAE,MAAW,GAAG,MAAM,CAItD;AAED,wBAAgB,mBAAmB,IAAI,MAAM,CAE5C"}
|
{"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../src/utils/auth.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAK1D,OAAO,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AAKvC,wBAAgB,aAAa,CAAC,OAAO,EAAE,WAAW,GAAG,MAAM,CAE1D;AAED,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,WAAW,GAAG,IAAI,CAM7D;AAED,wBAAsB,cAAc,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,EAAE,IAAI,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CAuCnG;AAED,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,EAAE,IAAI,EAAE,YAAY,GAAG,IAAI,CAW5F;AAED,wBAAgB,UAAU,CAAC,MAAM,GAAE,MAAW,GAAG,MAAM,CAItD;AAED,wBAAgB,mBAAmB,IAAI,MAAM,CAE5C"}
|
||||||
Vendored
+14
-1
@@ -10,6 +10,9 @@ exports.optionalAuthMiddleware = optionalAuthMiddleware;
|
|||||||
exports.generateId = generateId;
|
exports.generateId = generateId;
|
||||||
exports.getCurrentTimestamp = getCurrentTimestamp;
|
exports.getCurrentTimestamp = getCurrentTimestamp;
|
||||||
const jsonwebtoken_1 = __importDefault(require("jsonwebtoken"));
|
const jsonwebtoken_1 = __importDefault(require("jsonwebtoken"));
|
||||||
|
const db_1 = require("../db");
|
||||||
|
const schema_1 = require("../db/schema");
|
||||||
|
const drizzle_orm_1 = require("drizzle-orm");
|
||||||
const JWT_SECRET = process.env.JWT_SECRET || 'your-super-secret-jwt-key-change-in-production';
|
const JWT_SECRET = process.env.JWT_SECRET || 'your-super-secret-jwt-key-change-in-production';
|
||||||
const JWT_EXPIRES_IN = '7d';
|
const JWT_EXPIRES_IN = '7d';
|
||||||
function generateToken(payload) {
|
function generateToken(payload) {
|
||||||
@@ -23,7 +26,7 @@ function verifyToken(token) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function authMiddleware(req, res, next) {
|
async function authMiddleware(req, res, next) {
|
||||||
const authHeader = req.headers.authorization;
|
const authHeader = req.headers.authorization;
|
||||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
res.status(401).json({
|
res.status(401).json({
|
||||||
@@ -45,6 +48,16 @@ function authMiddleware(req, res, next) {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const user = await db_1.db.select({ id: schema_1.users.id }).from(schema_1.users).where((0, drizzle_orm_1.eq)(schema_1.users.id, payload.userId)).limit(1);
|
||||||
|
if (user.length === 0) {
|
||||||
|
res.status(401).json({
|
||||||
|
error: {
|
||||||
|
code: 'UNAUTHORIZED',
|
||||||
|
message: 'User no longer exists, please log in again',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
req.user = payload;
|
req.user = payload;
|
||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+1
-1
@@ -1 +1 @@
|
|||||||
{"version":3,"file":"auth.js","sourceRoot":"","sources":["../../src/utils/auth.ts"],"names":[],"mappings":";;;;;AAOA,sCAEC;AAED,kCAMC;AAED,wCA4BC;AAED,wDAWC;AAED,gCAIC;AAED,kDAEC;AArED,gEAA+B;AAG/B,MAAM,UAAU,GAAG,OAAO,CAAC,GAAG,CAAC,UAAU,IAAI,gDAAgD,CAAC;AAC9F,MAAM,cAAc,GAAG,IAAI,CAAC;AAE5B,SAAgB,aAAa,CAAC,OAAoB;IAChD,OAAO,sBAAG,CAAC,IAAI,CAAC,OAAO,EAAE,UAAU,EAAE,EAAE,SAAS,EAAE,cAAc,EAAE,CAAC,CAAC;AACtE,CAAC;AAED,SAAgB,WAAW,CAAC,KAAa;IACvC,IAAI,CAAC;QACH,OAAO,sBAAG,CAAC,MAAM,CAAC,KAAK,EAAE,UAAU,CAAgB,CAAC;IACtD,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC;AAED,SAAgB,cAAc,CAAC,GAAY,EAAE,GAAa,EAAE,IAAkB;IAC5E,MAAM,UAAU,GAAG,GAAG,CAAC,OAAO,CAAC,aAAa,CAAC;IAE7C,IAAI,CAAC,UAAU,IAAI,CAAC,UAAU,CAAC,UAAU,CAAC,SAAS,CAAC,EAAE,CAAC;QACrD,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC;YACnB,KAAK,EAAE;gBACL,IAAI,EAAE,cAAc;gBACpB,OAAO,EAAE,yCAAyC;aACnD;SACF,CAAC,CAAC;QACH,OAAO;IACT,CAAC;IAED,MAAM,KAAK,GAAG,UAAU,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;IAClC,MAAM,OAAO,GAAG,WAAW,CAAC,KAAK,CAAC,CAAC;IAEnC,IAAI,CAAC,OAAO,EAAE,CAAC;QACb,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC;YACnB,KAAK,EAAE;gBACL,IAAI,EAAE,cAAc;gBACpB,OAAO,EAAE,0BAA0B;aACpC;SACF,CAAC,CAAC;QACH,OAAO;IACT,CAAC;IAED,GAAG,CAAC,IAAI,GAAG,OAAO,CAAC;IACnB,IAAI,EAAE,CAAC;AACT,CAAC;AAED,SAAgB,sBAAsB,CAAC,GAAY,EAAE,GAAa,EAAE,IAAkB;IACpF,MAAM,UAAU,GAAG,GAAG,CAAC,OAAO,CAAC,aAAa,CAAC;IAE7C,IAAI,UAAU,IAAI,UAAU,CAAC,UAAU,CAAC,SAAS,CAAC,EAAE,CAAC;QACnD,MAAM,KAAK,GAAG,UAAU,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;QAClC,MAAM,OAAO,GAAG,WAAW,CAAC,KAAK,CAAC,CAAC;QACnC,IAAI,OAAO,EAAE,CAAC;YACZ,GAAG,CAAC,IAAI,GAAG,OAAO,CAAC;QACrB,CAAC;IACH,CAAC;IACD,IAAI,EAAE,CAAC;AACT,CAAC;AAED,SAAgB,UAAU,CAAC,SAAiB,EAAE;IAC5C,MAAM,SAAS,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC;IAC1C,MAAM,MAAM,GAAG,IAAI,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;IACvD,OAAO,GAAG,MAAM,GAAG,MAAM,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,GAAG,SAAS,GAAG,MAAM,EAAE,CAAC;AAC9D,CAAC;AAED,SAAgB,mBAAmB;IACjC,OAAO,IAAI,CAAC,GAAG,EAAE,CAAC;AACpB,CAAC"}
|
{"version":3,"file":"auth.js","sourceRoot":"","sources":["../../src/utils/auth.ts"],"names":[],"mappings":";;;;;AAUA,sCAEC;AAED,kCAMC;AAED,wCAuCC;AAED,wDAWC;AAED,gCAIC;AAED,kDAEC;AAnFD,gEAA+B;AAC/B,8BAA2B;AAC3B,yCAAqC;AACrC,6CAAiC;AAGjC,MAAM,UAAU,GAAG,OAAO,CAAC,GAAG,CAAC,UAAU,IAAI,gDAAgD,CAAC;AAC9F,MAAM,cAAc,GAAG,IAAI,CAAC;AAE5B,SAAgB,aAAa,CAAC,OAAoB;IAChD,OAAO,sBAAG,CAAC,IAAI,CAAC,OAAO,EAAE,UAAU,EAAE,EAAE,SAAS,EAAE,cAAc,EAAE,CAAC,CAAC;AACtE,CAAC;AAED,SAAgB,WAAW,CAAC,KAAa;IACvC,IAAI,CAAC;QACH,OAAO,sBAAG,CAAC,MAAM,CAAC,KAAK,EAAE,UAAU,CAAgB,CAAC;IACtD,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC;AAEM,KAAK,UAAU,cAAc,CAAC,GAAY,EAAE,GAAa,EAAE,IAAkB;IAClF,MAAM,UAAU,GAAG,GAAG,CAAC,OAAO,CAAC,aAAa,CAAC;IAE7C,IAAI,CAAC,UAAU,IAAI,CAAC,UAAU,CAAC,UAAU,CAAC,SAAS,CAAC,EAAE,CAAC;QACrD,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC;YACnB,KAAK,EAAE;gBACL,IAAI,EAAE,cAAc;gBACpB,OAAO,EAAE,yCAAyC;aACnD;SACF,CAAC,CAAC;QACH,OAAO;IACT,CAAC;IAED,MAAM,KAAK,GAAG,UAAU,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;IAClC,MAAM,OAAO,GAAG,WAAW,CAAC,KAAK,CAAC,CAAC;IAEnC,IAAI,CAAC,OAAO,EAAE,CAAC;QACb,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC;YACnB,KAAK,EAAE;gBACL,IAAI,EAAE,cAAc;gBACpB,OAAO,EAAE,0BAA0B;aACpC;SACF,CAAC,CAAC;QACH,OAAO;IACT,CAAC;IAED,MAAM,IAAI,GAAG,MAAM,OAAE,CAAC,MAAM,CAAC,EAAE,EAAE,EAAE,cAAK,CAAC,EAAE,EAAE,CAAC,CAAC,IAAI,CAAC,cAAK,CAAC,CAAC,KAAK,CAAC,IAAA,gBAAE,EAAC,cAAK,CAAC,EAAE,EAAE,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;IACxG,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACtB,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC;YACnB,KAAK,EAAE;gBACL,IAAI,EAAE,cAAc;gBACpB,OAAO,EAAE,4CAA4C;aACtD;SACF,CAAC,CAAC;QACH,OAAO;IACT,CAAC;IAED,GAAG,CAAC,IAAI,GAAG,OAAO,CAAC;IACnB,IAAI,EAAE,CAAC;AACT,CAAC;AAED,SAAgB,sBAAsB,CAAC,GAAY,EAAE,GAAa,EAAE,IAAkB;IACpF,MAAM,UAAU,GAAG,GAAG,CAAC,OAAO,CAAC,aAAa,CAAC;IAE7C,IAAI,UAAU,IAAI,UAAU,CAAC,UAAU,CAAC,SAAS,CAAC,EAAE,CAAC;QACnD,MAAM,KAAK,GAAG,UAAU,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;QAClC,MAAM,OAAO,GAAG,WAAW,CAAC,KAAK,CAAC,CAAC;QACnC,IAAI,OAAO,EAAE,CAAC;YACZ,GAAG,CAAC,IAAI,GAAG,OAAO,CAAC;QACrB,CAAC;IACH,CAAC;IACD,IAAI,EAAE,CAAC;AACT,CAAC;AAED,SAAgB,UAAU,CAAC,SAAiB,EAAE;IAC5C,MAAM,SAAS,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC;IAC1C,MAAM,MAAM,GAAG,IAAI,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;IACvD,OAAO,GAAG,MAAM,GAAG,MAAM,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,GAAG,SAAS,GAAG,MAAM,EAAE,CAAC;AAC9D,CAAC;AAED,SAAgB,mBAAmB;IACjC,OAAO,IAAI,CAAC,GAAG,EAAE,CAAC;AACpB,CAAC"}
|
||||||
Vendored
+370
-90
@@ -1,4 +1,5 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
export declare function canCompleteTask(dueDate: number): boolean;
|
||||||
export declare const categoryCreateSchema: z.ZodObject<{
|
export declare const categoryCreateSchema: z.ZodObject<{
|
||||||
name: z.ZodString;
|
name: z.ZodString;
|
||||||
color: z.ZodString;
|
color: z.ZodString;
|
||||||
@@ -26,20 +27,25 @@ export declare const categoryUpdateSchema: z.ZodObject<{
|
|||||||
order?: number | undefined;
|
order?: number | undefined;
|
||||||
}>;
|
}>;
|
||||||
export declare const repeatSchema: z.ZodEnum<["none", "daily", "weekly", "monthly", "custom"]>;
|
export declare const repeatSchema: z.ZodEnum<["none", "daily", "weekly", "monthly", "custom"]>;
|
||||||
|
export declare const repeatFieldSchema: z.ZodOptional<z.ZodEffects<z.ZodUnion<[z.ZodEnum<["none", "daily", "weekly", "monthly", "custom"]>, z.ZodLiteral<"">]>, "custom" | "none" | "daily" | "weekly" | "monthly", "" | "custom" | "none" | "daily" | "weekly" | "monthly">>;
|
||||||
|
export declare const repeatIntervalFieldSchema: z.ZodOptional<z.ZodEffects<z.ZodNumber, number, number>>;
|
||||||
export declare const taskCreateSchema: z.ZodObject<{
|
export declare const taskCreateSchema: z.ZodObject<{
|
||||||
title: z.ZodString;
|
title: z.ZodString;
|
||||||
description: z.ZodOptional<z.ZodString>;
|
description: z.ZodOptional<z.ZodString>;
|
||||||
categoryId: z.ZodString;
|
categoryId: z.ZodNullable<z.ZodEffects<z.ZodString, string | null, string>>;
|
||||||
priority: z.ZodOptional<z.ZodEnum<["none", "low", "medium", "high", "critical"]>>;
|
priority: z.ZodOptional<z.ZodEnum<["none", "low", "medium", "high", "critical"]>>;
|
||||||
dueDate: z.ZodOptional<z.ZodNumber>;
|
dueDate: z.ZodOptional<z.ZodNumber>;
|
||||||
dueTime: z.ZodNullable<z.ZodOptional<z.ZodString>>;
|
dueTime: z.ZodEffects<z.ZodUnion<[z.ZodOptional<z.ZodString>, z.ZodLiteral<"">]>, string, string | undefined>;
|
||||||
endTime: z.ZodUnion<[z.ZodOptional<z.ZodString>, z.ZodLiteral<"">]>;
|
endTime: z.ZodUnion<[z.ZodOptional<z.ZodString>, z.ZodLiteral<"">]>;
|
||||||
repeat: z.ZodOptional<z.ZodEnum<["none", "daily", "weekly", "monthly", "custom"]>>;
|
allDay: z.ZodOptional<z.ZodBoolean>;
|
||||||
repeatInterval: z.ZodOptional<z.ZodNumber>;
|
repeat: z.ZodOptional<z.ZodEffects<z.ZodUnion<[z.ZodEnum<["none", "daily", "weekly", "monthly", "custom"]>, z.ZodLiteral<"">]>, "custom" | "none" | "daily" | "weekly" | "monthly", "" | "custom" | "none" | "daily" | "weekly" | "monthly">>;
|
||||||
|
repeatInterval: z.ZodOptional<z.ZodEffects<z.ZodNumber, number, number>>;
|
||||||
repeatDays: z.ZodOptional<z.ZodString>;
|
repeatDays: z.ZodOptional<z.ZodString>;
|
||||||
seriesId: z.ZodOptional<z.ZodString>;
|
seriesId: z.ZodOptional<z.ZodString>;
|
||||||
reminder: z.ZodOptional<z.ZodEnum<["none", "at_time", "15", "30", "60", "120", "1440"]>>;
|
reminder: z.ZodOptional<z.ZodEnum<["none", "at_time", "15", "30", "60", "120", "1440"]>>;
|
||||||
|
reminders: z.ZodOptional<z.ZodString>;
|
||||||
assigneeId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
assigneeId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
||||||
|
completedAt: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
|
||||||
subtasks: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
subtasks: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||||
title: z.ZodString;
|
title: z.ZodString;
|
||||||
}, "strip", z.ZodTypeAny, {
|
}, "strip", z.ZodTypeAny, {
|
||||||
@@ -48,93 +54,189 @@ export declare const taskCreateSchema: z.ZodObject<{
|
|||||||
title: string;
|
title: string;
|
||||||
}>, "many">>;
|
}>, "many">>;
|
||||||
}, "strip", z.ZodTypeAny, {
|
}, "strip", z.ZodTypeAny, {
|
||||||
categoryId: string;
|
categoryId: string | null;
|
||||||
title: string;
|
title: string;
|
||||||
|
dueTime: string;
|
||||||
|
subtasks?: {
|
||||||
|
title: string;
|
||||||
|
}[] | undefined;
|
||||||
description?: string | undefined;
|
description?: string | undefined;
|
||||||
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
|
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
|
||||||
dueDate?: number | undefined;
|
dueDate?: number | undefined;
|
||||||
dueTime?: string | null | undefined;
|
|
||||||
endTime?: string | undefined;
|
endTime?: string | undefined;
|
||||||
|
allDay?: boolean | undefined;
|
||||||
repeat?: "custom" | "none" | "daily" | "weekly" | "monthly" | undefined;
|
repeat?: "custom" | "none" | "daily" | "weekly" | "monthly" | undefined;
|
||||||
repeatInterval?: number | undefined;
|
repeatInterval?: number | undefined;
|
||||||
repeatDays?: string | undefined;
|
repeatDays?: string | undefined;
|
||||||
seriesId?: string | undefined;
|
seriesId?: string | undefined;
|
||||||
assigneeId?: string | null | undefined;
|
assigneeId?: string | null | undefined;
|
||||||
reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined;
|
reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined;
|
||||||
subtasks?: {
|
reminders?: string | undefined;
|
||||||
title: string;
|
completedAt?: number | null | undefined;
|
||||||
}[] | undefined;
|
|
||||||
}, {
|
}, {
|
||||||
categoryId: string;
|
categoryId: string | null;
|
||||||
title: string;
|
title: string;
|
||||||
|
subtasks?: {
|
||||||
|
title: string;
|
||||||
|
}[] | undefined;
|
||||||
description?: string | undefined;
|
description?: string | undefined;
|
||||||
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
|
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
|
||||||
dueDate?: number | undefined;
|
dueDate?: number | undefined;
|
||||||
dueTime?: string | null | undefined;
|
dueTime?: string | undefined;
|
||||||
endTime?: string | undefined;
|
endTime?: string | undefined;
|
||||||
repeat?: "custom" | "none" | "daily" | "weekly" | "monthly" | undefined;
|
allDay?: boolean | undefined;
|
||||||
|
repeat?: "" | "custom" | "none" | "daily" | "weekly" | "monthly" | undefined;
|
||||||
repeatInterval?: number | undefined;
|
repeatInterval?: number | undefined;
|
||||||
repeatDays?: string | undefined;
|
repeatDays?: string | undefined;
|
||||||
seriesId?: string | undefined;
|
seriesId?: string | undefined;
|
||||||
assigneeId?: string | null | undefined;
|
assigneeId?: string | null | undefined;
|
||||||
reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined;
|
reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined;
|
||||||
subtasks?: {
|
reminders?: string | undefined;
|
||||||
title: string;
|
completedAt?: number | null | undefined;
|
||||||
}[] | undefined;
|
|
||||||
}>;
|
}>;
|
||||||
export declare const taskUpdateSchema: z.ZodObject<{
|
export declare const taskUpdateSchema: z.ZodObject<{
|
||||||
title: z.ZodOptional<z.ZodString>;
|
title: z.ZodOptional<z.ZodString>;
|
||||||
description: z.ZodOptional<z.ZodString>;
|
description: z.ZodOptional<z.ZodString>;
|
||||||
categoryId: z.ZodOptional<z.ZodString>;
|
categoryId: z.ZodEffects<z.ZodOptional<z.ZodNullable<z.ZodString>>, string | null | undefined, string | null | undefined>;
|
||||||
priority: z.ZodOptional<z.ZodEnum<["none", "low", "medium", "high", "critical"]>>;
|
priority: z.ZodOptional<z.ZodEnum<["none", "low", "medium", "high", "critical"]>>;
|
||||||
completed: z.ZodOptional<z.ZodBoolean>;
|
completed: z.ZodOptional<z.ZodBoolean>;
|
||||||
dueDate: z.ZodOptional<z.ZodNumber>;
|
dueDate: z.ZodOptional<z.ZodNumber>;
|
||||||
dueTime: z.ZodNullable<z.ZodOptional<z.ZodString>>;
|
dueTime: z.ZodEffects<z.ZodUnion<[z.ZodOptional<z.ZodString>, z.ZodLiteral<"">]>, string | undefined, string | undefined>;
|
||||||
endTime: z.ZodOptional<z.ZodString>;
|
endTime: z.ZodOptional<z.ZodString>;
|
||||||
|
reminders: z.ZodOptional<z.ZodString>;
|
||||||
assigneeId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
assigneeId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
||||||
|
completedAt: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
|
||||||
}, "strip", z.ZodTypeAny, {
|
}, "strip", z.ZodTypeAny, {
|
||||||
categoryId?: string | undefined;
|
categoryId?: string | null | undefined;
|
||||||
title?: string | undefined;
|
title?: string | undefined;
|
||||||
description?: string | undefined;
|
description?: string | undefined;
|
||||||
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
|
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
|
||||||
completed?: boolean | undefined;
|
completed?: boolean | undefined;
|
||||||
dueDate?: number | undefined;
|
dueDate?: number | undefined;
|
||||||
dueTime?: string | null | undefined;
|
dueTime?: string | undefined;
|
||||||
endTime?: string | undefined;
|
endTime?: string | undefined;
|
||||||
assigneeId?: string | null | undefined;
|
assigneeId?: string | null | undefined;
|
||||||
|
reminders?: string | undefined;
|
||||||
|
completedAt?: number | null | undefined;
|
||||||
}, {
|
}, {
|
||||||
categoryId?: string | undefined;
|
categoryId?: string | null | undefined;
|
||||||
title?: string | undefined;
|
title?: string | undefined;
|
||||||
description?: string | undefined;
|
description?: string | undefined;
|
||||||
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
|
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
|
||||||
completed?: boolean | undefined;
|
completed?: boolean | undefined;
|
||||||
dueDate?: number | undefined;
|
dueDate?: number | undefined;
|
||||||
dueTime?: string | null | undefined;
|
dueTime?: string | undefined;
|
||||||
endTime?: string | undefined;
|
endTime?: string | undefined;
|
||||||
assigneeId?: string | null | undefined;
|
assigneeId?: string | null | undefined;
|
||||||
|
reminders?: string | undefined;
|
||||||
|
completedAt?: number | null | undefined;
|
||||||
}>;
|
}>;
|
||||||
export declare const subtaskCreateSchema: z.ZodObject<{
|
export declare const subtaskCreateSchema: z.ZodObject<{
|
||||||
title: z.ZodString;
|
title: z.ZodString;
|
||||||
|
description: z.ZodOptional<z.ZodString>;
|
||||||
|
priority: z.ZodOptional<z.ZodEnum<["none", "low", "medium", "high", "critical"]>>;
|
||||||
|
dueDate: z.ZodOptional<z.ZodNumber>;
|
||||||
|
dueTime: z.ZodEffects<z.ZodUnion<[z.ZodOptional<z.ZodString>, z.ZodLiteral<"">]>, string, string | undefined>;
|
||||||
|
endTime: z.ZodUnion<[z.ZodOptional<z.ZodString>, z.ZodLiteral<"">]>;
|
||||||
|
allDay: z.ZodOptional<z.ZodBoolean>;
|
||||||
|
repeat: z.ZodOptional<z.ZodEffects<z.ZodUnion<[z.ZodEnum<["none", "daily", "weekly", "monthly", "custom"]>, z.ZodLiteral<"">]>, "custom" | "none" | "daily" | "weekly" | "monthly", "" | "custom" | "none" | "daily" | "weekly" | "monthly">>;
|
||||||
|
repeatInterval: z.ZodOptional<z.ZodEffects<z.ZodNumber, number, number>>;
|
||||||
|
repeatDays: z.ZodOptional<z.ZodString>;
|
||||||
|
seriesId: z.ZodOptional<z.ZodString>;
|
||||||
|
reminder: z.ZodOptional<z.ZodEnum<["none", "at_time", "15", "30", "60", "120", "1440"]>>;
|
||||||
|
reminders: z.ZodOptional<z.ZodString>;
|
||||||
|
assigneeId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
||||||
order: z.ZodOptional<z.ZodNumber>;
|
order: z.ZodOptional<z.ZodNumber>;
|
||||||
|
parentSubtaskId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
||||||
}, "strip", z.ZodTypeAny, {
|
}, "strip", z.ZodTypeAny, {
|
||||||
title: string;
|
title: string;
|
||||||
|
dueTime: string;
|
||||||
order?: number | undefined;
|
order?: number | undefined;
|
||||||
|
description?: string | undefined;
|
||||||
|
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
|
||||||
|
dueDate?: number | undefined;
|
||||||
|
endTime?: string | undefined;
|
||||||
|
allDay?: boolean | undefined;
|
||||||
|
repeat?: "custom" | "none" | "daily" | "weekly" | "monthly" | undefined;
|
||||||
|
repeatInterval?: number | undefined;
|
||||||
|
repeatDays?: string | undefined;
|
||||||
|
seriesId?: string | undefined;
|
||||||
|
assigneeId?: string | null | undefined;
|
||||||
|
reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined;
|
||||||
|
reminders?: string | undefined;
|
||||||
|
parentSubtaskId?: string | null | undefined;
|
||||||
}, {
|
}, {
|
||||||
title: string;
|
title: string;
|
||||||
order?: number | undefined;
|
order?: number | undefined;
|
||||||
|
description?: string | undefined;
|
||||||
|
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
|
||||||
|
dueDate?: number | undefined;
|
||||||
|
dueTime?: string | undefined;
|
||||||
|
endTime?: string | undefined;
|
||||||
|
allDay?: boolean | undefined;
|
||||||
|
repeat?: "" | "custom" | "none" | "daily" | "weekly" | "monthly" | undefined;
|
||||||
|
repeatInterval?: number | undefined;
|
||||||
|
repeatDays?: string | undefined;
|
||||||
|
seriesId?: string | undefined;
|
||||||
|
assigneeId?: string | null | undefined;
|
||||||
|
reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined;
|
||||||
|
reminders?: string | undefined;
|
||||||
|
parentSubtaskId?: string | null | undefined;
|
||||||
}>;
|
}>;
|
||||||
export declare const subtaskUpdateSchema: z.ZodObject<{
|
export declare const subtaskUpdateSchema: z.ZodObject<{
|
||||||
title: z.ZodOptional<z.ZodString>;
|
title: z.ZodOptional<z.ZodString>;
|
||||||
|
description: z.ZodOptional<z.ZodString>;
|
||||||
|
priority: z.ZodOptional<z.ZodEnum<["none", "low", "medium", "high", "critical"]>>;
|
||||||
completed: z.ZodOptional<z.ZodBoolean>;
|
completed: z.ZodOptional<z.ZodBoolean>;
|
||||||
|
dueDate: z.ZodOptional<z.ZodNumber>;
|
||||||
|
dueTime: z.ZodEffects<z.ZodUnion<[z.ZodOptional<z.ZodString>, z.ZodLiteral<"">]>, string | undefined, string | undefined>;
|
||||||
|
endTime: z.ZodOptional<z.ZodString>;
|
||||||
|
allDay: z.ZodOptional<z.ZodBoolean>;
|
||||||
|
repeat: z.ZodOptional<z.ZodEffects<z.ZodUnion<[z.ZodEnum<["none", "daily", "weekly", "monthly", "custom"]>, z.ZodLiteral<"">]>, "custom" | "none" | "daily" | "weekly" | "monthly", "" | "custom" | "none" | "daily" | "weekly" | "monthly">>;
|
||||||
|
repeatInterval: z.ZodOptional<z.ZodEffects<z.ZodNumber, number, number>>;
|
||||||
|
repeatDays: z.ZodOptional<z.ZodString>;
|
||||||
|
seriesId: z.ZodOptional<z.ZodString>;
|
||||||
|
reminder: z.ZodOptional<z.ZodEnum<["none", "at_time", "15", "30", "60", "120", "1440"]>>;
|
||||||
|
reminders: z.ZodOptional<z.ZodString>;
|
||||||
|
assigneeId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
||||||
order: z.ZodOptional<z.ZodNumber>;
|
order: z.ZodOptional<z.ZodNumber>;
|
||||||
|
parentSubtaskId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
||||||
}, "strip", z.ZodTypeAny, {
|
}, "strip", z.ZodTypeAny, {
|
||||||
order?: number | undefined;
|
order?: number | undefined;
|
||||||
title?: string | undefined;
|
title?: string | undefined;
|
||||||
|
description?: string | undefined;
|
||||||
|
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
|
||||||
completed?: boolean | undefined;
|
completed?: boolean | undefined;
|
||||||
|
dueDate?: number | undefined;
|
||||||
|
dueTime?: string | undefined;
|
||||||
|
endTime?: string | undefined;
|
||||||
|
allDay?: boolean | undefined;
|
||||||
|
repeat?: "custom" | "none" | "daily" | "weekly" | "monthly" | undefined;
|
||||||
|
repeatInterval?: number | undefined;
|
||||||
|
repeatDays?: string | undefined;
|
||||||
|
seriesId?: string | undefined;
|
||||||
|
assigneeId?: string | null | undefined;
|
||||||
|
reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined;
|
||||||
|
reminders?: string | undefined;
|
||||||
|
parentSubtaskId?: string | null | undefined;
|
||||||
}, {
|
}, {
|
||||||
order?: number | undefined;
|
order?: number | undefined;
|
||||||
title?: string | undefined;
|
title?: string | undefined;
|
||||||
|
description?: string | undefined;
|
||||||
|
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
|
||||||
completed?: boolean | undefined;
|
completed?: boolean | undefined;
|
||||||
|
dueDate?: number | undefined;
|
||||||
|
dueTime?: string | undefined;
|
||||||
|
endTime?: string | undefined;
|
||||||
|
allDay?: boolean | undefined;
|
||||||
|
repeat?: "" | "custom" | "none" | "daily" | "weekly" | "monthly" | undefined;
|
||||||
|
repeatInterval?: number | undefined;
|
||||||
|
repeatDays?: string | undefined;
|
||||||
|
seriesId?: string | undefined;
|
||||||
|
assigneeId?: string | null | undefined;
|
||||||
|
reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined;
|
||||||
|
reminders?: string | undefined;
|
||||||
|
parentSubtaskId?: string | null | undefined;
|
||||||
}>;
|
}>;
|
||||||
export declare const userSettingsSchema: z.ZodObject<{
|
export declare const userSettingsSchema: z.ZodObject<{
|
||||||
darkMode: z.ZodOptional<z.ZodBoolean>;
|
darkMode: z.ZodOptional<z.ZodBoolean>;
|
||||||
@@ -174,6 +276,29 @@ export declare const repeatProfileSchema: z.ZodObject<{
|
|||||||
repeatInterval: number;
|
repeatInterval: number;
|
||||||
repeatDays: string;
|
repeatDays: string;
|
||||||
}>;
|
}>;
|
||||||
|
export declare const repeatProfileUpdateSchema: z.ZodObject<{
|
||||||
|
name: z.ZodOptional<z.ZodString>;
|
||||||
|
repeat: z.ZodOptional<z.ZodEnum<["none", "daily", "weekly", "monthly", "custom"]>>;
|
||||||
|
repeatInterval: z.ZodOptional<z.ZodNumber>;
|
||||||
|
repeatDays: z.ZodOptional<z.ZodString>;
|
||||||
|
}, "strip", z.ZodTypeAny, {
|
||||||
|
name?: string | undefined;
|
||||||
|
repeat?: "custom" | "none" | "daily" | "weekly" | "monthly" | undefined;
|
||||||
|
repeatInterval?: number | undefined;
|
||||||
|
repeatDays?: string | undefined;
|
||||||
|
}, {
|
||||||
|
name?: string | undefined;
|
||||||
|
repeat?: "custom" | "none" | "daily" | "weekly" | "monthly" | undefined;
|
||||||
|
repeatInterval?: number | undefined;
|
||||||
|
repeatDays?: string | undefined;
|
||||||
|
}>;
|
||||||
|
export declare const searchQuerySchema: z.ZodObject<{
|
||||||
|
q: z.ZodString;
|
||||||
|
}, "strip", z.ZodTypeAny, {
|
||||||
|
q: string;
|
||||||
|
}, {
|
||||||
|
q: string;
|
||||||
|
}>;
|
||||||
export declare const friendRequestSchema: z.ZodObject<{
|
export declare const friendRequestSchema: z.ZodObject<{
|
||||||
username: z.ZodString;
|
username: z.ZodString;
|
||||||
}, "strip", z.ZodTypeAny, {
|
}, "strip", z.ZodTypeAny, {
|
||||||
@@ -203,6 +328,19 @@ export declare const friendshipSchema: z.ZodObject<{
|
|||||||
status: "pending" | "accepted";
|
status: "pending" | "accepted";
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
}>;
|
}>;
|
||||||
|
export declare const tombstoneSchema: z.ZodObject<{
|
||||||
|
entity: z.ZodEnum<["categories", "tasks", "subtasks", "repeatProfiles", "friendships"]>;
|
||||||
|
id: z.ZodString;
|
||||||
|
updatedAt: z.ZodNumber;
|
||||||
|
}, "strip", z.ZodTypeAny, {
|
||||||
|
id: string;
|
||||||
|
updatedAt: number;
|
||||||
|
entity: "categories" | "tasks" | "subtasks" | "repeatProfiles" | "friendships";
|
||||||
|
}, {
|
||||||
|
id: string;
|
||||||
|
updatedAt: number;
|
||||||
|
entity: "categories" | "tasks" | "subtasks" | "repeatProfiles" | "friendships";
|
||||||
|
}>;
|
||||||
export declare const pushChangesSchema: z.ZodObject<{
|
export declare const pushChangesSchema: z.ZodObject<{
|
||||||
changes: z.ZodObject<{
|
changes: z.ZodObject<{
|
||||||
categories: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
categories: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||||
@@ -231,16 +369,18 @@ export declare const pushChangesSchema: z.ZodObject<{
|
|||||||
tasks: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
tasks: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||||
title: z.ZodString;
|
title: z.ZodString;
|
||||||
description: z.ZodOptional<z.ZodString>;
|
description: z.ZodOptional<z.ZodString>;
|
||||||
categoryId: z.ZodString;
|
categoryId: z.ZodNullable<z.ZodEffects<z.ZodString, string | null, string>>;
|
||||||
priority: z.ZodOptional<z.ZodEnum<["none", "low", "medium", "high", "critical"]>>;
|
priority: z.ZodOptional<z.ZodEnum<["none", "low", "medium", "high", "critical"]>>;
|
||||||
dueDate: z.ZodOptional<z.ZodNumber>;
|
dueDate: z.ZodOptional<z.ZodNumber>;
|
||||||
dueTime: z.ZodNullable<z.ZodOptional<z.ZodString>>;
|
dueTime: z.ZodEffects<z.ZodUnion<[z.ZodOptional<z.ZodString>, z.ZodLiteral<"">]>, string, string | undefined>;
|
||||||
endTime: z.ZodUnion<[z.ZodOptional<z.ZodString>, z.ZodLiteral<"">]>;
|
endTime: z.ZodUnion<[z.ZodOptional<z.ZodString>, z.ZodLiteral<"">]>;
|
||||||
repeat: z.ZodOptional<z.ZodEnum<["none", "daily", "weekly", "monthly", "custom"]>>;
|
allDay: z.ZodOptional<z.ZodBoolean>;
|
||||||
repeatInterval: z.ZodOptional<z.ZodNumber>;
|
repeat: z.ZodOptional<z.ZodEffects<z.ZodUnion<[z.ZodEnum<["none", "daily", "weekly", "monthly", "custom"]>, z.ZodLiteral<"">]>, "custom" | "none" | "daily" | "weekly" | "monthly", "" | "custom" | "none" | "daily" | "weekly" | "monthly">>;
|
||||||
|
repeatInterval: z.ZodOptional<z.ZodEffects<z.ZodNumber, number, number>>;
|
||||||
repeatDays: z.ZodOptional<z.ZodString>;
|
repeatDays: z.ZodOptional<z.ZodString>;
|
||||||
seriesId: z.ZodOptional<z.ZodString>;
|
seriesId: z.ZodOptional<z.ZodString>;
|
||||||
reminder: z.ZodOptional<z.ZodEnum<["none", "at_time", "15", "30", "60", "120", "1440"]>>;
|
reminder: z.ZodOptional<z.ZodEnum<["none", "at_time", "15", "30", "60", "120", "1440"]>>;
|
||||||
|
reminders: z.ZodOptional<z.ZodString>;
|
||||||
assigneeId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
assigneeId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
||||||
subtasks: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
subtasks: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||||
title: z.ZodString;
|
title: z.ZodString;
|
||||||
@@ -254,52 +394,73 @@ export declare const pushChangesSchema: z.ZodObject<{
|
|||||||
completed: z.ZodBoolean;
|
completed: z.ZodBoolean;
|
||||||
createdAt: z.ZodNumber;
|
createdAt: z.ZodNumber;
|
||||||
updatedAt: z.ZodNumber;
|
updatedAt: z.ZodNumber;
|
||||||
|
completedAt: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
|
||||||
}, "strip", z.ZodTypeAny, {
|
}, "strip", z.ZodTypeAny, {
|
||||||
id: string;
|
id: string;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
categoryId: string;
|
categoryId: string | null;
|
||||||
title: string;
|
title: string;
|
||||||
completed: boolean;
|
completed: boolean;
|
||||||
|
dueTime: string;
|
||||||
|
subtasks?: {
|
||||||
|
title: string;
|
||||||
|
}[] | undefined;
|
||||||
description?: string | undefined;
|
description?: string | undefined;
|
||||||
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
|
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
|
||||||
dueDate?: number | undefined;
|
dueDate?: number | undefined;
|
||||||
dueTime?: string | null | undefined;
|
|
||||||
endTime?: string | undefined;
|
endTime?: string | undefined;
|
||||||
|
allDay?: boolean | undefined;
|
||||||
repeat?: "custom" | "none" | "daily" | "weekly" | "monthly" | undefined;
|
repeat?: "custom" | "none" | "daily" | "weekly" | "monthly" | undefined;
|
||||||
repeatInterval?: number | undefined;
|
repeatInterval?: number | undefined;
|
||||||
repeatDays?: string | undefined;
|
repeatDays?: string | undefined;
|
||||||
seriesId?: string | undefined;
|
seriesId?: string | undefined;
|
||||||
assigneeId?: string | null | undefined;
|
assigneeId?: string | null | undefined;
|
||||||
reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined;
|
reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined;
|
||||||
subtasks?: {
|
reminders?: string | undefined;
|
||||||
title: string;
|
completedAt?: number | null | undefined;
|
||||||
}[] | undefined;
|
|
||||||
}, {
|
}, {
|
||||||
id: string;
|
id: string;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
categoryId: string;
|
categoryId: string | null;
|
||||||
title: string;
|
title: string;
|
||||||
completed: boolean;
|
completed: boolean;
|
||||||
|
subtasks?: {
|
||||||
|
title: string;
|
||||||
|
}[] | undefined;
|
||||||
description?: string | undefined;
|
description?: string | undefined;
|
||||||
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
|
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
|
||||||
dueDate?: number | undefined;
|
dueDate?: number | undefined;
|
||||||
dueTime?: string | null | undefined;
|
dueTime?: string | undefined;
|
||||||
endTime?: string | undefined;
|
endTime?: string | undefined;
|
||||||
repeat?: "custom" | "none" | "daily" | "weekly" | "monthly" | undefined;
|
allDay?: boolean | undefined;
|
||||||
|
repeat?: "" | "custom" | "none" | "daily" | "weekly" | "monthly" | undefined;
|
||||||
repeatInterval?: number | undefined;
|
repeatInterval?: number | undefined;
|
||||||
repeatDays?: string | undefined;
|
repeatDays?: string | undefined;
|
||||||
seriesId?: string | undefined;
|
seriesId?: string | undefined;
|
||||||
assigneeId?: string | null | undefined;
|
assigneeId?: string | null | undefined;
|
||||||
reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined;
|
reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined;
|
||||||
subtasks?: {
|
reminders?: string | undefined;
|
||||||
title: string;
|
completedAt?: number | null | undefined;
|
||||||
}[] | undefined;
|
|
||||||
}>, "many">>;
|
}>, "many">>;
|
||||||
subtasks: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
subtasks: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||||
title: z.ZodString;
|
title: z.ZodString;
|
||||||
|
description: z.ZodOptional<z.ZodString>;
|
||||||
|
priority: z.ZodOptional<z.ZodEnum<["none", "low", "medium", "high", "critical"]>>;
|
||||||
|
dueDate: z.ZodOptional<z.ZodNumber>;
|
||||||
|
dueTime: z.ZodEffects<z.ZodUnion<[z.ZodOptional<z.ZodString>, z.ZodLiteral<"">]>, string, string | undefined>;
|
||||||
|
endTime: z.ZodUnion<[z.ZodOptional<z.ZodString>, z.ZodLiteral<"">]>;
|
||||||
|
allDay: z.ZodOptional<z.ZodBoolean>;
|
||||||
|
repeat: z.ZodOptional<z.ZodEffects<z.ZodUnion<[z.ZodEnum<["none", "daily", "weekly", "monthly", "custom"]>, z.ZodLiteral<"">]>, "custom" | "none" | "daily" | "weekly" | "monthly", "" | "custom" | "none" | "daily" | "weekly" | "monthly">>;
|
||||||
|
repeatInterval: z.ZodOptional<z.ZodEffects<z.ZodNumber, number, number>>;
|
||||||
|
repeatDays: z.ZodOptional<z.ZodString>;
|
||||||
|
seriesId: z.ZodOptional<z.ZodString>;
|
||||||
|
reminder: z.ZodOptional<z.ZodEnum<["none", "at_time", "15", "30", "60", "120", "1440"]>>;
|
||||||
|
reminders: z.ZodOptional<z.ZodString>;
|
||||||
|
assigneeId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
||||||
order: z.ZodOptional<z.ZodNumber>;
|
order: z.ZodOptional<z.ZodNumber>;
|
||||||
|
parentSubtaskId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
||||||
} & {
|
} & {
|
||||||
id: z.ZodString;
|
id: z.ZodString;
|
||||||
taskId: z.ZodString;
|
taskId: z.ZodString;
|
||||||
@@ -312,8 +473,22 @@ export declare const pushChangesSchema: z.ZodObject<{
|
|||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
title: string;
|
title: string;
|
||||||
completed: boolean;
|
completed: boolean;
|
||||||
|
dueTime: string;
|
||||||
taskId: string;
|
taskId: string;
|
||||||
order?: number | undefined;
|
order?: number | undefined;
|
||||||
|
description?: string | undefined;
|
||||||
|
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
|
||||||
|
dueDate?: number | undefined;
|
||||||
|
endTime?: string | undefined;
|
||||||
|
allDay?: boolean | undefined;
|
||||||
|
repeat?: "custom" | "none" | "daily" | "weekly" | "monthly" | undefined;
|
||||||
|
repeatInterval?: number | undefined;
|
||||||
|
repeatDays?: string | undefined;
|
||||||
|
seriesId?: string | undefined;
|
||||||
|
assigneeId?: string | null | undefined;
|
||||||
|
reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined;
|
||||||
|
reminders?: string | undefined;
|
||||||
|
parentSubtaskId?: string | null | undefined;
|
||||||
}, {
|
}, {
|
||||||
id: string;
|
id: string;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
@@ -322,6 +497,20 @@ export declare const pushChangesSchema: z.ZodObject<{
|
|||||||
completed: boolean;
|
completed: boolean;
|
||||||
taskId: string;
|
taskId: string;
|
||||||
order?: number | undefined;
|
order?: number | undefined;
|
||||||
|
description?: string | undefined;
|
||||||
|
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
|
||||||
|
dueDate?: number | undefined;
|
||||||
|
dueTime?: string | undefined;
|
||||||
|
endTime?: string | undefined;
|
||||||
|
allDay?: boolean | undefined;
|
||||||
|
repeat?: "" | "custom" | "none" | "daily" | "weekly" | "monthly" | undefined;
|
||||||
|
repeatInterval?: number | undefined;
|
||||||
|
repeatDays?: string | undefined;
|
||||||
|
seriesId?: string | undefined;
|
||||||
|
assigneeId?: string | null | undefined;
|
||||||
|
reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined;
|
||||||
|
reminders?: string | undefined;
|
||||||
|
parentSubtaskId?: string | null | undefined;
|
||||||
}>, "many">>;
|
}>, "many">>;
|
||||||
repeatProfiles: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
repeatProfiles: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||||
name: z.ZodString;
|
name: z.ZodString;
|
||||||
@@ -372,14 +561,6 @@ export declare const pushChangesSchema: z.ZodObject<{
|
|||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
}>, "many">>;
|
}>, "many">>;
|
||||||
}, "strip", z.ZodTypeAny, {
|
}, "strip", z.ZodTypeAny, {
|
||||||
friendships?: {
|
|
||||||
id: string;
|
|
||||||
createdAt: number;
|
|
||||||
userId: string;
|
|
||||||
friendId: string;
|
|
||||||
status: "pending" | "accepted";
|
|
||||||
updatedAt: number;
|
|
||||||
}[] | undefined;
|
|
||||||
categories?: {
|
categories?: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -392,23 +573,26 @@ export declare const pushChangesSchema: z.ZodObject<{
|
|||||||
id: string;
|
id: string;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
categoryId: string;
|
categoryId: string | null;
|
||||||
title: string;
|
title: string;
|
||||||
completed: boolean;
|
completed: boolean;
|
||||||
|
dueTime: string;
|
||||||
|
subtasks?: {
|
||||||
|
title: string;
|
||||||
|
}[] | undefined;
|
||||||
description?: string | undefined;
|
description?: string | undefined;
|
||||||
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
|
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
|
||||||
dueDate?: number | undefined;
|
dueDate?: number | undefined;
|
||||||
dueTime?: string | null | undefined;
|
|
||||||
endTime?: string | undefined;
|
endTime?: string | undefined;
|
||||||
|
allDay?: boolean | undefined;
|
||||||
repeat?: "custom" | "none" | "daily" | "weekly" | "monthly" | undefined;
|
repeat?: "custom" | "none" | "daily" | "weekly" | "monthly" | undefined;
|
||||||
repeatInterval?: number | undefined;
|
repeatInterval?: number | undefined;
|
||||||
repeatDays?: string | undefined;
|
repeatDays?: string | undefined;
|
||||||
seriesId?: string | undefined;
|
seriesId?: string | undefined;
|
||||||
assigneeId?: string | null | undefined;
|
assigneeId?: string | null | undefined;
|
||||||
reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined;
|
reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined;
|
||||||
subtasks?: {
|
reminders?: string | undefined;
|
||||||
title: string;
|
completedAt?: number | null | undefined;
|
||||||
}[] | undefined;
|
|
||||||
}[] | undefined;
|
}[] | undefined;
|
||||||
subtasks?: {
|
subtasks?: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -416,8 +600,22 @@ export declare const pushChangesSchema: z.ZodObject<{
|
|||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
title: string;
|
title: string;
|
||||||
completed: boolean;
|
completed: boolean;
|
||||||
|
dueTime: string;
|
||||||
taskId: string;
|
taskId: string;
|
||||||
order?: number | undefined;
|
order?: number | undefined;
|
||||||
|
description?: string | undefined;
|
||||||
|
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
|
||||||
|
dueDate?: number | undefined;
|
||||||
|
endTime?: string | undefined;
|
||||||
|
allDay?: boolean | undefined;
|
||||||
|
repeat?: "custom" | "none" | "daily" | "weekly" | "monthly" | undefined;
|
||||||
|
repeatInterval?: number | undefined;
|
||||||
|
repeatDays?: string | undefined;
|
||||||
|
seriesId?: string | undefined;
|
||||||
|
assigneeId?: string | null | undefined;
|
||||||
|
reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined;
|
||||||
|
reminders?: string | undefined;
|
||||||
|
parentSubtaskId?: string | null | undefined;
|
||||||
}[] | undefined;
|
}[] | undefined;
|
||||||
repeatProfiles?: {
|
repeatProfiles?: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -428,15 +626,15 @@ export declare const pushChangesSchema: z.ZodObject<{
|
|||||||
repeatInterval: number;
|
repeatInterval: number;
|
||||||
repeatDays: string;
|
repeatDays: string;
|
||||||
}[] | undefined;
|
}[] | undefined;
|
||||||
|
friendships?: {
|
||||||
|
id: string;
|
||||||
|
createdAt: number;
|
||||||
|
userId: string;
|
||||||
|
friendId: string;
|
||||||
|
status: "pending" | "accepted";
|
||||||
|
updatedAt: number;
|
||||||
|
}[] | undefined;
|
||||||
}, {
|
}, {
|
||||||
friendships?: {
|
|
||||||
id: string;
|
|
||||||
createdAt: number;
|
|
||||||
userId: string;
|
|
||||||
friendId: string;
|
|
||||||
status: "pending" | "accepted";
|
|
||||||
updatedAt: number;
|
|
||||||
}[] | undefined;
|
|
||||||
categories?: {
|
categories?: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -449,23 +647,26 @@ export declare const pushChangesSchema: z.ZodObject<{
|
|||||||
id: string;
|
id: string;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
categoryId: string;
|
categoryId: string | null;
|
||||||
title: string;
|
title: string;
|
||||||
completed: boolean;
|
completed: boolean;
|
||||||
|
subtasks?: {
|
||||||
|
title: string;
|
||||||
|
}[] | undefined;
|
||||||
description?: string | undefined;
|
description?: string | undefined;
|
||||||
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
|
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
|
||||||
dueDate?: number | undefined;
|
dueDate?: number | undefined;
|
||||||
dueTime?: string | null | undefined;
|
dueTime?: string | undefined;
|
||||||
endTime?: string | undefined;
|
endTime?: string | undefined;
|
||||||
repeat?: "custom" | "none" | "daily" | "weekly" | "monthly" | undefined;
|
allDay?: boolean | undefined;
|
||||||
|
repeat?: "" | "custom" | "none" | "daily" | "weekly" | "monthly" | undefined;
|
||||||
repeatInterval?: number | undefined;
|
repeatInterval?: number | undefined;
|
||||||
repeatDays?: string | undefined;
|
repeatDays?: string | undefined;
|
||||||
seriesId?: string | undefined;
|
seriesId?: string | undefined;
|
||||||
assigneeId?: string | null | undefined;
|
assigneeId?: string | null | undefined;
|
||||||
reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined;
|
reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined;
|
||||||
subtasks?: {
|
reminders?: string | undefined;
|
||||||
title: string;
|
completedAt?: number | null | undefined;
|
||||||
}[] | undefined;
|
|
||||||
}[] | undefined;
|
}[] | undefined;
|
||||||
subtasks?: {
|
subtasks?: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -475,6 +676,20 @@ export declare const pushChangesSchema: z.ZodObject<{
|
|||||||
completed: boolean;
|
completed: boolean;
|
||||||
taskId: string;
|
taskId: string;
|
||||||
order?: number | undefined;
|
order?: number | undefined;
|
||||||
|
description?: string | undefined;
|
||||||
|
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
|
||||||
|
dueDate?: number | undefined;
|
||||||
|
dueTime?: string | undefined;
|
||||||
|
endTime?: string | undefined;
|
||||||
|
allDay?: boolean | undefined;
|
||||||
|
repeat?: "" | "custom" | "none" | "daily" | "weekly" | "monthly" | undefined;
|
||||||
|
repeatInterval?: number | undefined;
|
||||||
|
repeatDays?: string | undefined;
|
||||||
|
seriesId?: string | undefined;
|
||||||
|
assigneeId?: string | null | undefined;
|
||||||
|
reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined;
|
||||||
|
reminders?: string | undefined;
|
||||||
|
parentSubtaskId?: string | null | undefined;
|
||||||
}[] | undefined;
|
}[] | undefined;
|
||||||
repeatProfiles?: {
|
repeatProfiles?: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -485,18 +700,31 @@ export declare const pushChangesSchema: z.ZodObject<{
|
|||||||
repeatInterval: number;
|
repeatInterval: number;
|
||||||
repeatDays: string;
|
repeatDays: string;
|
||||||
}[] | undefined;
|
}[] | undefined;
|
||||||
|
friendships?: {
|
||||||
|
id: string;
|
||||||
|
createdAt: number;
|
||||||
|
userId: string;
|
||||||
|
friendId: string;
|
||||||
|
status: "pending" | "accepted";
|
||||||
|
updatedAt: number;
|
||||||
|
}[] | undefined;
|
||||||
}>;
|
}>;
|
||||||
|
deleted: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||||
|
entity: z.ZodEnum<["categories", "tasks", "subtasks", "repeatProfiles", "friendships"]>;
|
||||||
|
id: z.ZodString;
|
||||||
|
updatedAt: z.ZodNumber;
|
||||||
|
}, "strip", z.ZodTypeAny, {
|
||||||
|
id: string;
|
||||||
|
updatedAt: number;
|
||||||
|
entity: "categories" | "tasks" | "subtasks" | "repeatProfiles" | "friendships";
|
||||||
|
}, {
|
||||||
|
id: string;
|
||||||
|
updatedAt: number;
|
||||||
|
entity: "categories" | "tasks" | "subtasks" | "repeatProfiles" | "friendships";
|
||||||
|
}>, "many">>;
|
||||||
lastPulledAt: z.ZodNumber;
|
lastPulledAt: z.ZodNumber;
|
||||||
}, "strip", z.ZodTypeAny, {
|
}, "strip", z.ZodTypeAny, {
|
||||||
changes: {
|
changes: {
|
||||||
friendships?: {
|
|
||||||
id: string;
|
|
||||||
createdAt: number;
|
|
||||||
userId: string;
|
|
||||||
friendId: string;
|
|
||||||
status: "pending" | "accepted";
|
|
||||||
updatedAt: number;
|
|
||||||
}[] | undefined;
|
|
||||||
categories?: {
|
categories?: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -509,23 +737,26 @@ export declare const pushChangesSchema: z.ZodObject<{
|
|||||||
id: string;
|
id: string;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
categoryId: string;
|
categoryId: string | null;
|
||||||
title: string;
|
title: string;
|
||||||
completed: boolean;
|
completed: boolean;
|
||||||
|
dueTime: string;
|
||||||
|
subtasks?: {
|
||||||
|
title: string;
|
||||||
|
}[] | undefined;
|
||||||
description?: string | undefined;
|
description?: string | undefined;
|
||||||
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
|
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
|
||||||
dueDate?: number | undefined;
|
dueDate?: number | undefined;
|
||||||
dueTime?: string | null | undefined;
|
|
||||||
endTime?: string | undefined;
|
endTime?: string | undefined;
|
||||||
|
allDay?: boolean | undefined;
|
||||||
repeat?: "custom" | "none" | "daily" | "weekly" | "monthly" | undefined;
|
repeat?: "custom" | "none" | "daily" | "weekly" | "monthly" | undefined;
|
||||||
repeatInterval?: number | undefined;
|
repeatInterval?: number | undefined;
|
||||||
repeatDays?: string | undefined;
|
repeatDays?: string | undefined;
|
||||||
seriesId?: string | undefined;
|
seriesId?: string | undefined;
|
||||||
assigneeId?: string | null | undefined;
|
assigneeId?: string | null | undefined;
|
||||||
reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined;
|
reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined;
|
||||||
subtasks?: {
|
reminders?: string | undefined;
|
||||||
title: string;
|
completedAt?: number | null | undefined;
|
||||||
}[] | undefined;
|
|
||||||
}[] | undefined;
|
}[] | undefined;
|
||||||
subtasks?: {
|
subtasks?: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -533,8 +764,22 @@ export declare const pushChangesSchema: z.ZodObject<{
|
|||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
title: string;
|
title: string;
|
||||||
completed: boolean;
|
completed: boolean;
|
||||||
|
dueTime: string;
|
||||||
taskId: string;
|
taskId: string;
|
||||||
order?: number | undefined;
|
order?: number | undefined;
|
||||||
|
description?: string | undefined;
|
||||||
|
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
|
||||||
|
dueDate?: number | undefined;
|
||||||
|
endTime?: string | undefined;
|
||||||
|
allDay?: boolean | undefined;
|
||||||
|
repeat?: "custom" | "none" | "daily" | "weekly" | "monthly" | undefined;
|
||||||
|
repeatInterval?: number | undefined;
|
||||||
|
repeatDays?: string | undefined;
|
||||||
|
seriesId?: string | undefined;
|
||||||
|
assigneeId?: string | null | undefined;
|
||||||
|
reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined;
|
||||||
|
reminders?: string | undefined;
|
||||||
|
parentSubtaskId?: string | null | undefined;
|
||||||
}[] | undefined;
|
}[] | undefined;
|
||||||
repeatProfiles?: {
|
repeatProfiles?: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -545,18 +790,23 @@ export declare const pushChangesSchema: z.ZodObject<{
|
|||||||
repeatInterval: number;
|
repeatInterval: number;
|
||||||
repeatDays: string;
|
repeatDays: string;
|
||||||
}[] | undefined;
|
}[] | undefined;
|
||||||
|
friendships?: {
|
||||||
|
id: string;
|
||||||
|
createdAt: number;
|
||||||
|
userId: string;
|
||||||
|
friendId: string;
|
||||||
|
status: "pending" | "accepted";
|
||||||
|
updatedAt: number;
|
||||||
|
}[] | undefined;
|
||||||
};
|
};
|
||||||
lastPulledAt: number;
|
lastPulledAt: number;
|
||||||
|
deleted?: {
|
||||||
|
id: string;
|
||||||
|
updatedAt: number;
|
||||||
|
entity: "categories" | "tasks" | "subtasks" | "repeatProfiles" | "friendships";
|
||||||
|
}[] | undefined;
|
||||||
}, {
|
}, {
|
||||||
changes: {
|
changes: {
|
||||||
friendships?: {
|
|
||||||
id: string;
|
|
||||||
createdAt: number;
|
|
||||||
userId: string;
|
|
||||||
friendId: string;
|
|
||||||
status: "pending" | "accepted";
|
|
||||||
updatedAt: number;
|
|
||||||
}[] | undefined;
|
|
||||||
categories?: {
|
categories?: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -569,23 +819,26 @@ export declare const pushChangesSchema: z.ZodObject<{
|
|||||||
id: string;
|
id: string;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
categoryId: string;
|
categoryId: string | null;
|
||||||
title: string;
|
title: string;
|
||||||
completed: boolean;
|
completed: boolean;
|
||||||
|
subtasks?: {
|
||||||
|
title: string;
|
||||||
|
}[] | undefined;
|
||||||
description?: string | undefined;
|
description?: string | undefined;
|
||||||
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
|
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
|
||||||
dueDate?: number | undefined;
|
dueDate?: number | undefined;
|
||||||
dueTime?: string | null | undefined;
|
dueTime?: string | undefined;
|
||||||
endTime?: string | undefined;
|
endTime?: string | undefined;
|
||||||
repeat?: "custom" | "none" | "daily" | "weekly" | "monthly" | undefined;
|
allDay?: boolean | undefined;
|
||||||
|
repeat?: "" | "custom" | "none" | "daily" | "weekly" | "monthly" | undefined;
|
||||||
repeatInterval?: number | undefined;
|
repeatInterval?: number | undefined;
|
||||||
repeatDays?: string | undefined;
|
repeatDays?: string | undefined;
|
||||||
seriesId?: string | undefined;
|
seriesId?: string | undefined;
|
||||||
assigneeId?: string | null | undefined;
|
assigneeId?: string | null | undefined;
|
||||||
reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined;
|
reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined;
|
||||||
subtasks?: {
|
reminders?: string | undefined;
|
||||||
title: string;
|
completedAt?: number | null | undefined;
|
||||||
}[] | undefined;
|
|
||||||
}[] | undefined;
|
}[] | undefined;
|
||||||
subtasks?: {
|
subtasks?: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -595,6 +848,20 @@ export declare const pushChangesSchema: z.ZodObject<{
|
|||||||
completed: boolean;
|
completed: boolean;
|
||||||
taskId: string;
|
taskId: string;
|
||||||
order?: number | undefined;
|
order?: number | undefined;
|
||||||
|
description?: string | undefined;
|
||||||
|
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
|
||||||
|
dueDate?: number | undefined;
|
||||||
|
dueTime?: string | undefined;
|
||||||
|
endTime?: string | undefined;
|
||||||
|
allDay?: boolean | undefined;
|
||||||
|
repeat?: "" | "custom" | "none" | "daily" | "weekly" | "monthly" | undefined;
|
||||||
|
repeatInterval?: number | undefined;
|
||||||
|
repeatDays?: string | undefined;
|
||||||
|
seriesId?: string | undefined;
|
||||||
|
assigneeId?: string | null | undefined;
|
||||||
|
reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined;
|
||||||
|
reminders?: string | undefined;
|
||||||
|
parentSubtaskId?: string | null | undefined;
|
||||||
}[] | undefined;
|
}[] | undefined;
|
||||||
repeatProfiles?: {
|
repeatProfiles?: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -605,8 +872,21 @@ export declare const pushChangesSchema: z.ZodObject<{
|
|||||||
repeatInterval: number;
|
repeatInterval: number;
|
||||||
repeatDays: string;
|
repeatDays: string;
|
||||||
}[] | undefined;
|
}[] | undefined;
|
||||||
|
friendships?: {
|
||||||
|
id: string;
|
||||||
|
createdAt: number;
|
||||||
|
userId: string;
|
||||||
|
friendId: string;
|
||||||
|
status: "pending" | "accepted";
|
||||||
|
updatedAt: number;
|
||||||
|
}[] | undefined;
|
||||||
};
|
};
|
||||||
lastPulledAt: number;
|
lastPulledAt: number;
|
||||||
|
deleted?: {
|
||||||
|
id: string;
|
||||||
|
updatedAt: number;
|
||||||
|
entity: "categories" | "tasks" | "subtasks" | "repeatProfiles" | "friendships";
|
||||||
|
}[] | undefined;
|
||||||
}>;
|
}>;
|
||||||
export declare const syncQuerySchema: z.ZodObject<{
|
export declare const syncQuerySchema: z.ZodObject<{
|
||||||
since: z.ZodPipeline<z.ZodEffects<z.ZodString, number, string>, z.ZodNumber>;
|
since: z.ZodPipeline<z.ZodEffects<z.ZodString, number, string>, z.ZodNumber>;
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
{"version":3,"file":"validation.d.ts","sourceRoot":"","sources":["../../src/utils/validation.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,eAAO,MAAM,oBAAoB;;;;;;;;;;;;EAI/B,CAAC;AAEH,eAAO,MAAM,oBAAoB;;;;;;;;;;;;EAI/B,CAAC;AAEH,eAAO,MAAM,YAAY,6DAA2D,CAAC;AAErF,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAe3B,CAAC;AAEH,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAU3B,CAAC;AAEH,eAAO,MAAM,mBAAmB;;;;;;;;;EAG9B,CAAC;AAEH,eAAO,MAAM,mBAAmB;;;;;;;;;;;;EAI9B,CAAC;AAEH,eAAO,MAAM,kBAAkB;;;;;;;;;;;;;;;;;;;;;EAO7B,CAAC;AAEH,eAAO,MAAM,mBAAmB;;;;;;;;;;;;;;;EAK9B,CAAC;AAEH,eAAO,MAAM,mBAAmB;;;;;;EAE9B,CAAC;AAEH,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;;;EAO3B,CAAC;AAEH,eAAO,MAAM,iBAAiB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAS5B,CAAC;AAEH,eAAO,MAAM,eAAe;;;;;;EAE1B,CAAC;AAEH,eAAO,MAAM,eAAe;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAU1B,CAAC"}
|
{"version":3,"file":"validation.d.ts","sourceRoot":"","sources":["../../src/utils/validation.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAKxD;AAED,eAAO,MAAM,oBAAoB;;;;;;;;;;;;EAI/B,CAAC;AAEH,eAAO,MAAM,oBAAoB;;;;;;;;;;;;EAI/B,CAAC;AAEH,eAAO,MAAM,YAAY,6DAA2D,CAAC;AAIrF,eAAO,MAAM,iBAAiB,uOAGjB,CAAC;AACd,eAAO,MAAM,yBAAyB,0DAMzB,CAAC;AAEd,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAkB3B,CAAC;AAEH,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAY3B,CAAC;AAEH,eAAO,MAAM,mBAAmB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAiB9B,CAAC;AAEH,eAAO,MAAM,mBAAmB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAkB9B,CAAC;AAEH,eAAO,MAAM,kBAAkB;;;;;;;;;;;;;;;;;;;;;EAO7B,CAAC;AAEH,eAAO,MAAM,mBAAmB;;;;;;;;;;;;;;;EAK9B,CAAC;AAEH,eAAO,MAAM,yBAAyB;;;;;;;;;;;;;;;EAKpC,CAAC;AAEH,eAAO,MAAM,iBAAiB;;;;;;EAE5B,CAAC;AAEH,eAAO,MAAM,mBAAmB;;;;;;EAE9B,CAAC;AAEH,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;;;EAO3B,CAAC;AAEH,eAAO,MAAM,eAAe;;;;;;;;;;;;EAI1B,CAAC;AAEH,eAAO,MAAM,iBAAiB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAU5B,CAAC;AAEH,eAAO,MAAM,eAAe;;;;;;EAE1B,CAAC;AAEH,eAAO,MAAM,eAAe;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAU1B,CAAC"}
|
||||||
Vendored
+77
-8
@@ -1,7 +1,15 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
exports.taskQuerySchema = exports.syncQuerySchema = exports.pushChangesSchema = exports.friendshipSchema = exports.friendRequestSchema = exports.repeatProfileSchema = exports.userSettingsSchema = exports.subtaskUpdateSchema = exports.subtaskCreateSchema = exports.taskUpdateSchema = exports.taskCreateSchema = exports.repeatSchema = exports.categoryUpdateSchema = exports.categoryCreateSchema = void 0;
|
exports.taskQuerySchema = exports.syncQuerySchema = exports.pushChangesSchema = exports.tombstoneSchema = exports.friendshipSchema = exports.friendRequestSchema = exports.searchQuerySchema = exports.repeatProfileUpdateSchema = exports.repeatProfileSchema = exports.userSettingsSchema = exports.subtaskUpdateSchema = exports.subtaskCreateSchema = exports.taskUpdateSchema = exports.taskCreateSchema = exports.repeatIntervalFieldSchema = exports.repeatFieldSchema = exports.repeatSchema = exports.categoryUpdateSchema = exports.categoryCreateSchema = void 0;
|
||||||
|
exports.canCompleteTask = canCompleteTask;
|
||||||
const zod_1 = require("zod");
|
const zod_1 = require("zod");
|
||||||
|
function canCompleteTask(dueDate) {
|
||||||
|
if (dueDate === 0)
|
||||||
|
return true;
|
||||||
|
const now = new Date();
|
||||||
|
const endOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999).getTime();
|
||||||
|
return dueDate <= endOfToday;
|
||||||
|
}
|
||||||
exports.categoryCreateSchema = zod_1.z.object({
|
exports.categoryCreateSchema = zod_1.z.object({
|
||||||
name: zod_1.z.string().min(1).max(50),
|
name: zod_1.z.string().min(1).max(50),
|
||||||
color: zod_1.z.string().regex(/^#[0-9A-Fa-f]{6}$/),
|
color: zod_1.z.string().regex(/^#[0-9A-Fa-f]{6}$/),
|
||||||
@@ -13,41 +21,87 @@ exports.categoryUpdateSchema = zod_1.z.object({
|
|||||||
order: zod_1.z.number().int().min(0).optional(),
|
order: zod_1.z.number().int().min(0).optional(),
|
||||||
});
|
});
|
||||||
exports.repeatSchema = zod_1.z.enum(['none', 'daily', 'weekly', 'monthly', 'custom']);
|
exports.repeatSchema = zod_1.z.enum(['none', 'daily', 'weekly', 'monthly', 'custom']);
|
||||||
|
// The client stores '' / 0 for unset repeat values; normalize them so stale or
|
||||||
|
// legacy rows never fail validation on push.
|
||||||
|
exports.repeatFieldSchema = exports.repeatSchema
|
||||||
|
.or(zod_1.z.literal(''))
|
||||||
|
.transform((v) => (v === '' ? 'none' : v))
|
||||||
|
.optional();
|
||||||
|
exports.repeatIntervalFieldSchema = zod_1.z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(0)
|
||||||
|
.max(30)
|
||||||
|
.transform((v) => Math.max(1, v))
|
||||||
|
.optional();
|
||||||
exports.taskCreateSchema = zod_1.z.object({
|
exports.taskCreateSchema = zod_1.z.object({
|
||||||
title: zod_1.z.string().min(1).max(100),
|
title: zod_1.z.string().min(1).max(100),
|
||||||
description: zod_1.z.string().max(1000).optional(),
|
description: zod_1.z.string().max(1000).optional(),
|
||||||
categoryId: zod_1.z.string().min(1),
|
categoryId: zod_1.z.string().max(100).transform((v) => (v === '' ? null : v)).nullable(),
|
||||||
priority: zod_1.z.enum(['none', 'low', 'medium', 'high', 'critical']).optional(),
|
priority: zod_1.z.enum(['none', 'low', 'medium', 'high', 'critical']).optional(),
|
||||||
dueDate: zod_1.z.number().int().min(0).optional(),
|
dueDate: zod_1.z.number().int().min(0).optional(),
|
||||||
dueTime: zod_1.z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().nullable(),
|
dueTime: zod_1.z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().or(zod_1.z.literal('')).transform(v => v ?? ''),
|
||||||
endTime: zod_1.z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().or(zod_1.z.literal('')),
|
endTime: zod_1.z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().or(zod_1.z.literal('')),
|
||||||
repeat: exports.repeatSchema.optional(),
|
allDay: zod_1.z.boolean().optional(),
|
||||||
repeatInterval: zod_1.z.number().int().min(1).max(30).optional(),
|
repeat: exports.repeatFieldSchema,
|
||||||
|
repeatInterval: exports.repeatIntervalFieldSchema,
|
||||||
repeatDays: zod_1.z.string().max(20).optional(),
|
repeatDays: zod_1.z.string().max(20).optional(),
|
||||||
seriesId: zod_1.z.string().max(50).optional(),
|
seriesId: zod_1.z.string().max(50).optional(),
|
||||||
reminder: zod_1.z.enum(['none', 'at_time', '15', '30', '60', '120', '1440']).optional(),
|
reminder: zod_1.z.enum(['none', 'at_time', '15', '30', '60', '120', '1440']).optional(),
|
||||||
|
reminders: zod_1.z.string().max(100).optional(),
|
||||||
assigneeId: zod_1.z.string().nullable().optional(),
|
assigneeId: zod_1.z.string().nullable().optional(),
|
||||||
|
completedAt: zod_1.z.number().int().min(0).nullable().optional(),
|
||||||
subtasks: zod_1.z.array(zod_1.z.object({ title: zod_1.z.string().min(1).max(100) })).optional(),
|
subtasks: zod_1.z.array(zod_1.z.object({ title: zod_1.z.string().min(1).max(100) })).optional(),
|
||||||
});
|
});
|
||||||
exports.taskUpdateSchema = zod_1.z.object({
|
exports.taskUpdateSchema = zod_1.z.object({
|
||||||
title: zod_1.z.string().min(1).max(100).optional(),
|
title: zod_1.z.string().min(1).max(100).optional(),
|
||||||
description: zod_1.z.string().max(1000).optional(),
|
description: zod_1.z.string().max(1000).optional(),
|
||||||
categoryId: zod_1.z.string().min(1).optional(),
|
categoryId: zod_1.z.string().max(100).nullable().optional().transform((v) => (v === '' ? null : v)),
|
||||||
priority: zod_1.z.enum(['none', 'low', 'medium', 'high', 'critical']).optional(),
|
priority: zod_1.z.enum(['none', 'low', 'medium', 'high', 'critical']).optional(),
|
||||||
completed: zod_1.z.boolean().optional(),
|
completed: zod_1.z.boolean().optional(),
|
||||||
dueDate: zod_1.z.number().int().min(0).optional(),
|
dueDate: zod_1.z.number().int().min(0).optional(),
|
||||||
dueTime: zod_1.z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().nullable(),
|
dueTime: zod_1.z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().or(zod_1.z.literal('')).transform(v => v === '' ? undefined : v),
|
||||||
endTime: zod_1.z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional(),
|
endTime: zod_1.z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional(),
|
||||||
|
reminders: zod_1.z.string().max(100).optional(),
|
||||||
assigneeId: zod_1.z.string().nullable().optional(),
|
assigneeId: zod_1.z.string().nullable().optional(),
|
||||||
|
completedAt: zod_1.z.number().int().min(0).nullable().optional(),
|
||||||
});
|
});
|
||||||
exports.subtaskCreateSchema = zod_1.z.object({
|
exports.subtaskCreateSchema = zod_1.z.object({
|
||||||
title: zod_1.z.string().min(1).max(100),
|
title: zod_1.z.string().min(1).max(100),
|
||||||
|
description: zod_1.z.string().max(1000).optional(),
|
||||||
|
priority: zod_1.z.enum(['none', 'low', 'medium', 'high', 'critical']).optional(),
|
||||||
|
dueDate: zod_1.z.number().int().min(0).optional(),
|
||||||
|
dueTime: zod_1.z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().or(zod_1.z.literal('')).transform(v => v ?? ''),
|
||||||
|
endTime: zod_1.z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().or(zod_1.z.literal('')),
|
||||||
|
allDay: zod_1.z.boolean().optional(),
|
||||||
|
repeat: exports.repeatFieldSchema,
|
||||||
|
repeatInterval: exports.repeatIntervalFieldSchema,
|
||||||
|
repeatDays: zod_1.z.string().max(20).optional(),
|
||||||
|
seriesId: zod_1.z.string().max(50).optional(),
|
||||||
|
reminder: zod_1.z.enum(['none', 'at_time', '15', '30', '60', '120', '1440']).optional(),
|
||||||
|
reminders: zod_1.z.string().max(100).optional(),
|
||||||
|
assigneeId: zod_1.z.string().nullable().optional(),
|
||||||
order: zod_1.z.number().int().min(0).optional(),
|
order: zod_1.z.number().int().min(0).optional(),
|
||||||
|
parentSubtaskId: zod_1.z.string().nullable().optional(),
|
||||||
});
|
});
|
||||||
exports.subtaskUpdateSchema = zod_1.z.object({
|
exports.subtaskUpdateSchema = zod_1.z.object({
|
||||||
title: zod_1.z.string().min(1).max(100).optional(),
|
title: zod_1.z.string().min(1).max(100).optional(),
|
||||||
|
description: zod_1.z.string().max(1000).optional(),
|
||||||
|
priority: zod_1.z.enum(['none', 'low', 'medium', 'high', 'critical']).optional(),
|
||||||
completed: zod_1.z.boolean().optional(),
|
completed: zod_1.z.boolean().optional(),
|
||||||
|
dueDate: zod_1.z.number().int().min(0).optional(),
|
||||||
|
dueTime: zod_1.z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().or(zod_1.z.literal('')).transform(v => v === '' ? undefined : v),
|
||||||
|
endTime: zod_1.z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional(),
|
||||||
|
allDay: zod_1.z.boolean().optional(),
|
||||||
|
repeat: exports.repeatFieldSchema,
|
||||||
|
repeatInterval: exports.repeatIntervalFieldSchema,
|
||||||
|
repeatDays: zod_1.z.string().max(20).optional(),
|
||||||
|
seriesId: zod_1.z.string().max(50).optional(),
|
||||||
|
reminder: zod_1.z.enum(['none', 'at_time', '15', '30', '60', '120', '1440']).optional(),
|
||||||
|
reminders: zod_1.z.string().max(100).optional(),
|
||||||
|
assigneeId: zod_1.z.string().nullable().optional(),
|
||||||
order: zod_1.z.number().int().min(0).optional(),
|
order: zod_1.z.number().int().min(0).optional(),
|
||||||
|
parentSubtaskId: zod_1.z.string().nullable().optional(),
|
||||||
});
|
});
|
||||||
exports.userSettingsSchema = zod_1.z.object({
|
exports.userSettingsSchema = zod_1.z.object({
|
||||||
darkMode: zod_1.z.boolean().optional(),
|
darkMode: zod_1.z.boolean().optional(),
|
||||||
@@ -63,6 +117,15 @@ exports.repeatProfileSchema = zod_1.z.object({
|
|||||||
repeatInterval: zod_1.z.number().int().min(1).max(30),
|
repeatInterval: zod_1.z.number().int().min(1).max(30),
|
||||||
repeatDays: zod_1.z.string().max(20),
|
repeatDays: zod_1.z.string().max(20),
|
||||||
});
|
});
|
||||||
|
exports.repeatProfileUpdateSchema = zod_1.z.object({
|
||||||
|
name: zod_1.z.string().min(1).max(50).optional(),
|
||||||
|
repeat: exports.repeatSchema.optional(),
|
||||||
|
repeatInterval: zod_1.z.number().int().min(1).max(30).optional(),
|
||||||
|
repeatDays: zod_1.z.string().max(20).optional(),
|
||||||
|
});
|
||||||
|
exports.searchQuerySchema = zod_1.z.object({
|
||||||
|
q: zod_1.z.string().min(1).max(50),
|
||||||
|
});
|
||||||
exports.friendRequestSchema = zod_1.z.object({
|
exports.friendRequestSchema = zod_1.z.object({
|
||||||
username: zod_1.z.string().min(1).max(50),
|
username: zod_1.z.string().min(1).max(50),
|
||||||
});
|
});
|
||||||
@@ -74,14 +137,20 @@ exports.friendshipSchema = zod_1.z.object({
|
|||||||
createdAt: zod_1.z.number(),
|
createdAt: zod_1.z.number(),
|
||||||
updatedAt: zod_1.z.number(),
|
updatedAt: zod_1.z.number(),
|
||||||
});
|
});
|
||||||
|
exports.tombstoneSchema = zod_1.z.object({
|
||||||
|
entity: zod_1.z.enum(['categories', 'tasks', 'subtasks', 'repeatProfiles', 'friendships']),
|
||||||
|
id: zod_1.z.string(),
|
||||||
|
updatedAt: zod_1.z.number().int().min(0),
|
||||||
|
});
|
||||||
exports.pushChangesSchema = zod_1.z.object({
|
exports.pushChangesSchema = zod_1.z.object({
|
||||||
changes: zod_1.z.object({
|
changes: zod_1.z.object({
|
||||||
categories: zod_1.z.array(exports.categoryCreateSchema.extend({ id: zod_1.z.string(), createdAt: zod_1.z.number(), updatedAt: zod_1.z.number() })).optional(),
|
categories: zod_1.z.array(exports.categoryCreateSchema.extend({ id: zod_1.z.string(), createdAt: zod_1.z.number(), updatedAt: zod_1.z.number() })).optional(),
|
||||||
tasks: zod_1.z.array(exports.taskCreateSchema.extend({ id: zod_1.z.string(), completed: zod_1.z.boolean(), createdAt: zod_1.z.number(), updatedAt: zod_1.z.number() })).optional(),
|
tasks: zod_1.z.array(exports.taskCreateSchema.extend({ id: zod_1.z.string(), completed: zod_1.z.boolean(), createdAt: zod_1.z.number(), updatedAt: zod_1.z.number(), completedAt: zod_1.z.number().int().min(0).nullable().optional() })).optional(),
|
||||||
subtasks: zod_1.z.array(exports.subtaskCreateSchema.extend({ id: zod_1.z.string(), taskId: zod_1.z.string(), completed: zod_1.z.boolean(), createdAt: zod_1.z.number(), updatedAt: zod_1.z.number() })).optional(),
|
subtasks: zod_1.z.array(exports.subtaskCreateSchema.extend({ id: zod_1.z.string(), taskId: zod_1.z.string(), completed: zod_1.z.boolean(), createdAt: zod_1.z.number(), updatedAt: zod_1.z.number() })).optional(),
|
||||||
repeatProfiles: zod_1.z.array(exports.repeatProfileSchema.extend({ id: zod_1.z.string(), createdAt: zod_1.z.number(), updatedAt: zod_1.z.number() })).optional(),
|
repeatProfiles: zod_1.z.array(exports.repeatProfileSchema.extend({ id: zod_1.z.string(), createdAt: zod_1.z.number(), updatedAt: zod_1.z.number() })).optional(),
|
||||||
friendships: zod_1.z.array(exports.friendshipSchema).optional(),
|
friendships: zod_1.z.array(exports.friendshipSchema).optional(),
|
||||||
}),
|
}),
|
||||||
|
deleted: zod_1.z.array(exports.tombstoneSchema).optional(),
|
||||||
lastPulledAt: zod_1.z.number().int().min(0),
|
lastPulledAt: zod_1.z.number().int().min(0),
|
||||||
});
|
});
|
||||||
exports.syncQuerySchema = zod_1.z.object({
|
exports.syncQuerySchema = zod_1.z.object({
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+37
-11
@@ -1,6 +1,9 @@
|
|||||||
import { pgTable, text, integer, bigint, boolean, timestamp, unique, index } from 'drizzle-orm/pg-core';
|
import { pgTable, text, integer, bigint, boolean, unique, index, primaryKey, AnyPgColumn } from 'drizzle-orm/pg-core';
|
||||||
import { sql } from 'drizzle-orm';
|
import { sql } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export const ENTITIES = ['categories', 'tasks', 'subtasks', 'repeatProfiles', 'friendships'] as const;
|
||||||
|
export type EntityName = (typeof ENTITIES)[number];
|
||||||
|
|
||||||
export const users = pgTable('users', {
|
export const users = pgTable('users', {
|
||||||
id: text('id').primaryKey(),
|
id: text('id').primaryKey(),
|
||||||
username: text('username').notNull().unique(),
|
username: text('username').notNull().unique(),
|
||||||
@@ -40,7 +43,8 @@ export const categories = pgTable('categories', {
|
|||||||
export const tasks = pgTable('tasks', {
|
export const tasks = pgTable('tasks', {
|
||||||
id: text('id').primaryKey(),
|
id: text('id').primaryKey(),
|
||||||
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||||
categoryId: text('category_id').notNull().references(() => categories.id, { onDelete: 'cascade' }),
|
categoryId: text('category_id').references(() => categories.id, { onDelete: 'cascade' }),
|
||||||
|
tags: text('tags').notNull().default(''),
|
||||||
title: text('title').notNull(),
|
title: text('title').notNull(),
|
||||||
description: text('description').notNull().default(''),
|
description: text('description').notNull().default(''),
|
||||||
priority: text('priority', { enum: ['none', 'low', 'medium', 'high', 'critical'] }).notNull().default('none'),
|
priority: text('priority', { enum: ['none', 'low', 'medium', 'high', 'critical'] }).notNull().default('none'),
|
||||||
@@ -56,25 +60,32 @@ export const tasks = pgTable('tasks', {
|
|||||||
assigneeId: text('assignee_id').references(() => users.id, { onDelete: 'set null' }),
|
assigneeId: text('assignee_id').references(() => users.id, { onDelete: 'set null' }),
|
||||||
reminder: text('reminder', { enum: ['none', 'at_time', '15', '30', '60', '120', '1440'] }).notNull().default('none'),
|
reminder: text('reminder', { enum: ['none', 'at_time', '15', '30', '60', '120', '1440'] }).notNull().default('none'),
|
||||||
reminders: text('reminders').notNull().default(''),
|
reminders: text('reminders').notNull().default(''),
|
||||||
|
completedAt: bigint('completed_at', { mode: 'number' }),
|
||||||
createdAt: bigint('created_at', { mode: 'number' }).notNull().default(sql`EXTRACT(EPOCH FROM NOW()) * 1000`),
|
createdAt: bigint('created_at', { mode: 'number' }).notNull().default(sql`EXTRACT(EPOCH FROM NOW()) * 1000`),
|
||||||
updatedAt: bigint('updated_at', { mode: 'number' }).notNull().default(sql`EXTRACT(EPOCH FROM NOW()) * 1000`),
|
updatedAt: bigint('updated_at', { mode: 'number' }).notNull().default(sql`EXTRACT(EPOCH FROM NOW()) * 1000`),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const repeatProfiles = pgTable('repeat_profiles', {
|
export const tombstones = pgTable(
|
||||||
id: text('id').primaryKey(),
|
'tombstones',
|
||||||
|
{
|
||||||
|
entity: text('entity', { enum: ENTITIES }).notNull(),
|
||||||
|
entityId: text('entity_id').notNull(),
|
||||||
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||||
name: text('name').notNull(),
|
updatedAt: bigint('updated_at', { mode: 'number' }).notNull(),
|
||||||
repeat: text('repeat', { enum: ['none', 'daily', 'weekly', 'monthly', 'custom'] }).notNull().default('none'),
|
},
|
||||||
repeatInterval: integer('repeat_interval').notNull().default(1),
|
(t) => ({
|
||||||
repeatDays: text('repeat_days').notNull().default(''),
|
pk: primaryKey({ columns: [t.entity, t.entityId] }),
|
||||||
createdAt: bigint('created_at', { mode: 'number' }).notNull().default(sql`EXTRACT(EPOCH FROM NOW()) * 1000`),
|
userIdx: index('tombstones_user_updated_idx').on(t.userId, t.updatedAt),
|
||||||
updatedAt: bigint('updated_at', { mode: 'number' }).notNull().default(sql`EXTRACT(EPOCH FROM NOW()) * 1000`),
|
})
|
||||||
});
|
);
|
||||||
|
|
||||||
|
// Define subtasks with explicit type to avoid circular reference
|
||||||
export const subtasks = pgTable('subtasks', {
|
export const subtasks = pgTable('subtasks', {
|
||||||
id: text('id').primaryKey(),
|
id: text('id').primaryKey(),
|
||||||
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||||
taskId: text('task_id').notNull().references(() => tasks.id, { onDelete: 'cascade' }),
|
taskId: text('task_id').notNull().references(() => tasks.id, { onDelete: 'cascade' }),
|
||||||
|
parentSubtaskId: text('parent_subtask_id').references((): AnyPgColumn => subtasks.id, { onDelete: 'cascade' }),
|
||||||
|
categoryId: text('category_id').references(() => categories.id, { onDelete: 'set null' }),
|
||||||
title: text('title').notNull(),
|
title: text('title').notNull(),
|
||||||
description: text('description').notNull().default(''),
|
description: text('description').notNull().default(''),
|
||||||
priority: text('priority', { enum: ['none', 'low', 'medium', 'high', 'critical'] }).notNull().default('none'),
|
priority: text('priority', { enum: ['none', 'low', 'medium', 'high', 'critical'] }).notNull().default('none'),
|
||||||
@@ -93,6 +104,21 @@ export const subtasks = pgTable('subtasks', {
|
|||||||
order: integer('order').notNull().default(0),
|
order: integer('order').notNull().default(0),
|
||||||
createdAt: bigint('created_at', { mode: 'number' }).notNull().default(sql`EXTRACT(EPOCH FROM NOW()) * 1000`),
|
createdAt: bigint('created_at', { mode: 'number' }).notNull().default(sql`EXTRACT(EPOCH FROM NOW()) * 1000`),
|
||||||
updatedAt: bigint('updated_at', { mode: 'number' }).notNull().default(sql`EXTRACT(EPOCH FROM NOW()) * 1000`),
|
updatedAt: bigint('updated_at', { mode: 'number' }).notNull().default(sql`EXTRACT(EPOCH FROM NOW()) * 1000`),
|
||||||
|
}, (t) => ({
|
||||||
|
parentIdx: index('subtasks_parent_idx').on(t.parentSubtaskId),
|
||||||
|
taskIdx: index('subtasks_task_idx').on(t.taskId),
|
||||||
|
userIdx: index('subtasks_user_idx').on(t.userId),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const repeatProfiles = pgTable('repeat_profiles', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
repeat: text('repeat', { enum: ['none', 'daily', 'weekly', 'monthly', 'custom'] }).notNull().default('none'),
|
||||||
|
repeatInterval: integer('repeat_interval').notNull().default(1),
|
||||||
|
repeatDays: text('repeat_days').notNull().default(''),
|
||||||
|
createdAt: bigint('created_at', { mode: 'number' }).notNull().default(sql`EXTRACT(EPOCH FROM NOW()) * 1000`),
|
||||||
|
updatedAt: bigint('updated_at', { mode: 'number' }).notNull().default(sql`EXTRACT(EPOCH FROM NOW()) * 1000`),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const userSettings = pgTable('user_settings', {
|
export const userSettings = pgTable('user_settings', {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Router, Request, Response } from 'express';
|
|||||||
import { asyncHandler } from '../utils/asyncHandler';
|
import { asyncHandler } from '../utils/asyncHandler';
|
||||||
import { db } from '../db';
|
import { db } from '../db';
|
||||||
import { subtasks, tasks } from '../db/schema';
|
import { subtasks, tasks } from '../db/schema';
|
||||||
import { eq, and, asc, desc } from 'drizzle-orm';
|
import { eq, and, asc, desc, isNull } from 'drizzle-orm';
|
||||||
import { authMiddleware } from '../utils/auth';
|
import { authMiddleware } from '../utils/auth';
|
||||||
import { AppError } from '../middleware/errorHandler';
|
import { AppError } from '../middleware/errorHandler';
|
||||||
import { subtaskCreateSchema, subtaskUpdateSchema } from '../utils/validation';
|
import { subtaskCreateSchema, subtaskUpdateSchema } from '../utils/validation';
|
||||||
@@ -11,6 +11,17 @@ const router = Router();
|
|||||||
|
|
||||||
router.use(authMiddleware);
|
router.use(authMiddleware);
|
||||||
|
|
||||||
|
// Helper to build nested subtask tree
|
||||||
|
const buildSubtaskTree = (allSubtasks: any[], parentId: string | null = null): any[] => {
|
||||||
|
return allSubtasks
|
||||||
|
.filter((s) => s.parentSubtaskId === parentId)
|
||||||
|
.sort((a, b) => a.order - b.order)
|
||||||
|
.map((s) => ({
|
||||||
|
...s,
|
||||||
|
subtasks: buildSubtaskTree(allSubtasks, s.id),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
router.get('/task/:taskId', asyncHandler(async (req: Request, res: Response) => {
|
router.get('/task/:taskId', asyncHandler(async (req: Request, res: Response) => {
|
||||||
const userId = req.user!.userId;
|
const userId = req.user!.userId;
|
||||||
|
|
||||||
@@ -31,7 +42,9 @@ router.get('/task/:taskId', asyncHandler(async (req: Request, res: Response) =>
|
|||||||
.where(and(eq(subtasks.taskId, req.params.taskId), eq(subtasks.userId, userId)))
|
.where(and(eq(subtasks.taskId, req.params.taskId), eq(subtasks.userId, userId)))
|
||||||
.orderBy(asc(subtasks.order));
|
.orderBy(asc(subtasks.order));
|
||||||
|
|
||||||
res.json({ subtasks: taskSubtasks });
|
const nestedSubtasks = buildSubtaskTree(taskSubtasks);
|
||||||
|
|
||||||
|
res.json({ subtasks: nestedSubtasks });
|
||||||
}));
|
}));
|
||||||
|
|
||||||
router.post('/task/:taskId', asyncHandler(async (req: Request, res: Response) => {
|
router.post('/task/:taskId', asyncHandler(async (req: Request, res: Response) => {
|
||||||
@@ -49,10 +62,16 @@ router.post('/task/:taskId', asyncHandler(async (req: Request, res: Response) =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
const parentSubtaskId = data.parentSubtaskId || null;
|
||||||
|
|
||||||
const maxOrder = await db
|
const maxOrder = await db
|
||||||
.select({ order: subtasks.order })
|
.select({ order: subtasks.order })
|
||||||
.from(subtasks)
|
.from(subtasks)
|
||||||
.where(and(eq(subtasks.taskId, req.params.taskId), eq(subtasks.userId, userId)))
|
.where(and(
|
||||||
|
eq(subtasks.taskId, req.params.taskId),
|
||||||
|
eq(subtasks.userId, userId),
|
||||||
|
parentSubtaskId ? eq(subtasks.parentSubtaskId, parentSubtaskId) : isNull(subtasks.parentSubtaskId)
|
||||||
|
))
|
||||||
.orderBy(desc(subtasks.order))
|
.orderBy(desc(subtasks.order))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
@@ -62,6 +81,8 @@ router.post('/task/:taskId', asyncHandler(async (req: Request, res: Response) =>
|
|||||||
id: subtaskId,
|
id: subtaskId,
|
||||||
userId,
|
userId,
|
||||||
taskId: req.params.taskId,
|
taskId: req.params.taskId,
|
||||||
|
parentSubtaskId,
|
||||||
|
categoryId: data.categoryId ?? null,
|
||||||
title: data.title,
|
title: data.title,
|
||||||
description: data.description ?? '',
|
description: data.description ?? '',
|
||||||
priority: data.priority ?? 'none',
|
priority: data.priority ?? 'none',
|
||||||
@@ -97,7 +118,7 @@ router.patch('/:id', asyncHandler(async (req: Request, res: Response) => {
|
|||||||
const data = subtaskUpdateSchema.parse(req.body);
|
const data = subtaskUpdateSchema.parse(req.body);
|
||||||
const userId = req.user!.userId;
|
const userId = req.user!.userId;
|
||||||
|
|
||||||
const existing = await db
|
const existing: any[] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(subtasks)
|
.from(subtasks)
|
||||||
.where(and(eq(subtasks.id, req.params.id), eq(subtasks.userId, userId)))
|
.where(and(eq(subtasks.id, req.params.id), eq(subtasks.userId, userId)))
|
||||||
|
|||||||
+242
-8
@@ -1,8 +1,8 @@
|
|||||||
import { Router, Request, Response } from 'express';
|
import { Router, Request, Response } from 'express';
|
||||||
import { asyncHandler } from '../utils/asyncHandler';
|
import { asyncHandler } from '../utils/asyncHandler';
|
||||||
import { db } from '../db';
|
import { db } from '../db';
|
||||||
import { categories, tasks, subtasks, repeatProfiles, users, friendships } from '../db/schema';
|
import { categories, tasks, subtasks, repeatProfiles, users, friendships, tombstones, type EntityName } from '../db/schema';
|
||||||
import { eq, and, gte, lte, asc, or, inArray, sql } from 'drizzle-orm';
|
import { eq, and, gte, asc, or, inArray, sql } from 'drizzle-orm';
|
||||||
import { authMiddleware } from '../utils/auth';
|
import { authMiddleware } from '../utils/auth';
|
||||||
import { AppError } from '../middleware/errorHandler';
|
import { AppError } from '../middleware/errorHandler';
|
||||||
import { syncQuerySchema, pushChangesSchema, canCompleteTask } from '../utils/validation';
|
import { syncQuerySchema, pushChangesSchema, canCompleteTask } from '../utils/validation';
|
||||||
@@ -32,17 +32,15 @@ router.get('/', asyncHandler(async (req: Request, res: Response) => {
|
|||||||
.orderBy(asc(tasks.updatedAt));
|
.orderBy(asc(tasks.updatedAt));
|
||||||
|
|
||||||
// Fetch subtasks changed since timestamp
|
// Fetch subtasks changed since timestamp
|
||||||
const taskIds = changedTasks.map(t => t.id);
|
|
||||||
let changedSubtasks: any[] = [];
|
let changedSubtasks: any[] = [];
|
||||||
|
if (changedTasks.length > 0) {
|
||||||
if (taskIds.length > 0) {
|
|
||||||
changedSubtasks = await db
|
changedSubtasks = await db
|
||||||
.select()
|
.select()
|
||||||
.from(subtasks)
|
.from(subtasks)
|
||||||
.where(and(eq(subtasks.userId, userId), gte(subtasks.updatedAt, sinceDate)))
|
.where(and(eq(subtasks.userId, userId), gte(subtasks.updatedAt, sinceDate)))
|
||||||
.orderBy(asc(subtasks.updatedAt));
|
.orderBy(asc(subtasks.updatedAt));
|
||||||
} else {
|
} else {
|
||||||
// Also fetch subtasks for tasks that might have been deleted (we track by updatedAt)
|
// Also fetch subtasks for tasks that might have been deleted
|
||||||
changedSubtasks = await db
|
changedSubtasks = await db
|
||||||
.select()
|
.select()
|
||||||
.from(subtasks)
|
.from(subtasks)
|
||||||
@@ -67,6 +65,13 @@ router.get('/', asyncHandler(async (req: Request, res: Response) => {
|
|||||||
))
|
))
|
||||||
.orderBy(asc(friendships.updatedAt));
|
.orderBy(asc(friendships.updatedAt));
|
||||||
|
|
||||||
|
// Fetch tombstones (deletions) changed since timestamp
|
||||||
|
const changedTombstones = await db
|
||||||
|
.select()
|
||||||
|
.from(tombstones)
|
||||||
|
.where(and(eq(tombstones.userId, userId), gte(tombstones.updatedAt, sinceDate)))
|
||||||
|
.orderBy(asc(tombstones.updatedAt));
|
||||||
|
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
@@ -75,6 +80,7 @@ router.get('/', asyncHandler(async (req: Request, res: Response) => {
|
|||||||
subtasks: changedSubtasks,
|
subtasks: changedSubtasks,
|
||||||
repeatProfiles: changedRepeatProfiles,
|
repeatProfiles: changedRepeatProfiles,
|
||||||
friendships: changedFriendships,
|
friendships: changedFriendships,
|
||||||
|
deleted: changedTombstones.map((t) => ({ entity: t.entity, id: t.entityId, updatedAt: t.updatedAt })),
|
||||||
timestamp,
|
timestamp,
|
||||||
});
|
});
|
||||||
}));
|
}));
|
||||||
@@ -125,6 +131,27 @@ router.post('/push', asyncHandler(async (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sanitize assignee references: only real user ids may be stored (FK).
|
||||||
|
// Stale/unknown assignee ids are silently dropped to null instead of
|
||||||
|
// failing the whole push transaction.
|
||||||
|
const assigneeIds = new Set<string>();
|
||||||
|
for (const task of data.changes.tasks ?? []) {
|
||||||
|
if (task.assigneeId) assigneeIds.add(task.assigneeId);
|
||||||
|
}
|
||||||
|
for (const sub of data.changes.subtasks ?? []) {
|
||||||
|
if (sub.assigneeId) assigneeIds.add(sub.assigneeId);
|
||||||
|
}
|
||||||
|
const validAssignees = new Set<string>();
|
||||||
|
if (assigneeIds.size > 0) {
|
||||||
|
const rows = await tx
|
||||||
|
.select({ id: users.id })
|
||||||
|
.from(users)
|
||||||
|
.where(inArray(users.id, [...assigneeIds]));
|
||||||
|
for (const r of rows) validAssignees.add(r.id);
|
||||||
|
}
|
||||||
|
const sanitizeAssignee = (a: string | null | undefined): string | null =>
|
||||||
|
a && validAssignees.has(a) ? a : null;
|
||||||
|
|
||||||
// Process categories
|
// Process categories
|
||||||
if (data.changes.categories && data.changes.categories.length > 0) {
|
if (data.changes.categories && data.changes.categories.length > 0) {
|
||||||
for (const cat of data.changes.categories) {
|
for (const cat of data.changes.categories) {
|
||||||
@@ -208,6 +235,7 @@ router.post('/push', asyncHandler(async (req: Request, res: Response) => {
|
|||||||
title: task.title,
|
title: task.title,
|
||||||
description: task.description,
|
description: task.description,
|
||||||
categoryId: task.categoryId,
|
categoryId: task.categoryId,
|
||||||
|
tags: task.tags ?? '',
|
||||||
priority: task.priority,
|
priority: task.priority,
|
||||||
completed: task.completed,
|
completed: task.completed,
|
||||||
dueDate: task.dueDate,
|
dueDate: task.dueDate,
|
||||||
@@ -220,13 +248,15 @@ router.post('/push', asyncHandler(async (req: Request, res: Response) => {
|
|||||||
seriesId: task.seriesId ?? '',
|
seriesId: task.seriesId ?? '',
|
||||||
reminder: task.reminder ?? 'none',
|
reminder: task.reminder ?? 'none',
|
||||||
reminders: task.reminders ?? '',
|
reminders: task.reminders ?? '',
|
||||||
assigneeId: task.assigneeId ?? null,
|
assigneeId: sanitizeAssignee(task.assigneeId),
|
||||||
|
completedAt: task.completedAt ?? null,
|
||||||
updatedAt: task.updatedAt,
|
updatedAt: task.updatedAt,
|
||||||
})
|
})
|
||||||
.where(and(eq(tasks.id, task.id), eq(tasks.userId, userId)));
|
.where(and(eq(tasks.id, task.id), eq(tasks.userId, userId)));
|
||||||
} else {
|
} else {
|
||||||
await tx.insert(tasks).values({
|
await tx.insert(tasks).values({
|
||||||
...task,
|
...task,
|
||||||
|
assigneeId: sanitizeAssignee(task.assigneeId),
|
||||||
allDay: task.allDay ?? false,
|
allDay: task.allDay ?? false,
|
||||||
userId,
|
userId,
|
||||||
});
|
});
|
||||||
@@ -260,6 +290,8 @@ router.post('/push', asyncHandler(async (req: Request, res: Response) => {
|
|||||||
.update(subtasks)
|
.update(subtasks)
|
||||||
.set({
|
.set({
|
||||||
taskId: sub.taskId,
|
taskId: sub.taskId,
|
||||||
|
parentSubtaskId: sub.parentSubtaskId ?? null,
|
||||||
|
categoryId: sub.categoryId ?? null,
|
||||||
title: sub.title,
|
title: sub.title,
|
||||||
description: sub.description ?? '',
|
description: sub.description ?? '',
|
||||||
priority: sub.priority ?? 'none',
|
priority: sub.priority ?? 'none',
|
||||||
@@ -274,7 +306,7 @@ router.post('/push', asyncHandler(async (req: Request, res: Response) => {
|
|||||||
seriesId: sub.seriesId ?? '',
|
seriesId: sub.seriesId ?? '',
|
||||||
reminder: sub.reminder ?? 'none',
|
reminder: sub.reminder ?? 'none',
|
||||||
reminders: sub.reminders ?? '',
|
reminders: sub.reminders ?? '',
|
||||||
assigneeId: sub.assigneeId ?? null,
|
assigneeId: sanitizeAssignee(sub.assigneeId),
|
||||||
order: sub.order,
|
order: sub.order,
|
||||||
updatedAt: sub.updatedAt,
|
updatedAt: sub.updatedAt,
|
||||||
})
|
})
|
||||||
@@ -282,6 +314,7 @@ router.post('/push', asyncHandler(async (req: Request, res: Response) => {
|
|||||||
} else {
|
} else {
|
||||||
await tx.insert(subtasks).values({
|
await tx.insert(subtasks).values({
|
||||||
...sub,
|
...sub,
|
||||||
|
assigneeId: sanitizeAssignee(sub.assigneeId),
|
||||||
userId,
|
userId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -365,6 +398,14 @@ router.post('/push', asyncHandler(async (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Process deletions (tombstones) - last so they see the state produced
|
||||||
|
// by the upserts above and resolve by last-writer-wins.
|
||||||
|
if (data.deleted && data.deleted.length > 0) {
|
||||||
|
for (const deleted of data.deleted) {
|
||||||
|
await applyTombstone(tx, deleted.entity, deleted.id, deleted.updatedAt, userId, conflicts);
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Sync push error:', error);
|
console.error('Sync push error:', error);
|
||||||
@@ -378,4 +419,197 @@ router.post('/push', asyncHandler(async (req: Request, res: Response) => {
|
|||||||
});
|
});
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Upsert a tombstone row, keeping the latest updatedAt.
|
||||||
|
async function upsertTombstone(
|
||||||
|
tx: any,
|
||||||
|
entity: EntityName,
|
||||||
|
entityId: string,
|
||||||
|
updatedAt: number,
|
||||||
|
userId: string
|
||||||
|
): Promise<void> {
|
||||||
|
const existing = await tx
|
||||||
|
.select({ updatedAt: tombstones.updatedAt })
|
||||||
|
.from(tombstones)
|
||||||
|
.where(and(eq(tombstones.entity, entity), eq(tombstones.entityId, entityId)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const merged = Math.max(existing[0]?.updatedAt ?? 0, updatedAt);
|
||||||
|
if (existing.length > 0) {
|
||||||
|
await tx
|
||||||
|
.update(tombstones)
|
||||||
|
.set({ updatedAt: merged })
|
||||||
|
.where(and(eq(tombstones.entity, entity), eq(tombstones.entityId, entityId)));
|
||||||
|
} else {
|
||||||
|
await tx.insert(tombstones).values({ entity, entityId, userId, updatedAt: merged });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply a client deletion. LWW: if the server row is newer than the deletion
|
||||||
|
// timestamp, the deletion is rejected (server_wins conflict) so the client
|
||||||
|
// re-pulls the row. Accepted deletions cascade tombstones to every FK-cascaded
|
||||||
|
// child so all devices remove them too.
|
||||||
|
async function applyTombstone(
|
||||||
|
tx: any,
|
||||||
|
entity: EntityName,
|
||||||
|
id: string,
|
||||||
|
deletedAt: number,
|
||||||
|
userId: string,
|
||||||
|
conflicts: any[]
|
||||||
|
): Promise<void> {
|
||||||
|
const tombstoneOf = (e: EntityName, ids: string[]) => ids.forEach((i) => upsertTombstone(tx, e, i, deletedAt, userId));
|
||||||
|
|
||||||
|
if (entity === 'tasks') {
|
||||||
|
const row = await tx
|
||||||
|
.select()
|
||||||
|
.from(tasks)
|
||||||
|
.where(and(eq(tasks.id, id), eq(tasks.userId, userId)))
|
||||||
|
.limit(1);
|
||||||
|
if (row.length === 0) {
|
||||||
|
await upsertTombstone(tx, entity, id, deletedAt, userId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (row[0].updatedAt > deletedAt) {
|
||||||
|
conflicts.push({
|
||||||
|
entity: 'tasks',
|
||||||
|
id,
|
||||||
|
serverVersion: row[0],
|
||||||
|
clientVersion: { id, updatedAt: deletedAt },
|
||||||
|
resolution: 'server_wins',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const children = await tx
|
||||||
|
.select({ id: subtasks.id })
|
||||||
|
.from(subtasks)
|
||||||
|
.where(and(eq(subtasks.taskId, id), eq(subtasks.userId, userId)));
|
||||||
|
tombstoneOf('subtasks', children.map((c: any) => c.id));
|
||||||
|
await tx.delete(tasks).where(and(eq(tasks.id, id), eq(tasks.userId, userId)));
|
||||||
|
await upsertTombstone(tx, entity, id, deletedAt, userId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entity === 'categories') {
|
||||||
|
const row = await tx
|
||||||
|
.select()
|
||||||
|
.from(categories)
|
||||||
|
.where(and(eq(categories.id, id), eq(categories.userId, userId)))
|
||||||
|
.limit(1);
|
||||||
|
if (row.length === 0) {
|
||||||
|
await upsertTombstone(tx, entity, id, deletedAt, userId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (row[0].updatedAt > deletedAt) {
|
||||||
|
conflicts.push({
|
||||||
|
entity: 'categories',
|
||||||
|
id,
|
||||||
|
serverVersion: row[0],
|
||||||
|
clientVersion: { id, updatedAt: deletedAt },
|
||||||
|
resolution: 'server_wins',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Deleting the category cascades its tasks (and their subtasks) -
|
||||||
|
// tombstone all of them so every client removes them.
|
||||||
|
const catTasks = await tx
|
||||||
|
.select()
|
||||||
|
.from(tasks)
|
||||||
|
.where(and(eq(tasks.categoryId, id), eq(tasks.userId, userId)));
|
||||||
|
for (const taskRow of catTasks) {
|
||||||
|
const subIds = await tx
|
||||||
|
.select({ id: subtasks.id })
|
||||||
|
.from(subtasks)
|
||||||
|
.where(and(eq(subtasks.taskId, taskRow.id), eq(subtasks.userId, userId)));
|
||||||
|
tombstoneOf('subtasks', subIds.map((s: any) => s.id));
|
||||||
|
tombstoneOf('tasks', [taskRow.id]);
|
||||||
|
}
|
||||||
|
await tx.delete(categories).where(and(eq(categories.id, id), eq(categories.userId, userId)));
|
||||||
|
await upsertTombstone(tx, entity, id, deletedAt, userId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entity === 'subtasks') {
|
||||||
|
const row = await tx
|
||||||
|
.select()
|
||||||
|
.from(subtasks)
|
||||||
|
.where(and(eq(subtasks.id, id), eq(subtasks.userId, userId)))
|
||||||
|
.limit(1);
|
||||||
|
if (row.length === 0) {
|
||||||
|
await upsertTombstone(tx, entity, id, deletedAt, userId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (row[0].updatedAt > deletedAt) {
|
||||||
|
conflicts.push({
|
||||||
|
entity: 'subtasks',
|
||||||
|
id,
|
||||||
|
serverVersion: row[0],
|
||||||
|
clientVersion: { id, updatedAt: deletedAt },
|
||||||
|
resolution: 'server_wins',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Deleting a parent subtask cascades its children in PG - tombstone them.
|
||||||
|
const children = await tx
|
||||||
|
.select({ id: subtasks.id })
|
||||||
|
.from(subtasks)
|
||||||
|
.where(and(eq(subtasks.parentSubtaskId, id), eq(subtasks.userId, userId)));
|
||||||
|
tombstoneOf('subtasks', children.map((c: any) => c.id));
|
||||||
|
await tx.delete(subtasks).where(and(eq(subtasks.id, id), eq(subtasks.userId, userId)));
|
||||||
|
await upsertTombstone(tx, entity, id, deletedAt, userId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entity === 'repeatProfiles') {
|
||||||
|
const row = await tx
|
||||||
|
.select()
|
||||||
|
.from(repeatProfiles)
|
||||||
|
.where(and(eq(repeatProfiles.id, id), eq(repeatProfiles.userId, userId)))
|
||||||
|
.limit(1);
|
||||||
|
if (row.length === 0) {
|
||||||
|
await upsertTombstone(tx, entity, id, deletedAt, userId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (row[0].updatedAt > deletedAt) {
|
||||||
|
conflicts.push({
|
||||||
|
entity: 'repeatProfiles',
|
||||||
|
id,
|
||||||
|
serverVersion: row[0],
|
||||||
|
clientVersion: { id, updatedAt: deletedAt },
|
||||||
|
resolution: 'server_wins',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await tx.delete(repeatProfiles).where(and(eq(repeatProfiles.id, id), eq(repeatProfiles.userId, userId)));
|
||||||
|
await upsertTombstone(tx, entity, id, deletedAt, userId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entity === 'friendships') {
|
||||||
|
const row = await tx
|
||||||
|
.select()
|
||||||
|
.from(friendships)
|
||||||
|
.where(and(
|
||||||
|
eq(friendships.id, id),
|
||||||
|
or(eq(friendships.userId, userId), eq(friendships.friendId, userId))
|
||||||
|
))
|
||||||
|
.limit(1);
|
||||||
|
if (row.length === 0) {
|
||||||
|
await upsertTombstone(tx, entity, id, deletedAt, userId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (row[0].updatedAt > deletedAt) {
|
||||||
|
conflicts.push({
|
||||||
|
entity: 'friendships',
|
||||||
|
id,
|
||||||
|
serverVersion: row[0],
|
||||||
|
clientVersion: { id, updatedAt: deletedAt },
|
||||||
|
resolution: 'server_wins',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await tx.delete(friendships).where(eq(friendships.id, id));
|
||||||
|
await upsertTombstone(tx, entity, id, deletedAt, userId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
@@ -116,7 +116,8 @@ router.post('/', asyncHandler(async (req: Request, res: Response) => {
|
|||||||
const userId = req.user!.userId;
|
const userId = req.user!.userId;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
// Verify category exists and belongs to user
|
// Verify category exists and belongs to user (optional - tasks may be uncategorized)
|
||||||
|
if (data.categoryId) {
|
||||||
const cat = await db
|
const cat = await db
|
||||||
.select()
|
.select()
|
||||||
.from(categories)
|
.from(categories)
|
||||||
@@ -126,6 +127,7 @@ router.post('/', asyncHandler(async (req: Request, res: Response) => {
|
|||||||
if (cat.length === 0) {
|
if (cat.length === 0) {
|
||||||
throw new AppError('NOT_FOUND', 'Category not found', 404);
|
throw new AppError('NOT_FOUND', 'Category not found', 404);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const taskId = `task_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
const taskId = `task_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||||
|
|
||||||
@@ -133,6 +135,7 @@ router.post('/', asyncHandler(async (req: Request, res: Response) => {
|
|||||||
id: taskId,
|
id: taskId,
|
||||||
userId,
|
userId,
|
||||||
categoryId: data.categoryId,
|
categoryId: data.categoryId,
|
||||||
|
tags: data.tags ?? '',
|
||||||
title: data.title,
|
title: data.title,
|
||||||
description: data.description ?? '',
|
description: data.description ?? '',
|
||||||
priority: data.priority ?? 'none',
|
priority: data.priority ?? 'none',
|
||||||
@@ -157,6 +160,7 @@ router.post('/', asyncHandler(async (req: Request, res: Response) => {
|
|||||||
userId,
|
userId,
|
||||||
taskId,
|
taskId,
|
||||||
title: st.title,
|
title: st.title,
|
||||||
|
categoryId: (st as any).categoryId ?? null,
|
||||||
completed: false,
|
completed: false,
|
||||||
order: index,
|
order: index,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
@@ -261,12 +265,14 @@ router.post('/batch', asyncHandler(async (req: Request, res: Response) => {
|
|||||||
for (const op of operations) {
|
for (const op of operations) {
|
||||||
try {
|
try {
|
||||||
if (op.type === 'create') {
|
if (op.type === 'create') {
|
||||||
|
if (op.data.categoryId) {
|
||||||
const cat = await db
|
const cat = await db
|
||||||
.select()
|
.select()
|
||||||
.from(categories)
|
.from(categories)
|
||||||
.where(and(eq(categories.id, op.data.categoryId), eq(categories.userId, userId)))
|
.where(and(eq(categories.id, op.data.categoryId), eq(categories.userId, userId)))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
if (cat.length === 0) throw new AppError('NOT_FOUND', 'Category not found', 404);
|
if (cat.length === 0) throw new AppError('NOT_FOUND', 'Category not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
const taskId = `task_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
const taskId = `task_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|||||||
@@ -21,29 +21,46 @@ export const categoryUpdateSchema = z.object({
|
|||||||
|
|
||||||
export const repeatSchema = z.enum(['none', 'daily', 'weekly', 'monthly', 'custom']);
|
export const repeatSchema = z.enum(['none', 'daily', 'weekly', 'monthly', 'custom']);
|
||||||
|
|
||||||
|
// The client stores '' / 0 for unset repeat values; normalize them so stale or
|
||||||
|
// legacy rows never fail validation on push.
|
||||||
|
export const repeatFieldSchema = repeatSchema
|
||||||
|
.or(z.literal(''))
|
||||||
|
.transform((v) => (v === '' ? 'none' : v))
|
||||||
|
.optional();
|
||||||
|
export const repeatIntervalFieldSchema = z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(0)
|
||||||
|
.max(30)
|
||||||
|
.transform((v) => Math.max(1, v))
|
||||||
|
.optional();
|
||||||
|
|
||||||
export const taskCreateSchema = z.object({
|
export const taskCreateSchema = z.object({
|
||||||
title: z.string().min(1).max(100),
|
title: z.string().min(1).max(100),
|
||||||
description: z.string().max(1000).optional(),
|
description: z.string().max(1000).optional(),
|
||||||
categoryId: z.string().min(1),
|
categoryId: z.string().max(100).transform((v) => (v === '' ? null : v)).nullable(),
|
||||||
|
tags: z.string().max(500).optional(),
|
||||||
priority: z.enum(['none', 'low', 'medium', 'high', 'critical']).optional(),
|
priority: z.enum(['none', 'low', 'medium', 'high', 'critical']).optional(),
|
||||||
dueDate: z.number().int().min(0).optional(),
|
dueDate: z.number().int().min(0).optional(),
|
||||||
dueTime: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().or(z.literal('')).transform(v => v ?? ''),
|
dueTime: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().or(z.literal('')).transform(v => v ?? ''),
|
||||||
endTime: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().or(z.literal('')),
|
endTime: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().or(z.literal('')),
|
||||||
allDay: z.boolean().optional(),
|
allDay: z.boolean().optional(),
|
||||||
repeat: repeatSchema.optional(),
|
repeat: repeatFieldSchema,
|
||||||
repeatInterval: z.number().int().min(1).max(30).optional(),
|
repeatInterval: repeatIntervalFieldSchema,
|
||||||
repeatDays: z.string().max(20).optional(),
|
repeatDays: z.string().max(20).optional(),
|
||||||
seriesId: z.string().max(50).optional(),
|
seriesId: z.string().max(50).optional(),
|
||||||
reminder: z.enum(['none', 'at_time', '15', '30', '60', '120', '1440']).optional(),
|
reminder: z.enum(['none', 'at_time', '15', '30', '60', '120', '1440']).optional(),
|
||||||
reminders: z.string().max(100).optional(),
|
reminders: z.string().max(100).optional(),
|
||||||
assigneeId: z.string().nullable().optional(),
|
assigneeId: z.string().nullable().optional(),
|
||||||
subtasks: z.array(z.object({ title: z.string().min(1).max(100) })).optional(),
|
completedAt: z.number().int().min(0).nullable().optional(),
|
||||||
|
subtasks: z.array(z.object({ title: z.string().min(1).max(100), categoryId: z.string().max(100).nullable().optional().transform((v) => (v === '' ? null : v)) })).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const taskUpdateSchema = z.object({
|
export const taskUpdateSchema = z.object({
|
||||||
title: z.string().min(1).max(100).optional(),
|
title: z.string().min(1).max(100).optional(),
|
||||||
description: z.string().max(1000).optional(),
|
description: z.string().max(1000).optional(),
|
||||||
categoryId: z.string().min(1).optional(),
|
categoryId: z.string().max(100).nullable().optional().transform((v) => (v === '' ? null : v)),
|
||||||
|
tags: z.string().max(500).optional(),
|
||||||
priority: z.enum(['none', 'low', 'medium', 'high', 'critical']).optional(),
|
priority: z.enum(['none', 'low', 'medium', 'high', 'critical']).optional(),
|
||||||
completed: z.boolean().optional(),
|
completed: z.boolean().optional(),
|
||||||
dueDate: z.number().int().min(0).optional(),
|
dueDate: z.number().int().min(0).optional(),
|
||||||
@@ -51,43 +68,48 @@ export const taskUpdateSchema = z.object({
|
|||||||
endTime: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional(),
|
endTime: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional(),
|
||||||
reminders: z.string().max(100).optional(),
|
reminders: z.string().max(100).optional(),
|
||||||
assigneeId: z.string().nullable().optional(),
|
assigneeId: z.string().nullable().optional(),
|
||||||
|
completedAt: z.number().int().min(0).nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const subtaskCreateSchema = z.object({
|
export const subtaskCreateSchema = z.object({
|
||||||
title: z.string().min(1).max(100),
|
title: z.string().min(1).max(100),
|
||||||
description: z.string().max(1000).optional(),
|
description: z.string().max(1000).optional(),
|
||||||
|
categoryId: z.string().max(100).nullable().optional().transform((v) => (v === '' ? null : v)),
|
||||||
priority: z.enum(['none', 'low', 'medium', 'high', 'critical']).optional(),
|
priority: z.enum(['none', 'low', 'medium', 'high', 'critical']).optional(),
|
||||||
dueDate: z.number().int().min(0).optional(),
|
dueDate: z.number().int().min(0).optional(),
|
||||||
dueTime: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().or(z.literal('')).transform(v => v ?? ''),
|
dueTime: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().or(z.literal('')).transform(v => v ?? ''),
|
||||||
endTime: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().or(z.literal('')),
|
endTime: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().or(z.literal('')),
|
||||||
allDay: z.boolean().optional(),
|
allDay: z.boolean().optional(),
|
||||||
repeat: repeatSchema.optional(),
|
repeat: repeatFieldSchema,
|
||||||
repeatInterval: z.number().int().min(1).max(30).optional(),
|
repeatInterval: repeatIntervalFieldSchema,
|
||||||
repeatDays: z.string().max(20).optional(),
|
repeatDays: z.string().max(20).optional(),
|
||||||
seriesId: z.string().max(50).optional(),
|
seriesId: z.string().max(50).optional(),
|
||||||
reminder: z.enum(['none', 'at_time', '15', '30', '60', '120', '1440']).optional(),
|
reminder: z.enum(['none', 'at_time', '15', '30', '60', '120', '1440']).optional(),
|
||||||
reminders: z.string().max(100).optional(),
|
reminders: z.string().max(100).optional(),
|
||||||
assigneeId: z.string().nullable().optional(),
|
assigneeId: z.string().nullable().optional(),
|
||||||
order: z.number().int().min(0).optional(),
|
order: z.number().int().min(0).optional(),
|
||||||
|
parentSubtaskId: z.string().nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const subtaskUpdateSchema = z.object({
|
export const subtaskUpdateSchema = z.object({
|
||||||
title: z.string().min(1).max(100).optional(),
|
title: z.string().min(1).max(100).optional(),
|
||||||
description: z.string().max(1000).optional(),
|
description: z.string().max(1000).optional(),
|
||||||
|
categoryId: z.string().max(100).nullable().optional().transform((v) => (v === '' ? null : v)),
|
||||||
priority: z.enum(['none', 'low', 'medium', 'high', 'critical']).optional(),
|
priority: z.enum(['none', 'low', 'medium', 'high', 'critical']).optional(),
|
||||||
completed: z.boolean().optional(),
|
completed: z.boolean().optional(),
|
||||||
dueDate: z.number().int().min(0).optional(),
|
dueDate: z.number().int().min(0).optional(),
|
||||||
dueTime: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().or(z.literal('')).transform(v => v === '' ? undefined : v),
|
dueTime: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().or(z.literal('')).transform(v => v === '' ? undefined : v),
|
||||||
endTime: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional(),
|
endTime: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional(),
|
||||||
allDay: z.boolean().optional(),
|
allDay: z.boolean().optional(),
|
||||||
repeat: repeatSchema.optional(),
|
repeat: repeatFieldSchema,
|
||||||
repeatInterval: z.number().int().min(1).max(30).optional(),
|
repeatInterval: repeatIntervalFieldSchema,
|
||||||
repeatDays: z.string().max(20).optional(),
|
repeatDays: z.string().max(20).optional(),
|
||||||
seriesId: z.string().max(50).optional(),
|
seriesId: z.string().max(50).optional(),
|
||||||
reminder: z.enum(['none', 'at_time', '15', '30', '60', '120', '1440']).optional(),
|
reminder: z.enum(['none', 'at_time', '15', '30', '60', '120', '1440']).optional(),
|
||||||
reminders: z.string().max(100).optional(),
|
reminders: z.string().max(100).optional(),
|
||||||
assigneeId: z.string().nullable().optional(),
|
assigneeId: z.string().nullable().optional(),
|
||||||
order: z.number().int().min(0).optional(),
|
order: z.number().int().min(0).optional(),
|
||||||
|
parentSubtaskId: z.string().nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const userSettingsSchema = z.object({
|
export const userSettingsSchema = z.object({
|
||||||
@@ -130,14 +152,21 @@ export const friendshipSchema = z.object({
|
|||||||
updatedAt: z.number(),
|
updatedAt: z.number(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const tombstoneSchema = z.object({
|
||||||
|
entity: z.enum(['categories', 'tasks', 'subtasks', 'repeatProfiles', 'friendships']),
|
||||||
|
id: z.string(),
|
||||||
|
updatedAt: z.number().int().min(0),
|
||||||
|
});
|
||||||
|
|
||||||
export const pushChangesSchema = z.object({
|
export const pushChangesSchema = z.object({
|
||||||
changes: z.object({
|
changes: z.object({
|
||||||
categories: z.array(categoryCreateSchema.extend({ id: z.string(), createdAt: z.number(), updatedAt: z.number() })).optional(),
|
categories: z.array(categoryCreateSchema.extend({ id: z.string(), createdAt: z.number(), updatedAt: z.number() })).optional(),
|
||||||
tasks: z.array(taskCreateSchema.extend({ id: z.string(), completed: z.boolean(), createdAt: z.number(), updatedAt: z.number() })).optional(),
|
tasks: z.array(taskCreateSchema.extend({ id: z.string(), completed: z.boolean(), createdAt: z.number(), updatedAt: z.number(), completedAt: z.number().int().min(0).nullable().optional() })).optional(),
|
||||||
subtasks: z.array(subtaskCreateSchema.extend({ id: z.string(), taskId: z.string(), completed: z.boolean(), createdAt: z.number(), updatedAt: z.number() })).optional(),
|
subtasks: z.array(subtaskCreateSchema.extend({ id: z.string(), taskId: z.string(), completed: z.boolean(), createdAt: z.number(), updatedAt: z.number() })).optional(),
|
||||||
repeatProfiles: z.array(repeatProfileSchema.extend({ id: z.string(), createdAt: z.number(), updatedAt: z.number() })).optional(),
|
repeatProfiles: z.array(repeatProfileSchema.extend({ id: z.string(), createdAt: z.number(), updatedAt: z.number() })).optional(),
|
||||||
friendships: z.array(friendshipSchema).optional(),
|
friendships: z.array(friendshipSchema).optional(),
|
||||||
}),
|
}),
|
||||||
|
deleted: z.array(tombstoneSchema).optional(),
|
||||||
lastPulledAt: z.number().int().min(0),
|
lastPulledAt: z.number().int().min(0),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
./gradlew assembleRelease
|
||||||
|
/home/tech08mag/Android/Sdk/platform-tools/adb push /home/tech08mag/Code/carry-your-live/carry-your-live/android/app/build/outputs/apk/release/app-release.apk /sdcard/Download/
|
||||||
|
|
||||||
|
./gradlew installDebug
|
||||||
|
|
||||||
|
./gradlew assembleRelease Build release APK (what you ran)
|
||||||
|
./gradlew bundleRelease Build release AAB (for Play Store)
|
||||||
|
./gradlew assembleDebug Build debug APK
|
||||||
|
./gradlew installRelease Build + install release APK to connected device
|
||||||
|
./gradlew installDebug Build + install debug APK
|
||||||
|
./gradlew clean Clean build outputs
|
||||||
|
./gradlew tasks List all available tasks
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.expo
|
||||||
|
.git
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
android
|
||||||
|
ios
|
||||||
|
coverage
|
||||||
|
*.local
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN npx expo export -p web
|
||||||
|
|
||||||
|
FROM nginx:alpine AS runner
|
||||||
|
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
EXPOSE 8081
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
@@ -6,10 +6,13 @@
|
|||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"icon": "./assets/icon.png",
|
"icon": "./assets/icon.png",
|
||||||
"userInterfaceStyle": "dark",
|
"userInterfaceStyle": "dark",
|
||||||
|
"backgroundColor": "#121212",
|
||||||
"ios": {
|
"ios": {
|
||||||
"supportsTablet": true
|
"supportsTablet": true
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
|
"softwareKeyboardLayoutMode": "resize",
|
||||||
|
"backgroundColor": "#121212",
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"backgroundColor": "#E6F4FE",
|
"backgroundColor": "#E6F4FE",
|
||||||
"foregroundImage": "./assets/android-icon-foreground.png",
|
"foregroundImage": "./assets/android-icon-foreground.png",
|
||||||
@@ -30,12 +33,19 @@
|
|||||||
"icon": "./assets/android-icon-foreground.png",
|
"icon": "./assets/android-icon-foreground.png",
|
||||||
"color": "#1E88E5"
|
"color": "#1E88E5"
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"./plugins/withQuickAddWidget"
|
||||||
],
|
],
|
||||||
"extra": {
|
"extra": {
|
||||||
"eas": {
|
"eas": {
|
||||||
"projectId": "93a56631-01e5-45ab-9de1-7e6fb863c0a9"
|
"projectId": "93a56631-01e5-45ab-9de1-7e6fb863c0a9"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"runtimeVersion": {
|
||||||
|
"policy": "appVersion"
|
||||||
|
},
|
||||||
|
"updates": {
|
||||||
|
"url": "https://u.expo.dev/93a56631-01e5-45ab-9de1-7e6fb863c0a9"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +1,56 @@
|
|||||||
import { Tabs } from 'expo-router';
|
import { Tabs } from 'expo-router';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { TabBarIcon } from '@/components/TabBarIcon';
|
import { TabBarIcon } from '@/components/TabBarIcon';
|
||||||
import { useSettings } from '@/theme';
|
import { useSettings } from '@/theme';
|
||||||
|
import { setLastVisitedTab } from '@/utils/tabHistory';
|
||||||
|
|
||||||
export default function TabLayout() {
|
export default function TabLayout() {
|
||||||
const { theme } = useSettings();
|
const { theme } = useSettings();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs
|
<Tabs
|
||||||
screenOptions={{
|
screenOptions={{
|
||||||
tabBarActiveTintColor: theme.accent,
|
tabBarActiveTintColor: theme.accent,
|
||||||
tabBarInactiveTintColor: '#8E8E8E',
|
tabBarInactiveTintColor: theme.text,
|
||||||
tabBarStyle: {
|
tabBarStyle: {
|
||||||
backgroundColor: theme.tabBarBg,
|
backgroundColor: theme.tabBarBg,
|
||||||
borderTopWidth: 1,
|
borderTopWidth: 1,
|
||||||
borderTopColor: theme.border,
|
borderTopColor: theme.border,
|
||||||
height: 64,
|
height: 68 + insets.bottom,
|
||||||
paddingBottom: 0,
|
paddingBottom: insets.bottom,
|
||||||
|
paddingTop: 6,
|
||||||
|
elevation: 8,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: -2 },
|
||||||
|
shadowOpacity: 0.08,
|
||||||
|
shadowRadius: 8,
|
||||||
|
},
|
||||||
|
tabBarItemStyle: {
|
||||||
|
paddingVertical: 0,
|
||||||
},
|
},
|
||||||
tabBarLabelStyle: {
|
tabBarLabelStyle: {
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontWeight: '500',
|
fontWeight: '700',
|
||||||
},
|
},
|
||||||
headerShown: false,
|
headerShown: false,
|
||||||
|
sceneStyle: { backgroundColor: theme.background },
|
||||||
}}
|
}}
|
||||||
|
screenListeners={({ route }) => ({
|
||||||
|
focus: () => {
|
||||||
|
if (route.name !== 'settings') {
|
||||||
|
setLastVisitedTab(route.name);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})}
|
||||||
>
|
>
|
||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="index"
|
name="index"
|
||||||
options={{
|
options={{
|
||||||
title: 'Tasks',
|
title: 'ToDo',
|
||||||
tabBarIcon: ({ focused, color }) => (
|
tabBarIcon: ({ color, focused }) => (
|
||||||
<TabBarIcon name="checklist" focused={focused} color={color} />
|
<TabBarIcon name="checklist" focused={focused} color={color} size={26} />
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -38,8 +58,17 @@ export default function TabLayout() {
|
|||||||
name="calendar"
|
name="calendar"
|
||||||
options={{
|
options={{
|
||||||
title: 'Calendar',
|
title: 'Calendar',
|
||||||
tabBarIcon: ({ focused, color }) => (
|
tabBarIcon: ({ color, focused }) => (
|
||||||
<TabBarIcon name="calendar" focused={focused} color={color} />
|
<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} />
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -47,8 +76,8 @@ export default function TabLayout() {
|
|||||||
name="settings"
|
name="settings"
|
||||||
options={{
|
options={{
|
||||||
title: 'Settings',
|
title: 'Settings',
|
||||||
tabBarIcon: ({ focused, color }) => (
|
tabBarIcon: ({ color, focused }) => (
|
||||||
<TabBarIcon name="gear" focused={focused} color={color} />
|
<TabBarIcon name="gear" focused={focused} color={color} size={26} />
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,194 +1,427 @@
|
|||||||
import React, { useMemo, useRef, useState } from 'react';
|
import React, { useMemo, useRef, useState, useCallback } from 'react';
|
||||||
import { View, Text, StyleSheet, SafeAreaView, ScrollView, TouchableOpacity, FlatList } from 'react-native';
|
import { View, Text, StyleSheet, SafeAreaView, ScrollView, TouchableOpacity, Animated, Dimensions, BackHandler, KeyboardAvoidingView } from 'react-native';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter, useFocusEffect } from 'expo-router';
|
||||||
|
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
||||||
import { Header } from '@/components/Header';
|
import { Header } from '@/components/Header';
|
||||||
import { useTasksByDate } from '@/hooks/useTasks';
|
import { useTasksByDate, useTasksInMonth } from '@/hooks/useTasks';
|
||||||
import { toggleTaskComplete } from '@/utils/taskActions';
|
import { useSubtasks } from '@/hooks/useSubtasks';
|
||||||
|
import { useTaskModals } from '@/hooks/useTaskModals';
|
||||||
import { useSettings } from '@/theme';
|
import { useSettings } from '@/theme';
|
||||||
import { TaskItem } from '@/components/TaskItem';
|
import { useCategories } from '@/hooks/useDatabase';
|
||||||
|
import { toggleTaskComplete } from '@/utils/taskActions';
|
||||||
import { QuickAddBar } from '@/components/QuickAddBar';
|
import { QuickAddBar } from '@/components/QuickAddBar';
|
||||||
import { TaskData } from '@/types';
|
import { OptionPickerModal } from '@/components/OptionPickerModal';
|
||||||
import { format, startOfMonth, endOfMonth, eachDayOfInterval, isSameDay, isSameMonth, addMonths, isToday } from 'date-fns';
|
import Task from '@/models/Task';
|
||||||
import Svg, { Path } from 'react-native-svg';
|
import { format, addMonths, addDays, startOfMonth, isSameDay, isSameMonth, isToday } from 'date-fns';
|
||||||
|
import Svg, { Path, Circle } from 'react-native-svg';
|
||||||
|
import { desaturate } from '@/theme';
|
||||||
|
import type { ThemeColors } from '@/theme';
|
||||||
|
|
||||||
const DAY_WIDTH = 44;
|
const WIDTH = Dimensions.get('window').width;
|
||||||
const DAY_GAP = 6;
|
const GERMAN_WEEKDAYS = ['mo', 'di', 'mi', 'do', 'fr', 'sa', 'so'];
|
||||||
|
const MONTH_NAMES = [
|
||||||
|
'January', 'February', 'March', 'April', 'May', 'June',
|
||||||
|
'July', 'August', 'September', 'October', 'November', 'December',
|
||||||
|
];
|
||||||
|
|
||||||
export default function CalendarScreen() {
|
export default function CalendarScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { theme } = useSettings();
|
const { theme } = useSettings();
|
||||||
|
const categories = useCategories();
|
||||||
|
const { modals, openTaskEdit } = useTaskModals();
|
||||||
|
|
||||||
const [visibleMonth, setVisibleMonth] = useState(() => new Date());
|
const [visibleMonth, setVisibleMonth] = useState(() => new Date());
|
||||||
const [selectedDate, setSelectedDate] = useState(() => new Date());
|
const [selectedDate, setSelectedDate] = useState(() => new Date());
|
||||||
const stripRef = useRef<ScrollView>(null);
|
const [monthPickerVisible, setMonthPickerVisible] = useState(false);
|
||||||
|
const [yearPickerVisible, setYearPickerVisible] = useState(false);
|
||||||
|
const { map: subtasksByTask, refresh: refreshSubtasks } = useSubtasks();
|
||||||
|
|
||||||
const days = useMemo(
|
const visibleMonthRef = useRef(visibleMonth);
|
||||||
() => eachDayOfInterval({ start: startOfMonth(visibleMonth), end: endOfMonth(visibleMonth) }),
|
visibleMonthRef.current = visibleMonth;
|
||||||
[visibleMonth]
|
const selectedDateRef = useRef(selectedDate);
|
||||||
|
selectedDateRef.current = selectedDate;
|
||||||
|
|
||||||
|
const { tasks: selectedDayTasks, refresh: refreshDayTasks } = useTasksByDate(selectedDate);
|
||||||
|
const monthTasks = useTasksInMonth(visibleMonth);
|
||||||
|
const refreshMonthTasks = monthTasks.refresh;
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
const sub = BackHandler.addEventListener('hardwareBackPress', () => true);
|
||||||
|
refreshDayTasks();
|
||||||
|
refreshMonthTasks();
|
||||||
|
refreshSubtasks();
|
||||||
|
return () => sub.remove();
|
||||||
|
}, [refreshDayTasks, refreshMonthTasks, refreshSubtasks])
|
||||||
);
|
);
|
||||||
|
|
||||||
const { tasks, loading } = useTasksByDate(selectedDate);
|
const translateX = useRef(new Animated.Value(0)).current;
|
||||||
|
const animatingRef = useRef(false);
|
||||||
|
const gridWidthRef = useRef<number>(WIDTH);
|
||||||
|
|
||||||
const handleDayPress = (day: Date) => {
|
const weeks = useMemo(() => {
|
||||||
setSelectedDate(day);
|
const first = startOfMonth(visibleMonth);
|
||||||
if (!isSameMonth(day, visibleMonth)) {
|
const offset = (first.getDay() + 6) % 7; // week starts Monday
|
||||||
setVisibleMonth(day);
|
const gridStart = addDays(first, -offset);
|
||||||
}
|
const cells = Array.from({ length: 42 }, (_, i) => addDays(gridStart, i));
|
||||||
};
|
const rows: Date[][] = [];
|
||||||
|
for (let i = 0; i < 42; i += 7) rows.push(cells.slice(i, i + 7));
|
||||||
|
return rows;
|
||||||
|
}, [visibleMonth]);
|
||||||
|
|
||||||
const handlePrevMonth = () => {
|
const transitionTo = useCallback(
|
||||||
const prev = addMonths(visibleMonth, -1);
|
(dir: 1 | -1, animate = true) => {
|
||||||
setVisibleMonth(prev);
|
if (animatingRef.current) return;
|
||||||
if (!isSameMonth(selectedDate, prev)) {
|
const next = addMonths(visibleMonthRef.current, dir);
|
||||||
setSelectedDate(startOfMonth(prev));
|
if (!isSameMonth(selectedDateRef.current, next)) {
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNextMonth = () => {
|
|
||||||
const next = addMonths(visibleMonth, 1);
|
|
||||||
setVisibleMonth(next);
|
|
||||||
if (!isSameMonth(selectedDate, next)) {
|
|
||||||
setSelectedDate(startOfMonth(next));
|
setSelectedDate(startOfMonth(next));
|
||||||
}
|
}
|
||||||
};
|
if (!animate) {
|
||||||
|
setVisibleMonth(next);
|
||||||
const handleToggleComplete = async (taskId: string) => {
|
translateX.setValue(0);
|
||||||
await toggleTaskComplete(taskId);
|
return;
|
||||||
};
|
|
||||||
|
|
||||||
const today = new Date();
|
|
||||||
|
|
||||||
const scrollToDay = (day: Date) => {
|
|
||||||
const index = days.findIndex((d) => isSameDay(d, day));
|
|
||||||
if (index >= 0) {
|
|
||||||
stripRef.current?.scrollTo({ x: Math.max(0, index * (DAY_WIDTH + DAY_GAP) - 24), animated: true });
|
|
||||||
}
|
}
|
||||||
};
|
animatingRef.current = true;
|
||||||
|
const w = gridWidthRef.current || WIDTH;
|
||||||
|
const target = dir === 1 ? -w : w;
|
||||||
|
Animated.timing(translateX, { toValue: target, duration: 220, useNativeDriver: false }).start(() => {
|
||||||
|
setVisibleMonth(next);
|
||||||
|
translateX.setValue(-target);
|
||||||
|
Animated.timing(translateX, { toValue: 0, duration: 180, useNativeDriver: false }).start(() => {
|
||||||
|
animatingRef.current = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[translateX]
|
||||||
|
);
|
||||||
|
|
||||||
React.useEffect(() => {
|
const handleDayPress = useCallback(
|
||||||
scrollToDay(isSameMonth(selectedDate, visibleMonth) ? selectedDate : today);
|
(day: Date) => {
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
setSelectedDate(day);
|
||||||
}, [visibleMonth]);
|
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)
|
||||||
|
.onUpdate((e) => {
|
||||||
|
if (!animatingRef.current) translateX.setValue(e.translationX);
|
||||||
|
})
|
||||||
|
.onEnd((e) => {
|
||||||
|
if (animatingRef.current) return;
|
||||||
|
const w = gridWidthRef.current || WIDTH;
|
||||||
|
const dx = e.translationX;
|
||||||
|
if (dx <= -w / 4) {
|
||||||
|
translateX.stopAnimation();
|
||||||
|
transitionTo(1, false);
|
||||||
|
} else if (dx >= w / 4) {
|
||||||
|
translateX.stopAnimation();
|
||||||
|
transitionTo(-1, false);
|
||||||
|
} else {
|
||||||
|
Animated.spring(translateX, { toValue: 0, useNativeDriver: false }).start();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
[translateX, transitionTo]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSelectMonth = useCallback((value: string | string[]) => {
|
||||||
|
const monthIndex = parseInt(Array.isArray(value) ? value[0] : value, 10);
|
||||||
|
setVisibleMonth(new Date(visibleMonthRef.current.getFullYear(), monthIndex, 1));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSelectYear = useCallback((value: string | string[]) => {
|
||||||
|
const year = parseInt(Array.isArray(value) ? value[0] : value, 10);
|
||||||
|
setVisibleMonth(new Date(year, visibleMonthRef.current.getMonth(), 1));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
const monthOptions = MONTH_NAMES.map((label, i) => ({ value: String(i), label }));
|
||||||
|
const yearOptions = useMemo(() => {
|
||||||
|
const options: { value: string; label: string }[] = [];
|
||||||
|
for (let y = currentYear - 20; y <= currentYear + 10; y++) options.push({ value: String(y), label: String(y) });
|
||||||
|
return options;
|
||||||
|
}, [currentYear]);
|
||||||
|
|
||||||
|
const handleToggleComplete = useCallback(async (taskId: string) => {
|
||||||
|
await toggleTaskComplete(taskId);
|
||||||
|
refreshDayTasks();
|
||||||
|
refreshMonthTasks();
|
||||||
|
refreshSubtasks();
|
||||||
|
}, [refreshDayTasks, refreshMonthTasks, refreshSubtasks]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
|
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
|
||||||
<Header title="Calendar" showLogo={false} />
|
<Header title="Calendar" showLogo={false} />
|
||||||
|
|
||||||
<ScrollView
|
<KeyboardAvoidingView style={styles.kbAvoid} behavior="padding">
|
||||||
ref={stripRef}
|
<ScrollView style={styles.scrollBody} showsVerticalScrollIndicator={false} contentContainerStyle={styles.scrollContent}>
|
||||||
horizontal
|
<GestureDetector gesture={pan}>
|
||||||
showsHorizontalScrollIndicator={false}
|
<Animated.View
|
||||||
contentContainerStyle={styles.dateStrip}
|
onLayout={(e) => {
|
||||||
|
gridWidthRef.current = e.nativeEvent.layout.width;
|
||||||
|
}}
|
||||||
|
style={[styles.calendarArea, { transform: [{ translateX }] }]}
|
||||||
>
|
>
|
||||||
{days.map((day) => {
|
<View style={styles.monthRow}>
|
||||||
const isSelected = isSameDay(day, selectedDate);
|
|
||||||
const isCurrent = isToday(day);
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={day.toISOString()}
|
onPress={() => transitionTo(-1)}
|
||||||
style={[
|
style={[styles.monthNav, { borderColor: theme.borderStrong, backgroundColor: theme.card }]}
|
||||||
styles.dayButton,
|
|
||||||
{ backgroundColor: theme.card, borderColor: theme.borderStrong },
|
|
||||||
isCurrent && !isSelected && { borderColor: theme.accent, borderWidth: 1.5 },
|
|
||||||
isSelected && { backgroundColor: theme.accent, borderColor: theme.accent },
|
|
||||||
]}
|
|
||||||
onPress={() => handleDayPress(day)}
|
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
<Text style={[styles.dayWeekday, { color: theme.textMuted }, isSelected && styles.dayTextSelected]}>
|
|
||||||
{format(day, 'EEE').charAt(0)}
|
|
||||||
</Text>
|
|
||||||
<Text style={[styles.dayNumber, { color: theme.text }, isSelected && styles.dayTextSelected]}>
|
|
||||||
{format(day, 'd')}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ScrollView>
|
|
||||||
|
|
||||||
<View style={styles.monthRow}>
|
|
||||||
<TouchableOpacity onPress={handlePrevMonth} style={[styles.monthNav, { borderColor: theme.borderStrong, backgroundColor: theme.card }]} activeOpacity={0.7}>
|
|
||||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||||
<Path d="M15 18l-6-6 6-6" stroke={theme.textFaint} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
<Path d="M15 18l-6-6 6-6" stroke={theme.textSecondary} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||||
</Svg>
|
</Svg>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<Text style={[styles.monthLabel, { color: theme.text }]}>{format(visibleMonth, 'MMM yyyy').toUpperCase()}</Text>
|
|
||||||
<TouchableOpacity onPress={handleNextMonth} style={[styles.monthNav, { borderColor: theme.borderStrong, backgroundColor: theme.card }]} activeOpacity={0.7}>
|
<View style={styles.monthSelectorGroup}>
|
||||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
<TouchableOpacity onPress={() => setMonthPickerVisible(true)} activeOpacity={0.7} style={styles.monthButton}>
|
||||||
<Path d="M9 6l6 6-6 6" stroke={theme.textFaint} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
<Text style={[styles.monthLabel, { color: theme.text }]}>{format(visibleMonth, 'MMMM')}</Text>
|
||||||
|
<Svg width={14} height={14} 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>
|
</Svg>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<FlatList
|
<TouchableOpacity onPress={() => setYearPickerVisible(true)} activeOpacity={0.7} style={styles.yearButton}>
|
||||||
data={tasks}
|
<Text style={[styles.yearLabel, { color: theme.textMuted }]}>{format(visibleMonth, 'yyyy')}</Text>
|
||||||
keyExtractor={(item) => item.id}
|
</TouchableOpacity>
|
||||||
renderItem={({ item }) => (
|
|
||||||
<TaskItem
|
<TouchableOpacity
|
||||||
task={item as TaskData}
|
onPress={() => transitionTo(1)}
|
||||||
onToggle={() => handleToggleComplete(item.id)}
|
style={[styles.monthNav, { borderColor: theme.borderStrong, backgroundColor: theme.card }]}
|
||||||
onPress={() => router.push({ pathname: '/task-detail', params: { id: item.id } })}
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<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 style={styles.weekdayRow}>
|
||||||
|
{GERMAN_WEEKDAYS.map((day, i) => (
|
||||||
|
<Text key={day + i} style={[styles.weekday, { color: theme.textMuted }]}>
|
||||||
|
{day}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{weeks.map((week, wi) => (
|
||||||
|
<View key={wi} style={styles.weekRow}>
|
||||||
|
{week.map((day) => (
|
||||||
|
<DayCell
|
||||||
|
key={day.toISOString()}
|
||||||
|
day={day}
|
||||||
|
label={cellTitle(byDayOf(monthTasks.byDay, day))}
|
||||||
|
dotColor={cellColor(byDayOf(monthTasks.byDay, day), categories)}
|
||||||
|
selected={isSameDay(day, selectedDate)}
|
||||||
|
today={isToday(day)}
|
||||||
|
inMonth={isSameMonth(day, visibleMonth)}
|
||||||
|
theme={theme}
|
||||||
|
onPress={handleDayPress}
|
||||||
/>
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</Animated.View>
|
||||||
|
</GestureDetector>
|
||||||
|
|
||||||
|
<View style={styles.panelHeader}>
|
||||||
|
<Text style={[styles.panelDate, { color: theme.text }]}>{format(selectedDate, 'EEEE, MMMM d')}</Text>
|
||||||
|
{selectedDayTasks.length > 0 && (
|
||||||
|
<Text style={[styles.panelCount, { color: theme.textFaint }]}>
|
||||||
|
{selectedDayTasks.length} event{selectedDayTasks.length === 1 ? '' : 's'}
|
||||||
|
</Text>
|
||||||
)}
|
)}
|
||||||
ItemSeparatorComponent={() => <View style={styles.separator} />}
|
</View>
|
||||||
ListEmptyComponent={
|
|
||||||
loading ? (
|
{selectedDayTasks.length === 0 ? (
|
||||||
<View style={styles.emptyState}>
|
<View style={styles.emptyState}>
|
||||||
<Text style={[styles.emptyText, { color: theme.textFaint }]}>Loading...</Text>
|
<Text style={[styles.emptyText, { color: theme.textSecondary }]}>No events on this day</Text>
|
||||||
|
<Text style={[styles.emptySubtext, { color: theme.textMuted }]}>Tap a date or use the bar below</Text>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<View style={styles.emptyState}>
|
selectedDayTasks.map((task) => (
|
||||||
<Text style={[styles.emptyText, { color: theme.textSecondary }]}>No tasks scheduled.</Text>
|
<View key={task.id} style={[styles.eventCard, { backgroundColor: theme.card, borderColor: theme.border }]}>
|
||||||
<Text style={[styles.emptySubtext, { color: theme.textMuted }]}>Use the bar below to add one</Text>
|
<View style={styles.eventRow}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.checkCircle}
|
||||||
|
onPress={() => handleToggleComplete(task.id)}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<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.eventTitleTouch}
|
||||||
|
onPress={() => router.push({ pathname: '/task-detail', params: { id: task.id } })}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[styles.eventTitle, { color: theme.text }, task.completed && styles.eventCompleted]}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{task.title}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity style={styles.menuButton} onPress={() => openTaskEdit(task.id)} activeOpacity={0.7} accessibilityRole="button" accessibilityLabel={`Edit ${task.title}`}>
|
||||||
|
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||||
|
<Path d="M9 6l6 6-6 6" stroke={theme.textSecondary} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||||
|
</Svg>
|
||||||
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
)
|
|
||||||
}
|
{(subtasksByTask.get(task.id) ?? []).length > 0 && (
|
||||||
contentContainerStyle={styles.listContent}
|
<View style={styles.bullets}>
|
||||||
/>
|
{(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>
|
||||||
|
<Text
|
||||||
|
style={[styles.bulletText, { color: theme.textFaint }, sub.completed && styles.bulletCompleted]}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{sub.title}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
<QuickAddBar
|
<QuickAddBar
|
||||||
dueDate={selectedDate.getTime()}
|
dueDate={selectedDate.getTime()}
|
||||||
placeholder={`Add task for ${format(selectedDate, 'MMM d')}`}
|
placeholder={`Add event for ${format(selectedDate, 'MMM d')}`}
|
||||||
|
/>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
|
||||||
|
{modals(() => {})}
|
||||||
|
|
||||||
|
<OptionPickerModal
|
||||||
|
visible={monthPickerVisible}
|
||||||
|
title="Select Month"
|
||||||
|
options={monthOptions}
|
||||||
|
selectedValue={String(visibleMonth.getMonth())}
|
||||||
|
onSelect={handleSelectMonth}
|
||||||
|
onClose={() => setMonthPickerVisible(false)}
|
||||||
|
/>
|
||||||
|
<OptionPickerModal
|
||||||
|
visible={yearPickerVisible}
|
||||||
|
title="Select Year"
|
||||||
|
options={yearOptions}
|
||||||
|
selectedValue={String(visibleMonth.getFullYear())}
|
||||||
|
onSelect={handleSelectYear}
|
||||||
|
onClose={() => setYearPickerVisible(false)}
|
||||||
/>
|
/>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function byDayOf(byDay: Record<number, Task[]>, day: Date): Task[] {
|
||||||
|
return byDay[day.getDate()] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function cellTitle(tasks: Task[]): string | null {
|
||||||
|
if (tasks.length === 0) return null;
|
||||||
|
if (tasks.length === 1) return tasks[0].title;
|
||||||
|
return `${tasks[0].title} +${tasks.length - 1}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cellColor(tasks: Task[], categories: { id: string; color: string }[]): string {
|
||||||
|
if (tasks.length === 0) return '#8E8E8E';
|
||||||
|
const cat = categories.find((c) => c.id === tasks[0].categoryId);
|
||||||
|
return cat ? desaturate(cat.color, 0.3) : '#8E8E8E';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DayCellProps {
|
||||||
|
day: Date;
|
||||||
|
label: string | null;
|
||||||
|
dotColor: string;
|
||||||
|
selected: boolean;
|
||||||
|
today: boolean;
|
||||||
|
inMonth: boolean;
|
||||||
|
theme: ThemeColors;
|
||||||
|
onPress: (day: Date) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DayCell = React.memo(function DayCell({ day, label, dotColor, selected, today, inMonth, theme, onPress }: DayCellProps) {
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.dayCell, selected && { backgroundColor: theme.accentSoft, borderColor: theme.accentBorder }]}
|
||||||
|
onPress={() => onPress(day)}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.dayNumber,
|
||||||
|
{ color: theme.text },
|
||||||
|
!inMonth && { color: theme.textMuted },
|
||||||
|
today && !selected && { color: theme.accent },
|
||||||
|
selected && styles.dayNumberSelected,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{format(day, 'd')}
|
||||||
|
</Text>
|
||||||
|
{label ? (
|
||||||
|
<View style={[styles.chip, { backgroundColor: selected ? 'rgba(0,0,0,0.22)' : dotColor }]}>
|
||||||
|
<Text style={styles.chipText} numberOfLines={1}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : !inMonth ? (
|
||||||
|
<View style={[styles.chipPlaceholder, { backgroundColor: theme.borderStrong }]} />
|
||||||
|
) : null}
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
dateStrip: {
|
scrollContent: {
|
||||||
paddingHorizontal: 16,
|
paddingBottom: 16,
|
||||||
paddingTop: 12,
|
flexGrow: 1,
|
||||||
gap: DAY_GAP,
|
|
||||||
},
|
},
|
||||||
dayButton: {
|
scrollBody: {
|
||||||
width: DAY_WIDTH,
|
flex: 1,
|
||||||
height: 60,
|
|
||||||
borderRadius: 16,
|
|
||||||
borderWidth: 1,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
gap: 2,
|
|
||||||
},
|
},
|
||||||
dayTextSelected: {
|
kbAvoid: {
|
||||||
color: '#FFFFFF',
|
flex: 1,
|
||||||
},
|
},
|
||||||
dayWeekday: {
|
calendarArea: {
|
||||||
fontSize: 11,
|
paddingHorizontal: 12,
|
||||||
fontWeight: '600',
|
paddingTop: 6,
|
||||||
textTransform: 'uppercase',
|
paddingBottom: 4,
|
||||||
},
|
|
||||||
dayNumber: {
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: '600',
|
|
||||||
},
|
},
|
||||||
monthRow: {
|
monthRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'space-between',
|
||||||
gap: 24,
|
paddingHorizontal: 4,
|
||||||
paddingVertical: 12,
|
paddingBottom: 14,
|
||||||
},
|
},
|
||||||
monthNav: {
|
monthNav: {
|
||||||
width: 36,
|
width: 36,
|
||||||
@@ -198,34 +431,160 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
monthLabel: {
|
monthSelectorGroup: {
|
||||||
fontSize: 15,
|
alignItems: 'flex-start',
|
||||||
fontWeight: '700',
|
|
||||||
letterSpacing: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
textAlign: 'center',
|
|
||||||
},
|
|
||||||
listContent: {
|
|
||||||
paddingHorizontal: 16,
|
|
||||||
paddingTop: 4,
|
|
||||||
paddingBottom: 100,
|
|
||||||
flexGrow: 1,
|
|
||||||
},
|
|
||||||
separator: {
|
|
||||||
height: 8,
|
|
||||||
},
|
|
||||||
emptyState: {
|
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
},
|
||||||
|
monthButton: {
|
||||||
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
paddingVertical: 64,
|
gap: 6,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
},
|
||||||
|
monthLabel: {
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
yearButton: {
|
||||||
|
marginTop: -2,
|
||||||
|
paddingHorizontal: 6,
|
||||||
|
},
|
||||||
|
yearLabel: {
|
||||||
|
fontSize: 17,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
weekdayRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
marginBottom: 4,
|
||||||
|
paddingHorizontal: 2,
|
||||||
|
},
|
||||||
|
weekday: {
|
||||||
|
flex: 1,
|
||||||
|
textAlign: 'center',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: '600',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
},
|
||||||
|
weekRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 6,
|
||||||
|
marginBottom: 6,
|
||||||
|
},
|
||||||
|
dayCell: {
|
||||||
|
flex: 1,
|
||||||
|
height: 56,
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'transparent',
|
||||||
|
paddingVertical: 4,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
dayNumber: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
dayNumberSelected: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
},
|
||||||
|
chip: {
|
||||||
|
marginTop: 3,
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: 5,
|
||||||
|
maxWidth: '92%',
|
||||||
|
},
|
||||||
|
chipText: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontSize: 8,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
chipPlaceholder: {
|
||||||
|
marginTop: 3,
|
||||||
|
width: 6,
|
||||||
|
height: 2,
|
||||||
|
borderRadius: 1,
|
||||||
|
opacity: 0.4,
|
||||||
|
},
|
||||||
|
panelHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingHorizontal: 18,
|
||||||
|
paddingTop: 15,
|
||||||
|
paddingBottom: 4,
|
||||||
|
},
|
||||||
|
panelDate: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
panelCount: {
|
||||||
|
fontSize: 13,
|
||||||
|
},
|
||||||
|
emptyState: {
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 36,
|
||||||
},
|
},
|
||||||
emptyText: {
|
emptyText: {
|
||||||
fontSize: 16,
|
fontSize: 15,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
marginBottom: 4,
|
marginBottom: 4,
|
||||||
},
|
},
|
||||||
emptySubtext: {
|
emptySubtext: {
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
},
|
},
|
||||||
|
eventCard: {
|
||||||
|
marginHorizontal: 16,
|
||||||
|
marginTop: 6,
|
||||||
|
borderRadius: 16,
|
||||||
|
borderWidth: 1,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 12,
|
||||||
|
},
|
||||||
|
eventRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
checkCircle: {
|
||||||
|
width: 30,
|
||||||
|
marginRight: 12,
|
||||||
|
},
|
||||||
|
eventTitleTouch: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
eventTitle: {
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
eventCompleted: {
|
||||||
|
textDecorationLine: 'line-through',
|
||||||
|
color: '#9E9E9E',
|
||||||
|
},
|
||||||
|
menuButton: {
|
||||||
|
padding: 4,
|
||||||
|
marginLeft: 6,
|
||||||
|
},
|
||||||
|
bullets: {
|
||||||
|
marginTop: 8,
|
||||||
|
paddingTop: 8,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: 'rgba(255,255,255,0.06)',
|
||||||
|
gap: 6,
|
||||||
|
},
|
||||||
|
bulletRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
bulletDot: {
|
||||||
|
marginLeft: 8,
|
||||||
|
},
|
||||||
|
bulletText: {
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 13,
|
||||||
|
},
|
||||||
|
bulletCompleted: {
|
||||||
|
textDecorationLine: 'line-through',
|
||||||
|
color: '#6E6E6E',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
@@ -1,17 +1,27 @@
|
|||||||
import React from 'react';
|
import React, { useCallback, useMemo, useState } from 'react';
|
||||||
import { View, Text, StyleSheet, SafeAreaView } from 'react-native';
|
import { View, Text, StyleSheet, SafeAreaView, BackHandler, KeyboardAvoidingView, TouchableOpacity } from 'react-native';
|
||||||
|
import { useFocusEffect } from 'expo-router';
|
||||||
import { Header } from '@/components/Header';
|
import { Header } from '@/components/Header';
|
||||||
import { CategoryFilter } from '@/components/CategoryFilter';
|
import { CategoryFilter } from '@/components/CategoryFilter';
|
||||||
import { TaskList } from '@/components/TaskList';
|
import { TaskList } from '@/components/TaskList';
|
||||||
import { QuickAddBar } from '@/components/QuickAddBar';
|
import { QuickAddBar } from '@/components/QuickAddBar';
|
||||||
import { useDatabase } from '@/hooks/useDatabase';
|
import { useDatabase } from '@/hooks/useDatabase';
|
||||||
import { useSettings } from '@/theme';
|
import { useSettings } from '@/theme';
|
||||||
|
import Svg, { Path, Circle } from 'react-native-svg';
|
||||||
|
|
||||||
export default function TasksScreen() {
|
export default function TasksScreen() {
|
||||||
const { isReady } = useDatabase();
|
const { isReady } = useDatabase();
|
||||||
const { theme } = useSettings();
|
const { theme, showCompleted, setShowCompleted } = useSettings();
|
||||||
const [selectedCategory, setSelectedCategory] = React.useState<string>('all');
|
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
|
||||||
const [selectionActive, setSelectionActive] = React.useState(false);
|
|
||||||
|
const categoryIds = useMemo(() => selectedCategories, [selectedCategories]);
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
const sub = BackHandler.addEventListener('hardwareBackPress', () => true);
|
||||||
|
return () => sub.remove();
|
||||||
|
}, [])
|
||||||
|
);
|
||||||
|
|
||||||
if (!isReady) {
|
if (!isReady) {
|
||||||
return (
|
return (
|
||||||
@@ -23,10 +33,45 @@ export default function TasksScreen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
|
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
|
||||||
<Header title="TODO" showLogo={true} />
|
<Header title="ToDo" showLogo={false} />
|
||||||
<CategoryFilter selected={selectedCategory} onSelect={setSelectedCategory} />
|
<View style={styles.categoryFilterWrapper}>
|
||||||
<TaskList categoryId={selectedCategory} onSelectionChange={setSelectionActive} />
|
<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 categoryIds={categoryIds} showCompleted={showCompleted} />
|
||||||
<QuickAddBar />
|
<QuickAddBar />
|
||||||
|
</KeyboardAvoidingView>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -35,9 +80,27 @@ const styles = StyleSheet.create({
|
|||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
loadingContainer: {
|
categoryFilterWrapper: {
|
||||||
flex: 1,
|
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useCallback } from 'react';
|
||||||
import { View, Text, StyleSheet, SafeAreaView, ScrollView, Switch, TouchableOpacity } from 'react-native';
|
import { View, Text, StyleSheet, SafeAreaView, ScrollView, Switch, TouchableOpacity, Alert, Linking, Modal, Pressable, BackHandler } from 'react-native';
|
||||||
|
import { useFocusEffect, useRouter } from 'expo-router';
|
||||||
import { Header } from '@/components/Header';
|
import { Header } from '@/components/Header';
|
||||||
import { ListItem } from '@/components/ListItem';
|
import { ListItem } from '@/components/ListItem';
|
||||||
import { OptionPickerModal } from '@/components/OptionPickerModal';
|
import { OptionPickerModal } from '@/components/OptionPickerModal';
|
||||||
@@ -7,23 +8,53 @@ import { CategoryEditorModal } from '@/components/CategoryEditorModal';
|
|||||||
import { SyncModal } from '@/components/SyncModal';
|
import { SyncModal } from '@/components/SyncModal';
|
||||||
import { FriendsModal } from '@/components/FriendsModal';
|
import { FriendsModal } from '@/components/FriendsModal';
|
||||||
import { LegalModal } from '@/components/LegalModal';
|
import { LegalModal } from '@/components/LegalModal';
|
||||||
|
import { ServerUrlModal } from '@/components/ServerUrlModal';
|
||||||
import SyncStatus from '@/components/SyncStatus';
|
import SyncStatus from '@/components/SyncStatus';
|
||||||
import { useSettings, SORT_OPTIONS, REMINDER_OPTIONS } from '@/theme';
|
import { useSettings, SORT_OPTIONS, REMINDER_OPTIONS, ACCENT_PRESETS, DEFAULT_ACCENT } from '@/theme';
|
||||||
import { useCategories } from '@/hooks/useDatabase';
|
import { useCategories } from '@/hooks/useDatabase';
|
||||||
import { getAuthUser, getAuthToken } from '@/services/auth';
|
import { getAuthUser, getAuthToken } from '@/services/auth';
|
||||||
|
import { checkForUpdates, getCurrentAppVersion } from '@/services/updates';
|
||||||
import { getLastSyncTime } from '@/database/sync';
|
import { getLastSyncTime } from '@/database/sync';
|
||||||
import Category from '@/models/Category';
|
import Category from '@/models/Category';
|
||||||
import Svg, { Path } from 'react-native-svg';
|
import Svg, { Path } from 'react-native-svg';
|
||||||
|
import ColorWheel from '@/components/ColorWheel';
|
||||||
|
import { getLastVisitedTab, tabHref } from '@/utils/tabHistory';
|
||||||
|
|
||||||
export default function SettingsScreen() {
|
export default function SettingsScreen() {
|
||||||
const { theme, notifications, setNotifications, defaultCategoryId, setDefaultCategoryId, sortBy, setSortBy, reminderPreference, setReminderPreference } = useSettings();
|
const router = useRouter();
|
||||||
|
const { theme, notifications, setNotifications, defaultCategoryId, setDefaultCategoryId, sortBy, setSortBy, reminderPreference, setReminderPreference, apiUrl, accentColor, setAccentColor, todoAheadDays, setTodoAheadDays, showCompleted, setShowCompleted } = useSettings();
|
||||||
const categories = useCategories();
|
const categories = useCategories();
|
||||||
const [picker, setPicker] = useState<null | 'category' | 'sort' | 'reminder'>(null);
|
const [picker, setPicker] = useState<null | 'category' | 'sort' | 'reminder' | 'todoAhead'>(null);
|
||||||
const [editingCategory, setEditingCategory] = useState<Category | null | 'new'>(null);
|
const [editingCategory, setEditingCategory] = useState<Category | null | 'new'>(null);
|
||||||
const [syncVisible, setSyncVisible] = useState(false);
|
const [syncVisible, setSyncVisible] = useState(false);
|
||||||
const [friendsVisible, setFriendsVisible] = useState(false);
|
const [friendsVisible, setFriendsVisible] = useState(false);
|
||||||
const [legalVisible, setLegalVisible] = useState<null | 'privacy' | 'terms'>(null);
|
const [legalVisible, setLegalVisible] = useState<null | 'privacy' | 'terms'>(null);
|
||||||
|
const [serverUrlVisible, setServerUrlVisible] = useState(false);
|
||||||
|
const [customAccentVisible, setCustomAccentVisible] = useState(false);
|
||||||
|
const [draftAccent, setDraftAccent] = useState(accentColor);
|
||||||
const [syncSubtitle, setSyncSubtitle] = useState('Checking...');
|
const [syncSubtitle, setSyncSubtitle] = useState('Checking...');
|
||||||
|
const [updateSubtitle, setUpdateSubtitle] = useState('Tap to check');
|
||||||
|
|
||||||
|
const handleCheckUpdates = async () => {
|
||||||
|
setUpdateSubtitle('Checking...');
|
||||||
|
const update = await checkForUpdates();
|
||||||
|
if (!update) {
|
||||||
|
setUpdateSubtitle('Up to date');
|
||||||
|
Alert.alert('Up to date', `You're running the latest version (${getCurrentAppVersion()}).`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setUpdateSubtitle(`${update.version} available`);
|
||||||
|
const url = update.apkUrl ?? update.releaseUrl;
|
||||||
|
Alert.alert('Update available', `Version ${update.version} is available for download.`, [
|
||||||
|
{ text: 'Later', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Download',
|
||||||
|
onPress: () => {
|
||||||
|
if (url) Linking.openURL(url).catch(() => {});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
const refreshSyncStatus = async () => {
|
const refreshSyncStatus = async () => {
|
||||||
const token = await getAuthToken();
|
const token = await getAuthToken();
|
||||||
@@ -43,19 +74,42 @@ export default function SettingsScreen() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
refreshSyncStatus();
|
refreshSyncStatus();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
const sub = BackHandler.addEventListener('hardwareBackPress', () => {
|
||||||
|
const target = getLastVisitedTab();
|
||||||
|
if (target && target !== 'settings') {
|
||||||
|
router.navigate(tabHref(target));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
return () => sub.remove();
|
||||||
|
}, [router])
|
||||||
|
);
|
||||||
|
|
||||||
const defaultCategory = categories.find((c) => c.id === defaultCategoryId);
|
const defaultCategory = categories.find((c) => c.id === defaultCategoryId);
|
||||||
const defaultCategoryLabel = defaultCategory?.name ?? (categories.length > 0 ? categories[0].name : 'None');
|
const defaultCategoryLabel = defaultCategory?.name ?? 'None';
|
||||||
const reminderLabel = REMINDER_OPTIONS.find((o) => o.value === reminderPreference)?.label ?? 'No reminder';
|
const reminderLabel = REMINDER_OPTIONS.find((o) => o.value === reminderPreference)?.label ?? 'No reminder';
|
||||||
|
|
||||||
const handleDefaultCategory = (value: string) => {
|
const openCustomAccent = () => {
|
||||||
setDefaultCategoryId(value);
|
setDraftAccent(accentColor);
|
||||||
|
setCustomAccentVisible(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReminderPreference = (value: string) => {
|
const handleDefaultCategory = (value: string | string[]) => {
|
||||||
setReminderPreference(value as typeof reminderPreference);
|
setDefaultCategoryId(Array.isArray(value) ? value[0] : value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReminderPreference = (value: string | string[]) => {
|
||||||
|
setReminderPreference((Array.isArray(value) ? value[0] : value) as typeof reminderPreference);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTodoAheadDays = (value: string | string[]) => {
|
||||||
|
setTodoAheadDays(parseInt(Array.isArray(value) ? value[0] : value, 10));
|
||||||
};
|
};
|
||||||
|
|
||||||
const editorVisible = editingCategory !== null;
|
const editorVisible = editingCategory !== null;
|
||||||
@@ -85,7 +139,7 @@ export default function SettingsScreen() {
|
|||||||
>
|
>
|
||||||
<View style={[styles.addIcon, { backgroundColor: theme.accentSoft }]}>
|
<View style={[styles.addIcon, { backgroundColor: theme.accentSoft }]}>
|
||||||
<Svg width={16} height={16} viewBox="0 0 24 24">
|
<Svg width={16} height={16} viewBox="0 0 24 24">
|
||||||
<Path d="M12 5v14M5 12h14" stroke={theme.accent} strokeWidth={2} strokeLinecap="round" />
|
<Path d="M12 5v14M5 12h14" stroke={theme.accentText} strokeWidth={2} strokeLinecap="round" />
|
||||||
</Svg>
|
</Svg>
|
||||||
</View>
|
</View>
|
||||||
<Text style={[styles.addCategoryText, { color: theme.accent }]}>Add Category</Text>
|
<Text style={[styles.addCategoryText, { color: theme.accent }]}>Add Category</Text>
|
||||||
@@ -122,6 +176,53 @@ export default function SettingsScreen() {
|
|||||||
onPress={() => setPicker('sort')}
|
onPress={() => setPicker('sort')}
|
||||||
showChevron
|
showChevron
|
||||||
/>
|
/>
|
||||||
|
<ListItem
|
||||||
|
title="Show Completed Tasks"
|
||||||
|
subtitle="Hide or show finished tasks in the Todo list"
|
||||||
|
rightElement={
|
||||||
|
<Switch
|
||||||
|
value={showCompleted}
|
||||||
|
onValueChange={setShowCompleted}
|
||||||
|
thumbColor="#FFFFFF"
|
||||||
|
trackColor={{ false: theme.borderStrong, true: theme.accent }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ListItem
|
||||||
|
title="Show Calendar Tasks"
|
||||||
|
subtitle={todoAheadDays === 0 ? 'Only today' : `Up to ${todoAheadDays} days ahead`}
|
||||||
|
onPress={() => setPicker('todoAhead')}
|
||||||
|
showChevron
|
||||||
|
/>
|
||||||
|
<Text style={[styles.sectionTitle, { color: theme.textMuted }]}>Appearance</Text>
|
||||||
|
<Text style={[styles.sectionHint, { color: theme.textFaint }]}>Accent color</Text>
|
||||||
|
<View style={styles.accentPresets}>
|
||||||
|
{ACCENT_PRESETS.map((c) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={c}
|
||||||
|
style={[
|
||||||
|
styles.accentSwatch,
|
||||||
|
{ backgroundColor: c },
|
||||||
|
accentColor.toUpperCase() === c && styles.accentSwatchSelected,
|
||||||
|
]}
|
||||||
|
onPress={() => setAccentColor(c)}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
{accentColor.toUpperCase() === c && (
|
||||||
|
<Svg width={16} height={16} viewBox="0 0 24 24">
|
||||||
|
<Path d="M5 12l5 5 9-10" stroke={contrastOnSwatch(c)} strokeWidth={3} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||||
|
</Svg>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
<ListItem
|
||||||
|
title="Custom Color"
|
||||||
|
subtitle={accentColor}
|
||||||
|
leftElement={<View style={[styles.categoryDot, { backgroundColor: accentColor }]} />}
|
||||||
|
onPress={openCustomAccent}
|
||||||
|
showChevron
|
||||||
|
/>
|
||||||
<Text style={[styles.sectionTitle, { color: theme.textMuted }]}>Data</Text>
|
<Text style={[styles.sectionTitle, { color: theme.textMuted }]}>Data</Text>
|
||||||
<SyncStatus />
|
<SyncStatus />
|
||||||
<ListItem
|
<ListItem
|
||||||
@@ -136,10 +237,23 @@ export default function SettingsScreen() {
|
|||||||
onPress={() => setFriendsVisible(true)}
|
onPress={() => setFriendsVisible(true)}
|
||||||
showChevron
|
showChevron
|
||||||
/>
|
/>
|
||||||
|
<Text style={[styles.sectionTitle, { color: theme.textMuted }]}>Server</Text>
|
||||||
|
<ListItem
|
||||||
|
title="Backend URL"
|
||||||
|
subtitle={apiUrl}
|
||||||
|
onPress={() => setServerUrlVisible(true)}
|
||||||
|
showChevron
|
||||||
|
/>
|
||||||
<Text style={[styles.sectionTitle, { color: theme.textMuted }]}>About</Text>
|
<Text style={[styles.sectionTitle, { color: theme.textMuted }]}>About</Text>
|
||||||
|
<ListItem
|
||||||
|
title="Check for Updates"
|
||||||
|
subtitle={updateSubtitle}
|
||||||
|
onPress={handleCheckUpdates}
|
||||||
|
showChevron
|
||||||
|
/>
|
||||||
<ListItem
|
<ListItem
|
||||||
title="Version"
|
title="Version"
|
||||||
subtitle="1.0.0"
|
subtitle={getCurrentAppVersion()}
|
||||||
/>
|
/>
|
||||||
<ListItem
|
<ListItem
|
||||||
title="Privacy Policy"
|
title="Privacy Policy"
|
||||||
@@ -156,8 +270,8 @@ export default function SettingsScreen() {
|
|||||||
<OptionPickerModal
|
<OptionPickerModal
|
||||||
visible={picker === 'category'}
|
visible={picker === 'category'}
|
||||||
title="Default Category"
|
title="Default Category"
|
||||||
options={categories.map((c) => ({ value: c.id, label: c.name, color: c.color }))}
|
options={[{ value: '', label: 'None', color: '#9E9E9E' }, ...categories.map((c) => ({ value: c.id, label: c.name, color: c.color }))]}
|
||||||
selectedValue={defaultCategoryId || categories[0]?.id}
|
selectedValue={defaultCategoryId}
|
||||||
onSelect={handleDefaultCategory}
|
onSelect={handleDefaultCategory}
|
||||||
onClose={() => setPicker(null)}
|
onClose={() => setPicker(null)}
|
||||||
/>
|
/>
|
||||||
@@ -180,6 +294,15 @@ export default function SettingsScreen() {
|
|||||||
onClose={() => setPicker(null)}
|
onClose={() => setPicker(null)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<OptionPickerModal
|
||||||
|
visible={picker === 'todoAhead'}
|
||||||
|
title="Calendar Tasks in Todo"
|
||||||
|
options={AHEAD_OPTIONS}
|
||||||
|
selectedValue={String(todoAheadDays)}
|
||||||
|
onSelect={handleTodoAheadDays}
|
||||||
|
onClose={() => setPicker(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
<CategoryEditorModal
|
<CategoryEditorModal
|
||||||
visible={editorVisible}
|
visible={editorVisible}
|
||||||
category={editorCategory}
|
category={editorCategory}
|
||||||
@@ -191,6 +314,43 @@ export default function SettingsScreen() {
|
|||||||
|
|
||||||
<FriendsModal visible={friendsVisible} onClose={() => setFriendsVisible(false)} />
|
<FriendsModal visible={friendsVisible} onClose={() => setFriendsVisible(false)} />
|
||||||
|
|
||||||
|
<ServerUrlModal visible={serverUrlVisible} onClose={() => setServerUrlVisible(false)} />
|
||||||
|
|
||||||
|
<Modal visible={customAccentVisible} transparent animationType="fade" onRequestClose={() => setCustomAccentVisible(false)}>
|
||||||
|
<Pressable
|
||||||
|
style={styles.accentModalOverlay}
|
||||||
|
onPress={() => setCustomAccentVisible(false)}
|
||||||
|
>
|
||||||
|
<Pressable style={[styles.accentModalSheet, { backgroundColor: theme.sheetBg }]} onPress={() => {}}>
|
||||||
|
<View style={styles.accentModalHeader}>
|
||||||
|
<Text style={[styles.accentModalTitle, { color: theme.text }]}>Custom Accent Color</Text>
|
||||||
|
<TouchableOpacity onPress={() => setCustomAccentVisible(false)} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
|
||||||
|
<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" />
|
||||||
|
</Svg>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
<ColorWheel color={draftAccent} onChange={setDraftAccent} />
|
||||||
|
<View style={styles.accentModalActions}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.accentModalButton, { borderColor: theme.borderStrong }]}
|
||||||
|
onPress={() => { setDraftAccent(DEFAULT_ACCENT); setAccentColor(DEFAULT_ACCENT); }}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Text style={[styles.accentModalButtonText, { color: theme.textSecondary }]}>Reset</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.accentModalButton, styles.accentModalButtonPrimary, { backgroundColor: theme.accent }]}
|
||||||
|
onPress={() => { setAccentColor(draftAccent); setCustomAccentVisible(false); }}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<Text style={[styles.accentModalButtonText, { color: theme.accentText }]}>Apply</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
</Pressable>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<LegalModal
|
<LegalModal
|
||||||
visible={legalVisible !== null}
|
visible={legalVisible !== null}
|
||||||
type={legalVisible}
|
type={legalVisible}
|
||||||
@@ -200,6 +360,11 @@ export default function SettingsScreen() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const AHEAD_OPTIONS: { value: string; label: string }[] = Array.from({ length: 29 }, (_, i) => ({
|
||||||
|
value: String(i),
|
||||||
|
label: i === 0 ? 'Only today' : `${i} day${i === 1 ? '' : 's'}`,
|
||||||
|
}));
|
||||||
|
|
||||||
function formatSyncTime(timestamp: number): string {
|
function formatSyncTime(timestamp: number): string {
|
||||||
const seconds = Math.floor((Date.now() - timestamp) / 1000);
|
const seconds = Math.floor((Date.now() - timestamp) / 1000);
|
||||||
if (seconds < 60) return 'just now';
|
if (seconds < 60) return 'just now';
|
||||||
@@ -208,12 +373,22 @@ function formatSyncTime(timestamp: number): string {
|
|||||||
return new Date(timestamp).toLocaleDateString();
|
return new Date(timestamp).toLocaleDateString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function contrastOnSwatch(hex: string): string {
|
||||||
|
const h = hex.replace(/^#/, '');
|
||||||
|
const r = parseInt(h.slice(0, 2), 16) / 255;
|
||||||
|
const g = parseInt(h.slice(2, 4), 16) / 255;
|
||||||
|
const b = parseInt(h.slice(4, 6), 16) / 255;
|
||||||
|
const linear = (v: number) => (v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4));
|
||||||
|
const luminance = 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b);
|
||||||
|
return luminance > 0.5 ? '#111111' : '#FFFFFF';
|
||||||
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
content: {
|
content: {
|
||||||
flex: 1,
|
flexGrow: 1,
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 16,
|
||||||
paddingTop: 8,
|
paddingTop: 8,
|
||||||
},
|
},
|
||||||
@@ -253,4 +428,73 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
|
sectionHint: {
|
||||||
|
fontSize: 12,
|
||||||
|
marginLeft: 4,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
accentPresets: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 10,
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
accentSwatch: {
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: 18,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
accentSwatchSelected: {
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: '#FFFFFF',
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOpacity: 0.3,
|
||||||
|
shadowRadius: 3,
|
||||||
|
elevation: 3,
|
||||||
|
},
|
||||||
|
accentModalOverlay: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.6)',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: 24,
|
||||||
|
},
|
||||||
|
accentModalSheet: {
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: 400,
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 20,
|
||||||
|
},
|
||||||
|
accentModalHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
accentModalTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
accentModalActions: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 12,
|
||||||
|
marginTop: 16,
|
||||||
|
},
|
||||||
|
accentModalButton: {
|
||||||
|
flex: 1,
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
accentModalButtonPrimary: {
|
||||||
|
borderWidth: 0,
|
||||||
|
},
|
||||||
|
accentModalButtonText: {
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import React, { useCallback } from 'react';
|
||||||
|
import { View, Text, StyleSheet, SafeAreaView, ScrollView, BackHandler } from 'react-native';
|
||||||
|
import { useFocusEffect } from 'expo-router';
|
||||||
|
import { Header } from '@/components/Header';
|
||||||
|
import { useStats } from '@/hooks/useStats';
|
||||||
|
import { useSettings } from '@/theme';
|
||||||
|
|
||||||
|
export default function StatsScreen() {
|
||||||
|
const { theme } = useSettings();
|
||||||
|
const stats = useStats();
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
const sub = BackHandler.addEventListener('hardwareBackPress', () => true);
|
||||||
|
return () => sub.remove();
|
||||||
|
}, [])
|
||||||
|
);
|
||||||
|
|
||||||
|
const maxDaily = Math.max(1, ...stats.daily.map((d) => d.count));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
|
||||||
|
<Header title="Stats" showLogo={true} />
|
||||||
|
<ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
|
||||||
|
<View style={[styles.card, { backgroundColor: theme.card }]}>
|
||||||
|
<Text style={[styles.cardTitle, { color: theme.textMuted }]}>Overview</Text>
|
||||||
|
<View style={styles.summaryGrid}>
|
||||||
|
<View style={styles.summaryItem}>
|
||||||
|
<Text style={[styles.summaryValue, { color: theme.accent }]}>{stats.totalCompleted}</Text>
|
||||||
|
<Text style={[styles.summaryLabel, { color: theme.textMuted }]}>Completed</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.summaryItem}>
|
||||||
|
<Text style={[styles.summaryValue, { color: theme.text }]}>{stats.completedLast7}</Text>
|
||||||
|
<Text style={[styles.summaryLabel, { color: theme.textMuted }]}>Last 7 days</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.summaryItem}>
|
||||||
|
<Text style={[styles.summaryValue, { color: theme.text }]}>{stats.completedLast30}</Text>
|
||||||
|
<Text style={[styles.summaryLabel, { color: theme.textMuted }]}>Last 30 days</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.summaryItem}>
|
||||||
|
<Text style={[styles.summaryValue, { color: theme.text }]}>{stats.completionRate}%</Text>
|
||||||
|
<Text style={[styles.summaryLabel, { color: theme.textMuted }]}>Success rate</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.summaryItem}>
|
||||||
|
<Text style={[styles.summaryValue, { color: theme.text }]}>{stats.currentStreak}</Text>
|
||||||
|
<Text style={[styles.summaryLabel, { color: theme.textMuted }]}>Day streak</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.summaryItem}>
|
||||||
|
<Text style={[styles.summaryValue, { color: stats.overdueCount > 0 ? theme.accent : theme.text }]}>
|
||||||
|
{stats.overdueCount}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.summaryLabel, { color: theme.textMuted }]}>Overdue</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={[styles.card, { backgroundColor: theme.card }]}>
|
||||||
|
<Text style={[styles.cardTitle, { color: theme.textMuted }]}>Last 7 days</Text>
|
||||||
|
<View style={styles.chartRow}>
|
||||||
|
{stats.daily.map((d, i) => {
|
||||||
|
const height = (d.count / maxDaily) * BASE_HEIGHT;
|
||||||
|
return (
|
||||||
|
<View key={i} style={styles.chartCol}>
|
||||||
|
<View style={styles.chartBarTrack}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.chartBar,
|
||||||
|
{ height: Math.max(4, height), backgroundColor: d.count > 0 ? theme.accent : theme.cardAlt },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text style={[styles.chartLabel, { color: theme.textFaint }]}>{d.label}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={[styles.card, { backgroundColor: theme.card }]}>
|
||||||
|
<Text style={[styles.cardTitle, { color: theme.textMuted }]}>By category</Text>
|
||||||
|
{stats.byCategory.length === 0 ? (
|
||||||
|
<Text style={[styles.emptyText, { color: theme.textFaint }]}>No completed tasks yet</Text>
|
||||||
|
) : (
|
||||||
|
stats.byCategory.map((c) => (
|
||||||
|
<View key={c.id} style={styles.row}>
|
||||||
|
<View style={[styles.dot, { backgroundColor: c.color }]} />
|
||||||
|
<Text style={[styles.rowLabel, { color: theme.text }]}>{c.name}</Text>
|
||||||
|
<Text style={[styles.rowValue, { color: theme.textMuted }]}>{c.count}</Text>
|
||||||
|
</View>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={[styles.card, { backgroundColor: theme.card }]}>
|
||||||
|
<Text style={[styles.cardTitle, { color: theme.textMuted }]}>By priority</Text>
|
||||||
|
{stats.byPriority.length === 0 ? (
|
||||||
|
<Text style={[styles.emptyText, { color: theme.textFaint }]}>No completed tasks yet</Text>
|
||||||
|
) : (
|
||||||
|
stats.byPriority.map((p) => (
|
||||||
|
<View key={p.priority} style={styles.row}>
|
||||||
|
<View style={[styles.dot, { backgroundColor: priorityColor(p.priority) }]} />
|
||||||
|
<Text style={[styles.rowLabel, { color: theme.text }]}>{p.label}</Text>
|
||||||
|
<Text style={[styles.rowValue, { color: theme.textMuted }]}>{p.count}</Text>
|
||||||
|
</View>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE_HEIGHT = 90;
|
||||||
|
|
||||||
|
function priorityColor(priority: string): string {
|
||||||
|
switch (priority) {
|
||||||
|
case 'high':
|
||||||
|
return '#EF5350';
|
||||||
|
case 'medium':
|
||||||
|
return '#FFA726';
|
||||||
|
case 'low':
|
||||||
|
return '#66BB6A';
|
||||||
|
default:
|
||||||
|
return '#9E9E9E';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
padding: 16,
|
||||||
|
paddingBottom: 32,
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 16,
|
||||||
|
},
|
||||||
|
cardTitle: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '600',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
marginBottom: 14,
|
||||||
|
},
|
||||||
|
summaryGrid: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
},
|
||||||
|
summaryItem: {
|
||||||
|
width: '33.33%',
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
summaryValue: {
|
||||||
|
fontSize: 26,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
summaryLabel: {
|
||||||
|
fontSize: 12,
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
chartRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
chartCol: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
chartBarTrack: {
|
||||||
|
height: BASE_HEIGHT,
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
chartBar: {
|
||||||
|
width: '100%',
|
||||||
|
borderRadius: 6,
|
||||||
|
minHeight: 4,
|
||||||
|
},
|
||||||
|
chartLabel: {
|
||||||
|
fontSize: 11,
|
||||||
|
marginTop: 6,
|
||||||
|
},
|
||||||
|
row: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 8,
|
||||||
|
},
|
||||||
|
dot: {
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
borderRadius: 5,
|
||||||
|
marginRight: 10,
|
||||||
|
},
|
||||||
|
rowLabel: {
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 15,
|
||||||
|
},
|
||||||
|
rowValue: {
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,11 +1,31 @@
|
|||||||
import { Stack } from 'expo-router';
|
import { Stack, useRouter } from 'expo-router';
|
||||||
import { DatabaseProvider } from '@/hooks/useDatabase';
|
import { DatabaseProvider } from '@/hooks/useDatabase';
|
||||||
import { SettingsProvider } from '@/theme';
|
import { SettingsProvider } from '@/theme';
|
||||||
import { FriendsProvider } from '@/hooks/useFriends';
|
import { FriendsProvider } from '@/hooks/useFriends';
|
||||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||||
import { StatusBar } from 'expo-status-bar';
|
import { StatusBar } from 'expo-status-bar';
|
||||||
|
import { UpdateNotifier } from '@/components/UpdateNotifier';
|
||||||
|
import { Linking } from 'react-native';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { requestQuickAddFocus } from '@/utils/quickAddFocus';
|
||||||
|
|
||||||
function RootNavigator() {
|
function RootNavigator() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleUrl = (url: string | null) => {
|
||||||
|
if (!url || !url.includes('://quick-add')) return;
|
||||||
|
router.replace('/(tabs)');
|
||||||
|
requestQuickAddFocus();
|
||||||
|
};
|
||||||
|
|
||||||
|
Linking.getInitialURL().then(handleUrl).catch(() => {});
|
||||||
|
const subscription = Linking.addEventListener('url', ({ url }) => handleUrl(url));
|
||||||
|
return () => {
|
||||||
|
subscription.remove();
|
||||||
|
};
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
<StatusBar style="light" />
|
<StatusBar style="light" />
|
||||||
@@ -13,6 +33,7 @@ function RootNavigator() {
|
|||||||
<Stack.Screen name="(tabs)" />
|
<Stack.Screen name="(tabs)" />
|
||||||
<Stack.Screen name="add-task" />
|
<Stack.Screen name="add-task" />
|
||||||
<Stack.Screen name="task-detail" />
|
<Stack.Screen name="task-detail" />
|
||||||
|
<Stack.Screen name="day-view" />
|
||||||
</Stack>
|
</Stack>
|
||||||
</GestureHandlerRootView>
|
</GestureHandlerRootView>
|
||||||
);
|
);
|
||||||
@@ -23,6 +44,7 @@ export default function RootLayout() {
|
|||||||
<SettingsProvider>
|
<SettingsProvider>
|
||||||
<DatabaseProvider>
|
<DatabaseProvider>
|
||||||
<FriendsProvider>
|
<FriendsProvider>
|
||||||
|
<UpdateNotifier />
|
||||||
<RootNavigator />
|
<RootNavigator />
|
||||||
</FriendsProvider>
|
</FriendsProvider>
|
||||||
</DatabaseProvider>
|
</DatabaseProvider>
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ import { AssigneeSelector } from '@/components/AssigneeSelector';
|
|||||||
import { useForm, FormProvider, Controller } from 'react-hook-form';
|
import { useForm, FormProvider, Controller } from 'react-hook-form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { useDatabase, useCategories } from '@/hooks/useDatabase';
|
import { useDatabase } from '@/hooks/useDatabase';
|
||||||
import { database, collections } from '@/database';
|
import { database, collections } from '@/database';
|
||||||
import { TaskFormData } from '@/types';
|
import { TaskFormData, tagsToString } from '@/types';
|
||||||
import { useSettings } from '@/theme';
|
import { useSettings } from '@/theme';
|
||||||
import { scheduleTaskReminder } from '@/services/notifications';
|
import { scheduleTaskReminder } from '@/services/notifications';
|
||||||
import { useFriends } from '@/hooks/useFriends';
|
import { useFriends } from '@/hooks/useFriends';
|
||||||
@@ -25,7 +25,8 @@ import { useFriends } from '@/hooks/useFriends';
|
|||||||
const taskSchema = z.object({
|
const taskSchema = z.object({
|
||||||
title: z.string().trim().min(1, 'Task name is required').max(100),
|
title: z.string().trim().min(1, 'Task name is required').max(100),
|
||||||
description: z.string().max(1000).optional(),
|
description: z.string().max(1000).optional(),
|
||||||
categoryId: z.string().min(1, 'Category is required'),
|
categoryId: z.string().optional(),
|
||||||
|
tags: z.array(z.string()).optional(),
|
||||||
priority: z.enum(['none', 'low', 'medium', 'high', 'critical']),
|
priority: z.enum(['none', 'low', 'medium', 'high', 'critical']),
|
||||||
dueDate: z.date().nullable().optional(),
|
dueDate: z.date().nullable().optional(),
|
||||||
dueTime: z.string().optional(),
|
dueTime: z.string().optional(),
|
||||||
@@ -35,19 +36,19 @@ const taskSchema = z.object({
|
|||||||
repeatInterval: z.number().int().min(1).max(30).optional(),
|
repeatInterval: z.number().int().min(1).max(30).optional(),
|
||||||
repeatDays: z.array(z.number().int().min(0).max(6)).optional(),
|
repeatDays: z.array(z.number().int().min(0).max(6)).optional(),
|
||||||
reminder: z.enum(['none', 'at_time', '15', '30', '60', '120', '1440']),
|
reminder: z.enum(['none', 'at_time', '15', '30', '60', '120', '1440']),
|
||||||
|
reminders: z.string().optional(),
|
||||||
assigneeId: z.string().nullable().optional(),
|
assigneeId: z.string().nullable().optional(),
|
||||||
subtasks: z.array(z.object({ title: z.string(), _key: z.string().optional() })).optional(),
|
subtasks: z.array(z.object({ title: z.string(), _key: z.string().optional() })).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export default function AddTaskScreen() {
|
export default function AddTaskScreen() {
|
||||||
const { isReady } = useDatabase();
|
const { isReady } = useDatabase();
|
||||||
const categories = useCategories();
|
|
||||||
const { defaultCategoryId } = useSettings();
|
const { defaultCategoryId } = useSettings();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { theme } = useSettings();
|
const { theme } = useSettings();
|
||||||
const { date: dateParam } = useLocalSearchParams<{ date?: string }>();
|
const { date: dateParam } = useLocalSearchParams<{ date?: string }>();
|
||||||
const { friends } = useFriends();
|
const { friends } = useFriends();
|
||||||
const initialCategory = defaultCategoryId || categories[0]?.id || '';
|
const initialCategory = defaultCategoryId || '';
|
||||||
const initialDate = useMemo(() => {
|
const initialDate = useMemo(() => {
|
||||||
if (!dateParam) return null;
|
if (!dateParam) return null;
|
||||||
const parsed = new Date(Array.isArray(dateParam) ? dateParam[0] : dateParam);
|
const parsed = new Date(Array.isArray(dateParam) ? dateParam[0] : dateParam);
|
||||||
@@ -60,6 +61,7 @@ export default function AddTaskScreen() {
|
|||||||
title: '',
|
title: '',
|
||||||
description: '',
|
description: '',
|
||||||
categoryId: initialCategory,
|
categoryId: initialCategory,
|
||||||
|
tags: initialCategory ? [initialCategory] : [],
|
||||||
priority: 'none',
|
priority: 'none',
|
||||||
dueDate: initialDate,
|
dueDate: initialDate,
|
||||||
dueTime: '',
|
dueTime: '',
|
||||||
@@ -69,6 +71,7 @@ export default function AddTaskScreen() {
|
|||||||
repeatInterval: 1,
|
repeatInterval: 1,
|
||||||
repeatDays: [],
|
repeatDays: [],
|
||||||
reminder: 'none',
|
reminder: 'none',
|
||||||
|
reminders: '',
|
||||||
assigneeId: null,
|
assigneeId: null,
|
||||||
subtasks: [],
|
subtasks: [],
|
||||||
},
|
},
|
||||||
@@ -82,20 +85,21 @@ export default function AddTaskScreen() {
|
|||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = methods;
|
} = methods;
|
||||||
|
|
||||||
const categoryId = watch('categoryId');
|
const tags = watch('tags') ?? [];
|
||||||
const priority = watch('priority');
|
const priority = watch('priority');
|
||||||
const repeat = watch('repeat');
|
const repeat = watch('repeat');
|
||||||
const repeatInterval = watch('repeatInterval') ?? 1;
|
const repeatInterval = watch('repeatInterval') ?? 1;
|
||||||
const repeatDays = watch('repeatDays') ?? [];
|
const repeatDays = watch('repeatDays') ?? [];
|
||||||
const reminder = watch('reminder');
|
const reminder = watch('reminder');
|
||||||
|
const reminders = watch('reminders');
|
||||||
const dueDate = watch('dueDate');
|
const dueDate = watch('dueDate');
|
||||||
const assigneeId = watch('assigneeId');
|
const assigneeId = watch('assigneeId');
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!categoryId && initialCategory) {
|
if (tags.length === 0 && initialCategory) {
|
||||||
setValue('categoryId', initialCategory);
|
setValue('tags', [initialCategory]);
|
||||||
}
|
}
|
||||||
}, [initialCategory, categoryId, setValue]);
|
}, [initialCategory, tags, setValue]);
|
||||||
|
|
||||||
const onSubmit = async (data: TaskFormData) => {
|
const onSubmit = async (data: TaskFormData) => {
|
||||||
if (!isReady) return;
|
if (!isReady) return;
|
||||||
@@ -105,6 +109,7 @@ export default function AddTaskScreen() {
|
|||||||
const seriesId = data.repeat !== 'none'
|
const seriesId = data.repeat !== 'none'
|
||||||
? `series_${Math.random().toString(36).slice(2, 10)}${Date.now().toString(36)}`
|
? `series_${Math.random().toString(36).slice(2, 10)}${Date.now().toString(36)}`
|
||||||
: '';
|
: '';
|
||||||
|
const resolvedCategoryId = (data.tags && data.tags[0]) || '';
|
||||||
|
|
||||||
let createdTask: any = null;
|
let createdTask: any = null;
|
||||||
|
|
||||||
@@ -112,7 +117,8 @@ export default function AddTaskScreen() {
|
|||||||
const task = await collections.tasks.create((t) => {
|
const task = await collections.tasks.create((t) => {
|
||||||
t.title = data.title.trim();
|
t.title = data.title.trim();
|
||||||
t.description = data.description || '';
|
t.description = data.description || '';
|
||||||
t.categoryId = data.categoryId;
|
t.categoryId = resolvedCategoryId;
|
||||||
|
t.tags = tagsToString(data.tags || []);
|
||||||
t.priority = data.priority;
|
t.priority = data.priority;
|
||||||
t.completed = false;
|
t.completed = false;
|
||||||
t.dueDate = dueDateTimestamp;
|
t.dueDate = dueDateTimestamp;
|
||||||
@@ -124,6 +130,7 @@ export default function AddTaskScreen() {
|
|||||||
t.repeatDays = (data.repeatDays || []).join(',');
|
t.repeatDays = (data.repeatDays || []).join(',');
|
||||||
t.seriesId = seriesId;
|
t.seriesId = seriesId;
|
||||||
t.reminder = data.reminder || 'none';
|
t.reminder = data.reminder || 'none';
|
||||||
|
t.reminders = data.reminders || '';
|
||||||
t.assigneeId = data.assigneeId ?? null;
|
t.assigneeId = data.assigneeId ?? null;
|
||||||
t.createdAt = now;
|
t.createdAt = now;
|
||||||
t.updatedAt = now;
|
t.updatedAt = now;
|
||||||
@@ -180,8 +187,8 @@ export default function AddTaskScreen() {
|
|||||||
keyboardShouldPersistTaps="handled"
|
keyboardShouldPersistTaps="handled"
|
||||||
>
|
>
|
||||||
<CategorySelector
|
<CategorySelector
|
||||||
value={categoryId}
|
value={tags}
|
||||||
onChange={(value) => setValue('categoryId', value)}
|
onChange={(value) => setValue('tags', value)}
|
||||||
error={errors.categoryId?.message}
|
error={errors.categoryId?.message}
|
||||||
/>
|
/>
|
||||||
<Controller
|
<Controller
|
||||||
@@ -213,9 +220,9 @@ export default function AddTaskScreen() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<ReminderSelector
|
<ReminderSelector
|
||||||
value={reminder}
|
value={reminders}
|
||||||
hasDueDate={!!dueDate}
|
hasDueDate={!!dueDate}
|
||||||
onChange={(value) => setValue('reminder', value)}
|
onChange={(value) => setValue('reminders', value)}
|
||||||
/>
|
/>
|
||||||
<AssigneeSelector
|
<AssigneeSelector
|
||||||
value={assigneeId}
|
value={assigneeId}
|
||||||
@@ -255,8 +262,8 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
scrollContent: {
|
scrollContent: {
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 16,
|
||||||
paddingTop: 8,
|
paddingTop: 12,
|
||||||
paddingBottom: 100,
|
paddingBottom: 120,
|
||||||
gap: 24,
|
gap: 20,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,286 @@
|
|||||||
|
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 { 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 { toggleTaskComplete, 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 } 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 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 toggleTaskComplete(taskId);
|
||||||
|
refresh();
|
||||||
|
refreshSubtasks();
|
||||||
|
}, [refresh, refreshSubtasks]);
|
||||||
|
|
||||||
|
const handleToggleSubtask = useCallback(async (subtaskId: string) => {
|
||||||
|
await toggleSubtaskComplete(subtaskId);
|
||||||
|
refreshSubtasks();
|
||||||
|
}, [refreshSubtasks]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
|
||||||
|
<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 styles = 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: '#9E9E9E',
|
||||||
|
},
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { View, Text, StyleSheet, SafeAreaView, ScrollView, KeyboardAvoidingView, Platform, TouchableOpacity, Alert } from 'react-native';
|
||||||
|
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
|
import { Header } from '@/components/Header';
|
||||||
|
import { TaskNameInput } from '@/components/TaskNameInput';
|
||||||
|
import { DateTimePickerComponent } from '@/components/DateTimePicker';
|
||||||
|
import { PrioritySelector } from '@/components/PrioritySelector';
|
||||||
|
import { RepeatSelector } from '@/components/RepeatSelector';
|
||||||
|
import { ReminderSelector } from '@/components/ReminderSelector';
|
||||||
|
import { DescriptionInput } from '@/components/DescriptionInput';
|
||||||
|
import { FormButtons } from '@/components/FormButtons';
|
||||||
|
import { AssigneeSelector } from '@/components/AssigneeSelector';
|
||||||
|
import { useForm, FormProvider, Controller } from 'react-hook-form';
|
||||||
|
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 { 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(),
|
||||||
|
categoryId: z.string().optional(),
|
||||||
|
priority: z.enum(['none', 'low', 'medium', 'high', 'critical']),
|
||||||
|
dueDate: z.date().nullable().optional(),
|
||||||
|
dueTime: z.string().optional(),
|
||||||
|
endTime: z.string().optional(),
|
||||||
|
allDay: z.boolean().optional(),
|
||||||
|
repeat: z.enum(['none', 'daily', 'weekly', 'monthly', 'custom']),
|
||||||
|
repeatInterval: z.number().int().min(1).max(30).optional(),
|
||||||
|
repeatDays: z.array(z.number().int().min(0).max(6)).optional(),
|
||||||
|
reminder: z.enum(['none', 'at_time', '15', '30', '60', '120', '1440']),
|
||||||
|
reminders: z.string().optional(),
|
||||||
|
assigneeId: z.string().nullable().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function SubtaskDetailScreen() {
|
||||||
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
|
const { isReady } = useDatabase();
|
||||||
|
const router = useRouter();
|
||||||
|
const { theme } = useSettings();
|
||||||
|
const { friends } = useFriends();
|
||||||
|
const [loaded, setLoaded] = React.useState(false);
|
||||||
|
const [notFound, setNotFound] = React.useState(false);
|
||||||
|
|
||||||
|
const methods = useForm<SubtaskFormData>({
|
||||||
|
resolver: zodResolver(subtaskSchema),
|
||||||
|
defaultValues: {
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
categoryId: '',
|
||||||
|
priority: 'none',
|
||||||
|
dueDate: null,
|
||||||
|
dueTime: '',
|
||||||
|
endTime: '',
|
||||||
|
allDay: false,
|
||||||
|
repeat: 'none',
|
||||||
|
repeatInterval: 1,
|
||||||
|
repeatDays: [],
|
||||||
|
reminder: 'none',
|
||||||
|
reminders: '',
|
||||||
|
assigneeId: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { handleSubmit, watch, setValue, control, reset, formState: { errors } } = methods;
|
||||||
|
|
||||||
|
const priority = watch('priority');
|
||||||
|
const categoryId = watch('categoryId');
|
||||||
|
const repeat = watch('repeat');
|
||||||
|
const repeatInterval = watch('repeatInterval') ?? 1;
|
||||||
|
const repeatDays = watch('repeatDays') ?? [];
|
||||||
|
const reminder = watch('reminder');
|
||||||
|
const reminders = watch('reminders');
|
||||||
|
const dueDate = watch('dueDate');
|
||||||
|
const assigneeId = watch('assigneeId');
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!id || !isReady) return;
|
||||||
|
let mounted = true;
|
||||||
|
|
||||||
|
const subscription = collections.subtasks.findAndObserve(id).subscribe({
|
||||||
|
next: (subtask: any) => {
|
||||||
|
if (!mounted) return;
|
||||||
|
reset({
|
||||||
|
title: subtask.title,
|
||||||
|
description: subtask.description,
|
||||||
|
categoryId: subtask.categoryId || '',
|
||||||
|
priority: subtask.priority,
|
||||||
|
dueDate: subtask.dueDate ? new Date(subtask.dueDate) : null,
|
||||||
|
dueTime: subtask.dueTime,
|
||||||
|
endTime: subtask.endTime || '',
|
||||||
|
allDay: subtask.allDay ?? false,
|
||||||
|
repeat: subtask.repeat,
|
||||||
|
repeatInterval: subtask.repeatInterval || 1,
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
if (mounted) setNotFound(true);
|
||||||
|
},
|
||||||
|
complete: () => {
|
||||||
|
if (mounted) setNotFound(true);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
subscription.unsubscribe();
|
||||||
|
};
|
||||||
|
}, [id, isReady, reset]);
|
||||||
|
|
||||||
|
const onSubmit = async (data: SubtaskFormData) => {
|
||||||
|
if (!id || !isReady) return;
|
||||||
|
|
||||||
|
await updateSubtask(id, {
|
||||||
|
title: data.title,
|
||||||
|
description: data.description || '',
|
||||||
|
categoryId: data.categoryId || '',
|
||||||
|
priority: data.priority,
|
||||||
|
dueDate: data.dueDate ? data.dueDate.getTime() : 0,
|
||||||
|
dueTime: data.dueTime || '',
|
||||||
|
endTime: data.endTime || '',
|
||||||
|
allDay: data.allDay ?? false,
|
||||||
|
repeat: data.repeat,
|
||||||
|
repeatInterval: data.repeatInterval || 1,
|
||||||
|
repeatDays: (data.repeatDays || []).join(','),
|
||||||
|
reminder: data.reminder || 'none',
|
||||||
|
assigneeId: data.assigneeId ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
router.back();
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = () => {
|
||||||
|
Alert.alert('Delete Subtask', 'This action cannot be undone.', [
|
||||||
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Delete',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: async () => {
|
||||||
|
if (!id) return;
|
||||||
|
await deleteSubtask(id);
|
||||||
|
router.back();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isReady || !loaded) {
|
||||||
|
return (
|
||||||
|
<View style={[styles.loadingContainer, { backgroundColor: theme.background }]}>
|
||||||
|
<Text style={{ color: theme.textFaint }}>{notFound ? 'Subtask not found' : 'Loading...'}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
|
||||||
|
<Header
|
||||||
|
title="Edit Subtask"
|
||||||
|
showLogo={true}
|
||||||
|
rightAction={
|
||||||
|
<TouchableOpacity onPress={confirmDelete} activeOpacity={0.7} style={[styles.deleteButton, { borderColor: theme.accentBorder, backgroundColor: theme.accentSoft }]}>
|
||||||
|
<Svg width={22} height={22} viewBox="0 0 24 24">
|
||||||
|
<Path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13" stroke={theme.accent} strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||||
|
</Svg>
|
||||||
|
</TouchableOpacity>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<FormProvider {...methods}>
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
style={styles.keyboardAvoiding}
|
||||||
|
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||||
|
>
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={styles.scrollContent}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="title"
|
||||||
|
render={({ field }) => (
|
||||||
|
<TaskNameInput
|
||||||
|
value={field.value}
|
||||||
|
onChangeText={field.onChange}
|
||||||
|
onBlur={field.onBlur}
|
||||||
|
error={errors.title?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<CategorySelector
|
||||||
|
value={categoryId ? [categoryId] : []}
|
||||||
|
onChange={(value) => setValue('categoryId', value[0] ?? '')}
|
||||||
|
/>
|
||||||
|
<PrioritySelector
|
||||||
|
value={priority}
|
||||||
|
onChange={(value) => setValue('priority', value)}
|
||||||
|
/>
|
||||||
|
<DateTimePickerComponent control={control as any} />
|
||||||
|
<RepeatSelector
|
||||||
|
value={repeat}
|
||||||
|
interval={repeatInterval}
|
||||||
|
days={repeatDays}
|
||||||
|
onChange={(nextRepeat, nextInterval, nextDays) => {
|
||||||
|
setValue('repeat', nextRepeat);
|
||||||
|
setValue('repeatInterval', nextInterval);
|
||||||
|
setValue('repeatDays', nextDays);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ReminderSelector
|
||||||
|
value={reminders}
|
||||||
|
hasDueDate={!!dueDate}
|
||||||
|
onChange={(value) => setValue('reminders', value)}
|
||||||
|
/>
|
||||||
|
<AssigneeSelector
|
||||||
|
value={assigneeId}
|
||||||
|
onChange={(value: string | null) => setValue('assigneeId', value)}
|
||||||
|
friends={friends.map((f) => f.username)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="description"
|
||||||
|
render={({ field }) => (
|
||||||
|
<DescriptionInput
|
||||||
|
value={field.value ?? ''}
|
||||||
|
onChangeText={field.onChange}
|
||||||
|
onBlur={field.onBlur}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</ScrollView>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
<FormButtons onSubmit={handleSubmit(onSubmit)} submitLabel="Save" />
|
||||||
|
</FormProvider>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
loadingContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
keyboardAvoiding: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
scrollContent: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingTop: 8,
|
||||||
|
paddingBottom: 100,
|
||||||
|
gap: 24,
|
||||||
|
},
|
||||||
|
deleteButton: {
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -19,17 +19,19 @@ import { z } from 'zod';
|
|||||||
import { useDatabase } from '@/hooks/useDatabase';
|
import { useDatabase } from '@/hooks/useDatabase';
|
||||||
import { database, collections } from '@/database';
|
import { database, collections } from '@/database';
|
||||||
import { Q } from '@nozbe/watermelondb';
|
import { Q } from '@nozbe/watermelondb';
|
||||||
import { TaskFormData } from '@/types';
|
import { TaskFormData, parseTaskTags, tagsToString } from '@/types';
|
||||||
import { useSettings } from '@/theme';
|
import { useSettings } from '@/theme';
|
||||||
import { deleteTaskOccurrences } from '@/utils/taskActions';
|
import { deleteTaskOccurrences } from '@/utils/taskActions';
|
||||||
import { scheduleTaskReminder, cancelTaskReminder } from '@/services/notifications';
|
import { scheduleTaskReminder } from '@/services/notifications';
|
||||||
|
import { recordTombstonesInBatch } from '@/database/tombstones';
|
||||||
import { useFriends } from '@/hooks/useFriends';
|
import { useFriends } from '@/hooks/useFriends';
|
||||||
import Svg, { Path } from 'react-native-svg';
|
import Svg, { Path, Circle } from 'react-native-svg';
|
||||||
|
|
||||||
const taskSchema = z.object({
|
const taskSchema = z.object({
|
||||||
title: z.string().trim().min(1, 'Task name is required').max(100),
|
title: z.string().trim().min(1, 'Task name is required').max(100),
|
||||||
description: z.string().max(1000).optional(),
|
description: z.string().max(1000).optional(),
|
||||||
categoryId: z.string().min(1, 'Category is required'),
|
categoryId: z.string().optional(),
|
||||||
|
tags: z.array(z.string()).optional(),
|
||||||
priority: z.enum(['none', 'low', 'medium', 'high', 'critical']),
|
priority: z.enum(['none', 'low', 'medium', 'high', 'critical']),
|
||||||
dueDate: z.date().nullable().optional(),
|
dueDate: z.date().nullable().optional(),
|
||||||
dueTime: z.string().optional(),
|
dueTime: z.string().optional(),
|
||||||
@@ -39,10 +41,38 @@ const taskSchema = z.object({
|
|||||||
repeatInterval: z.number().int().min(1).max(30).optional(),
|
repeatInterval: z.number().int().min(1).max(30).optional(),
|
||||||
repeatDays: z.array(z.number().int().min(0).max(6)).optional(),
|
repeatDays: z.array(z.number().int().min(0).max(6)).optional(),
|
||||||
reminder: z.enum(['none', 'at_time', '15', '30', '60', '120', '1440']),
|
reminder: z.enum(['none', 'at_time', '15', '30', '60', '120', '1440']),
|
||||||
|
reminders: z.string().optional(),
|
||||||
assigneeId: z.string().nullable().optional(),
|
assigneeId: z.string().nullable().optional(),
|
||||||
subtasks: z.array(z.object({ title: z.string(), _key: z.string().optional() })).optional(),
|
subtasks: z.array(z.object({ title: z.string(), _key: z.string().optional() })).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function CollapsibleSection({ title, children, defaultExpanded = false, icon }: { title: string; children: React.ReactNode; defaultExpanded?: boolean; icon: React.ReactNode }) {
|
||||||
|
const { theme } = useSettings();
|
||||||
|
const [expanded, setExpanded] = React.useState(defaultExpanded);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.section}>
|
||||||
|
<TouchableOpacity style={[styles.sectionHeader, { backgroundColor: theme.card, borderColor: theme.border }]} onPress={() => setExpanded(!expanded)} activeOpacity={0.8}>
|
||||||
|
<View style={styles.sectionHeaderLeft}>
|
||||||
|
{icon}
|
||||||
|
<Text style={[styles.sectionTitle, { color: theme.text }]}>{title}</Text>
|
||||||
|
</View>
|
||||||
|
<Svg width={20} height={20} 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>
|
||||||
|
{expanded && <View style={styles.sectionContent}>{children}</View>}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function TaskDetailScreen() {
|
export default function TaskDetailScreen() {
|
||||||
const { id } = useLocalSearchParams<{ id: string }>();
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
const { isReady } = useDatabase();
|
const { isReady } = useDatabase();
|
||||||
@@ -58,6 +88,7 @@ export default function TaskDetailScreen() {
|
|||||||
title: '',
|
title: '',
|
||||||
description: '',
|
description: '',
|
||||||
categoryId: '',
|
categoryId: '',
|
||||||
|
tags: [],
|
||||||
priority: 'none',
|
priority: 'none',
|
||||||
dueDate: null,
|
dueDate: null,
|
||||||
dueTime: '',
|
dueTime: '',
|
||||||
@@ -74,12 +105,13 @@ export default function TaskDetailScreen() {
|
|||||||
|
|
||||||
const { handleSubmit, watch, setValue, control, reset, formState: { errors } } = methods;
|
const { handleSubmit, watch, setValue, control, reset, formState: { errors } } = methods;
|
||||||
|
|
||||||
const categoryId = watch('categoryId');
|
const tags = watch('tags') ?? [];
|
||||||
const priority = watch('priority');
|
const priority = watch('priority');
|
||||||
const repeat = watch('repeat');
|
const repeat = watch('repeat');
|
||||||
const repeatInterval = watch('repeatInterval') ?? 1;
|
const repeatInterval = watch('repeatInterval') ?? 1;
|
||||||
const repeatDays = watch('repeatDays') ?? [];
|
const repeatDays = watch('repeatDays') ?? [];
|
||||||
const reminder = watch('reminder');
|
const reminder = watch('reminder');
|
||||||
|
const reminders = watch('reminders');
|
||||||
const dueDate = watch('dueDate');
|
const dueDate = watch('dueDate');
|
||||||
const assigneeId = watch('assigneeId');
|
const assigneeId = watch('assigneeId');
|
||||||
const [deleteModalVisible, setDeleteModalVisible] = React.useState(false);
|
const [deleteModalVisible, setDeleteModalVisible] = React.useState(false);
|
||||||
@@ -98,6 +130,7 @@ export default function TaskDetailScreen() {
|
|||||||
title: task.title,
|
title: task.title,
|
||||||
description: task.description,
|
description: task.description,
|
||||||
categoryId: task.categoryId,
|
categoryId: task.categoryId,
|
||||||
|
tags: parseTaskTags(task.tags, task.categoryId),
|
||||||
priority: task.priority,
|
priority: task.priority,
|
||||||
dueDate: task.dueDate ? new Date(task.dueDate) : null,
|
dueDate: task.dueDate ? new Date(task.dueDate) : null,
|
||||||
dueTime: task.dueTime,
|
dueTime: task.dueTime,
|
||||||
@@ -131,14 +164,12 @@ export default function TaskDetailScreen() {
|
|||||||
const task = await collections.tasks.find(id);
|
const task = await collections.tasks.find(id);
|
||||||
savedTask = task;
|
savedTask = task;
|
||||||
const existingSubtasks = await collections.subtasks.query(Q.where('task_id', id)).fetch();
|
const existingSubtasks = await collections.subtasks.query(Q.where('task_id', id)).fetch();
|
||||||
for (const subtask of existingSubtasks) {
|
|
||||||
await subtask.destroyPermanently();
|
|
||||||
}
|
|
||||||
|
|
||||||
await task.update((t) => {
|
await task.update((t) => {
|
||||||
t.title = data.title.trim();
|
t.title = data.title.trim();
|
||||||
t.description = data.description || '';
|
t.description = data.description || '';
|
||||||
t.categoryId = data.categoryId;
|
t.categoryId = (data.tags && data.tags[0]) || task.categoryId || '';
|
||||||
|
t.tags = tagsToString(data.tags || []);
|
||||||
t.priority = data.priority;
|
t.priority = data.priority;
|
||||||
t.dueDate = dueDateTimestamp;
|
t.dueDate = dueDateTimestamp;
|
||||||
t.dueTime = data.dueTime || '';
|
t.dueTime = data.dueTime || '';
|
||||||
@@ -155,13 +186,28 @@ export default function TaskDetailScreen() {
|
|||||||
t.updatedAt = now;
|
t.updatedAt = now;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (data.subtasks && data.subtasks.length > 0) {
|
const keptIds = new Set<string>();
|
||||||
for (let i = 0; i < data.subtasks.length; i++) {
|
let order = 0;
|
||||||
const subtask = data.subtasks[i];
|
for (const formItem of data.subtasks ?? []) {
|
||||||
if (subtask.title.trim()) {
|
const trimmed = formItem.title.trim();
|
||||||
|
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) => {
|
await collections.subtasks.create((s) => {
|
||||||
s.taskId = task.id;
|
s.taskId = task.id;
|
||||||
s.title = subtask.title.trim();
|
s.categoryId = task.categoryId || '';
|
||||||
|
s.title = trimmed;
|
||||||
s.description = '';
|
s.description = '';
|
||||||
s.priority = 'none';
|
s.priority = 'none';
|
||||||
s.completed = false;
|
s.completed = false;
|
||||||
@@ -174,13 +220,30 @@ export default function TaskDetailScreen() {
|
|||||||
s.repeatDays = '';
|
s.repeatDays = '';
|
||||||
s.seriesId = '';
|
s.seriesId = '';
|
||||||
s.reminder = 'none';
|
s.reminder = 'none';
|
||||||
|
s.reminders = '';
|
||||||
s.assigneeId = null;
|
s.assigneeId = null;
|
||||||
s.order = i;
|
s.order = order++;
|
||||||
s.createdAt = now;
|
s.createdAt = now;
|
||||||
s.updatedAt = 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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -216,7 +279,7 @@ export default function TaskDetailScreen() {
|
|||||||
rightAction={
|
rightAction={
|
||||||
<TouchableOpacity onPress={handleDelete} activeOpacity={0.7} style={[styles.deleteButton, { borderColor: theme.accentBorder, backgroundColor: theme.accentSoft }]}>
|
<TouchableOpacity onPress={handleDelete} activeOpacity={0.7} style={[styles.deleteButton, { borderColor: theme.accentBorder, backgroundColor: theme.accentSoft }]}>
|
||||||
<Svg width={22} height={22} viewBox="0 0 24 24">
|
<Svg width={22} height={22} viewBox="0 0 24 24">
|
||||||
<Path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13" stroke={theme.accent} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
<Path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13" stroke={theme.accent} strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||||
</Svg>
|
</Svg>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
}
|
}
|
||||||
@@ -232,8 +295,8 @@ export default function TaskDetailScreen() {
|
|||||||
keyboardShouldPersistTaps="handled"
|
keyboardShouldPersistTaps="handled"
|
||||||
>
|
>
|
||||||
<CategorySelector
|
<CategorySelector
|
||||||
value={categoryId}
|
value={tags}
|
||||||
onChange={(value) => setValue('categoryId', value)}
|
onChange={(value) => setValue('tags', value)}
|
||||||
error={errors.categoryId?.message}
|
error={errors.categoryId?.message}
|
||||||
/>
|
/>
|
||||||
<Controller
|
<Controller
|
||||||
@@ -253,7 +316,28 @@ export default function TaskDetailScreen() {
|
|||||||
value={priority}
|
value={priority}
|
||||||
onChange={(value) => setValue('priority', value)}
|
onChange={(value) => setValue('priority', value)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<CollapsibleSection title="Date & Time" icon={
|
||||||
|
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||||
|
<Circle cx={12} cy={12} r={10} stroke={theme.accent} strokeWidth={1.8} fill="none" />
|
||||||
|
<Path d="M12 6v6l4 2" stroke={theme.accent} strokeWidth={1.8} strokeLinecap="round" />
|
||||||
|
</Svg>
|
||||||
|
} defaultExpanded={!!dueDate}>
|
||||||
<DateTimePickerComponent control={control} />
|
<DateTimePickerComponent control={control} />
|
||||||
|
</CollapsibleSection>
|
||||||
|
|
||||||
|
<CollapsibleSection title="Repeat" icon={
|
||||||
|
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||||
|
<Path
|
||||||
|
d="M17 2l4 4-4 4M3 11v-1a4 4 0 0 1 4-4h14M7 22l-4-4 4-4M21 13v1a4 4 0 0 1-4 4H3"
|
||||||
|
stroke={theme.accent}
|
||||||
|
strokeWidth={1.8}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
fill="none"
|
||||||
|
/>
|
||||||
|
</Svg>
|
||||||
|
} defaultExpanded={repeat !== 'none'}>
|
||||||
<RepeatSelector
|
<RepeatSelector
|
||||||
value={repeat}
|
value={repeat}
|
||||||
interval={repeatInterval}
|
interval={repeatInterval}
|
||||||
@@ -264,16 +348,45 @@ export default function TaskDetailScreen() {
|
|||||||
setValue('repeatDays', nextDays);
|
setValue('repeatDays', nextDays);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<ReminderSelector
|
</CollapsibleSection>
|
||||||
value={reminder}
|
|
||||||
hasDueDate={!!dueDate}
|
<CollapsibleSection title="Reminder" icon={
|
||||||
onChange={(value) => setValue('reminder', value)}
|
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||||
|
<Path
|
||||||
|
d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9M10.3 21a1.94 1.94 0 0 0 3.4 0"
|
||||||
|
stroke={theme.accent}
|
||||||
|
strokeWidth={1.8}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
fill="none"
|
||||||
/>
|
/>
|
||||||
|
</Svg>
|
||||||
|
} defaultExpanded={!!reminders && reminders !== ''}>
|
||||||
|
<ReminderSelector
|
||||||
|
value={reminders}
|
||||||
|
hasDueDate={!!dueDate}
|
||||||
|
onChange={(value) => setValue('reminders', value)}
|
||||||
|
/>
|
||||||
|
</CollapsibleSection>
|
||||||
|
|
||||||
|
<CollapsibleSection title="Assignee" icon={
|
||||||
|
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||||
|
<Circle cx={12} cy={12} r={10} stroke={theme.accent} strokeWidth={1.8} fill="none" />
|
||||||
|
<Path d="M12 10v6M12 19v1" stroke={theme.accent} strokeWidth={1.8} strokeLinecap="round" />
|
||||||
|
</Svg>
|
||||||
|
} defaultExpanded={!!assigneeId}>
|
||||||
<AssigneeSelector
|
<AssigneeSelector
|
||||||
value={assigneeId}
|
value={assigneeId}
|
||||||
onChange={(value: string | null) => setValue('assigneeId', value)}
|
onChange={(value: string | null) => setValue('assigneeId', value)}
|
||||||
friends={friends.map((f) => f.username)}
|
friends={friends.map((f) => f.username)}
|
||||||
/>
|
/>
|
||||||
|
</CollapsibleSection>
|
||||||
|
|
||||||
|
<CollapsibleSection title="Description" icon={
|
||||||
|
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||||
|
<Path d="M4 6h16M4 12h16M4 18h10" stroke={theme.accent} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||||
|
</Svg>
|
||||||
|
} defaultExpanded={!!methods.getValues('description')}>
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name="description"
|
name="description"
|
||||||
@@ -285,6 +398,7 @@ export default function TaskDetailScreen() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
</CollapsibleSection>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
<FormButtons onSubmit={handleSubmit(onSubmit)} submitLabel="Save" />
|
<FormButtons onSubmit={handleSubmit(onSubmit)} submitLabel="Save" />
|
||||||
@@ -315,15 +429,40 @@ const styles = StyleSheet.create({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
scrollContent: {
|
scrollContent: {
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 12,
|
||||||
paddingTop: 8,
|
paddingTop: 8,
|
||||||
paddingBottom: 100,
|
paddingBottom: 80,
|
||||||
gap: 24,
|
gap: 12,
|
||||||
|
},
|
||||||
|
section: {
|
||||||
|
gap: 6,
|
||||||
|
},
|
||||||
|
sectionHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingVertical: 10,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
borderRadius: 10,
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
sectionHeaderLeft: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
sectionContent: {
|
||||||
|
paddingHorizontal: 2,
|
||||||
|
gap: 6,
|
||||||
},
|
},
|
||||||
deleteButton: {
|
deleteButton: {
|
||||||
width: 36,
|
width: 32,
|
||||||
height: 36,
|
height: 32,
|
||||||
borderRadius: 12,
|
borderRadius: 10,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
/* global __dirname */
|
||||||
|
const { app, BrowserWindow } = require('electron')
|
||||||
|
const path = require('path')
|
||||||
|
|
||||||
|
function createWindow() {
|
||||||
|
const win = new BrowserWindow({
|
||||||
|
width: 1200,
|
||||||
|
height: 800,
|
||||||
|
minWidth: 800,
|
||||||
|
minHeight: 600,
|
||||||
|
webPreferences: {
|
||||||
|
nodeIntegration: false,
|
||||||
|
contextIsolation: true,
|
||||||
|
webSecurity: true,
|
||||||
|
},
|
||||||
|
icon: path.join(__dirname, '../assets/icon.png'),
|
||||||
|
titleBarStyle: 'default',
|
||||||
|
show: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
win.loadFile(path.join(__dirname, '../dist/index.html'))
|
||||||
|
|
||||||
|
win.once('ready-to-show', () => {
|
||||||
|
win.show()
|
||||||
|
})
|
||||||
|
|
||||||
|
win.on('closed', () => {
|
||||||
|
app.quit()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
app.whenReady().then(createWindow)
|
||||||
|
|
||||||
|
app.on('window-all-closed', () => {
|
||||||
|
if (process.platform !== 'darwin') {
|
||||||
|
app.quit()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.on('activate', () => {
|
||||||
|
if (BrowserWindow.getAllWindows().length === 0) {
|
||||||
|
createWindow()
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -2,9 +2,25 @@
|
|||||||
const { defineConfig } = require('eslint/config');
|
const { defineConfig } = require('eslint/config');
|
||||||
const expoConfig = require("eslint-config-expo/flat");
|
const expoConfig = require("eslint-config-expo/flat");
|
||||||
|
|
||||||
|
const platformExtensions = [];
|
||||||
|
for (const platform of ['.android', '.ios', '.web', '.native', '']) {
|
||||||
|
for (const base of ['.ts', '.tsx', '.d.ts']) {
|
||||||
|
platformExtensions.push(`${platform}${base}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = defineConfig([
|
module.exports = defineConfig([
|
||||||
expoConfig,
|
expoConfig,
|
||||||
{
|
{
|
||||||
ignores: ["dist/*"],
|
ignores: ["dist/*"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
settings: {
|
||||||
|
'import/resolver': {
|
||||||
|
typescript: {
|
||||||
|
extensions: platformExtensions,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
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
+252
-62
@@ -7,6 +7,7 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "carry-your-live",
|
"name": "carry-your-live",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^3.3.4",
|
"@hookform/resolvers": "^3.3.4",
|
||||||
"@nozbe/watermelondb": "^0.28.1-0",
|
"@nozbe/watermelondb": "^0.28.1-0",
|
||||||
@@ -21,18 +22,18 @@
|
|||||||
"expo-router": "~57.0.10",
|
"expo-router": "~57.0.10",
|
||||||
"expo-sqlite": "~57.0.1",
|
"expo-sqlite": "~57.0.1",
|
||||||
"expo-status-bar": "~57.0.1",
|
"expo-status-bar": "~57.0.1",
|
||||||
|
"expo-system-ui": "~57.0.2",
|
||||||
|
"expo-updates": "~57.0.12",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
"react-hook-form": "^7.51.5",
|
"react-hook-form": "^7.51.5",
|
||||||
"react-native": "0.86.2",
|
"react-native": "0.86.2",
|
||||||
"react-native-gesture-handler": "2.32.0",
|
"react-native-gesture-handler": "2.32.0",
|
||||||
"react-native-paper": "^5.12.3",
|
"react-native-keyboard-controller": "1.21.9",
|
||||||
"react-native-reanimated": "4.5.1",
|
|
||||||
"react-native-safe-area-context": "5.7.0",
|
"react-native-safe-area-context": "5.7.0",
|
||||||
"react-native-screens": "4.26.0",
|
"react-native-screens": "4.26.0",
|
||||||
"react-native-svg": "^15.15.4",
|
"react-native-svg": "^15.15.4",
|
||||||
"react-native-web": "^0.21.2",
|
"react-native-web": "^0.21.2",
|
||||||
"react-native-worklets": "0.10.1",
|
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -40,6 +41,7 @@
|
|||||||
"@types/react": "~19.2.2",
|
"@types/react": "~19.2.2",
|
||||||
"eslint": "^9.0.0",
|
"eslint": "^9.0.0",
|
||||||
"eslint-config-expo": "~57.0.1",
|
"eslint-config-expo": "~57.0.1",
|
||||||
|
"patch-package": "^8.0.1",
|
||||||
"prettier": "^3.9.6",
|
"prettier": "^3.9.6",
|
||||||
"typescript": "~6.0.3"
|
"typescript": "~6.0.3"
|
||||||
}
|
}
|
||||||
@@ -574,6 +576,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.29.7.tgz",
|
||||||
"integrity": "sha512-N7zArUXWzAMzm+/N0uPBeVB3Fam5lMxtUwMmDK5f/IBBS7a7p1qeUoxd/6CckXoxUdgsntq1Dh8xNW06maZbDQ==",
|
"integrity": "sha512-N7zArUXWzAMzm+/N0uPBeVB3Fam5lMxtUwMmDK5f/IBBS7a7p1qeUoxd/6CckXoxUdgsntq1Dh8xNW06maZbDQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-plugin-utils": "^7.29.7"
|
"@babel/helper-plugin-utils": "^7.29.7"
|
||||||
},
|
},
|
||||||
@@ -1055,6 +1058,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.29.7.tgz",
|
||||||
"integrity": "sha512-I+WYbGBAiCn7nA6xBrlgPH+MB7HWb4u8pv5S0Pv7OtwNvIFvCCb24YlttKEeUFVurfBCEaOTnuhlqsb7f0Z5Dg==",
|
"integrity": "sha512-I+WYbGBAiCn7nA6xBrlgPH+MB7HWb4u8pv5S0Pv7OtwNvIFvCCb24YlttKEeUFVurfBCEaOTnuhlqsb7f0Z5Dg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-plugin-utils": "^7.29.7"
|
"@babel/helper-plugin-utils": "^7.29.7"
|
||||||
},
|
},
|
||||||
@@ -1070,6 +1074,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.29.7.tgz",
|
||||||
"integrity": "sha512-NCSEJ4sLFU2gqAub45HYh4fus2yQ36rr6ei6vpU7NdoJqCpxvEG8E6eJpscGyXP3VHD2Ny+fSXr04k1hoUrFqA==",
|
"integrity": "sha512-NCSEJ4sLFU2gqAub45HYh4fus2yQ36rr6ei6vpU7NdoJqCpxvEG8E6eJpscGyXP3VHD2Ny+fSXr04k1hoUrFqA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-plugin-utils": "^7.29.7"
|
"@babel/helper-plugin-utils": "^7.29.7"
|
||||||
},
|
},
|
||||||
@@ -1188,28 +1193,6 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@callstack/react-theme-provider": {
|
|
||||||
"version": "3.0.9",
|
|
||||||
"resolved": "https://registry.npmjs.org/@callstack/react-theme-provider/-/react-theme-provider-3.0.9.tgz",
|
|
||||||
"integrity": "sha512-tTQ0uDSCL0ypeMa8T/E9wAZRGKWj8kXP7+6RYgPTfOPs9N07C9xM8P02GJ3feETap4Ux5S69D9nteq9mEj86NA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"deepmerge": "^3.2.0",
|
|
||||||
"hoist-non-react-statics": "^3.3.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": ">=16.3.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@callstack/react-theme-provider/node_modules/deepmerge": {
|
|
||||||
"version": "3.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-3.3.0.tgz",
|
|
||||||
"integrity": "sha512-GRQOafGHwMHpjPx9iCvTgpu9NojZ49q794EEL94JVEw6VaeA8XTUyBKvAkOOjBX9oJNiV6G3P+T+tihFjo2TqA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@egjs/hammerjs": {
|
"node_modules/@egjs/hammerjs": {
|
||||||
"version": "2.0.17",
|
"version": "2.0.17",
|
||||||
"resolved": "https://registry.npmjs.org/@egjs/hammerjs/-/hammerjs-2.0.17.tgz",
|
"resolved": "https://registry.npmjs.org/@egjs/hammerjs/-/hammerjs-2.0.17.tgz",
|
||||||
@@ -3594,6 +3577,13 @@
|
|||||||
"node": ">=10.0.0"
|
"node": ">=10.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@yarnpkg/lockfile": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
"node_modules/abort-controller": {
|
"node_modules/abort-controller": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
|
||||||
@@ -5965,6 +5955,12 @@
|
|||||||
"expo": "*"
|
"expo": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/expo-eas-client": {
|
||||||
|
"version": "57.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-eas-client/-/expo-eas-client-57.0.1.tgz",
|
||||||
|
"integrity": "sha512-4w51+zsl/ziUHQMJgLgUdgsNhRPAwHBfySpPB1hpWU21X74QS9T4SqDftaRnrDagn/DfcrXUMJvHbDQUxLPJNA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/expo-font": {
|
"node_modules/expo-font": {
|
||||||
"version": "57.0.1",
|
"version": "57.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/expo-font/-/expo-font-57.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/expo-font/-/expo-font-57.0.1.tgz",
|
||||||
@@ -6297,6 +6293,12 @@
|
|||||||
"react-native": "*"
|
"react-native": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/expo-structured-headers": {
|
||||||
|
"version": "57.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-structured-headers/-/expo-structured-headers-57.0.0.tgz",
|
||||||
|
"integrity": "sha512-//t9UNPbJSEysc2x4VKJG/u7Osvv5DYJWsET5bqt/B+qcD1by/JXvSQzX3Q/YAgA96xFPontrz6OAPLbO4JKEA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/expo-symbols": {
|
"node_modules/expo-symbols": {
|
||||||
"version": "57.0.1",
|
"version": "57.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/expo-symbols/-/expo-symbols-57.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/expo-symbols/-/expo-symbols-57.0.1.tgz",
|
||||||
@@ -6313,6 +6315,63 @@
|
|||||||
"react-native": "*"
|
"react-native": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/expo-system-ui": {
|
||||||
|
"version": "57.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-system-ui/-/expo-system-ui-57.0.2.tgz",
|
||||||
|
"integrity": "sha512-zABCRqFSioDBAo/RtmS0dQiGgDtDPZUVk01Y3Ti4ducobNM4HTM2sNAtq+YPpEUhaVW3hccPVsEUH4LK/ADVhA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@react-native/normalize-colors": "0.86.2",
|
||||||
|
"debug": "^4.3.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": "*",
|
||||||
|
"react-native": "*",
|
||||||
|
"react-native-web": "*"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react-native-web": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/expo-updates": {
|
||||||
|
"version": "57.0.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-updates/-/expo-updates-57.0.12.tgz",
|
||||||
|
"integrity": "sha512-ZFsW8Mi9qFrrYPSXF++1FXejjflRdgbVPzaSJJATuHelVmIIb3D98gCO9sIsaLPHO1RCQVj1ltkj51VnwVL+4g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@expo/code-signing-certificates": "^0.0.6",
|
||||||
|
"@expo/plist": "^0.8.1",
|
||||||
|
"@expo/spawn-async": "^1.8.0",
|
||||||
|
"arg": "^4.1.0",
|
||||||
|
"chalk": "^4.1.2",
|
||||||
|
"debug": "^4.3.4",
|
||||||
|
"expo-eas-client": "~57.0.1",
|
||||||
|
"expo-manifests": "~57.0.1",
|
||||||
|
"expo-structured-headers": "~57.0.0",
|
||||||
|
"expo-updates-interface": "~57.0.1",
|
||||||
|
"getenv": "^2.0.0",
|
||||||
|
"glob": "^13.0.0",
|
||||||
|
"ignore": "^5.3.1",
|
||||||
|
"nullthrows": "^1.1.1",
|
||||||
|
"resolve-from": "^5.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"expo-updates": "bin/cli.js"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": "*",
|
||||||
|
"expo-dev-client": "*",
|
||||||
|
"react": "*",
|
||||||
|
"react-native": "*"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"expo-dev-client": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/expo-updates-interface": {
|
"node_modules/expo-updates-interface": {
|
||||||
"version": "57.0.1",
|
"version": "57.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/expo-updates-interface/-/expo-updates-interface-57.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/expo-updates-interface/-/expo-updates-interface-57.0.1.tgz",
|
||||||
@@ -6322,6 +6381,12 @@
|
|||||||
"expo": "*"
|
"expo": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/expo-updates/node_modules/arg": {
|
||||||
|
"version": "4.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
|
||||||
|
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/expo/node_modules/@expo/cli": {
|
"node_modules/expo/node_modules/@expo/cli": {
|
||||||
"version": "57.0.12",
|
"version": "57.0.12",
|
||||||
"resolved": "https://registry.npmjs.org/@expo/cli/-/cli-57.0.12.tgz",
|
"resolved": "https://registry.npmjs.org/@expo/cli/-/cli-57.0.12.tgz",
|
||||||
@@ -6861,6 +6926,16 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/find-yarn-workspace-root": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"micromatch": "^4.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/flat-cache": {
|
"node_modules/flat-cache": {
|
||||||
"version": "4.0.1",
|
"version": "4.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
|
||||||
@@ -6919,6 +6994,21 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fs-extra": {
|
||||||
|
"version": "10.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
|
||||||
|
"integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"graceful-fs": "^4.2.0",
|
||||||
|
"jsonfile": "^6.0.1",
|
||||||
|
"universalify": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/function-bind": {
|
"node_modules/function-bind": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
@@ -8112,6 +8202,26 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/json-stable-stringify": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind": "^1.0.8",
|
||||||
|
"call-bound": "^1.0.4",
|
||||||
|
"isarray": "^2.0.5",
|
||||||
|
"jsonify": "^0.0.1",
|
||||||
|
"object-keys": "^1.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/json-stable-stringify-without-jsonify": {
|
"node_modules/json-stable-stringify-without-jsonify": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
|
||||||
@@ -8131,6 +8241,29 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jsonfile": {
|
||||||
|
"version": "6.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
|
||||||
|
"integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"universalify": "^2.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"graceful-fs": "^4.1.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jsonify": {
|
||||||
|
"version": "0.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz",
|
||||||
|
"integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Public Domain",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/jsx-ast-utils": {
|
"node_modules/jsx-ast-utils": {
|
||||||
"version": "3.3.5",
|
"version": "3.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
|
||||||
@@ -8157,6 +8290,16 @@
|
|||||||
"json-buffer": "3.0.1"
|
"json-buffer": "3.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/klaw-sync": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"graceful-fs": "^4.1.11"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/kleur": {
|
"node_modules/kleur": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
|
||||||
@@ -9628,6 +9771,52 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/patch-package": {
|
||||||
|
"version": "8.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.1.tgz",
|
||||||
|
"integrity": "sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@yarnpkg/lockfile": "^1.1.0",
|
||||||
|
"chalk": "^4.1.2",
|
||||||
|
"ci-info": "^3.7.0",
|
||||||
|
"cross-spawn": "^7.0.3",
|
||||||
|
"find-yarn-workspace-root": "^2.0.0",
|
||||||
|
"fs-extra": "^10.0.0",
|
||||||
|
"json-stable-stringify": "^1.0.2",
|
||||||
|
"klaw-sync": "^6.0.0",
|
||||||
|
"minimist": "^1.2.6",
|
||||||
|
"open": "^7.4.2",
|
||||||
|
"semver": "^7.5.3",
|
||||||
|
"slash": "^2.0.0",
|
||||||
|
"tmp": "^0.2.4",
|
||||||
|
"yaml": "^2.2.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"patch-package": "index.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14",
|
||||||
|
"npm": ">5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/patch-package/node_modules/ci-info": {
|
||||||
|
"version": "3.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz",
|
||||||
|
"integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/sibiraj-s"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/path-exists": {
|
"node_modules/path-exists": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||||
@@ -10101,56 +10290,26 @@
|
|||||||
"react-native": "*"
|
"react-native": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-native-paper": {
|
"node_modules/react-native-keyboard-controller": {
|
||||||
"version": "5.15.3",
|
"version": "1.21.9",
|
||||||
"resolved": "https://registry.npmjs.org/react-native-paper/-/react-native-paper-5.15.3.tgz",
|
"resolved": "https://registry.npmjs.org/react-native-keyboard-controller/-/react-native-keyboard-controller-1.21.9.tgz",
|
||||||
"integrity": "sha512-GEyNTmWElIZgnYw09AjjCNupRYzCmP79uAAyGSyCEUZz7KBz1wtJcC0wVUkozR1Rn3PK/td/9LlR6+F1hzmYvA==",
|
"integrity": "sha512-+TkkFldht4+AXBQeDy1hLE7iqiW8/NkY/ekhcFsKIiRdI9qC5JDzx0TfAg1iYZB2IeOXppmURIy2jFCUjOcV1w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"workspaces": [
|
|
||||||
"example",
|
|
||||||
"docs"
|
|
||||||
],
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@callstack/react-theme-provider": "^3.0.9",
|
"react-native-is-edge-to-edge": "^1.2.1"
|
||||||
"color": "^3.1.2",
|
|
||||||
"use-latest-callback": "^0.2.3"
|
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "*",
|
"react": "*",
|
||||||
"react-native": "*",
|
"react-native": "*",
|
||||||
"react-native-safe-area-context": "*"
|
"react-native-reanimated": ">=3.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-native-paper/node_modules/color": {
|
|
||||||
"version": "3.2.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/color/-/color-3.2.1.tgz",
|
|
||||||
"integrity": "sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"color-convert": "^1.9.3",
|
|
||||||
"color-string": "^1.6.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/react-native-paper/node_modules/color-convert": {
|
|
||||||
"version": "1.9.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
|
|
||||||
"integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"color-name": "1.1.3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/react-native-paper/node_modules/color-name": {
|
|
||||||
"version": "1.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
|
|
||||||
"integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/react-native-reanimated": {
|
"node_modules/react-native-reanimated": {
|
||||||
"version": "4.5.1",
|
"version": "4.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.5.1.tgz",
|
||||||
"integrity": "sha512-RnMvtDuR+68ig864gAvZCOdZehqhC5rFmMo0kn+ARfgVSTvFeF6IFLBVgMPUu0KwihaapEyW24WRi6nEyy1kSA==",
|
"integrity": "sha512-RnMvtDuR+68ig864gAvZCOdZehqhC5rFmMo0kn+ARfgVSTvFeF6IFLBVgMPUu0KwihaapEyW24WRi6nEyy1kSA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react-native-is-edge-to-edge": "^1.3.1",
|
"react-native-is-edge-to-edge": "^1.3.1",
|
||||||
"semver": "^7.7.3"
|
"semver": "^7.7.3"
|
||||||
@@ -10237,6 +10396,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.10.1.tgz",
|
||||||
"integrity": "sha512-62mRM19bDpfpdI8HLkEErcdOsrAPDtE9lA/sw+5lLRpzBHNhxaoj9QyY2KjXqUmirelxkX4zuPGTC3VdA0feJA==",
|
"integrity": "sha512-62mRM19bDpfpdI8HLkEErcdOsrAPDtE9lA/sw+5lLRpzBHNhxaoj9QyY2KjXqUmirelxkX4zuPGTC3VdA0feJA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/plugin-transform-arrow-functions": "^7.27.1",
|
"@babel/plugin-transform-arrow-functions": "^7.27.1",
|
||||||
"@babel/plugin-transform-class-properties": "^7.28.6",
|
"@babel/plugin-transform-class-properties": "^7.28.6",
|
||||||
@@ -10965,6 +11125,16 @@
|
|||||||
"integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
|
"integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/slash": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/slugify": {
|
"node_modules/slugify": {
|
||||||
"version": "1.6.9",
|
"version": "1.6.9",
|
||||||
"resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.9.tgz",
|
"resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.9.tgz",
|
||||||
@@ -11398,6 +11568,16 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tmp": {
|
||||||
|
"version": "0.2.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz",
|
||||||
|
"integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tmpl": {
|
"node_modules/tmpl": {
|
||||||
"version": "1.0.5",
|
"version": "1.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
|
||||||
@@ -11687,6 +11867,16 @@
|
|||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/universalify": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/unpipe": {
|
"node_modules/unpipe": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||||
|
|||||||
@@ -16,18 +16,18 @@
|
|||||||
"expo-router": "~57.0.10",
|
"expo-router": "~57.0.10",
|
||||||
"expo-sqlite": "~57.0.1",
|
"expo-sqlite": "~57.0.1",
|
||||||
"expo-status-bar": "~57.0.1",
|
"expo-status-bar": "~57.0.1",
|
||||||
|
"expo-system-ui": "~57.0.2",
|
||||||
|
"expo-updates": "~57.0.12",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
"react-hook-form": "^7.51.5",
|
"react-hook-form": "^7.51.5",
|
||||||
"react-native": "0.86.2",
|
"react-native": "0.86.2",
|
||||||
"react-native-gesture-handler": "2.32.0",
|
"react-native-gesture-handler": "2.32.0",
|
||||||
"react-native-paper": "^5.12.3",
|
"react-native-keyboard-controller": "1.21.9",
|
||||||
"react-native-reanimated": "4.5.1",
|
|
||||||
"react-native-safe-area-context": "5.7.0",
|
"react-native-safe-area-context": "5.7.0",
|
||||||
"react-native-screens": "4.26.0",
|
"react-native-screens": "4.26.0",
|
||||||
"react-native-svg": "^15.15.4",
|
"react-native-svg": "^15.15.4",
|
||||||
"react-native-web": "^0.21.2",
|
"react-native-web": "^0.21.2",
|
||||||
"react-native-worklets": "0.10.1",
|
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -35,6 +35,7 @@
|
|||||||
"@types/react": "~19.2.2",
|
"@types/react": "~19.2.2",
|
||||||
"eslint": "^9.0.0",
|
"eslint": "^9.0.0",
|
||||||
"eslint-config-expo": "~57.0.1",
|
"eslint-config-expo": "~57.0.1",
|
||||||
|
"patch-package": "^8.0.1",
|
||||||
"prettier": "^3.9.6",
|
"prettier": "^3.9.6",
|
||||||
"typescript": "~6.0.3"
|
"typescript": "~6.0.3"
|
||||||
},
|
},
|
||||||
@@ -50,7 +51,8 @@
|
|||||||
"build:android": "eas build --platform android",
|
"build:android": "eas build --platform android",
|
||||||
"build:web": "eas build --platform web",
|
"build:web": "eas build --platform web",
|
||||||
"submit:ios": "eas submit --platform ios",
|
"submit:ios": "eas submit --platform ios",
|
||||||
"submit:android": "eas submit --platform android"
|
"submit:android": "eas submit --platform android",
|
||||||
|
"postinstall": "patch-package"
|
||||||
},
|
},
|
||||||
"private": true
|
"private": true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
diff --git a/node_modules/react-native/src/private/webapis/dom/events/Event.js b/node_modules/react-native/src/private/webapis/dom/events/Event.js
|
||||||
|
index f918f97..5deab46 100644
|
||||||
|
--- a/node_modules/react-native/src/private/webapis/dom/events/Event.js
|
||||||
|
+++ b/node_modules/react-native/src/private/webapis/dom/events/Event.js
|
||||||
|
@@ -70,7 +70,7 @@ export default class Event {
|
||||||
|
[CURRENT_TARGET_KEY]: EventTarget | null = null;
|
||||||
|
|
||||||
|
// $FlowExpectedError[unsupported-syntax]
|
||||||
|
- [EVENT_PHASE_KEY]: boolean = Event.NONE;
|
||||||
|
+ [EVENT_PHASE_KEY]: boolean = 0;
|
||||||
|
|
||||||
|
// $FlowExpectedError[unsupported-syntax]
|
||||||
|
[IN_PASSIVE_LISTENER_FLAG_KEY]: boolean = false;
|
||||||
|
@@ -193,48 +193,64 @@ export default class Event {
|
||||||
|
|
||||||
|
// $FlowExpectedError[cannot-write]
|
||||||
|
Object.defineProperty(Event, 'NONE', {
|
||||||
|
+ writable: true,
|
||||||
|
+ configurable: true,
|
||||||
|
enumerable: true,
|
||||||
|
value: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// $FlowExpectedError[cannot-write]
|
||||||
|
Object.defineProperty(Event.prototype, 'NONE', {
|
||||||
|
+ writable: true,
|
||||||
|
+ configurable: true,
|
||||||
|
enumerable: true,
|
||||||
|
value: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// $FlowExpectedError[cannot-write]
|
||||||
|
Object.defineProperty(Event, 'CAPTURING_PHASE', {
|
||||||
|
+ writable: true,
|
||||||
|
+ configurable: true,
|
||||||
|
enumerable: true,
|
||||||
|
value: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
// $FlowExpectedError[cannot-write]
|
||||||
|
Object.defineProperty(Event.prototype, 'CAPTURING_PHASE', {
|
||||||
|
+ writable: true,
|
||||||
|
+ configurable: true,
|
||||||
|
enumerable: true,
|
||||||
|
value: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
// $FlowExpectedError[cannot-write]
|
||||||
|
Object.defineProperty(Event, 'AT_TARGET', {
|
||||||
|
+ writable: true,
|
||||||
|
+ configurable: true,
|
||||||
|
enumerable: true,
|
||||||
|
value: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
// $FlowExpectedError[cannot-write]
|
||||||
|
Object.defineProperty(Event.prototype, 'AT_TARGET', {
|
||||||
|
+ writable: true,
|
||||||
|
+ configurable: true,
|
||||||
|
enumerable: true,
|
||||||
|
value: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
// $FlowExpectedError[cannot-write]
|
||||||
|
Object.defineProperty(Event, 'BUBBLING_PHASE', {
|
||||||
|
+ writable: true,
|
||||||
|
+ configurable: true,
|
||||||
|
enumerable: true,
|
||||||
|
value: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
// $FlowExpectedError[cannot-write]
|
||||||
|
Object.defineProperty(Event.prototype, 'BUBBLING_PHASE', {
|
||||||
|
+ writable: true,
|
||||||
|
+ configurable: true,
|
||||||
|
enumerable: true,
|
||||||
|
value: 3,
|
||||||
|
});
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
const {
|
||||||
|
withAndroidManifest,
|
||||||
|
withDangerousMod,
|
||||||
|
withStringsXml,
|
||||||
|
} = require('expo/config-plugins');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const RECEIVER_NAME = '.QuickAddWidgetProvider';
|
||||||
|
|
||||||
|
const WIDGET_PROVIDER_XML = `<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:minWidth="250dp"
|
||||||
|
android:minHeight="40dp"
|
||||||
|
android:targetCellWidth="4"
|
||||||
|
android:targetCellHeight="1"
|
||||||
|
android:updatePeriodMillis="0"
|
||||||
|
android:initialLayout="@layout/widget_quick_add"
|
||||||
|
android:resizeMode="horizontal"
|
||||||
|
android:widgetCategory="home_screen"
|
||||||
|
android:description="@string/widget_quick_add_description" />
|
||||||
|
`;
|
||||||
|
|
||||||
|
const WIDGET_LAYOUT_XML = `<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:background="@drawable/widget_quick_add_bg"
|
||||||
|
android:padding="14dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/widget_title"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="carry your live"
|
||||||
|
android:textColor="#8E8E8E"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/quick_add_button"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:background="@drawable/widget_quick_add_button_bg"
|
||||||
|
android:text="+ Add task"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
|
android:textSize="16sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
</LinearLayout>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const WIDGET_BG_XML = `<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||||
|
<solid android:color="#1E1E1E" />
|
||||||
|
<corners android:radius="16dp" />
|
||||||
|
<stroke android:width="1dp" android:color="#2A2A2A" />
|
||||||
|
</shape>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const WIDGET_BUTTON_BG_XML = `<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||||
|
<solid android:color="#EF5350" />
|
||||||
|
<corners android:radius="12dp" />
|
||||||
|
</shape>
|
||||||
|
`;
|
||||||
|
|
||||||
|
function kotlinProvider(packageName) {
|
||||||
|
return `package ${packageName}
|
||||||
|
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.appwidget.AppWidgetManager
|
||||||
|
import android.appwidget.AppWidgetProvider
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.widget.RemoteViews
|
||||||
|
|
||||||
|
class QuickAddWidgetProvider : AppWidgetProvider() {
|
||||||
|
|
||||||
|
override fun onUpdate(
|
||||||
|
context: Context,
|
||||||
|
appWidgetManager: AppWidgetManager,
|
||||||
|
appWidgetIds: IntArray
|
||||||
|
) {
|
||||||
|
for (appWidgetId in appWidgetIds) {
|
||||||
|
updateWidget(context, appWidgetManager, appWidgetId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateWidget(
|
||||||
|
context: Context,
|
||||||
|
appWidgetManager: AppWidgetManager,
|
||||||
|
appWidgetId: Int
|
||||||
|
) {
|
||||||
|
val views = RemoteViews(context.packageName, R.layout.widget_quick_add)
|
||||||
|
|
||||||
|
val openAppIntent = Intent(context, MainActivity::class.java).apply {
|
||||||
|
action = Intent.ACTION_VIEW
|
||||||
|
data = Uri.parse("exp+carry-your-live://quick-add")
|
||||||
|
}
|
||||||
|
val pendingIntent = PendingIntent.getActivity(
|
||||||
|
context,
|
||||||
|
0,
|
||||||
|
openAppIntent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
views.setOnClickPendingIntent(R.id.quick_add_button, pendingIntent)
|
||||||
|
|
||||||
|
appWidgetManager.updateAppWidget(appWidgetId, views)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function withQuickAddWidget(config) {
|
||||||
|
const packageName = config.android?.package ?? 'com.anonymous.carryyourlive';
|
||||||
|
|
||||||
|
config = withStringsXml(config, (config) => {
|
||||||
|
const strings = config.modResults;
|
||||||
|
if (!strings.resources.string) {
|
||||||
|
strings.resources.string = [];
|
||||||
|
}
|
||||||
|
const existing = (strings.resources.string || []).find(
|
||||||
|
(s) => s && s['$'] && s['$'].name === 'widget_quick_add_description'
|
||||||
|
);
|
||||||
|
if (!existing) {
|
||||||
|
strings.resources.string.push({
|
||||||
|
$: { name: 'widget_quick_add_description' },
|
||||||
|
_: 'Quickly add a task',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
config = withAndroidManifest(config, (config) => {
|
||||||
|
const manifest = config.modResults;
|
||||||
|
const application = manifest.manifest.application?.[0];
|
||||||
|
if (!application) return config;
|
||||||
|
|
||||||
|
const receivers = application.receiver || [];
|
||||||
|
const exists = receivers.some((r) => r && r['$'] && r['$']['android:name'] === RECEIVER_NAME);
|
||||||
|
if (!exists) {
|
||||||
|
application.receiver = [
|
||||||
|
...receivers,
|
||||||
|
{
|
||||||
|
$: {
|
||||||
|
'android:name': RECEIVER_NAME,
|
||||||
|
'android:exported': 'false',
|
||||||
|
'android:label': 'Quick Add Task',
|
||||||
|
},
|
||||||
|
'intent-filter': [
|
||||||
|
{
|
||||||
|
action: [{ $: { 'android:name': 'android.appwidget.action.APPWIDGET_UPDATE' } }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'meta-data': [
|
||||||
|
{
|
||||||
|
$: {
|
||||||
|
'android:name': 'android.appwidget.provider',
|
||||||
|
'android:resource': '@xml/quick_add_widget',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
config = withDangerousMod(config, [
|
||||||
|
'android',
|
||||||
|
async (config) => {
|
||||||
|
const projectRoot = config.modRequest.projectRoot;
|
||||||
|
const resDir = path.join(projectRoot, 'android', 'app', 'src', 'main', 'res');
|
||||||
|
const javaDir = path.join(
|
||||||
|
projectRoot,
|
||||||
|
'android',
|
||||||
|
'app',
|
||||||
|
'src',
|
||||||
|
'main',
|
||||||
|
'java',
|
||||||
|
...packageName.split('.')
|
||||||
|
);
|
||||||
|
|
||||||
|
fs.mkdirSync(path.join(resDir, 'xml'), { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(resDir, 'layout'), { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(resDir, 'drawable'), { recursive: true });
|
||||||
|
fs.mkdirSync(javaDir, { recursive: true });
|
||||||
|
|
||||||
|
fs.writeFileSync(path.join(resDir, 'xml', 'quick_add_widget.xml'), WIDGET_PROVIDER_XML);
|
||||||
|
fs.writeFileSync(path.join(resDir, 'layout', 'widget_quick_add.xml'), WIDGET_LAYOUT_XML);
|
||||||
|
fs.writeFileSync(path.join(resDir, 'drawable', 'widget_quick_add_bg.xml'), WIDGET_BG_XML);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(resDir, 'drawable', 'widget_quick_add_button_bg.xml'),
|
||||||
|
WIDGET_BUTTON_BG_XML
|
||||||
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(javaDir, 'QuickAddWidgetProvider.kt'),
|
||||||
|
kotlinProvider(packageName)
|
||||||
|
);
|
||||||
|
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = withQuickAddWidget;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { View, Text, TouchableOpacity, StyleSheet, Modal, FlatList, ActivityIndicator, TextInput } from 'react-native';
|
import { View, Text, TouchableOpacity, StyleSheet, Modal, FlatList, ActivityIndicator, TextInput, KeyboardAvoidingView } from 'react-native';
|
||||||
import { useSettings } from '@/theme';
|
import { useSettings } from '@/theme';
|
||||||
import { useFriends } from '@/hooks/useFriends';
|
import { useFriends } from '@/hooks/useFriends';
|
||||||
|
|
||||||
@@ -15,11 +15,19 @@ export function AssigneeSelector({ value, onChange, disabled = false }: Assignee
|
|||||||
const { friends, searchUsers, loading: friendsLoading } = useFriends();
|
const { friends, searchUsers, loading: friendsLoading } = useFriends();
|
||||||
const [showModal, setShowModal] = React.useState(false);
|
const [showModal, setShowModal] = React.useState(false);
|
||||||
const [searchQuery, setSearchQuery] = React.useState('');
|
const [searchQuery, setSearchQuery] = React.useState('');
|
||||||
const [searchResults, setSearchResults] = React.useState<Array<{ id: string; username: string }>>([]);
|
const [searchResults, setSearchResults] = React.useState<{ id: string; username: string }[]>([]);
|
||||||
const [searching, setSearching] = React.useState(false);
|
const [searching, setSearching] = React.useState(false);
|
||||||
|
|
||||||
const selectedFriend = value ? friends.find(f => f.id === value) : null;
|
const selectedFriend = value ? friends.find(f => f.id === value) : null;
|
||||||
|
|
||||||
|
const searchKey = `${showModal}:${searchQuery}`;
|
||||||
|
const [prevSearchKey, setPrevSearchKey] = React.useState(searchKey);
|
||||||
|
|
||||||
|
if (prevSearchKey !== searchKey && (!showModal || searchQuery.length < 2)) {
|
||||||
|
setPrevSearchKey(searchKey);
|
||||||
|
setSearchResults([]);
|
||||||
|
}
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (showModal && searchQuery.length >= 2) {
|
if (showModal && searchQuery.length >= 2) {
|
||||||
const timeout = setTimeout(async () => {
|
const timeout = setTimeout(async () => {
|
||||||
@@ -34,8 +42,6 @@ export function AssigneeSelector({ value, onChange, disabled = false }: Assignee
|
|||||||
}
|
}
|
||||||
}, 300);
|
}, 300);
|
||||||
return () => clearTimeout(timeout);
|
return () => clearTimeout(timeout);
|
||||||
} else {
|
|
||||||
setSearchResults([]);
|
|
||||||
}
|
}
|
||||||
}, [searchQuery, showModal, friends, searchUsers]);
|
}, [searchQuery, showModal, friends, searchUsers]);
|
||||||
|
|
||||||
@@ -54,8 +60,11 @@ export function AssigneeSelector({ value, onChange, disabled = false }: Assignee
|
|||||||
await sendFriendRequest(username);
|
await sendFriendRequest(username);
|
||||||
onChange(friendId);
|
onChange(friendId);
|
||||||
setShowModal(false);
|
setShowModal(false);
|
||||||
} catch (err) {
|
} catch (err: any) {
|
||||||
console.error('Failed to add friend:', err);
|
console.error('Failed to add friend:', err.message, err.details || '');
|
||||||
|
if (err.details) {
|
||||||
|
console.error('Validation details:', JSON.stringify(err.details, null, 2));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -70,7 +79,7 @@ export function AssigneeSelector({ value, onChange, disabled = false }: Assignee
|
|||||||
onPress={() => !disabled && setShowModal(true)}
|
onPress={() => !disabled && setShowModal(true)}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
>
|
>
|
||||||
<Text style={[styles.selectorIcon, { color: theme.accent }]}>👤</Text>
|
<Text style={[styles.selectorIcon, { color: theme.accentStrong }]}>👤</Text>
|
||||||
<View style={styles.selectorContent}>
|
<View style={styles.selectorContent}>
|
||||||
<Text style={[styles.selectorLabel, { color: theme.textMuted }]}>Assignee</Text>
|
<Text style={[styles.selectorLabel, { color: theme.textMuted }]}>Assignee</Text>
|
||||||
<Text
|
<Text
|
||||||
@@ -97,7 +106,10 @@ export function AssigneeSelector({ value, onChange, disabled = false }: Assignee
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
<Modal visible={showModal} transparent animationType="fade" onRequestClose={() => setShowModal(false)}>
|
<Modal visible={showModal} transparent animationType="fade" onRequestClose={() => setShowModal(false)}>
|
||||||
<View style={[styles.modalOverlay, { backgroundColor: 'rgba(0,0,0,0.5)' }]}>
|
<KeyboardAvoidingView
|
||||||
|
style={[styles.modalOverlay, { backgroundColor: 'rgba(0,0,0,0.5)' }]}
|
||||||
|
behavior="padding"
|
||||||
|
>
|
||||||
<View style={[styles.modalSheet, { backgroundColor: theme.sheetBg }]}>
|
<View style={[styles.modalSheet, { backgroundColor: theme.sheetBg }]}>
|
||||||
<View style={styles.modalHeader}>
|
<View style={styles.modalHeader}>
|
||||||
<Text style={[styles.modalTitle, { color: theme.text }]}>Assign Task</Text>
|
<Text style={[styles.modalTitle, { color: theme.text }]}>Assign Task</Text>
|
||||||
@@ -119,7 +131,7 @@ export function AssigneeSelector({ value, onChange, disabled = false }: Assignee
|
|||||||
</View>
|
</View>
|
||||||
<Text style={[styles.optionText, { color: theme.text }]}>Unassigned</Text>
|
<Text style={[styles.optionText, { color: theme.text }]}>Unassigned</Text>
|
||||||
{selectedFriend && (
|
{selectedFriend && (
|
||||||
<Text style={[styles.checkmark, { color: theme.accent }]}>✓</Text>
|
<Text style={[styles.checkmark, { color: theme.accentStrong }]}>✓</Text>
|
||||||
)}
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
@@ -149,7 +161,7 @@ export function AssigneeSelector({ value, onChange, disabled = false }: Assignee
|
|||||||
</View>
|
</View>
|
||||||
<Text style={[styles.optionText, { color: theme.text }]}>{item.username}</Text>
|
<Text style={[styles.optionText, { color: theme.text }]}>{item.username}</Text>
|
||||||
{value === item.id && (
|
{value === item.id && (
|
||||||
<Text style={[styles.checkmark, { color: theme.accent }]}>✓</Text>
|
<Text style={[styles.checkmark, { color: theme.accentStrong }]}>✓</Text>
|
||||||
)}
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
@@ -197,7 +209,7 @@ export function AssigneeSelector({ value, onChange, disabled = false }: Assignee
|
|||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</KeyboardAvoidingView>
|
||||||
</Modal>
|
</Modal>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -210,48 +222,48 @@ const styles = StyleSheet.create({
|
|||||||
selectorButton: {
|
selectorButton: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 10,
|
gap: 8,
|
||||||
paddingVertical: 14,
|
paddingVertical: 12,
|
||||||
paddingHorizontal: 12,
|
paddingHorizontal: 10,
|
||||||
borderRadius: 12,
|
borderRadius: 10,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
minHeight: 52,
|
minHeight: 48,
|
||||||
},
|
},
|
||||||
selectorIcon: {
|
selectorIcon: {
|
||||||
fontSize: 20,
|
fontSize: 18,
|
||||||
},
|
},
|
||||||
selectorContent: {
|
selectorContent: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
selectorLabel: {
|
selectorLabel: {
|
||||||
fontSize: 11,
|
fontSize: 10,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
letterSpacing: 0.5,
|
letterSpacing: 0.5,
|
||||||
marginBottom: 2,
|
marginBottom: 1,
|
||||||
},
|
},
|
||||||
selectorValue: {
|
selectorValue: {
|
||||||
fontSize: 15,
|
fontSize: 14,
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
},
|
},
|
||||||
clearButton: {
|
clearButton: {
|
||||||
padding: 4,
|
padding: 3,
|
||||||
},
|
},
|
||||||
clearText: {
|
clearText: {
|
||||||
fontSize: 18,
|
fontSize: 16,
|
||||||
fontWeight: '300',
|
fontWeight: '300',
|
||||||
},
|
},
|
||||||
modalOverlay: {
|
modalOverlay: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
padding: 24,
|
padding: 20,
|
||||||
},
|
},
|
||||||
modalSheet: {
|
modalSheet: {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
maxWidth: 400,
|
maxWidth: 360,
|
||||||
borderRadius: 20,
|
borderRadius: 16,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
maxHeight: '85%',
|
maxHeight: '85%',
|
||||||
},
|
},
|
||||||
@@ -259,81 +271,81 @@ const styles = StyleSheet.create({
|
|||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
padding: 20,
|
padding: 16,
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
},
|
},
|
||||||
modalTitle: {
|
modalTitle: {
|
||||||
fontSize: 18,
|
fontSize: 16,
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
},
|
},
|
||||||
closeText: {
|
closeText: {
|
||||||
fontSize: 22,
|
fontSize: 20,
|
||||||
fontWeight: '300',
|
fontWeight: '300',
|
||||||
},
|
},
|
||||||
modalSection: {
|
modalSection: {
|
||||||
padding: 12,
|
padding: 10,
|
||||||
paddingBottom: 20,
|
paddingBottom: 16,
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
},
|
},
|
||||||
sectionTitle: {
|
sectionTitle: {
|
||||||
fontSize: 12,
|
fontSize: 11,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
letterSpacing: 0.5,
|
letterSpacing: 0.5,
|
||||||
marginBottom: 8,
|
marginBottom: 6,
|
||||||
paddingHorizontal: 8,
|
paddingHorizontal: 6,
|
||||||
},
|
},
|
||||||
optionRow: {
|
optionRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 12,
|
gap: 10,
|
||||||
paddingVertical: 12,
|
paddingVertical: 10,
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 14,
|
||||||
borderRadius: 10,
|
borderRadius: 9,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
},
|
},
|
||||||
optionIcon: {
|
optionIcon: {
|
||||||
width: 36,
|
width: 32,
|
||||||
height: 36,
|
height: 32,
|
||||||
borderRadius: 18,
|
borderRadius: 16,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
backgroundColor: 'rgba(0,0,0,0.05)',
|
backgroundColor: 'rgba(0,0,0,0.05)',
|
||||||
},
|
},
|
||||||
optionIconText: {
|
optionIconText: {
|
||||||
fontSize: 16,
|
fontSize: 15,
|
||||||
},
|
},
|
||||||
optionText: {
|
optionText: {
|
||||||
fontSize: 16,
|
fontSize: 15,
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
optionSubtext: {
|
optionSubtext: {
|
||||||
fontSize: 12,
|
fontSize: 11,
|
||||||
marginTop: 2,
|
marginTop: 1,
|
||||||
},
|
},
|
||||||
checkmark: {
|
checkmark: {
|
||||||
fontSize: 18,
|
fontSize: 16,
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
},
|
},
|
||||||
loading: {
|
loading: {
|
||||||
padding: 20,
|
padding: 16,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
emptyText: {
|
emptyText: {
|
||||||
fontSize: 14,
|
fontSize: 13,
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
paddingHorizontal: 20,
|
paddingHorizontal: 16,
|
||||||
},
|
},
|
||||||
searchContainer: {
|
searchContainer: {
|
||||||
padding: 12,
|
padding: 10,
|
||||||
paddingBottom: 8,
|
paddingBottom: 6,
|
||||||
},
|
},
|
||||||
searchInput: {
|
searchInput: {
|
||||||
fontSize: 16,
|
fontSize: 15,
|
||||||
paddingVertical: 12,
|
paddingVertical: 10,
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 14,
|
||||||
borderRadius: 12,
|
borderRadius: 10,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Modal, View, Text, StyleSheet, TextInput, TouchableOpacity, ScrollView } from 'react-native';
|
import { Modal, View, Text, StyleSheet, TextInput, TouchableOpacity, ScrollView, KeyboardAvoidingView, Platform, Pressable } from 'react-native';
|
||||||
import Category from '@/models/Category';
|
import Category from '@/models/Category';
|
||||||
import { useSettings } from '@/theme';
|
import { useSettings } from '@/theme';
|
||||||
import { CATEGORY_COLORS } from '@/constants';
|
import { CATEGORY_COLORS } from '@/constants';
|
||||||
@@ -19,13 +19,15 @@ export function CategoryEditorModal({ visible, category, categoryCount, onClose
|
|||||||
const { theme } = useSettings();
|
const { theme } = useSettings();
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [color, setColor] = useState(CATEGORY_COLORS[0]);
|
const [color, setColor] = useState(CATEGORY_COLORS[0]);
|
||||||
|
const [prevVisible, setPrevVisible] = useState(visible);
|
||||||
|
|
||||||
useEffect(() => {
|
if (prevVisible !== visible) {
|
||||||
|
setPrevVisible(visible);
|
||||||
if (visible) {
|
if (visible) {
|
||||||
setName(category?.name ?? '');
|
setName(category?.name ?? '');
|
||||||
setColor(category?.color ?? CATEGORY_COLORS[0]);
|
setColor(category?.color ?? CATEGORY_COLORS[0]);
|
||||||
}
|
}
|
||||||
}, [visible, category]);
|
}
|
||||||
|
|
||||||
const canDelete = category !== null && categoryCount > 1;
|
const canDelete = category !== null && categoryCount > 1;
|
||||||
|
|
||||||
@@ -56,15 +58,30 @@ export function CategoryEditorModal({ visible, category, categoryCount, onClose
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
||||||
<View style={styles.overlay}>
|
<KeyboardAvoidingView
|
||||||
<View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}>
|
style={styles.fill}
|
||||||
|
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
style={styles.overlay}
|
||||||
|
onPress={onClose}
|
||||||
|
accessibilityRole="none"
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
style={[styles.sheet, { backgroundColor: theme.sheetBg }]}
|
||||||
|
onPress={() => {}}
|
||||||
|
>
|
||||||
|
<ScrollView
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
>
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<Text style={[styles.title, { color: theme.text }]}>
|
<Text style={[styles.title, { color: theme.text }]}>
|
||||||
{category ? 'Edit Category' : 'New Category'}
|
{category ? 'Edit Category' : 'New Category'}
|
||||||
</Text>
|
</Text>
|
||||||
<TouchableOpacity onPress={onClose} style={styles.closeButton} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
|
<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">
|
<Svg width={18} height={18} viewBox="0 0 24 24">
|
||||||
<Path d="M18 6L6 18M6 6l12 12" stroke={theme.textMuted} strokeWidth={2} strokeLinecap="round" />
|
<Path d="M18 6L6 18M6 6l12 12" stroke={theme.textSecondary} strokeWidth={2.2} strokeLinecap="round" />
|
||||||
</Svg>
|
</Svg>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
@@ -96,6 +113,9 @@ export function CategoryEditorModal({ visible, category, categoryCount, onClose
|
|||||||
]}
|
]}
|
||||||
onPress={() => setColor(c)}
|
onPress={() => setColor(c)}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
|
accessibilityRole="radio"
|
||||||
|
accessibilityLabel={`Color ${c}`}
|
||||||
|
accessibilityState={{ selected: color === c }}
|
||||||
>
|
>
|
||||||
{color === c && (
|
{color === c && (
|
||||||
<Svg width={16} height={16} viewBox="0 0 24 24">
|
<Svg width={16} height={16} viewBox="0 0 24 24">
|
||||||
@@ -126,19 +146,24 @@ export function CategoryEditorModal({ visible, category, categoryCount, onClose
|
|||||||
disabled={!name.trim()}
|
disabled={!name.trim()}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
>
|
>
|
||||||
<Text style={styles.saveButtonText}>Save</Text>
|
<Text style={[styles.saveButtonText, { color: theme.accentText }]}>Save</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</ScrollView>
|
||||||
</View>
|
</Pressable>
|
||||||
|
</Pressable>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
|
fill: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
overlay: {
|
overlay: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: 'rgba(0,0,0,0.4)',
|
backgroundColor: 'rgba(0,0,0,0.55)',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
padding: 24,
|
padding: 24,
|
||||||
@@ -146,6 +171,7 @@ const styles = StyleSheet.create({
|
|||||||
sheet: {
|
sheet: {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
maxWidth: 380,
|
maxWidth: 380,
|
||||||
|
maxHeight: '100%',
|
||||||
borderRadius: 16,
|
borderRadius: 16,
|
||||||
padding: 20,
|
padding: 20,
|
||||||
gap: 8,
|
gap: 8,
|
||||||
@@ -224,6 +250,5 @@ const styles = StyleSheet.create({
|
|||||||
saveButtonText: {
|
saveButtonText: {
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
color: '#FFFFFF',
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,21 +1,32 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { View, Text, StyleSheet, ScrollView, TouchableOpacity, Animated, Easing } from 'react-native';
|
import { View, Text, StyleSheet, ScrollView, TouchableOpacity } from 'react-native';
|
||||||
import { useCategories } from '@/hooks/useDatabase';
|
import { useRouter } from 'expo-router';
|
||||||
|
import { useUniqueCategories } from '@/hooks/useDatabase';
|
||||||
import { useSettings, ThemeColors } from '@/theme';
|
import { useSettings, ThemeColors } from '@/theme';
|
||||||
import Category from '@/models/Category';
|
import Svg, { Path, Circle } from 'react-native-svg';
|
||||||
|
|
||||||
interface CategoryFilterProps {
|
interface CategoryFilterProps {
|
||||||
selected: string;
|
selected: string[];
|
||||||
onSelect: (categoryId: string) => void;
|
onSelect: (categoryIds: string[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CategoryFilter({ selected, onSelect }: CategoryFilterProps) {
|
export function CategoryFilter({ selected, onSelect }: CategoryFilterProps) {
|
||||||
const categories = useCategories();
|
const categories = useUniqueCategories();
|
||||||
const { theme } = useSettings();
|
const { theme } = useSettings();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
if (categories.length === 0) {
|
const selectedSet = new Set(selected);
|
||||||
return null;
|
const isAnythingSelected = selected.length > 0;
|
||||||
|
|
||||||
|
const toggle = (id: string) => {
|
||||||
|
const next = new Set(selectedSet);
|
||||||
|
if (next.has(id)) {
|
||||||
|
next.delete(id);
|
||||||
|
} else {
|
||||||
|
next.add(id);
|
||||||
}
|
}
|
||||||
|
onSelect(Array.from(next));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
@@ -28,19 +39,37 @@ export function CategoryFilter({ selected, onSelect }: CategoryFilterProps) {
|
|||||||
id="all"
|
id="all"
|
||||||
name="All"
|
name="All"
|
||||||
color="#9E9E9E"
|
color="#9E9E9E"
|
||||||
selected={selected === 'all'}
|
selected={!isAnythingSelected}
|
||||||
onPress={() => onSelect('all')}
|
onPress={() => onSelect([])}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
/>
|
/>
|
||||||
{categories.map((category) => (
|
{categories.map((category) => (
|
||||||
<AnimatedCategoryButton
|
<CategoryButton
|
||||||
key={category.id}
|
key={category.id}
|
||||||
category={category}
|
id={category.id}
|
||||||
selected={selected === category.id}
|
name={category.name}
|
||||||
onPress={() => onSelect(category.id)}
|
color={category.color}
|
||||||
|
selected={selectedSet.has(category.id)}
|
||||||
|
onPress={() => toggle(category.id)}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.manageButton, { borderColor: theme.borderStrong, backgroundColor: theme.cardAlt }]}
|
||||||
|
onPress={() => router.push('/settings')}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Manage categories"
|
||||||
|
hitSlop={{ top: 8, bottom: 8, left: 4, right: 4 }}
|
||||||
|
>
|
||||||
|
<Svg width={14} height={14} viewBox="0 0 24 24">
|
||||||
|
<Circle cx={12} cy={12} r={9} stroke={theme.textSecondary} strokeWidth={2.2} fill="none" />
|
||||||
|
<Circle cx={12} cy={8.5} r={1.2} fill={theme.textSecondary} />
|
||||||
|
<Circle cx={12} cy={15.5} r={1.2} fill={theme.textSecondary} />
|
||||||
|
<Path d="M7.8 6.2l1 1.7M16.2 16.1l1 1.7M7.8 17.8l1-1.7M16.2 7.9l1-1.7" stroke={theme.textSecondary} strokeWidth={1.8} strokeLinecap="round" />
|
||||||
|
</Svg>
|
||||||
|
<Text style={[styles.manageLabel, { color: theme.textSecondary }]}>Manage</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -54,7 +83,7 @@ interface CategoryButtonProps {
|
|||||||
theme: ThemeColors;
|
theme: ThemeColors;
|
||||||
}
|
}
|
||||||
|
|
||||||
function CategoryButton({ id, name, color, selected, onPress, theme }: CategoryButtonProps) {
|
function CategoryButton({ name, color, selected, onPress, theme }: CategoryButtonProps) {
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[
|
style={[
|
||||||
@@ -64,18 +93,18 @@ function CategoryButton({ id, name, color, selected, onPress, theme }: CategoryB
|
|||||||
]}
|
]}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
|
hitSlop={{ top: 6, bottom: 6, left: 4, right: 4 }}
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
styles.colorDot,
|
styles.colorDot,
|
||||||
{ backgroundColor: color },
|
{ backgroundColor: color },
|
||||||
selected && styles.colorDotSelected,
|
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<Text style={[
|
<Text style={[
|
||||||
styles.buttonText,
|
styles.buttonText,
|
||||||
{ color: theme.textSecondary },
|
{ color: theme.textSecondary },
|
||||||
selected && { color: theme.accent, fontWeight: '600' },
|
selected && { color: theme.text, fontWeight: '600' },
|
||||||
]}>
|
]}>
|
||||||
{name}
|
{name}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -83,113 +112,50 @@ function CategoryButton({ id, name, color, selected, onPress, theme }: CategoryB
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AnimatedCategoryButtonProps {
|
|
||||||
category: Category;
|
|
||||||
selected: boolean;
|
|
||||||
onPress: () => void;
|
|
||||||
theme: ThemeColors;
|
|
||||||
}
|
|
||||||
|
|
||||||
function AnimatedCategoryButton({ category, selected, onPress, theme }: AnimatedCategoryButtonProps) {
|
|
||||||
const scaleAnim = React.useRef(new Animated.Value(selected ? 1.05 : 1)).current;
|
|
||||||
const borderWidthAnim = React.useRef(new Animated.Value(selected ? 2 : 1)).current;
|
|
||||||
const shadowOpacityAnim = React.useRef(new Animated.Value(selected ? 0.15 : 0)).current;
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
Animated.timing(scaleAnim, {
|
|
||||||
toValue: selected ? 1.05 : 1,
|
|
||||||
duration: 150,
|
|
||||||
easing: Easing.out(Easing.cubic),
|
|
||||||
useNativeDriver: false,
|
|
||||||
}).start();
|
|
||||||
|
|
||||||
Animated.timing(borderWidthAnim, {
|
|
||||||
toValue: selected ? 2 : 1,
|
|
||||||
duration: 150,
|
|
||||||
useNativeDriver: false,
|
|
||||||
}).start();
|
|
||||||
|
|
||||||
Animated.timing(shadowOpacityAnim, {
|
|
||||||
toValue: selected ? 0.15 : 0,
|
|
||||||
duration: 150,
|
|
||||||
useNativeDriver: false,
|
|
||||||
}).start();
|
|
||||||
}, [selected, scaleAnim, borderWidthAnim, shadowOpacityAnim]);
|
|
||||||
|
|
||||||
const animatedStyle = {
|
|
||||||
transform: [{ scale: scaleAnim }],
|
|
||||||
borderWidth: borderWidthAnim,
|
|
||||||
shadowOpacity: shadowOpacityAnim,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Animated.View style={[styles.button, styles.animatedButton, { backgroundColor: theme.card, borderColor: theme.borderStrong }, animatedStyle]}>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.buttonInner}
|
|
||||||
onPress={onPress}
|
|
||||||
activeOpacity={0.8}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
styles.colorDot,
|
|
||||||
{ backgroundColor: category.color },
|
|
||||||
selected && styles.colorDotSelected,
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<Text style={[
|
|
||||||
styles.buttonText,
|
|
||||||
{ color: theme.textSecondary },
|
|
||||||
selected && { color: theme.accent, fontWeight: '600' },
|
|
||||||
]}>
|
|
||||||
{category.name}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</Animated.View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
scrollView: {
|
scrollView: {
|
||||||
paddingVertical: 8,
|
paddingVertical: 6,
|
||||||
},
|
},
|
||||||
container: {
|
container: {
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 12,
|
||||||
gap: 8,
|
gap: 8,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
button: {
|
button: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingHorizontal: 14,
|
paddingHorizontal: 12,
|
||||||
paddingVertical: 8,
|
paddingVertical: 10,
|
||||||
borderRadius: 20,
|
borderRadius: 18,
|
||||||
borderWidth: 1,
|
borderWidth: 1.5,
|
||||||
minWidth: 72,
|
minHeight: 38,
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
gap: 8,
|
||||||
},
|
},
|
||||||
animatedButton: {
|
colorDot: {
|
||||||
shadowColor: '#000',
|
width: 8,
|
||||||
shadowOffset: { width: 0, height: 2 },
|
height: 8,
|
||||||
shadowRadius: 8,
|
borderRadius: 4,
|
||||||
elevation: 3,
|
|
||||||
},
|
},
|
||||||
buttonInner: {
|
buttonText: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
manageButton: {
|
||||||
|
alignSelf: 'center',
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 6,
|
gap: 6,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 10,
|
||||||
|
borderRadius: 18,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: 'dashed',
|
||||||
|
minHeight: 38,
|
||||||
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
colorDot: {
|
manageLabel: {
|
||||||
width: 10,
|
fontSize: 12,
|
||||||
height: 10,
|
fontWeight: '600',
|
||||||
borderRadius: 5,
|
|
||||||
},
|
|
||||||
colorDotSelected: {
|
|
||||||
width: 12,
|
|
||||||
height: 12,
|
|
||||||
borderRadius: 6,
|
|
||||||
},
|
|
||||||
buttonText: {
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: '500',
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -1,93 +1,187 @@
|
|||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import { View, Text, StyleSheet, TouchableOpacity, ScrollView } from 'react-native';
|
import { View, Text, StyleSheet, TouchableOpacity, ScrollView, Modal, Pressable, KeyboardAvoidingView } from 'react-native';
|
||||||
import { useCategories } from '@/hooks/useDatabase';
|
import { useCategories } from '@/hooks/useDatabase';
|
||||||
import { useSettings } from '@/theme';
|
import { useSettings } from '@/theme';
|
||||||
|
import Svg, { Path } from 'react-native-svg';
|
||||||
|
|
||||||
interface CategorySelectorProps {
|
interface CategorySelectorProps {
|
||||||
value: string;
|
value: string[];
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string[]) => void;
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CategorySelector({ value, onChange, error }: CategorySelectorProps) {
|
export function CategorySelector({ value, onChange, error }: CategorySelectorProps) {
|
||||||
const categories = useCategories();
|
const categories = useCategories();
|
||||||
const { theme } = useSettings();
|
const { theme } = useSettings();
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
|
||||||
|
const selected = new Set(value);
|
||||||
|
const selectedCategories = categories.filter((c) => selected.has(c.id));
|
||||||
|
|
||||||
|
const toggle = (id: string) => {
|
||||||
|
const next = new Set(selected);
|
||||||
|
if (next.has(id)) {
|
||||||
|
next.delete(id);
|
||||||
|
} else {
|
||||||
|
next.add(id);
|
||||||
|
}
|
||||||
|
onChange(Array.from(next));
|
||||||
|
};
|
||||||
|
|
||||||
|
const summary =
|
||||||
|
selectedCategories.length === 0
|
||||||
|
? 'None'
|
||||||
|
: selectedCategories
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((c) => c.name)
|
||||||
|
.join(', ') + (selectedCategories.length > 2 ? ` +${selectedCategories.length - 2}` : '');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<Text style={[styles.label, { color: theme.text }]}>Category</Text>
|
<Text style={[styles.label, { color: theme.text }]}>Tags</Text>
|
||||||
<View style={styles.requiredIndicator} />
|
<TouchableOpacity
|
||||||
<ScrollView
|
style={[
|
||||||
horizontal
|
styles.selectorButton,
|
||||||
showsHorizontalScrollIndicator={false}
|
{ backgroundColor: theme.inputBg, borderColor: theme.borderStrong },
|
||||||
contentContainerStyle={styles.scrollContent}
|
]}
|
||||||
style={styles.scrollView}
|
onPress={() => setShowModal(true)}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Select tags"
|
||||||
|
accessibilityHint="Opens a list of tags to choose from"
|
||||||
>
|
>
|
||||||
{categories.map((category) => (
|
<View style={styles.selectorContent}>
|
||||||
|
{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>}
|
||||||
|
|
||||||
|
<Modal visible={showModal} transparent animationType="fade" onRequestClose={() => setShowModal(false)}>
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
style={[styles.modalOverlay, { backgroundColor: 'rgba(0,0,0,0.5)' }]}
|
||||||
|
behavior="padding"
|
||||||
|
>
|
||||||
|
<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 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}>
|
||||||
|
<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
|
<TouchableOpacity
|
||||||
key={category.id}
|
key={category.id}
|
||||||
style={[
|
style={[
|
||||||
styles.categoryButton,
|
styles.modalOption,
|
||||||
{ backgroundColor: theme.card, borderColor: theme.borderStrong },
|
{ backgroundColor: theme.card, borderColor: theme.borderStrong },
|
||||||
value === category.id && { borderColor: theme.accent, backgroundColor: theme.accentSoft, borderWidth: 2 },
|
isSelected && { borderColor: theme.accent, backgroundColor: theme.accentSoft, borderWidth: 2 },
|
||||||
]}
|
]}
|
||||||
onPress={() => onChange(category.id)}
|
onPress={() => toggle(category.id)}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
|
accessibilityRole="checkbox"
|
||||||
|
accessibilityLabel={`Tag ${category.name}`}
|
||||||
|
accessibilityState={{ checked: isSelected }}
|
||||||
>
|
>
|
||||||
<View
|
<View style={[styles.colorCircle, { backgroundColor: category.color }, isSelected && styles.colorCircleSelected]} />
|
||||||
style={[
|
|
||||||
styles.colorCircle,
|
|
||||||
{ backgroundColor: category.color },
|
|
||||||
value === category.id && styles.colorCircleSelected,
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<Text style={[
|
<Text style={[
|
||||||
styles.categoryName,
|
styles.categoryName,
|
||||||
{ color: theme.textSecondary },
|
{ color: theme.textSecondary },
|
||||||
value === category.id && { color: theme.accent, fontWeight: '600' },
|
isSelected && { color: theme.text, fontWeight: '600' },
|
||||||
]}>
|
]}>
|
||||||
{category.name}
|
{category.name}
|
||||||
</Text>
|
</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>
|
</TouchableOpacity>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
{error && <Text style={styles.errorText}>{error}</Text>}
|
</Pressable>
|
||||||
|
</Pressable>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
</Modal>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
gap: 8,
|
gap: 6,
|
||||||
},
|
},
|
||||||
label: {
|
label: {
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
requiredIndicator: {
|
selectorButton: {
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
right: 0,
|
|
||||||
color: '#E53935',
|
|
||||||
fontSize: 14,
|
|
||||||
},
|
|
||||||
scrollView: {
|
|
||||||
paddingVertical: 4,
|
|
||||||
},
|
|
||||||
scrollContent: {
|
|
||||||
paddingHorizontal: 16,
|
|
||||||
gap: 10,
|
|
||||||
},
|
|
||||||
categoryButton: {
|
|
||||||
paddingHorizontal: 16,
|
|
||||||
paddingVertical: 10,
|
|
||||||
borderRadius: 24,
|
|
||||||
borderWidth: 1,
|
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 8,
|
justifyContent: 'space-between',
|
||||||
minWidth: 90,
|
paddingVertical: 14,
|
||||||
justifyContent: 'center',
|
paddingHorizontal: 12,
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
minHeight: 52,
|
||||||
|
},
|
||||||
|
selectorContent: {
|
||||||
|
flex: 1,
|
||||||
|
paddingRight: 8,
|
||||||
|
},
|
||||||
|
chipRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 6,
|
||||||
|
},
|
||||||
|
chip: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 4,
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
chipText: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
colorCircle: {
|
colorCircle: {
|
||||||
width: 12,
|
width: 12,
|
||||||
@@ -99,13 +193,73 @@ const styles = StyleSheet.create({
|
|||||||
height: 14,
|
height: 14,
|
||||||
borderRadius: 7,
|
borderRadius: 7,
|
||||||
},
|
},
|
||||||
categoryName: {
|
selectorValue: {
|
||||||
fontSize: 13,
|
fontSize: 15,
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
},
|
},
|
||||||
errorText: {
|
errorText: {
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: '#E53935',
|
marginLeft: 4,
|
||||||
marginLeft: 16,
|
},
|
||||||
|
modalOverlay: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: 24,
|
||||||
|
},
|
||||||
|
modalSheet: {
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: 400,
|
||||||
|
borderRadius: 20,
|
||||||
|
overflow: 'hidden',
|
||||||
|
maxHeight: '80%',
|
||||||
|
},
|
||||||
|
modalHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: 20,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
},
|
||||||
|
modalHeaderRight: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 16,
|
||||||
|
},
|
||||||
|
modalTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
clearText: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
doneText: {
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
modalHint: {
|
||||||
|
fontSize: 12,
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
paddingBottom: 4,
|
||||||
|
},
|
||||||
|
modalContent: {
|
||||||
|
padding: 12,
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
modalOption: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: 12,
|
||||||
|
paddingVertical: 14,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
categoryName: {
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: '500',
|
||||||
|
flex: 1,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -7,30 +7,39 @@ interface ColorPickerInputProps {
|
|||||||
onChange: (color: string) => void;
|
onChange: (color: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeHex(input: string): string {
|
const HEX_PATTERN = /^[0-9a-fA-F]{6}$/;
|
||||||
const cleaned = input.replace(/[^0-9a-fA-F]/g, '').slice(0, 6);
|
|
||||||
return cleaned ? `#${cleaned}` : '';
|
function isValidHex(color: string): boolean {
|
||||||
|
return HEX_PATTERN.test(color);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function NativeColorPickerInput({ value, onChange }: ColorPickerInputProps) {
|
export default function NativeColorPickerInput({ value, onChange }: ColorPickerInputProps) {
|
||||||
const { theme } = useSettings();
|
const { theme } = useSettings();
|
||||||
|
|
||||||
|
const hex = value.replace(/^#/, '').toUpperCase();
|
||||||
|
const valid = isValidHex(hex);
|
||||||
|
|
||||||
|
const handleChange = (raw: string) => {
|
||||||
|
const cleaned = raw.replace(/[^0-9a-fA-F]/g, '').slice(0, 6).toUpperCase();
|
||||||
|
onChange(`#${cleaned}`);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.row}>
|
<View style={styles.row}>
|
||||||
<View style={[styles.preview, { backgroundColor: value }]} />
|
<View style={[styles.preview, { backgroundColor: valid ? value : theme.borderStrong, borderColor: theme.borderStrong }]} />
|
||||||
|
<View style={[styles.inputWrap, { backgroundColor: theme.inputBg, borderColor: theme.borderStrong }]}>
|
||||||
|
<Text style={[styles.hash, { color: theme.textMuted }]}>#</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={[
|
style={[styles.input, { color: theme.text }]}
|
||||||
styles.input,
|
value={hex}
|
||||||
{ backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text },
|
onChangeText={handleChange}
|
||||||
]}
|
placeholder="E53935"
|
||||||
value={value}
|
|
||||||
onChangeText={(text) => onChange(normalizeHex(text))}
|
|
||||||
placeholder="#E53935"
|
|
||||||
placeholderTextColor={theme.textMuted}
|
placeholderTextColor={theme.textMuted}
|
||||||
autoCapitalize="characters"
|
autoCapitalize="characters"
|
||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
maxLength={7}
|
maxLength={6}
|
||||||
/>
|
/>
|
||||||
|
</View>
|
||||||
<Text style={[styles.hint, { color: theme.textMuted }]}>Hex code</Text>
|
<Text style={[styles.hint, { color: theme.textMuted }]}>Hex code</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -47,16 +56,26 @@ const styles = StyleSheet.create({
|
|||||||
height: 36,
|
height: 36,
|
||||||
borderRadius: 18,
|
borderRadius: 18,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: '#E0E0E0',
|
|
||||||
},
|
},
|
||||||
input: {
|
inputWrap: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
height: 44,
|
height: 44,
|
||||||
paddingHorizontal: 14,
|
paddingHorizontal: 14,
|
||||||
borderRadius: 10,
|
borderRadius: 10,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
},
|
||||||
|
hash: {
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
flex: 1,
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
|
paddingVertical: 0,
|
||||||
},
|
},
|
||||||
hint: {
|
hint: {
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
|
|||||||
@@ -7,15 +7,23 @@ interface ColorPickerInputProps {
|
|||||||
onChange: (color: string) => void;
|
onChange: (color: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const HEX_PATTERN = /^#[0-9a-fA-F]{6}$/;
|
||||||
|
|
||||||
export default function WebColorPickerInput({ value, onChange }: ColorPickerInputProps) {
|
export default function WebColorPickerInput({ value, onChange }: ColorPickerInputProps) {
|
||||||
const { theme } = useSettings();
|
const { theme } = useSettings();
|
||||||
|
const valid = HEX_PATTERN.test(value);
|
||||||
|
const normalized = valid ? value.toUpperCase() : '#000000';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.row}>
|
<View style={styles.row}>
|
||||||
{React.createElement('input', {
|
{React.createElement('input', {
|
||||||
type: 'color',
|
type: 'color',
|
||||||
value: value.toUpperCase(),
|
value: normalized,
|
||||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => onChange(e.target.value),
|
onChange: (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (HEX_PATTERN.test(e.target.value)) {
|
||||||
|
onChange(e.target.value.toUpperCase());
|
||||||
|
}
|
||||||
|
},
|
||||||
style: {
|
style: {
|
||||||
width: 44,
|
width: 44,
|
||||||
height: 44,
|
height: 44,
|
||||||
@@ -26,7 +34,7 @@ export default function WebColorPickerInput({ value, onChange }: ColorPickerInpu
|
|||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
},
|
},
|
||||||
})}
|
})}
|
||||||
<Text style={[styles.hexText, { color: theme.textSecondary }]}>{value.toUpperCase()}</Text>
|
<Text style={[styles.hexText, { color: theme.textSecondary }]}>{normalized}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { View, Text as RNText, StyleSheet, PanResponder, Dimensions } from 'react-native';
|
||||||
|
import Svg, { Circle, Rect, Defs, LinearGradient, Stop } from 'react-native-svg';
|
||||||
|
import { useSettings } from '@/theme';
|
||||||
|
|
||||||
|
const DefsElement = Defs as unknown as React.ComponentType<{ children?: React.ReactNode }>;
|
||||||
|
|
||||||
|
const { width: SCREEN_WIDTH } = Dimensions.get('window');
|
||||||
|
const WHEEL_SIZE = Math.min(SCREEN_WIDTH - 64, 280);
|
||||||
|
const THUMB_SIZE = 24;
|
||||||
|
|
||||||
|
function hexToHsv(hex: string) {
|
||||||
|
const r = parseInt(hex.slice(1, 3), 16) / 255;
|
||||||
|
const g = parseInt(hex.slice(3, 5), 16) / 255;
|
||||||
|
const b = parseInt(hex.slice(5, 7), 16) / 255;
|
||||||
|
const max = Math.max(r, g, b);
|
||||||
|
const min = Math.min(r, g, b);
|
||||||
|
const delta = max - min;
|
||||||
|
let h = 0;
|
||||||
|
if (delta !== 0) {
|
||||||
|
if (max === r) h = ((g - b) / delta) % 6;
|
||||||
|
else if (max === g) h = (b - r) / delta + 2;
|
||||||
|
else h = (r - g) / delta + 4;
|
||||||
|
h = Math.round(h * 60);
|
||||||
|
if (h < 0) h += 360;
|
||||||
|
}
|
||||||
|
const s = max === 0 ? 0 : delta / max;
|
||||||
|
const v = max;
|
||||||
|
return { h, s, v };
|
||||||
|
}
|
||||||
|
|
||||||
|
function hsvToHex(h: number, s: number, v: number) {
|
||||||
|
const c = v * s;
|
||||||
|
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
||||||
|
const m = v - c;
|
||||||
|
let r = 0, g = 0, b = 0;
|
||||||
|
if (h < 60) { r = c; g = x; b = 0; }
|
||||||
|
else if (h < 120) { r = x; g = c; b = 0; }
|
||||||
|
else if (h < 180) { r = 0; g = c; b = x; }
|
||||||
|
else if (h < 240) { r = 0; g = x; b = c; }
|
||||||
|
else if (h < 300) { r = x; g = 0; b = c; }
|
||||||
|
else { r = c; g = 0; b = x; }
|
||||||
|
const toHex = (n: number) => Math.round((n + m) * 255).toString(16).padStart(2, '0');
|
||||||
|
return `#${toHex(r)}${toHex(g)}${toHex(b)}`.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ColorWheelProps {
|
||||||
|
color: string;
|
||||||
|
onChange: (color: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ColorWheel({ color, onChange }: ColorWheelProps) {
|
||||||
|
const { theme } = useSettings();
|
||||||
|
const initialHsv = useMemo(() => hexToHsv(color), [color]);
|
||||||
|
const [hue, setHue] = useState(initialHsv.h);
|
||||||
|
const [saturation, setSaturation] = useState(initialHsv.s);
|
||||||
|
const [value, setValue] = useState(initialHsv.v);
|
||||||
|
const [prevColor, setPrevColor] = useState(color);
|
||||||
|
|
||||||
|
if (prevColor !== color) {
|
||||||
|
setPrevColor(color);
|
||||||
|
setHue(initialHsv.h);
|
||||||
|
setSaturation(initialHsv.s);
|
||||||
|
setValue(initialHsv.v);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const newColor = hsvToHex(hue, saturation, value);
|
||||||
|
onChange(newColor);
|
||||||
|
}, [hue, saturation, value, onChange]);
|
||||||
|
|
||||||
|
const updateFromWheel = (locationX: number, locationY: number) => {
|
||||||
|
const center = WHEEL_SIZE / 2;
|
||||||
|
const dx = locationX - center;
|
||||||
|
const dy = locationY - center;
|
||||||
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
const radius = (WHEEL_SIZE - THUMB_SIZE) / 2;
|
||||||
|
if (distance > radius) return;
|
||||||
|
const angle = Math.atan2(dy, dx) * (180 / Math.PI);
|
||||||
|
let h = angle + 180;
|
||||||
|
if (h >= 360) h -= 360;
|
||||||
|
setHue(h);
|
||||||
|
setSaturation(distance / radius);
|
||||||
|
setValue(1 - distance / radius);
|
||||||
|
};
|
||||||
|
|
||||||
|
const wheelPanResponder = React.useMemo(() => PanResponder.create({
|
||||||
|
onStartShouldSetPanResponder: () => true,
|
||||||
|
onMoveShouldSetPanResponder: () => true,
|
||||||
|
onPanResponderGrant: (event) => {
|
||||||
|
updateFromWheel(event.nativeEvent.locationX, event.nativeEvent.locationY);
|
||||||
|
},
|
||||||
|
onPanResponderMove: (event) => {
|
||||||
|
updateFromWheel(event.nativeEvent.locationX, event.nativeEvent.locationY);
|
||||||
|
},
|
||||||
|
}), []);
|
||||||
|
|
||||||
|
const setHueFromLocation = (locationX: number) => {
|
||||||
|
setHue(Math.min(360, Math.max(0, (locationX / WHEEL_SIZE) * 360)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const huePanResponder = React.useMemo(() => PanResponder.create({
|
||||||
|
onStartShouldSetPanResponder: () => true,
|
||||||
|
onMoveShouldSetPanResponder: () => true,
|
||||||
|
onPanResponderGrant: (event) => {
|
||||||
|
setHueFromLocation(event.nativeEvent.locationX);
|
||||||
|
},
|
||||||
|
onPanResponderMove: (event) => {
|
||||||
|
setHueFromLocation(event.nativeEvent.locationX);
|
||||||
|
},
|
||||||
|
}), []);
|
||||||
|
|
||||||
|
const thumbX = ((WHEEL_SIZE - THUMB_SIZE) / 2) * saturation * Math.cos((hue - 180) * Math.PI / 180) + (WHEEL_SIZE / 2) - (THUMB_SIZE / 2);
|
||||||
|
const thumbY = ((WHEEL_SIZE - THUMB_SIZE) / 2) * saturation * Math.sin((hue - 180) * Math.PI / 180) + (WHEEL_SIZE / 2) - (THUMB_SIZE / 2);
|
||||||
|
const hueThumbX = (hue / 360) * WHEEL_SIZE - (THUMB_SIZE / 2);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={styles.wheelContainer}>
|
||||||
|
<Svg width={WHEEL_SIZE} height={WHEEL_SIZE}>
|
||||||
|
<DefsElement>
|
||||||
|
<LinearGradient id="satGradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<Stop offset="0%" stopColor={`hsl(${hue}, 100%, 50%)`} stopOpacity={1} />
|
||||||
|
<Stop offset="100%" stopColor="#FFFFFF" stopOpacity={1} />
|
||||||
|
</LinearGradient>
|
||||||
|
<LinearGradient id="valGradient" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<Stop offset="0%" stopColor="transparent" stopOpacity={0} />
|
||||||
|
<Stop offset="100%" stopColor="#000000" stopOpacity={1} />
|
||||||
|
</LinearGradient>
|
||||||
|
</DefsElement>
|
||||||
|
<Circle
|
||||||
|
cx={WHEEL_SIZE / 2}
|
||||||
|
cy={WHEEL_SIZE / 2}
|
||||||
|
r={(WHEEL_SIZE - THUMB_SIZE) / 2}
|
||||||
|
fill="url(#satGradient)"
|
||||||
|
/>
|
||||||
|
<Circle
|
||||||
|
cx={WHEEL_SIZE / 2}
|
||||||
|
cy={WHEEL_SIZE / 2}
|
||||||
|
r={(WHEEL_SIZE - THUMB_SIZE) / 2}
|
||||||
|
fill="url(#valGradient)"
|
||||||
|
/>
|
||||||
|
<Circle
|
||||||
|
cx={thumbX + THUMB_SIZE / 2}
|
||||||
|
cy={thumbY + THUMB_SIZE / 2}
|
||||||
|
r={THUMB_SIZE / 2}
|
||||||
|
fill="#FFFFFF"
|
||||||
|
stroke="#000000"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
</Svg>
|
||||||
|
<View {...wheelPanResponder.panHandlers} style={StyleSheet.absoluteFill} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.hueContainer}>
|
||||||
|
<Svg width={WHEEL_SIZE} height={36}>
|
||||||
|
<DefsElement>
|
||||||
|
<LinearGradient id="hueGradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<Stop offset="0%" stopColor="#FF0000" />
|
||||||
|
<Stop offset="17%" stopColor="#FFFF00" />
|
||||||
|
<Stop offset="33%" stopColor="#00FF00" />
|
||||||
|
<Stop offset="50%" stopColor="#00FFFF" />
|
||||||
|
<Stop offset="67%" stopColor="#0000FF" />
|
||||||
|
<Stop offset="83%" stopColor="#FF00FF" />
|
||||||
|
<Stop offset="100%" stopColor="#FF0000" />
|
||||||
|
</LinearGradient>
|
||||||
|
</DefsElement>
|
||||||
|
<Rect x={0} y={0} width={WHEEL_SIZE} height={36} rx={18} fill="url(#hueGradient)" />
|
||||||
|
<Circle
|
||||||
|
cx={hueThumbX + THUMB_SIZE / 2}
|
||||||
|
cy={18}
|
||||||
|
r={THUMB_SIZE / 2}
|
||||||
|
fill="#FFFFFF"
|
||||||
|
stroke="#000000"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
</Svg>
|
||||||
|
<View {...huePanResponder.panHandlers} style={StyleSheet.absoluteFill} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.previewContainer}>
|
||||||
|
<View style={[styles.preview, { backgroundColor: hsvToHex(hue, saturation, value) }]} />
|
||||||
|
<RNText style={[styles.previewText, { color: theme.text }]}>{hsvToHex(hue, saturation, value)}</RNText>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 16,
|
||||||
|
},
|
||||||
|
wheelContainer: {
|
||||||
|
position: 'relative',
|
||||||
|
},
|
||||||
|
hueContainer: {
|
||||||
|
position: 'relative',
|
||||||
|
width: WHEEL_SIZE,
|
||||||
|
},
|
||||||
|
previewContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#00000020',
|
||||||
|
},
|
||||||
|
previewText: {
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: '500',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default } from './ColorWheel.native';
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { View, Text, StyleSheet } from 'react-native';
|
||||||
|
import { useSettings } from '@/theme';
|
||||||
|
import Svg, { Rect, Circle, Defs, LinearGradient, Stop } from 'react-native-svg';
|
||||||
|
|
||||||
|
const DefsElement = Defs as unknown as React.ComponentType<{ children?: React.ReactNode }>;
|
||||||
|
|
||||||
|
const WHEEL_SIZE = 280;
|
||||||
|
const THUMB_SIZE = 24;
|
||||||
|
|
||||||
|
function hexToHsv(hex: string) {
|
||||||
|
const r = parseInt(hex.slice(1, 3), 16) / 255;
|
||||||
|
const g = parseInt(hex.slice(3, 5), 16) / 255;
|
||||||
|
const b = parseInt(hex.slice(5, 7), 16) / 255;
|
||||||
|
const max = Math.max(r, g, b);
|
||||||
|
const min = Math.min(r, g, b);
|
||||||
|
const delta = max - min;
|
||||||
|
let h = 0;
|
||||||
|
if (delta !== 0) {
|
||||||
|
if (max === r) h = ((g - b) / delta) % 6;
|
||||||
|
else if (max === g) h = (b - r) / delta + 2;
|
||||||
|
else h = (r - g) / delta + 4;
|
||||||
|
h = Math.round(h * 60);
|
||||||
|
if (h < 0) h += 360;
|
||||||
|
}
|
||||||
|
const s = max === 0 ? 0 : delta / max;
|
||||||
|
const v = max;
|
||||||
|
return { h, s, v };
|
||||||
|
}
|
||||||
|
|
||||||
|
function hsvToHex(h: number, s: number, v: number) {
|
||||||
|
const c = v * s;
|
||||||
|
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
||||||
|
const m = v - c;
|
||||||
|
let r = 0, g = 0, b = 0;
|
||||||
|
if (h < 60) { r = c; g = x; b = 0; }
|
||||||
|
else if (h < 120) { r = x; g = c; b = 0; }
|
||||||
|
else if (h < 180) { r = 0; g = c; b = x; }
|
||||||
|
else if (h < 240) { r = 0; g = x; b = c; }
|
||||||
|
else if (h < 300) { r = x; g = 0; b = c; }
|
||||||
|
else { r = c; g = 0; b = x; }
|
||||||
|
const toHex = (n: number) => Math.round((n + m) * 255).toString(16).padStart(2, '0');
|
||||||
|
return `#${toHex(r)}${toHex(g)}${toHex(b)}`.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ColorWheelProps {
|
||||||
|
color: string;
|
||||||
|
onChange: (color: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ColorWheel({ color, onChange }: ColorWheelProps) {
|
||||||
|
const { theme } = useSettings();
|
||||||
|
const initialHsv = useMemo(() => hexToHsv(color), [color]);
|
||||||
|
const [hue, setHue] = useState(initialHsv.h);
|
||||||
|
const [saturation, setSaturation] = useState(initialHsv.s);
|
||||||
|
const [value, setValue] = useState(initialHsv.v);
|
||||||
|
const [prevColor, setPrevColor] = useState(color);
|
||||||
|
|
||||||
|
if (prevColor !== color) {
|
||||||
|
setPrevColor(color);
|
||||||
|
setHue(initialHsv.h);
|
||||||
|
setSaturation(initialHsv.s);
|
||||||
|
setValue(initialHsv.v);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const newColor = hsvToHex(hue, saturation, value);
|
||||||
|
onChange(newColor);
|
||||||
|
}, [hue, saturation, value, onChange]);
|
||||||
|
|
||||||
|
const handleWheelMouseDown = (e: React.MouseEvent) => {
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
const center = WHEEL_SIZE / 2;
|
||||||
|
const radius = (WHEEL_SIZE - THUMB_SIZE) / 2;
|
||||||
|
const handleMove = (moveEvent: MouseEvent) => {
|
||||||
|
const dx = moveEvent.clientX - rect.left - center;
|
||||||
|
const dy = moveEvent.clientY - rect.top - center;
|
||||||
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
if (distance > radius) return;
|
||||||
|
const angle = Math.atan2(dy, dx) * (180 / Math.PI);
|
||||||
|
let h = angle + 180;
|
||||||
|
if (h >= 360) h -= 360;
|
||||||
|
setHue(h);
|
||||||
|
setSaturation(distance / radius);
|
||||||
|
setValue(1 - distance / radius);
|
||||||
|
};
|
||||||
|
const handleUp = () => {
|
||||||
|
window.removeEventListener('mousemove', handleMove);
|
||||||
|
window.removeEventListener('mouseup', handleUp);
|
||||||
|
};
|
||||||
|
window.addEventListener('mousemove', handleMove);
|
||||||
|
window.addEventListener('mouseup', handleUp);
|
||||||
|
handleMove(e.nativeEvent);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleHueMouseDown = (e: React.MouseEvent) => {
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
const handleMove = (moveEvent: MouseEvent) => {
|
||||||
|
const h = ((moveEvent.clientX - rect.left) / WHEEL_SIZE) * 360;
|
||||||
|
setHue(Math.min(360, Math.max(0, h)));
|
||||||
|
};
|
||||||
|
const handleUp = () => {
|
||||||
|
window.removeEventListener('mousemove', handleMove);
|
||||||
|
window.removeEventListener('mouseup', handleUp);
|
||||||
|
};
|
||||||
|
window.addEventListener('mousemove', handleMove);
|
||||||
|
window.addEventListener('mouseup', handleUp);
|
||||||
|
handleMove(e.nativeEvent);
|
||||||
|
};
|
||||||
|
|
||||||
|
const thumbX = ((WHEEL_SIZE - THUMB_SIZE) / 2) * saturation * Math.cos((hue - 180) * Math.PI / 180) + (WHEEL_SIZE / 2) - (THUMB_SIZE / 2);
|
||||||
|
const thumbY = ((WHEEL_SIZE - THUMB_SIZE) / 2) * saturation * Math.sin((hue - 180) * Math.PI / 180) + (WHEEL_SIZE / 2) - (THUMB_SIZE / 2);
|
||||||
|
const hueThumbX = (hue / 360) * WHEEL_SIZE - (THUMB_SIZE / 2);
|
||||||
|
const currentColor = hsvToHex(hue, saturation, value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={styles.wheelContainer}>
|
||||||
|
<Svg width={WHEEL_SIZE} height={WHEEL_SIZE} {...({ onMouseDown: handleWheelMouseDown } as object)}>
|
||||||
|
<DefsElement>
|
||||||
|
<LinearGradient id="satGradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<Stop offset="0%" stopColor={`hsl(${hue}, 100%, 50%)`} stopOpacity={1} />
|
||||||
|
<Stop offset="100%" stopColor="#FFFFFF" stopOpacity={1} />
|
||||||
|
</LinearGradient>
|
||||||
|
<LinearGradient id="valGradient" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<Stop offset="0%" stopColor="transparent" stopOpacity={0} />
|
||||||
|
<Stop offset="100%" stopColor="#000000" stopOpacity={1} />
|
||||||
|
</LinearGradient>
|
||||||
|
</DefsElement>
|
||||||
|
<Circle
|
||||||
|
cx={WHEEL_SIZE / 2}
|
||||||
|
cy={WHEEL_SIZE / 2}
|
||||||
|
r={(WHEEL_SIZE - THUMB_SIZE) / 2}
|
||||||
|
fill="url(#satGradient)"
|
||||||
|
/>
|
||||||
|
<Circle
|
||||||
|
cx={WHEEL_SIZE / 2}
|
||||||
|
cy={WHEEL_SIZE / 2}
|
||||||
|
r={(WHEEL_SIZE - THUMB_SIZE) / 2}
|
||||||
|
fill="url(#valGradient)"
|
||||||
|
/>
|
||||||
|
<Circle
|
||||||
|
cx={thumbX + THUMB_SIZE / 2}
|
||||||
|
cy={thumbY + THUMB_SIZE / 2}
|
||||||
|
r={THUMB_SIZE / 2}
|
||||||
|
fill="#FFFFFF"
|
||||||
|
stroke="#000000"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
</Svg>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.hueContainer}>
|
||||||
|
<Svg width={WHEEL_SIZE} height={36} {...({ onMouseDown: handleHueMouseDown } as object)}>
|
||||||
|
<DefsElement>
|
||||||
|
<LinearGradient id="hueGradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<Stop offset="0%" stopColor="#FF0000" />
|
||||||
|
<Stop offset="17%" stopColor="#FFFF00" />
|
||||||
|
<Stop offset="33%" stopColor="#00FF00" />
|
||||||
|
<Stop offset="50%" stopColor="#00FFFF" />
|
||||||
|
<Stop offset="67%" stopColor="#0000FF" />
|
||||||
|
<Stop offset="83%" stopColor="#FF00FF" />
|
||||||
|
<Stop offset="100%" stopColor="#FF0000" />
|
||||||
|
</LinearGradient>
|
||||||
|
</DefsElement>
|
||||||
|
<Rect x={0} y={0} width={WHEEL_SIZE} height={36} rx={18} fill="url(#hueGradient)" />
|
||||||
|
<Circle
|
||||||
|
cx={hueThumbX + THUMB_SIZE / 2}
|
||||||
|
cy={18}
|
||||||
|
r={THUMB_SIZE / 2}
|
||||||
|
fill="#FFFFFF"
|
||||||
|
stroke="#000000"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
</Svg>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.previewContainer}>
|
||||||
|
<View style={[styles.preview, { backgroundColor: currentColor }]} />
|
||||||
|
<Text style={[styles.previewText, { color: theme.text }]}>{currentColor}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 16,
|
||||||
|
},
|
||||||
|
wheelContainer: {},
|
||||||
|
hueContainer: {
|
||||||
|
width: WHEEL_SIZE,
|
||||||
|
},
|
||||||
|
previewContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#00000020',
|
||||||
|
},
|
||||||
|
previewText: {
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: '500',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { View, StyleSheet } from 'react-native';
|
import { StyleSheet } from 'react-native';
|
||||||
import DateTimePicker, { DateTimePickerEvent } from '@react-native-community/datetimepicker';
|
import DateTimePicker, { DateTimePickerEvent } from '@react-native-community/datetimepicker';
|
||||||
|
|
||||||
interface NativeDateTimeInputProps {
|
interface NativeDateTimeInputProps {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { View, Text, TouchableOpacity, StyleSheet, Modal } from 'react-native';
|
import { View, Text, TouchableOpacity, StyleSheet, Modal } from 'react-native';
|
||||||
import { DateTimePickerEvent } from '@react-native-community/datetimepicker';
|
import { DateTimePickerEvent } from '@react-native-community/datetimepicker';
|
||||||
|
import { useSettings } from '@/theme';
|
||||||
|
|
||||||
interface WebDateTimeInputProps {
|
interface WebDateTimeInputProps {
|
||||||
value: Date;
|
value: Date;
|
||||||
@@ -32,11 +33,14 @@ export default function WebDateTimeInput({
|
|||||||
onDismiss,
|
onDismiss,
|
||||||
is24Hour,
|
is24Hour,
|
||||||
}: WebDateTimeInputProps) {
|
}: WebDateTimeInputProps) {
|
||||||
|
const { theme } = useSettings();
|
||||||
const [inputValue, setInputValue] = useState(() => dateInputValue(value));
|
const [inputValue, setInputValue] = useState(() => dateInputValue(value));
|
||||||
|
const [prevValue, setPrevValue] = useState(value);
|
||||||
|
|
||||||
useEffect(() => {
|
if (value !== prevValue) {
|
||||||
|
setPrevValue(value);
|
||||||
setInputValue(dateInputValue(value));
|
setInputValue(dateInputValue(value));
|
||||||
}, [value]);
|
}
|
||||||
|
|
||||||
const emit = (raw: string) => {
|
const emit = (raw: string) => {
|
||||||
if (!raw) return;
|
if (!raw) return;
|
||||||
@@ -55,11 +59,11 @@ export default function WebDateTimeInput({
|
|||||||
onChange(event, date);
|
onChange(event, date);
|
||||||
};
|
};
|
||||||
|
|
||||||
const bgColor = '#1E1E1E';
|
const bgColor = theme.sheetBg;
|
||||||
const textColor = '#FFFFFF';
|
const textColor = theme.text;
|
||||||
const borderColor = '#333333';
|
const borderColor = theme.borderStrong;
|
||||||
const overlayColor = 'rgba(0,0,0,0.6)';
|
const overlayColor = theme.overlay;
|
||||||
const mutedColor = '#9E9E9E';
|
const mutedColor = theme.textFaint;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal visible={isVisible} transparent animationType="fade" onRequestClose={onDismiss}>
|
<Modal visible={isVisible} transparent animationType="fade" onRequestClose={onDismiss}>
|
||||||
@@ -88,7 +92,7 @@ export default function WebDateTimeInput({
|
|||||||
borderRadius: 10,
|
borderRadius: 10,
|
||||||
border: `1px solid ${borderColor}`,
|
border: `1px solid ${borderColor}`,
|
||||||
outline: 'none',
|
outline: 'none',
|
||||||
backgroundColor: '#2C2C2C',
|
backgroundColor: theme.inputBg,
|
||||||
color: textColor,
|
color: textColor,
|
||||||
fontFamily: 'inherit',
|
fontFamily: 'inherit',
|
||||||
marginVertical: 16,
|
marginVertical: 16,
|
||||||
@@ -99,14 +103,14 @@ export default function WebDateTimeInput({
|
|||||||
</View>
|
</View>
|
||||||
<View style={styles.actions}>
|
<View style={styles.actions}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.clearButton, { borderColor, backgroundColor: '#2C2C2C' }]}
|
style={[styles.clearButton, { borderColor, backgroundColor: theme.inputBg }]}
|
||||||
onPress={onDismiss}
|
onPress={onDismiss}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
<Text style={[styles.clearButtonText, { color: mutedColor }]}>Clear</Text>
|
<Text style={[styles.clearButtonText, { color: mutedColor }]}>Clear</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity style={styles.doneButton} onPress={onDismiss} activeOpacity={0.8}>
|
<TouchableOpacity style={[styles.doneButton, { backgroundColor: theme.accent }]} onPress={onDismiss} activeOpacity={0.8}>
|
||||||
<Text style={styles.doneButtonText}>Done</Text>
|
<Text style={[styles.doneButtonText, { color: theme.accentText }]}>Done</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -157,11 +161,9 @@ const styles = StyleSheet.create({
|
|||||||
paddingVertical: 10,
|
paddingVertical: 10,
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 16,
|
||||||
borderRadius: 10,
|
borderRadius: 10,
|
||||||
backgroundColor: '#E53935',
|
|
||||||
},
|
},
|
||||||
doneButtonText: {
|
doneButtonText: {
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
color: '#FFFFFF',
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -15,14 +15,12 @@ interface DateTimePickerComponentProps {
|
|||||||
export function DateTimePickerComponent({ control }: DateTimePickerComponentProps) {
|
export function DateTimePickerComponent({ control }: DateTimePickerComponentProps) {
|
||||||
const { theme } = useSettings();
|
const { theme } = useSettings();
|
||||||
const [picker, setPicker] = React.useState<'date' | 'time' | 'endTime' | null>(null);
|
const [picker, setPicker] = React.useState<'date' | 'time' | 'endTime' | null>(null);
|
||||||
const dateValueRef = useRef<Date | null>(null);
|
|
||||||
const timeValueRef = useRef<string>('');
|
|
||||||
const endTimeValueRef = useRef<string>('');
|
|
||||||
const dateRef = useRef<((value: Date | null) => void) | null>(null);
|
const dateRef = useRef<((value: Date | null) => void) | null>(null);
|
||||||
const timeRef = useRef<((value: string) => void) | null>(null);
|
const timeRef = useRef<((value: string) => void) | null>(null);
|
||||||
const endTimeRef = useRef<((value: string) => void) | null>(null);
|
const endTimeRef = useRef<((value: string) => void) | null>(null);
|
||||||
|
|
||||||
const allDay = useWatch({ control, name: 'allDay' }) ?? false;
|
const allDay = useWatch({ control, name: 'allDay' }) ?? false;
|
||||||
|
const dueDate = useWatch({ control, name: 'dueDate' }) ?? null;
|
||||||
const startTime = useWatch({ control, name: 'dueTime' }) ?? '';
|
const startTime = useWatch({ control, name: 'dueTime' }) ?? '';
|
||||||
const endTime = useWatch({ control, name: 'endTime' }) ?? '';
|
const endTime = useWatch({ control, name: 'endTime' }) ?? '';
|
||||||
|
|
||||||
@@ -37,7 +35,6 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
|
|||||||
const renderTimeButton = (
|
const renderTimeButton = (
|
||||||
fieldName: 'dueTime' | 'endTime',
|
fieldName: 'dueTime' | 'endTime',
|
||||||
ref: React.MutableRefObject<((value: string) => void) | null>,
|
ref: React.MutableRefObject<((value: string) => void) | null>,
|
||||||
valueRef: React.MutableRefObject<string>,
|
|
||||||
placeholder: string,
|
placeholder: string,
|
||||||
onPress: () => void
|
onPress: () => void
|
||||||
) => (
|
) => (
|
||||||
@@ -46,7 +43,6 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
|
|||||||
name={fieldName}
|
name={fieldName}
|
||||||
render={({ field }) => {
|
render={({ field }) => {
|
||||||
ref.current = field.onChange;
|
ref.current = field.onChange;
|
||||||
valueRef.current = field.value ?? '';
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[
|
style={[
|
||||||
@@ -59,8 +55,8 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
|
|||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
>
|
>
|
||||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||||
<Circle cx={12} cy={12} r={10} stroke={field.value ? theme.accent : theme.textMuted} strokeWidth={1.5} fill="none" />
|
<Circle cx={12} cy={12} r={10} stroke={field.value ? theme.accent : theme.textFaint} strokeWidth={1.8} fill="none" />
|
||||||
<Path d="M12 6v6l4 2" stroke={field.value ? theme.accent : theme.textMuted} strokeWidth={1.5} strokeLinecap="round" />
|
<Path d="M12 6v6l4 2" stroke={field.value ? theme.accent : theme.textFaint} strokeWidth={1.8} strokeLinecap="round" />
|
||||||
</Svg>
|
</Svg>
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
@@ -72,7 +68,7 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
|
|||||||
>
|
>
|
||||||
{field.value ? formatTime12(field.value) : placeholder}
|
{field.value ? formatTime12(field.value) : placeholder}
|
||||||
</Text>
|
</Text>
|
||||||
{field.value && (
|
{field.value ? (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.clearButton}
|
style={styles.clearButton}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
@@ -86,10 +82,10 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
|
|||||||
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
||||||
>
|
>
|
||||||
<Svg width={14} height={14} viewBox="0 0 24 24">
|
<Svg width={14} height={14} viewBox="0 0 24 24">
|
||||||
<Path d="M18 6L6 18M6 6l12 12" stroke={theme.textMuted} strokeWidth={2} strokeLinecap="round" />
|
<Path d="M18 6L6 18M6 6l12 12" stroke={theme.textSecondary} strokeWidth={2.2} strokeLinecap="round" />
|
||||||
</Svg>
|
</Svg>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
) : null}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
@@ -105,7 +101,6 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
|
|||||||
name="dueDate"
|
name="dueDate"
|
||||||
render={({ field }) => {
|
render={({ field }) => {
|
||||||
dateRef.current = field.onChange;
|
dateRef.current = field.onChange;
|
||||||
dateValueRef.current = field.value;
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[
|
style={[
|
||||||
@@ -119,14 +114,14 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
|
|||||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||||
<Path
|
<Path
|
||||||
d="M8 2v4M16 2v4M3 10h18M3 18h18"
|
d="M8 2v4M16 2v4M3 10h18M3 18h18"
|
||||||
stroke={field.value ? theme.accent : theme.textMuted}
|
stroke={field.value ? theme.accent : theme.textFaint}
|
||||||
strokeWidth={1.5}
|
strokeWidth={1.8}
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
/>
|
/>
|
||||||
<Path
|
<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"
|
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={field.value ? theme.accent : theme.textMuted}
|
stroke={field.value ? theme.accent : theme.textFaint}
|
||||||
strokeWidth={1.5}
|
strokeWidth={1.8}
|
||||||
fill="none"
|
fill="none"
|
||||||
/>
|
/>
|
||||||
</Svg>
|
</Svg>
|
||||||
@@ -140,17 +135,17 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
|
|||||||
>
|
>
|
||||||
{field.value ? format(field.value, 'MMM d, yyyy') : 'Due Date'}
|
{field.value ? format(field.value, 'MMM d, yyyy') : 'Due Date'}
|
||||||
</Text>
|
</Text>
|
||||||
{field.value && (
|
{field.value ? (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.clearButton}
|
style={styles.clearButton}
|
||||||
onPress={() => setDateValue(null)}
|
onPress={() => setDateValue(null)}
|
||||||
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
||||||
>
|
>
|
||||||
<Svg width={14} height={14} viewBox="0 0 24 24">
|
<Svg width={14} height={14} viewBox="0 0 24 24">
|
||||||
<Path d="M18 6L6 18M6 6l12 12" stroke={theme.textMuted} strokeWidth={2} strokeLinecap="round" />
|
<Path d="M18 6L6 18M6 6l12 12" stroke={theme.textSecondary} strokeWidth={2.2} strokeLinecap="round" />
|
||||||
</Svg>
|
</Svg>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
) : null}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
@@ -181,14 +176,14 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
|
|||||||
{!allDay && (
|
{!allDay && (
|
||||||
<>
|
<>
|
||||||
<View style={styles.timeRow}>
|
<View style={styles.timeRow}>
|
||||||
{renderTimeButton('dueTime', timeRef, timeValueRef, 'Start Time', () => setPicker('time'))}
|
{renderTimeButton('dueTime', timeRef, 'Start Time', () => setPicker('time'))}
|
||||||
{renderTimeButton('endTime', endTimeRef, endTimeValueRef, 'End Time', () => setPicker('endTime'))}
|
{renderTimeButton('endTime', endTimeRef, 'End Time', () => setPicker('endTime'))}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{endTimeInvalid && (
|
{endTimeInvalid && (
|
||||||
<View style={[styles.warningRow, { backgroundColor: theme.accentSoft, borderColor: '#E53935' }]}>
|
<View style={[styles.warningRow, { backgroundColor: theme.accentSoft, borderColor: '#E53935' }]}>
|
||||||
<Svg width={14} height={14} viewBox="0 0 24 24">
|
<Svg width={14} height={14} viewBox="0 0 24 24">
|
||||||
<Circle cx={12} cy={12} r={10} stroke="#E53935" strokeWidth={1.5} fill="none" />
|
<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" />
|
<Path d="M12 8v5M12 16.5v.5" stroke="#E53935" strokeWidth={1.8} strokeLinecap="round" />
|
||||||
</Svg>
|
</Svg>
|
||||||
<Text style={[styles.warningText, { color: '#E53935' }]}>
|
<Text style={[styles.warningText, { color: '#E53935' }]}>
|
||||||
@@ -203,7 +198,7 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
|
|||||||
|
|
||||||
<DateTimeInput
|
<DateTimeInput
|
||||||
testID="date-picker"
|
testID="date-picker"
|
||||||
value={dateValueRef.current ?? new Date()}
|
value={dueDate ?? new Date()}
|
||||||
mode="date"
|
mode="date"
|
||||||
is24Hour={false}
|
is24Hour={false}
|
||||||
isVisible={picker === 'date'}
|
isVisible={picker === 'date'}
|
||||||
@@ -220,8 +215,8 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
|
|||||||
title={picker === 'endTime' ? 'End Time' : 'Start Time'}
|
title={picker === 'endTime' ? 'End Time' : 'Start Time'}
|
||||||
initialTime={
|
initialTime={
|
||||||
picker === 'endTime'
|
picker === 'endTime'
|
||||||
? endTimeValueRef.current || timeValueRef.current
|
? endTime || startTime
|
||||||
: timeValueRef.current
|
: startTime
|
||||||
}
|
}
|
||||||
onConfirm={(time) => {
|
onConfirm={(time) => {
|
||||||
if (picker === 'endTime') {
|
if (picker === 'endTime') {
|
||||||
@@ -261,7 +256,7 @@ const styles = StyleSheet.create({
|
|||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
paddingVertical: 12,
|
paddingVertical: 14,
|
||||||
},
|
},
|
||||||
allDayLabel: {
|
allDayLabel: {
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
@@ -293,7 +288,7 @@ const styles = StyleSheet.create({
|
|||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
},
|
},
|
||||||
clearButton: {
|
clearButton: {
|
||||||
padding: 2,
|
padding: 4,
|
||||||
},
|
},
|
||||||
warningRow: {
|
warningRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { View, Text, StyleSheet, TextInput } from 'react-native';
|
import { View, Text, StyleSheet, TextInput , TextInputProps } from 'react-native';
|
||||||
import { TextInputProps } from 'react-native';
|
|
||||||
import { useSettings } from '@/theme';
|
import { useSettings } from '@/theme';
|
||||||
|
|
||||||
interface DescriptionInputProps extends TextInputProps {
|
interface DescriptionInputProps extends TextInputProps {
|
||||||
@@ -38,30 +37,30 @@ export function DescriptionInput({ error, ...props }: DescriptionInputProps) {
|
|||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
gap: 6,
|
gap: 5,
|
||||||
},
|
},
|
||||||
label: {
|
label: {
|
||||||
fontSize: 14,
|
fontSize: 13,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
input: {
|
input: {
|
||||||
minHeight: 100,
|
minHeight: 88,
|
||||||
padding: 16,
|
padding: 14,
|
||||||
borderRadius: 12,
|
borderRadius: 10,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
fontSize: 15,
|
fontSize: 14,
|
||||||
},
|
},
|
||||||
inputError: {
|
inputError: {
|
||||||
borderColor: '#E53935',
|
borderColor: '#E53935',
|
||||||
borderWidth: 1.5,
|
borderWidth: 1.5,
|
||||||
},
|
},
|
||||||
charCount: {
|
charCount: {
|
||||||
fontSize: 11,
|
fontSize: 10,
|
||||||
textAlign: 'right',
|
textAlign: 'right',
|
||||||
marginTop: -4,
|
marginTop: -3,
|
||||||
},
|
},
|
||||||
errorText: {
|
errorText: {
|
||||||
fontSize: 12,
|
fontSize: 11,
|
||||||
color: '#E53935',
|
color: '#E53935',
|
||||||
marginLeft: 4,
|
marginLeft: 4,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { View, StyleSheet, TouchableOpacity, Animated, Easing } from 'react-native';
|
import { StyleSheet, TouchableOpacity, Animated, Easing } from 'react-native';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import Svg, { Path } from 'react-native-svg';
|
import Svg, { Path } from 'react-native-svg';
|
||||||
|
import { useSettings } from '@/theme';
|
||||||
|
|
||||||
export function FloatingActionButton() {
|
export function FloatingActionButton() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { theme } = useSettings();
|
||||||
const [scaleAnim] = React.useState(new Animated.Value(1));
|
const [scaleAnim] = React.useState(new Animated.Value(1));
|
||||||
const [rotateAnim] = React.useState(new Animated.Value(0));
|
const [rotateAnim] = React.useState(new Animated.Value(0));
|
||||||
const [rotation, setRotation] = useState(0);
|
const [rotation, setRotation] = useState(0);
|
||||||
@@ -52,11 +54,14 @@ export function FloatingActionButton() {
|
|||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.button}
|
style={[styles.button, { backgroundColor: theme.accent, shadowColor: theme.accent }]}
|
||||||
onPress={handlePress}
|
onPress={handlePress}
|
||||||
onPressIn={handlePressIn}
|
onPressIn={handlePressIn}
|
||||||
onPressOut={handlePressOut}
|
onPressOut={handlePressOut}
|
||||||
activeOpacity={1}
|
activeOpacity={1}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Add new task"
|
||||||
|
hitSlop={12}
|
||||||
>
|
>
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={{
|
style={{
|
||||||
@@ -66,7 +71,7 @@ export function FloatingActionButton() {
|
|||||||
<Svg width={24} height={24} viewBox="0 0 24 24">
|
<Svg width={24} height={24} viewBox="0 0 24 24">
|
||||||
<Path
|
<Path
|
||||||
d="M12 5v14M5 12h14"
|
d="M12 5v14M5 12h14"
|
||||||
stroke="#FFFFFF"
|
stroke={theme.accentText}
|
||||||
strokeWidth={2.5}
|
strokeWidth={2.5}
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
@@ -83,7 +88,6 @@ const styles = StyleSheet.create({
|
|||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
bottom: 24,
|
bottom: 24,
|
||||||
right: 24,
|
right: 24,
|
||||||
shadowColor: '#E53935',
|
|
||||||
shadowOffset: { width: 0, height: 4 },
|
shadowOffset: { width: 0, height: 4 },
|
||||||
shadowOpacity: 0.3,
|
shadowOpacity: 0.3,
|
||||||
shadowRadius: 12,
|
shadowRadius: 12,
|
||||||
@@ -93,7 +97,6 @@ const styles = StyleSheet.create({
|
|||||||
width: 56,
|
width: 56,
|
||||||
height: 56,
|
height: 56,
|
||||||
borderRadius: 16,
|
borderRadius: 16,
|
||||||
backgroundColor: '#E53935',
|
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { View, StyleSheet, TouchableOpacity, Text } from 'react-native';
|
|||||||
import { useFormContext } from 'react-hook-form';
|
import { useFormContext } from 'react-hook-form';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import { useSettings } from '@/theme';
|
import { useSettings } from '@/theme';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
interface FormButtonsProps {
|
interface FormButtonsProps {
|
||||||
onSubmit: (data: any) => void;
|
onSubmit: (data: any) => void;
|
||||||
@@ -13,6 +14,7 @@ export function FormButtons({ onSubmit, submitLabel = 'Submit' }: FormButtonsPro
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { theme } = useSettings();
|
const { theme } = useSettings();
|
||||||
const { handleSubmit, formState: { isSubmitting } } = useFormContext();
|
const { handleSubmit, formState: { isSubmitting } } = useFormContext();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
const cancel = () => {
|
const cancel = () => {
|
||||||
if (router.canGoBack()) {
|
if (router.canGoBack()) {
|
||||||
@@ -23,22 +25,28 @@ export function FormButtons({ onSubmit, submitLabel = 'Submit' }: FormButtonsPro
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.container, { backgroundColor: theme.background, borderTopColor: theme.border }]}>
|
<View style={[styles.container, { backgroundColor: theme.background, borderTopColor: theme.border, paddingBottom: insets.bottom + 12 }]}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.cancelButton, { borderColor: theme.borderStrong, backgroundColor: theme.card }]}
|
style={[styles.cancelButton, { borderColor: theme.borderStrong, backgroundColor: theme.card }]}
|
||||||
onPress={cancel}
|
onPress={cancel}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Cancel"
|
||||||
|
accessibilityState={{ disabled: isSubmitting }}
|
||||||
>
|
>
|
||||||
<Text style={[styles.cancelButtonText, { color: theme.textSecondary }]}>Cancel</Text>
|
<Text style={[styles.cancelButtonText, { color: theme.textSecondary }]}>Cancel</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.submitButton, isSubmitting && styles.buttonDisabled]}
|
style={[styles.submitButton, { backgroundColor: theme.accent }, isSubmitting && styles.buttonDisabled]}
|
||||||
onPress={handleSubmit(onSubmit)}
|
onPress={handleSubmit(onSubmit)}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={isSubmitting ? 'Saving' : submitLabel}
|
||||||
|
accessibilityState={{ disabled: isSubmitting, busy: isSubmitting }}
|
||||||
>
|
>
|
||||||
<Text style={styles.submitButtonText}>
|
<Text style={[styles.submitButtonText, { color: theme.accentText }]}>
|
||||||
{isSubmitting ? 'Saving...' : submitLabel}
|
{isSubmitting ? 'Saving...' : submitLabel}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -52,47 +60,40 @@ const styles = StyleSheet.create({
|
|||||||
bottom: 0,
|
bottom: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 12,
|
||||||
paddingVertical: 16,
|
paddingVertical: 12,
|
||||||
borderTopWidth: 1,
|
borderTopWidth: 1,
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
gap: 12,
|
gap: 10,
|
||||||
shadowColor: '#000',
|
shadowColor: '#000',
|
||||||
shadowOffset: { width: 0, height: -2 },
|
shadowOffset: { width: 0, height: -2 },
|
||||||
shadowOpacity: 0.05,
|
shadowOpacity: 0.05,
|
||||||
shadowRadius: 8,
|
shadowRadius: 6,
|
||||||
elevation: 4,
|
elevation: 3,
|
||||||
},
|
},
|
||||||
cancelButton: {
|
cancelButton: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
paddingVertical: 14,
|
paddingVertical: 12,
|
||||||
borderRadius: 12,
|
borderRadius: 10,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
cancelButtonText: {
|
cancelButtonText: {
|
||||||
fontSize: 16,
|
fontSize: 15,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
submitButton: {
|
submitButton: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
paddingVertical: 14,
|
paddingVertical: 12,
|
||||||
borderRadius: 12,
|
borderRadius: 10,
|
||||||
backgroundColor: '#E53935',
|
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
shadowColor: '#E53935',
|
|
||||||
shadowOffset: { width: 0, height: 2 },
|
|
||||||
shadowOpacity: 0.3,
|
|
||||||
shadowRadius: 8,
|
|
||||||
elevation: 3,
|
|
||||||
},
|
},
|
||||||
submitButtonText: {
|
submitButtonText: {
|
||||||
fontSize: 16,
|
fontSize: 15,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
color: '#FFFFFF',
|
|
||||||
},
|
},
|
||||||
buttonDisabled: {
|
buttonDisabled: {
|
||||||
opacity: 0.6,
|
opacity: 0.6,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
FlatList,
|
FlatList,
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Alert,
|
Alert,
|
||||||
|
KeyboardAvoidingView,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { useSettings } from '@/theme';
|
import { useSettings } from '@/theme';
|
||||||
import { useFriends } from '@/hooks/useFriends';
|
import { useFriends } from '@/hooks/useFriends';
|
||||||
@@ -22,10 +23,18 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
|
|||||||
const { theme } = useSettings();
|
const { theme } = useSettings();
|
||||||
const { friends, incoming, outgoing, loading, sendRequest, acceptRequest, declineRequest, removeFriend, searchUsers } = useFriends();
|
const { friends, incoming, outgoing, loading, sendRequest, acceptRequest, declineRequest, removeFriend, searchUsers } = useFriends();
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [searchResults, setSearchResults] = useState<Array<{ id: string; username: string }>>([]);
|
const [searchResults, setSearchResults] = useState<{ id: string; username: string }[]>([]);
|
||||||
const [searching, setSearching] = useState(false);
|
const [searching, setSearching] = useState(false);
|
||||||
const [selectedTab, setSelectedTab] = useState<'friends' | 'incoming' | 'outgoing' | 'add'>('friends');
|
const [selectedTab, setSelectedTab] = useState<'friends' | 'incoming' | 'outgoing' | 'add'>('friends');
|
||||||
|
|
||||||
|
const searchKey = `${selectedTab}:${searchQuery}`;
|
||||||
|
const [prevSearchKey, setPrevSearchKey] = useState(searchKey);
|
||||||
|
|
||||||
|
if (prevSearchKey !== searchKey && (selectedTab !== 'add' || searchQuery.length < 2)) {
|
||||||
|
setPrevSearchKey(searchKey);
|
||||||
|
setSearchResults([]);
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedTab === 'add' && searchQuery.length >= 2) {
|
if (selectedTab === 'add' && searchQuery.length >= 2) {
|
||||||
const timeout = setTimeout(async () => {
|
const timeout = setTimeout(async () => {
|
||||||
@@ -40,23 +49,9 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
|
|||||||
}
|
}
|
||||||
}, 300);
|
}, 300);
|
||||||
return () => clearTimeout(timeout);
|
return () => clearTimeout(timeout);
|
||||||
} else {
|
|
||||||
setSearchResults([]);
|
|
||||||
}
|
}
|
||||||
}, [searchQuery, selectedTab, friends, outgoing, searchUsers]);
|
}, [searchQuery, selectedTab, friends, outgoing, searchUsers]);
|
||||||
|
|
||||||
const handleAddFriend = async () => {
|
|
||||||
if (!searchQuery.trim()) return;
|
|
||||||
try {
|
|
||||||
await sendRequest(searchQuery.trim());
|
|
||||||
setSearchQuery('');
|
|
||||||
setSearchResults([]);
|
|
||||||
setSelectedTab('friends');
|
|
||||||
} catch (err) {
|
|
||||||
Alert.alert('Error', err instanceof Error ? err.message : 'Failed to send request');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAccept = async (requestId: string) => {
|
const handleAccept = async (requestId: string) => {
|
||||||
try {
|
try {
|
||||||
await acceptRequest(requestId);
|
await acceptRequest(requestId);
|
||||||
@@ -90,7 +85,10 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
||||||
<View style={[styles.overlay, { backgroundColor: 'rgba(0,0,0,0.5)' }]}>
|
<KeyboardAvoidingView
|
||||||
|
style={[styles.overlay, { backgroundColor: 'rgba(0,0,0,0.5)' }]}
|
||||||
|
behavior="padding"
|
||||||
|
>
|
||||||
<View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}>
|
<View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}>
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<Text style={[styles.title, { color: theme.text }]}>Friends</Text>
|
<Text style={[styles.title, { color: theme.text }]}>Friends</Text>
|
||||||
@@ -113,15 +111,15 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
|
|||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
styles.tabText,
|
styles.tabText,
|
||||||
{ color: selectedTab === tab ? '#FFFFFF' : theme.textMuted },
|
{ color: selectedTab === tab ? theme.accentText : theme.textMuted },
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{tab.charAt(0).toUpperCase() + tab.slice(1)}
|
{tab.charAt(0).toUpperCase() + tab.slice(1)}
|
||||||
{tab === 'friends' && friends.length > 0 && (
|
{tab === 'friends' && friends.length > 0 && (
|
||||||
<Text style={[styles.badge, { color: '#FFFFFF' }]}>{friends.length}</Text>
|
<Text style={[styles.badge, { color: theme.accentText }]}>{friends.length}</Text>
|
||||||
)}
|
)}
|
||||||
{tab === 'incoming' && incoming.length > 0 && (
|
{tab === 'incoming' && incoming.length > 0 && (
|
||||||
<Text style={[styles.badge, { color: '#FFFFFF' }]}>{incoming.length}</Text>
|
<Text style={[styles.badge, { color: theme.accentText }]}>{incoming.length}</Text>
|
||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -137,7 +135,7 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
|
|||||||
) : friends.length === 0 ? (
|
) : friends.length === 0 ? (
|
||||||
<View style={styles.empty}>
|
<View style={styles.empty}>
|
||||||
<Text style={[styles.emptyText, { color: theme.textMuted }]}>No friends yet</Text>
|
<Text style={[styles.emptyText, { color: theme.textMuted }]}>No friends yet</Text>
|
||||||
<Text style={[styles.emptyHint, { color: theme.textFaint }]}>Tap "Add" to find friends by username</Text>
|
<Text style={[styles.emptyHint, { color: theme.textFaint }]}>{'Tap "Add" to find friends by username'}</Text>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<FlatList
|
<FlatList
|
||||||
@@ -150,7 +148,7 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
|
|||||||
style={[styles.removeBtn, { backgroundColor: theme.accentSoft, borderColor: theme.accent }]}
|
style={[styles.removeBtn, { backgroundColor: theme.accentSoft, borderColor: theme.accent }]}
|
||||||
onPress={() => handleRemove(item.id)}
|
onPress={() => handleRemove(item.id)}
|
||||||
>
|
>
|
||||||
<Text style={[styles.removeBtnText, { color: theme.accent }]}>Remove</Text>
|
<Text style={[styles.removeBtnText, { color: theme.accentStrong }]}>Remove</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@@ -181,7 +179,7 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
|
|||||||
style={[styles.actionBtn, { backgroundColor: theme.accent }]}
|
style={[styles.actionBtn, { backgroundColor: theme.accent }]}
|
||||||
onPress={() => handleAccept(item.requestId)}
|
onPress={() => handleAccept(item.requestId)}
|
||||||
>
|
>
|
||||||
<Text style={styles.actionBtnText}>Accept</Text>
|
<Text style={[styles.actionBtnText, { color: theme.accentText }]}>Accept</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.actionBtn, { backgroundColor: 'transparent', borderColor: theme.borderStrong, borderWidth: 1 }]}
|
style={[styles.actionBtn, { backgroundColor: 'transparent', borderColor: theme.borderStrong, borderWidth: 1 }]}
|
||||||
@@ -258,18 +256,16 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
|
|||||||
<Text style={[styles.friendName, { color: theme.text }]}>{item.username}</Text>
|
<Text style={[styles.friendName, { color: theme.text }]}>{item.username}</Text>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.addBtn, { backgroundColor: theme.accent }]}
|
style={[styles.addBtn, { backgroundColor: theme.accent }]}
|
||||||
onPress={async () => {
|
onPress={() => sendRequest(item.username)
|
||||||
try {
|
.then(() => {
|
||||||
await sendRequest(item.username);
|
|
||||||
setSearchQuery('');
|
setSearchQuery('');
|
||||||
setSearchResults([]);
|
setSearchResults([]);
|
||||||
setSelectedTab('friends');
|
setSelectedTab('friends');
|
||||||
} catch (err) {
|
})
|
||||||
Alert.alert('Error', err instanceof Error ? err.message : 'Failed to send request');
|
.catch(err => Alert.alert('Error', err instanceof Error ? err.message : 'Failed to send request'))
|
||||||
}
|
}
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Text style={styles.addBtnText}>Add</Text>
|
<Text style={[styles.addBtnText, { color: theme.accentText }]}>Add</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@@ -282,7 +278,7 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</KeyboardAvoidingView>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -403,7 +399,6 @@ const styles = StyleSheet.create({
|
|||||||
addBtnText: {
|
addBtnText: {
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
color: '#FFFFFF',
|
|
||||||
},
|
},
|
||||||
requestActions: {
|
requestActions: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -417,7 +412,6 @@ const styles = StyleSheet.create({
|
|||||||
actionBtnText: {
|
actionBtnText: {
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
color: '#FFFFFF',
|
|
||||||
},
|
},
|
||||||
cancelBtn: {
|
cancelBtn: {
|
||||||
paddingVertical: 6,
|
paddingVertical: 6,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { View, Text, StyleSheet } from 'react-native';
|
import { View, Text, StyleSheet, StatusBar, Platform } from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
|
||||||
import { useSettings } from '@/theme';
|
import { useSettings } from '@/theme';
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
@@ -11,70 +10,61 @@ interface HeaderProps {
|
|||||||
|
|
||||||
export function Header({ title, showLogo, rightAction }: HeaderProps) {
|
export function Header({ title, showLogo, rightAction }: HeaderProps) {
|
||||||
const { theme } = useSettings();
|
const { theme } = useSettings();
|
||||||
|
const topInset = Platform.OS === 'android' ? (StatusBar.currentHeight ?? 24) : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={[styles.header, { backgroundColor: theme.background }]}>
|
<View style={[styles.header, { backgroundColor: theme.background, paddingTop: topInset }]}>
|
||||||
<View style={styles.headerContent}>
|
<View style={styles.headerContent}>
|
||||||
{showLogo && (
|
{showLogo && (
|
||||||
<View style={styles.logoContainer}>
|
<View style={[styles.logoContainer, { backgroundColor: theme.accent }]}>
|
||||||
<Text style={styles.logoText}>✓</Text>
|
<Text style={[styles.logoText, { color: theme.accentText }]}>✓</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
<Text style={[styles.title, { color: theme.text }]}>{title}</Text>
|
<Text style={[styles.title, { color: theme.text }]} accessibilityRole="header">{title}</Text>
|
||||||
<View style={styles.spacer}>{rightAction}</View>
|
<View style={styles.spacer}>{rightAction}</View>
|
||||||
</View>
|
</View>
|
||||||
<View style={[styles.bottomRounded, { backgroundColor: theme.background }]} />
|
</View>
|
||||||
</SafeAreaView>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
header: {
|
header: {
|
||||||
borderBottomLeftRadius: 24,
|
borderBottomLeftRadius: 16,
|
||||||
borderBottomRightRadius: 24,
|
borderBottomRightRadius: 16,
|
||||||
shadowColor: '#000',
|
shadowColor: '#000',
|
||||||
shadowOffset: { width: 0, height: 2 },
|
shadowOffset: { width: 0, height: 1 },
|
||||||
shadowOpacity: 0.05,
|
shadowOpacity: 0.04,
|
||||||
shadowRadius: 8,
|
shadowRadius: 4,
|
||||||
elevation: 2,
|
elevation: 1,
|
||||||
},
|
},
|
||||||
headerContent: {
|
headerContent: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
paddingHorizontal: 20,
|
paddingHorizontal: 20,
|
||||||
paddingTop: 8,
|
height: 48,
|
||||||
paddingBottom: 16,
|
|
||||||
height: 80,
|
|
||||||
},
|
},
|
||||||
logoContainer: {
|
logoContainer: {
|
||||||
width: 36,
|
width: 32,
|
||||||
height: 36,
|
height: 32,
|
||||||
borderRadius: 10,
|
borderRadius: 10,
|
||||||
backgroundColor: '#E53935',
|
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
logoText: {
|
logoText: {
|
||||||
color: '#FFFFFF',
|
fontSize: 18,
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
},
|
},
|
||||||
title: {
|
title: {
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
left: '50%',
|
left: 0,
|
||||||
marginLeft: -30,
|
right: 0,
|
||||||
|
textAlign: 'center',
|
||||||
},
|
},
|
||||||
spacer: {
|
spacer: {
|
||||||
width: 36,
|
width: 32,
|
||||||
alignItems: 'flex-end',
|
alignItems: 'flex-end',
|
||||||
},
|
},
|
||||||
bottomRounded: {
|
|
||||||
height: 24,
|
|
||||||
borderBottomLeftRadius: 24,
|
|
||||||
borderBottomRightRadius: 24,
|
|
||||||
marginTop: -24,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
@@ -123,12 +123,12 @@ For questions about these Terms, contact us through the app's feedback channel.
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
export function LegalModal({ visible, type, onClose }: LegalModalProps) {
|
export function LegalModal({ visible, type, onClose }: LegalModalProps) {
|
||||||
if (!visible || !type) return null;
|
|
||||||
|
|
||||||
const { theme } = useSettings();
|
const { theme } = useSettings();
|
||||||
const content = type === 'privacy' ? PRIVACY_POLICY : TERMS_OF_SERVICE;
|
const content = type === 'privacy' ? PRIVACY_POLICY : TERMS_OF_SERVICE;
|
||||||
const title = type === 'privacy' ? 'Privacy Policy' : 'Terms of Service';
|
const title = type === 'privacy' ? 'Privacy Policy' : 'Terms of Service';
|
||||||
|
|
||||||
|
if (!visible || !type) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={[styles.overlay, { backgroundColor: theme.overlay }]}>
|
<SafeAreaView style={[styles.overlay, { backgroundColor: theme.overlay }]}>
|
||||||
<View style={[styles.modal, { backgroundColor: theme.card }]}>
|
<View style={[styles.modal, { backgroundColor: theme.card }]}>
|
||||||
@@ -136,7 +136,7 @@ export function LegalModal({ visible, type, onClose }: LegalModalProps) {
|
|||||||
<Text style={[styles.title, { color: theme.text }]}>{title}</Text>
|
<Text style={[styles.title, { color: theme.text }]}>{title}</Text>
|
||||||
<TouchableOpacity onPress={onClose} style={styles.closeButton} activeOpacity={0.7}>
|
<TouchableOpacity onPress={onClose} style={styles.closeButton} activeOpacity={0.7}>
|
||||||
<Svg width={24} height={24} viewBox="0 0 24 24">
|
<Svg width={24} height={24} viewBox="0 0 24 24">
|
||||||
<Path d="M18 6L6 18M6 6l12 12" stroke={theme.textMuted} strokeWidth={2} strokeLinecap="round" />
|
<Path d="M18 6L6 18M6 6l12 12" stroke={theme.textSecondary} strokeWidth={2.2} strokeLinecap="round" />
|
||||||
</Svg>
|
</Svg>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
@@ -192,6 +192,5 @@ const styles = StyleSheet.create({
|
|||||||
body: {
|
body: {
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
lineHeight: 22,
|
lineHeight: 22,
|
||||||
whiteSpace: 'pre-wrap' as const,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -26,6 +26,10 @@ export function ListItem({ title, subtitle, leftElement, rightElement, onPress,
|
|||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
disabled={!isInteractive}
|
disabled={!isInteractive}
|
||||||
|
accessibilityRole={isInteractive ? 'button' : 'none'}
|
||||||
|
accessibilityLabel={title}
|
||||||
|
accessibilityHint={subtitle ? subtitle : undefined}
|
||||||
|
accessibilityState={{ disabled: !isInteractive }}
|
||||||
>
|
>
|
||||||
{leftElement && <View style={styles.leftElement}>{leftElement}</View>}
|
{leftElement && <View style={styles.leftElement}>{leftElement}</View>}
|
||||||
<View style={styles.leftContent}>
|
<View style={styles.leftContent}>
|
||||||
@@ -38,8 +42,8 @@ export function ListItem({ title, subtitle, leftElement, rightElement, onPress,
|
|||||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||||
<Path
|
<Path
|
||||||
d="M9 18l6-6-6-6"
|
d="M9 18l6-6-6-6"
|
||||||
stroke={theme.textMuted}
|
stroke={theme.textSecondary}
|
||||||
strokeWidth={2}
|
strokeWidth={2.5}
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
fill="none"
|
fill="none"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { View, Text, StyleSheet, Modal, TouchableOpacity, FlatList } from 'react-native';
|
import { View, Text, StyleSheet, Modal, TouchableOpacity, FlatList } from 'react-native';
|
||||||
import { useSettings } from '@/theme';
|
import { useSettings } from '@/theme';
|
||||||
import Svg, { Path } from 'react-native-svg';
|
import Svg, { Path, Circle } from 'react-native-svg';
|
||||||
|
|
||||||
export interface PickerOption {
|
export interface PickerOption {
|
||||||
value: string;
|
value: string;
|
||||||
@@ -13,14 +13,17 @@ interface OptionPickerModalProps {
|
|||||||
visible: boolean;
|
visible: boolean;
|
||||||
title: string;
|
title: string;
|
||||||
options: PickerOption[];
|
options: PickerOption[];
|
||||||
selectedValue?: string;
|
selectedValue?: string | string[];
|
||||||
onSelect: (value: string) => void;
|
onSelect: (value: string | string[]) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
multiSelect?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function OptionPickerModal({ visible, title, options, selectedValue, onSelect, onClose }: OptionPickerModalProps) {
|
export function OptionPickerModal({ visible, title, options, selectedValue, onSelect, onClose, multiSelect = false }: OptionPickerModalProps) {
|
||||||
const { theme } = useSettings();
|
const { theme } = useSettings();
|
||||||
|
|
||||||
|
const selectedValues = Array.isArray(selectedValue) ? selectedValue : selectedValue == null ? [] : [selectedValue];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
||||||
<View style={[styles.overlay, { backgroundColor: theme.overlay }]}>
|
<View style={[styles.overlay, { backgroundColor: theme.overlay }]}>
|
||||||
@@ -30,7 +33,7 @@ export function OptionPickerModal({ visible, title, options, selectedValue, onSe
|
|||||||
data={options}
|
data={options}
|
||||||
keyExtractor={(item) => item.value}
|
keyExtractor={(item) => item.value}
|
||||||
renderItem={({ item }) => {
|
renderItem={({ item }) => {
|
||||||
const selected = item.value === selectedValue;
|
const selected = selectedValues.includes(item.value);
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[
|
style={[
|
||||||
@@ -39,10 +42,20 @@ export function OptionPickerModal({ visible, title, options, selectedValue, onSe
|
|||||||
selected && { backgroundColor: theme.accentSoft },
|
selected && { backgroundColor: theme.accentSoft },
|
||||||
]}
|
]}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
|
if (multiSelect) {
|
||||||
|
const newValues = selected
|
||||||
|
? selectedValues.filter((v) => v !== item.value)
|
||||||
|
: [...selectedValues, item.value];
|
||||||
|
onSelect(newValues);
|
||||||
|
} else {
|
||||||
onSelect(item.value);
|
onSelect(item.value);
|
||||||
onClose();
|
onClose();
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
|
accessibilityRole={multiSelect ? 'checkbox' : 'button'}
|
||||||
|
accessibilityLabel={item.label}
|
||||||
|
accessibilityState={multiSelect ? { checked: selected } : { selected }}
|
||||||
>
|
>
|
||||||
{item.color && (
|
{item.color && (
|
||||||
<View style={[styles.dot, { backgroundColor: item.color }]} />
|
<View style={[styles.dot, { backgroundColor: item.color }]} />
|
||||||
@@ -50,7 +63,14 @@ export function OptionPickerModal({ visible, title, options, selectedValue, onSe
|
|||||||
<Text style={[styles.optionText, { color: selected ? theme.accent : theme.textSecondary }]}>
|
<Text style={[styles.optionText, { color: selected ? theme.accent : theme.textSecondary }]}>
|
||||||
{item.label}
|
{item.label}
|
||||||
</Text>
|
</Text>
|
||||||
|
{multiSelect ? (
|
||||||
|
<Svg width={22} height={22} viewBox="0 0 24 24">
|
||||||
|
<Circle cx={12} cy={12} r={10} stroke={selected ? theme.accent : theme.borderStrong} strokeWidth={2} fill={selected ? theme.accent : 'transparent'} />
|
||||||
{selected && (
|
{selected && (
|
||||||
|
<Path d="M8 12l3 3 6-6" stroke="#FFFFFF" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||||
|
)}
|
||||||
|
</Svg>
|
||||||
|
) : selected && (
|
||||||
<Svg width={18} height={18} viewBox="0 0 24 24">
|
<Svg width={18} height={18} viewBox="0 0 24 24">
|
||||||
<Path d="M5 12l5 5 9-10" stroke={theme.accent} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
<Path d="M5 12l5 5 9-10" stroke={theme.accent} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||||
</Svg>
|
</Svg>
|
||||||
@@ -64,8 +84,10 @@ export function OptionPickerModal({ visible, title, options, selectedValue, onSe
|
|||||||
style={[styles.cancelButton, { borderColor: theme.borderStrong }]}
|
style={[styles.cancelButton, { borderColor: theme.borderStrong }]}
|
||||||
onPress={onClose}
|
onPress={onClose}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Cancel"
|
||||||
>
|
>
|
||||||
<Text style={[styles.cancelText, { color: theme.textFaint }]}>Cancel</Text>
|
<Text style={[styles.cancelText, { color: theme.textSecondary }]}>Cancel</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
}
|
}
|
||||||
contentContainerStyle={styles.listContent}
|
contentContainerStyle={styles.listContent}
|
||||||
|
|||||||
@@ -3,14 +3,13 @@ import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
|
|||||||
import { PRIORITY_COLORS } from '@/constants';
|
import { PRIORITY_COLORS } from '@/constants';
|
||||||
import { Priority } from '@/types';
|
import { Priority } from '@/types';
|
||||||
import { useSettings } from '@/theme';
|
import { useSettings } from '@/theme';
|
||||||
import Svg, { Circle } from 'react-native-svg';
|
|
||||||
|
|
||||||
interface PrioritySelectorProps {
|
interface PrioritySelectorProps {
|
||||||
value: Priority;
|
value: Priority;
|
||||||
onChange: (value: Priority) => void;
|
onChange: (value: Priority) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const priorities: Array<{ value: Priority; label: string }> = [
|
const priorities: { value: Priority; label: string }[] = [
|
||||||
{ value: 'none', label: 'None' },
|
{ value: 'none', label: 'None' },
|
||||||
{ value: 'low', label: 'Low' },
|
{ value: 'low', label: 'Low' },
|
||||||
{ value: 'medium', label: 'Medium' },
|
{ value: 'medium', label: 'Medium' },
|
||||||
@@ -24,7 +23,7 @@ export function PrioritySelector({ value, onChange }: PrioritySelectorProps) {
|
|||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<Text style={[styles.label, { color: theme.text }]}>Priority</Text>
|
<Text style={[styles.label, { color: theme.text }]}>Priority</Text>
|
||||||
<View style={styles.options}>
|
<View style={styles.options} accessibilityRole="radiogroup" accessibilityLabel="Priority">
|
||||||
{priorities.map((priority) => (
|
{priorities.map((priority) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={priority.value}
|
key={priority.value}
|
||||||
@@ -35,6 +34,9 @@ export function PrioritySelector({ value, onChange }: PrioritySelectorProps) {
|
|||||||
]}
|
]}
|
||||||
onPress={() => onChange(priority.value)}
|
onPress={() => onChange(priority.value)}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
|
accessibilityRole="radio"
|
||||||
|
accessibilityLabel={`${priority.label} priority`}
|
||||||
|
accessibilityState={{ selected: value === priority.value }}
|
||||||
>
|
>
|
||||||
<View style={[
|
<View style={[
|
||||||
styles.colorIndicator,
|
styles.colorIndicator,
|
||||||
@@ -44,7 +46,7 @@ export function PrioritySelector({ value, onChange }: PrioritySelectorProps) {
|
|||||||
<Text style={[
|
<Text style={[
|
||||||
styles.optionText,
|
styles.optionText,
|
||||||
{ color: theme.textFaint },
|
{ color: theme.textFaint },
|
||||||
value === priority.value && { color: theme.accent, fontWeight: '600' },
|
value === priority.value && { color: theme.accentStrong, fontWeight: '600' },
|
||||||
]}>
|
]}>
|
||||||
{priority.label}
|
{priority.label}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -57,15 +59,15 @@ export function PrioritySelector({ value, onChange }: PrioritySelectorProps) {
|
|||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
gap: 10,
|
gap: 6,
|
||||||
},
|
},
|
||||||
label: {
|
label: {
|
||||||
fontSize: 14,
|
fontSize: 13,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
gap: 8,
|
gap: 6,
|
||||||
},
|
},
|
||||||
option: {
|
option: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@@ -73,16 +75,17 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
gap: 6,
|
gap: 6,
|
||||||
paddingVertical: 12,
|
paddingVertical: 10,
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 8,
|
||||||
borderRadius: 12,
|
borderRadius: 10,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
|
minHeight: 40,
|
||||||
},
|
},
|
||||||
colorIndicator: {
|
colorIndicator: {
|
||||||
width: 10,
|
width: 10,
|
||||||
height: 10,
|
height: 10,
|
||||||
borderRadius: 5,
|
borderRadius: 5,
|
||||||
opacity: 0.5,
|
opacity: 0.6,
|
||||||
},
|
},
|
||||||
colorIndicatorSelected: {
|
colorIndicatorSelected: {
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
||||||
import { View, Text, StyleSheet, TextInput, TouchableOpacity, KeyboardAvoidingView, Platform } from 'react-native';
|
import { View, StyleSheet, TextInput, TouchableOpacity, Keyboard, Text } from 'react-native';
|
||||||
import { database, collections } from '@/database';
|
import { database, collections } from '@/database';
|
||||||
import { useCategories } from '@/hooks/useDatabase';
|
import { useCategories } from '@/hooks/useDatabase';
|
||||||
import { useSettings } from '@/theme';
|
import { useSettings } from '@/theme';
|
||||||
import { OptionPickerModal } from '@/components/OptionPickerModal';
|
import { OptionPickerModal } from '@/components/OptionPickerModal';
|
||||||
|
import { tagsToString } from '@/types';
|
||||||
import Svg, { Path } from 'react-native-svg';
|
import Svg, { Path } from 'react-native-svg';
|
||||||
|
import { subscribeToQuickAdd } from '@/utils/quickAddFocus';
|
||||||
|
|
||||||
interface QuickAddBarProps {
|
interface QuickAddBarProps {
|
||||||
dueDate?: number;
|
dueDate?: number;
|
||||||
@@ -15,28 +17,35 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
|
|||||||
const { theme, defaultCategoryId } = useSettings();
|
const { theme, defaultCategoryId } = useSettings();
|
||||||
const categories = useCategories();
|
const categories = useCategories();
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
const [categoryId, setCategoryId] = useState(defaultCategoryId || categories[0]?.id || '');
|
const [categoryId, setCategoryId] = useState(() => defaultCategoryId || '');
|
||||||
const [categoryPickerVisible, setCategoryPickerVisible] = useState(false);
|
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 ?? '#9E9E9E';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!categoryId) {
|
return subscribeToQuickAdd(() => {
|
||||||
setCategoryId(defaultCategoryId || categories[0]?.id || '');
|
setTimeout(() => {
|
||||||
}
|
inputRef.current?.focus();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}, 100);
|
||||||
}, [categories, defaultCategoryId]);
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleAdd = async () => {
|
const handleAdd = async () => {
|
||||||
const trimmed = title.trim();
|
const trimmed = title.trim();
|
||||||
if (!trimmed || !categoryId) return;
|
if (!trimmed) return;
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
await database.write(async () => {
|
await database.write(async () => {
|
||||||
await collections.tasks.create((t) => {
|
await collections.tasks.create((t) => {
|
||||||
t.title = trimmed;
|
t.title = trimmed;
|
||||||
t.description = '';
|
t.description = '';
|
||||||
t.categoryId = categoryId;
|
t.categoryId = categoryId || '';
|
||||||
|
t.tags = tagsToString(categoryId ? [categoryId] : []);
|
||||||
t.priority = 'none';
|
t.priority = 'none';
|
||||||
t.completed = false;
|
t.completed = false;
|
||||||
t.dueDate = dueDate;
|
t.dueDate = dueDate;
|
||||||
@@ -52,13 +61,11 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
setTitle('');
|
setTitle('');
|
||||||
|
Keyboard.dismiss();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<KeyboardAvoidingView
|
<View style={styles.wrapper}>
|
||||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
|
||||||
style={styles.wrapper}
|
|
||||||
>
|
|
||||||
<View style={[styles.bar, { backgroundColor: theme.card, borderColor: theme.border }]}>
|
<View style={[styles.bar, { backgroundColor: theme.card, borderColor: theme.border }]}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.categoryButton, { borderColor: theme.borderStrong, backgroundColor: theme.cardAlt }]}
|
style={[styles.categoryButton, { borderColor: theme.borderStrong, backgroundColor: theme.cardAlt }]}
|
||||||
@@ -68,10 +75,11 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
|
|||||||
>
|
>
|
||||||
<View style={[styles.categoryButtonDot, { backgroundColor: categoryColor }]} />
|
<View style={[styles.categoryButtonDot, { backgroundColor: categoryColor }]} />
|
||||||
<Svg width={12} height={12} viewBox="0 0 24 24">
|
<Svg width={12} height={12} viewBox="0 0 24 24">
|
||||||
<Path d="M6 9l6 6 6-6" stroke={theme.textFaint} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
<Path d="M6 9l6 6 6-6" stroke={theme.textSecondary} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||||
</Svg>
|
</Svg>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TextInput
|
<TextInput
|
||||||
|
ref={inputRef}
|
||||||
style={[styles.input, { color: theme.text }]}
|
style={[styles.input, { color: theme.text }]}
|
||||||
placeholder={placeholder ?? 'Add a task'}
|
placeholder={placeholder ?? 'Add a task'}
|
||||||
placeholderTextColor={theme.textMuted}
|
placeholderTextColor={theme.textMuted}
|
||||||
@@ -79,85 +87,103 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
|
|||||||
onChangeText={setTitle}
|
onChangeText={setTitle}
|
||||||
onSubmitEditing={handleAdd}
|
onSubmitEditing={handleAdd}
|
||||||
returnKeyType="done"
|
returnKeyType="done"
|
||||||
|
accessibilityLabel="Quick add task"
|
||||||
|
accessibilityHint="Enter a task name and press the add button"
|
||||||
/>
|
/>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.submit, { backgroundColor: theme.accent }, !title.trim() && styles.submitDisabled]}
|
style={[styles.submit, { backgroundColor: theme.accent }, title.trim() ? {} : styles.submitDisabled]}
|
||||||
onPress={handleAdd}
|
onPress={title.trim() ? handleAdd : undefined}
|
||||||
disabled={!title.trim()}
|
disabled={!title.trim()}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
accessibilityLabel="Add task"
|
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">
|
<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">
|
||||||
<Path
|
<Path
|
||||||
d="M12 5v14M5 12h14"
|
d="M12 5v14M12 5l-5 5M12 5l5 5"
|
||||||
stroke="#FFFFFF"
|
stroke={theme.accentText}
|
||||||
strokeWidth={2.5}
|
strokeWidth={2.5}
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
/>
|
/>
|
||||||
</Svg>
|
</Svg>
|
||||||
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<OptionPickerModal
|
<OptionPickerModal
|
||||||
visible={categoryPickerVisible}
|
visible={categoryPickerVisible}
|
||||||
title="Select Category"
|
title="Select Category"
|
||||||
options={categories.map((c) => ({ value: c.id, label: c.name, color: c.color }))}
|
options={[{ value: '', label: 'None', color: '#9E9E9E' }, ...visibleCategories.map((c) => ({ value: c.id, label: c.name, color: c.color }))]}
|
||||||
selectedValue={categoryId}
|
selectedValue={categoryId}
|
||||||
onSelect={(value) => setCategoryId(value)}
|
onSelect={(value) => setCategoryId(Array.isArray(value) ? value[0] : value)}
|
||||||
onClose={() => setCategoryPickerVisible(false)}
|
onClose={() => setCategoryPickerVisible(false)}
|
||||||
/>
|
/>
|
||||||
</KeyboardAvoidingView>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
wrapper: {
|
wrapper: {
|
||||||
position: 'absolute',
|
paddingHorizontal: 8,
|
||||||
left: 16,
|
paddingTop: 4,
|
||||||
right: 16,
|
|
||||||
bottom: 24,
|
|
||||||
},
|
},
|
||||||
bar: {
|
bar: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 10,
|
gap: 6,
|
||||||
paddingHorizontal: 10,
|
paddingHorizontal: 8,
|
||||||
paddingVertical: 10,
|
paddingVertical: 8,
|
||||||
borderRadius: 16,
|
borderRadius: 14,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
|
borderBottomWidth: 1,
|
||||||
shadowColor: '#000',
|
shadowColor: '#000',
|
||||||
shadowOffset: { width: 0, height: 4 },
|
shadowOffset: { width: 0, height: -2 },
|
||||||
shadowOpacity: 0.12,
|
shadowOpacity: 0.10,
|
||||||
shadowRadius: 12,
|
shadowRadius: 6,
|
||||||
elevation: 8,
|
elevation: 6,
|
||||||
},
|
},
|
||||||
categoryButton: {
|
categoryButton: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 6,
|
gap: 3,
|
||||||
paddingVertical: 10,
|
paddingVertical: 6,
|
||||||
paddingHorizontal: 12,
|
paddingHorizontal: 6,
|
||||||
borderRadius: 12,
|
borderRadius: 10,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
|
height: 40,
|
||||||
},
|
},
|
||||||
categoryButtonDot: {
|
categoryButtonDot: {
|
||||||
width: 12,
|
width: 8,
|
||||||
height: 12,
|
height: 8,
|
||||||
borderRadius: 6,
|
borderRadius: 4,
|
||||||
},
|
},
|
||||||
input: {
|
input: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
paddingVertical: 10,
|
paddingVertical: 10,
|
||||||
|
minHeight: 40,
|
||||||
},
|
},
|
||||||
submit: {
|
submit: {
|
||||||
width: 40,
|
minWidth: 88,
|
||||||
height: 40,
|
height: 40,
|
||||||
borderRadius: 12,
|
paddingHorizontal: 12,
|
||||||
|
borderRadius: 10,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
|
submitLabelRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
},
|
||||||
|
submitText: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
submitDisabled: {
|
submitDisabled: {
|
||||||
opacity: 0.5,
|
opacity: 0.5,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
|
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
|
||||||
import { Reminder, REMINDER_OPTIONS } from '@/types';
|
import { Reminder, REMINDER_OPTIONS, parseReminders, toRemindersString } from '@/types';
|
||||||
import { useSettings } from '@/theme';
|
import { useSettings } from '@/theme';
|
||||||
import { OptionPickerModal } from '@/components/OptionPickerModal';
|
import { OptionPickerModal } from '@/components/OptionPickerModal';
|
||||||
import Svg, { Path, Circle } from 'react-native-svg';
|
import Svg, { Path, Circle, Rect } from 'react-native-svg';
|
||||||
|
|
||||||
interface ReminderSelectorProps {
|
interface ReminderSelectorProps {
|
||||||
value: Reminder;
|
value: string;
|
||||||
onChange: (value: Reminder) => void;
|
onChange: (value: string) => void;
|
||||||
hasDueDate: boolean;
|
hasDueDate: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -15,60 +15,69 @@ export function ReminderSelector({ value, onChange, hasDueDate }: ReminderSelect
|
|||||||
const { theme } = useSettings();
|
const { theme } = useSettings();
|
||||||
const [showPicker, setShowPicker] = useState(false);
|
const [showPicker, setShowPicker] = useState(false);
|
||||||
|
|
||||||
const selected = REMINDER_OPTIONS.find((o) => o.value === value) ?? REMINDER_OPTIONS[0];
|
const selectedReminders = parseReminders(value);
|
||||||
const disabled = !hasDueDate;
|
const disabled = !hasDueDate;
|
||||||
|
|
||||||
|
const reminderLabels = selectedReminders.length > 0
|
||||||
|
? selectedReminders.map(r => REMINDER_OPTIONS.find(o => o.value === r)?.label).filter(Boolean).join(', ')
|
||||||
|
: 'No reminder';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<Text style={[styles.label, { color: theme.text }]}>Reminder</Text>
|
<Text style={[styles.label, { color: theme.text }]}>Reminders</Text>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[
|
style={[
|
||||||
styles.row,
|
styles.row,
|
||||||
{ backgroundColor: theme.card, borderColor: theme.borderStrong },
|
{ backgroundColor: theme.card, borderColor: theme.borderStrong },
|
||||||
value !== 'none' && { borderColor: theme.accent, backgroundColor: theme.accentSoft },
|
selectedReminders.length > 0 && { borderColor: theme.accent, backgroundColor: theme.accentSoft },
|
||||||
disabled && { opacity: 0.5 },
|
disabled && { opacity: 0.5 },
|
||||||
]}
|
]}
|
||||||
onPress={() => setShowPicker(true)}
|
onPress={() => setShowPicker(true)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Reminders"
|
||||||
|
accessibilityHint="Opens a list of reminder options"
|
||||||
|
accessibilityState={{ disabled }}
|
||||||
>
|
>
|
||||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||||
<Path
|
<Path
|
||||||
d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9M10.3 21a1.94 1.94 0 0 0 3.4 0"
|
d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9M10.3 21a1.94 1.94 0 0 0 3.4 0"
|
||||||
stroke={value !== 'none' ? theme.accent : theme.textMuted}
|
stroke={selectedReminders.length > 0 ? theme.accentText : theme.textSecondary}
|
||||||
strokeWidth={1.5}
|
strokeWidth={1.8}
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
fill="none"
|
fill="none"
|
||||||
/>
|
/>
|
||||||
<Circle cx={18.5} cy={5.5} r={3.5} fill={value !== 'none' ? theme.accent : 'transparent'} stroke={value !== 'none' ? theme.accent : theme.textMuted} strokeWidth={1.5} />
|
<Circle cx={18.5} cy={5.5} r={3.5} fill={selectedReminders.length > 0 ? theme.accent : 'transparent'} stroke={selectedReminders.length > 0 ? theme.accentText : theme.textSecondary} strokeWidth={1.8} />
|
||||||
</Svg>
|
</Svg>
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
styles.valueText,
|
styles.valueText,
|
||||||
{ color: value !== 'none' ? theme.text : theme.textMuted },
|
{ color: selectedReminders.length > 0 ? theme.text : theme.textMuted },
|
||||||
value !== 'none' && styles.valueTextFilled,
|
selectedReminders.length > 0 && styles.valueTextFilled,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{value !== 'none' ? selected.label : 'No reminder'}
|
{reminderLabels}
|
||||||
</Text>
|
</Text>
|
||||||
<View style={styles.chevron}>
|
<View style={styles.chevron}>
|
||||||
<Svg width={16} height={16} viewBox="0 0 24 24">
|
<Svg width={16} height={16} viewBox="0 0 24 24">
|
||||||
<Path d="M9 18l6-6-6-6" stroke={theme.textMuted} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
<Path d="M9 18l6-6-6-6" stroke={theme.textSecondary} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||||
</Svg>
|
</Svg>
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
{!hasDueDate && (
|
{!hasDueDate && (
|
||||||
<Text style={[styles.hint, { color: theme.textMuted }]}>Set a due date to add a reminder.</Text>
|
<Text style={[styles.hint, { color: theme.textMuted }]}>Set a due date to add reminders.</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<OptionPickerModal
|
<OptionPickerModal
|
||||||
visible={showPicker}
|
visible={showPicker}
|
||||||
title="Reminder"
|
title="Reminders"
|
||||||
options={REMINDER_OPTIONS.map((o) => ({ value: o.value, label: o.label }))}
|
options={REMINDER_OPTIONS.filter((o) => o.value !== 'none').map((o) => ({ value: o.value, label: o.label }))}
|
||||||
selectedValue={value}
|
selectedValue={selectedReminders}
|
||||||
onSelect={(v) => onChange(v as Reminder)}
|
onSelect={(v) => onChange(toRemindersString(v as Reminder[]))}
|
||||||
onClose={() => setShowPicker(false)}
|
onClose={() => setShowPicker(false)}
|
||||||
|
multiSelect
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -76,10 +85,10 @@ export function ReminderSelector({ value, onChange, hasDueDate }: ReminderSelect
|
|||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
gap: 8,
|
gap: 6,
|
||||||
},
|
},
|
||||||
label: {
|
label: {
|
||||||
fontSize: 14,
|
fontSize: 13,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
row: {
|
row: {
|
||||||
@@ -93,7 +102,7 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
valueText: {
|
valueText: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
fontSize: 15,
|
fontSize: 14,
|
||||||
},
|
},
|
||||||
valueTextFilled: {
|
valueTextFilled: {
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
@@ -102,7 +111,7 @@ const styles = StyleSheet.create({
|
|||||||
transform: [{ rotate: '-90deg' }],
|
transform: [{ rotate: '-90deg' }],
|
||||||
},
|
},
|
||||||
hint: {
|
hint: {
|
||||||
fontSize: 12,
|
fontSize: 11,
|
||||||
marginLeft: 4,
|
marginLeft: 4,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { View, Text, StyleSheet, TouchableOpacity, Modal, TextInput, Pressable } from 'react-native';
|
import { View, Text, StyleSheet, TouchableOpacity, Modal, TextInput, Pressable, KeyboardAvoidingView } from 'react-native';
|
||||||
import { Repeat, REPEAT_OPTIONS, WEEKDAY_LABELS, repeatDaysFromString } from '@/types';
|
import { Repeat, REPEAT_OPTIONS, WEEKDAY_LABELS, WEEKDAY_ORDER, weekdaysInDisplayOrder, repeatDaysFromString } from '@/types';
|
||||||
import { useSettings } from '@/theme';
|
import { useSettings } from '@/theme';
|
||||||
import { useRepeatProfiles } from '@/hooks/useDatabase';
|
import { useRepeatProfiles } from '@/hooks/useDatabase';
|
||||||
import { createRepeatProfile, deleteRepeatProfile } from '@/utils/repeatProfileActions';
|
import { createRepeatProfile, deleteRepeatProfile } from '@/utils/repeatProfileActions';
|
||||||
@@ -43,6 +43,8 @@ function RepeatIcon({ repeat, color }: { repeat: Repeat; color: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const WEEKDAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||||
|
|
||||||
function unitLabel(value: Repeat): string {
|
function unitLabel(value: Repeat): string {
|
||||||
switch (value) {
|
switch (value) {
|
||||||
case 'daily':
|
case 'daily':
|
||||||
@@ -129,12 +131,14 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<TouchableOpacity onPress={() => applyProfile(profile)} activeOpacity={0.8}>
|
<TouchableOpacity onPress={() => applyProfile(profile)} activeOpacity={0.8} accessibilityRole="button" accessibilityLabel={`Apply repeat profile ${profile.name}`}>
|
||||||
<Text style={[styles.profileChipText, { color: theme.textSecondary }]}>{profile.name}</Text>
|
<Text style={[styles.profileChipText, { color: theme.textSecondary }]}>{profile.name}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => handleDeleteProfile(profile.id, profile.name)}
|
onPress={() => handleDeleteProfile(profile.id, profile.name)}
|
||||||
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={`Delete repeat profile ${profile.name}`}
|
||||||
>
|
>
|
||||||
<Text style={[styles.profileChipX, { color: theme.textFaint }]}>×</Text>
|
<Text style={[styles.profileChipX, { color: theme.textFaint }]}>×</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -144,7 +148,7 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<View style={styles.chipRow}>
|
<View style={styles.chipRow} accessibilityRole="radiogroup" accessibilityLabel="Repeat">
|
||||||
{REPEAT_OPTIONS.map((option) => (
|
{REPEAT_OPTIONS.map((option) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={option.value}
|
key={option.value}
|
||||||
@@ -155,13 +159,16 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
|
|||||||
]}
|
]}
|
||||||
onPress={() => selectRepeat(option.value)}
|
onPress={() => selectRepeat(option.value)}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
|
accessibilityRole="radio"
|
||||||
|
accessibilityLabel={`Repeat ${option.label.replace('No Repeat', 'none')}`}
|
||||||
|
accessibilityState={{ selected: value === option.value }}
|
||||||
>
|
>
|
||||||
<RepeatIcon repeat={option.value} color={value === option.value ? theme.accent : theme.textFaint} />
|
<RepeatIcon repeat={option.value} color={value === option.value ? theme.accent : theme.textFaint} />
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
styles.chipText,
|
styles.chipText,
|
||||||
{ color: theme.textFaint },
|
{ color: theme.textFaint },
|
||||||
value === option.value && { color: theme.accent, fontWeight: '600' },
|
value === option.value && { color: theme.accentStrong, fontWeight: '600' },
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{option.label.replace('No Repeat', 'None')}
|
{option.label.replace('No Repeat', 'None')}
|
||||||
@@ -179,6 +186,9 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
|
|||||||
onPress={() => bumpInterval(-1)}
|
onPress={() => bumpInterval(-1)}
|
||||||
disabled={interval <= 1}
|
disabled={interval <= 1}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Decrease repeat interval"
|
||||||
|
accessibilityState={{ disabled: interval <= 1 }}
|
||||||
>
|
>
|
||||||
<Text style={[styles.stepButtonText, { color: interval <= 1 ? theme.textMuted : theme.text }]}>−</Text>
|
<Text style={[styles.stepButtonText, { color: interval <= 1 ? theme.textMuted : theme.text }]}>−</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -188,6 +198,9 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
|
|||||||
onPress={() => bumpInterval(1)}
|
onPress={() => bumpInterval(1)}
|
||||||
disabled={interval >= 30}
|
disabled={interval >= 30}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Increase repeat interval"
|
||||||
|
accessibilityState={{ disabled: interval >= 30 }}
|
||||||
>
|
>
|
||||||
<Text style={[styles.stepButtonText, { color: interval >= 30 ? theme.textMuted : theme.text }]}>+</Text>
|
<Text style={[styles.stepButtonText, { color: interval >= 30 ? theme.textMuted : theme.text }]}>+</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -199,9 +212,9 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
|
|||||||
|
|
||||||
{isDaysBased(value) && (
|
{isDaysBased(value) && (
|
||||||
<View style={styles.dayRow}>
|
<View style={styles.dayRow}>
|
||||||
{WEEKDAY_LABELS.map((label, day) => (
|
{WEEKDAY_ORDER.map((day) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={`${label}-${day}`}
|
key={`${WEEKDAY_LABELS[day]}-${day}`}
|
||||||
style={[
|
style={[
|
||||||
styles.dayChip,
|
styles.dayChip,
|
||||||
{ backgroundColor: theme.card, borderColor: theme.borderStrong },
|
{ backgroundColor: theme.card, borderColor: theme.borderStrong },
|
||||||
@@ -210,6 +223,9 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
|
|||||||
]}
|
]}
|
||||||
onPress={() => toggleDay(day)}
|
onPress={() => toggleDay(day)}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
|
accessibilityRole="checkbox"
|
||||||
|
accessibilityLabel={WEEKDAY_NAMES[day]}
|
||||||
|
accessibilityState={{ checked: days.includes(day) }}
|
||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
@@ -218,7 +234,7 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
|
|||||||
days.includes(day) && { color: '#FFFFFF', fontWeight: '700' },
|
days.includes(day) && { color: '#FFFFFF', fontWeight: '700' },
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{label}
|
{WEEKDAY_LABELS[day]}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
))}
|
))}
|
||||||
@@ -236,7 +252,7 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
|
|||||||
<Svg width={13} height={13} viewBox="0 0 24 24">
|
<Svg width={13} height={13} viewBox="0 0 24 24">
|
||||||
<Path d="M12 5v14M5 12h14" stroke={theme.accent} strokeWidth={2.5} strokeLinecap="round" />
|
<Path d="M12 5v14M5 12h14" stroke={theme.accent} strokeWidth={2.5} strokeLinecap="round" />
|
||||||
</Svg>
|
</Svg>
|
||||||
<Text style={[styles.saveProfileText, { color: theme.accent }]}>Save as profile</Text>
|
<Text style={[styles.saveProfileText, { color: theme.accentStrong }]}>Save as profile</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -245,13 +261,17 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
|
|||||||
transparent
|
transparent
|
||||||
animationType="fade"
|
animationType="fade"
|
||||||
onRequestClose={() => setSaveModalVisible(false)}
|
onRequestClose={() => setSaveModalVisible(false)}
|
||||||
|
>
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
style={styles.modalBackdrop}
|
||||||
|
behavior="padding"
|
||||||
>
|
>
|
||||||
<Pressable style={styles.modalBackdrop} onPress={() => setSaveModalVisible(false)}>
|
<Pressable style={styles.modalBackdrop} onPress={() => setSaveModalVisible(false)}>
|
||||||
<Pressable style={[styles.modalCard, { backgroundColor: theme.card, borderColor: theme.border }]}>
|
<Pressable style={[styles.modalCard, { backgroundColor: theme.card, borderColor: theme.border }]}>
|
||||||
<Text style={[styles.modalTitle, { color: theme.text }]}>Save repeat profile</Text>
|
<Text style={[styles.modalTitle, { color: theme.text }]}>Save repeat profile</Text>
|
||||||
<Text style={[styles.modalHint, { color: theme.textFaint }]}>
|
<Text style={[styles.modalHint, { color: theme.textFaint }]}>
|
||||||
{`${REPEAT_OPTIONS.find((o) => o.value === value)?.label.replace('No Repeat', 'None')}, every ${interval} ${unitLabel(value)}${interval > 1 ? 's' : ''}`}
|
{`${REPEAT_OPTIONS.find((o) => o.value === value)?.label.replace('No Repeat', 'None')}, every ${interval} ${unitLabel(value)}${interval > 1 ? 's' : ''}`}
|
||||||
{isDaysBased(value) && days.length > 0 ? ` · ${days.map((d) => WEEKDAY_LABELS[d]).join(' ')}` : ''}
|
{isDaysBased(value) && days.length > 0 ? ` · ${weekdaysInDisplayOrder(days).map((d) => WEEKDAY_LABELS[d]).join(' ')}` : ''}
|
||||||
</Text>
|
</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={[styles.modalInput, { backgroundColor: theme.cardAlt, borderColor: theme.borderStrong, color: theme.text }]}
|
style={[styles.modalInput, { backgroundColor: theme.cardAlt, borderColor: theme.borderStrong, color: theme.text }]}
|
||||||
@@ -278,11 +298,12 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
|
|||||||
disabled={!profileName.trim()}
|
disabled={!profileName.trim()}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
>
|
>
|
||||||
<Text style={[styles.modalButtonText, { color: '#FFFFFF', fontWeight: '600' }]}>Save</Text>
|
<Text style={[styles.modalButtonText, { color: theme.accentText, fontWeight: '600' }]}>Save</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
</Modal>
|
</Modal>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -290,115 +311,115 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
|
|||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
gap: 10,
|
gap: 8,
|
||||||
},
|
},
|
||||||
label: {
|
label: {
|
||||||
fontSize: 14,
|
fontSize: 13,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
chipRow: {
|
chipRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
flexWrap: 'wrap',
|
flexWrap: 'wrap',
|
||||||
gap: 8,
|
gap: 6,
|
||||||
},
|
},
|
||||||
chip: {
|
chip: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 6,
|
gap: 5,
|
||||||
paddingVertical: 10,
|
paddingVertical: 8,
|
||||||
paddingHorizontal: 12,
|
paddingHorizontal: 10,
|
||||||
borderRadius: 20,
|
borderRadius: 18,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
},
|
},
|
||||||
chipText: {
|
chipText: {
|
||||||
fontSize: 13,
|
fontSize: 12,
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
gap: 10,
|
gap: 8,
|
||||||
},
|
},
|
||||||
intervalRow: {
|
intervalRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 10,
|
gap: 8,
|
||||||
paddingHorizontal: 14,
|
paddingHorizontal: 12,
|
||||||
paddingVertical: 10,
|
paddingVertical: 8,
|
||||||
borderRadius: 12,
|
borderRadius: 10,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
},
|
},
|
||||||
intervalLabel: {
|
intervalLabel: {
|
||||||
fontSize: 14,
|
fontSize: 13,
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
},
|
},
|
||||||
intervalValue: {
|
intervalValue: {
|
||||||
fontSize: 16,
|
fontSize: 15,
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
minWidth: 24,
|
minWidth: 22,
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
},
|
},
|
||||||
stepButton: {
|
stepButton: {
|
||||||
width: 32,
|
width: 28,
|
||||||
height: 32,
|
height: 28,
|
||||||
borderRadius: 8,
|
borderRadius: 7,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
stepButtonText: {
|
stepButtonText: {
|
||||||
fontSize: 18,
|
fontSize: 16,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
lineHeight: 20,
|
lineHeight: 18,
|
||||||
},
|
},
|
||||||
dayRow: {
|
dayRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
gap: 6,
|
gap: 5,
|
||||||
},
|
},
|
||||||
dayChip: {
|
dayChip: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
height: 40,
|
height: 36,
|
||||||
borderRadius: 10,
|
borderRadius: 9,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
dayChipLast: {},
|
dayChipLast: {},
|
||||||
dayChipText: {
|
dayChipText: {
|
||||||
fontSize: 13,
|
fontSize: 12,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
profileRow: {
|
profileRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'flex-start',
|
alignItems: 'flex-start',
|
||||||
gap: 8,
|
gap: 6,
|
||||||
},
|
},
|
||||||
profileLabel: {
|
profileLabel: {
|
||||||
fontSize: 12,
|
fontSize: 11,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
paddingTop: 8,
|
paddingTop: 6,
|
||||||
},
|
},
|
||||||
profileChips: {
|
profileChips: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
flexWrap: 'wrap',
|
flexWrap: 'wrap',
|
||||||
gap: 8,
|
gap: 6,
|
||||||
},
|
},
|
||||||
profileChip: {
|
profileChip: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 6,
|
gap: 5,
|
||||||
paddingVertical: 6,
|
paddingVertical: 5,
|
||||||
paddingHorizontal: 10,
|
paddingHorizontal: 8,
|
||||||
borderRadius: 12,
|
borderRadius: 10,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
},
|
},
|
||||||
profileChipText: {
|
profileChipText: {
|
||||||
fontSize: 13,
|
fontSize: 12,
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
},
|
},
|
||||||
profileChipX: {
|
profileChipX: {
|
||||||
fontSize: 14,
|
fontSize: 13,
|
||||||
lineHeight: 16,
|
lineHeight: 15,
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
paddingHorizontal: 2,
|
paddingHorizontal: 2,
|
||||||
},
|
},
|
||||||
@@ -406,14 +427,14 @@ const styles = StyleSheet.create({
|
|||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
gap: 6,
|
gap: 5,
|
||||||
paddingVertical: 10,
|
paddingVertical: 8,
|
||||||
borderRadius: 12,
|
borderRadius: 10,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderStyle: 'dashed',
|
borderStyle: 'dashed',
|
||||||
},
|
},
|
||||||
saveProfileText: {
|
saveProfileText: {
|
||||||
fontSize: 13,
|
fontSize: 12,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
modalBackdrop: {
|
modalBackdrop: {
|
||||||
@@ -421,45 +442,45 @@ const styles = StyleSheet.create({
|
|||||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
padding: 24,
|
padding: 20,
|
||||||
},
|
},
|
||||||
modalCard: {
|
modalCard: {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
maxWidth: 400,
|
maxWidth: 360,
|
||||||
borderRadius: 16,
|
borderRadius: 14,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
padding: 20,
|
padding: 16,
|
||||||
gap: 12,
|
gap: 10,
|
||||||
},
|
},
|
||||||
modalTitle: {
|
modalTitle: {
|
||||||
fontSize: 16,
|
fontSize: 15,
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
},
|
},
|
||||||
modalHint: {
|
modalHint: {
|
||||||
fontSize: 13,
|
fontSize: 12,
|
||||||
},
|
},
|
||||||
modalInput: {
|
modalInput: {
|
||||||
borderRadius: 10,
|
borderRadius: 9,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
paddingHorizontal: 12,
|
paddingHorizontal: 10,
|
||||||
paddingVertical: 10,
|
paddingVertical: 8,
|
||||||
fontSize: 14,
|
fontSize: 13,
|
||||||
},
|
},
|
||||||
modalButtons: {
|
modalButtons: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
gap: 10,
|
gap: 8,
|
||||||
},
|
},
|
||||||
modalButton: {
|
modalButton: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingVertical: 11,
|
paddingVertical: 10,
|
||||||
borderRadius: 10,
|
borderRadius: 9,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
},
|
},
|
||||||
modalButtonPrimary: {
|
modalButtonPrimary: {
|
||||||
borderWidth: 0,
|
borderWidth: 0,
|
||||||
},
|
},
|
||||||
modalButtonText: {
|
modalButtonText: {
|
||||||
fontSize: 14,
|
fontSize: 13,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Modal, View, Text, StyleSheet, TextInput, TouchableOpacity, KeyboardAvoidingView } from 'react-native';
|
||||||
|
import { useSettings } from '@/theme';
|
||||||
|
import { DEFAULT_API_BASE_URL } from '@/services/auth';
|
||||||
|
import Svg, { Path } from 'react-native-svg';
|
||||||
|
|
||||||
|
interface ServerUrlModalProps {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ServerUrlModal({ visible, onClose }: ServerUrlModalProps) {
|
||||||
|
const { theme, apiUrl, setApiUrl } = useSettings();
|
||||||
|
const [value, setValue] = useState(apiUrl);
|
||||||
|
const [prevVisible, setPrevVisible] = useState(visible);
|
||||||
|
|
||||||
|
if (prevVisible !== visible) {
|
||||||
|
setPrevVisible(visible);
|
||||||
|
if (visible) {
|
||||||
|
setValue(apiUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
const trimmed = value.trim().replace(/\/+$/, '');
|
||||||
|
if (trimmed && /^https?:\/\/.+/.test(trimmed)) {
|
||||||
|
setApiUrl(trimmed);
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const isValid = /^https?:\/\/.+/.test(value.trim());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
style={styles.overlay}
|
||||||
|
behavior="padding"
|
||||||
|
>
|
||||||
|
<View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={[styles.title, { color: theme.text }]}>Backend URL</Text>
|
||||||
|
<TouchableOpacity onPress={onClose} style={styles.closeButton} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
|
||||||
|
<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" />
|
||||||
|
</Svg>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={[styles.label, { color: theme.textSecondary }]}>API base URL</Text>
|
||||||
|
<TextInput
|
||||||
|
style={[
|
||||||
|
styles.input,
|
||||||
|
{ backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text },
|
||||||
|
]}
|
||||||
|
placeholder={DEFAULT_API_BASE_URL}
|
||||||
|
placeholderTextColor={theme.textMuted}
|
||||||
|
value={value}
|
||||||
|
onChangeText={setValue}
|
||||||
|
autoFocus
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
keyboardType="url"
|
||||||
|
/>
|
||||||
|
<Text style={[styles.hint, { color: theme.textMuted }]}>
|
||||||
|
Include the /api suffix, e.g. https://example.com/api
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={styles.actions}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.cancelButton, { borderColor: theme.borderStrong }]}
|
||||||
|
onPress={onClose}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Text style={[styles.cancelButtonText, { color: theme.textSecondary }]}>Cancel</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.saveButton, { backgroundColor: theme.accent }, !isValid && styles.saveButtonDisabled]}
|
||||||
|
onPress={handleSave}
|
||||||
|
disabled={!isValid}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<Text style={[styles.saveButtonText, { color: theme.accentText }]}>Save</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
overlay: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.4)',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: 24,
|
||||||
|
},
|
||||||
|
sheet: {
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: 380,
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 20,
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
closeButton: {
|
||||||
|
padding: 4,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
height: 48,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
hint: {
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 12,
|
||||||
|
marginTop: 16,
|
||||||
|
},
|
||||||
|
cancelButton: {
|
||||||
|
flex: 1,
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
cancelButtonText: {
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
saveButton: {
|
||||||
|
flex: 1,
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderRadius: 12,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
saveButtonDisabled: {
|
||||||
|
opacity: 0.5,
|
||||||
|
},
|
||||||
|
saveButtonText: {
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,145 +1,121 @@
|
|||||||
import React from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
|
import { View, StyleSheet } from 'react-native';
|
||||||
import { SubtaskData } from '@/types';
|
import { SubtaskData } from '@/types';
|
||||||
import { PRIORITY_COLORS } from '@/constants';
|
import { TaskItem } from './TaskItem';
|
||||||
import { useSettings } from '@/theme';
|
|
||||||
import Svg, { Path } from 'react-native-svg';
|
|
||||||
|
|
||||||
interface SubtaskItemProps {
|
interface SubtaskItemProps {
|
||||||
subtask: SubtaskData;
|
subtask: SubtaskData;
|
||||||
onToggle: () => void;
|
onToggle?: (subtask: SubtaskData) => void | Promise<void>;
|
||||||
|
onDelete?: (subtask: SubtaskData) => void;
|
||||||
|
onPress?: (subtask: SubtaskData) => void;
|
||||||
|
onLongPress?: (subtask: SubtaskData) => void;
|
||||||
|
onMenuOpen?: (subtask: SubtaskData) => void;
|
||||||
|
selected?: boolean;
|
||||||
|
selectionMode?: boolean;
|
||||||
|
draggable?: boolean;
|
||||||
|
onDragStart?: (subtask: SubtaskData) => void;
|
||||||
|
onDragUpdate?: (absoluteY: number) => void;
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SubtaskItem({ subtask, onToggle }: SubtaskItemProps) {
|
export const SubtaskItem = React.memo(function SubtaskItem({
|
||||||
const { theme } = useSettings();
|
subtask,
|
||||||
|
onToggle,
|
||||||
|
onDelete,
|
||||||
|
onPress,
|
||||||
|
onLongPress,
|
||||||
|
onMenuOpen,
|
||||||
|
selected,
|
||||||
|
selectionMode,
|
||||||
|
draggable,
|
||||||
|
onDragStart,
|
||||||
|
onDragUpdate,
|
||||||
|
onDragEnd,
|
||||||
|
depth = 1,
|
||||||
|
categoryColor,
|
||||||
|
categoryColorResolver,
|
||||||
|
registerRef,
|
||||||
|
hoveredId,
|
||||||
|
}: SubtaskItemProps) {
|
||||||
|
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);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hovered && hasChildren && !expanded) {
|
||||||
|
setExpanded(true);
|
||||||
|
}
|
||||||
|
}, [hovered, hasChildren, expanded]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.container, { backgroundColor: theme.cardAlt }]}>
|
<View>
|
||||||
<TouchableOpacity
|
<View ref={(ref) => registerRef?.(subtask.id, subtask.taskId, ref)}>
|
||||||
style={styles.checkCircle}
|
<TaskItem
|
||||||
onPress={onToggle}
|
task={subtask}
|
||||||
activeOpacity={0.7}
|
indented
|
||||||
accessibilityLabel={subtask.completed ? 'Mark incomplete' : 'Mark complete'}
|
depth={depth}
|
||||||
>
|
expanded={hasChildren && expanded}
|
||||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
onToggle={() => onToggle?.(subtask)}
|
||||||
{subtask.completed ? (
|
onDelete={() => onDelete?.(subtask)}
|
||||||
<>
|
onPress={onPress ? () => onPress(subtask) : handleExpand}
|
||||||
<Path
|
onLongPress={() => onLongPress?.(subtask)}
|
||||||
d="M20 6L9 17l-5-5"
|
onMenuOpen={() => onMenuOpen?.(subtask)}
|
||||||
stroke={theme.accent}
|
selected={selected}
|
||||||
strokeWidth={2.5}
|
selectionMode={selectionMode}
|
||||||
strokeLinecap="round"
|
completedSection={subtask.completed}
|
||||||
strokeLinejoin="round"
|
draggable={draggable}
|
||||||
fill="none"
|
hovered={hovered}
|
||||||
|
onDragStart={() => onDragStart?.(subtask)}
|
||||||
|
onDragUpdate={onDragUpdate}
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
|
categoryColor={effectiveColor}
|
||||||
/>
|
/>
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Path
|
|
||||||
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2"
|
|
||||||
stroke={theme.borderStrong}
|
|
||||||
strokeWidth={2}
|
|
||||||
fill="none"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Svg>
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
styles.title,
|
|
||||||
{ color: theme.text },
|
|
||||||
subtask.completed && styles.titleCompleted,
|
|
||||||
]}
|
|
||||||
numberOfLines={1}
|
|
||||||
>
|
|
||||||
{subtask.title}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{subtask.priority && subtask.priority !== 'none' && (
|
|
||||||
<View style={[styles.priorityBadge, { backgroundColor: PRIORITY_COLORS[subtask.priority] }]}>
|
|
||||||
<Text style={styles.priorityText}>{subtask.priority.charAt(0).toUpperCase()}</Text>
|
|
||||||
</View>
|
</View>
|
||||||
)}
|
{hasChildren && expanded && (
|
||||||
|
<View style={[styles.nestedSubtasks, { marginLeft: depth * 12 }]}>
|
||||||
{subtask.dueDate && subtask.dueDate > 0 && (
|
{subtask.subtasks
|
||||||
<Text style={[styles.dueText, { color: theme.textFaint }]} numberOfLines={1}>
|
.slice()
|
||||||
{formatDueDate(subtask.dueDate, subtask.dueTime, subtask.endTime || '')}
|
.sort((a, b) => a.order - b.order)
|
||||||
</Text>
|
.map((child) => (
|
||||||
|
<SubtaskItem
|
||||||
|
key={child.id}
|
||||||
|
subtask={child}
|
||||||
|
onToggle={onToggle}
|
||||||
|
onDelete={onDelete}
|
||||||
|
onPress={onPress}
|
||||||
|
onLongPress={onLongPress}
|
||||||
|
onMenuOpen={onMenuOpen}
|
||||||
|
selected={selected}
|
||||||
|
selectionMode={selectionMode}
|
||||||
|
draggable={draggable}
|
||||||
|
onDragStart={onDragStart}
|
||||||
|
onDragUpdate={onDragUpdate}
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
|
depth={depth + 1}
|
||||||
|
categoryColor={effectiveColor}
|
||||||
|
categoryColorResolver={categoryColorResolver}
|
||||||
|
registerRef={registerRef}
|
||||||
|
hoveredId={hoveredId}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
function formatDueDate(dueDate: number, dueTime: string, endTime?: string): string {
|
|
||||||
const date = new Date(dueDate);
|
|
||||||
const today = new Date();
|
|
||||||
today.setHours(0, 0, 0, 0);
|
|
||||||
const tomorrow = new Date(today);
|
|
||||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
|
||||||
|
|
||||||
let dateStr = '';
|
|
||||||
if (date.toDateString() === today.toDateString()) {
|
|
||||||
dateStr = 'Today';
|
|
||||||
} else if (date.toDateString() === tomorrow.toDateString()) {
|
|
||||||
dateStr = 'Tomorrow';
|
|
||||||
} else {
|
|
||||||
dateStr = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dueTime && endTime) {
|
|
||||||
return `${dateStr} · ${formatTime12(dueTime)} – ${formatTime12(endTime)}`;
|
|
||||||
}
|
|
||||||
return dueTime ? `${dateStr} at ${formatTime12(dueTime)}` : dateStr;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTime12(time: string): string {
|
|
||||||
const [hours, minutes] = time.split(':').map(Number);
|
|
||||||
if (Number.isNaN(hours) || Number.isNaN(minutes)) return time;
|
|
||||||
const period = hours >= 12 ? 'PM' : 'AM';
|
|
||||||
const h = hours % 12 === 0 ? 12 : hours % 12;
|
|
||||||
return `${h}:${String(minutes).padStart(2, '0')} ${period}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
nestedSubtasks: {
|
||||||
flexDirection: 'row',
|
marginTop: 4,
|
||||||
alignItems: 'center',
|
|
||||||
gap: 8,
|
|
||||||
paddingHorizontal: 12,
|
|
||||||
paddingVertical: 10,
|
|
||||||
borderRadius: 10,
|
|
||||||
marginLeft: 32,
|
|
||||||
marginBottom: 4,
|
|
||||||
},
|
|
||||||
checkCircle: {
|
|
||||||
width: 20,
|
|
||||||
height: 20,
|
|
||||||
borderRadius: 10,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
fontSize: 14,
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
titleCompleted: {
|
|
||||||
textDecorationLine: 'line-through',
|
|
||||||
color: '#9E9E9E',
|
|
||||||
},
|
|
||||||
priorityBadge: {
|
|
||||||
paddingHorizontal: 5,
|
|
||||||
paddingVertical: 1,
|
|
||||||
borderRadius: 6,
|
|
||||||
minWidth: 18,
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
priorityText: {
|
|
||||||
fontSize: 8,
|
|
||||||
fontWeight: '700',
|
|
||||||
color: '#FFFFFF',
|
|
||||||
},
|
|
||||||
dueText: {
|
|
||||||
fontSize: 12,
|
|
||||||
maxWidth: '30%',
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -48,7 +48,7 @@ export function SubtasksSection({ control }: SubtasksSectionProps) {
|
|||||||
<Text style={[styles.label, { color: theme.text }]}>Subtasks</Text>
|
<Text style={[styles.label, { color: theme.text }]}>Subtasks</Text>
|
||||||
{items.length > 0 && (
|
{items.length > 0 && (
|
||||||
<View style={[styles.badge, { backgroundColor: theme.accentSoft }]}>
|
<View style={[styles.badge, { backgroundColor: theme.accentSoft }]}>
|
||||||
<Text style={[styles.badgeText, { color: theme.accent }]}>{items.length}</Text>
|
<Text style={[styles.badgeText, { color: theme.accentStrong }]}>{items.length}</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -67,8 +67,8 @@ export function SubtasksSection({ control }: SubtasksSectionProps) {
|
|||||||
<Svg width={16} height={16} viewBox="0 0 24 24">
|
<Svg width={16} height={16} viewBox="0 0 24 24">
|
||||||
<Path
|
<Path
|
||||||
d="M6 9l6 6 6-6"
|
d="M6 9l6 6 6-6"
|
||||||
stroke={theme.textMuted}
|
stroke={theme.textSecondary}
|
||||||
strokeWidth={2}
|
strokeWidth={2.5}
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
fill="none"
|
fill="none"
|
||||||
@@ -105,7 +105,7 @@ export function SubtasksSection({ control }: SubtasksSectionProps) {
|
|||||||
<Svg width={16} height={16} viewBox="0 0 24 24">
|
<Svg width={16} height={16} viewBox="0 0 24 24">
|
||||||
<Path d="M12 5v14M5 12h14" stroke={theme.accent} strokeWidth={2} strokeLinecap="round" />
|
<Path d="M12 5v14M5 12h14" stroke={theme.accent} strokeWidth={2} strokeLinecap="round" />
|
||||||
</Svg>
|
</Svg>
|
||||||
<Text style={[styles.addButtonText, { color: theme.accent }]}>Add Subtask</Text>
|
<Text style={[styles.addButtonText, { color: theme.accentStrong }]}>Add Subtask</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</View>
|
</View>
|
||||||
@@ -131,8 +131,8 @@ function SubtaskItem({ index, value, onChange, onRemove }: SubtaskItemProps) {
|
|||||||
<Svg width={22} height={22} viewBox="0 0 24 24">
|
<Svg width={22} height={22} viewBox="0 0 24 24">
|
||||||
<Path
|
<Path
|
||||||
d="M4 12.5l5 5 10-10"
|
d="M4 12.5l5 5 10-10"
|
||||||
stroke={theme.borderStrong}
|
stroke={theme.textFaint}
|
||||||
strokeWidth={2}
|
strokeWidth={2.2}
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
fill="none"
|
fill="none"
|
||||||
@@ -150,7 +150,7 @@ function SubtaskItem({ index, value, onChange, onRemove }: SubtaskItemProps) {
|
|||||||
/>
|
/>
|
||||||
<TouchableOpacity style={styles.removeButton} onPress={onRemove} activeOpacity={0.7}>
|
<TouchableOpacity style={styles.removeButton} onPress={onRemove} activeOpacity={0.7}>
|
||||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||||
<Path d="M18 6L6 18M6 6l12 12" stroke={theme.textMuted} strokeWidth={2} strokeLinecap="round" />
|
<Path d="M18 6L6 18M6 6l12 12" stroke={theme.textSecondary} strokeWidth={2.2} strokeLinecap="round" />
|
||||||
</Svg>
|
</Svg>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
@@ -159,35 +159,35 @@ function SubtaskItem({ index, value, onChange, onRemove }: SubtaskItemProps) {
|
|||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
gap: 8,
|
gap: 6,
|
||||||
},
|
},
|
||||||
header: {
|
header: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
paddingVertical: 4,
|
paddingVertical: 2,
|
||||||
},
|
},
|
||||||
headerLeft: {
|
headerLeft: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 8,
|
gap: 6,
|
||||||
},
|
},
|
||||||
label: {
|
label: {
|
||||||
fontSize: 14,
|
fontSize: 13,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
badge: {
|
badge: {
|
||||||
paddingHorizontal: 8,
|
paddingHorizontal: 6,
|
||||||
paddingVertical: 2,
|
paddingVertical: 1,
|
||||||
borderRadius: 10,
|
borderRadius: 8,
|
||||||
},
|
},
|
||||||
badgeText: {
|
badgeText: {
|
||||||
fontSize: 12,
|
fontSize: 11,
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
},
|
},
|
||||||
chevron: {
|
chevron: {
|
||||||
width: 16,
|
width: 14,
|
||||||
height: 16,
|
height: 14,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
@@ -195,43 +195,43 @@ const styles = StyleSheet.create({
|
|||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
list: {
|
list: {
|
||||||
gap: 8,
|
gap: 6,
|
||||||
paddingBottom: 8,
|
paddingBottom: 6,
|
||||||
},
|
},
|
||||||
item: {
|
item: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 10,
|
gap: 8,
|
||||||
paddingHorizontal: 12,
|
paddingHorizontal: 10,
|
||||||
paddingVertical: 10,
|
paddingVertical: 8,
|
||||||
borderRadius: 12,
|
borderRadius: 10,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
},
|
},
|
||||||
checkbox: {
|
checkbox: {
|
||||||
width: 22,
|
width: 20,
|
||||||
height: 22,
|
height: 20,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
input: {
|
input: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
fontSize: 15,
|
fontSize: 14,
|
||||||
paddingVertical: 4,
|
paddingVertical: 2,
|
||||||
},
|
},
|
||||||
removeButton: {
|
removeButton: {
|
||||||
padding: 4,
|
padding: 3,
|
||||||
},
|
},
|
||||||
addButton: {
|
addButton: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
gap: 6,
|
gap: 5,
|
||||||
paddingVertical: 10,
|
paddingVertical: 8,
|
||||||
borderRadius: 12,
|
borderRadius: 10,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
},
|
},
|
||||||
addButtonText: {
|
addButtonText: {
|
||||||
fontSize: 13,
|
fontSize: 12,
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -7,13 +7,11 @@ import {
|
|||||||
TextInput,
|
TextInput,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
KeyboardAvoidingView,
|
KeyboardAvoidingView,
|
||||||
Platform,
|
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { useSettings } from '@/theme';
|
import { useSettings } from '@/theme';
|
||||||
import { AuthUser, getAuthToken, getAuthUser, login, register, signOutAuth } from '@/services/auth';
|
import { AuthUser, getAuthToken, getAuthUser, login, register, signOutAuth } from '@/services/auth';
|
||||||
import { runSync, getLastSyncTime, SyncResult } from '@/database/sync';
|
import { runSyncGuarded, startAutoSync, stopAutoSync, getLastSyncTime, SyncResult } from '@/database/sync';
|
||||||
import Svg, { Path } from 'react-native-svg';
|
|
||||||
|
|
||||||
interface SyncModalProps {
|
interface SyncModalProps {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
@@ -35,14 +33,21 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
|
|||||||
const [status, setStatus] = useState<Status>('idle');
|
const [status, setStatus] = useState<Status>('idle');
|
||||||
const [lastSync, setLastSync] = useState<number | null>(null);
|
const [lastSync, setLastSync] = useState<number | null>(null);
|
||||||
const [result, setResult] = useState<SyncResult | null>(null);
|
const [result, setResult] = useState<SyncResult | null>(null);
|
||||||
|
const [prevVisible, setPrevVisible] = useState(visible);
|
||||||
|
|
||||||
useEffect(() => {
|
if (prevVisible !== visible) {
|
||||||
if (!visible) return;
|
setPrevVisible(visible);
|
||||||
let mounted = true;
|
if (visible) {
|
||||||
setChecking(true);
|
setChecking(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
setStatus('idle');
|
setStatus('idle');
|
||||||
setResult(null);
|
setResult(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible) return;
|
||||||
|
let mounted = true;
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
const token = await getAuthToken();
|
const token = await getAuthToken();
|
||||||
@@ -77,6 +82,7 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
|
|||||||
? await register(username.trim(), password)
|
? await register(username.trim(), password)
|
||||||
: await login(username.trim(), password);
|
: await login(username.trim(), password);
|
||||||
setUser(authedUser);
|
setUser(authedUser);
|
||||||
|
startAutoSync();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err?.message ?? 'Sign in failed');
|
setError(err?.message ?? 'Sign in failed');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -89,13 +95,15 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
|
|||||||
setError(null);
|
setError(null);
|
||||||
setStatus('syncing');
|
setStatus('syncing');
|
||||||
try {
|
try {
|
||||||
const syncResult = await runSync();
|
const syncResult = await runSyncGuarded();
|
||||||
setResult(syncResult);
|
setResult(syncResult);
|
||||||
setStatus('success');
|
setStatus('success');
|
||||||
setLastSync(Date.now());
|
setLastSync(Date.now());
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setStatus('error');
|
setStatus('idle');
|
||||||
if (err?.message === 'NOT_SIGNED_IN') {
|
if (err?.message === 'SYNC_IN_FLIGHT') {
|
||||||
|
setError('Sync already in progress');
|
||||||
|
} else if (err?.message === 'NOT_SIGNED_IN') {
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setError('Not signed in. Sign in to sync.');
|
setError('Not signed in. Sign in to sync.');
|
||||||
} else {
|
} else {
|
||||||
@@ -106,6 +114,7 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
|
|||||||
|
|
||||||
const handleSignOut = async () => {
|
const handleSignOut = async () => {
|
||||||
await signOutAuth();
|
await signOutAuth();
|
||||||
|
stopAutoSync();
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setError(null);
|
setError(null);
|
||||||
setStatus('idle');
|
setStatus('idle');
|
||||||
@@ -116,7 +125,7 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
|
|||||||
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
||||||
<KeyboardAvoidingView
|
<KeyboardAvoidingView
|
||||||
style={styles.overlay}
|
style={styles.overlay}
|
||||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
behavior="padding"
|
||||||
>
|
>
|
||||||
<TouchableOpacity style={styles.backdrop} activeOpacity={1} onPress={onClose} />
|
<TouchableOpacity style={styles.backdrop} activeOpacity={1} onPress={onClose} />
|
||||||
<View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}>
|
<View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}>
|
||||||
@@ -143,9 +152,9 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
|
|||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
>
|
>
|
||||||
{status === 'syncing' ? (
|
{status === 'syncing' ? (
|
||||||
<ActivityIndicator color="#FFFFFF" />
|
<ActivityIndicator color={theme.accentText} />
|
||||||
) : (
|
) : (
|
||||||
<Text style={styles.primaryButtonText}>Sync Now</Text>
|
<Text style={[styles.primaryButtonText, { color: theme.accentText }]}>Sync Now</Text>
|
||||||
)}
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
@@ -193,7 +202,7 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
|
|||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
styles.segmentText,
|
styles.segmentText,
|
||||||
{ color: mode === m ? '#FFFFFF' : theme.textSecondary },
|
{ color: mode === m ? theme.accentText : theme.textSecondary },
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{m === 'login' ? 'Sign In' : 'Create Account'}
|
{m === 'login' ? 'Sign In' : 'Create Account'}
|
||||||
@@ -234,9 +243,9 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
|
|||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
>
|
>
|
||||||
{busy ? (
|
{busy ? (
|
||||||
<ActivityIndicator color="#FFFFFF" />
|
<ActivityIndicator color={theme.accentText} />
|
||||||
) : (
|
) : (
|
||||||
<Text style={styles.primaryButtonText}>
|
<Text style={[styles.primaryButtonText, { color: theme.accentText }]}>
|
||||||
{mode === 'login' ? 'Sign In' : 'Create Account'}
|
{mode === 'login' ? 'Sign In' : 'Create Account'}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
@@ -340,7 +349,6 @@ const styles = StyleSheet.create({
|
|||||||
minHeight: 50,
|
minHeight: 50,
|
||||||
},
|
},
|
||||||
primaryButtonText: {
|
primaryButtonText: {
|
||||||
color: '#FFFFFF',
|
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react';
|
|||||||
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
|
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
|
||||||
import { useSettings } from '@/theme';
|
import { useSettings } from '@/theme';
|
||||||
import Svg, { Path, Circle } from 'react-native-svg';
|
import Svg, { Path, Circle } from 'react-native-svg';
|
||||||
import { runSync, getLastSyncTime } from '@/database/sync';
|
import { runSyncGuarded, getLastSyncTime } from '@/database/sync';
|
||||||
|
|
||||||
interface SyncStatusProps {
|
interface SyncStatusProps {
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
@@ -13,21 +13,23 @@ export default function SyncStatus({ compact = false }: SyncStatusProps) {
|
|||||||
const [status, setStatus] = useState<'idle' | 'syncing' | 'success' | 'error'>('idle');
|
const [status, setStatus] = useState<'idle' | 'syncing' | 'success' | 'error'>('idle');
|
||||||
const [lastSync, setLastSync] = useState<number | null>(null);
|
const [lastSync, setLastSync] = useState<number | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [now] = useState(() => Date.now());
|
||||||
useEffect(() => {
|
|
||||||
loadLastSync();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const loadLastSync = async () => {
|
const loadLastSync = async () => {
|
||||||
const time = await getLastSyncTime();
|
const time = await getLastSyncTime();
|
||||||
setLastSync(time);
|
setLastSync(time);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
loadLastSync();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleSync = async () => {
|
const handleSync = async () => {
|
||||||
setStatus('syncing');
|
setStatus('syncing');
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await runSync();
|
await runSyncGuarded();
|
||||||
setStatus('success');
|
setStatus('success');
|
||||||
await loadLastSync();
|
await loadLastSync();
|
||||||
setTimeout(() => setStatus('idle'), 3000);
|
setTimeout(() => setStatus('idle'), 3000);
|
||||||
@@ -40,7 +42,7 @@ export default function SyncStatus({ compact = false }: SyncStatusProps) {
|
|||||||
|
|
||||||
const formatTime = (timestamp: number | null): string => {
|
const formatTime = (timestamp: number | null): string => {
|
||||||
if (!timestamp) return 'Never';
|
if (!timestamp) return 'Never';
|
||||||
const diff = Date.now() - timestamp;
|
const diff = now - timestamp;
|
||||||
if (diff < 60000) return 'Just now';
|
if (diff < 60000) return 'Just now';
|
||||||
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
|
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
|
||||||
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
|
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
|
||||||
@@ -64,21 +66,21 @@ export default function SyncStatus({ compact = false }: SyncStatusProps) {
|
|||||||
case 'success':
|
case 'success':
|
||||||
return (
|
return (
|
||||||
<Svg width={16} height={16} viewBox="0 0 24 24">
|
<Svg width={16} height={16} viewBox="0 0 24 24">
|
||||||
<Path d="M5 12l5 5 9-10" stroke="#43A047" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
<Path d="M5 12l5 5 9-10" stroke="#66BB6A" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||||
</Svg>
|
</Svg>
|
||||||
);
|
);
|
||||||
case 'error':
|
case 'error':
|
||||||
return (
|
return (
|
||||||
<Svg width={16} height={16} viewBox="0 0 24 24">
|
<Svg width={16} height={16} viewBox="0 0 24 24">
|
||||||
<Circle cx={12} cy={12} r={10} stroke="#E53935" strokeWidth={2} fill="none" />
|
<Circle cx={12} cy={12} r={10} stroke="#EF5350" strokeWidth={2} fill="none" />
|
||||||
<Path d="M12 8v4M12 16h.01" stroke="#E53935" strokeWidth={2} strokeLinecap="round" />
|
<Path d="M12 8v4M12 16h.01" stroke="#EF5350" strokeWidth={2} strokeLinecap="round" />
|
||||||
</Svg>
|
</Svg>
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
<Svg width={16} height={16} viewBox="0 0 24 24">
|
<Svg width={16} height={16} viewBox="0 0 24 24">
|
||||||
<Path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" stroke={theme.textMuted} strokeWidth={1.5} fill="none" />
|
<Path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" stroke={theme.textFaint} strokeWidth={1.8} fill="none" />
|
||||||
<Path d="M12 22V12" stroke={theme.textMuted} strokeWidth={1.5} strokeLinecap="round" />
|
<Path d="M12 22V12" stroke={theme.textFaint} strokeWidth={1.8} strokeLinecap="round" />
|
||||||
</Svg>
|
</Svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -119,7 +121,7 @@ export default function SyncStatus({ compact = false }: SyncStatusProps) {
|
|||||||
disabled={status === 'syncing'}
|
disabled={status === 'syncing'}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
>
|
>
|
||||||
<Text style={[styles.syncButtonText, { color: status === 'syncing' ? theme.accent : '#FFFFFF' }]}>
|
<Text style={[styles.syncButtonText, { color: status === 'syncing' ? theme.accent : theme.accentText }]}>
|
||||||
{status === 'syncing' ? 'Syncing...' : 'Sync Now'}
|
{status === 'syncing' ? 'Syncing...' : 'Sync Now'}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { ColorValue } from 'react-native';
|
import { ColorValue } from 'react-native';
|
||||||
import Svg, { Path, Circle } from 'react-native-svg';
|
import Svg, { Path, Circle, Rect } from 'react-native-svg';
|
||||||
|
|
||||||
interface TabBarIconProps {
|
interface TabBarIconProps {
|
||||||
name: 'checklist' | 'calendar' | 'gear';
|
name: 'checklist' | 'calendar' | 'gear' | 'stats';
|
||||||
focused: boolean;
|
focused: boolean;
|
||||||
color: ColorValue;
|
color: ColorValue;
|
||||||
size?: number;
|
size?: number;
|
||||||
@@ -15,41 +15,71 @@ export function TabBarIcon({ name, focused, color, size = 24 }: TabBarIconProps)
|
|||||||
{name === 'checklist' && (
|
{name === 'checklist' && (
|
||||||
<>
|
<>
|
||||||
<Path
|
<Path
|
||||||
d="M3 5h18M3 12h18M3 19h18"
|
d="M8.5 6h12.5M8.5 12h12.5M8.5 18h12.5"
|
||||||
stroke={color}
|
stroke={color}
|
||||||
strokeWidth={focused ? 2.5 : 2}
|
strokeWidth={focused ? 3.5 : 3}
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
/>
|
/>
|
||||||
{focused && (
|
<Circle cx={4} cy={6} r={focused ? 1.75 : 1.5} fill={color} />
|
||||||
<Circle cx={6} cy={5} r={2} fill={color} />
|
<Circle cx={4} cy={12} r={focused ? 1.75 : 1.5} fill={color} />
|
||||||
)}
|
<Circle cx={4} cy={18} r={focused ? 1.75 : 1.5} fill={color} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{name === 'calendar' && (
|
{name === 'calendar' && (
|
||||||
<>
|
<>
|
||||||
<Path
|
<Path
|
||||||
d="M8 2v4M16 2v4M3 10h18M3 18h18"
|
d="M8 2v4M16 2v4M3 10h18"
|
||||||
stroke={color}
|
stroke={color}
|
||||||
strokeWidth={focused ? 2.5 : 2}
|
strokeWidth={focused ? 3.5 : 3}
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
/>
|
/>
|
||||||
<Path
|
<Rect
|
||||||
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"
|
x={3}
|
||||||
|
y={4}
|
||||||
|
width={18}
|
||||||
|
height={18}
|
||||||
|
rx={2}
|
||||||
stroke={color}
|
stroke={color}
|
||||||
strokeWidth={focused ? 2.5 : 2}
|
strokeWidth={focused ? 3.5 : 3}
|
||||||
|
fill="none"
|
||||||
|
/>
|
||||||
|
{focused && (
|
||||||
|
<>
|
||||||
|
<Circle cx={12} cy={16} r={1.5} fill={color} />
|
||||||
|
<Circle cx={7} cy={16} r={1.5} fill={color} />
|
||||||
|
<Circle cx={17} cy={16} r={1.5} fill={color} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{name === 'gear' && (
|
||||||
|
<>
|
||||||
|
<Circle cx={12} cy={12} r={3.5} stroke={color} strokeWidth={focused ? 3.5 : 3} fill="none" />
|
||||||
|
<Path
|
||||||
|
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={focused ? 3 : 2.5}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
fill="none"
|
fill="none"
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{name === 'gear' && (
|
{name === 'stats' && (
|
||||||
|
<>
|
||||||
<Path
|
<Path
|
||||||
d="M12 3.5a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5v-1zM11.3 4.5a.7.7 0 0 1 1.4 0l.9.9a.5.5 0 0 1-.7.7l-.9-.9a.7.7 0 0 1 0-1.4zM20.5 12a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1zM17.3 11.3a.7.7 0 0 1 0 1.4l-.9.9a.5.5 0 0 1-.7-.7l.9-.9a.7.7 0 0 1 1.4 0zM12 20.5a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1zM11.3 19.6a.7.7 0 0 1 1.4 0l.9.9a.5.5 0 0 1-.7.7l-.9-.9a.7.7 0 0 1 0-1.4zM3.5 12a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5v-1zM4.5 11.3a.7.7 0 0 1 1.4 0l.9.9a.5.5 0 0 1-.7.7l-.9-.9a.7.7 0 0 1 0-1.4zM12 7.5a4.5 4.5 0 1 0 0 9 4.5 4.5 0 0 0 0-9z"
|
d="M4 20V10M10 20V4M16 20v-7M21 20H3"
|
||||||
stroke={color}
|
stroke={color}
|
||||||
strokeWidth={focused ? 2.5 : 2}
|
strokeWidth={focused ? 3.5 : 3}
|
||||||
fill="none"
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
/>
|
/>
|
||||||
|
{focused && (
|
||||||
|
<Circle cx={4} cy={10} r={2} fill={color} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</Svg>
|
</Svg>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,16 +9,17 @@ interface TaskDeleteModalProps {
|
|||||||
taskId: string | null;
|
taskId: string | null;
|
||||||
taskTitle?: string;
|
taskTitle?: string;
|
||||||
isRepeating?: boolean;
|
isRepeating?: boolean;
|
||||||
|
subtask?: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onDelete: (scope: TaskDeleteScope) => void;
|
onDelete: (scope: TaskDeleteScope) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TaskDeleteModal({ visible, taskId, taskTitle, isRepeating, onClose, onDelete }: TaskDeleteModalProps) {
|
export function TaskDeleteModal({ visible, taskId, taskTitle, isRepeating, subtask, onClose, onDelete }: TaskDeleteModalProps) {
|
||||||
const { theme } = useSettings();
|
const { theme } = useSettings();
|
||||||
const [counts, setCounts] = useState({ future: 1, all: 1 });
|
const [counts, setCounts] = useState({ future: 1, all: 1 });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!visible || !taskId) return;
|
if (!visible || !taskId || subtask) return;
|
||||||
let mounted = true;
|
let mounted = true;
|
||||||
getSeriesOccurrenceCounts(taskId)
|
getSeriesOccurrenceCounts(taskId)
|
||||||
.then((c) => {
|
.then((c) => {
|
||||||
@@ -26,11 +27,11 @@ export function TaskDeleteModal({ visible, taskId, taskTitle, isRepeating, onClo
|
|||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
return () => { mounted = false; };
|
return () => { mounted = false; };
|
||||||
}, [visible, taskId]);
|
}, [visible, taskId, subtask]);
|
||||||
|
|
||||||
const options: Array<{ scope: TaskDeleteScope; label: string; hint?: string }> = [
|
const options: { scope: TaskDeleteScope; label: string; hint?: string }[] = subtask
|
||||||
{ scope: 'this', label: 'This task only' },
|
? [{ scope: 'this', label: 'This subtask only' }]
|
||||||
];
|
: [{ scope: 'this', label: 'This task only' }];
|
||||||
|
|
||||||
if (isRepeating) {
|
if (isRepeating) {
|
||||||
options.push({ scope: 'future', label: 'This and future tasks', hint: counts.future > 1 ? `${counts.future} occurrences` : undefined });
|
options.push({ scope: 'future', label: 'This and future tasks', hint: counts.future > 1 ? `${counts.future} occurrences` : undefined });
|
||||||
@@ -42,10 +43,10 @@ export function TaskDeleteModal({ visible, taskId, taskTitle, isRepeating, onClo
|
|||||||
<View style={styles.overlay}>
|
<View style={styles.overlay}>
|
||||||
<View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}>
|
<View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}>
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<Text style={[styles.title, { color: theme.text }]}>Delete Task</Text>
|
<Text style={[styles.title, { color: theme.text }]}>{subtask ? 'Delete Subtask' : 'Delete Task'}</Text>
|
||||||
<TouchableOpacity onPress={onClose} style={styles.closeButton} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
|
<TouchableOpacity onPress={onClose} style={styles.closeButton} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
|
||||||
<Svg width={18} height={18} viewBox="0 0 24 24">
|
<Svg width={18} height={18} viewBox="0 0 24 24">
|
||||||
<Path d="M18 6L6 18M6 6l12 12" stroke={theme.textMuted} strokeWidth={2} strokeLinecap="round" />
|
<Path d="M18 6L6 18M6 6l12 12" stroke={theme.textSecondary} strokeWidth={2.2} strokeLinecap="round" />
|
||||||
</Svg>
|
</Svg>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
@@ -70,7 +71,7 @@ export function TaskDeleteModal({ visible, taskId, taskTitle, isRepeating, onClo
|
|||||||
onPress={() => onDelete(option.scope)}
|
onPress={() => onDelete(option.scope)}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
<Text style={[styles.actionLabel, { color: theme.accent }]}>{option.label}</Text>
|
<Text style={[styles.actionLabel, { color: theme.accentStrong }]}>{option.label}</Text>
|
||||||
{option.hint && <Text style={[styles.actionHint, { color: theme.textMuted }]}>{option.hint}</Text>}
|
{option.hint && <Text style={[styles.actionHint, { color: theme.textMuted }]}>{option.hint}</Text>}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,13 +1,27 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { View, Text, StyleSheet, TouchableOpacity, Animated } from 'react-native';
|
import { View, Text, StyleSheet, TouchableOpacity, Animated, Platform, Dimensions } from 'react-native';
|
||||||
import { Swipeable, Gesture, GestureDetector } from 'react-native-gesture-handler';
|
import { Swipeable, Gesture, GestureDetector, PanGestureHandler } from 'react-native-gesture-handler';
|
||||||
import { TaskData } from '@/types';
|
import { TaskData, SubtaskData, Priority, Repeat, Reminder } from '@/types';
|
||||||
import { PRIORITY_COLORS } from '@/constants';
|
import { PRIORITY_COLORS } from '@/constants';
|
||||||
import { useSettings } from '@/theme';
|
import { useSettings } from '@/theme';
|
||||||
import Svg, { Path, Circle } from 'react-native-svg';
|
import Svg, { Path, Circle } from 'react-native-svg';
|
||||||
|
|
||||||
|
export type TaskLike = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
priority: Priority;
|
||||||
|
assigneeId: string | null;
|
||||||
|
repeat: Repeat;
|
||||||
|
allDay: boolean;
|
||||||
|
completed: boolean;
|
||||||
|
dueDate: number;
|
||||||
|
dueTime: string;
|
||||||
|
endTime: string;
|
||||||
|
reminder: Reminder;
|
||||||
|
};
|
||||||
|
|
||||||
interface TaskItemProps {
|
interface TaskItemProps {
|
||||||
task: TaskData;
|
task: TaskData | SubtaskData | TaskLike;
|
||||||
onToggle: () => void | Promise<void>;
|
onToggle: () => void | Promise<void>;
|
||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
@@ -21,16 +35,23 @@ interface TaskItemProps {
|
|||||||
onDragStart?: () => void;
|
onDragStart?: () => void;
|
||||||
onDragUpdate?: (absoluteY: number) => void;
|
onDragUpdate?: (absoluteY: number) => void;
|
||||||
onDragEnd?: (absoluteY: number) => void;
|
onDragEnd?: (absoluteY: number) => void;
|
||||||
assigneeUsername?: string;
|
onReorderStart?: () => void;
|
||||||
|
onReorderUpdate?: (absoluteY: number) => void;
|
||||||
|
onReorderEnd?: (absoluteY: number, translationY: number) => void;
|
||||||
expanded?: boolean;
|
expanded?: boolean;
|
||||||
|
indented?: boolean;
|
||||||
|
depth?: number;
|
||||||
|
categoryColor?: string;
|
||||||
|
categoryColors?: (string | undefined)[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TaskItem({ task, onToggle, onDelete, onPress, onLongPress, onMenuOpen, selected, selectionMode, completedSection, draggable, hovered, onDragStart, onDragUpdate, onDragEnd, expanded }: TaskItemProps) {
|
export const TaskItem = React.memo(function TaskItem({ task, onToggle, onDelete, onPress, onLongPress, onMenuOpen, selected, selectionMode, completedSection, draggable, hovered, onDragStart, onDragUpdate, onDragEnd, onReorderStart, onReorderUpdate, onReorderEnd, expanded, indented, depth = 0, categoryColor, categoryColors }: TaskItemProps) {
|
||||||
const { theme } = useSettings();
|
const { theme } = useSettings();
|
||||||
const [opacityAnim] = React.useState(new Animated.Value(task.completed ? 0.5 : 1));
|
const [opacityAnim] = React.useState(new Animated.Value(task.completed ? 0.5 : 1));
|
||||||
const [dragTranslateX] = React.useState(new Animated.Value(0));
|
const [dragTranslateX] = React.useState(new Animated.Value(0));
|
||||||
const [dragTranslateY] = React.useState(new Animated.Value(0));
|
const [dragTranslateY] = React.useState(new Animated.Value(0));
|
||||||
const [dragging, setDragging] = React.useState(false);
|
const [dragging, setDragging] = React.useState(false);
|
||||||
|
const [dragStartY, setDragStartY] = React.useState(0);
|
||||||
const swipeableRef = React.useRef<Swipeable>(null);
|
const swipeableRef = React.useRef<Swipeable>(null);
|
||||||
const [rotateAnim] = React.useState(new Animated.Value(0));
|
const [rotateAnim] = React.useState(new Animated.Value(0));
|
||||||
|
|
||||||
@@ -44,13 +65,40 @@ export function TaskItem({ task, onToggle, onDelete, onPress, onLongPress, onMen
|
|||||||
|
|
||||||
const rotate = rotateAnim.interpolate({ inputRange: [0, 1], outputRange: ['0deg', '180deg'] });
|
const rotate = rotateAnim.interpolate({ inputRange: [0, 1], outputRange: ['0deg', '180deg'] });
|
||||||
|
|
||||||
|
const reorderGesture = React.useMemo(
|
||||||
|
() =>
|
||||||
|
Gesture.Pan()
|
||||||
|
.activateAfterLongPress(400)
|
||||||
|
.minDistance(5)
|
||||||
|
.runOnJS(true)
|
||||||
|
.onStart((e) => {
|
||||||
|
setDragging(true);
|
||||||
|
setDragStartY(e.absoluteY);
|
||||||
|
onReorderStart?.();
|
||||||
|
})
|
||||||
|
.onUpdate((e) => {
|
||||||
|
dragTranslateX.setValue(e.translationX);
|
||||||
|
dragTranslateY.setValue(e.translationY);
|
||||||
|
onReorderUpdate?.(e.absoluteY);
|
||||||
|
})
|
||||||
|
.onEnd((e) => {
|
||||||
|
onReorderEnd?.(e.absoluteY, e.translationY);
|
||||||
|
})
|
||||||
|
.onFinalize(() => {
|
||||||
|
setDragging(false);
|
||||||
|
dragTranslateX.setValue(0);
|
||||||
|
dragTranslateY.setValue(0);
|
||||||
|
}),
|
||||||
|
[onReorderStart, onReorderUpdate, onReorderEnd, dragTranslateX, dragTranslateY]
|
||||||
|
);
|
||||||
|
|
||||||
const dragGesture = React.useMemo(
|
const dragGesture = React.useMemo(
|
||||||
() =>
|
() =>
|
||||||
Gesture.Pan()
|
Gesture.Pan()
|
||||||
.activateAfterLongPress(400)
|
.activateAfterLongPress(400)
|
||||||
.minDistance(2)
|
.minDistance(2)
|
||||||
.runOnJS(true)
|
.runOnJS(true)
|
||||||
. onStart(() => {
|
.onStart(() => {
|
||||||
setDragging(true);
|
setDragging(true);
|
||||||
onDragStart?.();
|
onDragStart?.();
|
||||||
})
|
})
|
||||||
@@ -78,12 +126,23 @@ export function TaskItem({ task, onToggle, onDelete, onPress, onLongPress, onMen
|
|||||||
}).start();
|
}).start();
|
||||||
}, [task.completed, opacityAnim]);
|
}, [task.completed, opacityAnim]);
|
||||||
|
|
||||||
|
const dueInfo = React.useMemo(() => {
|
||||||
const startOfToday = new Date();
|
const startOfToday = new Date();
|
||||||
startOfToday.setHours(0, 0, 0, 0);
|
startOfToday.setHours(0, 0, 0, 0);
|
||||||
|
const endOfToday = new Date();
|
||||||
|
endOfToday.setHours(23, 59, 59, 999);
|
||||||
const hasDueDate = task.dueDate > 0;
|
const hasDueDate = task.dueDate > 0;
|
||||||
const isOverdue = hasDueDate && !task.completed && task.dueDate < startOfToday.getTime();
|
const isOverdue = hasDueDate && !task.completed && task.dueDate < startOfToday.getTime();
|
||||||
const isDueToday = hasDueDate && !task.completed && task.dueDate >= startOfToday.getTime() && task.dueDate < new Date().setHours(23, 59, 59, 999);
|
const isDueToday = hasDueDate && !task.completed && task.dueDate >= startOfToday.getTime() && task.dueDate <= endOfToday.getTime();
|
||||||
const canComplete = !hasDueDate || isOverdue || isDueToday;
|
return { hasDueDate, isOverdue, isDueToday };
|
||||||
|
}, [task.dueDate, task.completed]);
|
||||||
|
|
||||||
|
const { hasDueDate, isOverdue, isDueToday } = dueInfo;
|
||||||
|
|
||||||
|
const formattedDueDate = React.useMemo(
|
||||||
|
() => formatDueDate(task.dueDate, task.dueTime, task.endTime || ''),
|
||||||
|
[task.dueDate, task.dueTime, task.endTime]
|
||||||
|
);
|
||||||
|
|
||||||
const renderRightActions = (progress: Animated.AnimatedInterpolation<number>) => {
|
const renderRightActions = (progress: Animated.AnimatedInterpolation<number>) => {
|
||||||
const translateX = progress.interpolate({ inputRange: [0, 1], outputRange: [80, 0] });
|
const translateX = progress.interpolate({ inputRange: [0, 1], outputRange: [80, 0] });
|
||||||
@@ -99,17 +158,6 @@ export function TaskItem({ task, onToggle, onDelete, onPress, onLongPress, onMen
|
|||||||
|
|
||||||
const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) => {
|
const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) => {
|
||||||
const translateX = progress.interpolate({ inputRange: [0, 1], outputRange: [-80, 0] });
|
const translateX = progress.interpolate({ inputRange: [0, 1], outputRange: [-80, 0] });
|
||||||
if (!canComplete && !task.completed) {
|
|
||||||
return (
|
|
||||||
<Animated.View style={[styles.swipeAction, styles.swipeComplete, { transform: [{ translateX }] }]}>
|
|
||||||
<Svg width={24} height={24} viewBox="0 0 24 24">
|
|
||||||
<Circle cx={12} cy={12} r={10} stroke="#FFFFFF" strokeWidth={2} fill="none" />
|
|
||||||
<Path d="M12 8v5M12 16.5v.5" stroke="#FFFFFF" strokeWidth={1.8} strokeLinecap="round" />
|
|
||||||
</Svg>
|
|
||||||
<Text style={styles.swipeActionText}>Not Due</Text>
|
|
||||||
</Animated.View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<Animated.View style={[styles.swipeAction, styles.swipeComplete, { transform: [{ translateX }] }]}>
|
<Animated.View style={[styles.swipeAction, styles.swipeComplete, { transform: [{ translateX }] }]}>
|
||||||
<Svg width={24} height={24} viewBox="0 0 24 24">
|
<Svg width={24} height={24} viewBox="0 0 24 24">
|
||||||
@@ -130,27 +178,26 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) =>
|
|||||||
swipeableRef.current?.close();
|
swipeableRef.current?.close();
|
||||||
}}
|
}}
|
||||||
onSwipeableLeftOpen={() => {
|
onSwipeableLeftOpen={() => {
|
||||||
if (canComplete || task.completed) {
|
|
||||||
onToggle();
|
onToggle();
|
||||||
}
|
|
||||||
swipeableRef.current?.close();
|
swipeableRef.current?.close();
|
||||||
}}
|
}}
|
||||||
overshootRight={false}
|
overshootRight={false}
|
||||||
overshootLeft={false}
|
overshootLeft={false}
|
||||||
>
|
>
|
||||||
<GestureDetector gesture={draggable ? dragGesture : Gesture.Native()}>
|
<GestureDetector gesture={draggable ? (onReorderStart ? reorderGesture : dragGesture) : Gesture.Native()}>
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={[
|
style={[
|
||||||
styles.container,
|
styles.container,
|
||||||
{ backgroundColor: theme.card, borderColor: theme.border },
|
{ backgroundColor: theme.card, borderColor: theme.border },
|
||||||
|
(indented || depth > 0) && { marginLeft: depth > 0 ? depth * 12 : 32, marginBottom: 4 },
|
||||||
task.completed && styles.taskCompleted,
|
task.completed && styles.taskCompleted,
|
||||||
isOverdue && styles.taskOverdue,
|
isOverdue && styles.taskOverdue,
|
||||||
isDueToday && styles.taskDueToday,
|
isDueToday && styles.taskDueToday,
|
||||||
selectionMode && styles.taskSelected,
|
selectionMode && styles.taskSelected,
|
||||||
selected && { borderColor: theme.accent, borderWidth: 2 },
|
selected && { borderColor: theme.accent, borderWidth: 2 },
|
||||||
hovered && { borderColor: theme.accent, borderWidth: 2, backgroundColor: theme.accentSoft },
|
|
||||||
completedSection && { backgroundColor: theme.cardAlt },
|
completedSection && { backgroundColor: theme.cardAlt },
|
||||||
dragging && styles.dragLifted,
|
dragging && styles.dragLifted,
|
||||||
|
hovered && styles.taskHovered,
|
||||||
{ transform: [{ translateX: dragTranslateX }, { translateY: dragTranslateY }] },
|
{ transform: [{ translateX: dragTranslateX }, { translateY: dragTranslateY }] },
|
||||||
]}
|
]}
|
||||||
pointerEvents={dragging ? 'none' : 'auto'}
|
pointerEvents={dragging ? 'none' : 'auto'}
|
||||||
@@ -161,23 +208,41 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) =>
|
|||||||
onLongPress={onLongPress}
|
onLongPress={onLongPress}
|
||||||
delayLongPress={350}
|
delayLongPress={350}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
|
accessibilityRole={selectionMode ? 'checkbox' : 'button'}
|
||||||
|
accessibilityLabel={selectionMode ? `Select ${task.title}` : task.title}
|
||||||
|
accessibilityState={selectionMode ? { checked: selected } : { expanded }}
|
||||||
|
accessibilityHint={selectionMode ? undefined : 'Expands the task to show subtasks'}
|
||||||
>
|
>
|
||||||
<View style={styles.content}>
|
<View style={styles.content}>
|
||||||
<View style={styles.titleRow}>
|
<View style={styles.titleRow}>
|
||||||
|
<View style={styles.categoryDotSlot}>
|
||||||
|
{(categoryColors ?? (categoryColor ? [categoryColor] : [])).slice(0, 3).map((color, i) => (
|
||||||
|
<View
|
||||||
|
key={`${color}-${i}`}
|
||||||
|
style={[
|
||||||
|
styles.categoryDot,
|
||||||
|
{ backgroundColor: color },
|
||||||
|
i > 0 && { marginLeft: -6 },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.checkCircle, !canComplete && !task.completed && styles.checkCircleDisabled]}
|
style={styles.checkCircle}
|
||||||
onPress={canComplete || task.completed ? onToggle : undefined}
|
onPress={onToggle}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
accessibilityLabel={task.completed ? 'Mark incomplete' : canComplete ? 'Mark complete' : 'Task not due yet'}
|
accessibilityRole="checkbox"
|
||||||
|
accessibilityLabel={task.completed ? 'Mark incomplete' : 'Mark complete'}
|
||||||
|
accessibilityState={{ checked: task.completed }}
|
||||||
>
|
>
|
||||||
<Svg width={24} height={24} viewBox="0 0 24 24">
|
<Svg width={30} height={30} viewBox="0 0 24 24">
|
||||||
{task.completed ? (
|
{task.completed ? (
|
||||||
<>
|
<>
|
||||||
<Circle cx={12} cy={12} r={10} fill={theme.accent} />
|
<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" />
|
<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={!canComplete ? theme.textFaint : isOverdue ? '#E53935' : theme.textMuted} strokeWidth={2} fill="none" />
|
<Circle cx={12} cy={12} r={10} stroke={isOverdue ? '#FF8A80' : theme.textSecondary} strokeWidth={2.5} fill="none" />
|
||||||
)}
|
)}
|
||||||
</Svg>
|
</Svg>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -215,8 +280,8 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) =>
|
|||||||
<Svg width={14} height={14} viewBox="0 0 24 24">
|
<Svg width={14} height={14} viewBox="0 0 24 24">
|
||||||
<Path
|
<Path
|
||||||
d="M17 2l4 4-4 4M3 11v-1a4 4 0 0 1 4-4h14M7 22l-4-4 4-4M21 13v1a4 4 0 0 1-4 4H3"
|
d="M17 2l4 4-4 4M3 11v-1a4 4 0 0 1 4-4h14M7 22l-4-4 4-4M21 13v1a4 4 0 0 1-4 4H3"
|
||||||
stroke={theme.textMuted}
|
stroke={theme.textSecondary}
|
||||||
strokeWidth={2}
|
strokeWidth={2.2}
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
fill="none"
|
fill="none"
|
||||||
@@ -230,7 +295,7 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) =>
|
|||||||
<Path
|
<Path
|
||||||
d="M12 2v2M12 20v2M2 12h2M20 12h2M5.6 5.6l1.4 1.4M17 17l1.4 1.4M5.6 18.4l1.4-1.4M17 7l1.4-1.4"
|
d="M12 2v2M12 20v2M2 12h2M20 12h2M5.6 5.6l1.4 1.4M17 17l1.4 1.4M5.6 18.4l1.4-1.4M17 7l1.4-1.4"
|
||||||
stroke={theme.accent}
|
stroke={theme.accent}
|
||||||
strokeWidth={1.5}
|
strokeWidth={1.8}
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
fill="none"
|
fill="none"
|
||||||
/>
|
/>
|
||||||
@@ -243,8 +308,8 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) =>
|
|||||||
<Svg width={16} height={16} viewBox="0 0 24 24">
|
<Svg width={16} height={16} viewBox="0 0 24 24">
|
||||||
<Path
|
<Path
|
||||||
d="M6 9l6 6 6-6"
|
d="M6 9l6 6 6-6"
|
||||||
stroke={theme.textMuted}
|
stroke={theme.textSecondary}
|
||||||
strokeWidth={2}
|
strokeWidth={2.5}
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
fill="none"
|
fill="none"
|
||||||
@@ -252,11 +317,11 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) =>
|
|||||||
</Svg>
|
</Svg>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</View>
|
</View>
|
||||||
{task.dueDate && (
|
{hasDueDate && (
|
||||||
<View style={styles.dueRow}>
|
<View style={styles.dueRow}>
|
||||||
<Svg width={14} height={14} viewBox="0 0 24 24">
|
<Svg width={14} height={14} viewBox="0 0 24 24">
|
||||||
<Circle cx={12} cy={12} r={10} stroke={theme.textMuted} strokeWidth={1.5} fill="none" />
|
<Circle cx={12} cy={12} r={10} stroke={theme.textSecondary} strokeWidth={2} fill="none" />
|
||||||
<Path d="M12 6v6l4 2" stroke={theme.textMuted} strokeWidth={1.5} strokeLinecap="round" />
|
<Path d="M12 6v6l4 2" stroke={theme.textSecondary} strokeWidth={2} strokeLinecap="round" />
|
||||||
</Svg>
|
</Svg>
|
||||||
<Animated.Text
|
<Animated.Text
|
||||||
style={[
|
style={[
|
||||||
@@ -266,15 +331,15 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) =>
|
|||||||
isDueToday && styles.dueTextDueToday,
|
isDueToday && styles.dueTextDueToday,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{formatDueDate(task.dueDate, task.dueTime, task.endTime || '')}
|
{formattedDueDate}
|
||||||
</Animated.Text>
|
</Animated.Text>
|
||||||
{task.reminder && task.reminder !== 'none' && (
|
{task.reminder && task.reminder !== 'none' && (
|
||||||
<View style={styles.reminderIcon}>
|
<View style={styles.reminderIcon}>
|
||||||
<Svg width={13} height={13} viewBox="0 0 24 24">
|
<Svg width={13} height={13} viewBox="0 0 24 24">
|
||||||
<Path
|
<Path
|
||||||
d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9M10.3 21a1.94 1.94 0 0 0 3.4 0"
|
d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9M10.3 21a1.94 1.94 0 0 0 3.4 0"
|
||||||
stroke={theme.textFaint}
|
stroke={theme.textSecondary}
|
||||||
strokeWidth={1.6}
|
strokeWidth={1.8}
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
fill="none"
|
fill="none"
|
||||||
@@ -291,6 +356,10 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) =>
|
|||||||
style={styles.menuButton}
|
style={styles.menuButton}
|
||||||
onPress={onMenuOpen}
|
onPress={onMenuOpen}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={`Edit ${task.title}`}
|
||||||
|
accessibilityHint="Opens the task editor"
|
||||||
|
hitSlop={8}
|
||||||
>
|
>
|
||||||
<Svg width={24} height={24} viewBox="0 0 24 24">
|
<Svg width={24} height={24} viewBox="0 0 24 24">
|
||||||
<Circle cx={12} cy={12} r={1.5} fill={theme.textMuted} />
|
<Circle cx={12} cy={12} r={1.5} fill={theme.textMuted} />
|
||||||
@@ -302,7 +371,7 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) =>
|
|||||||
</GestureDetector>
|
</GestureDetector>
|
||||||
</Swipeable>
|
</Swipeable>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
function formatDueDate(dueDate: number, dueTime: string, endTime?: string): string {
|
function formatDueDate(dueDate: number, dueTime: string, endTime?: string): string {
|
||||||
const date = new Date(dueDate);
|
const date = new Date(dueDate);
|
||||||
@@ -338,24 +407,29 @@ const styles = StyleSheet.create({
|
|||||||
container: {
|
container: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 12,
|
||||||
paddingVertical: 14,
|
paddingVertical: 14,
|
||||||
borderRadius: 16,
|
borderRadius: 16,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
|
marginVertical: 4,
|
||||||
shadowColor: '#000',
|
shadowColor: '#000',
|
||||||
shadowOffset: { width: 0, height: 1 },
|
shadowOffset: { width: 0, height: 1 },
|
||||||
shadowOpacity: 0.04,
|
shadowOpacity: 0.05,
|
||||||
shadowRadius: 4,
|
shadowRadius: 6,
|
||||||
elevation: 1,
|
elevation: 1,
|
||||||
},
|
},
|
||||||
taskCompleted: {
|
taskCompleted: {
|
||||||
opacity: 0.5,
|
opacity: 0.5,
|
||||||
},
|
},
|
||||||
|
indented: {
|
||||||
|
marginLeft: 32,
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
taskOverdue: {
|
taskOverdue: {
|
||||||
borderColor: '#4A2B2B',
|
borderColor: '#573431',
|
||||||
},
|
},
|
||||||
taskDueToday: {
|
taskDueToday: {
|
||||||
borderColor: '#1E88E5',
|
borderColor: '#2C4766',
|
||||||
},
|
},
|
||||||
taskSelected: {
|
taskSelected: {
|
||||||
shadowColor: '#000',
|
shadowColor: '#000',
|
||||||
@@ -363,6 +437,10 @@ const styles = StyleSheet.create({
|
|||||||
shadowRadius: 6,
|
shadowRadius: 6,
|
||||||
elevation: 2,
|
elevation: 2,
|
||||||
},
|
},
|
||||||
|
taskHovered: {
|
||||||
|
borderColor: '#2C4766',
|
||||||
|
backgroundColor: 'rgba(44, 71, 102, 0.15)',
|
||||||
|
},
|
||||||
dragLifted: {
|
dragLifted: {
|
||||||
zIndex: 100,
|
zIndex: 100,
|
||||||
elevation: 12,
|
elevation: 12,
|
||||||
@@ -375,18 +453,38 @@ const styles = StyleSheet.create({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
content: {
|
content: {
|
||||||
gap: 4,
|
gap: 12,
|
||||||
},
|
},
|
||||||
titleRow: {
|
titleRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
},
|
},
|
||||||
|
dragHandle: {
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: 14,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginRight: 8,
|
||||||
|
},
|
||||||
|
categoryDotSlot: {
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginRight: 8,
|
||||||
|
},
|
||||||
|
categoryDot: {
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
borderRadius: 5,
|
||||||
|
},
|
||||||
title: {
|
title: {
|
||||||
fontSize: 16,
|
fontSize: 17,
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
marginRight: 8,
|
marginRight: 16,
|
||||||
},
|
},
|
||||||
titleCompleted: {
|
titleCompleted: {
|
||||||
textDecorationLine: 'line-through',
|
textDecorationLine: 'line-through',
|
||||||
@@ -414,7 +512,7 @@ const styles = StyleSheet.create({
|
|||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
titleOverdue: {
|
titleOverdue: {
|
||||||
color: '#E53935',
|
color: '#E57373',
|
||||||
},
|
},
|
||||||
priorityBadge: {
|
priorityBadge: {
|
||||||
paddingHorizontal: 6,
|
paddingHorizontal: 6,
|
||||||
@@ -432,29 +530,29 @@ const styles = StyleSheet.create({
|
|||||||
width: 20,
|
width: 20,
|
||||||
height: 20,
|
height: 20,
|
||||||
borderRadius: 10,
|
borderRadius: 10,
|
||||||
backgroundColor: '#4A2B2B',
|
backgroundColor: '#43302E',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
assigneeText: {
|
assigneeText: {
|
||||||
fontSize: 9,
|
fontSize: 9,
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
color: '#EF5350',
|
color: '#FF9C8F',
|
||||||
},
|
},
|
||||||
dueRow: {
|
dueRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 4,
|
gap: 8,
|
||||||
},
|
},
|
||||||
dueText: {
|
dueText: {
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
},
|
},
|
||||||
dueTextOverdue: {
|
dueTextOverdue: {
|
||||||
color: '#E53935',
|
color: '#E57373',
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
dueTextDueToday: {
|
dueTextDueToday: {
|
||||||
color: '#1E88E5',
|
color: '#64B5F6',
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
menuButton: {
|
menuButton: {
|
||||||
@@ -469,24 +567,21 @@ const styles = StyleSheet.create({
|
|||||||
paddingHorizontal: 6,
|
paddingHorizontal: 6,
|
||||||
paddingVertical: 2,
|
paddingVertical: 2,
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
backgroundColor: 'rgba(30, 136, 229, 0.15)',
|
backgroundColor: 'rgba(100, 181, 246, 0.15)',
|
||||||
},
|
},
|
||||||
allDayText: {
|
allDayText: {
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
color: '#1E88E5',
|
color: '#64B5F6',
|
||||||
},
|
},
|
||||||
checkCircle: {
|
checkCircle: {
|
||||||
width: 24,
|
width: 30,
|
||||||
height: 24,
|
height: 30,
|
||||||
borderRadius: 12,
|
borderRadius: 15,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
marginRight: 12,
|
marginRight: 12,
|
||||||
},
|
},
|
||||||
checkCircleDisabled: {
|
|
||||||
opacity: 0.4,
|
|
||||||
},
|
|
||||||
swipeAction: {
|
swipeAction: {
|
||||||
width: 80,
|
width: 80,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -494,12 +589,12 @@ const styles = StyleSheet.create({
|
|||||||
gap: 4,
|
gap: 4,
|
||||||
},
|
},
|
||||||
swipeDelete: {
|
swipeDelete: {
|
||||||
backgroundColor: '#E53935',
|
backgroundColor: '#B8504A',
|
||||||
borderTopRightRadius: 16,
|
borderTopRightRadius: 16,
|
||||||
borderBottomRightRadius: 16,
|
borderBottomRightRadius: 16,
|
||||||
},
|
},
|
||||||
swipeComplete: {
|
swipeComplete: {
|
||||||
backgroundColor: '#43A047',
|
backgroundColor: '#3D7A4A',
|
||||||
borderTopLeftRadius: 16,
|
borderTopLeftRadius: 16,
|
||||||
borderBottomLeftRadius: 16,
|
borderBottomLeftRadius: 16,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,60 +1,99 @@
|
|||||||
import React, { useState, useCallback, useMemo, useRef, useEffect } from 'react';
|
import React, { useState, useCallback, useMemo, useRef } from 'react';
|
||||||
import { View, Text, StyleSheet, FlatList, TouchableOpacity, Animated, RefreshControl, Alert } from 'react-native';
|
import { View, Text, StyleSheet, Animated, RefreshControl, Alert, TouchableOpacity } from 'react-native';
|
||||||
import { useRouter } from 'expo-router';
|
|
||||||
import { useTasks } from '@/hooks/useTasks';
|
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 { TaskItem } from './TaskItem';
|
||||||
import { SubtaskItem } from './SubtaskItem';
|
import { SubtaskItem } from './SubtaskItem';
|
||||||
import { TaskData, SubtaskData } from '@/types';
|
import { TaskData, SubtaskData, parseTaskTags } from '@/types';
|
||||||
import { useCategories, useDatabase } from '@/hooks/useDatabase';
|
import Task from '@/models/Task';
|
||||||
import { useSettings } from '@/theme';
|
import { useSettings } from '@/theme';
|
||||||
import { OptionPickerModal } from './OptionPickerModal';
|
|
||||||
import { TaskOverflowMenu } from './TaskOverflowMenu';
|
|
||||||
import { TaskDeleteModal } from './TaskDeleteModal';
|
|
||||||
import {
|
import {
|
||||||
toggleTaskComplete,
|
toggleTaskComplete,
|
||||||
deleteTask,
|
deleteTask,
|
||||||
duplicateTask,
|
|
||||||
setTaskCategory,
|
|
||||||
setTaskPriority,
|
|
||||||
setTaskCompleted,
|
setTaskCompleted,
|
||||||
deleteTaskOccurrences,
|
|
||||||
convertTaskToSubtask,
|
convertTaskToSubtask,
|
||||||
|
convertSubtaskToTask,
|
||||||
|
moveSubtaskToTask,
|
||||||
|
setSubtaskParent,
|
||||||
toggleSubtaskComplete,
|
toggleSubtaskComplete,
|
||||||
TaskDeleteScope,
|
|
||||||
} from '@/utils/taskActions';
|
} from '@/utils/taskActions';
|
||||||
import { PRIORITY_LABELS } from '@/constants';
|
|
||||||
import { Q } from '@nozbe/watermelondb';
|
|
||||||
import Svg, { Path } from 'react-native-svg';
|
import Svg, { Path } from 'react-native-svg';
|
||||||
|
|
||||||
interface TaskListProps {
|
interface TaskListProps {
|
||||||
categoryId?: string;
|
categoryIds?: string[];
|
||||||
|
showCompleted?: boolean;
|
||||||
onSelectionChange?: (active: boolean) => void;
|
onSelectionChange?: (active: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PRIORITY_RANK: Record<string, number> = { none: 0, low: 1, medium: 2, high: 3, critical: 4 };
|
const PRIORITY_RANK: Record<string, number> = { none: 0, low: 1, medium: 2, high: 3, critical: 4 };
|
||||||
|
|
||||||
export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProps) {
|
const SEPARATOR = () => <View style={styles.separator} />;
|
||||||
const router = useRouter();
|
const MemoSeparator = React.memo(SEPARATOR);
|
||||||
const { theme, sortBy } = useSettings();
|
|
||||||
const { collections } = useDatabase();
|
|
||||||
const categories = useCategories();
|
|
||||||
|
|
||||||
const { tasks, loading } = useTasks(categoryId, false);
|
const DropIndicator = ({ theme }: { theme: any }) => (
|
||||||
const { tasks: completedTasks } = useTasks(categoryId, true);
|
<View style={styles.dropIndicatorContainer}>
|
||||||
|
<View style={[styles.dropIndicator, { backgroundColor: theme.accent }]} />
|
||||||
|
<View style={[styles.dropIndicatorDot, { backgroundColor: theme.accent }]} />
|
||||||
|
<View style={[styles.dropIndicatorDot, { backgroundColor: theme.accent }]} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
export function TaskList({ categoryIds = [], showCompleted = false, onSelectionChange }: TaskListProps) {
|
||||||
|
const { theme, sortBy, todoAheadDays } = useSettings();
|
||||||
|
|
||||||
|
const { tasks, loading, refresh: refreshTasks } = useTasks(categoryIds, showCompleted ? 'all' : false, todoAheadDays);
|
||||||
|
|
||||||
|
const categories = useCategories();
|
||||||
|
const categoryColors = useMemo(() => {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
for (const c of categories) {
|
||||||
|
map.set(c.id, c.color);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [categories]);
|
||||||
|
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
const [selectionMode, setSelectionMode] = useState(false);
|
const [selectionMode, setSelectionMode] = useState(false);
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||||
const [menuTaskId, setMenuTaskId] = useState<string | null>(null);
|
|
||||||
const [deleteTaskId, setDeleteTaskId] = useState<string | null>(null);
|
|
||||||
const [picker, setPicker] = useState<null | { type: 'category' | 'priority'; taskId?: string }>(null);
|
|
||||||
const [hoverTaskId, setHoverTaskId] = useState<string | null>(null);
|
const [hoverTaskId, setHoverTaskId] = useState<string | null>(null);
|
||||||
const [expandedTasks, setExpandedTasks] = useState<Set<string>>(new Set());
|
const [expandedTasks, setExpandedTasks] = useState<Set<string>>(new Set());
|
||||||
const [subtasksMap, setSubtasksMap] = useState<Map<string, SubtaskData[]>>(new Map());
|
const { map: subtasksMap, refresh: refreshSubtasks } = useSubtasks();
|
||||||
|
const [dropIndicator, setDropIndicator] = useState<{ targetId: string | null; position: 'above' | 'below' } | null>(null);
|
||||||
const itemRefs = useRef<Map<string, View>>(new Map());
|
const itemRefs = useRef<Map<string, View>>(new Map());
|
||||||
const dragStateRef = useRef<{ taskId: string; positions: Record<string, { top: number; bottom: number }> } | null>(null);
|
const subtaskRefs = useRef<Map<string, { ref: View; parentTaskId: string }>>(new Map());
|
||||||
|
const dragStateRef = useRef<{ taskId: string; positions: Record<string, { top: number; bottom: number }>; subPositions: Record<string, { top: number; bottom: number }> } | null>(null);
|
||||||
|
const subtaskDragRef = useRef<{ subtaskId: string; parentTaskId: string; positions: Record<string, { top: number; bottom: number }>; subPositions: Record<string, { top: number; bottom: number }> } | null>(null);
|
||||||
|
const {
|
||||||
|
modals,
|
||||||
|
openTaskDelete,
|
||||||
|
openSubtaskDelete,
|
||||||
|
openSubtaskEdit,
|
||||||
|
openTaskEdit,
|
||||||
|
} = useTaskModals();
|
||||||
|
|
||||||
const allTasks = useMemo(() => [...tasks, ...completedTasks], [tasks, completedTasks]);
|
const registerRef = useCallback((taskId: string, ref: View | null) => {
|
||||||
|
if (ref) {
|
||||||
|
itemRefs.current.set(taskId, ref);
|
||||||
|
} else {
|
||||||
|
itemRefs.current.delete(taskId);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const registerSubtaskRef = useCallback((subtaskId: string, parentTaskId: string, ref: View | null) => {
|
||||||
|
if (ref) {
|
||||||
|
subtaskRefs.current.set(subtaskId, { ref, parentTaskId });
|
||||||
|
} else {
|
||||||
|
subtaskRefs.current.delete(subtaskId);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const categoryColorResolver = useCallback(
|
||||||
|
(categoryId: string | undefined) => (categoryId ? categoryColors.get(categoryId) : undefined),
|
||||||
|
[categoryColors]
|
||||||
|
);
|
||||||
|
|
||||||
const sortedTasks = useMemo(() => {
|
const sortedTasks = useMemo(() => {
|
||||||
const sorted = [...tasks];
|
const sorted = [...tasks];
|
||||||
@@ -72,23 +111,27 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
|
|||||||
sorted.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
|
sorted.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
return sorted;
|
const active: typeof sorted = [];
|
||||||
|
const done: typeof sorted = [];
|
||||||
|
for (const t of sorted) {
|
||||||
|
(t.completed ? done : active).push(t);
|
||||||
|
}
|
||||||
|
return [...active, ...done];
|
||||||
}, [tasks, sortBy]);
|
}, [tasks, sortBy]);
|
||||||
|
|
||||||
const menuTask = useMemo(
|
useFocusEffect(
|
||||||
() => allTasks.find((t) => t.id === menuTaskId) ?? null,
|
useCallback(() => {
|
||||||
[allTasks, menuTaskId]
|
refreshTasks();
|
||||||
);
|
refreshSubtasks();
|
||||||
|
}, [refreshTasks, refreshSubtasks])
|
||||||
const deleteTarget = useMemo(
|
|
||||||
() => allTasks.find((t) => t.id === deleteTaskId) ?? null,
|
|
||||||
[allTasks, deleteTaskId]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const onRefresh = useCallback(() => {
|
const onRefresh = useCallback(() => {
|
||||||
setRefreshing(true);
|
setRefreshing(true);
|
||||||
|
refreshTasks();
|
||||||
|
refreshSubtasks();
|
||||||
setTimeout(() => setRefreshing(false), 600);
|
setTimeout(() => setRefreshing(false), 600);
|
||||||
}, []);
|
}, [refreshTasks, refreshSubtasks]);
|
||||||
|
|
||||||
const exitSelection = useCallback(() => {
|
const exitSelection = useCallback(() => {
|
||||||
setSelectionMode(false);
|
setSelectionMode(false);
|
||||||
@@ -118,41 +161,10 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
|
|||||||
});
|
});
|
||||||
}, [onSelectionChange]);
|
}, [onSelectionChange]);
|
||||||
|
|
||||||
const refreshAll = useCallback(() => {}, []);
|
|
||||||
|
|
||||||
const handleEdit = useCallback((taskId: string) => {
|
|
||||||
router.push({ pathname: '/task-detail', params: { id: taskId } });
|
|
||||||
}, [router]);
|
|
||||||
|
|
||||||
const handleToggle = useCallback(async (taskId: string) => {
|
const handleToggle = useCallback(async (taskId: string) => {
|
||||||
await toggleTaskComplete(taskId);
|
await toggleTaskComplete(taskId);
|
||||||
refreshAll();
|
refreshTasks();
|
||||||
}, [refreshAll]);
|
}, [refreshTasks]);
|
||||||
|
|
||||||
const fetchSubtasks = useCallback(async (taskId: string) => {
|
|
||||||
const subs = await collections.subtasks.query(Q.where('task_id', taskId)).fetch();
|
|
||||||
const mapped: SubtaskData[] = subs.map((s: any) => ({
|
|
||||||
id: s.id,
|
|
||||||
taskId: s.taskId,
|
|
||||||
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,
|
|
||||||
}));
|
|
||||||
setSubtasksMap((prev) => new Map(prev).set(taskId, mapped));
|
|
||||||
return mapped;
|
|
||||||
}, [collections.subtasks]);
|
|
||||||
|
|
||||||
const toggleExpand = useCallback(async (taskId: string) => {
|
const toggleExpand = useCallback(async (taskId: string) => {
|
||||||
setExpandedTasks((prev) => {
|
setExpandedTasks((prev) => {
|
||||||
@@ -164,40 +176,13 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
|
|||||||
}
|
}
|
||||||
return next;
|
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) => {
|
|
||||||
await toggleSubtaskComplete(subtaskId);
|
|
||||||
await fetchSubtasks(taskId);
|
|
||||||
}, [fetchSubtasks]);
|
|
||||||
|
|
||||||
const handleDeleteOne = useCallback((taskId: string) => {
|
|
||||||
setDeleteTaskId(taskId);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleDeleteScope = useCallback(async (scope: TaskDeleteScope) => {
|
const handleSubtaskToggle = useCallback(async (subtaskId: string) => {
|
||||||
const taskId = deleteTaskId;
|
await toggleSubtaskComplete(subtaskId);
|
||||||
if (!taskId) return;
|
refreshTasks();
|
||||||
setDeleteTaskId(null);
|
refreshSubtasks();
|
||||||
await deleteTaskOccurrences(taskId, scope);
|
}, [refreshTasks, refreshSubtasks]);
|
||||||
refreshAll();
|
|
||||||
}, [deleteTaskId, refreshAll]);
|
|
||||||
|
|
||||||
const handleDuplicate = useCallback(async (taskId: string) => {
|
|
||||||
await duplicateTask(taskId);
|
|
||||||
refreshAll();
|
|
||||||
}, [refreshAll]);
|
|
||||||
|
|
||||||
const handleBulkDelete = useCallback(() => {
|
const handleBulkDelete = useCallback(() => {
|
||||||
Alert.alert(`Delete ${selectedIds.size} task${selectedIds.size > 1 ? 's' : ''}?`, 'This cannot be undone.', [
|
Alert.alert(`Delete ${selectedIds.size} task${selectedIds.size > 1 ? 's' : ''}?`, 'This cannot be undone.', [
|
||||||
@@ -206,37 +191,17 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
|
|||||||
text: 'Delete',
|
text: 'Delete',
|
||||||
style: 'destructive',
|
style: 'destructive',
|
||||||
onPress: async () => {
|
onPress: async () => {
|
||||||
for (const taskId of selectedIds) {
|
await Promise.all(Array.from(selectedIds).map(taskId => deleteTask(taskId)));
|
||||||
await deleteTask(taskId);
|
|
||||||
}
|
|
||||||
exitSelection();
|
exitSelection();
|
||||||
refreshAll();
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
}, [selectedIds, exitSelection, refreshAll]);
|
}, [selectedIds, exitSelection]);
|
||||||
|
|
||||||
const handleBulkComplete = useCallback(async () => {
|
const handleBulkComplete = useCallback(async () => {
|
||||||
for (const taskId of selectedIds) {
|
await Promise.all(Array.from(selectedIds).map(taskId => setTaskCompleted(taskId, true)));
|
||||||
await setTaskCompleted(taskId, true);
|
|
||||||
}
|
|
||||||
exitSelection();
|
exitSelection();
|
||||||
refreshAll();
|
}, [selectedIds, exitSelection]);
|
||||||
}, [selectedIds, exitSelection, refreshAll]);
|
|
||||||
|
|
||||||
const handleSingleCategory = useCallback(async (value: string) => {
|
|
||||||
if (picker?.taskId) {
|
|
||||||
await setTaskCategory(picker.taskId, value);
|
|
||||||
refreshAll();
|
|
||||||
}
|
|
||||||
}, [picker, refreshAll]);
|
|
||||||
|
|
||||||
const handleSinglePriority = useCallback(async (value: string) => {
|
|
||||||
if (picker?.taskId) {
|
|
||||||
await setTaskPriority(picker.taskId, value as TaskData['priority']);
|
|
||||||
refreshAll();
|
|
||||||
}
|
|
||||||
}, [picker, refreshAll]);
|
|
||||||
|
|
||||||
const measureItems = useCallback(async () => {
|
const measureItems = useCallback(async () => {
|
||||||
const positions: Record<string, { top: number; bottom: number }> = {};
|
const positions: Record<string, { top: number; bottom: number }> = {};
|
||||||
@@ -252,7 +217,30 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
|
|||||||
return positions;
|
return positions;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const findHoverTarget = useCallback((absoluteY: number, draggedId: string, positions: Record<string, { top: number; bottom: number }>) => {
|
const measureAll = useCallback(async () => {
|
||||||
|
const positions = await measureItems();
|
||||||
|
const subPositions: Record<string, { top: number; bottom: number }> = {};
|
||||||
|
const entries = Array.from(subtaskRefs.current.entries());
|
||||||
|
await Promise.all(entries.map(([id, entry]) => {
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
entry.ref?.measureInWindow((_x, y, _w, h) => {
|
||||||
|
subPositions[id] = { top: y, bottom: y + h };
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
return { positions, subPositions };
|
||||||
|
}, [measureItems]);
|
||||||
|
|
||||||
|
const findHoverTarget = useCallback((absoluteY: number, draggedId: string, positions: Record<string, { top: number; bottom: number }>, subPositions: Record<string, { top: number; bottom: number }>) => {
|
||||||
|
const subEntries = Object.entries(subPositions);
|
||||||
|
for (let i = subEntries.length - 1; i >= 0; i--) {
|
||||||
|
const [id, p] = subEntries[i];
|
||||||
|
if (id === draggedId) continue;
|
||||||
|
if (absoluteY >= p.top && absoluteY <= p.bottom) {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
}
|
||||||
for (const [id, p] of Object.entries(positions)) {
|
for (const [id, p] of Object.entries(positions)) {
|
||||||
if (id === draggedId) continue;
|
if (id === draggedId) continue;
|
||||||
if (absoluteY >= p.top && absoluteY <= p.bottom) {
|
if (absoluteY >= p.top && absoluteY <= p.bottom) {
|
||||||
@@ -262,33 +250,224 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
|
|||||||
return null;
|
return null;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const calculateDropPosition = useCallback((absoluteY: number, targetId: string, positions: Record<string, { top: number; bottom: number }>) => {
|
||||||
|
const target = positions[targetId];
|
||||||
|
if (!target) return 'below' as const;
|
||||||
|
const middle = (target.top + target.bottom) / 2;
|
||||||
|
return absoluteY < middle ? 'above' : 'below';
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleDragStart = useCallback(async (taskId: string) => {
|
const handleDragStart = useCallback(async (taskId: string) => {
|
||||||
enterSelection(taskId);
|
dragStateRef.current = { taskId, ...(await measureAll()) };
|
||||||
dragStateRef.current = { taskId, positions: await measureItems() };
|
}, [measureAll]);
|
||||||
}, [enterSelection, measureItems]);
|
|
||||||
|
const refreshDragPositions = useCallback(() => {
|
||||||
|
measureAll().then((m) => {
|
||||||
|
if (subtaskDragRef.current) {
|
||||||
|
subtaskDragRef.current = { ...subtaskDragRef.current, ...m };
|
||||||
|
} else if (dragStateRef.current) {
|
||||||
|
dragStateRef.current = { ...dragStateRef.current, ...m };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [measureAll]);
|
||||||
|
|
||||||
|
const updateHoverTarget = useCallback((target: string | null) => {
|
||||||
|
let changed = false;
|
||||||
|
setHoverTaskId((prev) => {
|
||||||
|
if (prev === target) return prev;
|
||||||
|
changed = true;
|
||||||
|
return target;
|
||||||
|
});
|
||||||
|
if (changed && target) {
|
||||||
|
setTimeout(refreshDragPositions, 80);
|
||||||
|
}
|
||||||
|
}, [refreshDragPositions]);
|
||||||
|
|
||||||
const handleDragUpdate = useCallback((absoluteY: number) => {
|
const handleDragUpdate = useCallback((absoluteY: number) => {
|
||||||
const state = dragStateRef.current;
|
const state = dragStateRef.current;
|
||||||
if (!state) return;
|
if (!state) return;
|
||||||
const target = findHoverTarget(absoluteY, state.taskId, state.positions);
|
const target = findHoverTarget(absoluteY, state.taskId, state.positions, state.subPositions);
|
||||||
setHoverTaskId((prev) => (prev === target ? prev : target));
|
updateHoverTarget(target);
|
||||||
}, [findHoverTarget]);
|
if (target) {
|
||||||
|
if (state.subPositions[target]) {
|
||||||
|
setDropIndicator(null);
|
||||||
|
} else {
|
||||||
|
const position = calculateDropPosition(absoluteY, target, state.positions);
|
||||||
|
setDropIndicator({ targetId: target, position });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Check if below last item
|
||||||
|
const positions = state.positions;
|
||||||
|
const lastItem = Object.values(positions).reduce((max, p) => p.bottom > max.bottom ? p : max, { bottom: 0 });
|
||||||
|
if (absoluteY > lastItem.bottom) {
|
||||||
|
setDropIndicator({ targetId: null, position: 'below' });
|
||||||
|
} else {
|
||||||
|
setDropIndicator(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [findHoverTarget, calculateDropPosition, updateHoverTarget]);
|
||||||
|
|
||||||
const handleDragEnd = useCallback((absoluteY: number) => {
|
const handleDragEnd = useCallback((absoluteY: number) => {
|
||||||
const state = dragStateRef.current;
|
const state = dragStateRef.current;
|
||||||
dragStateRef.current = null;
|
dragStateRef.current = null;
|
||||||
setHoverTaskId(null);
|
setHoverTaskId(null);
|
||||||
|
setDropIndicator(null);
|
||||||
if (!state) return;
|
if (!state) return;
|
||||||
|
|
||||||
const target = findHoverTarget(absoluteY, state.taskId, state.positions);
|
const target = findHoverTarget(absoluteY, state.taskId, state.positions, state.subPositions);
|
||||||
if (target) {
|
if (target) {
|
||||||
|
if (state.subPositions[target]) {
|
||||||
|
const parentTaskId = subtaskRefs.current.get(target)?.parentTaskId ?? state.taskId;
|
||||||
|
(async () => {
|
||||||
|
await convertTaskToSubtask(state.taskId, parentTaskId, target);
|
||||||
|
})();
|
||||||
|
} else {
|
||||||
(async () => {
|
(async () => {
|
||||||
await convertTaskToSubtask(state.taskId, target);
|
await convertTaskToSubtask(state.taskId, target);
|
||||||
exitSelection();
|
|
||||||
refreshAll();
|
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
}, [findHoverTarget, exitSelection, refreshAll]);
|
}
|
||||||
|
}, [findHoverTarget]);
|
||||||
|
|
||||||
|
const handleSubtaskDragStart = useCallback(async (subtaskId: string, parentTaskId: string) => {
|
||||||
|
subtaskDragRef.current = { subtaskId, parentTaskId, ...(await measureAll()) };
|
||||||
|
}, [measureAll]);
|
||||||
|
|
||||||
|
const handleSubtaskDragUpdate = useCallback((absoluteY: number) => {
|
||||||
|
const state = subtaskDragRef.current;
|
||||||
|
if (!state) return;
|
||||||
|
const target = findHoverTarget(absoluteY, state.subtaskId, state.positions, state.subPositions);
|
||||||
|
updateHoverTarget(target);
|
||||||
|
if (target) {
|
||||||
|
if (state.subPositions[target]) {
|
||||||
|
setDropIndicator(null);
|
||||||
|
} else {
|
||||||
|
const position = calculateDropPosition(absoluteY, target, state.positions);
|
||||||
|
setDropIndicator({ targetId: target, position });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const positions = state.positions;
|
||||||
|
const lastItem = Object.values(positions).reduce((max, p) => p.bottom > max.bottom ? p : max, { bottom: 0 });
|
||||||
|
if (absoluteY > lastItem.bottom) {
|
||||||
|
setDropIndicator({ targetId: null, position: 'below' });
|
||||||
|
} else {
|
||||||
|
setDropIndicator(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [findHoverTarget, calculateDropPosition, updateHoverTarget]);
|
||||||
|
|
||||||
|
const handleSubtaskDragEnd = useCallback(async (absoluteY: number) => {
|
||||||
|
const state = subtaskDragRef.current;
|
||||||
|
subtaskDragRef.current = null;
|
||||||
|
setHoverTaskId(null);
|
||||||
|
setDropIndicator(null);
|
||||||
|
if (!state) return;
|
||||||
|
|
||||||
|
const target = findHoverTarget(absoluteY, state.subtaskId, state.positions, state.subPositions);
|
||||||
|
if (target === state.subtaskId) return;
|
||||||
|
|
||||||
|
if (target) {
|
||||||
|
if (state.subPositions[target]) {
|
||||||
|
await setSubtaskParent(state.subtaskId, target);
|
||||||
|
} else if (target !== state.parentTaskId) {
|
||||||
|
await moveSubtaskToTask(state.subtaskId, target);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await convertSubtaskToTask(state.subtaskId);
|
||||||
|
}
|
||||||
|
}, [findHoverTarget]);
|
||||||
|
|
||||||
|
const renderItem = useCallback(
|
||||||
|
({ item, index }: { item: Task; index: number }) => {
|
||||||
|
const isExpanded = expandedTasks.has(item.id);
|
||||||
|
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} />}
|
||||||
|
<TaskRow
|
||||||
|
task={item}
|
||||||
|
expanded={isExpanded}
|
||||||
|
subtasks={itemSubtasks}
|
||||||
|
selected={selectedIds.has(item.id)}
|
||||||
|
selectionMode={selectionMode}
|
||||||
|
hovered={hoverTaskId === item.id}
|
||||||
|
registerRef={registerRef}
|
||||||
|
registerSubtaskRef={registerSubtaskRef}
|
||||||
|
hoverTaskId={hoverTaskId}
|
||||||
|
onToggle={handleToggle}
|
||||||
|
onDelete={openTaskDelete}
|
||||||
|
onExpand={toggleExpand}
|
||||||
|
onSelect={toggleSelect}
|
||||||
|
onEnterSelection={enterSelection}
|
||||||
|
onMenuOpen={(task) => openTaskEdit(task.id)}
|
||||||
|
onSubtaskToggle={handleSubtaskToggle}
|
||||||
|
onSubtaskDelete={openSubtaskDelete}
|
||||||
|
onSubtaskMenuOpen={(subtask) => openSubtaskEdit(subtask.id)}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragUpdate={handleDragUpdate}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
onSubtaskDragStart={handleSubtaskDragStart}
|
||||||
|
onSubtaskDragUpdate={handleSubtaskDragUpdate}
|
||||||
|
onSubtaskDragEnd={handleSubtaskDragEnd}
|
||||||
|
onReorderStart={() => handleDragStart(item.id)}
|
||||||
|
onReorderUpdate={handleDragUpdate}
|
||||||
|
onReorderEnd={handleDragEnd}
|
||||||
|
selectedIds={selectedIds}
|
||||||
|
categoryColor={categoryColors.get(item.categoryId)}
|
||||||
|
categoryColors={tagColors}
|
||||||
|
categoryColorResolver={categoryColorResolver}
|
||||||
|
/>
|
||||||
|
{showDropBelow && <DropIndicator theme={theme} />}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
expandedTasks,
|
||||||
|
subtasksMap,
|
||||||
|
selectedIds,
|
||||||
|
selectionMode,
|
||||||
|
hoverTaskId,
|
||||||
|
dropIndicator,
|
||||||
|
theme,
|
||||||
|
registerRef,
|
||||||
|
handleToggle,
|
||||||
|
openTaskDelete,
|
||||||
|
toggleExpand,
|
||||||
|
toggleSelect,
|
||||||
|
enterSelection,
|
||||||
|
openTaskEdit,
|
||||||
|
handleSubtaskToggle,
|
||||||
|
openSubtaskDelete,
|
||||||
|
openSubtaskEdit,
|
||||||
|
handleDragStart,
|
||||||
|
handleDragUpdate,
|
||||||
|
handleDragEnd,
|
||||||
|
handleSubtaskDragStart,
|
||||||
|
handleSubtaskDragUpdate,
|
||||||
|
handleSubtaskDragEnd,
|
||||||
|
registerSubtaskRef,
|
||||||
|
categoryColorResolver,
|
||||||
|
categoryColors,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const listHeader = useMemo(() => {
|
||||||
|
if (sortedTasks.length > 0) return null;
|
||||||
|
return (
|
||||||
|
<View style={styles.emptyState}>
|
||||||
|
<Text style={[styles.emptyText, { color: theme.textSecondary }]}>No tasks yet</Text>
|
||||||
|
<Text style={[styles.emptySubtext, { color: theme.textMuted }]}>Tap + to add your first task</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}, [sortedTasks.length, theme.textSecondary, theme.textMuted]);
|
||||||
|
|
||||||
|
const listFooter = useMemo(() => {
|
||||||
|
const showDropAtEnd = dropIndicator && dropIndicator.targetId === null;
|
||||||
|
return showDropAtEnd ? <DropIndicator theme={theme} /> : null;
|
||||||
|
}, [dropIndicator, theme]);
|
||||||
|
|
||||||
if (loading && !refreshing) {
|
if (loading && !refreshing) {
|
||||||
return (
|
return (
|
||||||
@@ -298,86 +477,16 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const priorityOptions = Object.entries(PRIORITY_LABELS).map(([value, label]) => ({ value, label }));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<Animated.FlatList
|
<Animated.FlatList
|
||||||
data={sortedTasks}
|
data={sortedTasks}
|
||||||
keyExtractor={(item) => item.id}
|
keyExtractor={(item) => item.id}
|
||||||
renderItem={({ item }) => {
|
renderItem={renderItem}
|
||||||
const isExpanded = expandedTasks.has(item.id);
|
extraData={{ expandedTasks, subtasksMap, selectedIds, selectionMode, hoverTaskId, dropIndicator }}
|
||||||
const itemSubtasks = subtasksMap.get(item.id) ?? [];
|
ItemSeparatorComponent={MemoSeparator}
|
||||||
return (
|
ListHeaderComponent={listHeader}
|
||||||
<View
|
ListFooterComponent={listFooter}
|
||||||
ref={(ref) => {
|
|
||||||
if (ref) {
|
|
||||||
itemRefs.current.set(item.id, ref);
|
|
||||||
} else {
|
|
||||||
itemRefs.current.delete(item.id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TaskItem
|
|
||||||
task={item as TaskData}
|
|
||||||
onToggle={() => handleToggle(item.id)}
|
|
||||||
onDelete={() => handleDeleteOne(item.id)}
|
|
||||||
onPress={() => selectionMode ? toggleSelect(item.id) : handleEdit(item.id)}
|
|
||||||
onLongPress={selectionMode ? undefined : () => enterSelection(item.id)}
|
|
||||||
onMenuOpen={() => setMenuTaskId(item.id)}
|
|
||||||
selected={selectedIds.has(item.id)}
|
|
||||||
selectionMode={selectionMode}
|
|
||||||
draggable
|
|
||||||
hovered={hoverTaskId === item.id}
|
|
||||||
onDragStart={() => handleDragStart(item.id)}
|
|
||||||
onDragUpdate={handleDragUpdate}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
/>
|
|
||||||
{isExpanded && itemSubtasks.length > 0 && (
|
|
||||||
<View style={styles.subtaskList}>
|
|
||||||
{itemSubtasks
|
|
||||||
.slice()
|
|
||||||
.sort((a, b) => a.order - b.order)
|
|
||||||
.map((sub) => (
|
|
||||||
<SubtaskItem
|
|
||||||
key={sub.id}
|
|
||||||
subtask={sub}
|
|
||||||
onToggle={() => handleSubtaskToggle(sub.id, item.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
ItemSeparatorComponent={() => <View style={styles.separator} />}
|
|
||||||
ListHeaderComponent={
|
|
||||||
sortedTasks.length === 0 && completedTasks.length === 0 ? (
|
|
||||||
<View style={styles.emptyState}>
|
|
||||||
<Text style={[styles.emptyText, { color: theme.textSecondary }]}>No tasks yet</Text>
|
|
||||||
<Text style={[styles.emptySubtext, { color: theme.textMuted }]}>Tap + to add your first task</Text>
|
|
||||||
</View>
|
|
||||||
) : sortedTasks.length === 0 ? (
|
|
||||||
<View style={styles.emptyState}>
|
|
||||||
<Text style={[styles.emptyText, { color: theme.textSecondary }]}>All caught up!</Text>
|
|
||||||
<Text style={[styles.emptySubtext, { color: theme.textMuted }]}>No pending tasks</Text>
|
|
||||||
</View>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
ListFooterComponent={
|
|
||||||
completedTasks.length > 0 ? (
|
|
||||||
<CompletedSection
|
|
||||||
tasks={completedTasks}
|
|
||||||
onToggle={(task) => handleToggle(task.id)}
|
|
||||||
onDelete={(task) => handleDeleteOne(task.id)}
|
|
||||||
onMenuOpen={(task) => setMenuTaskId(task.id)}
|
|
||||||
onLongPress={(task) => enterSelection(task.id)}
|
|
||||||
selectionMode={selectionMode}
|
|
||||||
selectedIds={selectedIds}
|
|
||||||
onSelect={(taskId) => toggleSelect(taskId)}
|
|
||||||
/>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl
|
<RefreshControl
|
||||||
refreshing={refreshing}
|
refreshing={refreshing}
|
||||||
@@ -390,50 +499,13 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
|
|||||||
contentContainerStyle={styles.listContent}
|
contentContainerStyle={styles.listContent}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TaskDeleteModal
|
{modals(() => {})}
|
||||||
visible={deleteTarget !== null}
|
|
||||||
taskId={deleteTarget?.id ?? null}
|
|
||||||
taskTitle={deleteTarget?.title}
|
|
||||||
isRepeating={deleteTarget ? deleteTarget.repeat !== 'none' : false}
|
|
||||||
onClose={() => setDeleteTaskId(null)}
|
|
||||||
onDelete={handleDeleteScope}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TaskOverflowMenu
|
|
||||||
visible={menuTask !== null}
|
|
||||||
task={menuTask}
|
|
||||||
onClose={() => setMenuTaskId(null)}
|
|
||||||
onEdit={() => menuTask && handleEdit(menuTask.id)}
|
|
||||||
onDelete={() => menuTask && handleDeleteOne(menuTask.id)}
|
|
||||||
onDuplicate={() => menuTask && handleDuplicate(menuTask.id)}
|
|
||||||
onToggleComplete={() => menuTask && handleToggle(menuTask.id)}
|
|
||||||
onChangeCategory={() => menuTask && setPicker({ type: 'category', taskId: menuTask.id })}
|
|
||||||
onChangePriority={() => menuTask && setPicker({ type: 'priority', taskId: menuTask.id })}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<OptionPickerModal
|
|
||||||
visible={picker?.type === 'category'}
|
|
||||||
title="Change Category"
|
|
||||||
options={categories.map((c) => ({ value: c.id, label: c.name, color: c.color }))}
|
|
||||||
selectedValue={menuTask?.categoryId}
|
|
||||||
onSelect={handleSingleCategory}
|
|
||||||
onClose={() => setPicker(null)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<OptionPickerModal
|
|
||||||
visible={picker?.type === 'priority'}
|
|
||||||
title="Change Priority"
|
|
||||||
options={priorityOptions}
|
|
||||||
selectedValue={menuTask?.priority}
|
|
||||||
onSelect={handleSinglePriority}
|
|
||||||
onClose={() => setPicker(null)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{selectionMode && (
|
{selectionMode && (
|
||||||
<View style={[styles.selectionBar, { backgroundColor: theme.sheetBg, borderTopColor: theme.border }]}>
|
<View style={[styles.selectionBar, { backgroundColor: theme.sheetBg, borderTopColor: theme.border }]}>
|
||||||
<TouchableOpacity onPress={exitSelection} style={styles.selectionCancel} activeOpacity={0.7}>
|
<TouchableOpacity onPress={exitSelection} style={styles.selectionCancel} activeOpacity={0.7}>
|
||||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||||
<Path d="M18 6L6 18M6 6l12 12" stroke={theme.textFaint} strokeWidth={2} strokeLinecap="round" />
|
<Path d="M18 6L6 18M6 6l12 12" stroke={theme.textSecondary} strokeWidth={2.2} strokeLinecap="round" />
|
||||||
</Svg>
|
</Svg>
|
||||||
<Text style={[styles.selectionCancelText, { color: theme.textFaint }]}>Cancel</Text>
|
<Text style={[styles.selectionCancelText, { color: theme.textFaint }]}>Cancel</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -448,8 +520,7 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectionButton({ label, color, onPress }: { label: string; color: string; onPress: () => void }) {
|
const SelectionButton = React.memo(function SelectionButton({ label, color, onPress }: { label: string; color: string; onPress: () => void }) {
|
||||||
const { theme } = useSettings();
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.selectionButton, { backgroundColor: color }]}
|
style={[styles.selectionButton, { backgroundColor: color }]}
|
||||||
@@ -459,57 +530,131 @@ function SelectionButton({ label, color, onPress }: { label: string; color: stri
|
|||||||
<Text style={styles.selectionButtonText}>{label}</Text>
|
<Text style={styles.selectionButtonText}>{label}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
interface CompletedSectionProps {
|
interface TaskRowProps {
|
||||||
tasks: TaskData[];
|
task: Task;
|
||||||
onToggle: (task: TaskData) => void;
|
expanded: boolean;
|
||||||
onDelete: (task: TaskData) => void;
|
subtasks: SubtaskData[];
|
||||||
onMenuOpen: (task: TaskData) => void;
|
selected: boolean;
|
||||||
onLongPress: (task: TaskData) => void;
|
|
||||||
selectionMode: boolean;
|
selectionMode: boolean;
|
||||||
selectedIds: Set<string>;
|
hovered: boolean;
|
||||||
|
registerRef: (taskId: string, ref: View | null) => void;
|
||||||
|
registerSubtaskRef: (subtaskId: string, parentTaskId: string, ref: View | null) => void;
|
||||||
|
hoverTaskId: string | null;
|
||||||
|
onToggle: (taskId: string) => void | Promise<void>;
|
||||||
|
onDelete: (task: Task) => void;
|
||||||
|
onExpand: (taskId: string) => void | Promise<void>;
|
||||||
onSelect: (taskId: string) => void;
|
onSelect: (taskId: string) => void;
|
||||||
|
onEnterSelection: (taskId: string) => void;
|
||||||
|
onMenuOpen: (task: Task) => void;
|
||||||
|
onSubtaskToggle: (subtaskId: string, taskId: string) => void;
|
||||||
|
onSubtaskDelete: (subtask: SubtaskData) => void;
|
||||||
|
onSubtaskMenuOpen: (subtask: SubtaskData) => void;
|
||||||
|
onDragStart: (taskId: string) => void;
|
||||||
|
onDragUpdate: (absoluteY: number) => void;
|
||||||
|
onDragEnd: (absoluteY: number) => void;
|
||||||
|
onSubtaskDragStart: (subtaskId: string, parentTaskId: string) => void;
|
||||||
|
onSubtaskDragUpdate: (absoluteY: number) => void;
|
||||||
|
onSubtaskDragEnd: (absoluteY: number) => void;
|
||||||
|
onReorderStart: () => void;
|
||||||
|
onReorderUpdate: (absoluteY: number) => void;
|
||||||
|
onReorderEnd: (absoluteY: number, translationY: number) => void;
|
||||||
|
selectedIds: Set<string>;
|
||||||
|
categoryColor?: string;
|
||||||
|
categoryColors?: (string | undefined)[];
|
||||||
|
categoryColorResolver?: (categoryId: string | undefined) => string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function CompletedSection({ tasks, onToggle, onDelete, onMenuOpen, onLongPress, selectionMode, selectedIds, onSelect }: CompletedSectionProps) {
|
const TaskRow = React.memo(function TaskRow({
|
||||||
const { theme } = useSettings();
|
task,
|
||||||
const [expanded, setExpanded] = useState(false);
|
expanded,
|
||||||
|
subtasks,
|
||||||
|
selected,
|
||||||
|
selectionMode,
|
||||||
|
hovered,
|
||||||
|
hoverTaskId,
|
||||||
|
registerRef,
|
||||||
|
registerSubtaskRef,
|
||||||
|
onToggle,
|
||||||
|
onDelete,
|
||||||
|
onExpand,
|
||||||
|
onSelect,
|
||||||
|
onEnterSelection,
|
||||||
|
onMenuOpen,
|
||||||
|
onSubtaskToggle,
|
||||||
|
onSubtaskDelete,
|
||||||
|
onSubtaskMenuOpen,
|
||||||
|
onDragStart,
|
||||||
|
onDragUpdate,
|
||||||
|
onDragEnd,
|
||||||
|
onSubtaskDragStart,
|
||||||
|
onSubtaskDragUpdate,
|
||||||
|
onSubtaskDragEnd,
|
||||||
|
onReorderStart,
|
||||||
|
onReorderUpdate,
|
||||||
|
onReorderEnd,
|
||||||
|
selectedIds,
|
||||||
|
categoryColor,
|
||||||
|
categoryColors,
|
||||||
|
categoryColorResolver,
|
||||||
|
}: TaskRowProps) {
|
||||||
|
const sortedSubtasks = useMemo(
|
||||||
|
() => subtasks.slice().sort((a, b) => a.order - b.order),
|
||||||
|
[subtasks]
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<View style={styles.completedSection}>
|
<View
|
||||||
<TouchableOpacity
|
ref={(ref) => registerRef(task.id, ref)}
|
||||||
style={styles.completedHeader}
|
style={styles.dragContainer}
|
||||||
onPress={() => setExpanded(!expanded)}
|
|
||||||
>
|
>
|
||||||
<Text style={[styles.completedTitle, { color: theme.textFaint }]}>
|
|
||||||
Completed ({tasks.length})
|
|
||||||
</Text>
|
|
||||||
<Text style={[styles.completedToggle, { color: theme.accent }]}>
|
|
||||||
{expanded ? 'Hide' : 'Show'}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
{expanded && (
|
|
||||||
<View style={styles.completedList}>
|
|
||||||
{tasks.map((task) => (
|
|
||||||
<TaskItem
|
<TaskItem
|
||||||
key={task.id}
|
task={task as unknown as TaskData}
|
||||||
task={task}
|
onToggle={() => onToggle(task.id)}
|
||||||
onToggle={() => onToggle(task)}
|
|
||||||
onDelete={() => onDelete(task)}
|
onDelete={() => onDelete(task)}
|
||||||
onPress={() => {}}
|
onPress={() => (selectionMode ? onSelect(task.id) : onExpand(task.id))}
|
||||||
onLongPress={() => onLongPress(task)}
|
onLongPress={selectionMode ? undefined : () => onEnterSelection(task.id)}
|
||||||
onMenuOpen={() => onMenuOpen(task)}
|
onMenuOpen={() => onMenuOpen(task)}
|
||||||
selected={selectedIds.has(task.id)}
|
selected={selected}
|
||||||
selectionMode={selectionMode}
|
selectionMode={selectionMode}
|
||||||
completedSection
|
expanded={expanded}
|
||||||
|
draggable
|
||||||
|
hovered={hovered}
|
||||||
|
onDragStart={() => onDragStart(task.id)}
|
||||||
|
onDragUpdate={onDragUpdate}
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
|
onReorderStart={onReorderStart}
|
||||||
|
onReorderUpdate={onReorderUpdate}
|
||||||
|
onReorderEnd={onReorderEnd}
|
||||||
|
categoryColor={categoryColor}
|
||||||
|
categoryColors={categoryColors}
|
||||||
|
/>
|
||||||
|
{expanded && subtasks.length > 0 && (
|
||||||
|
<View style={styles.subtaskList}>
|
||||||
|
{sortedSubtasks.map((sub) => (
|
||||||
|
<SubtaskItem
|
||||||
|
key={sub.id}
|
||||||
|
subtask={sub}
|
||||||
|
hoveredId={hoverTaskId}
|
||||||
|
registerRef={registerSubtaskRef}
|
||||||
|
onToggle={(sub) => onSubtaskToggle(sub.id, task.id)}
|
||||||
|
onDelete={() => onSubtaskDelete(sub)}
|
||||||
|
onMenuOpen={() => onSubtaskMenuOpen(sub)}
|
||||||
|
selected={selectedIds.has(sub.id)}
|
||||||
|
selectionMode={selectionMode}
|
||||||
|
draggable
|
||||||
|
onDragStart={(sub) => onSubtaskDragStart(sub.id, task.id)}
|
||||||
|
onDragUpdate={onSubtaskDragUpdate}
|
||||||
|
onDragEnd={onSubtaskDragEnd}
|
||||||
|
categoryColor={categoryColor}
|
||||||
|
categoryColorResolver={categoryColorResolver}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
@@ -518,7 +663,7 @@ const styles = StyleSheet.create({
|
|||||||
listContent: {
|
listContent: {
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 16,
|
||||||
paddingTop: 8,
|
paddingTop: 8,
|
||||||
paddingBottom: 100,
|
paddingBottom: 12,
|
||||||
},
|
},
|
||||||
loadingContainer: {
|
loadingContainer: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@@ -534,6 +679,31 @@ const styles = StyleSheet.create({
|
|||||||
subtaskList: {
|
subtaskList: {
|
||||||
paddingLeft: 8,
|
paddingLeft: 8,
|
||||||
paddingRight: 4,
|
paddingRight: 4,
|
||||||
|
paddingTop: 8,
|
||||||
|
},
|
||||||
|
completedSubtasks: {
|
||||||
|
paddingLeft: 8,
|
||||||
|
paddingRight: 4,
|
||||||
|
paddingTop: 4,
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
dragContainer: {
|
||||||
|
},
|
||||||
|
dropIndicatorContainer: {
|
||||||
|
height: 8,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
dropIndicator: {
|
||||||
|
width: '80%',
|
||||||
|
height: 2,
|
||||||
|
borderRadius: 1,
|
||||||
|
},
|
||||||
|
dropIndicatorDot: {
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: 4,
|
||||||
|
position: 'absolute',
|
||||||
},
|
},
|
||||||
emptyState: {
|
emptyState: {
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { View, Text, StyleSheet, TextInput } from 'react-native';
|
import { View, Text, StyleSheet, TextInput , TextInputProps } from 'react-native';
|
||||||
import { TextInputProps } from 'react-native';
|
|
||||||
import { useSettings } from '@/theme';
|
import { useSettings } from '@/theme';
|
||||||
|
|
||||||
interface TaskNameInputProps extends TextInputProps {
|
interface TaskNameInputProps extends TextInputProps {
|
||||||
@@ -26,6 +25,8 @@ export function TaskNameInput({ error, ...props }: TaskNameInputProps) {
|
|||||||
placeholderTextColor={theme.textMuted}
|
placeholderTextColor={theme.textMuted}
|
||||||
maxLength={100}
|
maxLength={100}
|
||||||
autoCapitalize="sentences"
|
autoCapitalize="sentences"
|
||||||
|
accessibilityLabel="Task name"
|
||||||
|
accessibilityHint="Required field. Enter a name for the task"
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
{error && <Text style={styles.errorText}>{error}</Text>}
|
{error && <Text style={styles.errorText}>{error}</Text>}
|
||||||
@@ -35,34 +36,34 @@ export function TaskNameInput({ error, ...props }: TaskNameInputProps) {
|
|||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
gap: 6,
|
gap: 5,
|
||||||
},
|
},
|
||||||
labelRow: {
|
labelRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
label: {
|
label: {
|
||||||
fontSize: 14,
|
fontSize: 13,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
required: {
|
required: {
|
||||||
color: '#E53935',
|
color: '#E53935',
|
||||||
fontSize: 14,
|
fontSize: 13,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
input: {
|
input: {
|
||||||
height: 52,
|
height: 48,
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 14,
|
||||||
borderRadius: 12,
|
borderRadius: 10,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
fontSize: 16,
|
fontSize: 15,
|
||||||
},
|
},
|
||||||
inputError: {
|
inputError: {
|
||||||
borderColor: '#E53935',
|
borderColor: '#E53935',
|
||||||
borderWidth: 1.5,
|
borderWidth: 1.5,
|
||||||
},
|
},
|
||||||
errorText: {
|
errorText: {
|
||||||
fontSize: 12,
|
fontSize: 11,
|
||||||
color: '#E53935',
|
color: '#E53935',
|
||||||
marginLeft: 4,
|
marginLeft: 4,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { View, Text, StyleSheet, Modal, TouchableOpacity } from 'react-native';
|
import { View, Text, StyleSheet, Modal, TouchableOpacity } from 'react-native';
|
||||||
import { useSettings } from '@/theme';
|
import { useSettings } from '@/theme';
|
||||||
import { TaskData } from '@/types';
|
import { TaskData, SubtaskData } from '@/types';
|
||||||
import Svg, { Path } from 'react-native-svg';
|
import Svg, { Path } from 'react-native-svg';
|
||||||
|
|
||||||
interface TaskOverflowMenuProps {
|
interface TaskOverflowMenuProps {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
task: TaskData | null;
|
task: TaskData | SubtaskData | null;
|
||||||
|
subtask?: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
onDuplicate: () => void;
|
onDuplicate: () => void;
|
||||||
onToggleComplete: () => void;
|
onToggleComplete: () => void;
|
||||||
onChangeCategory: () => void;
|
onChangeCategory?: () => void;
|
||||||
onChangePriority: () => void;
|
onChangePriority: () => void;
|
||||||
|
onAddSubtask?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MenuAction {
|
interface MenuAction {
|
||||||
@@ -27,6 +29,7 @@ interface MenuAction {
|
|||||||
export function TaskOverflowMenu({
|
export function TaskOverflowMenu({
|
||||||
visible,
|
visible,
|
||||||
task,
|
task,
|
||||||
|
subtask,
|
||||||
onClose,
|
onClose,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
@@ -34,6 +37,7 @@ export function TaskOverflowMenu({
|
|||||||
onToggleComplete,
|
onToggleComplete,
|
||||||
onChangeCategory,
|
onChangeCategory,
|
||||||
onChangePriority,
|
onChangePriority,
|
||||||
|
onAddSubtask,
|
||||||
}: TaskOverflowMenuProps) {
|
}: TaskOverflowMenuProps) {
|
||||||
const { theme } = useSettings();
|
const { theme } = useSettings();
|
||||||
|
|
||||||
@@ -43,41 +47,56 @@ export function TaskOverflowMenu({
|
|||||||
{
|
{
|
||||||
key: 'edit',
|
key: 'edit',
|
||||||
label: 'Edit',
|
label: 'Edit',
|
||||||
icon: <Svg width={20} height={20} viewBox="0 0 24 24"><Path d="M4 20h4L19.5 8.5a2.1 2.1 0 0 0-3-3L5 17v3z" stroke={theme.textSecondary} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" fill="none" /></Svg>,
|
icon: <Svg width={20} height={20} viewBox="0 0 24 24"><Path d="M4 20h4L19.5 8.5a2.1 2.1 0 0 0-3-3L5 17v3z" stroke={theme.text} strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" fill="none" /></Svg>,
|
||||||
onPress: onEdit,
|
onPress: onEdit,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'complete',
|
key: 'complete',
|
||||||
label: task.completed ? 'Mark Incomplete' : 'Mark Complete',
|
label: task.completed ? 'Mark Incomplete' : 'Mark Complete',
|
||||||
icon: <Svg width={20} height={20} viewBox="0 0 24 24"><Path d="M5 12l5 5 9-10" stroke={theme.textSecondary} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" fill="none" /></Svg>,
|
icon: <Svg width={20} height={20} viewBox="0 0 24 24"><Path d="M5 12l5 5 9-10" stroke={theme.text} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" /></Svg>,
|
||||||
onPress: onToggleComplete,
|
onPress: onToggleComplete,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'duplicate',
|
key: 'duplicate',
|
||||||
label: 'Duplicate',
|
label: 'Duplicate',
|
||||||
icon: <Svg width={20} height={20} viewBox="0 0 24 24"><Path d="M8 8h12v12H8zM4 16V4h12" stroke={theme.textSecondary} strokeWidth={1.8} fill="none" /></Svg>,
|
icon: <Svg width={20} height={20} viewBox="0 0 24 24"><Path d="M8 8h12v12H8zM4 16V4h12" stroke={theme.text} strokeWidth={2.2} fill="none" /></Svg>,
|
||||||
onPress: onDuplicate,
|
onPress: onDuplicate,
|
||||||
},
|
},
|
||||||
{
|
];
|
||||||
|
|
||||||
|
if (!subtask) {
|
||||||
|
actions.push({
|
||||||
key: 'category',
|
key: 'category',
|
||||||
label: 'Change Category',
|
label: 'Change Category',
|
||||||
icon: <Svg width={20} height={20} viewBox="0 0 24 24"><Circle2 /></Svg>,
|
icon: <Svg width={20} height={20} viewBox="0 0 24 24"><Circle2 /></Svg>,
|
||||||
onPress: onChangeCategory,
|
onPress: onChangeCategory ?? (() => {}),
|
||||||
},
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
actions.push(
|
||||||
{
|
{
|
||||||
key: 'priority',
|
key: 'priority',
|
||||||
label: 'Change Priority',
|
label: 'Change Priority',
|
||||||
icon: <Svg width={20} height={20} viewBox="0 0 24 24"><Path d="M5 3v18M5 5h14l-3 4 3 4H5" stroke={theme.textSecondary} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" fill="none" /></Svg>,
|
icon: <Svg width={20} height={20} viewBox="0 0 24 24"><Path d="M5 3v18M5 5h14l-3 4 3 4H5" stroke={theme.text} strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" fill="none" /></Svg>,
|
||||||
onPress: onChangePriority,
|
onPress: onChangePriority,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'delete',
|
key: 'delete',
|
||||||
label: 'Delete',
|
label: 'Delete',
|
||||||
destructive: true,
|
destructive: true,
|
||||||
icon: <Svg width={20} height={20} viewBox="0 0 24 24"><Path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13" stroke="#E53935" strokeWidth={1.8} 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="#E57373" strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" fill="none" /></Svg>,
|
||||||
onPress: onDelete,
|
onPress: onDelete,
|
||||||
},
|
},
|
||||||
];
|
);
|
||||||
|
|
||||||
|
if (subtask && onAddSubtask) {
|
||||||
|
actions.push({
|
||||||
|
key: 'addSubtask',
|
||||||
|
label: 'Add Subtask',
|
||||||
|
icon: <Svg width={20} height={20} viewBox="0 0 24 24"><Path d="M12 5v14M5 12h14" stroke={theme.text} strokeWidth={2.5} strokeLinecap="round" /></Svg>,
|
||||||
|
onPress: onAddSubtask,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
||||||
@@ -100,6 +119,8 @@ export function TaskOverflowMenu({
|
|||||||
action.onPress();
|
action.onPress();
|
||||||
}}
|
}}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={`${action.label}${action.destructive ? ' (dangerous)' : ''}`}
|
||||||
>
|
>
|
||||||
{action.icon}
|
{action.icon}
|
||||||
<Text style={[styles.actionText, action.destructive ? styles.destructiveText : { color: theme.textSecondary }]}>
|
<Text style={[styles.actionText, action.destructive ? styles.destructiveText : { color: theme.textSecondary }]}>
|
||||||
@@ -117,8 +138,8 @@ function Circle2() {
|
|||||||
const { theme } = useSettings();
|
const { theme } = useSettings();
|
||||||
return (
|
return (
|
||||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||||
<Path d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20z" stroke={theme.textSecondary} strokeWidth={1.8} fill="none" />
|
<Path d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20z" stroke={theme.text} strokeWidth={2.2} fill="none" />
|
||||||
<Path d="M12 2a10 10 0 0 1 0 20z" fill={theme.textSecondary} opacity={0.3} />
|
<Path d="M12 2a10 10 0 0 1 0 20z" fill={theme.text} opacity={0.35} />
|
||||||
</Svg>
|
</Svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { Alert, Linking } from 'react-native';
|
||||||
|
import { checkForUpdates, wasPromptedFor, markPrompted } from '@/services/updates';
|
||||||
|
|
||||||
|
export function UpdateNotifier() {
|
||||||
|
useEffect(() => {
|
||||||
|
const run = async () => {
|
||||||
|
const update = await checkForUpdates();
|
||||||
|
if (!update || (await wasPromptedFor(update.tagName))) return;
|
||||||
|
await markPrompted(update.tagName);
|
||||||
|
const url = update.apkUrl ?? update.releaseUrl;
|
||||||
|
Alert.alert(
|
||||||
|
'Update available',
|
||||||
|
`A new version (${update.version}) is available for download.`,
|
||||||
|
[
|
||||||
|
{ text: 'Later', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Download',
|
||||||
|
onPress: () => {
|
||||||
|
if (url) Linking.openURL(url).catch(() => {});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
run();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -110,12 +110,6 @@ function WheelColumn({
|
|||||||
[data.length, onIndexChange]
|
[data.length, onIndexChange]
|
||||||
);
|
);
|
||||||
|
|
||||||
const snapToNearest = useCallback(() => {
|
|
||||||
const current = listRef.current;
|
|
||||||
if (!current) return;
|
|
||||||
current.scrollToOffset({ offset: indexRef.current * ITEM_HEIGHT - (ITEM_HEIGHT * VISIBLE_ITEMS - ITEM_HEIGHT) / 2, animated: true });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const renderItem = useCallback(
|
const renderItem = useCallback(
|
||||||
({ item, index }: ListRenderItemInfo<number>) => (
|
({ item, index }: ListRenderItemInfo<number>) => (
|
||||||
<WheelRow
|
<WheelRow
|
||||||
@@ -143,10 +137,12 @@ function WheelColumn({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleScrollEnd = useCallback(() => {
|
const handleScrollEnd = useCallback(() => {
|
||||||
// Rest the wheel exactly on the snapped row so the selection band
|
// Let FlatList's native snap handle the alignment
|
||||||
// and the highlighted text always line up (FlatList doesn't snap on web).
|
// Just update the indexRef from the scroll position
|
||||||
snapToNearest();
|
const current = listRef.current;
|
||||||
}, [snapToNearest]);
|
if (!current) return;
|
||||||
|
// The native snap will handle positioning, we just sync the index
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FlatList
|
<FlatList
|
||||||
@@ -161,7 +157,7 @@ function WheelColumn({
|
|||||||
})}
|
})}
|
||||||
initialScrollIndex={initialIndex}
|
initialScrollIndex={initialIndex}
|
||||||
snapToOffsets={data.map((_, i) => i * ITEM_HEIGHT)}
|
snapToOffsets={data.map((_, i) => i * ITEM_HEIGHT)}
|
||||||
decelerationRate="fast"
|
decelerationRate="normal"
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
onScroll={handleScroll}
|
onScroll={handleScroll}
|
||||||
onScrollEndDrag={handleScrollEnd}
|
onScrollEndDrag={handleScrollEnd}
|
||||||
@@ -169,6 +165,8 @@ function WheelColumn({
|
|||||||
scrollEventThrottle={16}
|
scrollEventThrottle={16}
|
||||||
style={[styles.column, { width }]}
|
style={[styles.column, { width }]}
|
||||||
contentContainerStyle={{ paddingBottom: (VISIBLE_ITEMS - 1) * ITEM_HEIGHT, alignItems: 'stretch' }}
|
contentContainerStyle={{ paddingBottom: (VISIBLE_ITEMS - 1) * ITEM_HEIGHT, alignItems: 'stretch' }}
|
||||||
|
snapToAlignment="center"
|
||||||
|
snapToInterval={ITEM_HEIGHT}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -214,7 +212,7 @@ export function WheelTimePicker({
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<View style={styles.headerCenter}>
|
<View style={styles.headerCenter}>
|
||||||
<Text style={[styles.title, { color: theme.text }]}>{title}</Text>
|
<Text style={[styles.title, { color: theme.text }]}>{title}</Text>
|
||||||
<Text style={[styles.preview, { color: theme.accent }]}>
|
<Text style={[styles.preview, { color: theme.accentStrong }]}>
|
||||||
{formatTime12(hourIndex, minuteIndex)}
|
{formatTime12(hourIndex, minuteIndex)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -222,7 +220,7 @@ export function WheelTimePicker({
|
|||||||
onPress={() => onConfirm(`${String(hourIndex).padStart(2, '0')}:${String(minuteIndex).padStart(2, '0')}`)}
|
onPress={() => onConfirm(`${String(hourIndex).padStart(2, '0')}:${String(minuteIndex).padStart(2, '0')}`)}
|
||||||
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
||||||
>
|
>
|
||||||
<Text style={[styles.headerAction, styles.doneText, { color: theme.accent }]}>Done</Text>
|
<Text style={[styles.headerAction, styles.doneText, { color: theme.accentStrong }]}>Done</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -240,7 +238,7 @@ export function WheelTimePicker({
|
|||||||
onIndexChange={setHourIndex}
|
onIndexChange={setHourIndex}
|
||||||
/>
|
/>
|
||||||
<View style={[styles.colonWrap, { width: 24, backgroundColor: theme.sheetBg }]}>
|
<View style={[styles.colonWrap, { width: 24, backgroundColor: theme.sheetBg }]}>
|
||||||
<Text style={[styles.colon, { color: theme.accent }]}>:</Text>
|
<Text style={[styles.colon, { color: theme.accentStrong }]}>:</Text>
|
||||||
</View>
|
</View>
|
||||||
<WheelColumn
|
<WheelColumn
|
||||||
data={MINUTES}
|
data={MINUTES}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user