Compare commits

..
21 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
tech08mag c787b87cc5 Merge pull request 'Removed the yes | sdkmanager --licenses that caused SIGPIPE' (#31) from dev into main
Build and Release APK / build (push) Successful in 19m59s
Build and Release APK / release (push) Failing after 6m41s
Reviewed-on: #31
2026-08-28 20:09:13 +00:00
tech08mag 0685735c74 Removed the yes | sdkmanager --licenses that caused SIGPIPE 2026-08-28 22:08:16 +02:00
tech08mag 064650a585 Merge pull request 'added explicit NDK license acceptance by writing the known license hashes to $ANDROID_HOME/licenses/android-sdk-license' (#30) from dev into main
Build and Release APK / build (push) Failing after 7s
Build and Release APK / release (push) Skipped
Reviewed-on: #30
2026-08-28 20:06:58 +00:00
tech08mag bdfe53fa83 added explicit NDK license acceptance by writing the known license hashes to $ANDROID_HOME/licenses/android-sdk-license 2026-08-28 22:06:00 +02:00
tech08mag f6abf628e3 Merge pull request 'new release pipe' (#29) from dev into main
Build and Release APK / build (push) Failing after 3m48s
Build and Release APK / release (push) Skipped
Reviewed-on: #29
2026-08-28 20:00:40 +00:00
tech08mag 40077da57f new release pipe 2026-08-28 22:00:13 +02:00
tech08mag aa57d07dc0 Merge pull request 'gradle update' (#28) from dev into main
Build APK / build (push) Successful in 17m50s
release / release (push) Canceled after 0s
Reviewed-on: #28
2026-08-28 19:39:21 +00:00
tech08mag 89c85ed7c0 gradle update 2026-08-28 21:38:23 +02:00
76 changed files with 5630 additions and 2629 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
-76
View File
@@ -1,76 +0,0 @@
name: Build APK
on:
push:
branches: [main]
workflow_dispatch:
env:
ANDROID_HOME: ${{ runner.tool_cache }}/android-sdk
ANDROID_SDK_ROOT: ${{ runner.tool_cache }}/android-sdk
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 22
- name: Set up JDK
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
- 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
printf 'y\n' | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses > /dev/null
"$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 JS 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: Build release APK
run: |
cd carry-your-live/android
./gradlew assembleRelease
- name: Upload APK
uses: actions/upload-artifact@v4
with:
name: carry-your-live-release
path: carry-your-live/android/app/build/outputs/release/app-release.apk
+218 -45
View File
@@ -1,64 +1,237 @@
name: release name: Build and Release Android
on: on:
push: push:
branches: tags:
- main - 'v*'
workflow_dispatch:
permissions: env:
contents: write NODE_VERSION: '20'
JAVA_VERSION: '17'
ANDROID_HOME: ${{ runner.tool_cache }}/android-sdk
ANDROID_SDK_ROOT: ${{ runner.tool_cache }}/android-sdk
GITEA_SERVER_URL: "https://techmos.gitea.org"
jobs: jobs:
release: build:
needs: build name: Build Android
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 45
permissions:
contents: read
steps: steps:
- name: Checkout - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Download APK artifact - 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: npx expo prebuild --platform android --clean
- name: Configure Release Signing
if: env.KEYSTORE_BASE64 != ''
working-directory: carry-your-live/android/app
run: |
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: 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: 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: Download Build Artifacts
uses: actions/download-artifact@v4 uses: actions/download-artifact@v4
with: with:
name: carry-your-live-release name: android-release-build
path: . path: artifacts
- name: Compute next version - name: Create Gitea Release
id: version env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: | run: |
latest=$(git tag --list 'v[0-9]*' --sort=-v:refname | head -n 1) TAG_NAME="${GITHUB_REF#refs/tags/}"
if [ -z "$latest" ]; then REPO_OWNER="${GITHUB_REPOSITORY%/*}"
next="v1.0.0" REPO_NAME="${GITHUB_REPOSITORY#*/}"
else
latest=${latest#v}
IFS='.' read -r major minor patch <<< "$latest"
next="v${major}.${minor}.$((patch + 1))"
fi
echo "next=${next}" >> "$GITHUB_OUTPUT"
- name: Build changelog # Detect Gitea server URL
id: changelog 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
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: Upload APK to Release
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: | run: |
prev_tag=$(git describe --tags --abbrev=0 2>/dev/null || echo "") GITEA_URL="${GITEA_URL:-https://techmos.gitea.org}"
if [ -z "$prev_tag" ]; then REPO_OWNER="${GITHUB_REPOSITORY%/*}"
log=$(git log --oneline --no-merges main | head -n 40) REPO_NAME="${GITHUB_REPOSITORY#*/}"
else RELEASE_ID="${{ steps.create-release.outputs.release_id }}"
log=$(git log --oneline --no-merges "$prev_tag"..main | head -n 40) TAG_NAME="${GITHUB_REF#refs/tags/}"
fi
{
echo "body<<EOF"
echo "## What's new in this release"
echo ""
echo "$log" | sed 's/^/- /'
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Create release for file in artifacts/*; do
uses: softprops/action-gh-release@v2 if [[ "$file" == *.apk ]]; then
with: FILENAME="carry-your-live-${TAG_NAME}.apk"
tag_name: ${{ steps.version.outputs.next }} elif [[ "$file" == *.aab ]]; then
name: ${{ steps.version.outputs.next }} FILENAME="carry-your-live-${TAG_NAME}.aab"
body: ${{ steps.changelog.outputs.body }} else
files: ./app-release.apk continue
generate_release_notes: false 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
+490 -109
View File
@@ -1,15 +1,40 @@
# Carry Your Live - Mobile To-Do & Calendar App # 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 ### Added
- **Subtask drag-and-drop**: Added `parentSubtaskId` type support for proper subtask reparenting - **Friends feature**: Friend requests, accept/decline/remove, user search, full backend routes
- **Task drag sorting**: Added task drag-and-drop reordering functionality with visual drop indicators - **Assignee field on tasks**: Schema v7, model, types, forms, sync, task actions
- **iCal import**: Fixed import functionality - replaced invalid `splice()` with proper `create()` calls and fixed date type handling - **AssigneeSelector component** and assignee display in TaskItem
- **Settings context**: Added `accentOpacity` and `setAccentOpacity` to the Settings context - **QuickAddBar** on tasks overview (copied from calendar)
- **Tag inheritance**: Subtasks no longer automatically inherit parent task tags when converting tasks to subtasks - **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 ## 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 - **Offline-First Architecture**: Full CRUD operations work offline with WatermelonDB + SQLite
- **Auto-Sync**: Background synchronization with custom Postgres API when online - **Auto-Sync**: Background synchronization with custom Postgres API when online
- **Cross-Platform**: iOS, Android, and Web from single codebase - **Cross-Platform**: iOS, Android, and Web from single codebase
- **Desktop Support**: Electron wrapper for desktop app distribution
### Screens ### Screens
#### 1. To-Do List Screen #### 1. Tasks Screen (To-Do List)
- **Header**: App logo + "TODO" title with rounded corners - **Header**: App logo + "TODO" title with rounded corners
- **Category Filter**: Horizontal scrollable chips (Work, Personal, School, Shopping, Health, Finance, All) - **Category Filter**: Horizontal scrollable chips (Work, Personal, School, Shopping, Health, Finance, All)
- Single selection with visual feedback (thicker border, shadow, scale animation) - Single selection with visual feedback (thicker border, shadow, scale animation)
- Instant task filtering - Instant task filtering
- **Task List**: Vertical scrolling with: - **QuickAddBar**: Quick task creation from list view
- Checkbox (empty/checked states with animation) - **Task List**: Virtualized FlatList with sections (Pending/Completed)
- Task title (truncated with ellipsis) - Task title (truncated with ellipsis)
- Three-dot overflow menu (Edit, Delete, Duplicate, Mark Complete, Change Category, Change Priority) - Three-dot overflow menu (Edit, Delete, Duplicate, Mark Complete, Change Category, Change Priority)
- Priority badges with colors - Priority badges with colors
- Due date/time with overdue/due-today highlighting - Due date/time with overdue/due-today highlighting
- Completed section (collapsible) - Category color indicator strip
- **Floating Action Button**: Bottom-right, opens Add Task screen with press animation - 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) - **Category Selector**: Horizontal row of colored circles (required)
- **Task Name**: Single-line input, 100 char max (required) - **Task Name**: Single-line input, 100 char max (required)
- **Subtasks**: Unlimited, add/remove individually - **Subtasks**: Unlimited, add/remove individually, nested subtask support
- **Date & Time**: Date picker, time picker, calendar button - **Date & Time**: Date picker, time picker (WheelTimePicker), calendar button
- **Priority**: Dropdown (None, Low, Medium, High, Critical) with color indicators - **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 - **Description**: Multi-line text area, 1000 char max
- **Bottom Actions**: Submit (primary) / Cancel (secondary) fixed buttons - **Bottom Actions**: Submit (primary) / Cancel (secondary) fixed buttons
#### 3. Calendar Screen #### 6. Task Detail Screen
- **Date Strip**: Horizontal scrollable days of current month - Full task view with all fields
- **Month Label**: Centered (e.g., "JAN 2026") - Subtask management (add, complete, reorder, convert to task)
- **Task List**: Tasks for selected day with category color, title, time, completion status - Edit functionality
- **Empty State**: "No tasks scheduled." - Repeat profile configuration
#### 4. Settings Screen #### 7. Subtask Detail Screen
- Dark Mode toggle - Nested subtask management
- Push Notifications toggle - Convert subtask ↔ task
- Reminder Preferences - Re-parent subtasks
- Default Category selector
- Sort Tasks By (Date, Priority, Alphabetically, Creation Date)
- Backup & Sync
- Version, Privacy Policy, Terms of Service
### Task States #### 8. Sync Modal
- **Pending**: Empty checkbox, normal text - Login/Register forms
- **Completed**: Checked checkbox, strikethrough, reduced opacity - Forgot/Reset password flow
- **Overdue**: Red due date, warning indicator - Server URL configuration
- **Due Today**: Blue highlight - Sync status display
- **High Priority**: Priority badge
### Interactions ### Task Features
- Tap task → Edit screen - **Nested Subtasks**: Unlimited depth, re-parenting, drag reorder
- Long press → Selection mode (multi-delete, multi-complete, change category/priority) - **Repeating Tasks**: Repeat profiles, series IDs, this/future/all delete scopes, undo-complete
- Swipe left → Delete - **Categories/Tags**: 6 default categories with colors, custom category editor
- Swipe right → Complete - **Priorities**: None, Low, Medium, High, Critical with color badges
- Pull down → Refresh - **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 ## Tech Stack
| Layer | Technology | | Layer | Technology |
|-------|------------| |-------|------------|
| Framework | Expo (React Native) | | Framework | Expo (React Native) / Expo Router v3 |
| Language | TypeScript | | Language | TypeScript (strict mode) |
| Navigation | Expo Router (file-based) | | Navigation | Expo Router (file-based) |
| Database | WatermelonDB + SQLite | | Database | WatermelonDB + SQLite (expo-sqlite) |
| State | WatermelonDB (reactive queries, no Redux needed) | | State | WatermelonDB reactive queries (no Redux) |
| Forms | React Hook Form + Zod validation | | Forms | React Hook Form + Zod validation |
| UI Components | React Native Paper + Custom SVG icons | | UI Components | React Native Paper + Custom components |
| Animations | React Native Reanimated | | Animations | React Native Reanimated v3 |
| Date/Time | @react-native-community/datetimepicker + date-fns | | Date/Time | @react-native-community/datetimepicker + date-fns + WheelTimePicker |
| Build | EAS Build / Gradle (local APK) | | Build | EAS Build / Gradle (local APK) / Electron Builder (desktop) |
| CI/CD | Gitea Actions (`.gitea/workflows/build-apk.yml`) | | 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 ## Project Structure
``` ```
carry-your-live/ carry-your-live/
├── app/ ├── app/
│ ├── _layout.tsx # Root layout + DatabaseProvider │ ├── _layout.tsx # Root layout + DatabaseProvider + AuthProvider
│ ├── (tabs)/ │ ├── (tabs)/
│ │ ├── _layout.tsx # Bottom tab navigator │ │ ├── _layout.tsx # Bottom tab navigator
│ │ ├── index.tsx # Tasks screen │ │ ├── index.tsx # Tasks screen
│ │ ├── calendar.tsx # Calendar screen │ │ ├── calendar.tsx # Calendar screen
│ │ ├── stats.tsx # Stats screen
│ │ └── settings.tsx # Settings 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/ ├── src/
│ ├── components/ # Reusable UI components │ ├── components/ # Reusable UI components (30+)
│ │ ├── Header.tsx │ │ ├── Header.tsx
│ │ ├── CategoryFilter.tsx │ │ ├── CategoryFilter.tsx
│ │ ├── TaskList.tsx │ │ ├── TaskList.tsx
│ │ ├── TaskItem.tsx │ │ ├── TaskItem.tsx
│ │ ├── FloatingActionButton.tsx │ │ ├── FloatingActionButton.tsx
│ │ ├── TabBarIcon.tsx
│ │ ├── CategorySelector.tsx │ │ ├── CategorySelector.tsx
│ │ ├── TaskNameInput.tsx │ │ ├── TaskNameInput.tsx
│ │ ├── SubtasksSection.tsx │ │ ├── SubtasksSection.tsx
@@ -115,24 +188,102 @@ carry-your-live/
│ │ ├── PrioritySelector.tsx │ │ ├── PrioritySelector.tsx
│ │ ├── DescriptionInput.tsx │ │ ├── DescriptionInput.tsx
│ │ ├── FormButtons.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/ │ ├── database/
│ │ ├── schema.ts # WatermelonDB schema │ │ ├── schema.ts # WatermelonDB schema (v21)
│ │ ├── index.ts # Database instance + collections │ │ ├── index.ts # Database instance + collections
│ │ └── sync.ts # Custom API sync adapter │ │ ├── sync.ts # Custom API sync adapter (25KB)
│ ├── models/ │ │ ├── migrations.ts # Schema migrations
│ │ ├── Task.ts │ │ ├── adapter.ts # Platform-specific adapters
│ │ ├── Category.ts │ │ └── tombstones.ts # Deleted record tracking
│ │ └── Subtask.ts
│ ├── hooks/ │ ├── hooks/
│ │ ├── useDatabase.tsx # Database context & providers │ │ ├── useDatabase.tsx # Database context & providers
│ │ └── useTasks.tsx # Task queries with reactive state │ │ ├── useTasks.tsx # Task queries with reactive state
│ ├── constants/ │ │ ├── useCategories.tsx # Category queries
│ │ └── index.ts # Categories, priorities, colors │ │ ├── useTasksByDate.tsx # Calendar date queries
│ └── types/ │ │ ├── useFriends.tsx # Friends state & actions
│ └── index.ts # TypeScript interfaces │ │ ├── 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 ## Data Models
### Task ### Task
@@ -144,8 +295,14 @@ carry-your-live/
categoryId: string; categoryId: string;
priority: 'none' | 'low' | 'medium' | 'high' | 'critical'; priority: 'none' | 'low' | 'medium' | 'high' | 'critical';
completed: boolean; completed: boolean;
dueDate: number; // timestamp dueDate: number; // timestamp (ms)
dueTime: string; // "HH:MM" 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; createdAt: Date;
updatedAt: Date; updatedAt: Date;
subtasks: Subtask[]; subtasks: Subtask[];
@@ -159,6 +316,8 @@ carry-your-live/
name: string; name: string;
color: '#HEX'; color: '#HEX';
order: number; order: number;
createdAt: Date;
updatedAt: Date;
} }
``` ```
@@ -167,14 +326,47 @@ carry-your-live/
{ {
id: string; id: string;
taskId: string; taskId: string;
parentSubtaskId: string | null; // For nesting
title: string; title: string;
completed: boolean; completed: boolean;
order: number; order: number;
assigneeId: string | null;
createdAt: Date; createdAt: Date;
updatedAt: 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 ## Default Categories
| Name | Color | Order | | Name | Color | Order |
|------|-------|-------| |------|-------|-------|
@@ -185,6 +377,8 @@ carry-your-live/
| Health | #8E24AA (Purple) | 4 | | Health | #8E24AA (Purple) | 4 |
| Finance | #FDD835 (Yellow) | 5 | | Finance | #FDD835 (Yellow) | 5 |
---
## Priority Colors ## Priority Colors
| Priority | Color | | Priority | Color |
|----------|-------| |----------|-------|
@@ -194,6 +388,8 @@ carry-your-live/
| High | #E53935 | | High | #E53935 |
| Critical | #B71C1C | | Critical | #B71C1C |
---
## Getting Started ## Getting Started
### Prerequisites ### Prerequisites
@@ -201,15 +397,23 @@ carry-your-live/
- npm or yarn - npm or yarn
- Expo CLI (`npm install -g expo-cli`) - Expo CLI (`npm install -g expo-cli`)
- iOS Simulator (Mac) / Android Studio for mobile testing - iOS Simulator (Mac) / Android Studio for mobile testing
- PostgreSQL 16+ (or Docker)
### Installation ### Installation
```bash ```bash
cd carry-your-live cd carry-your-live
npm install npm install
cd ../backend
npm install
``` ```
### Development ### Development
#### Frontend
```bash ```bash
cd carry-your-live
# Start Expo dev server # Start Expo dev server
npx expo start npx expo start
@@ -219,6 +423,37 @@ npx expo start --android
npx expo start --web 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 ### Building
#### EAS Build (cloud) #### EAS Build (cloud)
@@ -234,18 +469,16 @@ eas build --platform ios
eas build --platform android eas build --platform android
eas build --platform web eas build --platform web
``` ```
Profiles are defined in `eas.json` (`development`, `preview`, `production`; production auto-increments version code). Profiles defined in `eas.json` (`development`, `preview`, `production`; production auto-increments version code).
#### Gradle (local Android APK) #### 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 ```bash
cd carry-your-live/android cd carry-your-live/android
# Debug APK (unsigned) # Debug APK (unsigned)
./gradlew assembleDebug ./gradlew assembleDebug
# Release APK (currently signed with the debug keystore) # Release APK (signed with debug keystore)
./gradlew assembleRelease ./gradlew assembleRelease
``` ```
Output: Output:
@@ -253,24 +486,21 @@ Output:
android/app/build/outputs/apk/debug/app-debug.apk android/app/build/outputs/apk/debug/app-debug.apk
android/app/build/outputs/apk/release/app-release.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) ## 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) 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` 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/` 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/` 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) 6. **Upload**: APK saved as `carry-your-live-release` artifact
To run the build locally exactly as CI does:
To run locally exactly as CI does:
```bash ```bash
keytool -genkeypair -v \ keytool -genkeypair -v \
-keystore carry-your-live/android/app/debug.keystore \ -keystore carry-your-live/android/app/debug.keystore \
@@ -280,48 +510,119 @@ keytool -genkeypair -v \
cd carry-your-live/android && ./gradlew assembleRelease 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 Server runs at `http://localhost:3000/api` (configurable in `src/services/api.ts` and `src/database/sync.ts`):
```
GET /sync?since={timestamp} ### Authentication
``` - `POST /api/auth/register` - Register new user
Response: - `POST /api/auth/login` - Login (returns access + refresh tokens)
```json - `POST /api/auth/refresh` - Refresh access token
{ - `POST /api/auth/forgot-password` - Request password reset
"categories": [{ "id", "name", "color", "order", "createdAt", "updatedAt" }], - `POST /api/auth/reset-password` - Reset password with token
"tasks": [{ "id", "title", "description", "categoryId", "priority", "completed", "dueDate", "dueTime", "createdAt", "updatedAt" }], - `GET /api/auth/me` - Get current user
"subtasks": [{ "id", "taskId", "title", "completed", "order", "createdAt", "updatedAt" }],
### 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 "timestamp": 1234567890
} }
``` ```
### Push Changes 2. **Push**: Client sends batched changes with `lastPulledAt`
``` ```json
POST /sync/push {
```
Body:
```json
{
"changes": { "changes": {
"categories": [...], "categories": [...],
"tasks": [...], "tasks": [...],
"subtasks": [...] "subtasks": [...],
"repeatProfiles": [...],
"friendships": [...]
}, },
"tombstones": [...],
"lastPulledAt": 1234567890 "lastPulledAt": 1234567890
} }
``` ```
3. **Conflict Resolution**: Last-write-wins based on `updatedAt` (customizable in `src/database/sync.ts`)
---
## Configuration ## Configuration
### Environment Variables ### Frontend Environment Variables
Create `.env` file: Create `.env` file in `carry-your-live/`:
``` ```
EXPO_PUBLIC_API_URL=http://your-api-url/api 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 ### App Config
Modify `app.json` for: Modify `app.json` for:
- App name, slug, version - App name, slug, version
@@ -329,10 +630,12 @@ Modify `app.json` for:
- Permissions (notifications, etc.) - Permissions (notifications, etc.)
- Orientation, theme - Orientation, theme
---
## Visual Style Guide ## Visual Style Guide
- **Background**: White (#FFFFFF) - **Background**: White (#FFFFFF) / Dark mode (planned)
- **Primary Accent**: #E53935 (Pink/Red) - **Primary Accent**: #E53935 (Pink/Red) - customizable
- **Typography**: Black (#212121) primary, #757575 secondary, #9E9E9E tertiary - **Typography**: Black (#212121) primary, #757575 secondary, #9E9E9E tertiary
- **Borders**: Thin (#E0E0E0), selected #E53935 - **Borders**: Thin (#E0E0E0), selected #E53935
- **Radius**: 16-20px for cards/buttons/inputs - **Radius**: 16-20px for cards/buttons/inputs
@@ -340,11 +643,13 @@ Modify `app.json` for:
- **Shadows**: Minimal (elevation 1-3) - **Shadows**: Minimal (elevation 1-3)
- **Animations**: 150-250ms, cubic easing - **Animations**: 150-250ms, cubic easing
---
## Offline-First Architecture ## Offline-First Architecture
``` ```
┌─────────────┐ ┌──────────────┐ ┌─────────────┐ ┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ Expo App │────▶│ WatermelonDB │────▶│ Your API │ │ Expo App │────▶│ WatermelonDB │────▶│ Backend │
│ (iOS/Android│ │ (SQLite) │ │ (Postgres) │ │ (iOS/Android│ │ (SQLite) │ │ (Postgres) │
│ Web) │◀───▶│ + Sync │◀───▶│ │ │ Web) │◀───▶│ + Sync │◀───▶│ │
└─────────────┘ └──────────────┘ └─────────────┘ └─────────────┘ └──────────────┘ └─────────────┘
@@ -354,6 +659,79 @@ Modify `app.json` for:
2. Reactive queries update UI instantly 2. Reactive queries update UI instantly
3. Background sync pushes/pulls changes 3. Background sync pushes/pulls changes
4. Conflict resolution: last-write-wins (customizable) 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 ## Contributing
@@ -362,6 +740,9 @@ Modify `app.json` for:
3. Write components with React Hook Form + Zod 3. Write components with React Hook Form + Zod
4. Use WatermelonDB decorators for models 4. Use WatermelonDB decorators for models
5. Test offline scenarios 5. Test offline scenarios
6. Run `npm run lint` and `npm run typecheck` before committing
---
## License ## License
+15 -3
View File
@@ -104,15 +104,27 @@ android {
keyAlias 'androiddebugkey' keyAlias 'androiddebugkey'
keyPassword 'android' 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 { buildTypes {
debug { debug {
signingConfig = signingConfigs.debug signingConfig = signingConfigs.debug
} }
release { release {
// Caution! In production, you need to generate your own keystore file. signingConfig = signingConfigs.release
// see https://reactnative.dev/docs/signed-apk-android.
signingConfig = signingConfigs.debug
def enableShrinkResources = findProperty('android.enableShrinkResourcesInReleaseBuilds') ?: 'false' def enableShrinkResources = findProperty('android.enableShrinkResourcesInReleaseBuilds') ?: 'false'
shrinkResources = enableShrinkResources.toBoolean() shrinkResources = enableShrinkResources.toBoolean()
minifyEnabled enableMinifyInReleaseBuilds minifyEnabled enableMinifyInReleaseBuilds
+3
View File
@@ -1,4 +1,7 @@
pluginManagement { pluginManagement {
repositories {
gradlePluginPortal()
}
def reactNativeGradlePlugin = new File( def reactNativeGradlePlugin = new File(
providers.exec { providers.exec {
workingDir(rootDir) workingDir(rootDir)
+3 -1
View File
@@ -34,7 +34,9 @@
"color": "#1E88E5" "color": "#1E88E5"
} }
], ],
"./plugins/withQuickAddWidget" "./plugins/withQuickAddWidget",
"expo-router",
"expo-sqlite"
], ],
"extra": { "extra": {
"eas": { "eas": {
+22 -13
View File
@@ -1,7 +1,7 @@
import { Tabs } from 'expo-router'; import { Tabs } from 'expo-router';
import React from 'react'; import React from 'react';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { View, StyleSheet, TouchableOpacity, Animated, } from 'react-native'; import { View, StyleSheet, TouchableOpacity, Animated } from 'react-native';
import { TabBarIcon } from '@/components/TabBarIcon'; import { TabBarIcon } from '@/components/TabBarIcon';
import { useSettings } from '@/theme'; import { useSettings } from '@/theme';
import { setLastVisitedTab } from '@/utils/tabHistory'; import { setLastVisitedTab } from '@/utils/tabHistory';
@@ -12,7 +12,7 @@ export default function TabLayout() {
return ( return (
<Tabs <Tabs
screenOptions={{ screenOptions={{
tabBarActiveTintColor: theme.accent, tabBarActiveTintColor: theme.accent,
tabBarInactiveTintColor: theme.textSecondary, tabBarInactiveTintColor: theme.textSecondary,
tabBarStyle: { tabBarStyle: {
@@ -40,7 +40,7 @@ screenOptions={{
headerShown: false, headerShown: false,
sceneStyle: { backgroundColor: theme.background }, sceneStyle: { backgroundColor: theme.background },
}} }}
tabBar={(props) => { tabBar={props => {
return ( return (
<View <View
style={[ style={[
@@ -56,8 +56,10 @@ screenOptions={{
}, },
]} ]}
> >
{props.navigation.getState().routes.map((route) => { {props.navigation.getState().routes.map(route => {
const focused = props.navigation.getState().index === props.navigation.getState().routes.indexOf(route); const focused =
props.navigation.getState().index ===
props.navigation.getState().routes.indexOf(route);
return ( return (
<TouchableOpacity <TouchableOpacity
key={route.name} key={route.name}
@@ -67,17 +69,24 @@ screenOptions={{
]} ]}
onPress={() => props.navigation.navigate(route.name as never)} onPress={() => props.navigation.navigate(route.name as never)}
accessibilityRole="button" 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 }} accessibilityState={{ selected: focused }}
> >
<Animated.View <Animated.View style={[styles.rippleContainer, { opacity: focused ? 1 : 0.5 }]}>
style={[
styles.rippleContainer,
{ opacity: focused ? 1 : 0.5 },
]}
>
<TabBarIcon <TabBarIcon
name={route.name === 'index' ? 'checklist' : route.name === 'calendar' ? 'calendar' : route.name === 'stats' ? 'stats' : 'gear'} name={
route.name === 'index'
? 'checklist'
: route.name === 'calendar'
? 'calendar'
: route.name === 'stats'
? 'stats'
: 'gear'
}
focused={focused} focused={focused}
color={focused ? theme.accent : theme.textSecondary} color={focused ? theme.accent : theme.textSecondary}
size={24} size={24}
+158 -40
View File
@@ -1,5 +1,15 @@
import React, { useMemo, useRef, useState, useCallback, useEffect } from 'react'; import React, { useMemo, useRef, useState, useCallback, useEffect } from 'react';
import { View, Text, StyleSheet, SafeAreaView, ScrollView, TouchableOpacity, Animated, Dimensions, KeyboardAvoidingView } from 'react-native'; import {
View,
Text,
StyleSheet,
SafeAreaView,
ScrollView,
TouchableOpacity,
Animated,
Dimensions,
KeyboardAvoidingView,
} from 'react-native';
import { useRouter, useFocusEffect } from 'expo-router'; import { useRouter, useFocusEffect } from 'expo-router';
import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import { Header } from '@/components/Header'; import { Header } from '@/components/Header';
@@ -15,7 +25,15 @@ import { OptionPickerModal } from '@/components/OptionPickerModal';
import { TaskItem } from '@/components/TaskItem'; import { TaskItem } from '@/components/TaskItem';
import { SubtaskItem } from '@/components/SubtaskItem'; import { SubtaskItem } from '@/components/SubtaskItem';
import Task from '@/models/Task'; import Task from '@/models/Task';
import { format, addMonths, addDays, startOfMonth, isSameDay, isSameMonth, isToday } from 'date-fns'; import {
format,
addMonths,
addDays,
startOfMonth,
isSameDay,
isSameMonth,
isToday,
} from 'date-fns';
import Svg, { Path } from 'react-native-svg'; import Svg, { Path } from 'react-native-svg';
import type { ThemeColors } from '@/theme'; import type { ThemeColors } from '@/theme';
import type { TaskData, SubtaskData } from '@/types'; 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 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 PRIORITY_RANK: Record<string, number> = { none: 0, low: 1, medium: 2, high: 3, critical: 4 };
const MONTH_NAMES = [ const MONTH_NAMES = [
'January', 'February', 'March', 'April', 'May', 'June', 'January',
'July', 'August', 'September', 'October', 'November', 'December', 'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
]; ];
export default function CalendarScreen() { export default function CalendarScreen() {
@@ -70,7 +98,9 @@ export default function CalendarScreen() {
const sorted = [...selectedDayTasks]; const sorted = [...selectedDayTasks];
switch (sortBy) { switch (sortBy) {
case 'date': 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; break;
case 'priority': case 'priority':
sorted.sort((a, b) => PRIORITY_RANK[b.priority] - PRIORITY_RANK[a.priority]); sorted.sort((a, b) => PRIORITY_RANK[b.priority] - PRIORITY_RANK[a.priority]);
@@ -118,10 +148,18 @@ export default function CalendarScreen() {
animatingRef.current = true; animatingRef.current = true;
const w = gridWidthRef.current || WIDTH; const w = gridWidthRef.current || WIDTH;
const target = dir === 1 ? -w : w; const target = dir === 1 ? -w : w;
Animated.timing(translateX.current, { toValue: target, duration: 220, useNativeDriver: false }).start(() => { Animated.timing(translateX.current, {
toValue: target,
duration: 220,
useNativeDriver: false,
}).start(() => {
setVisibleMonth(next); setVisibleMonth(next);
translateX.current.setValue(-target); 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; animatingRef.current = false;
}); });
}); });
@@ -148,11 +186,11 @@ export default function CalendarScreen() {
.minDistance(6) .minDistance(6)
.runOnJS(true) .runOnJS(true)
// eslint-disable-next-line react-hooks/refs // eslint-disable-next-line react-hooks/refs
.onUpdate((e) => { .onUpdate(e => {
if (!animatingRef.current) translateX.current.setValue(e.translationX); if (!animatingRef.current) translateX.current.setValue(e.translationX);
}) })
// eslint-disable-next-line react-hooks/refs // eslint-disable-next-line react-hooks/refs
.onEnd((e) => { .onEnd(e => {
if (animatingRef.current) return; if (animatingRef.current) return;
const w = gridWidthRef.current || WIDTH; const w = gridWidthRef.current || WIDTH;
const dx = e.translationX; const dx = e.translationX;
@@ -183,22 +221,26 @@ export default function CalendarScreen() {
const monthOptions = MONTH_NAMES.map((label, i) => ({ value: String(i), label })); const monthOptions = MONTH_NAMES.map((label, i) => ({ value: String(i), label }));
const yearOptions = useMemo(() => { const yearOptions = useMemo(() => {
const options: { value: string; label: string }[] = []; 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; return options;
}, [currentYear]); }, [currentYear]);
const handleToggleComplete = useCallback(async (taskId: string) => { const handleToggleComplete = useCallback(
async (taskId: string) => {
await confirmAndToggleTaskComplete(taskId, () => { await confirmAndToggleTaskComplete(taskId, () => {
refreshDayTasks(); refreshDayTasks();
refreshMonthTasks(); refreshMonthTasks();
refreshSubtasks(); refreshSubtasks();
}); });
}, [refreshDayTasks, refreshMonthTasks, refreshSubtasks]); },
[refreshDayTasks, refreshMonthTasks, refreshSubtasks]
);
const [expandedTasks, setExpandedTasks] = useState<Set<string>>(new Set()); const [expandedTasks, setExpandedTasks] = useState<Set<string>>(new Set());
const toggleExpand = useCallback((taskId: string) => { const toggleExpand = useCallback((taskId: string) => {
setExpandedTasks((prev) => { setExpandedTasks(prev => {
const next = new Set(prev); const next = new Set(prev);
if (next.has(taskId)) { if (next.has(taskId)) {
next.delete(taskId); next.delete(taskId);
@@ -209,12 +251,15 @@ export default function CalendarScreen() {
}); });
}, []); }, []);
const handleSubtaskToggle = useCallback(async (subtaskId: string) => { const handleSubtaskToggle = useCallback(
async (subtaskId: string) => {
await toggleSubtaskComplete(subtaskId); await toggleSubtaskComplete(subtaskId);
refreshDayTasks(); refreshDayTasks();
refreshMonthTasks(); refreshMonthTasks();
refreshSubtasks(); refreshSubtasks();
}, [refreshDayTasks, refreshMonthTasks, refreshSubtasks]); },
[refreshDayTasks, refreshMonthTasks, refreshSubtasks]
);
return ( return (
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}> <SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
@@ -229,17 +274,28 @@ export default function CalendarScreen() {
accessibilityLabel="Back" accessibilityLabel="Back"
> >
<Svg width={20} height={20} viewBox="0 0 24 24"> <Svg width={20} height={20} viewBox="0 0 24 24">
<Path d="M15 18l-6-6 6-6" stroke="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> </Svg>
</TouchableOpacity> </TouchableOpacity>
} }
/> />
<KeyboardAvoidingView style={styles.kbAvoid} behavior="padding"> <KeyboardAvoidingView style={styles.kbAvoid} behavior="padding">
<ScrollView style={styles.scrollBody} showsVerticalScrollIndicator={false} contentContainerStyle={styles.scrollContent}> <ScrollView
style={styles.scrollBody}
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.scrollContent}
>
<GestureDetector gesture={pan}> <GestureDetector gesture={pan}>
<Animated.View <Animated.View
onLayout={(e) => { onLayout={e => {
gridWidthRef.current = e.nativeEvent.layout.width; gridWidthRef.current = e.nativeEvent.layout.width;
}} }}
// eslint-disable-next-line react-hooks/refs // eslint-disable-next-line react-hooks/refs
@@ -248,34 +304,73 @@ export default function CalendarScreen() {
<View style={styles.monthRow}> <View style={styles.monthRow}>
<TouchableOpacity <TouchableOpacity
onPress={() => transitionTo(-1)} onPress={() => transitionTo(-1)}
style={[styles.monthNav, { borderColor: theme.borderStrong, backgroundColor: theme.card }]} style={[
styles.monthNav,
{ borderColor: theme.borderStrong, backgroundColor: theme.card },
]}
activeOpacity={0.7} activeOpacity={0.7}
> >
<Svg width={20} height={20} viewBox="0 0 24 24"> <Svg width={20} height={20} viewBox="0 0 24 24">
<Path d="M15 18l-6-6 6-6" stroke={theme.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> </Svg>
</TouchableOpacity> </TouchableOpacity>
<View style={styles.monthSelectorGroup}> <View style={styles.monthSelectorGroup}>
<TouchableOpacity onPress={() => setMonthPickerVisible(true)} activeOpacity={0.7} style={styles.monthButton}> <TouchableOpacity
<Text style={[styles.monthLabel, { color: theme.text }]}>{format(visibleMonth, 'MMMM')}</Text> 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"> <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> </Svg>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
<TouchableOpacity onPress={() => setYearPickerVisible(true)} activeOpacity={0.7} style={styles.yearButton}> <TouchableOpacity
<Text style={[styles.yearLabel, { color: theme.textMuted }]}>{format(visibleMonth, 'yyyy')}</Text> onPress={() => setYearPickerVisible(true)}
activeOpacity={0.7}
style={styles.yearButton}
>
<Text style={[styles.yearLabel, { color: theme.textMuted }]}>
{format(visibleMonth, 'yyyy')}
</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
onPress={() => transitionTo(1)} onPress={() => transitionTo(1)}
style={[styles.monthNav, { borderColor: theme.borderStrong, backgroundColor: theme.card }]} style={[
styles.monthNav,
{ borderColor: theme.borderStrong, backgroundColor: theme.card },
]}
activeOpacity={0.7} activeOpacity={0.7}
> >
<Svg width={20} height={20} viewBox="0 0 24 24"> <Svg width={20} height={20} viewBox="0 0 24 24">
<Path d="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> </Svg>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@@ -290,7 +385,7 @@ export default function CalendarScreen() {
{weeks.map((week, wi) => ( {weeks.map((week, wi) => (
<View key={wi} style={styles.weekRow}> <View key={wi} style={styles.weekRow}>
{week.map((day) => ( {week.map(day => (
<DayCell <DayCell
key={day.toISOString()} key={day.toISOString()}
day={day} day={day}
@@ -309,7 +404,9 @@ export default function CalendarScreen() {
</GestureDetector> </GestureDetector>
<View style={styles.panelHeader}> <View style={styles.panelHeader}>
<Text style={[styles.panelDate, { color: theme.text }]}>{format(selectedDate, 'EEEE, MMMM d')}</Text> <Text style={[styles.panelDate, { color: theme.text }]}>
{format(selectedDate, 'EEEE, MMMM d')}
</Text>
{sortedDayTasks.length > 0 && ( {sortedDayTasks.length > 0 && (
<Text style={[styles.panelCount, { color: theme.textFaint }]}> <Text style={[styles.panelCount, { color: theme.textFaint }]}>
{sortedDayTasks.length} event{sortedDayTasks.length === 1 ? '' : 's'} {sortedDayTasks.length} event{sortedDayTasks.length === 1 ? '' : 's'}
@@ -319,11 +416,15 @@ export default function CalendarScreen() {
{sortedDayTasks.length === 0 ? ( {sortedDayTasks.length === 0 ? (
<View style={styles.emptyState}> <View style={styles.emptyState}>
<Text style={[styles.emptyText, { color: theme.textSecondary }]}>No events on this day</Text> <Text style={[styles.emptyText, { color: theme.textSecondary }]}>
<Text style={[styles.emptySubtext, { color: theme.textMuted }]}>Tap a date or use the bar below</Text> No events on this day
</Text>
<Text style={[styles.emptySubtext, { color: theme.textMuted }]}>
Tap a date or use the bar below
</Text>
</View> </View>
) : ( ) : (
sortedDayTasks.map((task) => { sortedDayTasks.map(task => {
const isExpanded = expandedTasks.has(task.id); const isExpanded = expandedTasks.has(task.id);
const itemSubtasks = subtasksByTask.get(task.id) ?? []; const itemSubtasks = subtasksByTask.get(task.id) ?? [];
return ( return (
@@ -334,12 +435,12 @@ export default function CalendarScreen() {
subtasks={itemSubtasks} subtasks={itemSubtasks}
categoryColor={categoryColors.get(task.categoryId)} categoryColor={categoryColors.get(task.categoryId)}
onToggle={() => handleToggleComplete(task.id)} onToggle={() => handleToggleComplete(task.id)}
onPress={(id) => onPress={id =>
expandedTasks.has(id) expandedTasks.has(id)
? router.push({ pathname: '/task-detail', params: { id } }) ? router.push({ pathname: '/task-detail', params: { id } })
: toggleExpand(id) : toggleExpand(id)
} }
onMenuOpen={(id) => openTaskEdit(id)} onMenuOpen={id => openTaskEdit(id)}
onSubtaskToggle={handleSubtaskToggle} onSubtaskToggle={handleSubtaskToggle}
/> />
</View> </View>
@@ -419,7 +520,7 @@ const CalendarTaskRow = React.memo(function CalendarTaskRow({
/> />
{expanded && sortedSubtasks.length > 0 && ( {expanded && sortedSubtasks.length > 0 && (
<View style={styles.subtaskList}> <View style={styles.subtaskList}>
{sortedSubtasks.map((sub) => ( {sortedSubtasks.map(sub => (
<SubtaskItem <SubtaskItem
key={sub.id} key={sub.id}
subtask={sub} subtask={sub}
@@ -440,7 +541,11 @@ function cellTitle(tasks: Task[]): string | null {
return `${tasks[0].title} +${tasks.length - 1}`; 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; if (tasks.length === 0) return theme.textMuted;
return theme.accent; return theme.accent;
} }
@@ -456,11 +561,23 @@ interface DayCellProps {
onPress: (day: Date) => void; 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]); const styles = React.useMemo(() => createStyles(theme), [theme]);
return ( return (
<TouchableOpacity <TouchableOpacity
style={[styles.dayCell, selected && { backgroundColor: theme.accentSoft, borderColor: theme.accentBorder }]} style={[
styles.dayCell,
selected && { backgroundColor: theme.accentSoft, borderColor: theme.accentBorder },
]}
onPress={() => onPress(day)} onPress={() => onPress(day)}
activeOpacity={0.7} activeOpacity={0.7}
> >
@@ -488,7 +605,8 @@ const DayCell = React.memo(function DayCell({ day, label, dotColor, selected, to
); );
}); });
const createStyles = (theme: ThemeColors) => StyleSheet.create({ const createStyles = (theme: ThemeColors) =>
StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
}, },
@@ -636,4 +754,4 @@ const createStyles = (theme: ThemeColors) => StyleSheet.create({
paddingRight: 4, paddingRight: 4,
paddingTop: 4, paddingTop: 4,
}, },
}); });
+28 -8
View File
@@ -1,5 +1,13 @@
import React, { useCallback, useMemo, useState } from 'react'; 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 { useFocusEffect } from 'expo-router';
import { Header } from '@/components/Header'; import { Header } from '@/components/Header';
import { CategoryFilter } from '@/components/CategoryFilter'; import { CategoryFilter } from '@/components/CategoryFilter';
@@ -39,7 +47,10 @@ export default function TasksScreen() {
<TouchableOpacity <TouchableOpacity
style={[ style={[
styles.completedToggle, styles.completedToggle,
{ backgroundColor: theme.card, borderColor: showCompleted ? theme.accent : theme.borderStrong }, {
backgroundColor: theme.card,
borderColor: showCompleted ? theme.accent : theme.borderStrong,
},
]} ]}
onPress={() => setShowCompleted(!showCompleted)} onPress={() => setShowCompleted(!showCompleted)}
activeOpacity={0.8} activeOpacity={0.8}
@@ -57,18 +68,27 @@ export default function TasksScreen() {
fill="none" fill="none"
/> />
{showCompleted && ( {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> </Svg>
<Text style={[styles.completedToggleText, { color: showCompleted ? theme.text : theme.textMuted }]}> <Text
style={[
styles.completedToggleText,
{ color: showCompleted ? theme.text : theme.textMuted },
]}
>
Completed Completed
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
<KeyboardAvoidingView <KeyboardAvoidingView style={styles.kbAvoid} behavior="padding">
style={styles.kbAvoid}
behavior="padding"
>
<TaskList categoryIds={categoryIds} showCompleted={showCompleted} /> <TaskList categoryIds={categoryIds} showCompleted={showCompleted} />
<QuickAddBar /> <QuickAddBar />
</KeyboardAvoidingView> </KeyboardAvoidingView>
+214 -60
View File
@@ -1,5 +1,18 @@
import React, { useState, useCallback } from 'react'; 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 { useFocusEffect, useRouter } from 'expo-router';
import { Header } from '@/components/Header'; import { Header } from '@/components/Header';
import { ListItem } from '@/components/ListItem'; import { ListItem } from '@/components/ListItem';
@@ -10,7 +23,15 @@ import { FriendsModal } from '@/components/FriendsModal';
import { LegalModal } from '@/components/LegalModal'; import { LegalModal } from '@/components/LegalModal';
import { ServerUrlModal } from '@/components/ServerUrlModal'; import { ServerUrlModal } from '@/components/ServerUrlModal';
import SyncStatus from '@/components/SyncStatus'; 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 { useCategories } from '@/hooks/useDatabase';
import { getAuthUser, getAuthToken } from '@/services/auth'; import { getAuthUser, getAuthToken } from '@/services/auth';
import { checkForUpdates, getCurrentAppVersion } from '@/services/updates'; import { checkForUpdates, getCurrentAppVersion } from '@/services/updates';
@@ -24,9 +45,38 @@ import { getLastVisitedTab, tabHref } from '@/utils/tabHistory';
export default function SettingsScreen() { export default function SettingsScreen() {
const router = useRouter(); const router = useRouter();
const { theme, notifications, setNotifications, defaultCategoryId, setDefaultCategoryId, sortBy, setSortBy, reminderPreference, setReminderPreference, 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 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 [reminderTagPicker, setReminderTagPicker] = useState<Category | null>(null);
const [editingCategory, setEditingCategory] = useState<Category | null | 'new'>(null); const [editingCategory, setEditingCategory] = useState<Category | null | 'new'>(null);
const [syncVisible, setSyncVisible] = useState(false); const [syncVisible, setSyncVisible] = useState(false);
@@ -84,7 +134,9 @@ export default function SettingsScreen() {
const lastSync = await getLastSyncTime(); const lastSync = await getLastSyncTime();
if (user) { if (user) {
setSyncSubtitle( setSyncSubtitle(
lastSync ? `Signed in · synced ${formatSyncTime(lastSync)}` : `Signed in as ${user.username}` lastSync
? `Signed in · synced ${formatSyncTime(lastSync)}`
: `Signed in as ${user.username}`
); );
} else { } else {
setSyncSubtitle('Not signed in'); setSyncSubtitle('Not signed in');
@@ -111,16 +163,15 @@ export default function SettingsScreen() {
}, [refreshSyncStatus, router]) }, [refreshSyncStatus, router])
); );
const defaultCategory = categories.find((c) => c.id === defaultCategoryId); const defaultCategory = categories.find(c => c.id === defaultCategoryId);
const defaultCategoryLabel = defaultCategory?.name ?? 'None'; const defaultCategoryLabel = defaultCategory?.name ?? 'None';
const reminderLabel = REMINDER_OPTIONS.find((o) => o.value === reminderPreference)?.label ?? 'No reminder'; const reminderLabel =
REMINDER_OPTIONS.find(o => o.value === reminderPreference)?.label ?? 'No reminder';
const tagReminderLabel = (categoryId: string) => { const tagReminderLabel = (categoryId: string) => {
const selected = tagReminderPreferences[categoryId] ?? []; const selected = tagReminderPreferences[categoryId] ?? [];
if (selected.length === 0) return `Default (${reminderLabel})`; if (selected.length === 0) return `Default (${reminderLabel})`;
return selected return selected.map(v => REMINDER_OPTIONS.find(o => o.value === v)?.label ?? v).join(', ');
.map((v) => REMINDER_OPTIONS.find((o) => o.value === v)?.label ?? v)
.join(', ');
}; };
const openCustomAccent = () => { const openCustomAccent = () => {
@@ -162,14 +213,12 @@ export default function SettingsScreen() {
<Header title="Settings" showLogo={false} /> <Header title="Settings" showLogo={false} />
<ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}> <ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
<Text style={[styles.sectionTitle, { color: theme.textMuted }]}>Tags</Text> <Text style={[styles.sectionTitle, { color: theme.textMuted }]}>Tags</Text>
{categories.map((category) => ( {categories.map(category => (
<ListItem <ListItem
key={category.id} key={category.id}
title={category.name} title={category.name}
subtitle={category.id === defaultCategoryId ? 'Default tag' : undefined} subtitle={category.id === defaultCategoryId ? 'Default tag' : undefined}
leftElement={ leftElement={<View style={[styles.categoryDot, { backgroundColor: category.color }]} />}
<View style={[styles.categoryDot, { backgroundColor: category.color }]} />
}
onPress={() => setEditingCategory(category)} onPress={() => setEditingCategory(category)}
showChevron showChevron
/> />
@@ -181,7 +230,12 @@ export default function SettingsScreen() {
> >
<View style={[styles.addIcon, { backgroundColor: theme.accentSoft }]}> <View style={[styles.addIcon, { backgroundColor: theme.accentSoft }]}>
<Svg width={16} height={16} viewBox="0 0 24 24"> <Svg width={16} height={16} viewBox="0 0 24 24">
<Path d="M12 5v14M5 12h14" stroke={theme.accentText} strokeWidth={2} strokeLinecap="round" /> <Path
d="M12 5v14M5 12h14"
stroke={theme.accentText}
strokeWidth={2}
strokeLinecap="round"
/>
</Svg> </Svg>
</View> </View>
<Text style={[styles.addCategoryText, { color: theme.accent }]}>Add Tag</Text> <Text style={[styles.addCategoryText, { color: theme.accent }]}>Add Tag</Text>
@@ -193,7 +247,18 @@ export default function SettingsScreen() {
rightElement={ rightElement={
<Switch <Switch
value={notifications} 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" thumbColor="#FFFFFF"
trackColor={{ false: theme.borderStrong, true: theme.accent }} trackColor={{ false: theme.borderStrong, true: theme.accent }}
/> />
@@ -205,14 +270,12 @@ export default function SettingsScreen() {
onPress={() => notifications && setPicker('reminder')} onPress={() => notifications && setPicker('reminder')}
showChevron showChevron
/> />
{categories.map((category) => ( {categories.map(category => (
<ListItem <ListItem
key={`tag-reminder-${category.id}`} key={`tag-reminder-${category.id}`}
title={category.name} title={category.name}
subtitle={notifications ? tagReminderLabel(category.id) : 'Enable notifications first'} subtitle={notifications ? tagReminderLabel(category.id) : 'Enable notifications first'}
leftElement={ leftElement={<View style={[styles.categoryDot, { backgroundColor: category.color }]} />}
<View style={[styles.categoryDot, { backgroundColor: category.color }]} />
}
onPress={() => notifications && setReminderTagPicker(category)} onPress={() => notifications && setReminderTagPicker(category)}
showChevron showChevron
/> />
@@ -226,7 +289,7 @@ export default function SettingsScreen() {
/> />
<ListItem <ListItem
title="Sort Tasks By" 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')} onPress={() => setPicker('sort')}
showChevron showChevron
/> />
@@ -264,7 +327,7 @@ export default function SettingsScreen() {
<Text style={[styles.sectionTitle, { color: theme.textMuted }]}>Appearance</Text> <Text style={[styles.sectionTitle, { color: theme.textMuted }]}>Appearance</Text>
<Text style={[styles.sectionHint, { color: theme.textFaint }]}>Accent color</Text> <Text style={[styles.sectionHint, { color: theme.textFaint }]}>Accent color</Text>
<View style={styles.accentPresets}> <View style={styles.accentPresets}>
{ACCENT_PRESETS.map((c) => ( {ACCENT_PRESETS.map(c => (
<TouchableOpacity <TouchableOpacity
key={c} key={c}
style={[ style={[
@@ -277,7 +340,14 @@ export default function SettingsScreen() {
> >
{accentColor.toUpperCase() === c && ( {accentColor.toUpperCase() === c && (
<Svg width={16} height={16} viewBox="0 0 24 24"> <Svg width={16} height={16} viewBox="0 0 24 24">
<Path d="M5 12l5 5 9-10" stroke={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> </Svg>
)} )}
</TouchableOpacity> </TouchableOpacity>
@@ -290,6 +360,15 @@ export default function SettingsScreen() {
onPress={openCustomAccent} onPress={openCustomAccent}
showChevron 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> <Text style={[styles.sectionTitle, { color: theme.textMuted }]}>Data</Text>
<SyncStatus /> <SyncStatus />
<ListItem <ListItem
@@ -298,6 +377,32 @@ export default function SettingsScreen() {
onPress={() => setSyncVisible(true)} onPress={() => setSyncVisible(true)}
showChevron 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 <ListItem
title="Friends" title="Friends"
subtitle="Manage friends and requests" subtitle="Manage friends and requests"
@@ -318,30 +423,19 @@ export default function SettingsScreen() {
onPress={handleCheckUpdates} onPress={handleCheckUpdates}
showChevron showChevron
/> />
<ListItem <ListItem title="Last checked" subtitle={lastUpdateCheck ?? 'Never'} />
title="Last checked" <ListItem title="Version" subtitle={getCurrentAppVersion()} />
subtitle={lastUpdateCheck ?? 'Never'} <ListItem title="Privacy Policy" onPress={() => setLegalVisible('privacy')} showChevron />
/> <ListItem title="Terms of Service" onPress={() => setLegalVisible('terms')} showChevron />
<ListItem
title="Version"
subtitle={getCurrentAppVersion()}
/>
<ListItem
title="Privacy Policy"
onPress={() => setLegalVisible('privacy')}
showChevron
/>
<ListItem
title="Terms of Service"
onPress={() => setLegalVisible('terms')}
showChevron
/>
</ScrollView> </ScrollView>
<OptionPickerModal <OptionPickerModal
visible={picker === 'category'} visible={picker === 'category'}
title="Default Tag" 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} selectedValue={defaultCategoryId}
onSelect={handleDefaultCategory} onSelect={handleDefaultCategory}
onClose={() => setPicker(null)} onClose={() => setPicker(null)}
@@ -352,7 +446,7 @@ export default function SettingsScreen() {
title="Sort Tasks By" title="Sort Tasks By"
options={SORT_OPTIONS} options={SORT_OPTIONS}
selectedValue={sortBy} selectedValue={sortBy}
onSelect={(value) => setSortBy(value as typeof sortBy)} onSelect={value => setSortBy(value as typeof sortBy)}
onClose={() => setPicker(null)} onClose={() => setPicker(null)}
/> />
@@ -365,15 +459,15 @@ export default function SettingsScreen() {
onClose={() => setPicker(null)} onClose={() => setPicker(null)}
/> />
{categories.map((category) => ( {categories.map(category => (
<OptionPickerModal <OptionPickerModal
key={`tag-reminder-picker-${category.id}`} key={`tag-reminder-picker-${category.id}`}
visible={reminderTagPicker?.id === category.id} visible={reminderTagPicker?.id === category.id}
title={`Reminders · ${category.name}`} title={`Reminders · ${category.name}`}
options={REMINDER_OPTIONS.filter((o) => o.value !== 'none')} options={REMINDER_OPTIONS.filter(o => o.value !== 'none')}
selectedValue={tagReminderPreferences[category.id] ?? []} selectedValue={tagReminderPreferences[category.id] ?? []}
multiSelect multiSelect
onSelect={(value) => handleTagReminderPreference(category.id, value)} onSelect={value => handleTagReminderPreference(category.id, value)}
onClose={() => setReminderTagPicker(null)} onClose={() => setReminderTagPicker(null)}
/> />
))} ))}
@@ -387,6 +481,15 @@ export default function SettingsScreen() {
onClose={() => setPicker(null)} 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 <CategoryEditorModal
visible={editorVisible} visible={editorVisible}
category={editorCategory} category={editorCategory}
@@ -394,41 +497,86 @@ export default function SettingsScreen() {
onClose={() => setEditingCategory(null)} onClose={() => setEditingCategory(null)}
/> />
<SyncModal visible={syncVisible} onClose={() => { setSyncVisible(false); refreshSyncStatus(); }} /> <SyncModal
visible={syncVisible}
onClose={() => {
setSyncVisible(false);
refreshSyncStatus();
}}
/>
<FriendsModal visible={friendsVisible} onClose={() => setFriendsVisible(false)} /> <FriendsModal visible={friendsVisible} onClose={() => setFriendsVisible(false)} />
<ServerUrlModal visible={serverUrlVisible} onClose={() => setServerUrlVisible(false)} /> <ServerUrlModal visible={serverUrlVisible} onClose={() => setServerUrlVisible(false)} />
<Modal visible={customAccentVisible} transparent animationType="fade" onRequestClose={() => setCustomAccentVisible(false)}> <Modal
visible={customAccentVisible}
transparent
animationType="fade"
onRequestClose={() => setCustomAccentVisible(false)}
>
<Pressable style={styles.accentModalOverlay} onPress={() => setCustomAccentVisible(false)}>
<Pressable <Pressable
style={styles.accentModalOverlay} style={[styles.accentModalSheet, { backgroundColor: theme.sheetBg }]}
onPress={() => setCustomAccentVisible(false)} onPress={() => {}}
> >
<Pressable style={[styles.accentModalSheet, { backgroundColor: theme.sheetBg }]} onPress={() => {}}>
<View style={styles.accentModalHeader}> <View style={styles.accentModalHeader}>
<Text style={[styles.accentModalTitle, { color: theme.text }]}>Custom Accent Color</Text> <Text style={[styles.accentModalTitle, { color: theme.text }]}>
<TouchableOpacity onPress={() => setCustomAccentVisible(false)} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}> 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"> <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> </Svg>
</TouchableOpacity> </TouchableOpacity>
</View> </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}> <View style={styles.accentModalActions}>
<TouchableOpacity <TouchableOpacity
style={[styles.accentModalButton, { borderColor: theme.borderStrong }]} 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} activeOpacity={0.7}
> >
<Text style={[styles.accentModalButtonText, { color: theme.textSecondary }]}>Reset</Text> <Text style={[styles.accentModalButtonText, { color: theme.textSecondary }]}>
Reset
</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
style={[styles.accentModalButton, styles.accentModalButtonPrimary, { backgroundColor: theme.accent }]} style={[
onPress={() => { setAccentColor(draftAccent); setAccentOpacity(draftAccentOpacity); setCustomAccentVisible(false); }} styles.accentModalButton,
styles.accentModalButtonPrimary,
{ backgroundColor: theme.accent },
]}
onPress={() => {
setAccentColor(draftAccent);
setAccentOpacity(draftAccentOpacity);
setCustomAccentVisible(false);
}}
activeOpacity={0.8} activeOpacity={0.8}
> >
<Text style={[styles.accentModalButtonText, { color: theme.accentText }]}>Apply</Text> <Text style={[styles.accentModalButtonText, { color: theme.accentText }]}>
Apply
</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</Pressable> </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'}`, 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 { function formatSyncTime(timestamp: number): string {
const seconds = Math.floor((Date.now() - timestamp) / 1000); const seconds = Math.floor((Date.now() - timestamp) / 1000);
if (seconds < 60) return 'just now'; if (seconds < 60) return 'just now';
+34 -12
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 ( return (
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}> <SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
@@ -25,27 +25,42 @@ export default function StatsScreen() {
<Text style={[styles.cardTitle, { color: theme.textMuted }]}>Overview</Text> <Text style={[styles.cardTitle, { color: theme.textMuted }]}>Overview</Text>
<View style={styles.summaryGrid}> <View style={styles.summaryGrid}>
<View style={styles.summaryItem}> <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> <Text style={[styles.summaryLabel, { color: theme.textMuted }]}>Completed</Text>
</View> </View>
<View style={styles.summaryItem}> <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> <Text style={[styles.summaryLabel, { color: theme.textMuted }]}>Last 7 days</Text>
</View> </View>
<View style={styles.summaryItem}> <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> <Text style={[styles.summaryLabel, { color: theme.textMuted }]}>Last 30 days</Text>
</View> </View>
<View style={styles.summaryItem}> <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> <Text style={[styles.summaryLabel, { color: theme.textMuted }]}>Success rate</Text>
</View> </View>
<View style={styles.summaryItem}> <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> <Text style={[styles.summaryLabel, { color: theme.textMuted }]}>Day streak</Text>
</View> </View>
<View style={styles.summaryItem}> <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} {stats.overdueCount}
</Text> </Text>
<Text style={[styles.summaryLabel, { color: theme.textMuted }]}>Overdue</Text> <Text style={[styles.summaryLabel, { color: theme.textMuted }]}>Overdue</Text>
@@ -64,7 +79,10 @@ export default function StatsScreen() {
<View <View
style={[ style={[
styles.chartBar, 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> </View>
@@ -78,9 +96,11 @@ export default function StatsScreen() {
<View style={[styles.card, { backgroundColor: theme.card }]}> <View style={[styles.card, { backgroundColor: theme.card }]}>
<Text style={[styles.cardTitle, { color: theme.textMuted }]}>By tags</Text> <Text style={[styles.cardTitle, { color: theme.textMuted }]}>By tags</Text>
{stats.byCategory.length === 0 ? ( {stats.byCategory.length === 0 ? (
<Text style={[styles.emptyText, { color: theme.textFaint }]}>No completed tasks yet</Text> <Text style={[styles.emptyText, { color: theme.textFaint }]}>
No completed tasks yet
</Text>
) : ( ) : (
stats.byCategory.map((c) => ( stats.byCategory.map(c => (
<View key={c.id} style={styles.row}> <View key={c.id} style={styles.row}>
<View style={[styles.dot, { backgroundColor: c.color }]} /> <View style={[styles.dot, { backgroundColor: c.color }]} />
<Text style={[styles.rowLabel, { color: theme.text }]}>{c.name}</Text> <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 }]}> <View style={[styles.card, { backgroundColor: theme.card }]}>
<Text style={[styles.cardTitle, { color: theme.textMuted }]}>By priority</Text> <Text style={[styles.cardTitle, { color: theme.textMuted }]}>By priority</Text>
{stats.byPriority.length === 0 ? ( {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 key={p.priority} style={styles.row}>
<View style={[styles.dot, { backgroundColor: priorityColor(theme, p.priority) }]} /> <View style={[styles.dot, { backgroundColor: priorityColor(theme, p.priority) }]} />
<Text style={[styles.rowLabel, { color: theme.text }]}>{p.label}</Text> <Text style={[styles.rowLabel, { color: theme.text }]}>{p.label}</Text>
+10 -3
View File
@@ -8,7 +8,12 @@ import { UpdateNotifier } from '@/components/UpdateNotifier';
import { Linking } from 'react-native'; import { Linking } from 'react-native';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { requestQuickAddFocus } from '@/utils/quickAddFocus'; import { requestQuickAddFocus } from '@/utils/quickAddFocus';
import { registerForPushNotificationsAsync, setupNotificationChannels, addNotificationResponseReceivedListener, removeNotificationSubscription } from '@/services/pushNotifications'; import {
registerForPushNotificationsAsync,
setupNotificationChannels,
addNotificationResponseReceivedListener,
removeNotificationSubscription,
} from '@/services/pushNotifications';
import * as Notifications from 'expo-notifications'; import * as Notifications from 'expo-notifications';
Notifications.setNotificationHandler({ Notifications.setNotificationHandler({
@@ -28,7 +33,7 @@ function RootNavigator() {
setupNotificationChannels(); setupNotificationChannels();
registerForPushNotificationsAsync().catch(() => {}); registerForPushNotificationsAsync().catch(() => {});
const responseListener = addNotificationResponseReceivedListener((response) => { const responseListener = addNotificationResponseReceivedListener(response => {
const data = response.notification.request.content.data as Record<string, any> | undefined; const data = response.notification.request.content.data as Record<string, any> | undefined;
if (!data) return; if (!data) return;
@@ -53,7 +58,9 @@ function RootNavigator() {
requestQuickAddFocus(); requestQuickAddFocus();
}; };
Linking.getInitialURL().then(handleUrl).catch(() => {}); Linking.getInitialURL()
.then(handleUrl)
.catch(() => {});
const subscription = Linking.addEventListener('url', ({ url }) => handleUrl(url)); const subscription = Linking.addEventListener('url', ({ url }) => handleUrl(url));
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
+18 -11
View File
@@ -1,5 +1,14 @@
import React, { useMemo, useRef } from 'react'; 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 { Header } from '@/components/Header';
import { CategorySelector } from '@/components/CategorySelector'; import { CategorySelector } from '@/components/CategorySelector';
import { TaskNameInput } from '@/components/TaskNameInput'; import { TaskNameInput } from '@/components/TaskNameInput';
@@ -112,7 +121,8 @@ export default function AddTaskScreen() {
const now = new Date(); const now = new Date();
const dueDateTimestamp = data.dueDate ? data.dueDate.getTime() : 0; const dueDateTimestamp = data.dueDate ? data.dueDate.getTime() : 0;
const seriesId = data.repeat !== 'none' const seriesId =
data.repeat !== 'none'
? `series_${Math.random().toString(36).slice(2, 10)}${Date.now().toString(36)}` ? `series_${Math.random().toString(36).slice(2, 10)}${Date.now().toString(36)}`
: ''; : '';
const resolvedCategoryId = (data.tags && data.tags[0]) || ''; const resolvedCategoryId = (data.tags && data.tags[0]) || '';
@@ -120,7 +130,7 @@ export default function AddTaskScreen() {
let createdTask: any = null; let createdTask: any = null;
await database.write(async () => { await database.write(async () => {
const task = await collections.tasks.create((t) => { const task = await collections.tasks.create(t => {
t.title = data.title.trim(); t.title = data.title.trim();
t.description = data.description || ''; t.description = data.description || '';
t.categoryId = resolvedCategoryId; t.categoryId = resolvedCategoryId;
@@ -146,7 +156,7 @@ export default function AddTaskScreen() {
for (let i = 0; i < data.subtasks.length; i++) { for (let i = 0; i < data.subtasks.length; i++) {
const subtask = data.subtasks[i]; const subtask = data.subtasks[i];
if (subtask.title.trim()) { if (subtask.title.trim()) {
await collections.subtasks.create((s) => { await collections.subtasks.create(s => {
s.taskId = task.id; s.taskId = task.id;
s.title = subtask.title.trim(); s.title = subtask.title.trim();
s.description = ''; s.description = '';
@@ -198,7 +208,7 @@ export default function AddTaskScreen() {
> >
<CategorySelector <CategorySelector
value={tags} value={tags}
onChange={(value) => setValue('tags', value)} onChange={value => setValue('tags', value)}
error={errors.categoryId?.message} error={errors.categoryId?.message}
/> />
<Controller <Controller
@@ -214,10 +224,7 @@ export default function AddTaskScreen() {
)} )}
/> />
<SubtasksSection control={control} /> <SubtasksSection control={control} />
<PrioritySelector <PrioritySelector value={priority} onChange={value => setValue('priority', value)} />
value={priority}
onChange={(value) => setValue('priority', value)}
/>
<DateTimePickerComponent control={control} /> <DateTimePickerComponent control={control} />
<RepeatSelector <RepeatSelector
value={repeat} value={repeat}
@@ -232,12 +239,12 @@ export default function AddTaskScreen() {
<ReminderSelector <ReminderSelector
value={reminders} value={reminders}
hasDueDate={!!dueDate} hasDueDate={!!dueDate}
onChange={(value) => setValue('reminders', value)} onChange={value => setValue('reminders', value)}
/> />
<AssigneeSelector <AssigneeSelector
value={assigneeId} value={assigneeId}
onChange={(value: string | null) => setValue('assigneeId', value)} onChange={(value: string | null) => setValue('assigneeId', value)}
friends={friends.map((f) => f.username)} friends={friends.map(f => f.username)}
/> />
<Controller <Controller
control={control} control={control}
+146 -35
View File
@@ -1,5 +1,14 @@
import React, { useCallback, useMemo } from 'react'; 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 { useRouter, useLocalSearchParams, useFocusEffect } from 'expo-router';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Header } from '@/components/Header'; import { Header } from '@/components/Header';
@@ -17,7 +26,11 @@ import { desaturate, type ThemeColors } from '@/theme';
function isDayMatch(timestamp: number, day: Date): boolean { function isDayMatch(timestamp: number, day: Date): boolean {
const d = new Date(timestamp); 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() { export default function DayViewScreen() {
@@ -53,45 +66,68 @@ export default function DayViewScreen() {
const subtasksOnDay = useMemo(() => { const subtasksOnDay = useMemo(() => {
const map = new Map<string, SubtaskData[]>(); const map = new Map<string, SubtaskData[]>();
for (const [taskId, roots] of subtasksByTask) { 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); if (due.length > 0) map.set(taskId, due);
} }
return map; return map;
}, [subtasksByTask, day]); }, [subtasksByTask, day]);
const handleToggleComplete = useCallback(async (taskId: string) => { const handleToggleComplete = useCallback(
async (taskId: string) => {
await confirmAndToggleTaskComplete(taskId, () => { await confirmAndToggleTaskComplete(taskId, () => {
refresh(); refresh();
refreshSubtasks(); refreshSubtasks();
}); });
}, [refresh, refreshSubtasks]); },
[refresh, refreshSubtasks]
);
const handleToggleSubtask = useCallback(async (subtaskId: string) => { const handleToggleSubtask = useCallback(
async (subtaskId: string) => {
await toggleSubtaskComplete(subtaskId); await toggleSubtaskComplete(subtaskId);
refreshSubtasks(); refreshSubtasks();
}, [refreshSubtasks]); },
[refreshSubtasks]
);
return ( 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" /> <Header title={format(day, 'EEEE, MMM d')} showLogo={false} rightAction="Back" />
<KeyboardAvoidingView style={styles.kbAvoid} behavior="padding"> <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 ? ( {loading ? (
<View style={styles.emptyState}> <View style={styles.emptyState}>
<Text style={[styles.emptyText, { color: theme.textSecondary }]}>Loading...</Text> <Text style={[styles.emptyText, { color: theme.textSecondary }]}>Loading...</Text>
</View> </View>
) : dayTasks.length === 0 && subtasksOnDay.size === 0 ? ( ) : dayTasks.length === 0 && subtasksOnDay.size === 0 ? (
<View style={styles.emptyState}> <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> </View>
) : ( ) : (
<> <>
{dayTasks.map((task) => { {dayTasks.map(task => {
const subs = subtasksOnDay.get(task.id) ?? []; const subs = subtasksOnDay.get(task.id) ?? [];
const openCount = subs.filter((s) => !s.completed).length; const openCount = subs.filter(s => !s.completed).length;
const cat = categories.find((c) => c.id === task.categoryId); const cat = categories.find(c => c.id === task.categoryId);
return ( 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}> <View style={styles.eventRow}>
<TouchableOpacity <TouchableOpacity
style={styles.checkCircle} style={styles.checkCircle}
@@ -104,41 +140,76 @@ export default function DayViewScreen() {
{task.completed ? ( {task.completed ? (
<> <>
<Circle cx={12} cy={12} r={10} fill={theme.accent} /> <Circle cx={12} cy={12} r={10} fill={theme.accent} />
<Path d="M7 12l3 3 7-7" stroke="#FFFFFF" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" /> <Path
d="M7 12l3 3 7-7"
stroke="#FFFFFF"
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</> </>
) : ( ) : (
<Circle cx={12} cy={12} r={10} stroke={theme.textSecondary} strokeWidth={2.5} fill="none" /> <Circle
cx={12}
cy={12}
r={10}
stroke={theme.textSecondary}
strokeWidth={2.5}
fill="none"
/>
)} )}
</Svg> </Svg>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
style={styles.eventTouch} 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} activeOpacity={0.7}
> >
<Text <Text
style={[styles.eventTitle, { color: theme.text }, task.completed && styles.eventCompleted]} style={[
styles.eventTitle,
{ color: theme.text },
task.completed && styles.eventCompleted,
]}
numberOfLines={1} numberOfLines={1}
> >
{task.title} {task.title}
</Text> </Text>
{openCount > 0 && ( {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'} {openCount} open subtask{openCount === 1 ? '' : 's'}
</Text> </Text>
)} )}
{!task.completed && ( {!task.completed && (
<View style={styles.metaRow}> <View style={styles.metaRow}>
{cat && ( {cat && (
<View style={[styles.tagChip, { backgroundColor: desaturate(cat.color, 0.3) }]}> <View
<Text style={styles.tagText} numberOfLines={1}>{cat.name}</Text> style={[
styles.tagChip,
{ backgroundColor: desaturate(cat.color, 0.3) },
]}
>
<Text style={styles.tagText} numberOfLines={1}>
{cat.name}
</Text>
</View> </View>
)} )}
{task.dueTime ? ( {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 ? ( ) : 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} ) : null}
</View> </View>
)} )}
@@ -152,7 +223,14 @@ export default function DayViewScreen() {
accessibilityLabel={`Edit ${task.title}`} accessibilityLabel={`Edit ${task.title}`}
> >
<Svg width={20} height={20} viewBox="0 0 24 24"> <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> </Svg>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@@ -160,10 +238,16 @@ export default function DayViewScreen() {
); );
})} })}
{Array.from(subtasksOnDay.entries()).flatMap(([taskId, subs]) => { {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 []; if (parent) return [];
return subs.map((sub) => ( return subs.map(sub => (
<View key={sub.id} style={[styles.eventCard, { backgroundColor: theme.card, borderColor: theme.border }]}> <View
key={sub.id}
style={[
styles.eventCard,
{ backgroundColor: theme.card, borderColor: theme.border },
]}
>
<View style={styles.eventRow}> <View style={styles.eventRow}>
<TouchableOpacity <TouchableOpacity
style={styles.checkCircle} style={styles.checkCircle}
@@ -176,26 +260,49 @@ export default function DayViewScreen() {
{sub.completed ? ( {sub.completed ? (
<> <>
<Circle cx={12} cy={12} r={10} fill={theme.accent} /> <Circle cx={12} cy={12} r={10} fill={theme.accent} />
<Path d="M7 12l3 3 7-7" stroke="#FFFFFF" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" /> <Path
d="M7 12l3 3 7-7"
stroke="#FFFFFF"
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</> </>
) : ( ) : (
<Circle cx={12} cy={12} r={10} stroke={theme.textSecondary} strokeWidth={2.5} fill="none" /> <Circle
cx={12}
cy={12}
r={10}
stroke={theme.textSecondary}
strokeWidth={2.5}
fill="none"
/>
)} )}
</Svg> </Svg>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
style={styles.eventTouch} 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} activeOpacity={0.7}
> >
<Text <Text
style={[styles.eventTitle, { color: theme.text }, sub.completed && styles.eventCompleted]} style={[
styles.eventTitle,
{ color: theme.text },
sub.completed && styles.eventCompleted,
]}
numberOfLines={1} numberOfLines={1}
> >
{sub.title} {sub.title}
</Text> </Text>
{sub.dueTime ? ( {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} ) : null}
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@@ -205,14 +312,18 @@ export default function DayViewScreen() {
</> </>
)} )}
</ScrollView> </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> </KeyboardAvoidingView>
{modals(() => {})} {modals(() => {})}
</SafeAreaView> </SafeAreaView>
); );
} }
const createStyles = (theme: ThemeColors) => StyleSheet.create({ const createStyles = (theme: ThemeColors) =>
StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
}, },
@@ -290,4 +401,4 @@ const createStyles = (theme: ThemeColors) => StyleSheet.create({
padding: 4, padding: 4,
marginLeft: 6, marginLeft: 6,
}, },
}); });
+54 -17
View File
@@ -1,5 +1,15 @@
import React from 'react'; 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 { useLocalSearchParams, useRouter } from 'expo-router';
import { Header } from '@/components/Header'; import { Header } from '@/components/Header';
import { TaskNameInput } from '@/components/TaskNameInput'; import { TaskNameInput } from '@/components/TaskNameInput';
@@ -15,7 +25,14 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod'; import { z } from 'zod';
import { useDatabase } from '@/hooks/useDatabase'; import { useDatabase } from '@/hooks/useDatabase';
import { collections } from '@/database'; import { collections } from '@/database';
import { SubtaskFormData, Reminder, parseReminders, toRemindersString, parseTaskTags, tagsToString } from '@/types'; import {
SubtaskFormData,
Reminder,
parseReminders,
toRemindersString,
parseTaskTags,
tagsToString,
} from '@/types';
import { useSettings } from '@/theme'; import { useSettings } from '@/theme';
import { updateSubtask, deleteSubtask } from '@/utils/taskActions'; import { updateSubtask, deleteSubtask } from '@/utils/taskActions';
import { CategorySelector } from '@/components/CategorySelector'; import { 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 priority = watch('priority');
const tags = watch('tags') ?? []; const tags = watch('tags') ?? [];
@@ -98,7 +122,10 @@ export default function SubtaskDetailScreen() {
allDay: subtask.allDay ?? false, allDay: subtask.allDay ?? false,
repeat: subtask.repeat, repeat: subtask.repeat,
repeatInterval: subtask.repeatInterval || 1, repeatInterval: subtask.repeatInterval || 1,
repeatDays: ((subtask.repeatDays || '') 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, reminder: (subtask.reminder || 'none') as Reminder,
reminders: subtask.reminders || '', reminders: subtask.reminders || '',
assigneeId: subtask.assigneeId ?? null, assigneeId: subtask.assigneeId ?? null,
@@ -160,7 +187,9 @@ export default function SubtaskDetailScreen() {
if (!isReady || !loaded) { if (!isReady || !loaded) {
return ( return (
<View style={[styles.loadingContainer, { backgroundColor: theme.background }]}> <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> </View>
); );
} }
@@ -171,9 +200,23 @@ export default function SubtaskDetailScreen() {
title="Edit Subtask" title="Edit Subtask"
showLogo={true} showLogo={true}
rightAction={ 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"> <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> </Svg>
</TouchableOpacity> </TouchableOpacity>
} }
@@ -201,14 +244,8 @@ export default function SubtaskDetailScreen() {
/> />
)} )}
/> />
<CategorySelector <CategorySelector value={tags} onChange={value => setValue('tags', value)} />
value={tags} <PrioritySelector value={priority} onChange={value => setValue('priority', value)} />
onChange={(value) => setValue('tags', value)}
/>
<PrioritySelector
value={priority}
onChange={(value) => setValue('priority', value)}
/>
<DateTimePickerComponent control={control as any} /> <DateTimePickerComponent control={control as any} />
<RepeatSelector <RepeatSelector
value={repeat} value={repeat}
@@ -223,12 +260,12 @@ export default function SubtaskDetailScreen() {
<ReminderSelector <ReminderSelector
value={reminders} value={reminders}
hasDueDate={!!dueDate} hasDueDate={!!dueDate}
onChange={(value) => setValue('reminders', value)} onChange={value => setValue('reminders', value)}
/> />
<AssigneeSelector <AssigneeSelector
value={assigneeId} value={assigneeId}
onChange={(value: string | null) => setValue('assigneeId', value)} onChange={(value: string | null) => setValue('assigneeId', value)}
friends={friends.map((f) => f.username)} friends={friends.map(f => f.username)}
/> />
<Controller <Controller
control={control} control={control}
+139 -38
View File
@@ -1,5 +1,15 @@
import React, { useRef } from 'react'; 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 { useLocalSearchParams, useRouter } from 'expo-router';
import { Header } from '@/components/Header'; import { Header } from '@/components/Header';
import { CategorySelector } from '@/components/CategorySelector'; import { CategorySelector } from '@/components/CategorySelector';
@@ -47,13 +57,27 @@ const taskSchema = z.object({
subtasks: z.array(z.object({ title: z.string(), _key: z.string().optional() })).optional(), subtasks: z.array(z.object({ title: z.string(), _key: z.string().optional() })).optional(),
}); });
function CollapsibleSection({ title, children, defaultExpanded = false, icon }: { title: string; children: React.ReactNode; defaultExpanded?: boolean; icon: React.ReactNode }) { function CollapsibleSection({
title,
children,
defaultExpanded = false,
icon,
}: {
title: string;
children: React.ReactNode;
defaultExpanded?: boolean;
icon: React.ReactNode;
}) {
const { theme } = useSettings(); const { theme } = useSettings();
const [expanded, setExpanded] = React.useState(defaultExpanded); const [expanded, setExpanded] = React.useState(defaultExpanded);
return ( return (
<View style={styles.section}> <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}> <View style={styles.sectionHeaderLeft}>
{icon} {icon}
<Text style={[styles.sectionTitle, { color: theme.text }]}>{title}</Text> <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 tags = watch('tags') ?? [];
const priority = watch('priority'); const priority = watch('priority');
@@ -129,7 +160,8 @@ export default function TaskDetailScreen() {
const subtasks = await collections.subtasks.query(Q.where('task_id', id)).fetch(); const subtasks = await collections.subtasks.query(Q.where('task_id', id)).fetch();
if (!mounted) return; if (!mounted) return;
const remindersValue = task.reminders || (task.reminder && task.reminder !== 'none' ? task.reminder : ''); const remindersValue =
task.reminders || (task.reminder && task.reminder !== 'none' ? task.reminder : '');
reset({ reset({
title: task.title, title: task.title,
@@ -143,11 +175,14 @@ export default function TaskDetailScreen() {
allDay: task.allDay ?? false, allDay: task.allDay ?? false,
repeat: task.repeat, repeat: task.repeat,
repeatInterval: task.repeatInterval || 1, repeatInterval: task.repeatInterval || 1,
repeatDays: (task.repeatDays || '').split(',').map(Number).filter((d) => !Number.isNaN(d)), repeatDays: (task.repeatDays || '')
.split(',')
.map(Number)
.filter(d => !Number.isNaN(d)),
reminder: (task.reminder || 'none') as TaskFormData['reminder'], reminder: (task.reminder || 'none') as TaskFormData['reminder'],
reminders: remindersValue, reminders: remindersValue,
assigneeId: task.assigneeId ?? null, assigneeId: task.assigneeId ?? null,
subtasks: subtasks.map((s) => ({ title: s.title, _key: s.id })), subtasks: subtasks.map(s => ({ title: s.title, _key: s.id })),
}); });
setLoaded(true); setLoaded(true);
} catch { } catch {
@@ -155,7 +190,9 @@ export default function TaskDetailScreen() {
} }
})(); })();
return () => { mounted = false; }; return () => {
mounted = false;
};
}, [id, isReady, reset]); }, [id, isReady, reset]);
const onSubmit = async (data: TaskFormData) => { const onSubmit = async (data: TaskFormData) => {
@@ -174,7 +211,7 @@ export default function TaskDetailScreen() {
savedTask = task; savedTask = task;
const existingSubtasks = await collections.subtasks.query(Q.where('task_id', id)).fetch(); const existingSubtasks = await collections.subtasks.query(Q.where('task_id', id)).fetch();
await task.update((t) => { await task.update(t => {
t.title = data.title.trim(); t.title = data.title.trim();
t.description = data.description || ''; t.description = data.description || '';
t.categoryId = (data.tags && data.tags[0]) || task.categoryId || ''; t.categoryId = (data.tags && data.tags[0]) || task.categoryId || '';
@@ -202,10 +239,10 @@ export default function TaskDetailScreen() {
const trimmed = formItem.title.trim(); const trimmed = formItem.title.trim();
if (!trimmed) continue; if (!trimmed) continue;
if (formItem._key) { 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) { if (existing) {
keptIds.add(existing.id); keptIds.add(existing.id);
await existing.update((s) => { await existing.update(s => {
s.title = trimmed; s.title = trimmed;
s.order = order; s.order = order;
s.updatedAt = now; s.updatedAt = now;
@@ -214,7 +251,7 @@ export default function TaskDetailScreen() {
continue; continue;
} }
} }
await collections.subtasks.create((s) => { await collections.subtasks.create(s => {
s.taskId = task.id; s.taskId = task.id;
s.categoryId = task.categoryId || ''; s.categoryId = task.categoryId || '';
s.tags = task.tags || ''; s.tags = task.tags || '';
@@ -244,9 +281,11 @@ export default function TaskDetailScreen() {
if (existing.parentSubtaskId) continue; if (existing.parentSubtaskId) continue;
if (keptIds.has(existing.id)) continue; if (keptIds.has(existing.id)) continue;
removedIds.push(existing.id); 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) { for (const child of children) {
await child.update((c) => { await child.update(c => {
c.parentSubtaskId = null; c.parentSubtaskId = null;
c.updatedAt = now; c.updatedAt = now;
}); });
@@ -288,9 +327,23 @@ export default function TaskDetailScreen() {
title="Edit Task" title="Edit Task"
showLogo={true} showLogo={true}
rightAction={ rightAction={
<TouchableOpacity onPress={handleDelete} activeOpacity={0.7} style={[styles.deleteButton, { borderColor: theme.accentBorder, backgroundColor: theme.accentSoft }]}> <TouchableOpacity
onPress={handleDelete}
activeOpacity={0.7}
style={[
styles.deleteButton,
{ borderColor: theme.accentBorder, backgroundColor: theme.accentSoft },
]}
>
<Svg width={22} height={22} viewBox="0 0 24 24"> <Svg width={22} height={22} viewBox="0 0 24 24">
<Path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13" stroke={theme.accent} strokeWidth={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> </Svg>
</TouchableOpacity> </TouchableOpacity>
} }
@@ -310,7 +363,7 @@ export default function TaskDetailScreen() {
> >
<CategorySelector <CategorySelector
value={tags} value={tags}
onChange={(value) => setValue('tags', value)} onChange={value => setValue('tags', value)}
error={errors.categoryId?.message} error={errors.categoryId?.message}
/> />
<Controller <Controller
@@ -326,21 +379,36 @@ export default function TaskDetailScreen() {
)} )}
/> />
<SubtasksSection control={control} /> <SubtasksSection control={control} />
<PrioritySelector <PrioritySelector value={priority} onChange={value => setValue('priority', value)} />
value={priority}
onChange={(value) => setValue('priority', value)}
/>
<CollapsibleSection title="Date & Time" icon={ <CollapsibleSection
title="Date & Time"
icon={
<Svg width={20} height={20} viewBox="0 0 24 24"> <Svg width={20} height={20} viewBox="0 0 24 24">
<Circle cx={12} cy={12} r={10} stroke={theme.accent} strokeWidth={1.8} fill="none" /> <Circle
<Path d="M12 6v6l4 2" stroke={theme.accent} strokeWidth={1.8} strokeLinecap="round" /> 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> </Svg>
} defaultExpanded={!!dueDate}> }
defaultExpanded={!!dueDate}
>
<DateTimePickerComponent control={control} /> <DateTimePickerComponent control={control} />
</CollapsibleSection> </CollapsibleSection>
<CollapsibleSection title="Repeat" icon={ <CollapsibleSection
title="Repeat"
icon={
<Svg width={20} height={20} viewBox="0 0 24 24"> <Svg width={20} height={20} viewBox="0 0 24 24">
<Path <Path
d="M17 2l4 4-4 4M3 11v-1a4 4 0 0 1 4-4h14M7 22l-4-4 4-4M21 13v1a4 4 0 0 1-4 4H3" d="M17 2l4 4-4 4M3 11v-1a4 4 0 0 1 4-4h14M7 22l-4-4 4-4M21 13v1a4 4 0 0 1-4 4H3"
@@ -351,7 +419,9 @@ export default function TaskDetailScreen() {
fill="none" fill="none"
/> />
</Svg> </Svg>
} defaultExpanded={repeat !== 'none'}> }
defaultExpanded={repeat !== 'none'}
>
<RepeatSelector <RepeatSelector
value={repeat} value={repeat}
interval={repeatInterval} interval={repeatInterval}
@@ -364,7 +434,9 @@ export default function TaskDetailScreen() {
/> />
</CollapsibleSection> </CollapsibleSection>
<CollapsibleSection title="Reminder" icon={ <CollapsibleSection
title="Reminder"
icon={
<Svg width={20} height={20} viewBox="0 0 24 24"> <Svg width={20} height={20} viewBox="0 0 24 24">
<Path <Path
d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9M10.3 21a1.94 1.94 0 0 0 3.4 0" d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9M10.3 21a1.94 1.94 0 0 0 3.4 0"
@@ -375,32 +447,61 @@ export default function TaskDetailScreen() {
fill="none" fill="none"
/> />
</Svg> </Svg>
} defaultExpanded={!!reminders && reminders !== ''}> }
defaultExpanded={!!reminders && reminders !== ''}
>
<ReminderSelector <ReminderSelector
value={reminders} value={reminders}
hasDueDate={!!dueDate} hasDueDate={!!dueDate}
onChange={(value) => setValue('reminders', value)} onChange={value => setValue('reminders', value)}
/> />
</CollapsibleSection> </CollapsibleSection>
<CollapsibleSection title="Assignee" icon={ <CollapsibleSection
title="Assignee"
icon={
<Svg width={20} height={20} viewBox="0 0 24 24"> <Svg width={20} height={20} viewBox="0 0 24 24">
<Circle cx={12} cy={12} r={10} stroke={theme.accent} strokeWidth={1.8} fill="none" /> <Circle
<Path d="M12 10v6M12 19v1" stroke={theme.accent} strokeWidth={1.8} strokeLinecap="round" /> 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> </Svg>
} defaultExpanded={!!assigneeId}> }
defaultExpanded={!!assigneeId}
>
<AssigneeSelector <AssigneeSelector
value={assigneeId} value={assigneeId}
onChange={(value: string | null) => setValue('assigneeId', value)} onChange={(value: string | null) => setValue('assigneeId', value)}
friends={friends.map((f) => f.username)} friends={friends.map(f => f.username)}
/> />
</CollapsibleSection> </CollapsibleSection>
<CollapsibleSection title="Description" icon={ <CollapsibleSection
title="Description"
icon={
<Svg width={20} height={20} viewBox="0 0 24 24"> <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" /> <Path
d="M4 6h16M4 12h16M4 18h10"
stroke={theme.accent}
strokeWidth={1.8}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</Svg> </Svg>
} defaultExpanded={!!methods.getValues('description')}> }
defaultExpanded={!!methods.getValues('description')}
>
<Controller <Controller
control={control} control={control}
name="description" 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-async-storage/async-storage": "2.2.0",
"@react-native-community/datetimepicker": "9.1.0", "@react-native-community/datetimepicker": "9.1.0",
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"expo": "~57.0.10", "expo": "~57.0.18",
"expo-blur": "~57.0.2", "expo-blur": "~57.0.2",
"expo-constants": "~57.0.9", "expo-constants": "~57.0.9",
"expo-dev-client": "~57.0.1", "expo-dev-client": "~57.0.16",
"expo-device": "^57.0.1", "expo-device": "^57.0.1",
"expo-linking": "~57.0.5", "expo-intent-launcher": "~57.0.1",
"expo-notifications": "~57.0.8", "expo-linking": "~57.0.8",
"expo-router": "~57.0.10", "expo-notifications": "~57.0.15",
"expo-sqlite": "~57.0.1", "expo-router": "~57.0.17",
"expo-sqlite": "~57.0.2",
"expo-status-bar": "~57.0.1", "expo-status-bar": "~57.0.1",
"expo-system-ui": "~57.0.2", "expo-system-ui": "~57.0.3",
"expo-updates": "~57.0.12", "expo-updates": "~57.0.19",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"react-hook-form": "^7.51.5", "react-hook-form": "^7.51.5",
"react-native": "0.86.2", "react-native": "0.86.3",
"react-native-gesture-handler": "2.32.0", "react-native-gesture-handler": "2.32.0",
"react-native-keyboard-controller": "1.21.9", "react-native-keyboard-controller": "1.21.9",
"react-native-safe-area-context": "5.7.0", "react-native-safe-area-context": "5.7.0",
@@ -40,7 +41,7 @@
"electron": "^41.7.1", "electron": "^41.7.1",
"electron-builder": "^26.15.3", "electron-builder": "^26.15.3",
"eslint": "^9.0.0", "eslint": "^9.0.0",
"eslint-config-expo": "~57.0.1", "eslint-config-expo": "~57.0.2",
"patch-package": "^8.0.1", "patch-package": "^8.0.1",
"prettier": "^3.9.6", "prettier": "^3.9.6",
"typescript": "~6.0.3" "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,
});
+11 -1
View File
@@ -1,7 +1,17 @@
import React from 'react'; import React from 'react';
import { View, Text, TouchableOpacity, StyleSheet, Switch } from 'react-native'; 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; title: string;
subtitle?: string | undefined; subtitle?: string | undefined;
onPress?: () => void; onPress?: () => void;
@@ -19,10 +19,22 @@ interface OptionPickerModalProps {
multiSelect?: boolean; 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 { theme } = useSettings();
const selectedValues = Array.isArray(selectedValue) ? selectedValue : selectedValue == null ? [] : [selectedValue]; const selectedValues = Array.isArray(selectedValue)
? selectedValue
: selectedValue == null
? []
: [selectedValue];
return ( return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}> <Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
@@ -31,7 +43,7 @@ export function OptionPickerModal({ visible, title, options, selectedValue, onSe
<Text style={[styles.title, { color: theme.text }]}>{title}</Text> <Text style={[styles.title, { color: theme.text }]}>{title}</Text>
<FlatList <FlatList
data={options} data={options}
keyExtractor={(item) => item.value} keyExtractor={item => item.value}
renderItem={({ item }) => { renderItem={({ item }) => {
const selected = selectedValues.includes(item.value); const selected = selectedValues.includes(item.value);
return ( return (
@@ -44,7 +56,7 @@ export function OptionPickerModal({ visible, title, options, selectedValue, onSe
onPress={() => { onPress={() => {
if (multiSelect) { if (multiSelect) {
const newValues = selected const newValues = selected
? selectedValues.filter((v) => v !== item.value) ? selectedValues.filter(v => v !== item.value)
: [...selectedValues, item.value]; : [...selectedValues, item.value];
onSelect(newValues); onSelect(newValues);
} else { } else {
@@ -57,23 +69,49 @@ export function OptionPickerModal({ visible, title, options, selectedValue, onSe
accessibilityLabel={item.label} accessibilityLabel={item.label}
accessibilityState={multiSelect ? { checked: selected } : { selected }} accessibilityState={multiSelect ? { checked: selected } : { selected }}
> >
{item.color && ( {item.color && <View style={[styles.dot, { backgroundColor: item.color }]} />}
<View style={[styles.dot, { backgroundColor: item.color }]} /> <Text
)} style={[
<Text style={[styles.optionText, { color: selected ? theme.accent : theme.textSecondary }]}> styles.optionText,
{ color: selected ? theme.accent : theme.textSecondary },
]}
>
{item.label} {item.label}
</Text> </Text>
{multiSelect ? ( {multiSelect ? (
<Svg width={22} height={22} viewBox="0 0 24 24"> <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 && ( {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> </Svg>
) : selected && ( ) : (
selected && (
<Svg width={18} height={18} viewBox="0 0 24 24"> <Svg width={18} height={18} viewBox="0 0 24 24">
<Path d="M5 12l5 5 9-10" stroke={theme.accent} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" /> <Path
d="M5 12l5 5 9-10"
stroke={theme.accent}
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</Svg> </Svg>
)
)} )}
</TouchableOpacity> </TouchableOpacity>
); );
@@ -24,13 +24,17 @@ export function PrioritySelector({ value, onChange }: PrioritySelectorProps) {
<View style={styles.container}> <View style={styles.container}>
<Text style={[styles.label, { color: theme.text }]}>Priority</Text> <Text style={[styles.label, { color: theme.text }]}>Priority</Text>
<View style={styles.options} accessibilityRole="radiogroup" accessibilityLabel="Priority"> <View style={styles.options} accessibilityRole="radiogroup" accessibilityLabel="Priority">
{priorities.map((priority) => ( {priorities.map(priority => (
<TouchableOpacity <TouchableOpacity
key={priority.value} key={priority.value}
style={[ style={[
styles.option, styles.option,
{ backgroundColor: theme.card, borderColor: theme.borderStrong }, { 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)} onPress={() => onChange(priority.value)}
activeOpacity={0.8} activeOpacity={0.8}
@@ -38,16 +42,20 @@ export function PrioritySelector({ value, onChange }: PrioritySelectorProps) {
accessibilityLabel={`${priority.label} priority`} accessibilityLabel={`${priority.label} priority`}
accessibilityState={{ selected: value === priority.value }} accessibilityState={{ selected: value === priority.value }}
> >
<View style={[ <View
style={[
styles.colorIndicator, styles.colorIndicator,
{ backgroundColor: PRIORITY_COLORS[priority.value] }, { backgroundColor: PRIORITY_COLORS[priority.value] },
value === priority.value && styles.colorIndicatorSelected, value === priority.value && styles.colorIndicatorSelected,
]} /> ]}
<Text style={[ />
<Text
style={[
styles.optionText, styles.optionText,
{ color: theme.textFaint }, { color: theme.textFaint },
value === priority.value && { color: theme.accentStrong, fontWeight: '600' }, value === priority.value && { color: theme.accentStrong, fontWeight: '600' },
]}> ]}
>
{priority.label} {priority.label}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
+53 -13
View File
@@ -1,5 +1,13 @@
import React, { useState, useEffect, useMemo, useRef } from 'react'; 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 { BlurView } from 'expo-blur';
import { database, collections } from '@/database'; import { database, collections } from '@/database';
import { useCategories } from '@/hooks/useDatabase'; import { useCategories } from '@/hooks/useDatabase';
@@ -14,10 +22,20 @@ interface QuickAddBarProps {
placeholder?: string; 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') { if (Platform.OS === 'web') {
return ( return (
<View style={[style, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }]}> <View
style={[style, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }]}
>
{children} {children}
</View> </View>
); );
@@ -38,10 +56,10 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
const inputRef = useRef<TextInput>(null); const inputRef = useRef<TextInput>(null);
const visibleCategories = useMemo( const visibleCategories = useMemo(
() => categories.filter((c) => c.name.toLowerCase() !== 'calendar'), () => categories.filter(c => c.name.toLowerCase() !== 'calendar'),
[categories] [categories]
); );
const categoryColor = visibleCategories.find((c) => c.id === categoryId)?.color ?? theme.textMuted; const categoryColor = visibleCategories.find(c => c.id === categoryId)?.color ?? theme.textMuted;
useEffect(() => { useEffect(() => {
return subscribeToQuickAdd(() => { return subscribeToQuickAdd(() => {
@@ -57,7 +75,7 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
const now = new Date(); const now = new Date();
await database.write(async () => { await database.write(async () => {
await collections.tasks.create((t) => { await collections.tasks.create(t => {
t.title = trimmed; t.title = trimmed;
t.description = ''; t.description = '';
t.categoryId = categoryId || ''; t.categoryId = categoryId || '';
@@ -83,16 +101,31 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
return ( return (
<View style={styles.wrapper}> <View style={styles.wrapper}>
<BlurContainer theme={theme} style={styles.blurContainer}> <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 <TouchableOpacity
style={[styles.categoryButton, { borderColor: theme.borderStrong, backgroundColor: theme.cardAlt }]} style={[
styles.categoryButton,
{ borderColor: theme.borderStrong, backgroundColor: theme.cardAlt },
]}
onPress={() => setCategoryPickerVisible(true)} onPress={() => setCategoryPickerVisible(true)}
activeOpacity={0.7} activeOpacity={0.7}
accessibilityLabel="Select tags" accessibilityLabel="Select tags"
> >
<View style={[styles.categoryButtonDot, { backgroundColor: categoryColor }]} /> <View style={[styles.categoryButtonDot, { backgroundColor: categoryColor }]} />
<Svg width={12} height={12} viewBox="0 0 24 24"> <Svg width={12} height={12} viewBox="0 0 24 24">
<Path d="M6 9l6 6 6-6" stroke={theme.textSecondary} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" /> <Path
d="M6 9l6 6 6-6"
stroke={theme.textSecondary}
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</Svg> </Svg>
</TouchableOpacity> </TouchableOpacity>
<TextInput <TextInput
@@ -108,7 +141,11 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
accessibilityHint="Enter a task name and press the add button" accessibilityHint="Enter a task name and press the add button"
/> />
<TouchableOpacity <TouchableOpacity
style={[styles.submit, { backgroundColor: theme.accent }, title.trim() ? {} : styles.submitDisabled]} style={[
styles.submit,
{ backgroundColor: theme.accent },
title.trim() ? {} : styles.submitDisabled,
]}
onPress={title.trim() ? handleAdd : undefined} onPress={title.trim() ? handleAdd : undefined}
disabled={!title.trim()} disabled={!title.trim()}
activeOpacity={0.8} activeOpacity={0.8}
@@ -132,9 +169,12 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
<OptionPickerModal <OptionPickerModal
visible={categoryPickerVisible} visible={categoryPickerVisible}
title="Select Tags" 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} selectedValue={categoryId}
onSelect={(value) => setCategoryId(Array.isArray(value) ? value[0] : value)} onSelect={value => setCategoryId(Array.isArray(value) ? value[0] : value)}
onClose={() => setCategoryPickerVisible(false)} onClose={() => setCategoryPickerVisible(false)}
/> />
</View> </View>
@@ -162,7 +202,7 @@ const styles = StyleSheet.create({
borderBottomWidth: 1, borderBottomWidth: 1,
shadowColor: '#000', shadowColor: '#000',
shadowOffset: { width: 0, height: -2 }, shadowOffset: { width: 0, height: -2 },
shadowOpacity: 0.10, shadowOpacity: 0.1,
shadowRadius: 6, shadowRadius: 6,
elevation: 6, elevation: 6,
minHeight: 56, minHeight: 56,
@@ -18,8 +18,12 @@ export function ReminderSelector({ value, onChange, hasDueDate }: ReminderSelect
const selectedReminders = parseReminders(value); const selectedReminders = parseReminders(value);
const disabled = !hasDueDate; const disabled = !hasDueDate;
const reminderLabels = selectedReminders.length > 0 const reminderLabels =
? selectedReminders.map(r => REMINDER_OPTIONS.find(o => o.value === r)?.label).filter(Boolean).join(', ') selectedReminders.length > 0
? selectedReminders
.map(r => REMINDER_OPTIONS.find(o => o.value === r)?.label)
.filter(Boolean)
.join(', ')
: 'No reminder'; : 'No reminder';
return ( return (
@@ -29,7 +33,10 @@ export function ReminderSelector({ value, onChange, hasDueDate }: ReminderSelect
style={[ style={[
styles.row, styles.row,
{ backgroundColor: theme.card, borderColor: theme.borderStrong }, { 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 }, disabled && { opacity: 0.5 },
]} ]}
onPress={() => setShowPicker(true)} onPress={() => setShowPicker(true)}
@@ -49,7 +56,14 @@ export function ReminderSelector({ value, onChange, hasDueDate }: ReminderSelect
strokeLinejoin="round" strokeLinejoin="round"
fill="none" 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> </Svg>
<Text <Text
style={[ style={[
@@ -62,20 +76,32 @@ export function ReminderSelector({ value, onChange, hasDueDate }: ReminderSelect
</Text> </Text>
<View style={styles.chevron}> <View style={styles.chevron}>
<Svg width={16} height={16} viewBox="0 0 24 24"> <Svg width={16} height={16} viewBox="0 0 24 24">
<Path d="M9 18l6-6-6-6" stroke={theme.textSecondary} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" /> <Path
d="M9 18l6-6-6-6"
stroke={theme.textSecondary}
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</Svg> </Svg>
</View> </View>
</TouchableOpacity> </TouchableOpacity>
{!hasDueDate && ( {!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 <OptionPickerModal
visible={showPicker} visible={showPicker}
title="Reminders" 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} selectedValue={selectedReminders}
onSelect={(v) => onChange(toRemindersString(v as Reminder[]))} onSelect={v => onChange(toRemindersString(v as Reminder[]))}
onClose={() => setShowPicker(false)} onClose={() => setShowPicker(false)}
multiSelect multiSelect
/> />
+124 -30
View File
@@ -1,6 +1,22 @@
import React from 'react'; import React from 'react';
import { View, Text, StyleSheet, TouchableOpacity, Modal, TextInput, Pressable, KeyboardAvoidingView } from 'react-native'; import {
import { Repeat, REPEAT_OPTIONS, WEEKDAY_LABELS, WEEKDAY_ORDER, weekdaysInDisplayOrder, repeatDaysFromString } from '@/types'; 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 { useSettings } from '@/theme';
import { useRepeatProfiles } from '@/hooks/useDatabase'; import { useRepeatProfiles } from '@/hooks/useDatabase';
import { createRepeatProfile, deleteRepeatProfile } from '@/utils/repeatProfileActions'; import { createRepeatProfile, deleteRepeatProfile } from '@/utils/repeatProfileActions';
@@ -43,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 { function unitLabel(value: Repeat): string {
switch (value) { switch (value) {
@@ -77,12 +101,16 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
}; };
const toggleDay = (day: number) => { 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; if (value === 'custom' && nextDays.length === 0) return;
onChange(value, interval, nextDays); 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)); onChange(profile.repeat, profile.repeatInterval, repeatDaysFromString(profile.repeatDays));
}; };
@@ -116,7 +144,7 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
<View style={styles.profileRow}> <View style={styles.profileRow}>
<Text style={[styles.profileLabel, { color: theme.textFaint }]}>Profiles</Text> <Text style={[styles.profileLabel, { color: theme.textFaint }]}>Profiles</Text>
<View style={styles.profileChips}> <View style={styles.profileChips}>
{profiles.map((profile) => ( {profiles.map(profile => (
<View <View
key={profile.id} key={profile.id}
style={[ 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}`}> <TouchableOpacity
<Text style={[styles.profileChipText, { color: theme.textSecondary }]}>{profile.name}</Text> 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>
<TouchableOpacity <TouchableOpacity
onPress={() => handleDeleteProfile(profile.id, profile.name)} 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"> <View style={styles.chipRow} accessibilityRole="radiogroup" accessibilityLabel="Repeat">
{REPEAT_OPTIONS.map((option) => ( {REPEAT_OPTIONS.map(option => (
<TouchableOpacity <TouchableOpacity
key={option.value} key={option.value}
style={[ style={[
styles.chip, styles.chip,
{ backgroundColor: theme.card, borderColor: theme.borderStrong }, { 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)} onPress={() => selectRepeat(option.value)}
activeOpacity={0.8} activeOpacity={0.8}
@@ -163,7 +202,10 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
accessibilityLabel={`Repeat ${option.label.replace('No Repeat', 'none')}`} accessibilityLabel={`Repeat ${option.label.replace('No Repeat', 'none')}`}
accessibilityState={{ selected: value === option.value }} accessibilityState={{ selected: value === option.value }}
> >
<RepeatIcon repeat={option.value} color={value === option.value ? theme.accent : theme.textFaint} /> <RepeatIcon
repeat={option.value}
color={value === option.value ? theme.accent : theme.textFaint}
/>
<Text <Text
style={[ style={[
styles.chipText, styles.chipText,
@@ -179,7 +221,12 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
{value !== 'none' && ( {value !== 'none' && (
<View style={styles.settings}> <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> <Text style={[styles.intervalLabel, { color: theme.textSecondary }]}>Every</Text>
<TouchableOpacity <TouchableOpacity
style={[styles.stepButton, { borderColor: theme.borderStrong }]} style={[styles.stepButton, { borderColor: theme.borderStrong }]}
@@ -190,7 +237,14 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
accessibilityLabel="Decrease repeat interval" accessibilityLabel="Decrease repeat interval"
accessibilityState={{ disabled: interval <= 1 }} accessibilityState={{ disabled: interval <= 1 }}
> >
<Text style={[styles.stepButtonText, { color: interval <= 1 ? theme.textMuted : theme.text }]}>−</Text> <Text
style={[
styles.stepButtonText,
{ color: interval <= 1 ? theme.textMuted : theme.text },
]}
>
−
</Text>
</TouchableOpacity> </TouchableOpacity>
<Text style={[styles.intervalValue, { color: theme.text }]}>{interval}</Text> <Text style={[styles.intervalValue, { color: theme.text }]}>{interval}</Text>
<TouchableOpacity <TouchableOpacity
@@ -202,7 +256,14 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
accessibilityLabel="Increase repeat interval" accessibilityLabel="Increase repeat interval"
accessibilityState={{ disabled: interval >= 30 }} accessibilityState={{ disabled: interval >= 30 }}
> >
<Text style={[styles.stepButtonText, { color: interval >= 30 ? theme.textMuted : theme.text }]}>+</Text> <Text
style={[
styles.stepButtonText,
{ color: interval >= 30 ? theme.textMuted : theme.text },
]}
>
+
</Text>
</TouchableOpacity> </TouchableOpacity>
<Text style={[styles.intervalLabel, { color: theme.textSecondary }]}> <Text style={[styles.intervalLabel, { color: theme.textSecondary }]}>
{unitLabel(value)} {unitLabel(value)}
@@ -212,14 +273,20 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
{isDaysBased(value) && ( {isDaysBased(value) && (
<View style={styles.dayRow}> <View style={styles.dayRow}>
{WEEKDAY_ORDER.map((day) => ( {WEEKDAY_ORDER.map(day => (
<TouchableOpacity <TouchableOpacity
key={`${WEEKDAY_LABELS[day]}-${day}`} key={`${WEEKDAY_LABELS[day]}-${day}`}
style={[ style={[
styles.dayChip, styles.dayChip,
{ backgroundColor: theme.card, borderColor: theme.borderStrong }, { backgroundColor: theme.card, borderColor: theme.borderStrong },
days.includes(day) && { backgroundColor: theme.accent, borderColor: theme.accent }, days.includes(day) && {
value === 'custom' && days.length === 1 && days.includes(day) && styles.dayChipLast, backgroundColor: theme.accent,
borderColor: theme.accent,
},
value === 'custom' &&
days.length === 1 &&
days.includes(day) &&
styles.dayChipLast,
]} ]}
onPress={() => toggleDay(day)} onPress={() => toggleDay(day)}
activeOpacity={0.8} activeOpacity={0.8}
@@ -250,9 +317,16 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
activeOpacity={0.8} activeOpacity={0.8}
> >
<Svg width={13} height={13} viewBox="0 0 24 24"> <Svg width={13} height={13} viewBox="0 0 24 24">
<Path d="M12 5v14M5 12h14" stroke={theme.accent} strokeWidth={2.5} strokeLinecap="round" /> <Path
d="M12 5v14M5 12h14"
stroke={theme.accent}
strokeWidth={2.5}
strokeLinecap="round"
/>
</Svg> </Svg>
<Text style={[styles.saveProfileText, { color: theme.accentStrong }]}>Save as profile</Text> <Text style={[styles.saveProfileText, { color: theme.accentStrong }]}>
Save as profile
</Text>
</TouchableOpacity> </TouchableOpacity>
)} )}
@@ -262,19 +336,29 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
animationType="fade" animationType="fade"
onRequestClose={() => setSaveModalVisible(false)} onRequestClose={() => setSaveModalVisible(false)}
> >
<KeyboardAvoidingView <KeyboardAvoidingView style={styles.modalBackdrop} behavior="padding">
style={styles.modalBackdrop}
behavior="padding"
>
<Pressable style={styles.modalBackdrop} onPress={() => setSaveModalVisible(false)}> <Pressable style={styles.modalBackdrop} onPress={() => setSaveModalVisible(false)}>
<Pressable style={[styles.modalCard, { backgroundColor: theme.card, borderColor: theme.border }]}> <Pressable
style={[styles.modalCard, { backgroundColor: theme.card, borderColor: theme.border }]}
>
<Text style={[styles.modalTitle, { color: theme.text }]}>Save repeat profile</Text> <Text style={[styles.modalTitle, { color: theme.text }]}>Save repeat profile</Text>
<Text style={[styles.modalHint, { color: theme.textFaint }]}> <Text style={[styles.modalHint, { color: theme.textFaint }]}>
{`${REPEAT_OPTIONS.find((o) => o.value === value)?.label.replace('No Repeat', 'None')}, every ${interval} ${unitLabel(value)}${interval > 1 ? 's' : ''}`} {`${REPEAT_OPTIONS.find(o => o.value === value)?.label.replace('No Repeat', 'None')}, every ${interval} ${unitLabel(value)}${interval > 1 ? 's' : ''}`}
{isDaysBased(value) && days.length > 0 ? ` · ${weekdaysInDisplayOrder(days).map((d) => WEEKDAY_LABELS[d]).join(' ')}` : ''} {isDaysBased(value) && days.length > 0
? ` · ${weekdaysInDisplayOrder(days)
.map(d => WEEKDAY_LABELS[d])
.join(' ')}`
: ''}
</Text> </Text>
<TextInput <TextInput
style={[styles.modalInput, { backgroundColor: theme.cardAlt, borderColor: theme.borderStrong, color: theme.text }]} style={[
styles.modalInput,
{
backgroundColor: theme.cardAlt,
borderColor: theme.borderStrong,
color: theme.text,
},
]}
value={profileName} value={profileName}
onChangeText={setProfileName} onChangeText={setProfileName}
placeholder="Profile name (e.g. Every weekday)" placeholder="Profile name (e.g. Every weekday)"
@@ -290,15 +374,25 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
onPress={() => setSaveModalVisible(false)} onPress={() => setSaveModalVisible(false)}
activeOpacity={0.8} activeOpacity={0.8}
> >
<Text style={[styles.modalButtonText, { color: theme.textSecondary }]}>Cancel</Text> <Text style={[styles.modalButtonText, { color: theme.textSecondary }]}>
Cancel
</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
style={[styles.modalButton, styles.modalButtonPrimary, { backgroundColor: theme.accent }]} style={[
styles.modalButton,
styles.modalButtonPrimary,
{ backgroundColor: theme.accent },
]}
onPress={handleSaveProfile} onPress={handleSaveProfile}
disabled={!profileName.trim()} disabled={!profileName.trim()}
activeOpacity={0.8} 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> </TouchableOpacity>
</View> </View>
</Pressable> </Pressable>
@@ -1,5 +1,13 @@
import React, { useState } from 'react'; 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 { useSettings } from '@/theme';
import { DEFAULT_API_BASE_URL } from '@/services/auth'; import { DEFAULT_API_BASE_URL } from '@/services/auth';
import Svg, { Path } from 'react-native-svg'; import Svg, { Path } from 'react-native-svg';
@@ -33,16 +41,22 @@ export function ServerUrlModal({ visible, onClose }: ServerUrlModalProps) {
return ( return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}> <Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<KeyboardAvoidingView <KeyboardAvoidingView style={styles.overlay} behavior="padding">
style={styles.overlay}
behavior="padding"
>
<View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}> <View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}>
<View style={styles.header}> <View style={styles.header}>
<Text style={[styles.title, { color: theme.text }]}>Backend URL</Text> <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"> <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> </Svg>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@@ -51,7 +65,11 @@ export function ServerUrlModal({ visible, onClose }: ServerUrlModalProps) {
<TextInput <TextInput
style={[ style={[
styles.input, 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} placeholder={DEFAULT_API_BASE_URL}
placeholderTextColor={theme.textMuted} placeholderTextColor={theme.textMuted}
@@ -75,7 +93,11 @@ export function ServerUrlModal({ visible, onClose }: ServerUrlModalProps) {
<Text style={[styles.cancelButtonText, { color: theme.textSecondary }]}>Cancel</Text> <Text style={[styles.cancelButtonText, { color: theme.textSecondary }]}>Cancel</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
style={[styles.saveButton, { backgroundColor: theme.accent }, !isValid && styles.saveButtonDisabled]} style={[
styles.saveButton,
{ backgroundColor: theme.accent },
!isValid && styles.saveButtonDisabled,
]}
onPress={handleSave} onPress={handleSave}
disabled={!isValid} disabled={!isValid}
activeOpacity={0.8} activeOpacity={0.8}
@@ -46,7 +46,7 @@ export const SubtaskItem = React.memo(function SubtaskItem({
const hasChildren = subtask.subtasks && subtask.subtasks.length > 0; const hasChildren = subtask.subtasks && subtask.subtasks.length > 0;
const hovered = hoveredId === subtask.id; const hovered = hoveredId === subtask.id;
const effectiveColor = subtask.categoryId const effectiveColor = subtask.categoryId
? categoryColorResolver?.(subtask.categoryId) ?? categoryColor ? (categoryColorResolver?.(subtask.categoryId) ?? categoryColor)
: categoryColor; : categoryColor;
const handleExpand = () => setExpanded(!expanded); const handleExpand = () => setExpanded(!expanded);
@@ -59,7 +59,7 @@ export const SubtaskItem = React.memo(function SubtaskItem({
return ( return (
<View> <View>
<View ref={(ref) => registerRef?.(subtask.id, subtask.taskId, ref)}> <View ref={ref => registerRef?.(subtask.id, subtask.taskId, ref)}>
<TaskItem <TaskItem
task={subtask} task={subtask}
indented indented
@@ -86,7 +86,7 @@ export const SubtaskItem = React.memo(function SubtaskItem({
{subtask.subtasks {subtask.subtasks
.slice() .slice()
.sort((a, b) => a.order - b.order) .sort((a, b) => a.order - b.order)
.map((child) => ( .map(child => (
<SubtaskItem <SubtaskItem
key={child.id} key={child.id}
subtask={child} subtask={child}
@@ -48,7 +48,9 @@ export function SubtasksSection({ control }: SubtasksSectionProps) {
<Text style={[styles.label, { color: theme.text }]}>Subtasks</Text> <Text style={[styles.label, { color: theme.text }]}>Subtasks</Text>
{items.length > 0 && ( {items.length > 0 && (
<View style={[styles.badge, { backgroundColor: theme.accentSoft }]}> <View style={[styles.badge, { backgroundColor: theme.accentSoft }]}>
<Text style={[styles.badgeText, { color: theme.accentStrong }]}>{items.length}</Text> <Text style={[styles.badgeText, { color: theme.accentStrong }]}>
{items.length}
</Text>
</View> </View>
)} )}
</View> </View>
@@ -92,20 +94,30 @@ export function SubtasksSection({ control }: SubtasksSectionProps) {
key={item._key ?? `subtask-${index}`} key={item._key ?? `subtask-${index}`}
index={index} index={index}
value={item.title} value={item.title}
onChange={(value) => updateSubtask(index, value)} onChange={value => updateSubtask(index, value)}
onRemove={() => removeSubtask(index)} onRemove={() => removeSubtask(index)}
/> />
))} ))}
</View> </View>
<TouchableOpacity <TouchableOpacity
style={[styles.addButton, { backgroundColor: theme.accentSoft, borderColor: theme.accentBorder }]} style={[
styles.addButton,
{ backgroundColor: theme.accentSoft, borderColor: theme.accentBorder },
]}
onPress={addSubtask} onPress={addSubtask}
activeOpacity={0.8} activeOpacity={0.8}
> >
<Svg width={16} height={16} viewBox="0 0 24 24"> <Svg width={16} height={16} viewBox="0 0 24 24">
<Path d="M12 5v14M5 12h14" stroke={theme.accent} strokeWidth={2} strokeLinecap="round" /> <Path
d="M12 5v14M5 12h14"
stroke={theme.accent}
strokeWidth={2}
strokeLinecap="round"
/>
</Svg> </Svg>
<Text style={[styles.addButtonText, { color: theme.accentStrong }]}>Add Subtask</Text> <Text style={[styles.addButtonText, { color: theme.accentStrong }]}>
Add Subtask
</Text>
</TouchableOpacity> </TouchableOpacity>
</Animated.View> </Animated.View>
</View> </View>
@@ -150,7 +162,12 @@ function SubtaskItem({ index, value, onChange, onRemove }: SubtaskItemProps) {
/> />
<TouchableOpacity style={styles.removeButton} onPress={onRemove} activeOpacity={0.7}> <TouchableOpacity style={styles.removeButton} onPress={onRemove} activeOpacity={0.7}>
<Svg width={20} height={20} viewBox="0 0 24 24"> <Svg width={20} height={20} viewBox="0 0 24 24">
<Path d="M18 6L6 18M6 6l12 12" stroke={theme.textSecondary} strokeWidth={2.2} strokeLinecap="round" /> <Path
d="M18 6L6 18M6 6l12 12"
stroke={theme.textSecondary}
strokeWidth={2.2}
strokeLinecap="round"
/>
</Svg> </Svg>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
+48 -19
View File
@@ -11,9 +11,18 @@ import {
} from 'react-native'; } from 'react-native';
import { useSettings } from '@/theme'; import { useSettings } from '@/theme';
import { AuthUser, getAuthToken, getAuthUser, login, register, signOutAuth } from '@/services/auth'; import { AuthUser, getAuthToken, getAuthUser, login, register, signOutAuth } from '@/services/auth';
import { runSyncGuarded, startAutoSync, stopAutoSync, getLastSyncTime, SyncResult } from '@/database/sync'; import {
runSyncGuarded,
startAutoSync,
stopAutoSync,
getLastSyncTime,
SyncResult,
} from '@/database/sync';
import { ForgotPasswordModal } from './ForgotPasswordModal'; import { ForgotPasswordModal } from './ForgotPasswordModal';
import { registerForPushNotificationsAsync, unregisterForPushNotificationsAsync } from '@/services/pushNotifications'; import {
registerForPushNotificationsAsync,
unregisterForPushNotificationsAsync,
} from '@/services/pushNotifications';
interface SyncModalProps { interface SyncModalProps {
visible: boolean; visible: boolean;
@@ -81,7 +90,8 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
} }
setBusy(true); setBusy(true);
try { try {
const authedUser = mode === 'register' const authedUser =
mode === 'register'
? await register(username.trim(), password) ? await register(username.trim(), password)
: await login(username.trim(), password); : await login(username.trim(), password);
setUser(authedUser); setUser(authedUser);
@@ -128,10 +138,7 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
return ( return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}> <Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<KeyboardAvoidingView <KeyboardAvoidingView style={styles.overlay} behavior="padding">
style={styles.overlay}
behavior="padding"
>
<TouchableOpacity style={styles.backdrop} activeOpacity={1} onPress={onClose} /> <TouchableOpacity style={styles.backdrop} activeOpacity={1} onPress={onClose} />
<View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}> <View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}>
<View style={[styles.handle, { backgroundColor: theme.borderStrong }]} /> <View style={[styles.handle, { backgroundColor: theme.borderStrong }]} />
@@ -159,7 +166,9 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
{status === 'syncing' ? ( {status === 'syncing' ? (
<ActivityIndicator color={theme.accentText} /> <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> </TouchableOpacity>
@@ -181,7 +190,9 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
onPress={handleSignOut} onPress={handleSignOut}
activeOpacity={0.7} activeOpacity={0.7}
> >
<Text style={[styles.secondaryButtonText, { color: theme.dangerStrong }]}>Sign Out</Text> <Text style={[styles.secondaryButtonText, { color: theme.dangerStrong }]}>
Sign Out
</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
) : ( ) : (
@@ -191,13 +202,10 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
</Text> </Text>
<View style={[styles.segmentRow, { backgroundColor: theme.inputBg }]}> <View style={[styles.segmentRow, { backgroundColor: theme.inputBg }]}>
{(['login', 'register'] as const).map((m) => ( {(['login', 'register'] as const).map(m => (
<TouchableOpacity <TouchableOpacity
key={m} key={m}
style={[ style={[styles.segment, mode === m && { backgroundColor: theme.accent }]}
styles.segment,
mode === m && { backgroundColor: theme.accent },
]}
onPress={() => { onPress={() => {
setMode(m); setMode(m);
setError(null); setError(null);
@@ -222,7 +230,14 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
</Text> </Text>
)} )}
<TextInput <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" placeholder="Username"
placeholderTextColor={theme.textMuted} placeholderTextColor={theme.textMuted}
value={username} value={username}
@@ -231,7 +246,14 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
autoCorrect={false} autoCorrect={false}
/> />
<TextInput <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)" placeholder="Password (min 8 chars)"
placeholderTextColor={theme.textMuted} placeholderTextColor={theme.textMuted}
value={password} value={password}
@@ -245,11 +267,15 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
onPress={() => setShowForgotPassword(true)} onPress={() => setShowForgotPassword(true)}
activeOpacity={0.7} activeOpacity={0.7}
> >
<Text style={[styles.forgotPasswordText, { color: theme.accent }]}>Forgot Password?</Text> <Text style={[styles.forgotPasswordText, { color: theme.accent }]}>
Forgot Password?
</Text>
</TouchableOpacity> </TouchableOpacity>
)} )}
{error && <Text style={[styles.errorText, { color: theme.dangerStrong }]}>{error}</Text>} {error && (
<Text style={[styles.errorText, { color: theme.dangerStrong }]}>{error}</Text>
)}
<TouchableOpacity <TouchableOpacity
style={[styles.primaryButton, { backgroundColor: theme.accent }]} style={[styles.primaryButton, { backgroundColor: theme.accent }]}
@@ -268,7 +294,10 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
</View> </View>
)} )}
<ForgotPasswordModal visible={showForgotPassword} onClose={() => setShowForgotPassword(false)} /> <ForgotPasswordModal
visible={showForgotPassword}
onClose={() => setShowForgotPassword(false)}
/>
<TouchableOpacity <TouchableOpacity
style={[styles.secondaryButton, { borderColor: theme.borderStrong }]} style={[styles.secondaryButton, { borderColor: theme.borderStrong }]}
+44 -10
View File
@@ -66,20 +66,37 @@ export default function SyncStatus({ compact = false }: SyncStatusProps) {
case 'success': case 'success':
return ( return (
<Svg width={16} height={16} viewBox="0 0 24 24"> <Svg width={16} height={16} viewBox="0 0 24 24">
<Path d="M5 12l5 5 9-10" stroke={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> </Svg>
); );
case 'error': case 'error':
return ( return (
<Svg width={16} height={16} viewBox="0 0 24 24"> <Svg width={16} height={16} viewBox="0 0 24 24">
<Circle cx={12} cy={12} r={10} stroke={theme.danger} strokeWidth={2} fill="none" /> <Circle cx={12} cy={12} r={10} stroke={theme.danger} strokeWidth={2} fill="none" />
<Path d="M12 8v4M12 16h.01" stroke={theme.danger} strokeWidth={2} strokeLinecap="round" /> <Path
d="M12 8v4M12 16h.01"
stroke={theme.danger}
strokeWidth={2}
strokeLinecap="round"
/>
</Svg> </Svg>
); );
default: default:
return ( return (
<Svg width={16} height={16} viewBox="0 0 24 24"> <Svg width={16} height={16} viewBox="0 0 24 24">
<Path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" stroke={theme.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" /> <Path d="M12 22V12" stroke={theme.textFaint} strokeWidth={1.8} strokeLinecap="round" />
</Svg> </Svg>
); );
@@ -88,16 +105,25 @@ export default function SyncStatus({ compact = false }: SyncStatusProps) {
const getStatusText = () => { const getStatusText = () => {
switch (status) { switch (status) {
case 'syncing': return 'Syncing...'; case 'syncing':
case 'success': return 'Synced'; return 'Syncing...';
case 'error': return 'Sync failed'; case 'success':
default: return lastSync ? `Last sync: ${formatTime(lastSync)}` : 'Not synced'; return 'Synced';
case 'error':
return 'Sync failed';
default:
return lastSync ? `Last sync: ${formatTime(lastSync)}` : 'Not synced';
} }
}; };
if (compact) { if (compact) {
return ( 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}> <View style={styles.compactRow}>
{getStatusIcon()} {getStatusIcon()}
<Text style={[styles.compactText, { color: theme.textMuted }]}> <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> <Text style={[styles.statusText, { color: theme.text }]}>{getStatusText()}</Text>
</View> </View>
<TouchableOpacity <TouchableOpacity
style={[styles.syncButton, { backgroundColor: status === 'syncing' ? theme.accentSoft : theme.accent }]} style={[
styles.syncButton,
{ backgroundColor: status === 'syncing' ? theme.accentSoft : theme.accent },
]}
onPress={handleSync} onPress={handleSync}
disabled={status === 'syncing'} disabled={status === 'syncing'}
activeOpacity={0.8} 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'} {status === 'syncing' ? 'Syncing...' : 'Sync Now'}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
@@ -56,7 +56,14 @@ export function TabBarIcon({ name, focused, color, size = 24 }: TabBarIconProps)
)} )}
{name === 'gear' && ( {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 <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" 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} stroke={color}
@@ -76,9 +83,7 @@ export function TabBarIcon({ name, focused, color, size = 24 }: TabBarIconProps)
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
/> />
{focused && ( {focused && <Circle cx={4} cy={10} r={2} fill={color} />}
<Circle cx={4} cy={10} r={2} fill={color} />
)}
</> </>
)} )}
</Svg> </Svg>
@@ -14,7 +14,15 @@ interface TaskDeleteModalProps {
onDelete: (scope: TaskDeleteScope) => void; 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 { theme } = useSettings();
const [counts, setCounts] = useState({ future: 1, all: 1 }); 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; if (!visible || !taskId || subtask) return;
let mounted = true; let mounted = true;
getSeriesOccurrenceCounts(taskId) getSeriesOccurrenceCounts(taskId)
.then((c) => { .then(c => {
if (mounted) setCounts(c); if (mounted) setCounts(c);
}) })
.catch(() => {}); .catch(() => {});
return () => { mounted = false; }; return () => {
mounted = false;
};
}, [visible, taskId, subtask]); }, [visible, taskId, subtask]);
const options: { scope: TaskDeleteScope; label: string; hint?: string }[] = 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' }]; : [{ scope: 'this', label: 'This task only' }];
if (isRepeating) { if (isRepeating) {
options.push({ scope: 'future', label: 'This and future tasks', hint: counts.future > 1 ? `${counts.future} occurrences` : undefined }); options.push({
scope: 'future',
label: 'This and future tasks',
hint: counts.future > 1 ? `${counts.future} occurrences` : undefined,
});
options.push({ scope: 'all', label: 'All tasks in series', hint: `${counts.all} occurrences` }); 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.overlay}>
<View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}> <View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}>
<View style={styles.header}> <View style={styles.header}>
<Text style={[styles.title, { color: theme.text }]}>{subtask ? 'Delete Subtask' : 'Delete Task'}</Text> <Text style={[styles.title, { color: theme.text }]}>
<TouchableOpacity onPress={onClose} style={styles.closeButton} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}> {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"> <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> </Svg>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@@ -64,19 +89,26 @@ export function TaskDeleteModal({ visible, taskId, taskTitle, isRepeating, subta
)} )}
<View style={styles.actions}> <View style={styles.actions}>
{options.map((option) => ( {options.map(option => (
<TouchableOpacity <TouchableOpacity
key={option.scope} key={option.scope}
style={[styles.actionButton, { borderColor: theme.accentBorder }]} style={[styles.actionButton, { borderColor: theme.accentBorder }]}
onPress={() => onDelete(option.scope)} onPress={() => onDelete(option.scope)}
activeOpacity={0.7} activeOpacity={0.7}
> >
<Text style={[styles.actionLabel, { color: theme.accentStrong }]}>{option.label}</Text> <Text style={[styles.actionLabel, { color: theme.accentStrong }]}>
{option.hint && <Text style={[styles.actionHint, { color: theme.textMuted }]}>{option.hint}</Text>} {option.label}
</Text>
{option.hint && (
<Text style={[styles.actionHint, { color: theme.textMuted }]}>{option.hint}</Text>
)}
</TouchableOpacity> </TouchableOpacity>
))} ))}
<TouchableOpacity <TouchableOpacity
style={[styles.cancelButton, { borderColor: theme.borderStrong, backgroundColor: theme.card }]} style={[
styles.cancelButton,
{ borderColor: theme.borderStrong, backgroundColor: theme.card },
]}
onPress={onClose} onPress={onClose}
activeOpacity={0.7} activeOpacity={0.7}
> >
+118 -26
View File
@@ -1,6 +1,19 @@
import React from 'react'; import React from 'react';
import { View, Text, StyleSheet, TouchableOpacity, Animated, Platform, Dimensions } from 'react-native'; import {
import { Swipeable, Gesture, GestureDetector, PanGestureHandler } from 'react-native-gesture-handler'; 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 { TaskData, SubtaskData, Priority, Repeat, Reminder } from '@/types';
import { PRIORITY_COLORS } from '@/constants'; import { PRIORITY_COLORS } from '@/constants';
import { useSettings, mixHex } from '@/theme'; import { useSettings, mixHex } from '@/theme';
@@ -46,7 +59,30 @@ interface TaskItemProps {
categoryColors?: (string | undefined)[]; 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 { theme, deleteBySwipe } = useSettings();
const styles = React.useMemo(() => createStyles(theme), [theme]); const styles = React.useMemo(() => createStyles(theme), [theme]);
const [opacityAnim] = React.useState(new Animated.Value(task.completed ? 0.5 : 1)); const [opacityAnim] = React.useState(new Animated.Value(task.completed ? 0.5 : 1));
@@ -73,17 +109,17 @@ export const TaskItem = React.memo(function TaskItem({ task, onToggle, onDelete,
.activateAfterLongPress(400) .activateAfterLongPress(400)
.minDistance(5) .minDistance(5)
.runOnJS(true) .runOnJS(true)
.onStart((e) => { .onStart(e => {
setDragging(true); setDragging(true);
setDragStartY(e.absoluteY); setDragStartY(e.absoluteY);
onReorderStart?.(); onReorderStart?.();
}) })
.onUpdate((e) => { .onUpdate(e => {
dragTranslateX.setValue(e.translationX); dragTranslateX.setValue(e.translationX);
dragTranslateY.setValue(e.translationY); dragTranslateY.setValue(e.translationY);
onReorderUpdate?.(e.absoluteY); onReorderUpdate?.(e.absoluteY);
}) })
.onEnd((e) => { .onEnd(e => {
onReorderEnd?.(e.absoluteY, e.translationY); onReorderEnd?.(e.absoluteY, e.translationY);
}) })
.onFinalize(() => { .onFinalize(() => {
@@ -104,12 +140,12 @@ export const TaskItem = React.memo(function TaskItem({ task, onToggle, onDelete,
setDragging(true); setDragging(true);
onDragStart?.(); onDragStart?.();
}) })
.onUpdate((e) => { .onUpdate(e => {
dragTranslateX.setValue(e.translationX); dragTranslateX.setValue(e.translationX);
dragTranslateY.setValue(e.translationY); dragTranslateY.setValue(e.translationY);
onDragUpdate?.(e.absoluteY); onDragUpdate?.(e.absoluteY);
}) })
.onEnd((e) => { .onEnd(e => {
onDragEnd?.(e.absoluteY); onDragEnd?.(e.absoluteY);
}) })
.onFinalize(() => { .onFinalize(() => {
@@ -135,7 +171,11 @@ export const TaskItem = React.memo(function TaskItem({ task, onToggle, onDelete,
endOfToday.setHours(23, 59, 59, 999); endOfToday.setHours(23, 59, 59, 999);
const hasDueDate = task.dueDate > 0; const hasDueDate = task.dueDate > 0;
const isOverdue = hasDueDate && !task.completed && task.dueDate < startOfToday.getTime(); const isOverdue = hasDueDate && !task.completed && task.dueDate < startOfToday.getTime();
const isDueToday = hasDueDate && !task.completed && task.dueDate >= startOfToday.getTime() && task.dueDate <= endOfToday.getTime(); const isDueToday =
hasDueDate &&
!task.completed &&
task.dueDate >= startOfToday.getTime() &&
task.dueDate <= endOfToday.getTime();
return { hasDueDate, isOverdue, isDueToday }; return { hasDueDate, isOverdue, isDueToday };
}, [task.dueDate, task.completed]); }, [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 renderRightActions = (progress: Animated.AnimatedInterpolation<number>) => {
const translateX = progress.interpolate({ inputRange: [0, 1], outputRange: [80, 0] }); const translateX = progress.interpolate({ inputRange: [0, 1], outputRange: [80, 0] });
return ( 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"> <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> </Svg>
<Text style={styles.swipeActionText}>Delete</Text> <Text style={styles.swipeActionText}>Delete</Text>
</Animated.View> </Animated.View>
); );
}; };
const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) => { const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) => {
const translateX = progress.interpolate({ inputRange: [0, 1], outputRange: [-80, 0] }); const translateX = progress.interpolate({ inputRange: [0, 1], outputRange: [-80, 0] });
return ( return (
<Animated.View style={[styles.swipeAction, styles.swipeComplete, { transform: [{ translateX }] }]}> <Animated.View
style={[styles.swipeAction, styles.swipeComplete, { transform: [{ translateX }] }]}
>
<Svg width={24} height={24} viewBox="0 0 24 24"> <Svg width={24} height={24} viewBox="0 0 24 24">
<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> </Svg>
<Text style={styles.swipeActionText}>{task.completed ? 'Undo' : 'Done'}</Text> <Text style={styles.swipeActionText}>{task.completed ? 'Undo' : 'Done'}</Text>
</Animated.View> </Animated.View>
@@ -188,7 +246,9 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) =>
overshootRight={false} overshootRight={false}
overshootLeft={false} overshootLeft={false}
> >
<GestureDetector gesture={draggable ? (onReorderStart ? reorderGesture : dragGesture) : Gesture.Native()}> <GestureDetector
gesture={draggable ? (onReorderStart ? reorderGesture : dragGesture) : Gesture.Native()}
>
<Animated.View <Animated.View
style={[ style={[
styles.container, styles.container,
@@ -217,7 +277,7 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) =>
accessibilityState={selectionMode ? { checked: selected } : { expanded }} accessibilityState={selectionMode ? { checked: selected } : { expanded }}
accessibilityHint={selectionMode ? undefined : 'Expands the task to show subtasks'} accessibilityHint={selectionMode ? undefined : 'Expands the task to show subtasks'}
> >
<View style={styles.content}> <View style={styles.content}>
<View style={styles.titleRow}> <View style={styles.titleRow}>
<View style={styles.categoryDotSlot}> <View style={styles.categoryDotSlot}>
{categoryColor && ( {categoryColor && (
@@ -236,10 +296,24 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) =>
{task.completed ? ( {task.completed ? (
<> <>
<Circle cx={12} cy={12} r={10} fill={theme.accent} /> <Circle cx={12} cy={12} r={10} fill={theme.accent} />
<Path d="M7 12l3 3 7-7" stroke="#FFFFFF" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" /> <Path
d="M7 12l3 3 7-7"
stroke="#FFFFFF"
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</> </>
) : ( ) : (
<Circle cx={12} cy={12} r={10} stroke={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> </Svg>
</TouchableOpacity> </TouchableOpacity>
@@ -260,9 +334,7 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) =>
{ backgroundColor: PRIORITY_COLORS[task.priority] }, { backgroundColor: PRIORITY_COLORS[task.priority] },
]} ]}
> >
<Text style={styles.priorityText}> <Text style={styles.priorityText}>{task.priority.charAt(0).toUpperCase()}</Text>
{task.priority.charAt(0).toUpperCase()}
</Text>
</View> </View>
)} )}
{task.assigneeId && ( {task.assigneeId && (
@@ -296,7 +368,14 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) =>
strokeLinecap="round" strokeLinecap="round"
fill="none" fill="none"
/> />
<Circle cx={12} cy={12} r={4} stroke={theme.accent} strokeWidth={1.5} fill="none" /> <Circle
cx={12}
cy={12}
r={4}
stroke={theme.accent}
strokeWidth={1.5}
fill="none"
/>
</Svg> </Svg>
<Text style={styles.allDayText}>All Day</Text> <Text style={styles.allDayText}>All Day</Text>
</View> </View>
@@ -317,8 +396,20 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) =>
{hasDueDate && ( {hasDueDate && (
<View style={styles.dueRow}> <View style={styles.dueRow}>
<Svg width={14} height={14} viewBox="0 0 24 24"> <Svg width={14} height={14} viewBox="0 0 24 24">
<Circle cx={12} cy={12} r={10} stroke={theme.textSecondary} strokeWidth={2} fill="none" /> <Circle
<Path d="M12 6v6l4 2" stroke={theme.textSecondary} strokeWidth={2} strokeLinecap="round" /> 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> </Svg>
<Animated.Text <Animated.Text
style={[ style={[
@@ -400,7 +491,8 @@ function formatTime12(time: string): string {
return `${h}:${String(minutes).padStart(2, '0')} ${period}`; return `${h}:${String(minutes).padStart(2, '0')} ${period}`;
} }
const createStyles = (theme: ThemeColors) => StyleSheet.create({ const createStyles = (theme: ThemeColors) =>
StyleSheet.create({
container: { container: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
@@ -600,4 +692,4 @@ const createStyles = (theme: ThemeColors) => StyleSheet.create({
fontSize: 12, fontSize: 12,
fontWeight: '600', fontWeight: '600',
}, },
}); });
+261 -95
View File
@@ -1,5 +1,13 @@
import React, { useState, useCallback, useMemo, useRef } from 'react'; import React, { useState, useCallback, useMemo, useRef } from 'react';
import { View, Text, StyleSheet, Animated, RefreshControl, Alert, TouchableOpacity } from 'react-native'; import {
View,
Text,
StyleSheet,
Animated,
RefreshControl,
Alert,
TouchableOpacity,
} from 'react-native';
import { useTasks } from '@/hooks/useTasks'; import { useTasks } from '@/hooks/useTasks';
import { useSubtasks } from '@/hooks/useSubtasks'; import { useSubtasks } from '@/hooks/useSubtasks';
import { useCategories } from '@/hooks/useDatabase'; import { useCategories } from '@/hooks/useDatabase';
@@ -42,10 +50,18 @@ const DropIndicator = ({ theme }: { theme: any }) => (
</View> </View>
); );
export function TaskList({ categoryIds = [], showCompleted = false, onSelectionChange }: TaskListProps) { export function TaskList({
categoryIds = [],
showCompleted = false,
onSelectionChange,
}: TaskListProps) {
const { theme, sortBy, todoAheadDays } = useSettings(); 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 categories = useCategories();
const categoryColors = useMemo(() => { const categoryColors = useMemo(() => {
@@ -62,19 +78,30 @@ export function TaskList({ categoryIds = [], showCompleted = false, onSelectionC
const [hoverTaskId, setHoverTaskId] = useState<string | null>(null); const [hoverTaskId, setHoverTaskId] = useState<string | null>(null);
const [expandedTasks, setExpandedTasks] = useState<Set<string>>(new Set()); const [expandedTasks, setExpandedTasks] = useState<Set<string>>(new Set());
const { map: subtasksMap, refresh: refreshSubtasks } = useSubtasks(); const { map: subtasksMap, refresh: refreshSubtasks } = useSubtasks();
const [dropIndicator, setDropIndicator] = useState<{ targetId: string | null; position: 'above' | 'below' } | null>(null); const [dropIndicator, setDropIndicator] = useState<{
targetId: string | null;
position: 'above' | 'below';
} | null>(null);
const itemRefs = useRef<Map<string, View>>(new Map()); const itemRefs = useRef<Map<string, View>>(new Map());
const subtaskRefs = useRef<Map<string, { ref: View; parentTaskId: string }>>(new Map()); const subtaskRefs = useRef<Map<string, { ref: View; parentTaskId: string }>>(new Map());
const dragStateRef = useRef<{ taskId: string; positions: Record<string, { top: number; bottom: number }>; subPositions: Record<string, { top: number; bottom: number }> } | null>(null); const dragStateRef = useRef<{
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); taskId: string;
const taskDragRef = useRef<{ taskId: string; positions: Record<string, { top: number; bottom: number }> } | null>(null); positions: Record<string, { top: number; bottom: number }>;
const { subPositions: Record<string, { top: number; bottom: number }>;
modals, } | null>(null);
openTaskDelete, const subtaskDragRef = useRef<{
openSubtaskDelete, subtaskId: string;
openSubtaskEdit, parentTaskId: string;
openTaskEdit, parentSubtaskId: string | null;
} = useTaskModals(); 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) => { const registerRef = useCallback((taskId: string, ref: View | null) => {
if (ref) { if (ref) {
@@ -84,13 +111,16 @@ export function TaskList({ categoryIds = [], showCompleted = false, onSelectionC
} }
}, []); }, []);
const registerSubtaskRef = useCallback((subtaskId: string, parentTaskId: string, ref: View | null) => { const registerSubtaskRef = useCallback(
(subtaskId: string, parentTaskId: string, ref: View | null) => {
if (ref) { if (ref) {
subtaskRefs.current.set(subtaskId, { ref, parentTaskId }); subtaskRefs.current.set(subtaskId, { ref, parentTaskId });
} else { } else {
subtaskRefs.current.delete(subtaskId); subtaskRefs.current.delete(subtaskId);
} }
}, []); },
[]
);
const categoryColorResolver = useCallback( const categoryColorResolver = useCallback(
(categoryId: string | undefined) => (categoryId ? categoryColors.get(categoryId) : undefined), (categoryId: string | undefined) => (categoryId ? categoryColors.get(categoryId) : undefined),
@@ -101,7 +131,9 @@ export function TaskList({ categoryIds = [], showCompleted = false, onSelectionC
const sorted = [...tasks]; const sorted = [...tasks];
switch (sortBy) { switch (sortBy) {
case 'date': 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; break;
case 'priority': case 'priority':
sorted.sort((a, b) => PRIORITY_RANK[b.priority] - PRIORITY_RANK[a.priority]); sorted.sort((a, b) => PRIORITY_RANK[b.priority] - PRIORITY_RANK[a.priority]);
@@ -146,14 +178,18 @@ export function TaskList({ categoryIds = [], showCompleted = false, onSelectionC
onSelectionChange?.(false); onSelectionChange?.(false);
}, [onSelectionChange]); }, [onSelectionChange]);
const enterSelection = useCallback((taskId: string) => { const enterSelection = useCallback(
(taskId: string) => {
setSelectionMode(true); setSelectionMode(true);
setSelectedIds(new Set([taskId])); setSelectedIds(new Set([taskId]));
onSelectionChange?.(true); onSelectionChange?.(true);
}, [onSelectionChange]); },
[onSelectionChange]
);
const toggleSelect = useCallback((taskId: string) => { const toggleSelect = useCallback(
setSelectedIds((prev) => { (taskId: string) => {
setSelectedIds(prev => {
const next = new Set(prev); const next = new Set(prev);
if (next.has(taskId)) { if (next.has(taskId)) {
next.delete(taskId); next.delete(taskId);
@@ -166,14 +202,19 @@ export function TaskList({ categoryIds = [], showCompleted = false, onSelectionC
} }
return next; return next;
}); });
}, [onSelectionChange]); },
[onSelectionChange]
);
const handleToggle = useCallback(async (taskId: string) => { const handleToggle = useCallback(
async (taskId: string) => {
await confirmAndToggleTaskComplete(taskId, () => refreshTasks()); await confirmAndToggleTaskComplete(taskId, () => refreshTasks());
}, [refreshTasks]); },
[refreshTasks]
);
const toggleExpand = useCallback(async (taskId: string) => { const toggleExpand = useCallback(async (taskId: string) => {
setExpandedTasks((prev) => { setExpandedTasks(prev => {
const next = new Set(prev); const next = new Set(prev);
if (next.has(taskId)) { if (next.has(taskId)) {
next.delete(taskId); next.delete(taskId);
@@ -184,14 +225,20 @@ export function TaskList({ categoryIds = [], showCompleted = false, onSelectionC
}); });
}, []); }, []);
const handleSubtaskToggle = useCallback(async (subtaskId: string) => { const handleSubtaskToggle = useCallback(
async (subtaskId: string) => {
await toggleSubtaskComplete(subtaskId); await toggleSubtaskComplete(subtaskId);
refreshTasks(); refreshTasks();
refreshSubtasks(); refreshSubtasks();
}, [refreshTasks, refreshSubtasks]); },
[refreshTasks, refreshSubtasks]
);
const handleBulkDelete = useCallback(() => { const handleBulkDelete = useCallback(() => {
Alert.alert(`Delete ${selectedIds.size} task${selectedIds.size > 1 ? 's' : ''}?`, 'This cannot be undone.', [ Alert.alert(
`Delete ${selectedIds.size} task${selectedIds.size > 1 ? 's' : ''}?`,
'This cannot be undone.',
[
{ text: 'Cancel', style: 'cancel' }, { text: 'Cancel', style: 'cancel' },
{ {
text: 'Delete', text: 'Delete',
@@ -201,7 +248,8 @@ export function TaskList({ categoryIds = [], showCompleted = false, onSelectionC
exitSelection(); exitSelection();
}, },
}, },
]); ]
);
}, [selectedIds, exitSelection]); }, [selectedIds, exitSelection]);
const handleBulkComplete = useCallback(async () => { const handleBulkComplete = useCallback(async () => {
@@ -212,14 +260,16 @@ export function TaskList({ categoryIds = [], showCompleted = false, onSelectionC
const measureItems = useCallback(async () => { const measureItems = useCallback(async () => {
const positions: Record<string, { top: number; bottom: number }> = {}; const positions: Record<string, { top: number; bottom: number }> = {};
const entries = Array.from(itemRefs.current.entries()); const entries = Array.from(itemRefs.current.entries());
await Promise.all(entries.map(([id, ref]) => { await Promise.all(
return new Promise<void>((resolve) => { entries.map(([id, ref]) => {
return new Promise<void>(resolve => {
ref?.measureInWindow((_x, y, _w, h) => { ref?.measureInWindow((_x, y, _w, h) => {
positions[id] = { top: y, bottom: y + h }; positions[id] = { top: y, bottom: y + h };
resolve(); resolve();
}); });
}); });
})); })
);
return positions; return positions;
}, []); }, []);
@@ -227,18 +277,25 @@ export function TaskList({ categoryIds = [], showCompleted = false, onSelectionC
const positions = await measureItems(); const positions = await measureItems();
const subPositions: Record<string, { top: number; bottom: number }> = {}; const subPositions: Record<string, { top: number; bottom: number }> = {};
const entries = Array.from(subtaskRefs.current.entries()); const entries = Array.from(subtaskRefs.current.entries());
await Promise.all(entries.map(([id, entry]) => { await Promise.all(
return new Promise<void>((resolve) => { entries.map(([id, entry]) => {
return new Promise<void>(resolve => {
entry.ref?.measureInWindow((_x, y, _w, h) => { entry.ref?.measureInWindow((_x, y, _w, h) => {
subPositions[id] = { top: y, bottom: y + h }; subPositions[id] = { top: y, bottom: y + h };
resolve(); resolve();
}); });
}); });
})); })
);
return { positions, subPositions }; return { positions, subPositions };
}, [measureItems]); }, [measureItems]);
const findHoverTask = useCallback((absoluteY: number, draggedId: string, positions: Record<string, { top: number; bottom: number }>) => { const findHoverTask = useCallback(
(
absoluteY: number,
draggedId: string,
positions: Record<string, { top: number; bottom: number }>
) => {
const entries = Object.entries(positions); const entries = Object.entries(positions);
for (let i = entries.length - 1; i >= 0; i--) { for (let i = entries.length - 1; i >= 0; i--) {
const [id, p] = entries[i]; const [id, p] = entries[i];
@@ -248,13 +305,21 @@ export function TaskList({ categoryIds = [], showCompleted = false, onSelectionC
} }
} }
return null; return null;
}, []); },
[]
);
const updateHoverTaskTarget = useCallback((target: string | null) => { const updateHoverTaskTarget = useCallback((target: string | null) => {
setHoverTaskId(target ?? 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 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); const subEntries = Object.entries(subPositions);
for (let i = subEntries.length - 1; i >= 0; i--) { for (let i = subEntries.length - 1; i >= 0; i--) {
const [id, p] = subEntries[i]; const [id, p] = subEntries[i];
@@ -270,21 +335,33 @@ export function TaskList({ categoryIds = [], showCompleted = false, onSelectionC
} }
} }
return null; return null;
}, []); },
[]
);
const calculateDropPosition = useCallback((absoluteY: number, targetId: string, positions: Record<string, { top: number; bottom: number }>) => { const calculateDropPosition = useCallback(
(
absoluteY: number,
targetId: string,
positions: Record<string, { top: number; bottom: number }>
) => {
const target = positions[targetId]; const target = positions[targetId];
if (!target) return 'below' as const; if (!target) return 'below' as const;
const middle = (target.top + target.bottom) / 2; const middle = (target.top + target.bottom) / 2;
return absoluteY < middle ? 'above' : 'below'; return absoluteY < middle ? 'above' : 'below';
}, []); },
[]
);
const handleDragStart = useCallback(async (taskId: string) => { const handleDragStart = useCallback(
async (taskId: string) => {
dragStateRef.current = { taskId, ...(await measureAll()) }; dragStateRef.current = { taskId, ...(await measureAll()) };
}, [measureAll]); },
[measureAll]
);
const refreshDragPositions = useCallback(() => { const refreshDragPositions = useCallback(() => {
measureAll().then((m) => { measureAll().then(m => {
if (subtaskDragRef.current) { if (subtaskDragRef.current) {
subtaskDragRef.current = { ...subtaskDragRef.current, ...m }; subtaskDragRef.current = { ...subtaskDragRef.current, ...m };
} else if (dragStateRef.current) { } else if (dragStateRef.current) {
@@ -293,9 +370,10 @@ export function TaskList({ categoryIds = [], showCompleted = false, onSelectionC
}); });
}, [measureAll]); }, [measureAll]);
const updateHoverTarget = useCallback((target: string | null) => { const updateHoverTarget = useCallback(
(target: string | null) => {
let changed = false; let changed = false;
setHoverTaskId((prev) => { setHoverTaskId(prev => {
if (prev === target) return prev; if (prev === target) return prev;
changed = true; changed = true;
return target; return target;
@@ -303,9 +381,12 @@ export function TaskList({ categoryIds = [], showCompleted = false, onSelectionC
if (changed && target) { if (changed && target) {
setTimeout(refreshDragPositions, 80); setTimeout(refreshDragPositions, 80);
} }
}, [refreshDragPositions]); },
[refreshDragPositions]
);
const handleDragUpdate = useCallback((absoluteY: number) => { const handleDragUpdate = useCallback(
(absoluteY: number) => {
const state = dragStateRef.current; const state = dragStateRef.current;
if (!state) return; if (!state) return;
const target = findHoverTarget(absoluteY, state.taskId, state.positions, state.subPositions); const target = findHoverTarget(absoluteY, state.taskId, state.positions, state.subPositions);
@@ -320,16 +401,22 @@ export function TaskList({ categoryIds = [], showCompleted = false, onSelectionC
} else { } else {
// Check if below last item // Check if below last item
const positions = state.positions; const positions = state.positions;
const lastItem = Object.values(positions).reduce((max, p) => p.bottom > max.bottom ? p : max, { bottom: 0 }); const lastItem = Object.values(positions).reduce(
(max, p) => (p.bottom > max.bottom ? p : max),
{ bottom: 0 }
);
if (absoluteY > lastItem.bottom) { if (absoluteY > lastItem.bottom) {
setDropIndicator({ targetId: null, position: 'below' }); setDropIndicator({ targetId: null, position: 'below' });
} else { } else {
setDropIndicator(null); setDropIndicator(null);
} }
} }
}, [findHoverTarget, calculateDropPosition, updateHoverTarget]); },
[findHoverTarget, calculateDropPosition, updateHoverTarget]
);
const handleDragEnd = useCallback((absoluteY: number) => { const handleDragEnd = useCallback(
(absoluteY: number) => {
const state = dragStateRef.current; const state = dragStateRef.current;
dragStateRef.current = null; dragStateRef.current = null;
setHoverTaskId(null); setHoverTaskId(null);
@@ -349,16 +436,32 @@ export function TaskList({ categoryIds = [], showCompleted = false, onSelectionC
})(); })();
} }
} }
}, [findHoverTarget]); },
[findHoverTarget]
);
const handleSubtaskDragStart = useCallback(async (subtaskId: string, parentTaskId: string, parentSubtaskId: string | null = null) => { const handleSubtaskDragStart = useCallback(
subtaskDragRef.current = { subtaskId, parentTaskId, parentSubtaskId, ...(await measureAll()) }; async (subtaskId: string, parentTaskId: string, parentSubtaskId: string | null = null) => {
}, [measureAll]); subtaskDragRef.current = {
subtaskId,
parentTaskId,
parentSubtaskId,
...(await measureAll()),
};
},
[measureAll]
);
const handleSubtaskDragUpdate = useCallback((absoluteY: number) => { const handleSubtaskDragUpdate = useCallback(
(absoluteY: number) => {
const state = subtaskDragRef.current; const state = subtaskDragRef.current;
if (!state) return; if (!state) return;
const target = findHoverTarget(absoluteY, state.subtaskId, state.positions, state.subPositions); const target = findHoverTarget(
absoluteY,
state.subtaskId,
state.positions,
state.subPositions
);
updateHoverTarget(target); updateHoverTarget(target);
if (target) { if (target) {
if (state.subPositions[target]) { if (state.subPositions[target]) {
@@ -369,23 +472,34 @@ export function TaskList({ categoryIds = [], showCompleted = false, onSelectionC
} }
} else { } else {
const positions = state.positions; const positions = state.positions;
const lastItem = Object.values(positions).reduce((max, p) => p.bottom > max.bottom ? p : max, { bottom: 0 }); const lastItem = Object.values(positions).reduce(
(max, p) => (p.bottom > max.bottom ? p : max),
{ bottom: 0 }
);
if (absoluteY > lastItem.bottom) { if (absoluteY > lastItem.bottom) {
setDropIndicator({ targetId: null, position: 'below' }); setDropIndicator({ targetId: null, position: 'below' });
} else { } else {
setDropIndicator(null); setDropIndicator(null);
} }
} }
}, [findHoverTarget, calculateDropPosition, updateHoverTarget]); },
[findHoverTarget, calculateDropPosition, updateHoverTarget]
);
const handleSubtaskDragEnd = useCallback(async (absoluteY: number) => { const handleSubtaskDragEnd = useCallback(
async (absoluteY: number) => {
const state = subtaskDragRef.current; const state = subtaskDragRef.current;
subtaskDragRef.current = null; subtaskDragRef.current = null;
setHoverTaskId(null); setHoverTaskId(null);
setDropIndicator(null); setDropIndicator(null);
if (!state) return; if (!state) return;
const target = findHoverTarget(absoluteY, state.subtaskId, state.positions, state.subPositions); const target = findHoverTarget(
absoluteY,
state.subtaskId,
state.positions,
state.subPositions
);
if (target === state.subtaskId) return; if (target === state.subtaskId) return;
// Only allow dropping on tasks (not subtasks) to match task behavior // Only allow dropping on tasks (not subtasks) to match task behavior
@@ -396,14 +510,20 @@ const handleSubtaskDragEnd = useCallback(async (absoluteY: number) => {
// If no target or target is a subtask: do nothing (matches task behavior) // If no target or target is a subtask: do nothing (matches task behavior)
await moveSubtaskToTask(state.subtaskId, target); await moveSubtaskToTask(state.subtaskId, target);
} }
}, [findHoverTarget, moveSubtaskToTask]); },
[findHoverTarget, moveSubtaskToTask]
);
const handleTaskDragStart = useCallback(async (taskId: string) => { const handleTaskDragStart = useCallback(
const positions = await measureItems() as Record<string, { top: number; bottom: number }>; async (taskId: string) => {
const positions = (await measureItems()) as Record<string, { top: number; bottom: number }>;
taskDragRef.current = { taskId, positions }; taskDragRef.current = { taskId, positions };
}, [measureItems]); },
[measureItems]
);
const handleTaskDragUpdate = useCallback((absoluteY: number) => { const handleTaskDragUpdate = useCallback(
(absoluteY: number) => {
const state = taskDragRef.current; const state = taskDragRef.current;
if (!state) return; if (!state) return;
const target = findHoverTask(absoluteY, state.taskId, state.positions); const target = findHoverTask(absoluteY, state.taskId, state.positions);
@@ -411,16 +531,22 @@ const handleSubtaskDragEnd = useCallback(async (absoluteY: number) => {
if (target) { if (target) {
setDropIndicator({ targetId: target, position: 'below' }); setDropIndicator({ targetId: target, position: 'below' });
} else { } else {
const lastItem = Object.values(state.positions).reduce((max, p) => p.bottom > max.bottom ? p : max, { bottom: 0 }); const lastItem = Object.values(state.positions).reduce(
(max, p) => (p.bottom > max.bottom ? p : max),
{ bottom: 0 }
);
if (absoluteY > lastItem.bottom) { if (absoluteY > lastItem.bottom) {
setDropIndicator({ targetId: null, position: 'below' }); setDropIndicator({ targetId: null, position: 'below' });
} else { } else {
setDropIndicator(null); setDropIndicator(null);
} }
} }
}, [findHoverTask, updateHoverTaskTarget]); },
[findHoverTask, updateHoverTaskTarget]
);
const handleTaskDragEnd = useCallback(async (absoluteY: number) => { const handleTaskDragEnd = useCallback(
async (absoluteY: number) => {
const state = taskDragRef.current; const state = taskDragRef.current;
taskDragRef.current = null; taskDragRef.current = null;
setHoverTaskId(null); setHoverTaskId(null);
@@ -434,20 +560,27 @@ const handleSubtaskDragEnd = useCallback(async (absoluteY: number) => {
if (target) { if (target) {
setDropIndicator({ targetId: target, position: 'below' }); setDropIndicator({ targetId: target, position: 'below' });
} else { } else {
const lastItem = Object.values(state.positions).reduce((max, p) => p.bottom > max.bottom ? p : max, { bottom: 0 }); const lastItem = Object.values(state.positions).reduce(
(max, p) => (p.bottom > max.bottom ? p : max),
{ bottom: 0 }
);
if (absoluteY > lastItem.bottom) { if (absoluteY > lastItem.bottom) {
setDropIndicator({ targetId: null, position: 'below' }); setDropIndicator({ targetId: null, position: 'below' });
} }
} }
}, [findHoverTask]); },
[findHoverTask]
);
const renderItem = useCallback( const renderItem = useCallback(
({ item, index }: { item: Task; index: number }) => { ({ item, index }: { item: Task; index: number }) => {
const isExpanded = expandedTasks.has(item.id); const isExpanded = expandedTasks.has(item.id);
const itemSubtasks = subtasksMap.get(item.id) ?? []; const itemSubtasks = subtasksMap.get(item.id) ?? [];
const showDropAbove = dropIndicator?.targetId === item.id && dropIndicator?.position === 'above'; const showDropAbove =
const showDropBelow = dropIndicator?.targetId === item.id && dropIndicator?.position === 'below'; dropIndicator?.targetId === item.id && dropIndicator?.position === 'above';
const tagColors = parseTaskTags(item.tags, item.categoryId).map((id) => categoryColors.get(id)); const showDropBelow =
dropIndicator?.targetId === item.id && dropIndicator?.position === 'below';
const tagColors = parseTaskTags(item.tags, item.categoryId).map(id => categoryColors.get(id));
return ( return (
<View> <View>
{showDropAbove && <DropIndicator theme={theme} />} {showDropAbove && <DropIndicator theme={theme} />}
@@ -466,10 +599,10 @@ const handleSubtaskDragEnd = useCallback(async (absoluteY: number) => {
onExpand={toggleExpand} onExpand={toggleExpand}
onSelect={toggleSelect} onSelect={toggleSelect}
onEnterSelection={enterSelection} onEnterSelection={enterSelection}
onMenuOpen={(task) => openTaskEdit(task.id)} onMenuOpen={task => openTaskEdit(task.id)}
onSubtaskToggle={handleSubtaskToggle} onSubtaskToggle={handleSubtaskToggle}
onSubtaskDelete={openSubtaskDelete} onSubtaskDelete={openSubtaskDelete}
onSubtaskMenuOpen={(subtask) => openSubtaskEdit(subtask.id)} onSubtaskMenuOpen={subtask => openSubtaskEdit(subtask.id)}
onDragStart={handleDragStart} onDragStart={handleDragStart}
onDragUpdate={handleDragUpdate} onDragUpdate={handleDragUpdate}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
@@ -523,7 +656,9 @@ const handleSubtaskDragEnd = useCallback(async (absoluteY: number) => {
return ( return (
<View style={styles.emptyState}> <View style={styles.emptyState}>
<Text style={[styles.emptyText, { color: theme.textSecondary }]}>No tasks yet</Text> <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> </View>
); );
}, [sortedTasks.length, theme.textSecondary, theme.textMuted]); }, [sortedTasks.length, theme.textSecondary, theme.textMuted]);
@@ -545,9 +680,16 @@ const handleSubtaskDragEnd = useCallback(async (absoluteY: number) => {
<View style={styles.container}> <View style={styles.container}>
<Animated.FlatList <Animated.FlatList
data={sortedTasks} data={sortedTasks}
keyExtractor={(item) => item.id} keyExtractor={item => item.id}
renderItem={renderItem} renderItem={renderItem}
extraData={{ expandedTasks, subtasksMap, selectedIds, selectionMode, hoverTaskId, dropIndicator }} extraData={{
expandedTasks,
subtasksMap,
selectedIds,
selectionMode,
hoverTaskId,
dropIndicator,
}}
ItemSeparatorComponent={MemoSeparator} ItemSeparatorComponent={MemoSeparator}
ListHeaderComponent={listHeader} ListHeaderComponent={listHeader}
ListFooterComponent={listFooter} ListFooterComponent={listFooter}
@@ -566,16 +708,36 @@ const handleSubtaskDragEnd = useCallback(async (absoluteY: number) => {
{modals(() => {})} {modals(() => {})}
{selectionMode && ( {selectionMode && (
<View style={[styles.selectionBar, { backgroundColor: theme.sheetBg, borderTopColor: theme.border }]}> <View
<TouchableOpacity onPress={exitSelection} style={styles.selectionCancel} activeOpacity={0.7}> 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"> <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> </Svg>
<Text style={[styles.selectionCancelText, { color: theme.textFaint }]}>Cancel</Text> <Text style={[styles.selectionCancelText, { color: theme.textFaint }]}>Cancel</Text>
</TouchableOpacity> </TouchableOpacity>
<Text style={[styles.selectionCount, { color: theme.text }]}>{selectedIds.size} selected</Text> <Text style={[styles.selectionCount, { color: theme.text }]}>
{selectedIds.size} selected
</Text>
<View style={styles.selectionActions}> <View style={styles.selectionActions}>
<SelectionButton label="Complete" color={theme.successStrong} onPress={handleBulkComplete} /> <SelectionButton
label="Complete"
color={theme.successStrong}
onPress={handleBulkComplete}
/>
<SelectionButton label="Delete" color={theme.dangerStrong} onPress={handleBulkDelete} /> <SelectionButton label="Delete" color={theme.dangerStrong} onPress={handleBulkDelete} />
</View> </View>
</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 ( return (
<TouchableOpacity <TouchableOpacity
style={[styles.selectionButton, { backgroundColor: color }]} style={[styles.selectionButton, { backgroundColor: color }]}
@@ -668,10 +838,7 @@ const TaskRow = React.memo(function TaskRow({
[subtasks] [subtasks]
); );
return ( return (
<View <View ref={ref => registerRef(task.id, ref)} style={styles.dragContainer}>
ref={(ref) => registerRef(task.id, ref)}
style={styles.dragContainer}
>
<TaskItem <TaskItem
task={task as unknown as TaskData} task={task as unknown as TaskData}
onToggle={() => onToggle(task.id)} onToggle={() => onToggle(task.id)}
@@ -695,19 +862,19 @@ const TaskRow = React.memo(function TaskRow({
/> />
{expanded && subtasks.length > 0 && ( {expanded && subtasks.length > 0 && (
<View style={styles.subtaskList}> <View style={styles.subtaskList}>
{sortedSubtasks.map((sub) => ( {sortedSubtasks.map(sub => (
<SubtaskItem <SubtaskItem
key={sub.id} key={sub.id}
subtask={sub} subtask={sub}
hoveredId={hoverTaskId} hoveredId={hoverTaskId}
registerRef={registerSubtaskRef} registerRef={registerSubtaskRef}
onToggle={(sub) => onSubtaskToggle(sub.id, task.id)} onToggle={sub => onSubtaskToggle(sub.id, task.id)}
onDelete={() => onSubtaskDelete(sub)} onDelete={() => onSubtaskDelete(sub)}
onMenuOpen={() => onSubtaskMenuOpen(sub)} onMenuOpen={() => onSubtaskMenuOpen(sub)}
selected={selectedIds.has(sub.id)} selected={selectedIds.has(sub.id)}
selectionMode={selectionMode} selectionMode={selectionMode}
draggable draggable
onDragStart={(sub) => onSubtaskDragStart(sub.id, task.id)} onDragStart={sub => onSubtaskDragStart(sub.id, task.id)}
onDragUpdate={onSubtaskDragUpdate} onDragUpdate={onSubtaskDragUpdate}
onDragEnd={onSubtaskDragEnd} onDragEnd={onSubtaskDragEnd}
categoryColor={categoryColor} categoryColor={categoryColor}
@@ -751,8 +918,7 @@ const styles = StyleSheet.create({
paddingTop: 4, paddingTop: 4,
marginBottom: 4, marginBottom: 4,
}, },
dragContainer: { dragContainer: {},
},
dropIndicatorContainer: { dropIndicatorContainer: {
height: 8, height: 8,
justifyContent: 'center', justifyContent: 'center',
@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { View, Text, StyleSheet, TextInput , TextInputProps } from 'react-native'; import { View, Text, StyleSheet, TextInput, TextInputProps } from 'react-native';
import { useSettings, type ThemeColors } from '@/theme'; import { useSettings, type ThemeColors } from '@/theme';
interface TaskNameInputProps extends TextInputProps { interface TaskNameInputProps extends TextInputProps {
@@ -35,7 +35,8 @@ export function TaskNameInput({ error, ...props }: TaskNameInputProps) {
); );
} }
const createStyles = (theme: ThemeColors) => StyleSheet.create({ const createStyles = (theme: ThemeColors) =>
StyleSheet.create({
container: { container: {
gap: 5, gap: 5,
}, },
@@ -68,4 +69,4 @@ const createStyles = (theme: ThemeColors) => StyleSheet.create({
color: theme.dangerStrong, color: theme.dangerStrong,
marginLeft: 4, marginLeft: 4,
}, },
}); });
@@ -49,19 +49,45 @@ export function TaskOverflowMenu({
{ {
key: 'edit', key: 'edit',
label: 'Edit', label: 'Edit',
icon: <Svg width={20} height={20} viewBox="0 0 24 24"><Path d="M4 20h4L19.5 8.5a2.1 2.1 0 0 0-3-3L5 17v3z" stroke={theme.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, onPress: onEdit,
}, },
{ {
key: 'complete', key: 'complete',
label: task.completed ? 'Mark Incomplete' : 'Mark Complete', label: task.completed ? 'Mark Incomplete' : 'Mark Complete',
icon: <Svg width={20} height={20} viewBox="0 0 24 24"><Path d="M5 12l5 5 9-10" stroke={theme.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, onPress: onToggleComplete,
}, },
{ {
key: 'duplicate', key: 'duplicate',
label: 'Duplicate', label: 'Duplicate',
icon: <Svg width={20} height={20} viewBox="0 0 24 24"><Path d="M8 8h12v12H8zM4 16V4h12" stroke={theme.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, onPress: onDuplicate,
}, },
]; ];
@@ -70,7 +96,11 @@ export function TaskOverflowMenu({
actions.push({ actions.push({
key: 'category', key: 'category',
label: 'Change Tags', label: 'Change Tags',
icon: <Svg width={20} height={20} viewBox="0 0 24 24"><Circle2 /></Svg>, icon: (
<Svg width={20} height={20} viewBox="0 0 24 24">
<Circle2 />
</Svg>
),
onPress: onChangeCategory ?? (() => {}), onPress: onChangeCategory ?? (() => {}),
}); });
} }
@@ -79,23 +109,49 @@ export function TaskOverflowMenu({
{ {
key: 'priority', key: 'priority',
label: 'Change Priority', label: 'Change Priority',
icon: <Svg width={20} height={20} viewBox="0 0 24 24"><Path d="M5 3v18M5 5h14l-3 4 3 4H5" stroke={theme.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, onPress: onChangePriority,
}, },
{ {
key: 'delete', key: 'delete',
label: 'Delete', label: 'Delete',
destructive: true, destructive: true,
icon: <Svg width={20} height={20} viewBox="0 0 24 24"><Path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13" stroke={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, onPress: onDelete,
}, }
); );
if (subtask && onAddSubtask) { if (subtask && onAddSubtask) {
actions.push({ actions.push({
key: 'addSubtask', key: 'addSubtask',
label: 'Add Subtask', 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, onPress: onAddSubtask,
}); });
} }
@@ -112,7 +168,7 @@ export function TaskOverflowMenu({
<Text style={[styles.title, { color: theme.text }]} numberOfLines={1}> <Text style={[styles.title, { color: theme.text }]} numberOfLines={1}>
{task.title} {task.title}
</Text> </Text>
{actions.map((action) => ( {actions.map(action => (
<TouchableOpacity <TouchableOpacity
key={action.key} key={action.key}
style={[styles.action, { borderColor: theme.border }]} style={[styles.action, { borderColor: theme.border }]}
@@ -125,7 +181,12 @@ export function TaskOverflowMenu({
accessibilityLabel={`${action.label}${action.destructive ? ' (dangerous)' : ''}`} accessibilityLabel={`${action.label}${action.destructive ? ' (dangerous)' : ''}`}
> >
{action.icon} {action.icon}
<Text style={[styles.actionText, action.destructive ? styles.destructiveText : { color: theme.textSecondary }]}> <Text
style={[
styles.actionText,
action.destructive ? styles.destructiveText : { color: theme.textSecondary },
]}
>
{action.label} {action.label}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
@@ -140,13 +201,19 @@ function Circle2() {
const { theme } = useSettings(); const { theme } = useSettings();
return ( return (
<Svg width={20} height={20} viewBox="0 0 24 24"> <Svg width={20} height={20} viewBox="0 0 24 24">
<Path d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20z" stroke={theme.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} /> <Path d="M12 2a10 10 0 0 1 0 20z" fill={theme.text} opacity={0.35} />
</Svg> </Svg>
); );
} }
const createStyles = (theme: ThemeColors) => StyleSheet.create({ const createStyles = (theme: ThemeColors) =>
StyleSheet.create({
overlay: { overlay: {
flex: 1, flex: 1,
justifyContent: 'flex-end', justifyContent: 'flex-end',
@@ -190,4 +257,4 @@ const createStyles = (theme: ThemeColors) => StyleSheet.create({
color: theme.dangerStrong, color: theme.dangerStrong,
fontWeight: '600', fontWeight: '600',
}, },
}); });
+290 -25
View File
@@ -1,30 +1,295 @@
import { useEffect } from 'react'; import { useEffect, useState, useCallback } from 'react';
import { Alert, Linking } from 'react-native'; import { Modal, View, Text, StyleSheet, Pressable, ScrollView, Platform } from 'react-native';
import { checkForUpdates, wasPromptedFor, markPrompted } from '@/services/updates'; import {
checkForUpdates,
wasPromptedFor,
markPrompted,
downloadAPK,
installAPK,
openReleaseUrl,
AppUpdate,
} from '@/services/updates';
import { useSettings } from '@/theme';
export function UpdateNotifier() { export function UpdateNotifier() {
useEffect(() => { const { theme } = useSettings();
const run = async () => { const [update, setUpdate] = useState<AppUpdate | null>(null);
const update = await checkForUpdates(); const [visible, setVisible] = useState(false);
if (!update || (await wasPromptedFor(update.tagName))) return; const [downloading, setDownloading] = useState(false);
await markPrompted(update.tagName); const [downloadProgress, setDownloadProgress] = useState(0);
const url = update.apkUrl ?? update.releaseUrl; const [downloadError, setDownloadError] = useState<string | null>(null);
Alert.alert(
'Update available', const checkUpdate = useCallback(async () => {
`A new version (${update.version}) is available for download.`, const available = await checkForUpdates();
[ if (!available) return;
{ text: 'Later', style: 'cancel' },
{ const prompted = await wasPromptedFor(available.tagName);
text: 'Download', if (prompted && !available.mandatory) return;
onPress: () => {
if (url) Linking.openURL(url).catch(() => {}); setUpdate(available);
}, setVisible(true);
},
],
);
};
run();
}, []); }, []);
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; 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 ( return (
<TouchableOpacity <TouchableOpacity
style={[styles.row, selected && styles.rowSelected]} style={[styles.row, selected && styles.rowSelected]}
@@ -96,7 +102,10 @@ function WheelColumn({
const indexRef = useRef(initialIndex); const indexRef = useRef(initialIndex);
useEffect(() => { 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; indexRef.current = initialIndex;
}, [initialIndex]); }, [initialIndex]);
@@ -105,7 +114,10 @@ function WheelColumn({
const clamped = Math.min(Math.max(index, 0), data.length - 1); const clamped = Math.min(Math.max(index, 0), data.length - 1);
indexRef.current = clamped; indexRef.current = clamped;
onIndexChange(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] [data.length, onIndexChange]
); );
@@ -126,7 +138,9 @@ function WheelColumn({
const handleScroll = useCallback( const handleScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => { (event: NativeSyntheticEvent<NativeScrollEvent>) => {
const offset = event.nativeEvent.contentOffset.y; 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); const clamped = Math.min(Math.max(index, 0), data.length - 1);
if (clamped !== indexRef.current) { if (clamped !== indexRef.current) {
indexRef.current = clamped; indexRef.current = clamped;
@@ -148,7 +162,7 @@ function WheelColumn({
<FlatList <FlatList
ref={listRef} ref={listRef}
data={data} data={data}
keyExtractor={(item) => String(item)} keyExtractor={item => String(item)}
renderItem={renderItem} renderItem={renderItem}
getItemLayout={(_data, index) => ({ getItemLayout={(_data, index) => ({
length: ITEM_HEIGHT, length: ITEM_HEIGHT,
@@ -164,7 +178,10 @@ function WheelColumn({
onMomentumScrollEnd={handleScrollEnd} onMomentumScrollEnd={handleScrollEnd}
scrollEventThrottle={16} scrollEventThrottle={16}
style={[styles.column, { width }]} style={[styles.column, { width }]}
contentContainerStyle={{ paddingBottom: (VISIBLE_ITEMS - 1) * ITEM_HEIGHT, alignItems: 'stretch' }} contentContainerStyle={{
paddingBottom: (VISIBLE_ITEMS - 1) * ITEM_HEIGHT,
alignItems: 'stretch',
}}
snapToAlignment="center" snapToAlignment="center"
snapToInterval={ITEM_HEIGHT} snapToInterval={ITEM_HEIGHT}
/> />
@@ -207,7 +224,10 @@ export function WheelTimePicker({
<View style={[styles.overlay, { backgroundColor: theme.overlay }]}> <View style={[styles.overlay, { backgroundColor: theme.overlay }]}>
<View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}> <View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}>
<View style={styles.header}> <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> <Text style={[styles.headerAction, { color: theme.textMuted }]}>Cancel</Text>
</TouchableOpacity> </TouchableOpacity>
<View style={styles.headerCenter}> <View style={styles.headerCenter}>
@@ -217,10 +237,16 @@ export function WheelTimePicker({
</Text> </Text>
</View> </View>
<TouchableOpacity <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 }} 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> </TouchableOpacity>
</View> </View>
@@ -6,7 +6,7 @@ export const adapter = new SQLiteAdapter({
schema, schema,
migrations, migrations,
dbName: 'carryyourlive', dbName: 'carryyourlive',
onSetUpError: (error) => { onSetUpError: error => {
console.error('Database setup error:', error); console.error('Database setup error:', error);
}, },
}); });
+1 -1
View File
@@ -8,7 +8,7 @@ export const adapter = new LokiJSAdapter({
dbName: 'carryyourlive', dbName: 'carryyourlive',
useWebWorker: false, useWebWorker: false,
useIncrementalIndexedDB: true, useIncrementalIndexedDB: true,
onSetUpError: (error) => { onSetUpError: error => {
console.error('Database setup error:', 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({ export const migrations = schemaMigrations({
migrations: [ 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_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_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_tasks_completed ON tasks(completed);'),
unsafeExecuteSql('CREATE INDEX IF NOT EXISTS idx_subtasks_updated_at ON subtasks(updated_at);'), unsafeExecuteSql(
unsafeExecuteSql('CREATE INDEX IF NOT EXISTS idx_subtasks_created_at ON subtasks(created_at);'), 'CREATE INDEX IF NOT EXISTS idx_subtasks_updated_at ON subtasks(updated_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(
unsafeExecuteSql('CREATE INDEX IF NOT EXISTS idx_friendships_updated_at ON friendships(updated_at);'), '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);'), unsafeExecuteSql('CREATE INDEX IF NOT EXISTS idx_tasks_series_id ON tasks(series_id);'),
], ],
}, },
+41 -22
View File
@@ -17,8 +17,20 @@ const LAST_PUSHED_AT_KEY = 'sync:lastPushedAt';
const LAST_RUN_AT_KEY = 'sync:lastRunAt'; const LAST_RUN_AT_KEY = 'sync:lastRunAt';
export interface SyncResult { export interface SyncResult {
pushed: { categories: number; tasks: number; subtasks: number; repeatProfiles: number; friendships: number }; pushed: {
pulled: { categories: number; tasks: number; subtasks: number; repeatProfiles: number; friendships: number }; categories: number;
tasks: number;
subtasks: number;
repeatProfiles: number;
friendships: number;
};
pulled: {
categories: number;
tasks: number;
subtasks: number;
repeatProfiles: number;
friendships: number;
};
conflicts: number; conflicts: number;
timestamp: number; timestamp: number;
} }
@@ -150,7 +162,10 @@ export async function runSync(): Promise<SyncResult> {
return result; 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 lastPushedAt = await getLastPushedAt();
const [tasks, subtasks, repeatProfiles, friendships] = await Promise.all([ 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); if (t.categoryId) referencedCategoryIds.add(t.categoryId);
} }
const changedSubtasks = subtasks.map((s) => ({ const changedSubtasks = subtasks.map(s => ({
id: s.id, id: s.id,
taskId: s.taskId, taskId: s.taskId,
parentSubtaskId: s.parentSubtaskId || null, parentSubtaskId: s.parentSubtaskId || null,
@@ -251,7 +266,7 @@ async function pushChanges(): Promise<{ conflicts: PushConflict[]; pushedDeletio
} }
const categories = await categoryQuery.fetch(); const categories = await categoryQuery.fetch();
const changedCategories = categories.map((c) => ({ const changedCategories = categories.map(c => ({
id: c.id, id: c.id,
name: c.name, name: c.name,
color: c.color, color: c.color,
@@ -260,7 +275,7 @@ async function pushChanges(): Promise<{ conflicts: PushConflict[]; pushedDeletio
updatedAt: c.updatedAt.getTime(), updatedAt: c.updatedAt.getTime(),
})); }));
const changedRepeatProfiles = repeatProfiles.map((p) => ({ const changedRepeatProfiles = repeatProfiles.map(p => ({
id: p.id, id: p.id,
name: p.name, name: p.name,
repeat: p.repeat, repeat: p.repeat,
@@ -270,7 +285,7 @@ async function pushChanges(): Promise<{ conflicts: PushConflict[]; pushedDeletio
updatedAt: p.updatedAt.getTime(), updatedAt: p.updatedAt.getTime(),
})); }));
const changedFriendships = friendships.map((f) => ({ const changedFriendships = friendships.map(f => ({
id: f.id, id: f.id,
userId: f.userId, userId: f.userId,
friendId: f.friendId, friendId: f.friendId,
@@ -280,7 +295,7 @@ async function pushChanges(): Promise<{ conflicts: PushConflict[]; pushedDeletio
})); }));
const pendingTombstones = await fetchPendingTombstones(lastPushedAt); const pendingTombstones = await fetchPendingTombstones(lastPushedAt);
const changedDeletions = pendingTombstones.map((t) => ({ const changedDeletions = pendingTombstones.map(t => ({
entity: t.entity, entity: t.entity,
id: t.entityId, id: t.entityId,
updatedAt: t.deletedAt.getTime(), updatedAt: t.deletedAt.getTime(),
@@ -315,7 +330,7 @@ async function pushChanges(): Promise<{ conflicts: PushConflict[]; pushedDeletio
const data = await response.json(); const data = await response.json();
return { return {
conflicts: (data.conflicts ?? []) as PushConflict[], 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 }[] pushedDeletions: { entity: TombstoneEntity; id: string }[]
): Promise<void> { ): Promise<void> {
if (pushedDeletions.length === 0) return; if (pushedDeletions.length === 0) return;
const conflictKeys = new Set(conflicts.map((c) => `${c.entity}:${c.id}`)); const conflictKeys = new Set(conflicts.map(c => `${c.entity}:${c.id}`));
const accepted = pushedDeletions.filter((d) => !conflictKeys.has(`${d.entity}:${d.id}`)); const accepted = pushedDeletions.filter(d => !conflictKeys.has(`${d.entity}:${d.id}`));
const grouped = new Map<TombstoneEntity, string[]>(); const grouped = new Map<TombstoneEntity, string[]>();
for (const t of accepted) { for (const t of accepted) {
const list = grouped.get(t.entity) ?? []; 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 // Apply a server-side deletion to the local DB, cascading to every related
// row (category -> tasks -> subtasks, task -> subtasks, subtask -> children). // row (category -> tasks -> subtasks, task -> subtasks, subtask -> children).
// LWW: if the local row is newer than the deletion, it survives. // 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 { entity, id, updatedAt } = tomb;
const row = await findOrNull<any>(collections[entity as keyof typeof collections] as any, id); const row = await findOrNull<any>(collections[entity as keyof typeof collections] as any, id);
if (row) { if (row) {
@@ -474,7 +493,7 @@ async function destroyLocalEntityCascade(entity: TombstoneEntity, id: string): P
async function upsertCategory(row: ServerRow, force: boolean): Promise<boolean> { async function upsertCategory(row: ServerRow, force: boolean): Promise<boolean> {
const local = await findOrNull<Category>(collections.categories, row.id); const local = await findOrNull<Category>(collections.categories, row.id);
if (!local) { if (!local) {
await collections.categories.create((c) => { await collections.categories.create(c => {
c.name = String(row.name ?? ''); c.name = String(row.name ?? '');
c.color = String(row.color ?? '#9E9E9E'); c.color = String(row.color ?? '#9E9E9E');
c.order = Number(row.order ?? 0); 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)) { if (!force && local.updatedAt.getTime() > Number(row.updatedAt ?? 0)) {
return false; return false;
} }
await local.update((c) => { await local.update(c => {
c.name = String(row.name ?? c.name); c.name = String(row.name ?? c.name);
c.color = String(row.color ?? c.color); c.color = String(row.color ?? c.color);
c.order = Number(row.order ?? c.order); 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> { async function upsertTask(row: ServerRow, force: boolean): Promise<boolean> {
const local = await findOrNull<Task>(collections.tasks, row.id); const local = await findOrNull<Task>(collections.tasks, row.id);
if (!local) { if (!local) {
await collections.tasks.create((t) => { await collections.tasks.create(t => {
t.title = String(row.title ?? ''); t.title = String(row.title ?? '');
t.description = String(row.description ?? ''); t.description = String(row.description ?? '');
t.categoryId = String(row.categoryId ?? ''); t.categoryId = String(row.categoryId ?? '');
@@ -524,7 +543,7 @@ async function upsertTask(row: ServerRow, force: boolean): Promise<boolean> {
if (!force && local.updatedAt.getTime() > Number(row.updatedAt ?? 0)) { if (!force && local.updatedAt.getTime() > Number(row.updatedAt ?? 0)) {
return false; return false;
} }
await local.update((t) => { await local.update(t => {
t.title = String(row.title ?? t.title); t.title = String(row.title ?? t.title);
t.description = String(row.description ?? t.description); t.description = String(row.description ?? t.description);
t.categoryId = String(row.categoryId ?? t.categoryId); t.categoryId = String(row.categoryId ?? t.categoryId);
@@ -550,7 +569,7 @@ async function upsertTask(row: ServerRow, force: boolean): Promise<boolean> {
async function upsertRepeatProfile(row: ServerRow, force: boolean): Promise<boolean> { async function upsertRepeatProfile(row: ServerRow, force: boolean): Promise<boolean> {
const local = await findOrNull<RepeatProfile>(collections.repeatProfiles, row.id); const local = await findOrNull<RepeatProfile>(collections.repeatProfiles, row.id);
if (!local) { if (!local) {
await collections.repeatProfiles.create((p) => { await collections.repeatProfiles.create(p => {
p.name = String(row.name ?? ''); p.name = String(row.name ?? '');
p.repeat = row.repeat ?? 'none'; p.repeat = row.repeat ?? 'none';
p.repeatInterval = Number(row.repeatInterval ?? 1); 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)) { if (!force && local.updatedAt.getTime() > Number(row.updatedAt ?? 0)) {
return false; return false;
} }
await local.update((p) => { await local.update(p => {
p.name = String(row.name ?? p.name); p.name = String(row.name ?? p.name);
p.repeat = row.repeat ?? p.repeat; p.repeat = row.repeat ?? p.repeat;
p.repeatInterval = Number(row.repeatInterval ?? p.repeatInterval); 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); const local = await findOrNull<Subtask>(collections.subtasks, row.id);
if (!local) { if (!local) {
await collections.subtasks.create((s) => { await collections.subtasks.create(s => {
s.taskId = String(row.taskId ?? ''); s.taskId = String(row.taskId ?? '');
s.parentSubtaskId = row.parentSubtaskId == null ? null : String(row.parentSubtaskId); s.parentSubtaskId = row.parentSubtaskId == null ? null : String(row.parentSubtaskId);
s.categoryId = row.categoryId ? String(row.categoryId) : ''; s.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)) { if (!force && local.updatedAt.getTime() > Number(row.updatedAt ?? 0)) {
return false; return false;
} }
await local.update((s) => { await local.update(s => {
s.taskId = String(row.taskId ?? s.taskId); s.taskId = String(row.taskId ?? s.taskId);
s.parentSubtaskId = row.parentSubtaskId == null ? null : String(row.parentSubtaskId); s.parentSubtaskId = row.parentSubtaskId == null ? null : String(row.parentSubtaskId);
s.categoryId = row.categoryId ? String(row.categoryId) : s.categoryId; s.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> { async function upsertFriendship(row: ServerRow, force: boolean): Promise<boolean> {
const local = await findOrNull<Friendship>(collections.friendships, row.id); const local = await findOrNull<Friendship>(collections.friendships, row.id);
if (!local) { if (!local) {
await collections.friendships.create((f) => { await collections.friendships.create(f => {
f.userId = String(row.userId ?? ''); f.userId = String(row.userId ?? '');
f.friendId = String(row.friendId ?? ''); f.friendId = String(row.friendId ?? '');
f.status = (row.status as 'pending' | 'accepted') ?? 'pending'; 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)) { if (!force && local.updatedAt.getTime() > Number(row.updatedAt ?? 0)) {
return false; return false;
} }
await local.update((f) => { await local.update(f => {
f.userId = String(row.userId ?? f.userId); f.userId = String(row.userId ?? f.userId);
f.friendId = String(row.friendId ?? f.friendId); f.friendId = String(row.friendId ?? f.friendId);
f.status = (row.status as 'pending' | 'accepted') ?? f.status; f.status = (row.status as 'pending' | 'accepted') ?? f.status;
+10 -4
View File
@@ -3,7 +3,10 @@ import { Q } from '@nozbe/watermelondb';
import Tombstone, { TombstoneEntity } from '@/models/Tombstone'; import Tombstone, { TombstoneEntity } from '@/models/Tombstone';
// Batch variant: must be called from inside an existing database.write(). // 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); const uniqueIds = [...new Set(ids)].filter(Boolean);
for (const id of uniqueIds) { for (const id of uniqueIds) {
const existing = await collections.tombstones const existing = await collections.tombstones
@@ -11,11 +14,11 @@ export async function recordTombstonesInBatch(entity: TombstoneEntity, ids: stri
.fetch(); .fetch();
const now = new Date(); const now = new Date();
if (existing.length > 0) { if (existing.length > 0) {
await existing[0].update((t) => { await existing[0].update(t => {
t.deletedAt = now; t.deletedAt = now;
}); });
} else { } else {
await collections.tombstones.create((t) => { await collections.tombstones.create(t => {
t.entity = entity; t.entity = entity;
t.entityId = id; t.entityId = id;
t.deletedAt = now; 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(). // 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) { for (const id of ids) {
const existing = await collections.tombstones const existing = await collections.tombstones
.query(Q.where('entity', entity), Q.where('entity_id', id)) .query(Q.where('entity', entity), Q.where('entity_id', id))
+12 -8
View File
@@ -25,7 +25,7 @@ export function DatabaseProvider({ children }: { children: ReactNode }) {
const existingCategories = await collections.categories.query().fetch(); const existingCategories = await collections.categories.query().fetch();
// Merge categories with the same spelling (case-insensitive) // 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 = []; const duplicates: typeof existingCategories = [];
for (const cat of existingCategories) { for (const cat of existingCategories) {
@@ -42,9 +42,13 @@ export function DatabaseProvider({ children }: { children: ReactNode }) {
for (const dup of duplicates) { for (const dup of duplicates) {
const keep = categoriesByName.get(dup.name.toLowerCase())!; const keep = categoriesByName.get(dup.name.toLowerCase())!;
// Reassign tasks from duplicate to kept category // 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) { for (const task of tasksToUpdate) {
await task.update((t) => { t.categoryId = keep.id; }); await task.update(t => {
t.categoryId = keep.id;
});
} }
// Delete duplicate category // Delete duplicate category
await dup.destroyPermanently(); await dup.destroyPermanently();
@@ -57,7 +61,7 @@ export function DatabaseProvider({ children }: { children: ReactNode }) {
if (freshCategories.length === 0) { if (freshCategories.length === 0) {
await database.write(async () => { await database.write(async () => {
for (const cat of DEFAULT_CATEGORIES) { for (const cat of DEFAULT_CATEGORIES) {
await collections.categories.create((c) => { await collections.categories.create(c => {
c.name = cat.name; c.name = cat.name;
c.color = cat.color; c.color = cat.color;
c.order = cat.order; c.order = cat.order;
@@ -115,9 +119,9 @@ export function useCategories(): Category[] {
const subscription = collections.categories const subscription = collections.categories
.query() .query()
.observeWithColumns(['name', 'color', 'order']) .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({ .subscribe({
next: (cats) => { next: cats => {
if (mounted) setCategories(cats); if (mounted) setCategories(cats);
}, },
error: () => {}, error: () => {},
@@ -154,9 +158,9 @@ export function useRepeatProfiles(): RepeatProfile[] {
const subscription = collections.repeatProfiles const subscription = collections.repeatProfiles
.query() .query()
.observeWithColumns(['name', 'repeat', 'repeat_interval', 'repeat_days']) .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({ .subscribe({
next: (rows) => { next: rows => {
if (mounted) setProfiles(rows); if (mounted) setProfiles(rows);
}, },
error: () => {}, error: () => {},
+10 -1
View File
@@ -1,5 +1,14 @@
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react'; 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 { interface FriendsContextType {
friends: Friend[]; friends: Friend[];
+7 -3
View File
@@ -43,7 +43,11 @@ function mapICalEventToTask(event: {
let allDay = event.allDay === true || !event.end; let allDay = event.allDay === true || !event.end;
if (event.allDay) { 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'; dueTime = '00:00';
endTime = '23:59'; endTime = '23:59';
} else { } else {
@@ -143,8 +147,8 @@ export function useICalImport() {
// Insert tasks into WatermelonDB // Insert tasks into WatermelonDB
await Promise.all( await Promise.all(
taskData.map((td) => taskData.map(td =>
collections.tasks.create((t) => { collections.tasks.create(t => {
t.title = td.title; t.title = td.title;
t.description = td.description; t.description = td.description;
t.categoryId = td.categoryId; t.categoryId = td.categoryId;
+13 -12
View File
@@ -83,10 +83,7 @@ export function useStats(): TaskStats {
let mounted = true; let mounted = true;
const compute = () => { const compute = () => {
Promise.all([ Promise.all([collections.tasks.query().fetch(), collections.categories.query().fetch()])
collections.tasks.query().fetch(),
collections.categories.query().fetch(),
])
.then(([allTasks, categories]) => { .then(([allTasks, categories]) => {
const now = Date.now(); const now = Date.now();
const todayStart = startOfDay(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()) const byCategory: CategoryStat[] = Array.from(categoryMap.values())
.map((c) => ({ .map(c => ({
id: c.id, id: c.id,
name: c.name, name: c.name,
color: c.color, color: c.color,
count: categoryCounts.get(c.id) ?? 0, count: categoryCounts.get(c.id) ?? 0,
})) }))
.filter((c) => c.count > 0) .filter(c => c.count > 0)
.sort((a, b) => b.count - a.count); .sort((a, b) => b.count - a.count);
const priorityOrder = ['high', 'medium', 'low', 'none']; const priorityOrder = ['high', 'medium', 'low', 'none'];
const byPriority: PriorityStat[] = priorityOrder const byPriority: PriorityStat[] = priorityOrder
.map((p) => ({ .map(p => ({
priority: p, priority: p,
label: PRIORITY_LABELS[p], label: PRIORITY_LABELS[p],
count: priorityCounts.get(p) ?? 0, count: priorityCounts.get(p) ?? 0,
})) }))
.filter((p) => p.count > 0); .filter(p => p.count > 0);
let streak = 0; let streak = 0;
const todayCount = dayBucket.get(todayStart) ?? 0; const todayCount = dayBucket.get(todayStart) ?? 0;
@@ -165,7 +162,8 @@ export function useStats(): TaskStats {
completedLast30, completedLast30,
activeCount, activeCount,
overdueCount, overdueCount,
completionRate: totalCompleted > 0 completionRate:
totalCompleted > 0
? Math.round((totalCompleted / (totalCompleted + activeCount)) * 100) ? Math.round((totalCompleted / (totalCompleted + activeCount)) * 100)
: 0, : 0,
daily, daily,
@@ -176,13 +174,16 @@ export function useStats(): TaskStats {
} }
}) })
.catch(() => { .catch(() => {
if (mounted) setStats((s) => ({ ...s, loading: false })); if (mounted) setStats(s => ({ ...s, loading: false }));
}); });
}; };
compute(); compute();
const subscription = collections.tasks.query().observe().subscribe({ const subscription = collections.tasks
.query()
.observe()
.subscribe({
next: () => { next: () => {
compute(); compute();
}, },
+2 -2
View File
@@ -62,7 +62,7 @@ export function useSubtasks(): { map: Map<string, SubtaskData[]>; refresh: () =>
const { collections } = useDatabase(); const { collections } = useDatabase();
const [rows, setRows] = useState<any[]>([]); const [rows, setRows] = useState<any[]>([]);
const [refreshKey, setRefreshKey] = useState(0); const [refreshKey, setRefreshKey] = useState(0);
const refresh = useCallback(() => setRefreshKey((k) => k + 1), []); const refresh = useCallback(() => setRefreshKey(k => k + 1), []);
useEffect(() => { useEffect(() => {
let mounted = true; let mounted = true;
@@ -70,7 +70,7 @@ export function useSubtasks(): { map: Map<string, SubtaskData[]>; refresh: () =>
.query() .query()
.observe() .observe()
.subscribe({ .subscribe({
next: (result) => { next: result => {
if (mounted) setRows(result); if (mounted) setRows(result);
}, },
error: () => {}, error: () => {},
+67 -26
View File
@@ -22,7 +22,10 @@ import {
confirmAndToggleTaskComplete, confirmAndToggleTaskComplete,
} from '@/utils/taskActions'; } from '@/utils/taskActions';
const PRIORITY_OPTIONS = Object.entries(PRIORITY_LABELS).map(([value, label]) => ({ value, label })); const PRIORITY_OPTIONS = Object.entries(PRIORITY_LABELS).map(([value, label]) => ({
value,
label,
}));
interface AddSubtaskModalProps { interface AddSubtaskModalProps {
visible: boolean; visible: boolean;
@@ -122,9 +125,7 @@ function AddSubtaskModal({ visible, parentSubtask, onClose, onAdd }: AddSubtaskM
> >
<View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}> <View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}>
<View style={[styles.handle, { backgroundColor: theme.borderStrong }]} /> <View style={[styles.handle, { backgroundColor: theme.borderStrong }]} />
<Text style={styles.title}> <Text style={styles.title}>Add Subtask to &ldquo;{parentSubtask.title}&rdquo;</Text>
Add Subtask to &ldquo;{parentSubtask.title}&rdquo;
</Text>
<View style={styles.inputContainer}> <View style={styles.inputContainer}>
<TextInput <TextInput
style={styles.input} style={styles.input}
@@ -159,21 +160,35 @@ export function useTaskModals() {
const [menuSubtask, setMenuSubtask] = useState<SubtaskData | null>(null); const [menuSubtask, setMenuSubtask] = useState<SubtaskData | null>(null);
const [deleteTarget, setDeleteTarget] = useState<TaskData | null>(null); const [deleteTarget, setDeleteTarget] = useState<TaskData | null>(null);
const [deleteSubtaskTarget, setDeleteSubtaskTarget] = useState<SubtaskData | null>(null); const [deleteSubtaskTarget, setDeleteSubtaskTarget] = useState<SubtaskData | null>(null);
const [picker, setPicker] = useState<null | { type: 'category' | 'priority'; taskId?: string }>(null); const [picker, setPicker] = useState<null | { type: 'category' | 'priority'; taskId?: string }>(
const [subtaskPicker, setSubtaskPicker] = useState<null | { type: 'priority'; subtaskId?: string }>(null); null
);
const [subtaskPicker, setSubtaskPicker] = useState<null | {
type: 'priority';
subtaskId?: string;
}>(null);
const [addSubtaskTarget, setAddSubtaskTarget] = useState<SubtaskData | null>(null); const [addSubtaskTarget, setAddSubtaskTarget] = useState<SubtaskData | null>(null);
const refreshRef = useRef<() => void>(() => {}); const refreshRef = useRef<() => void>(() => {});
const openTaskMenu = useCallback((task: TaskData) => setMenuTask(task), []); const openTaskMenu = useCallback((task: TaskData) => setMenuTask(task), []);
const openSubtaskMenu = useCallback((subtask: SubtaskData) => setMenuSubtask(subtask), []); const openSubtaskMenu = useCallback((subtask: SubtaskData) => setMenuSubtask(subtask), []);
const openTaskDelete = useCallback((task: TaskData) => setDeleteTarget(task), []); const openTaskDelete = useCallback((task: TaskData) => setDeleteTarget(task), []);
const openSubtaskDelete = useCallback((subtask: SubtaskData) => setDeleteSubtaskTarget(subtask), []); const openSubtaskDelete = useCallback(
const openTaskEdit = useCallback((taskId: string) => { (subtask: SubtaskData) => setDeleteSubtaskTarget(subtask),
[]
);
const openTaskEdit = useCallback(
(taskId: string) => {
router.push({ pathname: '/task-detail', params: { id: taskId } }); router.push({ pathname: '/task-detail', params: { id: taskId } });
}, [router]); },
const openSubtaskEdit = useCallback((subtaskId: string) => { [router]
);
const openSubtaskEdit = useCallback(
(subtaskId: string) => {
router.push({ pathname: '/subtask-detail', params: { id: subtaskId } }); router.push({ pathname: '/subtask-detail', params: { id: subtaskId } });
}, [router]); },
[router]
);
const openSubtaskAdd = useCallback((subtask: SubtaskData) => { const openSubtaskAdd = useCallback((subtask: SubtaskData) => {
setAddSubtaskTarget(subtask); setAddSubtaskTarget(subtask);
}, []); }, []);
@@ -197,13 +212,16 @@ export function useTaskModals() {
refreshRef.current(); refreshRef.current();
}, []); }, []);
const handleDeleteScope = useCallback(async (scope: TaskDeleteScope) => { const handleDeleteScope = useCallback(
async (scope: TaskDeleteScope) => {
const target = deleteTarget; const target = deleteTarget;
if (!target) return; if (!target) return;
setDeleteTarget(null); setDeleteTarget(null);
await deleteTaskOccurrences(target.id, scope); await deleteTaskOccurrences(target.id, scope);
refreshRef.current(); refreshRef.current();
}, [deleteTarget]); },
[deleteTarget]
);
const handleDeleteSubtask = useCallback(async () => { const handleDeleteSubtask = useCallback(async () => {
const target = deleteSubtaskTarget; const target = deleteSubtaskTarget;
@@ -213,28 +231,44 @@ export function useTaskModals() {
refreshRef.current(); refreshRef.current();
}, [deleteSubtaskTarget]); }, [deleteSubtaskTarget]);
const handleSingleCategory = useCallback(async (value: string | string[]) => { const handleSingleCategory = useCallback(
async (value: string | string[]) => {
if (picker?.taskId) { if (picker?.taskId) {
await setTaskCategory(picker.taskId, Array.isArray(value) ? value[0] : value); await setTaskCategory(picker.taskId, Array.isArray(value) ? value[0] : value);
refreshRef.current(); refreshRef.current();
} }
}, [picker]); },
[picker]
);
const handleSinglePriority = useCallback(async (value: string | string[]) => { const handleSinglePriority = useCallback(
async (value: string | string[]) => {
if (picker?.taskId) { if (picker?.taskId) {
await setTaskPriority(picker.taskId, (Array.isArray(value) ? value[0] : value) as TaskData['priority']); await setTaskPriority(
picker.taskId,
(Array.isArray(value) ? value[0] : value) as TaskData['priority']
);
refreshRef.current(); refreshRef.current();
} }
}, [picker]); },
[picker]
);
const handleSubtaskPriority = useCallback(async (value: string | string[]) => { const handleSubtaskPriority = useCallback(
async (value: string | string[]) => {
if (subtaskPicker?.subtaskId) { if (subtaskPicker?.subtaskId) {
await setSubtaskPriority(subtaskPicker.subtaskId, (Array.isArray(value) ? value[0] : value) as TaskData['priority']); await setSubtaskPriority(
subtaskPicker.subtaskId,
(Array.isArray(value) ? value[0] : value) as TaskData['priority']
);
refreshRef.current(); refreshRef.current();
} }
}, [subtaskPicker]); },
[subtaskPicker]
);
const handleAddSubtask = useCallback(async (title: string) => { const handleAddSubtask = useCallback(
async (title: string) => {
const target = addSubtaskTarget; const target = addSubtaskTarget;
if (!target || !title.trim()) return; if (!target || !title.trim()) return;
await createSubtask({ await createSubtask({
@@ -244,7 +278,9 @@ export function useTaskModals() {
}); });
setAddSubtaskTarget(null); setAddSubtaskTarget(null);
refreshRef.current(); refreshRef.current();
}, [addSubtaskTarget]); },
[addSubtaskTarget]
);
const modals = (refresh: () => void) => { const modals = (refresh: () => void) => {
refreshRef.current = refresh; refreshRef.current = refresh;
@@ -289,7 +325,9 @@ export function useTaskModals() {
onDelete={() => menuSubtask && openSubtaskDelete(menuSubtask)} onDelete={() => menuSubtask && openSubtaskDelete(menuSubtask)}
onDuplicate={() => menuSubtask && handleSubtaskDuplicate(menuSubtask.id)} onDuplicate={() => menuSubtask && handleSubtaskDuplicate(menuSubtask.id)}
onToggleComplete={() => menuSubtask && handleSubtaskToggle(menuSubtask)} onToggleComplete={() => menuSubtask && handleSubtaskToggle(menuSubtask)}
onChangePriority={() => menuSubtask && setSubtaskPicker({ type: 'priority', subtaskId: menuSubtask.id })} onChangePriority={() =>
menuSubtask && setSubtaskPicker({ type: 'priority', subtaskId: menuSubtask.id })
}
onAddSubtask={() => menuSubtask && openSubtaskAdd(menuSubtask)} onAddSubtask={() => menuSubtask && openSubtaskAdd(menuSubtask)}
/> />
@@ -303,7 +341,10 @@ export function useTaskModals() {
<OptionPickerModal <OptionPickerModal
visible={picker?.type === 'category'} visible={picker?.type === 'category'}
title="Change Tags" title="Change Tags"
options={[{ value: '', label: 'None', color: '#9E9E9E' }, ...categories.map((c) => ({ value: c.id, label: c.name, color: c.color }))]} options={[
{ value: '', label: 'None', color: '#9E9E9E' },
...categories.map(c => ({ value: c.id, label: c.name, color: c.color })),
]}
selectedValue={menuTask?.categoryId} selectedValue={menuTask?.categoryId}
onSelect={handleSingleCategory} onSelect={handleSingleCategory}
onClose={() => setPicker(null)} onClose={() => setPicker(null)}
@@ -318,7 +359,7 @@ export function useTaskModals() {
onClose={() => setPicker(null)} onClose={() => setPicker(null)}
/> />
<OptionPickerModal <OptionPickerModal
visible={subtaskPicker?.type === 'priority'} visible={subtaskPicker?.type === 'priority'}
title="Change Priority" title="Change Priority"
options={PRIORITY_OPTIONS} options={PRIORITY_OPTIONS}
+17 -20
View File
@@ -9,7 +9,7 @@ export function useTasksInMonth(monthDate: Date) {
const [tasks, setTasks] = useState<Task[]>([]); const [tasks, setTasks] = useState<Task[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [refreshKey, setRefreshKey] = useState(0); const [refreshKey, setRefreshKey] = useState(0);
const refresh = useCallback(() => setRefreshKey((k) => k + 1), []); const refresh = useCallback(() => setRefreshKey(k => k + 1), []);
const range = useMemo(() => { const range = useMemo(() => {
const s = startOfMonth(monthDate); const s = startOfMonth(monthDate);
@@ -23,13 +23,10 @@ export function useTasksInMonth(monthDate: Date) {
let mounted = true; let mounted = true;
setLoading(true); setLoading(true);
const subscription = collections.tasks const subscription = collections.tasks
.query( .query(Q.where('due_date', Q.between(range.start, range.end)), Q.sortBy('due_date', 'asc'))
Q.where('due_date', Q.between(range.start, range.end)),
Q.sortBy('due_date', 'asc')
)
.observe() .observe()
.subscribe({ .subscribe({
next: (result) => { next: result => {
if (mounted) { if (mounted) {
setTasks(result); setTasks(result);
setLoading(false); setLoading(false);
@@ -60,12 +57,16 @@ export function useTasksInMonth(monthDate: Date) {
return { tasks, byDay, loading, refresh }; 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 { collections } = useDatabase();
const [tasks, setTasks] = useState<Task[]>([]); const [tasks, setTasks] = useState<Task[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [refreshKey, setRefreshKey] = useState(0); const [refreshKey, setRefreshKey] = useState(0);
const refresh = useCallback(() => setRefreshKey((k) => k + 1), []); const refresh = useCallback(() => setRefreshKey(k => k + 1), []);
const cutoff = useMemo(() => { const cutoff = useMemo(() => {
if (maxAheadDays === undefined) return null; if (maxAheadDays === undefined) return null;
@@ -80,11 +81,8 @@ export function useTasks(categoryIds: string[] = [], showCompleted: boolean | 'a
const conditions: any[] = []; const conditions: any[] = [];
if (categoryIds.length > 0) { if (categoryIds.length > 0) {
const tagConditions = categoryIds.map((id) => const tagConditions = categoryIds.map(id =>
Q.or( Q.or(Q.where('tags', Q.like(`%,${id},%`)), Q.where('category_id', id))
Q.where('tags', Q.like(`%,${id},%`)),
Q.where('category_id', id)
)
); );
conditions.push(tagConditions.length === 1 ? tagConditions[0] : Q.or(...tagConditions)); conditions.push(tagConditions.length === 1 ? tagConditions[0] : Q.or(...tagConditions));
} }
@@ -99,14 +97,13 @@ export function useTasks(categoryIds: string[] = [], showCompleted: boolean | 'a
conditions.push(Q.where('due_date', Q.lte(cutoff))); conditions.push(Q.where('due_date', Q.lte(cutoff)));
} }
const query = conditions.length > 0 const query =
conditions.length > 0
? collections.tasks.query(Q.and(...conditions)) ? collections.tasks.query(Q.and(...conditions))
: collections.tasks.query(); : collections.tasks.query();
const subscription = query const subscription = query.observe().subscribe({
.observe() next: result => {
.subscribe({
next: (result) => {
if (mounted) { if (mounted) {
setTasks(result); setTasks(result);
setLoading(false); setLoading(false);
@@ -131,7 +128,7 @@ export function useTasksByDate(date: Date) {
const [tasks, setTasks] = useState<Task[]>([]); const [tasks, setTasks] = useState<Task[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [refreshKey, setRefreshKey] = useState(0); const [refreshKey, setRefreshKey] = useState(0);
const refresh = useCallback(() => setRefreshKey((k) => k + 1), []); const refresh = useCallback(() => setRefreshKey(k => k + 1), []);
const startOfDay = useMemo(() => { const startOfDay = useMemo(() => {
const d = new Date(date); const d = new Date(date);
@@ -153,7 +150,7 @@ export function useTasksByDate(date: Date) {
) )
.observe() .observe()
.subscribe({ .subscribe({
next: (result) => { next: result => {
if (mounted) { if (mounted) {
setTasks(result); setTasks(result);
setLoading(false); setLoading(false);
+2 -1
View File
@@ -1,7 +1,8 @@
import { Model } from '@nozbe/watermelondb'; import { Model } from '@nozbe/watermelondb';
import { field, date } from '@nozbe/watermelondb/decorators'; 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 { export default class Tombstone extends Model {
static table = 'tombstones'; 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; 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', { const response = await apiFetch('/auth/forgot-password', {
method: 'POST', method: 'POST',
body: JSON.stringify({ username }), body: JSON.stringify({ username }),
@@ -157,7 +159,10 @@ export async function forgotPassword(username: string): Promise<{ success: boole
return data; 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', { const response = await apiFetch('/auth/reset-password', {
method: 'POST', method: 'POST',
body: JSON.stringify({ token, password }), body: JSON.stringify({ token, password }),
+24 -3
View File
@@ -1,6 +1,13 @@
import { Platform } from 'react-native'; import { Platform } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import { Reminder, reminderToMinutes, parseReminders, ReminderPreference, parseTaskTags } from '@/types'; import { useSettings } from '@/theme';
import {
Reminder,
reminderToMinutes,
parseReminders,
ReminderPreference,
parseTaskTags,
} from '@/types';
import { TagReminderPreferences, TAG_REMINDER_PREFERENCES_KEY } from '@/theme'; import { TagReminderPreferences, TAG_REMINDER_PREFERENCES_KEY } from '@/theme';
const ID_MAP_KEY = 'notifications:taskIdToNotificationIds'; const ID_MAP_KEY = 'notifications:taskIdToNotificationIds';
@@ -95,7 +102,11 @@ function getRemindersForTask(
return []; 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 notifications = await getNotificationsModule();
const dueDate = task.dueDate ? new Date(task.dueDate) : null; const dueDate = task.dueDate ? new Date(task.dueDate) : null;
@@ -207,7 +218,11 @@ export async function cancelAllReminders(): Promise<void> {
await writeIdMap(idMap); 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) { if (!notificationsEnabled) {
await cancelAllReminders(); await cancelAllReminders();
return; return;
@@ -265,3 +280,9 @@ export async function rescheduleAllReminders(tasks: any[], notificationsEnabled:
await writeIdMap(idMap); 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 { 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({ const tokenData = await Notifications.getExpoPushTokenAsync({
projectId, projectId,
}); });
@@ -98,7 +100,9 @@ export function addNotificationResponseReceivedListener(
return Notifications.addNotificationResponseReceivedListener(listener); return Notifications.addNotificationResponseReceivedListener(listener);
} }
export function removeNotificationSubscription(subscription: Notifications.EventSubscription): void { export function removeNotificationSubscription(
subscription: Notifications.EventSubscription
): void {
subscription.remove(); subscription.remove();
} }
+72 -4
View File
@@ -1,5 +1,8 @@
import Constants from 'expo-constants'; import Constants from 'expo-constants';
import AsyncStorage from '@react-native-async-storage/async-storage'; 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_BASE_URL = 'https://gitea.techmos.org';
const GITEA_OWNER = 'tech08mag'; const GITEA_OWNER = 'tech08mag';
@@ -12,6 +15,8 @@ export interface AppUpdate {
notes: string; notes: string;
apkUrl: string | null; apkUrl: string | null;
releaseUrl: string; releaseUrl: string;
mandatory: boolean;
publishedAt: string;
} }
export function getCurrentAppVersion(): 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> { export async function fetchLatestRelease(): Promise<AppUpdate | null> {
try { try {
const response = await fetchWithTimeout( const response = await fetchWithTimeout(
`/api/v1/repos/${GITEA_OWNER}/${GITEA_REPO}/releases/latest`, `/api/v1/repos/${GITEA_OWNER}/${GITEA_REPO}/releases/latest`,
8000, 8000
); );
if (!response.ok) return null; if (!response.ok) return null;
const release = await response.json(); const release = await response.json();
const apkAsset = (release.assets ?? []).find( 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 tagName: string = release.tag_name ?? release.name ?? '';
const notes: string = release.body ?? '';
return { return {
version: tagName.replace(/^v/i, ''), version: tagName.replace(/^v/i, ''),
tagName, tagName,
notes: release.body ?? '', notes,
apkUrl: apkAsset?.browser_download_url ?? null, apkUrl: apkAsset?.browser_download_url ?? null,
releaseUrl: releaseUrl:
release.html_url ?? release.html_url ??
`${GITEA_BASE_URL}/${GITEA_OWNER}/${GITEA_REPO}/releases/tag/${tagName}`, `${GITEA_BASE_URL}/${GITEA_OWNER}/${GITEA_REPO}/releases/tag/${tagName}`,
mandatory: parseMandatoryFromNotes(notes, tagName),
publishedAt: release.published_at ?? new Date().toISOString(),
}; };
} catch { } catch {
return null; return null;
@@ -56,7 +71,7 @@ export async function fetchLatestRelease(): Promise<AppUpdate | null> {
export function compareVersions(a: string, b: string): number { export function compareVersions(a: string, b: string): number {
const parse = (value: string) => 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 left = parse(a);
const right = parse(b); const right = parse(b);
const length = Math.max(left.length, right.length); const length = Math.max(left.length, right.length);
@@ -92,3 +107,56 @@ export async function markPrompted(tagName: string): Promise<void> {
// ignore // 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 AsyncStorage from '@react-native-async-storage/async-storage';
import { DEFAULT_API_BASE_URL, API_URL_KEY } from '@/services/auth'; import { DEFAULT_API_BASE_URL, API_URL_KEY } from '@/services/auth';
import { Platform } from 'react-native';
export type SortBy = 'date' | 'priority' | 'alpha' | 'created'; 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 const TAG_REMINDER_PREFERENCES_KEY = 'settings:tagReminderPreferences';
export type ThemeMode = 'light' | 'dark' | 'system';
export interface ThemeColors { export interface ThemeColors {
background: string; background: string;
card: string; card: string;
@@ -75,7 +86,6 @@ export const ACCENT_PRESETS: string[] = [
function parseHex(hex: string): [number, number, number] { function parseHex(hex: string): [number, number, number] {
const h = hex.replace(/^#/, ''); const h = hex.replace(/^#/, '');
// Handle both #RRGGBB (6 digits) and #AARRGGBB (8 digits with alpha)
if (h.length === 8) { if (h.length === 8) {
return [parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16), parseInt(h.slice(6, 8), 16)]; 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 { 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 { 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 { 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 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); const luminance = 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b);
return luminance > 0.5 ? '#111111' : '#FFFFFF'; 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 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 { return {
background: '#121212', background: '#121212',
card: '#1E1E1E', card: '#1E1E1E',
@@ -162,7 +212,11 @@ interface SettingsContextType {
setShowCompleted: (value: boolean) => void; setShowCompleted: (value: boolean) => void;
deleteBySwipe: boolean; deleteBySwipe: boolean;
setDeleteBySwipe: (value: boolean) => void; setDeleteBySwipe: (value: boolean) => void;
themeMode: ThemeMode;
setThemeMode: (value: ThemeMode) => void;
theme: ThemeColors; theme: ThemeColors;
syncSettingsToServer: () => Promise<void>;
loadSettingsFromServer: () => Promise<void>;
} }
export const DEFAULT_TODO_AHEAD_DAYS = 7; export const DEFAULT_TODO_AHEAD_DAYS = 7;
@@ -178,17 +232,43 @@ const STORAGE_KEYS = {
todoAheadDays: 'settings:todoAheadDays', todoAheadDays: 'settings:todoAheadDays',
showCompleted: 'settings:showCompleted', showCompleted: 'settings:showCompleted',
deleteBySwipe: 'settings:deleteBySwipe', deleteBySwipe: 'settings:deleteBySwipe',
themeMode: 'settings:themeMode',
}; };
const SettingsContext = createContext<SettingsContextType | null>(null); 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] { function useStoredSetting<T>(key: string, initialValue: T): [T, (value: T) => void, boolean] {
const [value, setValue] = useState<T>(initialValue); const [value, setValue] = useState<T>(initialValue);
const [loaded, setLoaded] = useState(false); const [loaded, setLoaded] = useState(false);
useEffect(() => { useEffect(() => {
AsyncStorage.getItem(key) AsyncStorage.getItem(key)
.then((stored) => { .then(stored => {
if (stored !== null) { if (stored !== null) {
setValue(JSON.parse(stored) as T); 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 }) { export function SettingsProvider({ children }: { children: ReactNode }) {
const [notifications, setNotifications] = useStoredSetting<boolean>(STORAGE_KEYS.notifications, true); const [notifications, setNotifications, notificationsLoaded] = useStoredSetting<boolean>(
const [defaultCategoryId, setDefaultCategoryId] = useStoredSetting<string>(STORAGE_KEYS.defaultCategoryId, ''); STORAGE_KEYS.notifications,
true
);
const [defaultCategoryId, setDefaultCategoryId] = useStoredSetting<string>(
STORAGE_KEYS.defaultCategoryId,
''
);
const [sortBy, setSortBy] = useStoredSetting<SortBy>(STORAGE_KEYS.sortBy, 'date'); const [sortBy, setSortBy] = useStoredSetting<SortBy>(STORAGE_KEYS.sortBy, 'date');
const [reminderPreference, setReminderPreference] = useStoredSetting<ReminderPreference>( const [reminderPreference, setReminderPreference] = useStoredSetting<ReminderPreference>(
STORAGE_KEYS.reminderPreference, STORAGE_KEYS.reminderPreference,
'15', '15'
);
const [tagReminderPreferences, setTagReminderPreferences] = useStoredSetting<TagReminderPreferences>(
STORAGE_KEYS.tagReminderPreferences,
{},
); );
const [tagReminderPreferences, setTagReminderPreferences] =
useStoredSetting<TagReminderPreferences>(STORAGE_KEYS.tagReminderPreferences, {});
const [apiUrl, setApiUrl] = useStoredSetting<string>(API_URL_KEY, DEFAULT_API_BASE_URL); const [apiUrl, setApiUrl] = useStoredSetting<string>(API_URL_KEY, DEFAULT_API_BASE_URL);
const [accentColor, setAccentColor] = useStoredSetting<string>(STORAGE_KEYS.accentColor, DEFAULT_ACCENT); const [accentColor, setAccentColor] = useStoredSetting<string>(
const [accentOpacity, setAccentOpacity] = useStoredSetting<number>(STORAGE_KEYS.accentOpacity, DEFAULT_ACCENT_OPACITY); STORAGE_KEYS.accentColor,
const [todoAheadDays, setTodoAheadDays] = useStoredSetting<number>(STORAGE_KEYS.todoAheadDays, DEFAULT_TODO_AHEAD_DAYS); DEFAULT_ACCENT
const [showCompleted, setShowCompleted] = useStoredSetting<boolean>(STORAGE_KEYS.showCompleted, false); );
const [deleteBySwipe, setDeleteBySwipe] = useStoredSetting<boolean>(STORAGE_KEYS.deleteBySwipe, false); 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>( const value = useMemo<SettingsContextType>(
() => ({ () => ({
@@ -253,7 +420,11 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
setShowCompleted, setShowCompleted,
deleteBySwipe, deleteBySwipe,
setDeleteBySwipe, setDeleteBySwipe,
themeMode,
setThemeMode,
theme, theme,
syncSettingsToServer,
loadSettingsFromServer,
}), }),
[ [
notifications, notifications,
@@ -270,18 +441,23 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
setApiUrl, setApiUrl,
accentColor, accentColor,
setAccentColor, setAccentColor,
accentOpacity,
setAccentOpacity,
todoAheadDays, todoAheadDays,
setTodoAheadDays, setTodoAheadDays,
showCompleted, showCompleted,
setShowCompleted, setShowCompleted,
deleteBySwipe,
setDeleteBySwipe,
themeMode,
setThemeMode,
theme,
syncSettingsToServer,
loadSettingsFromServer,
] ]
); );
return ( return <SettingsContext.Provider value={value}>{children}</SettingsContext.Provider>;
<SettingsContext.Provider value={value}>
{children}
</SettingsContext.Provider>
);
} }
export function useSettings() { export function useSettings() {
+6 -6
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 const WEEKDAY_ORDER = [1, 2, 3, 4, 5, 6, 0];
export function weekdaysInDisplayOrder(days: number[]): number[] { 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 { export function repeatDaysToString(days: number[]): string {
@@ -28,7 +28,7 @@ export function repeatDaysFromString(raw: string): number[] {
return (raw || '') return (raw || '')
.split(',') .split(',')
.map(Number) .map(Number)
.filter((d) => !Number.isNaN(d)); .filter(d => !Number.isNaN(d));
} }
export interface RepeatProfileData { export interface RepeatProfileData {
@@ -53,7 +53,7 @@ export const REMINDER_OPTIONS: { value: Reminder; label: string }[] = [
{ value: '1440', label: '1 day before' }, { 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 { export function reminderToMinutes(reminder: Reminder): number | null {
if (reminder === 'none') return null; if (reminder === 'none') return null;
@@ -86,12 +86,12 @@ export function parseReminders(reminders: string): Reminder[] {
if (!reminders) return []; if (!reminders) return [];
return reminders return reminders
.split(',') .split(',')
.map((r) => r.trim() as Reminder) .map(r => r.trim() as Reminder)
.filter((r): r is Exclude<Reminder, 'none'> => r !== 'none' && Boolean(r)); .filter((r): r is Exclude<Reminder, 'none'> => r !== 'none' && Boolean(r));
} }
export function toRemindersString(reminders: Reminder[]): string { export function toRemindersString(reminders: Reminder[]): string {
return reminders.filter((r) => r !== 'none').join(','); return reminders.filter(r => r !== 'none').join(',');
} }
export interface CategoryData { export interface CategoryData {
@@ -109,7 +109,7 @@ export function tagsFromString(raw: string | null | undefined): string[] {
if (!raw) return []; if (!raw) return [];
return raw return raw
.split(',') .split(',')
.map((t) => t.trim()) .map(t => t.trim())
.filter(Boolean); .filter(Boolean);
} }
+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 existing = await collections.categories.query().fetch();
const nextOrder = existing.reduce((max, c) => Math.max(max, c.order), -1) + 1; const nextOrder = existing.reduce((max, c) => Math.max(max, c.order), -1) + 1;
const now = new Date(); const now = new Date();
await collections.categories.create((c) => { await collections.categories.create(c => {
c.name = name; c.name = name;
c.color = color; c.color = color;
c.order = nextOrder; 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 () => { await database.write(async () => {
const category = await collections.categories.find(categoryId); const category = await collections.categories.find(categoryId);
await category.update((c) => { await category.update(c => {
c.name = name; c.name = name;
c.color = color; c.color = color;
c.updatedAt = new Date(); 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> { export async function deleteCategory(categoryId: string): Promise<void> {
await database.write(async () => { 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 fallback = others[0];
const tasks = await collections.tasks.query(Q.where('category_id', categoryId)).fetch(); const tasks = await collections.tasks.query(Q.where('category_id', categoryId)).fetch();
for (const task of tasks) { for (const task of tasks) {
await task.update((t) => { await task.update(t => {
const tags = parseTaskTags(t.tags, t.categoryId).filter((id) => id !== categoryId); const tags = parseTaskTags(t.tags, t.categoryId).filter(id => id !== categoryId);
t.categoryId = fallback?.id ?? tags[0] ?? ''; t.categoryId = fallback?.id ?? tags[0] ?? '';
t.tags = tagsToString(tags); t.tags = tagsToString(tags);
t.updatedAt = new Date(); 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) { for (const task of tagReferencedTasks) {
const tags = parseTaskTags(task.tags, task.categoryId).filter((id) => id !== categoryId); const tags = parseTaskTags(task.tags, task.categoryId).filter(id => id !== categoryId);
await task.update((t) => { await task.update(t => {
t.tags = tagsToString(tags); t.tags = tagsToString(tags);
t.updatedAt = new Date(); t.updatedAt = new Date();
}); });
+7 -1
View File
@@ -8,7 +8,13 @@ type ConfirmOptions = {
onConfirm: () => void; 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') { if (Platform.OS === 'web') {
const confirmed = (globalThis as any).confirm(message ? `${title}\n\n${message}` : title); const confirmed = (globalThis as any).confirm(message ? `${title}\n\n${message}` : title);
if (confirmed) onConfirm(); if (confirmed) onConfirm();
+1 -1
View File
@@ -5,7 +5,7 @@ let pending = false;
export function requestQuickAddFocus(): void { export function requestQuickAddFocus(): void {
pending = true; pending = true;
listeners.forEach((listener) => listener()); listeners.forEach(listener => listener());
} }
export function subscribeToQuickAdd(listener: QuickAddListener): () => void { export function subscribeToQuickAdd(listener: QuickAddListener): () => void {
@@ -10,7 +10,7 @@ export async function createRepeatProfile(
): Promise<void> { ): Promise<void> {
const now = new Date(); const now = new Date();
await database.write(async () => { await database.write(async () => {
await collections.repeatProfiles.create((p) => { await collections.repeatProfiles.create(p => {
p.name = name.trim(); p.name = name.trim();
p.repeat = repeat; p.repeat = repeat;
p.repeatInterval = interval || 1; p.repeatInterval = interval || 1;
+103 -66
View File
@@ -32,12 +32,16 @@ function mapSubtaskRow(s: any, taskId?: string): SubtaskData {
} }
export async function fetchSubtaskTree(taskId: string): Promise<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 items: SubtaskData[] = topLevel.map((s: any) => mapSubtaskRow(s, taskId));
const decorate = async (subtasks: SubtaskData[]): Promise<SubtaskData[]> => { const decorate = async (subtasks: SubtaskData[]): Promise<SubtaskData[]> => {
for (const sub of subtasks) { 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) { if (children.length > 0) {
sub.subtasks = await decorate(children.map((c: any) => mapSubtaskRow(c, taskId))); sub.subtasks = await decorate(children.map((c: any) => mapSubtaskRow(c, taskId)));
} }
@@ -70,7 +74,12 @@ function startOfDay(date: Date): Date {
return d; 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; const msDay = 86400000;
for (let offset = 1; offset <= intervalWeeks * 7 + 7; offset++) { for (let offset = 1; offset <= intervalWeeks * 7 + 7; offset++) {
@@ -95,8 +104,8 @@ export function parseRepeatDays(repeatDays: string): number[] {
if (!repeatDays) return []; if (!repeatDays) return [];
return repeatDays return repeatDays
.split(',') .split(',')
.map((d) => parseInt(d, 10)) .map(d => parseInt(d, 10))
.filter((d) => !Number.isNaN(d) && d >= 0 && d <= 6); .filter(d => !Number.isNaN(d) && d >= 0 && d <= 6);
} }
export function computeNextOccurrence( export function computeNextOccurrence(
@@ -152,7 +161,10 @@ export async function deleteTaskWithSubtasks(taskId: string): Promise<void> {
const task = await collections.tasks.find(taskId); const task = await collections.tasks.find(taskId);
await task.destroyPermanently(); await task.destroyPermanently();
if (subtasks.length > 0) { if (subtasks.length > 0) {
await recordTombstonesInBatch('subtasks', subtasks.map((s) => s.id)); await recordTombstonesInBatch(
'subtasks',
subtasks.map(s => s.id)
);
} }
await recordTombstonesInBatch('tasks', [taskId]); 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 seriesTasks = await collections.tasks.query(Q.where('series_id', task.seriesId)).fetch();
const anchor = task.dueDate || task.createdAt.getTime(); const anchor = task.dueDate || task.createdAt.getTime();
const toDelete = seriesTasks.filter((t) => { const toDelete = seriesTasks.filter(t => {
if (scope === 'all') return true; if (scope === 'all') return true;
return (t.dueDate || t.createdAt.getTime()) >= anchor; 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); const task = await collections.tasks.find(taskId);
if (!task.seriesId || task.repeat === 'none') { if (!task.seriesId || task.repeat === 'none') {
return { future: 1, all: 1 }; return { future: 1, all: 1 };
} }
const seriesTasks = await collections.tasks.query(Q.where('series_id', task.seriesId)).fetch(); const seriesTasks = await collections.tasks.query(Q.where('series_id', task.seriesId)).fetch();
const anchor = task.dueDate || task.createdAt.getTime(); 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 }; return { future, all: seriesTasks.length };
} }
@@ -202,7 +216,7 @@ export async function toggleTaskComplete(taskId: string): Promise<string | null>
await database.write(async () => { await database.write(async () => {
const task = await collections.tasks.find(taskId); const task = await collections.tasks.find(taskId);
const completing = !task.completed; const completing = !task.completed;
await task.update((t) => { await task.update(t => {
t.completed = completing; t.completed = completing;
t.completedAt = completing ? Date.now() : null; t.completedAt = completing ? Date.now() : null;
t.updatedAt = new Date(); 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> { export async function countOpenSubtasks(taskId: string): Promise<number> {
const open = await collections.subtasks.query( const open = await collections.subtasks
Q.where('task_id', taskId), .query(Q.where('task_id', taskId), Q.where('completed', false))
Q.where('completed', false) .fetch();
).fetch();
return open.length; return open.length;
} }
@@ -266,12 +279,12 @@ export async function confirmAndToggleTaskComplete(
export async function toggleSubtaskComplete(subtaskId: string): Promise<void> { export async function toggleSubtaskComplete(subtaskId: string): Promise<void> {
await database.write(async () => { await database.write(async () => {
const subtask = await collections.subtasks.find(subtaskId); const subtask = await collections.subtasks.find(subtaskId);
await subtask.update((s) => { await subtask.update(s => {
s.completed = !s.completed; s.completed = !s.completed;
s.updatedAt = new Date(); s.updatedAt = new Date();
}); });
const task = await collections.tasks.find(subtask.taskId); const task = await collections.tasks.find(subtask.taskId);
await task.update((t) => { await task.update(t => {
t.updatedAt = new Date(); t.updatedAt = new Date();
}); });
}); });
@@ -300,7 +313,7 @@ export interface SubtaskUpdateData {
export async function updateSubtask(subtaskId: string, data: SubtaskUpdateData): Promise<void> { export async function updateSubtask(subtaskId: string, data: SubtaskUpdateData): Promise<void> {
await database.write(async () => { await database.write(async () => {
const subtask = await collections.subtasks.find(subtaskId); const subtask = await collections.subtasks.find(subtaskId);
await subtask.update((s) => { await subtask.update(s => {
s.title = data.title.trim(); s.title = data.title.trim();
s.description = data.description || ''; s.description = data.description || '';
if (data.categoryId !== undefined) s.categoryId = data.categoryId; if (data.categoryId !== undefined) s.categoryId = data.categoryId;
@@ -318,7 +331,7 @@ export async function updateSubtask(subtaskId: string, data: SubtaskUpdateData):
s.updatedAt = new Date(); s.updatedAt = new Date();
}); });
const task = await collections.tasks.find(subtask.taskId); const task = await collections.tasks.find(subtask.taskId);
await task.update((t) => { await task.update(t => {
t.updatedAt = new Date(); t.updatedAt = new Date();
}); });
}); });
@@ -332,9 +345,11 @@ export async function deleteSubtask(subtaskId: string): Promise<void> {
await database.write(async () => { await database.write(async () => {
const subtask = await collections.subtasks.find(subtaskId); const subtask = await collections.subtasks.find(subtaskId);
parentTaskId = subtask.taskId; 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) { for (const child of children) {
await child.update((c) => { await child.update(c => {
c.parentSubtaskId = null; c.parentSubtaskId = null;
c.updatedAt = new Date(); c.updatedAt = new Date();
}); });
@@ -347,7 +362,7 @@ export async function deleteSubtask(subtaskId: string): Promise<void> {
try { try {
await database.write(async () => { await database.write(async () => {
const task = await collections.tasks.find(parentTaskId!); const task = await collections.tasks.find(parentTaskId!);
await task.update((t) => { await task.update(t => {
t.updatedAt = new Date(); 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 siblings = await collections.subtasks.query(Q.where('task_id', subtask.taskId)).fetch();
const now = new Date(); const now = new Date();
clone = await collections.subtasks.create((s) => { clone = await collections.subtasks.create(s => {
s.taskId = subtask.taskId; s.taskId = subtask.taskId;
s.categoryId = subtask.categoryId || ''; s.categoryId = subtask.categoryId || '';
s.tags = subtask.tags || ''; s.tags = subtask.tags || '';
@@ -388,7 +403,7 @@ export async function duplicateSubtask(subtaskId: string): Promise<void> {
}); });
const task = await collections.tasks.find(subtask.taskId); const task = await collections.tasks.find(subtask.taskId);
await task.update((t) => { await task.update(t => {
t.updatedAt = new Date(); 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> { export async function setSubtaskPriority(subtaskId: string, priority: Priority): Promise<void> {
await database.write(async () => { await database.write(async () => {
const subtask = await collections.subtasks.find(subtaskId); const subtask = await collections.subtasks.find(subtaskId);
await subtask.update((s) => { await subtask.update(s => {
s.priority = priority; s.priority = priority;
s.updatedAt = new Date(); s.updatedAt = new Date();
}); });
const task = await collections.tasks.find(subtask.taskId); const task = await collections.tasks.find(subtask.taskId);
await task.update((t) => { await task.update(t => {
t.updatedAt = new Date(); 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 () => { await database.write(async () => {
if (nextOccurrenceId) { if (nextOccurrenceId) {
try { try {
@@ -423,7 +441,7 @@ export async function undoTaskComplete(taskId: string, nextOccurrenceId: string
try { try {
const task = await collections.tasks.find(taskId); const task = await collections.tasks.find(taskId);
if (task.completed) { if (task.completed) {
await task.update((t) => { await task.update(t => {
t.completed = false; t.completed = false;
t.updatedAt = new Date(); 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; if (taskId === parentTaskId) return;
await database.write(async () => { await database.write(async () => {
const task = await collections.tasks.find(taskId); const task = await collections.tasks.find(taskId);
const ownSubtasks = await collections.subtasks.query(Q.where('task_id', taskId)).fetch(); const ownSubtasks = await collections.subtasks.query(Q.where('task_id', taskId)).fetch();
const existing = await collections.subtasks.query( const existing = await collections.subtasks
.query(
Q.where('task_id', parentTaskId), Q.where('task_id', parentTaskId),
parentSubtaskId ? Q.where('parent_subtask_id', parentSubtaskId) : Q.where('parent_subtask_id', null) parentSubtaskId
).fetch(); ? Q.where('parent_subtask_id', parentSubtaskId)
: Q.where('parent_subtask_id', null)
)
.fetch();
const now = new Date(); const now = new Date();
let order = existing.length; let order = existing.length;
const created = await collections.subtasks.create((s) => { const created = await collections.subtasks.create(s => {
s.taskId = parentTaskId; s.taskId = parentTaskId;
s.parentSubtaskId = parentSubtaskId; s.parentSubtaskId = parentSubtaskId;
s.categoryId = task.categoryId || ''; s.categoryId = task.categoryId || '';
@@ -482,7 +508,7 @@ export async function convertTaskToSubtask(taskId: string, parentTaskId: string,
}); });
for (const subtask of ownSubtasks) { for (const subtask of ownSubtasks) {
await collections.subtasks.create((s) => { await collections.subtasks.create(s => {
s.taskId = parentTaskId; s.taskId = parentTaskId;
s.parentSubtaskId = created.id; s.parentSubtaskId = created.id;
s.categoryId = subtask.categoryId || ''; s.categoryId = subtask.categoryId || '';
@@ -507,7 +533,7 @@ export async function convertTaskToSubtask(taskId: string, parentTaskId: string,
} }
const parent = await collections.tasks.find(parentTaskId); const parent = await collections.tasks.find(parentTaskId);
await parent.update((p) => { await parent.update(p => {
p.updatedAt = now; p.updatedAt = now;
}); });
@@ -524,11 +550,13 @@ export async function convertSubtaskToTask(subtaskId: string): Promise<void> {
const now = new Date(); const now = new Date();
const subTags = parseTaskTags(subtask.tags, subtask.categoryId); const subTags = parseTaskTags(subtask.tags, subtask.categoryId);
const task = await collections.tasks.create((t) => { const task = await collections.tasks.create(t => {
t.title = subtask.title; t.title = subtask.title;
t.description = subtask.description || ''; t.description = subtask.description || '';
t.categoryId = subTags[0] || parent.categoryId || ''; 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.priority = subtask.priority;
t.completed = subtask.completed; t.completed = subtask.completed;
t.dueDate = subtask.dueDate; t.dueDate = subtask.dueDate;
@@ -546,9 +574,11 @@ export async function convertSubtaskToTask(subtaskId: string): Promise<void> {
}); });
newTaskId = task.id; 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) { for (const child of children) {
await child.update((c) => { await child.update(c => {
c.parentSubtaskId = null; c.parentSubtaskId = null;
c.updatedAt = now; c.updatedAt = now;
}); });
@@ -556,7 +586,7 @@ export async function convertSubtaskToTask(subtaskId: string): Promise<void> {
await subtask.destroyPermanently(); await subtask.destroyPermanently();
await recordTombstonesInBatch('subtasks', [subtaskId]); await recordTombstonesInBatch('subtasks', [subtaskId]);
await parent.update((p) => { await parent.update(p => {
p.updatedAt = now; 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 siblings = await collections.subtasks.query(Q.where('task_id', toTaskId)).fetch();
const now = new Date(); const now = new Date();
await subtask.update((s) => { await subtask.update(s => {
s.taskId = toTaskId; s.taskId = toTaskId;
s.parentSubtaskId = null; s.parentSubtaskId = null;
s.order = siblings.length; s.order = siblings.length;
@@ -584,14 +614,14 @@ export async function moveSubtaskToTask(subtaskId: string, toTaskId: string): Pr
}); });
try { try {
const oldParent = await collections.tasks.find(oldParentId); const oldParent = await collections.tasks.find(oldParentId);
await oldParent.update((p) => { await oldParent.update(p => {
p.updatedAt = now; p.updatedAt = now;
}); });
} catch { } catch {
// parent already gone // parent already gone
} }
const newParent = await collections.tasks.find(toTaskId); const newParent = await collections.tasks.find(toTaskId);
await newParent.update((p) => { await newParent.update(p => {
p.updatedAt = now; p.updatedAt = now;
}); });
}); });
@@ -617,19 +647,18 @@ export async function setSubtaskParent(subtaskId: string, newParentId: string):
cursor = await collections.subtasks.find(cursor.parentSubtaskId); cursor = await collections.subtasks.find(cursor.parentSubtaskId);
} }
const siblings = await collections.subtasks.query( const siblings = await collections.subtasks
Q.where('task_id', subtask.taskId), .query(Q.where('task_id', subtask.taskId), Q.where('parent_subtask_id', newParentId))
Q.where('parent_subtask_id', newParentId) .fetch();
).fetch();
await subtask.update((s) => { await subtask.update(s => {
s.parentSubtaskId = newParentId; s.parentSubtaskId = newParentId;
s.order = siblings.length; s.order = siblings.length;
s.updatedAt = now; s.updatedAt = now;
}); });
const task = await collections.tasks.find(subtask.taskId); const task = await collections.tasks.find(subtask.taskId);
await task.update((t) => { await task.update(t => {
t.updatedAt = now; t.updatedAt = now;
}); });
}); });
@@ -639,7 +668,7 @@ export async function setTaskCompleted(taskId: string, completed: boolean): Prom
let nextOccurrence: any = null; let nextOccurrence: any = null;
await database.write(async () => { await database.write(async () => {
const task = await collections.tasks.find(taskId); const task = await collections.tasks.find(taskId);
await task.update((t) => { await task.update(t => {
t.completed = completed; t.completed = completed;
t.completedAt = completed ? Date.now() : null; t.completedAt = completed ? Date.now() : null;
t.updatedAt = new Date(); t.updatedAt = new Date();
@@ -656,10 +685,9 @@ export async function setTaskCompleted(taskId: string, completed: boolean): Prom
async function createNextOccurrenceIfNeeded(task: any): Promise<any> { async function createNextOccurrenceIfNeeded(task: any): Promise<any> {
const seriesId = task.seriesId || newSeriesId(); const seriesId = task.seriesId || newSeriesId();
const existing = await collections.tasks.query( const existing = await collections.tasks
Q.where('series_id', seriesId), .query(Q.where('series_id', seriesId), Q.where('completed', false))
Q.where('completed', false) .fetch();
).fetch();
if (existing.length > 0) return null; if (existing.length > 0) return null;
const next = await createNextOccurrence(task, seriesId); const next = await createNextOccurrence(task, seriesId);
if (!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> { 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 subtasks = await collections.subtasks.query(Q.where('task_id', task.id)).fetch();
const now = new Date(); const now = new Date();
const next = await collections.tasks.create((t) => { const next = await collections.tasks.create(t => {
t.title = task.title; t.title = task.title;
t.description = task.description; t.description = task.description;
t.categoryId = task.categoryId; t.categoryId = task.categoryId;
@@ -697,7 +730,7 @@ async function createNextOccurrence(task: any, seriesId: string): Promise<any> {
}); });
for (const subtask of subtasks) { for (const subtask of subtasks) {
await collections.subtasks.create((s) => { await collections.subtasks.create(s => {
s.taskId = next.id; s.taskId = next.id;
s.title = subtask.title; s.title = subtask.title;
s.description = subtask.description; 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> { export async function setTaskCategory(taskId: string, categoryId: string): Promise<void> {
await database.write(async () => { await database.write(async () => {
const task = await collections.tasks.find(taskId); const task = await collections.tasks.find(taskId);
await task.update((t) => { await task.update(t => {
t.categoryId = categoryId; t.categoryId = categoryId;
t.tags = tagsToString(categoryId ? [categoryId] : []); t.tags = tagsToString(categoryId ? [categoryId] : []);
t.updatedAt = new Date(); 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> { export async function setTaskPriority(taskId: string, priority: Priority): Promise<void> {
await database.write(async () => { await database.write(async () => {
const task = await collections.tasks.find(taskId); const task = await collections.tasks.find(taskId);
await task.update((t) => { await task.update(t => {
t.priority = priority; t.priority = priority;
t.updatedAt = new Date(); 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 subtasks = await collections.subtasks.query(Q.where('task_id', taskId)).fetch();
const now = new Date(); const now = new Date();
clone = await collections.tasks.create((t) => { clone = await collections.tasks.create(t => {
t.title = task.title; t.title = task.title;
t.description = task.description; t.description = task.description;
t.categoryId = task.categoryId; t.categoryId = task.categoryId;
@@ -770,7 +803,7 @@ export async function duplicateTask(taskId: string): Promise<void> {
}); });
for (const subtask of subtasks) { for (const subtask of subtasks) {
await collections.subtasks.create((s) => { await collections.subtasks.create(s => {
s.taskId = clone.id; s.taskId = clone.id;
s.title = subtask.title; s.title = subtask.title;
s.description = subtask.description; s.description = subtask.description;
@@ -796,7 +829,7 @@ export async function reorderTasks(taskIds: string[]): Promise<void> {
const now = new Date(); const now = new Date();
for (let i = 0; i < taskIds.length; i++) { for (let i = 0; i < taskIds.length; i++) {
const task = await collections.tasks.find(taskIds[i]); const task = await collections.tasks.find(taskIds[i]);
await task.update((t) => { await task.update(t => {
t.order = i; t.order = i;
t.updatedAt = now; t.updatedAt = now;
}); });
@@ -809,7 +842,7 @@ export async function reorderSubtasks(subtaskIds: string[]): Promise<void> {
const now = new Date(); const now = new Date();
for (let i = 0; i < subtaskIds.length; i++) { for (let i = 0; i < subtaskIds.length; i++) {
const subtask = await collections.subtasks.find(subtaskIds[i]); const subtask = await collections.subtasks.find(subtaskIds[i]);
await subtask.update((s) => { await subtask.update(s => {
s.order = i; s.order = i;
s.updatedAt = now; s.updatedAt = now;
}); });
@@ -840,12 +873,16 @@ export async function createSubtask(data: CreateSubtaskData): Promise<string> {
let newSubtaskId: string | null = null; let newSubtaskId: string | null = null;
await database.write(async () => { await database.write(async () => {
const now = new Date(); const now = new Date();
const siblings = await collections.subtasks.query( const siblings = await collections.subtasks
.query(
Q.where('task_id', data.taskId), Q.where('task_id', data.taskId),
data.parentSubtaskId ? Q.where('parent_subtask_id', data.parentSubtaskId) : Q.where('parent_subtask_id', null) data.parentSubtaskId
).fetch(); ? 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.taskId = data.taskId;
s.parentSubtaskId = data.parentSubtaskId || null; s.parentSubtaskId = data.parentSubtaskId || null;
s.categoryId = data.categoryId || ''; s.categoryId = data.categoryId || '';