Compare commits
18
Commits
dev
..
beb6cc5715
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
beb6cc5715 | ||
|
|
c787b87cc5 | ||
|
|
064650a585 | ||
|
|
f6abf628e3 | ||
|
|
aa57d07dc0 | ||
|
|
ee6af8fb26 | ||
|
|
5043d9cdff | ||
|
|
157fc8d95b | ||
|
|
8e805316fb | ||
|
|
7e06415db4 | ||
|
|
2be4d5aa4e | ||
|
|
0093c4aec2 | ||
|
|
9ade0fa0ca | ||
|
|
39fd491d24 | ||
|
|
c4cc2712ee | ||
|
|
49ef8ae82f | ||
|
|
0b87f3e4ed | ||
|
|
9a9aff0880 |
@@ -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
|
|
||||||
+71
-168
@@ -1,46 +1,36 @@
|
|||||||
name: Build and Release Android
|
name: Build and Release APK
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
branches: [main]
|
||||||
- 'v*'
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
NODE_VERSION: '20'
|
|
||||||
JAVA_VERSION: '17'
|
|
||||||
ANDROID_HOME: ${{ runner.tool_cache }}/android-sdk
|
ANDROID_HOME: ${{ runner.tool_cache }}/android-sdk
|
||||||
ANDROID_SDK_ROOT: ${{ runner.tool_cache }}/android-sdk
|
ANDROID_SDK_ROOT: ${{ runner.tool_cache }}/android-sdk
|
||||||
GITEA_SERVER_URL: "https://techmos.gitea.org"
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
name: Build Android
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 45
|
outputs:
|
||||||
permissions:
|
apk-path: ${{ steps.upload-apk.outputs.path }}
|
||||||
contents: read
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Set up Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
node-version: 22
|
||||||
cache: 'npm'
|
|
||||||
cache-dependency-path: carry-your-live/package-lock.json
|
|
||||||
|
|
||||||
- name: Setup Java
|
- name: Set up JDK
|
||||||
uses: actions/setup-java@v4
|
uses: actions/setup-java@v4
|
||||||
with:
|
with:
|
||||||
distribution: 'temurin'
|
distribution: temurin
|
||||||
java-version: ${{ env.JAVA_VERSION }}
|
java-version: 17
|
||||||
cache: 'gradle'
|
|
||||||
cache-dependency-path: carry-your-live/android/gradle/wrapper/gradle-wrapper.properties
|
|
||||||
|
|
||||||
- name: Install Android SDK
|
- name: Install Android SDK
|
||||||
run: |
|
run: |
|
||||||
@@ -53,6 +43,7 @@ jobs:
|
|||||||
unzip -q /tmp/cmdtools.zip -d "$ANDROID_HOME/cmdline-tools"
|
unzip -q /tmp/cmdtools.zip -d "$ANDROID_HOME/cmdline-tools"
|
||||||
mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest"
|
mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest"
|
||||||
fi
|
fi
|
||||||
|
# Accept all licenses by writing known license hashes
|
||||||
mkdir -p "$ANDROID_HOME/licenses"
|
mkdir -p "$ANDROID_HOME/licenses"
|
||||||
echo "8933bad161af4178b1185d1a37fbf41ea5269c55" > "$ANDROID_HOME/licenses/android-sdk-license"
|
echo "8933bad161af4178b1185d1a37fbf41ea5269c55" > "$ANDROID_HOME/licenses/android-sdk-license"
|
||||||
echo "d56f5187479451eabf01fb78af6dfcb131a6481e" >> "$ANDROID_HOME/licenses/android-sdk-license"
|
echo "d56f5187479451eabf01fb78af6dfcb131a6481e" >> "$ANDROID_HOME/licenses/android-sdk-license"
|
||||||
@@ -65,173 +56,85 @@ jobs:
|
|||||||
mkdir -p carry-your-live/android/app
|
mkdir -p carry-your-live/android/app
|
||||||
echo "sdk.dir=$ANDROID_HOME" > carry-your-live/android/app/local.properties
|
echo "sdk.dir=$ANDROID_HOME" > carry-your-live/android/app/local.properties
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install JS dependencies
|
||||||
working-directory: carry-your-live
|
working-directory: carry-your-live
|
||||||
run: |
|
run: |
|
||||||
for i in {1..3}; do
|
for i in {1..3}; do
|
||||||
npm ci && break || sleep 10
|
npm ci && break || sleep 10
|
||||||
done
|
done
|
||||||
|
|
||||||
- name: Prebuild Android
|
- name: Generate debug keystore (signs the release APK)
|
||||||
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: |
|
run: |
|
||||||
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > release.keystore
|
mkdir -p carry-your-live/android/app
|
||||||
cat > ../release-signing.properties <<EOF
|
rm -f carry-your-live/android/app/debug.keystore
|
||||||
storeFile=release.keystore
|
keytool -genkeypair -v \
|
||||||
storePassword=${{ secrets.KEYSTORE_PASSWORD }}
|
-keystore carry-your-live/android/app/debug.keystore \
|
||||||
keyAlias=${{ secrets.KEY_ALIAS }}
|
-alias androiddebugkey -storepass android -keypass android \
|
||||||
keyPassword=${{ secrets.KEY_PASSWORD }}
|
-keyalg RSA -keysize 2048 -validity 10000 \
|
||||||
EOF
|
-dname "CN=Android Debug,O=Android,C=US"
|
||||||
|
|
||||||
- name: Build Release APK
|
- name: Build release APK
|
||||||
working-directory: carry-your-live/android
|
|
||||||
run: |
|
run: |
|
||||||
if [ -f "../release-signing.properties" ]; then
|
cd carry-your-live/android
|
||||||
./gradlew assembleRelease -PreleaseSigningProperties=../release-signing.properties --no-daemon
|
./gradlew assembleRelease
|
||||||
else
|
|
||||||
./gradlew assembleRelease --no-daemon
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Build Release Bundle (AAB)
|
- name: Upload APK
|
||||||
working-directory: carry-your-live/android
|
id: upload-apk
|
||||||
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
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: android-release-build
|
name: carry-your-live-release
|
||||||
path: |
|
path: carry-your-live/android/app/build/outputs/apk/release/app-release.apk
|
||||||
${{ steps.find-release-apk.outputs.apk_path }}
|
|
||||||
${{ steps.find-release-aab.outputs.aab_path }}
|
|
||||||
retention-days: 90
|
|
||||||
|
|
||||||
- name: Upload Test Reports
|
|
||||||
if: always()
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: android-test-reports
|
|
||||||
path: carry-your-live/android/app/build/reports/tests/
|
|
||||||
retention-days: 30
|
|
||||||
|
|
||||||
- name: Upload Lint Reports
|
|
||||||
if: always()
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: android-lint-reports
|
|
||||||
path: carry-your-live/android/app/build/reports/lint-results*.html
|
|
||||||
retention-days: 30
|
|
||||||
|
|
||||||
release:
|
release:
|
||||||
name: Create Gitea Release
|
|
||||||
needs: build
|
needs: build
|
||||||
if: startsWith(github.ref, 'refs/tags/v')
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 10
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Download Build Artifacts
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Download APK artifact
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: android-release-build
|
name: carry-your-live-release
|
||||||
path: artifacts
|
path: .
|
||||||
|
|
||||||
- name: Create Gitea Release
|
- name: Compute next version
|
||||||
env:
|
id: version
|
||||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
|
||||||
run: |
|
run: |
|
||||||
TAG_NAME="${GITHUB_REF#refs/tags/}"
|
latest=$(git tag --list 'v[0-9]*' --sort=-v:refname | head -n 1)
|
||||||
REPO_OWNER="${GITHUB_REPOSITORY%/*}"
|
if [ -z "$latest" ]; then
|
||||||
REPO_NAME="${GITHUB_REPOSITORY#*/}"
|
next="v1.0.0"
|
||||||
|
|
||||||
# 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
|
else
|
||||||
continue
|
latest=${latest#v}
|
||||||
|
IFS='.' read -r major minor patch <<< "$latest"
|
||||||
|
next="v${major}.${minor}.$((patch + 1))"
|
||||||
fi
|
fi
|
||||||
|
echo "next=${next}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
echo "Uploading $file as $FILENAME..."
|
- name: Build changelog
|
||||||
curl -s -X POST "${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}/assets" \
|
id: changelog
|
||||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
run: |
|
||||||
-F "attachment=@${file};filename=${FILENAME}"
|
prev_tag=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
|
||||||
|
if [ -z "$prev_tag" ]; then
|
||||||
|
log=$(git log --oneline --no-merges main | head -n 40)
|
||||||
|
else
|
||||||
|
log=$(git log --oneline --no-merges "$prev_tag"..main | head -n 40)
|
||||||
|
fi
|
||||||
|
{
|
||||||
|
echo "body<<EOF"
|
||||||
|
echo "## What's new in this release"
|
||||||
|
echo ""
|
||||||
|
echo "$log" | sed 's/^/- /'
|
||||||
|
echo "EOF"
|
||||||
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
echo "Uploaded $FILENAME"
|
- name: Create release
|
||||||
done
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
tag_name: ${{ steps.version.outputs.next }}
|
||||||
|
name: ${{ steps.version.outputs.next }}
|
||||||
|
body: ${{ steps.changelog.outputs.body }}
|
||||||
|
files: ./app-release.apk
|
||||||
|
generate_release_notes: false
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -34,9 +34,7 @@
|
|||||||
"color": "#1E88E5"
|
"color": "#1E88E5"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"./plugins/withQuickAddWidget",
|
"./plugins/withQuickAddWidget"
|
||||||
"expo-router",
|
|
||||||
"expo-sqlite"
|
|
||||||
],
|
],
|
||||||
"extra": {
|
"extra": {
|
||||||
"eas": {
|
"eas": {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Tabs } from 'expo-router';
|
import { Tabs } from 'expo-router';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { View, StyleSheet, TouchableOpacity, Animated } from 'react-native';
|
import { View, StyleSheet, TouchableOpacity, Animated, } from 'react-native';
|
||||||
import { TabBarIcon } from '@/components/TabBarIcon';
|
import { TabBarIcon } from '@/components/TabBarIcon';
|
||||||
import { useSettings } from '@/theme';
|
import { useSettings } from '@/theme';
|
||||||
import { setLastVisitedTab } from '@/utils/tabHistory';
|
import { setLastVisitedTab } from '@/utils/tabHistory';
|
||||||
@@ -12,7 +12,7 @@ export default function TabLayout() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs
|
<Tabs
|
||||||
screenOptions={{
|
screenOptions={{
|
||||||
tabBarActiveTintColor: theme.accent,
|
tabBarActiveTintColor: theme.accent,
|
||||||
tabBarInactiveTintColor: theme.textSecondary,
|
tabBarInactiveTintColor: theme.textSecondary,
|
||||||
tabBarStyle: {
|
tabBarStyle: {
|
||||||
@@ -40,7 +40,7 @@ export default function TabLayout() {
|
|||||||
headerShown: false,
|
headerShown: false,
|
||||||
sceneStyle: { backgroundColor: theme.background },
|
sceneStyle: { backgroundColor: theme.background },
|
||||||
}}
|
}}
|
||||||
tabBar={props => {
|
tabBar={(props) => {
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
@@ -56,10 +56,8 @@ export default function TabLayout() {
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{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}
|
||||||
@@ -69,24 +67,17 @@ export default function TabLayout() {
|
|||||||
]}
|
]}
|
||||||
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 }]}>
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.rippleContainer,
|
||||||
|
{ opacity: focused ? 1 : 0.5 },
|
||||||
|
]}
|
||||||
|
>
|
||||||
<TabBarIcon
|
<TabBarIcon
|
||||||
name={
|
name={route.name === 'index' ? 'checklist' : route.name === 'calendar' ? 'calendar' : route.name === 'stats' ? 'stats' : 'gear'}
|
||||||
route.name === 'index'
|
|
||||||
? 'checklist'
|
|
||||||
: route.name === 'calendar'
|
|
||||||
? 'calendar'
|
|
||||||
: route.name === 'stats'
|
|
||||||
? 'stats'
|
|
||||||
: 'gear'
|
|
||||||
}
|
|
||||||
focused={focused}
|
focused={focused}
|
||||||
color={focused ? theme.accent : theme.textSecondary}
|
color={focused ? theme.accent : theme.textSecondary}
|
||||||
size={24}
|
size={24}
|
||||||
|
|||||||
@@ -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,28 +229,17 @@ 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}
|
|
||||||
showsVerticalScrollIndicator={false}
|
|
||||||
contentContainerStyle={styles.scrollContent}
|
|
||||||
>
|
|
||||||
<GestureDetector gesture={pan}>
|
<GestureDetector gesture={pan}>
|
||||||
<Animated.View
|
<Animated.View
|
||||||
onLayout={e => {
|
onLayout={(e) => {
|
||||||
gridWidthRef.current = e.nativeEvent.layout.width;
|
gridWidthRef.current = e.nativeEvent.layout.width;
|
||||||
}}
|
}}
|
||||||
// eslint-disable-next-line react-hooks/refs
|
// eslint-disable-next-line react-hooks/refs
|
||||||
@@ -304,73 +248,34 @@ export default function CalendarScreen() {
|
|||||||
<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}
|
||||||
@@ -404,9 +309,7 @@ export default function CalendarScreen() {
|
|||||||
</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')}
|
|
||||||
</Text>
|
|
||||||
{sortedDayTasks.length > 0 && (
|
{sortedDayTasks.length > 0 && (
|
||||||
<Text style={[styles.panelCount, { color: theme.textFaint }]}>
|
<Text style={[styles.panelCount, { color: theme.textFaint }]}>
|
||||||
{sortedDayTasks.length} event{sortedDayTasks.length === 1 ? '' : 's'}
|
{sortedDayTasks.length} event{sortedDayTasks.length === 1 ? '' : 's'}
|
||||||
@@ -416,15 +319,11 @@ export default function CalendarScreen() {
|
|||||||
|
|
||||||
{sortedDayTasks.length === 0 ? (
|
{sortedDayTasks.length === 0 ? (
|
||||||
<View style={styles.emptyState}>
|
<View style={styles.emptyState}>
|
||||||
<Text style={[styles.emptyText, { color: theme.textSecondary }]}>
|
<Text style={[styles.emptyText, { color: theme.textSecondary }]}>No events on this day</Text>
|
||||||
No events on this day
|
<Text style={[styles.emptySubtext, { color: theme.textMuted }]}>Tap a date or use the bar below</Text>
|
||||||
</Text>
|
|
||||||
<Text style={[styles.emptySubtext, { color: theme.textMuted }]}>
|
|
||||||
Tap a date or use the bar below
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
sortedDayTasks.map(task => {
|
sortedDayTasks.map((task) => {
|
||||||
const isExpanded = expandedTasks.has(task.id);
|
const isExpanded = expandedTasks.has(task.id);
|
||||||
const itemSubtasks = subtasksByTask.get(task.id) ?? [];
|
const itemSubtasks = subtasksByTask.get(task.id) ?? [];
|
||||||
return (
|
return (
|
||||||
@@ -435,12 +334,12 @@ export default function CalendarScreen() {
|
|||||||
subtasks={itemSubtasks}
|
subtasks={itemSubtasks}
|
||||||
categoryColor={categoryColors.get(task.categoryId)}
|
categoryColor={categoryColors.get(task.categoryId)}
|
||||||
onToggle={() => handleToggleComplete(task.id)}
|
onToggle={() => handleToggleComplete(task.id)}
|
||||||
onPress={id =>
|
onPress={(id) =>
|
||||||
expandedTasks.has(id)
|
expandedTasks.has(id)
|
||||||
? router.push({ pathname: '/task-detail', params: { id } })
|
? router.push({ pathname: '/task-detail', params: { id } })
|
||||||
: toggleExpand(id)
|
: toggleExpand(id)
|
||||||
}
|
}
|
||||||
onMenuOpen={id => openTaskEdit(id)}
|
onMenuOpen={(id) => openTaskEdit(id)}
|
||||||
onSubtaskToggle={handleSubtaskToggle}
|
onSubtaskToggle={handleSubtaskToggle}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
@@ -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,8 +488,7 @@ const DayCell = React.memo(function DayCell({
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const createStyles = (theme: ThemeColors) =>
|
const createStyles = (theme: ThemeColors) => StyleSheet.create({
|
||||||
StyleSheet.create({
|
|
||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
@@ -754,4 +636,4 @@ const createStyles = (theme: ThemeColors) =>
|
|||||||
paddingRight: 4,
|
paddingRight: 4,
|
||||||
paddingTop: 4,
|
paddingTop: 4,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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, DEFAULT_ACCENT_OPACITY } 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);
|
||||||
@@ -134,9 +84,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');
|
||||||
@@ -163,15 +111,16 @@ export default function SettingsScreen() {
|
|||||||
}, [refreshSyncStatus, router])
|
}, [refreshSyncStatus, router])
|
||||||
);
|
);
|
||||||
|
|
||||||
const defaultCategory = categories.find(c => c.id === defaultCategoryId);
|
const defaultCategory = categories.find((c) => c.id === defaultCategoryId);
|
||||||
const defaultCategoryLabel = defaultCategory?.name ?? 'None';
|
const defaultCategoryLabel = defaultCategory?.name ?? 'None';
|
||||||
const reminderLabel =
|
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 +162,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 +181,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 +193,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 +205,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 +226,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 +264,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 +277,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 +290,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 +298,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 +318,30 @@ export default function SettingsScreen() {
|
|||||||
onPress={handleCheckUpdates}
|
onPress={handleCheckUpdates}
|
||||||
showChevron
|
showChevron
|
||||||
/>
|
/>
|
||||||
<ListItem title="Last checked" subtitle={lastUpdateCheck ?? 'Never'} />
|
<ListItem
|
||||||
<ListItem title="Version" subtitle={getCurrentAppVersion()} />
|
title="Last checked"
|
||||||
<ListItem title="Privacy Policy" onPress={() => setLegalVisible('privacy')} showChevron />
|
subtitle={lastUpdateCheck ?? 'Never'}
|
||||||
<ListItem title="Terms of Service" onPress={() => setLegalVisible('terms')} showChevron />
|
/>
|
||||||
|
<ListItem
|
||||||
|
title="Version"
|
||||||
|
subtitle={getCurrentAppVersion()}
|
||||||
|
/>
|
||||||
|
<ListItem
|
||||||
|
title="Privacy Policy"
|
||||||
|
onPress={() => setLegalVisible('privacy')}
|
||||||
|
showChevron
|
||||||
|
/>
|
||||||
|
<ListItem
|
||||||
|
title="Terms of Service"
|
||||||
|
onPress={() => setLegalVisible('terms')}
|
||||||
|
showChevron
|
||||||
|
/>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
<OptionPickerModal
|
<OptionPickerModal
|
||||||
visible={picker === 'category'}
|
visible={picker === 'category'}
|
||||||
title="Default Tag"
|
title="Default Tag"
|
||||||
options={[
|
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 +352,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 +365,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 +387,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 +394,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}
|
|
||||||
transparent
|
|
||||||
animationType="fade"
|
|
||||||
onRequestClose={() => setCustomAccentVisible(false)}
|
|
||||||
>
|
|
||||||
<Pressable style={styles.accentModalOverlay} onPress={() => setCustomAccentVisible(false)}>
|
|
||||||
<Pressable
|
<Pressable
|
||||||
style={[styles.accentModalSheet, { backgroundColor: theme.sheetBg }]}
|
style={styles.accentModalOverlay}
|
||||||
onPress={() => {}}
|
|
||||||
>
|
|
||||||
<View style={styles.accentModalHeader}>
|
|
||||||
<Text style={[styles.accentModalTitle, { color: theme.text }]}>
|
|
||||||
Custom Accent Color
|
|
||||||
</Text>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => setCustomAccentVisible(false)}
|
onPress={() => setCustomAccentVisible(false)}
|
||||||
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
|
||||||
>
|
>
|
||||||
|
<Pressable style={[styles.accentModalSheet, { backgroundColor: theme.sheetBg }]} onPress={() => {}}>
|
||||||
|
<View style={styles.accentModalHeader}>
|
||||||
|
<Text style={[styles.accentModalTitle, { color: theme.text }]}>Custom Accent Color</Text>
|
||||||
|
<TouchableOpacity onPress={() => setCustomAccentVisible(false)} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
|
||||||
<Svg width={18} height={18} viewBox="0 0 24 24">
|
<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 +449,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';
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -8,12 +8,7 @@ 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';
|
||||||
|
|
||||||
Notifications.setNotificationHandler({
|
Notifications.setNotificationHandler({
|
||||||
@@ -33,7 +28,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,9 +53,7 @@ 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) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
|||||||
@@ -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,8 +112,7 @@ 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]) || '';
|
||||||
@@ -130,7 +120,7 @@ export default function AddTaskScreen() {
|
|||||||
let createdTask: any = null;
|
let createdTask: any = null;
|
||||||
|
|
||||||
await database.write(async () => {
|
await database.write(async () => {
|
||||||
const task = await collections.tasks.create(t => {
|
const task = await collections.tasks.create((t) => {
|
||||||
t.title = data.title.trim();
|
t.title = data.title.trim();
|
||||||
t.description = data.description || '';
|
t.description = data.description || '';
|
||||||
t.categoryId = resolvedCategoryId;
|
t.categoryId = resolvedCategoryId;
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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();
|
refreshSubtasks();
|
||||||
});
|
});
|
||||||
},
|
}, [refresh, refreshSubtasks]);
|
||||||
[refresh, refreshSubtasks]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleToggleSubtask = useCallback(
|
const handleToggleSubtask = useCallback(async (subtaskId: string) => {
|
||||||
async (subtaskId: string) => {
|
|
||||||
await toggleSubtaskComplete(subtaskId);
|
await toggleSubtaskComplete(subtaskId);
|
||||||
refreshSubtasks();
|
refreshSubtasks();
|
||||||
},
|
}, [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,18 +205,14 @@ 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,
|
||||||
},
|
},
|
||||||
@@ -401,4 +290,4 @@ const createStyles = (theme: ThemeColors) =>
|
|||||||
padding: 4,
|
padding: 4,
|
||||||
marginLeft: 6,
|
marginLeft: 6,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,36 +326,21 @@ 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"
|
|
||||||
icon={
|
|
||||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||||
<Circle
|
<Circle cx={12} cy={12} r={10} stroke={theme.accent} strokeWidth={1.8} fill="none" />
|
||||||
cx={12}
|
<Path d="M12 6v6l4 2" stroke={theme.accent} strokeWidth={1.8} strokeLinecap="round" />
|
||||||
cy={12}
|
|
||||||
r={10}
|
|
||||||
stroke={theme.accent}
|
|
||||||
strokeWidth={1.8}
|
|
||||||
fill="none"
|
|
||||||
/>
|
|
||||||
<Path
|
|
||||||
d="M12 6v6l4 2"
|
|
||||||
stroke={theme.accent}
|
|
||||||
strokeWidth={1.8}
|
|
||||||
strokeLinecap="round"
|
|
||||||
/>
|
|
||||||
</Svg>
|
</Svg>
|
||||||
}
|
} defaultExpanded={!!dueDate}>
|
||||||
defaultExpanded={!!dueDate}
|
|
||||||
>
|
|
||||||
<DateTimePickerComponent control={control} />
|
<DateTimePickerComponent control={control} />
|
||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
|
|
||||||
<CollapsibleSection
|
<CollapsibleSection title="Repeat" icon={
|
||||||
title="Repeat"
|
|
||||||
icon={
|
|
||||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||||
<Path
|
<Path
|
||||||
d="M17 2l4 4-4 4M3 11v-1a4 4 0 0 1 4-4h14M7 22l-4-4 4-4M21 13v1a4 4 0 0 1-4 4H3"
|
d="M17 2l4 4-4 4M3 11v-1a4 4 0 0 1 4-4h14M7 22l-4-4 4-4M21 13v1a4 4 0 0 1-4 4H3"
|
||||||
@@ -419,9 +351,7 @@ export default function TaskDetailScreen() {
|
|||||||
fill="none"
|
fill="none"
|
||||||
/>
|
/>
|
||||||
</Svg>
|
</Svg>
|
||||||
}
|
} defaultExpanded={repeat !== 'none'}>
|
||||||
defaultExpanded={repeat !== 'none'}
|
|
||||||
>
|
|
||||||
<RepeatSelector
|
<RepeatSelector
|
||||||
value={repeat}
|
value={repeat}
|
||||||
interval={repeatInterval}
|
interval={repeatInterval}
|
||||||
@@ -434,9 +364,7 @@ export default function TaskDetailScreen() {
|
|||||||
/>
|
/>
|
||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
|
|
||||||
<CollapsibleSection
|
<CollapsibleSection title="Reminder" icon={
|
||||||
title="Reminder"
|
|
||||||
icon={
|
|
||||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||||
<Path
|
<Path
|
||||||
d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9M10.3 21a1.94 1.94 0 0 0 3.4 0"
|
d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9M10.3 21a1.94 1.94 0 0 0 3.4 0"
|
||||||
@@ -447,61 +375,32 @@ export default function TaskDetailScreen() {
|
|||||||
fill="none"
|
fill="none"
|
||||||
/>
|
/>
|
||||||
</Svg>
|
</Svg>
|
||||||
}
|
} defaultExpanded={!!reminders && reminders !== ''}>
|
||||||
defaultExpanded={!!reminders && reminders !== ''}
|
|
||||||
>
|
|
||||||
<ReminderSelector
|
<ReminderSelector
|
||||||
value={reminders}
|
value={reminders}
|
||||||
hasDueDate={!!dueDate}
|
hasDueDate={!!dueDate}
|
||||||
onChange={value => setValue('reminders', value)}
|
onChange={(value) => setValue('reminders', value)}
|
||||||
/>
|
/>
|
||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
|
|
||||||
<CollapsibleSection
|
<CollapsibleSection title="Assignee" icon={
|
||||||
title="Assignee"
|
|
||||||
icon={
|
|
||||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||||
<Circle
|
<Circle cx={12} cy={12} r={10} stroke={theme.accent} strokeWidth={1.8} fill="none" />
|
||||||
cx={12}
|
<Path d="M12 10v6M12 19v1" stroke={theme.accent} strokeWidth={1.8} strokeLinecap="round" />
|
||||||
cy={12}
|
|
||||||
r={10}
|
|
||||||
stroke={theme.accent}
|
|
||||||
strokeWidth={1.8}
|
|
||||||
fill="none"
|
|
||||||
/>
|
|
||||||
<Path
|
|
||||||
d="M12 10v6M12 19v1"
|
|
||||||
stroke={theme.accent}
|
|
||||||
strokeWidth={1.8}
|
|
||||||
strokeLinecap="round"
|
|
||||||
/>
|
|
||||||
</Svg>
|
</Svg>
|
||||||
}
|
} defaultExpanded={!!assigneeId}>
|
||||||
defaultExpanded={!!assigneeId}
|
|
||||||
>
|
|
||||||
<AssigneeSelector
|
<AssigneeSelector
|
||||||
value={assigneeId}
|
value={assigneeId}
|
||||||
onChange={(value: string | null) => setValue('assigneeId', value)}
|
onChange={(value: string | null) => setValue('assigneeId', value)}
|
||||||
friends={friends.map(f => f.username)}
|
friends={friends.map((f) => f.username)}
|
||||||
/>
|
/>
|
||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
|
|
||||||
<CollapsibleSection
|
<CollapsibleSection title="Description" icon={
|
||||||
title="Description"
|
|
||||||
icon={
|
|
||||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||||
<Path
|
<Path d="M4 6h16M4 12h16M4 18h10" stroke={theme.accent} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||||
d="M4 6h16M4 12h16M4 18h10"
|
|
||||||
stroke={theme.accent}
|
|
||||||
strokeWidth={1.8}
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
fill="none"
|
|
||||||
/>
|
|
||||||
</Svg>
|
</Svg>
|
||||||
}
|
} defaultExpanded={!!methods.getValues('description')}>
|
||||||
defaultExpanded={!!methods.getValues('description')}
|
|
||||||
>
|
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name="description"
|
name="description"
|
||||||
|
|||||||
Generated
+605
-727
File diff suppressed because it is too large
Load Diff
@@ -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,
|
|
||||||
});
|
|
||||||
@@ -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
|
<Path d="M5 12l5 5 9-10" stroke={theme.accent} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||||
d="M5 12l5 5 9-10"
|
|
||||||
stroke={theme.accent}
|
|
||||||
strokeWidth={2.5}
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
fill="none"
|
|
||||||
/>
|
|
||||||
</Svg>
|
</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={[
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
styles.optionText,
|
styles.optionText,
|
||||||
{ color: theme.textFaint },
|
{ color: theme.textFaint },
|
||||||
value === priority.value && { color: theme.accentStrong, fontWeight: '600' },
|
value === priority.value && { color: theme.accentStrong, fontWeight: '600' },
|
||||||
]}
|
]}>
|
||||||
>
|
|
||||||
{priority.label}
|
{priority.label}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|||||||
@@ -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,12 +18,8 @@ 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
|
|
||||||
.map(r => REMINDER_OPTIONS.find(o => o.value === r)?.label)
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(', ')
|
|
||||||
: 'No reminder';
|
: 'No reminder';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -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
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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
|
||||||
<Pressable style={styles.modalBackdrop} onPress={() => setSaveModalVisible(false)}>
|
style={styles.modalBackdrop}
|
||||||
<Pressable
|
behavior="padding"
|
||||||
style={[styles.modalCard, { backgroundColor: theme.card, borderColor: theme.border }]}
|
|
||||||
>
|
>
|
||||||
|
<Pressable style={styles.modalBackdrop} onPress={() => setSaveModalVisible(false)}>
|
||||||
|
<Pressable style={[styles.modalCard, { backgroundColor: theme.card, borderColor: theme.border }]}>
|
||||||
<Text style={[styles.modalTitle, { color: theme.text }]}>Save repeat profile</Text>
|
<Text style={[styles.modalTitle, { color: theme.text }]}>Save repeat profile</Text>
|
||||||
<Text style={[styles.modalHint, { color: theme.textFaint }]}>
|
<Text style={[styles.modalHint, { color: theme.textFaint }]}>
|
||||||
{`${REPEAT_OPTIONS.find(o => o.value === value)?.label.replace('No Repeat', 'None')}, every ${interval} ${unitLabel(value)}${interval > 1 ? 's' : ''}`}
|
{`${REPEAT_OPTIONS.find((o) => o.value === value)?.label.replace('No Repeat', 'None')}, every ${interval} ${unitLabel(value)}${interval > 1 ? 's' : ''}`}
|
||||||
{isDaysBased(value) && days.length > 0
|
{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>
|
||||||
|
|||||||
@@ -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,8 +81,7 @@ 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);
|
||||||
@@ -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 }]}
|
||||||
|
|||||||
@@ -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}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -277,7 +217,7 @@ export const TaskItem = React.memo(function TaskItem({
|
|||||||
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>
|
||||||
@@ -334,7 +260,9 @@ export const TaskItem = React.memo(function TaskItem({
|
|||||||
{ backgroundColor: PRIORITY_COLORS[task.priority] },
|
{ backgroundColor: PRIORITY_COLORS[task.priority] },
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Text style={styles.priorityText}>{task.priority.charAt(0).toUpperCase()}</Text>
|
<Text style={styles.priorityText}>
|
||||||
|
{task.priority.charAt(0).toUpperCase()}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
{task.assigneeId && (
|
{task.assigneeId && (
|
||||||
@@ -368,14 +296,7 @@ export const TaskItem = React.memo(function TaskItem({
|
|||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
fill="none"
|
fill="none"
|
||||||
/>
|
/>
|
||||||
<Circle
|
<Circle cx={12} cy={12} r={4} stroke={theme.accent} strokeWidth={1.5} fill="none" />
|
||||||
cx={12}
|
|
||||||
cy={12}
|
|
||||||
r={4}
|
|
||||||
stroke={theme.accent}
|
|
||||||
strokeWidth={1.5}
|
|
||||||
fill="none"
|
|
||||||
/>
|
|
||||||
</Svg>
|
</Svg>
|
||||||
<Text style={styles.allDayText}>All Day</Text>
|
<Text style={styles.allDayText}>All Day</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -396,20 +317,8 @@ export const TaskItem = React.memo(function TaskItem({
|
|||||||
{hasDueDate && (
|
{hasDueDate && (
|
||||||
<View style={styles.dueRow}>
|
<View style={styles.dueRow}>
|
||||||
<Svg width={14} height={14} viewBox="0 0 24 24">
|
<Svg width={14} height={14} viewBox="0 0 24 24">
|
||||||
<Circle
|
<Circle cx={12} cy={12} r={10} stroke={theme.textSecondary} strokeWidth={2} fill="none" />
|
||||||
cx={12}
|
<Path d="M12 6v6l4 2" stroke={theme.textSecondary} strokeWidth={2} strokeLinecap="round" />
|
||||||
cy={12}
|
|
||||||
r={10}
|
|
||||||
stroke={theme.textSecondary}
|
|
||||||
strokeWidth={2}
|
|
||||||
fill="none"
|
|
||||||
/>
|
|
||||||
<Path
|
|
||||||
d="M12 6v6l4 2"
|
|
||||||
stroke={theme.textSecondary}
|
|
||||||
strokeWidth={2}
|
|
||||||
strokeLinecap="round"
|
|
||||||
/>
|
|
||||||
</Svg>
|
</Svg>
|
||||||
<Animated.Text
|
<Animated.Text
|
||||||
style={[
|
style={[
|
||||||
@@ -491,8 +400,7 @@ 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',
|
||||||
@@ -692,4 +600,4 @@ const createStyles = (theme: ThemeColors) =>
|
|||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,19 @@ 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 taskDragRef = useRef<{ taskId: string; positions: Record<string, { top: number; bottom: number }> } | null>(null);
|
||||||
subPositions: Record<string, { top: number; bottom: number }>;
|
const {
|
||||||
} | null>(null);
|
modals,
|
||||||
const subtaskDragRef = useRef<{
|
openTaskDelete,
|
||||||
subtaskId: string;
|
openSubtaskDelete,
|
||||||
parentTaskId: string;
|
openSubtaskEdit,
|
||||||
parentSubtaskId: string | null;
|
openTaskEdit,
|
||||||
positions: Record<string, { top: number; bottom: number }>;
|
} = useTaskModals();
|
||||||
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 +84,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 +101,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,18 +146,14 @@ 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);
|
||||||
@@ -202,19 +166,14 @@ export function TaskList({
|
|||||||
}
|
}
|
||||||
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,20 +184,14 @@ 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' : ''}?`,
|
|
||||||
'This cannot be undone.',
|
|
||||||
[
|
|
||||||
{ text: 'Cancel', style: 'cancel' },
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
{
|
{
|
||||||
text: 'Delete',
|
text: 'Delete',
|
||||||
@@ -248,8 +201,7 @@ export function TaskList({
|
|||||||
exitSelection();
|
exitSelection();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]
|
]);
|
||||||
);
|
|
||||||
}, [selectedIds, exitSelection]);
|
}, [selectedIds, exitSelection]);
|
||||||
|
|
||||||
const handleBulkComplete = useCallback(async () => {
|
const handleBulkComplete = useCallback(async () => {
|
||||||
@@ -260,16 +212,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,25 +227,18 @@ 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 findHoverTask = useCallback((absoluteY: number, draggedId: string, positions: Record<string, { top: number; bottom: number }>) => {
|
||||||
(
|
|
||||||
absoluteY: number,
|
|
||||||
draggedId: string,
|
|
||||||
positions: Record<string, { top: number; bottom: number }>
|
|
||||||
) => {
|
|
||||||
const entries = Object.entries(positions);
|
const entries = Object.entries(positions);
|
||||||
for (let i = entries.length - 1; i >= 0; i--) {
|
for (let i = entries.length - 1; i >= 0; i--) {
|
||||||
const [id, p] = entries[i];
|
const [id, p] = entries[i];
|
||||||
@@ -305,21 +248,13 @@ export function TaskList({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
}, []);
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const updateHoverTaskTarget = useCallback((target: string | null) => {
|
const updateHoverTaskTarget = useCallback((target: string | null) => {
|
||||||
setHoverTaskId(target ?? null);
|
setHoverTaskId(target ?? null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const findHoverTarget = useCallback(
|
const findHoverTarget = useCallback((absoluteY: number, draggedId: string, positions: Record<string, { top: number; bottom: number }>, subPositions: Record<string, { top: number; bottom: number }>) => {
|
||||||
(
|
|
||||||
absoluteY: number,
|
|
||||||
draggedId: string,
|
|
||||||
positions: Record<string, { top: number; bottom: number }>,
|
|
||||||
subPositions: Record<string, { top: number; bottom: number }>
|
|
||||||
) => {
|
|
||||||
const subEntries = Object.entries(subPositions);
|
const subEntries = Object.entries(subPositions);
|
||||||
for (let i = subEntries.length - 1; i >= 0; i--) {
|
for (let i = subEntries.length - 1; i >= 0; i--) {
|
||||||
const [id, p] = subEntries[i];
|
const [id, p] = subEntries[i];
|
||||||
@@ -335,33 +270,21 @@ export function TaskList({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
}, []);
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const calculateDropPosition = useCallback(
|
const calculateDropPosition = useCallback((absoluteY: number, targetId: string, positions: Record<string, { top: number; bottom: number }>) => {
|
||||||
(
|
|
||||||
absoluteY: number,
|
|
||||||
targetId: string,
|
|
||||||
positions: Record<string, { top: number; bottom: number }>
|
|
||||||
) => {
|
|
||||||
const target = positions[targetId];
|
const target = positions[targetId];
|
||||||
if (!target) return 'below' as const;
|
if (!target) return 'below' as const;
|
||||||
const middle = (target.top + target.bottom) / 2;
|
const middle = (target.top + target.bottom) / 2;
|
||||||
return absoluteY < middle ? 'above' : 'below';
|
return absoluteY < middle ? 'above' : 'below';
|
||||||
},
|
}, []);
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleDragStart = useCallback(
|
const handleDragStart = useCallback(async (taskId: string) => {
|
||||||
async (taskId: string) => {
|
|
||||||
dragStateRef.current = { taskId, ...(await measureAll()) };
|
dragStateRef.current = { taskId, ...(await measureAll()) };
|
||||||
},
|
}, [measureAll]);
|
||||||
[measureAll]
|
|
||||||
);
|
|
||||||
|
|
||||||
const refreshDragPositions = useCallback(() => {
|
const refreshDragPositions = useCallback(() => {
|
||||||
measureAll().then(m => {
|
measureAll().then((m) => {
|
||||||
if (subtaskDragRef.current) {
|
if (subtaskDragRef.current) {
|
||||||
subtaskDragRef.current = { ...subtaskDragRef.current, ...m };
|
subtaskDragRef.current = { ...subtaskDragRef.current, ...m };
|
||||||
} else if (dragStateRef.current) {
|
} else if (dragStateRef.current) {
|
||||||
@@ -370,10 +293,9 @@ 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;
|
||||||
@@ -381,12 +303,9 @@ export function TaskList({
|
|||||||
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);
|
||||||
@@ -401,22 +320,16 @@ export function TaskList({
|
|||||||
} else {
|
} else {
|
||||||
// Check if below last item
|
// Check if below last item
|
||||||
const positions = state.positions;
|
const positions = state.positions;
|
||||||
const lastItem = Object.values(positions).reduce(
|
const lastItem = Object.values(positions).reduce((max, p) => p.bottom > max.bottom ? p : max, { bottom: 0 });
|
||||||
(max, p) => (p.bottom > max.bottom ? p : max),
|
|
||||||
{ bottom: 0 }
|
|
||||||
);
|
|
||||||
if (absoluteY > lastItem.bottom) {
|
if (absoluteY > lastItem.bottom) {
|
||||||
setDropIndicator({ targetId: null, position: 'below' });
|
setDropIndicator({ targetId: null, position: 'below' });
|
||||||
} else {
|
} else {
|
||||||
setDropIndicator(null);
|
setDropIndicator(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
}, [findHoverTarget, calculateDropPosition, updateHoverTarget]);
|
||||||
[findHoverTarget, calculateDropPosition, updateHoverTarget]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleDragEnd = useCallback(
|
const handleDragEnd = useCallback((absoluteY: number) => {
|
||||||
(absoluteY: number) => {
|
|
||||||
const state = dragStateRef.current;
|
const state = dragStateRef.current;
|
||||||
dragStateRef.current = null;
|
dragStateRef.current = null;
|
||||||
setHoverTaskId(null);
|
setHoverTaskId(null);
|
||||||
@@ -436,32 +349,16 @@ export function TaskList({
|
|||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
}, [findHoverTarget]);
|
||||||
[findHoverTarget]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleSubtaskDragStart = useCallback(
|
const handleSubtaskDragStart = useCallback(async (subtaskId: string, parentTaskId: string, parentSubtaskId: string | null = null) => {
|
||||||
async (subtaskId: string, parentTaskId: string, parentSubtaskId: string | null = null) => {
|
subtaskDragRef.current = { subtaskId, parentTaskId, parentSubtaskId, ...(await measureAll()) };
|
||||||
subtaskDragRef.current = {
|
}, [measureAll]);
|
||||||
subtaskId,
|
|
||||||
parentTaskId,
|
|
||||||
parentSubtaskId,
|
|
||||||
...(await measureAll()),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
[measureAll]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleSubtaskDragUpdate = useCallback(
|
const handleSubtaskDragUpdate = useCallback((absoluteY: number) => {
|
||||||
(absoluteY: number) => {
|
|
||||||
const state = subtaskDragRef.current;
|
const state = subtaskDragRef.current;
|
||||||
if (!state) return;
|
if (!state) return;
|
||||||
const target = findHoverTarget(
|
const target = findHoverTarget(absoluteY, state.subtaskId, state.positions, state.subPositions);
|
||||||
absoluteY,
|
|
||||||
state.subtaskId,
|
|
||||||
state.positions,
|
|
||||||
state.subPositions
|
|
||||||
);
|
|
||||||
updateHoverTarget(target);
|
updateHoverTarget(target);
|
||||||
if (target) {
|
if (target) {
|
||||||
if (state.subPositions[target]) {
|
if (state.subPositions[target]) {
|
||||||
@@ -472,34 +369,23 @@ export function TaskList({
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const positions = state.positions;
|
const positions = state.positions;
|
||||||
const lastItem = Object.values(positions).reduce(
|
const lastItem = Object.values(positions).reduce((max, p) => p.bottom > max.bottom ? p : max, { bottom: 0 });
|
||||||
(max, p) => (p.bottom > max.bottom ? p : max),
|
|
||||||
{ bottom: 0 }
|
|
||||||
);
|
|
||||||
if (absoluteY > lastItem.bottom) {
|
if (absoluteY > lastItem.bottom) {
|
||||||
setDropIndicator({ targetId: null, position: 'below' });
|
setDropIndicator({ targetId: null, position: 'below' });
|
||||||
} else {
|
} else {
|
||||||
setDropIndicator(null);
|
setDropIndicator(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
}, [findHoverTarget, calculateDropPosition, updateHoverTarget]);
|
||||||
[findHoverTarget, calculateDropPosition, updateHoverTarget]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleSubtaskDragEnd = useCallback(
|
const handleSubtaskDragEnd = useCallback(async (absoluteY: number) => {
|
||||||
async (absoluteY: number) => {
|
|
||||||
const state = subtaskDragRef.current;
|
const state = subtaskDragRef.current;
|
||||||
subtaskDragRef.current = null;
|
subtaskDragRef.current = null;
|
||||||
setHoverTaskId(null);
|
setHoverTaskId(null);
|
||||||
setDropIndicator(null);
|
setDropIndicator(null);
|
||||||
if (!state) return;
|
if (!state) return;
|
||||||
|
|
||||||
const target = findHoverTarget(
|
const target = findHoverTarget(absoluteY, state.subtaskId, state.positions, state.subPositions);
|
||||||
absoluteY,
|
|
||||||
state.subtaskId,
|
|
||||||
state.positions,
|
|
||||||
state.subPositions
|
|
||||||
);
|
|
||||||
if (target === state.subtaskId) return;
|
if (target === state.subtaskId) return;
|
||||||
|
|
||||||
// Only allow dropping on tasks (not subtasks) to match task behavior
|
// Only allow dropping on tasks (not subtasks) to match task behavior
|
||||||
@@ -510,20 +396,14 @@ export function TaskList({
|
|||||||
// If no target or target is a subtask: do nothing (matches task behavior)
|
// If no target or target is a subtask: do nothing (matches task behavior)
|
||||||
await moveSubtaskToTask(state.subtaskId, target);
|
await moveSubtaskToTask(state.subtaskId, target);
|
||||||
}
|
}
|
||||||
},
|
}, [findHoverTarget, moveSubtaskToTask]);
|
||||||
[findHoverTarget, moveSubtaskToTask]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleTaskDragStart = useCallback(
|
const handleTaskDragStart = useCallback(async (taskId: string) => {
|
||||||
async (taskId: string) => {
|
const positions = await measureItems() as Record<string, { top: number; bottom: number }>;
|
||||||
const positions = (await measureItems()) as Record<string, { top: number; bottom: number }>;
|
|
||||||
taskDragRef.current = { taskId, positions };
|
taskDragRef.current = { taskId, positions };
|
||||||
},
|
}, [measureItems]);
|
||||||
[measureItems]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleTaskDragUpdate = useCallback(
|
const handleTaskDragUpdate = useCallback((absoluteY: number) => {
|
||||||
(absoluteY: number) => {
|
|
||||||
const state = taskDragRef.current;
|
const state = taskDragRef.current;
|
||||||
if (!state) return;
|
if (!state) return;
|
||||||
const target = findHoverTask(absoluteY, state.taskId, state.positions);
|
const target = findHoverTask(absoluteY, state.taskId, state.positions);
|
||||||
@@ -531,22 +411,16 @@ export function TaskList({
|
|||||||
if (target) {
|
if (target) {
|
||||||
setDropIndicator({ targetId: target, position: 'below' });
|
setDropIndicator({ targetId: target, position: 'below' });
|
||||||
} else {
|
} else {
|
||||||
const lastItem = Object.values(state.positions).reduce(
|
const lastItem = Object.values(state.positions).reduce((max, p) => p.bottom > max.bottom ? p : max, { bottom: 0 });
|
||||||
(max, p) => (p.bottom > max.bottom ? p : max),
|
|
||||||
{ bottom: 0 }
|
|
||||||
);
|
|
||||||
if (absoluteY > lastItem.bottom) {
|
if (absoluteY > lastItem.bottom) {
|
||||||
setDropIndicator({ targetId: null, position: 'below' });
|
setDropIndicator({ targetId: null, position: 'below' });
|
||||||
} else {
|
} else {
|
||||||
setDropIndicator(null);
|
setDropIndicator(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
}, [findHoverTask, updateHoverTaskTarget]);
|
||||||
[findHoverTask, updateHoverTaskTarget]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleTaskDragEnd = useCallback(
|
const handleTaskDragEnd = useCallback(async (absoluteY: number) => {
|
||||||
async (absoluteY: number) => {
|
|
||||||
const state = taskDragRef.current;
|
const state = taskDragRef.current;
|
||||||
taskDragRef.current = null;
|
taskDragRef.current = null;
|
||||||
setHoverTaskId(null);
|
setHoverTaskId(null);
|
||||||
@@ -560,27 +434,20 @@ export function TaskList({
|
|||||||
if (target) {
|
if (target) {
|
||||||
setDropIndicator({ targetId: target, position: 'below' });
|
setDropIndicator({ targetId: target, position: 'below' });
|
||||||
} else {
|
} else {
|
||||||
const lastItem = Object.values(state.positions).reduce(
|
const lastItem = Object.values(state.positions).reduce((max, p) => p.bottom > max.bottom ? p : max, { bottom: 0 });
|
||||||
(max, p) => (p.bottom > max.bottom ? p : max),
|
|
||||||
{ bottom: 0 }
|
|
||||||
);
|
|
||||||
if (absoluteY > lastItem.bottom) {
|
if (absoluteY > lastItem.bottom) {
|
||||||
setDropIndicator({ targetId: null, position: 'below' });
|
setDropIndicator({ targetId: null, position: 'below' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
}, [findHoverTask]);
|
||||||
[findHoverTask]
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderItem = useCallback(
|
const renderItem = useCallback(
|
||||||
({ item, index }: { item: Task; index: number }) => {
|
({ item, index }: { item: Task; index: number }) => {
|
||||||
const isExpanded = expandedTasks.has(item.id);
|
const isExpanded = expandedTasks.has(item.id);
|
||||||
const itemSubtasks = subtasksMap.get(item.id) ?? [];
|
const itemSubtasks = subtasksMap.get(item.id) ?? [];
|
||||||
const showDropAbove =
|
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 +466,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 +523,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 +545,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 +566,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 +584,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 +668,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 +695,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}
|
||||||
@@ -918,7 +751,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,8 +35,7 @@ export function TaskNameInput({ error, ...props }: TaskNameInputProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const createStyles = (theme: ThemeColors) =>
|
const createStyles = (theme: ThemeColors) => StyleSheet.create({
|
||||||
StyleSheet.create({
|
|
||||||
container: {
|
container: {
|
||||||
gap: 5,
|
gap: 5,
|
||||||
},
|
},
|
||||||
@@ -69,4 +68,4 @@ const createStyles = (theme: ThemeColors) =>
|
|||||||
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,19 +140,13 @@ 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',
|
||||||
@@ -257,4 +190,4 @@ const createStyles = (theme: ThemeColors) =>
|
|||||||
color: theme.dangerStrong,
|
color: theme.dangerStrong,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -162,7 +148,7 @@ function WheelColumn({
|
|||||||
<FlatList
|
<FlatList
|
||||||
ref={listRef}
|
ref={listRef}
|
||||||
data={data}
|
data={data}
|
||||||
keyExtractor={item => String(item)}
|
keyExtractor={(item) => String(item)}
|
||||||
renderItem={renderItem}
|
renderItem={renderItem}
|
||||||
getItemLayout={(_data, index) => ({
|
getItemLayout={(_data, index) => ({
|
||||||
length: ITEM_HEIGHT,
|
length: ITEM_HEIGHT,
|
||||||
@@ -178,10 +164,7 @@ function WheelColumn({
|
|||||||
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,
|
|
||||||
alignItems: 'stretch',
|
|
||||||
}}
|
|
||||||
snapToAlignment="center"
|
snapToAlignment="center"
|
||||||
snapToInterval={ITEM_HEIGHT}
|
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);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);'),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -17,20 +17,8 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -162,10 +150,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 +195,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 +251,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 +260,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 +270,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 +280,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 +315,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 +327,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 +412,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 +474,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 +486,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 +498,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 +524,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 +550,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 +563,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 +579,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 +607,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 +635,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 +647,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;
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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,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,11 +43,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 {
|
||||||
@@ -147,8 +143,8 @@ export function useICalImport() {
|
|||||||
|
|
||||||
// Insert tasks into WatermelonDB
|
// Insert tasks into WatermelonDB
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
taskData.map(td =>
|
taskData.map((td) =>
|
||||||
collections.tasks.create(t => {
|
collections.tasks.create((t) => {
|
||||||
t.title = td.title;
|
t.title = td.title;
|
||||||
t.description = td.description;
|
t.description = td.description;
|
||||||
t.categoryId = td.categoryId;
|
t.categoryId = td.categoryId;
|
||||||
|
|||||||
@@ -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,8 +165,7 @@ 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,
|
||||||
@@ -174,16 +176,13 @@ 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()
|
|
||||||
.observe()
|
|
||||||
.subscribe({
|
|
||||||
next: () => {
|
next: () => {
|
||||||
compute();
|
compute();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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: () => {},
|
||||||
|
|||||||
@@ -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 “{parentSubtask.title}”</Text>
|
<Text style={styles.title}>
|
||||||
|
Add Subtask to “{parentSubtask.title}”
|
||||||
|
</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) => {
|
||||||
[]
|
|
||||||
);
|
|
||||||
const openTaskEdit = useCallback(
|
|
||||||
(taskId: string) => {
|
|
||||||
router.push({ pathname: '/task-detail', params: { id: taskId } });
|
router.push({ pathname: '/task-detail', params: { id: taskId } });
|
||||||
},
|
}, [router]);
|
||||||
[router]
|
const openSubtaskEdit = useCallback((subtaskId: string) => {
|
||||||
);
|
|
||||||
const openSubtaskEdit = useCallback(
|
|
||||||
(subtaskId: string) => {
|
|
||||||
router.push({ pathname: '/subtask-detail', params: { id: subtaskId } });
|
router.push({ pathname: '/subtask-detail', params: { id: subtaskId } });
|
||||||
},
|
}, [router]);
|
||||||
[router]
|
|
||||||
);
|
|
||||||
const openSubtaskAdd = useCallback((subtask: SubtaskData) => {
|
const openSubtaskAdd = useCallback((subtask: SubtaskData) => {
|
||||||
setAddSubtaskTarget(subtask);
|
setAddSubtaskTarget(subtask);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -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,44 +213,28 @@ 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();
|
refreshRef.current();
|
||||||
}
|
}
|
||||||
},
|
}, [picker]);
|
||||||
[picker]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleSinglePriority = useCallback(
|
const handleSinglePriority = useCallback(async (value: string | string[]) => {
|
||||||
async (value: string | string[]) => {
|
|
||||||
if (picker?.taskId) {
|
if (picker?.taskId) {
|
||||||
await setTaskPriority(
|
await setTaskPriority(picker.taskId, (Array.isArray(value) ? value[0] : value) as TaskData['priority']);
|
||||||
picker.taskId,
|
|
||||||
(Array.isArray(value) ? value[0] : value) as TaskData['priority']
|
|
||||||
);
|
|
||||||
refreshRef.current();
|
refreshRef.current();
|
||||||
}
|
}
|
||||||
},
|
}, [picker]);
|
||||||
[picker]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleSubtaskPriority = useCallback(
|
const handleSubtaskPriority = useCallback(async (value: string | string[]) => {
|
||||||
async (value: string | string[]) => {
|
|
||||||
if (subtaskPicker?.subtaskId) {
|
if (subtaskPicker?.subtaskId) {
|
||||||
await setSubtaskPriority(
|
await setSubtaskPriority(subtaskPicker.subtaskId, (Array.isArray(value) ? value[0] : value) as TaskData['priority']);
|
||||||
subtaskPicker.subtaskId,
|
|
||||||
(Array.isArray(value) ? value[0] : value) as TaskData['priority']
|
|
||||||
);
|
|
||||||
refreshRef.current();
|
refreshRef.current();
|
||||||
}
|
}
|
||||||
},
|
}, [subtaskPicker]);
|
||||||
[subtaskPicker]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleAddSubtask = useCallback(
|
const handleAddSubtask = useCallback(async (title: string) => {
|
||||||
async (title: string) => {
|
|
||||||
const target = addSubtaskTarget;
|
const target = addSubtaskTarget;
|
||||||
if (!target || !title.trim()) return;
|
if (!target || !title.trim()) return;
|
||||||
await createSubtask({
|
await createSubtask({
|
||||||
@@ -278,9 +244,7 @@ export function useTaskModals() {
|
|||||||
});
|
});
|
||||||
setAddSubtaskTarget(null);
|
setAddSubtaskTarget(null);
|
||||||
refreshRef.current();
|
refreshRef.current();
|
||||||
},
|
}, [addSubtaskTarget]);
|
||||||
[addSubtaskTarget]
|
|
||||||
);
|
|
||||||
|
|
||||||
const modals = (refresh: () => void) => {
|
const modals = (refresh: () => void) => {
|
||||||
refreshRef.current = refresh;
|
refreshRef.current = refresh;
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,13 +99,14 @@ 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()
|
||||||
|
.subscribe({
|
||||||
|
next: (result) => {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setTasks(result);
|
setTasks(result);
|
||||||
setLoading(false);
|
setLoading(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,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';
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
-201
@@ -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',
|
||||||
@@ -212,11 +162,7 @@ interface SettingsContextType {
|
|||||||
setShowCompleted: (value: boolean) => void;
|
setShowCompleted: (value: boolean) => void;
|
||||||
deleteBySwipe: boolean;
|
deleteBySwipe: boolean;
|
||||||
setDeleteBySwipe: (value: boolean) => void;
|
setDeleteBySwipe: (value: boolean) => void;
|
||||||
themeMode: ThemeMode;
|
|
||||||
setThemeMode: (value: ThemeMode) => void;
|
|
||||||
theme: ThemeColors;
|
theme: ThemeColors;
|
||||||
syncSettingsToServer: () => Promise<void>;
|
|
||||||
loadSettingsFromServer: () => Promise<void>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_TODO_AHEAD_DAYS = 7;
|
export const DEFAULT_TODO_AHEAD_DAYS = 7;
|
||||||
@@ -232,43 +178,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 +209,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>(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -420,11 +253,7 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
|
|||||||
setShowCompleted,
|
setShowCompleted,
|
||||||
deleteBySwipe,
|
deleteBySwipe,
|
||||||
setDeleteBySwipe,
|
setDeleteBySwipe,
|
||||||
themeMode,
|
|
||||||
setThemeMode,
|
|
||||||
theme,
|
theme,
|
||||||
syncSettingsToServer,
|
|
||||||
loadSettingsFromServer,
|
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
notifications,
|
notifications,
|
||||||
@@ -441,23 +270,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() {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
parentSubtaskId ? Q.where('parent_subtask_id', parentSubtaskId) : Q.where('parent_subtask_id', null)
|
||||||
? Q.where('parent_subtask_id', parentSubtaskId)
|
).fetch();
|
||||||
: 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
|
data.parentSubtaskId ? Q.where('parent_subtask_id', data.parentSubtaskId) : Q.where('parent_subtask_id', null)
|
||||||
? Q.where('parent_subtask_id', data.parentSubtaskId)
|
).fetch();
|
||||||
: 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 || '';
|
||||||
|
|||||||
Reference in New Issue
Block a user