Compare commits
30
Commits
v1.0.17
...
b8e88bca6d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8e88bca6d | ||
|
|
41eaef92b0 | ||
|
|
93738a07ac | ||
|
|
55e21c06d9 | ||
|
|
eeab4d1221 | ||
|
|
c5f129bb42 | ||
|
|
4d3d741563 | ||
|
|
95c69d35e2 | ||
|
|
bc4903e3b8 | ||
|
|
beb6cc5715 | ||
|
|
4c6adc7117 | ||
|
|
c787b87cc5 | ||
|
|
0685735c74 | ||
|
|
064650a585 | ||
|
|
bdfe53fa83 | ||
|
|
f6abf628e3 | ||
|
|
40077da57f | ||
|
|
aa57d07dc0 | ||
|
|
89c85ed7c0 | ||
|
|
ee6af8fb26 | ||
|
|
c6e441d449 | ||
|
|
5043d9cdff | ||
|
|
7e36244478 | ||
|
|
157fc8d95b | ||
|
|
4d2f92b144 | ||
|
|
8e805316fb | ||
|
|
77051e5206 | ||
|
|
7e06415db4 | ||
|
|
cfb987a305 | ||
|
|
3dad9e93ec |
@@ -0,0 +1,158 @@
|
||||
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
|
||||
|
||||
jobs:
|
||||
build-android:
|
||||
name: Build Android
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
|
||||
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: npx expo prebuild --platform android --clean
|
||||
|
||||
- name: Build Android Debug APK
|
||||
working-directory: carry-your-live/android
|
||||
run: ./gradlew assembleDebug --no-daemon
|
||||
|
||||
- name: Build Android Release APK
|
||||
working-directory: carry-your-live/android
|
||||
run: ./gradlew assembleRelease --no-daemon
|
||||
|
||||
- name: Build Android Bundle (AAB)
|
||||
working-directory: carry-your-live/android
|
||||
run: ./gradlew bundleRelease --no-daemon
|
||||
|
||||
- name: Run Unit 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 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: 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 Debug APK
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: android-debug-apk
|
||||
path: ${{ steps.find-debug-apk.outputs.apk_path }}
|
||||
retention-days: 30
|
||||
|
||||
- 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
|
||||
|
||||
- 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
|
||||
@@ -1,75 +0,0 @@
|
||||
name: Build APK
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
ANDROID_HOME: ${{ runner.tool_cache }}/android-sdk
|
||||
ANDROID_SDK_ROOT: ${{ runner.tool_cache }}/android-sdk
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Set up JDK
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
|
||||
- name: Install Android SDK
|
||||
run: |
|
||||
if [ -x "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" ]; then
|
||||
echo "Android SDK already present in cache"
|
||||
else
|
||||
rm -rf "$ANDROID_HOME/cmdline-tools"
|
||||
mkdir -p "$ANDROID_HOME/cmdline-tools"
|
||||
wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -O /tmp/cmdtools.zip
|
||||
unzip -q /tmp/cmdtools.zip -d "$ANDROID_HOME/cmdline-tools"
|
||||
mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest"
|
||||
fi
|
||||
printf 'y\n' | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses > /dev/null
|
||||
"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" \
|
||||
"platform-tools" \
|
||||
"platforms;android-35" \
|
||||
"platforms;android-36" \
|
||||
"build-tools;35.0.0" \
|
||||
"build-tools;36.0.0" \
|
||||
"ndk;27.1.12297006"
|
||||
mkdir -p carry-your-live/android/app
|
||||
echo "sdk.dir=$ANDROID_HOME" > carry-your-live/android/app/local.properties
|
||||
|
||||
- name: Install JS dependencies
|
||||
working-directory: carry-your-live
|
||||
run: npm ci
|
||||
|
||||
- name: Generate debug keystore (signs the release APK)
|
||||
run: |
|
||||
mkdir -p carry-your-live/android/app
|
||||
rm -f carry-your-live/android/app/debug.keystore
|
||||
keytool -genkeypair -v \
|
||||
-keystore carry-your-live/android/app/debug.keystore \
|
||||
-alias androiddebugkey -storepass android -keypass android \
|
||||
-keyalg RSA -keysize 2048 -validity 10000 \
|
||||
-dname "CN=Android Debug,O=Android,C=US"
|
||||
|
||||
- name: Build release APK
|
||||
run: |
|
||||
cd carry-your-live/android
|
||||
./gradlew assembleRelease
|
||||
|
||||
- name: Upload APK
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: carry-your-live-release
|
||||
path: carry-your-live/android/app/build/outputs/release/app-release.apk
|
||||
@@ -1,36 +0,0 @@
|
||||
name: Build Desktop (deb)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install JS dependencies
|
||||
working-directory: carry-your-live
|
||||
run: npm ci
|
||||
|
||||
- name: Export web bundle
|
||||
working-directory: carry-your-live
|
||||
run: npx expo export --platform web
|
||||
|
||||
- name: Build deb package
|
||||
working-directory: carry-your-live
|
||||
run: npx electron-builder --linux deb --config electron-builder.config.js
|
||||
|
||||
- name: Upload deb
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: carry-your-live-desktop-deb
|
||||
path: carry-your-live/desktop-build/*.deb
|
||||
+224
-43
@@ -1,56 +1,237 @@
|
||||
name: release
|
||||
name: Build and Release Android
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
NODE_VERSION: '20'
|
||||
JAVA_VERSION: '17'
|
||||
ANDROID_HOME: ${{ runner.tool_cache }}/android-sdk
|
||||
ANDROID_SDK_ROOT: ${{ runner.tool_cache }}/android-sdk
|
||||
GITEA_SERVER_URL: "https://techmos.gitea.org"
|
||||
|
||||
jobs:
|
||||
release:
|
||||
build:
|
||||
name: Build Android
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Compute next version
|
||||
id: version
|
||||
run: |
|
||||
latest=$(git tag --list 'v[0-9]*' --sort=-v:refname | head -n 1)
|
||||
if [ -z "$latest" ]; then
|
||||
next="v1.0.0"
|
||||
else
|
||||
latest=${latest#v}
|
||||
IFS='.' read -r major minor patch <<< "$latest"
|
||||
next="v${major}.${minor}.$((patch + 1))"
|
||||
fi
|
||||
echo "next=${next}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build changelog
|
||||
id: changelog
|
||||
run: |
|
||||
prev_tag=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
|
||||
if [ -z "$prev_tag" ]; then
|
||||
log=$(git log --oneline --no-merges main | head -n 40)
|
||||
else
|
||||
log=$(git log --oneline --no-merges "$prev_tag"..main | head -n 40)
|
||||
fi
|
||||
{
|
||||
echo "body<<EOF"
|
||||
echo "## What's new in this release"
|
||||
echo ""
|
||||
echo "$log" | sed 's/^/- /'
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create release
|
||||
uses: softprops/action-gh-release@v2
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
tag_name: ${{ steps.version.outputs.next }}
|
||||
name: ${{ steps.version.outputs.next }}
|
||||
body: ${{ steps.changelog.outputs.body }}
|
||||
generate_release_notes: false
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: 'npm'
|
||||
cache-dependency-path: carry-your-live/package-lock.json
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: ${{ env.JAVA_VERSION }}
|
||||
cache: 'gradle'
|
||||
cache-dependency-path: carry-your-live/android/gradle/wrapper/gradle-wrapper.properties
|
||||
|
||||
- name: Install Android SDK
|
||||
run: |
|
||||
if [ -x "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" ]; then
|
||||
echo "Android SDK already present in cache"
|
||||
else
|
||||
rm -rf "$ANDROID_HOME/cmdline-tools"
|
||||
mkdir -p "$ANDROID_HOME/cmdline-tools"
|
||||
wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -O /tmp/cmdtools.zip
|
||||
unzip -q /tmp/cmdtools.zip -d "$ANDROID_HOME/cmdline-tools"
|
||||
mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest"
|
||||
fi
|
||||
mkdir -p "$ANDROID_HOME/licenses"
|
||||
echo "8933bad161af4178b1185d1a37fbf41ea5269c55" > "$ANDROID_HOME/licenses/android-sdk-license"
|
||||
echo "d56f5187479451eabf01fb78af6dfcb131a6481e" >> "$ANDROID_HOME/licenses/android-sdk-license"
|
||||
echo "24333f8a63b6825ea9c5514f83c2829b004d1fee" >> "$ANDROID_HOME/licenses/android-sdk-license"
|
||||
"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" \
|
||||
"platform-tools" \
|
||||
"platforms;android-36" \
|
||||
"build-tools;36.0.0" \
|
||||
"ndk;27.1.12297006"
|
||||
mkdir -p carry-your-live/android/app
|
||||
echo "sdk.dir=$ANDROID_HOME" > carry-your-live/android/app/local.properties
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: carry-your-live
|
||||
run: |
|
||||
for i in {1..3}; do
|
||||
npm ci && break || sleep 10
|
||||
done
|
||||
|
||||
- name: Prebuild Android
|
||||
working-directory: carry-your-live
|
||||
run: npx expo prebuild --platform android --clean
|
||||
|
||||
- name: Configure Release Signing
|
||||
if: env.KEYSTORE_BASE64 != ''
|
||||
working-directory: carry-your-live/android/app
|
||||
run: |
|
||||
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > release.keystore
|
||||
cat > ../release-signing.properties <<EOF
|
||||
storeFile=release.keystore
|
||||
storePassword=${{ secrets.KEYSTORE_PASSWORD }}
|
||||
keyAlias=${{ secrets.KEY_ALIAS }}
|
||||
keyPassword=${{ secrets.KEY_PASSWORD }}
|
||||
EOF
|
||||
|
||||
- name: Build Release APK
|
||||
working-directory: carry-your-live/android
|
||||
run: |
|
||||
if [ -f "../release-signing.properties" ]; then
|
||||
./gradlew assembleRelease -PreleaseSigningProperties=../release-signing.properties --no-daemon
|
||||
else
|
||||
./gradlew assembleRelease --no-daemon
|
||||
fi
|
||||
|
||||
- name: Build Release Bundle (AAB)
|
||||
working-directory: carry-your-live/android
|
||||
run: |
|
||||
if [ -f "../release-signing.properties" ]; then
|
||||
./gradlew bundleRelease -PreleaseSigningProperties=../release-signing.properties --no-daemon
|
||||
else
|
||||
./gradlew bundleRelease --no-daemon
|
||||
fi
|
||||
|
||||
- name: Run Tests
|
||||
working-directory: carry-your-live/android
|
||||
run: ./gradlew test --no-daemon
|
||||
|
||||
- name: Run Lint
|
||||
working-directory: carry-your-live/android
|
||||
run: ./gradlew lint --no-daemon
|
||||
|
||||
- name: Find Release APK
|
||||
id: find-release-apk
|
||||
run: |
|
||||
APK_PATH=$(find carry-your-live/android/app/build/outputs -name "*release*.apk" -type f | head -n 1)
|
||||
echo "apk_path=$APK_PATH" >> "$GITHUB_OUTPUT"
|
||||
echo "Found Release APK at: $APK_PATH"
|
||||
|
||||
- name: Find Release Bundle (AAB)
|
||||
id: find-release-aab
|
||||
run: |
|
||||
AAB_PATH=$(find carry-your-live/android/app/build/outputs -name "*release*.aab" -type f | head -n 1)
|
||||
echo "aab_path=$AAB_PATH" >> "$GITHUB_OUTPUT"
|
||||
echo "Found Release AAB at: $AAB_PATH"
|
||||
|
||||
- name: Upload Build Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: android-release-build
|
||||
path: |
|
||||
${{ steps.find-release-apk.outputs.apk_path }}
|
||||
${{ steps.find-release-aab.outputs.aab_path }}
|
||||
retention-days: 90
|
||||
|
||||
- name: Upload Test Reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: android-test-reports
|
||||
path: carry-your-live/android/app/build/reports/tests/
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload Lint Reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: android-lint-reports
|
||||
path: carry-your-live/android/app/build/reports/lint-results*.html
|
||||
retention-days: 30
|
||||
|
||||
release:
|
||||
name: Create Gitea Release
|
||||
needs: build
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Download Build Artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: android-release-build
|
||||
path: artifacts
|
||||
|
||||
- name: Create Gitea Release
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
TAG_NAME="${GITHUB_REF#refs/tags/}"
|
||||
REPO_OWNER="${GITHUB_REPOSITORY%/*}"
|
||||
REPO_NAME="${GITHUB_REPOSITORY#*/}"
|
||||
|
||||
# Detect Gitea server URL
|
||||
GITEA_URL="${GITEA_SERVER_URL:-https://techmos.gitea.org}"
|
||||
|
||||
# Create release
|
||||
RESPONSE=$(curl -s -X POST "${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"tag_name\": \"${TAG_NAME}\",
|
||||
\"name\": \"${TAG_NAME}\",
|
||||
\"body\": \"Automated release for ${TAG_NAME}\",
|
||||
\"draft\": false,
|
||||
\"prerelease\": false
|
||||
}")
|
||||
|
||||
RELEASE_ID=$(echo "$RESPONSE" | jq -r '.id')
|
||||
|
||||
if [ "$RELEASE_ID" = "null" ] || [ -z "$RELEASE_ID" ]; then
|
||||
echo "Release might already exist, fetching existing release..."
|
||||
RELEASE_ID=$(curl -s -H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/tags/${TAG_NAME}" | jq -r '.id')
|
||||
fi
|
||||
|
||||
if [ "$RELEASE_ID" = "null" ] || [ -z "$RELEASE_ID" ]; then
|
||||
echo "Failed to create or find release"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Release ID: $RELEASE_ID"
|
||||
echo "release_id=$RELEASE_ID" >> "$GITHUB_OUTPUT"
|
||||
id: create-release
|
||||
|
||||
- name: Upload APK to Release
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
GITEA_URL="${GITEA_URL:-https://techmos.gitea.org}"
|
||||
REPO_OWNER="${GITHUB_REPOSITORY%/*}"
|
||||
REPO_NAME="${GITHUB_REPOSITORY#*/}"
|
||||
RELEASE_ID="${{ steps.create-release.outputs.release_id }}"
|
||||
TAG_NAME="${GITHUB_REF#refs/tags/}"
|
||||
|
||||
for file in artifacts/*; do
|
||||
if [[ "$file" == *.apk ]]; then
|
||||
FILENAME="carry-your-live-${TAG_NAME}.apk"
|
||||
elif [[ "$file" == *.aab ]]; then
|
||||
FILENAME="carry-your-live-${TAG_NAME}.aab"
|
||||
else
|
||||
continue
|
||||
fi
|
||||
|
||||
echo "Uploading $file as $FILENAME..."
|
||||
curl -s -X POST "${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}/assets" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-F "attachment=@${file};filename=${FILENAME}"
|
||||
|
||||
echo "Uploaded $FILENAME"
|
||||
done
|
||||
@@ -15,3 +15,14 @@ System architecture design, technical strategy, and scalability planning.
|
||||
- Performance benchmarks
|
||||
- Technical debt ratio
|
||||
- Architecture compliance
|
||||
|
||||
## Android Design Guidelines
|
||||
- Material Design elevation patterns (0-12dp shadows)
|
||||
- Tab bar height: 56dp (standard Android)
|
||||
- Status bar: dark style for light backgrounds, light style for dark
|
||||
- System back button: router.back() or goBack() with confirmation on home screen
|
||||
- Touch ripple: opacity scaling on press (44dp touch targets, 8dp border radius)
|
||||
- Safe area insets: account for bottom navigation bar (84px + system insets)
|
||||
- Theme: primary accent color with complementary supporting colors
|
||||
- Typography: Roboto-inspired font weights (600 for labels, 400/500 for body)
|
||||
- Scaling: 8dp grid system for spacing and margins
|
||||
@@ -1,6 +1,40 @@
|
||||
# Carry Your Live - Mobile To-Do & Calendar App
|
||||
|
||||
A minimalist, offline-first task management app built with Expo, React Native, and WatermelonDB. Features three primary screens: To-Do List, Add New Task, and Calendar with bottom navigation.
|
||||
A minimalist, offline-first task management app built with Expo, React Native, and WatermelonDB. Features multiple screens with bottom navigation, social features, and a full-featured backend API.
|
||||
|
||||
## 📝 Recent Progress (v1.1.0 - Unreleased)
|
||||
|
||||
### Added
|
||||
- **Friends feature**: Friend requests, accept/decline/remove, user search, full backend routes
|
||||
- **Assignee field on tasks**: Schema v7, model, types, forms, sync, task actions
|
||||
- **AssigneeSelector component** and assignee display in TaskItem
|
||||
- **QuickAddBar** on tasks overview (copied from calendar)
|
||||
- **Backend**: Mounted friends route, added assigneeId to task validation and sync
|
||||
|
||||
### Changed
|
||||
- Due date prefilled from Calendar "+" button
|
||||
- 12-hour time formatting across task rows and pickers
|
||||
- Calendar strip scrolls to selected day when navigating months
|
||||
- Calendar completion toggle now handles repeating tasks and reminders
|
||||
- Task color strips now use the task's category color
|
||||
|
||||
### Fixed
|
||||
- WheelTimePicker FlatList scroll clamping - all hours (0-23) and minutes (0-59) reachable
|
||||
- WheelTimePicker selection highlight background connecting selected item with colon separator
|
||||
- WheelTimePicker number alignment in scroll list
|
||||
- Tasks without a due date no longer get overdue styling
|
||||
- `useOverdueTasks` infinite re-subscription loop (Date.now in effect deps)
|
||||
- Web/Android crashes from decorator transform ordering (babel config)
|
||||
- FormButtons crash outside FormProvider; Cancel navigation fallback
|
||||
- Task repeat settings, reminders and series info now sync to backend
|
||||
|
||||
### Removed
|
||||
- Task color picker (task color now comes from its category)
|
||||
- Drag lift popup from TaskItem (scale + shadow on drag)
|
||||
- Checkbox from task items
|
||||
- Selection bar overlapping QuickAddBar on tasks overview
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
@@ -8,97 +42,145 @@ A minimalist, offline-first task management app built with Expo, React Native, a
|
||||
- **Offline-First Architecture**: Full CRUD operations work offline with WatermelonDB + SQLite
|
||||
- **Auto-Sync**: Background synchronization with custom Postgres API when online
|
||||
- **Cross-Platform**: iOS, Android, and Web from single codebase
|
||||
- **Desktop Support**: Electron wrapper for desktop app distribution
|
||||
|
||||
### Screens
|
||||
|
||||
#### 1. To-Do List Screen
|
||||
#### 1. Tasks Screen (To-Do List)
|
||||
- **Header**: App logo + "TODO" title with rounded corners
|
||||
- **Category Filter**: Horizontal scrollable chips (Work, Personal, School, Shopping, Health, Finance, All)
|
||||
- Single selection with visual feedback (thicker border, shadow, scale animation)
|
||||
- Instant task filtering
|
||||
- **Task List**: Vertical scrolling with:
|
||||
- Checkbox (empty/checked states with animation)
|
||||
- **QuickAddBar**: Quick task creation from list view
|
||||
- **Task List**: Virtualized FlatList with sections (Pending/Completed)
|
||||
- Task title (truncated with ellipsis)
|
||||
- Three-dot overflow menu (Edit, Delete, Duplicate, Mark Complete, Change Category, Change Priority)
|
||||
- Priority badges with colors
|
||||
- Due date/time with overdue/due-today highlighting
|
||||
- Completed section (collapsible)
|
||||
- **Floating Action Button**: Bottom-right, opens Add Task screen with press animation
|
||||
- Category color indicator strip
|
||||
- Assignee display
|
||||
- Swipe actions (left: delete, right: complete)
|
||||
- Drag-to-reorder with visual drop indicators
|
||||
- Long press → Selection mode (multi-delete, multi-complete, change category/priority)
|
||||
- **Floating Action Button**: Bottom-right, opens Add Task screen
|
||||
|
||||
#### 2. Add New Task Screen
|
||||
#### 2. Calendar Screen
|
||||
- **Date Strip**: Horizontal scrollable days of current month
|
||||
- Current day highlighted, scrolls to selected day on month change
|
||||
- **Month Label**: Centered (e.g., "JAN 2026")
|
||||
- **QuickAddBar**: Quick task creation for selected date
|
||||
- **Task List**: Tasks for selected day with category color, title, time, completion status
|
||||
- **Analog Clock Widget**: Decorative analog clock display
|
||||
- **Empty State**: "No tasks scheduled."
|
||||
- **Day View**: Detailed view for selected day (pushes to stack)
|
||||
|
||||
#### 3. Stats Screen
|
||||
- Client-side statistics (`useStats` hook)
|
||||
- Task completion metrics
|
||||
- Category and priority breakdowns
|
||||
|
||||
#### 4. Settings Screen
|
||||
- **Account**: Login/Register/Sign Out (SyncModal)
|
||||
- **Server URL Override**: Custom API endpoint configuration
|
||||
- **Appearance**: Accent color picker (dark mode toggle planned)
|
||||
- **Task Defaults**: Default category, sort order (Date, Priority, Alphabetically, Creation Date), show completed toggle
|
||||
- **Calendar**: Days ahead to display
|
||||
- **Reminders**: Global reminder preference (stored, wiring in progress)
|
||||
- **Notifications**: Toggle (stored, wiring in progress)
|
||||
- **Legal**: Privacy Policy, Terms of Service
|
||||
- **About**: Version info
|
||||
|
||||
#### 5. Add New Task Screen
|
||||
- **Category Selector**: Horizontal row of colored circles (required)
|
||||
- **Task Name**: Single-line input, 100 char max (required)
|
||||
- **Subtasks**: Unlimited, add/remove individually
|
||||
- **Date & Time**: Date picker, time picker, calendar button
|
||||
- **Subtasks**: Unlimited, add/remove individually, nested subtask support
|
||||
- **Date & Time**: Date picker, time picker (WheelTimePicker), calendar button
|
||||
- **Priority**: Dropdown (None, Low, Medium, High, Critical) with color indicators
|
||||
- **Repeat Profile**: Recurring task configuration
|
||||
- **Reminder**: Reminder offset selector
|
||||
- **Assignee**: Assign task to friend
|
||||
- **Description**: Multi-line text area, 1000 char max
|
||||
- **Bottom Actions**: Submit (primary) / Cancel (secondary) fixed buttons
|
||||
|
||||
#### 3. Calendar Screen
|
||||
- **Date Strip**: Horizontal scrollable days of current month
|
||||
- **Month Label**: Centered (e.g., "JAN 2026")
|
||||
- **Task List**: Tasks for selected day with category color, title, time, completion status
|
||||
- **Empty State**: "No tasks scheduled."
|
||||
#### 6. Task Detail Screen
|
||||
- Full task view with all fields
|
||||
- Subtask management (add, complete, reorder, convert to task)
|
||||
- Edit functionality
|
||||
- Repeat profile configuration
|
||||
|
||||
#### 4. Settings Screen
|
||||
- Dark Mode toggle
|
||||
- Push Notifications toggle
|
||||
- Reminder Preferences
|
||||
- Default Category selector
|
||||
- Sort Tasks By (Date, Priority, Alphabetically, Creation Date)
|
||||
- Backup & Sync
|
||||
- Version, Privacy Policy, Terms of Service
|
||||
#### 7. Subtask Detail Screen
|
||||
- Nested subtask management
|
||||
- Convert subtask ↔ task
|
||||
- Re-parent subtasks
|
||||
|
||||
### Task States
|
||||
- **Pending**: Empty checkbox, normal text
|
||||
- **Completed**: Checked checkbox, strikethrough, reduced opacity
|
||||
- **Overdue**: Red due date, warning indicator
|
||||
- **Due Today**: Blue highlight
|
||||
- **High Priority**: Priority badge
|
||||
#### 8. Sync Modal
|
||||
- Login/Register forms
|
||||
- Forgot/Reset password flow
|
||||
- Server URL configuration
|
||||
- Sync status display
|
||||
|
||||
### Interactions
|
||||
- Tap task → Edit screen
|
||||
- Long press → Selection mode (multi-delete, multi-complete, change category/priority)
|
||||
- Swipe left → Delete
|
||||
- Swipe right → Complete
|
||||
- Pull down → Refresh
|
||||
### Task Features
|
||||
- **Nested Subtasks**: Unlimited depth, re-parenting, drag reorder
|
||||
- **Repeating Tasks**: Repeat profiles, series IDs, this/future/all delete scopes, undo-complete
|
||||
- **Categories/Tags**: 6 default categories with colors, custom category editor
|
||||
- **Priorities**: None, Low, Medium, High, Critical with color badges
|
||||
- **Due Dates/Times**: With all-day option
|
||||
- **Reminders**: Per-task reminder offsets
|
||||
- **Assignees**: Assign tasks to friends
|
||||
- **Overdue/Due Today**: Visual highlighting
|
||||
|
||||
### Social Features
|
||||
- **Friends System**: Send/accept/decline/remove friend requests
|
||||
- **User Search**: Find users by username/email
|
||||
- **Task Assignment**: Assign tasks to friends (client-side, backend schema ready)
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|-------|------------|
|
||||
| Framework | Expo (React Native) |
|
||||
| Language | TypeScript |
|
||||
| Framework | Expo (React Native) / Expo Router v3 |
|
||||
| Language | TypeScript (strict mode) |
|
||||
| Navigation | Expo Router (file-based) |
|
||||
| Database | WatermelonDB + SQLite |
|
||||
| State | WatermelonDB (reactive queries, no Redux needed) |
|
||||
| Database | WatermelonDB + SQLite (expo-sqlite) |
|
||||
| State | WatermelonDB reactive queries (no Redux) |
|
||||
| Forms | React Hook Form + Zod validation |
|
||||
| UI Components | React Native Paper + Custom SVG icons |
|
||||
| Animations | React Native Reanimated |
|
||||
| Date/Time | @react-native-community/datetimepicker + date-fns |
|
||||
| Build | EAS Build / Gradle (local APK) |
|
||||
| UI Components | React Native Paper + Custom components |
|
||||
| Animations | React Native Reanimated v3 |
|
||||
| Date/Time | @react-native-community/datetimepicker + date-fns + WheelTimePicker |
|
||||
| Build | EAS Build / Gradle (local APK) / Electron Builder (desktop) |
|
||||
| CI/CD | Gitea Actions (`.gitea/workflows/build-apk.yml`) |
|
||||
| Backend | Node.js + Express + TypeScript |
|
||||
| Backend DB | PostgreSQL + Drizzle ORM |
|
||||
| Auth | JWT (access + refresh tokens) |
|
||||
| Validation | Zod (shared with frontend) |
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
carry-your-live/
|
||||
├── app/
|
||||
│ ├── _layout.tsx # Root layout + DatabaseProvider
|
||||
│ ├── _layout.tsx # Root layout + DatabaseProvider + AuthProvider
|
||||
│ ├── (tabs)/
|
||||
│ │ ├── _layout.tsx # Bottom tab navigator
|
||||
│ │ ├── index.tsx # Tasks screen
|
||||
│ │ ├── calendar.tsx # Calendar screen
|
||||
│ │ ├── stats.tsx # Stats screen
|
||||
│ │ └── settings.tsx # Settings screen
|
||||
│ └── add-task.tsx # Add Task screen
|
||||
│ ├── add-task.tsx # Add Task screen
|
||||
│ ├── day-view.tsx # Calendar day detail
|
||||
│ ├── task-detail.tsx # Task view/edit
|
||||
│ └── subtask-detail.tsx # Subtask view/edit
|
||||
├── src/
|
||||
│ ├── components/ # Reusable UI components
|
||||
│ ├── components/ # Reusable UI components (30+)
|
||||
│ │ ├── Header.tsx
|
||||
│ │ ├── CategoryFilter.tsx
|
||||
│ │ ├── TaskList.tsx
|
||||
│ │ ├── TaskItem.tsx
|
||||
│ │ ├── FloatingActionButton.tsx
|
||||
│ │ ├── TabBarIcon.tsx
|
||||
│ │ ├── CategorySelector.tsx
|
||||
│ │ ├── TaskNameInput.tsx
|
||||
│ │ ├── SubtasksSection.tsx
|
||||
@@ -106,24 +188,102 @@ carry-your-live/
|
||||
│ │ ├── PrioritySelector.tsx
|
||||
│ │ ├── DescriptionInput.tsx
|
||||
│ │ ├── FormButtons.tsx
|
||||
│ │ └── ListItem.tsx
|
||||
│ │ ├── ListItem.tsx
|
||||
│ │ ├── AnalogClock.tsx
|
||||
│ │ ├── AssigneeSelector.tsx
|
||||
│ │ ├── CategoryEditorModal.tsx
|
||||
│ │ ├── ColorPickerInput.tsx
|
||||
│ │ ├── ColorWheel.tsx
|
||||
│ │ ├── DateTimeInput.tsx
|
||||
│ │ ├── ForgotPasswordModal.tsx
|
||||
│ │ ├── FriendsModal.tsx
|
||||
│ │ ├── LegalModal.tsx
|
||||
│ │ ├── OptionPickerModal.tsx
|
||||
│ │ ├── QuickAddBar.tsx
|
||||
│ │ ├── ReminderSelector.tsx
|
||||
│ │ ├── RepeatSelector.tsx
|
||||
│ │ ├── ServerUrlModal.tsx
|
||||
│ │ ├── SubtaskItem.tsx
|
||||
│ │ ├── SyncModal.tsx
|
||||
│ │ ├── SyncStatus.tsx
|
||||
│ │ ├── TabBarIcon.tsx
|
||||
│ │ ├── TaskDeleteModal.tsx
|
||||
│ │ ├── TaskOverflowMenu.tsx
|
||||
│ │ ├── UpdateNotifier.tsx
|
||||
│ │ └── WheelTimePicker.tsx
|
||||
│ ├── constants/
|
||||
│ │ └── index.ts # Categories, priorities, colors, reminder options
|
||||
│ ├── database/
|
||||
│ │ ├── schema.ts # WatermelonDB schema
|
||||
│ │ ├── schema.ts # WatermelonDB schema (v21)
|
||||
│ │ ├── index.ts # Database instance + collections
|
||||
│ │ └── sync.ts # Custom API sync adapter
|
||||
│ ├── models/
|
||||
│ │ ├── Task.ts
|
||||
│ │ ├── Category.ts
|
||||
│ │ └── Subtask.ts
|
||||
│ │ ├── sync.ts # Custom API sync adapter (25KB)
|
||||
│ │ ├── migrations.ts # Schema migrations
|
||||
│ │ ├── adapter.ts # Platform-specific adapters
|
||||
│ │ └── tombstones.ts # Deleted record tracking
|
||||
│ ├── hooks/
|
||||
│ │ ├── useDatabase.tsx # Database context & providers
|
||||
│ │ └── useTasks.tsx # Task queries with reactive state
|
||||
│ ├── constants/
|
||||
│ │ └── index.ts # Categories, priorities, colors
|
||||
│ └── types/
|
||||
│ └── index.ts # TypeScript interfaces
|
||||
│ │ ├── useTasks.tsx # Task queries with reactive state
|
||||
│ │ ├── useCategories.tsx # Category queries
|
||||
│ │ ├── useTasksByDate.tsx # Calendar date queries
|
||||
│ │ ├── useFriends.tsx # Friends state & actions
|
||||
│ │ ├── useAuth.tsx # Authentication state
|
||||
│ │ └── useStats.tsx # Statistics computation
|
||||
│ ├── models/
|
||||
│ │ ├── Task.ts # Task model (assigneeId, repeat, reminders)
|
||||
│ │ ├── Category.ts
|
||||
│ │ ├── Subtask.ts
|
||||
│ │ ├── RepeatProfile.ts
|
||||
│ │ ├── Friendship.ts
|
||||
│ │ └── Tombstone.ts
|
||||
│ ├── services/
|
||||
│ │ ├── api.ts # API client with auth
|
||||
│ │ ├── auth.ts # Auth token management
|
||||
│ │ ├── friends.ts # Friends API calls
|
||||
│ │ ├── notifications.ts # Reminder scheduling (expo-notifications)
|
||||
│ │ ├── repeat.ts # Repeat task generation
|
||||
│ │ └── taskActions.ts # Task CRUD + sync operations
|
||||
│ ├── theme/
|
||||
│ │ ├── index.ts # Theme context, colors, spacing
|
||||
│ │ └── theme.tsx # Accent color picker, settings sync
|
||||
│ ├── types/
|
||||
│ │ └── index.ts # TypeScript interfaces
|
||||
│ └── utils/
|
||||
│ ├── date.ts # Date formatting utilities
|
||||
│ ├── format.ts # General formatting
|
||||
│ └── validation.ts # Zod schemas
|
||||
├── backend/ # Backend API (separate package)
|
||||
│ ├── src/
|
||||
│ │ ├── index.ts # Express app entry
|
||||
│ │ ├── db/
|
||||
│ │ │ ├── schema.ts # Drizzle schema (Postgres)
|
||||
│ │ │ └── index.ts # DB connection
|
||||
│ │ ├── routes/
|
||||
│ │ │ ├── auth.ts # Register, login, me, forgot/reset password
|
||||
│ │ │ ├── categories.ts # Category CRUD
|
||||
│ │ │ ├── tasks.ts # Task CRUD + batch
|
||||
│ │ │ ├── subtasks.ts # Subtask CRUD
|
||||
│ │ │ ├── sync.ts # Pull/push sync endpoints
|
||||
│ │ │ ├── users.ts # User profile & settings
|
||||
│ │ │ ├── friends.ts # Friend requests, search, management
|
||||
│ │ │ ├── repeatProfiles.ts# Repeat profile CRUD
|
||||
│ │ │ └── pushNotifications.ts
|
||||
│ │ ├── middleware/
|
||||
│ │ │ ├── auth.ts # JWT verification
|
||||
│ │ │ ├── errorHandler.ts
|
||||
│ │ │ └── validation.ts # Zod validation middleware
|
||||
│ │ ├── services/
|
||||
│ │ └── utils/
|
||||
│ ├── drizzle.config.ts
|
||||
│ └── Dockerfile
|
||||
├── electron/ # Desktop app entry
|
||||
├── android/ # Native Android project
|
||||
├── assets/ # Images, fonts
|
||||
├── patches/ # patch-package fixes
|
||||
└── plugins/ # Expo config plugins
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data Models
|
||||
|
||||
### Task
|
||||
@@ -135,8 +295,14 @@ carry-your-live/
|
||||
categoryId: string;
|
||||
priority: 'none' | 'low' | 'medium' | 'high' | 'critical';
|
||||
completed: boolean;
|
||||
dueDate: number; // timestamp
|
||||
dueTime: string; // "HH:MM"
|
||||
dueDate: number; // timestamp (ms)
|
||||
dueTime: string; // "HH:MM" (24h stored, 12h displayed)
|
||||
allDay: boolean;
|
||||
assigneeId: string | null;
|
||||
repeatProfileId: string | null;
|
||||
seriesId: string | null; // For repeating tasks
|
||||
isTemplate: boolean; // Series template
|
||||
reminderOffset: string | null; // e.g., "5m", "1h", "1d"
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
subtasks: Subtask[];
|
||||
@@ -150,6 +316,8 @@ carry-your-live/
|
||||
name: string;
|
||||
color: '#HEX';
|
||||
order: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -158,14 +326,47 @@ carry-your-live/
|
||||
{
|
||||
id: string;
|
||||
taskId: string;
|
||||
parentSubtaskId: string | null; // For nesting
|
||||
title: string;
|
||||
completed: boolean;
|
||||
order: number;
|
||||
assigneeId: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
```
|
||||
|
||||
### Repeat Profile
|
||||
```typescript
|
||||
{
|
||||
id: string;
|
||||
name: string;
|
||||
frequency: 'daily' | 'weekly' | 'monthly' | 'yearly' | 'custom';
|
||||
interval: number;
|
||||
daysOfWeek: number[]; // 0-6 for weekly
|
||||
dayOfMonth: number | null; // For monthly
|
||||
monthOfYear: number | null; // For yearly
|
||||
endDate: number | null; // Timestamp
|
||||
occurrences: number | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
```
|
||||
|
||||
### Friendship
|
||||
```typescript
|
||||
{
|
||||
id: string;
|
||||
userId: string; // Requester
|
||||
friendId: string; // Recipient
|
||||
status: 'pending' | 'accepted' | 'declined' | 'blocked';
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Default Categories
|
||||
| Name | Color | Order |
|
||||
|------|-------|-------|
|
||||
@@ -176,6 +377,8 @@ carry-your-live/
|
||||
| Health | #8E24AA (Purple) | 4 |
|
||||
| Finance | #FDD835 (Yellow) | 5 |
|
||||
|
||||
---
|
||||
|
||||
## Priority Colors
|
||||
| Priority | Color |
|
||||
|----------|-------|
|
||||
@@ -185,6 +388,8 @@ carry-your-live/
|
||||
| High | #E53935 |
|
||||
| Critical | #B71C1C |
|
||||
|
||||
---
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
@@ -192,15 +397,23 @@ carry-your-live/
|
||||
- npm or yarn
|
||||
- Expo CLI (`npm install -g expo-cli`)
|
||||
- iOS Simulator (Mac) / Android Studio for mobile testing
|
||||
- PostgreSQL 16+ (or Docker)
|
||||
|
||||
### Installation
|
||||
```bash
|
||||
cd carry-your-live
|
||||
npm install
|
||||
|
||||
cd ../backend
|
||||
npm install
|
||||
```
|
||||
|
||||
### Development
|
||||
|
||||
#### Frontend
|
||||
```bash
|
||||
cd carry-your-live
|
||||
|
||||
# Start Expo dev server
|
||||
npx expo start
|
||||
|
||||
@@ -210,6 +423,37 @@ npx expo start --android
|
||||
npx expo start --web
|
||||
```
|
||||
|
||||
#### Backend
|
||||
```bash
|
||||
cd backend
|
||||
|
||||
# Start PostgreSQL (Docker)
|
||||
docker-compose up -d postgres
|
||||
|
||||
# Configure environment
|
||||
cp .env.example .env
|
||||
# Edit .env with your settings
|
||||
|
||||
# Run migrations
|
||||
npm run db:push
|
||||
|
||||
# Start dev server
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Server runs at `http://localhost:3000` (health check: `GET /health`)
|
||||
|
||||
#### Desktop (Electron)
|
||||
```bash
|
||||
cd carry-your-live
|
||||
|
||||
# Development with live reload
|
||||
npm run desktop:dev
|
||||
|
||||
# Production build
|
||||
npm run desktop:dist
|
||||
```
|
||||
|
||||
### Building
|
||||
|
||||
#### EAS Build (cloud)
|
||||
@@ -225,18 +469,16 @@ eas build --platform ios
|
||||
eas build --platform android
|
||||
eas build --platform web
|
||||
```
|
||||
Profiles are defined in `eas.json` (`development`, `preview`, `production`; production auto-increments version code).
|
||||
Profiles defined in `eas.json` (`development`, `preview`, `production`; production auto-increments version code).
|
||||
|
||||
#### Gradle (local Android APK)
|
||||
The native Android project lives in `carry-your-live/android/`. Requires JDK 17+ and the Android SDK (platform 35, build-tools 35.0.0, NDK 27.1).
|
||||
|
||||
```bash
|
||||
cd carry-your-live/android
|
||||
|
||||
# Debug APK (unsigned)
|
||||
./gradlew assembleDebug
|
||||
|
||||
# Release APK (currently signed with the debug keystore)
|
||||
# Release APK (signed with debug keystore)
|
||||
./gradlew assembleRelease
|
||||
```
|
||||
Output:
|
||||
@@ -244,24 +486,21 @@ Output:
|
||||
android/app/build/outputs/apk/debug/app-debug.apk
|
||||
android/app/build/outputs/apk/release/app-release.apk
|
||||
```
|
||||
Key notes:
|
||||
- The debug keystore (`android/app/debug.keystore`) is generated by the CI pipeline and must exist for release builds — create it locally with the same command used in the pipeline (see below) if it's missing.
|
||||
- Release builds use `signingConfig signingConfigs.debug` until a production keystore is configured.
|
||||
- Versions are set in `android/app/build.gradle` (`versionCode`, `versionName`).
|
||||
|
||||
---
|
||||
|
||||
## CI/CD Pipeline (Gitea Actions)
|
||||
|
||||
`.gitea/workflows/build-apk.yml` builds a release APK on every push to `main` (also manually triggerable via workflow_dispatch):
|
||||
`.gitea/workflows/build-apk.yml` builds a release APK on every push to `main` (also manually triggerable via `workflow_dispatch`):
|
||||
|
||||
1. **Checkout** and setup Node 22, JDK 17 (Temurin)
|
||||
2. **Android SDK**: installs cmdline-tools, licenses, platform-tools, `platforms;android-35`, `build-tools;35.0.0`, `ndk;27.1.12297006`
|
||||
3. **JS deps**: `npm ci` in `carry-your-live/`
|
||||
4. **Debug keystore**: generates `carry-your-live/android/app/debug.keystore` with `keytool` (alias `androiddebugkey`, passwords `android`)
|
||||
4. **Debug keystore**: generates `carry-your-live/android/app/debug.keystore` with `keytool`
|
||||
5. **Build**: `./gradlew assembleRelease` in `carry-your-live/android/`
|
||||
6. **Upload**: the APK is saved as the `carry-your-live-release` artifact (downloadable from the run's artifacts page)
|
||||
|
||||
To run the build locally exactly as CI does:
|
||||
6. **Upload**: APK saved as `carry-your-live-release` artifact
|
||||
|
||||
To run locally exactly as CI does:
|
||||
```bash
|
||||
keytool -genkeypair -v \
|
||||
-keystore carry-your-live/android/app/debug.keystore \
|
||||
@@ -271,48 +510,119 @@ keytool -genkeypair -v \
|
||||
cd carry-your-live/android && ./gradlew assembleRelease
|
||||
```
|
||||
|
||||
## Sync API Specification
|
||||
---
|
||||
|
||||
The app expects a custom API at `http://localhost:3000/api` (configurable in `src/database/sync.ts`):
|
||||
## Backend API Specification
|
||||
|
||||
### Pull Changes
|
||||
```
|
||||
GET /sync?since={timestamp}
|
||||
```
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"categories": [{ "id", "name", "color", "order", "createdAt", "updatedAt" }],
|
||||
"tasks": [{ "id", "title", "description", "categoryId", "priority", "completed", "dueDate", "dueTime", "createdAt", "updatedAt" }],
|
||||
"subtasks": [{ "id", "taskId", "title", "completed", "order", "createdAt", "updatedAt" }],
|
||||
Server runs at `http://localhost:3000/api` (configurable in `src/services/api.ts` and `src/database/sync.ts`):
|
||||
|
||||
### Authentication
|
||||
- `POST /api/auth/register` - Register new user
|
||||
- `POST /api/auth/login` - Login (returns access + refresh tokens)
|
||||
- `POST /api/auth/refresh` - Refresh access token
|
||||
- `POST /api/auth/forgot-password` - Request password reset
|
||||
- `POST /api/auth/reset-password` - Reset password with token
|
||||
- `GET /api/auth/me` - Get current user
|
||||
|
||||
### Categories
|
||||
- `GET /api/categories` - List categories
|
||||
- `POST /api/categories` - Create category
|
||||
- `PATCH /api/categories/:id` - Update category
|
||||
- `DELETE /api/categories/:id` - Delete category
|
||||
|
||||
### Tasks
|
||||
- `GET /api/tasks` - List tasks (with filters: category, completed, due date range, assignee)
|
||||
- `GET /api/tasks/:id` - Get task with subtasks
|
||||
- `POST /api/tasks` - Create task
|
||||
- `PATCH /api/tasks/:id` - Update task
|
||||
- `DELETE /api/tasks/:id` - Delete task (scopes: this/future/all for repeating)
|
||||
- `POST /api/tasks/batch` - Batch operations
|
||||
|
||||
### Subtasks
|
||||
- `GET /api/subtasks/task/:taskId` - List subtasks for task
|
||||
- `POST /api/subtasks/task/:taskId` - Create subtask
|
||||
- `PATCH /api/subtasks/:id` - Update subtask
|
||||
- `DELETE /api/subtasks/:id` - Delete subtask
|
||||
|
||||
### Repeat Profiles
|
||||
- `GET /api/repeat-profiles` - List repeat profiles
|
||||
- `POST /api/repeat-profiles` - Create repeat profile
|
||||
- `PATCH /api/repeat-profiles/:id` - Update repeat profile
|
||||
- `DELETE /api/repeat-profiles/:id` - Delete repeat profile
|
||||
|
||||
### Users
|
||||
- `GET /api/users/me` - Get user with settings
|
||||
- `PATCH /api/users/me/settings` - Update settings (darkMode, defaultCategoryId, sortOrder, etc.)
|
||||
- `GET /api/users/search` - Search users (for friends)
|
||||
|
||||
### Friends
|
||||
- `GET /api/friends` - List accepted friends
|
||||
- `GET /api/friends/requests` - List pending requests (incoming/outgoing)
|
||||
- `POST /api/friends/request` - Send friend request
|
||||
- `PATCH /api/friends/request/:id/accept` - Accept request
|
||||
- `PATCH /api/friends/request/:id/decline` - Decline request
|
||||
- `DELETE /api/friends/:id` - Remove friend
|
||||
|
||||
### Sync (Offline-first)
|
||||
- `GET /api/sync?since=<timestamp>` - Pull changes since timestamp
|
||||
- `POST /api/sync/push` - Push local changes
|
||||
|
||||
---
|
||||
|
||||
## Sync Protocol
|
||||
|
||||
The sync endpoint uses a cursor-based approach with tombstones:
|
||||
|
||||
1. **Pull**: Client sends `since` timestamp, server returns all changes since then
|
||||
```json
|
||||
{
|
||||
"categories": [...],
|
||||
"tasks": [...],
|
||||
"subtasks": [...],
|
||||
"repeatProfiles": [...],
|
||||
"friendships": [...],
|
||||
"tombstones": [...],
|
||||
"timestamp": 1234567890
|
||||
}
|
||||
```
|
||||
}
|
||||
```
|
||||
|
||||
### Push Changes
|
||||
```
|
||||
POST /sync/push
|
||||
```
|
||||
Body:
|
||||
```json
|
||||
{
|
||||
2. **Push**: Client sends batched changes with `lastPulledAt`
|
||||
```json
|
||||
{
|
||||
"changes": {
|
||||
"categories": [...],
|
||||
"tasks": [...],
|
||||
"subtasks": [...]
|
||||
"subtasks": [...],
|
||||
"repeatProfiles": [...],
|
||||
"friendships": [...]
|
||||
},
|
||||
"tombstones": [...],
|
||||
"lastPulledAt": 1234567890
|
||||
}
|
||||
```
|
||||
}
|
||||
```
|
||||
|
||||
3. **Conflict Resolution**: Last-write-wins based on `updatedAt` (customizable in `src/database/sync.ts`)
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
Create `.env` file:
|
||||
### Frontend Environment Variables
|
||||
Create `.env` file in `carry-your-live/`:
|
||||
```
|
||||
EXPO_PUBLIC_API_URL=http://your-api-url/api
|
||||
```
|
||||
|
||||
### Backend Environment Variables
|
||||
Create `.env` file in `backend/`:
|
||||
```
|
||||
DATABASE_URL=postgresql://user:pass@localhost:5432/carry_your_live
|
||||
JWT_SECRET=your-32-char-secret
|
||||
PORT=3000
|
||||
NODE_ENV=development
|
||||
FRONTEND_URL=http://localhost:8081
|
||||
```
|
||||
|
||||
### App Config
|
||||
Modify `app.json` for:
|
||||
- App name, slug, version
|
||||
@@ -320,10 +630,12 @@ Modify `app.json` for:
|
||||
- Permissions (notifications, etc.)
|
||||
- Orientation, theme
|
||||
|
||||
---
|
||||
|
||||
## Visual Style Guide
|
||||
|
||||
- **Background**: White (#FFFFFF)
|
||||
- **Primary Accent**: #E53935 (Pink/Red)
|
||||
- **Background**: White (#FFFFFF) / Dark mode (planned)
|
||||
- **Primary Accent**: #E53935 (Pink/Red) - customizable
|
||||
- **Typography**: Black (#212121) primary, #757575 secondary, #9E9E9E tertiary
|
||||
- **Borders**: Thin (#E0E0E0), selected #E53935
|
||||
- **Radius**: 16-20px for cards/buttons/inputs
|
||||
@@ -331,11 +643,13 @@ Modify `app.json` for:
|
||||
- **Shadows**: Minimal (elevation 1-3)
|
||||
- **Animations**: 150-250ms, cubic easing
|
||||
|
||||
---
|
||||
|
||||
## Offline-First Architecture
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
|
||||
│ Expo App │────▶│ WatermelonDB │────▶│ Your API │
|
||||
│ Expo App │────▶│ WatermelonDB │────▶│ Backend │
|
||||
│ (iOS/Android│ │ (SQLite) │ │ (Postgres) │
|
||||
│ Web) │◀───▶│ + Sync │◀───▶│ │
|
||||
└─────────────┘ └──────────────┘ └─────────────┘
|
||||
@@ -345,6 +659,79 @@ Modify `app.json` for:
|
||||
2. Reactive queries update UI instantly
|
||||
3. Background sync pushes/pulls changes
|
||||
4. Conflict resolution: last-write-wins (customizable)
|
||||
5. Tombstones track deletions for sync
|
||||
|
||||
---
|
||||
|
||||
## Current Implementation Status
|
||||
|
||||
Based on code audit (August 2026):
|
||||
|
||||
| Feature | Status | Notes |
|
||||
|---------|--------|-------|
|
||||
| Core CRUD (tasks, subtasks, categories) | ✅ Complete | |
|
||||
| Nested subtasks | ✅ Complete | Unlimited depth |
|
||||
| Repeating tasks | ✅ Complete | Profiles, series, this/future/all delete |
|
||||
| Categories & priorities | ✅ Complete | |
|
||||
| Calendar with date strip | ✅ Complete | Month nav, day detail |
|
||||
| Stats screen | ✅ Complete | Client-side only |
|
||||
| Settings screen | ✅ Complete | Most settings wired |
|
||||
| Friends system | ✅ Complete | Backend + UI |
|
||||
| Task assignees | ✅ Complete | Schema v7, UI, sync |
|
||||
| QuickAddBar | ✅ Complete | Tasks + Calendar |
|
||||
| Offline sync (WatermelonDB) | ✅ Complete | Schema v21, tombstones |
|
||||
| Backend API | ✅ Complete | Express + Drizzle + Postgres |
|
||||
| Auth (JWT) | ✅ Complete | Register, login, refresh, forgot/reset |
|
||||
| Dark mode / theme toggle | ❌ Missing | Backend column exists, UI not implemented |
|
||||
| Notifications toggle | 🟡 Partial | Stored but not respected |
|
||||
| Global reminder preference | 🟡 Partial | Stored but not applied |
|
||||
| Forgot/reset password UI | 🟡 Partial | Backend done, no client UI |
|
||||
| Pull-to-refresh → sync | 🟡 Partial | Reloads local only |
|
||||
| Client settings sync | 🟡 Partial | AsyncStorage only, not synced to backend |
|
||||
| Reminder enum mismatch | 🟡 Partial | Client uses '5m'/'1h', spec uses minutes |
|
||||
|
||||
---
|
||||
|
||||
## Roadmap Highlights
|
||||
|
||||
### v1.1.0 — Friends & Assignees (Current Unreleased)
|
||||
- Ship existing Unreleased changes
|
||||
- Fix notifications toggle, wire up `rescheduleAllReminders()`
|
||||
- Apply global reminder preference
|
||||
- Make pull-to-refresh trigger real sync
|
||||
- Add forgot/reset password UI
|
||||
- Unify reminder offset enums
|
||||
|
||||
### v1.2.0 — Theming & Findability
|
||||
- Light mode + follow-system-theme
|
||||
- Sync all settings to `PATCH /users/me/settings`
|
||||
- Task search (filter + global)
|
||||
- Friend request badges/indicators
|
||||
|
||||
### v1.3.0 — Stats & Insights
|
||||
- Persisted stats snapshots
|
||||
- Productivity trends, streaks
|
||||
- Export stats (CSV/image)
|
||||
|
||||
### v1.4.0 — Quick Capture & Calendar UX
|
||||
- Natural language quick-add
|
||||
- Drag-and-drop rescheduling in calendar
|
||||
- Month grid view
|
||||
- "Today at a glance" dashboard
|
||||
|
||||
### v1.5.0 — Power Tools & Personalization
|
||||
- Task templates, soft-archive, attachments
|
||||
- Markdown support, deep links
|
||||
- Biometric lock, haptics, reduced motion
|
||||
- Android home-screen widget
|
||||
|
||||
### v2.0.0 — Collaboration
|
||||
- Real task assignment delivery
|
||||
- Shared lists with permissions
|
||||
- Server push notifications (FCM/APNs)
|
||||
- Comments/activity feed
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
@@ -353,6 +740,9 @@ Modify `app.json` for:
|
||||
3. Write components with React Hook Form + Zod
|
||||
4. Use WatermelonDB decorators for models
|
||||
5. Test offline scenarios
|
||||
6. Run `npm run lint` and `npm run typecheck` before committing
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -104,15 +104,27 @@ android {
|
||||
keyAlias 'androiddebugkey'
|
||||
keyPassword 'android'
|
||||
}
|
||||
|
||||
release {
|
||||
if (project.hasProperty('releaseSigningProperties')) {
|
||||
def propsFile = file(project.property('releaseSigningProperties'))
|
||||
if (propsFile.exists()) {
|
||||
def props = new Properties()
|
||||
props.load(new FileInputStream(propsFile))
|
||||
storeFile = file(props['storeFile'])
|
||||
storePassword = props['storePassword']
|
||||
keyAlias = props['keyAlias']
|
||||
keyPassword = props['keyPassword']
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
debug {
|
||||
signingConfig = signingConfigs.debug
|
||||
}
|
||||
release {
|
||||
// Caution! In production, you need to generate your own keystore file.
|
||||
// see https://reactnative.dev/docs/signed-apk-android.
|
||||
signingConfig = signingConfigs.debug
|
||||
signingConfig = signingConfigs.release
|
||||
def enableShrinkResources = findProperty('android.enableShrinkResourcesInReleaseBuilds') ?: 'false'
|
||||
shrinkResources = enableShrinkResources.toBoolean()
|
||||
minifyEnabled enableMinifyInReleaseBuilds
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
# Specifies the JVM arguments used for the daemon process.
|
||||
# The setting is particularly useful for tweaking memory settings.
|
||||
# Default value: -Xmx512m -XX:MaxMetaspaceSize=256m
|
||||
org.gradle.jvmargs=-Xmx1536m -XX:MaxMetaspaceSize=384m --add-opens=java.base/java.lang=ALL-UNNAMED --add-opens=java.base/java.io=ALL-UNNAMED
|
||||
org.gradle.jvmargs=-Xmx1536m -XX:MaxMetaspaceSize=1024m --add-opens=java.base/java.lang=ALL-UNNAMED --add-opens=java.base/java.io=ALL-UNNAMED
|
||||
org.gradle.workers.max=2
|
||||
|
||||
# When configured, Gradle will run in incubating parallel mode.
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
gradlePluginPortal()
|
||||
}
|
||||
def reactNativeGradlePlugin = new File(
|
||||
providers.exec {
|
||||
workingDir(rootDir)
|
||||
|
||||
@@ -34,7 +34,9 @@
|
||||
"color": "#1E88E5"
|
||||
}
|
||||
],
|
||||
"./plugins/withQuickAddWidget"
|
||||
"./plugins/withQuickAddWidget",
|
||||
"expo-router",
|
||||
"expo-sqlite"
|
||||
],
|
||||
"extra": {
|
||||
"eas": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Tabs } from 'expo-router';
|
||||
import React from 'react';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { View, StyleSheet, TouchableOpacity } from 'react-native';
|
||||
import { View, StyleSheet, TouchableOpacity, Animated } from 'react-native';
|
||||
import { TabBarIcon } from '@/components/TabBarIcon';
|
||||
import { useSettings } from '@/theme';
|
||||
import { setLastVisitedTab } from '@/utils/tabHistory';
|
||||
@@ -18,28 +18,29 @@ export default function TabLayout() {
|
||||
tabBarStyle: {
|
||||
backgroundColor: theme.tabBarBg,
|
||||
borderTopWidth: 0,
|
||||
height: 84 + insets.bottom,
|
||||
height: 56,
|
||||
paddingBottom: insets.bottom,
|
||||
paddingTop: 12,
|
||||
paddingTop: insets.top,
|
||||
paddingHorizontal: 16,
|
||||
elevation: 0,
|
||||
elevation: 4,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: -4 },
|
||||
shadowOpacity: 0.12,
|
||||
shadowRadius: 20,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 8,
|
||||
},
|
||||
tabBarItemStyle: {
|
||||
paddingVertical: 0,
|
||||
paddingVertical: 6,
|
||||
paddingHorizontal: 12,
|
||||
},
|
||||
tabBarLabelStyle: {
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
marginTop: 4,
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
marginTop: 2,
|
||||
},
|
||||
headerShown: false,
|
||||
sceneStyle: { backgroundColor: theme.background },
|
||||
}}
|
||||
tabBar={(props) => {
|
||||
tabBar={props => {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
@@ -48,34 +49,49 @@ export default function TabLayout() {
|
||||
backgroundColor: theme.tabBarBg,
|
||||
paddingBottom: Math.max(props.insets.bottom, 8),
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: -4 },
|
||||
shadowOpacity: 0.12,
|
||||
shadowRadius: 20,
|
||||
elevation: 8,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 8,
|
||||
elevation: 4,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{props.navigation.getState().routes.map((route) => {
|
||||
const focused = props.navigation.getState().index === props.navigation.getState().routes.indexOf(route);
|
||||
{props.navigation.getState().routes.map(route => {
|
||||
const focused =
|
||||
props.navigation.getState().index ===
|
||||
props.navigation.getState().routes.indexOf(route);
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={route.name}
|
||||
style={[
|
||||
styles.tabItem,
|
||||
focused && styles.tabItemActive,
|
||||
{ backgroundColor: focused ? theme.accentSoft : 'transparent' },
|
||||
]}
|
||||
onPress={() => props.navigation.navigate(route.name as never)}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={route.name === 'index' ? 'ToDo' : route.name.charAt(0).toUpperCase() + route.name.slice(1)}
|
||||
accessibilityLabel={
|
||||
route.name === 'index'
|
||||
? 'ToDo'
|
||||
: route.name.charAt(0).toUpperCase() + route.name.slice(1)
|
||||
}
|
||||
accessibilityState={{ selected: focused }}
|
||||
>
|
||||
<Animated.View style={[styles.rippleContainer, { opacity: focused ? 1 : 0.5 }]}>
|
||||
<TabBarIcon
|
||||
name={route.name === 'index' ? 'checklist' : route.name === 'calendar' ? 'calendar' : route.name === 'stats' ? 'stats' : 'gear'}
|
||||
name={
|
||||
route.name === 'index'
|
||||
? 'checklist'
|
||||
: route.name === 'calendar'
|
||||
? 'calendar'
|
||||
: route.name === 'stats'
|
||||
? 'stats'
|
||||
: 'gear'
|
||||
}
|
||||
focused={focused}
|
||||
color={focused ? theme.accent : theme.textSecondary}
|
||||
size={24}
|
||||
/>
|
||||
</Animated.View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
@@ -103,22 +119,23 @@ const styles = StyleSheet.create({
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-around',
|
||||
paddingTop: 10,
|
||||
paddingTop: 6,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
tabItem: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 8,
|
||||
borderRadius: 16,
|
||||
paddingVertical: 6,
|
||||
paddingHorizontal: 12,
|
||||
borderRadius: 8,
|
||||
minWidth: 70,
|
||||
},
|
||||
tabItemActive: {
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 8,
|
||||
elevation: 4,
|
||||
rippleContainer: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
position: 'relative',
|
||||
},
|
||||
});
|
||||
@@ -1,5 +1,15 @@
|
||||
import React, { useMemo, useRef, useState, useCallback, useEffect } from 'react';
|
||||
import { View, Text, StyleSheet, SafeAreaView, ScrollView, TouchableOpacity, Animated, Dimensions, KeyboardAvoidingView } from 'react-native';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
SafeAreaView,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
Animated,
|
||||
Dimensions,
|
||||
KeyboardAvoidingView,
|
||||
} from 'react-native';
|
||||
import { useRouter, useFocusEffect } from 'expo-router';
|
||||
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
||||
import { Header } from '@/components/Header';
|
||||
@@ -15,7 +25,15 @@ import { OptionPickerModal } from '@/components/OptionPickerModal';
|
||||
import { TaskItem } from '@/components/TaskItem';
|
||||
import { SubtaskItem } from '@/components/SubtaskItem';
|
||||
import Task from '@/models/Task';
|
||||
import { format, addMonths, addDays, startOfMonth, isSameDay, isSameMonth, isToday } from 'date-fns';
|
||||
import {
|
||||
format,
|
||||
addMonths,
|
||||
addDays,
|
||||
startOfMonth,
|
||||
isSameDay,
|
||||
isSameMonth,
|
||||
isToday,
|
||||
} from 'date-fns';
|
||||
import Svg, { Path } from 'react-native-svg';
|
||||
import type { ThemeColors } from '@/theme';
|
||||
import type { TaskData, SubtaskData } from '@/types';
|
||||
@@ -24,8 +42,18 @@ const WIDTH = Dimensions.get('window').width;
|
||||
const GERMAN_WEEKDAYS = ['mo', 'di', 'mi', 'do', 'fr', 'sa', 'so'];
|
||||
const PRIORITY_RANK: Record<string, number> = { none: 0, low: 1, medium: 2, high: 3, critical: 4 };
|
||||
const MONTH_NAMES = [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December',
|
||||
'January',
|
||||
'February',
|
||||
'March',
|
||||
'April',
|
||||
'May',
|
||||
'June',
|
||||
'July',
|
||||
'August',
|
||||
'September',
|
||||
'October',
|
||||
'November',
|
||||
'December',
|
||||
];
|
||||
|
||||
export default function CalendarScreen() {
|
||||
@@ -70,7 +98,9 @@ export default function CalendarScreen() {
|
||||
const sorted = [...selectedDayTasks];
|
||||
switch (sortBy) {
|
||||
case 'date':
|
||||
sorted.sort((a, b) => (a.dueDate || Number.MAX_SAFE_INTEGER) - (b.dueDate || Number.MAX_SAFE_INTEGER));
|
||||
sorted.sort(
|
||||
(a, b) => (a.dueDate || Number.MAX_SAFE_INTEGER) - (b.dueDate || Number.MAX_SAFE_INTEGER)
|
||||
);
|
||||
break;
|
||||
case 'priority':
|
||||
sorted.sort((a, b) => PRIORITY_RANK[b.priority] - PRIORITY_RANK[a.priority]);
|
||||
@@ -118,10 +148,18 @@ export default function CalendarScreen() {
|
||||
animatingRef.current = true;
|
||||
const w = gridWidthRef.current || WIDTH;
|
||||
const target = dir === 1 ? -w : w;
|
||||
Animated.timing(translateX.current, { toValue: target, duration: 220, useNativeDriver: false }).start(() => {
|
||||
Animated.timing(translateX.current, {
|
||||
toValue: target,
|
||||
duration: 220,
|
||||
useNativeDriver: false,
|
||||
}).start(() => {
|
||||
setVisibleMonth(next);
|
||||
translateX.current.setValue(-target);
|
||||
Animated.timing(translateX.current, { toValue: 0, duration: 180, useNativeDriver: false }).start(() => {
|
||||
Animated.timing(translateX.current, {
|
||||
toValue: 0,
|
||||
duration: 180,
|
||||
useNativeDriver: false,
|
||||
}).start(() => {
|
||||
animatingRef.current = false;
|
||||
});
|
||||
});
|
||||
@@ -148,11 +186,11 @@ export default function CalendarScreen() {
|
||||
.minDistance(6)
|
||||
.runOnJS(true)
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
.onUpdate((e) => {
|
||||
.onUpdate(e => {
|
||||
if (!animatingRef.current) translateX.current.setValue(e.translationX);
|
||||
})
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
.onEnd((e) => {
|
||||
.onEnd(e => {
|
||||
if (animatingRef.current) return;
|
||||
const w = gridWidthRef.current || WIDTH;
|
||||
const dx = e.translationX;
|
||||
@@ -183,22 +221,26 @@ export default function CalendarScreen() {
|
||||
const monthOptions = MONTH_NAMES.map((label, i) => ({ value: String(i), label }));
|
||||
const yearOptions = useMemo(() => {
|
||||
const options: { value: string; label: string }[] = [];
|
||||
for (let y = currentYear - 20; y <= currentYear + 10; y++) options.push({ value: String(y), label: String(y) });
|
||||
for (let y = currentYear - 20; y <= currentYear + 10; y++)
|
||||
options.push({ value: String(y), label: String(y) });
|
||||
return options;
|
||||
}, [currentYear]);
|
||||
|
||||
const handleToggleComplete = useCallback(async (taskId: string) => {
|
||||
const handleToggleComplete = useCallback(
|
||||
async (taskId: string) => {
|
||||
await confirmAndToggleTaskComplete(taskId, () => {
|
||||
refreshDayTasks();
|
||||
refreshMonthTasks();
|
||||
refreshSubtasks();
|
||||
});
|
||||
}, [refreshDayTasks, refreshMonthTasks, refreshSubtasks]);
|
||||
},
|
||||
[refreshDayTasks, refreshMonthTasks, refreshSubtasks]
|
||||
);
|
||||
|
||||
const [expandedTasks, setExpandedTasks] = useState<Set<string>>(new Set());
|
||||
|
||||
const toggleExpand = useCallback((taskId: string) => {
|
||||
setExpandedTasks((prev) => {
|
||||
setExpandedTasks(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(taskId)) {
|
||||
next.delete(taskId);
|
||||
@@ -209,12 +251,15 @@ export default function CalendarScreen() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSubtaskToggle = useCallback(async (subtaskId: string) => {
|
||||
const handleSubtaskToggle = useCallback(
|
||||
async (subtaskId: string) => {
|
||||
await toggleSubtaskComplete(subtaskId);
|
||||
refreshDayTasks();
|
||||
refreshMonthTasks();
|
||||
refreshSubtasks();
|
||||
}, [refreshDayTasks, refreshMonthTasks, refreshSubtasks]);
|
||||
},
|
||||
[refreshDayTasks, refreshMonthTasks, refreshSubtasks]
|
||||
);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
|
||||
@@ -229,17 +274,28 @@ export default function CalendarScreen() {
|
||||
accessibilityLabel="Back"
|
||||
>
|
||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||
<Path d="M15 18l-6-6 6-6" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
<Path
|
||||
d="M15 18l-6-6 6-6"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
</Svg>
|
||||
</TouchableOpacity>
|
||||
}
|
||||
/>
|
||||
|
||||
<KeyboardAvoidingView style={styles.kbAvoid} behavior="padding">
|
||||
<ScrollView style={styles.scrollBody} showsVerticalScrollIndicator={false} contentContainerStyle={styles.scrollContent}>
|
||||
<ScrollView
|
||||
style={styles.scrollBody}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
>
|
||||
<GestureDetector gesture={pan}>
|
||||
<Animated.View
|
||||
onLayout={(e) => {
|
||||
onLayout={e => {
|
||||
gridWidthRef.current = e.nativeEvent.layout.width;
|
||||
}}
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
@@ -248,34 +304,73 @@ export default function CalendarScreen() {
|
||||
<View style={styles.monthRow}>
|
||||
<TouchableOpacity
|
||||
onPress={() => transitionTo(-1)}
|
||||
style={[styles.monthNav, { borderColor: theme.borderStrong, backgroundColor: theme.card }]}
|
||||
style={[
|
||||
styles.monthNav,
|
||||
{ borderColor: theme.borderStrong, backgroundColor: theme.card },
|
||||
]}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||
<Path d="M15 18l-6-6 6-6" stroke={theme.textSecondary} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
<Path
|
||||
d="M15 18l-6-6 6-6"
|
||||
stroke={theme.textSecondary}
|
||||
strokeWidth={2.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
</Svg>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.monthSelectorGroup}>
|
||||
<TouchableOpacity onPress={() => setMonthPickerVisible(true)} activeOpacity={0.7} style={styles.monthButton}>
|
||||
<Text style={[styles.monthLabel, { color: theme.text }]}>{format(visibleMonth, 'MMMM')}</Text>
|
||||
<TouchableOpacity
|
||||
onPress={() => setMonthPickerVisible(true)}
|
||||
activeOpacity={0.7}
|
||||
style={styles.monthButton}
|
||||
>
|
||||
<Text style={[styles.monthLabel, { color: theme.text }]}>
|
||||
{format(visibleMonth, 'MMMM')}
|
||||
</Text>
|
||||
<Svg width={14} height={14} viewBox="0 0 24 24">
|
||||
<Path d="M6 9l6 6 6-6" stroke={theme.textSecondary} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
<Path
|
||||
d="M6 9l6 6 6-6"
|
||||
stroke={theme.textSecondary}
|
||||
strokeWidth={2.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
</Svg>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity onPress={() => setYearPickerVisible(true)} activeOpacity={0.7} style={styles.yearButton}>
|
||||
<Text style={[styles.yearLabel, { color: theme.textMuted }]}>{format(visibleMonth, 'yyyy')}</Text>
|
||||
<TouchableOpacity
|
||||
onPress={() => setYearPickerVisible(true)}
|
||||
activeOpacity={0.7}
|
||||
style={styles.yearButton}
|
||||
>
|
||||
<Text style={[styles.yearLabel, { color: theme.textMuted }]}>
|
||||
{format(visibleMonth, 'yyyy')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={() => transitionTo(1)}
|
||||
style={[styles.monthNav, { borderColor: theme.borderStrong, backgroundColor: theme.card }]}
|
||||
style={[
|
||||
styles.monthNav,
|
||||
{ borderColor: theme.borderStrong, backgroundColor: theme.card },
|
||||
]}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||
<Path d="M9 6l6 6-6 6" stroke={theme.textSecondary} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
<Path
|
||||
d="M9 6l6 6-6 6"
|
||||
stroke={theme.textSecondary}
|
||||
strokeWidth={2.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
</Svg>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
@@ -290,7 +385,7 @@ export default function CalendarScreen() {
|
||||
|
||||
{weeks.map((week, wi) => (
|
||||
<View key={wi} style={styles.weekRow}>
|
||||
{week.map((day) => (
|
||||
{week.map(day => (
|
||||
<DayCell
|
||||
key={day.toISOString()}
|
||||
day={day}
|
||||
@@ -309,7 +404,9 @@ export default function CalendarScreen() {
|
||||
</GestureDetector>
|
||||
|
||||
<View style={styles.panelHeader}>
|
||||
<Text style={[styles.panelDate, { color: theme.text }]}>{format(selectedDate, 'EEEE, MMMM d')}</Text>
|
||||
<Text style={[styles.panelDate, { color: theme.text }]}>
|
||||
{format(selectedDate, 'EEEE, MMMM d')}
|
||||
</Text>
|
||||
{sortedDayTasks.length > 0 && (
|
||||
<Text style={[styles.panelCount, { color: theme.textFaint }]}>
|
||||
{sortedDayTasks.length} event{sortedDayTasks.length === 1 ? '' : 's'}
|
||||
@@ -319,11 +416,15 @@ export default function CalendarScreen() {
|
||||
|
||||
{sortedDayTasks.length === 0 ? (
|
||||
<View style={styles.emptyState}>
|
||||
<Text style={[styles.emptyText, { color: theme.textSecondary }]}>No events on this day</Text>
|
||||
<Text style={[styles.emptySubtext, { color: theme.textMuted }]}>Tap a date or use the bar below</Text>
|
||||
<Text style={[styles.emptyText, { color: theme.textSecondary }]}>
|
||||
No events on this day
|
||||
</Text>
|
||||
<Text style={[styles.emptySubtext, { color: theme.textMuted }]}>
|
||||
Tap a date or use the bar below
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
sortedDayTasks.map((task) => {
|
||||
sortedDayTasks.map(task => {
|
||||
const isExpanded = expandedTasks.has(task.id);
|
||||
const itemSubtasks = subtasksByTask.get(task.id) ?? [];
|
||||
return (
|
||||
@@ -334,12 +435,12 @@ export default function CalendarScreen() {
|
||||
subtasks={itemSubtasks}
|
||||
categoryColor={categoryColors.get(task.categoryId)}
|
||||
onToggle={() => handleToggleComplete(task.id)}
|
||||
onPress={(id) =>
|
||||
onPress={id =>
|
||||
expandedTasks.has(id)
|
||||
? router.push({ pathname: '/task-detail', params: { id } })
|
||||
: toggleExpand(id)
|
||||
}
|
||||
onMenuOpen={(id) => openTaskEdit(id)}
|
||||
onMenuOpen={id => openTaskEdit(id)}
|
||||
onSubtaskToggle={handleSubtaskToggle}
|
||||
/>
|
||||
</View>
|
||||
@@ -419,7 +520,7 @@ const CalendarTaskRow = React.memo(function CalendarTaskRow({
|
||||
/>
|
||||
{expanded && sortedSubtasks.length > 0 && (
|
||||
<View style={styles.subtaskList}>
|
||||
{sortedSubtasks.map((sub) => (
|
||||
{sortedSubtasks.map(sub => (
|
||||
<SubtaskItem
|
||||
key={sub.id}
|
||||
subtask={sub}
|
||||
@@ -440,7 +541,11 @@ function cellTitle(tasks: Task[]): string | null {
|
||||
return `${tasks[0].title} +${tasks.length - 1}`;
|
||||
}
|
||||
|
||||
function cellColor(tasks: Task[], categories: { id: string; color: string }[], theme: ThemeColors): string {
|
||||
function cellColor(
|
||||
tasks: Task[],
|
||||
categories: { id: string; color: string }[],
|
||||
theme: ThemeColors
|
||||
): string {
|
||||
if (tasks.length === 0) return theme.textMuted;
|
||||
return theme.accent;
|
||||
}
|
||||
@@ -456,11 +561,23 @@ interface DayCellProps {
|
||||
onPress: (day: Date) => void;
|
||||
}
|
||||
|
||||
const DayCell = React.memo(function DayCell({ day, label, dotColor, selected, today, inMonth, theme, onPress }: DayCellProps) {
|
||||
const DayCell = React.memo(function DayCell({
|
||||
day,
|
||||
label,
|
||||
dotColor,
|
||||
selected,
|
||||
today,
|
||||
inMonth,
|
||||
theme,
|
||||
onPress,
|
||||
}: DayCellProps) {
|
||||
const styles = React.useMemo(() => createStyles(theme), [theme]);
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.dayCell, selected && { backgroundColor: theme.accentSoft, borderColor: theme.accentBorder }]}
|
||||
style={[
|
||||
styles.dayCell,
|
||||
selected && { backgroundColor: theme.accentSoft, borderColor: theme.accentBorder },
|
||||
]}
|
||||
onPress={() => onPress(day)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
@@ -488,7 +605,8 @@ const DayCell = React.memo(function DayCell({ day, label, dotColor, selected, to
|
||||
);
|
||||
});
|
||||
|
||||
const createStyles = (theme: ThemeColors) => StyleSheet.create({
|
||||
const createStyles = (theme: ThemeColors) =>
|
||||
StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
@@ -636,4 +754,4 @@ const createStyles = (theme: ThemeColors) => StyleSheet.create({
|
||||
paddingRight: 4,
|
||||
paddingTop: 4,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { View, Text, StyleSheet, SafeAreaView, KeyboardAvoidingView, TouchableOpacity, BackHandler } from 'react-native';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
SafeAreaView,
|
||||
KeyboardAvoidingView,
|
||||
TouchableOpacity,
|
||||
BackHandler,
|
||||
} from 'react-native';
|
||||
import { useFocusEffect } from 'expo-router';
|
||||
import { Header } from '@/components/Header';
|
||||
import { CategoryFilter } from '@/components/CategoryFilter';
|
||||
@@ -39,7 +47,10 @@ export default function TasksScreen() {
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.completedToggle,
|
||||
{ backgroundColor: theme.card, borderColor: showCompleted ? theme.accent : theme.borderStrong },
|
||||
{
|
||||
backgroundColor: theme.card,
|
||||
borderColor: showCompleted ? theme.accent : theme.borderStrong,
|
||||
},
|
||||
]}
|
||||
onPress={() => setShowCompleted(!showCompleted)}
|
||||
activeOpacity={0.8}
|
||||
@@ -57,18 +68,27 @@ export default function TasksScreen() {
|
||||
fill="none"
|
||||
/>
|
||||
{showCompleted && (
|
||||
<Path d="M7 12.5l3.5 3.5 6.5-7" stroke={theme.accent} strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
<Path
|
||||
d="M7 12.5l3.5 3.5 6.5-7"
|
||||
stroke={theme.accent}
|
||||
strokeWidth={2.2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
)}
|
||||
</Svg>
|
||||
<Text style={[styles.completedToggleText, { color: showCompleted ? theme.text : theme.textMuted }]}>
|
||||
<Text
|
||||
style={[
|
||||
styles.completedToggleText,
|
||||
{ color: showCompleted ? theme.text : theme.textMuted },
|
||||
]}
|
||||
>
|
||||
Completed
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<KeyboardAvoidingView
|
||||
style={styles.kbAvoid}
|
||||
behavior="padding"
|
||||
>
|
||||
<KeyboardAvoidingView style={styles.kbAvoid} behavior="padding">
|
||||
<TaskList categoryIds={categoryIds} showCompleted={showCompleted} />
|
||||
<QuickAddBar />
|
||||
</KeyboardAvoidingView>
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { View, Text, StyleSheet, SafeAreaView, ScrollView, Switch, TouchableOpacity, Alert, Linking, Modal, Pressable, BackHandler } from 'react-native';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
SafeAreaView,
|
||||
ScrollView,
|
||||
Switch,
|
||||
TouchableOpacity,
|
||||
Alert,
|
||||
Linking,
|
||||
Modal,
|
||||
Pressable,
|
||||
BackHandler,
|
||||
} from 'react-native';
|
||||
import { useFocusEffect, useRouter } from 'expo-router';
|
||||
import { Header } from '@/components/Header';
|
||||
import { ListItem } from '@/components/ListItem';
|
||||
@@ -10,7 +23,15 @@ import { FriendsModal } from '@/components/FriendsModal';
|
||||
import { LegalModal } from '@/components/LegalModal';
|
||||
import { ServerUrlModal } from '@/components/ServerUrlModal';
|
||||
import SyncStatus from '@/components/SyncStatus';
|
||||
import { useSettings, SORT_OPTIONS, REMINDER_OPTIONS, ACCENT_PRESETS, DEFAULT_ACCENT } from '@/theme';
|
||||
import {
|
||||
useSettings,
|
||||
SORT_OPTIONS,
|
||||
REMINDER_OPTIONS,
|
||||
ACCENT_PRESETS,
|
||||
DEFAULT_ACCENT,
|
||||
DEFAULT_ACCENT_OPACITY,
|
||||
ThemeMode,
|
||||
} from '@/theme';
|
||||
import { useCategories } from '@/hooks/useDatabase';
|
||||
import { getAuthUser, getAuthToken } from '@/services/auth';
|
||||
import { checkForUpdates, getCurrentAppVersion } from '@/services/updates';
|
||||
@@ -24,9 +45,38 @@ import { getLastVisitedTab, tabHref } from '@/utils/tabHistory';
|
||||
|
||||
export default function SettingsScreen() {
|
||||
const router = useRouter();
|
||||
const { theme, notifications, setNotifications, defaultCategoryId, setDefaultCategoryId, sortBy, setSortBy, reminderPreference, setReminderPreference, tagReminderPreferences, setTagReminderPreferences, apiUrl, accentColor, setAccentColor, accentOpacity, setAccentOpacity, todoAheadDays, setTodoAheadDays, showCompleted, setShowCompleted, deleteBySwipe, setDeleteBySwipe } = useSettings();
|
||||
const {
|
||||
theme,
|
||||
notifications,
|
||||
setNotifications,
|
||||
defaultCategoryId,
|
||||
setDefaultCategoryId,
|
||||
sortBy,
|
||||
setSortBy,
|
||||
reminderPreference,
|
||||
setReminderPreference,
|
||||
tagReminderPreferences,
|
||||
setTagReminderPreferences,
|
||||
apiUrl,
|
||||
accentColor,
|
||||
setAccentColor,
|
||||
accentOpacity,
|
||||
setAccentOpacity,
|
||||
todoAheadDays,
|
||||
setTodoAheadDays,
|
||||
showCompleted,
|
||||
setShowCompleted,
|
||||
deleteBySwipe,
|
||||
setDeleteBySwipe,
|
||||
themeMode,
|
||||
setThemeMode,
|
||||
syncSettingsToServer,
|
||||
loadSettingsFromServer,
|
||||
} = useSettings();
|
||||
const categories = useCategories();
|
||||
const [picker, setPicker] = useState<null | 'category' | 'sort' | 'reminder' | 'todoAhead'>(null);
|
||||
const [picker, setPicker] = useState<
|
||||
null | 'category' | 'sort' | 'reminder' | 'todoAhead' | 'themeMode'
|
||||
>(null);
|
||||
const [reminderTagPicker, setReminderTagPicker] = useState<Category | null>(null);
|
||||
const [editingCategory, setEditingCategory] = useState<Category | null | 'new'>(null);
|
||||
const [syncVisible, setSyncVisible] = useState(false);
|
||||
@@ -38,16 +88,19 @@ export default function SettingsScreen() {
|
||||
const [draftAccentOpacity, setDraftAccentOpacity] = useState(accentOpacity);
|
||||
const [syncSubtitle, setSyncSubtitle] = useState('Checking...');
|
||||
const [updateSubtitle, setUpdateSubtitle] = useState('Tap to check');
|
||||
const [lastUpdateCheck, setLastUpdateCheck] = useState<string | null>(null);
|
||||
|
||||
const handleCheckUpdates = async () => {
|
||||
setUpdateSubtitle('Checking...');
|
||||
const update = await checkForUpdates();
|
||||
if (!update) {
|
||||
setUpdateSubtitle('Up to date');
|
||||
setLastUpdateCheck(new Date().toLocaleString());
|
||||
Alert.alert('Up to date', `You're running the latest version (${getCurrentAppVersion()}).`);
|
||||
return;
|
||||
}
|
||||
setUpdateSubtitle(`${update.version} available`);
|
||||
setLastUpdateCheck(new Date().toLocaleString());
|
||||
const url = update.apkUrl ?? update.releaseUrl;
|
||||
Alert.alert('Update available', `Version ${update.version} is available for download.`, [
|
||||
{ text: 'Later', style: 'cancel' },
|
||||
@@ -60,6 +113,17 @@ export default function SettingsScreen() {
|
||||
]);
|
||||
};
|
||||
|
||||
const checkForUpdatesOnLoad = async () => {
|
||||
const update = await checkForUpdates();
|
||||
if (!update) {
|
||||
setUpdateSubtitle('Up to date');
|
||||
setLastUpdateCheck(new Date().toLocaleString());
|
||||
return;
|
||||
}
|
||||
setUpdateSubtitle(`${update.version} available`);
|
||||
setLastUpdateCheck(new Date().toLocaleString());
|
||||
};
|
||||
|
||||
const refreshSyncStatus = async () => {
|
||||
const token = await getAuthToken();
|
||||
if (!token) {
|
||||
@@ -70,7 +134,9 @@ export default function SettingsScreen() {
|
||||
const lastSync = await getLastSyncTime();
|
||||
if (user) {
|
||||
setSyncSubtitle(
|
||||
lastSync ? `Signed in · synced ${formatSyncTime(lastSync)}` : `Signed in as ${user.username}`
|
||||
lastSync
|
||||
? `Signed in · synced ${formatSyncTime(lastSync)}`
|
||||
: `Signed in as ${user.username}`
|
||||
);
|
||||
} else {
|
||||
setSyncSubtitle('Not signed in');
|
||||
@@ -84,6 +150,8 @@ export default function SettingsScreen() {
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
refreshSyncStatus();
|
||||
checkForUpdatesOnLoad();
|
||||
const sub = BackHandler.addEventListener('hardwareBackPress', () => {
|
||||
const target = getLastVisitedTab();
|
||||
if (target && target !== 'settings') {
|
||||
@@ -92,19 +160,18 @@ export default function SettingsScreen() {
|
||||
return true;
|
||||
});
|
||||
return () => sub.remove();
|
||||
}, [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 reminderLabel = REMINDER_OPTIONS.find((o) => o.value === reminderPreference)?.label ?? 'No reminder';
|
||||
const reminderLabel =
|
||||
REMINDER_OPTIONS.find(o => o.value === reminderPreference)?.label ?? 'No reminder';
|
||||
|
||||
const tagReminderLabel = (categoryId: string) => {
|
||||
const selected = tagReminderPreferences[categoryId] ?? [];
|
||||
if (selected.length === 0) return `Default (${reminderLabel})`;
|
||||
return selected
|
||||
.map((v) => REMINDER_OPTIONS.find((o) => o.value === v)?.label ?? v)
|
||||
.join(', ');
|
||||
return selected.map(v => REMINDER_OPTIONS.find(o => o.value === v)?.label ?? v).join(', ');
|
||||
};
|
||||
|
||||
const openCustomAccent = () => {
|
||||
@@ -146,14 +213,12 @@ export default function SettingsScreen() {
|
||||
<Header title="Settings" showLogo={false} />
|
||||
<ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.textMuted }]}>Tags</Text>
|
||||
{categories.map((category) => (
|
||||
{categories.map(category => (
|
||||
<ListItem
|
||||
key={category.id}
|
||||
title={category.name}
|
||||
subtitle={category.id === defaultCategoryId ? 'Default tag' : undefined}
|
||||
leftElement={
|
||||
<View style={[styles.categoryDot, { backgroundColor: category.color }]} />
|
||||
}
|
||||
leftElement={<View style={[styles.categoryDot, { backgroundColor: category.color }]} />}
|
||||
onPress={() => setEditingCategory(category)}
|
||||
showChevron
|
||||
/>
|
||||
@@ -165,7 +230,12 @@ export default function SettingsScreen() {
|
||||
>
|
||||
<View style={[styles.addIcon, { backgroundColor: theme.accentSoft }]}>
|
||||
<Svg width={16} height={16} viewBox="0 0 24 24">
|
||||
<Path d="M12 5v14M5 12h14" stroke={theme.accentText} strokeWidth={2} strokeLinecap="round" />
|
||||
<Path
|
||||
d="M12 5v14M5 12h14"
|
||||
stroke={theme.accentText}
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</Svg>
|
||||
</View>
|
||||
<Text style={[styles.addCategoryText, { color: theme.accent }]}>Add Tag</Text>
|
||||
@@ -177,7 +247,18 @@ export default function SettingsScreen() {
|
||||
rightElement={
|
||||
<Switch
|
||||
value={notifications}
|
||||
onValueChange={setNotifications}
|
||||
onValueChange={async value => {
|
||||
setNotifications(value);
|
||||
if (!value) {
|
||||
const { cancelAllReminders } = await import('@/services/notifications');
|
||||
await cancelAllReminders();
|
||||
} else {
|
||||
const { rescheduleAllReminders } = await import('@/services/notifications');
|
||||
const { collections } = await import('@/database');
|
||||
const tasks = await collections.tasks.query().fetch();
|
||||
await rescheduleAllReminders(tasks, true, reminderPreference);
|
||||
}
|
||||
}}
|
||||
thumbColor="#FFFFFF"
|
||||
trackColor={{ false: theme.borderStrong, true: theme.accent }}
|
||||
/>
|
||||
@@ -189,14 +270,12 @@ export default function SettingsScreen() {
|
||||
onPress={() => notifications && setPicker('reminder')}
|
||||
showChevron
|
||||
/>
|
||||
{categories.map((category) => (
|
||||
{categories.map(category => (
|
||||
<ListItem
|
||||
key={`tag-reminder-${category.id}`}
|
||||
title={category.name}
|
||||
subtitle={notifications ? tagReminderLabel(category.id) : 'Enable notifications first'}
|
||||
leftElement={
|
||||
<View style={[styles.categoryDot, { backgroundColor: category.color }]} />
|
||||
}
|
||||
leftElement={<View style={[styles.categoryDot, { backgroundColor: category.color }]} />}
|
||||
onPress={() => notifications && setReminderTagPicker(category)}
|
||||
showChevron
|
||||
/>
|
||||
@@ -210,7 +289,7 @@ export default function SettingsScreen() {
|
||||
/>
|
||||
<ListItem
|
||||
title="Sort Tasks By"
|
||||
subtitle={SORT_OPTIONS.find((o) => o.value === sortBy)?.label ?? 'Date'}
|
||||
subtitle={SORT_OPTIONS.find(o => o.value === sortBy)?.label ?? 'Date'}
|
||||
onPress={() => setPicker('sort')}
|
||||
showChevron
|
||||
/>
|
||||
@@ -248,7 +327,7 @@ export default function SettingsScreen() {
|
||||
<Text style={[styles.sectionTitle, { color: theme.textMuted }]}>Appearance</Text>
|
||||
<Text style={[styles.sectionHint, { color: theme.textFaint }]}>Accent color</Text>
|
||||
<View style={styles.accentPresets}>
|
||||
{ACCENT_PRESETS.map((c) => (
|
||||
{ACCENT_PRESETS.map(c => (
|
||||
<TouchableOpacity
|
||||
key={c}
|
||||
style={[
|
||||
@@ -261,7 +340,14 @@ export default function SettingsScreen() {
|
||||
>
|
||||
{accentColor.toUpperCase() === c && (
|
||||
<Svg width={16} height={16} viewBox="0 0 24 24">
|
||||
<Path d="M5 12l5 5 9-10" stroke={contrastOnSwatch(c)} strokeWidth={3} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
<Path
|
||||
d="M5 12l5 5 9-10"
|
||||
stroke={contrastOnSwatch(c)}
|
||||
strokeWidth={3}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
</Svg>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
@@ -274,6 +360,15 @@ export default function SettingsScreen() {
|
||||
onPress={openCustomAccent}
|
||||
showChevron
|
||||
/>
|
||||
<Text style={[styles.sectionHint, { color: theme.textFaint }]}>Theme</Text>
|
||||
<ListItem
|
||||
title="Theme Mode"
|
||||
subtitle={
|
||||
themeMode === 'system' ? 'System default' : themeMode === 'dark' ? 'Dark' : 'Light'
|
||||
}
|
||||
onPress={() => setPicker('themeMode')}
|
||||
showChevron
|
||||
/>
|
||||
<Text style={[styles.sectionTitle, { color: theme.textMuted }]}>Data</Text>
|
||||
<SyncStatus />
|
||||
<ListItem
|
||||
@@ -282,6 +377,32 @@ export default function SettingsScreen() {
|
||||
onPress={() => setSyncVisible(true)}
|
||||
showChevron
|
||||
/>
|
||||
<ListItem
|
||||
title="Sync Settings to Server"
|
||||
subtitle="Push local settings to backend"
|
||||
onPress={async () => {
|
||||
try {
|
||||
await syncSettingsToServer();
|
||||
Alert.alert('Success', 'Settings synced to server');
|
||||
} catch {
|
||||
Alert.alert('Error', 'Failed to sync settings');
|
||||
}
|
||||
}}
|
||||
showChevron
|
||||
/>
|
||||
<ListItem
|
||||
title="Load Settings from Server"
|
||||
subtitle="Pull settings from backend"
|
||||
onPress={async () => {
|
||||
try {
|
||||
await loadSettingsFromServer();
|
||||
Alert.alert('Success', 'Settings loaded from server');
|
||||
} catch {
|
||||
Alert.alert('Error', 'Failed to load settings');
|
||||
}
|
||||
}}
|
||||
showChevron
|
||||
/>
|
||||
<ListItem
|
||||
title="Friends"
|
||||
subtitle="Manage friends and requests"
|
||||
@@ -302,26 +423,19 @@ export default function SettingsScreen() {
|
||||
onPress={handleCheckUpdates}
|
||||
showChevron
|
||||
/>
|
||||
<ListItem
|
||||
title="Version"
|
||||
subtitle={getCurrentAppVersion()}
|
||||
/>
|
||||
<ListItem
|
||||
title="Privacy Policy"
|
||||
onPress={() => setLegalVisible('privacy')}
|
||||
showChevron
|
||||
/>
|
||||
<ListItem
|
||||
title="Terms of Service"
|
||||
onPress={() => setLegalVisible('terms')}
|
||||
showChevron
|
||||
/>
|
||||
<ListItem title="Last checked" subtitle={lastUpdateCheck ?? 'Never'} />
|
||||
<ListItem title="Version" subtitle={getCurrentAppVersion()} />
|
||||
<ListItem title="Privacy Policy" onPress={() => setLegalVisible('privacy')} showChevron />
|
||||
<ListItem title="Terms of Service" onPress={() => setLegalVisible('terms')} showChevron />
|
||||
</ScrollView>
|
||||
|
||||
<OptionPickerModal
|
||||
visible={picker === 'category'}
|
||||
title="Default Tag"
|
||||
options={[{ value: '', label: 'None', color: theme.textMuted }, ...categories.map((c) => ({ value: c.id, label: c.name, color: c.color }))]}
|
||||
options={[
|
||||
{ value: '', label: 'None', color: theme.textMuted },
|
||||
...categories.map(c => ({ value: c.id, label: c.name, color: c.color })),
|
||||
]}
|
||||
selectedValue={defaultCategoryId}
|
||||
onSelect={handleDefaultCategory}
|
||||
onClose={() => setPicker(null)}
|
||||
@@ -332,7 +446,7 @@ export default function SettingsScreen() {
|
||||
title="Sort Tasks By"
|
||||
options={SORT_OPTIONS}
|
||||
selectedValue={sortBy}
|
||||
onSelect={(value) => setSortBy(value as typeof sortBy)}
|
||||
onSelect={value => setSortBy(value as typeof sortBy)}
|
||||
onClose={() => setPicker(null)}
|
||||
/>
|
||||
|
||||
@@ -345,15 +459,15 @@ export default function SettingsScreen() {
|
||||
onClose={() => setPicker(null)}
|
||||
/>
|
||||
|
||||
{categories.map((category) => (
|
||||
{categories.map(category => (
|
||||
<OptionPickerModal
|
||||
key={`tag-reminder-picker-${category.id}`}
|
||||
visible={reminderTagPicker?.id === category.id}
|
||||
title={`Reminders · ${category.name}`}
|
||||
options={REMINDER_OPTIONS.filter((o) => o.value !== 'none')}
|
||||
options={REMINDER_OPTIONS.filter(o => o.value !== 'none')}
|
||||
selectedValue={tagReminderPreferences[category.id] ?? []}
|
||||
multiSelect
|
||||
onSelect={(value) => handleTagReminderPreference(category.id, value)}
|
||||
onSelect={value => handleTagReminderPreference(category.id, value)}
|
||||
onClose={() => setReminderTagPicker(null)}
|
||||
/>
|
||||
))}
|
||||
@@ -367,6 +481,15 @@ export default function SettingsScreen() {
|
||||
onClose={() => setPicker(null)}
|
||||
/>
|
||||
|
||||
<OptionPickerModal
|
||||
visible={picker === 'themeMode'}
|
||||
title="Theme Mode"
|
||||
options={THEME_MODE_OPTIONS}
|
||||
selectedValue={themeMode}
|
||||
onSelect={value => setThemeMode(value as ThemeMode)}
|
||||
onClose={() => setPicker(null)}
|
||||
/>
|
||||
|
||||
<CategoryEditorModal
|
||||
visible={editorVisible}
|
||||
category={editorCategory}
|
||||
@@ -374,41 +497,86 @@ export default function SettingsScreen() {
|
||||
onClose={() => setEditingCategory(null)}
|
||||
/>
|
||||
|
||||
<SyncModal visible={syncVisible} onClose={() => { setSyncVisible(false); refreshSyncStatus(); }} />
|
||||
<SyncModal
|
||||
visible={syncVisible}
|
||||
onClose={() => {
|
||||
setSyncVisible(false);
|
||||
refreshSyncStatus();
|
||||
}}
|
||||
/>
|
||||
|
||||
<FriendsModal visible={friendsVisible} onClose={() => setFriendsVisible(false)} />
|
||||
|
||||
<ServerUrlModal visible={serverUrlVisible} onClose={() => setServerUrlVisible(false)} />
|
||||
|
||||
<Modal visible={customAccentVisible} transparent animationType="fade" onRequestClose={() => setCustomAccentVisible(false)}>
|
||||
<Modal
|
||||
visible={customAccentVisible}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={() => setCustomAccentVisible(false)}
|
||||
>
|
||||
<Pressable style={styles.accentModalOverlay} onPress={() => setCustomAccentVisible(false)}>
|
||||
<Pressable
|
||||
style={styles.accentModalOverlay}
|
||||
onPress={() => setCustomAccentVisible(false)}
|
||||
style={[styles.accentModalSheet, { backgroundColor: theme.sheetBg }]}
|
||||
onPress={() => {}}
|
||||
>
|
||||
<Pressable style={[styles.accentModalSheet, { backgroundColor: theme.sheetBg }]} onPress={() => {}}>
|
||||
<View style={styles.accentModalHeader}>
|
||||
<Text style={[styles.accentModalTitle, { color: theme.text }]}>Custom Accent Color</Text>
|
||||
<TouchableOpacity onPress={() => setCustomAccentVisible(false)} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
|
||||
<Text style={[styles.accentModalTitle, { color: theme.text }]}>
|
||||
Custom Accent Color
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
onPress={() => setCustomAccentVisible(false)}
|
||||
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
||||
>
|
||||
<Svg width={18} height={18} viewBox="0 0 24 24">
|
||||
<Path d="M18 6L6 18M6 6l12 12" stroke={theme.textSecondary} strokeWidth={2.2} strokeLinecap="round" />
|
||||
<Path
|
||||
d="M18 6L6 18M6 6l12 12"
|
||||
stroke={theme.textSecondary}
|
||||
strokeWidth={2.2}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</Svg>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<ColorWheel color={draftAccent} opacity={draftAccentOpacity} onChange={(color, opacity) => { setDraftAccent(color); setDraftAccentOpacity(opacity); }} />
|
||||
<ColorWheel
|
||||
color={draftAccent}
|
||||
opacity={draftAccentOpacity}
|
||||
onChange={(color, opacity) => {
|
||||
setDraftAccent(color);
|
||||
setDraftAccentOpacity(opacity);
|
||||
}}
|
||||
/>
|
||||
<View style={styles.accentModalActions}>
|
||||
<TouchableOpacity
|
||||
style={[styles.accentModalButton, { borderColor: theme.borderStrong }]}
|
||||
onPress={() => { setDraftAccent(DEFAULT_ACCENT); setDraftAccentOpacity(DEFAULT_ACCENT_OPACITY); setAccentColor(DEFAULT_ACCENT); setAccentOpacity(DEFAULT_ACCENT_OPACITY); }}
|
||||
onPress={() => {
|
||||
setDraftAccent(DEFAULT_ACCENT);
|
||||
setDraftAccentOpacity(DEFAULT_ACCENT_OPACITY);
|
||||
setAccentColor(DEFAULT_ACCENT);
|
||||
setAccentOpacity(DEFAULT_ACCENT_OPACITY);
|
||||
}}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={[styles.accentModalButtonText, { color: theme.textSecondary }]}>Reset</Text>
|
||||
<Text style={[styles.accentModalButtonText, { color: theme.textSecondary }]}>
|
||||
Reset
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.accentModalButton, styles.accentModalButtonPrimary, { backgroundColor: theme.accent }]}
|
||||
onPress={() => { setAccentColor(draftAccent); setAccentOpacity(draftAccentOpacity); setCustomAccentVisible(false); }}
|
||||
style={[
|
||||
styles.accentModalButton,
|
||||
styles.accentModalButtonPrimary,
|
||||
{ backgroundColor: theme.accent },
|
||||
]}
|
||||
onPress={() => {
|
||||
setAccentColor(draftAccent);
|
||||
setAccentOpacity(draftAccentOpacity);
|
||||
setCustomAccentVisible(false);
|
||||
}}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text style={[styles.accentModalButtonText, { color: theme.accentText }]}>Apply</Text>
|
||||
<Text style={[styles.accentModalButtonText, { color: theme.accentText }]}>
|
||||
Apply
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</Pressable>
|
||||
@@ -429,6 +597,12 @@ const AHEAD_OPTIONS: { value: string; label: string }[] = Array.from({ length: 2
|
||||
label: i === 0 ? 'Only today' : `${i} day${i === 1 ? '' : 's'}`,
|
||||
}));
|
||||
|
||||
const THEME_MODE_OPTIONS: { value: ThemeMode; label: string }[] = [
|
||||
{ value: 'light', label: 'Light' },
|
||||
{ value: 'dark', label: 'Dark' },
|
||||
{ value: 'system', label: 'System default' },
|
||||
];
|
||||
|
||||
function formatSyncTime(timestamp: number): string {
|
||||
const seconds = Math.floor((Date.now() - timestamp) / 1000);
|
||||
if (seconds < 60) return 'just now';
|
||||
|
||||
@@ -15,7 +15,7 @@ export default function StatsScreen() {
|
||||
}, [])
|
||||
);
|
||||
|
||||
const maxDaily = Math.max(1, ...stats.daily.map((d) => d.count));
|
||||
const maxDaily = Math.max(1, ...stats.daily.map(d => d.count));
|
||||
|
||||
return (
|
||||
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
|
||||
@@ -25,27 +25,42 @@ export default function StatsScreen() {
|
||||
<Text style={[styles.cardTitle, { color: theme.textMuted }]}>Overview</Text>
|
||||
<View style={styles.summaryGrid}>
|
||||
<View style={styles.summaryItem}>
|
||||
<Text style={[styles.summaryValue, { color: theme.accent }]}>{stats.totalCompleted}</Text>
|
||||
<Text style={[styles.summaryValue, { color: theme.accent }]}>
|
||||
{stats.totalCompleted}
|
||||
</Text>
|
||||
<Text style={[styles.summaryLabel, { color: theme.textMuted }]}>Completed</Text>
|
||||
</View>
|
||||
<View style={styles.summaryItem}>
|
||||
<Text style={[styles.summaryValue, { color: theme.text }]}>{stats.completedLast7}</Text>
|
||||
<Text style={[styles.summaryValue, { color: theme.text }]}>
|
||||
{stats.completedLast7}
|
||||
</Text>
|
||||
<Text style={[styles.summaryLabel, { color: theme.textMuted }]}>Last 7 days</Text>
|
||||
</View>
|
||||
<View style={styles.summaryItem}>
|
||||
<Text style={[styles.summaryValue, { color: theme.text }]}>{stats.completedLast30}</Text>
|
||||
<Text style={[styles.summaryValue, { color: theme.text }]}>
|
||||
{stats.completedLast30}
|
||||
</Text>
|
||||
<Text style={[styles.summaryLabel, { color: theme.textMuted }]}>Last 30 days</Text>
|
||||
</View>
|
||||
<View style={styles.summaryItem}>
|
||||
<Text style={[styles.summaryValue, { color: theme.text }]}>{stats.completionRate}%</Text>
|
||||
<Text style={[styles.summaryValue, { color: theme.text }]}>
|
||||
{stats.completionRate}%
|
||||
</Text>
|
||||
<Text style={[styles.summaryLabel, { color: theme.textMuted }]}>Success rate</Text>
|
||||
</View>
|
||||
<View style={styles.summaryItem}>
|
||||
<Text style={[styles.summaryValue, { color: theme.text }]}>{stats.currentStreak}</Text>
|
||||
<Text style={[styles.summaryValue, { color: theme.text }]}>
|
||||
{stats.currentStreak}
|
||||
</Text>
|
||||
<Text style={[styles.summaryLabel, { color: theme.textMuted }]}>Day streak</Text>
|
||||
</View>
|
||||
<View style={styles.summaryItem}>
|
||||
<Text style={[styles.summaryValue, { color: stats.overdueCount > 0 ? theme.accent : theme.text }]}>
|
||||
<Text
|
||||
style={[
|
||||
styles.summaryValue,
|
||||
{ color: stats.overdueCount > 0 ? theme.accent : theme.text },
|
||||
]}
|
||||
>
|
||||
{stats.overdueCount}
|
||||
</Text>
|
||||
<Text style={[styles.summaryLabel, { color: theme.textMuted }]}>Overdue</Text>
|
||||
@@ -64,7 +79,10 @@ export default function StatsScreen() {
|
||||
<View
|
||||
style={[
|
||||
styles.chartBar,
|
||||
{ height: Math.max(4, height), backgroundColor: d.count > 0 ? theme.accent : theme.cardAlt },
|
||||
{
|
||||
height: Math.max(4, height),
|
||||
backgroundColor: d.count > 0 ? theme.accent : theme.cardAlt,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
@@ -78,9 +96,11 @@ export default function StatsScreen() {
|
||||
<View style={[styles.card, { backgroundColor: theme.card }]}>
|
||||
<Text style={[styles.cardTitle, { color: theme.textMuted }]}>By tags</Text>
|
||||
{stats.byCategory.length === 0 ? (
|
||||
<Text style={[styles.emptyText, { color: theme.textFaint }]}>No completed tasks yet</Text>
|
||||
<Text style={[styles.emptyText, { color: theme.textFaint }]}>
|
||||
No completed tasks yet
|
||||
</Text>
|
||||
) : (
|
||||
stats.byCategory.map((c) => (
|
||||
stats.byCategory.map(c => (
|
||||
<View key={c.id} style={styles.row}>
|
||||
<View style={[styles.dot, { backgroundColor: c.color }]} />
|
||||
<Text style={[styles.rowLabel, { color: theme.text }]}>{c.name}</Text>
|
||||
@@ -93,9 +113,11 @@ export default function StatsScreen() {
|
||||
<View style={[styles.card, { backgroundColor: theme.card }]}>
|
||||
<Text style={[styles.cardTitle, { color: theme.textMuted }]}>By priority</Text>
|
||||
{stats.byPriority.length === 0 ? (
|
||||
<Text style={[styles.emptyText, { color: theme.textFaint }]}>No completed tasks yet</Text>
|
||||
<Text style={[styles.emptyText, { color: theme.textFaint }]}>
|
||||
No completed tasks yet
|
||||
</Text>
|
||||
) : (
|
||||
stats.byPriority.map((p) => (
|
||||
stats.byPriority.map(p => (
|
||||
<View key={p.priority} style={styles.row}>
|
||||
<View style={[styles.dot, { backgroundColor: priorityColor(theme, p.priority) }]} />
|
||||
<Text style={[styles.rowLabel, { color: theme.text }]}>{p.label}</Text>
|
||||
|
||||
@@ -8,9 +8,13 @@ import { UpdateNotifier } from '@/components/UpdateNotifier';
|
||||
import { Linking } from 'react-native';
|
||||
import { useEffect } from 'react';
|
||||
import { requestQuickAddFocus } from '@/utils/quickAddFocus';
|
||||
import { registerForPushNotificationsAsync, setupNotificationChannels, addNotificationResponseReceivedListener, removeNotificationSubscription } from '@/services/pushNotifications';
|
||||
import {
|
||||
registerForPushNotificationsAsync,
|
||||
setupNotificationChannels,
|
||||
addNotificationResponseReceivedListener,
|
||||
removeNotificationSubscription,
|
||||
} from '@/services/pushNotifications';
|
||||
import * as Notifications from 'expo-notifications';
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
Notifications.setNotificationHandler({
|
||||
handleNotification: async () => ({
|
||||
@@ -29,7 +33,7 @@ function RootNavigator() {
|
||||
setupNotificationChannels();
|
||||
registerForPushNotificationsAsync().catch(() => {});
|
||||
|
||||
const responseListener = addNotificationResponseReceivedListener((response) => {
|
||||
const responseListener = addNotificationResponseReceivedListener(response => {
|
||||
const data = response.notification.request.content.data as Record<string, any> | undefined;
|
||||
if (!data) return;
|
||||
|
||||
@@ -54,18 +58,29 @@ function RootNavigator() {
|
||||
requestQuickAddFocus();
|
||||
};
|
||||
|
||||
Linking.getInitialURL().then(handleUrl).catch(() => {});
|
||||
Linking.getInitialURL()
|
||||
.then(handleUrl)
|
||||
.catch(() => {});
|
||||
const subscription = Linking.addEventListener('url', ({ url }) => handleUrl(url));
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
router.back();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
subscription.remove();
|
||||
removeNotificationSubscription(responseListener);
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<StatusBar style="light" />
|
||||
<StatusBar style="dark" />
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="(tabs)" />
|
||||
<Stack.Screen name="add-task" />
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import React, { useMemo, useRef } from 'react';
|
||||
import { View, Text, StyleSheet, SafeAreaView, ScrollView, KeyboardAvoidingView, Platform, Keyboard } from 'react-native';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
SafeAreaView,
|
||||
ScrollView,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
Keyboard,
|
||||
} from 'react-native';
|
||||
import { Header } from '@/components/Header';
|
||||
import { CategorySelector } from '@/components/CategorySelector';
|
||||
import { TaskNameInput } from '@/components/TaskNameInput';
|
||||
@@ -112,7 +121,8 @@ export default function AddTaskScreen() {
|
||||
|
||||
const now = new Date();
|
||||
const dueDateTimestamp = data.dueDate ? data.dueDate.getTime() : 0;
|
||||
const seriesId = data.repeat !== 'none'
|
||||
const seriesId =
|
||||
data.repeat !== 'none'
|
||||
? `series_${Math.random().toString(36).slice(2, 10)}${Date.now().toString(36)}`
|
||||
: '';
|
||||
const resolvedCategoryId = (data.tags && data.tags[0]) || '';
|
||||
@@ -120,7 +130,7 @@ export default function AddTaskScreen() {
|
||||
let createdTask: any = null;
|
||||
|
||||
await database.write(async () => {
|
||||
const task = await collections.tasks.create((t) => {
|
||||
const task = await collections.tasks.create(t => {
|
||||
t.title = data.title.trim();
|
||||
t.description = data.description || '';
|
||||
t.categoryId = resolvedCategoryId;
|
||||
@@ -146,7 +156,7 @@ export default function AddTaskScreen() {
|
||||
for (let i = 0; i < data.subtasks.length; i++) {
|
||||
const subtask = data.subtasks[i];
|
||||
if (subtask.title.trim()) {
|
||||
await collections.subtasks.create((s) => {
|
||||
await collections.subtasks.create(s => {
|
||||
s.taskId = task.id;
|
||||
s.title = subtask.title.trim();
|
||||
s.description = '';
|
||||
@@ -198,7 +208,7 @@ export default function AddTaskScreen() {
|
||||
>
|
||||
<CategorySelector
|
||||
value={tags}
|
||||
onChange={(value) => setValue('tags', value)}
|
||||
onChange={value => setValue('tags', value)}
|
||||
error={errors.categoryId?.message}
|
||||
/>
|
||||
<Controller
|
||||
@@ -214,10 +224,7 @@ export default function AddTaskScreen() {
|
||||
)}
|
||||
/>
|
||||
<SubtasksSection control={control} />
|
||||
<PrioritySelector
|
||||
value={priority}
|
||||
onChange={(value) => setValue('priority', value)}
|
||||
/>
|
||||
<PrioritySelector value={priority} onChange={value => setValue('priority', value)} />
|
||||
<DateTimePickerComponent control={control} />
|
||||
<RepeatSelector
|
||||
value={repeat}
|
||||
@@ -232,12 +239,12 @@ export default function AddTaskScreen() {
|
||||
<ReminderSelector
|
||||
value={reminders}
|
||||
hasDueDate={!!dueDate}
|
||||
onChange={(value) => setValue('reminders', value)}
|
||||
onChange={value => setValue('reminders', value)}
|
||||
/>
|
||||
<AssigneeSelector
|
||||
value={assigneeId}
|
||||
onChange={(value: string | null) => setValue('assigneeId', value)}
|
||||
friends={friends.map((f) => f.username)}
|
||||
friends={friends.map(f => f.username)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { View, Text, StyleSheet, SafeAreaView, ScrollView, TouchableOpacity, KeyboardAvoidingView, BackHandler } from 'react-native';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
SafeAreaView,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
KeyboardAvoidingView,
|
||||
BackHandler,
|
||||
} from 'react-native';
|
||||
import { useRouter, useLocalSearchParams, useFocusEffect } from 'expo-router';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { Header } from '@/components/Header';
|
||||
@@ -17,7 +26,11 @@ import { desaturate, type ThemeColors } from '@/theme';
|
||||
|
||||
function isDayMatch(timestamp: number, day: Date): boolean {
|
||||
const d = new Date(timestamp);
|
||||
return d.getFullYear() === day.getFullYear() && d.getMonth() === day.getMonth() && d.getDate() === day.getDate();
|
||||
return (
|
||||
d.getFullYear() === day.getFullYear() &&
|
||||
d.getMonth() === day.getMonth() &&
|
||||
d.getDate() === day.getDate()
|
||||
);
|
||||
}
|
||||
|
||||
export default function DayViewScreen() {
|
||||
@@ -53,45 +66,68 @@ export default function DayViewScreen() {
|
||||
const subtasksOnDay = useMemo(() => {
|
||||
const map = new Map<string, SubtaskData[]>();
|
||||
for (const [taskId, roots] of subtasksByTask) {
|
||||
const due = roots.filter((s) => s.dueDate && isDayMatch(s.dueDate, day));
|
||||
const due = roots.filter(s => s.dueDate && isDayMatch(s.dueDate, day));
|
||||
if (due.length > 0) map.set(taskId, due);
|
||||
}
|
||||
return map;
|
||||
}, [subtasksByTask, day]);
|
||||
|
||||
const handleToggleComplete = useCallback(async (taskId: string) => {
|
||||
const handleToggleComplete = useCallback(
|
||||
async (taskId: string) => {
|
||||
await confirmAndToggleTaskComplete(taskId, () => {
|
||||
refresh();
|
||||
refreshSubtasks();
|
||||
});
|
||||
}, [refresh, refreshSubtasks]);
|
||||
},
|
||||
[refresh, refreshSubtasks]
|
||||
);
|
||||
|
||||
const handleToggleSubtask = useCallback(async (subtaskId: string) => {
|
||||
const handleToggleSubtask = useCallback(
|
||||
async (subtaskId: string) => {
|
||||
await toggleSubtaskComplete(subtaskId);
|
||||
refreshSubtasks();
|
||||
}, [refreshSubtasks]);
|
||||
},
|
||||
[refreshSubtasks]
|
||||
);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={[styles.container, { backgroundColor: theme.background, paddingBottom: insets.bottom }]}>
|
||||
<SafeAreaView
|
||||
style={[
|
||||
styles.container,
|
||||
{ backgroundColor: theme.background, paddingBottom: insets.bottom },
|
||||
]}
|
||||
>
|
||||
<Header title={format(day, 'EEEE, MMM d')} showLogo={false} rightAction="Back" />
|
||||
<KeyboardAvoidingView style={styles.kbAvoid} behavior="padding">
|
||||
<ScrollView style={styles.scrollBody} contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}>
|
||||
<ScrollView
|
||||
style={styles.scrollBody}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{loading ? (
|
||||
<View style={styles.emptyState}>
|
||||
<Text style={[styles.emptyText, { color: theme.textSecondary }]}>Loading...</Text>
|
||||
</View>
|
||||
) : dayTasks.length === 0 && subtasksOnDay.size === 0 ? (
|
||||
<View style={styles.emptyState}>
|
||||
<Text style={[styles.emptyText, { color: theme.textSecondary }]}>No tasks on this day</Text>
|
||||
<Text style={[styles.emptyText, { color: theme.textSecondary }]}>
|
||||
No tasks on this day
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
{dayTasks.map((task) => {
|
||||
{dayTasks.map(task => {
|
||||
const subs = subtasksOnDay.get(task.id) ?? [];
|
||||
const openCount = subs.filter((s) => !s.completed).length;
|
||||
const cat = categories.find((c) => c.id === task.categoryId);
|
||||
const openCount = subs.filter(s => !s.completed).length;
|
||||
const cat = categories.find(c => c.id === task.categoryId);
|
||||
return (
|
||||
<View key={task.id} style={[styles.eventCard, { backgroundColor: theme.card, borderColor: theme.border }]}>
|
||||
<View
|
||||
key={task.id}
|
||||
style={[
|
||||
styles.eventCard,
|
||||
{ backgroundColor: theme.card, borderColor: theme.border },
|
||||
]}
|
||||
>
|
||||
<View style={styles.eventRow}>
|
||||
<TouchableOpacity
|
||||
style={styles.checkCircle}
|
||||
@@ -104,41 +140,76 @@ export default function DayViewScreen() {
|
||||
{task.completed ? (
|
||||
<>
|
||||
<Circle cx={12} cy={12} r={10} fill={theme.accent} />
|
||||
<Path d="M7 12l3 3 7-7" stroke="#FFFFFF" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
<Path
|
||||
d="M7 12l3 3 7-7"
|
||||
stroke="#FFFFFF"
|
||||
strokeWidth={2.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Circle cx={12} cy={12} r={10} stroke={theme.textSecondary} strokeWidth={2.5} fill="none" />
|
||||
<Circle
|
||||
cx={12}
|
||||
cy={12}
|
||||
r={10}
|
||||
stroke={theme.textSecondary}
|
||||
strokeWidth={2.5}
|
||||
fill="none"
|
||||
/>
|
||||
)}
|
||||
</Svg>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.eventTouch}
|
||||
onPress={() => router.push({ pathname: '/task-detail', params: { id: task.id } })}
|
||||
onPress={() =>
|
||||
router.push({ pathname: '/task-detail', params: { id: task.id } })
|
||||
}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text
|
||||
style={[styles.eventTitle, { color: theme.text }, task.completed && styles.eventCompleted]}
|
||||
style={[
|
||||
styles.eventTitle,
|
||||
{ color: theme.text },
|
||||
task.completed && styles.eventCompleted,
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{task.title}
|
||||
</Text>
|
||||
{openCount > 0 && (
|
||||
<Text style={[styles.eventSub, { color: theme.textMuted }]} numberOfLines={1}>
|
||||
<Text
|
||||
style={[styles.eventSub, { color: theme.textMuted }]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{openCount} open subtask{openCount === 1 ? '' : 's'}
|
||||
</Text>
|
||||
)}
|
||||
{!task.completed && (
|
||||
<View style={styles.metaRow}>
|
||||
{cat && (
|
||||
<View style={[styles.tagChip, { backgroundColor: desaturate(cat.color, 0.3) }]}>
|
||||
<Text style={styles.tagText} numberOfLines={1}>{cat.name}</Text>
|
||||
<View
|
||||
style={[
|
||||
styles.tagChip,
|
||||
{ backgroundColor: desaturate(cat.color, 0.3) },
|
||||
]}
|
||||
>
|
||||
<Text style={styles.tagText} numberOfLines={1}>
|
||||
{cat.name}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{task.dueTime ? (
|
||||
<Text style={[styles.timeText, { color: theme.textSecondary }]}>{task.dueTime}{task.endTime ? `–${task.endTime}` : ''}</Text>
|
||||
<Text style={[styles.timeText, { color: theme.textSecondary }]}>
|
||||
{task.dueTime}
|
||||
{task.endTime ? `–${task.endTime}` : ''}
|
||||
</Text>
|
||||
) : task.dueDate ? (
|
||||
<Text style={[styles.timeText, { color: theme.textSecondary }]}>{format(task.dueDate, 'HH:mm')}</Text>
|
||||
<Text style={[styles.timeText, { color: theme.textSecondary }]}>
|
||||
{format(task.dueDate, 'HH:mm')}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
)}
|
||||
@@ -152,7 +223,14 @@ export default function DayViewScreen() {
|
||||
accessibilityLabel={`Edit ${task.title}`}
|
||||
>
|
||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||
<Path d="M9 6l6 6-6 6" stroke={theme.textSecondary} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
<Path
|
||||
d="M9 6l6 6-6 6"
|
||||
stroke={theme.textSecondary}
|
||||
strokeWidth={2.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
</Svg>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
@@ -160,10 +238,16 @@ export default function DayViewScreen() {
|
||||
);
|
||||
})}
|
||||
{Array.from(subtasksOnDay.entries()).flatMap(([taskId, subs]) => {
|
||||
const parent = dayTasks.find((t) => t.id === taskId);
|
||||
const parent = dayTasks.find(t => t.id === taskId);
|
||||
if (parent) return [];
|
||||
return subs.map((sub) => (
|
||||
<View key={sub.id} style={[styles.eventCard, { backgroundColor: theme.card, borderColor: theme.border }]}>
|
||||
return subs.map(sub => (
|
||||
<View
|
||||
key={sub.id}
|
||||
style={[
|
||||
styles.eventCard,
|
||||
{ backgroundColor: theme.card, borderColor: theme.border },
|
||||
]}
|
||||
>
|
||||
<View style={styles.eventRow}>
|
||||
<TouchableOpacity
|
||||
style={styles.checkCircle}
|
||||
@@ -176,26 +260,49 @@ export default function DayViewScreen() {
|
||||
{sub.completed ? (
|
||||
<>
|
||||
<Circle cx={12} cy={12} r={10} fill={theme.accent} />
|
||||
<Path d="M7 12l3 3 7-7" stroke="#FFFFFF" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
<Path
|
||||
d="M7 12l3 3 7-7"
|
||||
stroke="#FFFFFF"
|
||||
strokeWidth={2.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Circle cx={12} cy={12} r={10} stroke={theme.textSecondary} strokeWidth={2.5} fill="none" />
|
||||
<Circle
|
||||
cx={12}
|
||||
cy={12}
|
||||
r={10}
|
||||
stroke={theme.textSecondary}
|
||||
strokeWidth={2.5}
|
||||
fill="none"
|
||||
/>
|
||||
)}
|
||||
</Svg>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={styles.eventTouch}
|
||||
onPress={() => router.push({ pathname: '/subtask-detail', params: { id: sub.id } })}
|
||||
onPress={() =>
|
||||
router.push({ pathname: '/subtask-detail', params: { id: sub.id } })
|
||||
}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text
|
||||
style={[styles.eventTitle, { color: theme.text }, sub.completed && styles.eventCompleted]}
|
||||
style={[
|
||||
styles.eventTitle,
|
||||
{ color: theme.text },
|
||||
sub.completed && styles.eventCompleted,
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{sub.title}
|
||||
</Text>
|
||||
{sub.dueTime ? (
|
||||
<Text style={[styles.timeText, { color: theme.textSecondary }]}>{sub.dueTime}{sub.endTime ? `–${sub.endTime}` : ''}</Text>
|
||||
<Text style={[styles.timeText, { color: theme.textSecondary }]}>
|
||||
{sub.dueTime}
|
||||
{sub.endTime ? `–${sub.endTime}` : ''}
|
||||
</Text>
|
||||
) : null}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
@@ -205,14 +312,18 @@ export default function DayViewScreen() {
|
||||
</>
|
||||
)}
|
||||
</ScrollView>
|
||||
<QuickAddBar dueDate={dayStart.getTime()} placeholder={`Add event for ${format(day, 'MMM d')}`} />
|
||||
<QuickAddBar
|
||||
dueDate={dayStart.getTime()}
|
||||
placeholder={`Add event for ${format(day, 'MMM d')}`}
|
||||
/>
|
||||
</KeyboardAvoidingView>
|
||||
{modals(() => {})}
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const createStyles = (theme: ThemeColors) => StyleSheet.create({
|
||||
const createStyles = (theme: ThemeColors) =>
|
||||
StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
@@ -290,4 +401,4 @@ const createStyles = (theme: ThemeColors) => StyleSheet.create({
|
||||
padding: 4,
|
||||
marginLeft: 6,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, SafeAreaView, ScrollView, KeyboardAvoidingView, Platform, TouchableOpacity, Alert } from 'react-native';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
SafeAreaView,
|
||||
ScrollView,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
TouchableOpacity,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { Header } from '@/components/Header';
|
||||
import { TaskNameInput } from '@/components/TaskNameInput';
|
||||
@@ -15,7 +25,14 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useDatabase } from '@/hooks/useDatabase';
|
||||
import { collections } from '@/database';
|
||||
import { SubtaskFormData, Reminder, parseReminders, toRemindersString, parseTaskTags, tagsToString } from '@/types';
|
||||
import {
|
||||
SubtaskFormData,
|
||||
Reminder,
|
||||
parseReminders,
|
||||
toRemindersString,
|
||||
parseTaskTags,
|
||||
tagsToString,
|
||||
} from '@/types';
|
||||
import { useSettings } from '@/theme';
|
||||
import { updateSubtask, deleteSubtask } from '@/utils/taskActions';
|
||||
import { CategorySelector } from '@/components/CategorySelector';
|
||||
@@ -68,7 +85,14 @@ export default function SubtaskDetailScreen() {
|
||||
},
|
||||
});
|
||||
|
||||
const { handleSubmit, watch, setValue, control, reset, formState: { errors } } = methods;
|
||||
const {
|
||||
handleSubmit,
|
||||
watch,
|
||||
setValue,
|
||||
control,
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = methods;
|
||||
|
||||
const priority = watch('priority');
|
||||
const tags = watch('tags') ?? [];
|
||||
@@ -98,7 +122,10 @@ export default function SubtaskDetailScreen() {
|
||||
allDay: subtask.allDay ?? false,
|
||||
repeat: subtask.repeat,
|
||||
repeatInterval: subtask.repeatInterval || 1,
|
||||
repeatDays: ((subtask.repeatDays || '') as string).split(',').map(Number).filter((d) => !Number.isNaN(d)),
|
||||
repeatDays: ((subtask.repeatDays || '') as string)
|
||||
.split(',')
|
||||
.map(Number)
|
||||
.filter(d => !Number.isNaN(d)),
|
||||
reminder: (subtask.reminder || 'none') as Reminder,
|
||||
reminders: subtask.reminders || '',
|
||||
assigneeId: subtask.assigneeId ?? null,
|
||||
@@ -160,7 +187,9 @@ export default function SubtaskDetailScreen() {
|
||||
if (!isReady || !loaded) {
|
||||
return (
|
||||
<View style={[styles.loadingContainer, { backgroundColor: theme.background }]}>
|
||||
<Text style={{ color: theme.textFaint }}>{notFound ? 'Subtask not found' : 'Loading...'}</Text>
|
||||
<Text style={{ color: theme.textFaint }}>
|
||||
{notFound ? 'Subtask not found' : 'Loading...'}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -171,9 +200,23 @@ export default function SubtaskDetailScreen() {
|
||||
title="Edit Subtask"
|
||||
showLogo={true}
|
||||
rightAction={
|
||||
<TouchableOpacity onPress={confirmDelete} activeOpacity={0.7} style={[styles.deleteButton, { borderColor: theme.accentBorder, backgroundColor: theme.accentSoft }]}>
|
||||
<TouchableOpacity
|
||||
onPress={confirmDelete}
|
||||
activeOpacity={0.7}
|
||||
style={[
|
||||
styles.deleteButton,
|
||||
{ borderColor: theme.accentBorder, backgroundColor: theme.accentSoft },
|
||||
]}
|
||||
>
|
||||
<Svg width={22} height={22} viewBox="0 0 24 24">
|
||||
<Path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13" stroke={theme.accent} strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
<Path
|
||||
d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"
|
||||
stroke={theme.accent}
|
||||
strokeWidth={2.2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
</Svg>
|
||||
</TouchableOpacity>
|
||||
}
|
||||
@@ -201,14 +244,8 @@ export default function SubtaskDetailScreen() {
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<CategorySelector
|
||||
value={tags}
|
||||
onChange={(value) => setValue('tags', value)}
|
||||
/>
|
||||
<PrioritySelector
|
||||
value={priority}
|
||||
onChange={(value) => setValue('priority', value)}
|
||||
/>
|
||||
<CategorySelector value={tags} onChange={value => setValue('tags', value)} />
|
||||
<PrioritySelector value={priority} onChange={value => setValue('priority', value)} />
|
||||
<DateTimePickerComponent control={control as any} />
|
||||
<RepeatSelector
|
||||
value={repeat}
|
||||
@@ -223,12 +260,12 @@ export default function SubtaskDetailScreen() {
|
||||
<ReminderSelector
|
||||
value={reminders}
|
||||
hasDueDate={!!dueDate}
|
||||
onChange={(value) => setValue('reminders', value)}
|
||||
onChange={value => setValue('reminders', value)}
|
||||
/>
|
||||
<AssigneeSelector
|
||||
value={assigneeId}
|
||||
onChange={(value: string | null) => setValue('assigneeId', value)}
|
||||
friends={friends.map((f) => f.username)}
|
||||
friends={friends.map(f => f.username)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { View, Text, StyleSheet, SafeAreaView, ScrollView, KeyboardAvoidingView, Platform, TouchableOpacity, Keyboard } from 'react-native';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
SafeAreaView,
|
||||
ScrollView,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
TouchableOpacity,
|
||||
Keyboard,
|
||||
} from 'react-native';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { Header } from '@/components/Header';
|
||||
import { CategorySelector } from '@/components/CategorySelector';
|
||||
@@ -47,13 +57,27 @@ const taskSchema = z.object({
|
||||
subtasks: z.array(z.object({ title: z.string(), _key: z.string().optional() })).optional(),
|
||||
});
|
||||
|
||||
function CollapsibleSection({ title, children, defaultExpanded = false, icon }: { title: string; children: React.ReactNode; defaultExpanded?: boolean; icon: React.ReactNode }) {
|
||||
function CollapsibleSection({
|
||||
title,
|
||||
children,
|
||||
defaultExpanded = false,
|
||||
icon,
|
||||
}: {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
defaultExpanded?: boolean;
|
||||
icon: React.ReactNode;
|
||||
}) {
|
||||
const { theme } = useSettings();
|
||||
const [expanded, setExpanded] = React.useState(defaultExpanded);
|
||||
|
||||
return (
|
||||
<View style={styles.section}>
|
||||
<TouchableOpacity style={[styles.sectionHeader, { backgroundColor: theme.card, borderColor: theme.border }]} onPress={() => setExpanded(!expanded)} activeOpacity={0.8}>
|
||||
<TouchableOpacity
|
||||
style={[styles.sectionHeader, { backgroundColor: theme.card, borderColor: theme.border }]}
|
||||
onPress={() => setExpanded(!expanded)}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<View style={styles.sectionHeaderLeft}>
|
||||
{icon}
|
||||
<Text style={[styles.sectionTitle, { color: theme.text }]}>{title}</Text>
|
||||
@@ -106,7 +130,14 @@ export default function TaskDetailScreen() {
|
||||
},
|
||||
});
|
||||
|
||||
const { handleSubmit, watch, setValue, control, reset, formState: { errors } } = methods;
|
||||
const {
|
||||
handleSubmit,
|
||||
watch,
|
||||
setValue,
|
||||
control,
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = methods;
|
||||
|
||||
const tags = watch('tags') ?? [];
|
||||
const priority = watch('priority');
|
||||
@@ -129,7 +160,8 @@ export default function TaskDetailScreen() {
|
||||
const subtasks = await collections.subtasks.query(Q.where('task_id', id)).fetch();
|
||||
|
||||
if (!mounted) return;
|
||||
const remindersValue = task.reminders || (task.reminder && task.reminder !== 'none' ? task.reminder : '');
|
||||
const remindersValue =
|
||||
task.reminders || (task.reminder && task.reminder !== 'none' ? task.reminder : '');
|
||||
|
||||
reset({
|
||||
title: task.title,
|
||||
@@ -143,11 +175,14 @@ export default function TaskDetailScreen() {
|
||||
allDay: task.allDay ?? false,
|
||||
repeat: task.repeat,
|
||||
repeatInterval: task.repeatInterval || 1,
|
||||
repeatDays: (task.repeatDays || '').split(',').map(Number).filter((d) => !Number.isNaN(d)),
|
||||
repeatDays: (task.repeatDays || '')
|
||||
.split(',')
|
||||
.map(Number)
|
||||
.filter(d => !Number.isNaN(d)),
|
||||
reminder: (task.reminder || 'none') as TaskFormData['reminder'],
|
||||
reminders: remindersValue,
|
||||
assigneeId: task.assigneeId ?? null,
|
||||
subtasks: subtasks.map((s) => ({ title: s.title, _key: s.id })),
|
||||
subtasks: subtasks.map(s => ({ title: s.title, _key: s.id })),
|
||||
});
|
||||
setLoaded(true);
|
||||
} catch {
|
||||
@@ -155,7 +190,9 @@ export default function TaskDetailScreen() {
|
||||
}
|
||||
})();
|
||||
|
||||
return () => { mounted = false; };
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [id, isReady, reset]);
|
||||
|
||||
const onSubmit = async (data: TaskFormData) => {
|
||||
@@ -174,7 +211,7 @@ export default function TaskDetailScreen() {
|
||||
savedTask = task;
|
||||
const existingSubtasks = await collections.subtasks.query(Q.where('task_id', id)).fetch();
|
||||
|
||||
await task.update((t) => {
|
||||
await task.update(t => {
|
||||
t.title = data.title.trim();
|
||||
t.description = data.description || '';
|
||||
t.categoryId = (data.tags && data.tags[0]) || task.categoryId || '';
|
||||
@@ -202,10 +239,10 @@ export default function TaskDetailScreen() {
|
||||
const trimmed = formItem.title.trim();
|
||||
if (!trimmed) continue;
|
||||
if (formItem._key) {
|
||||
const existing = existingSubtasks.find((s) => s.id === formItem._key && !s.parentSubtaskId);
|
||||
const existing = existingSubtasks.find(s => s.id === formItem._key && !s.parentSubtaskId);
|
||||
if (existing) {
|
||||
keptIds.add(existing.id);
|
||||
await existing.update((s) => {
|
||||
await existing.update(s => {
|
||||
s.title = trimmed;
|
||||
s.order = order;
|
||||
s.updatedAt = now;
|
||||
@@ -214,7 +251,7 @@ export default function TaskDetailScreen() {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
await collections.subtasks.create((s) => {
|
||||
await collections.subtasks.create(s => {
|
||||
s.taskId = task.id;
|
||||
s.categoryId = task.categoryId || '';
|
||||
s.tags = task.tags || '';
|
||||
@@ -244,9 +281,11 @@ export default function TaskDetailScreen() {
|
||||
if (existing.parentSubtaskId) continue;
|
||||
if (keptIds.has(existing.id)) continue;
|
||||
removedIds.push(existing.id);
|
||||
const children = await collections.subtasks.query(Q.where('parent_subtask_id', existing.id)).fetch();
|
||||
const children = await collections.subtasks
|
||||
.query(Q.where('parent_subtask_id', existing.id))
|
||||
.fetch();
|
||||
for (const child of children) {
|
||||
await child.update((c) => {
|
||||
await child.update(c => {
|
||||
c.parentSubtaskId = null;
|
||||
c.updatedAt = now;
|
||||
});
|
||||
@@ -288,9 +327,23 @@ export default function TaskDetailScreen() {
|
||||
title="Edit Task"
|
||||
showLogo={true}
|
||||
rightAction={
|
||||
<TouchableOpacity onPress={handleDelete} activeOpacity={0.7} style={[styles.deleteButton, { borderColor: theme.accentBorder, backgroundColor: theme.accentSoft }]}>
|
||||
<TouchableOpacity
|
||||
onPress={handleDelete}
|
||||
activeOpacity={0.7}
|
||||
style={[
|
||||
styles.deleteButton,
|
||||
{ borderColor: theme.accentBorder, backgroundColor: theme.accentSoft },
|
||||
]}
|
||||
>
|
||||
<Svg width={22} height={22} viewBox="0 0 24 24">
|
||||
<Path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13" stroke={theme.accent} strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
<Path
|
||||
d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"
|
||||
stroke={theme.accent}
|
||||
strokeWidth={2.2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
</Svg>
|
||||
</TouchableOpacity>
|
||||
}
|
||||
@@ -310,7 +363,7 @@ export default function TaskDetailScreen() {
|
||||
>
|
||||
<CategorySelector
|
||||
value={tags}
|
||||
onChange={(value) => setValue('tags', value)}
|
||||
onChange={value => setValue('tags', value)}
|
||||
error={errors.categoryId?.message}
|
||||
/>
|
||||
<Controller
|
||||
@@ -326,21 +379,36 @@ export default function TaskDetailScreen() {
|
||||
)}
|
||||
/>
|
||||
<SubtasksSection control={control} />
|
||||
<PrioritySelector
|
||||
value={priority}
|
||||
onChange={(value) => setValue('priority', value)}
|
||||
/>
|
||||
<PrioritySelector value={priority} onChange={value => setValue('priority', value)} />
|
||||
|
||||
<CollapsibleSection title="Date & Time" icon={
|
||||
<CollapsibleSection
|
||||
title="Date & Time"
|
||||
icon={
|
||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||
<Circle cx={12} cy={12} r={10} stroke={theme.accent} strokeWidth={1.8} fill="none" />
|
||||
<Path d="M12 6v6l4 2" stroke={theme.accent} strokeWidth={1.8} strokeLinecap="round" />
|
||||
<Circle
|
||||
cx={12}
|
||||
cy={12}
|
||||
r={10}
|
||||
stroke={theme.accent}
|
||||
strokeWidth={1.8}
|
||||
fill="none"
|
||||
/>
|
||||
<Path
|
||||
d="M12 6v6l4 2"
|
||||
stroke={theme.accent}
|
||||
strokeWidth={1.8}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</Svg>
|
||||
} defaultExpanded={!!dueDate}>
|
||||
}
|
||||
defaultExpanded={!!dueDate}
|
||||
>
|
||||
<DateTimePickerComponent control={control} />
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection title="Repeat" icon={
|
||||
<CollapsibleSection
|
||||
title="Repeat"
|
||||
icon={
|
||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||
<Path
|
||||
d="M17 2l4 4-4 4M3 11v-1a4 4 0 0 1 4-4h14M7 22l-4-4 4-4M21 13v1a4 4 0 0 1-4 4H3"
|
||||
@@ -351,7 +419,9 @@ export default function TaskDetailScreen() {
|
||||
fill="none"
|
||||
/>
|
||||
</Svg>
|
||||
} defaultExpanded={repeat !== 'none'}>
|
||||
}
|
||||
defaultExpanded={repeat !== 'none'}
|
||||
>
|
||||
<RepeatSelector
|
||||
value={repeat}
|
||||
interval={repeatInterval}
|
||||
@@ -364,7 +434,9 @@ export default function TaskDetailScreen() {
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection title="Reminder" icon={
|
||||
<CollapsibleSection
|
||||
title="Reminder"
|
||||
icon={
|
||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||
<Path
|
||||
d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9M10.3 21a1.94 1.94 0 0 0 3.4 0"
|
||||
@@ -375,32 +447,61 @@ export default function TaskDetailScreen() {
|
||||
fill="none"
|
||||
/>
|
||||
</Svg>
|
||||
} defaultExpanded={!!reminders && reminders !== ''}>
|
||||
}
|
||||
defaultExpanded={!!reminders && reminders !== ''}
|
||||
>
|
||||
<ReminderSelector
|
||||
value={reminders}
|
||||
hasDueDate={!!dueDate}
|
||||
onChange={(value) => setValue('reminders', value)}
|
||||
onChange={value => setValue('reminders', value)}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection title="Assignee" icon={
|
||||
<CollapsibleSection
|
||||
title="Assignee"
|
||||
icon={
|
||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||
<Circle cx={12} cy={12} r={10} stroke={theme.accent} strokeWidth={1.8} fill="none" />
|
||||
<Path d="M12 10v6M12 19v1" stroke={theme.accent} strokeWidth={1.8} strokeLinecap="round" />
|
||||
<Circle
|
||||
cx={12}
|
||||
cy={12}
|
||||
r={10}
|
||||
stroke={theme.accent}
|
||||
strokeWidth={1.8}
|
||||
fill="none"
|
||||
/>
|
||||
<Path
|
||||
d="M12 10v6M12 19v1"
|
||||
stroke={theme.accent}
|
||||
strokeWidth={1.8}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</Svg>
|
||||
} defaultExpanded={!!assigneeId}>
|
||||
}
|
||||
defaultExpanded={!!assigneeId}
|
||||
>
|
||||
<AssigneeSelector
|
||||
value={assigneeId}
|
||||
onChange={(value: string | null) => setValue('assigneeId', value)}
|
||||
friends={friends.map((f) => f.username)}
|
||||
friends={friends.map(f => f.username)}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection title="Description" icon={
|
||||
<CollapsibleSection
|
||||
title="Description"
|
||||
icon={
|
||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||
<Path d="M4 6h16M4 12h16M4 18h10" stroke={theme.accent} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
<Path
|
||||
d="M4 6h16M4 12h16M4 18h10"
|
||||
stroke={theme.accent}
|
||||
strokeWidth={1.8}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
</Svg>
|
||||
} defaultExpanded={!!methods.getValues('description')}>
|
||||
}
|
||||
defaultExpanded={!!methods.getValues('description')}
|
||||
>
|
||||
<Controller
|
||||
control={control}
|
||||
name="description"
|
||||
|
||||
Generated
+727
-605
File diff suppressed because it is too large
Load Diff
@@ -9,22 +9,23 @@
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-native-community/datetimepicker": "9.1.0",
|
||||
"date-fns": "^3.6.0",
|
||||
"expo": "~57.0.10",
|
||||
"expo": "~57.0.18",
|
||||
"expo-blur": "~57.0.2",
|
||||
"expo-constants": "~57.0.9",
|
||||
"expo-dev-client": "~57.0.1",
|
||||
"expo-dev-client": "~57.0.16",
|
||||
"expo-device": "^57.0.1",
|
||||
"expo-linking": "~57.0.5",
|
||||
"expo-notifications": "~57.0.8",
|
||||
"expo-router": "~57.0.10",
|
||||
"expo-sqlite": "~57.0.1",
|
||||
"expo-intent-launcher": "~57.0.1",
|
||||
"expo-linking": "~57.0.8",
|
||||
"expo-notifications": "~57.0.15",
|
||||
"expo-router": "~57.0.17",
|
||||
"expo-sqlite": "~57.0.2",
|
||||
"expo-status-bar": "~57.0.1",
|
||||
"expo-system-ui": "~57.0.2",
|
||||
"expo-updates": "~57.0.12",
|
||||
"expo-system-ui": "~57.0.3",
|
||||
"expo-updates": "~57.0.19",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-hook-form": "^7.51.5",
|
||||
"react-native": "0.86.2",
|
||||
"react-native": "0.86.3",
|
||||
"react-native-gesture-handler": "2.32.0",
|
||||
"react-native-keyboard-controller": "1.21.9",
|
||||
"react-native-safe-area-context": "5.7.0",
|
||||
@@ -40,7 +41,7 @@
|
||||
"electron": "^41.7.1",
|
||||
"electron-builder": "^26.15.3",
|
||||
"eslint": "^9.0.0",
|
||||
"eslint-config-expo": "~57.0.1",
|
||||
"eslint-config-expo": "~57.0.2",
|
||||
"patch-package": "^8.0.1",
|
||||
"prettier": "^3.9.6",
|
||||
"typescript": "~6.0.3"
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
diff --git a/node_modules/react-native/src/private/webapis/dom/events/Event.js b/node_modules/react-native/src/private/webapis/dom/events/Event.js
|
||||
index f918f97..5deab46 100644
|
||||
--- a/node_modules/react-native/src/private/webapis/dom/events/Event.js
|
||||
+++ b/node_modules/react-native/src/private/webapis/dom/events/Event.js
|
||||
@@ -70,7 +70,7 @@ export default class Event {
|
||||
[CURRENT_TARGET_KEY]: EventTarget | null = null;
|
||||
|
||||
// $FlowExpectedError[unsupported-syntax]
|
||||
- [EVENT_PHASE_KEY]: boolean = Event.NONE;
|
||||
+ [EVENT_PHASE_KEY]: boolean = 0;
|
||||
|
||||
// $FlowExpectedError[unsupported-syntax]
|
||||
[IN_PASSIVE_LISTENER_FLAG_KEY]: boolean = false;
|
||||
@@ -193,48 +193,64 @@ export default class Event {
|
||||
|
||||
// $FlowExpectedError[cannot-write]
|
||||
Object.defineProperty(Event, 'NONE', {
|
||||
+ writable: true,
|
||||
+ configurable: true,
|
||||
enumerable: true,
|
||||
value: 0,
|
||||
});
|
||||
|
||||
// $FlowExpectedError[cannot-write]
|
||||
Object.defineProperty(Event.prototype, 'NONE', {
|
||||
+ writable: true,
|
||||
+ configurable: true,
|
||||
enumerable: true,
|
||||
value: 0,
|
||||
});
|
||||
|
||||
// $FlowExpectedError[cannot-write]
|
||||
Object.defineProperty(Event, 'CAPTURING_PHASE', {
|
||||
+ writable: true,
|
||||
+ configurable: true,
|
||||
enumerable: true,
|
||||
value: 1,
|
||||
});
|
||||
|
||||
// $FlowExpectedError[cannot-write]
|
||||
Object.defineProperty(Event.prototype, 'CAPTURING_PHASE', {
|
||||
+ writable: true,
|
||||
+ configurable: true,
|
||||
enumerable: true,
|
||||
value: 1,
|
||||
});
|
||||
|
||||
// $FlowExpectedError[cannot-write]
|
||||
Object.defineProperty(Event, 'AT_TARGET', {
|
||||
+ writable: true,
|
||||
+ configurable: true,
|
||||
enumerable: true,
|
||||
value: 2,
|
||||
});
|
||||
|
||||
// $FlowExpectedError[cannot-write]
|
||||
Object.defineProperty(Event.prototype, 'AT_TARGET', {
|
||||
+ writable: true,
|
||||
+ configurable: true,
|
||||
enumerable: true,
|
||||
value: 2,
|
||||
});
|
||||
|
||||
// $FlowExpectedError[cannot-write]
|
||||
Object.defineProperty(Event, 'BUBBLING_PHASE', {
|
||||
+ writable: true,
|
||||
+ configurable: true,
|
||||
enumerable: true,
|
||||
value: 3,
|
||||
});
|
||||
|
||||
// $FlowExpectedError[cannot-write]
|
||||
Object.defineProperty(Event.prototype, 'BUBBLING_PHASE', {
|
||||
+ writable: true,
|
||||
+ configurable: true,
|
||||
enumerable: true,
|
||||
value: 3,
|
||||
});
|
||||
@@ -161,7 +161,7 @@ export function CategoryEditorModal({
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
<ColorWheel color={color} onChange={setColor} />
|
||||
<ColorWheel color={color} opacity={1} onChange={setColor} />
|
||||
|
||||
<View style={styles.actions}>
|
||||
{category && (
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import React from 'react';
|
||||
import { View, Text, TouchableOpacity, StyleSheet, Switch } from 'react-native';
|
||||
|
||||
export const ListItem = ({ title, subtitle, onPress, leftIcon, rightIcon, disabled, leftElement, rightElement, showChevron }: {
|
||||
export const ListItem = ({
|
||||
title,
|
||||
subtitle,
|
||||
onPress,
|
||||
leftIcon,
|
||||
rightIcon,
|
||||
disabled,
|
||||
leftElement,
|
||||
rightElement,
|
||||
showChevron,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string | undefined;
|
||||
onPress?: () => void;
|
||||
|
||||
@@ -19,10 +19,22 @@ interface OptionPickerModalProps {
|
||||
multiSelect?: boolean;
|
||||
}
|
||||
|
||||
export function OptionPickerModal({ visible, title, options, selectedValue, onSelect, onClose, multiSelect = false }: OptionPickerModalProps) {
|
||||
export function OptionPickerModal({
|
||||
visible,
|
||||
title,
|
||||
options,
|
||||
selectedValue,
|
||||
onSelect,
|
||||
onClose,
|
||||
multiSelect = false,
|
||||
}: OptionPickerModalProps) {
|
||||
const { theme } = useSettings();
|
||||
|
||||
const selectedValues = Array.isArray(selectedValue) ? selectedValue : selectedValue == null ? [] : [selectedValue];
|
||||
const selectedValues = Array.isArray(selectedValue)
|
||||
? selectedValue
|
||||
: selectedValue == null
|
||||
? []
|
||||
: [selectedValue];
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
||||
@@ -31,7 +43,7 @@ export function OptionPickerModal({ visible, title, options, selectedValue, onSe
|
||||
<Text style={[styles.title, { color: theme.text }]}>{title}</Text>
|
||||
<FlatList
|
||||
data={options}
|
||||
keyExtractor={(item) => item.value}
|
||||
keyExtractor={item => item.value}
|
||||
renderItem={({ item }) => {
|
||||
const selected = selectedValues.includes(item.value);
|
||||
return (
|
||||
@@ -44,7 +56,7 @@ export function OptionPickerModal({ visible, title, options, selectedValue, onSe
|
||||
onPress={() => {
|
||||
if (multiSelect) {
|
||||
const newValues = selected
|
||||
? selectedValues.filter((v) => v !== item.value)
|
||||
? selectedValues.filter(v => v !== item.value)
|
||||
: [...selectedValues, item.value];
|
||||
onSelect(newValues);
|
||||
} else {
|
||||
@@ -57,23 +69,49 @@ export function OptionPickerModal({ visible, title, options, selectedValue, onSe
|
||||
accessibilityLabel={item.label}
|
||||
accessibilityState={multiSelect ? { checked: selected } : { selected }}
|
||||
>
|
||||
{item.color && (
|
||||
<View style={[styles.dot, { backgroundColor: item.color }]} />
|
||||
)}
|
||||
<Text style={[styles.optionText, { color: selected ? theme.accent : theme.textSecondary }]}>
|
||||
{item.color && <View style={[styles.dot, { backgroundColor: item.color }]} />}
|
||||
<Text
|
||||
style={[
|
||||
styles.optionText,
|
||||
{ color: selected ? theme.accent : theme.textSecondary },
|
||||
]}
|
||||
>
|
||||
{item.label}
|
||||
</Text>
|
||||
{multiSelect ? (
|
||||
<Svg width={22} height={22} viewBox="0 0 24 24">
|
||||
<Circle cx={12} cy={12} r={10} stroke={selected ? theme.accent : theme.borderStrong} strokeWidth={2} fill={selected ? theme.accent : 'transparent'} />
|
||||
<Circle
|
||||
cx={12}
|
||||
cy={12}
|
||||
r={10}
|
||||
stroke={selected ? theme.accent : theme.borderStrong}
|
||||
strokeWidth={2}
|
||||
fill={selected ? theme.accent : 'transparent'}
|
||||
/>
|
||||
{selected && (
|
||||
<Path d="M8 12l3 3 6-6" stroke="#FFFFFF" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
<Path
|
||||
d="M8 12l3 3 6-6"
|
||||
stroke="#FFFFFF"
|
||||
strokeWidth={2.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
)}
|
||||
</Svg>
|
||||
) : selected && (
|
||||
) : (
|
||||
selected && (
|
||||
<Svg width={18} height={18} viewBox="0 0 24 24">
|
||||
<Path d="M5 12l5 5 9-10" stroke={theme.accent} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
<Path
|
||||
d="M5 12l5 5 9-10"
|
||||
stroke={theme.accent}
|
||||
strokeWidth={2.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
</Svg>
|
||||
)
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
@@ -24,13 +24,17 @@ export function PrioritySelector({ value, onChange }: PrioritySelectorProps) {
|
||||
<View style={styles.container}>
|
||||
<Text style={[styles.label, { color: theme.text }]}>Priority</Text>
|
||||
<View style={styles.options} accessibilityRole="radiogroup" accessibilityLabel="Priority">
|
||||
{priorities.map((priority) => (
|
||||
{priorities.map(priority => (
|
||||
<TouchableOpacity
|
||||
key={priority.value}
|
||||
style={[
|
||||
styles.option,
|
||||
{ backgroundColor: theme.card, borderColor: theme.borderStrong },
|
||||
value === priority.value && { borderColor: theme.accent, backgroundColor: theme.accentSoft, borderWidth: 2 },
|
||||
value === priority.value && {
|
||||
borderColor: theme.accent,
|
||||
backgroundColor: theme.accentSoft,
|
||||
borderWidth: 2,
|
||||
},
|
||||
]}
|
||||
onPress={() => onChange(priority.value)}
|
||||
activeOpacity={0.8}
|
||||
@@ -38,16 +42,20 @@ export function PrioritySelector({ value, onChange }: PrioritySelectorProps) {
|
||||
accessibilityLabel={`${priority.label} priority`}
|
||||
accessibilityState={{ selected: value === priority.value }}
|
||||
>
|
||||
<View style={[
|
||||
<View
|
||||
style={[
|
||||
styles.colorIndicator,
|
||||
{ backgroundColor: PRIORITY_COLORS[priority.value] },
|
||||
value === priority.value && styles.colorIndicatorSelected,
|
||||
]} />
|
||||
<Text style={[
|
||||
]}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.optionText,
|
||||
{ color: theme.textFaint },
|
||||
value === priority.value && { color: theme.accentStrong, fontWeight: '600' },
|
||||
]}>
|
||||
]}
|
||||
>
|
||||
{priority.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { View, StyleSheet, TextInput, TouchableOpacity, Keyboard, Text, Platform } from 'react-native';
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
Keyboard,
|
||||
Text,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import { BlurView } from 'expo-blur';
|
||||
import { database, collections } from '@/database';
|
||||
import { useCategories } from '@/hooks/useDatabase';
|
||||
@@ -14,10 +22,20 @@ interface QuickAddBarProps {
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
const BlurContainer = ({ children, style, theme }: { children: React.ReactNode; style: any; theme: any }) => {
|
||||
const BlurContainer = ({
|
||||
children,
|
||||
style,
|
||||
theme,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
style: any;
|
||||
theme: any;
|
||||
}) => {
|
||||
if (Platform.OS === 'web') {
|
||||
return (
|
||||
<View style={[style, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }]}>
|
||||
<View
|
||||
style={[style, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }]}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
@@ -38,10 +56,10 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
|
||||
const visibleCategories = useMemo(
|
||||
() => categories.filter((c) => c.name.toLowerCase() !== 'calendar'),
|
||||
() => categories.filter(c => c.name.toLowerCase() !== 'calendar'),
|
||||
[categories]
|
||||
);
|
||||
const categoryColor = visibleCategories.find((c) => c.id === categoryId)?.color ?? theme.textMuted;
|
||||
const categoryColor = visibleCategories.find(c => c.id === categoryId)?.color ?? theme.textMuted;
|
||||
|
||||
useEffect(() => {
|
||||
return subscribeToQuickAdd(() => {
|
||||
@@ -57,7 +75,7 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
|
||||
|
||||
const now = new Date();
|
||||
await database.write(async () => {
|
||||
await collections.tasks.create((t) => {
|
||||
await collections.tasks.create(t => {
|
||||
t.title = trimmed;
|
||||
t.description = '';
|
||||
t.categoryId = categoryId || '';
|
||||
@@ -83,16 +101,31 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
|
||||
return (
|
||||
<View style={styles.wrapper}>
|
||||
<BlurContainer theme={theme} style={styles.blurContainer}>
|
||||
<View style={[styles.bar, { backgroundColor: 'rgba(255,255,255,0.05)', borderColor: 'rgba(255,255,255,0.1)' }]}>
|
||||
<View
|
||||
style={[
|
||||
styles.bar,
|
||||
{ backgroundColor: 'rgba(255,255,255,0.05)', borderColor: 'rgba(255,255,255,0.1)' },
|
||||
]}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={[styles.categoryButton, { borderColor: theme.borderStrong, backgroundColor: theme.cardAlt }]}
|
||||
style={[
|
||||
styles.categoryButton,
|
||||
{ borderColor: theme.borderStrong, backgroundColor: theme.cardAlt },
|
||||
]}
|
||||
onPress={() => setCategoryPickerVisible(true)}
|
||||
activeOpacity={0.7}
|
||||
accessibilityLabel="Select tags"
|
||||
>
|
||||
<View style={[styles.categoryButtonDot, { backgroundColor: categoryColor }]} />
|
||||
<Svg width={12} height={12} viewBox="0 0 24 24">
|
||||
<Path d="M6 9l6 6 6-6" stroke={theme.textSecondary} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
<Path
|
||||
d="M6 9l6 6 6-6"
|
||||
stroke={theme.textSecondary}
|
||||
strokeWidth={2.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
</Svg>
|
||||
</TouchableOpacity>
|
||||
<TextInput
|
||||
@@ -108,7 +141,11 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
|
||||
accessibilityHint="Enter a task name and press the add button"
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={[styles.submit, { backgroundColor: theme.accent }, title.trim() ? {} : styles.submitDisabled]}
|
||||
style={[
|
||||
styles.submit,
|
||||
{ backgroundColor: theme.accent },
|
||||
title.trim() ? {} : styles.submitDisabled,
|
||||
]}
|
||||
onPress={title.trim() ? handleAdd : undefined}
|
||||
disabled={!title.trim()}
|
||||
activeOpacity={0.8}
|
||||
@@ -132,9 +169,12 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
|
||||
<OptionPickerModal
|
||||
visible={categoryPickerVisible}
|
||||
title="Select Tags"
|
||||
options={[{ value: '', label: 'None', color: theme.textMuted }, ...visibleCategories.map((c) => ({ value: c.id, label: c.name, color: c.color }))]}
|
||||
options={[
|
||||
{ value: '', label: 'None', color: theme.textMuted },
|
||||
...visibleCategories.map(c => ({ value: c.id, label: c.name, color: c.color })),
|
||||
]}
|
||||
selectedValue={categoryId}
|
||||
onSelect={(value) => setCategoryId(Array.isArray(value) ? value[0] : value)}
|
||||
onSelect={value => setCategoryId(Array.isArray(value) ? value[0] : value)}
|
||||
onClose={() => setCategoryPickerVisible(false)}
|
||||
/>
|
||||
</View>
|
||||
@@ -162,7 +202,7 @@ const styles = StyleSheet.create({
|
||||
borderBottomWidth: 1,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: -2 },
|
||||
shadowOpacity: 0.10,
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 6,
|
||||
elevation: 6,
|
||||
minHeight: 56,
|
||||
|
||||
@@ -18,8 +18,12 @@ export function ReminderSelector({ value, onChange, hasDueDate }: ReminderSelect
|
||||
const selectedReminders = parseReminders(value);
|
||||
const disabled = !hasDueDate;
|
||||
|
||||
const reminderLabels = selectedReminders.length > 0
|
||||
? selectedReminders.map(r => REMINDER_OPTIONS.find(o => o.value === r)?.label).filter(Boolean).join(', ')
|
||||
const reminderLabels =
|
||||
selectedReminders.length > 0
|
||||
? selectedReminders
|
||||
.map(r => REMINDER_OPTIONS.find(o => o.value === r)?.label)
|
||||
.filter(Boolean)
|
||||
.join(', ')
|
||||
: 'No reminder';
|
||||
|
||||
return (
|
||||
@@ -29,7 +33,10 @@ export function ReminderSelector({ value, onChange, hasDueDate }: ReminderSelect
|
||||
style={[
|
||||
styles.row,
|
||||
{ backgroundColor: theme.card, borderColor: theme.borderStrong },
|
||||
selectedReminders.length > 0 && { borderColor: theme.accent, backgroundColor: theme.accentSoft },
|
||||
selectedReminders.length > 0 && {
|
||||
borderColor: theme.accent,
|
||||
backgroundColor: theme.accentSoft,
|
||||
},
|
||||
disabled && { opacity: 0.5 },
|
||||
]}
|
||||
onPress={() => setShowPicker(true)}
|
||||
@@ -49,7 +56,14 @@ export function ReminderSelector({ value, onChange, hasDueDate }: ReminderSelect
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<Circle cx={18.5} cy={5.5} r={3.5} fill={selectedReminders.length > 0 ? theme.accent : 'transparent'} stroke={selectedReminders.length > 0 ? theme.accentText : theme.textSecondary} strokeWidth={1.8} />
|
||||
<Circle
|
||||
cx={18.5}
|
||||
cy={5.5}
|
||||
r={3.5}
|
||||
fill={selectedReminders.length > 0 ? theme.accent : 'transparent'}
|
||||
stroke={selectedReminders.length > 0 ? theme.accentText : theme.textSecondary}
|
||||
strokeWidth={1.8}
|
||||
/>
|
||||
</Svg>
|
||||
<Text
|
||||
style={[
|
||||
@@ -62,20 +76,32 @@ export function ReminderSelector({ value, onChange, hasDueDate }: ReminderSelect
|
||||
</Text>
|
||||
<View style={styles.chevron}>
|
||||
<Svg width={16} height={16} viewBox="0 0 24 24">
|
||||
<Path d="M9 18l6-6-6-6" stroke={theme.textSecondary} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
<Path
|
||||
d="M9 18l6-6-6-6"
|
||||
stroke={theme.textSecondary}
|
||||
strokeWidth={2.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
</Svg>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
{!hasDueDate && (
|
||||
<Text style={[styles.hint, { color: theme.textMuted }]}>Set a due date to add reminders.</Text>
|
||||
<Text style={[styles.hint, { color: theme.textMuted }]}>
|
||||
Set a due date to add reminders.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<OptionPickerModal
|
||||
visible={showPicker}
|
||||
title="Reminders"
|
||||
options={REMINDER_OPTIONS.filter((o) => o.value !== 'none').map((o) => ({ value: o.value, label: o.label }))}
|
||||
options={REMINDER_OPTIONS.filter(o => o.value !== 'none').map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
}))}
|
||||
selectedValue={selectedReminders}
|
||||
onSelect={(v) => onChange(toRemindersString(v as Reminder[]))}
|
||||
onSelect={v => onChange(toRemindersString(v as Reminder[]))}
|
||||
onClose={() => setShowPicker(false)}
|
||||
multiSelect
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity, Modal, TextInput, Pressable, KeyboardAvoidingView } from 'react-native';
|
||||
import { Repeat, REPEAT_OPTIONS, WEEKDAY_LABELS, WEEKDAY_ORDER, weekdaysInDisplayOrder, repeatDaysFromString } from '@/types';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
Modal,
|
||||
TextInput,
|
||||
Pressable,
|
||||
KeyboardAvoidingView,
|
||||
} from 'react-native';
|
||||
import {
|
||||
Repeat,
|
||||
REPEAT_OPTIONS,
|
||||
WEEKDAY_LABELS,
|
||||
WEEKDAY_ORDER,
|
||||
weekdaysInDisplayOrder,
|
||||
repeatDaysFromString,
|
||||
} from '@/types';
|
||||
import { useSettings } from '@/theme';
|
||||
import { useRepeatProfiles } from '@/hooks/useDatabase';
|
||||
import { createRepeatProfile, deleteRepeatProfile } from '@/utils/repeatProfileActions';
|
||||
@@ -43,7 +59,15 @@ function RepeatIcon({ repeat, color }: { repeat: Repeat; color: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
const WEEKDAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||
const WEEKDAY_NAMES = [
|
||||
'Sunday',
|
||||
'Monday',
|
||||
'Tuesday',
|
||||
'Wednesday',
|
||||
'Thursday',
|
||||
'Friday',
|
||||
'Saturday',
|
||||
];
|
||||
|
||||
function unitLabel(value: Repeat): string {
|
||||
switch (value) {
|
||||
@@ -77,12 +101,16 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
|
||||
};
|
||||
|
||||
const toggleDay = (day: number) => {
|
||||
const nextDays = days.includes(day) ? days.filter((d) => d !== day) : [...days, day].sort();
|
||||
const nextDays = days.includes(day) ? days.filter(d => d !== day) : [...days, day].sort();
|
||||
if (value === 'custom' && nextDays.length === 0) return;
|
||||
onChange(value, interval, nextDays);
|
||||
};
|
||||
|
||||
const applyProfile = (profile: { repeat: Repeat; repeatInterval: number; repeatDays: string }) => {
|
||||
const applyProfile = (profile: {
|
||||
repeat: Repeat;
|
||||
repeatInterval: number;
|
||||
repeatDays: string;
|
||||
}) => {
|
||||
onChange(profile.repeat, profile.repeatInterval, repeatDaysFromString(profile.repeatDays));
|
||||
};
|
||||
|
||||
@@ -116,7 +144,7 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
|
||||
<View style={styles.profileRow}>
|
||||
<Text style={[styles.profileLabel, { color: theme.textFaint }]}>Profiles</Text>
|
||||
<View style={styles.profileChips}>
|
||||
{profiles.map((profile) => (
|
||||
{profiles.map(profile => (
|
||||
<View
|
||||
key={profile.id}
|
||||
style={[
|
||||
@@ -131,8 +159,15 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
|
||||
},
|
||||
]}
|
||||
>
|
||||
<TouchableOpacity onPress={() => applyProfile(profile)} activeOpacity={0.8} accessibilityRole="button" accessibilityLabel={`Apply repeat profile ${profile.name}`}>
|
||||
<Text style={[styles.profileChipText, { color: theme.textSecondary }]}>{profile.name}</Text>
|
||||
<TouchableOpacity
|
||||
onPress={() => applyProfile(profile)}
|
||||
activeOpacity={0.8}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Apply repeat profile ${profile.name}`}
|
||||
>
|
||||
<Text style={[styles.profileChipText, { color: theme.textSecondary }]}>
|
||||
{profile.name}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={() => handleDeleteProfile(profile.id, profile.name)}
|
||||
@@ -149,13 +184,17 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
|
||||
)}
|
||||
|
||||
<View style={styles.chipRow} accessibilityRole="radiogroup" accessibilityLabel="Repeat">
|
||||
{REPEAT_OPTIONS.map((option) => (
|
||||
{REPEAT_OPTIONS.map(option => (
|
||||
<TouchableOpacity
|
||||
key={option.value}
|
||||
style={[
|
||||
styles.chip,
|
||||
{ backgroundColor: theme.card, borderColor: theme.borderStrong },
|
||||
value === option.value && { borderColor: theme.accent, backgroundColor: theme.accentSoft, borderWidth: 2 },
|
||||
value === option.value && {
|
||||
borderColor: theme.accent,
|
||||
backgroundColor: theme.accentSoft,
|
||||
borderWidth: 2,
|
||||
},
|
||||
]}
|
||||
onPress={() => selectRepeat(option.value)}
|
||||
activeOpacity={0.8}
|
||||
@@ -163,7 +202,10 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
|
||||
accessibilityLabel={`Repeat ${option.label.replace('No Repeat', 'none')}`}
|
||||
accessibilityState={{ selected: value === option.value }}
|
||||
>
|
||||
<RepeatIcon repeat={option.value} color={value === option.value ? theme.accent : theme.textFaint} />
|
||||
<RepeatIcon
|
||||
repeat={option.value}
|
||||
color={value === option.value ? theme.accent : theme.textFaint}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.chipText,
|
||||
@@ -179,7 +221,12 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
|
||||
|
||||
{value !== 'none' && (
|
||||
<View style={styles.settings}>
|
||||
<View style={[styles.intervalRow, { backgroundColor: theme.card, borderColor: theme.borderStrong }]}>
|
||||
<View
|
||||
style={[
|
||||
styles.intervalRow,
|
||||
{ backgroundColor: theme.card, borderColor: theme.borderStrong },
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.intervalLabel, { color: theme.textSecondary }]}>Every</Text>
|
||||
<TouchableOpacity
|
||||
style={[styles.stepButton, { borderColor: theme.borderStrong }]}
|
||||
@@ -190,7 +237,14 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
|
||||
accessibilityLabel="Decrease repeat interval"
|
||||
accessibilityState={{ disabled: interval <= 1 }}
|
||||
>
|
||||
<Text style={[styles.stepButtonText, { color: interval <= 1 ? theme.textMuted : theme.text }]}>−</Text>
|
||||
<Text
|
||||
style={[
|
||||
styles.stepButtonText,
|
||||
{ color: interval <= 1 ? theme.textMuted : theme.text },
|
||||
]}
|
||||
>
|
||||
−
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={[styles.intervalValue, { color: theme.text }]}>{interval}</Text>
|
||||
<TouchableOpacity
|
||||
@@ -202,7 +256,14 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
|
||||
accessibilityLabel="Increase repeat interval"
|
||||
accessibilityState={{ disabled: interval >= 30 }}
|
||||
>
|
||||
<Text style={[styles.stepButtonText, { color: interval >= 30 ? theme.textMuted : theme.text }]}>+</Text>
|
||||
<Text
|
||||
style={[
|
||||
styles.stepButtonText,
|
||||
{ color: interval >= 30 ? theme.textMuted : theme.text },
|
||||
]}
|
||||
>
|
||||
+
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={[styles.intervalLabel, { color: theme.textSecondary }]}>
|
||||
{unitLabel(value)}
|
||||
@@ -212,14 +273,20 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
|
||||
|
||||
{isDaysBased(value) && (
|
||||
<View style={styles.dayRow}>
|
||||
{WEEKDAY_ORDER.map((day) => (
|
||||
{WEEKDAY_ORDER.map(day => (
|
||||
<TouchableOpacity
|
||||
key={`${WEEKDAY_LABELS[day]}-${day}`}
|
||||
style={[
|
||||
styles.dayChip,
|
||||
{ backgroundColor: theme.card, borderColor: theme.borderStrong },
|
||||
days.includes(day) && { backgroundColor: theme.accent, borderColor: theme.accent },
|
||||
value === 'custom' && days.length === 1 && days.includes(day) && styles.dayChipLast,
|
||||
days.includes(day) && {
|
||||
backgroundColor: theme.accent,
|
||||
borderColor: theme.accent,
|
||||
},
|
||||
value === 'custom' &&
|
||||
days.length === 1 &&
|
||||
days.includes(day) &&
|
||||
styles.dayChipLast,
|
||||
]}
|
||||
onPress={() => toggleDay(day)}
|
||||
activeOpacity={0.8}
|
||||
@@ -250,9 +317,16 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Svg width={13} height={13} viewBox="0 0 24 24">
|
||||
<Path d="M12 5v14M5 12h14" stroke={theme.accent} strokeWidth={2.5} strokeLinecap="round" />
|
||||
<Path
|
||||
d="M12 5v14M5 12h14"
|
||||
stroke={theme.accent}
|
||||
strokeWidth={2.5}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</Svg>
|
||||
<Text style={[styles.saveProfileText, { color: theme.accentStrong }]}>Save as profile</Text>
|
||||
<Text style={[styles.saveProfileText, { color: theme.accentStrong }]}>
|
||||
Save as profile
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
@@ -262,19 +336,29 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
|
||||
animationType="fade"
|
||||
onRequestClose={() => setSaveModalVisible(false)}
|
||||
>
|
||||
<KeyboardAvoidingView
|
||||
style={styles.modalBackdrop}
|
||||
behavior="padding"
|
||||
>
|
||||
<KeyboardAvoidingView style={styles.modalBackdrop} behavior="padding">
|
||||
<Pressable style={styles.modalBackdrop} onPress={() => setSaveModalVisible(false)}>
|
||||
<Pressable style={[styles.modalCard, { backgroundColor: theme.card, borderColor: theme.border }]}>
|
||||
<Pressable
|
||||
style={[styles.modalCard, { backgroundColor: theme.card, borderColor: theme.border }]}
|
||||
>
|
||||
<Text style={[styles.modalTitle, { color: theme.text }]}>Save repeat profile</Text>
|
||||
<Text style={[styles.modalHint, { color: theme.textFaint }]}>
|
||||
{`${REPEAT_OPTIONS.find((o) => o.value === value)?.label.replace('No Repeat', 'None')}, every ${interval} ${unitLabel(value)}${interval > 1 ? 's' : ''}`}
|
||||
{isDaysBased(value) && days.length > 0 ? ` · ${weekdaysInDisplayOrder(days).map((d) => WEEKDAY_LABELS[d]).join(' ')}` : ''}
|
||||
{`${REPEAT_OPTIONS.find(o => o.value === value)?.label.replace('No Repeat', 'None')}, every ${interval} ${unitLabel(value)}${interval > 1 ? 's' : ''}`}
|
||||
{isDaysBased(value) && days.length > 0
|
||||
? ` · ${weekdaysInDisplayOrder(days)
|
||||
.map(d => WEEKDAY_LABELS[d])
|
||||
.join(' ')}`
|
||||
: ''}
|
||||
</Text>
|
||||
<TextInput
|
||||
style={[styles.modalInput, { backgroundColor: theme.cardAlt, borderColor: theme.borderStrong, color: theme.text }]}
|
||||
style={[
|
||||
styles.modalInput,
|
||||
{
|
||||
backgroundColor: theme.cardAlt,
|
||||
borderColor: theme.borderStrong,
|
||||
color: theme.text,
|
||||
},
|
||||
]}
|
||||
value={profileName}
|
||||
onChangeText={setProfileName}
|
||||
placeholder="Profile name (e.g. Every weekday)"
|
||||
@@ -290,15 +374,25 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
|
||||
onPress={() => setSaveModalVisible(false)}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text style={[styles.modalButtonText, { color: theme.textSecondary }]}>Cancel</Text>
|
||||
<Text style={[styles.modalButtonText, { color: theme.textSecondary }]}>
|
||||
Cancel
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.modalButton, styles.modalButtonPrimary, { backgroundColor: theme.accent }]}
|
||||
style={[
|
||||
styles.modalButton,
|
||||
styles.modalButtonPrimary,
|
||||
{ backgroundColor: theme.accent },
|
||||
]}
|
||||
onPress={handleSaveProfile}
|
||||
disabled={!profileName.trim()}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text style={[styles.modalButtonText, { color: theme.accentText, fontWeight: '600' }]}>Save</Text>
|
||||
<Text
|
||||
style={[styles.modalButtonText, { color: theme.accentText, fontWeight: '600' }]}
|
||||
>
|
||||
Save
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</Pressable>
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Modal, View, Text, StyleSheet, TextInput, TouchableOpacity, KeyboardAvoidingView } from 'react-native';
|
||||
import {
|
||||
Modal,
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
KeyboardAvoidingView,
|
||||
} from 'react-native';
|
||||
import { useSettings } from '@/theme';
|
||||
import { DEFAULT_API_BASE_URL } from '@/services/auth';
|
||||
import Svg, { Path } from 'react-native-svg';
|
||||
@@ -33,16 +41,22 @@ export function ServerUrlModal({ visible, onClose }: ServerUrlModalProps) {
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
||||
<KeyboardAvoidingView
|
||||
style={styles.overlay}
|
||||
behavior="padding"
|
||||
>
|
||||
<KeyboardAvoidingView style={styles.overlay} behavior="padding">
|
||||
<View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}>
|
||||
<View style={styles.header}>
|
||||
<Text style={[styles.title, { color: theme.text }]}>Backend URL</Text>
|
||||
<TouchableOpacity onPress={onClose} style={styles.closeButton} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
|
||||
<TouchableOpacity
|
||||
onPress={onClose}
|
||||
style={styles.closeButton}
|
||||
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
||||
>
|
||||
<Svg width={18} height={18} viewBox="0 0 24 24">
|
||||
<Path d="M18 6L6 18M6 6l12 12" stroke={theme.textSecondary} strokeWidth={2.2} strokeLinecap="round" />
|
||||
<Path
|
||||
d="M18 6L6 18M6 6l12 12"
|
||||
stroke={theme.textSecondary}
|
||||
strokeWidth={2.2}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</Svg>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
@@ -51,7 +65,11 @@ export function ServerUrlModal({ visible, onClose }: ServerUrlModalProps) {
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
{ backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text },
|
||||
{
|
||||
backgroundColor: theme.inputBg,
|
||||
borderColor: theme.borderStrong,
|
||||
color: theme.text,
|
||||
},
|
||||
]}
|
||||
placeholder={DEFAULT_API_BASE_URL}
|
||||
placeholderTextColor={theme.textMuted}
|
||||
@@ -75,7 +93,11 @@ export function ServerUrlModal({ visible, onClose }: ServerUrlModalProps) {
|
||||
<Text style={[styles.cancelButtonText, { color: theme.textSecondary }]}>Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.saveButton, { backgroundColor: theme.accent }, !isValid && styles.saveButtonDisabled]}
|
||||
style={[
|
||||
styles.saveButton,
|
||||
{ backgroundColor: theme.accent },
|
||||
!isValid && styles.saveButtonDisabled,
|
||||
]}
|
||||
onPress={handleSave}
|
||||
disabled={!isValid}
|
||||
activeOpacity={0.8}
|
||||
|
||||
@@ -46,7 +46,7 @@ export const SubtaskItem = React.memo(function SubtaskItem({
|
||||
const hasChildren = subtask.subtasks && subtask.subtasks.length > 0;
|
||||
const hovered = hoveredId === subtask.id;
|
||||
const effectiveColor = subtask.categoryId
|
||||
? categoryColorResolver?.(subtask.categoryId) ?? categoryColor
|
||||
? (categoryColorResolver?.(subtask.categoryId) ?? categoryColor)
|
||||
: categoryColor;
|
||||
|
||||
const handleExpand = () => setExpanded(!expanded);
|
||||
@@ -59,7 +59,7 @@ export const SubtaskItem = React.memo(function SubtaskItem({
|
||||
|
||||
return (
|
||||
<View>
|
||||
<View ref={(ref) => registerRef?.(subtask.id, subtask.taskId, ref)}>
|
||||
<View ref={ref => registerRef?.(subtask.id, subtask.taskId, ref)}>
|
||||
<TaskItem
|
||||
task={subtask}
|
||||
indented
|
||||
@@ -86,7 +86,7 @@ export const SubtaskItem = React.memo(function SubtaskItem({
|
||||
{subtask.subtasks
|
||||
.slice()
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((child) => (
|
||||
.map(child => (
|
||||
<SubtaskItem
|
||||
key={child.id}
|
||||
subtask={child}
|
||||
|
||||
@@ -48,7 +48,9 @@ export function SubtasksSection({ control }: SubtasksSectionProps) {
|
||||
<Text style={[styles.label, { color: theme.text }]}>Subtasks</Text>
|
||||
{items.length > 0 && (
|
||||
<View style={[styles.badge, { backgroundColor: theme.accentSoft }]}>
|
||||
<Text style={[styles.badgeText, { color: theme.accentStrong }]}>{items.length}</Text>
|
||||
<Text style={[styles.badgeText, { color: theme.accentStrong }]}>
|
||||
{items.length}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
@@ -92,20 +94,30 @@ export function SubtasksSection({ control }: SubtasksSectionProps) {
|
||||
key={item._key ?? `subtask-${index}`}
|
||||
index={index}
|
||||
value={item.title}
|
||||
onChange={(value) => updateSubtask(index, value)}
|
||||
onChange={value => updateSubtask(index, value)}
|
||||
onRemove={() => removeSubtask(index)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={[styles.addButton, { backgroundColor: theme.accentSoft, borderColor: theme.accentBorder }]}
|
||||
style={[
|
||||
styles.addButton,
|
||||
{ backgroundColor: theme.accentSoft, borderColor: theme.accentBorder },
|
||||
]}
|
||||
onPress={addSubtask}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Svg width={16} height={16} viewBox="0 0 24 24">
|
||||
<Path d="M12 5v14M5 12h14" stroke={theme.accent} strokeWidth={2} strokeLinecap="round" />
|
||||
<Path
|
||||
d="M12 5v14M5 12h14"
|
||||
stroke={theme.accent}
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</Svg>
|
||||
<Text style={[styles.addButtonText, { color: theme.accentStrong }]}>Add Subtask</Text>
|
||||
<Text style={[styles.addButtonText, { color: theme.accentStrong }]}>
|
||||
Add Subtask
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
</View>
|
||||
@@ -150,7 +162,12 @@ function SubtaskItem({ index, value, onChange, onRemove }: SubtaskItemProps) {
|
||||
/>
|
||||
<TouchableOpacity style={styles.removeButton} onPress={onRemove} activeOpacity={0.7}>
|
||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||
<Path d="M18 6L6 18M6 6l12 12" stroke={theme.textSecondary} strokeWidth={2.2} strokeLinecap="round" />
|
||||
<Path
|
||||
d="M18 6L6 18M6 6l12 12"
|
||||
stroke={theme.textSecondary}
|
||||
strokeWidth={2.2}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</Svg>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
@@ -11,9 +11,18 @@ import {
|
||||
} from 'react-native';
|
||||
import { useSettings } from '@/theme';
|
||||
import { AuthUser, getAuthToken, getAuthUser, login, register, signOutAuth } from '@/services/auth';
|
||||
import { runSyncGuarded, startAutoSync, stopAutoSync, getLastSyncTime, SyncResult } from '@/database/sync';
|
||||
import {
|
||||
runSyncGuarded,
|
||||
startAutoSync,
|
||||
stopAutoSync,
|
||||
getLastSyncTime,
|
||||
SyncResult,
|
||||
} from '@/database/sync';
|
||||
import { ForgotPasswordModal } from './ForgotPasswordModal';
|
||||
import { registerForPushNotificationsAsync, unregisterForPushNotificationsAsync } from '@/services/pushNotifications';
|
||||
import {
|
||||
registerForPushNotificationsAsync,
|
||||
unregisterForPushNotificationsAsync,
|
||||
} from '@/services/pushNotifications';
|
||||
|
||||
interface SyncModalProps {
|
||||
visible: boolean;
|
||||
@@ -81,7 +90,8 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const authedUser = mode === 'register'
|
||||
const authedUser =
|
||||
mode === 'register'
|
||||
? await register(username.trim(), password)
|
||||
: await login(username.trim(), password);
|
||||
setUser(authedUser);
|
||||
@@ -128,10 +138,7 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
||||
<KeyboardAvoidingView
|
||||
style={styles.overlay}
|
||||
behavior="padding"
|
||||
>
|
||||
<KeyboardAvoidingView style={styles.overlay} behavior="padding">
|
||||
<TouchableOpacity style={styles.backdrop} activeOpacity={1} onPress={onClose} />
|
||||
<View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}>
|
||||
<View style={[styles.handle, { backgroundColor: theme.borderStrong }]} />
|
||||
@@ -159,7 +166,9 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
|
||||
{status === 'syncing' ? (
|
||||
<ActivityIndicator color={theme.accentText} />
|
||||
) : (
|
||||
<Text style={[styles.primaryButtonText, { color: theme.accentText }]}>Sync Now</Text>
|
||||
<Text style={[styles.primaryButtonText, { color: theme.accentText }]}>
|
||||
Sync Now
|
||||
</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
@@ -181,7 +190,9 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
|
||||
onPress={handleSignOut}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={[styles.secondaryButtonText, { color: theme.dangerStrong }]}>Sign Out</Text>
|
||||
<Text style={[styles.secondaryButtonText, { color: theme.dangerStrong }]}>
|
||||
Sign Out
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : (
|
||||
@@ -191,13 +202,10 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
|
||||
</Text>
|
||||
|
||||
<View style={[styles.segmentRow, { backgroundColor: theme.inputBg }]}>
|
||||
{(['login', 'register'] as const).map((m) => (
|
||||
{(['login', 'register'] as const).map(m => (
|
||||
<TouchableOpacity
|
||||
key={m}
|
||||
style={[
|
||||
styles.segment,
|
||||
mode === m && { backgroundColor: theme.accent },
|
||||
]}
|
||||
style={[styles.segment, mode === m && { backgroundColor: theme.accent }]}
|
||||
onPress={() => {
|
||||
setMode(m);
|
||||
setError(null);
|
||||
@@ -222,7 +230,14 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
|
||||
</Text>
|
||||
)}
|
||||
<TextInput
|
||||
style={[styles.input, { backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text }]}
|
||||
style={[
|
||||
styles.input,
|
||||
{
|
||||
backgroundColor: theme.inputBg,
|
||||
borderColor: theme.borderStrong,
|
||||
color: theme.text,
|
||||
},
|
||||
]}
|
||||
placeholder="Username"
|
||||
placeholderTextColor={theme.textMuted}
|
||||
value={username}
|
||||
@@ -231,7 +246,14 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
|
||||
autoCorrect={false}
|
||||
/>
|
||||
<TextInput
|
||||
style={[styles.input, { backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text }]}
|
||||
style={[
|
||||
styles.input,
|
||||
{
|
||||
backgroundColor: theme.inputBg,
|
||||
borderColor: theme.borderStrong,
|
||||
color: theme.text,
|
||||
},
|
||||
]}
|
||||
placeholder="Password (min 8 chars)"
|
||||
placeholderTextColor={theme.textMuted}
|
||||
value={password}
|
||||
@@ -245,11 +267,15 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
|
||||
onPress={() => setShowForgotPassword(true)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={[styles.forgotPasswordText, { color: theme.accent }]}>Forgot Password?</Text>
|
||||
<Text style={[styles.forgotPasswordText, { color: theme.accent }]}>
|
||||
Forgot Password?
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{error && <Text style={[styles.errorText, { color: theme.dangerStrong }]}>{error}</Text>}
|
||||
{error && (
|
||||
<Text style={[styles.errorText, { color: theme.dangerStrong }]}>{error}</Text>
|
||||
)}
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.primaryButton, { backgroundColor: theme.accent }]}
|
||||
@@ -268,7 +294,10 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
|
||||
</View>
|
||||
)}
|
||||
|
||||
<ForgotPasswordModal visible={showForgotPassword} onClose={() => setShowForgotPassword(false)} />
|
||||
<ForgotPasswordModal
|
||||
visible={showForgotPassword}
|
||||
onClose={() => setShowForgotPassword(false)}
|
||||
/>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.secondaryButton, { borderColor: theme.borderStrong }]}
|
||||
|
||||
@@ -66,20 +66,37 @@ export default function SyncStatus({ compact = false }: SyncStatusProps) {
|
||||
case 'success':
|
||||
return (
|
||||
<Svg width={16} height={16} viewBox="0 0 24 24">
|
||||
<Path d="M5 12l5 5 9-10" stroke={theme.success} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
<Path
|
||||
d="M5 12l5 5 9-10"
|
||||
stroke={theme.success}
|
||||
strokeWidth={2.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
case 'error':
|
||||
return (
|
||||
<Svg width={16} height={16} viewBox="0 0 24 24">
|
||||
<Circle cx={12} cy={12} r={10} stroke={theme.danger} strokeWidth={2} fill="none" />
|
||||
<Path d="M12 8v4M12 16h.01" stroke={theme.danger} strokeWidth={2} strokeLinecap="round" />
|
||||
<Path
|
||||
d="M12 8v4M12 16h.01"
|
||||
stroke={theme.danger}
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Svg width={16} height={16} viewBox="0 0 24 24">
|
||||
<Path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" stroke={theme.textFaint} strokeWidth={1.8} fill="none" />
|
||||
<Path
|
||||
d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"
|
||||
stroke={theme.textFaint}
|
||||
strokeWidth={1.8}
|
||||
fill="none"
|
||||
/>
|
||||
<Path d="M12 22V12" stroke={theme.textFaint} strokeWidth={1.8} strokeLinecap="round" />
|
||||
</Svg>
|
||||
);
|
||||
@@ -88,16 +105,25 @@ export default function SyncStatus({ compact = false }: SyncStatusProps) {
|
||||
|
||||
const getStatusText = () => {
|
||||
switch (status) {
|
||||
case 'syncing': return 'Syncing...';
|
||||
case 'success': return 'Synced';
|
||||
case 'error': return 'Sync failed';
|
||||
default: return lastSync ? `Last sync: ${formatTime(lastSync)}` : 'Not synced';
|
||||
case 'syncing':
|
||||
return 'Syncing...';
|
||||
case 'success':
|
||||
return 'Synced';
|
||||
case 'error':
|
||||
return 'Sync failed';
|
||||
default:
|
||||
return lastSync ? `Last sync: ${formatTime(lastSync)}` : 'Not synced';
|
||||
}
|
||||
};
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<TouchableOpacity style={[styles.compact, { backgroundColor: theme.sheetBg }]} onPress={handleSync} disabled={status === 'syncing'} activeOpacity={0.7}>
|
||||
<TouchableOpacity
|
||||
style={[styles.compact, { backgroundColor: theme.sheetBg }]}
|
||||
onPress={handleSync}
|
||||
disabled={status === 'syncing'}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.compactRow}>
|
||||
{getStatusIcon()}
|
||||
<Text style={[styles.compactText, { color: theme.textMuted }]}>
|
||||
@@ -116,12 +142,20 @@ export default function SyncStatus({ compact = false }: SyncStatusProps) {
|
||||
<Text style={[styles.statusText, { color: theme.text }]}>{getStatusText()}</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={[styles.syncButton, { backgroundColor: status === 'syncing' ? theme.accentSoft : theme.accent }]}
|
||||
style={[
|
||||
styles.syncButton,
|
||||
{ backgroundColor: status === 'syncing' ? theme.accentSoft : theme.accent },
|
||||
]}
|
||||
onPress={handleSync}
|
||||
disabled={status === 'syncing'}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text style={[styles.syncButtonText, { color: status === 'syncing' ? theme.accent : theme.accentText }]}>
|
||||
<Text
|
||||
style={[
|
||||
styles.syncButtonText,
|
||||
{ color: status === 'syncing' ? theme.accent : theme.accentText },
|
||||
]}
|
||||
>
|
||||
{status === 'syncing' ? 'Syncing...' : 'Sync Now'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
@@ -56,7 +56,14 @@ export function TabBarIcon({ name, focused, color, size = 24 }: TabBarIconProps)
|
||||
)}
|
||||
{name === 'gear' && (
|
||||
<>
|
||||
<Circle cx={12} cy={12} r={3.5} stroke={color} strokeWidth={focused ? 3.5 : 3} fill="none" />
|
||||
<Circle
|
||||
cx={12}
|
||||
cy={12}
|
||||
r={3.5}
|
||||
stroke={color}
|
||||
strokeWidth={focused ? 3.5 : 3}
|
||||
fill="none"
|
||||
/>
|
||||
<Path
|
||||
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"
|
||||
stroke={color}
|
||||
@@ -76,9 +83,7 @@ export function TabBarIcon({ name, focused, color, size = 24 }: TabBarIconProps)
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{focused && (
|
||||
<Circle cx={4} cy={10} r={2} fill={color} />
|
||||
)}
|
||||
{focused && <Circle cx={4} cy={10} r={2} fill={color} />}
|
||||
</>
|
||||
)}
|
||||
</Svg>
|
||||
|
||||
@@ -14,7 +14,15 @@ interface TaskDeleteModalProps {
|
||||
onDelete: (scope: TaskDeleteScope) => void;
|
||||
}
|
||||
|
||||
export function TaskDeleteModal({ visible, taskId, taskTitle, isRepeating, subtask, onClose, onDelete }: TaskDeleteModalProps) {
|
||||
export function TaskDeleteModal({
|
||||
visible,
|
||||
taskId,
|
||||
taskTitle,
|
||||
isRepeating,
|
||||
subtask,
|
||||
onClose,
|
||||
onDelete,
|
||||
}: TaskDeleteModalProps) {
|
||||
const { theme } = useSettings();
|
||||
const [counts, setCounts] = useState({ future: 1, all: 1 });
|
||||
|
||||
@@ -22,11 +30,13 @@ export function TaskDeleteModal({ visible, taskId, taskTitle, isRepeating, subta
|
||||
if (!visible || !taskId || subtask) return;
|
||||
let mounted = true;
|
||||
getSeriesOccurrenceCounts(taskId)
|
||||
.then((c) => {
|
||||
.then(c => {
|
||||
if (mounted) setCounts(c);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => { mounted = false; };
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [visible, taskId, subtask]);
|
||||
|
||||
const options: { scope: TaskDeleteScope; label: string; hint?: string }[] = subtask
|
||||
@@ -34,7 +44,11 @@ export function TaskDeleteModal({ visible, taskId, taskTitle, isRepeating, subta
|
||||
: [{ scope: 'this', label: 'This task only' }];
|
||||
|
||||
if (isRepeating) {
|
||||
options.push({ scope: 'future', label: 'This and future tasks', hint: counts.future > 1 ? `${counts.future} occurrences` : undefined });
|
||||
options.push({
|
||||
scope: 'future',
|
||||
label: 'This and future tasks',
|
||||
hint: counts.future > 1 ? `${counts.future} occurrences` : undefined,
|
||||
});
|
||||
options.push({ scope: 'all', label: 'All tasks in series', hint: `${counts.all} occurrences` });
|
||||
}
|
||||
|
||||
@@ -43,10 +57,21 @@ export function TaskDeleteModal({ visible, taskId, taskTitle, isRepeating, subta
|
||||
<View style={styles.overlay}>
|
||||
<View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}>
|
||||
<View style={styles.header}>
|
||||
<Text style={[styles.title, { color: theme.text }]}>{subtask ? 'Delete Subtask' : 'Delete Task'}</Text>
|
||||
<TouchableOpacity onPress={onClose} style={styles.closeButton} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
|
||||
<Text style={[styles.title, { color: theme.text }]}>
|
||||
{subtask ? 'Delete Subtask' : 'Delete Task'}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
onPress={onClose}
|
||||
style={styles.closeButton}
|
||||
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
||||
>
|
||||
<Svg width={18} height={18} viewBox="0 0 24 24">
|
||||
<Path d="M18 6L6 18M6 6l12 12" stroke={theme.textSecondary} strokeWidth={2.2} strokeLinecap="round" />
|
||||
<Path
|
||||
d="M18 6L6 18M6 6l12 12"
|
||||
stroke={theme.textSecondary}
|
||||
strokeWidth={2.2}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</Svg>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
@@ -64,19 +89,26 @@ export function TaskDeleteModal({ visible, taskId, taskTitle, isRepeating, subta
|
||||
)}
|
||||
|
||||
<View style={styles.actions}>
|
||||
{options.map((option) => (
|
||||
{options.map(option => (
|
||||
<TouchableOpacity
|
||||
key={option.scope}
|
||||
style={[styles.actionButton, { borderColor: theme.accentBorder }]}
|
||||
onPress={() => onDelete(option.scope)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={[styles.actionLabel, { color: theme.accentStrong }]}>{option.label}</Text>
|
||||
{option.hint && <Text style={[styles.actionHint, { color: theme.textMuted }]}>{option.hint}</Text>}
|
||||
<Text style={[styles.actionLabel, { color: theme.accentStrong }]}>
|
||||
{option.label}
|
||||
</Text>
|
||||
{option.hint && (
|
||||
<Text style={[styles.actionHint, { color: theme.textMuted }]}>{option.hint}</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
<TouchableOpacity
|
||||
style={[styles.cancelButton, { borderColor: theme.borderStrong, backgroundColor: theme.card }]}
|
||||
style={[
|
||||
styles.cancelButton,
|
||||
{ borderColor: theme.borderStrong, backgroundColor: theme.card },
|
||||
]}
|
||||
onPress={onClose}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity, Animated, Platform, Dimensions } from 'react-native';
|
||||
import { Swipeable, Gesture, GestureDetector, PanGestureHandler } from 'react-native-gesture-handler';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
Animated,
|
||||
Platform,
|
||||
Dimensions,
|
||||
} from 'react-native';
|
||||
import {
|
||||
Swipeable,
|
||||
Gesture,
|
||||
GestureDetector,
|
||||
PanGestureHandler,
|
||||
} from 'react-native-gesture-handler';
|
||||
import { TaskData, SubtaskData, Priority, Repeat, Reminder } from '@/types';
|
||||
import { PRIORITY_COLORS } from '@/constants';
|
||||
import { useSettings, mixHex } from '@/theme';
|
||||
@@ -46,7 +59,30 @@ interface TaskItemProps {
|
||||
categoryColors?: (string | undefined)[];
|
||||
}
|
||||
|
||||
export const TaskItem = React.memo(function TaskItem({ task, onToggle, onDelete, onPress, onLongPress, onMenuOpen, selected, selectionMode, completedSection, draggable, hovered, onDragStart, onDragUpdate, onDragEnd, onReorderStart, onReorderUpdate, onReorderEnd, expanded, indented, depth = 0, categoryColor, categoryColors }: TaskItemProps) {
|
||||
export const TaskItem = React.memo(function TaskItem({
|
||||
task,
|
||||
onToggle,
|
||||
onDelete,
|
||||
onPress,
|
||||
onLongPress,
|
||||
onMenuOpen,
|
||||
selected,
|
||||
selectionMode,
|
||||
completedSection,
|
||||
draggable,
|
||||
hovered,
|
||||
onDragStart,
|
||||
onDragUpdate,
|
||||
onDragEnd,
|
||||
onReorderStart,
|
||||
onReorderUpdate,
|
||||
onReorderEnd,
|
||||
expanded,
|
||||
indented,
|
||||
depth = 0,
|
||||
categoryColor,
|
||||
categoryColors,
|
||||
}: TaskItemProps) {
|
||||
const { theme, deleteBySwipe } = useSettings();
|
||||
const styles = React.useMemo(() => createStyles(theme), [theme]);
|
||||
const [opacityAnim] = React.useState(new Animated.Value(task.completed ? 0.5 : 1));
|
||||
@@ -73,17 +109,17 @@ export const TaskItem = React.memo(function TaskItem({ task, onToggle, onDelete,
|
||||
.activateAfterLongPress(400)
|
||||
.minDistance(5)
|
||||
.runOnJS(true)
|
||||
.onStart((e) => {
|
||||
.onStart(e => {
|
||||
setDragging(true);
|
||||
setDragStartY(e.absoluteY);
|
||||
onReorderStart?.();
|
||||
})
|
||||
.onUpdate((e) => {
|
||||
.onUpdate(e => {
|
||||
dragTranslateX.setValue(e.translationX);
|
||||
dragTranslateY.setValue(e.translationY);
|
||||
onReorderUpdate?.(e.absoluteY);
|
||||
})
|
||||
.onEnd((e) => {
|
||||
.onEnd(e => {
|
||||
onReorderEnd?.(e.absoluteY, e.translationY);
|
||||
})
|
||||
.onFinalize(() => {
|
||||
@@ -104,12 +140,12 @@ export const TaskItem = React.memo(function TaskItem({ task, onToggle, onDelete,
|
||||
setDragging(true);
|
||||
onDragStart?.();
|
||||
})
|
||||
.onUpdate((e) => {
|
||||
.onUpdate(e => {
|
||||
dragTranslateX.setValue(e.translationX);
|
||||
dragTranslateY.setValue(e.translationY);
|
||||
onDragUpdate?.(e.absoluteY);
|
||||
})
|
||||
.onEnd((e) => {
|
||||
.onEnd(e => {
|
||||
onDragEnd?.(e.absoluteY);
|
||||
})
|
||||
.onFinalize(() => {
|
||||
@@ -135,7 +171,11 @@ export const TaskItem = React.memo(function TaskItem({ task, onToggle, onDelete,
|
||||
endOfToday.setHours(23, 59, 59, 999);
|
||||
const hasDueDate = task.dueDate > 0;
|
||||
const isOverdue = hasDueDate && !task.completed && task.dueDate < startOfToday.getTime();
|
||||
const isDueToday = hasDueDate && !task.completed && task.dueDate >= startOfToday.getTime() && task.dueDate <= endOfToday.getTime();
|
||||
const isDueToday =
|
||||
hasDueDate &&
|
||||
!task.completed &&
|
||||
task.dueDate >= startOfToday.getTime() &&
|
||||
task.dueDate <= endOfToday.getTime();
|
||||
return { hasDueDate, isOverdue, isDueToday };
|
||||
}, [task.dueDate, task.completed]);
|
||||
|
||||
@@ -149,21 +189,39 @@ export const TaskItem = React.memo(function TaskItem({ task, onToggle, onDelete,
|
||||
const renderRightActions = (progress: Animated.AnimatedInterpolation<number>) => {
|
||||
const translateX = progress.interpolate({ inputRange: [0, 1], outputRange: [80, 0] });
|
||||
return (
|
||||
<Animated.View style={[styles.swipeAction, styles.swipeDelete, { transform: [{ translateX }] }]}>
|
||||
<Animated.View
|
||||
style={[styles.swipeAction, styles.swipeDelete, { transform: [{ translateX }] }]}
|
||||
>
|
||||
<Svg width={24} height={24} viewBox="0 0 24 24">
|
||||
<Path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13" stroke="#FFFFFF" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
<Path
|
||||
d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"
|
||||
stroke="#FFFFFF"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
</Svg>
|
||||
<Text style={styles.swipeActionText}>Delete</Text>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) => {
|
||||
const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) => {
|
||||
const translateX = progress.interpolate({ inputRange: [0, 1], outputRange: [-80, 0] });
|
||||
return (
|
||||
<Animated.View style={[styles.swipeAction, styles.swipeComplete, { transform: [{ translateX }] }]}>
|
||||
<Animated.View
|
||||
style={[styles.swipeAction, styles.swipeComplete, { transform: [{ translateX }] }]}
|
||||
>
|
||||
<Svg width={24} height={24} viewBox="0 0 24 24">
|
||||
<Path d="M5 12l5 5 9-10" stroke="#FFFFFF" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
<Path
|
||||
d="M5 12l5 5 9-10"
|
||||
stroke="#FFFFFF"
|
||||
strokeWidth={2.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
</Svg>
|
||||
<Text style={styles.swipeActionText}>{task.completed ? 'Undo' : 'Done'}</Text>
|
||||
</Animated.View>
|
||||
@@ -188,7 +246,9 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) =>
|
||||
overshootRight={false}
|
||||
overshootLeft={false}
|
||||
>
|
||||
<GestureDetector gesture={draggable ? (onReorderStart ? reorderGesture : dragGesture) : Gesture.Native()}>
|
||||
<GestureDetector
|
||||
gesture={draggable ? (onReorderStart ? reorderGesture : dragGesture) : Gesture.Native()}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.container,
|
||||
@@ -217,7 +277,7 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) =>
|
||||
accessibilityState={selectionMode ? { checked: selected } : { expanded }}
|
||||
accessibilityHint={selectionMode ? undefined : 'Expands the task to show subtasks'}
|
||||
>
|
||||
<View style={styles.content}>
|
||||
<View style={styles.content}>
|
||||
<View style={styles.titleRow}>
|
||||
<View style={styles.categoryDotSlot}>
|
||||
{categoryColor && (
|
||||
@@ -236,10 +296,24 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) =>
|
||||
{task.completed ? (
|
||||
<>
|
||||
<Circle cx={12} cy={12} r={10} fill={theme.accent} />
|
||||
<Path d="M7 12l3 3 7-7" stroke="#FFFFFF" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
<Path
|
||||
d="M7 12l3 3 7-7"
|
||||
stroke="#FFFFFF"
|
||||
strokeWidth={2.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Circle cx={12} cy={12} r={10} stroke={isOverdue ? theme.danger : theme.textSecondary} strokeWidth={2.5} fill="none" />
|
||||
<Circle
|
||||
cx={12}
|
||||
cy={12}
|
||||
r={10}
|
||||
stroke={isOverdue ? theme.danger : theme.textSecondary}
|
||||
strokeWidth={2.5}
|
||||
fill="none"
|
||||
/>
|
||||
)}
|
||||
</Svg>
|
||||
</TouchableOpacity>
|
||||
@@ -260,9 +334,7 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) =>
|
||||
{ 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>
|
||||
)}
|
||||
{task.assigneeId && (
|
||||
@@ -296,7 +368,14 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) =>
|
||||
strokeLinecap="round"
|
||||
fill="none"
|
||||
/>
|
||||
<Circle cx={12} cy={12} r={4} stroke={theme.accent} strokeWidth={1.5} fill="none" />
|
||||
<Circle
|
||||
cx={12}
|
||||
cy={12}
|
||||
r={4}
|
||||
stroke={theme.accent}
|
||||
strokeWidth={1.5}
|
||||
fill="none"
|
||||
/>
|
||||
</Svg>
|
||||
<Text style={styles.allDayText}>All Day</Text>
|
||||
</View>
|
||||
@@ -317,8 +396,20 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) =>
|
||||
{hasDueDate && (
|
||||
<View style={styles.dueRow}>
|
||||
<Svg width={14} height={14} viewBox="0 0 24 24">
|
||||
<Circle cx={12} cy={12} r={10} stroke={theme.textSecondary} strokeWidth={2} fill="none" />
|
||||
<Path d="M12 6v6l4 2" stroke={theme.textSecondary} strokeWidth={2} strokeLinecap="round" />
|
||||
<Circle
|
||||
cx={12}
|
||||
cy={12}
|
||||
r={10}
|
||||
stroke={theme.textSecondary}
|
||||
strokeWidth={2}
|
||||
fill="none"
|
||||
/>
|
||||
<Path
|
||||
d="M12 6v6l4 2"
|
||||
stroke={theme.textSecondary}
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</Svg>
|
||||
<Animated.Text
|
||||
style={[
|
||||
@@ -400,7 +491,8 @@ function formatTime12(time: string): string {
|
||||
return `${h}:${String(minutes).padStart(2, '0')} ${period}`;
|
||||
}
|
||||
|
||||
const createStyles = (theme: ThemeColors) => StyleSheet.create({
|
||||
const createStyles = (theme: ThemeColors) =>
|
||||
StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
@@ -600,4 +692,4 @@ const createStyles = (theme: ThemeColors) => StyleSheet.create({
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import React, { useState, useCallback, useMemo, useRef } from 'react';
|
||||
import { View, Text, StyleSheet, Animated, RefreshControl, Alert, TouchableOpacity } from 'react-native';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
Animated,
|
||||
RefreshControl,
|
||||
Alert,
|
||||
TouchableOpacity,
|
||||
} from 'react-native';
|
||||
import { useTasks } from '@/hooks/useTasks';
|
||||
import { useSubtasks } from '@/hooks/useSubtasks';
|
||||
import { useCategories } from '@/hooks/useDatabase';
|
||||
@@ -42,10 +50,18 @@ const DropIndicator = ({ theme }: { theme: any }) => (
|
||||
</View>
|
||||
);
|
||||
|
||||
export function TaskList({ categoryIds = [], showCompleted = false, onSelectionChange }: TaskListProps) {
|
||||
export function TaskList({
|
||||
categoryIds = [],
|
||||
showCompleted = false,
|
||||
onSelectionChange,
|
||||
}: TaskListProps) {
|
||||
const { theme, sortBy, todoAheadDays } = useSettings();
|
||||
|
||||
const { tasks, loading, refresh: refreshTasks } = useTasks(categoryIds, showCompleted ? 'all' : false, todoAheadDays);
|
||||
const {
|
||||
tasks,
|
||||
loading,
|
||||
refresh: refreshTasks,
|
||||
} = useTasks(categoryIds, showCompleted ? 'all' : false, todoAheadDays);
|
||||
|
||||
const categories = useCategories();
|
||||
const categoryColors = useMemo(() => {
|
||||
@@ -62,18 +78,30 @@ export function TaskList({ categoryIds = [], showCompleted = false, onSelectionC
|
||||
const [hoverTaskId, setHoverTaskId] = useState<string | null>(null);
|
||||
const [expandedTasks, setExpandedTasks] = useState<Set<string>>(new Set());
|
||||
const { map: subtasksMap, refresh: refreshSubtasks } = useSubtasks();
|
||||
const [dropIndicator, setDropIndicator] = useState<{ targetId: string | null; position: 'above' | 'below' } | null>(null);
|
||||
const [dropIndicator, setDropIndicator] = useState<{
|
||||
targetId: string | null;
|
||||
position: 'above' | 'below';
|
||||
} | null>(null);
|
||||
const itemRefs = useRef<Map<string, View>>(new Map());
|
||||
const subtaskRefs = useRef<Map<string, { ref: View; parentTaskId: string }>>(new Map());
|
||||
const dragStateRef = useRef<{ taskId: string; positions: Record<string, { top: number; bottom: number }>; subPositions: Record<string, { top: number; bottom: number }> } | null>(null);
|
||||
const subtaskDragRef = useRef<{ subtaskId: string; parentTaskId: string; parentSubtaskId: string | null; positions: Record<string, { top: number; bottom: number }>; subPositions: Record<string, { top: number; bottom: number }> } | null>(null);
|
||||
const {
|
||||
modals,
|
||||
openTaskDelete,
|
||||
openSubtaskDelete,
|
||||
openSubtaskEdit,
|
||||
openTaskEdit,
|
||||
} = useTaskModals();
|
||||
const dragStateRef = useRef<{
|
||||
taskId: string;
|
||||
positions: Record<string, { top: number; bottom: number }>;
|
||||
subPositions: Record<string, { top: number; bottom: number }>;
|
||||
} | null>(null);
|
||||
const subtaskDragRef = useRef<{
|
||||
subtaskId: string;
|
||||
parentTaskId: string;
|
||||
parentSubtaskId: string | null;
|
||||
positions: Record<string, { top: number; bottom: number }>;
|
||||
subPositions: Record<string, { top: number; bottom: number }>;
|
||||
} | null>(null);
|
||||
const taskDragRef = useRef<{
|
||||
taskId: string;
|
||||
positions: Record<string, { top: number; bottom: number }>;
|
||||
} | null>(null);
|
||||
const { modals, openTaskDelete, openSubtaskDelete, openSubtaskEdit, openTaskEdit } =
|
||||
useTaskModals();
|
||||
|
||||
const registerRef = useCallback((taskId: string, ref: View | null) => {
|
||||
if (ref) {
|
||||
@@ -83,13 +111,16 @@ export function TaskList({ categoryIds = [], showCompleted = false, onSelectionC
|
||||
}
|
||||
}, []);
|
||||
|
||||
const registerSubtaskRef = useCallback((subtaskId: string, parentTaskId: string, ref: View | null) => {
|
||||
const registerSubtaskRef = useCallback(
|
||||
(subtaskId: string, parentTaskId: string, ref: View | null) => {
|
||||
if (ref) {
|
||||
subtaskRefs.current.set(subtaskId, { ref, parentTaskId });
|
||||
} else {
|
||||
subtaskRefs.current.delete(subtaskId);
|
||||
}
|
||||
}, []);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const categoryColorResolver = useCallback(
|
||||
(categoryId: string | undefined) => (categoryId ? categoryColors.get(categoryId) : undefined),
|
||||
@@ -100,7 +131,9 @@ export function TaskList({ categoryIds = [], showCompleted = false, onSelectionC
|
||||
const sorted = [...tasks];
|
||||
switch (sortBy) {
|
||||
case 'date':
|
||||
sorted.sort((a, b) => (a.dueDate || Number.MAX_SAFE_INTEGER) - (b.dueDate || Number.MAX_SAFE_INTEGER));
|
||||
sorted.sort(
|
||||
(a, b) => (a.dueDate || Number.MAX_SAFE_INTEGER) - (b.dueDate || Number.MAX_SAFE_INTEGER)
|
||||
);
|
||||
break;
|
||||
case 'priority':
|
||||
sorted.sort((a, b) => PRIORITY_RANK[b.priority] - PRIORITY_RANK[a.priority]);
|
||||
@@ -145,14 +178,18 @@ export function TaskList({ categoryIds = [], showCompleted = false, onSelectionC
|
||||
onSelectionChange?.(false);
|
||||
}, [onSelectionChange]);
|
||||
|
||||
const enterSelection = useCallback((taskId: string) => {
|
||||
const enterSelection = useCallback(
|
||||
(taskId: string) => {
|
||||
setSelectionMode(true);
|
||||
setSelectedIds(new Set([taskId]));
|
||||
onSelectionChange?.(true);
|
||||
}, [onSelectionChange]);
|
||||
},
|
||||
[onSelectionChange]
|
||||
);
|
||||
|
||||
const toggleSelect = useCallback((taskId: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const toggleSelect = useCallback(
|
||||
(taskId: string) => {
|
||||
setSelectedIds(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(taskId)) {
|
||||
next.delete(taskId);
|
||||
@@ -165,14 +202,19 @@ export function TaskList({ categoryIds = [], showCompleted = false, onSelectionC
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [onSelectionChange]);
|
||||
},
|
||||
[onSelectionChange]
|
||||
);
|
||||
|
||||
const handleToggle = useCallback(async (taskId: string) => {
|
||||
const handleToggle = useCallback(
|
||||
async (taskId: string) => {
|
||||
await confirmAndToggleTaskComplete(taskId, () => refreshTasks());
|
||||
}, [refreshTasks]);
|
||||
},
|
||||
[refreshTasks]
|
||||
);
|
||||
|
||||
const toggleExpand = useCallback(async (taskId: string) => {
|
||||
setExpandedTasks((prev) => {
|
||||
setExpandedTasks(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(taskId)) {
|
||||
next.delete(taskId);
|
||||
@@ -183,14 +225,20 @@ export function TaskList({ categoryIds = [], showCompleted = false, onSelectionC
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSubtaskToggle = useCallback(async (subtaskId: string) => {
|
||||
const handleSubtaskToggle = useCallback(
|
||||
async (subtaskId: string) => {
|
||||
await toggleSubtaskComplete(subtaskId);
|
||||
refreshTasks();
|
||||
refreshSubtasks();
|
||||
}, [refreshTasks, refreshSubtasks]);
|
||||
},
|
||||
[refreshTasks, refreshSubtasks]
|
||||
);
|
||||
|
||||
const handleBulkDelete = useCallback(() => {
|
||||
Alert.alert(`Delete ${selectedIds.size} task${selectedIds.size > 1 ? 's' : ''}?`, 'This cannot be undone.', [
|
||||
Alert.alert(
|
||||
`Delete ${selectedIds.size} task${selectedIds.size > 1 ? 's' : ''}?`,
|
||||
'This cannot be undone.',
|
||||
[
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{
|
||||
text: 'Delete',
|
||||
@@ -200,7 +248,8 @@ export function TaskList({ categoryIds = [], showCompleted = false, onSelectionC
|
||||
exitSelection();
|
||||
},
|
||||
},
|
||||
]);
|
||||
]
|
||||
);
|
||||
}, [selectedIds, exitSelection]);
|
||||
|
||||
const handleBulkComplete = useCallback(async () => {
|
||||
@@ -211,14 +260,16 @@ export function TaskList({ categoryIds = [], showCompleted = false, onSelectionC
|
||||
const measureItems = useCallback(async () => {
|
||||
const positions: Record<string, { top: number; bottom: number }> = {};
|
||||
const entries = Array.from(itemRefs.current.entries());
|
||||
await Promise.all(entries.map(([id, ref]) => {
|
||||
return new Promise<void>((resolve) => {
|
||||
await Promise.all(
|
||||
entries.map(([id, ref]) => {
|
||||
return new Promise<void>(resolve => {
|
||||
ref?.measureInWindow((_x, y, _w, h) => {
|
||||
positions[id] = { top: y, bottom: y + h };
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}));
|
||||
})
|
||||
);
|
||||
return positions;
|
||||
}, []);
|
||||
|
||||
@@ -226,18 +277,49 @@ export function TaskList({ categoryIds = [], showCompleted = false, onSelectionC
|
||||
const positions = await measureItems();
|
||||
const subPositions: Record<string, { top: number; bottom: number }> = {};
|
||||
const entries = Array.from(subtaskRefs.current.entries());
|
||||
await Promise.all(entries.map(([id, entry]) => {
|
||||
return new Promise<void>((resolve) => {
|
||||
await Promise.all(
|
||||
entries.map(([id, entry]) => {
|
||||
return new Promise<void>(resolve => {
|
||||
entry.ref?.measureInWindow((_x, y, _w, h) => {
|
||||
subPositions[id] = { top: y, bottom: y + h };
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}));
|
||||
})
|
||||
);
|
||||
return { positions, subPositions };
|
||||
}, [measureItems]);
|
||||
|
||||
const findHoverTarget = useCallback((absoluteY: number, draggedId: string, positions: Record<string, { top: number; bottom: number }>, subPositions: Record<string, { top: number; bottom: number }>) => {
|
||||
const findHoverTask = useCallback(
|
||||
(
|
||||
absoluteY: number,
|
||||
draggedId: string,
|
||||
positions: Record<string, { top: number; bottom: number }>
|
||||
) => {
|
||||
const entries = Object.entries(positions);
|
||||
for (let i = entries.length - 1; i >= 0; i--) {
|
||||
const [id, p] = entries[i];
|
||||
if (id === draggedId) continue;
|
||||
if (absoluteY >= p.top && absoluteY <= p.bottom) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const updateHoverTaskTarget = useCallback((target: string | null) => {
|
||||
setHoverTaskId(target ?? null);
|
||||
}, []);
|
||||
|
||||
const findHoverTarget = useCallback(
|
||||
(
|
||||
absoluteY: number,
|
||||
draggedId: string,
|
||||
positions: Record<string, { top: number; bottom: number }>,
|
||||
subPositions: Record<string, { top: number; bottom: number }>
|
||||
) => {
|
||||
const subEntries = Object.entries(subPositions);
|
||||
for (let i = subEntries.length - 1; i >= 0; i--) {
|
||||
const [id, p] = subEntries[i];
|
||||
@@ -253,21 +335,33 @@ export function TaskList({ categoryIds = [], showCompleted = false, onSelectionC
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, []);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const calculateDropPosition = useCallback((absoluteY: number, targetId: string, positions: Record<string, { top: number; bottom: number }>) => {
|
||||
const calculateDropPosition = useCallback(
|
||||
(
|
||||
absoluteY: number,
|
||||
targetId: string,
|
||||
positions: Record<string, { top: number; bottom: number }>
|
||||
) => {
|
||||
const target = positions[targetId];
|
||||
if (!target) return 'below' as const;
|
||||
const middle = (target.top + target.bottom) / 2;
|
||||
return absoluteY < middle ? 'above' : 'below';
|
||||
}, []);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleDragStart = useCallback(async (taskId: string) => {
|
||||
const handleDragStart = useCallback(
|
||||
async (taskId: string) => {
|
||||
dragStateRef.current = { taskId, ...(await measureAll()) };
|
||||
}, [measureAll]);
|
||||
},
|
||||
[measureAll]
|
||||
);
|
||||
|
||||
const refreshDragPositions = useCallback(() => {
|
||||
measureAll().then((m) => {
|
||||
measureAll().then(m => {
|
||||
if (subtaskDragRef.current) {
|
||||
subtaskDragRef.current = { ...subtaskDragRef.current, ...m };
|
||||
} else if (dragStateRef.current) {
|
||||
@@ -276,9 +370,10 @@ export function TaskList({ categoryIds = [], showCompleted = false, onSelectionC
|
||||
});
|
||||
}, [measureAll]);
|
||||
|
||||
const updateHoverTarget = useCallback((target: string | null) => {
|
||||
const updateHoverTarget = useCallback(
|
||||
(target: string | null) => {
|
||||
let changed = false;
|
||||
setHoverTaskId((prev) => {
|
||||
setHoverTaskId(prev => {
|
||||
if (prev === target) return prev;
|
||||
changed = true;
|
||||
return target;
|
||||
@@ -286,9 +381,12 @@ export function TaskList({ categoryIds = [], showCompleted = false, onSelectionC
|
||||
if (changed && target) {
|
||||
setTimeout(refreshDragPositions, 80);
|
||||
}
|
||||
}, [refreshDragPositions]);
|
||||
},
|
||||
[refreshDragPositions]
|
||||
);
|
||||
|
||||
const handleDragUpdate = useCallback((absoluteY: number) => {
|
||||
const handleDragUpdate = useCallback(
|
||||
(absoluteY: number) => {
|
||||
const state = dragStateRef.current;
|
||||
if (!state) return;
|
||||
const target = findHoverTarget(absoluteY, state.taskId, state.positions, state.subPositions);
|
||||
@@ -303,16 +401,22 @@ export function TaskList({ categoryIds = [], showCompleted = false, onSelectionC
|
||||
} else {
|
||||
// Check if below last item
|
||||
const positions = state.positions;
|
||||
const lastItem = Object.values(positions).reduce((max, p) => p.bottom > max.bottom ? p : max, { bottom: 0 });
|
||||
const lastItem = Object.values(positions).reduce(
|
||||
(max, p) => (p.bottom > max.bottom ? p : max),
|
||||
{ bottom: 0 }
|
||||
);
|
||||
if (absoluteY > lastItem.bottom) {
|
||||
setDropIndicator({ targetId: null, position: 'below' });
|
||||
} else {
|
||||
setDropIndicator(null);
|
||||
}
|
||||
}
|
||||
}, [findHoverTarget, calculateDropPosition, updateHoverTarget]);
|
||||
},
|
||||
[findHoverTarget, calculateDropPosition, updateHoverTarget]
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback((absoluteY: number) => {
|
||||
const handleDragEnd = useCallback(
|
||||
(absoluteY: number) => {
|
||||
const state = dragStateRef.current;
|
||||
dragStateRef.current = null;
|
||||
setHoverTaskId(null);
|
||||
@@ -332,16 +436,32 @@ export function TaskList({ categoryIds = [], showCompleted = false, onSelectionC
|
||||
})();
|
||||
}
|
||||
}
|
||||
}, [findHoverTarget]);
|
||||
},
|
||||
[findHoverTarget]
|
||||
);
|
||||
|
||||
const handleSubtaskDragStart = useCallback(async (subtaskId: string, parentTaskId: string, parentSubtaskId: string | null = null) => {
|
||||
subtaskDragRef.current = { subtaskId, parentTaskId, parentSubtaskId, ...(await measureAll()) };
|
||||
}, [measureAll]);
|
||||
const handleSubtaskDragStart = useCallback(
|
||||
async (subtaskId: string, parentTaskId: string, parentSubtaskId: string | null = null) => {
|
||||
subtaskDragRef.current = {
|
||||
subtaskId,
|
||||
parentTaskId,
|
||||
parentSubtaskId,
|
||||
...(await measureAll()),
|
||||
};
|
||||
},
|
||||
[measureAll]
|
||||
);
|
||||
|
||||
const handleSubtaskDragUpdate = useCallback((absoluteY: number) => {
|
||||
const handleSubtaskDragUpdate = useCallback(
|
||||
(absoluteY: number) => {
|
||||
const state = subtaskDragRef.current;
|
||||
if (!state) return;
|
||||
const target = findHoverTarget(absoluteY, state.subtaskId, state.positions, state.subPositions);
|
||||
const target = findHoverTarget(
|
||||
absoluteY,
|
||||
state.subtaskId,
|
||||
state.positions,
|
||||
state.subPositions
|
||||
);
|
||||
updateHoverTarget(target);
|
||||
if (target) {
|
||||
if (state.subPositions[target]) {
|
||||
@@ -352,23 +472,34 @@ export function TaskList({ categoryIds = [], showCompleted = false, onSelectionC
|
||||
}
|
||||
} else {
|
||||
const positions = state.positions;
|
||||
const lastItem = Object.values(positions).reduce((max, p) => p.bottom > max.bottom ? p : max, { bottom: 0 });
|
||||
const lastItem = Object.values(positions).reduce(
|
||||
(max, p) => (p.bottom > max.bottom ? p : max),
|
||||
{ bottom: 0 }
|
||||
);
|
||||
if (absoluteY > lastItem.bottom) {
|
||||
setDropIndicator({ targetId: null, position: 'below' });
|
||||
} else {
|
||||
setDropIndicator(null);
|
||||
}
|
||||
}
|
||||
}, [findHoverTarget, calculateDropPosition, updateHoverTarget]);
|
||||
},
|
||||
[findHoverTarget, calculateDropPosition, updateHoverTarget]
|
||||
);
|
||||
|
||||
const handleSubtaskDragEnd = useCallback(async (absoluteY: number) => {
|
||||
const handleSubtaskDragEnd = useCallback(
|
||||
async (absoluteY: number) => {
|
||||
const state = subtaskDragRef.current;
|
||||
subtaskDragRef.current = null;
|
||||
setHoverTaskId(null);
|
||||
setDropIndicator(null);
|
||||
if (!state) return;
|
||||
|
||||
const target = findHoverTarget(absoluteY, state.subtaskId, state.positions, state.subPositions);
|
||||
const target = findHoverTarget(
|
||||
absoluteY,
|
||||
state.subtaskId,
|
||||
state.positions,
|
||||
state.subPositions
|
||||
);
|
||||
if (target === state.subtaskId) return;
|
||||
|
||||
// Only allow dropping on tasks (not subtasks) to match task behavior
|
||||
@@ -379,15 +510,77 @@ const handleSubtaskDragEnd = useCallback(async (absoluteY: number) => {
|
||||
// If no target or target is a subtask: do nothing (matches task behavior)
|
||||
await moveSubtaskToTask(state.subtaskId, target);
|
||||
}
|
||||
}, [findHoverTarget, moveSubtaskToTask]);
|
||||
},
|
||||
[findHoverTarget, moveSubtaskToTask]
|
||||
);
|
||||
|
||||
const handleTaskDragStart = useCallback(
|
||||
async (taskId: string) => {
|
||||
const positions = (await measureItems()) as Record<string, { top: number; bottom: number }>;
|
||||
taskDragRef.current = { taskId, positions };
|
||||
},
|
||||
[measureItems]
|
||||
);
|
||||
|
||||
const handleTaskDragUpdate = useCallback(
|
||||
(absoluteY: number) => {
|
||||
const state = taskDragRef.current;
|
||||
if (!state) return;
|
||||
const target = findHoverTask(absoluteY, state.taskId, state.positions);
|
||||
updateHoverTaskTarget(target);
|
||||
if (target) {
|
||||
setDropIndicator({ targetId: target, position: 'below' });
|
||||
} else {
|
||||
const lastItem = Object.values(state.positions).reduce(
|
||||
(max, p) => (p.bottom > max.bottom ? p : max),
|
||||
{ bottom: 0 }
|
||||
);
|
||||
if (absoluteY > lastItem.bottom) {
|
||||
setDropIndicator({ targetId: null, position: 'below' });
|
||||
} else {
|
||||
setDropIndicator(null);
|
||||
}
|
||||
}
|
||||
},
|
||||
[findHoverTask, updateHoverTaskTarget]
|
||||
);
|
||||
|
||||
const handleTaskDragEnd = useCallback(
|
||||
async (absoluteY: number) => {
|
||||
const state = taskDragRef.current;
|
||||
taskDragRef.current = null;
|
||||
setHoverTaskId(null);
|
||||
setDropIndicator(null);
|
||||
if (!state) return;
|
||||
|
||||
const target = findHoverTask(absoluteY, state.taskId, state.positions);
|
||||
if (target === state.taskId) return;
|
||||
|
||||
// Set drop indicator based on target position
|
||||
if (target) {
|
||||
setDropIndicator({ targetId: target, position: 'below' });
|
||||
} else {
|
||||
const lastItem = Object.values(state.positions).reduce(
|
||||
(max, p) => (p.bottom > max.bottom ? p : max),
|
||||
{ bottom: 0 }
|
||||
);
|
||||
if (absoluteY > lastItem.bottom) {
|
||||
setDropIndicator({ targetId: null, position: 'below' });
|
||||
}
|
||||
}
|
||||
},
|
||||
[findHoverTask]
|
||||
);
|
||||
|
||||
const renderItem = useCallback(
|
||||
({ item, index }: { item: Task; index: number }) => {
|
||||
const isExpanded = expandedTasks.has(item.id);
|
||||
const itemSubtasks = subtasksMap.get(item.id) ?? [];
|
||||
const showDropAbove = dropIndicator?.targetId === item.id && dropIndicator?.position === 'above';
|
||||
const showDropBelow = dropIndicator?.targetId === item.id && dropIndicator?.position === 'below';
|
||||
const tagColors = parseTaskTags(item.tags, item.categoryId).map((id) => categoryColors.get(id));
|
||||
const showDropAbove =
|
||||
dropIndicator?.targetId === item.id && dropIndicator?.position === 'above';
|
||||
const showDropBelow =
|
||||
dropIndicator?.targetId === item.id && dropIndicator?.position === 'below';
|
||||
const tagColors = parseTaskTags(item.tags, item.categoryId).map(id => categoryColors.get(id));
|
||||
return (
|
||||
<View>
|
||||
{showDropAbove && <DropIndicator theme={theme} />}
|
||||
@@ -406,10 +599,10 @@ const handleSubtaskDragEnd = useCallback(async (absoluteY: number) => {
|
||||
onExpand={toggleExpand}
|
||||
onSelect={toggleSelect}
|
||||
onEnterSelection={enterSelection}
|
||||
onMenuOpen={(task) => openTaskEdit(task.id)}
|
||||
onMenuOpen={task => openTaskEdit(task.id)}
|
||||
onSubtaskToggle={handleSubtaskToggle}
|
||||
onSubtaskDelete={openSubtaskDelete}
|
||||
onSubtaskMenuOpen={(subtask) => openSubtaskEdit(subtask.id)}
|
||||
onSubtaskMenuOpen={subtask => openSubtaskEdit(subtask.id)}
|
||||
onDragStart={handleDragStart}
|
||||
onDragUpdate={handleDragUpdate}
|
||||
onDragEnd={handleDragEnd}
|
||||
@@ -463,7 +656,9 @@ const handleSubtaskDragEnd = useCallback(async (absoluteY: number) => {
|
||||
return (
|
||||
<View style={styles.emptyState}>
|
||||
<Text style={[styles.emptyText, { color: theme.textSecondary }]}>No tasks yet</Text>
|
||||
<Text style={[styles.emptySubtext, { color: theme.textMuted }]}>Tap + to add your first task</Text>
|
||||
<Text style={[styles.emptySubtext, { color: theme.textMuted }]}>
|
||||
Tap + to add your first task
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}, [sortedTasks.length, theme.textSecondary, theme.textMuted]);
|
||||
@@ -485,9 +680,16 @@ const handleSubtaskDragEnd = useCallback(async (absoluteY: number) => {
|
||||
<View style={styles.container}>
|
||||
<Animated.FlatList
|
||||
data={sortedTasks}
|
||||
keyExtractor={(item) => item.id}
|
||||
keyExtractor={item => item.id}
|
||||
renderItem={renderItem}
|
||||
extraData={{ expandedTasks, subtasksMap, selectedIds, selectionMode, hoverTaskId, dropIndicator }}
|
||||
extraData={{
|
||||
expandedTasks,
|
||||
subtasksMap,
|
||||
selectedIds,
|
||||
selectionMode,
|
||||
hoverTaskId,
|
||||
dropIndicator,
|
||||
}}
|
||||
ItemSeparatorComponent={MemoSeparator}
|
||||
ListHeaderComponent={listHeader}
|
||||
ListFooterComponent={listFooter}
|
||||
@@ -506,16 +708,36 @@ const handleSubtaskDragEnd = useCallback(async (absoluteY: number) => {
|
||||
{modals(() => {})}
|
||||
|
||||
{selectionMode && (
|
||||
<View style={[styles.selectionBar, { backgroundColor: theme.sheetBg, borderTopColor: theme.border }]}>
|
||||
<TouchableOpacity onPress={exitSelection} style={styles.selectionCancel} activeOpacity={0.7}>
|
||||
<View
|
||||
style={[
|
||||
styles.selectionBar,
|
||||
{ backgroundColor: theme.sheetBg, borderTopColor: theme.border },
|
||||
]}
|
||||
>
|
||||
<TouchableOpacity
|
||||
onPress={exitSelection}
|
||||
style={styles.selectionCancel}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||
<Path d="M18 6L6 18M6 6l12 12" stroke={theme.textSecondary} strokeWidth={2.2} strokeLinecap="round" />
|
||||
<Path
|
||||
d="M18 6L6 18M6 6l12 12"
|
||||
stroke={theme.textSecondary}
|
||||
strokeWidth={2.2}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</Svg>
|
||||
<Text style={[styles.selectionCancelText, { color: theme.textFaint }]}>Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={[styles.selectionCount, { color: theme.text }]}>{selectedIds.size} selected</Text>
|
||||
<Text style={[styles.selectionCount, { color: theme.text }]}>
|
||||
{selectedIds.size} selected
|
||||
</Text>
|
||||
<View style={styles.selectionActions}>
|
||||
<SelectionButton label="Complete" color={theme.successStrong} onPress={handleBulkComplete} />
|
||||
<SelectionButton
|
||||
label="Complete"
|
||||
color={theme.successStrong}
|
||||
onPress={handleBulkComplete}
|
||||
/>
|
||||
<SelectionButton label="Delete" color={theme.dangerStrong} onPress={handleBulkDelete} />
|
||||
</View>
|
||||
</View>
|
||||
@@ -524,7 +746,15 @@ const handleSubtaskDragEnd = useCallback(async (absoluteY: number) => {
|
||||
);
|
||||
}
|
||||
|
||||
const SelectionButton = React.memo(function SelectionButton({ label, color, onPress }: { label: string; color: string; onPress: () => void }) {
|
||||
const SelectionButton = React.memo(function SelectionButton({
|
||||
label,
|
||||
color,
|
||||
onPress,
|
||||
}: {
|
||||
label: string;
|
||||
color: string;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.selectionButton, { backgroundColor: color }]}
|
||||
@@ -608,10 +838,7 @@ const TaskRow = React.memo(function TaskRow({
|
||||
[subtasks]
|
||||
);
|
||||
return (
|
||||
<View
|
||||
ref={(ref) => registerRef(task.id, ref)}
|
||||
style={styles.dragContainer}
|
||||
>
|
||||
<View ref={ref => registerRef(task.id, ref)} style={styles.dragContainer}>
|
||||
<TaskItem
|
||||
task={task as unknown as TaskData}
|
||||
onToggle={() => onToggle(task.id)}
|
||||
@@ -635,19 +862,19 @@ const TaskRow = React.memo(function TaskRow({
|
||||
/>
|
||||
{expanded && subtasks.length > 0 && (
|
||||
<View style={styles.subtaskList}>
|
||||
{sortedSubtasks.map((sub) => (
|
||||
<SubtaskItem
|
||||
{sortedSubtasks.map(sub => (
|
||||
<SubtaskItem
|
||||
key={sub.id}
|
||||
subtask={sub}
|
||||
hoveredId={hoverTaskId}
|
||||
registerRef={registerSubtaskRef}
|
||||
onToggle={(sub) => onSubtaskToggle(sub.id, task.id)}
|
||||
onToggle={sub => onSubtaskToggle(sub.id, task.id)}
|
||||
onDelete={() => onSubtaskDelete(sub)}
|
||||
onMenuOpen={() => onSubtaskMenuOpen(sub)}
|
||||
selected={selectedIds.has(sub.id)}
|
||||
selectionMode={selectionMode}
|
||||
draggable
|
||||
onDragStart={(sub) => onSubtaskDragStart(sub.id, task.id)}
|
||||
onDragStart={sub => onSubtaskDragStart(sub.id, task.id)}
|
||||
onDragUpdate={onSubtaskDragUpdate}
|
||||
onDragEnd={onSubtaskDragEnd}
|
||||
categoryColor={categoryColor}
|
||||
@@ -691,8 +918,7 @@ const styles = StyleSheet.create({
|
||||
paddingTop: 4,
|
||||
marginBottom: 4,
|
||||
},
|
||||
dragContainer: {
|
||||
},
|
||||
dragContainer: {},
|
||||
dropIndicatorContainer: {
|
||||
height: 8,
|
||||
justifyContent: 'center',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, TextInput , TextInputProps } from 'react-native';
|
||||
import { View, Text, StyleSheet, TextInput, TextInputProps } from 'react-native';
|
||||
import { useSettings, type ThemeColors } from '@/theme';
|
||||
|
||||
interface TaskNameInputProps extends TextInputProps {
|
||||
@@ -35,7 +35,8 @@ export function TaskNameInput({ error, ...props }: TaskNameInputProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const createStyles = (theme: ThemeColors) => StyleSheet.create({
|
||||
const createStyles = (theme: ThemeColors) =>
|
||||
StyleSheet.create({
|
||||
container: {
|
||||
gap: 5,
|
||||
},
|
||||
@@ -68,4 +69,4 @@ const createStyles = (theme: ThemeColors) => StyleSheet.create({
|
||||
color: theme.dangerStrong,
|
||||
marginLeft: 4,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,19 +49,45 @@ export function TaskOverflowMenu({
|
||||
{
|
||||
key: 'edit',
|
||||
label: 'Edit',
|
||||
icon: <Svg width={20} height={20} viewBox="0 0 24 24"><Path d="M4 20h4L19.5 8.5a2.1 2.1 0 0 0-3-3L5 17v3z" stroke={theme.text} strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" fill="none" /></Svg>,
|
||||
icon: (
|
||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||
<Path
|
||||
d="M4 20h4L19.5 8.5a2.1 2.1 0 0 0-3-3L5 17v3z"
|
||||
stroke={theme.text}
|
||||
strokeWidth={2.2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
</Svg>
|
||||
),
|
||||
onPress: onEdit,
|
||||
},
|
||||
{
|
||||
key: 'complete',
|
||||
label: task.completed ? 'Mark Incomplete' : 'Mark Complete',
|
||||
icon: <Svg width={20} height={20} viewBox="0 0 24 24"><Path d="M5 12l5 5 9-10" stroke={theme.text} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" /></Svg>,
|
||||
icon: (
|
||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||
<Path
|
||||
d="M5 12l5 5 9-10"
|
||||
stroke={theme.text}
|
||||
strokeWidth={2.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
</Svg>
|
||||
),
|
||||
onPress: onToggleComplete,
|
||||
},
|
||||
{
|
||||
key: 'duplicate',
|
||||
label: 'Duplicate',
|
||||
icon: <Svg width={20} height={20} viewBox="0 0 24 24"><Path d="M8 8h12v12H8zM4 16V4h12" stroke={theme.text} strokeWidth={2.2} fill="none" /></Svg>,
|
||||
icon: (
|
||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||
<Path d="M8 8h12v12H8zM4 16V4h12" stroke={theme.text} strokeWidth={2.2} fill="none" />
|
||||
</Svg>
|
||||
),
|
||||
onPress: onDuplicate,
|
||||
},
|
||||
];
|
||||
@@ -70,7 +96,11 @@ export function TaskOverflowMenu({
|
||||
actions.push({
|
||||
key: 'category',
|
||||
label: 'Change Tags',
|
||||
icon: <Svg width={20} height={20} viewBox="0 0 24 24"><Circle2 /></Svg>,
|
||||
icon: (
|
||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||
<Circle2 />
|
||||
</Svg>
|
||||
),
|
||||
onPress: onChangeCategory ?? (() => {}),
|
||||
});
|
||||
}
|
||||
@@ -79,23 +109,49 @@ export function TaskOverflowMenu({
|
||||
{
|
||||
key: 'priority',
|
||||
label: 'Change Priority',
|
||||
icon: <Svg width={20} height={20} viewBox="0 0 24 24"><Path d="M5 3v18M5 5h14l-3 4 3 4H5" stroke={theme.text} strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" fill="none" /></Svg>,
|
||||
icon: (
|
||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||
<Path
|
||||
d="M5 3v18M5 5h14l-3 4 3 4H5"
|
||||
stroke={theme.text}
|
||||
strokeWidth={2.2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
</Svg>
|
||||
),
|
||||
onPress: onChangePriority,
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
label: 'Delete',
|
||||
destructive: true,
|
||||
icon: <Svg width={20} height={20} viewBox="0 0 24 24"><Path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13" stroke={theme.danger} strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" fill="none" /></Svg>,
|
||||
icon: (
|
||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||
<Path
|
||||
d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"
|
||||
stroke={theme.danger}
|
||||
strokeWidth={2.2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
</Svg>
|
||||
),
|
||||
onPress: onDelete,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (subtask && onAddSubtask) {
|
||||
actions.push({
|
||||
key: 'addSubtask',
|
||||
label: 'Add Subtask',
|
||||
icon: <Svg width={20} height={20} viewBox="0 0 24 24"><Path d="M12 5v14M5 12h14" stroke={theme.text} strokeWidth={2.5} strokeLinecap="round" /></Svg>,
|
||||
icon: (
|
||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||
<Path d="M12 5v14M5 12h14" stroke={theme.text} strokeWidth={2.5} strokeLinecap="round" />
|
||||
</Svg>
|
||||
),
|
||||
onPress: onAddSubtask,
|
||||
});
|
||||
}
|
||||
@@ -112,7 +168,7 @@ export function TaskOverflowMenu({
|
||||
<Text style={[styles.title, { color: theme.text }]} numberOfLines={1}>
|
||||
{task.title}
|
||||
</Text>
|
||||
{actions.map((action) => (
|
||||
{actions.map(action => (
|
||||
<TouchableOpacity
|
||||
key={action.key}
|
||||
style={[styles.action, { borderColor: theme.border }]}
|
||||
@@ -125,7 +181,12 @@ export function TaskOverflowMenu({
|
||||
accessibilityLabel={`${action.label}${action.destructive ? ' (dangerous)' : ''}`}
|
||||
>
|
||||
{action.icon}
|
||||
<Text style={[styles.actionText, action.destructive ? styles.destructiveText : { color: theme.textSecondary }]}>
|
||||
<Text
|
||||
style={[
|
||||
styles.actionText,
|
||||
action.destructive ? styles.destructiveText : { color: theme.textSecondary },
|
||||
]}
|
||||
>
|
||||
{action.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
@@ -140,13 +201,19 @@ function Circle2() {
|
||||
const { theme } = useSettings();
|
||||
return (
|
||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||
<Path d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20z" stroke={theme.text} strokeWidth={2.2} fill="none" />
|
||||
<Path
|
||||
d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20z"
|
||||
stroke={theme.text}
|
||||
strokeWidth={2.2}
|
||||
fill="none"
|
||||
/>
|
||||
<Path d="M12 2a10 10 0 0 1 0 20z" fill={theme.text} opacity={0.35} />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
const createStyles = (theme: ThemeColors) => StyleSheet.create({
|
||||
const createStyles = (theme: ThemeColors) =>
|
||||
StyleSheet.create({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
justifyContent: 'flex-end',
|
||||
@@ -190,4 +257,4 @@ const createStyles = (theme: ThemeColors) => StyleSheet.create({
|
||||
color: theme.dangerStrong,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,30 +1,295 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Alert, Linking } from 'react-native';
|
||||
import { checkForUpdates, wasPromptedFor, markPrompted } from '@/services/updates';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Modal, View, Text, StyleSheet, Pressable, ScrollView, Platform } from 'react-native';
|
||||
import {
|
||||
checkForUpdates,
|
||||
wasPromptedFor,
|
||||
markPrompted,
|
||||
downloadAPK,
|
||||
installAPK,
|
||||
openReleaseUrl,
|
||||
AppUpdate,
|
||||
} from '@/services/updates';
|
||||
import { useSettings } from '@/theme';
|
||||
|
||||
export function UpdateNotifier() {
|
||||
useEffect(() => {
|
||||
const run = async () => {
|
||||
const update = await checkForUpdates();
|
||||
if (!update || (await wasPromptedFor(update.tagName))) return;
|
||||
await markPrompted(update.tagName);
|
||||
const url = update.apkUrl ?? update.releaseUrl;
|
||||
Alert.alert(
|
||||
'Update available',
|
||||
`A new version (${update.version}) is available for download.`,
|
||||
[
|
||||
{ text: 'Later', style: 'cancel' },
|
||||
{
|
||||
text: 'Download',
|
||||
onPress: () => {
|
||||
if (url) Linking.openURL(url).catch(() => {});
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
};
|
||||
run();
|
||||
const { theme } = useSettings();
|
||||
const [update, setUpdate] = useState<AppUpdate | null>(null);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [downloadProgress, setDownloadProgress] = useState(0);
|
||||
const [downloadError, setDownloadError] = useState<string | null>(null);
|
||||
|
||||
const checkUpdate = useCallback(async () => {
|
||||
const available = await checkForUpdates();
|
||||
if (!available) return;
|
||||
|
||||
const prompted = await wasPromptedFor(available.tagName);
|
||||
if (prompted && !available.mandatory) return;
|
||||
|
||||
setUpdate(available);
|
||||
setVisible(true);
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
useEffect(() => {
|
||||
checkUpdate();
|
||||
}, [checkUpdate]);
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (!update?.apkUrl) {
|
||||
await openReleaseUrl(update?.releaseUrl ?? '');
|
||||
return;
|
||||
}
|
||||
|
||||
setDownloading(true);
|
||||
setDownloadProgress(0);
|
||||
setDownloadError(null);
|
||||
|
||||
try {
|
||||
const uri = await downloadAPK(update.apkUrl, setDownloadProgress);
|
||||
if (uri) {
|
||||
const installed = await installAPK(uri);
|
||||
if (!installed) {
|
||||
setDownloadError('Failed to open installer. Please install manually from Downloads.');
|
||||
}
|
||||
} else {
|
||||
setDownloadError('Download failed. Please try again or download from the release page.');
|
||||
}
|
||||
} catch (error) {
|
||||
setDownloadError('An error occurred during download.');
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLater = async () => {
|
||||
if (update) {
|
||||
await markPrompted(update.tagName);
|
||||
}
|
||||
setVisible(false);
|
||||
};
|
||||
|
||||
const handleReleasePage = async () => {
|
||||
if (update) {
|
||||
await openReleaseUrl(update.releaseUrl);
|
||||
}
|
||||
};
|
||||
|
||||
if (!visible || !update) return null;
|
||||
|
||||
const isMandatory = update.mandatory;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={isMandatory ? () => {} : handleLater}
|
||||
>
|
||||
<View style={[styles.overlay, { backgroundColor: theme.overlay }]}>
|
||||
<View style={[styles.modal, { backgroundColor: theme.card }]}>
|
||||
<View style={[styles.header, { borderBottomColor: theme.border }]}>
|
||||
<Text style={[styles.title, { color: theme.text }]}>Update Available</Text>
|
||||
<Text style={[styles.version, { color: theme.textSecondary }]}>
|
||||
Version {update.version}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<ScrollView style={styles.content} contentContainerStyle={styles.contentContainer}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.text }]}>What's New</Text>
|
||||
<Text style={[styles.notes, { color: theme.textSecondary }]}>
|
||||
{update.notes || 'No release notes provided.'}
|
||||
</Text>
|
||||
|
||||
{isMandatory && (
|
||||
<View style={styles.mandatoryBadge}>
|
||||
<Text style={styles.mandatoryText}>Mandatory Update</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{downloading && (
|
||||
<View style={styles.progressContainer}>
|
||||
<View style={[styles.progressBar, { backgroundColor: theme.border }]}>
|
||||
<View
|
||||
style={[
|
||||
styles.progressFill,
|
||||
{ backgroundColor: theme.accent, width: `${downloadProgress * 100}%` },
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
<Text style={[styles.progressText, { color: theme.textSecondary }]}>
|
||||
Downloading... {Math.round(downloadProgress * 100)}%
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{downloadError && (
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={styles.errorText}>{downloadError}</Text>
|
||||
<Pressable style={styles.errorAction} onPress={handleDownload}>
|
||||
<Text style={styles.errorActionText}>Retry Download</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
<View style={[styles.actions, { borderTopColor: theme.border }]}>
|
||||
{!isMandatory && (
|
||||
<Pressable
|
||||
style={[styles.button, styles.buttonSecondary, { backgroundColor: theme.cardAlt }]}
|
||||
onPress={handleLater}
|
||||
>
|
||||
<Text style={[styles.buttonText, { color: theme.textSecondary }]}>Later</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
<Pressable
|
||||
style={[
|
||||
styles.button,
|
||||
styles.buttonPrimary,
|
||||
{ backgroundColor: theme.accent, opacity: downloading ? 0.7 : 1 },
|
||||
]}
|
||||
onPress={handleDownload}
|
||||
disabled={downloading}
|
||||
>
|
||||
<Text style={[styles.buttonText, { color: theme.accentText }]}>
|
||||
{downloading
|
||||
? 'Downloading...'
|
||||
: update.apkUrl
|
||||
? 'Download & Install'
|
||||
: 'View Release'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
style={[styles.button, styles.buttonGhost, { backgroundColor: 'transparent' }]}
|
||||
onPress={handleReleasePage}
|
||||
>
|
||||
<Text style={[styles.buttonText, { color: theme.textSecondary }]}>Release Notes</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
modal: {
|
||||
width: '100%',
|
||||
maxWidth: 480,
|
||||
borderRadius: 20,
|
||||
overflow: 'hidden',
|
||||
maxHeight: '85%',
|
||||
},
|
||||
header: {
|
||||
padding: 20,
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
title: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
marginBottom: 4,
|
||||
},
|
||||
version: {
|
||||
fontSize: 14,
|
||||
},
|
||||
content: {
|
||||
maxHeight: 300,
|
||||
},
|
||||
contentContainer: {
|
||||
padding: 20,
|
||||
paddingBottom: 12,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
marginBottom: 8,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
notes: {
|
||||
fontSize: 14,
|
||||
lineHeight: 22,
|
||||
},
|
||||
mandatoryBadge: {
|
||||
marginTop: 12,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 8,
|
||||
backgroundColor: '#FFEBEE',
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
mandatoryText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
color: '#C62828',
|
||||
},
|
||||
progressContainer: {
|
||||
marginTop: 16,
|
||||
},
|
||||
progressBar: {
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
progressFill: {
|
||||
height: '100%',
|
||||
borderRadius: 3,
|
||||
},
|
||||
progressText: {
|
||||
fontSize: 12,
|
||||
marginTop: 6,
|
||||
textAlign: 'center',
|
||||
},
|
||||
errorContainer: {
|
||||
marginTop: 16,
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
backgroundColor: '#FFF3E0',
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 13,
|
||||
color: '#E65100',
|
||||
marginBottom: 8,
|
||||
},
|
||||
errorAction: {
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 6,
|
||||
backgroundColor: '#FF9800',
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
errorActionText: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
color: '#FFF',
|
||||
},
|
||||
actions: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'flex-end',
|
||||
gap: 8,
|
||||
padding: 16,
|
||||
borderTopWidth: 1,
|
||||
},
|
||||
button: {
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 20,
|
||||
borderRadius: 10,
|
||||
minWidth: 100,
|
||||
alignItems: 'center',
|
||||
},
|
||||
buttonPrimary: {},
|
||||
buttonSecondary: {},
|
||||
buttonGhost: {},
|
||||
buttonText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -53,7 +53,13 @@ interface WheelRowProps {
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
const WheelRow = React.memo(function WheelRow({ label, selected, accent, textMuted, onPress }: WheelRowProps) {
|
||||
const WheelRow = React.memo(function WheelRow({
|
||||
label,
|
||||
selected,
|
||||
accent,
|
||||
textMuted,
|
||||
onPress,
|
||||
}: WheelRowProps) {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.row, selected && styles.rowSelected]}
|
||||
@@ -96,7 +102,10 @@ function WheelColumn({
|
||||
const indexRef = useRef(initialIndex);
|
||||
|
||||
useEffect(() => {
|
||||
listRef.current?.scrollToOffset({ offset: initialIndex * ITEM_HEIGHT - (ITEM_HEIGHT * VISIBLE_ITEMS - ITEM_HEIGHT) / 2, animated: false });
|
||||
listRef.current?.scrollToOffset({
|
||||
offset: initialIndex * ITEM_HEIGHT - (ITEM_HEIGHT * VISIBLE_ITEMS - ITEM_HEIGHT) / 2,
|
||||
animated: false,
|
||||
});
|
||||
indexRef.current = initialIndex;
|
||||
}, [initialIndex]);
|
||||
|
||||
@@ -105,7 +114,10 @@ function WheelColumn({
|
||||
const clamped = Math.min(Math.max(index, 0), data.length - 1);
|
||||
indexRef.current = clamped;
|
||||
onIndexChange(clamped);
|
||||
listRef.current?.scrollToOffset({ offset: clamped * ITEM_HEIGHT - (ITEM_HEIGHT * VISIBLE_ITEMS - ITEM_HEIGHT) / 2, animated: true });
|
||||
listRef.current?.scrollToOffset({
|
||||
offset: clamped * ITEM_HEIGHT - (ITEM_HEIGHT * VISIBLE_ITEMS - ITEM_HEIGHT) / 2,
|
||||
animated: true,
|
||||
});
|
||||
},
|
||||
[data.length, onIndexChange]
|
||||
);
|
||||
@@ -126,7 +138,9 @@ function WheelColumn({
|
||||
const handleScroll = useCallback(
|
||||
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
const offset = event.nativeEvent.contentOffset.y;
|
||||
const index = Math.round((offset + (ITEM_HEIGHT * VISIBLE_ITEMS - ITEM_HEIGHT) / 2) / ITEM_HEIGHT);
|
||||
const index = Math.round(
|
||||
(offset + (ITEM_HEIGHT * VISIBLE_ITEMS - ITEM_HEIGHT) / 2) / ITEM_HEIGHT
|
||||
);
|
||||
const clamped = Math.min(Math.max(index, 0), data.length - 1);
|
||||
if (clamped !== indexRef.current) {
|
||||
indexRef.current = clamped;
|
||||
@@ -148,7 +162,7 @@ function WheelColumn({
|
||||
<FlatList
|
||||
ref={listRef}
|
||||
data={data}
|
||||
keyExtractor={(item) => String(item)}
|
||||
keyExtractor={item => String(item)}
|
||||
renderItem={renderItem}
|
||||
getItemLayout={(_data, index) => ({
|
||||
length: ITEM_HEIGHT,
|
||||
@@ -164,7 +178,10 @@ function WheelColumn({
|
||||
onMomentumScrollEnd={handleScrollEnd}
|
||||
scrollEventThrottle={16}
|
||||
style={[styles.column, { width }]}
|
||||
contentContainerStyle={{ paddingBottom: (VISIBLE_ITEMS - 1) * ITEM_HEIGHT, alignItems: 'stretch' }}
|
||||
contentContainerStyle={{
|
||||
paddingBottom: (VISIBLE_ITEMS - 1) * ITEM_HEIGHT,
|
||||
alignItems: 'stretch',
|
||||
}}
|
||||
snapToAlignment="center"
|
||||
snapToInterval={ITEM_HEIGHT}
|
||||
/>
|
||||
@@ -207,7 +224,10 @@ export function WheelTimePicker({
|
||||
<View style={[styles.overlay, { backgroundColor: theme.overlay }]}>
|
||||
<View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}>
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity onPress={onDismiss} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
|
||||
<TouchableOpacity
|
||||
onPress={onDismiss}
|
||||
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
||||
>
|
||||
<Text style={[styles.headerAction, { color: theme.textMuted }]}>Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
<View style={styles.headerCenter}>
|
||||
@@ -217,10 +237,16 @@ export function WheelTimePicker({
|
||||
</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
onPress={() => onConfirm(`${String(hourIndex).padStart(2, '0')}:${String(minuteIndex).padStart(2, '0')}`)}
|
||||
onPress={() =>
|
||||
onConfirm(
|
||||
`${String(hourIndex).padStart(2, '0')}:${String(minuteIndex).padStart(2, '0')}`
|
||||
)
|
||||
}
|
||||
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
||||
>
|
||||
<Text style={[styles.headerAction, styles.doneText, { color: theme.accentStrong }]}>Done</Text>
|
||||
<Text style={[styles.headerAction, styles.doneText, { color: theme.accentStrong }]}>
|
||||
Done
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ export const adapter = new SQLiteAdapter({
|
||||
schema,
|
||||
migrations,
|
||||
dbName: 'carryyourlive',
|
||||
onSetUpError: (error) => {
|
||||
onSetUpError: error => {
|
||||
console.error('Database setup error:', error);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ export const adapter = new LokiJSAdapter({
|
||||
dbName: 'carryyourlive',
|
||||
useWebWorker: false,
|
||||
useIncrementalIndexedDB: true,
|
||||
onSetUpError: (error) => {
|
||||
onSetUpError: error => {
|
||||
console.error('Database setup error:', error);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { schemaMigrations, addColumns, createTable, unsafeExecuteSql } from '@nozbe/watermelondb/Schema/migrations';
|
||||
import {
|
||||
schemaMigrations,
|
||||
addColumns,
|
||||
createTable,
|
||||
unsafeExecuteSql,
|
||||
} from '@nozbe/watermelondb/Schema/migrations';
|
||||
|
||||
export const migrations = schemaMigrations({
|
||||
migrations: [
|
||||
@@ -152,11 +157,21 @@ export const migrations = schemaMigrations({
|
||||
unsafeExecuteSql('CREATE INDEX IF NOT EXISTS idx_tasks_updated_at ON tasks(updated_at);'),
|
||||
unsafeExecuteSql('CREATE INDEX IF NOT EXISTS idx_tasks_created_at ON tasks(created_at);'),
|
||||
unsafeExecuteSql('CREATE INDEX IF NOT EXISTS idx_tasks_completed ON tasks(completed);'),
|
||||
unsafeExecuteSql('CREATE INDEX IF NOT EXISTS idx_subtasks_updated_at ON subtasks(updated_at);'),
|
||||
unsafeExecuteSql('CREATE INDEX IF NOT EXISTS idx_subtasks_created_at ON subtasks(created_at);'),
|
||||
unsafeExecuteSql('CREATE INDEX IF NOT EXISTS idx_categories_updated_at ON categories(updated_at);'),
|
||||
unsafeExecuteSql('CREATE INDEX IF NOT EXISTS idx_repeat_profiles_updated_at ON repeat_profiles(updated_at);'),
|
||||
unsafeExecuteSql('CREATE INDEX IF NOT EXISTS idx_friendships_updated_at ON friendships(updated_at);'),
|
||||
unsafeExecuteSql(
|
||||
'CREATE INDEX IF NOT EXISTS idx_subtasks_updated_at ON subtasks(updated_at);'
|
||||
),
|
||||
unsafeExecuteSql(
|
||||
'CREATE INDEX IF NOT EXISTS idx_subtasks_created_at ON subtasks(created_at);'
|
||||
),
|
||||
unsafeExecuteSql(
|
||||
'CREATE INDEX IF NOT EXISTS idx_categories_updated_at ON categories(updated_at);'
|
||||
),
|
||||
unsafeExecuteSql(
|
||||
'CREATE INDEX IF NOT EXISTS idx_repeat_profiles_updated_at ON repeat_profiles(updated_at);'
|
||||
),
|
||||
unsafeExecuteSql(
|
||||
'CREATE INDEX IF NOT EXISTS idx_friendships_updated_at ON friendships(updated_at);'
|
||||
),
|
||||
unsafeExecuteSql('CREATE INDEX IF NOT EXISTS idx_tasks_series_id ON tasks(series_id);'),
|
||||
],
|
||||
},
|
||||
@@ -220,5 +235,9 @@ export const migrations = schemaMigrations({
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
toVersion: 21,
|
||||
steps: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -11,15 +11,26 @@ import RepeatProfile from '@/models/RepeatProfile';
|
||||
import Friendship from '@/models/Friendship';
|
||||
import { TombstoneEntity } from '@/models/Tombstone';
|
||||
import { fetchPendingTombstones, removeTombstonesInBatch } from './tombstones';
|
||||
import { useOnline } from '@/hooks/useOnline';
|
||||
|
||||
const LAST_PULLED_AT_KEY = 'sync:lastPulledAt';
|
||||
const LAST_PUSHED_AT_KEY = 'sync:lastPushedAt';
|
||||
const LAST_RUN_AT_KEY = 'sync:lastRunAt';
|
||||
|
||||
export interface SyncResult {
|
||||
pushed: { categories: number; tasks: number; subtasks: number; repeatProfiles: number; friendships: number };
|
||||
pulled: { categories: number; tasks: number; subtasks: number; repeatProfiles: number; friendships: number };
|
||||
pushed: {
|
||||
categories: number;
|
||||
tasks: number;
|
||||
subtasks: number;
|
||||
repeatProfiles: number;
|
||||
friendships: number;
|
||||
};
|
||||
pulled: {
|
||||
categories: number;
|
||||
tasks: number;
|
||||
subtasks: number;
|
||||
repeatProfiles: number;
|
||||
friendships: number;
|
||||
};
|
||||
conflicts: number;
|
||||
timestamp: number;
|
||||
}
|
||||
@@ -96,7 +107,7 @@ export async function runSync(): Promise<SyncResult> {
|
||||
}
|
||||
|
||||
// If offline, mark timestamps and return early - changes will sync on next foreground launch
|
||||
const isCurrentlyOnline = await useOnline();
|
||||
const isCurrentlyOnline = navigator.onLine;
|
||||
if (!isCurrentlyOnline) {
|
||||
const now = Date.now();
|
||||
await setLastPulledAt(now);
|
||||
@@ -151,7 +162,10 @@ export async function runSync(): Promise<SyncResult> {
|
||||
return result;
|
||||
}
|
||||
|
||||
async function pushChanges(): Promise<{ conflicts: PushConflict[]; pushedDeletions: { entity: TombstoneEntity; id: string }[] }> {
|
||||
async function pushChanges(): Promise<{
|
||||
conflicts: PushConflict[];
|
||||
pushedDeletions: { entity: TombstoneEntity; id: string }[];
|
||||
}> {
|
||||
const lastPushedAt = await getLastPushedAt();
|
||||
|
||||
const [tasks, subtasks, repeatProfiles, friendships] = await Promise.all([
|
||||
@@ -196,7 +210,7 @@ async function pushChanges(): Promise<{ conflicts: PushConflict[]; pushedDeletio
|
||||
if (t.categoryId) referencedCategoryIds.add(t.categoryId);
|
||||
}
|
||||
|
||||
const changedSubtasks = subtasks.map((s) => ({
|
||||
const changedSubtasks = subtasks.map(s => ({
|
||||
id: s.id,
|
||||
taskId: s.taskId,
|
||||
parentSubtaskId: s.parentSubtaskId || null,
|
||||
@@ -252,7 +266,7 @@ async function pushChanges(): Promise<{ conflicts: PushConflict[]; pushedDeletio
|
||||
}
|
||||
const categories = await categoryQuery.fetch();
|
||||
|
||||
const changedCategories = categories.map((c) => ({
|
||||
const changedCategories = categories.map(c => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
color: c.color,
|
||||
@@ -261,7 +275,7 @@ async function pushChanges(): Promise<{ conflicts: PushConflict[]; pushedDeletio
|
||||
updatedAt: c.updatedAt.getTime(),
|
||||
}));
|
||||
|
||||
const changedRepeatProfiles = repeatProfiles.map((p) => ({
|
||||
const changedRepeatProfiles = repeatProfiles.map(p => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
repeat: p.repeat,
|
||||
@@ -271,7 +285,7 @@ async function pushChanges(): Promise<{ conflicts: PushConflict[]; pushedDeletio
|
||||
updatedAt: p.updatedAt.getTime(),
|
||||
}));
|
||||
|
||||
const changedFriendships = friendships.map((f) => ({
|
||||
const changedFriendships = friendships.map(f => ({
|
||||
id: f.id,
|
||||
userId: f.userId,
|
||||
friendId: f.friendId,
|
||||
@@ -281,7 +295,7 @@ async function pushChanges(): Promise<{ conflicts: PushConflict[]; pushedDeletio
|
||||
}));
|
||||
|
||||
const pendingTombstones = await fetchPendingTombstones(lastPushedAt);
|
||||
const changedDeletions = pendingTombstones.map((t) => ({
|
||||
const changedDeletions = pendingTombstones.map(t => ({
|
||||
entity: t.entity,
|
||||
id: t.entityId,
|
||||
updatedAt: t.deletedAt.getTime(),
|
||||
@@ -316,7 +330,7 @@ async function pushChanges(): Promise<{ conflicts: PushConflict[]; pushedDeletio
|
||||
const data = await response.json();
|
||||
return {
|
||||
conflicts: (data.conflicts ?? []) as PushConflict[],
|
||||
pushedDeletions: pendingTombstones.map((t) => ({ entity: t.entity, id: t.entityId })),
|
||||
pushedDeletions: pendingTombstones.map(t => ({ entity: t.entity, id: t.entityId })),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -328,8 +342,8 @@ async function pruneSyncedTombstones(
|
||||
pushedDeletions: { entity: TombstoneEntity; id: string }[]
|
||||
): Promise<void> {
|
||||
if (pushedDeletions.length === 0) return;
|
||||
const conflictKeys = new Set(conflicts.map((c) => `${c.entity}:${c.id}`));
|
||||
const accepted = pushedDeletions.filter((d) => !conflictKeys.has(`${d.entity}:${d.id}`));
|
||||
const conflictKeys = new Set(conflicts.map(c => `${c.entity}:${c.id}`));
|
||||
const accepted = pushedDeletions.filter(d => !conflictKeys.has(`${d.entity}:${d.id}`));
|
||||
const grouped = new Map<TombstoneEntity, string[]>();
|
||||
for (const t of accepted) {
|
||||
const list = grouped.get(t.entity) ?? [];
|
||||
@@ -413,7 +427,11 @@ async function applyServerChanges(data: {
|
||||
// Apply a server-side deletion to the local DB, cascading to every related
|
||||
// row (category -> tasks -> subtasks, task -> subtasks, subtask -> children).
|
||||
// LWW: if the local row is newer than the deletion, it survives.
|
||||
async function applyRemoteTombstone(tomb: { entity: TombstoneEntity; id: string; updatedAt: number }): Promise<void> {
|
||||
async function applyRemoteTombstone(tomb: {
|
||||
entity: TombstoneEntity;
|
||||
id: string;
|
||||
updatedAt: number;
|
||||
}): Promise<void> {
|
||||
const { entity, id, updatedAt } = tomb;
|
||||
const row = await findOrNull<any>(collections[entity as keyof typeof collections] as any, id);
|
||||
if (row) {
|
||||
@@ -475,7 +493,7 @@ async function destroyLocalEntityCascade(entity: TombstoneEntity, id: string): P
|
||||
async function upsertCategory(row: ServerRow, force: boolean): Promise<boolean> {
|
||||
const local = await findOrNull<Category>(collections.categories, row.id);
|
||||
if (!local) {
|
||||
await collections.categories.create((c) => {
|
||||
await collections.categories.create(c => {
|
||||
c.name = String(row.name ?? '');
|
||||
c.color = String(row.color ?? '#9E9E9E');
|
||||
c.order = Number(row.order ?? 0);
|
||||
@@ -487,7 +505,7 @@ async function upsertCategory(row: ServerRow, force: boolean): Promise<boolean>
|
||||
if (!force && local.updatedAt.getTime() > Number(row.updatedAt ?? 0)) {
|
||||
return false;
|
||||
}
|
||||
await local.update((c) => {
|
||||
await local.update(c => {
|
||||
c.name = String(row.name ?? c.name);
|
||||
c.color = String(row.color ?? c.color);
|
||||
c.order = Number(row.order ?? c.order);
|
||||
@@ -499,7 +517,7 @@ async function upsertCategory(row: ServerRow, force: boolean): Promise<boolean>
|
||||
async function upsertTask(row: ServerRow, force: boolean): Promise<boolean> {
|
||||
const local = await findOrNull<Task>(collections.tasks, row.id);
|
||||
if (!local) {
|
||||
await collections.tasks.create((t) => {
|
||||
await collections.tasks.create(t => {
|
||||
t.title = String(row.title ?? '');
|
||||
t.description = String(row.description ?? '');
|
||||
t.categoryId = String(row.categoryId ?? '');
|
||||
@@ -525,7 +543,7 @@ async function upsertTask(row: ServerRow, force: boolean): Promise<boolean> {
|
||||
if (!force && local.updatedAt.getTime() > Number(row.updatedAt ?? 0)) {
|
||||
return false;
|
||||
}
|
||||
await local.update((t) => {
|
||||
await local.update(t => {
|
||||
t.title = String(row.title ?? t.title);
|
||||
t.description = String(row.description ?? t.description);
|
||||
t.categoryId = String(row.categoryId ?? t.categoryId);
|
||||
@@ -551,7 +569,7 @@ async function upsertTask(row: ServerRow, force: boolean): Promise<boolean> {
|
||||
async function upsertRepeatProfile(row: ServerRow, force: boolean): Promise<boolean> {
|
||||
const local = await findOrNull<RepeatProfile>(collections.repeatProfiles, row.id);
|
||||
if (!local) {
|
||||
await collections.repeatProfiles.create((p) => {
|
||||
await collections.repeatProfiles.create(p => {
|
||||
p.name = String(row.name ?? '');
|
||||
p.repeat = row.repeat ?? 'none';
|
||||
p.repeatInterval = Number(row.repeatInterval ?? 1);
|
||||
@@ -564,7 +582,7 @@ async function upsertRepeatProfile(row: ServerRow, force: boolean): Promise<bool
|
||||
if (!force && local.updatedAt.getTime() > Number(row.updatedAt ?? 0)) {
|
||||
return false;
|
||||
}
|
||||
await local.update((p) => {
|
||||
await local.update(p => {
|
||||
p.name = String(row.name ?? p.name);
|
||||
p.repeat = row.repeat ?? p.repeat;
|
||||
p.repeatInterval = Number(row.repeatInterval ?? p.repeatInterval);
|
||||
@@ -580,7 +598,7 @@ async function upsertSubtask(row: ServerRow, force: boolean): Promise<boolean> {
|
||||
|
||||
const local = await findOrNull<Subtask>(collections.subtasks, row.id);
|
||||
if (!local) {
|
||||
await collections.subtasks.create((s) => {
|
||||
await collections.subtasks.create(s => {
|
||||
s.taskId = String(row.taskId ?? '');
|
||||
s.parentSubtaskId = row.parentSubtaskId == null ? null : String(row.parentSubtaskId);
|
||||
s.categoryId = row.categoryId ? String(row.categoryId) : '';
|
||||
@@ -608,7 +626,7 @@ async function upsertSubtask(row: ServerRow, force: boolean): Promise<boolean> {
|
||||
if (!force && local.updatedAt.getTime() > Number(row.updatedAt ?? 0)) {
|
||||
return false;
|
||||
}
|
||||
await local.update((s) => {
|
||||
await local.update(s => {
|
||||
s.taskId = String(row.taskId ?? s.taskId);
|
||||
s.parentSubtaskId = row.parentSubtaskId == null ? null : String(row.parentSubtaskId);
|
||||
s.categoryId = row.categoryId ? String(row.categoryId) : s.categoryId;
|
||||
@@ -636,7 +654,7 @@ async function upsertSubtask(row: ServerRow, force: boolean): Promise<boolean> {
|
||||
async function upsertFriendship(row: ServerRow, force: boolean): Promise<boolean> {
|
||||
const local = await findOrNull<Friendship>(collections.friendships, row.id);
|
||||
if (!local) {
|
||||
await collections.friendships.create((f) => {
|
||||
await collections.friendships.create(f => {
|
||||
f.userId = String(row.userId ?? '');
|
||||
f.friendId = String(row.friendId ?? '');
|
||||
f.status = (row.status as 'pending' | 'accepted') ?? 'pending';
|
||||
@@ -648,7 +666,7 @@ async function upsertFriendship(row: ServerRow, force: boolean): Promise<boolean
|
||||
if (!force && local.updatedAt.getTime() > Number(row.updatedAt ?? 0)) {
|
||||
return false;
|
||||
}
|
||||
await local.update((f) => {
|
||||
await local.update(f => {
|
||||
f.userId = String(row.userId ?? f.userId);
|
||||
f.friendId = String(row.friendId ?? f.friendId);
|
||||
f.status = (row.status as 'pending' | 'accepted') ?? f.status;
|
||||
|
||||
@@ -3,7 +3,10 @@ import { Q } from '@nozbe/watermelondb';
|
||||
import Tombstone, { TombstoneEntity } from '@/models/Tombstone';
|
||||
|
||||
// Batch variant: must be called from inside an existing database.write().
|
||||
export async function recordTombstonesInBatch(entity: TombstoneEntity, ids: string[]): Promise<void> {
|
||||
export async function recordTombstonesInBatch(
|
||||
entity: TombstoneEntity,
|
||||
ids: string[]
|
||||
): Promise<void> {
|
||||
const uniqueIds = [...new Set(ids)].filter(Boolean);
|
||||
for (const id of uniqueIds) {
|
||||
const existing = await collections.tombstones
|
||||
@@ -11,11 +14,11 @@ export async function recordTombstonesInBatch(entity: TombstoneEntity, ids: stri
|
||||
.fetch();
|
||||
const now = new Date();
|
||||
if (existing.length > 0) {
|
||||
await existing[0].update((t) => {
|
||||
await existing[0].update(t => {
|
||||
t.deletedAt = now;
|
||||
});
|
||||
} else {
|
||||
await collections.tombstones.create((t) => {
|
||||
await collections.tombstones.create(t => {
|
||||
t.entity = entity;
|
||||
t.entityId = id;
|
||||
t.deletedAt = now;
|
||||
@@ -25,7 +28,10 @@ export async function recordTombstonesInBatch(entity: TombstoneEntity, ids: stri
|
||||
}
|
||||
|
||||
// Batch variant: must be called from inside an existing database.write().
|
||||
export async function removeTombstonesInBatch(entity: TombstoneEntity, ids: string[]): Promise<void> {
|
||||
export async function removeTombstonesInBatch(
|
||||
entity: TombstoneEntity,
|
||||
ids: string[]
|
||||
): Promise<void> {
|
||||
for (const id of ids) {
|
||||
const existing = await collections.tombstones
|
||||
.query(Q.where('entity', entity), Q.where('entity_id', id))
|
||||
|
||||
@@ -25,7 +25,7 @@ export function DatabaseProvider({ children }: { children: ReactNode }) {
|
||||
const existingCategories = await collections.categories.query().fetch();
|
||||
|
||||
// Merge categories with the same spelling (case-insensitive)
|
||||
const categoriesByName = new Map<string, typeof existingCategories[0]>();
|
||||
const categoriesByName = new Map<string, (typeof existingCategories)[0]>();
|
||||
const duplicates: typeof existingCategories = [];
|
||||
|
||||
for (const cat of existingCategories) {
|
||||
@@ -42,9 +42,13 @@ export function DatabaseProvider({ children }: { children: ReactNode }) {
|
||||
for (const dup of duplicates) {
|
||||
const keep = categoriesByName.get(dup.name.toLowerCase())!;
|
||||
// Reassign tasks from duplicate to kept category
|
||||
const tasksToUpdate = await collections.tasks.query(Q.where('category_id', dup.id)).fetch();
|
||||
const tasksToUpdate = await collections.tasks
|
||||
.query(Q.where('category_id', dup.id))
|
||||
.fetch();
|
||||
for (const task of tasksToUpdate) {
|
||||
await task.update((t) => { t.categoryId = keep.id; });
|
||||
await task.update(t => {
|
||||
t.categoryId = keep.id;
|
||||
});
|
||||
}
|
||||
// Delete duplicate category
|
||||
await dup.destroyPermanently();
|
||||
@@ -57,7 +61,7 @@ export function DatabaseProvider({ children }: { children: ReactNode }) {
|
||||
if (freshCategories.length === 0) {
|
||||
await database.write(async () => {
|
||||
for (const cat of DEFAULT_CATEGORIES) {
|
||||
await collections.categories.create((c) => {
|
||||
await collections.categories.create(c => {
|
||||
c.name = cat.name;
|
||||
c.color = cat.color;
|
||||
c.order = cat.order;
|
||||
@@ -115,9 +119,9 @@ export function useCategories(): Category[] {
|
||||
const subscription = collections.categories
|
||||
.query()
|
||||
.observeWithColumns(['name', 'color', 'order'])
|
||||
.pipe(map((cats) => [...cats].sort((a, b) => a.order - b.order)))
|
||||
.pipe(map(cats => [...cats].sort((a, b) => a.order - b.order)))
|
||||
.subscribe({
|
||||
next: (cats) => {
|
||||
next: cats => {
|
||||
if (mounted) setCategories(cats);
|
||||
},
|
||||
error: () => {},
|
||||
@@ -154,9 +158,9 @@ export function useRepeatProfiles(): RepeatProfile[] {
|
||||
const subscription = collections.repeatProfiles
|
||||
.query()
|
||||
.observeWithColumns(['name', 'repeat', 'repeat_interval', 'repeat_days'])
|
||||
.pipe(map((rows) => [...rows].sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())))
|
||||
.pipe(map(rows => [...rows].sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())))
|
||||
.subscribe({
|
||||
next: (rows) => {
|
||||
next: rows => {
|
||||
if (mounted) setProfiles(rows);
|
||||
},
|
||||
error: () => {},
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||
import { getFriends, sendFriendRequest, acceptFriendRequest, deleteFriendRequest, removeFriend, searchUsers, Friend, FriendRequest } from '@/services/friends';
|
||||
import {
|
||||
getFriends,
|
||||
sendFriendRequest,
|
||||
acceptFriendRequest,
|
||||
deleteFriendRequest,
|
||||
removeFriend,
|
||||
searchUsers,
|
||||
Friend,
|
||||
FriendRequest,
|
||||
} from '@/services/friends';
|
||||
|
||||
interface FriendsContextType {
|
||||
friends: Friend[];
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useDatabase } from './useDatabase';
|
||||
import { Q } from '@nozbe/watermelondb';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import Task from '../models/Task';
|
||||
import { Priority, Repeat, Reminder } from '@/types';
|
||||
import parseICal from '../utils/icalParser';
|
||||
import { startOfMonth, endOfMonth, addMonths, format } from 'date-fns';
|
||||
|
||||
@@ -42,7 +43,11 @@ function mapICalEventToTask(event: {
|
||||
let allDay = event.allDay === true || !event.end;
|
||||
|
||||
if (event.allDay) {
|
||||
dueDate = new Date(event.start.getFullYear(), event.start.getMonth(), event.start.getDate()).getTime();
|
||||
dueDate = new Date(
|
||||
event.start.getFullYear(),
|
||||
event.start.getMonth(),
|
||||
event.start.getDate()
|
||||
).getTime();
|
||||
dueTime = '00:00';
|
||||
endTime = '23:59';
|
||||
} else {
|
||||
@@ -138,57 +143,42 @@ export function useICalImport() {
|
||||
}
|
||||
|
||||
const taskData = events.map(mapICalEventToTask);
|
||||
const now = Date.now();
|
||||
|
||||
// Insert tasks into WatermelonDB
|
||||
const subscription = collections.tasks
|
||||
.query()
|
||||
.splice(
|
||||
0,
|
||||
0,
|
||||
...taskData.map((td) => ({
|
||||
title: td.title,
|
||||
description: td.description,
|
||||
category_id: td.categoryId,
|
||||
tags: td.tags,
|
||||
priority: td.priority,
|
||||
completed: td.completed,
|
||||
completed_at: td.completedAt,
|
||||
due_date: td.dueDate,
|
||||
due_time: td.dueTime,
|
||||
end_time: td.endTime,
|
||||
all_day: td.allDay,
|
||||
repeat: td.repeat,
|
||||
repeat_interval: td.repeatInterval,
|
||||
repeat_days: td.repeatDays,
|
||||
color: '#9E9E9E',
|
||||
series_id: td.seriesId,
|
||||
order: 0,
|
||||
reminder: td.reminder,
|
||||
reminders: td.reminders,
|
||||
assignee_id: null,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}))
|
||||
await Promise.all(
|
||||
taskData.map(td =>
|
||||
collections.tasks.create(t => {
|
||||
t.title = td.title;
|
||||
t.description = td.description;
|
||||
t.categoryId = td.categoryId;
|
||||
t.priority = td.priority as Priority;
|
||||
t.completed = td.completed;
|
||||
t.completedAt = td.completedAt;
|
||||
t.dueDate = td.dueDate;
|
||||
t.dueTime = td.dueTime;
|
||||
t.endTime = td.endTime;
|
||||
t.allDay = td.allDay;
|
||||
t.repeat = td.repeat as Repeat;
|
||||
t.repeatInterval = td.repeatInterval;
|
||||
t.repeatDays = td.repeatDays;
|
||||
t.seriesId = td.seriesId;
|
||||
t.reminder = td.reminder as Reminder;
|
||||
t.reminders = td.reminders;
|
||||
t.assigneeId = null;
|
||||
t.createdAt = new Date(now);
|
||||
t.updatedAt = new Date(now);
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
// Observe the changes
|
||||
subscription.subscribe({
|
||||
next: (result) => {
|
||||
// Tasks have been imported
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Import error:', err);
|
||||
setError(err.message);
|
||||
},
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
} catch (err: any) {
|
||||
console.error('ICal import failed:', err);
|
||||
setError(err.message || 'Import failed');
|
||||
setLoading(false);
|
||||
}
|
||||
}, [collections]);
|
||||
}, []);
|
||||
|
||||
return { importICal, loading, error };
|
||||
}
|
||||
@@ -83,10 +83,7 @@ export function useStats(): TaskStats {
|
||||
let mounted = true;
|
||||
|
||||
const compute = () => {
|
||||
Promise.all([
|
||||
collections.tasks.query().fetch(),
|
||||
collections.categories.query().fetch(),
|
||||
])
|
||||
Promise.all([collections.tasks.query().fetch(), collections.categories.query().fetch()])
|
||||
.then(([allTasks, categories]) => {
|
||||
const now = Date.now();
|
||||
const todayStart = startOfDay(now);
|
||||
@@ -129,25 +126,25 @@ export function useStats(): TaskStats {
|
||||
});
|
||||
}
|
||||
|
||||
const categoryMap = new Map(categories.map((c) => [c.id, c]));
|
||||
const categoryMap = new Map(categories.map(c => [c.id, c]));
|
||||
const byCategory: CategoryStat[] = Array.from(categoryMap.values())
|
||||
.map((c) => ({
|
||||
.map(c => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
color: c.color,
|
||||
count: categoryCounts.get(c.id) ?? 0,
|
||||
}))
|
||||
.filter((c) => c.count > 0)
|
||||
.filter(c => c.count > 0)
|
||||
.sort((a, b) => b.count - a.count);
|
||||
|
||||
const priorityOrder = ['high', 'medium', 'low', 'none'];
|
||||
const byPriority: PriorityStat[] = priorityOrder
|
||||
.map((p) => ({
|
||||
.map(p => ({
|
||||
priority: p,
|
||||
label: PRIORITY_LABELS[p],
|
||||
count: priorityCounts.get(p) ?? 0,
|
||||
}))
|
||||
.filter((p) => p.count > 0);
|
||||
.filter(p => p.count > 0);
|
||||
|
||||
let streak = 0;
|
||||
const todayCount = dayBucket.get(todayStart) ?? 0;
|
||||
@@ -165,7 +162,8 @@ export function useStats(): TaskStats {
|
||||
completedLast30,
|
||||
activeCount,
|
||||
overdueCount,
|
||||
completionRate: totalCompleted > 0
|
||||
completionRate:
|
||||
totalCompleted > 0
|
||||
? Math.round((totalCompleted / (totalCompleted + activeCount)) * 100)
|
||||
: 0,
|
||||
daily,
|
||||
@@ -176,13 +174,16 @@ export function useStats(): TaskStats {
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (mounted) setStats((s) => ({ ...s, loading: false }));
|
||||
if (mounted) setStats(s => ({ ...s, loading: false }));
|
||||
});
|
||||
};
|
||||
|
||||
compute();
|
||||
|
||||
const subscription = collections.tasks.query().observe().subscribe({
|
||||
const subscription = collections.tasks
|
||||
.query()
|
||||
.observe()
|
||||
.subscribe({
|
||||
next: () => {
|
||||
compute();
|
||||
},
|
||||
|
||||
@@ -62,7 +62,7 @@ export function useSubtasks(): { map: Map<string, SubtaskData[]>; refresh: () =>
|
||||
const { collections } = useDatabase();
|
||||
const [rows, setRows] = useState<any[]>([]);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const refresh = useCallback(() => setRefreshKey((k) => k + 1), []);
|
||||
const refresh = useCallback(() => setRefreshKey(k => k + 1), []);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
@@ -70,7 +70,7 @@ export function useSubtasks(): { map: Map<string, SubtaskData[]>; refresh: () =>
|
||||
.query()
|
||||
.observe()
|
||||
.subscribe({
|
||||
next: (result) => {
|
||||
next: result => {
|
||||
if (mounted) setRows(result);
|
||||
},
|
||||
error: () => {},
|
||||
|
||||
@@ -22,7 +22,10 @@ import {
|
||||
confirmAndToggleTaskComplete,
|
||||
} from '@/utils/taskActions';
|
||||
|
||||
const PRIORITY_OPTIONS = Object.entries(PRIORITY_LABELS).map(([value, label]) => ({ value, label }));
|
||||
const PRIORITY_OPTIONS = Object.entries(PRIORITY_LABELS).map(([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
}));
|
||||
|
||||
interface AddSubtaskModalProps {
|
||||
visible: boolean;
|
||||
@@ -122,9 +125,7 @@ function AddSubtaskModal({ visible, parentSubtask, onClose, onAdd }: AddSubtaskM
|
||||
>
|
||||
<View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}>
|
||||
<View style={[styles.handle, { backgroundColor: theme.borderStrong }]} />
|
||||
<Text style={styles.title}>
|
||||
Add Subtask to “{parentSubtask.title}”
|
||||
</Text>
|
||||
<Text style={styles.title}>Add Subtask to “{parentSubtask.title}”</Text>
|
||||
<View style={styles.inputContainer}>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
@@ -159,21 +160,35 @@ export function useTaskModals() {
|
||||
const [menuSubtask, setMenuSubtask] = useState<SubtaskData | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<TaskData | null>(null);
|
||||
const [deleteSubtaskTarget, setDeleteSubtaskTarget] = useState<SubtaskData | null>(null);
|
||||
const [picker, setPicker] = useState<null | { type: 'category' | 'priority'; taskId?: string }>(null);
|
||||
const [subtaskPicker, setSubtaskPicker] = useState<null | { type: 'priority'; subtaskId?: string }>(null);
|
||||
const [picker, setPicker] = useState<null | { type: 'category' | 'priority'; taskId?: string }>(
|
||||
null
|
||||
);
|
||||
const [subtaskPicker, setSubtaskPicker] = useState<null | {
|
||||
type: 'priority';
|
||||
subtaskId?: string;
|
||||
}>(null);
|
||||
const [addSubtaskTarget, setAddSubtaskTarget] = useState<SubtaskData | null>(null);
|
||||
const refreshRef = useRef<() => void>(() => {});
|
||||
|
||||
const openTaskMenu = useCallback((task: TaskData) => setMenuTask(task), []);
|
||||
const openSubtaskMenu = useCallback((subtask: SubtaskData) => setMenuSubtask(subtask), []);
|
||||
const openTaskDelete = useCallback((task: TaskData) => setDeleteTarget(task), []);
|
||||
const openSubtaskDelete = useCallback((subtask: SubtaskData) => setDeleteSubtaskTarget(subtask), []);
|
||||
const openTaskEdit = useCallback((taskId: string) => {
|
||||
const openSubtaskDelete = useCallback(
|
||||
(subtask: SubtaskData) => setDeleteSubtaskTarget(subtask),
|
||||
[]
|
||||
);
|
||||
const openTaskEdit = useCallback(
|
||||
(taskId: string) => {
|
||||
router.push({ pathname: '/task-detail', params: { id: taskId } });
|
||||
}, [router]);
|
||||
const openSubtaskEdit = useCallback((subtaskId: string) => {
|
||||
},
|
||||
[router]
|
||||
);
|
||||
const openSubtaskEdit = useCallback(
|
||||
(subtaskId: string) => {
|
||||
router.push({ pathname: '/subtask-detail', params: { id: subtaskId } });
|
||||
}, [router]);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
const openSubtaskAdd = useCallback((subtask: SubtaskData) => {
|
||||
setAddSubtaskTarget(subtask);
|
||||
}, []);
|
||||
@@ -197,13 +212,16 @@ export function useTaskModals() {
|
||||
refreshRef.current();
|
||||
}, []);
|
||||
|
||||
const handleDeleteScope = useCallback(async (scope: TaskDeleteScope) => {
|
||||
const handleDeleteScope = useCallback(
|
||||
async (scope: TaskDeleteScope) => {
|
||||
const target = deleteTarget;
|
||||
if (!target) return;
|
||||
setDeleteTarget(null);
|
||||
await deleteTaskOccurrences(target.id, scope);
|
||||
refreshRef.current();
|
||||
}, [deleteTarget]);
|
||||
},
|
||||
[deleteTarget]
|
||||
);
|
||||
|
||||
const handleDeleteSubtask = useCallback(async () => {
|
||||
const target = deleteSubtaskTarget;
|
||||
@@ -213,28 +231,44 @@ export function useTaskModals() {
|
||||
refreshRef.current();
|
||||
}, [deleteSubtaskTarget]);
|
||||
|
||||
const handleSingleCategory = useCallback(async (value: string | string[]) => {
|
||||
const handleSingleCategory = useCallback(
|
||||
async (value: string | string[]) => {
|
||||
if (picker?.taskId) {
|
||||
await setTaskCategory(picker.taskId, Array.isArray(value) ? value[0] : value);
|
||||
refreshRef.current();
|
||||
}
|
||||
}, [picker]);
|
||||
},
|
||||
[picker]
|
||||
);
|
||||
|
||||
const handleSinglePriority = useCallback(async (value: string | string[]) => {
|
||||
const handleSinglePriority = useCallback(
|
||||
async (value: string | string[]) => {
|
||||
if (picker?.taskId) {
|
||||
await setTaskPriority(picker.taskId, (Array.isArray(value) ? value[0] : value) as TaskData['priority']);
|
||||
await setTaskPriority(
|
||||
picker.taskId,
|
||||
(Array.isArray(value) ? value[0] : value) as TaskData['priority']
|
||||
);
|
||||
refreshRef.current();
|
||||
}
|
||||
}, [picker]);
|
||||
},
|
||||
[picker]
|
||||
);
|
||||
|
||||
const handleSubtaskPriority = useCallback(async (value: string | string[]) => {
|
||||
const handleSubtaskPriority = useCallback(
|
||||
async (value: string | string[]) => {
|
||||
if (subtaskPicker?.subtaskId) {
|
||||
await setSubtaskPriority(subtaskPicker.subtaskId, (Array.isArray(value) ? value[0] : value) as TaskData['priority']);
|
||||
await setSubtaskPriority(
|
||||
subtaskPicker.subtaskId,
|
||||
(Array.isArray(value) ? value[0] : value) as TaskData['priority']
|
||||
);
|
||||
refreshRef.current();
|
||||
}
|
||||
}, [subtaskPicker]);
|
||||
},
|
||||
[subtaskPicker]
|
||||
);
|
||||
|
||||
const handleAddSubtask = useCallback(async (title: string) => {
|
||||
const handleAddSubtask = useCallback(
|
||||
async (title: string) => {
|
||||
const target = addSubtaskTarget;
|
||||
if (!target || !title.trim()) return;
|
||||
await createSubtask({
|
||||
@@ -244,7 +278,9 @@ export function useTaskModals() {
|
||||
});
|
||||
setAddSubtaskTarget(null);
|
||||
refreshRef.current();
|
||||
}, [addSubtaskTarget]);
|
||||
},
|
||||
[addSubtaskTarget]
|
||||
);
|
||||
|
||||
const modals = (refresh: () => void) => {
|
||||
refreshRef.current = refresh;
|
||||
@@ -289,7 +325,9 @@ export function useTaskModals() {
|
||||
onDelete={() => menuSubtask && openSubtaskDelete(menuSubtask)}
|
||||
onDuplicate={() => menuSubtask && handleSubtaskDuplicate(menuSubtask.id)}
|
||||
onToggleComplete={() => menuSubtask && handleSubtaskToggle(menuSubtask)}
|
||||
onChangePriority={() => menuSubtask && setSubtaskPicker({ type: 'priority', subtaskId: menuSubtask.id })}
|
||||
onChangePriority={() =>
|
||||
menuSubtask && setSubtaskPicker({ type: 'priority', subtaskId: menuSubtask.id })
|
||||
}
|
||||
onAddSubtask={() => menuSubtask && openSubtaskAdd(menuSubtask)}
|
||||
/>
|
||||
|
||||
@@ -303,7 +341,10 @@ export function useTaskModals() {
|
||||
<OptionPickerModal
|
||||
visible={picker?.type === 'category'}
|
||||
title="Change Tags"
|
||||
options={[{ value: '', label: 'None', color: '#9E9E9E' }, ...categories.map((c) => ({ value: c.id, label: c.name, color: c.color }))]}
|
||||
options={[
|
||||
{ value: '', label: 'None', color: '#9E9E9E' },
|
||||
...categories.map(c => ({ value: c.id, label: c.name, color: c.color })),
|
||||
]}
|
||||
selectedValue={menuTask?.categoryId}
|
||||
onSelect={handleSingleCategory}
|
||||
onClose={() => setPicker(null)}
|
||||
@@ -318,7 +359,7 @@ export function useTaskModals() {
|
||||
onClose={() => setPicker(null)}
|
||||
/>
|
||||
|
||||
<OptionPickerModal
|
||||
<OptionPickerModal
|
||||
visible={subtaskPicker?.type === 'priority'}
|
||||
title="Change Priority"
|
||||
options={PRIORITY_OPTIONS}
|
||||
|
||||
@@ -9,7 +9,7 @@ export function useTasksInMonth(monthDate: Date) {
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const refresh = useCallback(() => setRefreshKey((k) => k + 1), []);
|
||||
const refresh = useCallback(() => setRefreshKey(k => k + 1), []);
|
||||
|
||||
const range = useMemo(() => {
|
||||
const s = startOfMonth(monthDate);
|
||||
@@ -23,13 +23,10 @@ export function useTasksInMonth(monthDate: Date) {
|
||||
let mounted = true;
|
||||
setLoading(true);
|
||||
const subscription = collections.tasks
|
||||
.query(
|
||||
Q.where('due_date', Q.between(range.start, range.end)),
|
||||
Q.sortBy('due_date', 'asc')
|
||||
)
|
||||
.query(Q.where('due_date', Q.between(range.start, range.end)), Q.sortBy('due_date', 'asc'))
|
||||
.observe()
|
||||
.subscribe({
|
||||
next: (result) => {
|
||||
next: result => {
|
||||
if (mounted) {
|
||||
setTasks(result);
|
||||
setLoading(false);
|
||||
@@ -60,12 +57,16 @@ export function useTasksInMonth(monthDate: Date) {
|
||||
return { tasks, byDay, loading, refresh };
|
||||
}
|
||||
|
||||
export function useTasks(categoryIds: string[] = [], showCompleted: boolean | 'all' = false, maxAheadDays?: number) {
|
||||
export function useTasks(
|
||||
categoryIds: string[] = [],
|
||||
showCompleted: boolean | 'all' = false,
|
||||
maxAheadDays?: number
|
||||
) {
|
||||
const { collections } = useDatabase();
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const refresh = useCallback(() => setRefreshKey((k) => k + 1), []);
|
||||
const refresh = useCallback(() => setRefreshKey(k => k + 1), []);
|
||||
|
||||
const cutoff = useMemo(() => {
|
||||
if (maxAheadDays === undefined) return null;
|
||||
@@ -80,11 +81,8 @@ export function useTasks(categoryIds: string[] = [], showCompleted: boolean | 'a
|
||||
const conditions: any[] = [];
|
||||
|
||||
if (categoryIds.length > 0) {
|
||||
const tagConditions = categoryIds.map((id) =>
|
||||
Q.or(
|
||||
Q.where('tags', Q.like(`%,${id},%`)),
|
||||
Q.where('category_id', id)
|
||||
)
|
||||
const tagConditions = categoryIds.map(id =>
|
||||
Q.or(Q.where('tags', Q.like(`%,${id},%`)), Q.where('category_id', id))
|
||||
);
|
||||
conditions.push(tagConditions.length === 1 ? tagConditions[0] : Q.or(...tagConditions));
|
||||
}
|
||||
@@ -99,14 +97,13 @@ export function useTasks(categoryIds: string[] = [], showCompleted: boolean | 'a
|
||||
conditions.push(Q.where('due_date', Q.lte(cutoff)));
|
||||
}
|
||||
|
||||
const query = conditions.length > 0
|
||||
const query =
|
||||
conditions.length > 0
|
||||
? collections.tasks.query(Q.and(...conditions))
|
||||
: collections.tasks.query();
|
||||
|
||||
const subscription = query
|
||||
.observe()
|
||||
.subscribe({
|
||||
next: (result) => {
|
||||
const subscription = query.observe().subscribe({
|
||||
next: result => {
|
||||
if (mounted) {
|
||||
setTasks(result);
|
||||
setLoading(false);
|
||||
@@ -131,7 +128,7 @@ export function useTasksByDate(date: Date) {
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const refresh = useCallback(() => setRefreshKey((k) => k + 1), []);
|
||||
const refresh = useCallback(() => setRefreshKey(k => k + 1), []);
|
||||
|
||||
const startOfDay = useMemo(() => {
|
||||
const d = new Date(date);
|
||||
@@ -153,7 +150,7 @@ export function useTasksByDate(date: Date) {
|
||||
)
|
||||
.observe()
|
||||
.subscribe({
|
||||
next: (result) => {
|
||||
next: result => {
|
||||
if (mounted) {
|
||||
setTasks(result);
|
||||
setLoading(false);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Model } from '@nozbe/watermelondb';
|
||||
import { field, date } from '@nozbe/watermelondb/decorators';
|
||||
|
||||
export type TombstoneEntity = 'categories' | 'tasks' | 'subtasks' | 'repeatProfiles' | 'friendships';
|
||||
export type TombstoneEntity =
|
||||
'categories' | 'tasks' | 'subtasks' | 'repeatProfiles' | 'friendships';
|
||||
|
||||
export default class Tombstone extends Model {
|
||||
static table = 'tombstones';
|
||||
|
||||
@@ -148,7 +148,9 @@ export async function register(username: string, password: string): Promise<Auth
|
||||
return data.user as AuthUser;
|
||||
}
|
||||
|
||||
export async function forgotPassword(username: string): Promise<{ success: boolean; resetToken?: string }> {
|
||||
export async function forgotPassword(
|
||||
username: string
|
||||
): Promise<{ success: boolean; resetToken?: string }> {
|
||||
const response = await apiFetch('/auth/forgot-password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username }),
|
||||
@@ -157,7 +159,10 @@ export async function forgotPassword(username: string): Promise<{ success: boole
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function resetPassword(token: string, password: string): Promise<{ success: boolean }> {
|
||||
export async function resetPassword(
|
||||
token: string,
|
||||
password: string
|
||||
): Promise<{ success: boolean }> {
|
||||
const response = await apiFetch('/auth/reset-password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ token, password }),
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { Platform } from 'react-native';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { Reminder, reminderToMinutes, parseReminders, ReminderPreference, parseTaskTags } from '@/types';
|
||||
import { useSettings } from '@/theme';
|
||||
import {
|
||||
Reminder,
|
||||
reminderToMinutes,
|
||||
parseReminders,
|
||||
ReminderPreference,
|
||||
parseTaskTags,
|
||||
} from '@/types';
|
||||
import { TagReminderPreferences, TAG_REMINDER_PREFERENCES_KEY } from '@/theme';
|
||||
|
||||
const ID_MAP_KEY = 'notifications:taskIdToNotificationIds';
|
||||
@@ -95,7 +102,11 @@ function getRemindersForTask(
|
||||
return [];
|
||||
}
|
||||
|
||||
export async function scheduleTaskReminder(task: any, notificationsEnabled: boolean = true, globalReminderPreference: ReminderPreference = '15'): Promise<void> {
|
||||
export async function scheduleTaskReminder(
|
||||
task: any,
|
||||
notificationsEnabled: boolean = true,
|
||||
globalReminderPreference: ReminderPreference = '15'
|
||||
): Promise<void> {
|
||||
const notifications = await getNotificationsModule();
|
||||
const dueDate = task.dueDate ? new Date(task.dueDate) : null;
|
||||
|
||||
@@ -207,7 +218,11 @@ export async function cancelAllReminders(): Promise<void> {
|
||||
await writeIdMap(idMap);
|
||||
}
|
||||
|
||||
export async function rescheduleAllReminders(tasks: any[], notificationsEnabled: boolean = true, globalReminderPreference: ReminderPreference = '15'): Promise<void> {
|
||||
export async function rescheduleAllReminders(
|
||||
tasks: any[],
|
||||
notificationsEnabled: boolean = true,
|
||||
globalReminderPreference: ReminderPreference = '15'
|
||||
): Promise<void> {
|
||||
if (!notificationsEnabled) {
|
||||
await cancelAllReminders();
|
||||
return;
|
||||
@@ -265,3 +280,9 @@ export async function rescheduleAllReminders(tasks: any[], notificationsEnabled:
|
||||
|
||||
await writeIdMap(idMap);
|
||||
}
|
||||
|
||||
// Hook to get current notification settings and reschedule on change
|
||||
export function useNotificationSettings() {
|
||||
const { notifications, reminderPreference } = useSettings();
|
||||
return { notificationsEnabled: notifications, globalReminderPreference: reminderPreference };
|
||||
}
|
||||
|
||||
@@ -29,7 +29,9 @@ export async function registerForPushNotificationsAsync(): Promise<string | null
|
||||
}
|
||||
|
||||
try {
|
||||
const projectId = (Constants.expoConfig as any)?.extra?.eas?.projectId ?? (Constants.expoConfig as any)?.projectId;
|
||||
const projectId =
|
||||
(Constants.expoConfig as any)?.extra?.eas?.projectId ??
|
||||
(Constants.expoConfig as any)?.projectId;
|
||||
const tokenData = await Notifications.getExpoPushTokenAsync({
|
||||
projectId,
|
||||
});
|
||||
@@ -98,7 +100,9 @@ export function addNotificationResponseReceivedListener(
|
||||
return Notifications.addNotificationResponseReceivedListener(listener);
|
||||
}
|
||||
|
||||
export function removeNotificationSubscription(subscription: Notifications.EventSubscription): void {
|
||||
export function removeNotificationSubscription(
|
||||
subscription: Notifications.EventSubscription
|
||||
): void {
|
||||
subscription.remove();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import Constants from 'expo-constants';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import * as FileSystem from 'expo-file-system/legacy';
|
||||
import * as IntentLauncher from 'expo-intent-launcher';
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
const GITEA_BASE_URL = 'https://gitea.techmos.org';
|
||||
const GITEA_OWNER = 'tech08mag';
|
||||
@@ -12,6 +15,8 @@ export interface AppUpdate {
|
||||
notes: string;
|
||||
apkUrl: string | null;
|
||||
releaseUrl: string;
|
||||
mandatory: boolean;
|
||||
publishedAt: string;
|
||||
}
|
||||
|
||||
export function getCurrentAppVersion(): string {
|
||||
@@ -28,26 +33,36 @@ async function fetchWithTimeout(path: string, timeoutMs: number): Promise<Respon
|
||||
}
|
||||
}
|
||||
|
||||
function parseMandatoryFromNotes(notes: string, tagName: string): boolean {
|
||||
const mandatoryKeywords = ['mandatory', 'critical', 'security', 'breaking', 'required'];
|
||||
const lowerNotes = notes.toLowerCase();
|
||||
const lowerTag = tagName.toLowerCase();
|
||||
return mandatoryKeywords.some(kw => lowerNotes.includes(kw) || lowerTag.includes(kw));
|
||||
}
|
||||
|
||||
export async function fetchLatestRelease(): Promise<AppUpdate | null> {
|
||||
try {
|
||||
const response = await fetchWithTimeout(
|
||||
`/api/v1/repos/${GITEA_OWNER}/${GITEA_REPO}/releases/latest`,
|
||||
8000,
|
||||
8000
|
||||
);
|
||||
if (!response.ok) return null;
|
||||
const release = await response.json();
|
||||
const apkAsset = (release.assets ?? []).find(
|
||||
(asset: { name?: string }) => asset.name && /\.apk$/i.test(asset.name),
|
||||
(asset: { name?: string }) => asset.name && /\.apk$/i.test(asset.name)
|
||||
);
|
||||
const tagName: string = release.tag_name ?? release.name ?? '';
|
||||
const notes: string = release.body ?? '';
|
||||
return {
|
||||
version: tagName.replace(/^v/i, ''),
|
||||
tagName,
|
||||
notes: release.body ?? '',
|
||||
notes,
|
||||
apkUrl: apkAsset?.browser_download_url ?? null,
|
||||
releaseUrl:
|
||||
release.html_url ??
|
||||
`${GITEA_BASE_URL}/${GITEA_OWNER}/${GITEA_REPO}/releases/tag/${tagName}`,
|
||||
mandatory: parseMandatoryFromNotes(notes, tagName),
|
||||
publishedAt: release.published_at ?? new Date().toISOString(),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
@@ -56,7 +71,7 @@ export async function fetchLatestRelease(): Promise<AppUpdate | null> {
|
||||
|
||||
export function compareVersions(a: string, b: string): number {
|
||||
const parse = (value: string) =>
|
||||
value.split('.').map((part) => parseInt(part.replace(/\D/g, ''), 10) || 0);
|
||||
value.split('.').map(part => parseInt(part.replace(/\D/g, ''), 10) || 0);
|
||||
const left = parse(a);
|
||||
const right = parse(b);
|
||||
const length = Math.max(left.length, right.length);
|
||||
@@ -92,3 +107,56 @@ export async function markPrompted(tagName: string): Promise<void> {
|
||||
// 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);
|
||||
}
|
||||
|
||||
+205
-25
@@ -1,6 +1,15 @@
|
||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState, ReactNode } from 'react';
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
ReactNode,
|
||||
} from 'react';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { DEFAULT_API_BASE_URL, API_URL_KEY } from '@/services/auth';
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
export type SortBy = 'date' | 'priority' | 'alpha' | 'created';
|
||||
|
||||
@@ -27,6 +36,8 @@ export type TagReminderPreferences = Record<string, ReminderPreference[]>;
|
||||
|
||||
export const TAG_REMINDER_PREFERENCES_KEY = 'settings:tagReminderPreferences';
|
||||
|
||||
export type ThemeMode = 'light' | 'dark' | 'system';
|
||||
|
||||
export interface ThemeColors {
|
||||
background: string;
|
||||
card: string;
|
||||
@@ -75,7 +86,6 @@ export const ACCENT_PRESETS: string[] = [
|
||||
|
||||
function parseHex(hex: string): [number, number, number] {
|
||||
const h = hex.replace(/^#/, '');
|
||||
// Handle both #RRGGBB (6 digits) and #AARRGGBB (8 digits with alpha)
|
||||
if (h.length === 8) {
|
||||
return [parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16), parseInt(h.slice(6, 8), 16)];
|
||||
}
|
||||
@@ -83,7 +93,9 @@ function parseHex(hex: string): [number, number, number] {
|
||||
}
|
||||
|
||||
function toHexByte(n: number): string {
|
||||
return Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, '0');
|
||||
return Math.max(0, Math.min(255, Math.round(n)))
|
||||
.toString(16)
|
||||
.padStart(2, '0');
|
||||
}
|
||||
|
||||
export function mixHex(color: string, target: string, ratio: number): string {
|
||||
@@ -100,15 +112,53 @@ export function desaturate(hex: string, amount: number): string {
|
||||
}
|
||||
|
||||
function accentTextColor(accent: string): string {
|
||||
const [r, g, b] = parseHex(accent).map((v) => v / 255);
|
||||
const [r, g, b] = parseHex(accent).map(v => v / 255);
|
||||
const linear = (v: number) => (v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4));
|
||||
const luminance = 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b);
|
||||
return luminance > 0.5 ? '#111111' : '#FFFFFF';
|
||||
}
|
||||
|
||||
export function colors(accent: string = DEFAULT_ACCENT, opacity: number = 1): ThemeColors {
|
||||
export function colors(
|
||||
accent: string = DEFAULT_ACCENT,
|
||||
mode: ThemeMode = 'dark',
|
||||
opacity: number = 1
|
||||
): ThemeColors {
|
||||
const softAccent = desaturate(accent, 0.35);
|
||||
const accentWithOpacity = opacity < 1 ? `${opacity.toString(16).padStart(2, '0')}${accent.slice(1)}` : accent;
|
||||
const accentWithOpacity =
|
||||
opacity < 1 ? `${opacity.toString(16).padStart(2, '0')}${accent.slice(1)}` : accent;
|
||||
|
||||
if (mode === 'light') {
|
||||
return {
|
||||
background: '#FFFFFF',
|
||||
card: '#FAFAFA',
|
||||
cardAlt: '#F0F0F0',
|
||||
border: '#E0E0E0',
|
||||
borderStrong: '#D0D0D0',
|
||||
text: '#212121',
|
||||
textSecondary: '#424242',
|
||||
textFaint: '#757575',
|
||||
textMuted: '#9E9E9E',
|
||||
accent: opacity < 1 ? `${accentWithOpacity}` : accent,
|
||||
accentStrong: mixHex(accent, '#000000', 0.55),
|
||||
accentSoft: mixHex(softAccent, '#FFFFFF', 0.18),
|
||||
accentBorder: mixHex(desaturate(accent, 0.15), '#FFFFFF', 0.5),
|
||||
accentText: accentTextColor(accent),
|
||||
inputBg: '#FFFFFF',
|
||||
overlay: 'rgba(0,0,0,0.4)',
|
||||
sheetBg: '#FAFAFA',
|
||||
tabBarBg: '#FFFFFF',
|
||||
danger: '#E53935',
|
||||
dangerStrong: '#C62828',
|
||||
dangerSoft: '#FBE9E7',
|
||||
dangerBorder: '#FFCDD2',
|
||||
success: '#43A047',
|
||||
successStrong: '#2E7D32',
|
||||
warning: '#FB8C00',
|
||||
info: '#1E88E5',
|
||||
};
|
||||
}
|
||||
|
||||
// dark mode
|
||||
return {
|
||||
background: '#121212',
|
||||
card: '#1E1E1E',
|
||||
@@ -154,13 +204,19 @@ interface SettingsContextType {
|
||||
setApiUrl: (value: string) => void;
|
||||
accentColor: string;
|
||||
setAccentColor: (value: string) => void;
|
||||
accentOpacity: number;
|
||||
setAccentOpacity: (value: number) => void;
|
||||
todoAheadDays: number;
|
||||
setTodoAheadDays: (value: number) => void;
|
||||
showCompleted: boolean;
|
||||
setShowCompleted: (value: boolean) => void;
|
||||
deleteBySwipe: boolean;
|
||||
setDeleteBySwipe: (value: boolean) => void;
|
||||
themeMode: ThemeMode;
|
||||
setThemeMode: (value: ThemeMode) => void;
|
||||
theme: ThemeColors;
|
||||
syncSettingsToServer: () => Promise<void>;
|
||||
loadSettingsFromServer: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const DEFAULT_TODO_AHEAD_DAYS = 7;
|
||||
@@ -176,17 +232,43 @@ const STORAGE_KEYS = {
|
||||
todoAheadDays: 'settings:todoAheadDays',
|
||||
showCompleted: 'settings:showCompleted',
|
||||
deleteBySwipe: 'settings:deleteBySwipe',
|
||||
themeMode: 'settings:themeMode',
|
||||
};
|
||||
|
||||
const SettingsContext = createContext<SettingsContextType | null>(null);
|
||||
|
||||
async function fetchServerSettings(apiUrl: string, token: string): Promise<any> {
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/users/me/settings`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (response.ok) return await response.json();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function pushServerSettings(apiUrl: string, token: string, settings: any): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/users/me/settings`, {
|
||||
method: 'PATCH',
|
||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(settings),
|
||||
});
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function useStoredSetting<T>(key: string, initialValue: T): [T, (value: T) => void, boolean] {
|
||||
const [value, setValue] = useState<T>(initialValue);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
AsyncStorage.getItem(key)
|
||||
.then((stored) => {
|
||||
.then(stored => {
|
||||
if (stored !== null) {
|
||||
setValue(JSON.parse(stored) as T);
|
||||
}
|
||||
@@ -207,25 +289,112 @@ function useStoredSetting<T>(key: string, initialValue: T): [T, (value: T) => vo
|
||||
}
|
||||
|
||||
export function SettingsProvider({ children }: { children: ReactNode }) {
|
||||
const [notifications, setNotifications] = useStoredSetting<boolean>(STORAGE_KEYS.notifications, true);
|
||||
const [defaultCategoryId, setDefaultCategoryId] = useStoredSetting<string>(STORAGE_KEYS.defaultCategoryId, '');
|
||||
const [notifications, setNotifications, notificationsLoaded] = useStoredSetting<boolean>(
|
||||
STORAGE_KEYS.notifications,
|
||||
true
|
||||
);
|
||||
const [defaultCategoryId, setDefaultCategoryId] = useStoredSetting<string>(
|
||||
STORAGE_KEYS.defaultCategoryId,
|
||||
''
|
||||
);
|
||||
const [sortBy, setSortBy] = useStoredSetting<SortBy>(STORAGE_KEYS.sortBy, 'date');
|
||||
const [reminderPreference, setReminderPreference] = useStoredSetting<ReminderPreference>(
|
||||
STORAGE_KEYS.reminderPreference,
|
||||
'15',
|
||||
);
|
||||
const [tagReminderPreferences, setTagReminderPreferences] = useStoredSetting<TagReminderPreferences>(
|
||||
STORAGE_KEYS.tagReminderPreferences,
|
||||
{},
|
||||
'15'
|
||||
);
|
||||
const [tagReminderPreferences, setTagReminderPreferences] =
|
||||
useStoredSetting<TagReminderPreferences>(STORAGE_KEYS.tagReminderPreferences, {});
|
||||
const [apiUrl, setApiUrl] = useStoredSetting<string>(API_URL_KEY, DEFAULT_API_BASE_URL);
|
||||
const [accentColor, setAccentColor] = useStoredSetting<string>(STORAGE_KEYS.accentColor, DEFAULT_ACCENT);
|
||||
const [accentOpacity, setAccentOpacity] = useStoredSetting<number>(STORAGE_KEYS.accentOpacity, DEFAULT_ACCENT_OPACITY);
|
||||
const [todoAheadDays, setTodoAheadDays] = useStoredSetting<number>(STORAGE_KEYS.todoAheadDays, DEFAULT_TODO_AHEAD_DAYS);
|
||||
const [showCompleted, setShowCompleted] = useStoredSetting<boolean>(STORAGE_KEYS.showCompleted, false);
|
||||
const [deleteBySwipe, setDeleteBySwipe] = useStoredSetting<boolean>(STORAGE_KEYS.deleteBySwipe, false);
|
||||
const [accentColor, setAccentColor] = useStoredSetting<string>(
|
||||
STORAGE_KEYS.accentColor,
|
||||
DEFAULT_ACCENT
|
||||
);
|
||||
const [accentOpacity, setAccentOpacity] = useStoredSetting<number>(
|
||||
STORAGE_KEYS.accentOpacity,
|
||||
DEFAULT_ACCENT_OPACITY
|
||||
);
|
||||
const [todoAheadDays, setTodoAheadDays] = useStoredSetting<number>(
|
||||
STORAGE_KEYS.todoAheadDays,
|
||||
DEFAULT_TODO_AHEAD_DAYS
|
||||
);
|
||||
const [showCompleted, setShowCompleted] = useStoredSetting<boolean>(
|
||||
STORAGE_KEYS.showCompleted,
|
||||
false
|
||||
);
|
||||
const [deleteBySwipe, setDeleteBySwipe] = useStoredSetting<boolean>(
|
||||
STORAGE_KEYS.deleteBySwipe,
|
||||
false
|
||||
);
|
||||
const [themeMode, setThemeMode, themeModeLoaded] = useStoredSetting<ThemeMode>(
|
||||
STORAGE_KEYS.themeMode,
|
||||
'system'
|
||||
);
|
||||
|
||||
const theme = useMemo(() => colors(accentColor), [accentColor]);
|
||||
const effectiveMode: 'light' | 'dark' =
|
||||
themeMode === 'system'
|
||||
? 'dark' // Will be updated by useColorScheme in real app
|
||||
: themeMode;
|
||||
|
||||
const theme = useMemo(
|
||||
() => colors(accentColor, effectiveMode, accentOpacity),
|
||||
[accentColor, effectiveMode, accentOpacity]
|
||||
);
|
||||
|
||||
const syncSettingsToServer = useCallback(async () => {
|
||||
const { getAuthToken } = await import('@/services/auth');
|
||||
const token = await getAuthToken();
|
||||
if (!token) return;
|
||||
|
||||
const isDark = effectiveMode === 'dark';
|
||||
const settings = {
|
||||
darkMode: isDark,
|
||||
defaultCategoryId,
|
||||
sortOrder: sortBy,
|
||||
reminderPreference,
|
||||
todoAheadDays,
|
||||
showCompleted,
|
||||
deleteBySwipe,
|
||||
};
|
||||
await pushServerSettings(apiUrl, token, settings);
|
||||
}, [
|
||||
apiUrl,
|
||||
effectiveMode,
|
||||
themeMode,
|
||||
defaultCategoryId,
|
||||
sortBy,
|
||||
reminderPreference,
|
||||
todoAheadDays,
|
||||
showCompleted,
|
||||
deleteBySwipe,
|
||||
]);
|
||||
|
||||
const loadSettingsFromServer = useCallback(async () => {
|
||||
const { getAuthToken } = await import('@/services/auth');
|
||||
const token = await getAuthToken();
|
||||
if (!token) return;
|
||||
|
||||
const serverSettings = await fetchServerSettings(apiUrl, token);
|
||||
if (!serverSettings) return;
|
||||
|
||||
if (serverSettings.darkMode !== undefined) {
|
||||
setThemeMode(serverSettings.darkMode ? 'dark' : 'light');
|
||||
}
|
||||
if (serverSettings.defaultCategoryId) setDefaultCategoryId(serverSettings.defaultCategoryId);
|
||||
if (serverSettings.sortOrder) setSortBy(serverSettings.sortOrder);
|
||||
if (serverSettings.reminderPreference) setReminderPreference(serverSettings.reminderPreference);
|
||||
if (serverSettings.todoAheadDays !== undefined) setTodoAheadDays(serverSettings.todoAheadDays);
|
||||
if (serverSettings.showCompleted !== undefined) setShowCompleted(serverSettings.showCompleted);
|
||||
if (serverSettings.deleteBySwipe !== undefined) setDeleteBySwipe(serverSettings.deleteBySwipe);
|
||||
}, [
|
||||
apiUrl,
|
||||
setThemeMode,
|
||||
setDefaultCategoryId,
|
||||
setSortBy,
|
||||
setReminderPreference,
|
||||
setTodoAheadDays,
|
||||
setShowCompleted,
|
||||
setDeleteBySwipe,
|
||||
]);
|
||||
|
||||
const value = useMemo<SettingsContextType>(
|
||||
() => ({
|
||||
@@ -243,13 +412,19 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
|
||||
setApiUrl,
|
||||
accentColor,
|
||||
setAccentColor,
|
||||
accentOpacity,
|
||||
setAccentOpacity,
|
||||
todoAheadDays,
|
||||
setTodoAheadDays,
|
||||
showCompleted,
|
||||
setShowCompleted,
|
||||
deleteBySwipe,
|
||||
setDeleteBySwipe,
|
||||
themeMode,
|
||||
setThemeMode,
|
||||
theme,
|
||||
syncSettingsToServer,
|
||||
loadSettingsFromServer,
|
||||
}),
|
||||
[
|
||||
notifications,
|
||||
@@ -266,18 +441,23 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
|
||||
setApiUrl,
|
||||
accentColor,
|
||||
setAccentColor,
|
||||
accentOpacity,
|
||||
setAccentOpacity,
|
||||
todoAheadDays,
|
||||
setTodoAheadDays,
|
||||
showCompleted,
|
||||
setShowCompleted,
|
||||
deleteBySwipe,
|
||||
setDeleteBySwipe,
|
||||
themeMode,
|
||||
setThemeMode,
|
||||
theme,
|
||||
syncSettingsToServer,
|
||||
loadSettingsFromServer,
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
<SettingsContext.Provider value={value}>
|
||||
{children}
|
||||
</SettingsContext.Provider>
|
||||
);
|
||||
return <SettingsContext.Provider value={value}>{children}</SettingsContext.Provider>;
|
||||
}
|
||||
|
||||
export function useSettings() {
|
||||
|
||||
@@ -17,7 +17,7 @@ export const WEEKDAY_LABELS = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];
|
||||
export const WEEKDAY_ORDER = [1, 2, 3, 4, 5, 6, 0];
|
||||
|
||||
export function weekdaysInDisplayOrder(days: number[]): number[] {
|
||||
return WEEKDAY_ORDER.filter((day) => days.includes(day));
|
||||
return WEEKDAY_ORDER.filter(day => days.includes(day));
|
||||
}
|
||||
|
||||
export function repeatDaysToString(days: number[]): string {
|
||||
@@ -28,7 +28,7 @@ export function repeatDaysFromString(raw: string): number[] {
|
||||
return (raw || '')
|
||||
.split(',')
|
||||
.map(Number)
|
||||
.filter((d) => !Number.isNaN(d));
|
||||
.filter(d => !Number.isNaN(d));
|
||||
}
|
||||
|
||||
export interface RepeatProfileData {
|
||||
@@ -53,7 +53,7 @@ export const REMINDER_OPTIONS: { value: Reminder; label: string }[] = [
|
||||
{ value: '1440', label: '1 day before' },
|
||||
];
|
||||
|
||||
export const MULTIPLE_REMINDER_OPTIONS = REMINDER_OPTIONS.filter((o) => o.value !== 'none');
|
||||
export const MULTIPLE_REMINDER_OPTIONS = REMINDER_OPTIONS.filter(o => o.value !== 'none');
|
||||
|
||||
export function reminderToMinutes(reminder: Reminder): number | null {
|
||||
if (reminder === 'none') return null;
|
||||
@@ -86,12 +86,12 @@ export function parseReminders(reminders: string): Reminder[] {
|
||||
if (!reminders) return [];
|
||||
return reminders
|
||||
.split(',')
|
||||
.map((r) => r.trim() as Reminder)
|
||||
.map(r => r.trim() as Reminder)
|
||||
.filter((r): r is Exclude<Reminder, 'none'> => r !== 'none' && Boolean(r));
|
||||
}
|
||||
|
||||
export function toRemindersString(reminders: Reminder[]): string {
|
||||
return reminders.filter((r) => r !== 'none').join(',');
|
||||
return reminders.filter(r => r !== 'none').join(',');
|
||||
}
|
||||
|
||||
export interface CategoryData {
|
||||
@@ -109,7 +109,7 @@ export function tagsFromString(raw: string | null | undefined): string[] {
|
||||
if (!raw) return [];
|
||||
return raw
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.map(t => t.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ export async function createCategory(name: string, color: string): Promise<void>
|
||||
const existing = await collections.categories.query().fetch();
|
||||
const nextOrder = existing.reduce((max, c) => Math.max(max, c.order), -1) + 1;
|
||||
const now = new Date();
|
||||
await collections.categories.create((c) => {
|
||||
await collections.categories.create(c => {
|
||||
c.name = name;
|
||||
c.color = color;
|
||||
c.order = nextOrder;
|
||||
@@ -18,10 +18,14 @@ export async function createCategory(name: string, color: string): Promise<void>
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateCategory(categoryId: string, name: string, color: string): Promise<void> {
|
||||
export async function updateCategory(
|
||||
categoryId: string,
|
||||
name: string,
|
||||
color: string
|
||||
): Promise<void> {
|
||||
await database.write(async () => {
|
||||
const category = await collections.categories.find(categoryId);
|
||||
await category.update((c) => {
|
||||
await category.update(c => {
|
||||
c.name = name;
|
||||
c.color = color;
|
||||
c.updatedAt = new Date();
|
||||
@@ -31,23 +35,25 @@ export async function updateCategory(categoryId: string, name: string, color: st
|
||||
|
||||
export async function deleteCategory(categoryId: string): Promise<void> {
|
||||
await database.write(async () => {
|
||||
const others = (await collections.categories.query().fetch()).filter((c) => c.id !== categoryId);
|
||||
const others = (await collections.categories.query().fetch()).filter(c => c.id !== categoryId);
|
||||
const fallback = others[0];
|
||||
|
||||
const tasks = await collections.tasks.query(Q.where('category_id', categoryId)).fetch();
|
||||
for (const task of tasks) {
|
||||
await task.update((t) => {
|
||||
const tags = parseTaskTags(t.tags, t.categoryId).filter((id) => id !== categoryId);
|
||||
await task.update(t => {
|
||||
const tags = parseTaskTags(t.tags, t.categoryId).filter(id => id !== categoryId);
|
||||
t.categoryId = fallback?.id ?? tags[0] ?? '';
|
||||
t.tags = tagsToString(tags);
|
||||
t.updatedAt = new Date();
|
||||
});
|
||||
}
|
||||
|
||||
const tagReferencedTasks = await collections.tasks.query(Q.where('tags', Q.like(`%,${categoryId},%`))).fetch();
|
||||
const tagReferencedTasks = await collections.tasks
|
||||
.query(Q.where('tags', Q.like(`%,${categoryId},%`)))
|
||||
.fetch();
|
||||
for (const task of tagReferencedTasks) {
|
||||
const tags = parseTaskTags(task.tags, task.categoryId).filter((id) => id !== categoryId);
|
||||
await task.update((t) => {
|
||||
const tags = parseTaskTags(task.tags, task.categoryId).filter(id => id !== categoryId);
|
||||
await task.update(t => {
|
||||
t.tags = tagsToString(tags);
|
||||
t.updatedAt = new Date();
|
||||
});
|
||||
|
||||
@@ -8,7 +8,13 @@ type ConfirmOptions = {
|
||||
onConfirm: () => void;
|
||||
};
|
||||
|
||||
export function confirmAction({ title, message, confirmLabel = 'Delete', destructive, onConfirm }: ConfirmOptions): void {
|
||||
export function confirmAction({
|
||||
title,
|
||||
message,
|
||||
confirmLabel = 'Delete',
|
||||
destructive,
|
||||
onConfirm,
|
||||
}: ConfirmOptions): void {
|
||||
if (Platform.OS === 'web') {
|
||||
const confirmed = (globalThis as any).confirm(message ? `${title}\n\n${message}` : title);
|
||||
if (confirmed) onConfirm();
|
||||
|
||||
@@ -5,7 +5,7 @@ let pending = false;
|
||||
|
||||
export function requestQuickAddFocus(): void {
|
||||
pending = true;
|
||||
listeners.forEach((listener) => listener());
|
||||
listeners.forEach(listener => listener());
|
||||
}
|
||||
|
||||
export function subscribeToQuickAdd(listener: QuickAddListener): () => void {
|
||||
|
||||
@@ -10,7 +10,7 @@ export async function createRepeatProfile(
|
||||
): Promise<void> {
|
||||
const now = new Date();
|
||||
await database.write(async () => {
|
||||
await collections.repeatProfiles.create((p) => {
|
||||
await collections.repeatProfiles.create(p => {
|
||||
p.name = name.trim();
|
||||
p.repeat = repeat;
|
||||
p.repeatInterval = interval || 1;
|
||||
|
||||
@@ -32,12 +32,16 @@ function mapSubtaskRow(s: any, taskId?: string): SubtaskData {
|
||||
}
|
||||
|
||||
export async function fetchSubtaskTree(taskId: string): Promise<SubtaskData[]> {
|
||||
const topLevel = await collections.subtasks.query(Q.where('task_id', taskId), Q.where('parent_subtask_id', null)).fetch();
|
||||
const topLevel = await collections.subtasks
|
||||
.query(Q.where('task_id', taskId), Q.where('parent_subtask_id', null))
|
||||
.fetch();
|
||||
const items: SubtaskData[] = topLevel.map((s: any) => mapSubtaskRow(s, taskId));
|
||||
|
||||
const decorate = async (subtasks: SubtaskData[]): Promise<SubtaskData[]> => {
|
||||
for (const sub of subtasks) {
|
||||
const children = await collections.subtasks.query(Q.where('parent_subtask_id', sub.id)).fetch();
|
||||
const children = await collections.subtasks
|
||||
.query(Q.where('parent_subtask_id', sub.id))
|
||||
.fetch();
|
||||
if (children.length > 0) {
|
||||
sub.subtasks = await decorate(children.map((c: any) => mapSubtaskRow(c, taskId)));
|
||||
}
|
||||
@@ -70,7 +74,12 @@ function startOfDay(date: Date): Date {
|
||||
return d;
|
||||
}
|
||||
|
||||
function nextSelectedDay(base: Date, days: number[], intervalWeeks: number, todayStart: Date): Date {
|
||||
function nextSelectedDay(
|
||||
base: Date,
|
||||
days: number[],
|
||||
intervalWeeks: number,
|
||||
todayStart: Date
|
||||
): Date {
|
||||
const msDay = 86400000;
|
||||
|
||||
for (let offset = 1; offset <= intervalWeeks * 7 + 7; offset++) {
|
||||
@@ -95,8 +104,8 @@ export function parseRepeatDays(repeatDays: string): number[] {
|
||||
if (!repeatDays) return [];
|
||||
return repeatDays
|
||||
.split(',')
|
||||
.map((d) => parseInt(d, 10))
|
||||
.filter((d) => !Number.isNaN(d) && d >= 0 && d <= 6);
|
||||
.map(d => parseInt(d, 10))
|
||||
.filter(d => !Number.isNaN(d) && d >= 0 && d <= 6);
|
||||
}
|
||||
|
||||
export function computeNextOccurrence(
|
||||
@@ -152,7 +161,10 @@ export async function deleteTaskWithSubtasks(taskId: string): Promise<void> {
|
||||
const task = await collections.tasks.find(taskId);
|
||||
await task.destroyPermanently();
|
||||
if (subtasks.length > 0) {
|
||||
await recordTombstonesInBatch('subtasks', subtasks.map((s) => s.id));
|
||||
await recordTombstonesInBatch(
|
||||
'subtasks',
|
||||
subtasks.map(s => s.id)
|
||||
);
|
||||
}
|
||||
await recordTombstonesInBatch('tasks', [taskId]);
|
||||
}
|
||||
@@ -171,7 +183,7 @@ export async function deleteTaskOccurrences(taskId: string, scope: TaskDeleteSco
|
||||
const seriesTasks = await collections.tasks.query(Q.where('series_id', task.seriesId)).fetch();
|
||||
const anchor = task.dueDate || task.createdAt.getTime();
|
||||
|
||||
const toDelete = seriesTasks.filter((t) => {
|
||||
const toDelete = seriesTasks.filter(t => {
|
||||
if (scope === 'all') return true;
|
||||
return (t.dueDate || t.createdAt.getTime()) >= anchor;
|
||||
});
|
||||
@@ -186,14 +198,16 @@ export async function deleteTaskOccurrences(taskId: string, scope: TaskDeleteSco
|
||||
});
|
||||
}
|
||||
|
||||
export async function getSeriesOccurrenceCounts(taskId: string): Promise<{ future: number; all: number }> {
|
||||
export async function getSeriesOccurrenceCounts(
|
||||
taskId: string
|
||||
): Promise<{ future: number; all: number }> {
|
||||
const task = await collections.tasks.find(taskId);
|
||||
if (!task.seriesId || task.repeat === 'none') {
|
||||
return { future: 1, all: 1 };
|
||||
}
|
||||
const seriesTasks = await collections.tasks.query(Q.where('series_id', task.seriesId)).fetch();
|
||||
const anchor = task.dueDate || task.createdAt.getTime();
|
||||
const future = seriesTasks.filter((t) => (t.dueDate || t.createdAt.getTime()) >= anchor).length;
|
||||
const future = seriesTasks.filter(t => (t.dueDate || t.createdAt.getTime()) >= anchor).length;
|
||||
return { future, all: seriesTasks.length };
|
||||
}
|
||||
|
||||
@@ -202,7 +216,7 @@ export async function toggleTaskComplete(taskId: string): Promise<string | null>
|
||||
await database.write(async () => {
|
||||
const task = await collections.tasks.find(taskId);
|
||||
const completing = !task.completed;
|
||||
await task.update((t) => {
|
||||
await task.update(t => {
|
||||
t.completed = completing;
|
||||
t.completedAt = completing ? Date.now() : null;
|
||||
t.updatedAt = new Date();
|
||||
@@ -219,10 +233,9 @@ export async function toggleTaskComplete(taskId: string): Promise<string | null>
|
||||
}
|
||||
|
||||
export async function countOpenSubtasks(taskId: string): Promise<number> {
|
||||
const open = await collections.subtasks.query(
|
||||
Q.where('task_id', taskId),
|
||||
Q.where('completed', false)
|
||||
).fetch();
|
||||
const open = await collections.subtasks
|
||||
.query(Q.where('task_id', taskId), Q.where('completed', false))
|
||||
.fetch();
|
||||
return open.length;
|
||||
}
|
||||
|
||||
@@ -266,12 +279,12 @@ export async function confirmAndToggleTaskComplete(
|
||||
export async function toggleSubtaskComplete(subtaskId: string): Promise<void> {
|
||||
await database.write(async () => {
|
||||
const subtask = await collections.subtasks.find(subtaskId);
|
||||
await subtask.update((s) => {
|
||||
await subtask.update(s => {
|
||||
s.completed = !s.completed;
|
||||
s.updatedAt = new Date();
|
||||
});
|
||||
const task = await collections.tasks.find(subtask.taskId);
|
||||
await task.update((t) => {
|
||||
await task.update(t => {
|
||||
t.updatedAt = new Date();
|
||||
});
|
||||
});
|
||||
@@ -300,7 +313,7 @@ export interface SubtaskUpdateData {
|
||||
export async function updateSubtask(subtaskId: string, data: SubtaskUpdateData): Promise<void> {
|
||||
await database.write(async () => {
|
||||
const subtask = await collections.subtasks.find(subtaskId);
|
||||
await subtask.update((s) => {
|
||||
await subtask.update(s => {
|
||||
s.title = data.title.trim();
|
||||
s.description = data.description || '';
|
||||
if (data.categoryId !== undefined) s.categoryId = data.categoryId;
|
||||
@@ -318,7 +331,7 @@ export async function updateSubtask(subtaskId: string, data: SubtaskUpdateData):
|
||||
s.updatedAt = new Date();
|
||||
});
|
||||
const task = await collections.tasks.find(subtask.taskId);
|
||||
await task.update((t) => {
|
||||
await task.update(t => {
|
||||
t.updatedAt = new Date();
|
||||
});
|
||||
});
|
||||
@@ -332,9 +345,11 @@ export async function deleteSubtask(subtaskId: string): Promise<void> {
|
||||
await database.write(async () => {
|
||||
const subtask = await collections.subtasks.find(subtaskId);
|
||||
parentTaskId = subtask.taskId;
|
||||
const children = await collections.subtasks.query(Q.where('parent_subtask_id', subtaskId)).fetch();
|
||||
const children = await collections.subtasks
|
||||
.query(Q.where('parent_subtask_id', subtaskId))
|
||||
.fetch();
|
||||
for (const child of children) {
|
||||
await child.update((c) => {
|
||||
await child.update(c => {
|
||||
c.parentSubtaskId = null;
|
||||
c.updatedAt = new Date();
|
||||
});
|
||||
@@ -347,7 +362,7 @@ export async function deleteSubtask(subtaskId: string): Promise<void> {
|
||||
try {
|
||||
await database.write(async () => {
|
||||
const task = await collections.tasks.find(parentTaskId!);
|
||||
await task.update((t) => {
|
||||
await task.update(t => {
|
||||
t.updatedAt = new Date();
|
||||
});
|
||||
});
|
||||
@@ -364,7 +379,7 @@ export async function duplicateSubtask(subtaskId: string): Promise<void> {
|
||||
const siblings = await collections.subtasks.query(Q.where('task_id', subtask.taskId)).fetch();
|
||||
const now = new Date();
|
||||
|
||||
clone = await collections.subtasks.create((s) => {
|
||||
clone = await collections.subtasks.create(s => {
|
||||
s.taskId = subtask.taskId;
|
||||
s.categoryId = subtask.categoryId || '';
|
||||
s.tags = subtask.tags || '';
|
||||
@@ -388,7 +403,7 @@ export async function duplicateSubtask(subtaskId: string): Promise<void> {
|
||||
});
|
||||
|
||||
const task = await collections.tasks.find(subtask.taskId);
|
||||
await task.update((t) => {
|
||||
await task.update(t => {
|
||||
t.updatedAt = new Date();
|
||||
});
|
||||
});
|
||||
@@ -398,18 +413,21 @@ export async function duplicateSubtask(subtaskId: string): Promise<void> {
|
||||
export async function setSubtaskPriority(subtaskId: string, priority: Priority): Promise<void> {
|
||||
await database.write(async () => {
|
||||
const subtask = await collections.subtasks.find(subtaskId);
|
||||
await subtask.update((s) => {
|
||||
await subtask.update(s => {
|
||||
s.priority = priority;
|
||||
s.updatedAt = new Date();
|
||||
});
|
||||
const task = await collections.tasks.find(subtask.taskId);
|
||||
await task.update((t) => {
|
||||
await task.update(t => {
|
||||
t.updatedAt = new Date();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function undoTaskComplete(taskId: string, nextOccurrenceId: string | null): Promise<void> {
|
||||
export async function undoTaskComplete(
|
||||
taskId: string,
|
||||
nextOccurrenceId: string | null
|
||||
): Promise<void> {
|
||||
await database.write(async () => {
|
||||
if (nextOccurrenceId) {
|
||||
try {
|
||||
@@ -423,7 +441,7 @@ export async function undoTaskComplete(taskId: string, nextOccurrenceId: string
|
||||
try {
|
||||
const task = await collections.tasks.find(taskId);
|
||||
if (task.completed) {
|
||||
await task.update((t) => {
|
||||
await task.update(t => {
|
||||
t.completed = false;
|
||||
t.updatedAt = new Date();
|
||||
});
|
||||
@@ -446,19 +464,27 @@ export async function deleteTask(taskId: string): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
export async function convertTaskToSubtask(taskId: string, parentTaskId: string, parentSubtaskId: string | null = null): Promise<void> {
|
||||
export async function convertTaskToSubtask(
|
||||
taskId: string,
|
||||
parentTaskId: string,
|
||||
parentSubtaskId: string | null = null
|
||||
): Promise<void> {
|
||||
if (taskId === parentTaskId) return;
|
||||
await database.write(async () => {
|
||||
const task = await collections.tasks.find(taskId);
|
||||
const ownSubtasks = await collections.subtasks.query(Q.where('task_id', taskId)).fetch();
|
||||
const existing = await collections.subtasks.query(
|
||||
const existing = await collections.subtasks
|
||||
.query(
|
||||
Q.where('task_id', parentTaskId),
|
||||
parentSubtaskId ? Q.where('parent_subtask_id', parentSubtaskId) : Q.where('parent_subtask_id', null)
|
||||
).fetch();
|
||||
parentSubtaskId
|
||||
? Q.where('parent_subtask_id', parentSubtaskId)
|
||||
: Q.where('parent_subtask_id', null)
|
||||
)
|
||||
.fetch();
|
||||
const now = new Date();
|
||||
|
||||
let order = existing.length;
|
||||
const created = await collections.subtasks.create((s) => {
|
||||
const created = await collections.subtasks.create(s => {
|
||||
s.taskId = parentTaskId;
|
||||
s.parentSubtaskId = parentSubtaskId;
|
||||
s.categoryId = task.categoryId || '';
|
||||
@@ -482,7 +508,7 @@ export async function convertTaskToSubtask(taskId: string, parentTaskId: string,
|
||||
});
|
||||
|
||||
for (const subtask of ownSubtasks) {
|
||||
await collections.subtasks.create((s) => {
|
||||
await collections.subtasks.create(s => {
|
||||
s.taskId = parentTaskId;
|
||||
s.parentSubtaskId = created.id;
|
||||
s.categoryId = subtask.categoryId || '';
|
||||
@@ -507,7 +533,7 @@ export async function convertTaskToSubtask(taskId: string, parentTaskId: string,
|
||||
}
|
||||
|
||||
const parent = await collections.tasks.find(parentTaskId);
|
||||
await parent.update((p) => {
|
||||
await parent.update(p => {
|
||||
p.updatedAt = now;
|
||||
});
|
||||
|
||||
@@ -524,11 +550,13 @@ export async function convertSubtaskToTask(subtaskId: string): Promise<void> {
|
||||
const now = new Date();
|
||||
const subTags = parseTaskTags(subtask.tags, subtask.categoryId);
|
||||
|
||||
const task = await collections.tasks.create((t) => {
|
||||
const task = await collections.tasks.create(t => {
|
||||
t.title = subtask.title;
|
||||
t.description = subtask.description || '';
|
||||
t.categoryId = subTags[0] || parent.categoryId || '';
|
||||
t.tags = tagsToString(subTags.length > 0 ? subTags : (parent.categoryId ? [parent.categoryId] : []));
|
||||
t.tags = tagsToString(
|
||||
subTags.length > 0 ? subTags : parent.categoryId ? [parent.categoryId] : []
|
||||
);
|
||||
t.priority = subtask.priority;
|
||||
t.completed = subtask.completed;
|
||||
t.dueDate = subtask.dueDate;
|
||||
@@ -546,9 +574,11 @@ export async function convertSubtaskToTask(subtaskId: string): Promise<void> {
|
||||
});
|
||||
newTaskId = task.id;
|
||||
|
||||
const children = await collections.subtasks.query(Q.where('parent_subtask_id', subtaskId)).fetch();
|
||||
const children = await collections.subtasks
|
||||
.query(Q.where('parent_subtask_id', subtaskId))
|
||||
.fetch();
|
||||
for (const child of children) {
|
||||
await child.update((c) => {
|
||||
await child.update(c => {
|
||||
c.parentSubtaskId = null;
|
||||
c.updatedAt = now;
|
||||
});
|
||||
@@ -556,7 +586,7 @@ export async function convertSubtaskToTask(subtaskId: string): Promise<void> {
|
||||
|
||||
await subtask.destroyPermanently();
|
||||
await recordTombstonesInBatch('subtasks', [subtaskId]);
|
||||
await parent.update((p) => {
|
||||
await parent.update(p => {
|
||||
p.updatedAt = now;
|
||||
});
|
||||
});
|
||||
@@ -576,7 +606,7 @@ export async function moveSubtaskToTask(subtaskId: string, toTaskId: string): Pr
|
||||
const siblings = await collections.subtasks.query(Q.where('task_id', toTaskId)).fetch();
|
||||
const now = new Date();
|
||||
|
||||
await subtask.update((s) => {
|
||||
await subtask.update(s => {
|
||||
s.taskId = toTaskId;
|
||||
s.parentSubtaskId = null;
|
||||
s.order = siblings.length;
|
||||
@@ -584,14 +614,14 @@ export async function moveSubtaskToTask(subtaskId: string, toTaskId: string): Pr
|
||||
});
|
||||
try {
|
||||
const oldParent = await collections.tasks.find(oldParentId);
|
||||
await oldParent.update((p) => {
|
||||
await oldParent.update(p => {
|
||||
p.updatedAt = now;
|
||||
});
|
||||
} catch {
|
||||
// parent already gone
|
||||
}
|
||||
const newParent = await collections.tasks.find(toTaskId);
|
||||
await newParent.update((p) => {
|
||||
await newParent.update(p => {
|
||||
p.updatedAt = now;
|
||||
});
|
||||
});
|
||||
@@ -617,19 +647,18 @@ export async function setSubtaskParent(subtaskId: string, newParentId: string):
|
||||
cursor = await collections.subtasks.find(cursor.parentSubtaskId);
|
||||
}
|
||||
|
||||
const siblings = await collections.subtasks.query(
|
||||
Q.where('task_id', subtask.taskId),
|
||||
Q.where('parent_subtask_id', newParentId)
|
||||
).fetch();
|
||||
const siblings = await collections.subtasks
|
||||
.query(Q.where('task_id', subtask.taskId), Q.where('parent_subtask_id', newParentId))
|
||||
.fetch();
|
||||
|
||||
await subtask.update((s) => {
|
||||
await subtask.update(s => {
|
||||
s.parentSubtaskId = newParentId;
|
||||
s.order = siblings.length;
|
||||
s.updatedAt = now;
|
||||
});
|
||||
|
||||
const task = await collections.tasks.find(subtask.taskId);
|
||||
await task.update((t) => {
|
||||
await task.update(t => {
|
||||
t.updatedAt = now;
|
||||
});
|
||||
});
|
||||
@@ -639,7 +668,7 @@ export async function setTaskCompleted(taskId: string, completed: boolean): Prom
|
||||
let nextOccurrence: any = null;
|
||||
await database.write(async () => {
|
||||
const task = await collections.tasks.find(taskId);
|
||||
await task.update((t) => {
|
||||
await task.update(t => {
|
||||
t.completed = completed;
|
||||
t.completedAt = completed ? Date.now() : null;
|
||||
t.updatedAt = new Date();
|
||||
@@ -656,10 +685,9 @@ export async function setTaskCompleted(taskId: string, completed: boolean): Prom
|
||||
|
||||
async function createNextOccurrenceIfNeeded(task: any): Promise<any> {
|
||||
const seriesId = task.seriesId || newSeriesId();
|
||||
const existing = await collections.tasks.query(
|
||||
Q.where('series_id', seriesId),
|
||||
Q.where('completed', false)
|
||||
).fetch();
|
||||
const existing = await collections.tasks
|
||||
.query(Q.where('series_id', seriesId), Q.where('completed', false))
|
||||
.fetch();
|
||||
if (existing.length > 0) return null;
|
||||
const next = await createNextOccurrence(task, seriesId);
|
||||
if (!task.seriesId) {
|
||||
@@ -671,11 +699,16 @@ async function createNextOccurrenceIfNeeded(task: any): Promise<any> {
|
||||
}
|
||||
|
||||
async function createNextOccurrence(task: any, seriesId: string): Promise<any> {
|
||||
const nextDate = computeNextOccurrence(task.dueDate, task.repeat, task.repeatInterval, task.repeatDays);
|
||||
const nextDate = computeNextOccurrence(
|
||||
task.dueDate,
|
||||
task.repeat,
|
||||
task.repeatInterval,
|
||||
task.repeatDays
|
||||
);
|
||||
const subtasks = await collections.subtasks.query(Q.where('task_id', task.id)).fetch();
|
||||
const now = new Date();
|
||||
|
||||
const next = await collections.tasks.create((t) => {
|
||||
const next = await collections.tasks.create(t => {
|
||||
t.title = task.title;
|
||||
t.description = task.description;
|
||||
t.categoryId = task.categoryId;
|
||||
@@ -697,7 +730,7 @@ async function createNextOccurrence(task: any, seriesId: string): Promise<any> {
|
||||
});
|
||||
|
||||
for (const subtask of subtasks) {
|
||||
await collections.subtasks.create((s) => {
|
||||
await collections.subtasks.create(s => {
|
||||
s.taskId = next.id;
|
||||
s.title = subtask.title;
|
||||
s.description = subtask.description;
|
||||
@@ -723,7 +756,7 @@ async function createNextOccurrence(task: any, seriesId: string): Promise<any> {
|
||||
export async function setTaskCategory(taskId: string, categoryId: string): Promise<void> {
|
||||
await database.write(async () => {
|
||||
const task = await collections.tasks.find(taskId);
|
||||
await task.update((t) => {
|
||||
await task.update(t => {
|
||||
t.categoryId = categoryId;
|
||||
t.tags = tagsToString(categoryId ? [categoryId] : []);
|
||||
t.updatedAt = new Date();
|
||||
@@ -734,7 +767,7 @@ export async function setTaskCategory(taskId: string, categoryId: string): Promi
|
||||
export async function setTaskPriority(taskId: string, priority: Priority): Promise<void> {
|
||||
await database.write(async () => {
|
||||
const task = await collections.tasks.find(taskId);
|
||||
await task.update((t) => {
|
||||
await task.update(t => {
|
||||
t.priority = priority;
|
||||
t.updatedAt = new Date();
|
||||
});
|
||||
@@ -748,7 +781,7 @@ export async function duplicateTask(taskId: string): Promise<void> {
|
||||
const subtasks = await collections.subtasks.query(Q.where('task_id', taskId)).fetch();
|
||||
const now = new Date();
|
||||
|
||||
clone = await collections.tasks.create((t) => {
|
||||
clone = await collections.tasks.create(t => {
|
||||
t.title = task.title;
|
||||
t.description = task.description;
|
||||
t.categoryId = task.categoryId;
|
||||
@@ -770,7 +803,7 @@ export async function duplicateTask(taskId: string): Promise<void> {
|
||||
});
|
||||
|
||||
for (const subtask of subtasks) {
|
||||
await collections.subtasks.create((s) => {
|
||||
await collections.subtasks.create(s => {
|
||||
s.taskId = clone.id;
|
||||
s.title = subtask.title;
|
||||
s.description = subtask.description;
|
||||
@@ -796,7 +829,7 @@ export async function reorderTasks(taskIds: string[]): Promise<void> {
|
||||
const now = new Date();
|
||||
for (let i = 0; i < taskIds.length; i++) {
|
||||
const task = await collections.tasks.find(taskIds[i]);
|
||||
await task.update((t) => {
|
||||
await task.update(t => {
|
||||
t.order = i;
|
||||
t.updatedAt = now;
|
||||
});
|
||||
@@ -809,7 +842,7 @@ export async function reorderSubtasks(subtaskIds: string[]): Promise<void> {
|
||||
const now = new Date();
|
||||
for (let i = 0; i < subtaskIds.length; i++) {
|
||||
const subtask = await collections.subtasks.find(subtaskIds[i]);
|
||||
await subtask.update((s) => {
|
||||
await subtask.update(s => {
|
||||
s.order = i;
|
||||
s.updatedAt = now;
|
||||
});
|
||||
@@ -840,12 +873,16 @@ export async function createSubtask(data: CreateSubtaskData): Promise<string> {
|
||||
let newSubtaskId: string | null = null;
|
||||
await database.write(async () => {
|
||||
const now = new Date();
|
||||
const siblings = await collections.subtasks.query(
|
||||
const siblings = await collections.subtasks
|
||||
.query(
|
||||
Q.where('task_id', data.taskId),
|
||||
data.parentSubtaskId ? Q.where('parent_subtask_id', data.parentSubtaskId) : Q.where('parent_subtask_id', null)
|
||||
).fetch();
|
||||
data.parentSubtaskId
|
||||
? Q.where('parent_subtask_id', data.parentSubtaskId)
|
||||
: Q.where('parent_subtask_id', null)
|
||||
)
|
||||
.fetch();
|
||||
|
||||
const subtask = await collections.subtasks.create((s) => {
|
||||
const subtask = await collections.subtasks.create(s => {
|
||||
s.taskId = data.taskId;
|
||||
s.parentSubtaskId = data.parentSubtaskId || null;
|
||||
s.categoryId = data.categoryId || '';
|
||||
|
||||
+35
-2
@@ -1,4 +1,37 @@
|
||||
{
|
||||
"compilerOptions": {},
|
||||
"extends": "expo/tsconfig.base"
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"esModuleInterop": true,
|
||||
"jsx": "react-jsx",
|
||||
"lib": ["DOM", "ESNext"],
|
||||
"module": "preserve",
|
||||
"moduleDetection": "force",
|
||||
"moduleResolution": "bundler",
|
||||
"customConditions": ["react-native"],
|
||||
"noEmit": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"target": "ESNext",
|
||||
"baseUrl": ".",
|
||||
"deprecationDiagnostics": false,
|
||||
"paths": {
|
||||
"@/*": ["carry-your-live/src/*"],
|
||||
"@/components/*": ["carry-your-live/src/components/*"],
|
||||
"@/hooks/*": ["carry-your-live/src/hooks/*"],
|
||||
"@/database/*": ["carry-your-live/src/database/*"],
|
||||
"@/models/*": ["carry-your-live/src/models/*"],
|
||||
"@/constants/*": ["carry-your-live/src/constants/*"],
|
||||
"@/types/*": ["carry-your-live/src/types/*"],
|
||||
"@/utils/*": ["carry-your-live/src/utils/*"]
|
||||
}
|
||||
},
|
||||
"extends": "expo/tsconfig.base",
|
||||
"exclude": [
|
||||
"${configDir}/node_modules",
|
||||
"${configDir}/babel.config.js",
|
||||
"${configDir}/metro.config.js",
|
||||
"${configDir}/jest.config.js",
|
||||
"${configDir}/android",
|
||||
"${configDir}/ios"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user