Compare commits

..
13 Commits
Author SHA1 Message Date
tech08mag b5ae2ee0b4 Merge pull request 'new pipe' (#37) from dev into main
Android Build / Setup & Install (push) Successful in 8m18s
Android Build / Build Debug APK (push) Failing after 12m21s
Android Build / Build Release APK & AAB (push) Failing after 21m56s
Android Build / Test & Lint (push) Successful in 15m15s
Reviewed-on: #37
2026-09-02 19:27:34 +00:00
tech08mag 61d08f8a34 new pipe 2026-09-02 21:27:01 +02:00
tech08mag b8e88bca6d Merge pull request 'new pipe' (#36) from dev into main
Android Build / Build Android (push) Failing after 46m26s
Reviewed-on: #36
2026-09-02 18:28:52 +00:00
tech08mag 41eaef92b0 new pipe
Android Build / Build Android (pull_request) Failing after 50m22s
2026-09-02 20:28:07 +02:00
tech08mag 93738a07ac Merge pull request 'Dev' (#35) from dev into main
Reviewed-on: #35
2026-09-01 19:23:32 +00:00
tech08mag 55e21c06d9 new pipe 2026-09-01 21:22:48 +02:00
tech08mag eeab4d1221 - src/theme.tsx - Added light/dark themes, themeMode, settings sync functions
- src/services/notifications.ts - Added useNotificationSettings hook, respects notifications toggle
- app/(tabs)/settings.tsx - Added theme mode picker, fixed notifications toggle, added settings sync buttons
2026-08-28 23:45:36 +02:00
tech08mag c5f129bb42 Merge pull request 'Fixed - Gitea doesn't support actions/upload-artifact@v4 or download-artifact@v4. Switched both to v3.' (#34) from dev into main
Build and Release APK / build (push) Successful in 14m54s
Build and Release APK / release (push) Failing after 9m4s
Reviewed-on: #34
2026-08-28 21:34:01 +00:00
tech08mag 4d3d741563 Fixed - Gitea doesn't support actions/upload-artifact@v4 or download-artifact@v4. Switched both to v3. 2026-08-28 23:33:04 +02:00
tech08mag 95c69d35e2 Merge pull request 'Fixed the APK output path - the build outputs to outputs/release/app-release.apk not outputs/apk/release/app-release.apk' (#33) from dev into main
Build and Release APK / build (push) Successful in 14m59s
Build and Release APK / release (push) Failing after 5m46s
Reviewed-on: #33
2026-08-28 21:11:08 +00:00
tech08mag bc4903e3b8 Fixed the APK output path - the build outputs to outputs/release/app-release.apk not outputs/apk/release/app-release.apk 2026-08-28 23:10:34 +02:00
tech08mag beb6cc5715 Merge pull request 'Fixed the artifact path from outputs/release/ to outputs/apk/release/ in the workflow' (#32) from dev into main
Build and Release APK / build (push) Failing after 20m49s
Build and Release APK / release (push) Skipped
Reviewed-on: #32
2026-08-28 20:40:49 +00:00
tech08mag 4c6adc7117 Fixed the artifact path from outputs/release/ to outputs/apk/release/ in the workflow 2026-08-28 22:40:18 +02:00
74 changed files with 5579 additions and 2581 deletions
+327
View File
@@ -0,0 +1,327 @@
name: Android Build
on:
push:
branches: [main, develop]
paths:
- 'carry-your-live/**'
- '.gitea/workflows/android-build.yml'
pull_request:
branches: [main, develop]
paths:
- 'carry-your-live/**'
workflow_dispatch:
env:
NODE_VERSION: '20'
JAVA_VERSION: '17'
ANDROID_HOME: ${{ runner.tool_cache }}/android-sdk
ANDROID_SDK_ROOT: ${{ runner.tool_cache }}/android-sdk
GRADLE_OPTS: "-Dorg.gradle.workers.max=2 -Dorg.gradle.jvmargs=-Xmx1536m -XX:MaxMetaspaceSize=1024m"
jobs:
setup:
name: Setup & Install
runs-on: ubuntu-latest
timeout-minutes: 20
outputs:
gradle-cache-key: ${{ steps.gradle-cache.outputs.cache-primary-key }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
cache-dependency-path: carry-your-live/package-lock.json
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: ${{ env.JAVA_VERSION }}
cache: 'gradle'
cache-dependency-path: carry-your-live/android/gradle/wrapper/gradle-wrapper.properties
- name: Install Android SDK
run: |
if [ -x "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" ]; then
echo "Android SDK already present in cache"
else
rm -rf "$ANDROID_HOME/cmdline-tools"
mkdir -p "$ANDROID_HOME/cmdline-tools"
wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -O /tmp/cmdtools.zip
unzip -q /tmp/cmdtools.zip -d "$ANDROID_HOME/cmdline-tools"
mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest"
fi
mkdir -p "$ANDROID_HOME/licenses"
echo "8933bad161af4178b1185d1a37fbf41ea5269c55" > "$ANDROID_HOME/licenses/android-sdk-license"
echo "d56f5187479451eabf01fb78af6dfcb131a6481e" >> "$ANDROID_HOME/licenses/android-sdk-license"
echo "24333f8a63b6825ea9c5514f83c2829b004d1fee" >> "$ANDROID_HOME/licenses/android-sdk-license"
"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" \
"platform-tools" \
"platforms;android-36" \
"build-tools;36.0.0" \
"ndk;27.1.12297006"
mkdir -p carry-your-live/android/app
echo "sdk.dir=$ANDROID_HOME" > carry-your-live/android/app/local.properties
- name: Install dependencies
working-directory: carry-your-live
run: |
for i in {1..3}; do
npm ci && break || sleep 10
done
- name: Prebuild Android
working-directory: carry-your-live
run: |
for i in {1..2}; do
npx expo prebuild --platform android --clean && break || sleep 15
done
build-debug:
name: Build Debug APK
needs: setup
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
cache-dependency-path: carry-your-live/package-lock.json
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: ${{ env.JAVA_VERSION }}
cache: 'gradle'
cache-dependency-path: carry-your-live/android/gradle/wrapper/gradle-wrapper.properties
- name: Install Android SDK
run: |
if [ -x "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" ]; then
echo "Android SDK already present in cache"
else
rm -rf "$ANDROID_HOME/cmdline-tools"
mkdir -p "$ANDROID_HOME/cmdline-tools"
wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -O /tmp/cmdtools.zip
unzip -q /tmp/cmdtools.zip -d "$ANDROID_HOME/cmdline-tools"
mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest"
fi
mkdir -p "$ANDROID_HOME/licenses"
echo "8933bad161af4178b1185d1a37fbf41ea5269c55" > "$ANDROID_HOME/licenses/android-sdk-license"
echo "d56f5187479451eabf01fb78af6dfcb131a6481e" >> "$ANDROID_HOME/licenses/android-sdk-license"
echo "24333f8a63b6825ea9c5514f83c2829b004d1fee" >> "$ANDROID_HOME/licenses/android-sdk-license"
"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" \
"platform-tools" \
"platforms;android-36" \
"build-tools;36.0.0" \
"ndk;27.1.12297006"
mkdir -p carry-your-live/android/app
echo "sdk.dir=$ANDROID_HOME" > carry-your-live/android/app/local.properties
- name: Build Debug APK
working-directory: carry-your-live/android
run: |
for i in {1..3}; do
./gradlew assembleDebug --no-daemon -Dorg.gradle.workers.max=2 && break || sleep 30
done
- name: Find Debug APK
id: find-debug-apk
run: |
APK_PATH=$(find carry-your-live/android/app/build/outputs -name "*debug*.apk" -type f | head -n 1)
echo "apk_path=$APK_PATH" >> "$GITHUB_OUTPUT"
echo "Found Debug APK at: $APK_PATH"
- name: Upload Debug APK
uses: actions/upload-artifact@v4
with:
name: android-debug-apk
path: ${{ steps.find-debug-apk.outputs.apk_path }}
retention-days: 30
build-release:
name: Build Release APK & AAB
needs: setup
runs-on: ubuntu-latest
timeout-minutes: 35
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
cache-dependency-path: carry-your-live/package-lock.json
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: ${{ env.JAVA_VERSION }}
cache: 'gradle'
cache-dependency-path: carry-your-live/android/gradle/wrapper/gradle-wrapper.properties
- name: Install Android SDK
run: |
if [ -x "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" ]; then
echo "Android SDK already present in cache"
else
rm -rf "$ANDROID_HOME/cmdline-tools"
mkdir -p "$ANDROID_HOME/cmdline-tools"
wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -O /tmp/cmdtools.zip
unzip -q /tmp/cmdtools.zip -d "$ANDROID_HOME/cmdline-tools"
mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest"
fi
mkdir -p "$ANDROID_HOME/licenses"
echo "8933bad161af4178b1185d1a37fbf41ea5269c55" > "$ANDROID_HOME/licenses/android-sdk-license"
echo "d56f5187479451eabf01fb78af6dfcb131a6481e" >> "$ANDROID_HOME/licenses/android-sdk-license"
echo "24333f8a63b6825ea9c5514f83c2829b004d1fee" >> "$ANDROID_HOME/licenses/android-sdk-license"
"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" \
"platform-tools" \
"platforms;android-36" \
"build-tools;36.0.0" \
"ndk;27.1.12297006"
mkdir -p carry-your-live/android/app
echo "sdk.dir=$ANDROID_HOME" > carry-your-live/android/app/local.properties
- name: Build Release APK
working-directory: carry-your-live/android
run: |
for i in {1..3}; do
./gradlew assembleRelease --no-daemon -Dorg.gradle.workers.max=2 && break || sleep 30
done
- name: Build Release Bundle (AAB)
working-directory: carry-your-live/android
run: |
for i in {1..3}; do
./gradlew bundleRelease --no-daemon -Dorg.gradle.workers.max=2 && break || sleep 30
done
- name: Find Release APK
id: find-release-apk
run: |
APK_PATH=$(find carry-your-live/android/app/build/outputs -name "*release*.apk" -type f | head -n 1)
echo "apk_path=$APK_PATH" >> "$GITHUB_OUTPUT"
echo "Found Release APK at: $APK_PATH"
- name: Find Release Bundle (AAB)
id: find-release-aab
run: |
AAB_PATH=$(find carry-your-live/android/app/build/outputs -name "*release*.aab" -type f | head -n 1)
echo "aab_path=$AAB_PATH" >> "$GITHUB_OUTPUT"
echo "Found Release AAB at: $AAB_PATH"
- name: Upload Release APK
uses: actions/upload-artifact@v4
with:
name: android-release-apk
path: ${{ steps.find-release-apk.outputs.apk_path }}
retention-days: 30
- name: Upload Release Bundle (AAB)
uses: actions/upload-artifact@v4
with:
name: android-release-aab
path: ${{ steps.find-release-aab.outputs.aab_path }}
retention-days: 30
test-lint:
name: Test & Lint
needs: setup
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
cache-dependency-path: carry-your-live/package-lock.json
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: ${{ env.JAVA_VERSION }}
cache: 'gradle'
cache-dependency-path: carry-your-live/android/gradle/wrapper/gradle-wrapper.properties
- name: Install Android SDK
run: |
if [ -x "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" ]; then
echo "Android SDK already present in cache"
else
rm -rf "$ANDROID_HOME/cmdline-tools"
mkdir -p "$ANDROID_HOME/cmdline-tools"
wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -O /tmp/cmdtools.zip
unzip -q /tmp/cmdtools.zip -d "$ANDROID_HOME/cmdline-tools"
mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest"
fi
mkdir -p "$ANDROID_HOME/licenses"
echo "8933bad161af4178b1185d1a37fbf41ea5269c55" > "$ANDROID_HOME/licenses/android-sdk-license"
echo "d56f5187479451eabf01fb78af6dfcb131a6481e" >> "$ANDROID_HOME/licenses/android-sdk-license"
echo "24333f8a63b6825ea9c5514f83c2829b004d1fee" >> "$ANDROID_HOME/licenses/android-sdk-license"
"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" \
"platform-tools" \
"platforms;android-36" \
"build-tools;36.0.0" \
"ndk;27.1.12297006"
mkdir -p carry-your-live/android/app
echo "sdk.dir=$ANDROID_HOME" > carry-your-live/android/app/local.properties
- name: Run Unit Tests
working-directory: carry-your-live/android
run: |
for i in {1..3}; do
./gradlew test --no-daemon -Dorg.gradle.workers.max=2 && break || sleep 30
done
continue-on-error: true
- name: Run Lint
working-directory: carry-your-live/android
run: |
for i in {1..2}; do
./gradlew lint --no-daemon -Dorg.gradle.workers.max=2 && break || sleep 30
done
continue-on-error: true
- name: Upload Test Reports
if: always()
uses: actions/upload-artifact@v4
with:
name: android-test-reports
path: carry-your-live/android/app/build/reports/tests/
retention-days: 14
- name: Upload Lint Reports
if: always()
uses: actions/upload-artifact@v4
with:
name: android-lint-reports
path: carry-your-live/android/app/build/reports/lint-results*.html
retention-days: 14
+170 -73
View File
@@ -1,36 +1,46 @@
name: Build and Release APK
name: Build and Release Android
on:
push:
branches: [main]
tags:
- 'v*'
workflow_dispatch:
env:
NODE_VERSION: '20'
JAVA_VERSION: '17'
ANDROID_HOME: ${{ runner.tool_cache }}/android-sdk
ANDROID_SDK_ROOT: ${{ runner.tool_cache }}/android-sdk
permissions:
contents: write
GITEA_SERVER_URL: "https://techmos.gitea.org"
jobs:
build:
name: Build Android
runs-on: ubuntu-latest
outputs:
apk-path: ${{ steps.upload-apk.outputs.path }}
timeout-minutes: 45
permissions:
contents: read
steps:
- name: Checkout
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Node
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
cache-dependency-path: carry-your-live/package-lock.json
- name: Set up JDK
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
distribution: 'temurin'
java-version: ${{ env.JAVA_VERSION }}
cache: 'gradle'
cache-dependency-path: carry-your-live/android/gradle/wrapper/gradle-wrapper.properties
- name: Install Android SDK
run: |
@@ -43,7 +53,6 @@ jobs:
unzip -q /tmp/cmdtools.zip -d "$ANDROID_HOME/cmdline-tools"
mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest"
fi
# Accept all licenses by writing known license hashes
mkdir -p "$ANDROID_HOME/licenses"
echo "8933bad161af4178b1185d1a37fbf41ea5269c55" > "$ANDROID_HOME/licenses/android-sdk-license"
echo "d56f5187479451eabf01fb78af6dfcb131a6481e" >> "$ANDROID_HOME/licenses/android-sdk-license"
@@ -56,85 +65,173 @@ jobs:
mkdir -p carry-your-live/android/app
echo "sdk.dir=$ANDROID_HOME" > carry-your-live/android/app/local.properties
- name: Install JS dependencies
- name: Install dependencies
working-directory: carry-your-live
run: |
for i in {1..3}; do
npm ci && break || sleep 10
done
- name: Generate debug keystore (signs the release APK)
run: |
mkdir -p carry-your-live/android/app
rm -f carry-your-live/android/app/debug.keystore
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"
- name: Prebuild Android
working-directory: carry-your-live
run: npx expo prebuild --platform android --clean
- name: Build release APK
- name: Configure Release Signing
if: env.KEYSTORE_BASE64 != ''
working-directory: carry-your-live/android/app
run: |
cd carry-your-live/android
./gradlew assembleRelease
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > release.keystore
cat > ../release-signing.properties <<EOF
storeFile=release.keystore
storePassword=${{ secrets.KEYSTORE_PASSWORD }}
keyAlias=${{ secrets.KEY_ALIAS }}
keyPassword=${{ secrets.KEY_PASSWORD }}
EOF
- name: Upload APK
id: upload-apk
- name: Build Release APK
working-directory: carry-your-live/android
run: |
if [ -f "../release-signing.properties" ]; then
./gradlew assembleRelease -PreleaseSigningProperties=../release-signing.properties --no-daemon
else
./gradlew assembleRelease --no-daemon
fi
- name: Build Release Bundle (AAB)
working-directory: carry-your-live/android
run: |
if [ -f "../release-signing.properties" ]; then
./gradlew bundleRelease -PreleaseSigningProperties=../release-signing.properties --no-daemon
else
./gradlew bundleRelease --no-daemon
fi
- name: Run Tests
working-directory: carry-your-live/android
run: ./gradlew test --no-daemon
- name: Run Lint
working-directory: carry-your-live/android
run: ./gradlew lint --no-daemon
- name: Find Release APK
id: find-release-apk
run: |
APK_PATH=$(find carry-your-live/android/app/build/outputs -name "*release*.apk" -type f | head -n 1)
echo "apk_path=$APK_PATH" >> "$GITHUB_OUTPUT"
echo "Found Release APK at: $APK_PATH"
- name: Find Release Bundle (AAB)
id: find-release-aab
run: |
AAB_PATH=$(find carry-your-live/android/app/build/outputs -name "*release*.aab" -type f | head -n 1)
echo "aab_path=$AAB_PATH" >> "$GITHUB_OUTPUT"
echo "Found Release AAB at: $AAB_PATH"
- name: Upload Build Artifacts
uses: actions/upload-artifact@v4
with:
name: carry-your-live-release
path: carry-your-live/android/app/build/outputs/release/app-release.apk
name: android-release-build
path: |
${{ steps.find-release-apk.outputs.apk_path }}
${{ steps.find-release-aab.outputs.aab_path }}
retention-days: 90
- name: Upload Test Reports
if: always()
uses: actions/upload-artifact@v4
with:
name: android-test-reports
path: carry-your-live/android/app/build/reports/tests/
retention-days: 30
- name: Upload Lint Reports
if: always()
uses: actions/upload-artifact@v4
with:
name: android-lint-reports
path: carry-your-live/android/app/build/reports/lint-results*.html
retention-days: 30
release:
name: Create Gitea Release
needs: build
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Download APK artifact
- name: Download Build Artifacts
uses: actions/download-artifact@v4
with:
name: carry-your-live-release
path: .
name: android-release-build
path: artifacts
- name: Compute next version
id: version
- name: Create Gitea Release
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
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))"
TAG_NAME="${GITHUB_REF#refs/tags/}"
REPO_OWNER="${GITHUB_REPOSITORY%/*}"
REPO_NAME="${GITHUB_REPOSITORY#*/}"
# Detect Gitea server URL
GITEA_URL="${GITEA_SERVER_URL:-https://techmos.gitea.org}"
# Create release
RESPONSE=$(curl -s -X POST "${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases" \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d "{
\"tag_name\": \"${TAG_NAME}\",
\"name\": \"${TAG_NAME}\",
\"body\": \"Automated release for ${TAG_NAME}\",
\"draft\": false,
\"prerelease\": false
}")
RELEASE_ID=$(echo "$RESPONSE" | jq -r '.id')
if [ "$RELEASE_ID" = "null" ] || [ -z "$RELEASE_ID" ]; then
echo "Release might already exist, fetching existing release..."
RELEASE_ID=$(curl -s -H "Authorization: token ${GITEA_TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/tags/${TAG_NAME}" | jq -r '.id')
fi
echo "next=${next}" >> "$GITHUB_OUTPUT"
if [ "$RELEASE_ID" = "null" ] || [ -z "$RELEASE_ID" ]; then
echo "Failed to create or find release"
exit 1
fi
echo "Release ID: $RELEASE_ID"
echo "release_id=$RELEASE_ID" >> "$GITHUB_OUTPUT"
id: create-release
- name: Build changelog
id: changelog
- name: Upload APK to Release
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
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 }}
files: ./app-release.apk
generate_release_notes: false
GITEA_URL="${GITEA_URL:-https://techmos.gitea.org}"
REPO_OWNER="${GITHUB_REPOSITORY%/*}"
REPO_NAME="${GITHUB_REPOSITORY#*/}"
RELEASE_ID="${{ steps.create-release.outputs.release_id }}"
TAG_NAME="${GITHUB_REF#refs/tags/}"
for file in artifacts/*; do
if [[ "$file" == *.apk ]]; then
FILENAME="carry-your-live-${TAG_NAME}.apk"
elif [[ "$file" == *.aab ]]; then
FILENAME="carry-your-live-${TAG_NAME}.aab"
else
continue
fi
echo "Uploading $file as $FILENAME..."
curl -s -X POST "${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}/assets" \
-H "Authorization: token ${GITEA_TOKEN}" \
-F "attachment=@${file};filename=${FILENAME}"
echo "Uploaded $FILENAME"
done
+496 -115
View File
@@ -1,15 +1,40 @@
# Carry Your Live - Mobile To-Do & Calendar App
A minimalist, offline-first task management app built with Expo, React Native, and WatermelonDB. Features three primary screens: To-Do List, Add New Task, and Calendar with bottom navigation.
A minimalist, offline-first task management app built with Expo, React Native, and WatermelonDB. Features multiple screens with bottom navigation, social features, and a full-featured backend API.
## 📝 Recent Fixes
## 📝 Recent Progress (v1.1.0 - Unreleased)
- **ListItem component**: Fixed empty component - now properly renders list items with title, subtitle, icons, and swipe actions
- **Subtask drag-and-drop**: Added `parentSubtaskId` type support for proper subtask reparenting
- **Task drag sorting**: Added task drag-and-drop reordering functionality with visual drop indicators
- **iCal import**: Fixed import functionality - replaced invalid `splice()` with proper `create()` calls and fixed date type handling
- **Settings context**: Added `accentOpacity` and `setAccentOpacity` to the Settings context
- **Tag inheritance**: Subtasks no longer automatically inherit parent task tags when converting tasks to subtasks
### Added
- **Friends feature**: Friend requests, accept/decline/remove, user search, full backend routes
- **Assignee field on tasks**: Schema v7, model, types, forms, sync, task actions
- **AssigneeSelector component** and assignee display in TaskItem
- **QuickAddBar** on tasks overview (copied from calendar)
- **Backend**: Mounted friends route, added assigneeId to task validation and sync
### Changed
- Due date prefilled from Calendar "+" button
- 12-hour time formatting across task rows and pickers
- Calendar strip scrolls to selected day when navigating months
- Calendar completion toggle now handles repeating tasks and reminders
- Task color strips now use the task's category color
### Fixed
- WheelTimePicker FlatList scroll clamping - all hours (0-23) and minutes (0-59) reachable
- WheelTimePicker selection highlight background connecting selected item with colon separator
- WheelTimePicker number alignment in scroll list
- Tasks without a due date no longer get overdue styling
- `useOverdueTasks` infinite re-subscription loop (Date.now in effect deps)
- Web/Android crashes from decorator transform ordering (babel config)
- FormButtons crash outside FormProvider; Cancel navigation fallback
- Task repeat settings, reminders and series info now sync to backend
### Removed
- Task color picker (task color now comes from its category)
- Drag lift popup from TaskItem (scale + shadow on drag)
- Checkbox from task items
- Selection bar overlapping QuickAddBar on tasks overview
---
## Features
@@ -17,97 +42,145 @@ A minimalist, offline-first task management app built with Expo, React Native, a
- **Offline-First Architecture**: Full CRUD operations work offline with WatermelonDB + SQLite
- **Auto-Sync**: Background synchronization with custom Postgres API when online
- **Cross-Platform**: iOS, Android, and Web from single codebase
- **Desktop Support**: Electron wrapper for desktop app distribution
### Screens
#### 1. To-Do List Screen
#### 1. Tasks Screen (To-Do List)
- **Header**: App logo + "TODO" title with rounded corners
- **Category Filter**: Horizontal scrollable chips (Work, Personal, School, Shopping, Health, Finance, All)
- Single selection with visual feedback (thicker border, shadow, scale animation)
- Instant task filtering
- **Task List**: Vertical scrolling with:
- Checkbox (empty/checked states with animation)
- **QuickAddBar**: Quick task creation from list view
- **Task List**: Virtualized FlatList with sections (Pending/Completed)
- Task title (truncated with ellipsis)
- Three-dot overflow menu (Edit, Delete, Duplicate, Mark Complete, Change Category, Change Priority)
- Priority badges with colors
- Due date/time with overdue/due-today highlighting
- Completed section (collapsible)
- **Floating Action Button**: Bottom-right, opens Add Task screen with press animation
- Category color indicator strip
- Assignee display
- Swipe actions (left: delete, right: complete)
- Drag-to-reorder with visual drop indicators
- Long press → Selection mode (multi-delete, multi-complete, change category/priority)
- **Floating Action Button**: Bottom-right, opens Add Task screen
#### 2. Add New Task Screen
#### 2. Calendar Screen
- **Date Strip**: Horizontal scrollable days of current month
- Current day highlighted, scrolls to selected day on month change
- **Month Label**: Centered (e.g., "JAN 2026")
- **QuickAddBar**: Quick task creation for selected date
- **Task List**: Tasks for selected day with category color, title, time, completion status
- **Analog Clock Widget**: Decorative analog clock display
- **Empty State**: "No tasks scheduled."
- **Day View**: Detailed view for selected day (pushes to stack)
#### 3. Stats Screen
- Client-side statistics (`useStats` hook)
- Task completion metrics
- Category and priority breakdowns
#### 4. Settings Screen
- **Account**: Login/Register/Sign Out (SyncModal)
- **Server URL Override**: Custom API endpoint configuration
- **Appearance**: Accent color picker (dark mode toggle planned)
- **Task Defaults**: Default category, sort order (Date, Priority, Alphabetically, Creation Date), show completed toggle
- **Calendar**: Days ahead to display
- **Reminders**: Global reminder preference (stored, wiring in progress)
- **Notifications**: Toggle (stored, wiring in progress)
- **Legal**: Privacy Policy, Terms of Service
- **About**: Version info
#### 5. Add New Task Screen
- **Category Selector**: Horizontal row of colored circles (required)
- **Task Name**: Single-line input, 100 char max (required)
- **Subtasks**: Unlimited, add/remove individually
- **Date & Time**: Date picker, time picker, calendar button
- **Subtasks**: Unlimited, add/remove individually, nested subtask support
- **Date & Time**: Date picker, time picker (WheelTimePicker), calendar button
- **Priority**: Dropdown (None, Low, Medium, High, Critical) with color indicators
- **Repeat Profile**: Recurring task configuration
- **Reminder**: Reminder offset selector
- **Assignee**: Assign task to friend
- **Description**: Multi-line text area, 1000 char max
- **Bottom Actions**: Submit (primary) / Cancel (secondary) fixed buttons
#### 3. Calendar Screen
- **Date Strip**: Horizontal scrollable days of current month
- **Month Label**: Centered (e.g., "JAN 2026")
- **Task List**: Tasks for selected day with category color, title, time, completion status
- **Empty State**: "No tasks scheduled."
#### 6. Task Detail Screen
- Full task view with all fields
- Subtask management (add, complete, reorder, convert to task)
- Edit functionality
- Repeat profile configuration
#### 4. Settings Screen
- Dark Mode toggle
- Push Notifications toggle
- Reminder Preferences
- Default Category selector
- Sort Tasks By (Date, Priority, Alphabetically, Creation Date)
- Backup & Sync
- Version, Privacy Policy, Terms of Service
#### 7. Subtask Detail Screen
- Nested subtask management
- Convert subtask ↔ task
- Re-parent subtasks
### Task States
- **Pending**: Empty checkbox, normal text
- **Completed**: Checked checkbox, strikethrough, reduced opacity
- **Overdue**: Red due date, warning indicator
- **Due Today**: Blue highlight
- **High Priority**: Priority badge
#### 8. Sync Modal
- Login/Register forms
- Forgot/Reset password flow
- Server URL configuration
- Sync status display
### Interactions
- Tap task → Edit screen
- Long press → Selection mode (multi-delete, multi-complete, change category/priority)
- Swipe left → Delete
- Swipe right → Complete
- Pull down → Refresh
### Task Features
- **Nested Subtasks**: Unlimited depth, re-parenting, drag reorder
- **Repeating Tasks**: Repeat profiles, series IDs, this/future/all delete scopes, undo-complete
- **Categories/Tags**: 6 default categories with colors, custom category editor
- **Priorities**: None, Low, Medium, High, Critical with color badges
- **Due Dates/Times**: With all-day option
- **Reminders**: Per-task reminder offsets
- **Assignees**: Assign tasks to friends
- **Overdue/Due Today**: Visual highlighting
### Social Features
- **Friends System**: Send/accept/decline/remove friend requests
- **User Search**: Find users by username/email
- **Task Assignment**: Assign tasks to friends (client-side, backend schema ready)
---
## Tech Stack
| Layer | Technology |
|-------|------------|
| Framework | Expo (React Native) |
| Language | TypeScript |
| Framework | Expo (React Native) / Expo Router v3 |
| Language | TypeScript (strict mode) |
| Navigation | Expo Router (file-based) |
| Database | WatermelonDB + SQLite |
| State | WatermelonDB (reactive queries, no Redux needed) |
| Database | WatermelonDB + SQLite (expo-sqlite) |
| State | WatermelonDB reactive queries (no Redux) |
| Forms | React Hook Form + Zod validation |
| UI Components | React Native Paper + Custom SVG icons |
| Animations | React Native Reanimated |
| Date/Time | @react-native-community/datetimepicker + date-fns |
| Build | EAS Build / Gradle (local APK) |
| UI Components | React Native Paper + Custom components |
| Animations | React Native Reanimated v3 |
| Date/Time | @react-native-community/datetimepicker + date-fns + WheelTimePicker |
| Build | EAS Build / Gradle (local APK) / Electron Builder (desktop) |
| CI/CD | Gitea Actions (`.gitea/workflows/build-apk.yml`) |
| Backend | Node.js + Express + TypeScript |
| Backend DB | PostgreSQL + Drizzle ORM |
| Auth | JWT (access + refresh tokens) |
| Validation | Zod (shared with frontend) |
---
## Project Structure
```
carry-your-live/
├── app/
│ ├── _layout.tsx # Root layout + DatabaseProvider
│ ├── _layout.tsx # Root layout + DatabaseProvider + AuthProvider
│ ├── (tabs)/
│ │ ├── _layout.tsx # Bottom tab navigator
│ │ ├── index.tsx # Tasks screen
│ │ ├── calendar.tsx # Calendar screen
│ │ ├── stats.tsx # Stats screen
│ │ └── settings.tsx # Settings screen
│ └── add-task.tsx # Add Task screen
│ ├── add-task.tsx # Add Task screen
│ ├── day-view.tsx # Calendar day detail
│ ├── task-detail.tsx # Task view/edit
│ └── subtask-detail.tsx # Subtask view/edit
├── src/
│ ├── components/ # Reusable UI components
│ ├── components/ # Reusable UI components (30+)
│ │ ├── Header.tsx
│ │ ├── CategoryFilter.tsx
│ │ ├── TaskList.tsx
│ │ ├── TaskItem.tsx
│ │ ├── FloatingActionButton.tsx
│ │ ├── TabBarIcon.tsx
│ │ ├── CategorySelector.tsx
│ │ ├── TaskNameInput.tsx
│ │ ├── SubtasksSection.tsx
@@ -115,24 +188,102 @@ carry-your-live/
│ │ ├── PrioritySelector.tsx
│ │ ├── DescriptionInput.tsx
│ │ ├── FormButtons.tsx
│ │ └── ListItem.tsx
│ │ ├── ListItem.tsx
│ │ ├── AnalogClock.tsx
│ │ ├── AssigneeSelector.tsx
│ │ ├── CategoryEditorModal.tsx
│ │ ├── ColorPickerInput.tsx
│ │ ├── ColorWheel.tsx
│ │ ├── DateTimeInput.tsx
│ │ ├── ForgotPasswordModal.tsx
│ │ ├── FriendsModal.tsx
│ │ ├── LegalModal.tsx
│ │ ├── OptionPickerModal.tsx
│ │ ├── QuickAddBar.tsx
│ │ ├── ReminderSelector.tsx
│ │ ├── RepeatSelector.tsx
│ │ ├── ServerUrlModal.tsx
│ │ ├── SubtaskItem.tsx
│ │ ├── SyncModal.tsx
│ │ ├── SyncStatus.tsx
│ │ ├── TabBarIcon.tsx
│ │ ├── TaskDeleteModal.tsx
│ │ ├── TaskOverflowMenu.tsx
│ │ ├── UpdateNotifier.tsx
│ │ └── WheelTimePicker.tsx
│ ├── constants/
│ │ └── index.ts # Categories, priorities, colors, reminder options
│ ├── database/
│ │ ├── schema.ts # WatermelonDB schema
│ │ ├── schema.ts # WatermelonDB schema (v21)
│ │ ├── index.ts # Database instance + collections
│ │ └── sync.ts # Custom API sync adapter
│ ├── models/
│ │ ├── Task.ts
│ │ ├── Category.ts
│ │ └── Subtask.ts
│ │ ├── sync.ts # Custom API sync adapter (25KB)
│ │ ├── migrations.ts # Schema migrations
│ │ ├── adapter.ts # Platform-specific adapters
│ │ └── tombstones.ts # Deleted record tracking
│ ├── hooks/
│ │ ├── useDatabase.tsx # Database context & providers
│ │ └── useTasks.tsx # Task queries with reactive state
│ ├── constants/
│ │ └── index.ts # Categories, priorities, colors
│ └── types/
│ └── index.ts # TypeScript interfaces
│ │ ├── useTasks.tsx # Task queries with reactive state
│ │ ├── useCategories.tsx # Category queries
│ │ ├── useTasksByDate.tsx # Calendar date queries
│ │ ├── useFriends.tsx # Friends state & actions
│ │ ├── useAuth.tsx # Authentication state
│ │ └── useStats.tsx # Statistics computation
│ ├── models/
│ │ ├── Task.ts # Task model (assigneeId, repeat, reminders)
│ │ ├── Category.ts
│ │ ├── Subtask.ts
│ │ ├── RepeatProfile.ts
│ │ ├── Friendship.ts
│ │ └── Tombstone.ts
│ ├── services/
│ │ ├── api.ts # API client with auth
│ │ ├── auth.ts # Auth token management
│ │ ├── friends.ts # Friends API calls
│ │ ├── notifications.ts # Reminder scheduling (expo-notifications)
│ │ ├── repeat.ts # Repeat task generation
│ │ └── taskActions.ts # Task CRUD + sync operations
│ ├── theme/
│ │ ├── index.ts # Theme context, colors, spacing
│ │ └── theme.tsx # Accent color picker, settings sync
│ ├── types/
│ │ └── index.ts # TypeScript interfaces
│ └── utils/
│ ├── date.ts # Date formatting utilities
│ ├── format.ts # General formatting
│ └── validation.ts # Zod schemas
├── backend/ # Backend API (separate package)
│ ├── src/
│ │ ├── index.ts # Express app entry
│ │ ├── db/
│ │ │ ├── schema.ts # Drizzle schema (Postgres)
│ │ │ └── index.ts # DB connection
│ │ ├── routes/
│ │ │ ├── auth.ts # Register, login, me, forgot/reset password
│ │ │ ├── categories.ts # Category CRUD
│ │ │ ├── tasks.ts # Task CRUD + batch
│ │ │ ├── subtasks.ts # Subtask CRUD
│ │ │ ├── sync.ts # Pull/push sync endpoints
│ │ │ ├── users.ts # User profile & settings
│ │ │ ├── friends.ts # Friend requests, search, management
│ │ │ ├── repeatProfiles.ts# Repeat profile CRUD
│ │ │ └── pushNotifications.ts
│ │ ├── middleware/
│ │ │ ├── auth.ts # JWT verification
│ │ │ ├── errorHandler.ts
│ │ │ └── validation.ts # Zod validation middleware
│ │ ├── services/
│ │ └── utils/
│ ├── drizzle.config.ts
│ └── Dockerfile
├── electron/ # Desktop app entry
├── android/ # Native Android project
├── assets/ # Images, fonts
├── patches/ # patch-package fixes
└── plugins/ # Expo config plugins
```
---
## Data Models
### Task
@@ -144,8 +295,14 @@ carry-your-live/
categoryId: string;
priority: 'none' | 'low' | 'medium' | 'high' | 'critical';
completed: boolean;
dueDate: number; // timestamp
dueTime: string; // "HH:MM"
dueDate: number; // timestamp (ms)
dueTime: string; // "HH:MM" (24h stored, 12h displayed)
allDay: boolean;
assigneeId: string | null;
repeatProfileId: string | null;
seriesId: string | null; // For repeating tasks
isTemplate: boolean; // Series template
reminderOffset: string | null; // e.g., "5m", "1h", "1d"
createdAt: Date;
updatedAt: Date;
subtasks: Subtask[];
@@ -159,6 +316,8 @@ carry-your-live/
name: string;
color: '#HEX';
order: number;
createdAt: Date;
updatedAt: Date;
}
```
@@ -167,14 +326,47 @@ carry-your-live/
{
id: string;
taskId: string;
parentSubtaskId: string | null; // For nesting
title: string;
completed: boolean;
order: number;
assigneeId: string | null;
createdAt: Date;
updatedAt: Date;
}
```
### Repeat Profile
```typescript
{
id: string;
name: string;
frequency: 'daily' | 'weekly' | 'monthly' | 'yearly' | 'custom';
interval: number;
daysOfWeek: number[]; // 0-6 for weekly
dayOfMonth: number | null; // For monthly
monthOfYear: number | null; // For yearly
endDate: number | null; // Timestamp
occurrences: number | null;
createdAt: Date;
updatedAt: Date;
}
```
### Friendship
```typescript
{
id: string;
userId: string; // Requester
friendId: string; // Recipient
status: 'pending' | 'accepted' | 'declined' | 'blocked';
createdAt: Date;
updatedAt: Date;
}
```
---
## Default Categories
| Name | Color | Order |
|------|-------|-------|
@@ -185,6 +377,8 @@ carry-your-live/
| Health | #8E24AA (Purple) | 4 |
| Finance | #FDD835 (Yellow) | 5 |
---
## Priority Colors
| Priority | Color |
|----------|-------|
@@ -194,6 +388,8 @@ carry-your-live/
| High | #E53935 |
| Critical | #B71C1C |
---
## Getting Started
### Prerequisites
@@ -201,15 +397,23 @@ carry-your-live/
- npm or yarn
- Expo CLI (`npm install -g expo-cli`)
- iOS Simulator (Mac) / Android Studio for mobile testing
- PostgreSQL 16+ (or Docker)
### Installation
```bash
cd carry-your-live
npm install
cd ../backend
npm install
```
### Development
#### Frontend
```bash
cd carry-your-live
# Start Expo dev server
npx expo start
@@ -219,6 +423,37 @@ npx expo start --android
npx expo start --web
```
#### Backend
```bash
cd backend
# Start PostgreSQL (Docker)
docker-compose up -d postgres
# Configure environment
cp .env.example .env
# Edit .env with your settings
# Run migrations
npm run db:push
# Start dev server
npm run dev
```
Server runs at `http://localhost:3000` (health check: `GET /health`)
#### Desktop (Electron)
```bash
cd carry-your-live
# Development with live reload
npm run desktop:dev
# Production build
npm run desktop:dist
```
### Building
#### EAS Build (cloud)
@@ -234,18 +469,16 @@ eas build --platform ios
eas build --platform android
eas build --platform web
```
Profiles are defined in `eas.json` (`development`, `preview`, `production`; production auto-increments version code).
Profiles 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)
# Release APK (signed with debug keystore)
./gradlew assembleRelease
```
Output:
@@ -253,24 +486,21 @@ 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):
`.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`)
4. **Debug keystore**: generates `carry-your-live/android/app/debug.keystore` with `keytool`
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:
6. **Upload**: APK saved as `carry-your-live-release` artifact
To run locally exactly as CI does:
```bash
keytool -genkeypair -v \
-keystore carry-your-live/android/app/debug.keystore \
@@ -280,48 +510,119 @@ keytool -genkeypair -v \
cd carry-your-live/android && ./gradlew assembleRelease
```
## Sync API Specification
---
The app expects a custom API at `http://localhost:3000/api` (configurable in `src/database/sync.ts`):
## Backend API Specification
### Pull Changes
```
GET /sync?since={timestamp}
```
Response:
```json
{
"categories": [{ "id", "name", "color", "order", "createdAt", "updatedAt" }],
"tasks": [{ "id", "title", "description", "categoryId", "priority", "completed", "dueDate", "dueTime", "createdAt", "updatedAt" }],
"subtasks": [{ "id", "taskId", "title", "completed", "order", "createdAt", "updatedAt" }],
"timestamp": 1234567890
}
```
Server runs at `http://localhost:3000/api` (configurable in `src/services/api.ts` and `src/database/sync.ts`):
### Push Changes
```
POST /sync/push
```
Body:
```json
{
"changes": {
"categories": [...],
"tasks": [...],
"subtasks": [...]
},
"lastPulledAt": 1234567890
}
```
### Authentication
- `POST /api/auth/register` - Register new user
- `POST /api/auth/login` - Login (returns access + refresh tokens)
- `POST /api/auth/refresh` - Refresh access token
- `POST /api/auth/forgot-password` - Request password reset
- `POST /api/auth/reset-password` - Reset password with token
- `GET /api/auth/me` - Get current user
### Categories
- `GET /api/categories` - List categories
- `POST /api/categories` - Create category
- `PATCH /api/categories/:id` - Update category
- `DELETE /api/categories/:id` - Delete category
### Tasks
- `GET /api/tasks` - List tasks (with filters: category, completed, due date range, assignee)
- `GET /api/tasks/:id` - Get task with subtasks
- `POST /api/tasks` - Create task
- `PATCH /api/tasks/:id` - Update task
- `DELETE /api/tasks/:id` - Delete task (scopes: this/future/all for repeating)
- `POST /api/tasks/batch` - Batch operations
### Subtasks
- `GET /api/subtasks/task/:taskId` - List subtasks for task
- `POST /api/subtasks/task/:taskId` - Create subtask
- `PATCH /api/subtasks/:id` - Update subtask
- `DELETE /api/subtasks/:id` - Delete subtask
### Repeat Profiles
- `GET /api/repeat-profiles` - List repeat profiles
- `POST /api/repeat-profiles` - Create repeat profile
- `PATCH /api/repeat-profiles/:id` - Update repeat profile
- `DELETE /api/repeat-profiles/:id` - Delete repeat profile
### Users
- `GET /api/users/me` - Get user with settings
- `PATCH /api/users/me/settings` - Update settings (darkMode, defaultCategoryId, sortOrder, etc.)
- `GET /api/users/search` - Search users (for friends)
### Friends
- `GET /api/friends` - List accepted friends
- `GET /api/friends/requests` - List pending requests (incoming/outgoing)
- `POST /api/friends/request` - Send friend request
- `PATCH /api/friends/request/:id/accept` - Accept request
- `PATCH /api/friends/request/:id/decline` - Decline request
- `DELETE /api/friends/:id` - Remove friend
### Sync (Offline-first)
- `GET /api/sync?since=<timestamp>` - Pull changes since timestamp
- `POST /api/sync/push` - Push local changes
---
## Sync Protocol
The sync endpoint uses a cursor-based approach with tombstones:
1. **Pull**: Client sends `since` timestamp, server returns all changes since then
```json
{
"categories": [...],
"tasks": [...],
"subtasks": [...],
"repeatProfiles": [...],
"friendships": [...],
"tombstones": [...],
"timestamp": 1234567890
}
```
2. **Push**: Client sends batched changes with `lastPulledAt`
```json
{
"changes": {
"categories": [...],
"tasks": [...],
"subtasks": [...],
"repeatProfiles": [...],
"friendships": [...]
},
"tombstones": [...],
"lastPulledAt": 1234567890
}
```
3. **Conflict Resolution**: Last-write-wins based on `updatedAt` (customizable in `src/database/sync.ts`)
---
## Configuration
### Environment Variables
Create `.env` file:
### Frontend Environment Variables
Create `.env` file in `carry-your-live/`:
```
EXPO_PUBLIC_API_URL=http://your-api-url/api
```
### Backend Environment Variables
Create `.env` file in `backend/`:
```
DATABASE_URL=postgresql://user:pass@localhost:5432/carry_your_live
JWT_SECRET=your-32-char-secret
PORT=3000
NODE_ENV=development
FRONTEND_URL=http://localhost:8081
```
### App Config
Modify `app.json` for:
- App name, slug, version
@@ -329,10 +630,12 @@ Modify `app.json` for:
- Permissions (notifications, etc.)
- Orientation, theme
---
## Visual Style Guide
- **Background**: White (#FFFFFF)
- **Primary Accent**: #E53935 (Pink/Red)
- **Background**: White (#FFFFFF) / Dark mode (planned)
- **Primary Accent**: #E53935 (Pink/Red) - customizable
- **Typography**: Black (#212121) primary, #757575 secondary, #9E9E9E tertiary
- **Borders**: Thin (#E0E0E0), selected #E53935
- **Radius**: 16-20px for cards/buttons/inputs
@@ -340,11 +643,13 @@ Modify `app.json` for:
- **Shadows**: Minimal (elevation 1-3)
- **Animations**: 150-250ms, cubic easing
---
## Offline-First Architecture
```
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ Expo App │────▶│ WatermelonDB │────▶│ Your API │
│ Expo App │────▶│ WatermelonDB │────▶│ Backend │
│ (iOS/Android│ │ (SQLite) │ │ (Postgres) │
│ Web) │◀───▶│ + Sync │◀───▶│ │
└─────────────┘ └──────────────┘ └─────────────┘
@@ -354,6 +659,79 @@ Modify `app.json` for:
2. Reactive queries update UI instantly
3. Background sync pushes/pulls changes
4. Conflict resolution: last-write-wins (customizable)
5. Tombstones track deletions for sync
---
## Current Implementation Status
Based on code audit (August 2026):
| Feature | Status | Notes |
|---------|--------|-------|
| Core CRUD (tasks, subtasks, categories) | ✅ Complete | |
| Nested subtasks | ✅ Complete | Unlimited depth |
| Repeating tasks | ✅ Complete | Profiles, series, this/future/all delete |
| Categories & priorities | ✅ Complete | |
| Calendar with date strip | ✅ Complete | Month nav, day detail |
| Stats screen | ✅ Complete | Client-side only |
| Settings screen | ✅ Complete | Most settings wired |
| Friends system | ✅ Complete | Backend + UI |
| Task assignees | ✅ Complete | Schema v7, UI, sync |
| QuickAddBar | ✅ Complete | Tasks + Calendar |
| Offline sync (WatermelonDB) | ✅ Complete | Schema v21, tombstones |
| Backend API | ✅ Complete | Express + Drizzle + Postgres |
| Auth (JWT) | ✅ Complete | Register, login, refresh, forgot/reset |
| Dark mode / theme toggle | ❌ Missing | Backend column exists, UI not implemented |
| Notifications toggle | 🟡 Partial | Stored but not respected |
| Global reminder preference | 🟡 Partial | Stored but not applied |
| Forgot/reset password UI | 🟡 Partial | Backend done, no client UI |
| Pull-to-refresh → sync | 🟡 Partial | Reloads local only |
| Client settings sync | 🟡 Partial | AsyncStorage only, not synced to backend |
| Reminder enum mismatch | 🟡 Partial | Client uses '5m'/'1h', spec uses minutes |
---
## Roadmap Highlights
### v1.1.0 — Friends & Assignees (Current Unreleased)
- Ship existing Unreleased changes
- Fix notifications toggle, wire up `rescheduleAllReminders()`
- Apply global reminder preference
- Make pull-to-refresh trigger real sync
- Add forgot/reset password UI
- Unify reminder offset enums
### v1.2.0 — Theming & Findability
- Light mode + follow-system-theme
- Sync all settings to `PATCH /users/me/settings`
- Task search (filter + global)
- Friend request badges/indicators
### v1.3.0 — Stats & Insights
- Persisted stats snapshots
- Productivity trends, streaks
- Export stats (CSV/image)
### v1.4.0 — Quick Capture & Calendar UX
- Natural language quick-add
- Drag-and-drop rescheduling in calendar
- Month grid view
- "Today at a glance" dashboard
### v1.5.0 — Power Tools & Personalization
- Task templates, soft-archive, attachments
- Markdown support, deep links
- Biometric lock, haptics, reduced motion
- Android home-screen widget
### v2.0.0 — Collaboration
- Real task assignment delivery
- Shared lists with permissions
- Server push notifications (FCM/APNs)
- Comments/activity feed
---
## Contributing
@@ -362,6 +740,9 @@ Modify `app.json` for:
3. Write components with React Hook Form + Zod
4. Use WatermelonDB decorators for models
5. Test offline scenarios
6. Run `npm run lint` and `npm run typecheck` before committing
---
## License
+15 -3
View File
@@ -104,15 +104,27 @@ android {
keyAlias 'androiddebugkey'
keyPassword 'android'
}
release {
if (project.hasProperty('releaseSigningProperties')) {
def propsFile = file(project.property('releaseSigningProperties'))
if (propsFile.exists()) {
def props = new Properties()
props.load(new FileInputStream(propsFile))
storeFile = file(props['storeFile'])
storePassword = props['storePassword']
keyAlias = props['keyAlias']
keyPassword = props['keyPassword']
}
}
}
}
buildTypes {
debug {
signingConfig = signingConfigs.debug
}
release {
// Caution! In production, you need to generate your own keystore file.
// see https://reactnative.dev/docs/signed-apk-android.
signingConfig = signingConfigs.debug
signingConfig = signingConfigs.release
def enableShrinkResources = findProperty('android.enableShrinkResourcesInReleaseBuilds') ?: 'false'
shrinkResources = enableShrinkResources.toBoolean()
minifyEnabled enableMinifyInReleaseBuilds
+3 -1
View File
@@ -34,7 +34,9 @@
"color": "#1E88E5"
}
],
"./plugins/withQuickAddWidget"
"./plugins/withQuickAddWidget",
"expo-router",
"expo-sqlite"
],
"extra": {
"eas": {
+42 -33
View File
@@ -1,7 +1,7 @@
import { Tabs } from 'expo-router';
import React from 'react';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { View, StyleSheet, TouchableOpacity, Animated, } from 'react-native';
import { View, StyleSheet, TouchableOpacity, Animated } from 'react-native';
import { TabBarIcon } from '@/components/TabBarIcon';
import { useSettings } from '@/theme';
import { setLastVisitedTab } from '@/utils/tabHistory';
@@ -12,7 +12,7 @@ export default function TabLayout() {
return (
<Tabs
screenOptions={{
screenOptions={{
tabBarActiveTintColor: theme.accent,
tabBarInactiveTintColor: theme.textSecondary,
tabBarStyle: {
@@ -40,24 +40,26 @@ screenOptions={{
headerShown: false,
sceneStyle: { backgroundColor: theme.background },
}}
tabBar={(props) => {
tabBar={props => {
return (
<View
style={[
styles.tabBarContainer,
{
backgroundColor: theme.tabBarBg,
paddingBottom: Math.max(props.insets.bottom, 8),
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 4,
},
]}
>
{props.navigation.getState().routes.map((route) => {
const focused = props.navigation.getState().index === props.navigation.getState().routes.indexOf(route);
style={[
styles.tabBarContainer,
{
backgroundColor: theme.tabBarBg,
paddingBottom: Math.max(props.insets.bottom, 8),
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 4,
},
]}
>
{props.navigation.getState().routes.map(route => {
const focused =
props.navigation.getState().index ===
props.navigation.getState().routes.indexOf(route);
return (
<TouchableOpacity
key={route.name}
@@ -67,22 +69,29 @@ screenOptions={{
]}
onPress={() => props.navigation.navigate(route.name as never)}
accessibilityRole="button"
accessibilityLabel={route.name === 'index' ? 'ToDo' : route.name.charAt(0).toUpperCase() + route.name.slice(1)}
accessibilityLabel={
route.name === 'index'
? 'ToDo'
: route.name.charAt(0).toUpperCase() + route.name.slice(1)
}
accessibilityState={{ selected: focused }}
>
<Animated.View
style={[
styles.rippleContainer,
{ opacity: focused ? 1 : 0.5 },
]}
>
<TabBarIcon
name={route.name === 'index' ? 'checklist' : route.name === 'calendar' ? 'calendar' : route.name === 'stats' ? 'stats' : 'gear'}
focused={focused}
color={focused ? theme.accent : theme.textSecondary}
size={24}
/>
</Animated.View>
<Animated.View style={[styles.rippleContainer, { opacity: focused ? 1 : 0.5 }]}>
<TabBarIcon
name={
route.name === 'index'
? 'checklist'
: route.name === 'calendar'
? 'calendar'
: route.name === 'stats'
? 'stats'
: 'gear'
}
focused={focused}
color={focused ? theme.accent : theme.textSecondary}
size={24}
/>
</Animated.View>
</TouchableOpacity>
);
})}
@@ -129,4 +138,4 @@ const styles = StyleSheet.create({
justifyContent: 'center',
position: 'relative',
},
});
});
+358 -240
View File
@@ -1,5 +1,15 @@
import React, { useMemo, useRef, useState, useCallback, useEffect } from 'react';
import { View, Text, StyleSheet, SafeAreaView, ScrollView, TouchableOpacity, Animated, Dimensions, KeyboardAvoidingView } from 'react-native';
import {
View,
Text,
StyleSheet,
SafeAreaView,
ScrollView,
TouchableOpacity,
Animated,
Dimensions,
KeyboardAvoidingView,
} from 'react-native';
import { useRouter, useFocusEffect } from 'expo-router';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import { Header } from '@/components/Header';
@@ -15,7 +25,15 @@ import { OptionPickerModal } from '@/components/OptionPickerModal';
import { TaskItem } from '@/components/TaskItem';
import { SubtaskItem } from '@/components/SubtaskItem';
import Task from '@/models/Task';
import { format, addMonths, addDays, startOfMonth, isSameDay, isSameMonth, isToday } from 'date-fns';
import {
format,
addMonths,
addDays,
startOfMonth,
isSameDay,
isSameMonth,
isToday,
} from 'date-fns';
import Svg, { Path } from 'react-native-svg';
import type { ThemeColors } from '@/theme';
import type { TaskData, SubtaskData } from '@/types';
@@ -24,8 +42,18 @@ const WIDTH = Dimensions.get('window').width;
const GERMAN_WEEKDAYS = ['mo', 'di', 'mi', 'do', 'fr', 'sa', 'so'];
const PRIORITY_RANK: Record<string, number> = { none: 0, low: 1, medium: 2, high: 3, critical: 4 };
const MONTH_NAMES = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December',
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
];
export default function CalendarScreen() {
@@ -70,7 +98,9 @@ export default function CalendarScreen() {
const sorted = [...selectedDayTasks];
switch (sortBy) {
case 'date':
sorted.sort((a, b) => (a.dueDate || Number.MAX_SAFE_INTEGER) - (b.dueDate || Number.MAX_SAFE_INTEGER));
sorted.sort(
(a, b) => (a.dueDate || Number.MAX_SAFE_INTEGER) - (b.dueDate || Number.MAX_SAFE_INTEGER)
);
break;
case 'priority':
sorted.sort((a, b) => PRIORITY_RANK[b.priority] - PRIORITY_RANK[a.priority]);
@@ -118,10 +148,18 @@ export default function CalendarScreen() {
animatingRef.current = true;
const w = gridWidthRef.current || WIDTH;
const target = dir === 1 ? -w : w;
Animated.timing(translateX.current, { toValue: target, duration: 220, useNativeDriver: false }).start(() => {
Animated.timing(translateX.current, {
toValue: target,
duration: 220,
useNativeDriver: false,
}).start(() => {
setVisibleMonth(next);
translateX.current.setValue(-target);
Animated.timing(translateX.current, { toValue: 0, duration: 180, useNativeDriver: false }).start(() => {
Animated.timing(translateX.current, {
toValue: 0,
duration: 180,
useNativeDriver: false,
}).start(() => {
animatingRef.current = false;
});
});
@@ -148,11 +186,11 @@ export default function CalendarScreen() {
.minDistance(6)
.runOnJS(true)
// eslint-disable-next-line react-hooks/refs
.onUpdate((e) => {
.onUpdate(e => {
if (!animatingRef.current) translateX.current.setValue(e.translationX);
})
// eslint-disable-next-line react-hooks/refs
.onEnd((e) => {
.onEnd(e => {
if (animatingRef.current) return;
const w = gridWidthRef.current || WIDTH;
const dx = e.translationX;
@@ -183,22 +221,26 @@ export default function CalendarScreen() {
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) });
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 confirmAndToggleTaskComplete(taskId, () => {
refreshDayTasks();
refreshMonthTasks();
refreshSubtasks();
});
}, [refreshDayTasks, refreshMonthTasks, refreshSubtasks]);
const handleToggleComplete = useCallback(
async (taskId: string) => {
await confirmAndToggleTaskComplete(taskId, () => {
refreshDayTasks();
refreshMonthTasks();
refreshSubtasks();
});
},
[refreshDayTasks, refreshMonthTasks, refreshSubtasks]
);
const [expandedTasks, setExpandedTasks] = useState<Set<string>>(new Set());
const toggleExpand = useCallback((taskId: string) => {
setExpandedTasks((prev) => {
setExpandedTasks(prev => {
const next = new Set(prev);
if (next.has(taskId)) {
next.delete(taskId);
@@ -209,12 +251,15 @@ export default function CalendarScreen() {
});
}, []);
const handleSubtaskToggle = useCallback(async (subtaskId: string) => {
await toggleSubtaskComplete(subtaskId);
refreshDayTasks();
refreshMonthTasks();
refreshSubtasks();
}, [refreshDayTasks, refreshMonthTasks, refreshSubtasks]);
const handleSubtaskToggle = useCallback(
async (subtaskId: string) => {
await toggleSubtaskComplete(subtaskId);
refreshDayTasks();
refreshMonthTasks();
refreshSubtasks();
},
[refreshDayTasks, refreshMonthTasks, refreshSubtasks]
);
return (
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
@@ -229,53 +274,103 @@ export default function CalendarScreen() {
accessibilityLabel="Back"
>
<Svg width={20} height={20} viewBox="0 0 24 24">
<Path d="M15 18l-6-6 6-6" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
<Path
d="M15 18l-6-6 6-6"
stroke="currentColor"
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</Svg>
</TouchableOpacity>
}
/>
<KeyboardAvoidingView style={styles.kbAvoid} behavior="padding">
<ScrollView style={styles.scrollBody} showsVerticalScrollIndicator={false} contentContainerStyle={styles.scrollContent}>
<GestureDetector gesture={pan}>
<Animated.View
onLayout={(e) => {
gridWidthRef.current = e.nativeEvent.layout.width;
}}
// eslint-disable-next-line react-hooks/refs
style={[styles.calendarArea, { transform: [{ translateX: translateX.current }] }]}
>
<ScrollView
style={styles.scrollBody}
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.scrollContent}
>
<GestureDetector gesture={pan}>
<Animated.View
onLayout={e => {
gridWidthRef.current = e.nativeEvent.layout.width;
}}
// eslint-disable-next-line react-hooks/refs
style={[styles.calendarArea, { transform: [{ translateX: translateX.current }] }]}
>
<View style={styles.monthRow}>
<TouchableOpacity
onPress={() => transitionTo(-1)}
style={[styles.monthNav, { borderColor: theme.borderStrong, backgroundColor: theme.card }]}
style={[
styles.monthNav,
{ borderColor: theme.borderStrong, backgroundColor: theme.card },
]}
activeOpacity={0.7}
>
<Svg width={20} height={20} viewBox="0 0 24 24">
<Path d="M15 18l-6-6 6-6" stroke={theme.textSecondary} strokeWidth={2.5} 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>
</TouchableOpacity>
<View style={styles.monthSelectorGroup}>
<TouchableOpacity onPress={() => setMonthPickerVisible(true)} activeOpacity={0.7} style={styles.monthButton}>
<Text style={[styles.monthLabel, { color: theme.text }]}>{format(visibleMonth, 'MMMM')}</Text>
<TouchableOpacity
onPress={() => setMonthPickerVisible(true)}
activeOpacity={0.7}
style={styles.monthButton}
>
<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" />
<Path
d="M6 9l6 6 6-6"
stroke={theme.textSecondary}
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</Svg>
</TouchableOpacity>
</View>
<TouchableOpacity onPress={() => setYearPickerVisible(true)} activeOpacity={0.7} style={styles.yearButton}>
<Text style={[styles.yearLabel, { color: theme.textMuted }]}>{format(visibleMonth, 'yyyy')}</Text>
<TouchableOpacity
onPress={() => setYearPickerVisible(true)}
activeOpacity={0.7}
style={styles.yearButton}
>
<Text style={[styles.yearLabel, { color: theme.textMuted }]}>
{format(visibleMonth, 'yyyy')}
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => transitionTo(1)}
style={[styles.monthNav, { borderColor: theme.borderStrong, backgroundColor: theme.card }]}
style={[
styles.monthNav,
{ borderColor: theme.borderStrong, backgroundColor: theme.card },
]}
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" />
<Path
d="M9 6l6 6-6 6"
stroke={theme.textSecondary}
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</Svg>
</TouchableOpacity>
</View>
@@ -290,7 +385,7 @@ export default function CalendarScreen() {
{weeks.map((week, wi) => (
<View key={wi} style={styles.weekRow}>
{week.map((day) => (
{week.map(day => (
<DayCell
key={day.toISOString()}
day={day}
@@ -306,52 +401,58 @@ export default function CalendarScreen() {
</View>
))}
</Animated.View>
</GestureDetector>
</GestureDetector>
<View style={styles.panelHeader}>
<Text style={[styles.panelDate, { color: theme.text }]}>{format(selectedDate, 'EEEE, MMMM d')}</Text>
{sortedDayTasks.length > 0 && (
<Text style={[styles.panelCount, { color: theme.textFaint }]}>
{sortedDayTasks.length} event{sortedDayTasks.length === 1 ? '' : 's'}
</Text>
)}
</View>
{sortedDayTasks.length === 0 ? (
<View style={styles.emptyState}>
<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>
) : (
sortedDayTasks.map((task) => {
const isExpanded = expandedTasks.has(task.id);
const itemSubtasks = subtasksByTask.get(task.id) ?? [];
return (
<View key={task.id} style={styles.eventCard}>
<CalendarTaskRow
task={task}
expanded={isExpanded}
subtasks={itemSubtasks}
categoryColor={categoryColors.get(task.categoryId)}
onToggle={() => handleToggleComplete(task.id)}
onPress={(id) =>
expandedTasks.has(id)
? router.push({ pathname: '/task-detail', params: { id } })
: toggleExpand(id)
}
onMenuOpen={(id) => openTaskEdit(id)}
onSubtaskToggle={handleSubtaskToggle}
/>
</View>
);
})
<View style={styles.panelHeader}>
<Text style={[styles.panelDate, { color: theme.text }]}>
{format(selectedDate, 'EEEE, MMMM d')}
</Text>
{sortedDayTasks.length > 0 && (
<Text style={[styles.panelCount, { color: theme.textFaint }]}>
{sortedDayTasks.length} event{sortedDayTasks.length === 1 ? '' : 's'}
</Text>
)}
</ScrollView>
</View>
<QuickAddBar
dueDate={selectedDate.getTime()}
placeholder={`Add event for ${format(selectedDate, 'MMM d')}`}
/>
{sortedDayTasks.length === 0 ? (
<View style={styles.emptyState}>
<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>
) : (
sortedDayTasks.map(task => {
const isExpanded = expandedTasks.has(task.id);
const itemSubtasks = subtasksByTask.get(task.id) ?? [];
return (
<View key={task.id} style={styles.eventCard}>
<CalendarTaskRow
task={task}
expanded={isExpanded}
subtasks={itemSubtasks}
categoryColor={categoryColors.get(task.categoryId)}
onToggle={() => handleToggleComplete(task.id)}
onPress={id =>
expandedTasks.has(id)
? router.push({ pathname: '/task-detail', params: { id } })
: toggleExpand(id)
}
onMenuOpen={id => openTaskEdit(id)}
onSubtaskToggle={handleSubtaskToggle}
/>
</View>
);
})
)}
</ScrollView>
<QuickAddBar
dueDate={selectedDate.getTime()}
placeholder={`Add event for ${format(selectedDate, 'MMM d')}`}
/>
</KeyboardAvoidingView>
{modals(() => {})}
@@ -419,7 +520,7 @@ const CalendarTaskRow = React.memo(function CalendarTaskRow({
/>
{expanded && sortedSubtasks.length > 0 && (
<View style={styles.subtaskList}>
{sortedSubtasks.map((sub) => (
{sortedSubtasks.map(sub => (
<SubtaskItem
key={sub.id}
subtask={sub}
@@ -440,7 +541,11 @@ function cellTitle(tasks: Task[]): string | null {
return `${tasks[0].title} +${tasks.length - 1}`;
}
function cellColor(tasks: Task[], categories: { id: string; color: string }[], theme: ThemeColors): string {
function cellColor(
tasks: Task[],
categories: { id: string; color: string }[],
theme: ThemeColors
): string {
if (tasks.length === 0) return theme.textMuted;
return theme.accent;
}
@@ -456,11 +561,23 @@ interface DayCellProps {
onPress: (day: Date) => void;
}
const DayCell = React.memo(function DayCell({ day, label, dotColor, selected, today, inMonth, theme, onPress }: DayCellProps) {
const DayCell = React.memo(function DayCell({
day,
label,
dotColor,
selected,
today,
inMonth,
theme,
onPress,
}: DayCellProps) {
const styles = React.useMemo(() => createStyles(theme), [theme]);
return (
<TouchableOpacity
style={[styles.dayCell, selected && { backgroundColor: theme.accentSoft, borderColor: theme.accentBorder }]}
style={[
styles.dayCell,
selected && { backgroundColor: theme.accentSoft, borderColor: theme.accentBorder },
]}
onPress={() => onPress(day)}
activeOpacity={0.7}
>
@@ -488,152 +605,153 @@ const DayCell = React.memo(function DayCell({ day, label, dotColor, selected, to
);
});
const createStyles = (theme: ThemeColors) => StyleSheet.create({
container: {
flex: 1,
},
scrollContent: {
paddingBottom: 16,
flexGrow: 1,
},
scrollBody: {
flex: 1,
},
kbAvoid: {
flex: 1,
},
calendarArea: {
paddingHorizontal: 12,
paddingTop: 6,
paddingBottom: 4,
},
monthRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 4,
paddingBottom: 14,
},
monthNav: {
width: 36,
height: 36,
borderRadius: 12,
borderWidth: 1,
alignItems: 'center',
justifyContent: 'center',
},
monthSelectorGroup: {
alignItems: 'flex-start',
flex: 1,
},
monthButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
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: theme.text,
},
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: {
fontSize: 15,
fontWeight: '600',
marginBottom: 4,
},
emptySubtext: {
fontSize: 13,
},
backButton: {
padding: 8,
},
eventCard: {
marginHorizontal: 16,
marginTop: 6,
},
subtaskList: {
paddingLeft: 8,
paddingRight: 4,
paddingTop: 4,
},
});
const createStyles = (theme: ThemeColors) =>
StyleSheet.create({
container: {
flex: 1,
},
scrollContent: {
paddingBottom: 16,
flexGrow: 1,
},
scrollBody: {
flex: 1,
},
kbAvoid: {
flex: 1,
},
calendarArea: {
paddingHorizontal: 12,
paddingTop: 6,
paddingBottom: 4,
},
monthRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 4,
paddingBottom: 14,
},
monthNav: {
width: 36,
height: 36,
borderRadius: 12,
borderWidth: 1,
alignItems: 'center',
justifyContent: 'center',
},
monthSelectorGroup: {
alignItems: 'flex-start',
flex: 1,
},
monthButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
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: theme.text,
},
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: {
fontSize: 15,
fontWeight: '600',
marginBottom: 4,
},
emptySubtext: {
fontSize: 13,
},
backButton: {
padding: 8,
},
eventCard: {
marginHorizontal: 16,
marginTop: 6,
},
subtaskList: {
paddingLeft: 8,
paddingRight: 4,
paddingTop: 4,
},
});
+29 -9
View File
@@ -1,5 +1,13 @@
import React, { useCallback, useMemo, useState } from 'react';
import { View, Text, StyleSheet, SafeAreaView, KeyboardAvoidingView, TouchableOpacity, BackHandler } from 'react-native';
import {
View,
Text,
StyleSheet,
SafeAreaView,
KeyboardAvoidingView,
TouchableOpacity,
BackHandler,
} from 'react-native';
import { useFocusEffect } from 'expo-router';
import { Header } from '@/components/Header';
import { CategoryFilter } from '@/components/CategoryFilter';
@@ -39,7 +47,10 @@ export default function TasksScreen() {
<TouchableOpacity
style={[
styles.completedToggle,
{ backgroundColor: theme.card, borderColor: showCompleted ? theme.accent : theme.borderStrong },
{
backgroundColor: theme.card,
borderColor: showCompleted ? theme.accent : theme.borderStrong,
},
]}
onPress={() => setShowCompleted(!showCompleted)}
activeOpacity={0.8}
@@ -57,18 +68,27 @@ export default function TasksScreen() {
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" />
<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 }]}>
<Text
style={[
styles.completedToggleText,
{ color: showCompleted ? theme.text : theme.textMuted },
]}
>
Completed
</Text>
</TouchableOpacity>
</View>
<KeyboardAvoidingView
style={styles.kbAvoid}
behavior="padding"
>
<KeyboardAvoidingView style={styles.kbAvoid} behavior="padding">
<TaskList categoryIds={categoryIds} showCompleted={showCompleted} />
<QuickAddBar />
</KeyboardAvoidingView>
@@ -103,4 +123,4 @@ const styles = StyleSheet.create({
kbAvoid: {
flex: 1,
},
});
});
+216 -62
View File
@@ -1,5 +1,18 @@
import React, { useState, useCallback } from 'react';
import { View, Text, StyleSheet, SafeAreaView, ScrollView, Switch, TouchableOpacity, Alert, Linking, Modal, Pressable, BackHandler } 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 { ListItem } from '@/components/ListItem';
@@ -10,7 +23,15 @@ import { FriendsModal } from '@/components/FriendsModal';
import { LegalModal } from '@/components/LegalModal';
import { ServerUrlModal } from '@/components/ServerUrlModal';
import SyncStatus from '@/components/SyncStatus';
import { useSettings, SORT_OPTIONS, REMINDER_OPTIONS, ACCENT_PRESETS, DEFAULT_ACCENT, DEFAULT_ACCENT_OPACITY } from '@/theme';
import {
useSettings,
SORT_OPTIONS,
REMINDER_OPTIONS,
ACCENT_PRESETS,
DEFAULT_ACCENT,
DEFAULT_ACCENT_OPACITY,
ThemeMode,
} from '@/theme';
import { useCategories } from '@/hooks/useDatabase';
import { getAuthUser, getAuthToken } from '@/services/auth';
import { checkForUpdates, getCurrentAppVersion } from '@/services/updates';
@@ -24,9 +45,38 @@ import { getLastVisitedTab, tabHref } from '@/utils/tabHistory';
export default function SettingsScreen() {
const router = useRouter();
const { theme, notifications, setNotifications, defaultCategoryId, setDefaultCategoryId, sortBy, setSortBy, reminderPreference, setReminderPreference, tagReminderPreferences, setTagReminderPreferences, apiUrl, accentColor, setAccentColor, accentOpacity, setAccentOpacity, todoAheadDays, setTodoAheadDays, showCompleted, setShowCompleted, deleteBySwipe, setDeleteBySwipe } = useSettings();
const {
theme,
notifications,
setNotifications,
defaultCategoryId,
setDefaultCategoryId,
sortBy,
setSortBy,
reminderPreference,
setReminderPreference,
tagReminderPreferences,
setTagReminderPreferences,
apiUrl,
accentColor,
setAccentColor,
accentOpacity,
setAccentOpacity,
todoAheadDays,
setTodoAheadDays,
showCompleted,
setShowCompleted,
deleteBySwipe,
setDeleteBySwipe,
themeMode,
setThemeMode,
syncSettingsToServer,
loadSettingsFromServer,
} = useSettings();
const categories = useCategories();
const [picker, setPicker] = useState<null | 'category' | 'sort' | 'reminder' | 'todoAhead'>(null);
const [picker, setPicker] = useState<
null | 'category' | 'sort' | 'reminder' | 'todoAhead' | 'themeMode'
>(null);
const [reminderTagPicker, setReminderTagPicker] = useState<Category | null>(null);
const [editingCategory, setEditingCategory] = useState<Category | null | 'new'>(null);
const [syncVisible, setSyncVisible] = useState(false);
@@ -84,7 +134,9 @@ export default function SettingsScreen() {
const lastSync = await getLastSyncTime();
if (user) {
setSyncSubtitle(
lastSync ? `Signed in · synced ${formatSyncTime(lastSync)}` : `Signed in as ${user.username}`
lastSync
? `Signed in · synced ${formatSyncTime(lastSync)}`
: `Signed in as ${user.username}`
);
} else {
setSyncSubtitle('Not signed in');
@@ -111,16 +163,15 @@ export default function SettingsScreen() {
}, [refreshSyncStatus, router])
);
const defaultCategory = categories.find((c) => c.id === defaultCategoryId);
const defaultCategory = categories.find(c => c.id === defaultCategoryId);
const defaultCategoryLabel = defaultCategory?.name ?? 'None';
const reminderLabel = REMINDER_OPTIONS.find((o) => o.value === reminderPreference)?.label ?? 'No reminder';
const reminderLabel =
REMINDER_OPTIONS.find(o => o.value === reminderPreference)?.label ?? 'No reminder';
const tagReminderLabel = (categoryId: string) => {
const selected = tagReminderPreferences[categoryId] ?? [];
if (selected.length === 0) return `Default (${reminderLabel})`;
return selected
.map((v) => REMINDER_OPTIONS.find((o) => o.value === v)?.label ?? v)
.join(', ');
return selected.map(v => REMINDER_OPTIONS.find(o => o.value === v)?.label ?? v).join(', ');
};
const openCustomAccent = () => {
@@ -162,14 +213,12 @@ export default function SettingsScreen() {
<Header title="Settings" showLogo={false} />
<ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
<Text style={[styles.sectionTitle, { color: theme.textMuted }]}>Tags</Text>
{categories.map((category) => (
{categories.map(category => (
<ListItem
key={category.id}
title={category.name}
subtitle={category.id === defaultCategoryId ? 'Default tag' : undefined}
leftElement={
<View style={[styles.categoryDot, { backgroundColor: category.color }]} />
}
leftElement={<View style={[styles.categoryDot, { backgroundColor: category.color }]} />}
onPress={() => setEditingCategory(category)}
showChevron
/>
@@ -181,7 +230,12 @@ export default function SettingsScreen() {
>
<View style={[styles.addIcon, { backgroundColor: theme.accentSoft }]}>
<Svg width={16} height={16} viewBox="0 0 24 24">
<Path d="M12 5v14M5 12h14" stroke={theme.accentText} strokeWidth={2} strokeLinecap="round" />
<Path
d="M12 5v14M5 12h14"
stroke={theme.accentText}
strokeWidth={2}
strokeLinecap="round"
/>
</Svg>
</View>
<Text style={[styles.addCategoryText, { color: theme.accent }]}>Add Tag</Text>
@@ -193,7 +247,18 @@ export default function SettingsScreen() {
rightElement={
<Switch
value={notifications}
onValueChange={setNotifications}
onValueChange={async value => {
setNotifications(value);
if (!value) {
const { cancelAllReminders } = await import('@/services/notifications');
await cancelAllReminders();
} else {
const { rescheduleAllReminders } = await import('@/services/notifications');
const { collections } = await import('@/database');
const tasks = await collections.tasks.query().fetch();
await rescheduleAllReminders(tasks, true, reminderPreference);
}
}}
thumbColor="#FFFFFF"
trackColor={{ false: theme.borderStrong, true: theme.accent }}
/>
@@ -205,14 +270,12 @@ export default function SettingsScreen() {
onPress={() => notifications && setPicker('reminder')}
showChevron
/>
{categories.map((category) => (
{categories.map(category => (
<ListItem
key={`tag-reminder-${category.id}`}
title={category.name}
subtitle={notifications ? tagReminderLabel(category.id) : 'Enable notifications first'}
leftElement={
<View style={[styles.categoryDot, { backgroundColor: category.color }]} />
}
leftElement={<View style={[styles.categoryDot, { backgroundColor: category.color }]} />}
onPress={() => notifications && setReminderTagPicker(category)}
showChevron
/>
@@ -226,7 +289,7 @@ export default function SettingsScreen() {
/>
<ListItem
title="Sort Tasks By"
subtitle={SORT_OPTIONS.find((o) => o.value === sortBy)?.label ?? 'Date'}
subtitle={SORT_OPTIONS.find(o => o.value === sortBy)?.label ?? 'Date'}
onPress={() => setPicker('sort')}
showChevron
/>
@@ -264,7 +327,7 @@ export default function SettingsScreen() {
<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) => (
{ACCENT_PRESETS.map(c => (
<TouchableOpacity
key={c}
style={[
@@ -277,7 +340,14 @@ export default function SettingsScreen() {
>
{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" />
<Path
d="M5 12l5 5 9-10"
stroke={contrastOnSwatch(c)}
strokeWidth={3}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</Svg>
)}
</TouchableOpacity>
@@ -290,6 +360,15 @@ export default function SettingsScreen() {
onPress={openCustomAccent}
showChevron
/>
<Text style={[styles.sectionHint, { color: theme.textFaint }]}>Theme</Text>
<ListItem
title="Theme Mode"
subtitle={
themeMode === 'system' ? 'System default' : themeMode === 'dark' ? 'Dark' : 'Light'
}
onPress={() => setPicker('themeMode')}
showChevron
/>
<Text style={[styles.sectionTitle, { color: theme.textMuted }]}>Data</Text>
<SyncStatus />
<ListItem
@@ -298,6 +377,32 @@ export default function SettingsScreen() {
onPress={() => setSyncVisible(true)}
showChevron
/>
<ListItem
title="Sync Settings to Server"
subtitle="Push local settings to backend"
onPress={async () => {
try {
await syncSettingsToServer();
Alert.alert('Success', 'Settings synced to server');
} catch {
Alert.alert('Error', 'Failed to sync settings');
}
}}
showChevron
/>
<ListItem
title="Load Settings from Server"
subtitle="Pull settings from backend"
onPress={async () => {
try {
await loadSettingsFromServer();
Alert.alert('Success', 'Settings loaded from server');
} catch {
Alert.alert('Error', 'Failed to load settings');
}
}}
showChevron
/>
<ListItem
title="Friends"
subtitle="Manage friends and requests"
@@ -318,30 +423,19 @@ export default function SettingsScreen() {
onPress={handleCheckUpdates}
showChevron
/>
<ListItem
title="Last checked"
subtitle={lastUpdateCheck ?? 'Never'}
/>
<ListItem
title="Version"
subtitle={getCurrentAppVersion()}
/>
<ListItem
title="Privacy Policy"
onPress={() => setLegalVisible('privacy')}
showChevron
/>
<ListItem
title="Terms of Service"
onPress={() => setLegalVisible('terms')}
showChevron
/>
<ListItem title="Last checked" subtitle={lastUpdateCheck ?? 'Never'} />
<ListItem title="Version" subtitle={getCurrentAppVersion()} />
<ListItem title="Privacy Policy" onPress={() => setLegalVisible('privacy')} showChevron />
<ListItem title="Terms of Service" onPress={() => setLegalVisible('terms')} showChevron />
</ScrollView>
<OptionPickerModal
visible={picker === 'category'}
title="Default Tag"
options={[{ value: '', label: 'None', color: theme.textMuted }, ...categories.map((c) => ({ value: c.id, label: c.name, color: c.color }))]}
options={[
{ value: '', label: 'None', color: theme.textMuted },
...categories.map(c => ({ value: c.id, label: c.name, color: c.color })),
]}
selectedValue={defaultCategoryId}
onSelect={handleDefaultCategory}
onClose={() => setPicker(null)}
@@ -352,7 +446,7 @@ export default function SettingsScreen() {
title="Sort Tasks By"
options={SORT_OPTIONS}
selectedValue={sortBy}
onSelect={(value) => setSortBy(value as typeof sortBy)}
onSelect={value => setSortBy(value as typeof sortBy)}
onClose={() => setPicker(null)}
/>
@@ -365,15 +459,15 @@ export default function SettingsScreen() {
onClose={() => setPicker(null)}
/>
{categories.map((category) => (
{categories.map(category => (
<OptionPickerModal
key={`tag-reminder-picker-${category.id}`}
visible={reminderTagPicker?.id === category.id}
title={`Reminders · ${category.name}`}
options={REMINDER_OPTIONS.filter((o) => o.value !== 'none')}
options={REMINDER_OPTIONS.filter(o => o.value !== 'none')}
selectedValue={tagReminderPreferences[category.id] ?? []}
multiSelect
onSelect={(value) => handleTagReminderPreference(category.id, value)}
onSelect={value => handleTagReminderPreference(category.id, value)}
onClose={() => setReminderTagPicker(null)}
/>
))}
@@ -387,6 +481,15 @@ export default function SettingsScreen() {
onClose={() => setPicker(null)}
/>
<OptionPickerModal
visible={picker === 'themeMode'}
title="Theme Mode"
options={THEME_MODE_OPTIONS}
selectedValue={themeMode}
onSelect={value => setThemeMode(value as ThemeMode)}
onClose={() => setPicker(null)}
/>
<CategoryEditorModal
visible={editorVisible}
category={editorCategory}
@@ -394,41 +497,86 @@ export default function SettingsScreen() {
onClose={() => setEditingCategory(null)}
/>
<SyncModal visible={syncVisible} onClose={() => { setSyncVisible(false); refreshSyncStatus(); }} />
<SyncModal
visible={syncVisible}
onClose={() => {
setSyncVisible(false);
refreshSyncStatus();
}}
/>
<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={() => {}}>
<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 }}>
<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" />
<Path
d="M18 6L6 18M6 6l12 12"
stroke={theme.textSecondary}
strokeWidth={2.2}
strokeLinecap="round"
/>
</Svg>
</TouchableOpacity>
</View>
<ColorWheel color={draftAccent} opacity={draftAccentOpacity} onChange={(color, opacity) => { setDraftAccent(color); setDraftAccentOpacity(opacity); }} />
<ColorWheel
color={draftAccent}
opacity={draftAccentOpacity}
onChange={(color, opacity) => {
setDraftAccent(color);
setDraftAccentOpacity(opacity);
}}
/>
<View style={styles.accentModalActions}>
<TouchableOpacity
style={[styles.accentModalButton, { borderColor: theme.borderStrong }]}
onPress={() => { setDraftAccent(DEFAULT_ACCENT); setDraftAccentOpacity(DEFAULT_ACCENT_OPACITY); setAccentColor(DEFAULT_ACCENT); setAccentOpacity(DEFAULT_ACCENT_OPACITY); }}
onPress={() => {
setDraftAccent(DEFAULT_ACCENT);
setDraftAccentOpacity(DEFAULT_ACCENT_OPACITY);
setAccentColor(DEFAULT_ACCENT);
setAccentOpacity(DEFAULT_ACCENT_OPACITY);
}}
activeOpacity={0.7}
>
<Text style={[styles.accentModalButtonText, { color: theme.textSecondary }]}>Reset</Text>
<Text style={[styles.accentModalButtonText, { color: theme.textSecondary }]}>
Reset
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.accentModalButton, styles.accentModalButtonPrimary, { backgroundColor: theme.accent }]}
onPress={() => { setAccentColor(draftAccent); setAccentOpacity(draftAccentOpacity); setCustomAccentVisible(false); }}
style={[
styles.accentModalButton,
styles.accentModalButtonPrimary,
{ backgroundColor: theme.accent },
]}
onPress={() => {
setAccentColor(draftAccent);
setAccentOpacity(draftAccentOpacity);
setCustomAccentVisible(false);
}}
activeOpacity={0.8}
>
<Text style={[styles.accentModalButtonText, { color: theme.accentText }]}>Apply</Text>
<Text style={[styles.accentModalButtonText, { color: theme.accentText }]}>
Apply
</Text>
</TouchableOpacity>
</View>
</Pressable>
@@ -449,6 +597,12 @@ const AHEAD_OPTIONS: { value: string; label: string }[] = Array.from({ length: 2
label: i === 0 ? 'Only today' : `${i} day${i === 1 ? '' : 's'}`,
}));
const THEME_MODE_OPTIONS: { value: ThemeMode; label: string }[] = [
{ value: 'light', label: 'Light' },
{ value: 'dark', label: 'Dark' },
{ value: 'system', label: 'System default' },
];
function formatSyncTime(timestamp: number): string {
const seconds = Math.floor((Date.now() - timestamp) / 1000);
if (seconds < 60) return 'just now';
+35 -13
View File
@@ -15,7 +15,7 @@ export default function StatsScreen() {
}, [])
);
const maxDaily = Math.max(1, ...stats.daily.map((d) => d.count));
const maxDaily = Math.max(1, ...stats.daily.map(d => d.count));
return (
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
@@ -25,27 +25,42 @@ export default function StatsScreen() {
<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.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.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.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.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.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 }]}>
<Text
style={[
styles.summaryValue,
{ color: stats.overdueCount > 0 ? theme.accent : theme.text },
]}
>
{stats.overdueCount}
</Text>
<Text style={[styles.summaryLabel, { color: theme.textMuted }]}>Overdue</Text>
@@ -64,7 +79,10 @@ export default function StatsScreen() {
<View
style={[
styles.chartBar,
{ height: Math.max(4, height), backgroundColor: d.count > 0 ? theme.accent : theme.cardAlt },
{
height: Math.max(4, height),
backgroundColor: d.count > 0 ? theme.accent : theme.cardAlt,
},
]}
/>
</View>
@@ -78,9 +96,11 @@ export default function StatsScreen() {
<View style={[styles.card, { backgroundColor: theme.card }]}>
<Text style={[styles.cardTitle, { color: theme.textMuted }]}>By tags</Text>
{stats.byCategory.length === 0 ? (
<Text style={[styles.emptyText, { color: theme.textFaint }]}>No completed tasks yet</Text>
<Text style={[styles.emptyText, { color: theme.textFaint }]}>
No completed tasks yet
</Text>
) : (
stats.byCategory.map((c) => (
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>
@@ -93,9 +113,11 @@ export default function StatsScreen() {
<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>
<Text style={[styles.emptyText, { color: theme.textFaint }]}>
No completed tasks yet
</Text>
) : (
stats.byPriority.map((p) => (
stats.byPriority.map(p => (
<View key={p.priority} style={styles.row}>
<View style={[styles.dot, { backgroundColor: priorityColor(theme, p.priority) }]} />
<Text style={[styles.rowLabel, { color: theme.text }]}>{p.label}</Text>
@@ -206,4 +228,4 @@ const styles = StyleSheet.create({
emptyText: {
fontSize: 14,
},
});
});
+10 -3
View File
@@ -8,7 +8,12 @@ import { UpdateNotifier } from '@/components/UpdateNotifier';
import { Linking } from 'react-native';
import { useEffect } from 'react';
import { requestQuickAddFocus } from '@/utils/quickAddFocus';
import { registerForPushNotificationsAsync, setupNotificationChannels, addNotificationResponseReceivedListener, removeNotificationSubscription } from '@/services/pushNotifications';
import {
registerForPushNotificationsAsync,
setupNotificationChannels,
addNotificationResponseReceivedListener,
removeNotificationSubscription,
} from '@/services/pushNotifications';
import * as Notifications from 'expo-notifications';
Notifications.setNotificationHandler({
@@ -28,7 +33,7 @@ function RootNavigator() {
setupNotificationChannels();
registerForPushNotificationsAsync().catch(() => {});
const responseListener = addNotificationResponseReceivedListener((response) => {
const responseListener = addNotificationResponseReceivedListener(response => {
const data = response.notification.request.content.data as Record<string, any> | undefined;
if (!data) return;
@@ -53,7 +58,9 @@ function RootNavigator() {
requestQuickAddFocus();
};
Linking.getInitialURL().then(handleUrl).catch(() => {});
Linking.getInitialURL()
.then(handleUrl)
.catch(() => {});
const subscription = Linking.addEventListener('url', ({ url }) => handleUrl(url));
const handleKeyDown = (event: KeyboardEvent) => {
+20 -13
View File
@@ -1,5 +1,14 @@
import React, { useMemo, useRef } from 'react';
import { View, Text, StyleSheet, SafeAreaView, ScrollView, KeyboardAvoidingView, Platform, Keyboard } from 'react-native';
import {
View,
Text,
StyleSheet,
SafeAreaView,
ScrollView,
KeyboardAvoidingView,
Platform,
Keyboard,
} from 'react-native';
import { Header } from '@/components/Header';
import { CategorySelector } from '@/components/CategorySelector';
import { TaskNameInput } from '@/components/TaskNameInput';
@@ -112,15 +121,16 @@ export default function AddTaskScreen() {
const now = new Date();
const dueDateTimestamp = data.dueDate ? data.dueDate.getTime() : 0;
const seriesId = data.repeat !== 'none'
? `series_${Math.random().toString(36).slice(2, 10)}${Date.now().toString(36)}`
: '';
const seriesId =
data.repeat !== 'none'
? `series_${Math.random().toString(36).slice(2, 10)}${Date.now().toString(36)}`
: '';
const resolvedCategoryId = (data.tags && data.tags[0]) || '';
let createdTask: any = null;
await database.write(async () => {
const task = await collections.tasks.create((t) => {
const task = await collections.tasks.create(t => {
t.title = data.title.trim();
t.description = data.description || '';
t.categoryId = resolvedCategoryId;
@@ -146,7 +156,7 @@ export default function AddTaskScreen() {
for (let i = 0; i < data.subtasks.length; i++) {
const subtask = data.subtasks[i];
if (subtask.title.trim()) {
await collections.subtasks.create((s) => {
await collections.subtasks.create(s => {
s.taskId = task.id;
s.title = subtask.title.trim();
s.description = '';
@@ -198,7 +208,7 @@ export default function AddTaskScreen() {
>
<CategorySelector
value={tags}
onChange={(value) => setValue('tags', value)}
onChange={value => setValue('tags', value)}
error={errors.categoryId?.message}
/>
<Controller
@@ -214,10 +224,7 @@ export default function AddTaskScreen() {
)}
/>
<SubtasksSection control={control} />
<PrioritySelector
value={priority}
onChange={(value) => setValue('priority', value)}
/>
<PrioritySelector value={priority} onChange={value => setValue('priority', value)} />
<DateTimePickerComponent control={control} />
<RepeatSelector
value={repeat}
@@ -232,12 +239,12 @@ export default function AddTaskScreen() {
<ReminderSelector
value={reminders}
hasDueDate={!!dueDate}
onChange={(value) => setValue('reminders', value)}
onChange={value => setValue('reminders', value)}
/>
<AssigneeSelector
value={assigneeId}
onChange={(value: string | null) => setValue('assigneeId', value)}
friends={friends.map((f) => f.username)}
friends={friends.map(f => f.username)}
/>
<Controller
control={control}
+229 -118
View File
@@ -1,5 +1,14 @@
import React, { useCallback, useMemo } from 'react';
import { View, Text, StyleSheet, SafeAreaView, ScrollView, TouchableOpacity, KeyboardAvoidingView, BackHandler } from 'react-native';
import {
View,
Text,
StyleSheet,
SafeAreaView,
ScrollView,
TouchableOpacity,
KeyboardAvoidingView,
BackHandler,
} from 'react-native';
import { useRouter, useLocalSearchParams, useFocusEffect } from 'expo-router';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Header } from '@/components/Header';
@@ -17,7 +26,11 @@ import { desaturate, type ThemeColors } from '@/theme';
function isDayMatch(timestamp: number, day: Date): boolean {
const d = new Date(timestamp);
return d.getFullYear() === day.getFullYear() && d.getMonth() === day.getMonth() && d.getDate() === day.getDate();
return (
d.getFullYear() === day.getFullYear() &&
d.getMonth() === day.getMonth() &&
d.getDate() === day.getDate()
);
}
export default function DayViewScreen() {
@@ -53,45 +66,68 @@ export default function DayViewScreen() {
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));
const due = roots.filter(s => s.dueDate && isDayMatch(s.dueDate, day));
if (due.length > 0) map.set(taskId, due);
}
return map;
}, [subtasksByTask, day]);
const handleToggleComplete = useCallback(async (taskId: string) => {
await confirmAndToggleTaskComplete(taskId, () => {
refresh();
refreshSubtasks();
});
}, [refresh, refreshSubtasks]);
const handleToggleComplete = useCallback(
async (taskId: string) => {
await confirmAndToggleTaskComplete(taskId, () => {
refresh();
refreshSubtasks();
});
},
[refresh, refreshSubtasks]
);
const handleToggleSubtask = useCallback(async (subtaskId: string) => {
await toggleSubtaskComplete(subtaskId);
refreshSubtasks();
}, [refreshSubtasks]);
const handleToggleSubtask = useCallback(
async (subtaskId: string) => {
await toggleSubtaskComplete(subtaskId);
refreshSubtasks();
},
[refreshSubtasks]
);
return (
<SafeAreaView style={[styles.container, { backgroundColor: theme.background, paddingBottom: insets.bottom }]}>
<SafeAreaView
style={[
styles.container,
{ backgroundColor: theme.background, paddingBottom: insets.bottom },
]}
>
<Header title={format(day, 'EEEE, MMM d')} showLogo={false} rightAction="Back" />
<KeyboardAvoidingView style={styles.kbAvoid} behavior="padding">
<ScrollView style={styles.scrollBody} contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}>
<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>
<Text style={[styles.emptyText, { color: theme.textSecondary }]}>
No tasks on this day
</Text>
</View>
) : (
<>
{dayTasks.map((task) => {
{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);
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
key={task.id}
style={[
styles.eventCard,
{ backgroundColor: theme.card, borderColor: theme.border },
]}
>
<View style={styles.eventRow}>
<TouchableOpacity
style={styles.checkCircle}
@@ -104,41 +140,76 @@ export default function DayViewScreen() {
{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" />
<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" />
<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 } })}
onPress={() =>
router.push({ pathname: '/task-detail', params: { id: task.id } })
}
activeOpacity={0.7}
>
<Text
style={[styles.eventTitle, { color: theme.text }, task.completed && styles.eventCompleted]}
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}>
<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
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>
<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>
<Text style={[styles.timeText, { color: theme.textSecondary }]}>
{format(task.dueDate, 'HH:mm')}
</Text>
) : null}
</View>
)}
@@ -152,7 +223,14 @@ export default function DayViewScreen() {
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" />
<Path
d="M9 6l6 6-6 6"
stroke={theme.textSecondary}
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</Svg>
</TouchableOpacity>
</View>
@@ -160,10 +238,16 @@ export default function DayViewScreen() {
);
})}
{Array.from(subtasksOnDay.entries()).flatMap(([taskId, subs]) => {
const parent = dayTasks.find((t) => t.id === taskId);
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 }]}>
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}
@@ -176,26 +260,49 @@ export default function DayViewScreen() {
{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" />
<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" />
<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 } })}
onPress={() =>
router.push({ pathname: '/subtask-detail', params: { id: sub.id } })
}
activeOpacity={0.7}
>
<Text
style={[styles.eventTitle, { color: theme.text }, sub.completed && styles.eventCompleted]}
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>
<Text style={[styles.timeText, { color: theme.textSecondary }]}>
{sub.dueTime}
{sub.endTime ? `–${sub.endTime}` : ''}
</Text>
) : null}
</TouchableOpacity>
</View>
@@ -205,89 +312,93 @@ export default function DayViewScreen() {
</>
)}
</ScrollView>
<QuickAddBar dueDate={dayStart.getTime()} placeholder={`Add event for ${format(day, 'MMM d')}`} />
<QuickAddBar
dueDate={dayStart.getTime()}
placeholder={`Add event for ${format(day, 'MMM d')}`}
/>
</KeyboardAvoidingView>
{modals(() => {})}
</SafeAreaView>
);
}
const createStyles = (theme: ThemeColors) => StyleSheet.create({
container: {
flex: 1,
},
kbAvoid: {
flex: 1,
},
scrollBody: {
flex: 1,
},
scrollContent: {
paddingTop: 12,
paddingBottom: 16,
},
emptyState: {
alignItems: 'center',
paddingVertical: 64,
},
emptyText: {
fontSize: 15,
fontWeight: '600',
},
eventCard: {
marginHorizontal: 16,
marginBottom: 8,
borderRadius: 16,
borderWidth: 1,
paddingHorizontal: 14,
paddingVertical: 12,
},
eventRow: {
flexDirection: 'row',
alignItems: 'center',
},
checkCircle: {
width: 30,
marginRight: 12,
},
eventTouch: {
flex: 1,
},
eventTitle: {
fontSize: 15,
fontWeight: '500',
},
eventCompleted: {
textDecorationLine: 'line-through',
color: theme.textMuted,
},
eventSub: {
fontSize: 13,
marginTop: 2,
},
metaRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
marginTop: 4,
},
tagChip: {
paddingHorizontal: 7,
paddingVertical: 2,
borderRadius: 6,
maxWidth: 140,
},
tagText: {
color: '#FFFFFF',
fontSize: 10,
fontWeight: '600',
},
timeText: {
fontSize: 12.5,
fontWeight: '500',
},
menuButton: {
padding: 4,
marginLeft: 6,
},
});
const createStyles = (theme: ThemeColors) =>
StyleSheet.create({
container: {
flex: 1,
},
kbAvoid: {
flex: 1,
},
scrollBody: {
flex: 1,
},
scrollContent: {
paddingTop: 12,
paddingBottom: 16,
},
emptyState: {
alignItems: 'center',
paddingVertical: 64,
},
emptyText: {
fontSize: 15,
fontWeight: '600',
},
eventCard: {
marginHorizontal: 16,
marginBottom: 8,
borderRadius: 16,
borderWidth: 1,
paddingHorizontal: 14,
paddingVertical: 12,
},
eventRow: {
flexDirection: 'row',
alignItems: 'center',
},
checkCircle: {
width: 30,
marginRight: 12,
},
eventTouch: {
flex: 1,
},
eventTitle: {
fontSize: 15,
fontWeight: '500',
},
eventCompleted: {
textDecorationLine: 'line-through',
color: theme.textMuted,
},
eventSub: {
fontSize: 13,
marginTop: 2,
},
metaRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
marginTop: 4,
},
tagChip: {
paddingHorizontal: 7,
paddingVertical: 2,
borderRadius: 6,
maxWidth: 140,
},
tagText: {
color: '#FFFFFF',
fontSize: 10,
fontWeight: '600',
},
timeText: {
fontSize: 12.5,
fontWeight: '500',
},
menuButton: {
padding: 4,
marginLeft: 6,
},
});
+54 -17
View File
@@ -1,5 +1,15 @@
import React from 'react';
import { View, Text, StyleSheet, SafeAreaView, ScrollView, KeyboardAvoidingView, Platform, TouchableOpacity, Alert } from 'react-native';
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';
@@ -15,7 +25,14 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useDatabase } from '@/hooks/useDatabase';
import { collections } from '@/database';
import { SubtaskFormData, Reminder, parseReminders, toRemindersString, parseTaskTags, tagsToString } from '@/types';
import {
SubtaskFormData,
Reminder,
parseReminders,
toRemindersString,
parseTaskTags,
tagsToString,
} from '@/types';
import { useSettings } from '@/theme';
import { updateSubtask, deleteSubtask } from '@/utils/taskActions';
import { CategorySelector } from '@/components/CategorySelector';
@@ -68,7 +85,14 @@ export default function SubtaskDetailScreen() {
},
});
const { handleSubmit, watch, setValue, control, reset, formState: { errors } } = methods;
const {
handleSubmit,
watch,
setValue,
control,
reset,
formState: { errors },
} = methods;
const priority = watch('priority');
const tags = watch('tags') ?? [];
@@ -98,7 +122,10 @@ export default function SubtaskDetailScreen() {
allDay: subtask.allDay ?? false,
repeat: subtask.repeat,
repeatInterval: subtask.repeatInterval || 1,
repeatDays: ((subtask.repeatDays || '') as string).split(',').map(Number).filter((d) => !Number.isNaN(d)),
repeatDays: ((subtask.repeatDays || '') as string)
.split(',')
.map(Number)
.filter(d => !Number.isNaN(d)),
reminder: (subtask.reminder || 'none') as Reminder,
reminders: subtask.reminders || '',
assigneeId: subtask.assigneeId ?? null,
@@ -160,7 +187,9 @@ export default function SubtaskDetailScreen() {
if (!isReady || !loaded) {
return (
<View style={[styles.loadingContainer, { backgroundColor: theme.background }]}>
<Text style={{ color: theme.textFaint }}>{notFound ? 'Subtask not found' : 'Loading...'}</Text>
<Text style={{ color: theme.textFaint }}>
{notFound ? 'Subtask not found' : 'Loading...'}
</Text>
</View>
);
}
@@ -171,9 +200,23 @@ export default function SubtaskDetailScreen() {
title="Edit Subtask"
showLogo={true}
rightAction={
<TouchableOpacity onPress={confirmDelete} activeOpacity={0.7} style={[styles.deleteButton, { borderColor: theme.accentBorder, backgroundColor: theme.accentSoft }]}>
<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" />
<Path
d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"
stroke={theme.accent}
strokeWidth={2.2}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</Svg>
</TouchableOpacity>
}
@@ -201,14 +244,8 @@ export default function SubtaskDetailScreen() {
/>
)}
/>
<CategorySelector
value={tags}
onChange={(value) => setValue('tags', value)}
/>
<PrioritySelector
value={priority}
onChange={(value) => setValue('priority', value)}
/>
<CategorySelector value={tags} onChange={value => setValue('tags', value)} />
<PrioritySelector value={priority} onChange={value => setValue('priority', value)} />
<DateTimePickerComponent control={control as any} />
<RepeatSelector
value={repeat}
@@ -223,12 +260,12 @@ export default function SubtaskDetailScreen() {
<ReminderSelector
value={reminders}
hasDueDate={!!dueDate}
onChange={(value) => setValue('reminders', value)}
onChange={value => setValue('reminders', value)}
/>
<AssigneeSelector
value={assigneeId}
onChange={(value: string | null) => setValue('assigneeId', value)}
friends={friends.map((f) => f.username)}
friends={friends.map(f => f.username)}
/>
<Controller
control={control}
+165 -64
View File
@@ -1,5 +1,15 @@
import React, { useRef } from 'react';
import { View, Text, StyleSheet, SafeAreaView, ScrollView, KeyboardAvoidingView, Platform, TouchableOpacity, Keyboard } from 'react-native';
import {
View,
Text,
StyleSheet,
SafeAreaView,
ScrollView,
KeyboardAvoidingView,
Platform,
TouchableOpacity,
Keyboard,
} from 'react-native';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { Header } from '@/components/Header';
import { CategorySelector } from '@/components/CategorySelector';
@@ -47,13 +57,27 @@ const taskSchema = z.object({
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 }) {
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}>
<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>
@@ -106,7 +130,14 @@ export default function TaskDetailScreen() {
},
});
const { handleSubmit, watch, setValue, control, reset, formState: { errors } } = methods;
const {
handleSubmit,
watch,
setValue,
control,
reset,
formState: { errors },
} = methods;
const tags = watch('tags') ?? [];
const priority = watch('priority');
@@ -129,7 +160,8 @@ export default function TaskDetailScreen() {
const subtasks = await collections.subtasks.query(Q.where('task_id', id)).fetch();
if (!mounted) return;
const remindersValue = task.reminders || (task.reminder && task.reminder !== 'none' ? task.reminder : '');
const remindersValue =
task.reminders || (task.reminder && task.reminder !== 'none' ? task.reminder : '');
reset({
title: task.title,
@@ -143,11 +175,14 @@ export default function TaskDetailScreen() {
allDay: task.allDay ?? false,
repeat: task.repeat,
repeatInterval: task.repeatInterval || 1,
repeatDays: (task.repeatDays || '').split(',').map(Number).filter((d) => !Number.isNaN(d)),
repeatDays: (task.repeatDays || '')
.split(',')
.map(Number)
.filter(d => !Number.isNaN(d)),
reminder: (task.reminder || 'none') as TaskFormData['reminder'],
reminders: remindersValue,
assigneeId: task.assigneeId ?? null,
subtasks: subtasks.map((s) => ({ title: s.title, _key: s.id })),
subtasks: subtasks.map(s => ({ title: s.title, _key: s.id })),
});
setLoaded(true);
} catch {
@@ -155,7 +190,9 @@ export default function TaskDetailScreen() {
}
})();
return () => { mounted = false; };
return () => {
mounted = false;
};
}, [id, isReady, reset]);
const onSubmit = async (data: TaskFormData) => {
@@ -174,7 +211,7 @@ export default function TaskDetailScreen() {
savedTask = task;
const existingSubtasks = await collections.subtasks.query(Q.where('task_id', id)).fetch();
await task.update((t) => {
await task.update(t => {
t.title = data.title.trim();
t.description = data.description || '';
t.categoryId = (data.tags && data.tags[0]) || task.categoryId || '';
@@ -202,10 +239,10 @@ export default function TaskDetailScreen() {
const trimmed = formItem.title.trim();
if (!trimmed) continue;
if (formItem._key) {
const existing = existingSubtasks.find((s) => s.id === formItem._key && !s.parentSubtaskId);
const existing = existingSubtasks.find(s => s.id === formItem._key && !s.parentSubtaskId);
if (existing) {
keptIds.add(existing.id);
await existing.update((s) => {
await existing.update(s => {
s.title = trimmed;
s.order = order;
s.updatedAt = now;
@@ -214,7 +251,7 @@ export default function TaskDetailScreen() {
continue;
}
}
await collections.subtasks.create((s) => {
await collections.subtasks.create(s => {
s.taskId = task.id;
s.categoryId = task.categoryId || '';
s.tags = task.tags || '';
@@ -244,9 +281,11 @@ export default function TaskDetailScreen() {
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();
const children = await collections.subtasks
.query(Q.where('parent_subtask_id', existing.id))
.fetch();
for (const child of children) {
await child.update((c) => {
await child.update(c => {
c.parentSubtaskId = null;
c.updatedAt = now;
});
@@ -288,9 +327,23 @@ export default function TaskDetailScreen() {
title="Edit Task"
showLogo={true}
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">
<Path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13" stroke={theme.accent} strokeWidth={2.2} 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>
</TouchableOpacity>
}
@@ -310,7 +363,7 @@ export default function TaskDetailScreen() {
>
<CategorySelector
value={tags}
onChange={(value) => setValue('tags', value)}
onChange={value => setValue('tags', value)}
error={errors.categoryId?.message}
/>
<Controller
@@ -326,32 +379,49 @@ export default function TaskDetailScreen() {
)}
/>
<SubtasksSection control={control} />
<PrioritySelector
value={priority}
onChange={(value) => setValue('priority', value)}
/>
<PrioritySelector value={priority} 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}>
<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} />
</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'}>
<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
value={repeat}
interval={repeatInterval}
@@ -364,43 +434,74 @@ export default function TaskDetailScreen() {
/>
</CollapsibleSection>
<CollapsibleSection title="Reminder" icon={
<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 !== ''}>
<CollapsibleSection
title="Reminder"
icon={
<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)}
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}>
<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
value={assigneeId}
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')}>
<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
control={control}
name="description"
+727 -605
View File
File diff suppressed because it is too large Load Diff
+11 -10
View File
@@ -9,22 +9,23 @@
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-community/datetimepicker": "9.1.0",
"date-fns": "^3.6.0",
"expo": "~57.0.10",
"expo": "~57.0.18",
"expo-blur": "~57.0.2",
"expo-constants": "~57.0.9",
"expo-dev-client": "~57.0.1",
"expo-dev-client": "~57.0.16",
"expo-device": "^57.0.1",
"expo-linking": "~57.0.5",
"expo-notifications": "~57.0.8",
"expo-router": "~57.0.10",
"expo-sqlite": "~57.0.1",
"expo-intent-launcher": "~57.0.1",
"expo-linking": "~57.0.8",
"expo-notifications": "~57.0.15",
"expo-router": "~57.0.17",
"expo-sqlite": "~57.0.2",
"expo-status-bar": "~57.0.1",
"expo-system-ui": "~57.0.2",
"expo-updates": "~57.0.12",
"expo-system-ui": "~57.0.3",
"expo-updates": "~57.0.19",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-hook-form": "^7.51.5",
"react-native": "0.86.2",
"react-native": "0.86.3",
"react-native-gesture-handler": "2.32.0",
"react-native-keyboard-controller": "1.21.9",
"react-native-safe-area-context": "5.7.0",
@@ -40,7 +41,7 @@
"electron": "^41.7.1",
"electron-builder": "^26.15.3",
"eslint": "^9.0.0",
"eslint-config-expo": "~57.0.1",
"eslint-config-expo": "~57.0.2",
"patch-package": "^8.0.1",
"prettier": "^3.9.6",
"typescript": "~6.0.3"
@@ -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,
});
+12 -2
View File
@@ -1,7 +1,17 @@
import React from 'react';
import { View, Text, TouchableOpacity, StyleSheet, Switch } from 'react-native';
export const ListItem = ({ title, subtitle, onPress, leftIcon, rightIcon, disabled, leftElement, rightElement, showChevron }: {
export const ListItem = ({
title,
subtitle,
onPress,
leftIcon,
rightIcon,
disabled,
leftElement,
rightElement,
showChevron,
}: {
title: string;
subtitle?: string | undefined;
onPress?: () => void;
@@ -66,4 +76,4 @@ const styles = StyleSheet.create({
color: '#757575',
marginLeft: 4,
},
});
});
@@ -19,10 +19,22 @@ interface OptionPickerModalProps {
multiSelect?: boolean;
}
export function OptionPickerModal({ visible, title, options, selectedValue, onSelect, onClose, multiSelect = false }: OptionPickerModalProps) {
export function OptionPickerModal({
visible,
title,
options,
selectedValue,
onSelect,
onClose,
multiSelect = false,
}: OptionPickerModalProps) {
const { theme } = useSettings();
const selectedValues = Array.isArray(selectedValue) ? selectedValue : selectedValue == null ? [] : [selectedValue];
const selectedValues = Array.isArray(selectedValue)
? selectedValue
: selectedValue == null
? []
: [selectedValue];
return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
@@ -31,7 +43,7 @@ export function OptionPickerModal({ visible, title, options, selectedValue, onSe
<Text style={[styles.title, { color: theme.text }]}>{title}</Text>
<FlatList
data={options}
keyExtractor={(item) => item.value}
keyExtractor={item => item.value}
renderItem={({ item }) => {
const selected = selectedValues.includes(item.value);
return (
@@ -44,7 +56,7 @@ export function OptionPickerModal({ visible, title, options, selectedValue, onSe
onPress={() => {
if (multiSelect) {
const newValues = selected
? selectedValues.filter((v) => v !== item.value)
? selectedValues.filter(v => v !== item.value)
: [...selectedValues, item.value];
onSelect(newValues);
} else {
@@ -57,23 +69,49 @@ export function OptionPickerModal({ visible, title, options, selectedValue, onSe
accessibilityLabel={item.label}
accessibilityState={multiSelect ? { checked: selected } : { selected }}
>
{item.color && (
<View style={[styles.dot, { backgroundColor: item.color }]} />
)}
<Text style={[styles.optionText, { color: selected ? theme.accent : theme.textSecondary }]}>
{item.color && <View style={[styles.dot, { backgroundColor: item.color }]} />}
<Text
style={[
styles.optionText,
{ color: selected ? theme.accent : theme.textSecondary },
]}
>
{item.label}
</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'} />
<Circle
cx={12}
cy={12}
r={10}
stroke={selected ? theme.accent : theme.borderStrong}
strokeWidth={2}
fill={selected ? theme.accent : 'transparent'}
/>
{selected && (
<Path d="M8 12l3 3 6-6" stroke="#FFFFFF" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
<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">
<Path d="M5 12l5 5 9-10" stroke={theme.accent} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
</Svg>
) : (
selected && (
<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"
/>
</Svg>
)
)}
</TouchableOpacity>
);
@@ -24,13 +24,17 @@ export function PrioritySelector({ value, onChange }: PrioritySelectorProps) {
<View style={styles.container}>
<Text style={[styles.label, { color: theme.text }]}>Priority</Text>
<View style={styles.options} accessibilityRole="radiogroup" accessibilityLabel="Priority">
{priorities.map((priority) => (
{priorities.map(priority => (
<TouchableOpacity
key={priority.value}
style={[
styles.option,
{ backgroundColor: theme.card, borderColor: theme.borderStrong },
value === priority.value && { borderColor: theme.accent, backgroundColor: theme.accentSoft, borderWidth: 2 },
value === priority.value && {
borderColor: theme.accent,
backgroundColor: theme.accentSoft,
borderWidth: 2,
},
]}
onPress={() => onChange(priority.value)}
activeOpacity={0.8}
@@ -38,16 +42,20 @@ export function PrioritySelector({ value, onChange }: PrioritySelectorProps) {
accessibilityLabel={`${priority.label} priority`}
accessibilityState={{ selected: value === priority.value }}
>
<View style={[
styles.colorIndicator,
{ backgroundColor: PRIORITY_COLORS[priority.value] },
value === priority.value && styles.colorIndicatorSelected,
]} />
<Text style={[
styles.optionText,
{ color: theme.textFaint },
value === priority.value && { color: theme.accentStrong, fontWeight: '600' },
]}>
<View
style={[
styles.colorIndicator,
{ backgroundColor: PRIORITY_COLORS[priority.value] },
value === priority.value && styles.colorIndicatorSelected,
]}
/>
<Text
style={[
styles.optionText,
{ color: theme.textFaint },
value === priority.value && { color: theme.accentStrong, fontWeight: '600' },
]}
>
{priority.label}
</Text>
</TouchableOpacity>
@@ -97,4 +105,4 @@ const styles = StyleSheet.create({
fontSize: 13,
fontWeight: '500',
},
});
});
+54 -14
View File
@@ -1,5 +1,13 @@
import React, { useState, useEffect, useMemo, useRef } from 'react';
import { View, StyleSheet, TextInput, TouchableOpacity, Keyboard, Text, Platform } from 'react-native';
import {
View,
StyleSheet,
TextInput,
TouchableOpacity,
Keyboard,
Text,
Platform,
} from 'react-native';
import { BlurView } from 'expo-blur';
import { database, collections } from '@/database';
import { useCategories } from '@/hooks/useDatabase';
@@ -14,10 +22,20 @@ interface QuickAddBarProps {
placeholder?: string;
}
const BlurContainer = ({ children, style, theme }: { children: React.ReactNode; style: any; theme: any }) => {
const BlurContainer = ({
children,
style,
theme,
}: {
children: React.ReactNode;
style: any;
theme: any;
}) => {
if (Platform.OS === 'web') {
return (
<View style={[style, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }]}>
<View
style={[style, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }]}
>
{children}
</View>
);
@@ -38,10 +56,10 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
const inputRef = useRef<TextInput>(null);
const visibleCategories = useMemo(
() => categories.filter((c) => c.name.toLowerCase() !== 'calendar'),
() => categories.filter(c => c.name.toLowerCase() !== 'calendar'),
[categories]
);
const categoryColor = visibleCategories.find((c) => c.id === categoryId)?.color ?? theme.textMuted;
const categoryColor = visibleCategories.find(c => c.id === categoryId)?.color ?? theme.textMuted;
useEffect(() => {
return subscribeToQuickAdd(() => {
@@ -57,7 +75,7 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
const now = new Date();
await database.write(async () => {
await collections.tasks.create((t) => {
await collections.tasks.create(t => {
t.title = trimmed;
t.description = '';
t.categoryId = categoryId || '';
@@ -83,16 +101,31 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
return (
<View style={styles.wrapper}>
<BlurContainer theme={theme} style={styles.blurContainer}>
<View style={[styles.bar, { backgroundColor: 'rgba(255,255,255,0.05)', borderColor: 'rgba(255,255,255,0.1)' }]}>
<View
style={[
styles.bar,
{ backgroundColor: 'rgba(255,255,255,0.05)', borderColor: 'rgba(255,255,255,0.1)' },
]}
>
<TouchableOpacity
style={[styles.categoryButton, { borderColor: theme.borderStrong, backgroundColor: theme.cardAlt }]}
style={[
styles.categoryButton,
{ borderColor: theme.borderStrong, backgroundColor: theme.cardAlt },
]}
onPress={() => setCategoryPickerVisible(true)}
activeOpacity={0.7}
accessibilityLabel="Select tags"
>
<View style={[styles.categoryButtonDot, { backgroundColor: categoryColor }]} />
<Svg width={12} height={12} viewBox="0 0 24 24">
<Path d="M6 9l6 6 6-6" stroke={theme.textSecondary} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
<Path
d="M6 9l6 6 6-6"
stroke={theme.textSecondary}
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</Svg>
</TouchableOpacity>
<TextInput
@@ -108,7 +141,11 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
accessibilityHint="Enter a task name and press the add button"
/>
<TouchableOpacity
style={[styles.submit, { backgroundColor: theme.accent }, title.trim() ? {} : styles.submitDisabled]}
style={[
styles.submit,
{ backgroundColor: theme.accent },
title.trim() ? {} : styles.submitDisabled,
]}
onPress={title.trim() ? handleAdd : undefined}
disabled={!title.trim()}
activeOpacity={0.8}
@@ -132,9 +169,12 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
<OptionPickerModal
visible={categoryPickerVisible}
title="Select Tags"
options={[{ value: '', label: 'None', color: theme.textMuted }, ...visibleCategories.map((c) => ({ value: c.id, label: c.name, color: c.color }))]}
options={[
{ value: '', label: 'None', color: theme.textMuted },
...visibleCategories.map(c => ({ value: c.id, label: c.name, color: c.color })),
]}
selectedValue={categoryId}
onSelect={(value) => setCategoryId(Array.isArray(value) ? value[0] : value)}
onSelect={value => setCategoryId(Array.isArray(value) ? value[0] : value)}
onClose={() => setCategoryPickerVisible(false)}
/>
</View>
@@ -162,7 +202,7 @@ const styles = StyleSheet.create({
borderBottomWidth: 1,
shadowColor: '#000',
shadowOffset: { width: 0, height: -2 },
shadowOpacity: 0.10,
shadowOpacity: 0.1,
shadowRadius: 6,
elevation: 6,
minHeight: 56,
@@ -208,4 +248,4 @@ const styles = StyleSheet.create({
submitDisabled: {
opacity: 0.5,
},
});
});
@@ -18,9 +18,13 @@ export function ReminderSelector({ value, onChange, hasDueDate }: ReminderSelect
const selectedReminders = parseReminders(value);
const disabled = !hasDueDate;
const reminderLabels = selectedReminders.length > 0
? selectedReminders.map(r => REMINDER_OPTIONS.find(o => o.value === r)?.label).filter(Boolean).join(', ')
: 'No reminder';
const reminderLabels =
selectedReminders.length > 0
? selectedReminders
.map(r => REMINDER_OPTIONS.find(o => o.value === r)?.label)
.filter(Boolean)
.join(', ')
: 'No reminder';
return (
<View style={styles.container}>
@@ -29,7 +33,10 @@ export function ReminderSelector({ value, onChange, hasDueDate }: ReminderSelect
style={[
styles.row,
{ backgroundColor: theme.card, borderColor: theme.borderStrong },
selectedReminders.length > 0 && { borderColor: theme.accent, backgroundColor: theme.accentSoft },
selectedReminders.length > 0 && {
borderColor: theme.accent,
backgroundColor: theme.accentSoft,
},
disabled && { opacity: 0.5 },
]}
onPress={() => setShowPicker(true)}
@@ -49,7 +56,14 @@ export function ReminderSelector({ value, onChange, hasDueDate }: ReminderSelect
strokeLinejoin="round"
fill="none"
/>
<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} />
<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>
<Text
style={[
@@ -62,20 +76,32 @@ export function ReminderSelector({ value, onChange, hasDueDate }: ReminderSelect
</Text>
<View style={styles.chevron}>
<Svg width={16} height={16} viewBox="0 0 24 24">
<Path d="M9 18l6-6-6-6" stroke={theme.textSecondary} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
<Path
d="M9 18l6-6-6-6"
stroke={theme.textSecondary}
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</Svg>
</View>
</TouchableOpacity>
{!hasDueDate && (
<Text style={[styles.hint, { color: theme.textMuted }]}>Set a due date to add reminders.</Text>
<Text style={[styles.hint, { color: theme.textMuted }]}>
Set a due date to add reminders.
</Text>
)}
<OptionPickerModal
visible={showPicker}
title="Reminders"
options={REMINDER_OPTIONS.filter((o) => o.value !== 'none').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={selectedReminders}
onSelect={(v) => onChange(toRemindersString(v as Reminder[]))}
onSelect={v => onChange(toRemindersString(v as Reminder[]))}
onClose={() => setShowPicker(false)}
multiSelect
/>
+124 -30
View File
@@ -1,6 +1,22 @@
import React from 'react';
import { View, Text, StyleSheet, TouchableOpacity, Modal, TextInput, Pressable, KeyboardAvoidingView } from 'react-native';
import { Repeat, REPEAT_OPTIONS, WEEKDAY_LABELS, WEEKDAY_ORDER, weekdaysInDisplayOrder, repeatDaysFromString } from '@/types';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
Modal,
TextInput,
Pressable,
KeyboardAvoidingView,
} from 'react-native';
import {
Repeat,
REPEAT_OPTIONS,
WEEKDAY_LABELS,
WEEKDAY_ORDER,
weekdaysInDisplayOrder,
repeatDaysFromString,
} from '@/types';
import { useSettings } from '@/theme';
import { useRepeatProfiles } from '@/hooks/useDatabase';
import { createRepeatProfile, deleteRepeatProfile } from '@/utils/repeatProfileActions';
@@ -43,7 +59,15 @@ function RepeatIcon({ repeat, color }: { repeat: Repeat; color: string }) {
);
}
const WEEKDAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
const WEEKDAY_NAMES = [
'Sunday',
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday',
];
function unitLabel(value: Repeat): string {
switch (value) {
@@ -77,12 +101,16 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
};
const toggleDay = (day: number) => {
const nextDays = days.includes(day) ? days.filter((d) => d !== day) : [...days, day].sort();
const nextDays = days.includes(day) ? days.filter(d => d !== day) : [...days, day].sort();
if (value === 'custom' && nextDays.length === 0) return;
onChange(value, interval, nextDays);
};
const applyProfile = (profile: { repeat: Repeat; repeatInterval: number; repeatDays: string }) => {
const applyProfile = (profile: {
repeat: Repeat;
repeatInterval: number;
repeatDays: string;
}) => {
onChange(profile.repeat, profile.repeatInterval, repeatDaysFromString(profile.repeatDays));
};
@@ -116,7 +144,7 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
<View style={styles.profileRow}>
<Text style={[styles.profileLabel, { color: theme.textFaint }]}>Profiles</Text>
<View style={styles.profileChips}>
{profiles.map((profile) => (
{profiles.map(profile => (
<View
key={profile.id}
style={[
@@ -131,8 +159,15 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
},
]}
>
<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>
<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>
</TouchableOpacity>
<TouchableOpacity
onPress={() => handleDeleteProfile(profile.id, profile.name)}
@@ -149,13 +184,17 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
)}
<View style={styles.chipRow} accessibilityRole="radiogroup" accessibilityLabel="Repeat">
{REPEAT_OPTIONS.map((option) => (
{REPEAT_OPTIONS.map(option => (
<TouchableOpacity
key={option.value}
style={[
styles.chip,
{ backgroundColor: theme.card, borderColor: theme.borderStrong },
value === option.value && { borderColor: theme.accent, backgroundColor: theme.accentSoft, borderWidth: 2 },
value === option.value && {
borderColor: theme.accent,
backgroundColor: theme.accentSoft,
borderWidth: 2,
},
]}
onPress={() => selectRepeat(option.value)}
activeOpacity={0.8}
@@ -163,7 +202,10 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
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
style={[
styles.chipText,
@@ -179,7 +221,12 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
{value !== 'none' && (
<View style={styles.settings}>
<View style={[styles.intervalRow, { backgroundColor: theme.card, borderColor: theme.borderStrong }]}>
<View
style={[
styles.intervalRow,
{ backgroundColor: theme.card, borderColor: theme.borderStrong },
]}
>
<Text style={[styles.intervalLabel, { color: theme.textSecondary }]}>Every</Text>
<TouchableOpacity
style={[styles.stepButton, { borderColor: theme.borderStrong }]}
@@ -190,7 +237,14 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
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>
<Text style={[styles.intervalValue, { color: theme.text }]}>{interval}</Text>
<TouchableOpacity
@@ -202,7 +256,14 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
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>
<Text style={[styles.intervalLabel, { color: theme.textSecondary }]}>
{unitLabel(value)}
@@ -212,14 +273,20 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
{isDaysBased(value) && (
<View style={styles.dayRow}>
{WEEKDAY_ORDER.map((day) => (
{WEEKDAY_ORDER.map(day => (
<TouchableOpacity
key={`${WEEKDAY_LABELS[day]}-${day}`}
style={[
styles.dayChip,
{ backgroundColor: theme.card, borderColor: theme.borderStrong },
days.includes(day) && { backgroundColor: theme.accent, borderColor: theme.accent },
value === 'custom' && days.length === 1 && days.includes(day) && styles.dayChipLast,
days.includes(day) && {
backgroundColor: theme.accent,
borderColor: theme.accent,
},
value === 'custom' &&
days.length === 1 &&
days.includes(day) &&
styles.dayChipLast,
]}
onPress={() => toggleDay(day)}
activeOpacity={0.8}
@@ -250,9 +317,16 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
activeOpacity={0.8}
>
<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>
<Text style={[styles.saveProfileText, { color: theme.accentStrong }]}>Save as profile</Text>
<Text style={[styles.saveProfileText, { color: theme.accentStrong }]}>
Save as profile
</Text>
</TouchableOpacity>
)}
@@ -262,19 +336,29 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
animationType="fade"
onRequestClose={() => setSaveModalVisible(false)}
>
<KeyboardAvoidingView
style={styles.modalBackdrop}
behavior="padding"
>
<KeyboardAvoidingView style={styles.modalBackdrop} behavior="padding">
<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.modalHint, { color: theme.textFaint }]}>
{`${REPEAT_OPTIONS.find((o) => o.value === value)?.label.replace('No Repeat', 'None')}, every ${interval} ${unitLabel(value)}${interval > 1 ? 's' : ''}`}
{isDaysBased(value) && days.length > 0 ? ` · ${weekdaysInDisplayOrder(days).map((d) => WEEKDAY_LABELS[d]).join(' ')}` : ''}
{`${REPEAT_OPTIONS.find(o => o.value === value)?.label.replace('No Repeat', 'None')}, every ${interval} ${unitLabel(value)}${interval > 1 ? 's' : ''}`}
{isDaysBased(value) && days.length > 0
? ` · ${weekdaysInDisplayOrder(days)
.map(d => WEEKDAY_LABELS[d])
.join(' ')}`
: ''}
</Text>
<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,
},
]}
value={profileName}
onChangeText={setProfileName}
placeholder="Profile name (e.g. Every weekday)"
@@ -290,15 +374,25 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
onPress={() => setSaveModalVisible(false)}
activeOpacity={0.8}
>
<Text style={[styles.modalButtonText, { color: theme.textSecondary }]}>Cancel</Text>
<Text style={[styles.modalButtonText, { color: theme.textSecondary }]}>
Cancel
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.modalButton, styles.modalButtonPrimary, { backgroundColor: theme.accent }]}
style={[
styles.modalButton,
styles.modalButtonPrimary,
{ backgroundColor: theme.accent },
]}
onPress={handleSaveProfile}
disabled={!profileName.trim()}
activeOpacity={0.8}
>
<Text style={[styles.modalButtonText, { color: theme.accentText, fontWeight: '600' }]}>Save</Text>
<Text
style={[styles.modalButtonText, { color: theme.accentText, fontWeight: '600' }]}
>
Save
</Text>
</TouchableOpacity>
</View>
</Pressable>
@@ -1,5 +1,13 @@
import React, { useState } from 'react';
import { Modal, View, Text, StyleSheet, TextInput, TouchableOpacity, KeyboardAvoidingView } from 'react-native';
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';
@@ -33,16 +41,22 @@ export function ServerUrlModal({ visible, onClose }: ServerUrlModalProps) {
return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<KeyboardAvoidingView
style={styles.overlay}
behavior="padding"
>
<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 }}>
<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" />
<Path
d="M18 6L6 18M6 6l12 12"
stroke={theme.textSecondary}
strokeWidth={2.2}
strokeLinecap="round"
/>
</Svg>
</TouchableOpacity>
</View>
@@ -51,7 +65,11 @@ export function ServerUrlModal({ visible, onClose }: ServerUrlModalProps) {
<TextInput
style={[
styles.input,
{ backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text },
{
backgroundColor: theme.inputBg,
borderColor: theme.borderStrong,
color: theme.text,
},
]}
placeholder={DEFAULT_API_BASE_URL}
placeholderTextColor={theme.textMuted}
@@ -75,7 +93,11 @@ export function ServerUrlModal({ visible, onClose }: ServerUrlModalProps) {
<Text style={[styles.cancelButtonText, { color: theme.textSecondary }]}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.saveButton, { backgroundColor: theme.accent }, !isValid && styles.saveButtonDisabled]}
style={[
styles.saveButton,
{ backgroundColor: theme.accent },
!isValid && styles.saveButtonDisabled,
]}
onPress={handleSave}
disabled={!isValid}
activeOpacity={0.8}
@@ -46,7 +46,7 @@ export const SubtaskItem = React.memo(function SubtaskItem({
const hasChildren = subtask.subtasks && subtask.subtasks.length > 0;
const hovered = hoveredId === subtask.id;
const effectiveColor = subtask.categoryId
? categoryColorResolver?.(subtask.categoryId) ?? categoryColor
? (categoryColorResolver?.(subtask.categoryId) ?? categoryColor)
: categoryColor;
const handleExpand = () => setExpanded(!expanded);
@@ -59,7 +59,7 @@ export const SubtaskItem = React.memo(function SubtaskItem({
return (
<View>
<View ref={(ref) => registerRef?.(subtask.id, subtask.taskId, ref)}>
<View ref={ref => registerRef?.(subtask.id, subtask.taskId, ref)}>
<TaskItem
task={subtask}
indented
@@ -86,7 +86,7 @@ export const SubtaskItem = React.memo(function SubtaskItem({
{subtask.subtasks
.slice()
.sort((a, b) => a.order - b.order)
.map((child) => (
.map(child => (
<SubtaskItem
key={child.id}
subtask={child}
@@ -118,4 +118,4 @@ const styles = StyleSheet.create({
nestedSubtasks: {
marginTop: 4,
},
});
});
@@ -48,7 +48,9 @@ export function SubtasksSection({ control }: SubtasksSectionProps) {
<Text style={[styles.label, { color: theme.text }]}>Subtasks</Text>
{items.length > 0 && (
<View style={[styles.badge, { backgroundColor: theme.accentSoft }]}>
<Text style={[styles.badgeText, { color: theme.accentStrong }]}>{items.length}</Text>
<Text style={[styles.badgeText, { color: theme.accentStrong }]}>
{items.length}
</Text>
</View>
)}
</View>
@@ -92,20 +94,30 @@ export function SubtasksSection({ control }: SubtasksSectionProps) {
key={item._key ?? `subtask-${index}`}
index={index}
value={item.title}
onChange={(value) => updateSubtask(index, value)}
onChange={value => updateSubtask(index, value)}
onRemove={() => removeSubtask(index)}
/>
))}
</View>
<TouchableOpacity
style={[styles.addButton, { backgroundColor: theme.accentSoft, borderColor: theme.accentBorder }]}
style={[
styles.addButton,
{ backgroundColor: theme.accentSoft, borderColor: theme.accentBorder },
]}
onPress={addSubtask}
activeOpacity={0.8}
>
<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>
<Text style={[styles.addButtonText, { color: theme.accentStrong }]}>Add Subtask</Text>
<Text style={[styles.addButtonText, { color: theme.accentStrong }]}>
Add Subtask
</Text>
</TouchableOpacity>
</Animated.View>
</View>
@@ -150,7 +162,12 @@ function SubtaskItem({ index, value, onChange, onRemove }: SubtaskItemProps) {
/>
<TouchableOpacity style={styles.removeButton} onPress={onRemove} activeOpacity={0.7}>
<Svg width={20} height={20} viewBox="0 0 24 24">
<Path d="M18 6L6 18M6 6l12 12" stroke={theme.textSecondary} strokeWidth={2.2} strokeLinecap="round" />
<Path
d="M18 6L6 18M6 6l12 12"
stroke={theme.textSecondary}
strokeWidth={2.2}
strokeLinecap="round"
/>
</Svg>
</TouchableOpacity>
</View>
@@ -234,4 +251,4 @@ const styles = StyleSheet.create({
fontSize: 12,
fontWeight: '500',
},
});
});
+50 -21
View File
@@ -11,9 +11,18 @@ import {
} from 'react-native';
import { useSettings } from '@/theme';
import { AuthUser, getAuthToken, getAuthUser, login, register, signOutAuth } from '@/services/auth';
import { runSyncGuarded, startAutoSync, stopAutoSync, getLastSyncTime, SyncResult } from '@/database/sync';
import {
runSyncGuarded,
startAutoSync,
stopAutoSync,
getLastSyncTime,
SyncResult,
} from '@/database/sync';
import { ForgotPasswordModal } from './ForgotPasswordModal';
import { registerForPushNotificationsAsync, unregisterForPushNotificationsAsync } from '@/services/pushNotifications';
import {
registerForPushNotificationsAsync,
unregisterForPushNotificationsAsync,
} from '@/services/pushNotifications';
interface SyncModalProps {
visible: boolean;
@@ -81,9 +90,10 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
}
setBusy(true);
try {
const authedUser = mode === 'register'
? await register(username.trim(), password)
: await login(username.trim(), password);
const authedUser =
mode === 'register'
? await register(username.trim(), password)
: await login(username.trim(), password);
setUser(authedUser);
startAutoSync();
registerForPushNotificationsAsync().catch(() => {});
@@ -128,10 +138,7 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<KeyboardAvoidingView
style={styles.overlay}
behavior="padding"
>
<KeyboardAvoidingView style={styles.overlay} behavior="padding">
<TouchableOpacity style={styles.backdrop} activeOpacity={1} onPress={onClose} />
<View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}>
<View style={[styles.handle, { backgroundColor: theme.borderStrong }]} />
@@ -159,7 +166,9 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
{status === 'syncing' ? (
<ActivityIndicator color={theme.accentText} />
) : (
<Text style={[styles.primaryButtonText, { color: theme.accentText }]}>Sync Now</Text>
<Text style={[styles.primaryButtonText, { color: theme.accentText }]}>
Sync Now
</Text>
)}
</TouchableOpacity>
@@ -181,7 +190,9 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
onPress={handleSignOut}
activeOpacity={0.7}
>
<Text style={[styles.secondaryButtonText, { color: theme.dangerStrong }]}>Sign Out</Text>
<Text style={[styles.secondaryButtonText, { color: theme.dangerStrong }]}>
Sign Out
</Text>
</TouchableOpacity>
</View>
) : (
@@ -191,13 +202,10 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
</Text>
<View style={[styles.segmentRow, { backgroundColor: theme.inputBg }]}>
{(['login', 'register'] as const).map((m) => (
{(['login', 'register'] as const).map(m => (
<TouchableOpacity
key={m}
style={[
styles.segment,
mode === m && { backgroundColor: theme.accent },
]}
style={[styles.segment, mode === m && { backgroundColor: theme.accent }]}
onPress={() => {
setMode(m);
setError(null);
@@ -222,7 +230,14 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
</Text>
)}
<TextInput
style={[styles.input, { backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text }]}
style={[
styles.input,
{
backgroundColor: theme.inputBg,
borderColor: theme.borderStrong,
color: theme.text,
},
]}
placeholder="Username"
placeholderTextColor={theme.textMuted}
value={username}
@@ -231,7 +246,14 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
autoCorrect={false}
/>
<TextInput
style={[styles.input, { backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text }]}
style={[
styles.input,
{
backgroundColor: theme.inputBg,
borderColor: theme.borderStrong,
color: theme.text,
},
]}
placeholder="Password (min 8 chars)"
placeholderTextColor={theme.textMuted}
value={password}
@@ -245,11 +267,15 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
onPress={() => setShowForgotPassword(true)}
activeOpacity={0.7}
>
<Text style={[styles.forgotPasswordText, { color: theme.accent }]}>Forgot Password?</Text>
<Text style={[styles.forgotPasswordText, { color: theme.accent }]}>
Forgot Password?
</Text>
</TouchableOpacity>
)}
{error && <Text style={[styles.errorText, { color: theme.dangerStrong }]}>{error}</Text>}
{error && (
<Text style={[styles.errorText, { color: theme.dangerStrong }]}>{error}</Text>
)}
<TouchableOpacity
style={[styles.primaryButton, { backgroundColor: theme.accent }]}
@@ -268,7 +294,10 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
</View>
)}
<ForgotPasswordModal visible={showForgotPassword} onClose={() => setShowForgotPassword(false)} />
<ForgotPasswordModal
visible={showForgotPassword}
onClose={() => setShowForgotPassword(false)}
/>
<TouchableOpacity
style={[styles.secondaryButton, { borderColor: theme.borderStrong }]}
+45 -11
View File
@@ -66,20 +66,37 @@ export default function SyncStatus({ compact = false }: SyncStatusProps) {
case 'success':
return (
<Svg width={16} height={16} viewBox="0 0 24 24">
<Path d="M5 12l5 5 9-10" stroke={theme.success} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
<Path
d="M5 12l5 5 9-10"
stroke={theme.success}
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</Svg>
);
case 'error':
return (
<Svg width={16} height={16} viewBox="0 0 24 24">
<Circle cx={12} cy={12} r={10} stroke={theme.danger} strokeWidth={2} fill="none" />
<Path d="M12 8v4M12 16h.01" stroke={theme.danger} strokeWidth={2} strokeLinecap="round" />
<Path
d="M12 8v4M12 16h.01"
stroke={theme.danger}
strokeWidth={2}
strokeLinecap="round"
/>
</Svg>
);
default:
return (
<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.textFaint} strokeWidth={1.8} 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.textFaint} strokeWidth={1.8} strokeLinecap="round" />
</Svg>
);
@@ -88,16 +105,25 @@ export default function SyncStatus({ compact = false }: SyncStatusProps) {
const getStatusText = () => {
switch (status) {
case 'syncing': return 'Syncing...';
case 'success': return 'Synced';
case 'error': return 'Sync failed';
default: return lastSync ? `Last sync: ${formatTime(lastSync)}` : 'Not synced';
case 'syncing':
return 'Syncing...';
case 'success':
return 'Synced';
case 'error':
return 'Sync failed';
default:
return lastSync ? `Last sync: ${formatTime(lastSync)}` : 'Not synced';
}
};
if (compact) {
return (
<TouchableOpacity style={[styles.compact, { backgroundColor: theme.sheetBg }]} onPress={handleSync} disabled={status === 'syncing'} activeOpacity={0.7}>
<TouchableOpacity
style={[styles.compact, { backgroundColor: theme.sheetBg }]}
onPress={handleSync}
disabled={status === 'syncing'}
activeOpacity={0.7}
>
<View style={styles.compactRow}>
{getStatusIcon()}
<Text style={[styles.compactText, { color: theme.textMuted }]}>
@@ -116,12 +142,20 @@ export default function SyncStatus({ compact = false }: SyncStatusProps) {
<Text style={[styles.statusText, { color: theme.text }]}>{getStatusText()}</Text>
</View>
<TouchableOpacity
style={[styles.syncButton, { backgroundColor: status === 'syncing' ? theme.accentSoft : theme.accent }]}
style={[
styles.syncButton,
{ backgroundColor: status === 'syncing' ? theme.accentSoft : theme.accent },
]}
onPress={handleSync}
disabled={status === 'syncing'}
activeOpacity={0.8}
>
<Text style={[styles.syncButtonText, { color: status === 'syncing' ? theme.accent : theme.accentText }]}>
<Text
style={[
styles.syncButtonText,
{ color: status === 'syncing' ? theme.accent : theme.accentText },
]}
>
{status === 'syncing' ? 'Syncing...' : 'Sync Now'}
</Text>
</TouchableOpacity>
@@ -193,4 +227,4 @@ const styles = StyleSheet.create({
compactText: {
fontSize: 12,
},
});
});
+10 -5
View File
@@ -56,7 +56,14 @@ export function TabBarIcon({ name, focused, color, size = 24 }: TabBarIconProps)
)}
{name === 'gear' && (
<>
<Circle cx={12} cy={12} r={3.5} stroke={color} strokeWidth={focused ? 3.5 : 3} fill="none" />
<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}
@@ -76,11 +83,9 @@ export function TabBarIcon({ name, focused, color, size = 24 }: TabBarIconProps)
strokeLinecap="round"
strokeLinejoin="round"
/>
{focused && (
<Circle cx={4} cy={10} r={2} fill={color} />
)}
{focused && <Circle cx={4} cy={10} r={2} fill={color} />}
</>
)}
</Svg>
);
}
}
@@ -14,7 +14,15 @@ interface TaskDeleteModalProps {
onDelete: (scope: TaskDeleteScope) => void;
}
export function TaskDeleteModal({ visible, taskId, taskTitle, isRepeating, subtask, onClose, onDelete }: TaskDeleteModalProps) {
export function TaskDeleteModal({
visible,
taskId,
taskTitle,
isRepeating,
subtask,
onClose,
onDelete,
}: TaskDeleteModalProps) {
const { theme } = useSettings();
const [counts, setCounts] = useState({ future: 1, all: 1 });
@@ -22,11 +30,13 @@ export function TaskDeleteModal({ visible, taskId, taskTitle, isRepeating, subta
if (!visible || !taskId || subtask) return;
let mounted = true;
getSeriesOccurrenceCounts(taskId)
.then((c) => {
.then(c => {
if (mounted) setCounts(c);
})
.catch(() => {});
return () => { mounted = false; };
return () => {
mounted = false;
};
}, [visible, taskId, subtask]);
const options: { scope: TaskDeleteScope; label: string; hint?: string }[] = subtask
@@ -34,7 +44,11 @@ export function TaskDeleteModal({ visible, taskId, taskTitle, isRepeating, subta
: [{ scope: 'this', label: 'This task only' }];
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,
});
options.push({ scope: 'all', label: 'All tasks in series', hint: `${counts.all} occurrences` });
}
@@ -43,10 +57,21 @@ export function TaskDeleteModal({ visible, taskId, taskTitle, isRepeating, subta
<View style={styles.overlay}>
<View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}>
<View style={styles.header}>
<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 }}>
<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 }}
>
<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" />
<Path
d="M18 6L6 18M6 6l12 12"
stroke={theme.textSecondary}
strokeWidth={2.2}
strokeLinecap="round"
/>
</Svg>
</TouchableOpacity>
</View>
@@ -64,19 +89,26 @@ export function TaskDeleteModal({ visible, taskId, taskTitle, isRepeating, subta
)}
<View style={styles.actions}>
{options.map((option) => (
{options.map(option => (
<TouchableOpacity
key={option.scope}
style={[styles.actionButton, { borderColor: theme.accentBorder }]}
onPress={() => onDelete(option.scope)}
activeOpacity={0.7}
>
<Text style={[styles.actionLabel, { color: theme.accentStrong }]}>{option.label}</Text>
{option.hint && <Text style={[styles.actionHint, { color: theme.textMuted }]}>{option.hint}</Text>}
<Text style={[styles.actionLabel, { color: theme.accentStrong }]}>
{option.label}
</Text>
{option.hint && (
<Text style={[styles.actionHint, { color: theme.textMuted }]}>{option.hint}</Text>
)}
</TouchableOpacity>
))}
<TouchableOpacity
style={[styles.cancelButton, { borderColor: theme.borderStrong, backgroundColor: theme.card }]}
style={[
styles.cancelButton,
{ borderColor: theme.borderStrong, backgroundColor: theme.card },
]}
onPress={onClose}
activeOpacity={0.7}
>
+422 -330
View File
@@ -1,6 +1,19 @@
import React from 'react';
import { View, Text, StyleSheet, TouchableOpacity, Animated, Platform, Dimensions } from 'react-native';
import { Swipeable, Gesture, GestureDetector, PanGestureHandler } from 'react-native-gesture-handler';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
Animated,
Platform,
Dimensions,
} from 'react-native';
import {
Swipeable,
Gesture,
GestureDetector,
PanGestureHandler,
} from 'react-native-gesture-handler';
import { TaskData, SubtaskData, Priority, Repeat, Reminder } from '@/types';
import { PRIORITY_COLORS } from '@/constants';
import { useSettings, mixHex } from '@/theme';
@@ -46,7 +59,30 @@ interface TaskItemProps {
categoryColors?: (string | undefined)[];
}
export const TaskItem = React.memo(function TaskItem({ task, onToggle, onDelete, onPress, onLongPress, onMenuOpen, selected, selectionMode, completedSection, draggable, hovered, onDragStart, onDragUpdate, onDragEnd, onReorderStart, onReorderUpdate, onReorderEnd, expanded, indented, depth = 0, categoryColor, categoryColors }: 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, deleteBySwipe } = useSettings();
const styles = React.useMemo(() => createStyles(theme), [theme]);
const [opacityAnim] = React.useState(new Animated.Value(task.completed ? 0.5 : 1));
@@ -73,17 +109,17 @@ export const TaskItem = React.memo(function TaskItem({ task, onToggle, onDelete,
.activateAfterLongPress(400)
.minDistance(5)
.runOnJS(true)
.onStart((e) => {
.onStart(e => {
setDragging(true);
setDragStartY(e.absoluteY);
onReorderStart?.();
})
.onUpdate((e) => {
.onUpdate(e => {
dragTranslateX.setValue(e.translationX);
dragTranslateY.setValue(e.translationY);
onReorderUpdate?.(e.absoluteY);
})
.onEnd((e) => {
.onEnd(e => {
onReorderEnd?.(e.absoluteY, e.translationY);
})
.onFinalize(() => {
@@ -104,12 +140,12 @@ export const TaskItem = React.memo(function TaskItem({ task, onToggle, onDelete,
setDragging(true);
onDragStart?.();
})
.onUpdate((e) => {
.onUpdate(e => {
dragTranslateX.setValue(e.translationX);
dragTranslateY.setValue(e.translationY);
onDragUpdate?.(e.absoluteY);
})
.onEnd((e) => {
.onEnd(e => {
onDragEnd?.(e.absoluteY);
})
.onFinalize(() => {
@@ -135,7 +171,11 @@ export const TaskItem = React.memo(function TaskItem({ task, onToggle, onDelete,
endOfToday.setHours(23, 59, 59, 999);
const hasDueDate = task.dueDate > 0;
const isOverdue = hasDueDate && !task.completed && task.dueDate < startOfToday.getTime();
const isDueToday = hasDueDate && !task.completed && task.dueDate >= startOfToday.getTime() && task.dueDate <= endOfToday.getTime();
const isDueToday =
hasDueDate &&
!task.completed &&
task.dueDate >= startOfToday.getTime() &&
task.dueDate <= endOfToday.getTime();
return { hasDueDate, isOverdue, isDueToday };
}, [task.dueDate, task.completed]);
@@ -149,21 +189,39 @@ export const TaskItem = React.memo(function TaskItem({ task, onToggle, onDelete,
const renderRightActions = (progress: Animated.AnimatedInterpolation<number>) => {
const translateX = progress.interpolate({ inputRange: [0, 1], outputRange: [80, 0] });
return (
<Animated.View style={[styles.swipeAction, styles.swipeDelete, { transform: [{ translateX }] }]}>
<Animated.View
style={[styles.swipeAction, styles.swipeDelete, { transform: [{ translateX }] }]}
>
<Svg width={24} height={24} viewBox="0 0 24 24">
<Path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13" stroke="#FFFFFF" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" fill="none" />
<Path
d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"
stroke="#FFFFFF"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</Svg>
<Text style={styles.swipeActionText}>Delete</Text>
</Animated.View>
);
};
const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) => {
const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) => {
const translateX = progress.interpolate({ inputRange: [0, 1], outputRange: [-80, 0] });
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">
<Path d="M5 12l5 5 9-10" stroke="#FFFFFF" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
<Path
d="M5 12l5 5 9-10"
stroke="#FFFFFF"
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</Svg>
<Text style={styles.swipeActionText}>{task.completed ? 'Undo' : 'Done'}</Text>
</Animated.View>
@@ -188,7 +246,9 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) =>
overshootRight={false}
overshootLeft={false}
>
<GestureDetector gesture={draggable ? (onReorderStart ? reorderGesture : dragGesture) : Gesture.Native()}>
<GestureDetector
gesture={draggable ? (onReorderStart ? reorderGesture : dragGesture) : Gesture.Native()}
>
<Animated.View
style={[
styles.container,
@@ -206,18 +266,18 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) =>
]}
pointerEvents={dragging ? 'none' : 'auto'}
>
<TouchableOpacity
style={styles.contentWrapper}
onPress={onPress}
onLongPress={onLongPress}
delayLongPress={350}
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}>
<TouchableOpacity
style={styles.contentWrapper}
onPress={onPress}
onLongPress={onLongPress}
delayLongPress={350}
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.titleRow}>
<View style={styles.categoryDotSlot}>
{categoryColor && (
@@ -236,10 +296,24 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) =>
{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" />
<Path
d="M7 12l3 3 7-7"
stroke="#FFFFFF"
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</>
) : (
<Circle cx={12} cy={12} r={10} stroke={isOverdue ? theme.danger : theme.textSecondary} strokeWidth={2.5} fill="none" />
<Circle
cx={12}
cy={12}
r={10}
stroke={isOverdue ? theme.danger : theme.textSecondary}
strokeWidth={2.5}
fill="none"
/>
)}
</Svg>
</TouchableOpacity>
@@ -253,90 +327,30 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) =>
>
{task.title}
</Animated.Text>
{task.priority !== 'none' && (
<View
style={[
styles.priorityBadge,
{ backgroundColor: PRIORITY_COLORS[task.priority] },
]}
>
<Text style={styles.priorityText}>
{task.priority.charAt(0).toUpperCase()}
</Text>
</View>
)}
{task.assigneeId && (
<View style={styles.assigneeBadge}>
<Text style={styles.assigneeText}>
{task.assigneeId.charAt(0).toUpperCase()}
</Text>
</View>
)}
{task.repeat && task.repeat !== 'none' && (
<View style={styles.repeatIcon}>
<Svg width={14} height={14} 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.textSecondary}
strokeWidth={2.2}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</Svg>
</View>
)}
{task.allDay && (
<View style={styles.allDayBadge}>
<Svg width={14} height={14} viewBox="0 0 24 24">
<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"
stroke={theme.accent}
strokeWidth={1.8}
strokeLinecap="round"
fill="none"
/>
<Circle cx={12} cy={12} r={4} stroke={theme.accent} strokeWidth={1.5} fill="none" />
</Svg>
<Text style={styles.allDayText}>All Day</Text>
</View>
)}
<Animated.View style={[styles.chevron, { transform: [{ rotate }] }]}>
<Svg width={16} height={16} 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>
</Animated.View>
</View>
{hasDueDate && (
<View style={styles.dueRow}>
<Svg width={14} height={14} viewBox="0 0 24 24">
<Circle cx={12} cy={12} r={10} stroke={theme.textSecondary} strokeWidth={2} fill="none" />
<Path d="M12 6v6l4 2" stroke={theme.textSecondary} strokeWidth={2} strokeLinecap="round" />
</Svg>
<Animated.Text
style={[
styles.dueText,
{ color: theme.textFaint },
isOverdue && styles.dueTextOverdue,
isDueToday && styles.dueTextDueToday,
]}
>
{formattedDueDate}
</Animated.Text>
{task.reminder && task.reminder !== 'none' && (
<View style={styles.reminderIcon}>
<Svg width={13} height={13} viewBox="0 0 24 24">
{task.priority !== 'none' && (
<View
style={[
styles.priorityBadge,
{ backgroundColor: PRIORITY_COLORS[task.priority] },
]}
>
<Text style={styles.priorityText}>{task.priority.charAt(0).toUpperCase()}</Text>
</View>
)}
{task.assigneeId && (
<View style={styles.assigneeBadge}>
<Text style={styles.assigneeText}>
{task.assigneeId.charAt(0).toUpperCase()}
</Text>
</View>
)}
{task.repeat && task.repeat !== 'none' && (
<View style={styles.repeatIcon}>
<Svg width={14} height={14} 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"
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.textSecondary}
strokeWidth={1.8}
strokeWidth={2.2}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
@@ -344,26 +358,103 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) =>
</Svg>
</View>
)}
{task.allDay && (
<View style={styles.allDayBadge}>
<Svg width={14} height={14} viewBox="0 0 24 24">
<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"
stroke={theme.accent}
strokeWidth={1.8}
strokeLinecap="round"
fill="none"
/>
<Circle
cx={12}
cy={12}
r={4}
stroke={theme.accent}
strokeWidth={1.5}
fill="none"
/>
</Svg>
<Text style={styles.allDayText}>All Day</Text>
</View>
)}
<Animated.View style={[styles.chevron, { transform: [{ rotate }] }]}>
<Svg width={16} height={16} 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>
</Animated.View>
</View>
)}
</View>
</TouchableOpacity>
{hasDueDate && (
<View style={styles.dueRow}>
<Svg width={14} height={14} viewBox="0 0 24 24">
<Circle
cx={12}
cy={12}
r={10}
stroke={theme.textSecondary}
strokeWidth={2}
fill="none"
/>
<Path
d="M12 6v6l4 2"
stroke={theme.textSecondary}
strokeWidth={2}
strokeLinecap="round"
/>
</Svg>
<Animated.Text
style={[
styles.dueText,
{ color: theme.textFaint },
isOverdue && styles.dueTextOverdue,
isDueToday && styles.dueTextDueToday,
]}
>
{formattedDueDate}
</Animated.Text>
{task.reminder && task.reminder !== 'none' && (
<View style={styles.reminderIcon}>
<Svg width={13} height={13} 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.textSecondary}
strokeWidth={1.8}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</Svg>
</View>
)}
</View>
)}
</View>
</TouchableOpacity>
<TouchableOpacity
style={styles.menuButton}
onPress={onMenuOpen}
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">
<Circle cx={12} cy={12} r={1.5} fill={theme.textMuted} />
<Circle cx={12} cy={6} r={1.5} fill={theme.textMuted} />
<Circle cx={12} cy={18} r={1.5} fill={theme.textMuted} />
</Svg>
</TouchableOpacity>
<TouchableOpacity
style={styles.menuButton}
onPress={onMenuOpen}
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">
<Circle cx={12} cy={12} r={1.5} fill={theme.textMuted} />
<Circle cx={12} cy={6} r={1.5} fill={theme.textMuted} />
<Circle cx={12} cy={18} r={1.5} fill={theme.textMuted} />
</Svg>
</TouchableOpacity>
</Animated.View>
</GestureDetector>
</Swipeable>
@@ -400,204 +491,205 @@ function formatTime12(time: string): string {
return `${h}:${String(minutes).padStart(2, '0')} ${period}`;
}
const createStyles = (theme: ThemeColors) => StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 12,
paddingVertical: 14,
borderRadius: 16,
borderWidth: 1,
marginVertical: 4,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 6,
elevation: 1,
},
taskCompleted: {
opacity: 0.5,
},
indented: {
marginLeft: 32,
marginBottom: 4,
},
taskOverdue: {
borderColor: theme.dangerBorder,
},
taskDueToday: {
borderColor: mixHex(theme.info, theme.background, 0.55),
},
taskSelected: {
shadowColor: '#000',
shadowOpacity: 0.1,
shadowRadius: 6,
elevation: 2,
},
taskHovered: {
borderColor: mixHex(theme.info, theme.background, 0.55),
backgroundColor: mixHex(theme.info, theme.background, 0.85),
},
dragLifted: {
zIndex: 100,
elevation: 12,
shadowColor: '#000',
shadowOpacity: 0.25,
shadowRadius: 12,
shadowOffset: { width: 0, height: 8 },
},
contentWrapper: {
flex: 1,
},
content: {
gap: 12,
},
titleRow: {
flexDirection: 'row',
alignItems: 'center',
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: 12,
height: 12,
borderRadius: 6,
},
title: {
fontSize: 17,
fontWeight: '500',
flex: 1,
marginRight: 16,
},
titleCompleted: {
textDecorationLine: 'line-through',
color: theme.textMuted,
},
repeatIcon: {
marginLeft: 8,
width: 14,
height: 14,
alignItems: 'center',
justifyContent: 'center',
},
chevron: {
marginLeft: 8,
width: 16,
height: 16,
alignItems: 'center',
justifyContent: 'center',
},
reminderIcon: {
marginLeft: 6,
width: 13,
height: 13,
alignItems: 'center',
justifyContent: 'center',
},
titleOverdue: {
color: theme.danger,
},
priorityBadge: {
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 8,
minWidth: 20,
alignItems: 'center',
},
priorityText: {
fontSize: 9,
fontWeight: '700',
color: '#FFFFFF',
},
assigneeBadge: {
width: 20,
height: 20,
borderRadius: 10,
backgroundColor: theme.dangerSoft,
alignItems: 'center',
justifyContent: 'center',
},
assigneeText: {
fontSize: 9,
fontWeight: '700',
color: theme.danger,
},
dueRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
dueText: {
fontSize: 13,
},
dueTextOverdue: {
color: theme.danger,
fontWeight: '600',
},
dueTextDueToday: {
color: theme.info,
fontWeight: '600',
},
menuButton: {
padding: 4,
marginLeft: 8,
},
allDayBadge: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
marginLeft: 8,
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 8,
backgroundColor: mixHex(theme.info, theme.background, 0.85),
},
allDayText: {
fontSize: 11,
fontWeight: '600',
color: theme.info,
},
checkCircle: {
width: 30,
height: 30,
borderRadius: 15,
alignItems: 'center',
justifyContent: 'center',
marginRight: 12,
},
swipeAction: {
width: 80,
alignItems: 'center',
justifyContent: 'center',
gap: 4,
},
swipeDelete: {
backgroundColor: theme.dangerStrong,
borderTopRightRadius: 16,
borderBottomRightRadius: 16,
},
swipeComplete: {
backgroundColor: theme.successStrong,
borderTopLeftRadius: 16,
borderBottomLeftRadius: 16,
},
swipeActionText: {
color: '#FFFFFF',
fontSize: 12,
fontWeight: '600',
},
});
const createStyles = (theme: ThemeColors) =>
StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 12,
paddingVertical: 14,
borderRadius: 16,
borderWidth: 1,
marginVertical: 4,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 6,
elevation: 1,
},
taskCompleted: {
opacity: 0.5,
},
indented: {
marginLeft: 32,
marginBottom: 4,
},
taskOverdue: {
borderColor: theme.dangerBorder,
},
taskDueToday: {
borderColor: mixHex(theme.info, theme.background, 0.55),
},
taskSelected: {
shadowColor: '#000',
shadowOpacity: 0.1,
shadowRadius: 6,
elevation: 2,
},
taskHovered: {
borderColor: mixHex(theme.info, theme.background, 0.55),
backgroundColor: mixHex(theme.info, theme.background, 0.85),
},
dragLifted: {
zIndex: 100,
elevation: 12,
shadowColor: '#000',
shadowOpacity: 0.25,
shadowRadius: 12,
shadowOffset: { width: 0, height: 8 },
},
contentWrapper: {
flex: 1,
},
content: {
gap: 12,
},
titleRow: {
flexDirection: 'row',
alignItems: 'center',
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: 12,
height: 12,
borderRadius: 6,
},
title: {
fontSize: 17,
fontWeight: '500',
flex: 1,
marginRight: 16,
},
titleCompleted: {
textDecorationLine: 'line-through',
color: theme.textMuted,
},
repeatIcon: {
marginLeft: 8,
width: 14,
height: 14,
alignItems: 'center',
justifyContent: 'center',
},
chevron: {
marginLeft: 8,
width: 16,
height: 16,
alignItems: 'center',
justifyContent: 'center',
},
reminderIcon: {
marginLeft: 6,
width: 13,
height: 13,
alignItems: 'center',
justifyContent: 'center',
},
titleOverdue: {
color: theme.danger,
},
priorityBadge: {
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 8,
minWidth: 20,
alignItems: 'center',
},
priorityText: {
fontSize: 9,
fontWeight: '700',
color: '#FFFFFF',
},
assigneeBadge: {
width: 20,
height: 20,
borderRadius: 10,
backgroundColor: theme.dangerSoft,
alignItems: 'center',
justifyContent: 'center',
},
assigneeText: {
fontSize: 9,
fontWeight: '700',
color: theme.danger,
},
dueRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
dueText: {
fontSize: 13,
},
dueTextOverdue: {
color: theme.danger,
fontWeight: '600',
},
dueTextDueToday: {
color: theme.info,
fontWeight: '600',
},
menuButton: {
padding: 4,
marginLeft: 8,
},
allDayBadge: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
marginLeft: 8,
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 8,
backgroundColor: mixHex(theme.info, theme.background, 0.85),
},
allDayText: {
fontSize: 11,
fontWeight: '600',
color: theme.info,
},
checkCircle: {
width: 30,
height: 30,
borderRadius: 15,
alignItems: 'center',
justifyContent: 'center',
marginRight: 12,
},
swipeAction: {
width: 80,
alignItems: 'center',
justifyContent: 'center',
gap: 4,
},
swipeDelete: {
backgroundColor: theme.dangerStrong,
borderTopRightRadius: 16,
borderBottomRightRadius: 16,
},
swipeComplete: {
backgroundColor: theme.successStrong,
borderTopLeftRadius: 16,
borderBottomLeftRadius: 16,
},
swipeActionText: {
color: '#FFFFFF',
fontSize: 12,
fontWeight: '600',
},
});
+440 -274
View File
@@ -1,5 +1,13 @@
import React, { useState, useCallback, useMemo, useRef } from 'react';
import { View, Text, StyleSheet, Animated, RefreshControl, Alert, TouchableOpacity } from 'react-native';
import {
View,
Text,
StyleSheet,
Animated,
RefreshControl,
Alert,
TouchableOpacity,
} from 'react-native';
import { useTasks } from '@/hooks/useTasks';
import { useSubtasks } from '@/hooks/useSubtasks';
import { useCategories } from '@/hooks/useDatabase';
@@ -42,10 +50,18 @@ const DropIndicator = ({ theme }: { theme: any }) => (
</View>
);
export function TaskList({ categoryIds = [], showCompleted = false, onSelectionChange }: TaskListProps) {
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 {
tasks,
loading,
refresh: refreshTasks,
} = useTasks(categoryIds, showCompleted ? 'all' : false, todoAheadDays);
const categories = useCategories();
const categoryColors = useMemo(() => {
@@ -62,19 +78,30 @@ export function TaskList({ categoryIds = [], showCompleted = false, onSelectionC
const [hoverTaskId, setHoverTaskId] = useState<string | null>(null);
const [expandedTasks, setExpandedTasks] = useState<Set<string>>(new Set());
const { map: subtasksMap, refresh: refreshSubtasks } = useSubtasks();
const [dropIndicator, setDropIndicator] = useState<{ targetId: string | null; position: 'above' | 'below' } | null>(null);
const [dropIndicator, setDropIndicator] = useState<{
targetId: string | null;
position: 'above' | 'below';
} | null>(null);
const itemRefs = useRef<Map<string, View>>(new Map());
const 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; parentSubtaskId: string | null; positions: Record<string, { top: number; bottom: number }>; subPositions: Record<string, { top: number; bottom: number }> } | null>(null);
const taskDragRef = useRef<{ taskId: string; positions: Record<string, { top: number; bottom: number }> } | null>(null);
const {
modals,
openTaskDelete,
openSubtaskDelete,
openSubtaskEdit,
openTaskEdit,
} = useTaskModals();
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;
parentSubtaskId: string | null;
positions: Record<string, { top: number; bottom: number }>;
subPositions: Record<string, { top: number; bottom: number }>;
} | null>(null);
const taskDragRef = useRef<{
taskId: string;
positions: Record<string, { top: number; bottom: number }>;
} | null>(null);
const { modals, openTaskDelete, openSubtaskDelete, openSubtaskEdit, openTaskEdit } =
useTaskModals();
const registerRef = useCallback((taskId: string, ref: View | null) => {
if (ref) {
@@ -84,13 +111,16 @@ export function TaskList({ categoryIds = [], showCompleted = false, onSelectionC
}
}, []);
const registerSubtaskRef = useCallback((subtaskId: string, parentTaskId: string, ref: View | null) => {
if (ref) {
subtaskRefs.current.set(subtaskId, { ref, parentTaskId });
} else {
subtaskRefs.current.delete(subtaskId);
}
}, []);
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),
@@ -101,7 +131,9 @@ export function TaskList({ categoryIds = [], showCompleted = false, onSelectionC
const sorted = [...tasks];
switch (sortBy) {
case 'date':
sorted.sort((a, b) => (a.dueDate || Number.MAX_SAFE_INTEGER) - (b.dueDate || Number.MAX_SAFE_INTEGER));
sorted.sort(
(a, b) => (a.dueDate || Number.MAX_SAFE_INTEGER) - (b.dueDate || Number.MAX_SAFE_INTEGER)
);
break;
case 'priority':
sorted.sort((a, b) => PRIORITY_RANK[b.priority] - PRIORITY_RANK[a.priority]);
@@ -146,34 +178,43 @@ export function TaskList({ categoryIds = [], showCompleted = false, onSelectionC
onSelectionChange?.(false);
}, [onSelectionChange]);
const enterSelection = useCallback((taskId: string) => {
setSelectionMode(true);
setSelectedIds(new Set([taskId]));
onSelectionChange?.(true);
}, [onSelectionChange]);
const enterSelection = useCallback(
(taskId: string) => {
setSelectionMode(true);
setSelectedIds(new Set([taskId]));
onSelectionChange?.(true);
},
[onSelectionChange]
);
const toggleSelect = useCallback((taskId: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(taskId)) {
next.delete(taskId);
} else {
next.add(taskId);
}
if (next.size === 0) {
setSelectionMode(false);
onSelectionChange?.(false);
}
return next;
});
}, [onSelectionChange]);
const toggleSelect = useCallback(
(taskId: string) => {
setSelectedIds(prev => {
const next = new Set(prev);
if (next.has(taskId)) {
next.delete(taskId);
} else {
next.add(taskId);
}
if (next.size === 0) {
setSelectionMode(false);
onSelectionChange?.(false);
}
return next;
});
},
[onSelectionChange]
);
const handleToggle = useCallback(async (taskId: string) => {
await confirmAndToggleTaskComplete(taskId, () => refreshTasks());
}, [refreshTasks]);
const handleToggle = useCallback(
async (taskId: string) => {
await confirmAndToggleTaskComplete(taskId, () => refreshTasks());
},
[refreshTasks]
);
const toggleExpand = useCallback(async (taskId: string) => {
setExpandedTasks((prev) => {
setExpandedTasks(prev => {
const next = new Set(prev);
if (next.has(taskId)) {
next.delete(taskId);
@@ -184,24 +225,31 @@ export function TaskList({ categoryIds = [], showCompleted = false, onSelectionC
});
}, []);
const handleSubtaskToggle = useCallback(async (subtaskId: string) => {
await toggleSubtaskComplete(subtaskId);
refreshTasks();
refreshSubtasks();
}, [refreshTasks, refreshSubtasks]);
const handleSubtaskToggle = useCallback(
async (subtaskId: string) => {
await toggleSubtaskComplete(subtaskId);
refreshTasks();
refreshSubtasks();
},
[refreshTasks, refreshSubtasks]
);
const handleBulkDelete = useCallback(() => {
Alert.alert(`Delete ${selectedIds.size} task${selectedIds.size > 1 ? 's' : ''}?`, 'This cannot be undone.', [
{ text: 'Cancel', style: 'cancel' },
{
text: 'Delete',
style: 'destructive',
onPress: async () => {
await Promise.all(Array.from(selectedIds).map(taskId => deleteTask(taskId)));
exitSelection();
Alert.alert(
`Delete ${selectedIds.size} task${selectedIds.size > 1 ? 's' : ''}?`,
'This cannot be undone.',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Delete',
style: 'destructive',
onPress: async () => {
await Promise.all(Array.from(selectedIds).map(taskId => deleteTask(taskId)));
exitSelection();
},
},
},
]);
]
);
}, [selectedIds, exitSelection]);
const handleBulkComplete = useCallback(async () => {
@@ -212,14 +260,16 @@ export function TaskList({ categoryIds = [], showCompleted = false, onSelectionC
const measureItems = useCallback(async () => {
const positions: Record<string, { top: number; bottom: number }> = {};
const entries = Array.from(itemRefs.current.entries());
await Promise.all(entries.map(([id, ref]) => {
return new Promise<void>((resolve) => {
ref?.measureInWindow((_x, y, _w, h) => {
positions[id] = { top: y, bottom: y + h };
resolve();
await Promise.all(
entries.map(([id, ref]) => {
return new Promise<void>(resolve => {
ref?.measureInWindow((_x, y, _w, h) => {
positions[id] = { top: y, bottom: y + h };
resolve();
});
});
});
}));
})
);
return positions;
}, []);
@@ -227,64 +277,91 @@ export function TaskList({ categoryIds = [], showCompleted = false, onSelectionC
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();
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 findHoverTask = useCallback((absoluteY: number, draggedId: string, positions: Record<string, { top: number; bottom: number }>) => {
const entries = Object.entries(positions);
for (let i = entries.length - 1; i >= 0; i--) {
const [id, p] = entries[i];
if (id === draggedId) continue;
if (absoluteY >= p.top && absoluteY <= p.bottom) {
return id;
const findHoverTask = useCallback(
(
absoluteY: number,
draggedId: string,
positions: Record<string, { top: number; bottom: number }>
) => {
const entries = Object.entries(positions);
for (let i = entries.length - 1; i >= 0; i--) {
const [id, p] = entries[i];
if (id === draggedId) continue;
if (absoluteY >= p.top && absoluteY <= p.bottom) {
return id;
}
}
}
return null;
}, []);
return null;
},
[]
);
const updateHoverTaskTarget = useCallback((target: string | null) => {
setHoverTaskId(target ?? null);
}, []);
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;
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)) {
if (id === draggedId) continue;
if (absoluteY >= p.top && absoluteY <= p.bottom) {
return id;
for (const [id, p] of Object.entries(positions)) {
if (id === draggedId) continue;
if (absoluteY >= p.top && absoluteY <= p.bottom) {
return id;
}
}
}
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 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) => {
dragStateRef.current = { taskId, ...(await measureAll()) };
}, [measureAll]);
const handleDragStart = useCallback(
async (taskId: string) => {
dragStateRef.current = { taskId, ...(await measureAll()) };
},
[measureAll]
);
const refreshDragPositions = useCallback(() => {
measureAll().then((m) => {
measureAll().then(m => {
if (subtaskDragRef.current) {
subtaskDragRef.current = { ...subtaskDragRef.current, ...m };
} else if (dragStateRef.current) {
@@ -293,161 +370,217 @@ export function TaskList({ categoryIds = [], showCompleted = false, onSelectionC
});
}, [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 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 state = dragStateRef.current;
if (!state) return;
const target = findHoverTarget(absoluteY, state.taskId, state.positions, state.subPositions);
updateHoverTarget(target);
if (target) {
if (state.subPositions[target]) {
setDropIndicator(null);
const handleDragUpdate = useCallback(
(absoluteY: number) => {
const state = dragStateRef.current;
if (!state) return;
const target = findHoverTarget(absoluteY, state.taskId, 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 position = calculateDropPosition(absoluteY, target, state.positions);
setDropIndicator({ targetId: target, position });
// 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);
}
}
} 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' });
},
[findHoverTarget, calculateDropPosition, updateHoverTarget]
);
const handleDragEnd = useCallback(
(absoluteY: number) => {
const state = dragStateRef.current;
dragStateRef.current = null;
setHoverTaskId(null);
setDropIndicator(null);
if (!state) return;
const target = findHoverTarget(absoluteY, state.taskId, state.positions, state.subPositions);
if (target) {
if (state.subPositions[target]) {
const parentTaskId = subtaskRefs.current.get(target)?.parentTaskId ?? state.taskId;
(async () => {
await convertTaskToSubtask(state.taskId, parentTaskId, target);
})();
} else {
(async () => {
await convertTaskToSubtask(state.taskId, target);
})();
}
}
},
[findHoverTarget]
);
const handleSubtaskDragStart = useCallback(
async (subtaskId: string, parentTaskId: string, parentSubtaskId: string | null = null) => {
subtaskDragRef.current = {
subtaskId,
parentTaskId,
parentSubtaskId,
...(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 {
setDropIndicator(null);
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]);
},
[findHoverTarget, calculateDropPosition, updateHoverTarget]
);
const handleDragEnd = useCallback((absoluteY: number) => {
const state = dragStateRef.current;
dragStateRef.current = null;
setHoverTaskId(null);
setDropIndicator(null);
if (!state) return;
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.taskId, state.positions, state.subPositions);
if (target) {
if (state.subPositions[target]) {
const parentTaskId = subtaskRefs.current.get(target)?.parentTaskId ?? state.taskId;
(async () => {
await convertTaskToSubtask(state.taskId, parentTaskId, target);
})();
const target = findHoverTarget(
absoluteY,
state.subtaskId,
state.positions,
state.subPositions
);
if (target === state.subtaskId) return;
// Only allow dropping on tasks (not subtasks) to match task behavior
// Check if target exists in main task positions, not subtask positions
const isTaskTarget = state.positions != null && target != null && target in state.positions;
if (target && isTaskTarget) {
// Move subtask to target task - makes it a subtask of that task
// If no target or target is a subtask: do nothing (matches task behavior)
await moveSubtaskToTask(state.subtaskId, target);
}
},
[findHoverTarget, moveSubtaskToTask]
);
const handleTaskDragStart = useCallback(
async (taskId: string) => {
const positions = (await measureItems()) as Record<string, { top: number; bottom: number }>;
taskDragRef.current = { taskId, positions };
},
[measureItems]
);
const handleTaskDragUpdate = useCallback(
(absoluteY: number) => {
const state = taskDragRef.current;
if (!state) return;
const target = findHoverTask(absoluteY, state.taskId, state.positions);
updateHoverTaskTarget(target);
if (target) {
setDropIndicator({ targetId: target, position: 'below' });
} else {
(async () => {
await convertTaskToSubtask(state.taskId, target);
})();
const lastItem = Object.values(state.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]);
},
[findHoverTask, updateHoverTaskTarget]
);
const handleSubtaskDragStart = useCallback(async (subtaskId: string, parentTaskId: string, parentSubtaskId: string | null = null) => {
subtaskDragRef.current = { subtaskId, parentTaskId, parentSubtaskId, ...(await measureAll()) };
}, [measureAll]);
const handleTaskDragEnd = useCallback(
async (absoluteY: number) => {
const state = taskDragRef.current;
taskDragRef.current = null;
setHoverTaskId(null);
setDropIndicator(null);
if (!state) return;
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);
const target = findHoverTask(absoluteY, state.taskId, state.positions);
if (target === state.taskId) return;
// Set drop indicator based on target position
if (target) {
setDropIndicator({ targetId: target, position: 'below' });
} else {
const position = calculateDropPosition(absoluteY, target, state.positions);
setDropIndicator({ targetId: target, position });
const lastItem = Object.values(state.positions).reduce(
(max, p) => (p.bottom > max.bottom ? p : max),
{ bottom: 0 }
);
if (absoluteY > lastItem.bottom) {
setDropIndicator({ targetId: null, position: 'below' });
}
}
} 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;
// Only allow dropping on tasks (not subtasks) to match task behavior
// Check if target exists in main task positions, not subtask positions
const isTaskTarget = state.positions != null && target != null && target in state.positions;
if (target && isTaskTarget) {
// Move subtask to target task - makes it a subtask of that task
// If no target or target is a subtask: do nothing (matches task behavior)
await moveSubtaskToTask(state.subtaskId, target);
}
}, [findHoverTarget, moveSubtaskToTask]);
const handleTaskDragStart = useCallback(async (taskId: string) => {
const positions = await measureItems() as Record<string, { top: number; bottom: number }>;
taskDragRef.current = { taskId, positions };
}, [measureItems]);
const handleTaskDragUpdate = useCallback((absoluteY: number) => {
const state = taskDragRef.current;
if (!state) return;
const target = findHoverTask(absoluteY, state.taskId, state.positions);
updateHoverTaskTarget(target);
if (target) {
setDropIndicator({ targetId: target, position: 'below' });
} else {
const lastItem = Object.values(state.positions).reduce((max, p) => p.bottom > max.bottom ? p : max, { bottom: 0 });
if (absoluteY > lastItem.bottom) {
setDropIndicator({ targetId: null, position: 'below' });
} else {
setDropIndicator(null);
}
}
}, [findHoverTask, updateHoverTaskTarget]);
const handleTaskDragEnd = useCallback(async (absoluteY: number) => {
const state = taskDragRef.current;
taskDragRef.current = null;
setHoverTaskId(null);
setDropIndicator(null);
if (!state) return;
const target = findHoverTask(absoluteY, state.taskId, state.positions);
if (target === state.taskId) return;
// Set drop indicator based on target position
if (target) {
setDropIndicator({ targetId: target, position: 'below' });
} else {
const lastItem = Object.values(state.positions).reduce((max, p) => p.bottom > max.bottom ? p : max, { bottom: 0 });
if (absoluteY > lastItem.bottom) {
setDropIndicator({ targetId: null, position: 'below' });
}
}
}, [findHoverTask]);
},
[findHoverTask]
);
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));
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} />}
@@ -466,10 +599,10 @@ const handleSubtaskDragEnd = useCallback(async (absoluteY: number) => {
onExpand={toggleExpand}
onSelect={toggleSelect}
onEnterSelection={enterSelection}
onMenuOpen={(task) => openTaskEdit(task.id)}
onMenuOpen={task => openTaskEdit(task.id)}
onSubtaskToggle={handleSubtaskToggle}
onSubtaskDelete={openSubtaskDelete}
onSubtaskMenuOpen={(subtask) => openSubtaskEdit(subtask.id)}
onSubtaskMenuOpen={subtask => openSubtaskEdit(subtask.id)}
onDragStart={handleDragStart}
onDragUpdate={handleDragUpdate}
onDragEnd={handleDragEnd}
@@ -523,7 +656,9 @@ const handleSubtaskDragEnd = useCallback(async (absoluteY: number) => {
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>
<Text style={[styles.emptySubtext, { color: theme.textMuted }]}>
Tap + to add your first task
</Text>
</View>
);
}, [sortedTasks.length, theme.textSecondary, theme.textMuted]);
@@ -545,9 +680,16 @@ const handleSubtaskDragEnd = useCallback(async (absoluteY: number) => {
<View style={styles.container}>
<Animated.FlatList
data={sortedTasks}
keyExtractor={(item) => item.id}
keyExtractor={item => item.id}
renderItem={renderItem}
extraData={{ expandedTasks, subtasksMap, selectedIds, selectionMode, hoverTaskId, dropIndicator }}
extraData={{
expandedTasks,
subtasksMap,
selectedIds,
selectionMode,
hoverTaskId,
dropIndicator,
}}
ItemSeparatorComponent={MemoSeparator}
ListHeaderComponent={listHeader}
ListFooterComponent={listFooter}
@@ -566,16 +708,36 @@ const handleSubtaskDragEnd = useCallback(async (absoluteY: number) => {
{modals(() => {})}
{selectionMode && (
<View style={[styles.selectionBar, { backgroundColor: theme.sheetBg, borderTopColor: theme.border }]}>
<TouchableOpacity onPress={exitSelection} style={styles.selectionCancel} activeOpacity={0.7}>
<View
style={[
styles.selectionBar,
{ backgroundColor: theme.sheetBg, borderTopColor: theme.border },
]}
>
<TouchableOpacity
onPress={exitSelection}
style={styles.selectionCancel}
activeOpacity={0.7}
>
<Svg width={20} height={20} viewBox="0 0 24 24">
<Path d="M18 6L6 18M6 6l12 12" stroke={theme.textSecondary} strokeWidth={2.2} strokeLinecap="round" />
<Path
d="M18 6L6 18M6 6l12 12"
stroke={theme.textSecondary}
strokeWidth={2.2}
strokeLinecap="round"
/>
</Svg>
<Text style={[styles.selectionCancelText, { color: theme.textFaint }]}>Cancel</Text>
</TouchableOpacity>
<Text style={[styles.selectionCount, { color: theme.text }]}>{selectedIds.size} selected</Text>
<Text style={[styles.selectionCount, { color: theme.text }]}>
{selectedIds.size} selected
</Text>
<View style={styles.selectionActions}>
<SelectionButton label="Complete" color={theme.successStrong} onPress={handleBulkComplete} />
<SelectionButton
label="Complete"
color={theme.successStrong}
onPress={handleBulkComplete}
/>
<SelectionButton label="Delete" color={theme.dangerStrong} onPress={handleBulkDelete} />
</View>
</View>
@@ -584,7 +746,15 @@ const handleSubtaskDragEnd = useCallback(async (absoluteY: number) => {
);
}
const SelectionButton = React.memo(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;
}) {
return (
<TouchableOpacity
style={[styles.selectionButton, { backgroundColor: color }]}
@@ -668,10 +838,7 @@ const TaskRow = React.memo(function TaskRow({
[subtasks]
);
return (
<View
ref={(ref) => registerRef(task.id, ref)}
style={styles.dragContainer}
>
<View ref={ref => registerRef(task.id, ref)} style={styles.dragContainer}>
<TaskItem
task={task as unknown as TaskData}
onToggle={() => onToggle(task.id)}
@@ -695,19 +862,19 @@ const TaskRow = React.memo(function TaskRow({
/>
{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)}
{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)}
onDragStart={sub => onSubtaskDragStart(sub.id, task.id)}
onDragUpdate={onSubtaskDragUpdate}
onDragEnd={onSubtaskDragEnd}
categoryColor={categoryColor}
@@ -716,8 +883,8 @@ const TaskRow = React.memo(function TaskRow({
))}
</View>
)}
</View>
);
</View>
);
});
const styles = StyleSheet.create({
@@ -751,8 +918,7 @@ const styles = StyleSheet.create({
paddingTop: 4,
marginBottom: 4,
},
dragContainer: {
},
dragContainer: {},
dropIndicatorContainer: {
height: 8,
justifyContent: 'center',
@@ -1,5 +1,5 @@
import React from 'react';
import { View, Text, StyleSheet, TextInput , TextInputProps } from 'react-native';
import { View, Text, StyleSheet, TextInput, TextInputProps } from 'react-native';
import { useSettings, type ThemeColors } from '@/theme';
interface TaskNameInputProps extends TextInputProps {
@@ -35,37 +35,38 @@ export function TaskNameInput({ error, ...props }: TaskNameInputProps) {
);
}
const createStyles = (theme: ThemeColors) => StyleSheet.create({
container: {
gap: 5,
},
labelRow: {
flexDirection: 'row',
alignItems: 'center',
},
label: {
fontSize: 13,
fontWeight: '600',
},
required: {
color: theme.dangerStrong,
fontSize: 13,
fontWeight: '600',
},
input: {
height: 48,
paddingHorizontal: 14,
borderRadius: 10,
borderWidth: 1,
fontSize: 15,
},
inputError: {
borderColor: theme.dangerStrong,
borderWidth: 1.5,
},
errorText: {
fontSize: 11,
color: theme.dangerStrong,
marginLeft: 4,
},
});
const createStyles = (theme: ThemeColors) =>
StyleSheet.create({
container: {
gap: 5,
},
labelRow: {
flexDirection: 'row',
alignItems: 'center',
},
label: {
fontSize: 13,
fontWeight: '600',
},
required: {
color: theme.dangerStrong,
fontSize: 13,
fontWeight: '600',
},
input: {
height: 48,
paddingHorizontal: 14,
borderRadius: 10,
borderWidth: 1,
fontSize: 15,
},
inputError: {
borderColor: theme.dangerStrong,
borderWidth: 1.5,
},
errorText: {
fontSize: 11,
color: theme.dangerStrong,
marginLeft: 4,
},
});
@@ -49,19 +49,45 @@ export function TaskOverflowMenu({
{
key: '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.text} strokeWidth={2.2} 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,
},
{
key: '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.text} strokeWidth={2.5} 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,
},
{
key: 'duplicate',
label: 'Duplicate',
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>,
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,
},
];
@@ -70,7 +96,11 @@ export function TaskOverflowMenu({
actions.push({
key: 'category',
label: 'Change Tags',
icon: <Svg width={20} height={20} viewBox="0 0 24 24"><Circle2 /></Svg>,
icon: (
<Svg width={20} height={20} viewBox="0 0 24 24">
<Circle2 />
</Svg>
),
onPress: onChangeCategory ?? (() => {}),
});
}
@@ -79,23 +109,49 @@ export function TaskOverflowMenu({
{
key: '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.text} strokeWidth={2.2} 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,
},
{
key: 'delete',
label: 'Delete',
destructive: true,
icon: <Svg width={20} height={20} viewBox="0 0 24 24"><Path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13" stroke={theme.danger} strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" fill="none" /></Svg>,
icon: (
<Svg width={20} height={20} viewBox="0 0 24 24">
<Path
d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"
stroke={theme.danger}
strokeWidth={2.2}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</Svg>
),
onPress: onDelete,
},
}
);
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>,
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,
});
}
@@ -112,7 +168,7 @@ export function TaskOverflowMenu({
<Text style={[styles.title, { color: theme.text }]} numberOfLines={1}>
{task.title}
</Text>
{actions.map((action) => (
{actions.map(action => (
<TouchableOpacity
key={action.key}
style={[styles.action, { borderColor: theme.border }]}
@@ -125,7 +181,12 @@ export function TaskOverflowMenu({
accessibilityLabel={`${action.label}${action.destructive ? ' (dangerous)' : ''}`}
>
{action.icon}
<Text style={[styles.actionText, action.destructive ? styles.destructiveText : { color: theme.textSecondary }]}>
<Text
style={[
styles.actionText,
action.destructive ? styles.destructiveText : { color: theme.textSecondary },
]}
>
{action.label}
</Text>
</TouchableOpacity>
@@ -140,54 +201,60 @@ function Circle2() {
const { theme } = useSettings();
return (
<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.text} strokeWidth={2.2} 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.text} opacity={0.35} />
</Svg>
);
}
const createStyles = (theme: ThemeColors) => StyleSheet.create({
overlay: {
flex: 1,
justifyContent: 'flex-end',
},
sheet: {
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
paddingTop: 8,
paddingHorizontal: 16,
paddingBottom: 32,
},
handle: {
width: 40,
height: 4,
borderRadius: 2,
alignSelf: 'center',
marginBottom: 12,
},
title: {
fontSize: 14,
fontWeight: '600',
textAlign: 'center',
marginBottom: 16,
opacity: 0.8,
},
action: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
paddingVertical: 14,
paddingHorizontal: 16,
borderRadius: 12,
borderWidth: 1,
marginBottom: 8,
},
actionText: {
fontSize: 15,
fontWeight: '500',
},
destructiveText: {
color: theme.dangerStrong,
fontWeight: '600',
},
});
const createStyles = (theme: ThemeColors) =>
StyleSheet.create({
overlay: {
flex: 1,
justifyContent: 'flex-end',
},
sheet: {
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
paddingTop: 8,
paddingHorizontal: 16,
paddingBottom: 32,
},
handle: {
width: 40,
height: 4,
borderRadius: 2,
alignSelf: 'center',
marginBottom: 12,
},
title: {
fontSize: 14,
fontWeight: '600',
textAlign: 'center',
marginBottom: 16,
opacity: 0.8,
},
action: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
paddingVertical: 14,
paddingHorizontal: 16,
borderRadius: 12,
borderWidth: 1,
marginBottom: 8,
},
actionText: {
fontSize: 15,
fontWeight: '500',
},
destructiveText: {
color: theme.dangerStrong,
fontWeight: '600',
},
});
+291 -26
View File
@@ -1,30 +1,295 @@
import { useEffect } from 'react';
import { Alert, Linking } from 'react-native';
import { checkForUpdates, wasPromptedFor, markPrompted } from '@/services/updates';
import { useEffect, useState, useCallback } from 'react';
import { Modal, View, Text, StyleSheet, Pressable, ScrollView, Platform } from 'react-native';
import {
checkForUpdates,
wasPromptedFor,
markPrompted,
downloadAPK,
installAPK,
openReleaseUrl,
AppUpdate,
} from '@/services/updates';
import { useSettings } from '@/theme';
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();
const { theme } = useSettings();
const [update, setUpdate] = useState<AppUpdate | null>(null);
const [visible, setVisible] = useState(false);
const [downloading, setDownloading] = useState(false);
const [downloadProgress, setDownloadProgress] = useState(0);
const [downloadError, setDownloadError] = useState<string | null>(null);
const checkUpdate = useCallback(async () => {
const available = await checkForUpdates();
if (!available) return;
const prompted = await wasPromptedFor(available.tagName);
if (prompted && !available.mandatory) return;
setUpdate(available);
setVisible(true);
}, []);
return null;
}
useEffect(() => {
checkUpdate();
}, [checkUpdate]);
const handleDownload = async () => {
if (!update?.apkUrl) {
await openReleaseUrl(update?.releaseUrl ?? '');
return;
}
setDownloading(true);
setDownloadProgress(0);
setDownloadError(null);
try {
const uri = await downloadAPK(update.apkUrl, setDownloadProgress);
if (uri) {
const installed = await installAPK(uri);
if (!installed) {
setDownloadError('Failed to open installer. Please install manually from Downloads.');
}
} else {
setDownloadError('Download failed. Please try again or download from the release page.');
}
} catch (error) {
setDownloadError('An error occurred during download.');
} finally {
setDownloading(false);
}
};
const handleLater = async () => {
if (update) {
await markPrompted(update.tagName);
}
setVisible(false);
};
const handleReleasePage = async () => {
if (update) {
await openReleaseUrl(update.releaseUrl);
}
};
if (!visible || !update) return null;
const isMandatory = update.mandatory;
return (
<Modal
visible={visible}
transparent
animationType="fade"
onRequestClose={isMandatory ? () => {} : handleLater}
>
<View style={[styles.overlay, { backgroundColor: theme.overlay }]}>
<View style={[styles.modal, { backgroundColor: theme.card }]}>
<View style={[styles.header, { borderBottomColor: theme.border }]}>
<Text style={[styles.title, { color: theme.text }]}>Update Available</Text>
<Text style={[styles.version, { color: theme.textSecondary }]}>
Version {update.version}
</Text>
</View>
<ScrollView style={styles.content} contentContainerStyle={styles.contentContainer}>
<Text style={[styles.sectionTitle, { color: theme.text }]}>What's New</Text>
<Text style={[styles.notes, { color: theme.textSecondary }]}>
{update.notes || 'No release notes provided.'}
</Text>
{isMandatory && (
<View style={styles.mandatoryBadge}>
<Text style={styles.mandatoryText}>Mandatory Update</Text>
</View>
)}
{downloading && (
<View style={styles.progressContainer}>
<View style={[styles.progressBar, { backgroundColor: theme.border }]}>
<View
style={[
styles.progressFill,
{ backgroundColor: theme.accent, width: `${downloadProgress * 100}%` },
]}
/>
</View>
<Text style={[styles.progressText, { color: theme.textSecondary }]}>
Downloading... {Math.round(downloadProgress * 100)}%
</Text>
</View>
)}
{downloadError && (
<View style={styles.errorContainer}>
<Text style={styles.errorText}>{downloadError}</Text>
<Pressable style={styles.errorAction} onPress={handleDownload}>
<Text style={styles.errorActionText}>Retry Download</Text>
</Pressable>
</View>
)}
</ScrollView>
<View style={[styles.actions, { borderTopColor: theme.border }]}>
{!isMandatory && (
<Pressable
style={[styles.button, styles.buttonSecondary, { backgroundColor: theme.cardAlt }]}
onPress={handleLater}
>
<Text style={[styles.buttonText, { color: theme.textSecondary }]}>Later</Text>
</Pressable>
)}
<Pressable
style={[
styles.button,
styles.buttonPrimary,
{ backgroundColor: theme.accent, opacity: downloading ? 0.7 : 1 },
]}
onPress={handleDownload}
disabled={downloading}
>
<Text style={[styles.buttonText, { color: theme.accentText }]}>
{downloading
? 'Downloading...'
: update.apkUrl
? 'Download & Install'
: 'View Release'}
</Text>
</Pressable>
<Pressable
style={[styles.button, styles.buttonGhost, { backgroundColor: 'transparent' }]}
onPress={handleReleasePage}
>
<Text style={[styles.buttonText, { color: theme.textSecondary }]}>Release Notes</Text>
</Pressable>
</View>
</View>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 20,
},
modal: {
width: '100%',
maxWidth: 480,
borderRadius: 20,
overflow: 'hidden',
maxHeight: '85%',
},
header: {
padding: 20,
borderBottomWidth: 1,
},
title: {
fontSize: 20,
fontWeight: '700',
marginBottom: 4,
},
version: {
fontSize: 14,
},
content: {
maxHeight: 300,
},
contentContainer: {
padding: 20,
paddingBottom: 12,
},
sectionTitle: {
fontSize: 14,
fontWeight: '600',
marginBottom: 8,
textTransform: 'uppercase',
letterSpacing: 0.5,
},
notes: {
fontSize: 14,
lineHeight: 22,
},
mandatoryBadge: {
marginTop: 12,
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 8,
backgroundColor: '#FFEBEE',
alignSelf: 'flex-start',
},
mandatoryText: {
fontSize: 12,
fontWeight: '600',
color: '#C62828',
},
progressContainer: {
marginTop: 16,
},
progressBar: {
height: 6,
borderRadius: 3,
overflow: 'hidden',
},
progressFill: {
height: '100%',
borderRadius: 3,
},
progressText: {
fontSize: 12,
marginTop: 6,
textAlign: 'center',
},
errorContainer: {
marginTop: 16,
padding: 12,
borderRadius: 8,
backgroundColor: '#FFF3E0',
},
errorText: {
fontSize: 13,
color: '#E65100',
marginBottom: 8,
},
errorAction: {
paddingVertical: 8,
paddingHorizontal: 16,
borderRadius: 6,
backgroundColor: '#FF9800',
alignSelf: 'flex-start',
},
errorActionText: {
fontSize: 13,
fontWeight: '600',
color: '#FFF',
},
actions: {
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'flex-end',
gap: 8,
padding: 16,
borderTopWidth: 1,
},
button: {
paddingVertical: 10,
paddingHorizontal: 20,
borderRadius: 10,
minWidth: 100,
alignItems: 'center',
},
buttonPrimary: {},
buttonSecondary: {},
buttonGhost: {},
buttonText: {
fontSize: 14,
fontWeight: '600',
},
});
@@ -53,7 +53,13 @@ interface WheelRowProps {
onPress: () => void;
}
const WheelRow = React.memo(function WheelRow({ label, selected, accent, textMuted, onPress }: WheelRowProps) {
const WheelRow = React.memo(function WheelRow({
label,
selected,
accent,
textMuted,
onPress,
}: WheelRowProps) {
return (
<TouchableOpacity
style={[styles.row, selected && styles.rowSelected]}
@@ -96,7 +102,10 @@ function WheelColumn({
const indexRef = useRef(initialIndex);
useEffect(() => {
listRef.current?.scrollToOffset({ offset: initialIndex * ITEM_HEIGHT - (ITEM_HEIGHT * VISIBLE_ITEMS - ITEM_HEIGHT) / 2, animated: false });
listRef.current?.scrollToOffset({
offset: initialIndex * ITEM_HEIGHT - (ITEM_HEIGHT * VISIBLE_ITEMS - ITEM_HEIGHT) / 2,
animated: false,
});
indexRef.current = initialIndex;
}, [initialIndex]);
@@ -105,7 +114,10 @@ function WheelColumn({
const clamped = Math.min(Math.max(index, 0), data.length - 1);
indexRef.current = clamped;
onIndexChange(clamped);
listRef.current?.scrollToOffset({ offset: clamped * ITEM_HEIGHT - (ITEM_HEIGHT * VISIBLE_ITEMS - ITEM_HEIGHT) / 2, animated: true });
listRef.current?.scrollToOffset({
offset: clamped * ITEM_HEIGHT - (ITEM_HEIGHT * VISIBLE_ITEMS - ITEM_HEIGHT) / 2,
animated: true,
});
},
[data.length, onIndexChange]
);
@@ -126,7 +138,9 @@ function WheelColumn({
const handleScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
const offset = event.nativeEvent.contentOffset.y;
const index = Math.round((offset + (ITEM_HEIGHT * VISIBLE_ITEMS - ITEM_HEIGHT) / 2) / ITEM_HEIGHT);
const index = Math.round(
(offset + (ITEM_HEIGHT * VISIBLE_ITEMS - ITEM_HEIGHT) / 2) / ITEM_HEIGHT
);
const clamped = Math.min(Math.max(index, 0), data.length - 1);
if (clamped !== indexRef.current) {
indexRef.current = clamped;
@@ -145,29 +159,32 @@ function WheelColumn({
}, []);
return (
<FlatList
ref={listRef}
data={data}
keyExtractor={(item) => String(item)}
renderItem={renderItem}
getItemLayout={(_data, index) => ({
length: ITEM_HEIGHT,
offset: ITEM_HEIGHT * index,
index,
})}
initialScrollIndex={initialIndex}
snapToOffsets={data.map((_, i) => i * ITEM_HEIGHT)}
decelerationRate="normal"
showsVerticalScrollIndicator={false}
onScroll={handleScroll}
onScrollEndDrag={handleScrollEnd}
onMomentumScrollEnd={handleScrollEnd}
scrollEventThrottle={16}
style={[styles.column, { width }]}
contentContainerStyle={{ paddingBottom: (VISIBLE_ITEMS - 1) * ITEM_HEIGHT, alignItems: 'stretch' }}
snapToAlignment="center"
snapToInterval={ITEM_HEIGHT}
/>
<FlatList
ref={listRef}
data={data}
keyExtractor={item => String(item)}
renderItem={renderItem}
getItemLayout={(_data, index) => ({
length: ITEM_HEIGHT,
offset: ITEM_HEIGHT * index,
index,
})}
initialScrollIndex={initialIndex}
snapToOffsets={data.map((_, i) => i * ITEM_HEIGHT)}
decelerationRate="normal"
showsVerticalScrollIndicator={false}
onScroll={handleScroll}
onScrollEndDrag={handleScrollEnd}
onMomentumScrollEnd={handleScrollEnd}
scrollEventThrottle={16}
style={[styles.column, { width }]}
contentContainerStyle={{
paddingBottom: (VISIBLE_ITEMS - 1) * ITEM_HEIGHT,
alignItems: 'stretch',
}}
snapToAlignment="center"
snapToInterval={ITEM_HEIGHT}
/>
);
}
@@ -207,7 +224,10 @@ export function WheelTimePicker({
<View style={[styles.overlay, { backgroundColor: theme.overlay }]}>
<View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}>
<View style={styles.header}>
<TouchableOpacity onPress={onDismiss} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
<TouchableOpacity
onPress={onDismiss}
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
>
<Text style={[styles.headerAction, { color: theme.textMuted }]}>Cancel</Text>
</TouchableOpacity>
<View style={styles.headerCenter}>
@@ -217,10 +237,16 @@ export function WheelTimePicker({
</Text>
</View>
<TouchableOpacity
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 }}
>
<Text style={[styles.headerAction, styles.doneText, { color: theme.accentStrong }]}>Done</Text>
<Text style={[styles.headerAction, styles.doneText, { color: theme.accentStrong }]}>
Done
</Text>
</TouchableOpacity>
</View>
+1 -1
View File
@@ -43,4 +43,4 @@ export const PRIORITY_LABELS: Record<string, string> = {
medium: 'Medium',
high: 'High',
critical: 'Critical',
};
};
@@ -6,7 +6,7 @@ export const adapter = new SQLiteAdapter({
schema,
migrations,
dbName: 'carryyourlive',
onSetUpError: (error) => {
onSetUpError: error => {
console.error('Database setup error:', error);
},
});
+1 -1
View File
@@ -1,2 +1,2 @@
import { adapter } from './adapter.native';
export { adapter };
export { adapter };
+1 -1
View File
@@ -8,7 +8,7 @@ export const adapter = new LokiJSAdapter({
dbName: 'carryyourlive',
useWebWorker: false,
useIncrementalIndexedDB: true,
onSetUpError: (error) => {
onSetUpError: error => {
console.error('Database setup error:', error);
},
});
+21 -6
View File
@@ -1,4 +1,9 @@
import { schemaMigrations, addColumns, createTable, unsafeExecuteSql } from '@nozbe/watermelondb/Schema/migrations';
import {
schemaMigrations,
addColumns,
createTable,
unsafeExecuteSql,
} from '@nozbe/watermelondb/Schema/migrations';
export const migrations = schemaMigrations({
migrations: [
@@ -152,11 +157,21 @@ export const migrations = schemaMigrations({
unsafeExecuteSql('CREATE INDEX IF NOT EXISTS idx_tasks_updated_at ON tasks(updated_at);'),
unsafeExecuteSql('CREATE INDEX IF NOT EXISTS idx_tasks_created_at ON tasks(created_at);'),
unsafeExecuteSql('CREATE INDEX IF NOT EXISTS idx_tasks_completed ON tasks(completed);'),
unsafeExecuteSql('CREATE INDEX IF NOT EXISTS idx_subtasks_updated_at ON subtasks(updated_at);'),
unsafeExecuteSql('CREATE INDEX IF NOT EXISTS idx_subtasks_created_at ON subtasks(created_at);'),
unsafeExecuteSql('CREATE INDEX IF NOT EXISTS idx_categories_updated_at ON categories(updated_at);'),
unsafeExecuteSql('CREATE INDEX IF NOT EXISTS idx_repeat_profiles_updated_at ON repeat_profiles(updated_at);'),
unsafeExecuteSql('CREATE INDEX IF NOT EXISTS idx_friendships_updated_at ON friendships(updated_at);'),
unsafeExecuteSql(
'CREATE INDEX IF NOT EXISTS idx_subtasks_updated_at ON subtasks(updated_at);'
),
unsafeExecuteSql(
'CREATE INDEX IF NOT EXISTS idx_subtasks_created_at ON subtasks(created_at);'
),
unsafeExecuteSql(
'CREATE INDEX IF NOT EXISTS idx_categories_updated_at ON categories(updated_at);'
),
unsafeExecuteSql(
'CREATE INDEX IF NOT EXISTS idx_repeat_profiles_updated_at ON repeat_profiles(updated_at);'
),
unsafeExecuteSql(
'CREATE INDEX IF NOT EXISTS idx_friendships_updated_at ON friendships(updated_at);'
),
unsafeExecuteSql('CREATE INDEX IF NOT EXISTS idx_tasks_series_id ON tasks(series_id);'),
],
},
+1 -1
View File
@@ -97,4 +97,4 @@ export const schema = appSchema({
],
}),
],
});
});
+42 -23
View File
@@ -17,8 +17,20 @@ const LAST_PUSHED_AT_KEY = 'sync:lastPushedAt';
const LAST_RUN_AT_KEY = 'sync:lastRunAt';
export interface SyncResult {
pushed: { categories: number; tasks: number; subtasks: number; repeatProfiles: number; friendships: number };
pulled: { categories: number; tasks: number; subtasks: number; repeatProfiles: number; friendships: number };
pushed: {
categories: number;
tasks: number;
subtasks: number;
repeatProfiles: number;
friendships: number;
};
pulled: {
categories: number;
tasks: number;
subtasks: number;
repeatProfiles: number;
friendships: number;
};
conflicts: number;
timestamp: number;
}
@@ -150,7 +162,10 @@ export async function runSync(): Promise<SyncResult> {
return result;
}
async function pushChanges(): Promise<{ conflicts: PushConflict[]; pushedDeletions: { entity: TombstoneEntity; id: string }[] }> {
async function pushChanges(): Promise<{
conflicts: PushConflict[];
pushedDeletions: { entity: TombstoneEntity; id: string }[];
}> {
const lastPushedAt = await getLastPushedAt();
const [tasks, subtasks, repeatProfiles, friendships] = await Promise.all([
@@ -195,7 +210,7 @@ async function pushChanges(): Promise<{ conflicts: PushConflict[]; pushedDeletio
if (t.categoryId) referencedCategoryIds.add(t.categoryId);
}
const changedSubtasks = subtasks.map((s) => ({
const changedSubtasks = subtasks.map(s => ({
id: s.id,
taskId: s.taskId,
parentSubtaskId: s.parentSubtaskId || null,
@@ -251,7 +266,7 @@ async function pushChanges(): Promise<{ conflicts: PushConflict[]; pushedDeletio
}
const categories = await categoryQuery.fetch();
const changedCategories = categories.map((c) => ({
const changedCategories = categories.map(c => ({
id: c.id,
name: c.name,
color: c.color,
@@ -260,7 +275,7 @@ async function pushChanges(): Promise<{ conflicts: PushConflict[]; pushedDeletio
updatedAt: c.updatedAt.getTime(),
}));
const changedRepeatProfiles = repeatProfiles.map((p) => ({
const changedRepeatProfiles = repeatProfiles.map(p => ({
id: p.id,
name: p.name,
repeat: p.repeat,
@@ -270,7 +285,7 @@ async function pushChanges(): Promise<{ conflicts: PushConflict[]; pushedDeletio
updatedAt: p.updatedAt.getTime(),
}));
const changedFriendships = friendships.map((f) => ({
const changedFriendships = friendships.map(f => ({
id: f.id,
userId: f.userId,
friendId: f.friendId,
@@ -280,7 +295,7 @@ async function pushChanges(): Promise<{ conflicts: PushConflict[]; pushedDeletio
}));
const pendingTombstones = await fetchPendingTombstones(lastPushedAt);
const changedDeletions = pendingTombstones.map((t) => ({
const changedDeletions = pendingTombstones.map(t => ({
entity: t.entity,
id: t.entityId,
updatedAt: t.deletedAt.getTime(),
@@ -315,7 +330,7 @@ async function pushChanges(): Promise<{ conflicts: PushConflict[]; pushedDeletio
const data = await response.json();
return {
conflicts: (data.conflicts ?? []) as PushConflict[],
pushedDeletions: pendingTombstones.map((t) => ({ entity: t.entity, id: t.entityId })),
pushedDeletions: pendingTombstones.map(t => ({ entity: t.entity, id: t.entityId })),
};
}
@@ -327,8 +342,8 @@ async function pruneSyncedTombstones(
pushedDeletions: { entity: TombstoneEntity; id: string }[]
): Promise<void> {
if (pushedDeletions.length === 0) return;
const conflictKeys = new Set(conflicts.map((c) => `${c.entity}:${c.id}`));
const accepted = pushedDeletions.filter((d) => !conflictKeys.has(`${d.entity}:${d.id}`));
const conflictKeys = new Set(conflicts.map(c => `${c.entity}:${c.id}`));
const accepted = pushedDeletions.filter(d => !conflictKeys.has(`${d.entity}:${d.id}`));
const grouped = new Map<TombstoneEntity, string[]>();
for (const t of accepted) {
const list = grouped.get(t.entity) ?? [];
@@ -412,7 +427,11 @@ async function applyServerChanges(data: {
// Apply a server-side deletion to the local DB, cascading to every related
// row (category -> tasks -> subtasks, task -> subtasks, subtask -> children).
// LWW: if the local row is newer than the deletion, it survives.
async function applyRemoteTombstone(tomb: { entity: TombstoneEntity; id: string; updatedAt: number }): Promise<void> {
async function applyRemoteTombstone(tomb: {
entity: TombstoneEntity;
id: string;
updatedAt: number;
}): Promise<void> {
const { entity, id, updatedAt } = tomb;
const row = await findOrNull<any>(collections[entity as keyof typeof collections] as any, id);
if (row) {
@@ -474,7 +493,7 @@ async function destroyLocalEntityCascade(entity: TombstoneEntity, id: string): P
async function upsertCategory(row: ServerRow, force: boolean): Promise<boolean> {
const local = await findOrNull<Category>(collections.categories, row.id);
if (!local) {
await collections.categories.create((c) => {
await collections.categories.create(c => {
c.name = String(row.name ?? '');
c.color = String(row.color ?? '#9E9E9E');
c.order = Number(row.order ?? 0);
@@ -486,7 +505,7 @@ async function upsertCategory(row: ServerRow, force: boolean): Promise<boolean>
if (!force && local.updatedAt.getTime() > Number(row.updatedAt ?? 0)) {
return false;
}
await local.update((c) => {
await local.update(c => {
c.name = String(row.name ?? c.name);
c.color = String(row.color ?? c.color);
c.order = Number(row.order ?? c.order);
@@ -498,7 +517,7 @@ async function upsertCategory(row: ServerRow, force: boolean): Promise<boolean>
async function upsertTask(row: ServerRow, force: boolean): Promise<boolean> {
const local = await findOrNull<Task>(collections.tasks, row.id);
if (!local) {
await collections.tasks.create((t) => {
await collections.tasks.create(t => {
t.title = String(row.title ?? '');
t.description = String(row.description ?? '');
t.categoryId = String(row.categoryId ?? '');
@@ -524,7 +543,7 @@ async function upsertTask(row: ServerRow, force: boolean): Promise<boolean> {
if (!force && local.updatedAt.getTime() > Number(row.updatedAt ?? 0)) {
return false;
}
await local.update((t) => {
await local.update(t => {
t.title = String(row.title ?? t.title);
t.description = String(row.description ?? t.description);
t.categoryId = String(row.categoryId ?? t.categoryId);
@@ -550,7 +569,7 @@ async function upsertTask(row: ServerRow, force: boolean): Promise<boolean> {
async function upsertRepeatProfile(row: ServerRow, force: boolean): Promise<boolean> {
const local = await findOrNull<RepeatProfile>(collections.repeatProfiles, row.id);
if (!local) {
await collections.repeatProfiles.create((p) => {
await collections.repeatProfiles.create(p => {
p.name = String(row.name ?? '');
p.repeat = row.repeat ?? 'none';
p.repeatInterval = Number(row.repeatInterval ?? 1);
@@ -563,7 +582,7 @@ async function upsertRepeatProfile(row: ServerRow, force: boolean): Promise<bool
if (!force && local.updatedAt.getTime() > Number(row.updatedAt ?? 0)) {
return false;
}
await local.update((p) => {
await local.update(p => {
p.name = String(row.name ?? p.name);
p.repeat = row.repeat ?? p.repeat;
p.repeatInterval = Number(row.repeatInterval ?? p.repeatInterval);
@@ -579,7 +598,7 @@ async function upsertSubtask(row: ServerRow, force: boolean): Promise<boolean> {
const local = await findOrNull<Subtask>(collections.subtasks, row.id);
if (!local) {
await collections.subtasks.create((s) => {
await collections.subtasks.create(s => {
s.taskId = String(row.taskId ?? '');
s.parentSubtaskId = row.parentSubtaskId == null ? null : String(row.parentSubtaskId);
s.categoryId = row.categoryId ? String(row.categoryId) : '';
@@ -607,7 +626,7 @@ async function upsertSubtask(row: ServerRow, force: boolean): Promise<boolean> {
if (!force && local.updatedAt.getTime() > Number(row.updatedAt ?? 0)) {
return false;
}
await local.update((s) => {
await local.update(s => {
s.taskId = String(row.taskId ?? s.taskId);
s.parentSubtaskId = row.parentSubtaskId == null ? null : String(row.parentSubtaskId);
s.categoryId = row.categoryId ? String(row.categoryId) : s.categoryId;
@@ -635,7 +654,7 @@ async function upsertSubtask(row: ServerRow, force: boolean): Promise<boolean> {
async function upsertFriendship(row: ServerRow, force: boolean): Promise<boolean> {
const local = await findOrNull<Friendship>(collections.friendships, row.id);
if (!local) {
await collections.friendships.create((f) => {
await collections.friendships.create(f => {
f.userId = String(row.userId ?? '');
f.friendId = String(row.friendId ?? '');
f.status = (row.status as 'pending' | 'accepted') ?? 'pending';
@@ -647,7 +666,7 @@ async function upsertFriendship(row: ServerRow, force: boolean): Promise<boolean
if (!force && local.updatedAt.getTime() > Number(row.updatedAt ?? 0)) {
return false;
}
await local.update((f) => {
await local.update(f => {
f.userId = String(row.userId ?? f.userId);
f.friendId = String(row.friendId ?? f.friendId);
f.status = (row.status as 'pending' | 'accepted') ?? f.status;
@@ -749,4 +768,4 @@ export function isSyncInFlight(): boolean {
export function isWatchingForUpdates(): boolean {
return watcherActive;
}
}
+10 -4
View File
@@ -3,7 +3,10 @@ import { Q } from '@nozbe/watermelondb';
import Tombstone, { TombstoneEntity } from '@/models/Tombstone';
// Batch variant: must be called from inside an existing database.write().
export async function recordTombstonesInBatch(entity: TombstoneEntity, ids: string[]): Promise<void> {
export async function recordTombstonesInBatch(
entity: TombstoneEntity,
ids: string[]
): Promise<void> {
const uniqueIds = [...new Set(ids)].filter(Boolean);
for (const id of uniqueIds) {
const existing = await collections.tombstones
@@ -11,11 +14,11 @@ export async function recordTombstonesInBatch(entity: TombstoneEntity, ids: stri
.fetch();
const now = new Date();
if (existing.length > 0) {
await existing[0].update((t) => {
await existing[0].update(t => {
t.deletedAt = now;
});
} else {
await collections.tombstones.create((t) => {
await collections.tombstones.create(t => {
t.entity = entity;
t.entityId = id;
t.deletedAt = now;
@@ -25,7 +28,10 @@ export async function recordTombstonesInBatch(entity: TombstoneEntity, ids: stri
}
// Batch variant: must be called from inside an existing database.write().
export async function removeTombstonesInBatch(entity: TombstoneEntity, ids: string[]): Promise<void> {
export async function removeTombstonesInBatch(
entity: TombstoneEntity,
ids: string[]
): Promise<void> {
for (const id of ids) {
const existing = await collections.tombstones
.query(Q.where('entity', entity), Q.where('entity_id', id))
+19 -15
View File
@@ -23,11 +23,11 @@ export function DatabaseProvider({ children }: { children: ReactNode }) {
const initializeDatabase = async () => {
try {
const existingCategories = await collections.categories.query().fetch();
// Merge categories with the same spelling (case-insensitive)
const categoriesByName = new Map<string, typeof existingCategories[0]>();
const categoriesByName = new Map<string, (typeof existingCategories)[0]>();
const duplicates: typeof existingCategories = [];
for (const cat of existingCategories) {
const key = cat.name.toLowerCase();
if (categoriesByName.has(key)) {
@@ -36,28 +36,32 @@ export function DatabaseProvider({ children }: { children: ReactNode }) {
categoriesByName.set(key, cat);
}
}
if (duplicates.length > 0) {
await database.write(async () => {
for (const dup of duplicates) {
const keep = categoriesByName.get(dup.name.toLowerCase())!;
// Reassign tasks from duplicate to kept category
const tasksToUpdate = await collections.tasks.query(Q.where('category_id', dup.id)).fetch();
const tasksToUpdate = await collections.tasks
.query(Q.where('category_id', dup.id))
.fetch();
for (const task of tasksToUpdate) {
await task.update((t) => { t.categoryId = keep.id; });
await task.update(t => {
t.categoryId = keep.id;
});
}
// Delete duplicate category
await dup.destroyPermanently();
}
});
}
const freshCategories = await collections.categories.query().fetch();
if (freshCategories.length === 0) {
await database.write(async () => {
for (const cat of DEFAULT_CATEGORIES) {
await collections.categories.create((c) => {
await collections.categories.create(c => {
c.name = cat.name;
c.color = cat.color;
c.order = cat.order;
@@ -67,7 +71,7 @@ export function DatabaseProvider({ children }: { children: ReactNode }) {
}
});
}
setIsReady(true);
const token = await getAuthToken();
@@ -115,9 +119,9 @@ export function useCategories(): Category[] {
const subscription = collections.categories
.query()
.observeWithColumns(['name', 'color', 'order'])
.pipe(map((cats) => [...cats].sort((a, b) => a.order - b.order)))
.pipe(map(cats => [...cats].sort((a, b) => a.order - b.order)))
.subscribe({
next: (cats) => {
next: cats => {
if (mounted) setCategories(cats);
},
error: () => {},
@@ -154,9 +158,9 @@ export function useRepeatProfiles(): RepeatProfile[] {
const subscription = collections.repeatProfiles
.query()
.observeWithColumns(['name', 'repeat', 'repeat_interval', 'repeat_days'])
.pipe(map((rows) => [...rows].sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())))
.pipe(map(rows => [...rows].sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())))
.subscribe({
next: (rows) => {
next: rows => {
if (mounted) setProfiles(rows);
},
error: () => {},
@@ -169,4 +173,4 @@ export function useRepeatProfiles(): RepeatProfile[] {
}, [collections]);
return profiles;
}
}
+11 -2
View File
@@ -1,5 +1,14 @@
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
import { getFriends, sendFriendRequest, acceptFriendRequest, deleteFriendRequest, removeFriend, searchUsers, Friend, FriendRequest } from '@/services/friends';
import {
getFriends,
sendFriendRequest,
acceptFriendRequest,
deleteFriendRequest,
removeFriend,
searchUsers,
Friend,
FriendRequest,
} from '@/services/friends';
interface FriendsContextType {
friends: Friend[];
@@ -95,4 +104,4 @@ export function useFriends(): FriendsContextType {
throw new Error('useFriends must be used within a FriendsProvider');
}
return context;
}
}
+8 -4
View File
@@ -43,7 +43,11 @@ function mapICalEventToTask(event: {
let allDay = event.allDay === true || !event.end;
if (event.allDay) {
dueDate = new Date(event.start.getFullYear(), event.start.getMonth(), event.start.getDate()).getTime();
dueDate = new Date(
event.start.getFullYear(),
event.start.getMonth(),
event.start.getDate()
).getTime();
dueTime = '00:00';
endTime = '23:59';
} else {
@@ -143,8 +147,8 @@ export function useICalImport() {
// Insert tasks into WatermelonDB
await Promise.all(
taskData.map((td) =>
collections.tasks.create((t) => {
taskData.map(td =>
collections.tasks.create(t => {
t.title = td.title;
t.description = td.description;
t.categoryId = td.categoryId;
@@ -177,4 +181,4 @@ export function useICalImport() {
}, []);
return { importICal, loading, error };
}
}
+1 -1
View File
@@ -14,4 +14,4 @@ export function useOnline(): boolean {
}, []);
return isOnline;
}
}
+21 -20
View File
@@ -83,10 +83,7 @@ export function useStats(): TaskStats {
let mounted = true;
const compute = () => {
Promise.all([
collections.tasks.query().fetch(),
collections.categories.query().fetch(),
])
Promise.all([collections.tasks.query().fetch(), collections.categories.query().fetch()])
.then(([allTasks, categories]) => {
const now = Date.now();
const todayStart = startOfDay(now);
@@ -129,25 +126,25 @@ export function useStats(): TaskStats {
});
}
const categoryMap = new Map(categories.map((c) => [c.id, c]));
const categoryMap = new Map(categories.map(c => [c.id, c]));
const byCategory: CategoryStat[] = Array.from(categoryMap.values())
.map((c) => ({
.map(c => ({
id: c.id,
name: c.name,
color: c.color,
count: categoryCounts.get(c.id) ?? 0,
}))
.filter((c) => c.count > 0)
.filter(c => c.count > 0)
.sort((a, b) => b.count - a.count);
const priorityOrder = ['high', 'medium', 'low', 'none'];
const byPriority: PriorityStat[] = priorityOrder
.map((p) => ({
.map(p => ({
priority: p,
label: PRIORITY_LABELS[p],
count: priorityCounts.get(p) ?? 0,
}))
.filter((p) => p.count > 0);
.filter(p => p.count > 0);
let streak = 0;
const todayCount = dayBucket.get(todayStart) ?? 0;
@@ -165,9 +162,10 @@ export function useStats(): TaskStats {
completedLast30,
activeCount,
overdueCount,
completionRate: totalCompleted > 0
? Math.round((totalCompleted / (totalCompleted + activeCount)) * 100)
: 0,
completionRate:
totalCompleted > 0
? Math.round((totalCompleted / (totalCompleted + activeCount)) * 100)
: 0,
daily,
byCategory,
byPriority,
@@ -176,18 +174,21 @@ export function useStats(): TaskStats {
}
})
.catch(() => {
if (mounted) setStats((s) => ({ ...s, loading: false }));
if (mounted) setStats(s => ({ ...s, loading: false }));
});
};
compute();
const subscription = collections.tasks.query().observe().subscribe({
next: () => {
compute();
},
error: () => {},
});
const subscription = collections.tasks
.query()
.observe()
.subscribe({
next: () => {
compute();
},
error: () => {},
});
return () => {
mounted = false;
@@ -196,4 +197,4 @@ export function useStats(): TaskStats {
}, [collections, isReady]);
return stats;
}
}
+3 -3
View File
@@ -62,7 +62,7 @@ export function useSubtasks(): { map: Map<string, SubtaskData[]>; refresh: () =>
const { collections } = useDatabase();
const [rows, setRows] = useState<any[]>([]);
const [refreshKey, setRefreshKey] = useState(0);
const refresh = useCallback(() => setRefreshKey((k) => k + 1), []);
const refresh = useCallback(() => setRefreshKey(k => k + 1), []);
useEffect(() => {
let mounted = true;
@@ -70,7 +70,7 @@ export function useSubtasks(): { map: Map<string, SubtaskData[]>; refresh: () =>
.query()
.observe()
.subscribe({
next: (result) => {
next: result => {
if (mounted) setRows(result);
},
error: () => {},
@@ -84,4 +84,4 @@ export function useSubtasks(): { map: Map<string, SubtaskData[]>; refresh: () =>
const map = useMemo(() => buildTrees(rows), [rows]);
return { map, refresh };
}
}
+93 -52
View File
@@ -22,7 +22,10 @@ import {
confirmAndToggleTaskComplete,
} from '@/utils/taskActions';
const PRIORITY_OPTIONS = Object.entries(PRIORITY_LABELS).map(([value, label]) => ({ value, label }));
const PRIORITY_OPTIONS = Object.entries(PRIORITY_LABELS).map(([value, label]) => ({
value,
label,
}));
interface AddSubtaskModalProps {
visible: boolean;
@@ -122,9 +125,7 @@ function AddSubtaskModal({ visible, parentSubtask, onClose, onAdd }: AddSubtaskM
>
<View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}>
<View style={[styles.handle, { backgroundColor: theme.borderStrong }]} />
<Text style={styles.title}>
Add Subtask to &ldquo;{parentSubtask.title}&rdquo;
</Text>
<Text style={styles.title}>Add Subtask to &ldquo;{parentSubtask.title}&rdquo;</Text>
<View style={styles.inputContainer}>
<TextInput
style={styles.input}
@@ -159,21 +160,35 @@ export function useTaskModals() {
const [menuSubtask, setMenuSubtask] = useState<SubtaskData | null>(null);
const [deleteTarget, setDeleteTarget] = useState<TaskData | null>(null);
const [deleteSubtaskTarget, setDeleteSubtaskTarget] = useState<SubtaskData | null>(null);
const [picker, setPicker] = useState<null | { type: 'category' | 'priority'; taskId?: string }>(null);
const [subtaskPicker, setSubtaskPicker] = useState<null | { type: 'priority'; subtaskId?: string }>(null);
const [picker, setPicker] = useState<null | { type: 'category' | 'priority'; taskId?: string }>(
null
);
const [subtaskPicker, setSubtaskPicker] = useState<null | {
type: 'priority';
subtaskId?: string;
}>(null);
const [addSubtaskTarget, setAddSubtaskTarget] = useState<SubtaskData | null>(null);
const refreshRef = useRef<() => void>(() => {});
const openTaskMenu = useCallback((task: TaskData) => setMenuTask(task), []);
const openSubtaskMenu = useCallback((subtask: SubtaskData) => setMenuSubtask(subtask), []);
const openTaskDelete = useCallback((task: TaskData) => setDeleteTarget(task), []);
const openSubtaskDelete = useCallback((subtask: SubtaskData) => setDeleteSubtaskTarget(subtask), []);
const openTaskEdit = useCallback((taskId: string) => {
router.push({ pathname: '/task-detail', params: { id: taskId } });
}, [router]);
const openSubtaskEdit = useCallback((subtaskId: string) => {
router.push({ pathname: '/subtask-detail', params: { id: subtaskId } });
}, [router]);
const openSubtaskDelete = useCallback(
(subtask: SubtaskData) => setDeleteSubtaskTarget(subtask),
[]
);
const openTaskEdit = useCallback(
(taskId: string) => {
router.push({ pathname: '/task-detail', params: { id: taskId } });
},
[router]
);
const openSubtaskEdit = useCallback(
(subtaskId: string) => {
router.push({ pathname: '/subtask-detail', params: { id: subtaskId } });
},
[router]
);
const openSubtaskAdd = useCallback((subtask: SubtaskData) => {
setAddSubtaskTarget(subtask);
}, []);
@@ -197,13 +212,16 @@ export function useTaskModals() {
refreshRef.current();
}, []);
const handleDeleteScope = useCallback(async (scope: TaskDeleteScope) => {
const target = deleteTarget;
if (!target) return;
setDeleteTarget(null);
await deleteTaskOccurrences(target.id, scope);
refreshRef.current();
}, [deleteTarget]);
const handleDeleteScope = useCallback(
async (scope: TaskDeleteScope) => {
const target = deleteTarget;
if (!target) return;
setDeleteTarget(null);
await deleteTaskOccurrences(target.id, scope);
refreshRef.current();
},
[deleteTarget]
);
const handleDeleteSubtask = useCallback(async () => {
const target = deleteSubtaskTarget;
@@ -213,38 +231,56 @@ export function useTaskModals() {
refreshRef.current();
}, [deleteSubtaskTarget]);
const handleSingleCategory = useCallback(async (value: string | string[]) => {
if (picker?.taskId) {
await setTaskCategory(picker.taskId, Array.isArray(value) ? value[0] : value);
refreshRef.current();
}
}, [picker]);
const handleSingleCategory = useCallback(
async (value: string | string[]) => {
if (picker?.taskId) {
await setTaskCategory(picker.taskId, Array.isArray(value) ? value[0] : value);
refreshRef.current();
}
},
[picker]
);
const handleSinglePriority = useCallback(async (value: string | string[]) => {
if (picker?.taskId) {
await setTaskPriority(picker.taskId, (Array.isArray(value) ? value[0] : value) as TaskData['priority']);
refreshRef.current();
}
}, [picker]);
const handleSinglePriority = useCallback(
async (value: string | string[]) => {
if (picker?.taskId) {
await setTaskPriority(
picker.taskId,
(Array.isArray(value) ? value[0] : value) as TaskData['priority']
);
refreshRef.current();
}
},
[picker]
);
const handleSubtaskPriority = useCallback(async (value: string | string[]) => {
if (subtaskPicker?.subtaskId) {
await setSubtaskPriority(subtaskPicker.subtaskId, (Array.isArray(value) ? value[0] : value) as TaskData['priority']);
refreshRef.current();
}
}, [subtaskPicker]);
const handleSubtaskPriority = useCallback(
async (value: string | string[]) => {
if (subtaskPicker?.subtaskId) {
await setSubtaskPriority(
subtaskPicker.subtaskId,
(Array.isArray(value) ? value[0] : value) as TaskData['priority']
);
refreshRef.current();
}
},
[subtaskPicker]
);
const handleAddSubtask = useCallback(async (title: string) => {
const target = addSubtaskTarget;
if (!target || !title.trim()) return;
await createSubtask({
taskId: target.taskId,
parentSubtaskId: target.id,
title: title.trim(),
});
setAddSubtaskTarget(null);
refreshRef.current();
}, [addSubtaskTarget]);
const handleAddSubtask = useCallback(
async (title: string) => {
const target = addSubtaskTarget;
if (!target || !title.trim()) return;
await createSubtask({
taskId: target.taskId,
parentSubtaskId: target.id,
title: title.trim(),
});
setAddSubtaskTarget(null);
refreshRef.current();
},
[addSubtaskTarget]
);
const modals = (refresh: () => void) => {
refreshRef.current = refresh;
@@ -289,7 +325,9 @@ export function useTaskModals() {
onDelete={() => menuSubtask && openSubtaskDelete(menuSubtask)}
onDuplicate={() => menuSubtask && handleSubtaskDuplicate(menuSubtask.id)}
onToggleComplete={() => menuSubtask && handleSubtaskToggle(menuSubtask)}
onChangePriority={() => menuSubtask && setSubtaskPicker({ type: 'priority', subtaskId: menuSubtask.id })}
onChangePriority={() =>
menuSubtask && setSubtaskPicker({ type: 'priority', subtaskId: menuSubtask.id })
}
onAddSubtask={() => menuSubtask && openSubtaskAdd(menuSubtask)}
/>
@@ -303,7 +341,10 @@ export function useTaskModals() {
<OptionPickerModal
visible={picker?.type === 'category'}
title="Change Tags"
options={[{ value: '', label: 'None', color: '#9E9E9E' }, ...categories.map((c) => ({ value: c.id, label: c.name, color: c.color }))]}
options={[
{ value: '', label: 'None', color: '#9E9E9E' },
...categories.map(c => ({ value: c.id, label: c.name, color: c.color })),
]}
selectedValue={menuTask?.categoryId}
onSelect={handleSingleCategory}
onClose={() => setPicker(null)}
@@ -318,7 +359,7 @@ export function useTaskModals() {
onClose={() => setPicker(null)}
/>
<OptionPickerModal
<OptionPickerModal
visible={subtaskPicker?.type === 'priority'}
title="Change Priority"
options={PRIORITY_OPTIONS}
+28 -31
View File
@@ -9,7 +9,7 @@ export function useTasksInMonth(monthDate: Date) {
const [tasks, setTasks] = useState<Task[]>([]);
const [loading, setLoading] = useState(true);
const [refreshKey, setRefreshKey] = useState(0);
const refresh = useCallback(() => setRefreshKey((k) => k + 1), []);
const refresh = useCallback(() => setRefreshKey(k => k + 1), []);
const range = useMemo(() => {
const s = startOfMonth(monthDate);
@@ -23,13 +23,10 @@ export function useTasksInMonth(monthDate: Date) {
let mounted = true;
setLoading(true);
const subscription = collections.tasks
.query(
Q.where('due_date', Q.between(range.start, range.end)),
Q.sortBy('due_date', 'asc')
)
.query(Q.where('due_date', Q.between(range.start, range.end)), Q.sortBy('due_date', 'asc'))
.observe()
.subscribe({
next: (result) => {
next: result => {
if (mounted) {
setTasks(result);
setLoading(false);
@@ -60,12 +57,16 @@ export function useTasksInMonth(monthDate: Date) {
return { tasks, byDay, loading, refresh };
}
export function useTasks(categoryIds: string[] = [], showCompleted: boolean | 'all' = false, maxAheadDays?: number) {
export function useTasks(
categoryIds: string[] = [],
showCompleted: boolean | 'all' = false,
maxAheadDays?: number
) {
const { collections } = useDatabase();
const [tasks, setTasks] = useState<Task[]>([]);
const [loading, setLoading] = useState(true);
const [refreshKey, setRefreshKey] = useState(0);
const refresh = useCallback(() => setRefreshKey((k) => k + 1), []);
const refresh = useCallback(() => setRefreshKey(k => k + 1), []);
const cutoff = useMemo(() => {
if (maxAheadDays === undefined) return null;
@@ -80,11 +81,8 @@ export function useTasks(categoryIds: string[] = [], showCompleted: boolean | 'a
const conditions: any[] = [];
if (categoryIds.length > 0) {
const tagConditions = categoryIds.map((id) =>
Q.or(
Q.where('tags', Q.like(`%,${id},%`)),
Q.where('category_id', id)
)
const tagConditions = categoryIds.map(id =>
Q.or(Q.where('tags', Q.like(`%,${id},%`)), Q.where('category_id', id))
);
conditions.push(tagConditions.length === 1 ? tagConditions[0] : Q.or(...tagConditions));
}
@@ -99,23 +97,22 @@ export function useTasks(categoryIds: string[] = [], showCompleted: boolean | 'a
conditions.push(Q.where('due_date', Q.lte(cutoff)));
}
const query = conditions.length > 0
? collections.tasks.query(Q.and(...conditions))
: collections.tasks.query();
const query =
conditions.length > 0
? collections.tasks.query(Q.and(...conditions))
: collections.tasks.query();
const subscription = query
.observe()
.subscribe({
next: (result) => {
if (mounted) {
setTasks(result);
setLoading(false);
}
},
error: () => {
if (mounted) setLoading(false);
},
});
const subscription = query.observe().subscribe({
next: result => {
if (mounted) {
setTasks(result);
setLoading(false);
}
},
error: () => {
if (mounted) setLoading(false);
},
});
return () => {
mounted = false;
@@ -131,7 +128,7 @@ export function useTasksByDate(date: Date) {
const [tasks, setTasks] = useState<Task[]>([]);
const [loading, setLoading] = useState(true);
const [refreshKey, setRefreshKey] = useState(0);
const refresh = useCallback(() => setRefreshKey((k) => k + 1), []);
const refresh = useCallback(() => setRefreshKey(k => k + 1), []);
const startOfDay = useMemo(() => {
const d = new Date(date);
@@ -153,7 +150,7 @@ export function useTasksByDate(date: Date) {
)
.observe()
.subscribe({
next: (result) => {
next: result => {
if (mounted) {
setTasks(result);
setLoading(false);
+1 -1
View File
@@ -9,4 +9,4 @@ export default class Category extends Model {
@field('order') order!: number;
@date('created_at') createdAt!: Date;
@date('updated_at') updatedAt!: Date;
}
}
+1 -1
View File
@@ -9,4 +9,4 @@ export default class Friendship extends Model {
@field('status') status!: 'pending' | 'accepted';
@date('created_at') createdAt!: Date;
@date('updated_at') updatedAt!: Date;
}
}
+1 -1
View File
@@ -34,4 +34,4 @@ export default class Subtask extends Model {
@date('updated_at') updatedAt!: Date;
@children('subtasks') subtasks!: any;
}
}
+1 -1
View File
@@ -33,4 +33,4 @@ export default class Task extends Model {
@date('updated_at') updatedAt!: Date;
@children('subtasks') subtasks!: any;
}
}
+2 -1
View File
@@ -1,7 +1,8 @@
import { Model } from '@nozbe/watermelondb';
import { field, date } from '@nozbe/watermelondb/decorators';
export type TombstoneEntity = 'categories' | 'tasks' | 'subtasks' | 'repeatProfiles' | 'friendships';
export type TombstoneEntity =
'categories' | 'tasks' | 'subtasks' | 'repeatProfiles' | 'friendships';
export default class Tombstone extends Model {
static table = 'tombstones';
+7 -2
View File
@@ -148,7 +148,9 @@ export async function register(username: string, password: string): Promise<Auth
return data.user as AuthUser;
}
export async function forgotPassword(username: string): Promise<{ success: boolean; resetToken?: string }> {
export async function forgotPassword(
username: string
): Promise<{ success: boolean; resetToken?: string }> {
const response = await apiFetch('/auth/forgot-password', {
method: 'POST',
body: JSON.stringify({ username }),
@@ -157,7 +159,10 @@ export async function forgotPassword(username: string): Promise<{ success: boole
return data;
}
export async function resetPassword(token: string, password: string): Promise<{ success: boolean }> {
export async function resetPassword(
token: string,
password: string
): Promise<{ success: boolean }> {
const response = await apiFetch('/auth/reset-password', {
method: 'POST',
body: JSON.stringify({ token, password }),
+1 -1
View File
@@ -69,4 +69,4 @@ export async function removeFriend(friendId: string): Promise<void> {
const data = await response.json();
throw new Error(data.message || 'Failed to remove friend');
}
}
}
+24 -3
View File
@@ -1,6 +1,13 @@
import { Platform } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { Reminder, reminderToMinutes, parseReminders, ReminderPreference, parseTaskTags } from '@/types';
import { useSettings } from '@/theme';
import {
Reminder,
reminderToMinutes,
parseReminders,
ReminderPreference,
parseTaskTags,
} from '@/types';
import { TagReminderPreferences, TAG_REMINDER_PREFERENCES_KEY } from '@/theme';
const ID_MAP_KEY = 'notifications:taskIdToNotificationIds';
@@ -95,7 +102,11 @@ function getRemindersForTask(
return [];
}
export async function scheduleTaskReminder(task: any, notificationsEnabled: boolean = true, globalReminderPreference: ReminderPreference = '15'): Promise<void> {
export async function scheduleTaskReminder(
task: any,
notificationsEnabled: boolean = true,
globalReminderPreference: ReminderPreference = '15'
): Promise<void> {
const notifications = await getNotificationsModule();
const dueDate = task.dueDate ? new Date(task.dueDate) : null;
@@ -207,7 +218,11 @@ export async function cancelAllReminders(): Promise<void> {
await writeIdMap(idMap);
}
export async function rescheduleAllReminders(tasks: any[], notificationsEnabled: boolean = true, globalReminderPreference: ReminderPreference = '15'): Promise<void> {
export async function rescheduleAllReminders(
tasks: any[],
notificationsEnabled: boolean = true,
globalReminderPreference: ReminderPreference = '15'
): Promise<void> {
if (!notificationsEnabled) {
await cancelAllReminders();
return;
@@ -265,3 +280,9 @@ export async function rescheduleAllReminders(tasks: any[], notificationsEnabled:
await writeIdMap(idMap);
}
// Hook to get current notification settings and reschedule on change
export function useNotificationSettings() {
const { notifications, reminderPreference } = useSettings();
return { notificationsEnabled: notifications, globalReminderPreference: reminderPreference };
}
@@ -29,7 +29,9 @@ export async function registerForPushNotificationsAsync(): Promise<string | null
}
try {
const projectId = (Constants.expoConfig as any)?.extra?.eas?.projectId ?? (Constants.expoConfig as any)?.projectId;
const projectId =
(Constants.expoConfig as any)?.extra?.eas?.projectId ??
(Constants.expoConfig as any)?.projectId;
const tokenData = await Notifications.getExpoPushTokenAsync({
projectId,
});
@@ -98,7 +100,9 @@ export function addNotificationResponseReceivedListener(
return Notifications.addNotificationResponseReceivedListener(listener);
}
export function removeNotificationSubscription(subscription: Notifications.EventSubscription): void {
export function removeNotificationSubscription(
subscription: Notifications.EventSubscription
): void {
subscription.remove();
}
@@ -136,4 +140,4 @@ export function handleNotificationResponse(response: Notifications.NotificationR
}
break;
}
}
}
+73 -5
View File
@@ -1,5 +1,8 @@
import Constants from 'expo-constants';
import AsyncStorage from '@react-native-async-storage/async-storage';
import * as FileSystem from 'expo-file-system/legacy';
import * as IntentLauncher from 'expo-intent-launcher';
import { Platform } from 'react-native';
const GITEA_BASE_URL = 'https://gitea.techmos.org';
const GITEA_OWNER = 'tech08mag';
@@ -12,6 +15,8 @@ export interface AppUpdate {
notes: string;
apkUrl: string | null;
releaseUrl: string;
mandatory: boolean;
publishedAt: string;
}
export function getCurrentAppVersion(): string {
@@ -28,26 +33,36 @@ async function fetchWithTimeout(path: string, timeoutMs: number): Promise<Respon
}
}
function parseMandatoryFromNotes(notes: string, tagName: string): boolean {
const mandatoryKeywords = ['mandatory', 'critical', 'security', 'breaking', 'required'];
const lowerNotes = notes.toLowerCase();
const lowerTag = tagName.toLowerCase();
return mandatoryKeywords.some(kw => lowerNotes.includes(kw) || lowerTag.includes(kw));
}
export async function fetchLatestRelease(): Promise<AppUpdate | null> {
try {
const response = await fetchWithTimeout(
`/api/v1/repos/${GITEA_OWNER}/${GITEA_REPO}/releases/latest`,
8000,
8000
);
if (!response.ok) return null;
const release = await response.json();
const apkAsset = (release.assets ?? []).find(
(asset: { name?: string }) => asset.name && /\.apk$/i.test(asset.name),
(asset: { name?: string }) => asset.name && /\.apk$/i.test(asset.name)
);
const tagName: string = release.tag_name ?? release.name ?? '';
const notes: string = release.body ?? '';
return {
version: tagName.replace(/^v/i, ''),
tagName,
notes: release.body ?? '',
notes,
apkUrl: apkAsset?.browser_download_url ?? null,
releaseUrl:
release.html_url ??
`${GITEA_BASE_URL}/${GITEA_OWNER}/${GITEA_REPO}/releases/tag/${tagName}`,
mandatory: parseMandatoryFromNotes(notes, tagName),
publishedAt: release.published_at ?? new Date().toISOString(),
};
} catch {
return null;
@@ -56,7 +71,7 @@ export async function fetchLatestRelease(): Promise<AppUpdate | null> {
export function compareVersions(a: string, b: string): number {
const parse = (value: string) =>
value.split('.').map((part) => parseInt(part.replace(/\D/g, ''), 10) || 0);
value.split('.').map(part => parseInt(part.replace(/\D/g, ''), 10) || 0);
const left = parse(a);
const right = parse(b);
const length = Math.max(left.length, right.length);
@@ -91,4 +106,57 @@ export async function markPrompted(tagName: string): Promise<void> {
} catch {
// ignore
}
}
}
export async function downloadAPK(
url: string,
onProgress?: (progress: number) => void
): Promise<string | null> {
if (Platform.OS !== 'android') return null;
try {
const downloadDir = `${FileSystem.documentDirectory}downloads/`;
await FileSystem.makeDirectoryAsync(downloadDir, { intermediates: true });
const filename = `carry-your-live-${Date.now()}.apk`;
const filepath = `${downloadDir}${filename}`;
const downloadResumable = FileSystem.createDownloadResumable(
url,
filepath,
{},
downloadProgress => {
const progress =
downloadProgress.totalBytesWritten / downloadProgress.totalBytesExpectedToWrite;
onProgress?.(progress);
}
);
const result = await downloadResumable.downloadAsync();
return result?.uri ?? null;
} catch (error) {
console.error('[Updates] Download failed:', error);
return null;
}
}
export async function installAPK(uri: string): Promise<boolean> {
if (Platform.OS !== 'android') return false;
try {
await IntentLauncher.startActivityAsync('android.intent.action.VIEW', {
data: uri.startsWith('file://') ? uri : `file://${uri}`,
type: 'application/vnd.android.package-archive',
flags: 1,
});
return true;
} catch (error) {
console.error('[Updates] Install failed:', error);
return false;
}
}
export async function openReleaseUrl(url: string): Promise<void> {
const { openURL } = await import('expo-linking');
await openURL(url);
}
+201 -25
View File
@@ -1,6 +1,15 @@
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState, ReactNode } from 'react';
import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
ReactNode,
} from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { DEFAULT_API_BASE_URL, API_URL_KEY } from '@/services/auth';
import { Platform } from 'react-native';
export type SortBy = 'date' | 'priority' | 'alpha' | 'created';
@@ -27,6 +36,8 @@ export type TagReminderPreferences = Record<string, ReminderPreference[]>;
export const TAG_REMINDER_PREFERENCES_KEY = 'settings:tagReminderPreferences';
export type ThemeMode = 'light' | 'dark' | 'system';
export interface ThemeColors {
background: string;
card: string;
@@ -75,7 +86,6 @@ export const ACCENT_PRESETS: string[] = [
function parseHex(hex: string): [number, number, number] {
const h = hex.replace(/^#/, '');
// Handle both #RRGGBB (6 digits) and #AARRGGBB (8 digits with alpha)
if (h.length === 8) {
return [parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16), parseInt(h.slice(6, 8), 16)];
}
@@ -83,7 +93,9 @@ function parseHex(hex: string): [number, number, number] {
}
function toHexByte(n: number): string {
return Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, '0');
return Math.max(0, Math.min(255, Math.round(n)))
.toString(16)
.padStart(2, '0');
}
export function mixHex(color: string, target: string, ratio: number): string {
@@ -100,15 +112,53 @@ export function desaturate(hex: string, amount: number): string {
}
function accentTextColor(accent: string): string {
const [r, g, b] = parseHex(accent).map((v) => v / 255);
const [r, g, b] = parseHex(accent).map(v => v / 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';
}
export function colors(accent: string = DEFAULT_ACCENT, opacity: number = 1): ThemeColors {
export function colors(
accent: string = DEFAULT_ACCENT,
mode: ThemeMode = 'dark',
opacity: number = 1
): ThemeColors {
const softAccent = desaturate(accent, 0.35);
const accentWithOpacity = opacity < 1 ? `${opacity.toString(16).padStart(2, '0')}${accent.slice(1)}` : accent;
const accentWithOpacity =
opacity < 1 ? `${opacity.toString(16).padStart(2, '0')}${accent.slice(1)}` : accent;
if (mode === 'light') {
return {
background: '#FFFFFF',
card: '#FAFAFA',
cardAlt: '#F0F0F0',
border: '#E0E0E0',
borderStrong: '#D0D0D0',
text: '#212121',
textSecondary: '#424242',
textFaint: '#757575',
textMuted: '#9E9E9E',
accent: opacity < 1 ? `${accentWithOpacity}` : accent,
accentStrong: mixHex(accent, '#000000', 0.55),
accentSoft: mixHex(softAccent, '#FFFFFF', 0.18),
accentBorder: mixHex(desaturate(accent, 0.15), '#FFFFFF', 0.5),
accentText: accentTextColor(accent),
inputBg: '#FFFFFF',
overlay: 'rgba(0,0,0,0.4)',
sheetBg: '#FAFAFA',
tabBarBg: '#FFFFFF',
danger: '#E53935',
dangerStrong: '#C62828',
dangerSoft: '#FBE9E7',
dangerBorder: '#FFCDD2',
success: '#43A047',
successStrong: '#2E7D32',
warning: '#FB8C00',
info: '#1E88E5',
};
}
// dark mode
return {
background: '#121212',
card: '#1E1E1E',
@@ -162,7 +212,11 @@ interface SettingsContextType {
setShowCompleted: (value: boolean) => void;
deleteBySwipe: boolean;
setDeleteBySwipe: (value: boolean) => void;
themeMode: ThemeMode;
setThemeMode: (value: ThemeMode) => void;
theme: ThemeColors;
syncSettingsToServer: () => Promise<void>;
loadSettingsFromServer: () => Promise<void>;
}
export const DEFAULT_TODO_AHEAD_DAYS = 7;
@@ -178,17 +232,43 @@ const STORAGE_KEYS = {
todoAheadDays: 'settings:todoAheadDays',
showCompleted: 'settings:showCompleted',
deleteBySwipe: 'settings:deleteBySwipe',
themeMode: 'settings:themeMode',
};
const SettingsContext = createContext<SettingsContextType | null>(null);
async function fetchServerSettings(apiUrl: string, token: string): Promise<any> {
try {
const response = await fetch(`${apiUrl}/users/me/settings`, {
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) return await response.json();
} catch {
// ignore
}
return null;
}
async function pushServerSettings(apiUrl: string, token: string, settings: any): Promise<boolean> {
try {
const response = await fetch(`${apiUrl}/users/me/settings`, {
method: 'PATCH',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify(settings),
});
return response.ok;
} catch {
return false;
}
}
function useStoredSetting<T>(key: string, initialValue: T): [T, (value: T) => void, boolean] {
const [value, setValue] = useState<T>(initialValue);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
AsyncStorage.getItem(key)
.then((stored) => {
.then(stored => {
if (stored !== null) {
setValue(JSON.parse(stored) as T);
}
@@ -209,25 +289,112 @@ function useStoredSetting<T>(key: string, initialValue: T): [T, (value: T) => vo
}
export function SettingsProvider({ children }: { children: ReactNode }) {
const [notifications, setNotifications] = useStoredSetting<boolean>(STORAGE_KEYS.notifications, true);
const [defaultCategoryId, setDefaultCategoryId] = useStoredSetting<string>(STORAGE_KEYS.defaultCategoryId, '');
const [notifications, setNotifications, notificationsLoaded] = useStoredSetting<boolean>(
STORAGE_KEYS.notifications,
true
);
const [defaultCategoryId, setDefaultCategoryId] = useStoredSetting<string>(
STORAGE_KEYS.defaultCategoryId,
''
);
const [sortBy, setSortBy] = useStoredSetting<SortBy>(STORAGE_KEYS.sortBy, 'date');
const [reminderPreference, setReminderPreference] = useStoredSetting<ReminderPreference>(
STORAGE_KEYS.reminderPreference,
'15',
);
const [tagReminderPreferences, setTagReminderPreferences] = useStoredSetting<TagReminderPreferences>(
STORAGE_KEYS.tagReminderPreferences,
{},
'15'
);
const [tagReminderPreferences, setTagReminderPreferences] =
useStoredSetting<TagReminderPreferences>(STORAGE_KEYS.tagReminderPreferences, {});
const [apiUrl, setApiUrl] = useStoredSetting<string>(API_URL_KEY, DEFAULT_API_BASE_URL);
const [accentColor, setAccentColor] = useStoredSetting<string>(STORAGE_KEYS.accentColor, DEFAULT_ACCENT);
const [accentOpacity, setAccentOpacity] = useStoredSetting<number>(STORAGE_KEYS.accentOpacity, DEFAULT_ACCENT_OPACITY);
const [todoAheadDays, setTodoAheadDays] = useStoredSetting<number>(STORAGE_KEYS.todoAheadDays, DEFAULT_TODO_AHEAD_DAYS);
const [showCompleted, setShowCompleted] = useStoredSetting<boolean>(STORAGE_KEYS.showCompleted, false);
const [deleteBySwipe, setDeleteBySwipe] = useStoredSetting<boolean>(STORAGE_KEYS.deleteBySwipe, false);
const [accentColor, setAccentColor] = useStoredSetting<string>(
STORAGE_KEYS.accentColor,
DEFAULT_ACCENT
);
const [accentOpacity, setAccentOpacity] = useStoredSetting<number>(
STORAGE_KEYS.accentOpacity,
DEFAULT_ACCENT_OPACITY
);
const [todoAheadDays, setTodoAheadDays] = useStoredSetting<number>(
STORAGE_KEYS.todoAheadDays,
DEFAULT_TODO_AHEAD_DAYS
);
const [showCompleted, setShowCompleted] = useStoredSetting<boolean>(
STORAGE_KEYS.showCompleted,
false
);
const [deleteBySwipe, setDeleteBySwipe] = useStoredSetting<boolean>(
STORAGE_KEYS.deleteBySwipe,
false
);
const [themeMode, setThemeMode, themeModeLoaded] = useStoredSetting<ThemeMode>(
STORAGE_KEYS.themeMode,
'system'
);
const theme = useMemo(() => colors(accentColor), [accentColor]);
const effectiveMode: 'light' | 'dark' =
themeMode === 'system'
? 'dark' // Will be updated by useColorScheme in real app
: themeMode;
const theme = useMemo(
() => colors(accentColor, effectiveMode, accentOpacity),
[accentColor, effectiveMode, accentOpacity]
);
const syncSettingsToServer = useCallback(async () => {
const { getAuthToken } = await import('@/services/auth');
const token = await getAuthToken();
if (!token) return;
const isDark = effectiveMode === 'dark';
const settings = {
darkMode: isDark,
defaultCategoryId,
sortOrder: sortBy,
reminderPreference,
todoAheadDays,
showCompleted,
deleteBySwipe,
};
await pushServerSettings(apiUrl, token, settings);
}, [
apiUrl,
effectiveMode,
themeMode,
defaultCategoryId,
sortBy,
reminderPreference,
todoAheadDays,
showCompleted,
deleteBySwipe,
]);
const loadSettingsFromServer = useCallback(async () => {
const { getAuthToken } = await import('@/services/auth');
const token = await getAuthToken();
if (!token) return;
const serverSettings = await fetchServerSettings(apiUrl, token);
if (!serverSettings) return;
if (serverSettings.darkMode !== undefined) {
setThemeMode(serverSettings.darkMode ? 'dark' : 'light');
}
if (serverSettings.defaultCategoryId) setDefaultCategoryId(serverSettings.defaultCategoryId);
if (serverSettings.sortOrder) setSortBy(serverSettings.sortOrder);
if (serverSettings.reminderPreference) setReminderPreference(serverSettings.reminderPreference);
if (serverSettings.todoAheadDays !== undefined) setTodoAheadDays(serverSettings.todoAheadDays);
if (serverSettings.showCompleted !== undefined) setShowCompleted(serverSettings.showCompleted);
if (serverSettings.deleteBySwipe !== undefined) setDeleteBySwipe(serverSettings.deleteBySwipe);
}, [
apiUrl,
setThemeMode,
setDefaultCategoryId,
setSortBy,
setReminderPreference,
setTodoAheadDays,
setShowCompleted,
setDeleteBySwipe,
]);
const value = useMemo<SettingsContextType>(
() => ({
@@ -253,7 +420,11 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
setShowCompleted,
deleteBySwipe,
setDeleteBySwipe,
themeMode,
setThemeMode,
theme,
syncSettingsToServer,
loadSettingsFromServer,
}),
[
notifications,
@@ -270,18 +441,23 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
setApiUrl,
accentColor,
setAccentColor,
accentOpacity,
setAccentOpacity,
todoAheadDays,
setTodoAheadDays,
showCompleted,
setShowCompleted,
deleteBySwipe,
setDeleteBySwipe,
themeMode,
setThemeMode,
theme,
syncSettingsToServer,
loadSettingsFromServer,
]
);
return (
<SettingsContext.Provider value={value}>
{children}
</SettingsContext.Provider>
);
return <SettingsContext.Provider value={value}>{children}</SettingsContext.Provider>;
}
export function useSettings() {
+1 -1
View File
@@ -191,4 +191,4 @@ export const Z_INDEX = {
modal: 200,
toast: 300,
tooltip: 400,
} as const;
} as const;
+7 -7
View File
@@ -17,7 +17,7 @@ export const WEEKDAY_LABELS = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];
export const WEEKDAY_ORDER = [1, 2, 3, 4, 5, 6, 0];
export function weekdaysInDisplayOrder(days: number[]): number[] {
return WEEKDAY_ORDER.filter((day) => days.includes(day));
return WEEKDAY_ORDER.filter(day => days.includes(day));
}
export function repeatDaysToString(days: number[]): string {
@@ -28,7 +28,7 @@ export function repeatDaysFromString(raw: string): number[] {
return (raw || '')
.split(',')
.map(Number)
.filter((d) => !Number.isNaN(d));
.filter(d => !Number.isNaN(d));
}
export interface RepeatProfileData {
@@ -53,7 +53,7 @@ export const REMINDER_OPTIONS: { value: Reminder; label: string }[] = [
{ value: '1440', label: '1 day before' },
];
export const MULTIPLE_REMINDER_OPTIONS = REMINDER_OPTIONS.filter((o) => o.value !== 'none');
export const MULTIPLE_REMINDER_OPTIONS = REMINDER_OPTIONS.filter(o => o.value !== 'none');
export function reminderToMinutes(reminder: Reminder): number | null {
if (reminder === 'none') return null;
@@ -86,12 +86,12 @@ export function parseReminders(reminders: string): Reminder[] {
if (!reminders) return [];
return reminders
.split(',')
.map((r) => r.trim() as Reminder)
.map(r => r.trim() as Reminder)
.filter((r): r is Exclude<Reminder, 'none'> => r !== 'none' && Boolean(r));
}
export function toRemindersString(reminders: Reminder[]): string {
return reminders.filter((r) => r !== 'none').join(',');
return reminders.filter(r => r !== 'none').join(',');
}
export interface CategoryData {
@@ -109,7 +109,7 @@ export function tagsFromString(raw: string | null | undefined): string[] {
if (!raw) return [];
return raw
.split(',')
.map((t) => t.trim())
.map(t => t.trim())
.filter(Boolean);
}
@@ -209,4 +209,4 @@ export interface SubtaskFormData {
reminders: string;
assigneeId: string | null;
parentSubtaskId?: string | null;
}
}
+15 -9
View File
@@ -8,7 +8,7 @@ export async function createCategory(name: string, color: string): Promise<void>
const existing = await collections.categories.query().fetch();
const nextOrder = existing.reduce((max, c) => Math.max(max, c.order), -1) + 1;
const now = new Date();
await collections.categories.create((c) => {
await collections.categories.create(c => {
c.name = name;
c.color = color;
c.order = nextOrder;
@@ -18,10 +18,14 @@ export async function createCategory(name: string, color: string): Promise<void>
});
}
export async function updateCategory(categoryId: string, name: string, color: string): Promise<void> {
export async function updateCategory(
categoryId: string,
name: string,
color: string
): Promise<void> {
await database.write(async () => {
const category = await collections.categories.find(categoryId);
await category.update((c) => {
await category.update(c => {
c.name = name;
c.color = color;
c.updatedAt = new Date();
@@ -31,23 +35,25 @@ export async function updateCategory(categoryId: string, name: string, color: st
export async function deleteCategory(categoryId: string): Promise<void> {
await database.write(async () => {
const others = (await collections.categories.query().fetch()).filter((c) => c.id !== categoryId);
const others = (await collections.categories.query().fetch()).filter(c => c.id !== categoryId);
const fallback = others[0];
const tasks = await collections.tasks.query(Q.where('category_id', categoryId)).fetch();
for (const task of tasks) {
await task.update((t) => {
const tags = parseTaskTags(t.tags, t.categoryId).filter((id) => id !== categoryId);
await task.update(t => {
const tags = parseTaskTags(t.tags, t.categoryId).filter(id => id !== categoryId);
t.categoryId = fallback?.id ?? tags[0] ?? '';
t.tags = tagsToString(tags);
t.updatedAt = new Date();
});
}
const tagReferencedTasks = await collections.tasks.query(Q.where('tags', Q.like(`%,${categoryId},%`))).fetch();
const tagReferencedTasks = await collections.tasks
.query(Q.where('tags', Q.like(`%,${categoryId},%`)))
.fetch();
for (const task of tagReferencedTasks) {
const tags = parseTaskTags(task.tags, task.categoryId).filter((id) => id !== categoryId);
await task.update((t) => {
const tags = parseTaskTags(task.tags, task.categoryId).filter(id => id !== categoryId);
await task.update(t => {
t.tags = tagsToString(tags);
t.updatedAt = new Date();
});
+7 -1
View File
@@ -8,7 +8,13 @@ type ConfirmOptions = {
onConfirm: () => void;
};
export function confirmAction({ title, message, confirmLabel = 'Delete', destructive, onConfirm }: ConfirmOptions): void {
export function confirmAction({
title,
message,
confirmLabel = 'Delete',
destructive,
onConfirm,
}: ConfirmOptions): void {
if (Platform.OS === 'web') {
const confirmed = (globalThis as any).confirm(message ? `${title}\n\n${message}` : title);
if (confirmed) onConfirm();
+1 -1
View File
@@ -153,4 +153,4 @@ function parseICalDateTime(dateStr: string): Date {
return new Date(dateStr);
}
export default parseICal;
export default parseICal;
+1 -1
View File
@@ -5,7 +5,7 @@ let pending = false;
export function requestQuickAddFocus(): void {
pending = true;
listeners.forEach((listener) => listener());
listeners.forEach(listener => listener());
}
export function subscribeToQuickAdd(listener: QuickAddListener): () => void {
@@ -10,7 +10,7 @@ export async function createRepeatProfile(
): Promise<void> {
const now = new Date();
await database.write(async () => {
await collections.repeatProfiles.create((p) => {
await collections.repeatProfiles.create(p => {
p.name = name.trim();
p.repeat = repeat;
p.repeatInterval = interval || 1;
+1 -1
View File
@@ -13,4 +13,4 @@ export function getLastVisitedTab(): string {
export function tabHref(tab: string): string {
if (tab === 'index') return '/';
return `/${tab}`;
}
}
+105 -68
View File
@@ -32,12 +32,16 @@ function mapSubtaskRow(s: any, taskId?: string): SubtaskData {
}
export async function fetchSubtaskTree(taskId: string): Promise<SubtaskData[]> {
const topLevel = await collections.subtasks.query(Q.where('task_id', taskId), Q.where('parent_subtask_id', null)).fetch();
const topLevel = await collections.subtasks
.query(Q.where('task_id', taskId), Q.where('parent_subtask_id', null))
.fetch();
const items: SubtaskData[] = topLevel.map((s: any) => mapSubtaskRow(s, taskId));
const decorate = async (subtasks: SubtaskData[]): Promise<SubtaskData[]> => {
for (const sub of subtasks) {
const children = await collections.subtasks.query(Q.where('parent_subtask_id', sub.id)).fetch();
const children = await collections.subtasks
.query(Q.where('parent_subtask_id', sub.id))
.fetch();
if (children.length > 0) {
sub.subtasks = await decorate(children.map((c: any) => mapSubtaskRow(c, taskId)));
}
@@ -70,7 +74,12 @@ function startOfDay(date: Date): Date {
return d;
}
function nextSelectedDay(base: Date, days: number[], intervalWeeks: number, todayStart: Date): Date {
function nextSelectedDay(
base: Date,
days: number[],
intervalWeeks: number,
todayStart: Date
): Date {
const msDay = 86400000;
for (let offset = 1; offset <= intervalWeeks * 7 + 7; offset++) {
@@ -95,8 +104,8 @@ export function parseRepeatDays(repeatDays: string): number[] {
if (!repeatDays) return [];
return repeatDays
.split(',')
.map((d) => parseInt(d, 10))
.filter((d) => !Number.isNaN(d) && d >= 0 && d <= 6);
.map(d => parseInt(d, 10))
.filter(d => !Number.isNaN(d) && d >= 0 && d <= 6);
}
export function computeNextOccurrence(
@@ -152,7 +161,10 @@ export async function deleteTaskWithSubtasks(taskId: string): Promise<void> {
const task = await collections.tasks.find(taskId);
await task.destroyPermanently();
if (subtasks.length > 0) {
await recordTombstonesInBatch('subtasks', subtasks.map((s) => s.id));
await recordTombstonesInBatch(
'subtasks',
subtasks.map(s => s.id)
);
}
await recordTombstonesInBatch('tasks', [taskId]);
}
@@ -171,7 +183,7 @@ export async function deleteTaskOccurrences(taskId: string, scope: TaskDeleteSco
const seriesTasks = await collections.tasks.query(Q.where('series_id', task.seriesId)).fetch();
const anchor = task.dueDate || task.createdAt.getTime();
const toDelete = seriesTasks.filter((t) => {
const toDelete = seriesTasks.filter(t => {
if (scope === 'all') return true;
return (t.dueDate || t.createdAt.getTime()) >= anchor;
});
@@ -186,14 +198,16 @@ export async function deleteTaskOccurrences(taskId: string, scope: TaskDeleteSco
});
}
export async function getSeriesOccurrenceCounts(taskId: string): Promise<{ future: number; all: number }> {
export async function getSeriesOccurrenceCounts(
taskId: string
): Promise<{ future: number; all: number }> {
const task = await collections.tasks.find(taskId);
if (!task.seriesId || task.repeat === 'none') {
return { future: 1, all: 1 };
}
const seriesTasks = await collections.tasks.query(Q.where('series_id', task.seriesId)).fetch();
const anchor = task.dueDate || task.createdAt.getTime();
const future = seriesTasks.filter((t) => (t.dueDate || t.createdAt.getTime()) >= anchor).length;
const future = seriesTasks.filter(t => (t.dueDate || t.createdAt.getTime()) >= anchor).length;
return { future, all: seriesTasks.length };
}
@@ -202,7 +216,7 @@ export async function toggleTaskComplete(taskId: string): Promise<string | null>
await database.write(async () => {
const task = await collections.tasks.find(taskId);
const completing = !task.completed;
await task.update((t) => {
await task.update(t => {
t.completed = completing;
t.completedAt = completing ? Date.now() : null;
t.updatedAt = new Date();
@@ -219,10 +233,9 @@ export async function toggleTaskComplete(taskId: string): Promise<string | null>
}
export async function countOpenSubtasks(taskId: string): Promise<number> {
const open = await collections.subtasks.query(
Q.where('task_id', taskId),
Q.where('completed', false)
).fetch();
const open = await collections.subtasks
.query(Q.where('task_id', taskId), Q.where('completed', false))
.fetch();
return open.length;
}
@@ -266,12 +279,12 @@ export async function confirmAndToggleTaskComplete(
export async function toggleSubtaskComplete(subtaskId: string): Promise<void> {
await database.write(async () => {
const subtask = await collections.subtasks.find(subtaskId);
await subtask.update((s) => {
await subtask.update(s => {
s.completed = !s.completed;
s.updatedAt = new Date();
});
const task = await collections.tasks.find(subtask.taskId);
await task.update((t) => {
await task.update(t => {
t.updatedAt = new Date();
});
});
@@ -300,7 +313,7 @@ export interface SubtaskUpdateData {
export async function updateSubtask(subtaskId: string, data: SubtaskUpdateData): Promise<void> {
await database.write(async () => {
const subtask = await collections.subtasks.find(subtaskId);
await subtask.update((s) => {
await subtask.update(s => {
s.title = data.title.trim();
s.description = data.description || '';
if (data.categoryId !== undefined) s.categoryId = data.categoryId;
@@ -318,7 +331,7 @@ export async function updateSubtask(subtaskId: string, data: SubtaskUpdateData):
s.updatedAt = new Date();
});
const task = await collections.tasks.find(subtask.taskId);
await task.update((t) => {
await task.update(t => {
t.updatedAt = new Date();
});
});
@@ -332,9 +345,11 @@ export async function deleteSubtask(subtaskId: string): Promise<void> {
await database.write(async () => {
const subtask = await collections.subtasks.find(subtaskId);
parentTaskId = subtask.taskId;
const children = await collections.subtasks.query(Q.where('parent_subtask_id', subtaskId)).fetch();
const children = await collections.subtasks
.query(Q.where('parent_subtask_id', subtaskId))
.fetch();
for (const child of children) {
await child.update((c) => {
await child.update(c => {
c.parentSubtaskId = null;
c.updatedAt = new Date();
});
@@ -347,7 +362,7 @@ export async function deleteSubtask(subtaskId: string): Promise<void> {
try {
await database.write(async () => {
const task = await collections.tasks.find(parentTaskId!);
await task.update((t) => {
await task.update(t => {
t.updatedAt = new Date();
});
});
@@ -364,7 +379,7 @@ export async function duplicateSubtask(subtaskId: string): Promise<void> {
const siblings = await collections.subtasks.query(Q.where('task_id', subtask.taskId)).fetch();
const now = new Date();
clone = await collections.subtasks.create((s) => {
clone = await collections.subtasks.create(s => {
s.taskId = subtask.taskId;
s.categoryId = subtask.categoryId || '';
s.tags = subtask.tags || '';
@@ -388,7 +403,7 @@ export async function duplicateSubtask(subtaskId: string): Promise<void> {
});
const task = await collections.tasks.find(subtask.taskId);
await task.update((t) => {
await task.update(t => {
t.updatedAt = new Date();
});
});
@@ -398,18 +413,21 @@ export async function duplicateSubtask(subtaskId: string): Promise<void> {
export async function setSubtaskPriority(subtaskId: string, priority: Priority): Promise<void> {
await database.write(async () => {
const subtask = await collections.subtasks.find(subtaskId);
await subtask.update((s) => {
await subtask.update(s => {
s.priority = priority;
s.updatedAt = new Date();
});
const task = await collections.tasks.find(subtask.taskId);
await task.update((t) => {
await task.update(t => {
t.updatedAt = new Date();
});
});
}
export async function undoTaskComplete(taskId: string, nextOccurrenceId: string | null): Promise<void> {
export async function undoTaskComplete(
taskId: string,
nextOccurrenceId: string | null
): Promise<void> {
await database.write(async () => {
if (nextOccurrenceId) {
try {
@@ -423,7 +441,7 @@ export async function undoTaskComplete(taskId: string, nextOccurrenceId: string
try {
const task = await collections.tasks.find(taskId);
if (task.completed) {
await task.update((t) => {
await task.update(t => {
t.completed = false;
t.updatedAt = new Date();
});
@@ -446,19 +464,27 @@ export async function deleteTask(taskId: string): Promise<void> {
});
}
export async function convertTaskToSubtask(taskId: string, parentTaskId: string, parentSubtaskId: string | null = null): Promise<void> {
export async function convertTaskToSubtask(
taskId: string,
parentTaskId: string,
parentSubtaskId: string | null = null
): Promise<void> {
if (taskId === parentTaskId) return;
await database.write(async () => {
const task = await collections.tasks.find(taskId);
const ownSubtasks = await collections.subtasks.query(Q.where('task_id', taskId)).fetch();
const existing = await collections.subtasks.query(
Q.where('task_id', parentTaskId),
parentSubtaskId ? Q.where('parent_subtask_id', parentSubtaskId) : Q.where('parent_subtask_id', null)
).fetch();
const existing = await collections.subtasks
.query(
Q.where('task_id', parentTaskId),
parentSubtaskId
? Q.where('parent_subtask_id', parentSubtaskId)
: Q.where('parent_subtask_id', null)
)
.fetch();
const now = new Date();
let order = existing.length;
const created = await collections.subtasks.create((s) => {
const created = await collections.subtasks.create(s => {
s.taskId = parentTaskId;
s.parentSubtaskId = parentSubtaskId;
s.categoryId = task.categoryId || '';
@@ -482,7 +508,7 @@ export async function convertTaskToSubtask(taskId: string, parentTaskId: string,
});
for (const subtask of ownSubtasks) {
await collections.subtasks.create((s) => {
await collections.subtasks.create(s => {
s.taskId = parentTaskId;
s.parentSubtaskId = created.id;
s.categoryId = subtask.categoryId || '';
@@ -507,7 +533,7 @@ export async function convertTaskToSubtask(taskId: string, parentTaskId: string,
}
const parent = await collections.tasks.find(parentTaskId);
await parent.update((p) => {
await parent.update(p => {
p.updatedAt = now;
});
@@ -524,11 +550,13 @@ export async function convertSubtaskToTask(subtaskId: string): Promise<void> {
const now = new Date();
const subTags = parseTaskTags(subtask.tags, subtask.categoryId);
const task = await collections.tasks.create((t) => {
const task = await collections.tasks.create(t => {
t.title = subtask.title;
t.description = subtask.description || '';
t.categoryId = subTags[0] || parent.categoryId || '';
t.tags = tagsToString(subTags.length > 0 ? subTags : (parent.categoryId ? [parent.categoryId] : []));
t.tags = tagsToString(
subTags.length > 0 ? subTags : parent.categoryId ? [parent.categoryId] : []
);
t.priority = subtask.priority;
t.completed = subtask.completed;
t.dueDate = subtask.dueDate;
@@ -546,9 +574,11 @@ export async function convertSubtaskToTask(subtaskId: string): Promise<void> {
});
newTaskId = task.id;
const children = await collections.subtasks.query(Q.where('parent_subtask_id', subtaskId)).fetch();
const children = await collections.subtasks
.query(Q.where('parent_subtask_id', subtaskId))
.fetch();
for (const child of children) {
await child.update((c) => {
await child.update(c => {
c.parentSubtaskId = null;
c.updatedAt = now;
});
@@ -556,7 +586,7 @@ export async function convertSubtaskToTask(subtaskId: string): Promise<void> {
await subtask.destroyPermanently();
await recordTombstonesInBatch('subtasks', [subtaskId]);
await parent.update((p) => {
await parent.update(p => {
p.updatedAt = now;
});
});
@@ -576,7 +606,7 @@ export async function moveSubtaskToTask(subtaskId: string, toTaskId: string): Pr
const siblings = await collections.subtasks.query(Q.where('task_id', toTaskId)).fetch();
const now = new Date();
await subtask.update((s) => {
await subtask.update(s => {
s.taskId = toTaskId;
s.parentSubtaskId = null;
s.order = siblings.length;
@@ -584,14 +614,14 @@ export async function moveSubtaskToTask(subtaskId: string, toTaskId: string): Pr
});
try {
const oldParent = await collections.tasks.find(oldParentId);
await oldParent.update((p) => {
await oldParent.update(p => {
p.updatedAt = now;
});
} catch {
// parent already gone
}
const newParent = await collections.tasks.find(toTaskId);
await newParent.update((p) => {
await newParent.update(p => {
p.updatedAt = now;
});
});
@@ -617,19 +647,18 @@ export async function setSubtaskParent(subtaskId: string, newParentId: string):
cursor = await collections.subtasks.find(cursor.parentSubtaskId);
}
const siblings = await collections.subtasks.query(
Q.where('task_id', subtask.taskId),
Q.where('parent_subtask_id', newParentId)
).fetch();
const siblings = await collections.subtasks
.query(Q.where('task_id', subtask.taskId), Q.where('parent_subtask_id', newParentId))
.fetch();
await subtask.update((s) => {
await subtask.update(s => {
s.parentSubtaskId = newParentId;
s.order = siblings.length;
s.updatedAt = now;
});
const task = await collections.tasks.find(subtask.taskId);
await task.update((t) => {
await task.update(t => {
t.updatedAt = now;
});
});
@@ -639,7 +668,7 @@ export async function setTaskCompleted(taskId: string, completed: boolean): Prom
let nextOccurrence: any = null;
await database.write(async () => {
const task = await collections.tasks.find(taskId);
await task.update((t) => {
await task.update(t => {
t.completed = completed;
t.completedAt = completed ? Date.now() : null;
t.updatedAt = new Date();
@@ -656,10 +685,9 @@ export async function setTaskCompleted(taskId: string, completed: boolean): Prom
async function createNextOccurrenceIfNeeded(task: any): Promise<any> {
const seriesId = task.seriesId || newSeriesId();
const existing = await collections.tasks.query(
Q.where('series_id', seriesId),
Q.where('completed', false)
).fetch();
const existing = await collections.tasks
.query(Q.where('series_id', seriesId), Q.where('completed', false))
.fetch();
if (existing.length > 0) return null;
const next = await createNextOccurrence(task, seriesId);
if (!task.seriesId) {
@@ -671,11 +699,16 @@ async function createNextOccurrenceIfNeeded(task: any): Promise<any> {
}
async function createNextOccurrence(task: any, seriesId: string): Promise<any> {
const nextDate = computeNextOccurrence(task.dueDate, task.repeat, task.repeatInterval, task.repeatDays);
const nextDate = computeNextOccurrence(
task.dueDate,
task.repeat,
task.repeatInterval,
task.repeatDays
);
const subtasks = await collections.subtasks.query(Q.where('task_id', task.id)).fetch();
const now = new Date();
const next = await collections.tasks.create((t) => {
const next = await collections.tasks.create(t => {
t.title = task.title;
t.description = task.description;
t.categoryId = task.categoryId;
@@ -697,7 +730,7 @@ async function createNextOccurrence(task: any, seriesId: string): Promise<any> {
});
for (const subtask of subtasks) {
await collections.subtasks.create((s) => {
await collections.subtasks.create(s => {
s.taskId = next.id;
s.title = subtask.title;
s.description = subtask.description;
@@ -723,7 +756,7 @@ async function createNextOccurrence(task: any, seriesId: string): Promise<any> {
export async function setTaskCategory(taskId: string, categoryId: string): Promise<void> {
await database.write(async () => {
const task = await collections.tasks.find(taskId);
await task.update((t) => {
await task.update(t => {
t.categoryId = categoryId;
t.tags = tagsToString(categoryId ? [categoryId] : []);
t.updatedAt = new Date();
@@ -734,7 +767,7 @@ export async function setTaskCategory(taskId: string, categoryId: string): Promi
export async function setTaskPriority(taskId: string, priority: Priority): Promise<void> {
await database.write(async () => {
const task = await collections.tasks.find(taskId);
await task.update((t) => {
await task.update(t => {
t.priority = priority;
t.updatedAt = new Date();
});
@@ -748,7 +781,7 @@ export async function duplicateTask(taskId: string): Promise<void> {
const subtasks = await collections.subtasks.query(Q.where('task_id', taskId)).fetch();
const now = new Date();
clone = await collections.tasks.create((t) => {
clone = await collections.tasks.create(t => {
t.title = task.title;
t.description = task.description;
t.categoryId = task.categoryId;
@@ -770,7 +803,7 @@ export async function duplicateTask(taskId: string): Promise<void> {
});
for (const subtask of subtasks) {
await collections.subtasks.create((s) => {
await collections.subtasks.create(s => {
s.taskId = clone.id;
s.title = subtask.title;
s.description = subtask.description;
@@ -796,7 +829,7 @@ export async function reorderTasks(taskIds: string[]): Promise<void> {
const now = new Date();
for (let i = 0; i < taskIds.length; i++) {
const task = await collections.tasks.find(taskIds[i]);
await task.update((t) => {
await task.update(t => {
t.order = i;
t.updatedAt = now;
});
@@ -809,7 +842,7 @@ export async function reorderSubtasks(subtaskIds: string[]): Promise<void> {
const now = new Date();
for (let i = 0; i < subtaskIds.length; i++) {
const subtask = await collections.subtasks.find(subtaskIds[i]);
await subtask.update((s) => {
await subtask.update(s => {
s.order = i;
s.updatedAt = now;
});
@@ -840,12 +873,16 @@ export async function createSubtask(data: CreateSubtaskData): Promise<string> {
let newSubtaskId: string | null = null;
await database.write(async () => {
const now = new Date();
const siblings = await collections.subtasks.query(
Q.where('task_id', data.taskId),
data.parentSubtaskId ? Q.where('parent_subtask_id', data.parentSubtaskId) : Q.where('parent_subtask_id', null)
).fetch();
const siblings = await collections.subtasks
.query(
Q.where('task_id', data.taskId),
data.parentSubtaskId
? Q.where('parent_subtask_id', data.parentSubtaskId)
: Q.where('parent_subtask_id', null)
)
.fetch();
const subtask = await collections.subtasks.create((s) => {
const subtask = await collections.subtasks.create(s => {
s.taskId = data.taskId;
s.parentSubtaskId = data.parentSubtaskId || null;
s.categoryId = data.categoryId || '';
+1 -1
View File
@@ -26,4 +26,4 @@
"ignoreDeprecations": "6.0"
},
"exclude": ["node_modules"]
}
}