Compare commits

..
7 Commits
Author SHA1 Message Date
tech08mag 0093c4aec2 Merge pull request 'fix: remove existing debug keystore before generation in CI' (#20) from dev into main
Build APK / build (push) Failing after 3m21s
Build Desktop (deb) / build (push) Successful in 2m22s
release / release (push) Successful in 6m15s
Reviewed-on: #20
2026-08-26 21:11:54 +00:00
tech08mag 9ade0fa0ca Merge pull request 'keystore key' (#19) from dev into main
Build APK / build (push) Failing after 51s
Build Desktop (deb) / build (push) Failing after 5m5s
release / release (push) Successful in 6m55s
Reviewed-on: #19
2026-08-26 21:04:21 +00:00
tech08mag 39fd491d24 Merge pull request 'include android directory' (#18) from dev into main
Build APK / build (push) Failing after 35s
Build Desktop (deb) / build (push) Successful in 2m9s
release / release (push) Successful in 7m52s
Reviewed-on: #18
2026-08-26 20:56:30 +00:00
tech08mag c4cc2712ee Merge pull request 'feat: remove /android from .gitignore to allow Gradle build in CI' (#17) from dev into main
Build APK / build (push) Failing after 1m7s
Build Desktop (deb) / build (push) Successful in 2m12s
release / release (push) Successful in 8m0s
Reviewed-on: #17
2026-08-26 20:52:22 +00:00
tech08mag 49ef8ae82f Merge pull request 'fix: remove duplicate useRouter() declaration in CalendarScreen' (#16) from dev into main
Build APK / build (push) Failing after 1m10s
Build Desktop (deb) / build (push) Successful in 2m20s
release / release (push) Successful in 6m14s
Reviewed-on: #16
2026-08-26 20:37:58 +00:00
tech08mag 0b87f3e4ed Merge pull request 'refactor: update component styles and layouts' (#15) from dev into main
Build APK / build (push) Failing after 1m8s
Build Desktop (deb) / build (push) Successful in 2m12s
release / release (push) Successful in 7m9s
Reviewed-on: #15
2026-08-26 20:17:11 +00:00
tech08mag 9a9aff0880 Merge pull request 'Fix Android APK build pipeline' (#14) from dev into main
Build APK / build (push) Failing after 1m3s
Build Desktop (deb) / build (push) Successful in 2m19s
release / release (push) Successful in 6m20s
Reviewed-on: #14
2026-08-25 21:17:59 +00:00
83 changed files with 2655 additions and 5769 deletions
-327
View File
@@ -1,327 +0,0 @@
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
+75
View File
@@ -0,0 +1,75 @@
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-35" \
"platforms;android-36" \
"build-tools;35.0.0" \
"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: npm ci
- 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
+36
View File
@@ -0,0 +1,36 @@
name: Build Desktop (deb)
on:
push:
branches: [main]
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 22
- name: Install JS dependencies
working-directory: carry-your-live
run: npm ci
- name: Export web bundle
working-directory: carry-your-live
run: npx expo export --platform web
- name: Build deb package
working-directory: carry-your-live
run: npx electron-builder --linux deb --config electron-builder.config.js
- name: Upload deb
uses: actions/upload-artifact@v4
with:
name: carry-your-live-desktop-deb
path: carry-your-live/desktop-build/*.deb
+35 -216
View File
@@ -1,237 +1,56 @@
name: Build and Release Android name: release
on: on:
push: push:
tags: branches:
- 'v*' - main
workflow_dispatch:
env: permissions:
NODE_VERSION: '20' contents: write
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:
build: release:
name: Build Android
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 45
permissions:
contents: read
steps: steps:
- name: Checkout repository - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Setup Node.js - name: Compute next version
uses: actions/setup-node@v4 id: version
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: | run: |
if [ -x "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" ]; then latest=$(git tag --list 'v[0-9]*' --sort=-v:refname | head -n 1)
echo "Android SDK already present in cache" if [ -z "$latest" ]; then
next="v1.0.0"
else else
rm -rf "$ANDROID_HOME/cmdline-tools" latest=${latest#v}
mkdir -p "$ANDROID_HOME/cmdline-tools" IFS='.' read -r major minor patch <<< "$latest"
wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -O /tmp/cmdtools.zip next="v${major}.${minor}.$((patch + 1))"
unzip -q /tmp/cmdtools.zip -d "$ANDROID_HOME/cmdline-tools"
mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest"
fi fi
mkdir -p "$ANDROID_HOME/licenses" echo "next=${next}" >> "$GITHUB_OUTPUT"
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 - name: Build changelog
working-directory: carry-your-live id: changelog
run: | run: |
for i in {1..3}; do prev_tag=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
npm ci && break || sleep 10 if [ -z "$prev_tag" ]; then
done log=$(git log --oneline --no-merges main | head -n 40)
- 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 else
./gradlew assembleRelease --no-daemon log=$(git log --oneline --no-merges "$prev_tag"..main | head -n 40)
fi fi
{
echo "body<<EOF"
echo "## What's new in this release"
echo ""
echo "$log" | sed 's/^/- /'
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Build Release Bundle (AAB) - name: Create release
working-directory: carry-your-live/android uses: softprops/action-gh-release@v2
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: with:
name: android-release-build tag_name: ${{ steps.version.outputs.next }}
path: | name: ${{ steps.version.outputs.next }}
${{ steps.find-release-apk.outputs.apk_path }} body: ${{ steps.changelog.outputs.body }}
${{ steps.find-release-aab.outputs.aab_path }} generate_release_notes: false
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
with:
name: android-release-build
path: artifacts
- name: Create Gitea Release
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
TAG_NAME="${GITHUB_REF#refs/tags/}"
REPO_OWNER="${GITHUB_REPOSITORY%/*}"
REPO_NAME="${GITHUB_REPOSITORY#*/}"
# Detect Gitea server URL
GITEA_URL="${GITEA_SERVER_URL:-https://techmos.gitea.org}"
# Create release
RESPONSE=$(curl -s -X POST "${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases" \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d "{
\"tag_name\": \"${TAG_NAME}\",
\"name\": \"${TAG_NAME}\",
\"body\": \"Automated release for ${TAG_NAME}\",
\"draft\": false,
\"prerelease\": false
}")
RELEASE_ID=$(echo "$RESPONSE" | jq -r '.id')
if [ "$RELEASE_ID" = "null" ] || [ -z "$RELEASE_ID" ]; then
echo "Release might already exist, fetching existing release..."
RELEASE_ID=$(curl -s -H "Authorization: token ${GITEA_TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/tags/${TAG_NAME}" | jq -r '.id')
fi
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: |
GITEA_URL="${GITEA_URL:-https://techmos.gitea.org}"
REPO_OWNER="${GITHUB_REPOSITORY%/*}"
REPO_NAME="${GITHUB_REPOSITORY#*/}"
RELEASE_ID="${{ steps.create-release.outputs.release_id }}"
TAG_NAME="${GITHUB_REF#refs/tags/}"
for file in artifacts/*; do
if [[ "$file" == *.apk ]]; then
FILENAME="carry-your-live-${TAG_NAME}.apk"
elif [[ "$file" == *.aab ]]; then
FILENAME="carry-your-live-${TAG_NAME}.aab"
else
continue
fi
echo "Uploading $file as $FILENAME..."
curl -s -X POST "${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}/assets" \
-H "Authorization: token ${GITEA_TOKEN}" \
-F "attachment=@${file};filename=${FILENAME}"
echo "Uploaded $FILENAME"
done
-11
View File
@@ -15,14 +15,3 @@ System architecture design, technical strategy, and scalability planning.
- Performance benchmarks - Performance benchmarks
- Technical debt ratio - Technical debt ratio
- Architecture compliance - Architecture compliance
## Android Design Guidelines
- Material Design elevation patterns (0-12dp shadows)
- Tab bar height: 56dp (standard Android)
- Status bar: dark style for light backgrounds, light style for dark
- System back button: router.back() or goBack() with confirmation on home screen
- Touch ripple: opacity scaling on press (44dp touch targets, 8dp border radius)
- Safe area insets: account for bottom navigation bar (84px + system insets)
- Theme: primary accent color with complementary supporting colors
- Typography: Roboto-inspired font weights (600 for labels, 400/500 for body)
- Scaling: 8dp grid system for spacing and margins
+108 -498
View File
@@ -1,40 +1,6 @@
# 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 multiple screens with bottom navigation, social features, and a full-featured backend API. 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.
## 📝 Recent Progress (v1.1.0 - Unreleased)
### Added
- **Friends feature**: Friend requests, accept/decline/remove, user search, full backend routes
- **Assignee field on tasks**: Schema v7, model, types, forms, sync, task actions
- **AssigneeSelector component** and assignee display in TaskItem
- **QuickAddBar** on tasks overview (copied from calendar)
- **Backend**: Mounted friends route, added assigneeId to task validation and sync
### Changed
- Due date prefilled from Calendar "+" button
- 12-hour time formatting across task rows and pickers
- Calendar strip scrolls to selected day when navigating months
- Calendar completion toggle now handles repeating tasks and reminders
- Task color strips now use the task's category color
### Fixed
- WheelTimePicker FlatList scroll clamping - all hours (0-23) and minutes (0-59) reachable
- WheelTimePicker selection highlight background connecting selected item with colon separator
- WheelTimePicker number alignment in scroll list
- Tasks without a due date no longer get overdue styling
- `useOverdueTasks` infinite re-subscription loop (Date.now in effect deps)
- Web/Android crashes from decorator transform ordering (babel config)
- FormButtons crash outside FormProvider; Cancel navigation fallback
- Task repeat settings, reminders and series info now sync to backend
### Removed
- Task color picker (task color now comes from its category)
- Drag lift popup from TaskItem (scale + shadow on drag)
- Checkbox from task items
- Selection bar overlapping QuickAddBar on tasks overview
---
## Features ## Features
@@ -42,145 +8,97 @@ 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. Tasks Screen (To-Do List) #### 1. To-Do List Screen
- **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
- **QuickAddBar**: Quick task creation from list view - **Task List**: Vertical scrolling with:
- **Task List**: Virtualized FlatList with sections (Pending/Completed) - Checkbox (empty/checked states with animation)
- 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
- Category color indicator strip - Completed section (collapsible)
- Assignee display - **Floating Action Button**: Bottom-right, opens Add Task screen with press animation
- 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. Calendar Screen #### 2. Add New Task 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, nested subtask support - **Subtasks**: Unlimited, add/remove individually
- **Date & Time**: Date picker, time picker (WheelTimePicker), calendar button - **Date & Time**: Date picker, time picker, 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
#### 6. Task Detail Screen #### 3. Calendar Screen
- Full task view with all fields - **Date Strip**: Horizontal scrollable days of current month
- Subtask management (add, complete, reorder, convert to task) - **Month Label**: Centered (e.g., "JAN 2026")
- Edit functionality - **Task List**: Tasks for selected day with category color, title, time, completion status
- Repeat profile configuration - **Empty State**: "No tasks scheduled."
#### 7. Subtask Detail Screen #### 4. Settings Screen
- Nested subtask management - Dark Mode toggle
- Convert subtask ↔ task - Push Notifications toggle
- Re-parent subtasks - Reminder Preferences
- Default Category selector
- Sort Tasks By (Date, Priority, Alphabetically, Creation Date)
- Backup & Sync
- Version, Privacy Policy, Terms of Service
#### 8. Sync Modal ### Task States
- Login/Register forms - **Pending**: Empty checkbox, normal text
- Forgot/Reset password flow - **Completed**: Checked checkbox, strikethrough, reduced opacity
- Server URL configuration - **Overdue**: Red due date, warning indicator
- Sync status display - **Due Today**: Blue highlight
- **High Priority**: Priority badge
### Task Features ### Interactions
- **Nested Subtasks**: Unlimited depth, re-parenting, drag reorder - Tap task → Edit screen
- **Repeating Tasks**: Repeat profiles, series IDs, this/future/all delete scopes, undo-complete - Long press → Selection mode (multi-delete, multi-complete, change category/priority)
- **Categories/Tags**: 6 default categories with colors, custom category editor - Swipe left → Delete
- **Priorities**: None, Low, Medium, High, Critical with color badges - Swipe right → Complete
- **Due Dates/Times**: With all-day option - Pull down → Refresh
- **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) / Expo Router v3 | | Framework | Expo (React Native) |
| Language | TypeScript (strict mode) | | Language | TypeScript |
| Navigation | Expo Router (file-based) | | Navigation | Expo Router (file-based) |
| Database | WatermelonDB + SQLite (expo-sqlite) | | Database | WatermelonDB + SQLite |
| State | WatermelonDB reactive queries (no Redux) | | State | WatermelonDB (reactive queries, no Redux needed) |
| Forms | React Hook Form + Zod validation | | Forms | React Hook Form + Zod validation |
| UI Components | React Native Paper + Custom components | | UI Components | React Native Paper + Custom SVG icons |
| Animations | React Native Reanimated v3 | | Animations | React Native Reanimated |
| Date/Time | @react-native-community/datetimepicker + date-fns + WheelTimePicker | | Date/Time | @react-native-community/datetimepicker + date-fns |
| Build | EAS Build / Gradle (local APK) / Electron Builder (desktop) | | Build | EAS Build / Gradle (local APK) |
| 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 + AuthProvider │ ├── _layout.tsx # Root layout + DatabaseProvider
│ ├── (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 (30+) │ ├── components/ # Reusable UI components
│ │ ├── 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
@@ -188,102 +106,24 @@ 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 (v21) │ │ ├── schema.ts # WatermelonDB schema
│ │ ├── index.ts # Database instance + collections │ │ ├── index.ts # Database instance + collections
│ │ ├── sync.ts # Custom API sync adapter (25KB) │ │ └── sync.ts # Custom API sync adapter
│ │ ├── migrations.ts # Schema migrations │ ├── models/
│ │ ├── adapter.ts # Platform-specific adapters │ │ ├── Task.ts
│ │ └── tombstones.ts # Deleted record tracking │ │ ├── Category.ts
│ │ └── 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
│ │ ├── useCategories.tsx # Category queries │ ├── constants/
│ │ ├── useTasksByDate.tsx # Calendar date queries │ │ └── index.ts # Categories, priorities, colors
│ │ ├── useFriends.tsx # Friends state & actions │ └── types/
│ │ ├── useAuth.tsx # Authentication state │ └── index.ts # TypeScript interfaces
│ │ └── 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
@@ -295,14 +135,8 @@ 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 (ms) dueDate: number; // timestamp
dueTime: string; // "HH:MM" (24h stored, 12h displayed) dueTime: string; // "HH:MM"
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[];
@@ -316,8 +150,6 @@ carry-your-live/
name: string; name: string;
color: '#HEX'; color: '#HEX';
order: number; order: number;
createdAt: Date;
updatedAt: Date;
} }
``` ```
@@ -326,47 +158,14 @@ 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 |
|------|-------|-------| |------|-------|-------|
@@ -377,8 +176,6 @@ 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 |
|----------|-------| |----------|-------|
@@ -388,8 +185,6 @@ carry-your-live/
| High | #E53935 | | High | #E53935 |
| Critical | #B71C1C | | Critical | #B71C1C |
---
## Getting Started ## Getting Started
### Prerequisites ### Prerequisites
@@ -397,23 +192,15 @@ 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
@@ -423,37 +210,6 @@ 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)
@@ -469,16 +225,18 @@ eas build --platform ios
eas build --platform android eas build --platform android
eas build --platform web eas build --platform web
``` ```
Profiles defined in `eas.json` (`development`, `preview`, `production`; production auto-increments version code). Profiles are 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 (signed with debug keystore) # Release APK (currently signed with the debug keystore)
./gradlew assembleRelease ./gradlew assembleRelease
``` ```
Output: Output:
@@ -486,21 +244,24 @@ 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` 4. **Debug keystore**: generates `carry-your-live/android/app/debug.keystore` with `keytool` (alias `androiddebugkey`, passwords `android`)
5. **Build**: `./gradlew assembleRelease` in `carry-your-live/android/` 5. **Build**: `./gradlew assembleRelease` in `carry-your-live/android/`
6. **Upload**: APK saved as `carry-your-live-release` artifact 6. **Upload**: the APK is saved as the `carry-your-live-release` artifact (downloadable from the run's artifacts page)
To run the build locally exactly as CI does:
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 \
@@ -510,119 +271,48 @@ keytool -genkeypair -v \
cd carry-your-live/android && ./gradlew assembleRelease cd carry-your-live/android && ./gradlew assembleRelease
``` ```
--- ## Sync API Specification
## Backend API Specification The app expects a custom API at `http://localhost:3000/api` (configurable in `src/database/sync.ts`):
Server runs at `http://localhost:3000/api` (configurable in `src/services/api.ts` and `src/database/sync.ts`): ### Pull Changes
```
GET /sync?since={timestamp}
```
Response:
```json
{
"categories": [{ "id", "name", "color", "order", "createdAt", "updatedAt" }],
"tasks": [{ "id", "title", "description", "categoryId", "priority", "completed", "dueDate", "dueTime", "createdAt", "updatedAt" }],
"subtasks": [{ "id", "taskId", "title", "completed", "order", "createdAt", "updatedAt" }],
"timestamp": 1234567890
}
```
### Authentication ### Push Changes
- `POST /api/auth/register` - Register new user ```
- `POST /api/auth/login` - Login (returns access + refresh tokens) POST /sync/push
- `POST /api/auth/refresh` - Refresh access token ```
- `POST /api/auth/forgot-password` - Request password reset Body:
- `POST /api/auth/reset-password` - Reset password with token ```json
- `GET /api/auth/me` - Get current user {
"changes": {
### Categories "categories": [...],
- `GET /api/categories` - List categories "tasks": [...],
- `POST /api/categories` - Create category "subtasks": [...]
- `PATCH /api/categories/:id` - Update category },
- `DELETE /api/categories/:id` - Delete category "lastPulledAt": 1234567890
}
### Tasks ```
- `GET /api/tasks` - List tasks (with filters: category, completed, due date range, assignee)
- `GET /api/tasks/:id` - Get task with subtasks
- `POST /api/tasks` - Create task
- `PATCH /api/tasks/:id` - Update task
- `DELETE /api/tasks/:id` - Delete task (scopes: this/future/all for repeating)
- `POST /api/tasks/batch` - Batch operations
### Subtasks
- `GET /api/subtasks/task/:taskId` - List subtasks for task
- `POST /api/subtasks/task/:taskId` - Create subtask
- `PATCH /api/subtasks/:id` - Update subtask
- `DELETE /api/subtasks/:id` - Delete subtask
### Repeat Profiles
- `GET /api/repeat-profiles` - List repeat profiles
- `POST /api/repeat-profiles` - Create repeat profile
- `PATCH /api/repeat-profiles/:id` - Update repeat profile
- `DELETE /api/repeat-profiles/:id` - Delete repeat profile
### Users
- `GET /api/users/me` - Get user with settings
- `PATCH /api/users/me/settings` - Update settings (darkMode, defaultCategoryId, sortOrder, etc.)
- `GET /api/users/search` - Search users (for friends)
### Friends
- `GET /api/friends` - List accepted friends
- `GET /api/friends/requests` - List pending requests (incoming/outgoing)
- `POST /api/friends/request` - Send friend request
- `PATCH /api/friends/request/:id/accept` - Accept request
- `PATCH /api/friends/request/:id/decline` - Decline request
- `DELETE /api/friends/:id` - Remove friend
### Sync (Offline-first)
- `GET /api/sync?since=<timestamp>` - Pull changes since timestamp
- `POST /api/sync/push` - Push local changes
---
## Sync Protocol
The sync endpoint uses a cursor-based approach with tombstones:
1. **Pull**: Client sends `since` timestamp, server returns all changes since then
```json
{
"categories": [...],
"tasks": [...],
"subtasks": [...],
"repeatProfiles": [...],
"friendships": [...],
"tombstones": [...],
"timestamp": 1234567890
}
```
2. **Push**: Client sends batched changes with `lastPulledAt`
```json
{
"changes": {
"categories": [...],
"tasks": [...],
"subtasks": [...],
"repeatProfiles": [...],
"friendships": [...]
},
"tombstones": [...],
"lastPulledAt": 1234567890
}
```
3. **Conflict Resolution**: Last-write-wins based on `updatedAt` (customizable in `src/database/sync.ts`)
---
## Configuration ## Configuration
### Frontend Environment Variables ### Environment Variables
Create `.env` file in `carry-your-live/`: Create `.env` file:
``` ```
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
@@ -630,12 +320,10 @@ Modify `app.json` for:
- Permissions (notifications, etc.) - Permissions (notifications, etc.)
- Orientation, theme - Orientation, theme
---
## Visual Style Guide ## Visual Style Guide
- **Background**: White (#FFFFFF) / Dark mode (planned) - **Background**: White (#FFFFFF)
- **Primary Accent**: #E53935 (Pink/Red) - customizable - **Primary Accent**: #E53935 (Pink/Red)
- **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
@@ -643,13 +331,11 @@ 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 │────▶│ Backend │ │ Expo App │────▶│ WatermelonDB │────▶│ Your API │
│ (iOS/Android│ │ (SQLite) │ │ (Postgres) │ │ (iOS/Android│ │ (SQLite) │ │ (Postgres) │
│ Web) │◀───▶│ + Sync │◀───▶│ │ │ Web) │◀───▶│ + Sync │◀───▶│ │
└─────────────┘ └──────────────┘ └─────────────┘ └─────────────┘ └──────────────┘ └─────────────┘
@@ -659,79 +345,6 @@ 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
@@ -740,9 +353,6 @@ Based on code audit (August 2026):
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
+3 -15
View File
@@ -104,27 +104,15 @@ 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 {
signingConfig = signingConfigs.release // Caution! In production, you need to generate your own keystore file.
// 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
Binary file not shown.
+1 -1
View File
@@ -10,7 +10,7 @@
# Specifies the JVM arguments used for the daemon process. # Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings. # The setting is particularly useful for tweaking memory settings.
# Default value: -Xmx512m -XX:MaxMetaspaceSize=256m # Default value: -Xmx512m -XX:MaxMetaspaceSize=256m
org.gradle.jvmargs=-Xmx1536m -XX:MaxMetaspaceSize=1024m --add-opens=java.base/java.lang=ALL-UNNAMED --add-opens=java.base/java.io=ALL-UNNAMED org.gradle.jvmargs=-Xmx1536m -XX:MaxMetaspaceSize=384m --add-opens=java.base/java.lang=ALL-UNNAMED --add-opens=java.base/java.io=ALL-UNNAMED
org.gradle.workers.max=2 org.gradle.workers.max=2
# When configured, Gradle will run in incubating parallel mode. # When configured, Gradle will run in incubating parallel mode.
-3
View File
@@ -1,7 +1,4 @@
pluginManagement { pluginManagement {
repositories {
gradlePluginPortal()
}
def reactNativeGradlePlugin = new File( def reactNativeGradlePlugin = new File(
providers.exec { providers.exec {
workingDir(rootDir) workingDir(rootDir)
+1 -3
View File
@@ -34,9 +34,7 @@
"color": "#1E88E5" "color": "#1E88E5"
} }
], ],
"./plugins/withQuickAddWidget", "./plugins/withQuickAddWidget"
"expo-router",
"expo-sqlite"
], ],
"extra": { "extra": {
"eas": { "eas": {
+44 -61
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 } 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';
@@ -18,80 +18,64 @@ export default function TabLayout() {
tabBarStyle: { tabBarStyle: {
backgroundColor: theme.tabBarBg, backgroundColor: theme.tabBarBg,
borderTopWidth: 0, borderTopWidth: 0,
height: 56, height: 84 + insets.bottom,
paddingBottom: insets.bottom, paddingBottom: insets.bottom,
paddingTop: insets.top, paddingTop: 12,
paddingHorizontal: 16, paddingHorizontal: 16,
elevation: 4, elevation: 0,
shadowColor: '#000', shadowColor: '#000',
shadowOffset: { width: 0, height: 2 }, shadowOffset: { width: 0, height: -4 },
shadowOpacity: 0.1, shadowOpacity: 0.12,
shadowRadius: 8, shadowRadius: 20,
}, },
tabBarItemStyle: { tabBarItemStyle: {
paddingVertical: 6, paddingVertical: 0,
paddingHorizontal: 12,
}, },
tabBarLabelStyle: { tabBarLabelStyle: {
fontSize: 12, fontSize: 11,
fontWeight: '600', fontWeight: '700',
marginTop: 2, marginTop: 4,
}, },
headerShown: false, headerShown: false,
sceneStyle: { backgroundColor: theme.background }, sceneStyle: { backgroundColor: theme.background },
}} }}
tabBar={props => { tabBar={(props) => {
return ( return (
<View <View
style={[ style={[
styles.tabBarContainer, styles.tabBarContainer,
{ {
backgroundColor: theme.tabBarBg, backgroundColor: theme.tabBarBg,
paddingBottom: Math.max(props.insets.bottom, 8), paddingBottom: Math.max(props.insets.bottom, 8),
shadowColor: '#000', shadowColor: '#000',
shadowOffset: { width: 0, height: 4 }, shadowOffset: { width: 0, height: -4 },
shadowOpacity: 0.1, shadowOpacity: 0.12,
shadowRadius: 8, shadowRadius: 20,
elevation: 4, elevation: 8,
}, },
]} ]}
> >
{props.navigation.getState().routes.map(route => { {props.navigation.getState().routes.map((route) => {
const focused = const focused = props.navigation.getState().index === props.navigation.getState().routes.indexOf(route);
props.navigation.getState().index ===
props.navigation.getState().routes.indexOf(route);
return ( return (
<TouchableOpacity <TouchableOpacity
key={route.name} key={route.name}
style={[ style={[
styles.tabItem, styles.tabItem,
focused && styles.tabItemActive,
{ backgroundColor: focused ? theme.accentSoft : 'transparent' }, { backgroundColor: focused ? theme.accentSoft : 'transparent' },
]} ]}
onPress={() => props.navigation.navigate(route.name as never)} onPress={() => props.navigation.navigate(route.name as never)}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={ accessibilityLabel={route.name === 'index' ? 'ToDo' : route.name.charAt(0).toUpperCase() + route.name.slice(1)}
route.name === 'index'
? 'ToDo'
: route.name.charAt(0).toUpperCase() + route.name.slice(1)
}
accessibilityState={{ selected: focused }} accessibilityState={{ selected: focused }}
> >
<Animated.View 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={ focused={focused}
route.name === 'index' color={focused ? theme.accent : theme.textSecondary}
? 'checklist' size={24}
: route.name === 'calendar' />
? 'calendar'
: route.name === 'stats'
? 'stats'
: 'gear'
}
focused={focused}
color={focused ? theme.accent : theme.textSecondary}
size={24}
/>
</Animated.View>
</TouchableOpacity> </TouchableOpacity>
); );
})} })}
@@ -119,23 +103,22 @@ const styles = StyleSheet.create({
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-around', justifyContent: 'space-around',
paddingTop: 6, paddingTop: 10,
paddingHorizontal: 16, paddingHorizontal: 16,
}, },
tabItem: { tabItem: {
flex: 1, flex: 1,
alignItems: 'center', alignItems: 'center',
paddingVertical: 6, paddingVertical: 8,
paddingHorizontal: 12, paddingHorizontal: 8,
borderRadius: 8, borderRadius: 16,
minWidth: 70, minWidth: 70,
}, },
rippleContainer: { tabItemActive: {
width: 44, shadowColor: '#000',
height: 44, shadowOffset: { width: 0, height: 2 },
borderRadius: 22, shadowOpacity: 0.1,
alignItems: 'center', shadowRadius: 8,
justifyContent: 'center', elevation: 4,
position: 'relative',
}, },
}); });
+240 -358
View File
@@ -1,15 +1,5 @@
import React, { useMemo, useRef, useState, useCallback, useEffect } from 'react'; import React, { useMemo, useRef, useState, useCallback, useEffect } from 'react';
import { import { View, Text, StyleSheet, SafeAreaView, ScrollView, TouchableOpacity, Animated, Dimensions, KeyboardAvoidingView } from 'react-native';
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';
@@ -25,15 +15,7 @@ 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 { import { format, addMonths, addDays, startOfMonth, isSameDay, isSameMonth, isToday } from 'date-fns';
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';
@@ -42,18 +24,8 @@ 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', 'January', 'February', 'March', 'April', 'May', 'June',
'February', 'July', 'August', 'September', 'October', 'November', 'December',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
]; ];
export default function CalendarScreen() { export default function CalendarScreen() {
@@ -98,9 +70,7 @@ export default function CalendarScreen() {
const sorted = [...selectedDayTasks]; const sorted = [...selectedDayTasks];
switch (sortBy) { switch (sortBy) {
case 'date': case 'date':
sorted.sort( sorted.sort((a, b) => (a.dueDate || Number.MAX_SAFE_INTEGER) - (b.dueDate || Number.MAX_SAFE_INTEGER));
(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]);
@@ -148,18 +118,10 @@ 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, { Animated.timing(translateX.current, { toValue: target, duration: 220, useNativeDriver: false }).start(() => {
toValue: target,
duration: 220,
useNativeDriver: false,
}).start(() => {
setVisibleMonth(next); setVisibleMonth(next);
translateX.current.setValue(-target); translateX.current.setValue(-target);
Animated.timing(translateX.current, { Animated.timing(translateX.current, { toValue: 0, duration: 180, useNativeDriver: false }).start(() => {
toValue: 0,
duration: 180,
useNativeDriver: false,
}).start(() => {
animatingRef.current = false; animatingRef.current = false;
}); });
}); });
@@ -186,11 +148,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;
@@ -221,26 +183,22 @@ 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++) for (let y = currentYear - 20; y <= currentYear + 10; y++) options.push({ value: String(y), label: String(y) });
options.push({ value: String(y), label: String(y) });
return options; return options;
}, [currentYear]); }, [currentYear]);
const handleToggleComplete = useCallback( const handleToggleComplete = useCallback(async (taskId: string) => {
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);
@@ -251,15 +209,12 @@ export default function CalendarScreen() {
}); });
}, []); }, []);
const handleSubtaskToggle = useCallback( const handleSubtaskToggle = useCallback(async (subtaskId: string) => {
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 }]}>
@@ -274,103 +229,53 @@ 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 <Path d="M15 18l-6-6 6-6" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
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 <ScrollView style={styles.scrollBody} showsVerticalScrollIndicator={false} contentContainerStyle={styles.scrollContent}>
style={styles.scrollBody} <GestureDetector gesture={pan}>
showsVerticalScrollIndicator={false} <Animated.View
contentContainerStyle={styles.scrollContent} onLayout={(e) => {
> gridWidthRef.current = e.nativeEvent.layout.width;
<GestureDetector gesture={pan}> }}
<Animated.View // eslint-disable-next-line react-hooks/refs
onLayout={e => { style={[styles.calendarArea, { transform: [{ translateX: translateX.current }] }]}
gridWidthRef.current = e.nativeEvent.layout.width; >
}}
// eslint-disable-next-line react-hooks/refs
style={[styles.calendarArea, { transform: [{ translateX: translateX.current }] }]}
>
<View style={styles.monthRow}> <View style={styles.monthRow}>
<TouchableOpacity <TouchableOpacity
onPress={() => transitionTo(-1)} onPress={() => transitionTo(-1)}
style={[ style={[styles.monthNav, { borderColor: theme.borderStrong, backgroundColor: theme.card }]}
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 <Path d="M15 18l-6-6 6-6" stroke={theme.textSecondary} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
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 <TouchableOpacity onPress={() => setMonthPickerVisible(true)} activeOpacity={0.7} style={styles.monthButton}>
onPress={() => setMonthPickerVisible(true)} <Text style={[styles.monthLabel, { color: theme.text }]}>{format(visibleMonth, 'MMMM')}</Text>
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 <Path d="M6 9l6 6 6-6" stroke={theme.textSecondary} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
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 <TouchableOpacity onPress={() => setYearPickerVisible(true)} activeOpacity={0.7} style={styles.yearButton}>
onPress={() => setYearPickerVisible(true)} <Text style={[styles.yearLabel, { color: theme.textMuted }]}>{format(visibleMonth, 'yyyy')}</Text>
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={[ style={[styles.monthNav, { borderColor: theme.borderStrong, backgroundColor: theme.card }]}
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 <Path d="M9 6l6 6-6 6" stroke={theme.textSecondary} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
d="M9 6l6 6-6 6"
stroke={theme.textSecondary}
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</Svg> </Svg>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@@ -385,7 +290,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}
@@ -401,58 +306,52 @@ export default function CalendarScreen() {
</View> </View>
))} ))}
</Animated.View> </Animated.View>
</GestureDetector> </GestureDetector>
<View style={styles.panelHeader}> <View style={styles.panelHeader}>
<Text style={[styles.panelDate, { color: theme.text }]}> <Text style={[styles.panelDate, { color: theme.text }]}>{format(selectedDate, 'EEEE, MMMM d')}</Text>
{format(selectedDate, 'EEEE, MMMM d')} {sortedDayTasks.length > 0 && (
</Text> <Text style={[styles.panelCount, { color: theme.textFaint }]}>
{sortedDayTasks.length > 0 && ( {sortedDayTasks.length} event{sortedDayTasks.length === 1 ? '' : 's'}
<Text style={[styles.panelCount, { color: theme.textFaint }]}> </Text>
{sortedDayTasks.length} event{sortedDayTasks.length === 1 ? '' : 's'} )}
</Text>
)}
</View>
{sortedDayTasks.length === 0 ? (
<View style={styles.emptyState}>
<Text style={[styles.emptyText, { color: theme.textSecondary }]}>
No events on this day
</Text>
<Text style={[styles.emptySubtext, { color: theme.textMuted }]}>
Tap a date or use the bar below
</Text>
</View> </View>
) : (
sortedDayTasks.map(task => {
const isExpanded = expandedTasks.has(task.id);
const itemSubtasks = subtasksByTask.get(task.id) ?? [];
return (
<View key={task.id} style={styles.eventCard}>
<CalendarTaskRow
task={task}
expanded={isExpanded}
subtasks={itemSubtasks}
categoryColor={categoryColors.get(task.categoryId)}
onToggle={() => handleToggleComplete(task.id)}
onPress={id =>
expandedTasks.has(id)
? router.push({ pathname: '/task-detail', params: { id } })
: toggleExpand(id)
}
onMenuOpen={id => openTaskEdit(id)}
onSubtaskToggle={handleSubtaskToggle}
/>
</View>
);
})
)}
</ScrollView>
<QuickAddBar {sortedDayTasks.length === 0 ? (
dueDate={selectedDate.getTime()} <View style={styles.emptyState}>
placeholder={`Add event for ${format(selectedDate, 'MMM d')}`} <Text style={[styles.emptyText, { color: theme.textSecondary }]}>No events on this day</Text>
/> <Text style={[styles.emptySubtext, { color: theme.textMuted }]}>Tap a date or use the bar below</Text>
</View>
) : (
sortedDayTasks.map((task) => {
const isExpanded = expandedTasks.has(task.id);
const itemSubtasks = subtasksByTask.get(task.id) ?? [];
return (
<View key={task.id} style={styles.eventCard}>
<CalendarTaskRow
task={task}
expanded={isExpanded}
subtasks={itemSubtasks}
categoryColor={categoryColors.get(task.categoryId)}
onToggle={() => handleToggleComplete(task.id)}
onPress={(id) =>
expandedTasks.has(id)
? router.push({ pathname: '/task-detail', params: { id } })
: toggleExpand(id)
}
onMenuOpen={(id) => openTaskEdit(id)}
onSubtaskToggle={handleSubtaskToggle}
/>
</View>
);
})
)}
</ScrollView>
<QuickAddBar
dueDate={selectedDate.getTime()}
placeholder={`Add event for ${format(selectedDate, 'MMM d')}`}
/>
</KeyboardAvoidingView> </KeyboardAvoidingView>
{modals(() => {})} {modals(() => {})}
@@ -520,7 +419,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}
@@ -541,11 +440,7 @@ function cellTitle(tasks: Task[]): string | null {
return `${tasks[0].title} +${tasks.length - 1}`; return `${tasks[0].title} +${tasks.length - 1}`;
} }
function cellColor( function cellColor(tasks: Task[], categories: { id: string; color: string }[], theme: ThemeColors): string {
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;
} }
@@ -561,23 +456,11 @@ interface DayCellProps {
onPress: (day: Date) => void; onPress: (day: Date) => void;
} }
const DayCell = React.memo(function DayCell({ const DayCell = React.memo(function DayCell({ day, label, dotColor, selected, today, inMonth, theme, onPress }: DayCellProps) {
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={[ style={[styles.dayCell, selected && { backgroundColor: theme.accentSoft, borderColor: theme.accentBorder }]}
styles.dayCell,
selected && { backgroundColor: theme.accentSoft, borderColor: theme.accentBorder },
]}
onPress={() => onPress(day)} onPress={() => onPress(day)}
activeOpacity={0.7} activeOpacity={0.7}
> >
@@ -605,153 +488,152 @@ const DayCell = React.memo(function DayCell({
); );
}); });
const createStyles = (theme: ThemeColors) => const createStyles = (theme: ThemeColors) => StyleSheet.create({
StyleSheet.create({ container: {
container: { flex: 1,
flex: 1, },
}, scrollContent: {
scrollContent: { paddingBottom: 16,
paddingBottom: 16, flexGrow: 1,
flexGrow: 1, },
}, scrollBody: {
scrollBody: { flex: 1,
flex: 1, },
}, kbAvoid: {
kbAvoid: { flex: 1,
flex: 1, },
}, calendarArea: {
calendarArea: { paddingHorizontal: 12,
paddingHorizontal: 12, paddingTop: 6,
paddingTop: 6, paddingBottom: 4,
paddingBottom: 4, },
}, monthRow: {
monthRow: { flexDirection: 'row',
flexDirection: 'row', alignItems: 'center',
alignItems: 'center', justifyContent: 'space-between',
justifyContent: 'space-between', paddingHorizontal: 4,
paddingHorizontal: 4, paddingBottom: 14,
paddingBottom: 14, },
}, monthNav: {
monthNav: { width: 36,
width: 36, height: 36,
height: 36, borderRadius: 12,
borderRadius: 12, borderWidth: 1,
borderWidth: 1, alignItems: 'center',
alignItems: 'center', justifyContent: 'center',
justifyContent: 'center', },
}, monthSelectorGroup: {
monthSelectorGroup: { alignItems: 'flex-start',
alignItems: 'flex-start', flex: 1,
flex: 1, },
}, monthButton: {
monthButton: { flexDirection: 'row',
flexDirection: 'row', alignItems: 'center',
alignItems: 'center', justifyContent: 'center',
justifyContent: 'center', gap: 6,
gap: 6, paddingHorizontal: 10,
paddingHorizontal: 10, },
}, monthLabel: {
monthLabel: { fontSize: 22,
fontSize: 22, fontWeight: '700',
fontWeight: '700', },
}, yearButton: {
yearButton: { marginTop: -2,
marginTop: -2, paddingHorizontal: 6,
paddingHorizontal: 6, },
}, yearLabel: {
yearLabel: { fontSize: 17,
fontSize: 17, fontWeight: '700',
fontWeight: '700', },
}, weekdayRow: {
weekdayRow: { flexDirection: 'row',
flexDirection: 'row', marginBottom: 4,
marginBottom: 4, paddingHorizontal: 2,
paddingHorizontal: 2, },
}, weekday: {
weekday: { flex: 1,
flex: 1, textAlign: 'center',
textAlign: 'center', fontSize: 11,
fontSize: 11, fontWeight: '600',
fontWeight: '600', textTransform: 'uppercase',
textTransform: 'uppercase', },
}, weekRow: {
weekRow: { flexDirection: 'row',
flexDirection: 'row', gap: 6,
gap: 6, marginBottom: 6,
marginBottom: 6, },
}, dayCell: {
dayCell: { flex: 1,
flex: 1, height: 56,
height: 56, borderRadius: 12,
borderRadius: 12, borderWidth: 1,
borderWidth: 1, borderColor: 'transparent',
borderColor: 'transparent', paddingVertical: 4,
paddingVertical: 4, alignItems: 'center',
alignItems: 'center', },
}, dayNumber: {
dayNumber: { fontSize: 14,
fontSize: 14, fontWeight: '600',
fontWeight: '600', },
}, dayNumberSelected: {
dayNumberSelected: { color: theme.text,
color: theme.text, },
}, chip: {
chip: { marginTop: 3,
marginTop: 3, paddingHorizontal: 4,
paddingHorizontal: 4, paddingVertical: 2,
paddingVertical: 2, borderRadius: 5,
borderRadius: 5, maxWidth: '92%',
maxWidth: '92%', },
}, chipText: {
chipText: { color: '#FFFFFF',
color: '#FFFFFF', fontSize: 8,
fontSize: 8, fontWeight: '600',
fontWeight: '600', },
}, chipPlaceholder: {
chipPlaceholder: { marginTop: 3,
marginTop: 3, width: 6,
width: 6, height: 2,
height: 2, borderRadius: 1,
borderRadius: 1, opacity: 0.4,
opacity: 0.4, },
}, panelHeader: {
panelHeader: { flexDirection: 'row',
flexDirection: 'row', alignItems: 'center',
alignItems: 'center', justifyContent: 'space-between',
justifyContent: 'space-between', paddingHorizontal: 18,
paddingHorizontal: 18, paddingTop: 15,
paddingTop: 15, paddingBottom: 4,
paddingBottom: 4, },
}, panelDate: {
panelDate: { fontSize: 16,
fontSize: 16, fontWeight: '700',
fontWeight: '700', },
}, panelCount: {
panelCount: { fontSize: 13,
fontSize: 13, },
}, emptyState: {
emptyState: { alignItems: 'center',
alignItems: 'center', paddingVertical: 36,
paddingVertical: 36, },
}, emptyText: {
emptyText: { fontSize: 15,
fontSize: 15, fontWeight: '600',
fontWeight: '600', marginBottom: 4,
marginBottom: 4, },
}, emptySubtext: {
emptySubtext: { fontSize: 13,
fontSize: 13, },
}, backButton: {
backButton: { padding: 8,
padding: 8, },
}, eventCard: {
eventCard: { marginHorizontal: 16,
marginHorizontal: 16, marginTop: 6,
marginTop: 6, },
}, subtaskList: {
subtaskList: { paddingLeft: 8,
paddingLeft: 8, paddingRight: 4,
paddingRight: 4, paddingTop: 4,
paddingTop: 4, },
}, });
});
+8 -28
View File
@@ -1,13 +1,5 @@
import React, { useCallback, useMemo, useState } from 'react'; import React, { useCallback, useMemo, useState } from 'react';
import { import { View, Text, StyleSheet, SafeAreaView, KeyboardAvoidingView, TouchableOpacity, BackHandler } from 'react-native';
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';
@@ -47,10 +39,7 @@ 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}
@@ -68,27 +57,18 @@ export default function TasksScreen() {
fill="none" fill="none"
/> />
{showCompleted && ( {showCompleted && (
<Path <Path d="M7 12.5l3.5 3.5 6.5-7" stroke={theme.accent} strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" fill="none" />
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 <Text style={[styles.completedToggleText, { color: showCompleted ? theme.text : theme.textMuted }]}>
style={[
styles.completedToggleText,
{ color: showCompleted ? theme.text : theme.textMuted },
]}
>
Completed Completed
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
<KeyboardAvoidingView style={styles.kbAvoid} behavior="padding"> <KeyboardAvoidingView
style={styles.kbAvoid}
behavior="padding"
>
<TaskList categoryIds={categoryIds} showCompleted={showCompleted} /> <TaskList categoryIds={categoryIds} showCompleted={showCompleted} />
<QuickAddBar /> <QuickAddBar />
</KeyboardAvoidingView> </KeyboardAvoidingView>
+59 -233
View File
@@ -1,18 +1,5 @@
import React, { useState, useCallback } from 'react'; import React, { useState, useCallback } from 'react';
import { import { View, Text, StyleSheet, SafeAreaView, ScrollView, Switch, TouchableOpacity, Alert, Linking, Modal, Pressable, BackHandler } from 'react-native';
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';
@@ -23,15 +10,7 @@ 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 { import { useSettings, SORT_OPTIONS, REMINDER_OPTIONS, ACCENT_PRESETS, DEFAULT_ACCENT } from '@/theme';
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';
@@ -45,38 +24,9 @@ import { getLastVisitedTab, tabHref } from '@/utils/tabHistory';
export default function SettingsScreen() { export default function SettingsScreen() {
const router = useRouter(); const router = useRouter();
const { const { theme, notifications, setNotifications, defaultCategoryId, setDefaultCategoryId, sortBy, setSortBy, reminderPreference, setReminderPreference, tagReminderPreferences, setTagReminderPreferences, apiUrl, accentColor, setAccentColor, accentOpacity, setAccentOpacity, todoAheadDays, setTodoAheadDays, showCompleted, setShowCompleted, deleteBySwipe, setDeleteBySwipe } = useSettings();
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< const [picker, setPicker] = useState<null | 'category' | 'sort' | 'reminder' | 'todoAhead'>(null);
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);
@@ -88,19 +38,16 @@ export default function SettingsScreen() {
const [draftAccentOpacity, setDraftAccentOpacity] = useState(accentOpacity); const [draftAccentOpacity, setDraftAccentOpacity] = useState(accentOpacity);
const [syncSubtitle, setSyncSubtitle] = useState('Checking...'); const [syncSubtitle, setSyncSubtitle] = useState('Checking...');
const [updateSubtitle, setUpdateSubtitle] = useState('Tap to check'); const [updateSubtitle, setUpdateSubtitle] = useState('Tap to check');
const [lastUpdateCheck, setLastUpdateCheck] = useState<string | null>(null);
const handleCheckUpdates = async () => { const handleCheckUpdates = async () => {
setUpdateSubtitle('Checking...'); setUpdateSubtitle('Checking...');
const update = await checkForUpdates(); const update = await checkForUpdates();
if (!update) { if (!update) {
setUpdateSubtitle('Up to date'); setUpdateSubtitle('Up to date');
setLastUpdateCheck(new Date().toLocaleString());
Alert.alert('Up to date', `You're running the latest version (${getCurrentAppVersion()}).`); Alert.alert('Up to date', `You're running the latest version (${getCurrentAppVersion()}).`);
return; return;
} }
setUpdateSubtitle(`${update.version} available`); setUpdateSubtitle(`${update.version} available`);
setLastUpdateCheck(new Date().toLocaleString());
const url = update.apkUrl ?? update.releaseUrl; const url = update.apkUrl ?? update.releaseUrl;
Alert.alert('Update available', `Version ${update.version} is available for download.`, [ Alert.alert('Update available', `Version ${update.version} is available for download.`, [
{ text: 'Later', style: 'cancel' }, { text: 'Later', style: 'cancel' },
@@ -113,17 +60,6 @@ export default function SettingsScreen() {
]); ]);
}; };
const checkForUpdatesOnLoad = async () => {
const update = await checkForUpdates();
if (!update) {
setUpdateSubtitle('Up to date');
setLastUpdateCheck(new Date().toLocaleString());
return;
}
setUpdateSubtitle(`${update.version} available`);
setLastUpdateCheck(new Date().toLocaleString());
};
const refreshSyncStatus = async () => { const refreshSyncStatus = async () => {
const token = await getAuthToken(); const token = await getAuthToken();
if (!token) { if (!token) {
@@ -134,9 +70,7 @@ export default function SettingsScreen() {
const lastSync = await getLastSyncTime(); const lastSync = await getLastSyncTime();
if (user) { if (user) {
setSyncSubtitle( setSyncSubtitle(
lastSync lastSync ? `Signed in · synced ${formatSyncTime(lastSync)}` : `Signed in as ${user.username}`
? `Signed in · synced ${formatSyncTime(lastSync)}`
: `Signed in as ${user.username}`
); );
} else { } else {
setSyncSubtitle('Not signed in'); setSyncSubtitle('Not signed in');
@@ -150,8 +84,6 @@ export default function SettingsScreen() {
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
refreshSyncStatus();
checkForUpdatesOnLoad();
const sub = BackHandler.addEventListener('hardwareBackPress', () => { const sub = BackHandler.addEventListener('hardwareBackPress', () => {
const target = getLastVisitedTab(); const target = getLastVisitedTab();
if (target && target !== 'settings') { if (target && target !== 'settings') {
@@ -160,18 +92,19 @@ export default function SettingsScreen() {
return true; return true;
}); });
return () => sub.remove(); return () => sub.remove();
}, [refreshSyncStatus, router]) }, [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 = const reminderLabel = REMINDER_OPTIONS.find((o) => o.value === reminderPreference)?.label ?? 'No reminder';
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.map(v => REMINDER_OPTIONS.find(o => o.value === v)?.label ?? v).join(', '); return selected
.map((v) => REMINDER_OPTIONS.find((o) => o.value === v)?.label ?? v)
.join(', ');
}; };
const openCustomAccent = () => { const openCustomAccent = () => {
@@ -213,12 +146,14 @@ 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={<View style={[styles.categoryDot, { backgroundColor: category.color }]} />} leftElement={
<View style={[styles.categoryDot, { backgroundColor: category.color }]} />
}
onPress={() => setEditingCategory(category)} onPress={() => setEditingCategory(category)}
showChevron showChevron
/> />
@@ -230,12 +165,7 @@ export default function SettingsScreen() {
> >
<View style={[styles.addIcon, { backgroundColor: theme.accentSoft }]}> <View style={[styles.addIcon, { backgroundColor: theme.accentSoft }]}>
<Svg width={16} height={16} viewBox="0 0 24 24"> <Svg width={16} height={16} viewBox="0 0 24 24">
<Path <Path d="M12 5v14M5 12h14" stroke={theme.accentText} strokeWidth={2} strokeLinecap="round" />
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>
@@ -247,18 +177,7 @@ export default function SettingsScreen() {
rightElement={ rightElement={
<Switch <Switch
value={notifications} value={notifications}
onValueChange={async value => { onValueChange={setNotifications}
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 }}
/> />
@@ -270,12 +189,14 @@ 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={<View style={[styles.categoryDot, { backgroundColor: category.color }]} />} leftElement={
<View style={[styles.categoryDot, { backgroundColor: category.color }]} />
}
onPress={() => notifications && setReminderTagPicker(category)} onPress={() => notifications && setReminderTagPicker(category)}
showChevron showChevron
/> />
@@ -289,7 +210,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
/> />
@@ -327,7 +248,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={[
@@ -340,14 +261,7 @@ 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 <Path d="M5 12l5 5 9-10" stroke={contrastOnSwatch(c)} strokeWidth={3} strokeLinecap="round" strokeLinejoin="round" fill="none" />
d="M5 12l5 5 9-10"
stroke={contrastOnSwatch(c)}
strokeWidth={3}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</Svg> </Svg>
)} )}
</TouchableOpacity> </TouchableOpacity>
@@ -360,15 +274,6 @@ 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
@@ -377,32 +282,6 @@ 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"
@@ -423,19 +302,26 @@ export default function SettingsScreen() {
onPress={handleCheckUpdates} onPress={handleCheckUpdates}
showChevron showChevron
/> />
<ListItem title="Last checked" subtitle={lastUpdateCheck ?? 'Never'} /> <ListItem
<ListItem title="Version" subtitle={getCurrentAppVersion()} /> title="Version"
<ListItem title="Privacy Policy" onPress={() => setLegalVisible('privacy')} showChevron /> subtitle={getCurrentAppVersion()}
<ListItem title="Terms of Service" onPress={() => setLegalVisible('terms')} showChevron /> />
<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={[ options={[{ value: '', label: 'None', color: theme.textMuted }, ...categories.map((c) => ({ value: c.id, label: c.name, color: c.color }))]}
{ 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)}
@@ -446,7 +332,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)}
/> />
@@ -459,15 +345,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)}
/> />
))} ))}
@@ -481,15 +367,6 @@ 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}
@@ -497,86 +374,41 @@ export default function SettingsScreen() {
onClose={() => setEditingCategory(null)} onClose={() => setEditingCategory(null)}
/> />
<SyncModal <SyncModal visible={syncVisible} onClose={() => { setSyncVisible(false); refreshSyncStatus(); }} />
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 <Modal visible={customAccentVisible} transparent animationType="fade" onRequestClose={() => setCustomAccentVisible(false)}>
visible={customAccentVisible} <Pressable
transparent style={styles.accentModalOverlay}
animationType="fade" onPress={() => setCustomAccentVisible(false)}
onRequestClose={() => setCustomAccentVisible(false)} >
> <Pressable style={[styles.accentModalSheet, { backgroundColor: theme.sheetBg }]} onPress={() => {}}>
<Pressable style={styles.accentModalOverlay} onPress={() => setCustomAccentVisible(false)}>
<Pressable
style={[styles.accentModalSheet, { backgroundColor: theme.sheetBg }]}
onPress={() => {}}
>
<View style={styles.accentModalHeader}> <View style={styles.accentModalHeader}>
<Text style={[styles.accentModalTitle, { color: theme.text }]}> <Text style={[styles.accentModalTitle, { color: theme.text }]}>Custom Accent Color</Text>
Custom Accent Color <TouchableOpacity onPress={() => setCustomAccentVisible(false)} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
</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 <Path d="M18 6L6 18M6 6l12 12" stroke={theme.textSecondary} strokeWidth={2.2} strokeLinecap="round" />
d="M18 6L6 18M6 6l12 12"
stroke={theme.textSecondary}
strokeWidth={2.2}
strokeLinecap="round"
/>
</Svg> </Svg>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
<ColorWheel <ColorWheel color={draftAccent} opacity={draftAccentOpacity} onChange={(color, opacity) => { setDraftAccent(color); setDraftAccentOpacity(opacity); }} />
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={() => { onPress={() => { setDraftAccent(DEFAULT_ACCENT); setDraftAccentOpacity(DEFAULT_ACCENT_OPACITY); setAccentColor(DEFAULT_ACCENT); setAccentOpacity(DEFAULT_ACCENT_OPACITY); }}
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 }]}> <Text style={[styles.accentModalButtonText, { color: theme.textSecondary }]}>Reset</Text>
Reset
</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
style={[ style={[styles.accentModalButton, styles.accentModalButtonPrimary, { backgroundColor: theme.accent }]}
styles.accentModalButton, onPress={() => { setAccentColor(draftAccent); setAccentOpacity(draftAccentOpacity); setCustomAccentVisible(false); }}
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 }]}> <Text style={[styles.accentModalButtonText, { color: theme.accentText }]}>Apply</Text>
Apply
</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</Pressable> </Pressable>
@@ -597,12 +429,6 @@ 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';
+12 -34
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,42 +25,27 @@ 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 }]}> <Text style={[styles.summaryValue, { color: theme.accent }]}>{stats.totalCompleted}</Text>
{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 }]}> <Text style={[styles.summaryValue, { color: theme.text }]}>{stats.completedLast7}</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 }]}> <Text style={[styles.summaryValue, { color: theme.text }]}>{stats.completedLast30}</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 }]}> <Text style={[styles.summaryValue, { color: theme.text }]}>{stats.completionRate}%</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 }]}> <Text style={[styles.summaryValue, { color: theme.text }]}>{stats.currentStreak}</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 <Text style={[styles.summaryValue, { color: stats.overdueCount > 0 ? theme.accent : theme.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>
@@ -79,10 +64,7 @@ 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>
@@ -96,11 +78,9 @@ 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 }]}> <Text style={[styles.emptyText, { color: theme.textFaint }]}>No completed tasks yet</Text>
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>
@@ -113,11 +93,9 @@ 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 }]}> <Text style={[styles.emptyText, { color: theme.textFaint }]}>No completed tasks yet</Text>
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>
+5 -20
View File
@@ -8,13 +8,9 @@ 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 { import { registerForPushNotificationsAsync, setupNotificationChannels, addNotificationResponseReceivedListener, removeNotificationSubscription } from '@/services/pushNotifications';
registerForPushNotificationsAsync,
setupNotificationChannels,
addNotificationResponseReceivedListener,
removeNotificationSubscription,
} from '@/services/pushNotifications';
import * as Notifications from 'expo-notifications'; import * as Notifications from 'expo-notifications';
import { Platform } from 'react-native';
Notifications.setNotificationHandler({ Notifications.setNotificationHandler({
handleNotification: async () => ({ handleNotification: async () => ({
@@ -33,7 +29,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;
@@ -58,29 +54,18 @@ function RootNavigator() {
requestQuickAddFocus(); requestQuickAddFocus();
}; };
Linking.getInitialURL() Linking.getInitialURL().then(handleUrl).catch(() => {});
.then(handleUrl)
.catch(() => {});
const subscription = Linking.addEventListener('url', ({ url }) => handleUrl(url)); const subscription = Linking.addEventListener('url', ({ url }) => handleUrl(url));
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
router.back();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => { return () => {
subscription.remove(); subscription.remove();
removeNotificationSubscription(responseListener); removeNotificationSubscription(responseListener);
window.removeEventListener('keydown', handleKeyDown);
}; };
}, [router]); }, [router]);
return ( return (
<GestureHandlerRootView style={{ flex: 1 }}> <GestureHandlerRootView style={{ flex: 1 }}>
<StatusBar style="dark" /> <StatusBar style="light" />
<Stack screenOptions={{ headerShown: false }}> <Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="(tabs)" /> <Stack.Screen name="(tabs)" />
<Stack.Screen name="add-task" /> <Stack.Screen name="add-task" />
+13 -20
View File
@@ -1,14 +1,5 @@
import React, { useMemo, useRef } from 'react'; import React, { useMemo, useRef } from 'react';
import { import { View, Text, StyleSheet, SafeAreaView, ScrollView, KeyboardAvoidingView, Platform, Keyboard } from 'react-native';
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';
@@ -121,16 +112,15 @@ 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 = const seriesId = data.repeat !== 'none'
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]) || '';
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;
@@ -156,7 +146,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 = '';
@@ -208,7 +198,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
@@ -224,7 +214,10 @@ export default function AddTaskScreen() {
)} )}
/> />
<SubtasksSection control={control} /> <SubtasksSection control={control} />
<PrioritySelector value={priority} onChange={value => setValue('priority', value)} /> <PrioritySelector
value={priority}
onChange={(value) => setValue('priority', value)}
/>
<DateTimePickerComponent control={control} /> <DateTimePickerComponent control={control} />
<RepeatSelector <RepeatSelector
value={repeat} value={repeat}
@@ -239,12 +232,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}
+118 -229
View File
@@ -1,14 +1,5 @@
import React, { useCallback, useMemo } from 'react'; import React, { useCallback, useMemo } from 'react';
import { import { View, Text, StyleSheet, SafeAreaView, ScrollView, TouchableOpacity, KeyboardAvoidingView, BackHandler } from 'react-native';
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';
@@ -26,11 +17,7 @@ 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 ( return d.getFullYear() === day.getFullYear() && d.getMonth() === day.getMonth() && d.getDate() === day.getDate();
d.getFullYear() === day.getFullYear() &&
d.getMonth() === day.getMonth() &&
d.getDate() === day.getDate()
);
} }
export default function DayViewScreen() { export default function DayViewScreen() {
@@ -66,68 +53,45 @@ 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( const handleToggleComplete = useCallback(async (taskId: string) => {
async (taskId: string) => { await confirmAndToggleTaskComplete(taskId, () => {
await confirmAndToggleTaskComplete(taskId, () => { refresh();
refresh();
refreshSubtasks();
});
},
[refresh, refreshSubtasks]
);
const handleToggleSubtask = useCallback(
async (subtaskId: string) => {
await toggleSubtaskComplete(subtaskId);
refreshSubtasks(); refreshSubtasks();
}, });
[refreshSubtasks] }, [refresh, refreshSubtasks]);
);
const handleToggleSubtask = useCallback(async (subtaskId: string) => {
await toggleSubtaskComplete(subtaskId);
refreshSubtasks();
}, [refreshSubtasks]);
return ( return (
<SafeAreaView <SafeAreaView style={[styles.container, { backgroundColor: theme.background, paddingBottom: insets.bottom }]}>
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 <ScrollView style={styles.scrollBody} contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}>
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 }]}> <Text style={[styles.emptyText, { color: theme.textSecondary }]}>No tasks on this day</Text>
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 <View key={task.id} style={[styles.eventCard, { backgroundColor: theme.card, borderColor: theme.border }]}>
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}
@@ -140,76 +104,41 @@ 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 <Path d="M7 12l3 3 7-7" stroke="#FFFFFF" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
d="M7 12l3 3 7-7"
stroke="#FFFFFF"
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</> </>
) : ( ) : (
<Circle <Circle cx={12} cy={12} r={10} stroke={theme.textSecondary} strokeWidth={2.5} fill="none" />
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={() => onPress={() => router.push({ pathname: '/task-detail', params: { id: task.id } })}
router.push({ pathname: '/task-detail', params: { id: task.id } })
}
activeOpacity={0.7} activeOpacity={0.7}
> >
<Text <Text
style={[ style={[styles.eventTitle, { color: theme.text }, task.completed && styles.eventCompleted]}
styles.eventTitle,
{ color: theme.text },
task.completed && styles.eventCompleted,
]}
numberOfLines={1} numberOfLines={1}
> >
{task.title} {task.title}
</Text> </Text>
{openCount > 0 && ( {openCount > 0 && (
<Text <Text style={[styles.eventSub, { color: theme.textMuted }]} numberOfLines={1}>
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 <View style={[styles.tagChip, { backgroundColor: desaturate(cat.color, 0.3) }]}>
style={[ <Text style={styles.tagText} numberOfLines={1}>{cat.name}</Text>
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 }]}> <Text style={[styles.timeText, { color: theme.textSecondary }]}>{task.dueTime}{task.endTime ? `–${task.endTime}` : ''}</Text>
{task.dueTime}
{task.endTime ? `–${task.endTime}` : ''}
</Text>
) : task.dueDate ? ( ) : task.dueDate ? (
<Text style={[styles.timeText, { color: theme.textSecondary }]}> <Text style={[styles.timeText, { color: theme.textSecondary }]}>{format(task.dueDate, 'HH:mm')}</Text>
{format(task.dueDate, 'HH:mm')}
</Text>
) : null} ) : null}
</View> </View>
)} )}
@@ -223,14 +152,7 @@ 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 <Path d="M9 6l6 6-6 6" stroke={theme.textSecondary} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
d="M9 6l6 6-6 6"
stroke={theme.textSecondary}
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</Svg> </Svg>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@@ -238,16 +160,10 @@ 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 <View key={sub.id} style={[styles.eventCard, { backgroundColor: theme.card, borderColor: theme.border }]}>
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}
@@ -260,49 +176,26 @@ 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 <Path d="M7 12l3 3 7-7" stroke="#FFFFFF" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
d="M7 12l3 3 7-7"
stroke="#FFFFFF"
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</> </>
) : ( ) : (
<Circle <Circle cx={12} cy={12} r={10} stroke={theme.textSecondary} strokeWidth={2.5} fill="none" />
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={() => onPress={() => router.push({ pathname: '/subtask-detail', params: { id: sub.id } })}
router.push({ pathname: '/subtask-detail', params: { id: sub.id } })
}
activeOpacity={0.7} activeOpacity={0.7}
> >
<Text <Text
style={[ style={[styles.eventTitle, { color: theme.text }, sub.completed && styles.eventCompleted]}
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 }]}> <Text style={[styles.timeText, { color: theme.textSecondary }]}>{sub.dueTime}{sub.endTime ? `–${sub.endTime}` : ''}</Text>
{sub.dueTime}
{sub.endTime ? `–${sub.endTime}` : ''}
</Text>
) : null} ) : null}
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@@ -312,93 +205,89 @@ export default function DayViewScreen() {
</> </>
)} )}
</ScrollView> </ScrollView>
<QuickAddBar <QuickAddBar dueDate={dayStart.getTime()} placeholder={`Add event for ${format(day, 'MMM d')}`} />
dueDate={dayStart.getTime()}
placeholder={`Add event for ${format(day, 'MMM d')}`}
/>
</KeyboardAvoidingView> </KeyboardAvoidingView>
{modals(() => {})} {modals(() => {})}
</SafeAreaView> </SafeAreaView>
); );
} }
const createStyles = (theme: ThemeColors) => const createStyles = (theme: ThemeColors) => StyleSheet.create({
StyleSheet.create({ container: {
container: { flex: 1,
flex: 1, },
}, kbAvoid: {
kbAvoid: { flex: 1,
flex: 1, },
}, scrollBody: {
scrollBody: { flex: 1,
flex: 1, },
}, scrollContent: {
scrollContent: { paddingTop: 12,
paddingTop: 12, paddingBottom: 16,
paddingBottom: 16, },
}, emptyState: {
emptyState: { alignItems: 'center',
alignItems: 'center', paddingVertical: 64,
paddingVertical: 64, },
}, emptyText: {
emptyText: { fontSize: 15,
fontSize: 15, fontWeight: '600',
fontWeight: '600', },
}, eventCard: {
eventCard: { marginHorizontal: 16,
marginHorizontal: 16, marginBottom: 8,
marginBottom: 8, borderRadius: 16,
borderRadius: 16, borderWidth: 1,
borderWidth: 1, paddingHorizontal: 14,
paddingHorizontal: 14, paddingVertical: 12,
paddingVertical: 12, },
}, eventRow: {
eventRow: { flexDirection: 'row',
flexDirection: 'row', alignItems: 'center',
alignItems: 'center', },
}, checkCircle: {
checkCircle: { width: 30,
width: 30, marginRight: 12,
marginRight: 12, },
}, eventTouch: {
eventTouch: { flex: 1,
flex: 1, },
}, eventTitle: {
eventTitle: { fontSize: 15,
fontSize: 15, fontWeight: '500',
fontWeight: '500', },
}, eventCompleted: {
eventCompleted: { textDecorationLine: 'line-through',
textDecorationLine: 'line-through', color: theme.textMuted,
color: theme.textMuted, },
}, eventSub: {
eventSub: { fontSize: 13,
fontSize: 13, marginTop: 2,
marginTop: 2, },
}, metaRow: {
metaRow: { flexDirection: 'row',
flexDirection: 'row', alignItems: 'center',
alignItems: 'center', gap: 8,
gap: 8, marginTop: 4,
marginTop: 4, },
}, tagChip: {
tagChip: { paddingHorizontal: 7,
paddingHorizontal: 7, paddingVertical: 2,
paddingVertical: 2, borderRadius: 6,
borderRadius: 6, maxWidth: 140,
maxWidth: 140, },
}, tagText: {
tagText: { color: '#FFFFFF',
color: '#FFFFFF', fontSize: 10,
fontSize: 10, fontWeight: '600',
fontWeight: '600', },
}, timeText: {
timeText: { fontSize: 12.5,
fontSize: 12.5, fontWeight: '500',
fontWeight: '500', },
}, menuButton: {
menuButton: { padding: 4,
padding: 4, marginLeft: 6,
marginLeft: 6, },
}, });
});
+17 -54
View File
@@ -1,15 +1,5 @@
import React from 'react'; import React from 'react';
import { import { View, Text, StyleSheet, SafeAreaView, ScrollView, KeyboardAvoidingView, Platform, TouchableOpacity, Alert } from 'react-native';
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';
@@ -25,14 +15,7 @@ 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 { import { SubtaskFormData, Reminder, parseReminders, toRemindersString, parseTaskTags, tagsToString } from '@/types';
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';
@@ -85,14 +68,7 @@ export default function SubtaskDetailScreen() {
}, },
}); });
const { const { handleSubmit, watch, setValue, control, reset, formState: { errors } } = methods;
handleSubmit,
watch,
setValue,
control,
reset,
formState: { errors },
} = methods;
const priority = watch('priority'); const priority = watch('priority');
const tags = watch('tags') ?? []; const tags = watch('tags') ?? [];
@@ -122,10 +98,7 @@ 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) repeatDays: ((subtask.repeatDays || '') as string).split(',').map(Number).filter((d) => !Number.isNaN(d)),
.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,
@@ -187,9 +160,7 @@ 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 }}> <Text style={{ color: theme.textFaint }}>{notFound ? 'Subtask not found' : 'Loading...'}</Text>
{notFound ? 'Subtask not found' : 'Loading...'}
</Text>
</View> </View>
); );
} }
@@ -200,23 +171,9 @@ export default function SubtaskDetailScreen() {
title="Edit Subtask" title="Edit Subtask"
showLogo={true} showLogo={true}
rightAction={ rightAction={
<TouchableOpacity <TouchableOpacity onPress={confirmDelete} activeOpacity={0.7} style={[styles.deleteButton, { borderColor: theme.accentBorder, backgroundColor: theme.accentSoft }]}>
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 <Path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13" stroke={theme.accent} strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" fill="none" />
d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"
stroke={theme.accent}
strokeWidth={2.2}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</Svg> </Svg>
</TouchableOpacity> </TouchableOpacity>
} }
@@ -244,8 +201,14 @@ export default function SubtaskDetailScreen() {
/> />
)} )}
/> />
<CategorySelector value={tags} onChange={value => setValue('tags', value)} /> <CategorySelector
<PrioritySelector value={priority} onChange={value => setValue('priority', value)} /> value={tags}
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}
@@ -260,12 +223,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}
+64 -165
View File
@@ -1,15 +1,5 @@
import React, { useRef } from 'react'; import React, { useRef } from 'react';
import { import { View, Text, StyleSheet, SafeAreaView, ScrollView, KeyboardAvoidingView, Platform, TouchableOpacity, Keyboard } from 'react-native';
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';
@@ -57,27 +47,13 @@ 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({ function CollapsibleSection({ title, children, defaultExpanded = false, icon }: { title: string; children: React.ReactNode; defaultExpanded?: boolean; icon: React.ReactNode }) {
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 <TouchableOpacity style={[styles.sectionHeader, { backgroundColor: theme.card, borderColor: theme.border }]} onPress={() => setExpanded(!expanded)} activeOpacity={0.8}>
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>
@@ -130,14 +106,7 @@ export default function TaskDetailScreen() {
}, },
}); });
const { const { handleSubmit, watch, setValue, control, reset, formState: { errors } } = methods;
handleSubmit,
watch,
setValue,
control,
reset,
formState: { errors },
} = methods;
const tags = watch('tags') ?? []; const tags = watch('tags') ?? [];
const priority = watch('priority'); const priority = watch('priority');
@@ -160,8 +129,7 @@ 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 = const remindersValue = task.reminders || (task.reminder && task.reminder !== 'none' ? task.reminder : '');
task.reminders || (task.reminder && task.reminder !== 'none' ? task.reminder : '');
reset({ reset({
title: task.title, title: task.title,
@@ -175,14 +143,11 @@ 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 || '') repeatDays: (task.repeatDays || '').split(',').map(Number).filter((d) => !Number.isNaN(d)),
.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 {
@@ -190,9 +155,7 @@ export default function TaskDetailScreen() {
} }
})(); })();
return () => { return () => { mounted = false; };
mounted = false;
};
}, [id, isReady, reset]); }, [id, isReady, reset]);
const onSubmit = async (data: TaskFormData) => { const onSubmit = async (data: TaskFormData) => {
@@ -211,7 +174,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 || '';
@@ -239,10 +202,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;
@@ -251,7 +214,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 || '';
@@ -281,11 +244,9 @@ 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 const children = await collections.subtasks.query(Q.where('parent_subtask_id', existing.id)).fetch();
.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;
}); });
@@ -327,23 +288,9 @@ export default function TaskDetailScreen() {
title="Edit Task" title="Edit Task"
showLogo={true} showLogo={true}
rightAction={ rightAction={
<TouchableOpacity <TouchableOpacity onPress={handleDelete} activeOpacity={0.7} style={[styles.deleteButton, { borderColor: theme.accentBorder, backgroundColor: theme.accentSoft }]}>
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 <Path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13" stroke={theme.accent} strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" fill="none" />
d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"
stroke={theme.accent}
strokeWidth={2.2}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</Svg> </Svg>
</TouchableOpacity> </TouchableOpacity>
} }
@@ -363,7 +310,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
@@ -379,49 +326,32 @@ export default function TaskDetailScreen() {
)} )}
/> />
<SubtasksSection control={control} /> <SubtasksSection control={control} />
<PrioritySelector value={priority} onChange={value => setValue('priority', value)} /> <PrioritySelector
value={priority}
onChange={(value) => setValue('priority', value)}
/>
<CollapsibleSection <CollapsibleSection title="Date & Time" icon={
title="Date & Time" <Svg width={20} height={20} viewBox="0 0 24 24">
icon={ <Circle cx={12} cy={12} r={10} stroke={theme.accent} strokeWidth={1.8} fill="none" />
<Svg width={20} height={20} viewBox="0 0 24 24"> <Path d="M12 6v6l4 2" stroke={theme.accent} strokeWidth={1.8} strokeLinecap="round" />
<Circle </Svg>
cx={12} } defaultExpanded={!!dueDate}>
cy={12}
r={10}
stroke={theme.accent}
strokeWidth={1.8}
fill="none"
/>
<Path
d="M12 6v6l4 2"
stroke={theme.accent}
strokeWidth={1.8}
strokeLinecap="round"
/>
</Svg>
}
defaultExpanded={!!dueDate}
>
<DateTimePickerComponent control={control} /> <DateTimePickerComponent control={control} />
</CollapsibleSection> </CollapsibleSection>
<CollapsibleSection <CollapsibleSection title="Repeat" icon={
title="Repeat" <Svg width={20} height={20} viewBox="0 0 24 24">
icon={ <Path
<Svg width={20} height={20} viewBox="0 0 24 24"> 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"
<Path stroke={theme.accent}
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" strokeWidth={1.8}
stroke={theme.accent} strokeLinecap="round"
strokeWidth={1.8} strokeLinejoin="round"
strokeLinecap="round" fill="none"
strokeLinejoin="round" />
fill="none" </Svg>
/> } defaultExpanded={repeat !== 'none'}>
</Svg>
}
defaultExpanded={repeat !== 'none'}
>
<RepeatSelector <RepeatSelector
value={repeat} value={repeat}
interval={repeatInterval} interval={repeatInterval}
@@ -434,74 +364,43 @@ export default function TaskDetailScreen() {
/> />
</CollapsibleSection> </CollapsibleSection>
<CollapsibleSection <CollapsibleSection title="Reminder" icon={
title="Reminder" <Svg width={20} height={20} viewBox="0 0 24 24">
icon={ <Path
<Svg width={20} height={20} viewBox="0 0 24 24"> 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"
<Path stroke={theme.accent}
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" strokeWidth={1.8}
stroke={theme.accent} strokeLinecap="round"
strokeWidth={1.8} strokeLinejoin="round"
strokeLinecap="round" fill="none"
strokeLinejoin="round" />
fill="none" </Svg>
/> } defaultExpanded={!!reminders && reminders !== ''}>
</Svg>
}
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 <CollapsibleSection title="Assignee" icon={
title="Assignee" <Svg width={20} height={20} viewBox="0 0 24 24">
icon={ <Circle cx={12} cy={12} r={10} stroke={theme.accent} strokeWidth={1.8} fill="none" />
<Svg width={20} height={20} viewBox="0 0 24 24"> <Path d="M12 10v6M12 19v1" stroke={theme.accent} strokeWidth={1.8} strokeLinecap="round" />
<Circle </Svg>
cx={12} } defaultExpanded={!!assigneeId}>
cy={12}
r={10}
stroke={theme.accent}
strokeWidth={1.8}
fill="none"
/>
<Path
d="M12 10v6M12 19v1"
stroke={theme.accent}
strokeWidth={1.8}
strokeLinecap="round"
/>
</Svg>
}
defaultExpanded={!!assigneeId}
>
<AssigneeSelector <AssigneeSelector
value={assigneeId} value={assigneeId}
onChange={(value: string | null) => setValue('assigneeId', value)} onChange={(value: string | null) => setValue('assigneeId', value)}
friends={friends.map(f => f.username)} friends={friends.map((f) => f.username)}
/> />
</CollapsibleSection> </CollapsibleSection>
<CollapsibleSection <CollapsibleSection title="Description" icon={
title="Description" <Svg width={20} height={20} viewBox="0 0 24 24">
icon={ <Path d="M4 6h16M4 12h16M4 18h10" stroke={theme.accent} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" fill="none" />
<Svg width={20} height={20} viewBox="0 0 24 24"> </Svg>
<Path } defaultExpanded={!!methods.getValues('description')}>
d="M4 6h16M4 12h16M4 18h10"
stroke={theme.accent}
strokeWidth={1.8}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</Svg>
}
defaultExpanded={!!methods.getValues('description')}
>
<Controller <Controller
control={control} control={control}
name="description" name="description"
+605 -727
View File
File diff suppressed because it is too large Load Diff
+10 -11
View File
@@ -9,23 +9,22 @@
"@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.18", "expo": "~57.0.10",
"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.16", "expo-dev-client": "~57.0.1",
"expo-device": "^57.0.1", "expo-device": "^57.0.1",
"expo-intent-launcher": "~57.0.1", "expo-linking": "~57.0.5",
"expo-linking": "~57.0.8", "expo-notifications": "~57.0.8",
"expo-notifications": "~57.0.15", "expo-router": "~57.0.10",
"expo-router": "~57.0.17", "expo-sqlite": "~57.0.1",
"expo-sqlite": "~57.0.2",
"expo-status-bar": "~57.0.1", "expo-status-bar": "~57.0.1",
"expo-system-ui": "~57.0.3", "expo-system-ui": "~57.0.2",
"expo-updates": "~57.0.19", "expo-updates": "~57.0.12",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"react-hook-form": "^7.51.5", "react-hook-form": "^7.51.5",
"react-native": "0.86.3", "react-native": "0.86.2",
"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",
@@ -41,7 +40,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.2", "eslint-config-expo": "~57.0.1",
"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"
@@ -1,78 +0,0 @@
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,
});
@@ -161,7 +161,7 @@ export function CategoryEditorModal({
</TouchableOpacity> </TouchableOpacity>
))} ))}
</View> </View>
<ColorWheel color={color} opacity={1} onChange={setColor} /> <ColorWheel color={color} onChange={setColor} />
<View style={styles.actions}> <View style={styles.actions}>
{category && ( {category && (
+1 -11
View File
@@ -1,17 +1,7 @@
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 = ({ export const ListItem = ({ title, subtitle, onPress, leftIcon, rightIcon, disabled, leftElement, rightElement, showChevron }: {
title,
subtitle,
onPress,
leftIcon,
rightIcon,
disabled,
leftElement,
rightElement,
showChevron,
}: {
title: string; title: string;
subtitle?: string | undefined; subtitle?: string | undefined;
onPress?: () => void; onPress?: () => void;
@@ -19,22 +19,10 @@ interface OptionPickerModalProps {
multiSelect?: boolean; multiSelect?: boolean;
} }
export function OptionPickerModal({ export function OptionPickerModal({ visible, title, options, selectedValue, onSelect, onClose, multiSelect = false }: OptionPickerModalProps) {
visible,
title,
options,
selectedValue,
onSelect,
onClose,
multiSelect = false,
}: OptionPickerModalProps) {
const { theme } = useSettings(); const { theme } = useSettings();
const selectedValues = Array.isArray(selectedValue) const selectedValues = Array.isArray(selectedValue) ? selectedValue : selectedValue == null ? [] : [selectedValue];
? selectedValue
: selectedValue == null
? []
: [selectedValue];
return ( return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}> <Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
@@ -43,7 +31,7 @@ export function OptionPickerModal({
<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 (
@@ -56,7 +44,7 @@ export function OptionPickerModal({
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 {
@@ -69,49 +57,23 @@ export function OptionPickerModal({
accessibilityLabel={item.label} accessibilityLabel={item.label}
accessibilityState={multiSelect ? { checked: selected } : { selected }} accessibilityState={multiSelect ? { checked: selected } : { selected }}
> >
{item.color && <View style={[styles.dot, { backgroundColor: item.color }]} />} {item.color && (
<Text <View style={[styles.dot, { backgroundColor: item.color }]} />
style={[ )}
styles.optionText, <Text style={[styles.optionText, { color: selected ? theme.accent : theme.textSecondary }]}>
{ 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 <Circle cx={12} cy={12} r={10} stroke={selected ? theme.accent : theme.borderStrong} strokeWidth={2} fill={selected ? theme.accent : 'transparent'} />
cx={12}
cy={12}
r={10}
stroke={selected ? theme.accent : theme.borderStrong}
strokeWidth={2}
fill={selected ? theme.accent : 'transparent'}
/>
{selected && ( {selected && (
<Path <Path d="M8 12l3 3 6-6" stroke="#FFFFFF" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
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 </Svg>
d="M5 12l5 5 9-10"
stroke={theme.accent}
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</Svg>
)
)} )}
</TouchableOpacity> </TouchableOpacity>
); );
@@ -24,17 +24,13 @@ 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 && { value === priority.value && { borderColor: theme.accent, backgroundColor: theme.accentSoft, borderWidth: 2 },
borderColor: theme.accent,
backgroundColor: theme.accentSoft,
borderWidth: 2,
},
]} ]}
onPress={() => onChange(priority.value)} onPress={() => onChange(priority.value)}
activeOpacity={0.8} activeOpacity={0.8}
@@ -42,20 +38,16 @@ 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 <View style={[
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={[
/> styles.optionText,
<Text { color: theme.textFaint },
style={[ value === priority.value && { color: theme.accentStrong, fontWeight: '600' },
styles.optionText, ]}>
{ color: theme.textFaint },
value === priority.value && { color: theme.accentStrong, fontWeight: '600' },
]}
>
{priority.label} {priority.label}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
+13 -53
View File
@@ -1,13 +1,5 @@
import React, { useState, useEffect, useMemo, useRef } from 'react'; import React, { useState, useEffect, useMemo, useRef } from 'react';
import { import { View, StyleSheet, TextInput, TouchableOpacity, Keyboard, Text, Platform } from 'react-native';
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';
@@ -22,20 +14,10 @@ interface QuickAddBarProps {
placeholder?: string; placeholder?: string;
} }
const BlurContainer = ({ const BlurContainer = ({ children, style, theme }: { children: React.ReactNode; style: any; theme: any }) => {
children,
style,
theme,
}: {
children: React.ReactNode;
style: any;
theme: any;
}) => {
if (Platform.OS === 'web') { if (Platform.OS === 'web') {
return ( return (
<View <View style={[style, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }]}>
style={[style, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }]}
>
{children} {children}
</View> </View>
); );
@@ -56,10 +38,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(() => {
@@ -75,7 +57,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 || '';
@@ -101,31 +83,16 @@ 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 <View style={[styles.bar, { backgroundColor: 'rgba(255,255,255,0.05)', borderColor: 'rgba(255,255,255,0.1)' }]}>
style={[
styles.bar,
{ backgroundColor: 'rgba(255,255,255,0.05)', borderColor: 'rgba(255,255,255,0.1)' },
]}
>
<TouchableOpacity <TouchableOpacity
style={[ style={[styles.categoryButton, { borderColor: theme.borderStrong, backgroundColor: theme.cardAlt }]}
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 <Path d="M6 9l6 6 6-6" stroke={theme.textSecondary} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
d="M6 9l6 6 6-6"
stroke={theme.textSecondary}
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</Svg> </Svg>
</TouchableOpacity> </TouchableOpacity>
<TextInput <TextInput
@@ -141,11 +108,7 @@ 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={[ style={[styles.submit, { backgroundColor: theme.accent }, title.trim() ? {} : styles.submitDisabled]}
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}
@@ -169,12 +132,9 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
<OptionPickerModal <OptionPickerModal
visible={categoryPickerVisible} visible={categoryPickerVisible}
title="Select Tags" title="Select Tags"
options={[ options={[{ value: '', label: 'None', color: theme.textMuted }, ...visibleCategories.map((c) => ({ value: c.id, label: c.name, color: c.color }))]}
{ 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>
@@ -202,7 +162,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.1, shadowOpacity: 0.10,
shadowRadius: 6, shadowRadius: 6,
elevation: 6, elevation: 6,
minHeight: 56, minHeight: 56,
@@ -18,13 +18,9 @@ export function ReminderSelector({ value, onChange, hasDueDate }: ReminderSelect
const selectedReminders = parseReminders(value); const selectedReminders = parseReminders(value);
const disabled = !hasDueDate; const disabled = !hasDueDate;
const reminderLabels = const reminderLabels = selectedReminders.length > 0
selectedReminders.length > 0 ? selectedReminders.map(r => REMINDER_OPTIONS.find(o => o.value === r)?.label).filter(Boolean).join(', ')
? selectedReminders : 'No reminder';
.map(r => REMINDER_OPTIONS.find(o => o.value === r)?.label)
.filter(Boolean)
.join(', ')
: 'No reminder';
return ( return (
<View style={styles.container}> <View style={styles.container}>
@@ -33,10 +29,7 @@ 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 && { selectedReminders.length > 0 && { borderColor: theme.accent, backgroundColor: theme.accentSoft },
borderColor: theme.accent,
backgroundColor: theme.accentSoft,
},
disabled && { opacity: 0.5 }, disabled && { opacity: 0.5 },
]} ]}
onPress={() => setShowPicker(true)} onPress={() => setShowPicker(true)}
@@ -56,14 +49,7 @@ export function ReminderSelector({ value, onChange, hasDueDate }: ReminderSelect
strokeLinejoin="round" strokeLinejoin="round"
fill="none" fill="none"
/> />
<Circle <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} />
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={[
@@ -76,32 +62,20 @@ 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 <Path d="M9 18l6-6-6-6" stroke={theme.textSecondary} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
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 }]}> <Text style={[styles.hint, { color: theme.textMuted }]}>Set a due date to add reminders.</Text>
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 => ({ options={REMINDER_OPTIONS.filter((o) => o.value !== 'none').map((o) => ({ value: o.value, label: o.label }))}
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
/> />
+30 -124
View File
@@ -1,22 +1,6 @@
import React from 'react'; import React from 'react';
import { import { View, Text, StyleSheet, TouchableOpacity, Modal, TextInput, Pressable, KeyboardAvoidingView } from 'react-native';
View, import { Repeat, REPEAT_OPTIONS, WEEKDAY_LABELS, WEEKDAY_ORDER, weekdaysInDisplayOrder, repeatDaysFromString } from '@/types';
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';
@@ -59,15 +43,7 @@ function RepeatIcon({ repeat, color }: { repeat: Repeat; color: string }) {
); );
} }
const WEEKDAY_NAMES = [ const WEEKDAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
'Sunday',
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday',
];
function unitLabel(value: Repeat): string { function unitLabel(value: Repeat): string {
switch (value) { switch (value) {
@@ -101,16 +77,12 @@ 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: { const applyProfile = (profile: { repeat: Repeat; repeatInterval: number; repeatDays: string }) => {
repeat: Repeat;
repeatInterval: number;
repeatDays: string;
}) => {
onChange(profile.repeat, profile.repeatInterval, repeatDaysFromString(profile.repeatDays)); onChange(profile.repeat, profile.repeatInterval, repeatDaysFromString(profile.repeatDays));
}; };
@@ -144,7 +116,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={[
@@ -159,15 +131,8 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
}, },
]} ]}
> >
<TouchableOpacity <TouchableOpacity onPress={() => applyProfile(profile)} activeOpacity={0.8} accessibilityRole="button" accessibilityLabel={`Apply repeat profile ${profile.name}`}>
onPress={() => applyProfile(profile)} <Text style={[styles.profileChipText, { color: theme.textSecondary }]}>{profile.name}</Text>
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)}
@@ -184,17 +149,13 @@ 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 && { value === option.value && { borderColor: theme.accent, backgroundColor: theme.accentSoft, borderWidth: 2 },
borderColor: theme.accent,
backgroundColor: theme.accentSoft,
borderWidth: 2,
},
]} ]}
onPress={() => selectRepeat(option.value)} onPress={() => selectRepeat(option.value)}
activeOpacity={0.8} activeOpacity={0.8}
@@ -202,10 +163,7 @@ 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 <RepeatIcon repeat={option.value} color={value === option.value ? theme.accent : theme.textFaint} />
repeat={option.value}
color={value === option.value ? theme.accent : theme.textFaint}
/>
<Text <Text
style={[ style={[
styles.chipText, styles.chipText,
@@ -221,12 +179,7 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
{value !== 'none' && ( {value !== 'none' && (
<View style={styles.settings}> <View style={styles.settings}>
<View <View style={[styles.intervalRow, { backgroundColor: theme.card, borderColor: theme.borderStrong }]}>
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 }]}
@@ -237,14 +190,7 @@ 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 <Text style={[styles.stepButtonText, { color: interval <= 1 ? theme.textMuted : theme.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
@@ -256,14 +202,7 @@ 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 <Text style={[styles.stepButtonText, { color: interval >= 30 ? theme.textMuted : theme.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)}
@@ -273,20 +212,14 @@ 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) && { days.includes(day) && { backgroundColor: theme.accent, borderColor: theme.accent },
backgroundColor: theme.accent, value === 'custom' && days.length === 1 && days.includes(day) && styles.dayChipLast,
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}
@@ -317,16 +250,9 @@ 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 <Path d="M12 5v14M5 12h14" stroke={theme.accent} strokeWidth={2.5} strokeLinecap="round" />
d="M12 5v14M5 12h14"
stroke={theme.accent}
strokeWidth={2.5}
strokeLinecap="round"
/>
</Svg> </Svg>
<Text style={[styles.saveProfileText, { color: theme.accentStrong }]}> <Text style={[styles.saveProfileText, { color: theme.accentStrong }]}>Save as profile</Text>
Save as profile
</Text>
</TouchableOpacity> </TouchableOpacity>
)} )}
@@ -336,29 +262,19 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
animationType="fade" animationType="fade"
onRequestClose={() => setSaveModalVisible(false)} onRequestClose={() => setSaveModalVisible(false)}
> >
<KeyboardAvoidingView style={styles.modalBackdrop} behavior="padding"> <KeyboardAvoidingView
style={styles.modalBackdrop}
behavior="padding"
>
<Pressable style={styles.modalBackdrop} onPress={() => setSaveModalVisible(false)}> <Pressable style={styles.modalBackdrop} onPress={() => setSaveModalVisible(false)}>
<Pressable <Pressable style={[styles.modalCard, { backgroundColor: theme.card, borderColor: theme.border }]}>
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 {isDaysBased(value) && days.length > 0 ? ` · ${weekdaysInDisplayOrder(days).map((d) => WEEKDAY_LABELS[d]).join(' ')}` : ''}
? ` · ${weekdaysInDisplayOrder(days)
.map(d => WEEKDAY_LABELS[d])
.join(' ')}`
: ''}
</Text> </Text>
<TextInput <TextInput
style={[ style={[styles.modalInput, { backgroundColor: theme.cardAlt, borderColor: theme.borderStrong, color: theme.text }]}
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)"
@@ -374,25 +290,15 @@ 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 }]}> <Text style={[styles.modalButtonText, { color: theme.textSecondary }]}>Cancel</Text>
Cancel
</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
style={[ style={[styles.modalButton, styles.modalButtonPrimary, { backgroundColor: theme.accent }]}
styles.modalButton,
styles.modalButtonPrimary,
{ backgroundColor: theme.accent },
]}
onPress={handleSaveProfile} onPress={handleSaveProfile}
disabled={!profileName.trim()} disabled={!profileName.trim()}
activeOpacity={0.8} activeOpacity={0.8}
> >
<Text <Text style={[styles.modalButtonText, { color: theme.accentText, fontWeight: '600' }]}>Save</Text>
style={[styles.modalButtonText, { color: theme.accentText, fontWeight: '600' }]}
>
Save
</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</Pressable> </Pressable>
@@ -1,13 +1,5 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { import { Modal, View, Text, StyleSheet, TextInput, TouchableOpacity, KeyboardAvoidingView } from 'react-native';
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';
@@ -41,22 +33,16 @@ 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 style={styles.overlay} behavior="padding"> <KeyboardAvoidingView
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 <TouchableOpacity onPress={onClose} style={styles.closeButton} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
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 <Path d="M18 6L6 18M6 6l12 12" stroke={theme.textSecondary} strokeWidth={2.2} strokeLinecap="round" />
d="M18 6L6 18M6 6l12 12"
stroke={theme.textSecondary}
strokeWidth={2.2}
strokeLinecap="round"
/>
</Svg> </Svg>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@@ -65,11 +51,7 @@ 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}
@@ -93,11 +75,7 @@ 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={[ style={[styles.saveButton, { backgroundColor: theme.accent }, !isValid && styles.saveButtonDisabled]}
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,9 +48,7 @@ export function SubtasksSection({ control }: SubtasksSectionProps) {
<Text style={[styles.label, { color: theme.text }]}>Subtasks</Text> <Text style={[styles.label, { color: theme.text }]}>Subtasks</Text>
{items.length > 0 && ( {items.length > 0 && (
<View style={[styles.badge, { backgroundColor: theme.accentSoft }]}> <View style={[styles.badge, { backgroundColor: theme.accentSoft }]}>
<Text style={[styles.badgeText, { color: theme.accentStrong }]}> <Text style={[styles.badgeText, { color: theme.accentStrong }]}>{items.length}</Text>
{items.length}
</Text>
</View> </View>
)} )}
</View> </View>
@@ -94,30 +92,20 @@ 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={[ style={[styles.addButton, { backgroundColor: theme.accentSoft, borderColor: theme.accentBorder }]}
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 <Path d="M12 5v14M5 12h14" stroke={theme.accent} strokeWidth={2} strokeLinecap="round" />
d="M12 5v14M5 12h14"
stroke={theme.accent}
strokeWidth={2}
strokeLinecap="round"
/>
</Svg> </Svg>
<Text style={[styles.addButtonText, { color: theme.accentStrong }]}> <Text style={[styles.addButtonText, { color: theme.accentStrong }]}>Add Subtask</Text>
Add Subtask
</Text>
</TouchableOpacity> </TouchableOpacity>
</Animated.View> </Animated.View>
</View> </View>
@@ -162,12 +150,7 @@ function SubtaskItem({ index, value, onChange, onRemove }: SubtaskItemProps) {
/> />
<TouchableOpacity style={styles.removeButton} onPress={onRemove} activeOpacity={0.7}> <TouchableOpacity style={styles.removeButton} onPress={onRemove} activeOpacity={0.7}>
<Svg width={20} height={20} viewBox="0 0 24 24"> <Svg width={20} height={20} viewBox="0 0 24 24">
<Path <Path d="M18 6L6 18M6 6l12 12" stroke={theme.textSecondary} strokeWidth={2.2} strokeLinecap="round" />
d="M18 6L6 18M6 6l12 12"
stroke={theme.textSecondary}
strokeWidth={2.2}
strokeLinecap="round"
/>
</Svg> </Svg>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
+21 -50
View File
@@ -11,18 +11,9 @@ 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 { import { runSyncGuarded, startAutoSync, stopAutoSync, getLastSyncTime, SyncResult } from '@/database/sync';
runSyncGuarded,
startAutoSync,
stopAutoSync,
getLastSyncTime,
SyncResult,
} from '@/database/sync';
import { ForgotPasswordModal } from './ForgotPasswordModal'; import { ForgotPasswordModal } from './ForgotPasswordModal';
import { import { registerForPushNotificationsAsync, unregisterForPushNotificationsAsync } from '@/services/pushNotifications';
registerForPushNotificationsAsync,
unregisterForPushNotificationsAsync,
} from '@/services/pushNotifications';
interface SyncModalProps { interface SyncModalProps {
visible: boolean; visible: boolean;
@@ -90,10 +81,9 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
} }
setBusy(true); setBusy(true);
try { try {
const authedUser = const authedUser = mode === 'register'
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);
startAutoSync(); startAutoSync();
registerForPushNotificationsAsync().catch(() => {}); registerForPushNotificationsAsync().catch(() => {});
@@ -138,7 +128,10 @@ 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 style={styles.overlay} behavior="padding"> <KeyboardAvoidingView
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 }]} />
@@ -166,9 +159,7 @@ 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 }]}> <Text style={[styles.primaryButtonText, { color: theme.accentText }]}>Sync Now</Text>
Sync Now
</Text>
)} )}
</TouchableOpacity> </TouchableOpacity>
@@ -190,9 +181,7 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
onPress={handleSignOut} onPress={handleSignOut}
activeOpacity={0.7} activeOpacity={0.7}
> >
<Text style={[styles.secondaryButtonText, { color: theme.dangerStrong }]}> <Text style={[styles.secondaryButtonText, { color: theme.dangerStrong }]}>Sign Out</Text>
Sign Out
</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
) : ( ) : (
@@ -202,10 +191,13 @@ 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={[styles.segment, mode === m && { backgroundColor: theme.accent }]} style={[
styles.segment,
mode === m && { backgroundColor: theme.accent },
]}
onPress={() => { onPress={() => {
setMode(m); setMode(m);
setError(null); setError(null);
@@ -230,14 +222,7 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
</Text> </Text>
)} )}
<TextInput <TextInput
style={[ style={[styles.input, { backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text }]}
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}
@@ -246,14 +231,7 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
autoCorrect={false} autoCorrect={false}
/> />
<TextInput <TextInput
style={[ style={[styles.input, { backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text }]}
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}
@@ -267,15 +245,11 @@ 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 }]}> <Text style={[styles.forgotPasswordText, { color: theme.accent }]}>Forgot Password?</Text>
Forgot Password?
</Text>
</TouchableOpacity> </TouchableOpacity>
)} )}
{error && ( {error && <Text style={[styles.errorText, { color: theme.dangerStrong }]}>{error}</Text>}
<Text style={[styles.errorText, { color: theme.dangerStrong }]}>{error}</Text>
)}
<TouchableOpacity <TouchableOpacity
style={[styles.primaryButton, { backgroundColor: theme.accent }]} style={[styles.primaryButton, { backgroundColor: theme.accent }]}
@@ -294,10 +268,7 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
</View> </View>
)} )}
<ForgotPasswordModal <ForgotPasswordModal visible={showForgotPassword} onClose={() => setShowForgotPassword(false)} />
visible={showForgotPassword}
onClose={() => setShowForgotPassword(false)}
/>
<TouchableOpacity <TouchableOpacity
style={[styles.secondaryButton, { borderColor: theme.borderStrong }]} style={[styles.secondaryButton, { borderColor: theme.borderStrong }]}
+10 -44
View File
@@ -66,37 +66,20 @@ 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 <Path d="M5 12l5 5 9-10" stroke={theme.success} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
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 <Path d="M12 8v4M12 16h.01" stroke={theme.danger} strokeWidth={2} strokeLinecap="round" />
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 <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" />
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>
); );
@@ -105,25 +88,16 @@ export default function SyncStatus({ compact = false }: SyncStatusProps) {
const getStatusText = () => { const getStatusText = () => {
switch (status) { switch (status) {
case 'syncing': case 'syncing': return 'Syncing...';
return 'Syncing...'; case 'success': return 'Synced';
case 'success': case 'error': return 'Sync failed';
return 'Synced'; default: return lastSync ? `Last sync: ${formatTime(lastSync)}` : 'Not synced';
case 'error':
return 'Sync failed';
default:
return lastSync ? `Last sync: ${formatTime(lastSync)}` : 'Not synced';
} }
}; };
if (compact) { if (compact) {
return ( return (
<TouchableOpacity <TouchableOpacity style={[styles.compact, { backgroundColor: theme.sheetBg }]} onPress={handleSync} disabled={status === 'syncing'} activeOpacity={0.7}>
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 }]}>
@@ -142,20 +116,12 @@ 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={[ style={[styles.syncButton, { backgroundColor: status === 'syncing' ? theme.accentSoft : theme.accent }]}
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 <Text style={[styles.syncButtonText, { color: status === 'syncing' ? theme.accent : theme.accentText }]}>
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,14 +56,7 @@ export function TabBarIcon({ name, focused, color, size = 24 }: TabBarIconProps)
)} )}
{name === 'gear' && ( {name === 'gear' && (
<> <>
<Circle <Circle cx={12} cy={12} r={3.5} stroke={color} strokeWidth={focused ? 3.5 : 3} fill="none" />
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}
@@ -83,7 +76,9 @@ export function TabBarIcon({ name, focused, color, size = 24 }: TabBarIconProps)
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
/> />
{focused && <Circle cx={4} cy={10} r={2} fill={color} />} {focused && (
<Circle cx={4} cy={10} r={2} fill={color} />
)}
</> </>
)} )}
</Svg> </Svg>
@@ -14,15 +14,7 @@ interface TaskDeleteModalProps {
onDelete: (scope: TaskDeleteScope) => void; onDelete: (scope: TaskDeleteScope) => void;
} }
export function TaskDeleteModal({ export function TaskDeleteModal({ visible, taskId, taskTitle, isRepeating, subtask, onClose, onDelete }: TaskDeleteModalProps) {
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 });
@@ -30,13 +22,11 @@ export function TaskDeleteModal({
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 () => { return () => { mounted = false; };
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
@@ -44,11 +34,7 @@ export function TaskDeleteModal({
: [{ scope: 'this', label: 'This task only' }]; : [{ scope: 'this', label: 'This task only' }];
if (isRepeating) { if (isRepeating) {
options.push({ options.push({ scope: 'future', label: 'This and future tasks', hint: counts.future > 1 ? `${counts.future} occurrences` : undefined });
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` });
} }
@@ -57,21 +43,10 @@ export function TaskDeleteModal({
<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 }]}> <Text style={[styles.title, { color: theme.text }]}>{subtask ? 'Delete Subtask' : 'Delete Task'}</Text>
{subtask ? 'Delete Subtask' : 'Delete Task'} <TouchableOpacity onPress={onClose} style={styles.closeButton} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
</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 <Path d="M18 6L6 18M6 6l12 12" stroke={theme.textSecondary} strokeWidth={2.2} strokeLinecap="round" />
d="M18 6L6 18M6 6l12 12"
stroke={theme.textSecondary}
strokeWidth={2.2}
strokeLinecap="round"
/>
</Svg> </Svg>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@@ -89,26 +64,19 @@ export function TaskDeleteModal({
)} )}
<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 }]}> <Text style={[styles.actionLabel, { color: theme.accentStrong }]}>{option.label}</Text>
{option.label} {option.hint && <Text style={[styles.actionHint, { color: theme.textMuted }]}>{option.hint}</Text>}
</Text>
{option.hint && (
<Text style={[styles.actionHint, { color: theme.textMuted }]}>{option.hint}</Text>
)}
</TouchableOpacity> </TouchableOpacity>
))} ))}
<TouchableOpacity <TouchableOpacity
style={[ style={[styles.cancelButton, { borderColor: theme.borderStrong, backgroundColor: theme.card }]}
styles.cancelButton,
{ borderColor: theme.borderStrong, backgroundColor: theme.card },
]}
onPress={onClose} onPress={onClose}
activeOpacity={0.7} activeOpacity={0.7}
> >
+330 -422
View File
@@ -1,19 +1,6 @@
import React from 'react'; import React from 'react';
import { import { View, Text, StyleSheet, TouchableOpacity, Animated, Platform, Dimensions } from 'react-native';
View, import { Swipeable, Gesture, GestureDetector, PanGestureHandler } from 'react-native-gesture-handler';
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';
@@ -59,30 +46,7 @@ interface TaskItemProps {
categoryColors?: (string | undefined)[]; categoryColors?: (string | undefined)[];
} }
export const TaskItem = React.memo(function TaskItem({ 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) {
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));
@@ -109,17 +73,17 @@ export const TaskItem = React.memo(function TaskItem({
.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(() => {
@@ -140,12 +104,12 @@ export const TaskItem = React.memo(function TaskItem({
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(() => {
@@ -171,11 +135,7 @@ export const TaskItem = React.memo(function TaskItem({
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 = const isDueToday = hasDueDate && !task.completed && task.dueDate >= startOfToday.getTime() && task.dueDate <= endOfToday.getTime();
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]);
@@ -189,39 +149,21 @@ export const TaskItem = React.memo(function TaskItem({
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 <Animated.View style={[styles.swipeAction, styles.swipeDelete, { transform: [{ translateX }] }]}>
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 <Path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13" stroke="#FFFFFF" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" fill="none" />
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 <Animated.View style={[styles.swipeAction, styles.swipeComplete, { transform: [{ translateX }] }]}>
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 <Path d="M5 12l5 5 9-10" stroke="#FFFFFF" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
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>
@@ -246,9 +188,7 @@ export const TaskItem = React.memo(function TaskItem({
overshootRight={false} overshootRight={false}
overshootLeft={false} overshootLeft={false}
> >
<GestureDetector <GestureDetector gesture={draggable ? (onReorderStart ? reorderGesture : dragGesture) : Gesture.Native()}>
gesture={draggable ? (onReorderStart ? reorderGesture : dragGesture) : Gesture.Native()}
>
<Animated.View <Animated.View
style={[ style={[
styles.container, styles.container,
@@ -266,18 +206,18 @@ export const TaskItem = React.memo(function TaskItem({
]} ]}
pointerEvents={dragging ? 'none' : 'auto'} pointerEvents={dragging ? 'none' : 'auto'}
> >
<TouchableOpacity <TouchableOpacity
style={styles.contentWrapper} style={styles.contentWrapper}
onPress={onPress} onPress={onPress}
onLongPress={onLongPress} onLongPress={onLongPress}
delayLongPress={350} delayLongPress={350}
activeOpacity={0.8} activeOpacity={0.8}
accessibilityRole={selectionMode ? 'checkbox' : 'button'} accessibilityRole={selectionMode ? 'checkbox' : 'button'}
accessibilityLabel={selectionMode ? `Select ${task.title}` : task.title} accessibilityLabel={selectionMode ? `Select ${task.title}` : task.title}
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 && (
@@ -296,24 +236,10 @@ export const TaskItem = React.memo(function TaskItem({
{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 <Path d="M7 12l3 3 7-7" stroke="#FFFFFF" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
d="M7 12l3 3 7-7"
stroke="#FFFFFF"
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</> </>
) : ( ) : (
<Circle <Circle cx={12} cy={12} r={10} stroke={isOverdue ? theme.danger : theme.textSecondary} strokeWidth={2.5} fill="none" />
cx={12}
cy={12}
r={10}
stroke={isOverdue ? theme.danger : theme.textSecondary}
strokeWidth={2.5}
fill="none"
/>
)} )}
</Svg> </Svg>
</TouchableOpacity> </TouchableOpacity>
@@ -327,30 +253,90 @@ export const TaskItem = React.memo(function TaskItem({
> >
{task.title} {task.title}
</Animated.Text> </Animated.Text>
{task.priority !== 'none' && ( {task.priority !== 'none' && (
<View <View
style={[ style={[
styles.priorityBadge, styles.priorityBadge,
{ backgroundColor: PRIORITY_COLORS[task.priority] }, { backgroundColor: PRIORITY_COLORS[task.priority] },
]} ]}
> >
<Text style={styles.priorityText}>{task.priority.charAt(0).toUpperCase()}</Text> <Text style={styles.priorityText}>
</View> {task.priority.charAt(0).toUpperCase()}
)} </Text>
{task.assigneeId && ( </View>
<View style={styles.assigneeBadge}> )}
<Text style={styles.assigneeText}> {task.assigneeId && (
{task.assigneeId.charAt(0).toUpperCase()} <View style={styles.assigneeBadge}>
</Text> <Text style={styles.assigneeText}>
</View> {task.assigneeId.charAt(0).toUpperCase()}
)} </Text>
{task.repeat && task.repeat !== 'none' && ( </View>
<View style={styles.repeatIcon}> )}
<Svg width={14} height={14} viewBox="0 0 24 24"> {task.repeat && task.repeat !== 'none' && (
<View style={styles.repeatIcon}>
<Svg width={14} height={14} viewBox="0 0 24 24">
<Path
d="M17 2l4 4-4 4M3 11v-1a4 4 0 0 1 4-4h14M7 22l-4-4 4-4M21 13v1a4 4 0 0 1-4 4H3"
stroke={theme.textSecondary}
strokeWidth={2.2}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</Svg>
</View>
)}
{task.allDay && (
<View style={styles.allDayBadge}>
<Svg width={14} height={14} viewBox="0 0 24 24">
<Path
d="M12 2v2M12 20v2M2 12h2M20 12h2M5.6 5.6l1.4 1.4M17 17l1.4 1.4M5.6 18.4l1.4-1.4M17 7l1.4-1.4"
stroke={theme.accent}
strokeWidth={1.8}
strokeLinecap="round"
fill="none"
/>
<Circle cx={12} cy={12} r={4} stroke={theme.accent} strokeWidth={1.5} fill="none" />
</Svg>
<Text style={styles.allDayText}>All Day</Text>
</View>
)}
<Animated.View style={[styles.chevron, { transform: [{ rotate }] }]}>
<Svg width={16} height={16} viewBox="0 0 24 24">
<Path
d="M6 9l6 6 6-6"
stroke={theme.textSecondary}
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</Svg>
</Animated.View>
</View>
{hasDueDate && (
<View style={styles.dueRow}>
<Svg width={14} height={14} viewBox="0 0 24 24">
<Circle cx={12} cy={12} r={10} stroke={theme.textSecondary} strokeWidth={2} fill="none" />
<Path d="M12 6v6l4 2" stroke={theme.textSecondary} strokeWidth={2} strokeLinecap="round" />
</Svg>
<Animated.Text
style={[
styles.dueText,
{ color: theme.textFaint },
isOverdue && styles.dueTextOverdue,
isDueToday && styles.dueTextDueToday,
]}
>
{formattedDueDate}
</Animated.Text>
{task.reminder && task.reminder !== 'none' && (
<View style={styles.reminderIcon}>
<Svg width={13} height={13} viewBox="0 0 24 24">
<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="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9M10.3 21a1.94 1.94 0 0 0 3.4 0"
stroke={theme.textSecondary} stroke={theme.textSecondary}
strokeWidth={2.2} strokeWidth={1.8}
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
fill="none" fill="none"
@@ -358,103 +344,26 @@ export const TaskItem = React.memo(function TaskItem({
</Svg> </Svg>
</View> </View>
)} )}
{task.allDay && (
<View style={styles.allDayBadge}>
<Svg width={14} height={14} viewBox="0 0 24 24">
<Path
d="M12 2v2M12 20v2M2 12h2M20 12h2M5.6 5.6l1.4 1.4M17 17l1.4 1.4M5.6 18.4l1.4-1.4M17 7l1.4-1.4"
stroke={theme.accent}
strokeWidth={1.8}
strokeLinecap="round"
fill="none"
/>
<Circle
cx={12}
cy={12}
r={4}
stroke={theme.accent}
strokeWidth={1.5}
fill="none"
/>
</Svg>
<Text style={styles.allDayText}>All Day</Text>
</View>
)}
<Animated.View style={[styles.chevron, { transform: [{ rotate }] }]}>
<Svg width={16} height={16} viewBox="0 0 24 24">
<Path
d="M6 9l6 6 6-6"
stroke={theme.textSecondary}
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</Svg>
</Animated.View>
</View> </View>
{hasDueDate && ( )}
<View style={styles.dueRow}> </View>
<Svg width={14} height={14} viewBox="0 0 24 24"> </TouchableOpacity>
<Circle
cx={12}
cy={12}
r={10}
stroke={theme.textSecondary}
strokeWidth={2}
fill="none"
/>
<Path
d="M12 6v6l4 2"
stroke={theme.textSecondary}
strokeWidth={2}
strokeLinecap="round"
/>
</Svg>
<Animated.Text
style={[
styles.dueText,
{ color: theme.textFaint },
isOverdue && styles.dueTextOverdue,
isDueToday && styles.dueTextDueToday,
]}
>
{formattedDueDate}
</Animated.Text>
{task.reminder && task.reminder !== 'none' && (
<View style={styles.reminderIcon}>
<Svg width={13} height={13} viewBox="0 0 24 24">
<Path
d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9M10.3 21a1.94 1.94 0 0 0 3.4 0"
stroke={theme.textSecondary}
strokeWidth={1.8}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</Svg>
</View>
)}
</View>
)}
</View>
</TouchableOpacity>
<TouchableOpacity <TouchableOpacity
style={styles.menuButton} style={styles.menuButton}
onPress={onMenuOpen} onPress={onMenuOpen}
activeOpacity={0.7} activeOpacity={0.7}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={`Edit ${task.title}`} accessibilityLabel={`Edit ${task.title}`}
accessibilityHint="Opens the task editor" accessibilityHint="Opens the task editor"
hitSlop={8} hitSlop={8}
> >
<Svg width={24} height={24} viewBox="0 0 24 24"> <Svg width={24} height={24} viewBox="0 0 24 24">
<Circle cx={12} cy={12} r={1.5} fill={theme.textMuted} /> <Circle cx={12} cy={12} r={1.5} fill={theme.textMuted} />
<Circle cx={12} cy={6} r={1.5} fill={theme.textMuted} /> <Circle cx={12} cy={6} r={1.5} fill={theme.textMuted} />
<Circle cx={12} cy={18} r={1.5} fill={theme.textMuted} /> <Circle cx={12} cy={18} r={1.5} fill={theme.textMuted} />
</Svg> </Svg>
</TouchableOpacity> </TouchableOpacity>
</Animated.View> </Animated.View>
</GestureDetector> </GestureDetector>
</Swipeable> </Swipeable>
@@ -491,205 +400,204 @@ 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) => const createStyles = (theme: ThemeColors) => StyleSheet.create({
StyleSheet.create({ container: {
container: { flexDirection: 'row',
flexDirection: 'row', alignItems: 'center',
alignItems: 'center', paddingHorizontal: 12,
paddingHorizontal: 12, paddingVertical: 14,
paddingVertical: 14, borderRadius: 16,
borderRadius: 16, borderWidth: 1,
borderWidth: 1, marginVertical: 4,
marginVertical: 4, shadowColor: '#000',
shadowColor: '#000', shadowOffset: { width: 0, height: 1 },
shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.05,
shadowOpacity: 0.05, shadowRadius: 6,
shadowRadius: 6, elevation: 1,
elevation: 1, },
}, taskCompleted: {
taskCompleted: { opacity: 0.5,
opacity: 0.5, },
}, indented: {
indented: { marginLeft: 32,
marginLeft: 32, marginBottom: 4,
marginBottom: 4, },
}, taskOverdue: {
taskOverdue: { borderColor: theme.dangerBorder,
borderColor: theme.dangerBorder, },
}, taskDueToday: {
taskDueToday: { borderColor: mixHex(theme.info, theme.background, 0.55),
borderColor: mixHex(theme.info, theme.background, 0.55), },
}, taskSelected: {
taskSelected: { shadowColor: '#000',
shadowColor: '#000', shadowOpacity: 0.1,
shadowOpacity: 0.1, shadowRadius: 6,
shadowRadius: 6, elevation: 2,
elevation: 2, },
}, taskHovered: {
taskHovered: { borderColor: mixHex(theme.info, theme.background, 0.55),
borderColor: mixHex(theme.info, theme.background, 0.55), backgroundColor: mixHex(theme.info, theme.background, 0.85),
backgroundColor: mixHex(theme.info, theme.background, 0.85), },
}, dragLifted: {
dragLifted: { zIndex: 100,
zIndex: 100, elevation: 12,
elevation: 12, shadowColor: '#000',
shadowColor: '#000', shadowOpacity: 0.25,
shadowOpacity: 0.25, shadowRadius: 12,
shadowRadius: 12, shadowOffset: { width: 0, height: 8 },
shadowOffset: { width: 0, height: 8 }, },
}, contentWrapper: {
contentWrapper: { flex: 1,
flex: 1, },
}, content: {
content: { gap: 12,
gap: 12, },
}, titleRow: {
titleRow: { flexDirection: 'row',
flexDirection: 'row', alignItems: 'center',
alignItems: 'center', justifyContent: 'space-between',
justifyContent: 'space-between', },
}, dragHandle: {
dragHandle: { width: 28,
width: 28, height: 28,
height: 28, borderRadius: 14,
borderRadius: 14, alignItems: 'center',
alignItems: 'center', justifyContent: 'center',
justifyContent: 'center', marginRight: 8,
marginRight: 8, },
}, categoryDotSlot: {
categoryDotSlot: { width: 28,
width: 28, height: 28,
height: 28, alignItems: 'center',
alignItems: 'center', justifyContent: 'center',
justifyContent: 'center', marginRight: 8,
marginRight: 8, },
}, categoryDot: {
categoryDot: { width: 12,
width: 12, height: 12,
height: 12, borderRadius: 6,
borderRadius: 6, },
}, title: {
title: { fontSize: 17,
fontSize: 17, fontWeight: '500',
fontWeight: '500', flex: 1,
flex: 1, marginRight: 16,
marginRight: 16, },
}, titleCompleted: {
titleCompleted: { textDecorationLine: 'line-through',
textDecorationLine: 'line-through', color: theme.textMuted,
color: theme.textMuted, },
}, repeatIcon: {
repeatIcon: { marginLeft: 8,
marginLeft: 8, width: 14,
width: 14, height: 14,
height: 14, alignItems: 'center',
alignItems: 'center', justifyContent: 'center',
justifyContent: 'center', },
}, chevron: {
chevron: { marginLeft: 8,
marginLeft: 8, width: 16,
width: 16, height: 16,
height: 16, alignItems: 'center',
alignItems: 'center', justifyContent: 'center',
justifyContent: 'center', },
}, reminderIcon: {
reminderIcon: { marginLeft: 6,
marginLeft: 6, width: 13,
width: 13, height: 13,
height: 13, alignItems: 'center',
alignItems: 'center', justifyContent: 'center',
justifyContent: 'center', },
}, titleOverdue: {
titleOverdue: { color: theme.danger,
color: theme.danger, },
}, priorityBadge: {
priorityBadge: { paddingHorizontal: 6,
paddingHorizontal: 6, paddingVertical: 2,
paddingVertical: 2, borderRadius: 8,
borderRadius: 8, minWidth: 20,
minWidth: 20, alignItems: 'center',
alignItems: 'center', },
}, priorityText: {
priorityText: { fontSize: 9,
fontSize: 9, fontWeight: '700',
fontWeight: '700', color: '#FFFFFF',
color: '#FFFFFF', },
}, assigneeBadge: {
assigneeBadge: { width: 20,
width: 20, height: 20,
height: 20, borderRadius: 10,
borderRadius: 10, backgroundColor: theme.dangerSoft,
backgroundColor: theme.dangerSoft, alignItems: 'center',
alignItems: 'center', justifyContent: 'center',
justifyContent: 'center', },
}, assigneeText: {
assigneeText: { fontSize: 9,
fontSize: 9, fontWeight: '700',
fontWeight: '700', color: theme.danger,
color: theme.danger, },
}, dueRow: {
dueRow: { flexDirection: 'row',
flexDirection: 'row', alignItems: 'center',
alignItems: 'center', gap: 8,
gap: 8, },
}, dueText: {
dueText: { fontSize: 13,
fontSize: 13, },
}, dueTextOverdue: {
dueTextOverdue: { color: theme.danger,
color: theme.danger, fontWeight: '600',
fontWeight: '600', },
}, dueTextDueToday: {
dueTextDueToday: { color: theme.info,
color: theme.info, fontWeight: '600',
fontWeight: '600', },
}, menuButton: {
menuButton: { padding: 4,
padding: 4, marginLeft: 8,
marginLeft: 8, },
}, allDayBadge: {
allDayBadge: { flexDirection: 'row',
flexDirection: 'row', alignItems: 'center',
alignItems: 'center', gap: 4,
gap: 4, marginLeft: 8,
marginLeft: 8, paddingHorizontal: 6,
paddingHorizontal: 6, paddingVertical: 2,
paddingVertical: 2, borderRadius: 8,
borderRadius: 8, backgroundColor: mixHex(theme.info, theme.background, 0.85),
backgroundColor: mixHex(theme.info, theme.background, 0.85), },
}, allDayText: {
allDayText: { fontSize: 11,
fontSize: 11, fontWeight: '600',
fontWeight: '600', color: theme.info,
color: theme.info, },
}, checkCircle: {
checkCircle: { width: 30,
width: 30, height: 30,
height: 30, borderRadius: 15,
borderRadius: 15, alignItems: 'center',
alignItems: 'center', justifyContent: 'center',
justifyContent: 'center', marginRight: 12,
marginRight: 12, },
}, swipeAction: {
swipeAction: { width: 80,
width: 80, alignItems: 'center',
alignItems: 'center', justifyContent: 'center',
justifyContent: 'center', gap: 4,
gap: 4, },
}, swipeDelete: {
swipeDelete: { backgroundColor: theme.dangerStrong,
backgroundColor: theme.dangerStrong, borderTopRightRadius: 16,
borderTopRightRadius: 16, borderBottomRightRadius: 16,
borderBottomRightRadius: 16, },
}, swipeComplete: {
swipeComplete: { backgroundColor: theme.successStrong,
backgroundColor: theme.successStrong, borderTopLeftRadius: 16,
borderTopLeftRadius: 16, borderBottomLeftRadius: 16,
borderBottomLeftRadius: 16, },
}, swipeActionText: {
swipeActionText: { color: '#FFFFFF',
color: '#FFFFFF', fontSize: 12,
fontSize: 12, fontWeight: '600',
fontWeight: '600', },
}, });
});
+220 -446
View File
@@ -1,13 +1,5 @@
import React, { useState, useCallback, useMemo, useRef } from 'react'; import React, { useState, useCallback, useMemo, useRef } from 'react';
import { import { View, Text, StyleSheet, Animated, RefreshControl, Alert, TouchableOpacity } from 'react-native';
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';
@@ -50,18 +42,10 @@ const DropIndicator = ({ theme }: { theme: any }) => (
</View> </View>
); );
export function TaskList({ export function TaskList({ categoryIds = [], showCompleted = false, onSelectionChange }: TaskListProps) {
categoryIds = [],
showCompleted = false,
onSelectionChange,
}: TaskListProps) {
const { theme, sortBy, todoAheadDays } = useSettings(); const { theme, sortBy, todoAheadDays } = useSettings();
const { const { tasks, loading, refresh: refreshTasks } = useTasks(categoryIds, showCompleted ? 'all' : false, todoAheadDays);
tasks,
loading,
refresh: refreshTasks,
} = useTasks(categoryIds, showCompleted ? 'all' : false, todoAheadDays);
const categories = useCategories(); const categories = useCategories();
const categoryColors = useMemo(() => { const categoryColors = useMemo(() => {
@@ -78,30 +62,18 @@ export function TaskList({
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<{ const [dropIndicator, setDropIndicator] = useState<{ targetId: string | null; position: 'above' | 'below' } | null>(null);
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<{ const dragStateRef = useRef<{ taskId: string; positions: Record<string, { top: number; bottom: number }>; subPositions: Record<string, { top: number; bottom: number }> } | null>(null);
taskId: string; 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);
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) {
@@ -111,16 +83,13 @@ export function TaskList({
} }
}, []); }, []);
const registerSubtaskRef = useCallback( const registerSubtaskRef = useCallback((subtaskId: string, parentTaskId: string, ref: View | null) => {
(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),
@@ -131,9 +100,7 @@ export function TaskList({
const sorted = [...tasks]; const sorted = [...tasks];
switch (sortBy) { switch (sortBy) {
case 'date': case 'date':
sorted.sort( sorted.sort((a, b) => (a.dueDate || Number.MAX_SAFE_INTEGER) - (b.dueDate || Number.MAX_SAFE_INTEGER));
(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]);
@@ -178,43 +145,34 @@ export function TaskList({
onSelectionChange?.(false); onSelectionChange?.(false);
}, [onSelectionChange]); }, [onSelectionChange]);
const enterSelection = useCallback( const enterSelection = useCallback((taskId: string) => {
(taskId: string) => { setSelectionMode(true);
setSelectionMode(true); setSelectedIds(new Set([taskId]));
setSelectedIds(new Set([taskId])); onSelectionChange?.(true);
onSelectionChange?.(true); }, [onSelectionChange]);
},
[onSelectionChange]
);
const toggleSelect = useCallback( const toggleSelect = useCallback((taskId: string) => {
(taskId: string) => { setSelectedIds((prev) => {
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); } else {
} else { next.add(taskId);
next.add(taskId); }
} if (next.size === 0) {
if (next.size === 0) { setSelectionMode(false);
setSelectionMode(false); onSelectionChange?.(false);
onSelectionChange?.(false); }
} return next;
return next; });
}); }, [onSelectionChange]);
},
[onSelectionChange]
);
const handleToggle = useCallback( const handleToggle = useCallback(async (taskId: string) => {
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);
@@ -225,31 +183,24 @@ export function TaskList({
}); });
}, []); }, []);
const handleSubtaskToggle = useCallback( const handleSubtaskToggle = useCallback(async (subtaskId: string) => {
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( Alert.alert(`Delete ${selectedIds.size} task${selectedIds.size > 1 ? 's' : ''}?`, 'This cannot be undone.', [
`Delete ${selectedIds.size} task${selectedIds.size > 1 ? 's' : ''}?`, { text: 'Cancel', style: 'cancel' },
'This cannot be undone.', {
[ text: 'Delete',
{ text: 'Cancel', style: 'cancel' }, style: 'destructive',
{ onPress: async () => {
text: 'Delete', await Promise.all(Array.from(selectedIds).map(taskId => deleteTask(taskId)));
style: 'destructive', exitSelection();
onPress: async () => {
await Promise.all(Array.from(selectedIds).map(taskId => deleteTask(taskId)));
exitSelection();
},
}, },
] },
); ]);
}, [selectedIds, exitSelection]); }, [selectedIds, exitSelection]);
const handleBulkComplete = useCallback(async () => { const handleBulkComplete = useCallback(async () => {
@@ -260,16 +211,14 @@ export function TaskList({
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( await Promise.all(entries.map(([id, ref]) => {
entries.map(([id, ref]) => { return new Promise<void>((resolve) => {
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;
}, []); }, []);
@@ -277,91 +226,48 @@ export function TaskList({
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( await Promise.all(entries.map(([id, entry]) => {
entries.map(([id, entry]) => { return new Promise<void>((resolve) => {
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( 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);
absoluteY: number, for (let i = subEntries.length - 1; i >= 0; i--) {
draggedId: string, const [id, p] = subEntries[i];
positions: Record<string, { top: number; bottom: number }> if (id === draggedId) continue;
) => { if (absoluteY >= p.top && absoluteY <= p.bottom) {
const entries = Object.entries(positions); return id;
for (let i = entries.length - 1; i >= 0; i--) {
const [id, p] = entries[i];
if (id === draggedId) continue;
if (absoluteY >= p.top && absoluteY <= p.bottom) {
return id;
}
} }
return null; }
}, for (const [id, p] of Object.entries(positions)) {
[] if (id === draggedId) continue;
); if (absoluteY >= p.top && absoluteY <= p.bottom) {
return id;
const updateHoverTaskTarget = useCallback((target: string | null) => { }
setHoverTaskId(target ?? null); }
return null;
}, []); }, []);
const findHoverTarget = useCallback( const calculateDropPosition = useCallback((absoluteY: number, targetId: string, positions: Record<string, { top: number; bottom: number }>) => {
( const target = positions[targetId];
absoluteY: number, if (!target) return 'below' as const;
draggedId: string, const middle = (target.top + target.bottom) / 2;
positions: Record<string, { top: number; bottom: number }>, return absoluteY < middle ? 'above' : 'below';
subPositions: Record<string, { top: number; bottom: number }> }, []);
) => {
const subEntries = Object.entries(subPositions);
for (let i = subEntries.length - 1; i >= 0; i--) {
const [id, p] = subEntries[i];
if (id === draggedId) continue;
if (absoluteY >= p.top && absoluteY <= p.bottom) {
return id;
}
}
for (const [id, p] of Object.entries(positions)) {
if (id === draggedId) continue;
if (absoluteY >= p.top && absoluteY <= p.bottom) {
return id;
}
}
return null;
},
[]
);
const calculateDropPosition = useCallback( const handleDragStart = useCallback(async (taskId: string) => {
( dragStateRef.current = { taskId, ...(await measureAll()) };
absoluteY: number, }, [measureAll]);
targetId: string,
positions: Record<string, { top: number; bottom: number }>
) => {
const target = positions[targetId];
if (!target) return 'below' as const;
const middle = (target.top + target.bottom) / 2;
return absoluteY < middle ? 'above' : 'below';
},
[]
);
const handleDragStart = useCallback(
async (taskId: string) => {
dragStateRef.current = { taskId, ...(await measureAll()) };
},
[measureAll]
);
const 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) {
@@ -370,217 +276,118 @@ export function TaskList({
}); });
}, [measureAll]); }, [measureAll]);
const updateHoverTarget = useCallback( const updateHoverTarget = useCallback((target: string | null) => {
(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; });
}); if (changed && target) {
if (changed && target) { setTimeout(refreshDragPositions, 80);
setTimeout(refreshDragPositions, 80); }
} }, [refreshDragPositions]);
},
[refreshDragPositions]
);
const handleDragUpdate = useCallback( const handleDragUpdate = useCallback((absoluteY: number) => {
(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); updateHoverTarget(target);
updateHoverTarget(target); if (target) {
if (target) { if (state.subPositions[target]) {
if (state.subPositions[target]) { setDropIndicator(null);
setDropIndicator(null);
} else {
const position = calculateDropPosition(absoluteY, target, state.positions);
setDropIndicator({ targetId: target, position });
}
} else { } else {
// Check if below last item const position = calculateDropPosition(absoluteY, target, state.positions);
const positions = state.positions; setDropIndicator({ targetId: target, position });
const lastItem = Object.values(positions).reduce(
(max, p) => (p.bottom > max.bottom ? p : max),
{ bottom: 0 }
);
if (absoluteY > lastItem.bottom) {
setDropIndicator({ targetId: null, position: 'below' });
} else {
setDropIndicator(null);
}
} }
}, } else {
[findHoverTarget, calculateDropPosition, updateHoverTarget] // Check if below last item
); const positions = state.positions;
const lastItem = Object.values(positions).reduce((max, p) => p.bottom > max.bottom ? p : max, { bottom: 0 });
const handleDragEnd = useCallback( if (absoluteY > lastItem.bottom) {
(absoluteY: number) => { setDropIndicator({ targetId: null, position: 'below' });
const state = dragStateRef.current;
dragStateRef.current = null;
setHoverTaskId(null);
setDropIndicator(null);
if (!state) return;
const target = findHoverTarget(absoluteY, state.taskId, state.positions, state.subPositions);
if (target) {
if (state.subPositions[target]) {
const parentTaskId = subtaskRefs.current.get(target)?.parentTaskId ?? state.taskId;
(async () => {
await convertTaskToSubtask(state.taskId, parentTaskId, target);
})();
} else {
(async () => {
await convertTaskToSubtask(state.taskId, target);
})();
}
}
},
[findHoverTarget]
);
const handleSubtaskDragStart = useCallback(
async (subtaskId: string, parentTaskId: string, parentSubtaskId: string | null = null) => {
subtaskDragRef.current = {
subtaskId,
parentTaskId,
parentSubtaskId,
...(await measureAll()),
};
},
[measureAll]
);
const handleSubtaskDragUpdate = useCallback(
(absoluteY: number) => {
const state = subtaskDragRef.current;
if (!state) return;
const target = findHoverTarget(
absoluteY,
state.subtaskId,
state.positions,
state.subPositions
);
updateHoverTarget(target);
if (target) {
if (state.subPositions[target]) {
setDropIndicator(null);
} else {
const position = calculateDropPosition(absoluteY, target, state.positions);
setDropIndicator({ targetId: target, position });
}
} else { } else {
const positions = state.positions; setDropIndicator(null);
const lastItem = Object.values(positions).reduce(
(max, p) => (p.bottom > max.bottom ? p : max),
{ bottom: 0 }
);
if (absoluteY > lastItem.bottom) {
setDropIndicator({ targetId: null, position: 'below' });
} else {
setDropIndicator(null);
}
} }
}, }
[findHoverTarget, calculateDropPosition, updateHoverTarget] }, [findHoverTarget, calculateDropPosition, updateHoverTarget]);
);
const handleSubtaskDragEnd = useCallback( const handleDragEnd = useCallback((absoluteY: number) => {
async (absoluteY: number) => { const state = dragStateRef.current;
const state = subtaskDragRef.current; dragStateRef.current = null;
subtaskDragRef.current = null; setHoverTaskId(null);
setHoverTaskId(null); setDropIndicator(null);
setDropIndicator(null); if (!state) return;
if (!state) return;
const target = findHoverTarget( const target = findHoverTarget(absoluteY, state.taskId, state.positions, state.subPositions);
absoluteY, if (target) {
state.subtaskId, if (state.subPositions[target]) {
state.positions, const parentTaskId = subtaskRefs.current.get(target)?.parentTaskId ?? state.taskId;
state.subPositions (async () => {
); await convertTaskToSubtask(state.taskId, parentTaskId, target);
if (target === state.subtaskId) return; })();
// Only allow dropping on tasks (not subtasks) to match task behavior
// Check if target exists in main task positions, not subtask positions
const isTaskTarget = state.positions != null && target != null && target in state.positions;
if (target && isTaskTarget) {
// Move subtask to target task - makes it a subtask of that task
// If no target or target is a subtask: do nothing (matches task behavior)
await moveSubtaskToTask(state.subtaskId, target);
}
},
[findHoverTarget, moveSubtaskToTask]
);
const handleTaskDragStart = useCallback(
async (taskId: string) => {
const positions = (await measureItems()) as Record<string, { top: number; bottom: number }>;
taskDragRef.current = { taskId, positions };
},
[measureItems]
);
const handleTaskDragUpdate = useCallback(
(absoluteY: number) => {
const state = taskDragRef.current;
if (!state) return;
const target = findHoverTask(absoluteY, state.taskId, state.positions);
updateHoverTaskTarget(target);
if (target) {
setDropIndicator({ targetId: target, position: 'below' });
} else { } else {
const lastItem = Object.values(state.positions).reduce( (async () => {
(max, p) => (p.bottom > max.bottom ? p : max), await convertTaskToSubtask(state.taskId, target);
{ bottom: 0 } })();
);
if (absoluteY > lastItem.bottom) {
setDropIndicator({ targetId: null, position: 'below' });
} else {
setDropIndicator(null);
}
} }
}, }
[findHoverTask, updateHoverTaskTarget] }, [findHoverTarget]);
);
const handleTaskDragEnd = useCallback( const handleSubtaskDragStart = useCallback(async (subtaskId: string, parentTaskId: string, parentSubtaskId: string | null = null) => {
async (absoluteY: number) => { subtaskDragRef.current = { subtaskId, parentTaskId, parentSubtaskId, ...(await measureAll()) };
const state = taskDragRef.current; }, [measureAll]);
taskDragRef.current = null;
setHoverTaskId(null);
setDropIndicator(null);
if (!state) return;
const target = findHoverTask(absoluteY, state.taskId, state.positions); const handleSubtaskDragUpdate = useCallback((absoluteY: number) => {
if (target === state.taskId) return; const state = subtaskDragRef.current;
if (!state) return;
// Set drop indicator based on target position const target = findHoverTarget(absoluteY, state.subtaskId, state.positions, state.subPositions);
if (target) { updateHoverTarget(target);
setDropIndicator({ targetId: target, position: 'below' }); if (target) {
if (state.subPositions[target]) {
setDropIndicator(null);
} else { } else {
const lastItem = Object.values(state.positions).reduce( const position = calculateDropPosition(absoluteY, target, state.positions);
(max, p) => (p.bottom > max.bottom ? p : max), setDropIndicator({ targetId: target, position });
{ bottom: 0 }
);
if (absoluteY > lastItem.bottom) {
setDropIndicator({ targetId: null, position: 'below' });
}
} }
}, } else {
[findHoverTask] const positions = state.positions;
); const lastItem = Object.values(positions).reduce((max, p) => p.bottom > max.bottom ? p : max, { bottom: 0 });
if (absoluteY > lastItem.bottom) {
setDropIndicator({ targetId: null, position: 'below' });
} else {
setDropIndicator(null);
}
}
}, [findHoverTarget, calculateDropPosition, updateHoverTarget]);
const handleSubtaskDragEnd = useCallback(async (absoluteY: number) => {
const state = subtaskDragRef.current;
subtaskDragRef.current = null;
setHoverTaskId(null);
setDropIndicator(null);
if (!state) return;
const target = findHoverTarget(absoluteY, state.subtaskId, state.positions, state.subPositions);
if (target === state.subtaskId) return;
// Only allow dropping on tasks (not subtasks) to match task behavior
// Check if target exists in main task positions, not subtask positions
const isTaskTarget = state.positions != null && target != null && target in state.positions;
if (target && isTaskTarget) {
// Move subtask to target task - makes it a subtask of that task
// If no target or target is a subtask: do nothing (matches task behavior)
await moveSubtaskToTask(state.subtaskId, target);
}
}, [findHoverTarget, moveSubtaskToTask]);
const 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 = const showDropAbove = dropIndicator?.targetId === item.id && dropIndicator?.position === 'above';
dropIndicator?.targetId === item.id && dropIndicator?.position === 'above'; const showDropBelow = dropIndicator?.targetId === item.id && dropIndicator?.position === 'below';
const showDropBelow = const tagColors = parseTaskTags(item.tags, item.categoryId).map((id) => categoryColors.get(id));
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} />}
@@ -599,10 +406,10 @@ export function TaskList({
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}
@@ -656,9 +463,7 @@ export function TaskList({
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 }]}> <Text style={[styles.emptySubtext, { color: theme.textMuted }]}>Tap + to add your first task</Text>
Tap + to add your first task
</Text>
</View> </View>
); );
}, [sortedTasks.length, theme.textSecondary, theme.textMuted]); }, [sortedTasks.length, theme.textSecondary, theme.textMuted]);
@@ -680,16 +485,9 @@ export function TaskList({
<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={{ extraData={{ expandedTasks, subtasksMap, selectedIds, selectionMode, hoverTaskId, dropIndicator }}
expandedTasks,
subtasksMap,
selectedIds,
selectionMode,
hoverTaskId,
dropIndicator,
}}
ItemSeparatorComponent={MemoSeparator} ItemSeparatorComponent={MemoSeparator}
ListHeaderComponent={listHeader} ListHeaderComponent={listHeader}
ListFooterComponent={listFooter} ListFooterComponent={listFooter}
@@ -708,36 +506,16 @@ export function TaskList({
{modals(() => {})} {modals(() => {})}
{selectionMode && ( {selectionMode && (
<View <View style={[styles.selectionBar, { backgroundColor: theme.sheetBg, borderTopColor: theme.border }]}>
style={[ <TouchableOpacity onPress={exitSelection} style={styles.selectionCancel} activeOpacity={0.7}>
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 <Path d="M18 6L6 18M6 6l12 12" stroke={theme.textSecondary} strokeWidth={2.2} strokeLinecap="round" />
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 }]}> <Text style={[styles.selectionCount, { color: theme.text }]}>{selectedIds.size} selected</Text>
{selectedIds.size} selected
</Text>
<View style={styles.selectionActions}> <View style={styles.selectionActions}>
<SelectionButton <SelectionButton label="Complete" color={theme.successStrong} onPress={handleBulkComplete} />
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>
@@ -746,15 +524,7 @@ export function TaskList({
); );
} }
const SelectionButton = React.memo(function SelectionButton({ const SelectionButton = React.memo(function SelectionButton({ label, color, onPress }: { label: string; color: string; onPress: () => void }) {
label,
color,
onPress,
}: {
label: string;
color: string;
onPress: () => void;
}) {
return ( return (
<TouchableOpacity <TouchableOpacity
style={[styles.selectionButton, { backgroundColor: color }]} style={[styles.selectionButton, { backgroundColor: color }]}
@@ -838,7 +608,10 @@ const TaskRow = React.memo(function TaskRow({
[subtasks] [subtasks]
); );
return ( return (
<View ref={ref => registerRef(task.id, ref)} style={styles.dragContainer}> <View
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)}
@@ -862,19 +635,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}
@@ -883,8 +656,8 @@ const TaskRow = React.memo(function TaskRow({
))} ))}
</View> </View>
)} )}
</View> </View>
); );
}); });
const styles = StyleSheet.create({ const styles = StyleSheet.create({
@@ -918,7 +691,8 @@ 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,38 +35,37 @@ export function TaskNameInput({ error, ...props }: TaskNameInputProps) {
); );
} }
const createStyles = (theme: ThemeColors) => const createStyles = (theme: ThemeColors) => StyleSheet.create({
StyleSheet.create({ container: {
container: { gap: 5,
gap: 5, },
}, labelRow: {
labelRow: { flexDirection: 'row',
flexDirection: 'row', alignItems: 'center',
alignItems: 'center', },
}, label: {
label: { fontSize: 13,
fontSize: 13, fontWeight: '600',
fontWeight: '600', },
}, required: {
required: { color: theme.dangerStrong,
color: theme.dangerStrong, fontSize: 13,
fontSize: 13, fontWeight: '600',
fontWeight: '600', },
}, input: {
input: { height: 48,
height: 48, paddingHorizontal: 14,
paddingHorizontal: 14, borderRadius: 10,
borderRadius: 10, borderWidth: 1,
borderWidth: 1, fontSize: 15,
fontSize: 15, },
}, inputError: {
inputError: { borderColor: theme.dangerStrong,
borderColor: theme.dangerStrong, borderWidth: 1.5,
borderWidth: 1.5, },
}, errorText: {
errorText: { fontSize: 11,
fontSize: 11, color: theme.dangerStrong,
color: theme.dangerStrong, marginLeft: 4,
marginLeft: 4, },
}, });
});
@@ -49,45 +49,19 @@ export function TaskOverflowMenu({
{ {
key: 'edit', key: 'edit',
label: 'Edit', label: 'Edit',
icon: ( 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>,
<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: ( 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>,
<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: ( 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>,
<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,
}, },
]; ];
@@ -96,11 +70,7 @@ export function TaskOverflowMenu({
actions.push({ actions.push({
key: 'category', key: 'category',
label: 'Change Tags', label: 'Change Tags',
icon: ( icon: <Svg width={20} height={20} viewBox="0 0 24 24"><Circle2 /></Svg>,
<Svg width={20} height={20} viewBox="0 0 24 24">
<Circle2 />
</Svg>
),
onPress: onChangeCategory ?? (() => {}), onPress: onChangeCategory ?? (() => {}),
}); });
} }
@@ -109,49 +79,23 @@ export function TaskOverflowMenu({
{ {
key: 'priority', key: 'priority',
label: 'Change Priority', label: 'Change Priority',
icon: ( 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>,
<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: ( 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>,
<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: ( 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>,
<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,
}); });
} }
@@ -168,7 +112,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 }]}
@@ -181,12 +125,7 @@ export function TaskOverflowMenu({
accessibilityLabel={`${action.label}${action.destructive ? ' (dangerous)' : ''}`} accessibilityLabel={`${action.label}${action.destructive ? ' (dangerous)' : ''}`}
> >
{action.icon} {action.icon}
<Text <Text style={[styles.actionText, action.destructive ? styles.destructiveText : { color: theme.textSecondary }]}>
style={[
styles.actionText,
action.destructive ? styles.destructiveText : { color: theme.textSecondary },
]}
>
{action.label} {action.label}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
@@ -201,60 +140,54 @@ 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 <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" />
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) => const createStyles = (theme: ThemeColors) => StyleSheet.create({
StyleSheet.create({ overlay: {
overlay: { flex: 1,
flex: 1, justifyContent: 'flex-end',
justifyContent: 'flex-end', },
}, sheet: {
sheet: { borderTopLeftRadius: 24,
borderTopLeftRadius: 24, borderTopRightRadius: 24,
borderTopRightRadius: 24, paddingTop: 8,
paddingTop: 8, paddingHorizontal: 16,
paddingHorizontal: 16, paddingBottom: 32,
paddingBottom: 32, },
}, handle: {
handle: { width: 40,
width: 40, height: 4,
height: 4, borderRadius: 2,
borderRadius: 2, alignSelf: 'center',
alignSelf: 'center', marginBottom: 12,
marginBottom: 12, },
}, title: {
title: { fontSize: 14,
fontSize: 14, fontWeight: '600',
fontWeight: '600', textAlign: 'center',
textAlign: 'center', marginBottom: 16,
marginBottom: 16, opacity: 0.8,
opacity: 0.8, },
}, action: {
action: { flexDirection: 'row',
flexDirection: 'row', alignItems: 'center',
alignItems: 'center', gap: 12,
gap: 12, paddingVertical: 14,
paddingVertical: 14, paddingHorizontal: 16,
paddingHorizontal: 16, borderRadius: 12,
borderRadius: 12, borderWidth: 1,
borderWidth: 1, marginBottom: 8,
marginBottom: 8, },
}, actionText: {
actionText: { fontSize: 15,
fontSize: 15, fontWeight: '500',
fontWeight: '500', },
}, destructiveText: {
destructiveText: { color: theme.dangerStrong,
color: theme.dangerStrong, fontWeight: '600',
fontWeight: '600', },
}, });
});
+25 -290
View File
@@ -1,295 +1,30 @@
import { useEffect, useState, useCallback } from 'react'; import { useEffect } from 'react';
import { Modal, View, Text, StyleSheet, Pressable, ScrollView, Platform } from 'react-native'; import { Alert, Linking } from 'react-native';
import { import { checkForUpdates, wasPromptedFor, markPrompted } from '@/services/updates';
checkForUpdates,
wasPromptedFor,
markPrompted,
downloadAPK,
installAPK,
openReleaseUrl,
AppUpdate,
} from '@/services/updates';
import { useSettings } from '@/theme';
export function UpdateNotifier() { export function UpdateNotifier() {
const { theme } = useSettings(); useEffect(() => {
const [update, setUpdate] = useState<AppUpdate | null>(null); const run = async () => {
const [visible, setVisible] = useState(false); const update = await checkForUpdates();
const [downloading, setDownloading] = useState(false); if (!update || (await wasPromptedFor(update.tagName))) return;
const [downloadProgress, setDownloadProgress] = useState(0); await markPrompted(update.tagName);
const [downloadError, setDownloadError] = useState<string | null>(null); const url = update.apkUrl ?? update.releaseUrl;
Alert.alert(
const checkUpdate = useCallback(async () => { 'Update available',
const available = await checkForUpdates(); `A new version (${update.version}) is available for download.`,
if (!available) return; [
{ text: 'Later', style: 'cancel' },
const prompted = await wasPromptedFor(available.tagName); {
if (prompted && !available.mandatory) return; text: 'Download',
onPress: () => {
setUpdate(available); if (url) Linking.openURL(url).catch(() => {});
setVisible(true); },
},
],
);
};
run();
}, []); }, []);
useEffect(() => { return null;
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,13 +53,7 @@ interface WheelRowProps {
onPress: () => void; onPress: () => void;
} }
const WheelRow = React.memo(function WheelRow({ const WheelRow = React.memo(function WheelRow({ label, selected, accent, textMuted, onPress }: WheelRowProps) {
label,
selected,
accent,
textMuted,
onPress,
}: WheelRowProps) {
return ( return (
<TouchableOpacity <TouchableOpacity
style={[styles.row, selected && styles.rowSelected]} style={[styles.row, selected && styles.rowSelected]}
@@ -102,10 +96,7 @@ function WheelColumn({
const indexRef = useRef(initialIndex); const indexRef = useRef(initialIndex);
useEffect(() => { useEffect(() => {
listRef.current?.scrollToOffset({ listRef.current?.scrollToOffset({ offset: initialIndex * ITEM_HEIGHT - (ITEM_HEIGHT * VISIBLE_ITEMS - ITEM_HEIGHT) / 2, animated: false });
offset: initialIndex * ITEM_HEIGHT - (ITEM_HEIGHT * VISIBLE_ITEMS - ITEM_HEIGHT) / 2,
animated: false,
});
indexRef.current = initialIndex; indexRef.current = initialIndex;
}, [initialIndex]); }, [initialIndex]);
@@ -114,10 +105,7 @@ 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({ listRef.current?.scrollToOffset({ offset: clamped * ITEM_HEIGHT - (ITEM_HEIGHT * VISIBLE_ITEMS - ITEM_HEIGHT) / 2, animated: true });
offset: clamped * ITEM_HEIGHT - (ITEM_HEIGHT * VISIBLE_ITEMS - ITEM_HEIGHT) / 2,
animated: true,
});
}, },
[data.length, onIndexChange] [data.length, onIndexChange]
); );
@@ -138,9 +126,7 @@ 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( const index = Math.round((offset + (ITEM_HEIGHT * VISIBLE_ITEMS - ITEM_HEIGHT) / 2) / ITEM_HEIGHT);
(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;
@@ -159,32 +145,29 @@ function WheelColumn({
}, []); }, []);
return ( return (
<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,
offset: ITEM_HEIGHT * index, offset: ITEM_HEIGHT * index,
index, index,
})} })}
initialScrollIndex={initialIndex} initialScrollIndex={initialIndex}
snapToOffsets={data.map((_, i) => i * ITEM_HEIGHT)} snapToOffsets={data.map((_, i) => i * ITEM_HEIGHT)}
decelerationRate="normal" decelerationRate="normal"
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
onScroll={handleScroll} onScroll={handleScroll}
onScrollEndDrag={handleScrollEnd} onScrollEndDrag={handleScrollEnd}
onMomentumScrollEnd={handleScrollEnd} onMomentumScrollEnd={handleScrollEnd}
scrollEventThrottle={16} scrollEventThrottle={16}
style={[styles.column, { width }]} style={[styles.column, { width }]}
contentContainerStyle={{ contentContainerStyle={{ paddingBottom: (VISIBLE_ITEMS - 1) * ITEM_HEIGHT, alignItems: 'stretch' }}
paddingBottom: (VISIBLE_ITEMS - 1) * ITEM_HEIGHT, snapToAlignment="center"
alignItems: 'stretch', snapToInterval={ITEM_HEIGHT}
}} />
snapToAlignment="center"
snapToInterval={ITEM_HEIGHT}
/>
); );
} }
@@ -224,10 +207,7 @@ 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 <TouchableOpacity onPress={onDismiss} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
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}>
@@ -237,16 +217,10 @@ export function WheelTimePicker({
</Text> </Text>
</View> </View>
<TouchableOpacity <TouchableOpacity
onPress={() => onPress={() => onConfirm(`${String(hourIndex).padStart(2, '0')}:${String(minuteIndex).padStart(2, '0')}`)}
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 }]}> <Text style={[styles.headerAction, styles.doneText, { color: theme.accentStrong }]}>Done</Text>
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);
}, },
}); });
+6 -25
View File
@@ -1,9 +1,4 @@
import { import { schemaMigrations, addColumns, createTable, unsafeExecuteSql } from '@nozbe/watermelondb/Schema/migrations';
schemaMigrations,
addColumns,
createTable,
unsafeExecuteSql,
} from '@nozbe/watermelondb/Schema/migrations';
export const migrations = schemaMigrations({ export const migrations = schemaMigrations({
migrations: [ migrations: [
@@ -157,21 +152,11 @@ 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( unsafeExecuteSql('CREATE INDEX IF NOT EXISTS idx_subtasks_updated_at ON subtasks(updated_at);'),
'CREATE INDEX IF NOT EXISTS idx_subtasks_updated_at ON subtasks(updated_at);' unsafeExecuteSql('CREATE INDEX IF NOT EXISTS idx_subtasks_created_at ON subtasks(created_at);'),
), unsafeExecuteSql('CREATE INDEX IF NOT EXISTS idx_categories_updated_at ON categories(updated_at);'),
unsafeExecuteSql( unsafeExecuteSql('CREATE INDEX IF NOT EXISTS idx_repeat_profiles_updated_at ON repeat_profiles(updated_at);'),
'CREATE INDEX IF NOT EXISTS idx_subtasks_created_at ON subtasks(created_at);' unsafeExecuteSql('CREATE INDEX IF NOT EXISTS idx_friendships_updated_at ON friendships(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(
'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);'),
], ],
}, },
@@ -235,9 +220,5 @@ export const migrations = schemaMigrations({
}), }),
], ],
}, },
{
toVersion: 21,
steps: [],
},
], ],
}); });
+24 -42
View File
@@ -11,26 +11,15 @@ import RepeatProfile from '@/models/RepeatProfile';
import Friendship from '@/models/Friendship'; import Friendship from '@/models/Friendship';
import { TombstoneEntity } from '@/models/Tombstone'; import { TombstoneEntity } from '@/models/Tombstone';
import { fetchPendingTombstones, removeTombstonesInBatch } from './tombstones'; import { fetchPendingTombstones, removeTombstonesInBatch } from './tombstones';
import { useOnline } from '@/hooks/useOnline';
const LAST_PULLED_AT_KEY = 'sync:lastPulledAt'; const LAST_PULLED_AT_KEY = 'sync:lastPulledAt';
const LAST_PUSHED_AT_KEY = 'sync:lastPushedAt'; 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: { pushed: { categories: number; tasks: number; subtasks: number; repeatProfiles: number; friendships: number };
categories: number; pulled: { categories: number; tasks: number; subtasks: number; repeatProfiles: number; friendships: 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;
} }
@@ -107,7 +96,7 @@ export async function runSync(): Promise<SyncResult> {
} }
// If offline, mark timestamps and return early - changes will sync on next foreground launch // If offline, mark timestamps and return early - changes will sync on next foreground launch
const isCurrentlyOnline = navigator.onLine; const isCurrentlyOnline = await useOnline();
if (!isCurrentlyOnline) { if (!isCurrentlyOnline) {
const now = Date.now(); const now = Date.now();
await setLastPulledAt(now); await setLastPulledAt(now);
@@ -162,10 +151,7 @@ export async function runSync(): Promise<SyncResult> {
return result; return result;
} }
async function pushChanges(): Promise<{ async function pushChanges(): Promise<{ conflicts: PushConflict[]; pushedDeletions: { entity: TombstoneEntity; id: string }[] }> {
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([
@@ -210,7 +196,7 @@ async function pushChanges(): Promise<{
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,
@@ -266,7 +252,7 @@ async function pushChanges(): Promise<{
} }
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,
@@ -275,7 +261,7 @@ async function pushChanges(): Promise<{
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,
@@ -285,7 +271,7 @@ async function pushChanges(): Promise<{
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,
@@ -295,7 +281,7 @@ async function pushChanges(): Promise<{
})); }));
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(),
@@ -330,7 +316,7 @@ async function pushChanges(): Promise<{
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 })),
}; };
} }
@@ -342,8 +328,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) ?? [];
@@ -427,11 +413,7 @@ 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: { async function applyRemoteTombstone(tomb: { entity: TombstoneEntity; id: string; updatedAt: number }): Promise<void> {
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) {
@@ -493,7 +475,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);
@@ -505,7 +487,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);
@@ -517,7 +499,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 ?? '');
@@ -543,7 +525,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);
@@ -569,7 +551,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);
@@ -582,7 +564,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);
@@ -598,7 +580,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) : '';
@@ -626,7 +608,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;
@@ -654,7 +636,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';
@@ -666,7 +648,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;
+4 -10
View File
@@ -3,10 +3,7 @@ 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( export async function recordTombstonesInBatch(entity: TombstoneEntity, ids: string[]): Promise<void> {
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
@@ -14,11 +11,11 @@ export async function recordTombstonesInBatch(
.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;
@@ -28,10 +25,7 @@ export async function recordTombstonesInBatch(
} }
// 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( export async function removeTombstonesInBatch(entity: TombstoneEntity, ids: string[]): Promise<void> {
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))
+8 -12
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,13 +42,9 @@ 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 const tasksToUpdate = await collections.tasks.query(Q.where('category_id', dup.id)).fetch();
.query(Q.where('category_id', dup.id))
.fetch();
for (const task of tasksToUpdate) { for (const task of tasksToUpdate) {
await task.update(t => { await task.update((t) => { t.categoryId = keep.id; });
t.categoryId = keep.id;
});
} }
// Delete duplicate category // Delete duplicate category
await dup.destroyPermanently(); await dup.destroyPermanently();
@@ -61,7 +57,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;
@@ -119,9 +115,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: () => {},
@@ -158,9 +154,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: () => {},
+1 -10
View File
@@ -1,14 +1,5 @@
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react'; import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
import { import { getFriends, sendFriendRequest, acceptFriendRequest, deleteFriendRequest, removeFriend, searchUsers, Friend, FriendRequest } from '@/services/friends';
getFriends,
sendFriendRequest,
acceptFriendRequest,
deleteFriendRequest,
removeFriend,
searchUsers,
Friend,
FriendRequest,
} from '@/services/friends';
interface FriendsContextType { interface FriendsContextType {
friends: Friend[]; friends: Friend[];
+43 -33
View File
@@ -2,7 +2,6 @@ import { useDatabase } from './useDatabase';
import { Q } from '@nozbe/watermelondb'; import { Q } from '@nozbe/watermelondb';
import { useEffect, useState, useCallback } from 'react'; import { useEffect, useState, useCallback } from 'react';
import Task from '../models/Task'; import Task from '../models/Task';
import { Priority, Repeat, Reminder } from '@/types';
import parseICal from '../utils/icalParser'; import parseICal from '../utils/icalParser';
import { startOfMonth, endOfMonth, addMonths, format } from 'date-fns'; import { startOfMonth, endOfMonth, addMonths, format } from 'date-fns';
@@ -43,11 +42,7 @@ 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( dueDate = new Date(event.start.getFullYear(), event.start.getMonth(), event.start.getDate()).getTime();
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,34 +138,49 @@ export function useICalImport() {
} }
const taskData = events.map(mapICalEventToTask); const taskData = events.map(mapICalEventToTask);
const now = Date.now();
// Insert tasks into WatermelonDB // Insert tasks into WatermelonDB
await Promise.all( const subscription = collections.tasks
taskData.map(td => .query()
collections.tasks.create(t => { .splice(
t.title = td.title; 0,
t.description = td.description; 0,
t.categoryId = td.categoryId; ...taskData.map((td) => ({
t.priority = td.priority as Priority; title: td.title,
t.completed = td.completed; description: td.description,
t.completedAt = td.completedAt; category_id: td.categoryId,
t.dueDate = td.dueDate; tags: td.tags,
t.dueTime = td.dueTime; priority: td.priority,
t.endTime = td.endTime; completed: td.completed,
t.allDay = td.allDay; completed_at: td.completedAt,
t.repeat = td.repeat as Repeat; due_date: td.dueDate,
t.repeatInterval = td.repeatInterval; due_time: td.dueTime,
t.repeatDays = td.repeatDays; end_time: td.endTime,
t.seriesId = td.seriesId; all_day: td.allDay,
t.reminder = td.reminder as Reminder; repeat: td.repeat,
t.reminders = td.reminders; repeat_interval: td.repeatInterval,
t.assigneeId = null; repeat_days: td.repeatDays,
t.createdAt = new Date(now); color: '#9E9E9E',
t.updatedAt = new Date(now); series_id: td.seriesId,
}) order: 0,
) reminder: td.reminder,
); reminders: td.reminders,
assignee_id: null,
created_at: now,
updated_at: now,
}))
);
// Observe the changes
subscription.subscribe({
next: (result) => {
// Tasks have been imported
},
error: (err) => {
console.error('Import error:', err);
setError(err.message);
},
});
setLoading(false); setLoading(false);
} catch (err: any) { } catch (err: any) {
@@ -178,7 +188,7 @@ export function useICalImport() {
setError(err.message || 'Import failed'); setError(err.message || 'Import failed');
setLoading(false); setLoading(false);
} }
}, []); }, [collections]);
return { importICal, loading, error }; return { importICal, loading, error };
} }
+19 -20
View File
@@ -83,7 +83,10 @@ export function useStats(): TaskStats {
let mounted = true; let mounted = true;
const compute = () => { const compute = () => {
Promise.all([collections.tasks.query().fetch(), collections.categories.query().fetch()]) Promise.all([
collections.tasks.query().fetch(),
collections.categories.query().fetch(),
])
.then(([allTasks, categories]) => { .then(([allTasks, categories]) => {
const now = Date.now(); const now = Date.now();
const todayStart = startOfDay(now); const todayStart = startOfDay(now);
@@ -126,25 +129,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;
@@ -162,10 +165,9 @@ export function useStats(): TaskStats {
completedLast30, completedLast30,
activeCount, activeCount,
overdueCount, overdueCount,
completionRate: completionRate: totalCompleted > 0
totalCompleted > 0 ? Math.round((totalCompleted / (totalCompleted + activeCount)) * 100)
? Math.round((totalCompleted / (totalCompleted + activeCount)) * 100) : 0,
: 0,
daily, daily,
byCategory, byCategory,
byPriority, byPriority,
@@ -174,21 +176,18 @@ 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 const subscription = collections.tasks.query().observe().subscribe({
.query() next: () => {
.observe() compute();
.subscribe({ },
next: () => { error: () => {},
compute(); });
},
error: () => {},
});
return () => { return () => {
mounted = false; mounted = false;
+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: () => {},
+54 -95
View File
@@ -22,10 +22,7 @@ import {
confirmAndToggleTaskComplete, confirmAndToggleTaskComplete,
} from '@/utils/taskActions'; } from '@/utils/taskActions';
const PRIORITY_OPTIONS = Object.entries(PRIORITY_LABELS).map(([value, label]) => ({ const PRIORITY_OPTIONS = Object.entries(PRIORITY_LABELS).map(([value, label]) => ({ value, label }));
value,
label,
}));
interface AddSubtaskModalProps { interface AddSubtaskModalProps {
visible: boolean; visible: boolean;
@@ -125,7 +122,9 @@ 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}>Add Subtask to &ldquo;{parentSubtask.title}&rdquo;</Text> <Text style={styles.title}>
Add Subtask to &ldquo;{parentSubtask.title}&rdquo;
</Text>
<View style={styles.inputContainer}> <View style={styles.inputContainer}>
<TextInput <TextInput
style={styles.input} style={styles.input}
@@ -160,35 +159,21 @@ 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 }>( const [picker, setPicker] = useState<null | { type: 'category' | 'priority'; taskId?: string }>(null);
null const [subtaskPicker, setSubtaskPicker] = useState<null | { type: 'priority'; subtaskId?: string }>(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( const openSubtaskDelete = useCallback((subtask: SubtaskData) => setDeleteSubtaskTarget(subtask), []);
(subtask: SubtaskData) => setDeleteSubtaskTarget(subtask), const openTaskEdit = useCallback((taskId: string) => {
[] router.push({ pathname: '/task-detail', params: { id: taskId } });
); }, [router]);
const openTaskEdit = useCallback( const openSubtaskEdit = useCallback((subtaskId: string) => {
(taskId: string) => { router.push({ pathname: '/subtask-detail', params: { id: subtaskId } });
router.push({ pathname: '/task-detail', params: { id: taskId } }); }, [router]);
},
[router]
);
const openSubtaskEdit = useCallback(
(subtaskId: string) => {
router.push({ pathname: '/subtask-detail', params: { id: subtaskId } });
},
[router]
);
const openSubtaskAdd = useCallback((subtask: SubtaskData) => { const openSubtaskAdd = useCallback((subtask: SubtaskData) => {
setAddSubtaskTarget(subtask); setAddSubtaskTarget(subtask);
}, []); }, []);
@@ -212,16 +197,13 @@ export function useTaskModals() {
refreshRef.current(); refreshRef.current();
}, []); }, []);
const handleDeleteScope = useCallback( const handleDeleteScope = useCallback(async (scope: TaskDeleteScope) => {
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;
@@ -231,56 +213,38 @@ export function useTaskModals() {
refreshRef.current(); refreshRef.current();
}, [deleteSubtaskTarget]); }, [deleteSubtaskTarget]);
const handleSingleCategory = useCallback( const handleSingleCategory = useCallback(async (value: string | string[]) => {
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();
}
},
[picker]
);
const handleSinglePriority = useCallback(
async (value: string | string[]) => {
if (picker?.taskId) {
await setTaskPriority(
picker.taskId,
(Array.isArray(value) ? value[0] : value) as TaskData['priority']
);
refreshRef.current();
}
},
[picker]
);
const handleSubtaskPriority = useCallback(
async (value: string | string[]) => {
if (subtaskPicker?.subtaskId) {
await setSubtaskPriority(
subtaskPicker.subtaskId,
(Array.isArray(value) ? value[0] : value) as TaskData['priority']
);
refreshRef.current();
}
},
[subtaskPicker]
);
const handleAddSubtask = useCallback(
async (title: string) => {
const target = addSubtaskTarget;
if (!target || !title.trim()) return;
await createSubtask({
taskId: target.taskId,
parentSubtaskId: target.id,
title: title.trim(),
});
setAddSubtaskTarget(null);
refreshRef.current(); refreshRef.current();
}, }
[addSubtaskTarget] }, [picker]);
);
const handleSinglePriority = useCallback(async (value: string | string[]) => {
if (picker?.taskId) {
await setTaskPriority(picker.taskId, (Array.isArray(value) ? value[0] : value) as TaskData['priority']);
refreshRef.current();
}
}, [picker]);
const handleSubtaskPriority = useCallback(async (value: string | string[]) => {
if (subtaskPicker?.subtaskId) {
await setSubtaskPriority(subtaskPicker.subtaskId, (Array.isArray(value) ? value[0] : value) as TaskData['priority']);
refreshRef.current();
}
}, [subtaskPicker]);
const handleAddSubtask = useCallback(async (title: string) => {
const target = addSubtaskTarget;
if (!target || !title.trim()) return;
await createSubtask({
taskId: target.taskId,
parentSubtaskId: target.id,
title: title.trim(),
});
setAddSubtaskTarget(null);
refreshRef.current();
}, [addSubtaskTarget]);
const modals = (refresh: () => void) => { const modals = (refresh: () => void) => {
refreshRef.current = refresh; refreshRef.current = refresh;
@@ -325,9 +289,7 @@ 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={() => onChangePriority={() => menuSubtask && setSubtaskPicker({ type: 'priority', subtaskId: menuSubtask.id })}
menuSubtask && setSubtaskPicker({ type: 'priority', subtaskId: menuSubtask.id })
}
onAddSubtask={() => menuSubtask && openSubtaskAdd(menuSubtask)} onAddSubtask={() => menuSubtask && openSubtaskAdd(menuSubtask)}
/> />
@@ -341,10 +303,7 @@ export function useTaskModals() {
<OptionPickerModal <OptionPickerModal
visible={picker?.type === 'category'} visible={picker?.type === 'category'}
title="Change Tags" title="Change Tags"
options={[ options={[{ value: '', label: 'None', color: '#9E9E9E' }, ...categories.map((c) => ({ value: c.id, label: c.name, color: c.color }))]}
{ 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)}
@@ -359,7 +318,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}
+31 -28
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,10 +23,13 @@ export function useTasksInMonth(monthDate: Date) {
let mounted = true; let mounted = true;
setLoading(true); setLoading(true);
const subscription = collections.tasks const subscription = collections.tasks
.query(Q.where('due_date', Q.between(range.start, range.end)), Q.sortBy('due_date', 'asc')) .query(
Q.where('due_date', Q.between(range.start, range.end)),
Q.sortBy('due_date', 'asc')
)
.observe() .observe()
.subscribe({ .subscribe({
next: result => { next: (result) => {
if (mounted) { if (mounted) {
setTasks(result); setTasks(result);
setLoading(false); setLoading(false);
@@ -57,16 +60,12 @@ export function useTasksInMonth(monthDate: Date) {
return { tasks, byDay, loading, refresh }; return { tasks, byDay, loading, refresh };
} }
export function useTasks( export function useTasks(categoryIds: string[] = [], showCompleted: boolean | 'all' = false, maxAheadDays?: number) {
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;
@@ -81,8 +80,11 @@ export function useTasks(
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.where('tags', Q.like(`%,${id},%`)), Q.where('category_id', id)) Q.or(
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));
} }
@@ -97,22 +99,23 @@ export function useTasks(
conditions.push(Q.where('due_date', Q.lte(cutoff))); conditions.push(Q.where('due_date', Q.lte(cutoff)));
} }
const query = const query = conditions.length > 0
conditions.length > 0 ? collections.tasks.query(Q.and(...conditions))
? collections.tasks.query(Q.and(...conditions)) : collections.tasks.query();
: collections.tasks.query();
const subscription = query.observe().subscribe({ const subscription = query
next: result => { .observe()
if (mounted) { .subscribe({
setTasks(result); next: (result) => {
setLoading(false); if (mounted) {
} setTasks(result);
}, setLoading(false);
error: () => { }
if (mounted) setLoading(false); },
}, error: () => {
}); if (mounted) setLoading(false);
},
});
return () => { return () => {
mounted = false; mounted = false;
@@ -128,7 +131,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);
@@ -150,7 +153,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);
+1 -2
View File
@@ -1,8 +1,7 @@
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 = export type TombstoneEntity = 'categories' | 'tasks' | 'subtasks' | 'repeatProfiles' | 'friendships';
'categories' | 'tasks' | 'subtasks' | 'repeatProfiles' | 'friendships';
export default class Tombstone extends Model { export default class Tombstone extends Model {
static table = 'tombstones'; static table = 'tombstones';
+2 -7
View File
@@ -148,9 +148,7 @@ export async function register(username: string, password: string): Promise<Auth
return data.user as AuthUser; return data.user as AuthUser;
} }
export async function forgotPassword( export async function forgotPassword(username: string): Promise<{ success: boolean; resetToken?: string }> {
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 }),
@@ -159,10 +157,7 @@ export async function forgotPassword(
return data; return data;
} }
export async function resetPassword( export async function resetPassword(token: string, password: string): Promise<{ success: boolean }> {
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 }),
+3 -24
View File
@@ -1,13 +1,6 @@
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 { useSettings } from '@/theme'; import { Reminder, reminderToMinutes, parseReminders, ReminderPreference, parseTaskTags } from '@/types';
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';
@@ -102,11 +95,7 @@ function getRemindersForTask(
return []; return [];
} }
export async function scheduleTaskReminder( export async function scheduleTaskReminder(task: any, notificationsEnabled: boolean = true, globalReminderPreference: ReminderPreference = '15'): Promise<void> {
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;
@@ -218,11 +207,7 @@ export async function cancelAllReminders(): Promise<void> {
await writeIdMap(idMap); await writeIdMap(idMap);
} }
export async function rescheduleAllReminders( export async function rescheduleAllReminders(tasks: any[], notificationsEnabled: boolean = true, globalReminderPreference: ReminderPreference = '15'): Promise<void> {
tasks: any[],
notificationsEnabled: boolean = true,
globalReminderPreference: ReminderPreference = '15'
): Promise<void> {
if (!notificationsEnabled) { if (!notificationsEnabled) {
await cancelAllReminders(); await cancelAllReminders();
return; return;
@@ -280,9 +265,3 @@ export async function rescheduleAllReminders(
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,9 +29,7 @@ export async function registerForPushNotificationsAsync(): Promise<string | null
} }
try { try {
const projectId = const projectId = (Constants.expoConfig as any)?.extra?.eas?.projectId ?? (Constants.expoConfig as any)?.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,
}); });
@@ -100,9 +98,7 @@ export function addNotificationResponseReceivedListener(
return Notifications.addNotificationResponseReceivedListener(listener); return Notifications.addNotificationResponseReceivedListener(listener);
} }
export function removeNotificationSubscription( export function removeNotificationSubscription(subscription: Notifications.EventSubscription): void {
subscription: Notifications.EventSubscription
): void {
subscription.remove(); subscription.remove();
} }
+4 -72
View File
@@ -1,8 +1,5 @@
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';
@@ -15,8 +12,6 @@ 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 {
@@ -33,36 +28,26 @@ 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, notes: release.body ?? '',
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;
@@ -71,7 +56,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);
@@ -107,56 +92,3 @@ 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);
}
+25 -205
View File
@@ -1,15 +1,6 @@
import React, { import React, { createContext, useCallback, useContext, useEffect, useMemo, useState, ReactNode } from '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';
@@ -36,8 +27,6 @@ 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;
@@ -86,6 +75,7 @@ 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)];
} }
@@ -93,9 +83,7 @@ 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))) return Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, '0');
.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 {
@@ -112,53 +100,15 @@ 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( export function colors(accent: string = DEFAULT_ACCENT, opacity: number = 1): ThemeColors {
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 = const accentWithOpacity = opacity < 1 ? `${opacity.toString(16).padStart(2, '0')}${accent.slice(1)}` : accent;
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',
@@ -204,19 +154,13 @@ interface SettingsContextType {
setApiUrl: (value: string) => void; setApiUrl: (value: string) => void;
accentColor: string; accentColor: string;
setAccentColor: (value: string) => void; setAccentColor: (value: string) => void;
accentOpacity: number;
setAccentOpacity: (value: number) => void;
todoAheadDays: number; todoAheadDays: number;
setTodoAheadDays: (value: number) => void; setTodoAheadDays: (value: number) => void;
showCompleted: boolean; showCompleted: boolean;
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;
@@ -232,43 +176,17 @@ 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);
} }
@@ -289,112 +207,25 @@ 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, notificationsLoaded] = useStoredSetting<boolean>( const [notifications, setNotifications] = useStoredSetting<boolean>(STORAGE_KEYS.notifications, true);
STORAGE_KEYS.notifications, const [defaultCategoryId, setDefaultCategoryId] = useStoredSetting<string>(STORAGE_KEYS.defaultCategoryId, '');
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>( const [accentColor, setAccentColor] = useStoredSetting<string>(STORAGE_KEYS.accentColor, DEFAULT_ACCENT);
STORAGE_KEYS.accentColor, const [accentOpacity, setAccentOpacity] = useStoredSetting<number>(STORAGE_KEYS.accentOpacity, DEFAULT_ACCENT_OPACITY);
DEFAULT_ACCENT const [todoAheadDays, setTodoAheadDays] = useStoredSetting<number>(STORAGE_KEYS.todoAheadDays, DEFAULT_TODO_AHEAD_DAYS);
); const [showCompleted, setShowCompleted] = useStoredSetting<boolean>(STORAGE_KEYS.showCompleted, false);
const [accentOpacity, setAccentOpacity] = useStoredSetting<number>( const [deleteBySwipe, setDeleteBySwipe] = useStoredSetting<boolean>(STORAGE_KEYS.deleteBySwipe, false);
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 effectiveMode: 'light' | 'dark' = const theme = useMemo(() => colors(accentColor), [accentColor]);
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>(
() => ({ () => ({
@@ -412,19 +243,13 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
setApiUrl, setApiUrl,
accentColor, accentColor,
setAccentColor, setAccentColor,
accentOpacity,
setAccentOpacity,
todoAheadDays, todoAheadDays,
setTodoAheadDays, setTodoAheadDays,
showCompleted, showCompleted,
setShowCompleted, setShowCompleted,
deleteBySwipe, deleteBySwipe,
setDeleteBySwipe, setDeleteBySwipe,
themeMode,
setThemeMode,
theme, theme,
syncSettingsToServer,
loadSettingsFromServer,
}), }),
[ [
notifications, notifications,
@@ -441,23 +266,18 @@ 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 <SettingsContext.Provider value={value}>{children}</SettingsContext.Provider>; return (
<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);
} }
+9 -15
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,14 +18,10 @@ export async function createCategory(name: string, color: string): Promise<void>
}); });
} }
export async function updateCategory( export async function updateCategory(categoryId: string, name: string, color: string): Promise<void> {
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();
@@ -35,25 +31,23 @@ export async function updateCategory(
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 const tagReferencedTasks = await collections.tasks.query(Q.where('tags', Q.like(`%,${categoryId},%`))).fetch();
.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();
}); });
+1 -7
View File
@@ -8,13 +8,7 @@ type ConfirmOptions = {
onConfirm: () => void; onConfirm: () => void;
}; };
export function confirmAction({ export function confirmAction({ title, message, confirmLabel = 'Delete', destructive, onConfirm }: ConfirmOptions): void {
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;
+68 -105
View File
@@ -32,16 +32,12 @@ 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 const topLevel = await collections.subtasks.query(Q.where('task_id', taskId), Q.where('parent_subtask_id', null)).fetch();
.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 const children = await collections.subtasks.query(Q.where('parent_subtask_id', sub.id)).fetch();
.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)));
} }
@@ -74,12 +70,7 @@ function startOfDay(date: Date): Date {
return d; return d;
} }
function nextSelectedDay( function nextSelectedDay(base: Date, days: number[], intervalWeeks: number, todayStart: Date): Date {
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++) {
@@ -104,8 +95,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(
@@ -161,10 +152,7 @@ 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( await recordTombstonesInBatch('subtasks', subtasks.map((s) => s.id));
'subtasks',
subtasks.map(s => s.id)
);
} }
await recordTombstonesInBatch('tasks', [taskId]); await recordTombstonesInBatch('tasks', [taskId]);
} }
@@ -183,7 +171,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;
}); });
@@ -198,16 +186,14 @@ export async function deleteTaskOccurrences(taskId: string, scope: TaskDeleteSco
}); });
} }
export async function getSeriesOccurrenceCounts( export async function getSeriesOccurrenceCounts(taskId: string): Promise<{ future: number; all: number }> {
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 };
} }
@@ -216,7 +202,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();
@@ -233,9 +219,10 @@ 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 const open = await collections.subtasks.query(
.query(Q.where('task_id', taskId), Q.where('completed', false)) Q.where('task_id', taskId),
.fetch(); Q.where('completed', false)
).fetch();
return open.length; return open.length;
} }
@@ -279,12 +266,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();
}); });
}); });
@@ -313,7 +300,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;
@@ -331,7 +318,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();
}); });
}); });
@@ -345,11 +332,9 @@ 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 const children = await collections.subtasks.query(Q.where('parent_subtask_id', subtaskId)).fetch();
.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();
}); });
@@ -362,7 +347,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();
}); });
}); });
@@ -379,7 +364,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 || '';
@@ -403,7 +388,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();
}); });
}); });
@@ -413,21 +398,18 @@ 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( export async function undoTaskComplete(taskId: string, nextOccurrenceId: string | null): Promise<void> {
taskId: string,
nextOccurrenceId: string | null
): Promise<void> {
await database.write(async () => { await database.write(async () => {
if (nextOccurrenceId) { if (nextOccurrenceId) {
try { try {
@@ -441,7 +423,7 @@ export async function undoTaskComplete(
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();
}); });
@@ -464,27 +446,19 @@ export async function deleteTask(taskId: string): Promise<void> {
}); });
} }
export async function convertTaskToSubtask( export async function convertTaskToSubtask(taskId: string, parentTaskId: string, parentSubtaskId: string | null = null): Promise<void> {
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 const existing = await collections.subtasks.query(
.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 || '';
@@ -508,7 +482,7 @@ export async function convertTaskToSubtask(
}); });
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 || '';
@@ -533,7 +507,7 @@ export async function convertTaskToSubtask(
} }
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;
}); });
@@ -550,13 +524,11 @@ 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( t.tags = tagsToString(subTags.length > 0 ? subTags : (parent.categoryId ? [parent.categoryId] : []));
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;
@@ -574,11 +546,9 @@ export async function convertSubtaskToTask(subtaskId: string): Promise<void> {
}); });
newTaskId = task.id; newTaskId = task.id;
const children = await collections.subtasks const children = await collections.subtasks.query(Q.where('parent_subtask_id', subtaskId)).fetch();
.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;
}); });
@@ -586,7 +556,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;
}); });
}); });
@@ -606,7 +576,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;
@@ -614,14 +584,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;
}); });
}); });
@@ -647,18 +617,19 @@ 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 const siblings = await collections.subtasks.query(
.query(Q.where('task_id', subtask.taskId), Q.where('parent_subtask_id', newParentId)) Q.where('task_id', subtask.taskId),
.fetch(); Q.where('parent_subtask_id', newParentId)
).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;
}); });
}); });
@@ -668,7 +639,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();
@@ -685,9 +656,10 @@ 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 const existing = await collections.tasks.query(
.query(Q.where('series_id', seriesId), Q.where('completed', false)) Q.where('series_id', seriesId),
.fetch(); Q.where('completed', false)
).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) {
@@ -699,16 +671,11 @@ 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( const nextDate = computeNextOccurrence(task.dueDate, task.repeat, task.repeatInterval, task.repeatDays);
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;
@@ -730,7 +697,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;
@@ -756,7 +723,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();
@@ -767,7 +734,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();
}); });
@@ -781,7 +748,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;
@@ -803,7 +770,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;
@@ -829,7 +796,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;
}); });
@@ -842,7 +809,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;
}); });
@@ -873,16 +840,12 @@ 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 const siblings = await collections.subtasks.query(
.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 || '';
+2
View File
@@ -0,0 +1,2 @@
tech08mag
7BXtaqyvwVB15W
+2 -35
View File
@@ -1,37 +1,4 @@
{ {
"compilerOptions": { "compilerOptions": {},
"allowJs": true, "extends": "expo/tsconfig.base"
"esModuleInterop": true,
"jsx": "react-jsx",
"lib": ["DOM", "ESNext"],
"module": "preserve",
"moduleDetection": "force",
"moduleResolution": "bundler",
"customConditions": ["react-native"],
"noEmit": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"target": "ESNext",
"baseUrl": ".",
"deprecationDiagnostics": false,
"paths": {
"@/*": ["carry-your-live/src/*"],
"@/components/*": ["carry-your-live/src/components/*"],
"@/hooks/*": ["carry-your-live/src/hooks/*"],
"@/database/*": ["carry-your-live/src/database/*"],
"@/models/*": ["carry-your-live/src/models/*"],
"@/constants/*": ["carry-your-live/src/constants/*"],
"@/types/*": ["carry-your-live/src/types/*"],
"@/utils/*": ["carry-your-live/src/utils/*"]
}
},
"extends": "expo/tsconfig.base",
"exclude": [
"${configDir}/node_modules",
"${configDir}/babel.config.js",
"${configDir}/metro.config.js",
"${configDir}/jest.config.js",
"${configDir}/android",
"${configDir}/ios"
]
} }