diff --git a/.github/actions/setup-rust-android/action.yml b/.github/actions/setup-rust-android/action.yml new file mode 100644 index 00000000..b86307f4 --- /dev/null +++ b/.github/actions/setup-rust-android/action.yml @@ -0,0 +1,73 @@ +name: Setup Rust for Android +description: > + Installs the Android NDK, a Rust toolchain with every Android target the app + ships, and cargo-ndk — everything android/app/build.gradle's cargoBuildTcHelper + task needs to compile libtc_helper.so from rust/ during the Gradle build. + + Any workflow that builds an APK must use this. Without it the Gradle hook finds + no cargo, prints "using committed jniLibs", and silently produces an APK whose + native library is stale or (once the committed .so files are purged) absent — + an APK that installs cleanly and then crashes at RustLib.init(). + +inputs: + ndk-version: + description: NDK version to install. Must satisfy the ring/taskchampion build. + required: false + default: "26.1.10909125" + cargo-ndk-version: + description: cargo-ndk version requirement. + required: false + default: "^4" + +runs: + using: composite + steps: + - uses: android-actions/setup-android@v3 + + - name: Install NDK ${{ inputs.ndk-version }} + shell: bash + run: | + sdkmanager "ndk;${{ inputs.ndk-version }}" + # cargo-ndk locates the clang cross-compilers through this variable. + echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/${{ inputs.ndk-version }}" >> "$GITHUB_ENV" + + # The three targets correspond to the three ABIs Flutter builds + # (arm64-v8a, armeabi-v7a, x86_64) and to the -t flags in + # android/app/build.gradle. Adding an ABI means changing both. + - name: Install Rust toolchain + Android targets + shell: bash + run: | + rustup toolchain install stable --profile minimal + rustup target add \ + aarch64-linux-android \ + armv7-linux-androideabi \ + x86_64-linux-android + + # Compiling taskchampion for three targets is the slow part; cache it. + - uses: Swatinem/rust-cache@v2 + with: + workspaces: rust + # Distinguishes this cache from any host-only Rust build. + key: android-${{ inputs.ndk-version }} + + - name: Install cargo-ndk + shell: bash + run: | + # Note: the binary must be invoked as `cargo ndk`, never as `cargo-ndk` — + # called directly it exits 1 with "This binary may only be called via + # `cargo ndk`". command -v is the safe way to test for its presence. + if command -v cargo-ndk >/dev/null 2>&1; then + echo "cargo-ndk already present: $(cargo ndk --version)" + else + cargo install cargo-ndk --version '${{ inputs.cargo-ndk-version }}' --locked + fi + + # Diagnostics only. Guarded with || true so a future CLI change here can never + # be the thing that fails a build — the APK verification is the real gate. + - name: Report toolchain + shell: bash + run: | + cargo --version || true + cargo ndk --version || true + echo "ANDROID_NDK_HOME=$ANDROID_NDK_HOME" + rustup target list --installed | grep android || true diff --git a/.github/workflows/build-nightly.yml b/.github/workflows/build-nightly.yml new file mode 100644 index 00000000..1d9a9bff --- /dev/null +++ b/.github/workflows/build-nightly.yml @@ -0,0 +1,94 @@ +name: Nightly Build & Log + +# Builds the signed nightly APK on a schedule and records the result in the +# website build log (website/data/nightly_builds.json). Committing that file to +# main touches website/**, which triggers deploy-website.yml to refresh the +# published site. This complements the push-triggered F-Droid deploy +# (nightlydepolyci.yml); the two can be unified later. + +on: + schedule: + - cron: '0 2 * * *' # 02:00 UTC daily + workflow_dispatch: + +permissions: + contents: write # commit the updated build log back to main + +jobs: + nightly: + name: Build nightly APK and record build log + runs-on: ubuntu-latest + steps: + - name: Free Disk Space (Ubuntu) + uses: jlumbroso/free-disk-space@main + with: + tool-cache: false + android: false # keep Android SDKs for Flutter + dotnet: true + haskell: true + large-packages: true + docker-images: true + swap-storage: true + + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17.x' + + # Rust + NDK + cargo-ndk, so the Gradle hook compiles libtc_helper.so from + # rust/ instead of falling back to a prebuilt binary. + - uses: ./.github/actions/setup-rust-android + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: '3.29.2' + + - name: Get dependencies + run: flutter pub get + + - name: Decode signing secrets + env: + NIGHTLY_KEYSTORE_B64: ${{ secrets.NIGHTLY_KEYSTORE_B64 }} + NIGHTLY_PROPERTIES_B64: ${{ secrets.NIGHTLY_PROPERTIES_B64 }} + run: | + echo "$NIGHTLY_KEYSTORE_B64" | base64 --decode > android/nightly.jks + echo "$NIGHTLY_PROPERTIES_B64" | base64 --decode > android/key_nightly.properties + + - name: Build nightly APK + id: build + run: flutter build apk --flavor nightly --build-number=${{ github.run_number }} --release + + # Placed before the build-log steps on purpose: an APK missing its native + # library must be recorded as a FAILED nightly, not published as a good one. + - name: Verify native libraries are in the APK + run: ./scripts/verify_apk_native_libs.sh build/app/outputs/flutter-apk/app-nightly-release.apk + + - name: Record successful build + if: success() + run: | + python3 scripts/update_build_log.py success \ + "https://github.com/${{ github.repository }}/raw/fdroid-repo/repo/nightly.${{ github.run_number }}.apk" \ + "${{ github.run_number }}" + + - name: Record failed build + if: failure() + run: python3 scripts/update_build_log.py failure "" "${{ github.run_number }}" + + - name: Commit build log + if: always() + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + if git diff --quiet -- website/data/nightly_builds.json; then + echo "No build-log changes to commit." + else + git add website/data/nightly_builds.json + git commit -m "chore(nightly): record build ${{ github.run_number }}" + git push + fi diff --git a/.github/workflows/build-tc-helper.yml b/.github/workflows/build-tc-helper.yml new file mode 100644 index 00000000..837ca76c --- /dev/null +++ b/.github/workflows/build-tc-helper.yml @@ -0,0 +1,80 @@ +# Compiles the tc_helper Rust native library from source (instead of relying on +# the pre-built .so committed under android/app/src/main/jniLibs/) and then +# builds the production APK against the freshly-compiled library. +# +# This is the "proper" fix for stale committed binaries: the .so is regenerated +# from rust/ on every run, so it can never drift from the Rust source. Once this +# is green, the committed jniLibs/*.so can be git-ignored and purged from history. +# +# This workflow has run green on CI (ubuntu-latest), so the NDK version and the +# cargo-ndk invocation are known to work on a runner and not just locally. The +# NDK + Rust setup has since moved to .github/actions/setup-rust-android, shared +# with every workflow that builds an APK. +# +# All three ABIs Flutter builds are covered: arm64-v8a, armeabi-v7a and x86_64. +# The list must stay in sync with android/app/build.gradle's cargoBuildTcHelper — +# an ABI missing here ships an APK without libtc_helper.so that installs fine and +# then crashes at RustLib.init(). taskchampion is configured with only +# the server-sync backend (see rust/Cargo.toml), so there's no aws-lc-sys — hence +# no cmake/ninja/bindgen/libclang toolchain needed; ring builds with just the +# Rust + NDK toolchain. +name: Build tc_helper (compile from source) + +on: + workflow_dispatch: + pull_request: + branches: [main, reports] + paths: + - "rust/**" + - "android/**" + - ".github/workflows/build-tc-helper.yml" + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + distribution: "temurin" + java-version: "17" + + # NDK + Rust + Android targets + cargo-ndk. Shared with the APK-building + # workflows so the target list can't drift between them. + - uses: ./.github/actions/setup-rust-android + + - name: Compile tc_helper (arm64-v8a + armeabi-v7a + x86_64) from source + working-directory: rust + run: | + cargo ndk -t arm64-v8a -t armeabi-v7a -t x86_64 \ + -o ../android/app/src/main/jniLibs \ + build --release + + - name: Assert every ABI Flutter builds got a library + run: | + for abi in arm64-v8a armeabi-v7a x86_64; do + f="android/app/src/main/jniLibs/$abi/libtc_helper.so" + [ -f "$f" ] || { echo "::error::missing $f — the APK for $abi would crash at RustLib.init()"; exit 1; } + echo "ok: $f" + done + + - name: Show freshly-built libs + run: ls -lhR android/app/src/main/jniLibs/ + + - uses: subosito/flutter-action@v2 + with: + flutter-version: "3.44.5" + + - run: flutter pub get + - run: flutter build apk --release --flavor production + + # The step above asserts the libraries were *built*; this asserts they were + # *packaged*. Both are needed — Gradle can silently drop a library it has. + - name: Verify native libraries are in the APK + run: ./scripts/verify_apk_native_libs.sh build/app/outputs/flutter-apk/app-production-release.apk + + - uses: actions/upload-artifact@v4 + with: + name: production-apk-from-source + path: build/app/outputs/flutter-apk/app-production-release.apk diff --git a/.github/workflows/deploy-website.yml b/.github/workflows/deploy-website.yml new file mode 100644 index 00000000..b5fa4d79 --- /dev/null +++ b/.github/workflows/deploy-website.yml @@ -0,0 +1,64 @@ +name: Deploy Website + +# Builds the Hugo site in website/ and publishes it to GitHub Pages. +# Triggered when the site content or this workflow changes (the nightly +# build-log commit made by build-nightly.yml touches website/data/, so a new +# nightly automatically refreshes the published site). + +on: + push: + branches: [main] + paths: + - 'website/**' + - '.github/workflows/deploy-website.yml' + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +# Allow one concurrent deployment; don't cancel an in-progress production deploy. +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + env: + HUGO_VERSION: 0.164.0 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 # hugo.toml enableGitInfo needs full history + + - name: Setup Hugo + uses: peaceiris/actions-hugo@v3 + with: + hugo-version: ${{ env.HUGO_VERSION }} + extended: true + + - name: Configure Pages + id: pages + uses: actions/configure-pages@v5 + + - name: Build site + run: hugo --source website --minify --baseURL "${{ steps.pages.outputs.base_url }}/" + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: website/public + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/flutterci.yml b/.github/workflows/flutterci.yml index 5d3d3a68..a99f4bc1 100644 --- a/.github/workflows/flutterci.yml +++ b/.github/workflows/flutterci.yml @@ -20,6 +20,11 @@ jobs: distribution: "temurin" java-version: "17.x" + # Rust + NDK + cargo-ndk, so the Gradle hook compiles libtc_helper.so from + # rust/ instead of falling back to a prebuilt binary. Must come before any + # `flutter build apk`. + - uses: ./.github/actions/setup-rust-android + # Step 3: Setup Flutter with version 3.7.11 - uses: subosito/flutter-action@v1 with: @@ -40,6 +45,11 @@ jobs: # Step 8: Build APK using flutter build apk - run: flutter build apk --release --flavor production + # The APK must carry libtc_helper.so for every ABI, or it installs fine and + # crashes at RustLib.init(). Fails the build rather than uploading a dud. + - name: Verify native libraries are in the APK + run: ./scripts/verify_apk_native_libs.sh build/app/outputs/flutter-apk/app-production-release.apk + # Step 9: Upload the built APK as an artifact - uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/nightlydepolyci.yml b/.github/workflows/nightlydepolyci.yml index e47359b8..3d932ca0 100644 --- a/.github/workflows/nightlydepolyci.yml +++ b/.github/workflows/nightlydepolyci.yml @@ -4,6 +4,13 @@ on: push: branches: - main + # Website content and the nightly build-log commit must not trigger a full + # APK rebuild + F-Droid redeploy (see build-nightly.yml / deploy-website.yml). + paths-ignore: + - 'website/**' + - 'scripts/**' + - '.github/workflows/deploy-website.yml' + - '.github/workflows/build-nightly.yml' jobs: build-and-deploy: @@ -35,6 +42,10 @@ jobs: distribution: "temurin" java-version: "17.x" + # Step 2b: Rust + NDK + cargo-ndk, so the Gradle hook compiles + # libtc_helper.so from rust/ instead of falling back to a prebuilt binary. + - uses: ./.github/actions/setup-rust-android + # Step 3: Setup Flutter - name: Setup Flutter uses: subosito/flutter-action@v2 @@ -58,6 +69,11 @@ jobs: - name: Build APK run: flutter build apk --flavor nightly --build-number=${{ github.run_number }} --release + # Step 6b: An APK missing its native library installs fine and then crashes + # at RustLib.init() — fail here rather than deploying it to F-Droid. + - name: Verify native libraries are in the APK + run: ./scripts/verify_apk_native_libs.sh build/app/outputs/flutter-apk/app-nightly-release.apk + # Step 7: Verify the APK is signed - name: Verify sign run: keytool -printcert -jarfile build/app/outputs/flutter-apk/app-nightly-release.apk diff --git a/android/app/build.gradle b/android/app/build.gradle index d0081ccd..785f297c 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -105,3 +105,61 @@ dependencies { coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4' } +// --------------------------------------------------------------------------- +// Auto-compile the tc_helper Rust native library on every Android build. +// +// When a Rust toolchain is available, this task cross-compiles rust/ with +// cargo-ndk straight into src/main/jniLibs//, so `flutter run` / +// `flutter build` always package a library that matches the current Rust +// source instead of a stale pre-built binary. It is wired into `preBuild`, so +// it runs before the APK's native libraries are merged. +// +// If the toolchain is NOT present (rustup/cargo/cargo-ndk missing), the task +// skips with a warning and the build falls back to the committed .so — so a +// checkout without Rust still builds. (Once every build environment, including +// CI, has the toolchain, the committed jniLibs can be git-ignored and removed.) +// +// The Android std targets are added on demand, so a fresh toolchain self-heals. +// --------------------------------------------------------------------------- +def rustCrateDir = file("${projectDir}/../../rust") +def rustJniOut = "${projectDir}/src/main/jniLibs" +def cargoBinDir = "${System.getenv('HOME')}/.cargo/bin" + +def resolveNdkDir = { + try { + if (android.ndkDirectory != null) return android.ndkDirectory.absolutePath + } catch (ignored) { /* no ndkVersion configured; fall through */ } + def ndkRoot = new File(android.sdkDirectory, "ndk") + if (ndkRoot.isDirectory()) { + def versions = ndkRoot.listFiles()?.findAll { it.isDirectory() }?.sort { it.name } + if (versions) return versions.last().absolutePath + } + return System.getenv("ANDROID_NDK_HOME") +} + +tasks.register("cargoBuildTcHelper", Exec) { + workingDir rustCrateDir + def ndkDir = resolveNdkDir() + def cargoPath = "${cargoBinDir}:/opt/homebrew/bin:/usr/local/bin:${System.getenv('PATH')}" + environment "PATH", cargoPath + if (ndkDir != null) environment "ANDROID_NDK_HOME", ndkDir + doFirst { new File(rustJniOut).mkdirs() } + // If cargo/cargo-ndk aren't on PATH, skip (the committed .so is used as a + // fallback) rather than failing the whole build. When they are present, a + // real compile error DOES fail the build, so problems surface loudly. + commandLine "sh", "-c", + "if ! command -v cargo >/dev/null 2>&1 || ! command -v cargo-ndk >/dev/null 2>&1; then " + + " echo 'cargoBuildTcHelper: Rust toolchain (cargo/cargo-ndk) not found — using committed jniLibs'; exit 0; " + + "fi; " + + "set -e; " + + // Must cover every ABI Flutter builds (arm64-v8a, armeabi-v7a, x86_64), + // or the APK for a missing one ships without libtc_helper.so and dies at + // RustLib.init(). x86_64 is what the standard Android emulator runs. + "rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android >/dev/null 2>&1 || true; " + + "cargo ndk -t arm64-v8a -t armeabi-v7a -t x86_64 -o \"${rustJniOut}\" build --release" +} + +tasks.matching { it.name == "preBuild" }.configureEach { + dependsOn "cargoBuildTcHelper" +} + diff --git a/android/app/src/main/jniLibs/arm64-v8a/libcrc_fast-b3182f249ae653b7.so b/android/app/src/main/jniLibs/arm64-v8a/libcrc_fast-b3182f249ae653b7.so deleted file mode 100755 index 3a8b6827..00000000 Binary files a/android/app/src/main/jniLibs/arm64-v8a/libcrc_fast-b3182f249ae653b7.so and /dev/null differ diff --git a/android/app/src/main/jniLibs/arm64-v8a/libtc_helper.so b/android/app/src/main/jniLibs/arm64-v8a/libtc_helper.so index 2ed5d305..c83cb8e2 100755 Binary files a/android/app/src/main/jniLibs/arm64-v8a/libtc_helper.so and b/android/app/src/main/jniLibs/arm64-v8a/libtc_helper.so differ diff --git a/android/app/src/main/jniLibs/armeabi-v7a/libcrc_fast-c8bd19aec5c73f3a.so b/android/app/src/main/jniLibs/armeabi-v7a/libcrc_fast-c8bd19aec5c73f3a.so deleted file mode 100755 index df205bcd..00000000 Binary files a/android/app/src/main/jniLibs/armeabi-v7a/libcrc_fast-c8bd19aec5c73f3a.so and /dev/null differ diff --git a/android/app/src/main/jniLibs/armeabi-v7a/libtc_helper.so b/android/app/src/main/jniLibs/armeabi-v7a/libtc_helper.so index 4fa2b74e..55e02c07 100755 Binary files a/android/app/src/main/jniLibs/armeabi-v7a/libtc_helper.so and b/android/app/src/main/jniLibs/armeabi-v7a/libtc_helper.so differ diff --git a/android/app/src/main/jniLibs/x86_64/libtc_helper.so b/android/app/src/main/jniLibs/x86_64/libtc_helper.so new file mode 100755 index 00000000..008578d6 Binary files /dev/null and b/android/app/src/main/jniLibs/x86_64/libtc_helper.so differ diff --git a/ios/Podfile b/ios/Podfile index 279576f3..61caee25 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -32,6 +32,14 @@ target 'Runner' do use_modular_headers! flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + + # Rebuild the tc_helper Rust library from rust/ on every build, so the native + # library can never drift from its source. The script is intentionally + # non-fatal: without a Rust toolchain it warns and the build proceeds using + # the existing binary, so contributors without Rust are unaffected. + script_phase :name => 'Build tc_helper (Rust)', + :script => '"$PODS_TARGET_SRCROOT"/../scripts/build_tc_helper_apple.sh ios', + :execution_position => :before_compile end post_install do |installer| diff --git a/ios/tc_helper.xcframework/Info.plist b/ios/tc_helper.xcframework/Info.plist index cf3e2802..560aabd5 100644 --- a/ios/tc_helper.xcframework/Info.plist +++ b/ios/tc_helper.xcframework/Info.plist @@ -8,32 +8,32 @@ BinaryPath tc_helper.framework/tc_helper LibraryIdentifier - ios-arm64 + ios-arm64_x86_64-simulator LibraryPath tc_helper.framework SupportedArchitectures arm64 + x86_64 SupportedPlatform ios + SupportedPlatformVariant + simulator BinaryPath tc_helper.framework/tc_helper LibraryIdentifier - ios-arm64_x86_64-simulator + ios-arm64 LibraryPath tc_helper.framework SupportedArchitectures arm64 - x86_64 SupportedPlatform ios - SupportedPlatformVariant - simulator CFBundlePackageType diff --git a/ios/tc_helper.xcframework/ios-arm64/tc_helper.framework/tc_helper b/ios/tc_helper.xcframework/ios-arm64/tc_helper.framework/tc_helper index 458cab50..a19b4587 100755 Binary files a/ios/tc_helper.xcframework/ios-arm64/tc_helper.framework/tc_helper and b/ios/tc_helper.xcframework/ios-arm64/tc_helper.framework/tc_helper differ diff --git a/ios/tc_helper.xcframework/ios-arm64_x86_64-simulator/tc_helper.framework/tc_helper b/ios/tc_helper.xcframework/ios-arm64_x86_64-simulator/tc_helper.framework/tc_helper index cec5b500..a9f3dfc8 100755 Binary files a/ios/tc_helper.xcframework/ios-arm64_x86_64-simulator/tc_helper.framework/tc_helper and b/ios/tc_helper.xcframework/ios-arm64_x86_64-simulator/tc_helper.framework/tc_helper differ diff --git a/lib/app/models/data.dart b/lib/app/models/data.dart index 3fe5c66c..565d5b2f 100644 --- a/lib/app/models/data.dart +++ b/lib/app/models/data.dart @@ -6,7 +6,7 @@ import 'dart:io'; import 'package:taskwarrior/app/models/json/task.dart'; import 'package:taskwarrior/app/services/notification_services.dart'; -import 'package:taskwarrior/app/utils/taskc/payload.dart'; +import 'package:taskwarrior/app/utils/taskchampion/payload.dart'; import 'package:taskwarrior/app/utils/taskfunctions/urgency.dart'; class Data { Data(this.home); diff --git a/lib/app/models/report.dart b/lib/app/models/report.dart new file mode 100644 index 00000000..b3ca74d0 --- /dev/null +++ b/lib/app/models/report.dart @@ -0,0 +1,88 @@ +/// Data model for the reporting engine (Issue #418). +/// +/// A *report* is a named, purpose-built bundle of a filter expression, a sort +/// order, and a set of display columns — mirroring Taskwarrior's report system +/// (`report..filter` / `.sort` / `.columns` / `.description`). Reports let +/// users invoke preset views like "next", "ready", or "overdue" instead of +/// hand-building a filter/sort every time. +library; + +/// A single column in a report's display (e.g. `description`, `due`). +class ColumnSpec { + final String field; + final String? label; + + const ColumnSpec(this.field, {this.label}); + + /// Parses a Taskwarrior `report.*.columns` value such as + /// `"id,description,due"` into a list of columns. Taskwarrior column formats + /// (e.g. `due.relative`) are reduced to their base attribute. + static List parseList(String value) { + return value + .split(',') + .map((c) => c.trim()) + .where((c) => c.isNotEmpty) + .map((c) => ColumnSpec(c.split('.').first)) + .toList(); + } +} + +/// One sort key: an attribute plus a direction. Taskwarrior encodes these as +/// `field+` (ascending) or `field-` (descending), optionally chained with +/// commas (e.g. `"urgency-,due+"`). +class SortCriterion { + final String field; + final bool ascending; + + const SortCriterion(this.field, {this.ascending = true}); + + /// Parses a Taskwarrior `report.*.sort` value into an ordered list of keys. + /// A trailing `+`/`-` sets direction (default ascending). A `/` break marker + /// (e.g. `urgency-/`) is ignored — only the ordering matters here. + static List parseList(String value) { + return value + .split(',') + .map((s) => s.trim().replaceAll('/', '')) + .where((s) => s.isNotEmpty) + .map((s) { + if (s.endsWith('-')) { + return SortCriterion(s.substring(0, s.length - 1), ascending: false); + } + if (s.endsWith('+')) { + return SortCriterion(s.substring(0, s.length - 1), ascending: true); + } + return SortCriterion(s); // no direction → ascending + }).toList(); + } +} + +/// A complete report definition. +class ReportDefinition { + /// Short identifier, e.g. `next` — also the display title. + final String name; + + /// Human-readable one-line summary shown under the name. + final String description; + + /// Columns to display (advisory for the UI; may be empty). + final List columns; + + /// Ordered sort keys applied after filtering. + final List sortCriteria; + + /// The filter expression (e.g. `"status:pending +READY"`), or null/empty for + /// "everything". + final String? filterExpression; + + /// True for reports read from a user `.taskrc` (grouped above the defaults). + final bool isCustom; + + const ReportDefinition({ + required this.name, + required this.description, + this.columns = const [], + this.sortCriteria = const [], + this.filterExpression, + this.isCustom = false, + }); +} diff --git a/lib/app/models/task_like.dart b/lib/app/models/task_like.dart new file mode 100644 index 00000000..ee59085e --- /dev/null +++ b/lib/app/models/task_like.dart @@ -0,0 +1,118 @@ +import 'package:taskwarrior/app/v3/models/annotation.dart'; + +/// The canonical read contract shared by every task model in the app. +/// +/// The app historically carried two parallel task models — `TaskForC` (the +/// local/Taskserver path, which stores `entry`/`modified` as strings) and +/// `TaskForReplica` (the TaskChampion path, which stores them as epoch +/// seconds). Shared logic (filtering, sorting, reports, urgency) had to be +/// hard-typed to one of them, so it only ever worked for a single sync mode. +/// +/// [TaskLike] is the unifying surface: both models implement it, so shared +/// logic can be written once against this contract and work for every mode. +/// +/// Most attributes are declared with the types both models already use, so +/// implementing this interface requires no field changes. The only two +/// attributes whose raw representations genuinely differ (`entry`, `modified`) +/// are exposed here as **normalized** [DateTime] accessors — [entryDate] and +/// [modifiedDate] — leaving each model free to keep its own storage format. +abstract class TaskLike { + /// Stable identifier. Nullable because the local model permits a null uuid + /// for tasks that have not been assigned one yet. + String? get uuid; + + String? get description; + + /// `pending` / `completed` / `deleted` / `recurring`. + String? get status; + + String? get project; + + /// `H` / `M` / `L`, or null for none. + String? get priority; + + List? get tags; + + /// UUIDs of the tasks this one depends on. + List? get depends; + + List? get annotations; + + /// Recurrence rule (e.g. `weekly`), or null for a non-recurring task. + String? get recur; + + /// Date attributes, in each model's existing string form. Parse with + /// [parseTaskDate] when a [DateTime] is required. + String? get due; + String? get start; + String? get wait; + + /// Whether this task has at least one *unresolved* dependency. + /// + /// Null means "unknown" — the local/Taskserver path does not compute + /// dependency resolution (that requires the full task set), so only the + /// TaskChampion path reports a definite value. Consumers should treat null + /// as "not blocked" rather than assuming either state. + bool? get isBlocked; + + /// Whether at least one other task depends on this one. Null means unknown + /// (see [isBlocked]). + bool? get isBlocking; + + /// Creation time, normalized across both models' storage formats. + DateTime? get entryDate; + + /// Last-modified time, normalized across both models' storage formats. + DateTime? get modifiedDate; +} + +/// Parses a task date attribute into UTC, accepting both representations used +/// across the app: an ISO-8601 string, a Taskwarrior compact stamp +/// (`20240701T161718Z`), or epoch seconds rendered as a string. +/// +/// Returns null for null/empty/unparseable input rather than throwing, since +/// task data arriving from sync or an older local database can be incomplete. +DateTime? parseTaskDate(String? value) { + if (value == null) return null; + final String raw = value.trim(); + if (raw.isEmpty) return null; + + // The specific formats are checked BEFORE DateTime.tryParse, because + // tryParse is permissive enough to swallow both of them and produce a wrong + // answer: it reads a compact stamp as *local* time (shifting it by the + // device's timezone) and reads bare epoch digits as a year. + + // Taskwarrior's compact form, e.g. 20240701T161718Z. Task timestamps are + // UTC, so a missing trailing Z is still treated as UTC rather than local — + // otherwise the same data would resolve differently per device timezone. + final RegExpMatch? compact = + RegExp(r'^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z?$') + .firstMatch(raw); + if (compact != null) { + return DateTime.utc( + int.parse(compact.group(1)!), + int.parse(compact.group(2)!), + int.parse(compact.group(3)!), + int.parse(compact.group(4)!), + int.parse(compact.group(5)!), + int.parse(compact.group(6)!), + ); + } + + // Epoch seconds, as emitted by the Rust FFI layer. Requires at least 9 + // digits so a short numeric string (e.g. a bare "2024") still falls through + // to the ISO parser instead of being read as a 1970s timestamp. + if (RegExp(r'^\d{9,}$').hasMatch(raw)) { + final int? epoch = int.tryParse(raw); + if (epoch != null) { + return DateTime.fromMillisecondsSinceEpoch(epoch * 1000, isUtc: true); + } + } + + return DateTime.tryParse(raw)?.toUtc(); +} + +/// Converts epoch seconds to a UTC [DateTime], or null when absent. +DateTime? epochToDate(int? epochSeconds) => epochSeconds == null + ? null + : DateTime.fromMillisecondsSinceEpoch(epochSeconds * 1000, isUtc: true); diff --git a/lib/app/models/task_urgency.dart b/lib/app/models/task_urgency.dart new file mode 100644 index 00000000..9974ff00 --- /dev/null +++ b/lib/app/models/task_urgency.dart @@ -0,0 +1,102 @@ +import 'package:taskwarrior/app/models/task_like.dart'; + +/// Computes a task's urgency using Taskwarrior's standard algorithm and its +/// built-in default coefficients. +/// +/// TaskChampion (the storage/sync layer this app embeds) does not compute or +/// store urgency — it is a Taskwarrior-CLI concept — so we reproduce the +/// formula here. It is written against [TaskLike] so every task model gets the +/// same ranking, rather than only the TaskChampion path. +/// +/// Default coefficients, matching upstream Taskwarrior (`Task.cpp urgency_c`): +/// +/// priority H/M/L = 6.0 / 3.9 / 1.8 due = 12.0 next(tag) = 15.0 +/// active = 4.0 age = 2.0 (over 365d) annotations = 1.0 +/// tags = 1.0 project = 1.0 blocking = 8.0 blocked = -5.0 +/// waiting = -3.0 +/// +/// `scheduled` (+5.0) and user-defined attributes/coefficients are omitted: +/// TaskChampion does not surface a scheduled date, and there are no UDAs here. +/// +/// [clock] overrides "now" (for age/due/waiting) so the result is testable. +double computeTaskUrgency(TaskLike task, {DateTime? clock}) { + final DateTime now = (clock ?? DateTime.now()).toUtc(); + double urgency = 0.0; + + // Priority. + switch (task.priority) { + case 'H': + urgency += 6.0; + break; + case 'M': + urgency += 3.9; + break; + case 'L': + urgency += 1.8; + break; + } + + // Belongs to a project. + final String? project = task.project; + if (project != null && project.isNotEmpty) urgency += 1.0; + + // Active (has been started). + final String? start = task.start; + if (start != null && start.isNotEmpty) urgency += 4.0; + + // Tags: 1 -> 0.8, 2 -> 0.9, 3+ -> 1.0. The special "next" tag adds 15.0. + final List tagList = task.tags ?? const []; + if (tagList.length == 1) { + urgency += 0.8; + } else if (tagList.length == 2) { + urgency += 0.9; + } else if (tagList.length >= 3) { + urgency += 1.0; + } + if (tagList.contains('next')) urgency += 15.0; + + // Annotations: 1 -> 0.8, 2 -> 0.9, 3+ -> 1.0. + final int annCount = task.annotations?.length ?? 0; + if (annCount == 1) { + urgency += 0.8; + } else if (annCount == 2) { + urgency += 0.9; + } else if (annCount >= 3) { + urgency += 1.0; + } + + // Age: linear ramp from 0 to 1 over 365 days since entry, coefficient 2.0. + final DateTime? entryDate = task.entryDate; + if (entryDate != null) { + final double ageDays = now.difference(entryDate).inSeconds / 86400.0; + const double maxAge = 365.0; + final double ageTerm = + ageDays >= maxAge ? 1.0 : (ageDays <= 0 ? 0.0 : ageDays / maxAge); + urgency += 2.0 * ageTerm; + } + + // Due: ramp mapping ~21 days around the due date to 0.2..1.0, coefficient 12. + final DateTime? dueDate = parseTaskDate(task.due); + if (dueDate != null) { + final double daysOverdue = now.difference(dueDate).inSeconds / 86400.0; + double term; + if (daysOverdue >= 7.0) { + term = 1.0; + } else if (daysOverdue >= -14.0) { + term = ((daysOverdue + 14.0) * 0.8 / 21.0) + 0.2; + } else { + term = 0.2; + } + urgency += 12.0 * term; + } + + // Waiting (wait date in the future). + final DateTime? waitDate = parseTaskDate(task.wait); + if (waitDate != null && waitDate.isAfter(now)) urgency -= 3.0; + + // Dependency relationships. + if (task.isBlocking == true) urgency += 8.0; + if (task.isBlocked == true) urgency -= 5.0; + + return urgency; +} diff --git a/lib/app/modules/home/controllers/home_controller.dart b/lib/app/modules/home/controllers/home_controller.dart index 3cf342f8..77f39730 100644 --- a/lib/app/modules/home/controllers/home_controller.dart +++ b/lib/app/modules/home/controllers/home_controller.dart @@ -33,9 +33,7 @@ import 'package:taskwarrior/app/utils/app_settings/app_settings.dart'; import 'package:taskwarrior/app/v3/champion/replica.dart'; import 'package:taskwarrior/app/v3/champion/models/task_for_replica.dart'; import 'package:taskwarrior/app/v3/db/task_database.dart'; -import 'package:taskwarrior/app/v3/db/update.dart'; import 'package:taskwarrior/app/v3/models/task.dart'; -import 'package:taskwarrior/app/v3/net/fetch.dart'; import 'package:textfield_tags/textfield_tags.dart'; import 'package:taskwarrior/app/utils/themes/theme_extension.dart'; import 'package:tutorial_coach_mark/tutorial_coach_mark.dart'; @@ -44,6 +42,10 @@ class HomeController extends GetxController { final SplashController splashController = Get.find(); late Storage storage; final RxBool pendingFilter = false.obs; + /// Which status the list is filtered to: pending / completed / deleted. + /// Supersedes [pendingFilter], which can only express the first two; that + /// flag is kept in step for the call sites still reading it. + final RxString statusFilter = Query.statusPending.obs; final RxBool waitingFilter = false.obs; final RxString projectFilter = ''.obs; final RxBool tagUnion = false.obs; @@ -51,6 +53,11 @@ class HomeController extends GetxController { final RxSet selectedTags = {}.obs; final RxList queriedTasks = [].obs; final RxList searchedTasks = [].obs; + // Reactive mirror of the search box text. `searchedTasks` only drives the + // local-taskc list (TasksBuilder); the TaskChampion replica list reads + // `tasksFromReplica` directly, so it needs an observable query to rebuild and + // filter on each keystroke. Kept in sync by search()/toggleSearch(). + final RxString searchQuery = ''.obs; final RxList selectedDates = List.filled(4, null).obs; final RxMap pendingTags = {}.obs; final RxMap projects = {}.obs; @@ -86,6 +93,13 @@ class HomeController extends GetxController { taskdb = TaskDatabase(); taskdb.open(); getUniqueProjects(); + // Initialize the pending/waiting filters from their persisted values. + // Without this the RxBool defaults to false, and the replica list view + // (which filters `status == pending` only when pendingFilter is true) + // shows completed tasks only — hiding every pending task on first load. + pendingFilter.value = Query(storage.tabs.tab()).getPendingFilter(); + statusFilter.value = Query(storage.tabs.tab()).getStatusFilter(); + waitingFilter.value = Query(storage.tabs.tab()).getWaitingFilter(); _loadTaskChampion(); fetchTasksFromDB(); @@ -99,6 +113,7 @@ class HomeController extends GetxController { }); everAll([ pendingFilter, + statusFilter, waitingFilter, projectFilter, tagUnion, @@ -168,16 +183,6 @@ class HomeController extends GetxController { debugPrint("Tasks from Replica: ${tasks.length}"); } - Future refreshTasks(String clientId, String encryptionSecret) async { - TaskDatabase taskDatabase = TaskDatabase(); - await taskDatabase.open(); - List tasksFromServer = - await fetchTasks(clientId, encryptionSecret); - await updateTasksInDatabase(tasksFromServer); - List fetchedTasks = await taskDatabase.fetchTasksFromDatabase(); - tasks.value = fetchedTasks; - } - Future fetchTasksFromDB() async { debugPrint("Fetching tasks from DB ${taskReplica.value}"); await _loadTaskChampion(); @@ -224,16 +229,19 @@ class HomeController extends GetxController { void _profileSet() { pendingFilter.value = Query(storage.tabs.tab()).getPendingFilter(); - if (!Query(storage.tabs.tab()).getWaitingFilter()) { - waitingFilter.value = Query(storage.tabs.tab()).getWaitingFilter(); - } else { - Query(storage.tabs.tab()).toggleWaitingFilter(); - waitingFilter.value = Query(storage.tabs.tab()).getWaitingFilter(); - } + statusFilter.value = Query(storage.tabs.tab()).getStatusFilter(); + // Load the persisted waiting-filter value as-is. The previous logic here + // toggled (and thus persisted) it to false whenever it was true, so a + // profile with the waiting filter enabled silently had it disabled every + // time the app started. + waitingFilter.value = Query(storage.tabs.tab()).getWaitingFilter(); projectFilter.value = Query(storage.tabs.tab()).projectFilter(); tagUnion.value = Query(storage.tabs.tab()).tagUnion(); selectedSort.value = Query(storage.tabs.tab()).getSelectedSort(); - selectedTags.addAll(Query(storage.tabs.tab()).getSelectedTags()); + // Replace, not merge: this runs every time a profile becomes active (see + // refreshTaskWithNewProfile), so a plain addAll would leak the previous + // profile's tag filters into the new one instead of loading its own. + selectedTags.assignAll(Query(storage.tabs.tab()).getSelectedTags()); _refreshTasks(); pendingTags.value = _pendingTags(); @@ -343,8 +351,14 @@ class HomeController extends GetxController { } void togglePendingFilter() { - Query(storage.tabs.tab()).togglePendingFilter(); + // Cycle pending -> completed -> deleted -> pending. "Deleted" is only + // offered on the TaskChampion path, which is the only one that keeps + // deleted tasks (the local list has no deleted view), so other modes keep + // the original two-way toggle. + Query(storage.tabs.tab()) + .cycleStatusFilter(includeDeleted: taskReplica.value); pendingFilter.value = Query(storage.tabs.tab()).getPendingFilter(); + statusFilter.value = Query(storage.tabs.tab()).getStatusFilter(); _refreshTasks(); } @@ -499,10 +513,12 @@ class HomeController extends GetxController { if (!searchVisible.value) { searchedTasks.assignAll(queriedTasks); searchController.text = ''; + searchQuery.value = ''; } } void search(String term) { + searchQuery.value = term; searchedTasks.assignAll( queriedTasks .where( @@ -516,6 +532,7 @@ class HomeController extends GetxController { void setInitialTabIndex(int index) { storage.tabs.setInitialTabIndex(index); pendingFilter.value = Query(storage.tabs.tab()).getPendingFilter(); + statusFilter.value = Query(storage.tabs.tab()).getStatusFilter(); waitingFilter.value = Query(storage.tabs.tab()).getWaitingFilter(); selectedSort.value = Query(storage.tabs.tab()).getSelectedSort(); selectedTags.addAll(Query(storage.tabs.tab()).getSelectedTags()); @@ -538,6 +555,7 @@ class HomeController extends GetxController { void removeTab(int index) { storage.tabs.removeTab(index); pendingFilter.value = Query(storage.tabs.tab()).getPendingFilter(); + statusFilter.value = Query(storage.tabs.tab()).getStatusFilter(); waitingFilter.value = Query(storage.tabs.tab()).getWaitingFilter(); selectedSort.value = Query(storage.tabs.tab()).getSelectedSort(); selectedTags.addAll(Query(storage.tabs.tab()).getSelectedTags()); @@ -580,9 +598,10 @@ class HomeController extends GetxController { await refreshReplicaTasks(); } } else if (taskchampion.value) { - if (clientId != null && encryptionSecret != null) { - await refreshTasks(clientId, encryptionSecret); - } + // CCSync HTTP sync has been retired; the local TaskChampion database is + // the source of truth for this mode, so reload it without a remote + // round-trip. + await fetchTasksFromDB(); } else { await synchronize(context, false); } @@ -676,7 +695,12 @@ class HomeController extends GetxController { '${splashController.baseDirectory.value.path}/profiles/${splashController.currentProfile.value}', ), ); - _refreshTasks(); + // Reload the filter/sort/tag state from the NEW profile's own persisted + // Query/storage.tabs, not just re-point storage and refresh with whatever + // filters happened to be in memory from the previous profile — otherwise + // the previous profile's project/tag/sort preferences silently carry over + // and get applied to the new profile's tasks. + _profileSet(); } void changeInDirectory() { @@ -686,7 +710,10 @@ class HomeController extends GetxController { '${splashController.baseDirectory.value.path}/profiles/${splashController.currentProfile.value}', ), ); - _refreshTasks(); + // Same reasoning as refreshTaskWithNewProfile: reload the filter/sort/tag + // state from the profile's Query/storage.tabs at the new location, rather + // than reusing whatever was in memory from the old base directory. + _profileSet(); } RxBool useDelayTask = false.obs; diff --git a/lib/app/modules/home/views/add_task_bottom_sheet_new.dart b/lib/app/modules/home/views/add_task_bottom_sheet_new.dart index 3442246f..7f61c77a 100644 --- a/lib/app/modules/home/views/add_task_bottom_sheet_new.dart +++ b/lib/app/modules/home/views/add_task_bottom_sheet_new.dart @@ -394,6 +394,7 @@ class AddTaskBottomSheet extends StatelessWidget { homeController.priority.value = 'X'; homeController.tagcontroller.text = ''; homeController.tags.value = []; + homeController.selectedDates.value = List.filled(4, null); homeController.update(); Get.back(); if (Platform.isAndroid) { @@ -473,6 +474,7 @@ class AddTaskBottomSheet extends StatelessWidget { homeController.priority.value = 'X'; homeController.tagcontroller.text = ''; homeController.tags.value = []; + homeController.selectedDates.value = List.filled(4, null); homeController.update(); Get.back(); if (Platform.isAndroid) { diff --git a/lib/app/modules/home/views/filter_drawer_home_page.dart b/lib/app/modules/home/views/filter_drawer_home_page.dart index 085a969d..15d881c1 100644 --- a/lib/app/modules/home/views/filter_drawer_home_page.dart +++ b/lib/app/modules/home/views/filter_drawer_home_page.dart @@ -11,6 +11,7 @@ import 'package:taskwarrior/app/utils/constants/taskwarrior_colors.dart'; import 'package:taskwarrior/app/utils/constants/taskwarrior_fonts.dart'; import 'package:taskwarrior/app/utils/gen/fonts.gen.dart'; import 'package:taskwarrior/app/utils/language/sentence_manager.dart'; +import 'package:taskwarrior/app/utils/taskfunctions/query.dart'; import 'package:taskwarrior/app/utils/app_settings/app_settings.dart'; import 'package:taskwarrior/app/utils/themes/theme_extension.dart'; @@ -97,17 +98,22 @@ class FilterDrawer extends StatelessWidget { color: tColors.primaryTextColor, )), TextSpan( - text: filters.pendingFilter - ? SentenceManager( - currentLanguage: homeController - .selectedLanguage.value) - .sentences - .filterDrawerPending - : SentenceManager( - currentLanguage: homeController - .selectedLanguage.value) - .sentences - .filterDrawerCompleted, + // pending / completed / deleted, rather than the + // old pending-or-completed boolean. + text: () { + final sentences = SentenceManager( + currentLanguage: + homeController.selectedLanguage.value) + .sentences; + switch (homeController.statusFilter.value) { + case Query.statusCompleted: + return sentences.filterDrawerCompleted; + case Query.statusDeleted: + return sentences.filterDrawerDeleted; + default: + return sentences.filterDrawerPending; + } + }(), style: TextStyle( fontFamily: FontFamily.poppins, fontSize: TaskWarriorFonts.fontSizeMedium, @@ -331,76 +337,105 @@ class FilterDrawer extends StatelessWidget { spacing: 8, runSpacing: 4, children: [ - for (var sort in [ - SentenceManager( - currentLanguage: - homeController.selectedLanguage.value) - .sentences - .filterDrawerCreated, - SentenceManager( - currentLanguage: - homeController.selectedLanguage.value) - .sentences - .filterDrawerModified, - SentenceManager( - currentLanguage: - homeController.selectedLanguage.value) - .sentences - .filterDrawerStartTime, - SentenceManager( - currentLanguage: - homeController.selectedLanguage.value) - .sentences - .filterDrawerDueTill, - SentenceManager( - currentLanguage: - homeController.selectedLanguage.value) - .sentences - .filterDrawerPriority, - SentenceManager( - currentLanguage: - homeController.selectedLanguage.value) - .sentences - .filterDrawerProject, - SentenceManager( - currentLanguage: - homeController.selectedLanguage.value) - .sentences - .filterDrawerTags, - SentenceManager( - currentLanguage: - homeController.selectedLanguage.value) - .sentences - .filterDrawerUrgency, + // Each sort option pairs a STABLE key (always English, + // e.g. 'Created') with a localized display label. The + // key is what gets stored in `selectedSort` and matched + // by the sort switches in show_tasks*.dart. Previously + // the localized label doubled as the sort value, so in + // any non-English locale the stored value (e.g. + // "Creado+") never matched case 'Created+' and sorting + // silently did nothing. + for (final option in >[ + { + 'key': 'Created', + 'label': SentenceManager( + currentLanguage: + homeController.selectedLanguage.value) + .sentences + .filterDrawerCreated + }, + { + 'key': 'Modified', + 'label': SentenceManager( + currentLanguage: + homeController.selectedLanguage.value) + .sentences + .filterDrawerModified + }, + { + 'key': 'Start Time', + 'label': SentenceManager( + currentLanguage: + homeController.selectedLanguage.value) + .sentences + .filterDrawerStartTime + }, + { + 'key': 'Due till', + 'label': SentenceManager( + currentLanguage: + homeController.selectedLanguage.value) + .sentences + .filterDrawerDueTill + }, + { + 'key': 'Priority', + 'label': SentenceManager( + currentLanguage: + homeController.selectedLanguage.value) + .sentences + .filterDrawerPriority + }, + { + 'key': 'Project', + 'label': SentenceManager( + currentLanguage: + homeController.selectedLanguage.value) + .sentences + .filterDrawerProject + }, + { + 'key': 'Tags', + 'label': SentenceManager( + currentLanguage: + homeController.selectedLanguage.value) + .sentences + .filterDrawerTags + }, + { + 'key': 'Urgency', + 'label': SentenceManager( + currentLanguage: + homeController.selectedLanguage.value) + .sentences + .filterDrawerUrgency + }, ]) Obx( - () => ChoiceChip( - label: (homeController.selectedSort.value - .startsWith(sort)) - ? Text( - homeController.selectedSort.value, - ) - : Text(sort), - selected: false, - onSelected: (_) { - if (homeController.selectedSort == '$sort+') { - homeController.selectSort('$sort-'); - } else if (homeController.selectedSort == - '$sort-') { - homeController.selectSort(sort); - } else { - homeController.selectSort('$sort+'); - } - }, - // labelStyle: GoogleFonts.poppins( - // color: AppSettings.isDarkMode - // ? TaskWarriorColors.black - // : TaskWarriorColors.white), - // backgroundColor: AppSettings.isDarkMode - // ? TaskWarriorColors - // .kLightSecondaryBackgroundColor - // : TaskWarriorColors.ksecondaryBackgroundColor, - ), + () { + final String key = option['key']!; + final String label = option['label']!; + final String value = + homeController.selectedSort.value; + final String display = value == '$key+' + ? '$label+' + : value == '$key-' + ? '$label-' + : label; + return ChoiceChip( + label: Text(display), + selected: false, + onSelected: (_) { + if (value == '$key+') { + homeController.selectSort('$key-'); + } else if (value == '$key-') { + homeController.selectSort(key); + } else { + homeController.selectSort('$key+'); + } + }, + ); + }, ) ], ), diff --git a/lib/app/modules/home/views/home_page_app_bar.dart b/lib/app/modules/home/views/home_page_app_bar.dart index 72643e2d..0e3023dd 100644 --- a/lib/app/modules/home/views/home_page_app_bar.dart +++ b/lib/app/modules/home/views/home_page_app_bar.dart @@ -186,7 +186,7 @@ class HomePageAppBar extends StatelessWidget implements PreferredSizeWidget { .sentences .homePageFetchingTasks); - await controller.refreshTasks(c, e); + await controller.fetchTasksFromDB(); ScaffoldMessenger.of(context) .hideCurrentSnackBar(); @@ -255,6 +255,14 @@ class HomePageAppBar extends StatelessWidget implements PreferredSizeWidget { }, )), ), + IconButton( + icon: Tooltip( + message: 'Reports', + child: Icon(Icons.assignment_outlined, + color: TaskWarriorColors.white), + ), + onPressed: () => Get.toNamed(Routes.REPORT_ENGINE), + ), Builder( builder: (context) => IconButton( key: controller.filterKey, diff --git a/lib/app/modules/home/views/home_page_body.dart b/lib/app/modules/home/views/home_page_body.dart index 0e139462..55ddc1ba 100644 --- a/lib/app/modules/home/views/home_page_body.dart +++ b/lib/app/modules/home/views/home_page_body.dart @@ -30,7 +30,25 @@ class HomePageBody extends StatelessWidget { child: Padding( padding: const EdgeInsets.only(left: 8.0, right: 8.0), child: Obx( - () => Column( + () { + // Read the replica list reactively here so the whole view rebuilds + // when tasks arrive. On launch, taskReplica flips true (triggering a + // rebuild) *before* the async FFI fetch populates tasksFromReplica; + // without a reactive read of the list itself, the populated tasks + // would never appear (the view stays on its initial empty build). + var replicaTasks = controller.tasksFromReplica.toList(); + // Apply the search text to the replica list. Unlike the local-taskc + // path (which reads the pre-filtered `searchedTasks`), this builder + // gets the raw list, so filter here. Reading `searchQuery`/ + // `searchVisible` inside the Obx makes it rebuild on each keystroke. + final query = controller.searchQuery.value.trim().toLowerCase(); + if (controller.searchVisible.value && query.isNotEmpty) { + replicaTasks = replicaTasks + .where((task) => + (task.description ?? '').toLowerCase().contains(query)) + .toList(); + } + return Column( children: [ if (controller.searchVisible.value) Container( @@ -134,14 +152,16 @@ class HomePageBody extends StatelessWidget { child: Expanded( child: Scrollbar( child: TaskReplicaViewBuilder( - replicaTasks: controller.tasksFromReplica, + replicaTasks: replicaTasks, pendingFilter: controller.pendingFilter.value, + statusFilter: controller.statusFilter.value, selectedSort: controller.selectedSort.value, project: controller.projectFilter.value, ), ))) ], - ), + ); + }, ), ), ), diff --git a/lib/app/modules/home/views/show_tasks.dart b/lib/app/modules/home/views/show_tasks.dart index 008b30fb..8421e18d 100644 --- a/lib/app/modules/home/views/show_tasks.dart +++ b/lib/app/modules/home/views/show_tasks.dart @@ -11,8 +11,6 @@ import 'package:taskwarrior/app/utils/themes/theme_extension.dart'; import 'package:taskwarrior/app/utils/language/sentence_manager.dart'; import 'package:taskwarrior/app/v3/db/task_database.dart'; import 'package:taskwarrior/app/v3/models/task.dart'; -import 'package:taskwarrior/app/v3/net/complete.dart'; -import 'package:taskwarrior/app/v3/net/delete.dart'; class TaskViewBuilder extends StatelessWidget { const TaskViewBuilder({ @@ -230,14 +228,12 @@ class TaskViewBuilder extends StatelessWidget { TaskDatabase taskDatabase = TaskDatabase(); await taskDatabase.open(); taskDatabase.markTaskAsCompleted(uuid); - completeTask('email', uuid); } void _markTaskAsDeleted(String uuid) async { TaskDatabase taskDatabase = TaskDatabase(); await taskDatabase.open(); taskDatabase.markTaskAsDeleted(uuid); - deleteTask('email', uuid); } Color _getPriorityColor(String priority) { diff --git a/lib/app/modules/home/views/show_tasks_replica.dart b/lib/app/modules/home/views/show_tasks_replica.dart index 642fbdff..41b69e99 100644 --- a/lib/app/modules/home/views/show_tasks_replica.dart +++ b/lib/app/modules/home/views/show_tasks_replica.dart @@ -17,12 +17,17 @@ class TaskReplicaViewBuilder extends StatelessWidget { super.key, this.project, required this.pendingFilter, + required this.statusFilter, required this.selectedSort, required this.replicaTasks, }); final String selectedSort; final bool pendingFilter; + + /// pending / completed / deleted — supersedes [pendingFilter], which cannot + /// express the deleted state. + final String statusFilter; final String? project; final List replicaTasks; @@ -31,40 +36,68 @@ class TaskReplicaViewBuilder extends StatelessWidget { TaskwarriorColorTheme tColors = Theme.of(context).extension()!; - return Obx(() { - List tasks = List.from(replicaTasks); - if (project != null && project != 'All Projects') { + // Reactivity is handled by the parent Obx in home_page_body (which reads + // tasksFromReplica); this widget just renders the snapshot it's given, so + // it must NOT be an Obx (an Obx with no observable read throws ObxError). + List tasks = List.from(replicaTasks); + if (project != null && project!.isNotEmpty && project != 'All Projects') { tasks = tasks.where((task) => task.project == project).toList(); } - tasks.sort((a, b) { - final am = a.modified ?? 0; - final bm = b.modified ?? 0; - return bm.compareTo(am); - }); - tasks = tasks.where((task) { - if (pendingFilter) { - return task.status == 'pending'; - } else { - return task.status == 'completed'; - } - }).toList(); + tasks = tasks.where((task) => task.status == statusFilter).toList(); + // Urgency is computed (TaskChampion doesn't store it) — precompute once + // per task against a single "now" so every comparison is consistent and + // we don't recompute inside the O(n log n) sort. + final bool sortingByUrgency = + selectedSort == 'Urgency+' || selectedSort == 'Urgency-'; + final DateTime now = DateTime.now().toUtc(); + final Map urgencyByUuid = sortingByUrgency + ? {for (final t in tasks) t.uuid: t.computeUrgency(clock: now)} + : const {}; + + // Sort by the selected column. All eight columns are backed: + // Created (entry), Modified, Start Time, Due till, Priority (by severity), + // Project, Tags (grouped by sorted tag list), and Urgency (computed). tasks.sort((a, b) { switch (selectedSort) { + case 'Created+': + return (a.entry ?? 0).compareTo(b.entry ?? 0); + case 'Created-': + return (b.entry ?? 0).compareTo(a.entry ?? 0); case 'Modified+': return (a.modified ?? 0).compareTo(b.modified ?? 0); case 'Modified-': return (b.modified ?? 0).compareTo(a.modified ?? 0); + case 'Start Time+': + return (a.start ?? '').compareTo(b.start ?? ''); + case 'Start Time-': + return (b.start ?? '').compareTo(a.start ?? ''); case 'Due till+': return (a.due ?? '').compareTo(b.due ?? ''); case 'Due till-': return (b.due ?? '').compareTo(a.due ?? ''); case 'Priority+': - return (a.priority ?? '').compareTo(b.priority ?? ''); + return _priorityRank(a.priority) + .compareTo(_priorityRank(b.priority)); case 'Priority-': - return (b.priority ?? '').compareTo(a.priority ?? ''); + return _priorityRank(b.priority) + .compareTo(_priorityRank(a.priority)); + case 'Project+': + return (a.project ?? '').compareTo(b.project ?? ''); + case 'Project-': + return (b.project ?? '').compareTo(a.project ?? ''); + case 'Tags+': + return _tagKey(a).compareTo(_tagKey(b)); + case 'Tags-': + return _tagKey(b).compareTo(_tagKey(a)); + case 'Urgency+': + return (urgencyByUuid[a.uuid] ?? 0.0) + .compareTo(urgencyByUuid[b.uuid] ?? 0.0); + case 'Urgency-': + return (urgencyByUuid[b.uuid] ?? 0.0) + .compareTo(urgencyByUuid[a.uuid] ?? 0.0); default: - return 0; + return (b.modified ?? 0).compareTo(a.modified ?? 0); } }); @@ -203,7 +236,29 @@ class TaskReplicaViewBuilder extends StatelessWidget { }, ), ); - }); + } + + // A stable key for Tags sort: the task's tags sorted alphabetically and + // joined, so tasks that share the same tags group together and untagged + // tasks (empty key) sort to one end. + String _tagKey(TaskForReplica task) { + final tags = [...(task.tags ?? const [])]..sort(); + return tags.join(' '); + } + + // Rank priorities by severity (H > M > L > none) so Priority sort is + // meaningful rather than alphabetical (which would order H < L < M). + int _priorityRank(String? priority) { + switch (priority) { + case 'H': + return 3; + case 'M': + return 2; + case 'L': + return 1; + default: + return 0; + } } Color _getPriorityColor(String priority) { diff --git a/lib/app/modules/manageTaskServer/controllers/manage_task_server_controller.dart b/lib/app/modules/manageTaskServer/controllers/manage_task_server_controller.dart index 2fe75484..42d194a0 100644 --- a/lib/app/modules/manageTaskServer/controllers/manage_task_server_controller.dart +++ b/lib/app/modules/manageTaskServer/controllers/manage_task_server_controller.dart @@ -9,6 +9,7 @@ import 'package:taskwarrior/app/models/storage/set_config.dart'; import 'package:taskwarrior/app/modules/home/controllers/home_controller.dart'; import 'package:taskwarrior/app/modules/splash/controllers/splash_controller.dart'; import 'package:taskwarrior/app/tour/manage_task_server_page_tour.dart'; +import 'package:taskwarrior/app/tour/safe_tour.dart'; import 'package:taskwarrior/app/utils/constants/taskwarrior_colors.dart'; import 'package:taskwarrior/app/utils/home_path/home_path.dart' as rc; import 'package:taskwarrior/app/utils/taskserver/taskserver.dart'; @@ -221,18 +222,20 @@ class ManageTaskServerController extends GetxController { void showManageTaskServerPageTour(BuildContext context) { Future.delayed( const Duration(milliseconds: 500), - () { - SaveTourStatus.getManageTaskServerTourStatus().then((value) => { - if (value == false) - { - tutorialCoachMark.show(context: context), - } - else - { - // ignore: avoid_print - print('User has seen this page'), - } - }); + () async { + final seen = await SaveTourStatus.getManageTaskServerTourStatus(); + if (seen) return; + await safeShowTour( + tutorialCoachMark: tutorialCoachMark, + context: context, + targetKeys: [ + configureTaskRC, + configureServerCertificate, + configureTaskServerKey, + configureYourCertificate, + ], + markSeen: () => SaveTourStatus.saveManageTaskServerTourStatus(true), + ); }, ); } diff --git a/lib/app/modules/manage_task_champion_creds/controllers/manage_task_champion_creds_controller.dart b/lib/app/modules/manage_task_champion_creds/controllers/manage_task_champion_creds_controller.dart index 8bf68577..e77a6ce7 100644 --- a/lib/app/modules/manage_task_champion_creds/controllers/manage_task_champion_creds_controller.dart +++ b/lib/app/modules/manage_task_champion_creds/controllers/manage_task_champion_creds_controller.dart @@ -1,17 +1,16 @@ -import 'dart:convert'; - import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:taskwarrior/app/modules/home/controllers/home_controller.dart'; import 'package:taskwarrior/app/modules/splash/controllers/splash_controller.dart'; import 'package:taskwarrior/app/utils/taskchampion/credentials_storage.dart'; -import 'package:taskwarrior/app/v3/net/origin.dart'; -import 'package:http/http.dart' as http; +import 'package:taskwarrior/app/v3/champion/replica.dart'; +import 'package:taskwarrior/rust_bridge/api.dart'; class ManageTaskChampionCredsController extends GetxController { final encryptionSecretController = TextEditingController(); final clientIdController = TextEditingController(); - final ccsyncBackendUrlController = TextEditingController(); + final syncServerUrlController = TextEditingController(); var profilesWidget = Get.find(); RxBool isCheckingCreds = false.obs; RxBool taskReplica = false.obs; @@ -25,50 +24,57 @@ class ManageTaskChampionCredsController extends GetxController { encryptionSecretController.text = await CredentialsStorage.getEncryptionSecret() ?? ''; clientIdController.text = await CredentialsStorage.getClientId() ?? ''; - ccsyncBackendUrlController.text = + syncServerUrlController.text = await CredentialsStorage.getApiUrl() ?? ''; final SharedPreferences prefs = await SharedPreferences.getInstance(); taskReplica.value = prefs.getBool('settings_taskr_repl') ?? false; } + /// Validates and persists sync credentials through a single path: the native + /// TaskChampion sync via the Rust FFI bridge. + /// + /// The entered credentials are validated with a real [sync_] *before* they + /// are persisted — a successful sync confirms they are valid, while invalid + /// credentials raise a Rust-level exception that surfaces here as a thrown + /// error. This ordering guarantees we never write unverified credentials into + /// the active profile. (Because validation is a live sync, saving requires + /// connectivity to the sync server.) Future saveCredentials() async { - if (taskReplica.value) { - profilesWidget.setTaskcCreds( - profilesWidget.currentProfile.value, - clientIdController.text, - encryptionSecretController.text, - ccsyncBackendUrlController.text); - return 0; - } isCheckingCreds.value = true; - String baseUrl = ccsyncBackendUrlController.text; - String uuid = clientIdController.text; - String encryptionSecret = encryptionSecretController.text; try { - String url = - '$baseUrl/tasks?email=email&origin=$origin&UUID=$uuid&encryptionSecret=$encryptionSecret'; - - var response = await http.get(Uri.parse(url), headers: { - "Content-Type": "application/json", - }).timeout(const Duration(seconds: 10000)); - debugPrint("Fetch tasks response: ${response.statusCode}"); - debugPrint("Fetch tasks body: ${response.body}"); - if (response.statusCode == 200) { - List allTasks = jsonDecode(response.body); - debugPrint(allTasks.toString()); - profilesWidget.setTaskcCreds( - profilesWidget.currentProfile.value, - clientIdController.text, - encryptionSecretController.text, - ccsyncBackendUrlController.text); - - isCheckingCreds.value = false; - return 0; - } else { - throw Exception('Failed to load tasks'); + final String replicaPath = await Replica.getReplicaPath(); + await sync_( + taskdbDirPath: replicaPath, + url: syncServerUrlController.text, + clientId: clientIdController.text, + encryptionSecret: encryptionSecretController.text, + ); + // Only persist after the sync has confirmed the credentials are valid. + profilesWidget.setTaskcCreds( + profilesWidget.currentProfile.value, + clientIdController.text, + encryptionSecretController.text, + syncServerUrlController.text, + ); + // Populate the task list immediately. The home list is otherwise only + // filled by an explicit refresh, so right after configuring credentials + // the user would see an empty list (until a manual refresh or restart) + // even though the sync above already succeeded. Reload the sync-mode flag + // and sync so the freshly-synced tasks appear at once. Guarded so a + // transient refresh failure never flips an already-successful save. + try { + if (Get.isRegistered()) { + final homeController = Get.find(); + await homeController.fetchTasksFromDB(); + await homeController.refreshReplicaTasks(); + } + } catch (refreshErr) { + debugPrint('Post-save replica refresh failed: $refreshErr'); } - } catch (e, s) { - debugPrint('Error fetching tasks: $e\n $s'); + isCheckingCreds.value = false; + return 0; + } catch (err) { + debugPrint('Credential check failed: $err'); isCheckingCreds.value = false; return 1; } @@ -78,7 +84,7 @@ class ManageTaskChampionCredsController extends GetxController { void onClose() { encryptionSecretController.dispose(); clientIdController.dispose(); - ccsyncBackendUrlController.dispose(); + syncServerUrlController.dispose(); super.onClose(); } } diff --git a/lib/app/modules/manage_task_champion_creds/views/manage_task_champion_creds_view.dart b/lib/app/modules/manage_task_champion_creds/views/manage_task_champion_creds_view.dart index e9b98351..fe9a6da1 100644 --- a/lib/app/modules/manage_task_champion_creds/views/manage_task_champion_creds_view.dart +++ b/lib/app/modules/manage_task_champion_creds/views/manage_task_champion_creds_view.dart @@ -36,22 +36,7 @@ class ManageTaskChampionCredsView ), ], ), - actions: [ - // IconButton( - // icon: Icon( - // Icons.info, - // color: TaskWarriorColors.white, - // ), - // onPressed: () async { - // String url = !controller.taskReplica.value - // ? "https://github.com/its-me-abhishek/ccsync" - // : "https://github.com/GothenburgBitFactory/taskchampion"; - // if (!await launchUrl(Uri.parse(url))) { - // throw Exception('Could not launch $url'); - // } - // }, - // ), - ], + actions: const [], leading: IconButton( icon: Icon(Icons.arrow_back, color: TaskWarriorColors.white), onPressed: () => Get.back(), @@ -74,7 +59,7 @@ class ManageTaskChampionCredsView labelText: SentenceManager( currentLanguage: AppSettings.selectedLanguage) .sentences - .ccsyncClientId, + .syncServerClientId, labelStyle: TextStyle(color: tColors.primaryTextColor), border: const OutlineInputBorder(), ), @@ -93,26 +78,18 @@ class ManageTaskChampionCredsView ), ), const SizedBox(height: 10), - Obx(() => TextField( - style: TextStyle(color: tColors.primaryTextColor), - controller: controller.ccsyncBackendUrlController, - decoration: InputDecoration( - labelText: controller.taskReplica.value - ? SentenceManager( - currentLanguage: - AppSettings.selectedLanguage) - .sentences - .taskchampionBackendUrl - : SentenceManager( - currentLanguage: - AppSettings.selectedLanguage) - .sentences - .ccsyncBackendUrl, - labelStyle: - TextStyle(color: tColors.primaryTextColor), - border: const OutlineInputBorder(), - ), - )), + TextField( + style: TextStyle(color: tColors.primaryTextColor), + controller: controller.syncServerUrlController, + decoration: InputDecoration( + labelText: SentenceManager( + currentLanguage: AppSettings.selectedLanguage) + .sentences + .syncServerBackendUrl, + labelStyle: TextStyle(color: tColors.primaryTextColor), + border: const OutlineInputBorder(), + ), + ), const SizedBox(height: 20), Obx(() => SizedBox( width: double.infinity, @@ -196,7 +173,7 @@ class ManageTaskChampionCredsView SentenceManager( currentLanguage: AppSettings.selectedLanguage) .sentences - .ccsyncEasySyncTitle, + .syncServerEasySyncTitle, style: GoogleFonts.poppins( fontSize: 18, fontWeight: FontWeight.w600, @@ -208,7 +185,7 @@ class ManageTaskChampionCredsView SentenceManager( currentLanguage: AppSettings.selectedLanguage) .sentences - .ccsyncIntro, + .syncServerIntro, style: TextStyle( fontSize: 14, color: tColors.primaryTextColor?.withValues(alpha: 0.8), @@ -220,7 +197,7 @@ class ManageTaskChampionCredsView SentenceManager( currentLanguage: AppSettings.selectedLanguage) .sentences - .ccsyncLoginInstruction, + .syncServerLoginInstruction, style: TextStyle( fontSize: 14, color: tColors.primaryTextColor?.withValues(alpha: 0.8), @@ -235,7 +212,7 @@ class ManageTaskChampionCredsView SentenceManager( currentLanguage: AppSettings.selectedLanguage) .sentences - .ccsyncOpenButton, + .syncServerOpenButton, style: TextStyle(color: tColors.primaryTextColor), ), style: OutlinedButton.styleFrom( @@ -261,7 +238,7 @@ class ManageTaskChampionCredsView SentenceManager( currentLanguage: AppSettings.selectedLanguage) .sentences - .ccsyncSelfHosted, + .syncServerSelfHosted, style: TextStyle( fontSize: 13, color: tColors.primaryTextColor?.withValues(alpha: 0.6), diff --git a/lib/app/modules/profile/controllers/profile_controller.dart b/lib/app/modules/profile/controllers/profile_controller.dart index 50e6e5cd..0c0303a5 100644 --- a/lib/app/modules/profile/controllers/profile_controller.dart +++ b/lib/app/modules/profile/controllers/profile_controller.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:taskwarrior/app/modules/splash/controllers/splash_controller.dart'; import 'package:taskwarrior/app/tour/profile_page_tour.dart'; +import 'package:taskwarrior/app/tour/safe_tour.dart'; import 'package:taskwarrior/app/utils/constants/taskwarrior_colors.dart'; import 'package:taskwarrior/app/utils/app_settings/app_settings.dart'; import 'package:tutorial_coach_mark/tutorial_coach_mark.dart'; @@ -45,18 +46,19 @@ class ProfileController extends GetxController { void showProfilePageTour(BuildContext context) { Future.delayed( const Duration(milliseconds: 500), - () { - SaveTourStatus.getProfileTourStatus().then((value) => { - if (value == false) - { - tutorialCoachMark.show(context: context), - } - else - { - // ignore: avoid_print - print('User has seen this page'), - } - }); + () async { + final seen = await SaveTourStatus.getProfileTourStatus(); + if (seen) return; + await safeShowTour( + tutorialCoachMark: tutorialCoachMark, + context: context, + targetKeys: [ + currentProfileKey, + addNewProfileKey, + manageSelectedProfileKey, + ], + markSeen: () => SaveTourStatus.saveProfileTourStatus(true), + ); }, ); } diff --git a/lib/app/modules/profile/views/profile_view.dart b/lib/app/modules/profile/views/profile_view.dart index a50eec66..7f9c97d6 100644 --- a/lib/app/modules/profile/views/profile_view.dart +++ b/lib/app/modules/profile/views/profile_view.dart @@ -264,17 +264,6 @@ class ProfileView extends GetView { }); }, ), - // CCSync v3 is deprecated, so hiding it for now - // RadioListTile( - // title: const Text('CCSync (v3)'), - // value: 'TW3', - // groupValue: selectedMode, - // onChanged: (String? value) { - // setState(() { - // selectedMode = value; - // }); - // }, - // ), RadioListTile( title: const Text('TaskServer'), value: 'TW2', @@ -327,8 +316,9 @@ class ProfileView extends GetView { AppSettings.selectedLanguage) .sentences .profilePageSuccessfullyChangedProfileModeTo + - ((selectedMode ?? "") == "TW3" - ? "CCSync" + " " + + ((selectedMode ?? "") == "TW3C" + ? "Taskchampion" : "Taskserver"), style: TextStyle( color: tColors.primaryTextColor, diff --git a/lib/app/modules/profile/views/profiles_list.dart b/lib/app/modules/profile/views/profiles_list.dart index 93d867a6..20ebbcd5 100644 --- a/lib/app/modules/profile/views/profiles_list.dart +++ b/lib/app/modules/profile/views/profiles_list.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:taskwarrior/app/modules/splash/controllers/splash_controller.dart'; import 'package:taskwarrior/app/utils/constants/taskwarrior_colors.dart'; import 'package:taskwarrior/app/utils/app_settings/app_settings.dart'; import 'package:taskwarrior/app/utils/language/sentence_manager.dart'; @@ -133,10 +134,20 @@ class ProfilesList extends StatelessWidget { ? TaskWarriorColors.kprimaryTextColor : TaskWarriorColors.kLightPrimaryTextColor), title: Text( - SentenceManager( - currentLanguage: AppSettings.selectedLanguage) - .sentences - .profilePageConfigureTaskserver, // New descriptive text + // Label the config option for the profile's actual sync + // mode: a Taskchampion (v3) profile configures + // Taskchampion, not the Taskserver. + Get.find().getMode(profileId) == 'TW3C' + ? SentenceManager( + currentLanguage: + AppSettings.selectedLanguage) + .sentences + .configureTaskchampion + : SentenceManager( + currentLanguage: + AppSettings.selectedLanguage) + .sentences + .profilePageConfigureTaskserver, style: TextStyle( color: AppSettings.isDarkMode ? TaskWarriorColors.kprimaryTextColor diff --git a/lib/app/modules/report_engine/bindings/report_engine_binding.dart b/lib/app/modules/report_engine/bindings/report_engine_binding.dart new file mode 100644 index 00000000..86b9d8fb --- /dev/null +++ b/lib/app/modules/report_engine/bindings/report_engine_binding.dart @@ -0,0 +1,12 @@ +import 'package:get/get.dart'; + +import '../controllers/report_engine_controller.dart'; + +class ReportEngineBinding extends Bindings { + @override + void dependencies() { + Get.lazyPut( + () => ReportEngineController(), + ); + } +} diff --git a/lib/app/modules/report_engine/controllers/report_engine_controller.dart b/lib/app/modules/report_engine/controllers/report_engine_controller.dart new file mode 100644 index 00000000..41523a52 --- /dev/null +++ b/lib/app/modules/report_engine/controllers/report_engine_controller.dart @@ -0,0 +1,72 @@ +import 'package:get/get.dart'; +import 'package:taskwarrior/app/models/report.dart'; +import 'package:taskwarrior/app/services/report_service.dart'; +import 'package:taskwarrior/app/services/taskrc_service.dart'; +import 'package:taskwarrior/app/v3/champion/models/task_for_replica.dart'; +import 'package:taskwarrior/app/v3/champion/replica.dart'; + +/// Drives the reporting-engine screen (Issue #418): loads the available report +/// definitions (custom `.taskrc` reports first, then defaults) and runs the +/// selected one over the current replica task list. +class ReportEngineController extends GetxController { + final RxList reports = [].obs; + final Rxn selectedReport = Rxn(); + final RxList results = [].obs; + final RxBool isLoading = false.obs; + final RxBool hasError = false.obs; + final RxString taskrcPath = ''.obs; + + @override + void onInit() { + super.onInit(); + loadReports(); + } + + /// (Re)loads the report catalogue: default reports plus any user-defined ones + /// found in `.taskrc`. Never throws — falls back to defaults on any error. + Future loadReports() async { + isLoading.value = true; + try { + final List custom = + await TaskrcService.loadCustomReports(); + reports.assignAll(ReportService.availableReports(custom)); + taskrcPath.value = await TaskrcService.taskrcPath(); + } catch (_) { + reports.assignAll(ReportService.availableReports()); + } finally { + isLoading.value = false; + } + } + + /// Runs [report] against a fresh snapshot of the local replica and shows the + /// filtered, sorted result. + Future runReport(ReportDefinition report) async { + isLoading.value = true; + hasError.value = false; + selectedReport.value = report; + try { + final List tasks = + await Replica.getAllTasksFromReplica(); + results.assignAll(ReportService.execute(report, tasks)); + } catch (_) { + // Surface the failure distinctly from a legitimately empty report — + // otherwise a broken replica read silently looks like "no tasks match". + results.clear(); + hasError.value = true; + } finally { + isLoading.value = false; + } + } + + /// Re-runs the currently selected report (e.g. after a pull-to-refresh). + Future rerunSelected() async { + final ReportDefinition? current = selectedReport.value; + if (current != null) await runReport(current); + } + + /// Returns to the report picker. + void clearSelection() { + selectedReport.value = null; + results.clear(); + } +} diff --git a/lib/app/modules/report_engine/views/report_engine_view.dart b/lib/app/modules/report_engine/views/report_engine_view.dart new file mode 100644 index 00000000..f6a5e4ef --- /dev/null +++ b/lib/app/modules/report_engine/views/report_engine_view.dart @@ -0,0 +1,281 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:taskwarrior/app/models/report.dart'; +import 'package:taskwarrior/app/modules/report_engine/controllers/report_engine_controller.dart'; +import 'package:taskwarrior/app/routes/app_pages.dart'; +import 'package:taskwarrior/app/utils/constants/taskwarrior_colors.dart'; +import 'package:taskwarrior/app/utils/themes/theme_extension.dart'; +import 'package:taskwarrior/app/v3/champion/models/task_for_replica.dart'; + +class ReportEngineView extends GetView { + const ReportEngineView({super.key}); + + /// Steps back one level: from report results to the picker, or from the + /// picker out of the screen entirely. Shared by the AppBar back arrow and + /// the hardware/gesture back button (via [PopScope]) so the two can + /// never drift out of sync. + bool _handleBack() { + if (controller.selectedReport.value != null) { + controller.clearSelection(); + return false; // handled here; don't pop the route + } + return true; // nothing to unwind; let the route pop + } + + @override + Widget build(BuildContext context) { + final TaskwarriorColorTheme tColors = + Theme.of(context).extension()!; + + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, result) { + if (didPop) return; + if (_handleBack()) Get.back(); + }, + child: Scaffold( + backgroundColor: tColors.primaryBackgroundColor, + appBar: AppBar( + backgroundColor: tColors.primaryBackgroundColor, + foregroundColor: tColors.primaryTextColor, + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () { + if (_handleBack()) Get.back(); + }, + ), + title: Obx(() { + final ReportDefinition? sel = controller.selectedReport.value; + return Text( + sel == null ? 'Reports' : sel.name, + style: GoogleFonts.poppins( + color: tColors.primaryTextColor, + fontWeight: FontWeight.w600), + ); + }), + ), + body: Obx(() { + if (controller.isLoading.value) { + return const Center(child: CircularProgressIndicator()); + } + return controller.selectedReport.value == null + ? _buildPicker(context, tColors) + : _buildResults(context, tColors); + }), + ), + ); + } + + // --- Report picker ------------------------------------------------------- + + Widget _buildPicker(BuildContext context, TaskwarriorColorTheme tColors) { + final List custom = + controller.reports.where((r) => r.isCustom).toList(); + final List defaults = + controller.reports.where((r) => !r.isCustom).toList(); + + return ListView( + padding: const EdgeInsets.symmetric(vertical: 8), + children: [ + if (custom.isNotEmpty) ...[ + _sectionHeader('Your reports', tColors), + ...custom.map((r) => _reportTile(context, r, tColors)), + ], + _sectionHeader('Default reports', tColors), + ...defaults.map((r) => _reportTile(context, r, tColors)), + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), + child: Text( + 'Tip: add or tweak reports by placing a .taskrc with ' + 'report..sort/.filter/.description entries at:\n' + '${controller.taskrcPath.value}', + style: GoogleFonts.poppins( + fontSize: 11, color: tColors.secondaryTextColor), + ), + ), + ], + ); + } + + Widget _sectionHeader(String text, TaskwarriorColorTheme tColors) => Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 6), + child: Text( + text.toUpperCase(), + style: GoogleFonts.poppins( + fontSize: 12, + fontWeight: FontWeight.w700, + letterSpacing: 0.5, + color: tColors.secondaryTextColor, + ), + ), + ); + + Widget _reportTile( + BuildContext context, ReportDefinition r, TaskwarriorColorTheme tColors) { + return Card( + color: tColors.secondaryBackgroundColor, + margin: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + child: ListTile( + onTap: () => controller.runReport(r), + title: Row( + children: [ + Text( + r.name, + style: GoogleFonts.poppins( + color: tColors.primaryTextColor, + fontWeight: FontWeight.w600), + ), + if (r.isCustom) ...[ + const SizedBox(width: 8), + Container( + padding: + const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + decoration: BoxDecoration( + color: TaskWarriorColors.purple.withValues(alpha: 0.25), + borderRadius: BorderRadius.circular(6), + ), + child: Text('CUSTOM', + style: GoogleFonts.poppins( + fontSize: 9, + fontWeight: FontWeight.w700, + color: tColors.primaryTextColor)), + ), + ], + ], + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(r.description, + style: GoogleFonts.poppins( + color: tColors.secondaryTextColor, fontSize: 12)), + if ((r.filterExpression ?? '').trim().isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 2), + child: Text( + r.filterExpression!, + style: GoogleFonts.robotoMono( + fontSize: 11, color: TaskWarriorColors.purple), + ), + ), + ], + ), + trailing: + Icon(Icons.chevron_right, color: tColors.secondaryTextColor), + ), + ); + } + + // --- Report results ------------------------------------------------------ + + Widget _buildResults(BuildContext context, TaskwarriorColorTheme tColors) { + final ReportDefinition report = controller.selectedReport.value!; + return Column( + children: [ + Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + color: tColors.secondaryBackgroundColor, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(report.description, + style: GoogleFonts.poppins( + color: tColors.primaryTextColor, fontSize: 13)), + const SizedBox(height: 2), + Text('${controller.results.length} task(s)', + style: GoogleFonts.poppins( + color: tColors.secondaryTextColor, fontSize: 11)), + ], + ), + ), + Expanded( + child: controller.hasError.value + ? RefreshIndicator( + onRefresh: controller.rerunSelected, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 24, vertical: 80), + child: Text( + "Couldn't load tasks for this report. " + 'Check that TaskChampion sync is configured, then ' + 'pull down to retry.', + textAlign: TextAlign.center, + style: GoogleFonts.poppins( + color: tColors.secondaryTextColor), + ), + ), + ], + ), + ) + : controller.results.isEmpty + ? Center( + child: Text('No tasks match this report.', + style: GoogleFonts.poppins( + color: tColors.secondaryTextColor)), + ) + : RefreshIndicator( + onRefresh: controller.rerunSelected, + child: ListView.builder( + padding: const EdgeInsets.only(top: 4, bottom: 24), + itemCount: controller.results.length, + itemBuilder: (context, index) => + _taskCard(controller.results[index], tColors), + ), + ), + ), + ], + ); + } + + Widget _taskCard(TaskForReplica task, TaskwarriorColorTheme tColors) { + return Card( + color: tColors.secondaryBackgroundColor, + margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: InkWell( + onTap: () => Get.toNamed(Routes.TASKC_DETAILS, arguments: task), + child: Container( + decoration: BoxDecoration( + border: Border.all(color: tColors.primaryTextColor!), + color: tColors.primaryBackgroundColor, + borderRadius: BorderRadius.circular(8.0), + ), + child: ListTile( + leading: CircleAvatar( + backgroundColor: _priorityColor(task.priority ?? ''), + radius: 8, + ), + title: Text( + task.description ?? '', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.poppins(color: tColors.primaryTextColor), + ), + subtitle: Text( + 'Status: ${task.status ?? ''}' + '${(task.project ?? '').isNotEmpty ? ' · ${task.project}' : ''}', + style: GoogleFonts.poppins(color: tColors.secondaryTextColor), + ), + ), + ), + ), + ); + } + + Color _priorityColor(String priority) { + switch (priority) { + case 'H': + return Colors.red; + case 'M': + return Colors.yellow; + case 'L': + return Colors.green; + default: + return Colors.grey; + } + } +} diff --git a/lib/app/modules/reports/burn_down_data.dart b/lib/app/modules/reports/burn_down_data.dart new file mode 100644 index 00000000..3306f32a --- /dev/null +++ b/lib/app/modules/reports/burn_down_data.dart @@ -0,0 +1,69 @@ +import 'package:taskwarrior/app/utils/constants/utilites.dart'; + +/// The time buckets a burndown chart can group tasks into. +enum BurnDownPeriod { daily, weekly, monthly } + +/// A single task reduced to the only two things a burndown chart needs: when it +/// happened, and whether it was pending or completed. +/// +/// Keeping the chart layer this narrow is what lets one implementation serve +/// every sync mode. The app has three task models — the built-value `Task` +/// (local/Taskserver), `TaskForC` (taskc) and `TaskForReplica` (TaskChampion) — +/// and each caller maps its own model into this shape. That also preserves each +/// mode's existing choice of *which* date to bucket by, since the caller picks +/// the date it passes in. +class BurnDownEntry { + const BurnDownEntry({required this.date, required this.status}); + + /// The date this task is counted under, already in local time. + final DateTime date; + + /// `pending` / `completed` / anything else (which is simply not counted, + /// matching the previous per-mode behaviour). + final String status; +} + +/// Groups [entries] into `{bucketKey: {'pending': n, 'completed': n}}`, the +/// shape the chart series expect. +/// +/// Bucket keys match what the previous per-mode charts produced, so the x-axis +/// labels are unchanged: +/// * daily — `MM-dd` +/// * weekly — ISO week number +/// * monthly — `MonthName YYYY` +Map> bucketBurnDown( + Iterable entries, + BurnDownPeriod period, +) { + final Map> buckets = {}; + + // Oldest first, so the chart reads left-to-right in chronological order. + final List sorted = entries.toList() + ..sort((a, b) => a.date.compareTo(b.date)); + + for (final BurnDownEntry entry in sorted) { + final String key = burnDownBucketKey(entry.date, period); + final Map bucket = + buckets.putIfAbsent(key, () => {'pending': 0, 'completed': 0}); + + // Only pending/completed are plotted; other statuses (deleted, recurring) + // are ignored, as they were before. + if (entry.status == 'pending' || entry.status == 'completed') { + bucket[entry.status] = (bucket[entry.status] ?? 0) + 1; + } + } + + return buckets; +} + +/// The bucket a [date] falls into for a given [period]. +String burnDownBucketKey(DateTime date, BurnDownPeriod period) { + switch (period) { + case BurnDownPeriod.daily: + return Utils.formatDate(date, 'MM-dd'); + case BurnDownPeriod.weekly: + return Utils.getWeekNumbertoInt(date).toString(); + case BurnDownPeriod.monthly: + return '${Utils.getMonthName(date.month)} ${date.year}'; + } +} diff --git a/lib/app/modules/reports/controllers/reports_controller.dart b/lib/app/modules/reports/controllers/reports_controller.dart index fd81d97e..dff27b5d 100644 --- a/lib/app/modules/reports/controllers/reports_controller.dart +++ b/lib/app/modules/reports/controllers/reports_controller.dart @@ -1,17 +1,11 @@ import 'dart:io'; import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; import 'package:get/get.dart'; -import 'package:syncfusion_flutter_charts/charts.dart'; import 'package:taskwarrior/app/models/json/task.dart'; import 'package:taskwarrior/app/models/storage.dart'; -import 'package:taskwarrior/app/modules/home/controllers/home_controller.dart'; import 'package:taskwarrior/app/modules/splash/controllers/splash_controller.dart'; import 'package:taskwarrior/app/tour/reports_page_tour.dart'; import 'package:taskwarrior/app/utils/constants/taskwarrior_colors.dart'; -import 'package:taskwarrior/app/utils/constants/taskwarrior_fonts.dart'; -import 'package:taskwarrior/app/utils/constants/utilites.dart'; -import 'package:taskwarrior/app/utils/gen/fonts.gen.dart'; import 'package:taskwarrior/app/utils/app_settings/app_settings.dart'; import 'package:taskwarrior/app/v3/db/task_database.dart'; import 'package:taskwarrior/app/v3/models/task.dart'; @@ -30,7 +24,6 @@ class ReportsController extends GetxController var selectedIndex = 0.obs; var allData = [].obs; late Storage storage; - var storageWidget; // void _initReportsTour() { // tutorialCoachMark = TutorialCoachMark( @@ -107,9 +100,6 @@ class ReportsController extends GetxController @override void onInit() { super.onInit(); - initDailyReports(); - initWeeklyReports(); - initMonthlyReports(); tabController = TabController(length: 3, vsync: this); @@ -123,329 +113,15 @@ class ReportsController extends GetxController }); } - /// This method is used to get the daily burn down data - late TooltipBehavior dailyBurndownTooltipBehaviour; - - ///this method is used to get the weekly burn down data - late TooltipBehavior weeklyBurndownTooltipBehaviour; - // daily report - void initDailyReports() { - ///initialize the _dailyBurndownTooltipBehaviour tooltip behavior - dailyBurndownTooltipBehaviour = TooltipBehavior( - enable: true, - builder: (dynamic data, dynamic point, dynamic series, int pointIndex, - int seriesIndex) { - final String date = data.x; - final int pendingCount = data.y1; - final int completedCount = data.y2; - - return Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(5), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - 'Date: $date', - // style: GoogleFonts.poppins( - // fontWeight: TaskWarriorFonts.bold, - // ), - - style: const TextStyle( - fontFamily: FontFamily.poppins, - fontWeight: TaskWarriorFonts.bold, - color: Colors.black), - ), - Text( - 'Pending: $pendingCount', - style: const TextStyle( - fontWeight: TaskWarriorFonts.bold, color: Colors.black), - ), - Text( - 'Completed: $completedCount', - style: const TextStyle( - fontWeight: TaskWarriorFonts.bold, color: Colors.black), - ), - ], - ), - ); - }, - ); - - ///initialize the storage widget - Future.delayed(Duration.zero, () { - storageWidget = Get.find(); - var currentProfile = Get.find().currentProfile; - - Directory baseDirectory = Get.find().baseDirectory(); - storage = Storage( - Directory('${baseDirectory.path}/profiles/$currentProfile'), - ); - - ///fetch all data contains all the tasks - allData.value = storage.data.allData(); - - ///check if allData is not empty - if (allData.isNotEmpty) { - ///sort the data by daily burn down - sortBurnDownDaily(); - } - }); - } - - /// dailyInfo is a map that contains the daily burn down data - /// The key is the date (formatted as "MM-dd") and the value is a map - /// containing the pending and completed tasks count for that day. - RxMap> dailyInfo = >{}.obs; - - void sortBurnDownDaily() { - // Initialize dailyInfo map - dailyInfo.value = {}; - - // Sort allData by entry date in ascending order - allData.sort((a, b) => a.entry.compareTo(b.entry)); - - /// Loop through allData and get the date - for (int i = 0; i < allData.length; i++) { - final String date = Utils.formatDate(allData[i].entry, 'MM-dd'); - - /// Check if dailyInfo contains the date - if (dailyInfo.containsKey(date)) { - /// Check if the status is pending or completed - if (allData[i].status == 'pending') { - /// If the status is pending, then add 1 to the pending count - dailyInfo[date]!['pending'] = (dailyInfo[date]!['pending'] ?? 0) + 1; - } else if (allData[i].status == 'completed') { - /// If the status is completed, then add 1 to the completed count - dailyInfo[date]!['completed'] = - (dailyInfo[date]!['completed'] ?? 0) + 1; - } - } else { - /// If dailyInfo does not contain the date - dailyInfo[date] = { - 'pending': allData[i].status == 'pending' ? 1 : 0, - 'completed': allData[i].status == 'completed' ? 1 : 0, - }; - } - } - - debugPrint("dailyInfo $dailyInfo"); - } - // weekly reports - ///weeklyInfo is a map that contains the weekly burn down data - ///first int holds the week value - ///the second map holds the pending and completed tasks - ///the key is the status and the value is the count - RxMap> weeklyInfo = >{}.obs; - - void sortBurnDownWeekLy() { - // Initialize weeklyInfo map - weeklyInfo.value = {}; - - // Sort allData by entry date in ascending order - allData.sort((a, b) => a.entry.compareTo(b.entry)); - - ///loop through allData and get the week number - for (int i = 0; i < allData.length; i++) { - final int weekNumber = Utils.getWeekNumbertoInt(allData[i].entry); - - ///check if weeklyInfo contains the week number - if (weeklyInfo.containsKey(weekNumber)) { - ///check if the status is pending or completed - if (allData[i].status == 'pending') { - ///if the status is pending then add 1 to the pending count - weeklyInfo[weekNumber]!['pending'] = - (weeklyInfo[weekNumber]!['pending'] ?? 0) + 1; - } else if (allData[i].status == 'completed') { - ///if the status is completed then add 1 to the completed count - weeklyInfo[weekNumber]!['completed'] = - (weeklyInfo[weekNumber]!['completed'] ?? 0) + 1; - } - } else { - ///if weeklyInfo does not contain the week number - weeklyInfo[weekNumber] = { - 'pending': allData[i].status == 'pending' ? 1 : 0, - 'completed': allData[i].status == 'completed' ? 1 : 0, - }; - } - } - - debugPrint("weeklyInfo $weeklyInfo"); - } - - void initWeeklyReports() { - ///initialize the _weeklyBurndownTooltipBehaviour tooltip behavior - weeklyBurndownTooltipBehaviour = TooltipBehavior( - enable: true, - builder: (dynamic data, dynamic point, dynamic series, int pointIndex, - int seriesIndex) { - final String weekNumber = data.x; - final int pendingCount = data.y1; - final int completedCount = data.y2; - - return Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(5), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - weekNumber, - style: const TextStyle( - fontWeight: TaskWarriorFonts.bold, color: Colors.black), - ), - Text( - 'Pending: $pendingCount', - style: const TextStyle( - fontWeight: TaskWarriorFonts.bold, color: Colors.black), - ), - Text( - 'Completed: $completedCount', - style: const TextStyle( - fontWeight: TaskWarriorFonts.bold, color: Colors.black), - ), - ], - ), - ); - }, - ); - - ///initialize the storage widget - Future.delayed(Duration.zero, () { - storageWidget = Get.find(); - var currentProfile = Get.find().currentProfile; - - Directory baseDirectory = Get.find().baseDirectory(); - storage = Storage( - Directory('${baseDirectory.path}/profiles/$currentProfile'), - ); - - ///fetch all data contains all the tasks - allData.value = storage.data.allData(); - - ///check if allData is not empty - if (allData.isNotEmpty) { - ///sort the data by weekly burn down - sortBurnDownWeekLy(); - } - }); - } - Future> fetchTasks() async { await taskDatabase.open(); return await taskDatabase.fetchTasksFromDatabase(); } // monthly report - late TooltipBehavior monthlyBurndownTooltipBehaviour; - RxMap> monthlyInfo = - >{}.obs; - - void sortBurnDownMonthly() { - monthlyInfo.value = {}; - - allData.sort((a, b) => a.entry.compareTo(b.entry)); - - for (int i = 0; i < allData.length; i++) { - final DateTime entryDate = allData[i].entry; - final String monthYear = - '${Utils.getMonthName(entryDate.month)} ${entryDate.year}'; - if (monthlyInfo.containsKey(monthYear)) { - if (allData[i].status == 'pending') { - monthlyInfo[monthYear]!['pending'] = - (monthlyInfo[monthYear]!['pending'] ?? 0) + 1; - } else if (allData[i].status == 'completed') { - monthlyInfo[monthYear]!['completed'] = - (monthlyInfo[monthYear]!['completed'] ?? 0) + 1; - } - } else { - monthlyInfo[monthYear] = { - 'pending': allData[i].status == 'pending' ? 1 : 0, - 'completed': allData[i].status == 'completed' ? 1 : 0, - }; - } - } - - debugPrint("monthlyInfo: $monthlyInfo"); - } - - void initMonthlyReports() { - monthlyBurndownTooltipBehaviour = TooltipBehavior( - enable: true, - builder: (dynamic data, dynamic point, dynamic series, int pointIndex, - int seriesIndex) { - final String monthYear = data.x; - final int pendingCount = data.y1; - final int completedCount = data.y2; - - return Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(5), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - 'Month-Year: $monthYear', - style: const TextStyle( - fontFamily: FontFamily.poppins, - fontWeight: TaskWarriorFonts.bold, - color: Colors.black), - ), - Text( - 'Pending: $pendingCount', - style: const TextStyle( - fontFamily: FontFamily.poppins, - fontWeight: TaskWarriorFonts.bold, - color: Colors.black), - ), - Text( - 'Completed: $completedCount', - style: const TextStyle( - fontFamily: FontFamily.poppins, - fontWeight: TaskWarriorFonts.bold, - color: Colors.black), - ), - ], - ), - ); - }, - ); - - ///initialize the storage widget - Future.delayed(Duration.zero, () { - storageWidget = Get.find(); - var currentProfile = Get.find().currentProfile; - - Directory baseDirectory = Get.find().baseDirectory(); - storage = Storage( - Directory('${baseDirectory.path}/profiles/$currentProfile'), - ); - - ///fetch all data contains all the tasks - allData.value = storage.data.allData(); - - ///check if allData is not empty - if (allData.isNotEmpty) { - ///sort the data by weekly burn down - sortBurnDownMonthly(); - } - }); - } } diff --git a/lib/app/modules/reports/views/burn_down_chart.dart b/lib/app/modules/reports/views/burn_down_chart.dart new file mode 100644 index 00000000..d2acc3e4 --- /dev/null +++ b/lib/app/modules/reports/views/burn_down_chart.dart @@ -0,0 +1,161 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:syncfusion_flutter_charts/charts.dart'; +import 'package:taskwarrior/app/models/chart.dart'; +import 'package:taskwarrior/app/modules/reports/burn_down_data.dart'; +import 'package:taskwarrior/app/modules/reports/views/common_chart_indicator.dart'; +import 'package:taskwarrior/app/utils/app_settings/app_settings.dart'; +import 'package:taskwarrior/app/utils/constants/taskwarrior_colors.dart'; +import 'package:taskwarrior/app/utils/constants/taskwarrior_fonts.dart'; +import 'package:taskwarrior/app/utils/language/sentence_manager.dart'; +import 'package:taskwarrior/app/utils/themes/theme_extension.dart'; + +/// The stacked pending/completed burndown chart, for any period and any sync +/// mode. +/// +/// This replaces the nine near-identical `burn_down_{daily,weekly,monthly}` +/// × `{base,taskc,replica}` widgets. Those differed only in where they fetched +/// tasks from, which date field they bucketed by, and their title text — the +/// chart configuration itself was duplicated verbatim. Callers now do the +/// fetching and hand over [entries]; everything below is shared. +class BurnDownChart extends StatelessWidget { + const BurnDownChart({ + super.key, + required this.entries, + required this.period, + this.titleSuffix = '', + this.dateAxisSuffix = '', + }); + + /// Tasks reduced to (date, status). See [BurnDownEntry]. + final List entries; + + final BurnDownPeriod period; + + /// Appended to the chart caption, e.g. ` (Replica)`, to keep the previous + /// per-mode captions intact. + final String titleSuffix; + + /// Appended to the x-axis label, e.g. ` (Modified Date)`, so a mode that + /// buckets by a non-obvious date still says which one. + final String dateAxisSuffix; + + String get _xAxisTitle { + switch (period) { + case BurnDownPeriod.daily: + return 'Day - Month$dateAxisSuffix'; + case BurnDownPeriod.weekly: + return 'Weeks - Year$dateAxisSuffix'; + case BurnDownPeriod.monthly: + return 'Month - Year$dateAxisSuffix'; + } + } + + String get _caption { + switch (period) { + case BurnDownPeriod.daily: + return 'Daily Burndown Chart$titleSuffix'; + case BurnDownPeriod.weekly: + return 'Weekly Burndown Chart$titleSuffix'; + case BurnDownPeriod.monthly: + return 'Monthly Burndown Chart$titleSuffix'; + } + } + + TooltipBehavior _buildTooltip() { + return TooltipBehavior( + enable: true, + builder: (dynamic data, dynamic point, dynamic series, int pointIndex, + int seriesIndex) { + final sentences = + SentenceManager(currentLanguage: AppSettings.selectedLanguage) + .sentences; + return Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(5), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '${sentences.reportsDate}: ${data.x}', + style: GoogleFonts.poppins(fontWeight: TaskWarriorFonts.bold), + ), + Text('${sentences.reportsPending}: ${data.y1}'), + Text('${sentences.reportsCompleted}: ${data.y2}'), + ], + ), + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + final double height = MediaQuery.of(context).size.height; + final TaskwarriorColorTheme tColors = + Theme.of(context).extension()!; + + final Map> buckets = + bucketBurnDown(entries, period); + + final List data = buckets.entries + .map((e) => ChartData( + e.key, + e.value['pending'] ?? 0, + e.value['completed'] ?? 0, + )) + .toList(); + + final TextStyle axisStyle = GoogleFonts.poppins( + fontWeight: TaskWarriorFonts.bold, + fontSize: TaskWarriorFonts.fontSizeSmall, + color: tColors.primaryTextColor, + ); + + return Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: SizedBox( + height: height * 0.6, + child: SfCartesianChart( + primaryXAxis: CategoryAxis( + title: AxisTitle(text: _xAxisTitle, textStyle: axisStyle), + ), + primaryYAxis: NumericAxis( + title: AxisTitle(text: 'Tasks', textStyle: axisStyle), + ), + tooltipBehavior: _buildTooltip(), + series: [ + StackedColumnSeries( + groupName: 'Group A', + enableTooltip: true, + color: TaskWarriorColors.green, + dataSource: data, + xValueMapper: (ChartData d, _) => d.x, + yValueMapper: (ChartData d, _) => d.y2, + name: 'Completed', + ), + StackedColumnSeries( + groupName: 'Group A', + enableTooltip: true, + color: TaskWarriorColors.yellow, + dataSource: data, + xValueMapper: (ChartData d, _) => d.x, + yValueMapper: (ChartData d, _) => d.y1, + name: 'Pending', + ), + ], + ), + ), + ), + CommonChartIndicator(title: _caption), + ], + ); + } +} diff --git a/lib/app/modules/reports/views/burn_down_daily.dart b/lib/app/modules/reports/views/burn_down_daily.dart deleted file mode 100644 index dd459087..00000000 --- a/lib/app/modules/reports/views/burn_down_daily.dart +++ /dev/null @@ -1,113 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:get/get.dart'; -import 'package:syncfusion_flutter_charts/charts.dart'; -import 'package:taskwarrior/app/models/chart.dart'; -import 'package:taskwarrior/app/modules/reports/controllers/reports_controller.dart'; -import 'package:taskwarrior/app/modules/reports/views/common_chart_indicator.dart'; -import 'package:taskwarrior/app/utils/constants/taskwarrior_colors.dart'; -import 'package:taskwarrior/app/utils/constants/taskwarrior_fonts.dart'; -import 'package:taskwarrior/app/utils/gen/fonts.gen.dart'; -import 'package:taskwarrior/app/utils/language/sentence_manager.dart'; -import 'package:taskwarrior/app/utils/app_settings/app_settings.dart'; -import 'package:taskwarrior/app/utils/themes/theme_extension.dart'; - -class BurnDownDaily extends StatelessWidget { - final ReportsController reportsController; - const BurnDownDaily({super.key, required this.reportsController}); - - @override - Widget build(BuildContext context) { - // Keeping your Theme change - TaskwarriorColorTheme tColors = - Theme.of(context).extension()!; - double height = MediaQuery.of(context).size.height; - - // Removed the Stack and Positioned button since the controller doesn't support capturing yet - return Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Expanded( - child: SizedBox( - height: height * 0.6, - // Removed RepaintBoundary and chartKey - child: Obx( - () => SfCartesianChart( - primaryXAxis: CategoryAxis( - title: AxisTitle( - text: SentenceManager( - currentLanguage: AppSettings.selectedLanguage) - .sentences - .reportsPageDailyDayMonth, - textStyle: TextStyle( - fontFamily: FontFamily.poppins, - fontWeight: TaskWarriorFonts.bold, - color: tColors.primaryTextColor, - fontSize: TaskWarriorFonts.fontSizeSmall, - ), - ), - ), - primaryYAxis: NumericAxis( - title: AxisTitle( - text: SentenceManager( - currentLanguage: AppSettings.selectedLanguage) - .sentences - .reportsPageTasks, - textStyle: TextStyle( - fontFamily: FontFamily.poppins, - fontWeight: TaskWarriorFonts.bold, - color: tColors.primaryTextColor, - fontSize: TaskWarriorFonts.fontSizeSmall, - ), - ), - ), - tooltipBehavior: - reportsController.dailyBurndownTooltipBehaviour, - series: [ - /// This is the completed tasks - StackedColumnSeries( - groupName: 'Group A', - enableTooltip: true, - color: TaskWarriorColors.green, - dataSource: reportsController.dailyInfo.entries - .map((entry) => ChartData( - entry.key, - entry.value['pending'] ?? 0, - entry.value['completed'] ?? 0, - )) - .toList(), - xValueMapper: (ChartData data, _) => data.x, - yValueMapper: (ChartData data, _) => data.y2, - name: 'Completed', - ), - - /// This is the pending tasks - StackedColumnSeries( - groupName: 'Group A', - color: TaskWarriorColors.yellow, - enableTooltip: true, - dataSource: reportsController.dailyInfo.entries - .map((entry) => ChartData( - entry.key, - entry.value['pending'] ?? 0, - entry.value['completed'] ?? 0, - )) - .toList(), - xValueMapper: (ChartData data, _) => data.x, - yValueMapper: (ChartData data, _) => data.y1, - name: 'Pending', - ), - ], - ), - ), - ), - ), - CommonChartIndicator( - title: SentenceManager(currentLanguage: AppSettings.selectedLanguage) - .sentences - .reportsPageDailyBurnDownChart, - ), - ], - ); - } -} diff --git a/lib/app/modules/reports/views/burn_down_daily_replica.dart b/lib/app/modules/reports/views/burn_down_daily_replica.dart deleted file mode 100644 index 66d00083..00000000 --- a/lib/app/modules/reports/views/burn_down_daily_replica.dart +++ /dev/null @@ -1,187 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:google_fonts/google_fonts.dart'; -import 'package:syncfusion_flutter_charts/charts.dart'; -import 'package:taskwarrior/app/models/chart.dart'; -import 'package:taskwarrior/app/modules/reports/views/common_chart_indicator.dart'; -import 'package:taskwarrior/app/utils/app_settings/app_settings.dart'; -import 'package:taskwarrior/app/utils/constants/taskwarrior_colors.dart'; -import 'package:taskwarrior/app/utils/constants/taskwarrior_fonts.dart'; -import 'package:taskwarrior/app/utils/constants/utilites.dart'; -import 'package:taskwarrior/app/utils/themes/theme_extension.dart'; -import 'package:taskwarrior/app/utils/language/sentence_manager.dart'; -import 'package:taskwarrior/app/v3/champion/models/task_for_replica.dart'; -import 'package:taskwarrior/app/v3/champion/replica.dart'; - -class BurnDownDailyReplica extends StatelessWidget { - BurnDownDailyReplica({super.key}); - - final TooltipBehavior _dailyBurndownTooltipBehaviour = TooltipBehavior( - enable: true, - builder: (dynamic data, dynamic point, dynamic series, int pointIndex, - int seriesIndex) { - final String date = data.x; - final int pendingCount = data.y1; - final int completedCount = data.y2; - - return Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(5), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - '${SentenceManager(currentLanguage: AppSettings.selectedLanguage).sentences.reportsDate}: $date', - style: GoogleFonts.poppins( - fontWeight: TaskWarriorFonts.bold, - ), - ), - Text( - '${SentenceManager(currentLanguage: AppSettings.selectedLanguage).sentences.reportsPending}: $pendingCount'), - Text( - '${SentenceManager(currentLanguage: AppSettings.selectedLanguage).sentences.reportsCompleted}: $completedCount'), - ], - ), - ); - }, - ); - - Future>> fetchDailyInfo() async { - // Use the Replica class to fetch tasks - List tasks = await Replica.getAllTasksFromReplica(); - return _processData(tasks); - } - - Map> _processData(List tasks) { - debugPrint( - 'Processing ${tasks.length} tasks for daily burndown chart (Replica).'); - Map> dailyInfo = {}; - - // Sort tasks by modified date in ascending order - tasks.sort((a, b) => (a.modified ?? 0).compareTo(b.modified ?? 0)); - - for (var task in tasks) { - // Use 'modified' timestamp (seconds since epoch) - final int? modifiedTimestamp = task.modified; - if (modifiedTimestamp == null) continue; - - final DateTime modifiedDate = DateTime.fromMillisecondsSinceEpoch( - modifiedTimestamp * 1000, - isUtc: true); - - final String date = Utils.formatDate(modifiedDate.toLocal(), 'MM-dd'); - - if (dailyInfo.containsKey(date)) { - if (task.status == 'pending') { - dailyInfo[date]!['pending'] = (dailyInfo[date]!['pending'] ?? 0) + 1; - } else if (task.status == 'completed') { - dailyInfo[date]!['completed'] = - (dailyInfo[date]!['completed'] ?? 0) + 1; - } - } else { - dailyInfo[date] = { - 'pending': task.status == 'pending' ? 1 : 0, - 'completed': task.status == 'completed' ? 1 : 0, - }; - } - } - - return dailyInfo; - } - - @override - Widget build(BuildContext context) { - double height = MediaQuery.of(context).size.height; // Screen height - TaskwarriorColorTheme tColors = - Theme.of(context).extension()!; - return FutureBuilder>>( - future: fetchDailyInfo(), - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.waiting) { - return const Center(child: CircularProgressIndicator()); - } - - if (snapshot.hasError) { - return Center( - child: Text( - '${SentenceManager(currentLanguage: AppSettings.selectedLanguage).sentences.reportsError}: ${snapshot.error}')); - } - - Map> dailyInfo = snapshot.data ?? {}; - - return Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Expanded( - child: SizedBox( - height: height * 0.6, - child: SfCartesianChart( - primaryXAxis: CategoryAxis( - title: AxisTitle( - text: 'Day - Month (Modified Date)', - textStyle: GoogleFonts.poppins( - fontWeight: TaskWarriorFonts.bold, - color: tColors.primaryTextColor, - fontSize: TaskWarriorFonts.fontSizeSmall, - ), - ), - ), - primaryYAxis: NumericAxis( - title: AxisTitle( - text: 'Tasks', - textStyle: GoogleFonts.poppins( - fontWeight: TaskWarriorFonts.bold, - fontSize: TaskWarriorFonts.fontSizeSmall, - color: tColors.primaryTextColor, - ), - ), - ), - tooltipBehavior: _dailyBurndownTooltipBehaviour, - series: [ - StackedColumnSeries( - groupName: 'Group A', - enableTooltip: true, - color: TaskWarriorColors.green, - dataSource: dailyInfo.entries - .map((entry) => ChartData( - entry.key, - entry.value['pending'] ?? 0, - entry.value['completed'] ?? 0, - )) - .toList(), - xValueMapper: (ChartData data, _) => data.x, - yValueMapper: (ChartData data, _) => data.y2, - name: 'Completed', - ), - StackedColumnSeries( - groupName: 'Group A', - color: TaskWarriorColors.yellow, - enableTooltip: true, - dataSource: dailyInfo.entries - .map((entry) => ChartData( - entry.key, - entry.value['pending'] ?? 0, - entry.value['completed'] ?? 0, - )) - .toList(), - xValueMapper: (ChartData data, _) => data.x, - yValueMapper: (ChartData data, _) => data.y1, - name: 'Pending', - ), - ], - ), - ), - ), - const CommonChartIndicator( - title: 'Daily Burndown Chart (Replica)', - ), - ], - ); - }, - ); - } -} diff --git a/lib/app/modules/reports/views/burn_down_daily_taskc.dart b/lib/app/modules/reports/views/burn_down_daily_taskc.dart deleted file mode 100644 index bd3ee0a3..00000000 --- a/lib/app/modules/reports/views/burn_down_daily_taskc.dart +++ /dev/null @@ -1,186 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:google_fonts/google_fonts.dart'; -import 'package:intl/intl.dart'; -import 'package:syncfusion_flutter_charts/charts.dart'; -import 'package:taskwarrior/app/models/chart.dart'; -import 'package:taskwarrior/app/modules/reports/views/common_chart_indicator.dart'; -import 'package:taskwarrior/app/utils/app_settings/app_settings.dart'; -import 'package:taskwarrior/app/utils/constants/taskwarrior_colors.dart'; -import 'package:taskwarrior/app/utils/constants/taskwarrior_fonts.dart'; -import 'package:taskwarrior/app/utils/constants/utilites.dart'; -import 'package:taskwarrior/app/utils/themes/theme_extension.dart'; -import 'package:taskwarrior/app/utils/language/sentence_manager.dart'; -import 'package:taskwarrior/app/v3/db/task_database.dart'; -import 'package:taskwarrior/app/v3/models/task.dart'; - -class BurnDownDailyTaskc extends StatelessWidget { - BurnDownDailyTaskc({super.key}); - - final TooltipBehavior _dailyBurndownTooltipBehaviour = TooltipBehavior( - enable: true, - builder: (dynamic data, dynamic point, dynamic series, int pointIndex, - int seriesIndex) { - final String date = data.x; - final int pendingCount = data.y1; - final int completedCount = data.y2; - - return Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(5), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - '${SentenceManager(currentLanguage: AppSettings.selectedLanguage).sentences.reportsDate}: $date', - style: GoogleFonts.poppins( - fontWeight: TaskWarriorFonts.bold, - ), - ), - Text( - '${SentenceManager(currentLanguage: AppSettings.selectedLanguage).sentences.reportsPending}: $pendingCount'), - Text( - '${SentenceManager(currentLanguage: AppSettings.selectedLanguage).sentences.reportsCompleted}: $completedCount'), - ], - ), - ); - }, - ); - - Future>> fetchDailyInfo() async { - TaskDatabase taskDatabase = TaskDatabase(); - await taskDatabase.open(); - List tasks = await taskDatabase.fetchTasksFromDatabase(); - return _processData(tasks); - } - - Map> _processData(List tasks) { - debugPrint('Processing ${tasks.length} tasks for daily burndown chart.'); - Map> dailyInfo = {}; - - // Sort tasks by entry date in ascending order - tasks.sort((a, b) => a.entry.compareTo(b.entry)); - - for (var task in tasks) { - final String date; - try { - date = Utils.formatDate(DateTime.parse(task.entry), 'MM-dd'); - } catch (e) { - debugPrint( - 'Error parsing date for task ID ${task.id}: ${e.toString()}'); - continue; // Skip this task if date parsing fails - } - if (dailyInfo.containsKey(date)) { - if (task.status == 'pending') { - dailyInfo[date]!['pending'] = (dailyInfo[date]!['pending'] ?? 0) + 1; - } else if (task.status == 'completed') { - dailyInfo[date]!['completed'] = - (dailyInfo[date]!['completed'] ?? 0) + 1; - } - } else { - dailyInfo[date] = { - 'pending': task.status == 'pending' ? 1 : 0, - 'completed': task.status == 'completed' ? 1 : 0, - }; - } - } - - return dailyInfo; - } - - @override - Widget build(BuildContext context) { - double height = MediaQuery.of(context).size.height; // Screen height - TaskwarriorColorTheme tColors = - Theme.of(context).extension()!; - return FutureBuilder>>( - future: fetchDailyInfo(), - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.waiting) { - return const Center(child: CircularProgressIndicator()); - } - - if (snapshot.hasError) { - return Center( - child: Text( - '${SentenceManager(currentLanguage: AppSettings.selectedLanguage).sentences.reportsError} ERR at 101: ${snapshot.error}')); - } - - Map> dailyInfo = snapshot.data ?? {}; - - return Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Expanded( - child: SizedBox( - height: height * 0.6, - child: SfCartesianChart( - primaryXAxis: CategoryAxis( - title: AxisTitle( - text: 'Day - Month', - textStyle: GoogleFonts.poppins( - fontWeight: TaskWarriorFonts.bold, - color: tColors.primaryTextColor, - fontSize: TaskWarriorFonts.fontSizeSmall, - ), - ), - ), - primaryYAxis: NumericAxis( - title: AxisTitle( - text: 'Tasks', - textStyle: GoogleFonts.poppins( - fontWeight: TaskWarriorFonts.bold, - fontSize: TaskWarriorFonts.fontSizeSmall, - color: tColors.primaryTextColor, - ), - ), - ), - tooltipBehavior: _dailyBurndownTooltipBehaviour, - series: [ - StackedColumnSeries( - groupName: 'Group A', - enableTooltip: true, - color: TaskWarriorColors.green, - dataSource: dailyInfo.entries - .map((entry) => ChartData( - entry.key, - entry.value['pending'] ?? 0, - entry.value['completed'] ?? 0, - )) - .toList(), - xValueMapper: (ChartData data, _) => data.x, - yValueMapper: (ChartData data, _) => data.y2, - name: 'Completed', - ), - StackedColumnSeries( - groupName: 'Group A', - color: TaskWarriorColors.yellow, - enableTooltip: true, - dataSource: dailyInfo.entries - .map((entry) => ChartData( - entry.key, - entry.value['pending'] ?? 0, - entry.value['completed'] ?? 0, - )) - .toList(), - xValueMapper: (ChartData data, _) => data.x, - yValueMapper: (ChartData data, _) => data.y1, - name: 'Pending', - ), - ], - ), - ), - ), - const CommonChartIndicator( - title: 'Daily Burndown Chart', - ), - ], - ); - }, - ); - } -} diff --git a/lib/app/modules/reports/views/burn_down_monthly.dart b/lib/app/modules/reports/views/burn_down_monthly.dart deleted file mode 100644 index db1c2b55..00000000 --- a/lib/app/modules/reports/views/burn_down_monthly.dart +++ /dev/null @@ -1,102 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:get/get.dart'; -import 'package:syncfusion_flutter_charts/charts.dart'; -import 'package:taskwarrior/app/models/chart.dart'; -import 'package:taskwarrior/app/modules/reports/controllers/reports_controller.dart'; -import 'package:taskwarrior/app/modules/reports/views/common_chart_indicator.dart'; -import 'package:taskwarrior/app/utils/constants/taskwarrior_fonts.dart'; -import 'package:taskwarrior/app/utils/gen/fonts.gen.dart'; -import 'package:taskwarrior/app/utils/language/sentence_manager.dart'; -import 'package:taskwarrior/app/utils/app_settings/app_settings.dart'; -import 'package:taskwarrior/app/utils/themes/theme_extension.dart'; - -class BurnDownMonthly extends StatelessWidget { - final ReportsController reportsController; - const BurnDownMonthly({super.key, required this.reportsController}); - - @override - Widget build(BuildContext context) { - var height = Get.height; - TaskwarriorColorTheme tColors = Theme.of(context).extension()!; - return Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Expanded( - child: SizedBox( - height: height * 0.6, - child: SfCartesianChart( - primaryXAxis: CategoryAxis( - title: AxisTitle( - text: SentenceManager( - currentLanguage: AppSettings.selectedLanguage) - .sentences - .reportsPageMonthlyMonthYear, - textStyle: TextStyle( - fontFamily: FontFamily.poppins, - fontWeight: TaskWarriorFonts.bold, - fontSize: TaskWarriorFonts.fontSizeSmall, - color: - tColors.primaryTextColor, - )), - ), - primaryYAxis: NumericAxis( - title: AxisTitle( - text: SentenceManager( - currentLanguage: AppSettings.selectedLanguage) - .sentences - .reportsPageTasks, - textStyle: TextStyle( - fontFamily: FontFamily.poppins, - fontWeight: TaskWarriorFonts.bold, - fontSize: TaskWarriorFonts.fontSizeSmall, - color: - tColors.primaryTextColor, - )), - ), - tooltipBehavior: - reportsController.monthlyBurndownTooltipBehaviour, - series: [ - StackedColumnSeries( - groupName: 'Group A', - enableTooltip: true, - color: Colors.green, - dataSource: reportsController.monthlyInfo.entries - .map((entry) => ChartData( - entry.key, - entry.value['pending'] ?? 0, - entry.value['completed'] ?? 0, - )) - .toList(), - xValueMapper: (ChartData data, _) => data.x, - yValueMapper: (ChartData data, _) => data.y2, - name: 'Completed', - ), - StackedColumnSeries( - groupName: 'Group A', - color: Colors.yellow, - enableTooltip: true, - dataSource: reportsController.monthlyInfo.entries - .map((entry) => ChartData( - entry.key, - entry.value['pending'] ?? 0, - entry.value['completed'] ?? 0, - )) - .toList(), - xValueMapper: (ChartData data, _) => data.x, - yValueMapper: (ChartData data, _) => data.y1, - name: 'Pending', - ), - ], - ), - ), - ), - CommonChartIndicator( - title: SentenceManager(currentLanguage: AppSettings.selectedLanguage) - .sentences - .reportsPageMonthlyBurnDownChart, - ), - ], - ); - } -} diff --git a/lib/app/modules/reports/views/burn_down_monthly_replica.dart b/lib/app/modules/reports/views/burn_down_monthly_replica.dart deleted file mode 100644 index 3f4c3aad..00000000 --- a/lib/app/modules/reports/views/burn_down_monthly_replica.dart +++ /dev/null @@ -1,190 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:google_fonts/google_fonts.dart'; -import 'package:syncfusion_flutter_charts/charts.dart'; -import 'package:taskwarrior/app/models/chart.dart'; -import 'package:taskwarrior/app/modules/reports/views/common_chart_indicator.dart'; -import 'package:taskwarrior/app/utils/app_settings/app_settings.dart'; -import 'package:taskwarrior/app/utils/constants/taskwarrior_colors.dart'; -import 'package:taskwarrior/app/utils/constants/taskwarrior_fonts.dart'; -import 'package:taskwarrior/app/utils/constants/utilites.dart'; -import 'package:taskwarrior/app/utils/themes/theme_extension.dart'; -import 'package:taskwarrior/app/utils/language/sentence_manager.dart'; -import 'package:taskwarrior/app/v3/champion/models/task_for_replica.dart'; -import 'package:taskwarrior/app/v3/champion/replica.dart'; - -class BurnDownMonthlyReplica extends StatelessWidget { - BurnDownMonthlyReplica({super.key}); - - final _monthlyBurndownTooltipBehaviour = TooltipBehavior( - enable: true, - builder: (dynamic data, dynamic point, dynamic series, int pointIndex, - int seriesIndex) { - final String monthYear = data.x; - final int pendingCount = data.y1; - final int completedCount = data.y2; - - return Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(5), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - '${SentenceManager(currentLanguage: AppSettings.selectedLanguage).sentences.reportsMonthYear}: $monthYear', - style: const TextStyle( - fontWeight: TaskWarriorFonts.bold, - ), - ), - Text( - '${SentenceManager(currentLanguage: AppSettings.selectedLanguage).sentences.reportsPending}: $pendingCount', - ), - Text( - '${SentenceManager(currentLanguage: AppSettings.selectedLanguage).sentences.reportsCompleted}: $completedCount', - ), - ], - ), - ); - }, - ); - - Future>> fetchMonthlyInfo() async { - // Use the Replica class to fetch tasks - List tasks = await Replica.getAllTasksFromReplica(); - return sortBurnDownMonthly(tasks); - } - - Map> sortBurnDownMonthly( - List allData) { - Map> monthlyInfo = {}; - - // Sort allData by modified date in ascending order - allData.sort((a, b) => (a.modified ?? 0).compareTo(b.modified ?? 0)); - - for (int i = 0; i < allData.length; i++) { - final int? modifiedTimestamp = allData[i].modified; - if (modifiedTimestamp == null) continue; - - final DateTime modifiedDate = DateTime.fromMillisecondsSinceEpoch( - modifiedTimestamp * 1000, - isUtc: true); - - // We use .toLocal() to get the date in the user's timezone for monthly grouping - final String monthYear = - '${Utils.getMonthName(modifiedDate.toLocal().month)} ${modifiedDate.toLocal().year}'; - - if (monthlyInfo.containsKey(monthYear)) { - if (allData[i].status == 'pending') { - monthlyInfo[monthYear]!['pending'] = - (monthlyInfo[monthYear]!['pending'] ?? 0) + 1; - } else if (allData[i].status == 'completed') { - monthlyInfo[monthYear]!['completed'] = - (monthlyInfo[monthYear]!['completed'] ?? 0) + 1; - } - } else { - monthlyInfo[monthYear] = { - 'pending': allData[i].status == 'pending' ? 1 : 0, - 'completed': allData[i].status == 'completed' ? 1 : 0, - }; - } - } - - debugPrint("monthlyInfo: $monthlyInfo"); - return monthlyInfo; - } - - @override - Widget build(BuildContext context) { - final double height = MediaQuery.of(context).size.height; - TaskwarriorColorTheme tColors = - Theme.of(context).extension()!; - return FutureBuilder>>( - future: fetchMonthlyInfo(), - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.waiting) { - return const Center(child: CircularProgressIndicator()); - } - - if (snapshot.hasError) { - return Center( - child: Text( - '${SentenceManager(currentLanguage: AppSettings.selectedLanguage).sentences.reportsError}: ${snapshot.error}')); - } - - Map> monthlyInfo = snapshot.data ?? {}; - return Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Expanded( - child: SizedBox( - height: height * 0.6, - child: SfCartesianChart( - primaryXAxis: CategoryAxis( - title: AxisTitle( - text: 'Month - Year (Modified Date)', - textStyle: GoogleFonts.poppins( - fontWeight: TaskWarriorFonts.bold, - fontSize: TaskWarriorFonts.fontSizeSmall, - color: tColors.primaryTextColor, - ), - ), - ), - primaryYAxis: NumericAxis( - title: AxisTitle( - text: 'Tasks', - textStyle: GoogleFonts.poppins( - fontWeight: TaskWarriorFonts.bold, - fontSize: TaskWarriorFonts.fontSizeSmall, - color: tColors.primaryTextColor, - ), - ), - ), - tooltipBehavior: _monthlyBurndownTooltipBehaviour, - series: [ - StackedColumnSeries( - groupName: 'Group A', - enableTooltip: true, - color: TaskWarriorColors.green, - dataSource: monthlyInfo.entries - .map((entry) => ChartData( - entry.key, - entry.value['pending'] ?? 0, - entry.value['completed'] ?? 0, - )) - .toList(), - xValueMapper: (ChartData data, _) => data.x, - yValueMapper: (ChartData data, _) => data.y2, - name: 'Completed', - ), - StackedColumnSeries( - groupName: 'Group A', - color: TaskWarriorColors.yellow, - enableTooltip: true, - dataSource: monthlyInfo.entries - .map((entry) => ChartData( - entry.key, - entry.value['pending'] ?? 0, - entry.value['completed'] ?? 0, - )) - .toList(), - xValueMapper: (ChartData data, _) => data.x, - yValueMapper: (ChartData data, _) => data.y1, - name: 'Pending', - ), - ], - ), - ), - ), - const CommonChartIndicator( - title: 'Monthly Burndown Chart (Replica)', - ), - ], - ); - }, - ); - } -} diff --git a/lib/app/modules/reports/views/burn_down_monthly_taskc.dart b/lib/app/modules/reports/views/burn_down_monthly_taskc.dart deleted file mode 100644 index 8ceeb20e..00000000 --- a/lib/app/modules/reports/views/burn_down_monthly_taskc.dart +++ /dev/null @@ -1,189 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:google_fonts/google_fonts.dart'; -import 'package:syncfusion_flutter_charts/charts.dart'; -import 'package:taskwarrior/app/models/chart.dart'; -import 'package:taskwarrior/app/modules/reports/views/common_chart_indicator.dart'; -import 'package:taskwarrior/app/utils/app_settings/app_settings.dart'; -import 'package:taskwarrior/app/utils/constants/taskwarrior_colors.dart'; -import 'package:taskwarrior/app/utils/constants/taskwarrior_fonts.dart'; -import 'package:taskwarrior/app/utils/constants/utilites.dart'; -import 'package:taskwarrior/app/utils/themes/theme_extension.dart'; -import 'package:taskwarrior/app/utils/language/sentence_manager.dart'; -import 'package:taskwarrior/app/v3/db/task_database.dart'; -import 'package:taskwarrior/app/v3/models/task.dart'; - -class BurnDownMonthlyTaskc extends StatelessWidget { - BurnDownMonthlyTaskc({super.key}); - - final _monthlyBurndownTooltipBehaviour = TooltipBehavior( - enable: true, - builder: (dynamic data, dynamic point, dynamic series, int pointIndex, - int seriesIndex) { - final String monthYear = data.x; - final int pendingCount = data.y1; - final int completedCount = data.y2; - - return Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(5), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - '${SentenceManager(currentLanguage: AppSettings.selectedLanguage).sentences.reportsMonthYear}: $monthYear', - style: const TextStyle( - fontWeight: TaskWarriorFonts.bold, - ), - ), - Text( - '${SentenceManager(currentLanguage: AppSettings.selectedLanguage).sentences.reportsPending}: $pendingCount', - ), - Text( - '${SentenceManager(currentLanguage: AppSettings.selectedLanguage).sentences.reportsCompleted}: $completedCount', - ), - ], - ), - ); - }, - ); - - Future>> fetchMonthlyInfo() async { - TaskDatabase taskDatabase = TaskDatabase(); - await taskDatabase.open(); - List tasks = await taskDatabase.fetchTasksFromDatabase(); - return sortBurnDownMonthly(tasks); - } - - Map> sortBurnDownMonthly(List allData) { - Map> monthlyInfo = {}; - - allData.sort((a, b) => a.entry.compareTo(b.entry)); - - for (int i = 0; i < allData.length; i++) { - final DateTime entryDate; - try { - entryDate = DateTime.parse(allData[i].entry); - } catch (e) { - debugPrint( - 'Error parsing date for task ID ${allData[i].id}: ${e.toString()}'); - continue; // Skip this task if date parsing fails - } - final String monthYear = - '${Utils.getMonthName(entryDate.month)} ${entryDate.year}'; - - if (monthlyInfo.containsKey(monthYear)) { - if (allData[i].status == 'pending') { - monthlyInfo[monthYear]!['pending'] = - (monthlyInfo[monthYear]!['pending'] ?? 0) + 1; - } else if (allData[i].status == 'completed') { - monthlyInfo[monthYear]!['completed'] = - (monthlyInfo[monthYear]!['completed'] ?? 0) + 1; - } - } else { - monthlyInfo[monthYear] = { - 'pending': allData[i].status == 'pending' ? 1 : 0, - 'completed': allData[i].status == 'completed' ? 1 : 0, - }; - } - } - - debugPrint("monthlyInfo: $monthlyInfo"); - return monthlyInfo; - } - - @override - Widget build(BuildContext context) { - final double height = MediaQuery.of(context).size.height; - TaskwarriorColorTheme tColors = - Theme.of(context).extension()!; - return FutureBuilder>>( - future: fetchMonthlyInfo(), - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.waiting) { - return const Center(child: CircularProgressIndicator()); - } - - if (snapshot.hasError) { - return Center( - child: Text( - '${SentenceManager(currentLanguage: AppSettings.selectedLanguage).sentences.reportsError}: ${snapshot.error}')); - } - - Map> monthlyInfo = snapshot.data ?? {}; - return Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Expanded( - child: SizedBox( - height: height * 0.6, - child: SfCartesianChart( - primaryXAxis: CategoryAxis( - title: AxisTitle( - text: 'Month - Year', - textStyle: GoogleFonts.poppins( - fontWeight: TaskWarriorFonts.bold, - fontSize: TaskWarriorFonts.fontSizeSmall, - color: tColors.primaryTextColor, - ), - ), - ), - primaryYAxis: NumericAxis( - title: AxisTitle( - text: 'Tasks', - textStyle: GoogleFonts.poppins( - fontWeight: TaskWarriorFonts.bold, - fontSize: TaskWarriorFonts.fontSizeSmall, - color: tColors.primaryTextColor, - ), - ), - ), - tooltipBehavior: _monthlyBurndownTooltipBehaviour, - series: [ - StackedColumnSeries( - groupName: 'Group A', - enableTooltip: true, - color: TaskWarriorColors.green, - dataSource: monthlyInfo.entries - .map((entry) => ChartData( - entry.key, - entry.value['pending'] ?? 0, - entry.value['completed'] ?? 0, - )) - .toList(), - xValueMapper: (ChartData data, _) => data.x, - yValueMapper: (ChartData data, _) => data.y2, - name: 'Completed', - ), - StackedColumnSeries( - groupName: 'Group A', - color: TaskWarriorColors.yellow, - enableTooltip: true, - dataSource: monthlyInfo.entries - .map((entry) => ChartData( - entry.key, - entry.value['pending'] ?? 0, - entry.value['completed'] ?? 0, - )) - .toList(), - xValueMapper: (ChartData data, _) => data.x, - yValueMapper: (ChartData data, _) => data.y1, - name: 'Pending', - ), - ], - ), - ), - ), - const CommonChartIndicator( - title: 'Monthly Burndown Chart', - ), - ], - ); - }, - ); - } -} diff --git a/lib/app/modules/reports/views/burn_down_weekly.dart b/lib/app/modules/reports/views/burn_down_weekly.dart deleted file mode 100644 index 2eee2254..00000000 --- a/lib/app/modules/reports/views/burn_down_weekly.dart +++ /dev/null @@ -1,119 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:get/get.dart'; -import 'package:syncfusion_flutter_charts/charts.dart'; -import 'package:taskwarrior/app/models/chart.dart'; -import 'package:taskwarrior/app/modules/reports/controllers/reports_controller.dart'; -import 'package:taskwarrior/app/modules/reports/views/common_chart_indicator.dart'; -import 'package:taskwarrior/app/utils/constants/taskwarrior_colors.dart'; -import 'package:taskwarrior/app/utils/constants/taskwarrior_fonts.dart'; -import 'package:taskwarrior/app/utils/constants/utilites.dart'; -import 'package:taskwarrior/app/utils/gen/fonts.gen.dart'; -import 'package:taskwarrior/app/utils/language/sentence_manager.dart'; -import 'package:taskwarrior/app/utils/app_settings/app_settings.dart'; -import 'package:taskwarrior/app/utils/themes/theme_extension.dart'; - -class BurnDownWeekly extends StatelessWidget { - final ReportsController reportsController; - const BurnDownWeekly({super.key, required this.reportsController}); - - @override - Widget build(BuildContext context) { - TaskwarriorColorTheme tColors = Theme.of(context).extension()!; - var height = Get.height; - return Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Expanded( - child: SizedBox( - height: height * 0.6, - child: SfCartesianChart( - primaryXAxis: CategoryAxis( - title: AxisTitle( - text: SentenceManager( - currentLanguage: AppSettings.selectedLanguage) - .sentences - .reportsPageWeeklyWeeksYear, - textStyle: TextStyle( - fontFamily: FontFamily.poppins, - fontWeight: TaskWarriorFonts.bold, - fontSize: TaskWarriorFonts.fontSizeSmall, - color: - tColors.primaryTextColor, - )), - ), - primaryYAxis: NumericAxis( - title: AxisTitle( - text: SentenceManager( - currentLanguage: AppSettings.selectedLanguage) - .sentences - .reportsPageTasks, - textStyle: TextStyle( - fontFamily: FontFamily.poppins, - fontWeight: TaskWarriorFonts.bold, - fontSize: TaskWarriorFonts.fontSizeSmall, - color: - tColors.primaryTextColor, - )), - ), - tooltipBehavior: reportsController.weeklyBurndownTooltipBehaviour, - series: [ - ///this is the completed tasks - StackedColumnSeries( - groupName: 'Group A', - enableTooltip: true, - color: TaskWarriorColors.green, - dataSource: reportsController.allData - .map((task) => ChartData( - 'Week ${Utils.getWeekNumbertoInt(task.entry)}, ${task.entry.year}', - reportsController.weeklyInfo[ - Utils.getWeekNumbertoInt(task.entry)] - ?['pending'] ?? - 0, - reportsController.weeklyInfo[ - Utils.getWeekNumbertoInt(task.entry)] - ?['completed'] ?? - 0, - )) - .toList(), - xValueMapper: (ChartData data, _) => data.x, - yValueMapper: (ChartData data, _) => data.y2, - name: 'Completed', - ), - - ///this is the pending tasks - StackedColumnSeries( - groupName: 'Group A', - color: TaskWarriorColors.yellow, - enableTooltip: true, - dataSource: reportsController.allData - .map((task) => ChartData( - 'Week ${Utils.getWeekNumbertoInt(task.entry)}, ${task.entry.year}', - reportsController.weeklyInfo[ - Utils.getWeekNumbertoInt(task.entry)] - ?['pending'] ?? - 0, - reportsController.weeklyInfo[ - Utils.getWeekNumbertoInt(task.entry)] - ?['completed'] ?? - 0, - )) - .toList(), - xValueMapper: (ChartData data, _) => data.x, - yValueMapper: (ChartData data, _) => data.y1, - name: 'Pending', - ), - ], - ), - ), - ), - CommonChartIndicator( - title: SentenceManager(currentLanguage: AppSettings.selectedLanguage) - .sentences - .reportsPageWeeklyBurnDownChart, - ), - ], - ); - } -} diff --git a/lib/app/modules/reports/views/burn_down_weekly_replica.dart b/lib/app/modules/reports/views/burn_down_weekly_replica.dart deleted file mode 100644 index 97858af9..00000000 --- a/lib/app/modules/reports/views/burn_down_weekly_replica.dart +++ /dev/null @@ -1,191 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:google_fonts/google_fonts.dart'; -import 'package:syncfusion_flutter_charts/charts.dart'; -import 'package:taskwarrior/app/models/chart.dart'; -import 'package:taskwarrior/app/modules/reports/views/common_chart_indicator.dart'; -import 'package:taskwarrior/app/utils/app_settings/app_settings.dart'; -import 'package:taskwarrior/app/utils/constants/taskwarrior_colors.dart'; -import 'package:taskwarrior/app/utils/constants/taskwarrior_fonts.dart'; -import 'package:taskwarrior/app/utils/constants/utilites.dart'; -import 'package:taskwarrior/app/utils/themes/theme_extension.dart'; -import 'package:taskwarrior/app/utils/language/sentence_manager.dart'; -import 'package:taskwarrior/app/v3/champion/models/task_for_replica.dart'; -import 'package:taskwarrior/app/v3/champion/replica.dart'; - -class BurnDownWeeklyReplica extends StatelessWidget { - BurnDownWeeklyReplica({super.key}); - - final TooltipBehavior _weeklyBurndownTooltipBehaviour = TooltipBehavior( - enable: true, - builder: (dynamic data, dynamic point, dynamic series, int pointIndex, - int seriesIndex) { - final String weekNumber = data.x; - final int pendingCount = data.y1; - final int completedCount = data.y2; - - return Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(5), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - weekNumber, - style: const TextStyle( - fontWeight: TaskWarriorFonts.bold, - ), - ), - Text( - '${SentenceManager(currentLanguage: AppSettings.selectedLanguage).sentences.reportsPending}: $pendingCount', - ), - Text( - '${SentenceManager(currentLanguage: AppSettings.selectedLanguage).sentences.reportsCompleted}: $completedCount', - ), - ], - ), - ); - }, - ); - - Future>> fetchWeeklyInfo() async { - // Use the Replica class to fetch tasks - List tasks = await Replica.getAllTasksFromReplica(); - return sortBurnDownWeekly(tasks); - } - - Map> sortBurnDownWeekly( - List allData) { - Map> weeklyInfo = {}; - - // Sort allData by modified date in ascending order - allData.sort((a, b) => (a.modified ?? 0).compareTo(b.modified ?? 0)); - - for (int i = 0; i < allData.length; i++) { - final int? modifiedTimestamp = allData[i].modified; - if (modifiedTimestamp == null) continue; - - final DateTime modifiedDate = DateTime.fromMillisecondsSinceEpoch( - modifiedTimestamp * 1000, - isUtc: true); - - final int weekNumber = Utils.getWeekNumbertoInt(modifiedDate.toLocal()); - - if (weeklyInfo.containsKey(weekNumber.toString())) { - if (allData[i].status == 'pending') { - weeklyInfo[weekNumber.toString()]!['pending'] = - (weeklyInfo[weekNumber.toString()]!['pending'] ?? 0) + 1; - } else if (allData[i].status == 'completed') { - weeklyInfo[weekNumber.toString()]!['completed'] = - (weeklyInfo[weekNumber.toString()]!['completed'] ?? 0) + 1; - } - } else { - weeklyInfo[weekNumber.toString()] = { - 'pending': allData[i].status == 'pending' ? 1 : 0, - 'completed': allData[i].status == 'completed' ? 1 : 0, - }; - } - } - - debugPrint("weeklyInfo $weeklyInfo"); - return weeklyInfo; - } - - @override - Widget build(BuildContext context) { - TaskwarriorColorTheme tColors = - Theme.of(context).extension()!; - double height = MediaQuery.of(context).size.height; // Screen height - return FutureBuilder>>( - future: fetchWeeklyInfo(), - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.waiting) { - return const Center(child: CircularProgressIndicator()); - } - - if (snapshot.hasError) { - return Center( - child: Text( - '${SentenceManager(currentLanguage: AppSettings.selectedLanguage).sentences.reportsError}: ${snapshot.error}')); - } - - Map> weeklyInfo = snapshot.data ?? {}; - - return Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Expanded( - child: SizedBox( - height: height * 0.6, - child: SfCartesianChart( - primaryXAxis: CategoryAxis( - title: AxisTitle( - text: 'Weeks - Year (Modified Date)', - textStyle: GoogleFonts.poppins( - fontWeight: TaskWarriorFonts.bold, - fontSize: TaskWarriorFonts.fontSizeSmall, - color: tColors.primaryTextColor, - ), - ), - ), - primaryYAxis: NumericAxis( - title: AxisTitle( - text: 'Tasks', - textStyle: GoogleFonts.poppins( - fontWeight: TaskWarriorFonts.bold, - color: tColors.primaryTextColor, - fontSize: TaskWarriorFonts.fontSizeSmall, - ), - ), - ), - tooltipBehavior: _weeklyBurndownTooltipBehaviour, - series: [ - ///this is the completed tasks - StackedColumnSeries( - groupName: 'Group A', - enableTooltip: true, - color: TaskWarriorColors.green, - dataSource: weeklyInfo.entries - .map((entry) => ChartData( - 'Week ${entry.key}', - entry.value['pending'] ?? 0, - entry.value['completed'] ?? 0, - )) - .toList(), - xValueMapper: (ChartData data, _) => data.x, - yValueMapper: (ChartData data, _) => data.y2, - name: 'Completed', - ), - - ///this is the pending tasks - StackedColumnSeries( - groupName: 'Group A', - color: TaskWarriorColors.yellow, - enableTooltip: true, - dataSource: weeklyInfo.entries - .map((entry) => ChartData( - 'Week ${entry.key}', - entry.value['pending'] ?? 0, - entry.value['completed'] ?? 0, - )) - .toList(), - xValueMapper: (ChartData data, _) => data.x, - yValueMapper: (ChartData data, _) => data.y1, - name: 'Pending', - ), - ], - ), - ), - ), - const CommonChartIndicator( - title: 'Weekly Burndown Chart (Replica)', - ), - ], - ); - }); - } -} diff --git a/lib/app/modules/reports/views/burn_down_weekly_taskc.dart b/lib/app/modules/reports/views/burn_down_weekly_taskc.dart deleted file mode 100644 index 823551d6..00000000 --- a/lib/app/modules/reports/views/burn_down_weekly_taskc.dart +++ /dev/null @@ -1,199 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:google_fonts/google_fonts.dart'; -import 'package:syncfusion_flutter_charts/charts.dart'; -import 'package:taskwarrior/app/models/chart.dart'; -import 'package:taskwarrior/app/modules/reports/views/common_chart_indicator.dart'; -import 'package:taskwarrior/app/utils/app_settings/app_settings.dart'; -import 'package:taskwarrior/app/utils/constants/taskwarrior_colors.dart'; -import 'package:taskwarrior/app/utils/constants/taskwarrior_fonts.dart'; -import 'package:taskwarrior/app/utils/constants/utilites.dart'; -import 'package:taskwarrior/app/utils/themes/theme_extension.dart'; -import 'package:taskwarrior/app/utils/language/sentence_manager.dart'; -import 'package:taskwarrior/app/v3/db/task_database.dart'; -import 'package:taskwarrior/app/v3/models/task.dart'; - -class BurnDownWeeklyTask extends StatelessWidget { - BurnDownWeeklyTask({super.key}); - - final TooltipBehavior _weeklyBurndownTooltipBehaviour = TooltipBehavior( - enable: true, - builder: (dynamic data, dynamic point, dynamic series, int pointIndex, - int seriesIndex) { - final String weekNumber = data.x; - final int pendingCount = data.y1; - final int completedCount = data.y2; - - return Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(5), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - weekNumber, - style: const TextStyle( - fontWeight: TaskWarriorFonts.bold, - ), - ), - Text( - '${SentenceManager(currentLanguage: AppSettings.selectedLanguage).sentences.reportsPending}: $pendingCount', - ), - Text( - '${SentenceManager(currentLanguage: AppSettings.selectedLanguage).sentences.reportsCompleted}: $completedCount', - ), - ], - ), - ); - }, - ); - - Future>> fetchWeeklyInfo() async { - TaskDatabase taskDatabase = TaskDatabase(); - await taskDatabase.open(); - List tasks = await taskDatabase.fetchTasksFromDatabase(); - return sortBurnDownWeekly(tasks); - } - - Map> sortBurnDownWeekly(List allData) { - // Initialize weeklyInfo map - Map> weeklyInfo = {}; - - // Sort allData by entry date in ascending order - allData.sort((a, b) => a.entry.compareTo(b.entry)); - - ///loop through allData and get the week number - for (int i = 0; i < allData.length; i++) { - final int weekNumber; - try { - weekNumber = Utils.getWeekNumbertoInt(DateTime.parse(allData[i].entry)); - } catch (e) { - debugPrint( - 'Error parsing date for task ID ${allData[i].id}: ${e.toString()}'); - continue; // Skip this task if date parsing fails - } - - ///check if weeklyInfo contains the week number - if (weeklyInfo.containsKey(weekNumber.toString())) { - ///check if the status is pending or completed - if (allData[i].status == 'pending') { - ///if the status is pending then add 1 to the pending count - weeklyInfo[weekNumber.toString()]!['pending'] = - (weeklyInfo[weekNumber.toString()]!['pending'] ?? 0) + 1; - } else if (allData[i].status == 'completed') { - ///if the status is completed then add 1 to the completed count - weeklyInfo[weekNumber.toString()]!['completed'] = - (weeklyInfo[weekNumber.toString()]!['completed'] ?? 0) + 1; - } - } else { - ///if weeklyInfo does not contain the week number - // ignore: collection_methods_unrelated_type - weeklyInfo[weekNumber.toString()] = { - 'pending': allData[i].status == 'pending' ? 1 : 0, - 'completed': allData[i].status == 'completed' ? 1 : 0, - }; - } - } - - debugPrint("weeklyInfo $weeklyInfo"); - return weeklyInfo; - } - - @override - Widget build(BuildContext context) { - TaskwarriorColorTheme tColors = - Theme.of(context).extension()!; - double height = MediaQuery.of(context).size.height; // Screen height - return FutureBuilder>>( - future: fetchWeeklyInfo(), - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.waiting) { - return const Center(child: CircularProgressIndicator()); - } - - if (snapshot.hasError) { - return Center( - child: Text( - '${SentenceManager(currentLanguage: AppSettings.selectedLanguage).sentences.reportsError}: ${snapshot.error}')); - } - - Map> weeklyInfo = snapshot.data ?? {}; - - return Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Expanded( - child: SizedBox( - height: height * 0.6, - child: SfCartesianChart( - primaryXAxis: CategoryAxis( - title: AxisTitle( - text: 'Weeks - Year', - textStyle: GoogleFonts.poppins( - fontWeight: TaskWarriorFonts.bold, - fontSize: TaskWarriorFonts.fontSizeSmall, - color: tColors.primaryTextColor, - ), - ), - ), - primaryYAxis: NumericAxis( - title: AxisTitle( - text: 'Tasks', - textStyle: GoogleFonts.poppins( - fontWeight: TaskWarriorFonts.bold, - color: tColors.primaryTextColor, - fontSize: TaskWarriorFonts.fontSizeSmall, - ), - ), - ), - tooltipBehavior: _weeklyBurndownTooltipBehaviour, - series: [ - ///this is the completed tasks - StackedColumnSeries( - groupName: 'Group A', - enableTooltip: true, - color: TaskWarriorColors.green, - dataSource: weeklyInfo.entries - .map((entry) => ChartData( - 'Week ${entry.key}', - entry.value['pending'] ?? 0, - entry.value['completed'] ?? 0, - )) - .toList(), - xValueMapper: (ChartData data, _) => data.x, - yValueMapper: (ChartData data, _) => data.y2, - name: 'Completed', - ), - - ///this is the pending tasks - StackedColumnSeries( - groupName: 'Group A', - color: TaskWarriorColors.yellow, - enableTooltip: true, - dataSource: weeklyInfo.entries - .map((entry) => ChartData( - 'Week ${entry.key}', - entry.value['pending'] ?? 0, - entry.value['completed'] ?? 0, - )) - .toList(), - xValueMapper: (ChartData data, _) => data.x, - yValueMapper: (ChartData data, _) => data.y1, - name: 'Pending', - ), - ], - ), - ), - ), - const CommonChartIndicator( - title: 'Weekly Burndown Chart', - ), - ], - ); - }); - } -} diff --git a/lib/app/modules/reports/views/reports_view.dart b/lib/app/modules/reports/views/reports_view.dart index 5eb03f8a..1b1f56fd 100644 --- a/lib/app/modules/reports/views/reports_view.dart +++ b/lib/app/modules/reports/views/reports_view.dart @@ -2,9 +2,8 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:google_fonts/google_fonts.dart'; -import 'package:taskwarrior/app/modules/reports/views/burn_down_daily.dart'; -import 'package:taskwarrior/app/modules/reports/views/burn_down_monthly.dart'; -import 'package:taskwarrior/app/modules/reports/views/burn_down_weekly.dart'; +import 'package:taskwarrior/app/modules/reports/burn_down_data.dart'; +import 'package:taskwarrior/app/modules/reports/views/burn_down_chart.dart'; import 'package:taskwarrior/app/utils/constants/constants.dart'; import 'package:taskwarrior/app/utils/gen/fonts.gen.dart'; import 'package:taskwarrior/app/utils/language/sentence_manager.dart'; @@ -130,20 +129,24 @@ class ReportsView extends GetView { ), ], ) - : TabBarView( - controller: controller.tabController, - children: [ - BurnDownDaily( - reportsController: controller, - ), - BurnDownWeekly( - reportsController: controller, - ), - BurnDownMonthly( - reportsController: controller, - ), - ], - ), + : Builder(builder: (context) { + // The local path buckets by `entry`, which is already a + // DateTime on this model, matching the controller's previous + // sortBurnDown* behaviour. + final entries = controller.allData + .map((t) => BurnDownEntry( + date: t.entry, + status: t.status, + )) + .toList(); + return TabBarView( + controller: controller.tabController, + children: [ + for (final period in BurnDownPeriod.values) + BurnDownChart(entries: entries, period: period), + ], + ); + }), ), ); } diff --git a/lib/app/modules/reports/views/reports_view_replica.dart b/lib/app/modules/reports/views/reports_view_replica.dart index 0a66345b..3c51f9fb 100644 --- a/lib/app/modules/reports/views/reports_view_replica.dart +++ b/lib/app/modules/reports/views/reports_view_replica.dart @@ -2,9 +2,8 @@ import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:get/get.dart'; import 'package:taskwarrior/app/modules/reports/controllers/reports_controller.dart'; -import 'package:taskwarrior/app/modules/reports/views/burn_down_daily_replica.dart'; -import 'package:taskwarrior/app/modules/reports/views/burn_down_monthly_replica.dart'; -import 'package:taskwarrior/app/modules/reports/views/burn_down_weekly_replica.dart'; +import 'package:taskwarrior/app/modules/reports/burn_down_data.dart'; +import 'package:taskwarrior/app/modules/reports/views/burn_down_chart.dart'; import 'package:taskwarrior/app/utils/app_settings/app_settings.dart'; import 'package:taskwarrior/app/utils/constants/taskwarrior_colors.dart'; import 'package:taskwarrior/app/utils/constants/taskwarrior_fonts.dart'; @@ -149,14 +148,33 @@ class ReportsHomeReplica extends StatelessWidget { ), ], ) - : TabBarView( - controller: reportsController.tabController, - children: [ - BurnDownDailyReplica(), - BurnDownWeeklyReplica(), - BurnDownMonthlyReplica(), - ], - ), + : Builder(builder: (context) { + // Reuse the tasks this screen already fetched instead of + // each chart re-fetching them. Replica charts bucket by + // `modified` (epoch seconds), as they did before. + final entries = allTasks + .where((t) => t.modified != null) + .map((t) => BurnDownEntry( + date: DateTime.fromMillisecondsSinceEpoch( + t.modified! * 1000, + isUtc: true) + .toLocal(), + status: t.status ?? '', + )) + .toList(); + return TabBarView( + controller: reportsController.tabController, + children: [ + for (final period in BurnDownPeriod.values) + BurnDownChart( + entries: entries, + period: period, + titleSuffix: ' (Replica)', + dateAxisSuffix: ' (Modified Date)', + ), + ], + ); + }), ); }, ); diff --git a/lib/app/modules/reports/views/reports_view_taskc.dart b/lib/app/modules/reports/views/reports_view_taskc.dart index e5f457c3..63080acb 100644 --- a/lib/app/modules/reports/views/reports_view_taskc.dart +++ b/lib/app/modules/reports/views/reports_view_taskc.dart @@ -2,9 +2,8 @@ import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:get/get.dart'; import 'package:taskwarrior/app/modules/reports/controllers/reports_controller.dart'; -import 'package:taskwarrior/app/modules/reports/views/burn_down_daily_taskc.dart'; -import 'package:taskwarrior/app/modules/reports/views/burn_down_monthly_taskc.dart'; -import 'package:taskwarrior/app/modules/reports/views/burn_down_weekly_taskc.dart'; +import 'package:taskwarrior/app/modules/reports/burn_down_data.dart'; +import 'package:taskwarrior/app/modules/reports/views/burn_down_chart.dart'; import 'package:taskwarrior/app/utils/app_settings/app_settings.dart'; import 'package:taskwarrior/app/utils/constants/taskwarrior_colors.dart'; import 'package:taskwarrior/app/utils/constants/taskwarrior_fonts.dart'; @@ -145,14 +144,27 @@ class ReportsHomeTaskc extends StatelessWidget { ), ], ) - : TabBarView( - controller: reportsController.tabController, - children: [ - BurnDownDailyTaskc(), - BurnDownWeeklyTask(), - BurnDownMonthlyTaskc(), - ], - ), + : Builder(builder: (context) { + // Reuse the tasks this screen already fetched. The taskc + // charts bucketed by `entry` (an ISO/compact string); + // unparseable entries were skipped, as they are here. + final entries = []; + for (final t in allTasks) { + final parsed = DateTime.tryParse(t.entry); + if (parsed == null) continue; + entries.add(BurnDownEntry( + date: parsed, + status: t.status, + )); + } + return TabBarView( + controller: reportsController.tabController, + children: [ + for (final period in BurnDownPeriod.values) + BurnDownChart(entries: entries, period: period), + ], + ); + }), ); }, ); diff --git a/lib/app/modules/splash/controllers/splash_controller.dart b/lib/app/modules/splash/controllers/splash_controller.dart index a126ef0e..9d8c6b84 100644 --- a/lib/app/modules/splash/controllers/splash_controller.dart +++ b/lib/app/modules/splash/controllers/splash_controller.dart @@ -119,7 +119,15 @@ class SplashController extends GetxController { void changeModeTo(String profile, String mode) { _profiles.setModeTo(profile, mode); - selectProfile(currentProfile.value); + // Only refresh the live app state (HomeController's mode flags, task + // list, etc.) when the profile whose mode just changed is the one + // actually active. selectProfile() unconditionally clears + // HomeController.tasks as a side effect, so calling it for an unrelated, + // inactive profile would wipe the currently-active profile's visible + // task list even though nothing about it changed. + if (profile == currentProfile.value) { + selectProfile(profile); + } profilesMap.value = _profiles.profilesMap(); } diff --git a/lib/app/modules/taskc_details/controllers/taskc_details_controller.dart b/lib/app/modules/taskc_details/controllers/taskc_details_controller.dart index 6116cf7e..f925dba3 100644 --- a/lib/app/modules/taskc_details/controllers/taskc_details_controller.dart +++ b/lib/app/modules/taskc_details/controllers/taskc_details_controller.dart @@ -13,7 +13,6 @@ import 'package:taskwarrior/app/v3/models/annotation.dart'; import 'package:taskwarrior/app/v3/models/task.dart'; import 'package:taskwarrior/app/v3/champion/replica.dart'; import 'package:taskwarrior/app/v3/champion/models/task_for_replica.dart'; -import 'package:taskwarrior/app/v3/net/modify.dart'; enum UnsavedChangesAction { save, discard, cancel } @@ -35,6 +34,9 @@ class TaskcDetailsController extends GetxController { late RxString rtype; late RxString recur; late RxList annotations; + // Blocking state surfaced by the Rust serializer (replica tasks only). + late RxBool isBlocked; + late RxBool isBlocking; late RxList previousTags = [].obs; @override @@ -64,6 +66,8 @@ class TaskcDetailsController extends GetxController { rtype = "".obs; recur = "".obs; annotations = [].obs; + isBlocked = false.obs; + isBlocking = false.obs; } else if (task is TaskForReplica) { description = (task.description ?? '').obs; project = (task.project ?? 'None').obs; @@ -81,10 +85,13 @@ class TaskcDetailsController extends GetxController { ? task.tags!.map((e) => e.toString()).toList().obs : [].obs; previousTags = tags.toList().obs; - depends = "".split(",").obs; + // Attributes now surfaced by the Rust serializer. + depends = (task.depends ?? []).obs; rtype = "".obs; - recur = "".obs; - annotations = [].obs; + recur = (task.recur ?? "").obs; + annotations = (task.annotations ?? []).obs; + isBlocked = (task.isBlocked ?? false).obs; + isBlocking = (task.isBlocking ?? false).obs; } else { // Fallback description = ''.obs; @@ -100,6 +107,8 @@ class TaskcDetailsController extends GetxController { rtype = "".obs; recur = "".obs; annotations = [].obs; + isBlocked = false.obs; + isBlocking = false.obs; } } @@ -249,16 +258,6 @@ class TaskcDetailsController extends GetxController { hasChanges.value = false; debugPrint('Task saved in local DB ${description.string}'); processTagsLists(); - await modifyTaskOnTaskwarrior( - description.string, - project.string, - DateTime.parse(due.string).toIso8601String(), - priority.string, - status.string, - initialTask.uuid!, - initialTask.id.toString(), - tags.toList(), - ); } else if (initialTask is TaskForReplica) { debugPrint( 'Saving replica task changes... status ${status.string} ${tags.join(", ")}'); diff --git a/lib/app/modules/taskc_details/views/taskc_details_view.dart b/lib/app/modules/taskc_details/views/taskc_details_view.dart index 2203274f..ba2a12f3 100644 --- a/lib/app/modules/taskc_details/views/taskc_details_view.dart +++ b/lib/app/modules/taskc_details/views/taskc_details_view.dart @@ -111,6 +111,45 @@ class TaskcDetailsView extends GetView { controller.tags.join(', '), (value) => controller.updateListField(controller.tags, value), ), + // Attributes surfaced by the enriched Rust serializer (D2). + // Replica tasks only; read-only (the mobile UI cannot edit + // dependencies/annotations yet). + if (controller.isReplicaTask) ...[ + _buildDetail( + context, + 'Blocked:', + controller.isBlocked.value ? 'Yes' : 'No', + ), + _buildDetail( + context, + 'Blocking:', + controller.isBlocking.value ? 'Yes' : 'No', + ), + _buildDetail( + context, + 'Depends:', + controller.depends.isEmpty + ? 'None' + : controller.depends.join(', '), + ), + _buildDetail( + context, + 'Recur:', + controller.recur.value.isEmpty + ? 'None' + : controller.recur.value, + ), + _buildDetail( + context, + 'Annotations:', + controller.annotations.isEmpty + ? 'None' + : controller.annotations + .map((a) => + '• ${a.description ?? ''}${(a.entry != null && a.entry!.isNotEmpty) ? ' (${a.entry})' : ''}') + .join('\n'), + ), + ], if (controller.isLocalTask) ...[ _buildDetail( context, diff --git a/lib/app/routes/app_pages.dart b/lib/app/routes/app_pages.dart index 90ace627..7f638683 100644 --- a/lib/app/routes/app_pages.dart +++ b/lib/app/routes/app_pages.dart @@ -20,6 +20,8 @@ import '../modules/profile/bindings/profile_binding.dart'; import '../modules/profile/views/profile_view.dart'; import '../modules/reports/bindings/reports_binding.dart'; import '../modules/reports/views/reports_view.dart'; +import '../modules/report_engine/bindings/report_engine_binding.dart'; +import '../modules/report_engine/views/report_engine_view.dart'; import '../modules/settings/bindings/settings_binding.dart'; import '../modules/settings/views/settings_view.dart'; import '../modules/splash/bindings/splash_binding.dart'; @@ -77,6 +79,11 @@ class AppPages { page: () => const ReportsView(), binding: ReportsBinding(), ), + GetPage( + name: _Paths.REPORT_ENGINE, + page: () => const ReportEngineView(), + binding: ReportEngineBinding(), + ), GetPage( name: _Paths.SETTINGS, page: () => const SettingsView(), diff --git a/lib/app/routes/app_routes.dart b/lib/app/routes/app_routes.dart index 04490f3f..20018eb6 100644 --- a/lib/app/routes/app_routes.dart +++ b/lib/app/routes/app_routes.dart @@ -13,6 +13,7 @@ abstract class Routes { static const PROFILE = _Paths.PROFILE; static const ABOUT = _Paths.ABOUT; static const REPORTS = _Paths.REPORTS; + static const REPORT_ENGINE = _Paths.REPORT_ENGINE; static const SETTINGS = _Paths.SETTINGS; static const PERMISSION = _Paths.PERMISSION; static const MANAGE_TASK_CHAMPION_CREDS = _Paths.MANAGE_TASK_CHAMPION_CREDS; @@ -30,6 +31,7 @@ abstract class _Paths { static const PROFILE = '/profile'; static const ABOUT = '/about'; static const REPORTS = '/reports'; + static const REPORT_ENGINE = '/report-engine'; static const SETTINGS = '/settings'; static const PERMISSION = '/permission'; static const MANAGE_TASK_CHAMPION_CREDS = '/manage-task-champion-creds'; diff --git a/lib/app/services/report_service.dart b/lib/app/services/report_service.dart new file mode 100644 index 00000000..b648c785 --- /dev/null +++ b/lib/app/services/report_service.dart @@ -0,0 +1,181 @@ +import 'package:taskwarrior/app/models/report.dart'; +import 'package:taskwarrior/app/models/task_like.dart'; +import 'package:taskwarrior/app/models/task_urgency.dart'; +import 'package:taskwarrior/app/utils/taskchampion/virtual_filter_engine.dart'; + +/// The reporting engine (Issue #418): the default report catalogue plus the +/// executor that turns a [ReportDefinition] + a task list into a filtered, +/// sorted result. +class ReportService { + /// Taskwarrior's core default reports (those with a `report.*.sort`). + /// Filters use the [VirtualFilterEngine] vocabulary; note that under + /// TaskChampion a "waiting" task is a pending task with a future `wait` + /// (there is no distinct waiting status), so the waiting report uses the + /// `+WAITING` virtual tag rather than `status:waiting`. + static final List defaultReports = [ + ReportDefinition( + name: 'next', + description: 'Highest-urgency pending tasks', + filterExpression: 'status:pending', + sortCriteria: SortCriterion.parseList('urgency-'), + columns: ColumnSpec.parseList('id,description,urgency'), + ), + ReportDefinition( + name: 'active', + description: 'Tasks with a start date set', + filterExpression: 'status:pending +ACTIVE', + sortCriteria: SortCriterion.parseList('urgency-'), + columns: ColumnSpec.parseList('id,description,start'), + ), + ReportDefinition( + name: 'ready', + description: 'Pending, not waiting, not blocked', + filterExpression: 'status:pending +READY', + sortCriteria: SortCriterion.parseList('urgency-'), + columns: ColumnSpec.parseList('id,description,urgency'), + ), + ReportDefinition( + name: 'blocked', + description: 'Tasks with unresolved dependencies', + filterExpression: 'status:pending +BLOCKED', + sortCriteria: SortCriterion.parseList('urgency-'), + columns: ColumnSpec.parseList('id,description'), + ), + ReportDefinition( + name: 'waiting', + description: 'Deferred tasks with a wait date', + filterExpression: '+WAITING', + sortCriteria: SortCriterion.parseList('wait+'), + columns: ColumnSpec.parseList('id,description,wait'), + ), + ReportDefinition( + name: 'completed', + description: 'Finished tasks', + filterExpression: 'status:completed', + sortCriteria: SortCriterion.parseList('modified-'), + columns: ColumnSpec.parseList('id,description'), + ), + ReportDefinition( + name: 'recurring', + description: 'Recurrence templates', + filterExpression: 'status:recurring', + sortCriteria: SortCriterion.parseList('due+'), + columns: ColumnSpec.parseList('id,description,recur'), + ), + ReportDefinition( + name: 'overdue', + description: 'Past-due tasks', + filterExpression: 'status:pending +OVERDUE', + sortCriteria: SortCriterion.parseList('due+'), + columns: ColumnSpec.parseList('id,description,due'), + ), + ReportDefinition( + name: 'all', + description: 'Every task in the replica', + filterExpression: null, + sortCriteria: SortCriterion.parseList('urgency-'), + columns: ColumnSpec.parseList('id,description,status'), + ), + ]; + + /// Lists reports for the picker: user-defined (from `.taskrc`) first, then the + /// defaults. A custom report with the same name as a default overrides it. + static List availableReports( + [List customReports = const []]) { + final Set customNames = customReports.map((r) => r.name).toSet(); + return [ + ...customReports, + ...defaultReports.where((r) => !customNames.contains(r.name)), + ]; + } + + /// Runs a report over [tasks]: apply its filter, then its (multi-key) sort. + /// [clock] anchors time-relative logic (urgency, `+OVERDUE`) for determinism. + static List execute( + ReportDefinition report, + List tasks, { + DateTime? clock, + }) { + final DateTime now = (clock ?? DateTime.now()).toUtc(); + + final List filtered = VirtualFilterEngine.applyFilter( + tasks, + report.filterExpression, + now: now, + ); + + // Urgency is comparatively expensive; compute once per task. + final Map urgencyCache = {}; + double urgencyOf(TaskLike t) => urgencyCache.putIfAbsent( + t.uuid ?? identityHashCode(t).toString(), + () => computeTaskUrgency(t, clock: now)); + + final List sorted = List.from(filtered); + sorted.sort((a, b) { + for (final SortCriterion c in report.sortCriteria) { + final int cmp = _compareField(a, b, c.field, urgencyOf); + if (cmp != 0) return c.ascending ? cmp : -cmp; + } + return 0; + }); + return sorted; + } + + static int _compareField( + TaskLike a, + TaskLike b, + String field, + double Function(TaskLike) urgencyOf, + ) { + switch (field) { + case 'urgency': + return urgencyOf(a).compareTo(urgencyOf(b)); + case 'due': + return _s(a.due).compareTo(_s(b.due)); + case 'wait': + return _s(a.wait).compareTo(_s(b.wait)); + case 'start': + return _s(a.start).compareTo(_s(b.start)); + case 'entry': + return _cmpDate(a.entryDate, b.entryDate); + case 'modified': + return _cmpDate(a.modifiedDate, b.modifiedDate); + case 'priority': + return _priorityRank(a.priority).compareTo(_priorityRank(b.priority)); + case 'project': + return _s(a.project).compareTo(_s(b.project)); + case 'description': + return _s(a.description).toLowerCase().compareTo( + _s(b.description).toLowerCase(), + ); + case 'status': + return _s(a.status).compareTo(_s(b.status)); + default: + return 0; + } + } + + static String _s(String? v) => v ?? ''; + + /// Nulls sort before real dates, so a task with no date never jumps ahead of + /// one that has it under an ascending sort. + static int _cmpDate(DateTime? a, DateTime? b) { + if (a == null && b == null) return 0; + if (a == null) return -1; + if (b == null) return 1; + return a.compareTo(b); + } + + static int _priorityRank(String? p) { + switch (p) { + case 'H': + return 3; + case 'M': + return 2; + case 'L': + return 1; + default: + return 0; + } + } +} diff --git a/lib/app/services/taskrc_service.dart b/lib/app/services/taskrc_service.dart new file mode 100644 index 00000000..d4739fd2 --- /dev/null +++ b/lib/app/services/taskrc_service.dart @@ -0,0 +1,32 @@ +import 'dart:io'; + +import 'package:path_provider/path_provider.dart'; +import 'package:taskwarrior/app/models/report.dart'; +import 'package:taskwarrior/app/utils/taskchampion/taskrc_parser.dart'; + +/// Loads an optional user `.taskrc` so power users can tweak or add reports +/// (Issue #418). The file lives in the app's documents directory; if it is +/// absent or unreadable we simply fall back to the built-in default reports. +class TaskrcService { + static Future _file() async { + final Directory dir = await getApplicationDocumentsDirectory(); + return File('${dir.path}/.taskrc'); + } + + /// Absolute path where a `.taskrc` may be placed (shown in the UI so users + /// know where to drop their config). + static Future taskrcPath() async => (await _file()).path; + + /// User-defined reports from `.taskrc`, or an empty list if none. + static Future> loadCustomReports() async { + try { + final File file = await _file(); + if (!await file.exists()) return []; + final String content = await file.readAsString(); + final TaskrcParser parser = TaskrcParser()..parse(content); + return parser.customReports(); + } catch (_) { + return []; + } + } +} diff --git a/lib/app/tour/safe_tour.dart b/lib/app/tour/safe_tour.dart new file mode 100644 index 00000000..fa13c457 --- /dev/null +++ b/lib/app/tour/safe_tour.dart @@ -0,0 +1,36 @@ +import 'package:flutter/widgets.dart'; +import 'package:tutorial_coach_mark/tutorial_coach_mark.dart'; + +/// Safely shows a coach-mark tour. +/// +/// `tutorial_coach_mark` throws +/// `FormatException: It was not possible to obtain target position (null)` +/// when any target's [GlobalKey] is not currently laid out. This happens when +/// the screen changes during the pre-show delay (e.g. the user navigates +/// deeper before the tour fires): the target widgets are unmounted, so their +/// render boxes are null. +/// +/// This helper guards against that: it only calls `show()` when the context is +/// still mounted and every target key has a live element. Otherwise — or if +/// `show()` throws anyway — it marks the tour as seen via [markSeen] so a +/// failed attempt never surfaces an exception or loops forever (the tour's own +/// `onFinish` would never run to persist the flag). +Future safeShowTour({ + required TutorialCoachMark tutorialCoachMark, + required BuildContext context, + required List targetKeys, + Future Function()? markSeen, +}) async { + final bool allMounted = + targetKeys.every((k) => k.currentContext != null); + if (!context.mounted || !allMounted) { + await markSeen?.call(); + return; + } + try { + tutorialCoachMark.show(context: context); + } catch (_) { + // Defensive: never let a tour failure bubble up or repeat. + await markSeen?.call(); + } +} diff --git a/lib/app/utils/home_path/impl/data.dart b/lib/app/utils/home_path/impl/data.dart index d118f01b..ec3f1037 100644 --- a/lib/app/utils/home_path/impl/data.dart +++ b/lib/app/utils/home_path/impl/data.dart @@ -5,7 +5,7 @@ import 'dart:convert'; import 'dart:io'; import 'package:taskwarrior/app/models/json/task.dart'; -import 'package:taskwarrior/app/utils/taskc/payload.dart'; +import 'package:taskwarrior/app/utils/taskchampion/payload.dart'; import 'package:taskwarrior/app/utils/taskfunctions/urgency.dart'; diff --git a/lib/app/utils/home_path/impl/taskd_client.dart b/lib/app/utils/home_path/impl/taskd_client.dart index 8d6c0f40..b870b29f 100644 --- a/lib/app/utils/home_path/impl/taskd_client.dart +++ b/lib/app/utils/home_path/impl/taskd_client.dart @@ -6,10 +6,10 @@ import 'dart:io'; import 'package:taskwarrior/app/models/storage/exceptions/taskserver_configuration_exception.dart'; -import 'package:taskwarrior/app/utils/taskc/impl/codec.dart'; -import 'package:taskwarrior/app/utils/taskc/impl/message.dart'; -import 'package:taskwarrior/app/utils/taskc/message.dart'; -import 'package:taskwarrior/app/utils/taskc/response.dart'; +import 'package:taskwarrior/app/utils/taskchampion/impl/codec.dart'; +import 'package:taskwarrior/app/utils/taskchampion/impl/message.dart'; +import 'package:taskwarrior/app/utils/taskchampion/message.dart'; +import 'package:taskwarrior/app/utils/taskchampion/response.dart'; import 'package:taskwarrior/app/utils/taskserver/pem_file_paths.dart'; import 'package:taskwarrior/app/utils/taskserver/taskrc.dart'; diff --git a/lib/app/utils/language/bengali_sentences.dart b/lib/app/utils/language/bengali_sentences.dart index 4761031c..a23d8fa6 100644 --- a/lib/app/utils/language/bengali_sentences.dart +++ b/lib/app/utils/language/bengali_sentences.dart @@ -2,17 +2,17 @@ import 'package:taskwarrior/app/utils/language/sentences.dart'; class BengaliSentences extends Sentences { @override - String get ccsyncLoginInstruction => - 'CCSync-এ লগইন করুন, আপনার শংসাপত্র কপি করুন এবং উপরে পেস্ট করুন।'; + String get syncServerLoginInstruction => + 'TaskChampion-এ লগইন করুন, আপনার শংসাপত্র কপি করুন এবং উপরে পেস্ট করুন।'; @override - String get ccsyncEasySyncTitle => 'সহজ সিঙ্কের জন্য CCSync ব্যবহার করুন'; + String get syncServerEasySyncTitle => 'সহজ সিঙ্কের জন্য TaskChampion ব্যবহার করুন'; @override - String get ccsyncOpenButton => 'CCSync খুলুন'; + String get syncServerOpenButton => 'TaskChampion খুলুন'; @override - String get ccsyncIntro => - 'CCSync TaskChampion ব্যবহার করে আপনার কাজগুলি একাধিক ডিভাইসে নির্বিঘ্নে সিঙ্ক করে। আপনি যেকোনো ব্রাউজার থেকে আপনার কাজগুলি পরিচালনা করার জন্য একটি ওয়েব ড্যাশবোর্ডও পান।'; + String get syncServerIntro => + 'TaskChampion ব্যবহার করে আপনার কাজগুলি একাধিক ডিভাইসে নির্বিঘ্নে সিঙ্ক করে। আপনি যেকোনো ব্রাউজার থেকে আপনার কাজগুলি পরিচালনা করার জন্য একটি ওয়েব ড্যাশবোর্ডও পান।'; @override - String get ccsyncSelfHosted => + String get syncServerSelfHosted => 'অথবা একটি স্ব-হোস্টেড TaskChampion সিঙ্ক সার্ভার থেকে আপনার নিজস্ব শংসাপত্র আনুন।'; @override String get helloWorld => 'হ্যালো বিশ্ব!'; @@ -139,6 +139,7 @@ class BengaliSentences extends Sentences { String get filterDrawerPending => 'মুলতুবি'; @override String get filterDrawerCompleted => 'সম্পন্ন'; + String get filterDrawerDeleted => 'মুছে ফেলা'; @override String get filterDrawerFilterTagBy => 'ট্যাগ দ্বারা ফিল্টার করুন'; @override @@ -204,13 +205,13 @@ class BengaliSentences extends Sentences { @override String get taskchampionTileDescription => - 'Taskwarrior সিঙ্কিং CCSync বা Taskchampion সিঙ্ক সার্ভারে পরিবর্তন করুন'; + 'Taskwarrior সিঙ্কিং TaskChampion সিঙ্ক সার্ভারে পরিবর্তন করুন'; @override String get taskchampionTileTitle => 'Taskchampion সিঙ্ক'; @override - String get ccsyncCredentials => 'CCSync ক্রেডেনশিয়াল'; + String get syncServerCredentials => 'TaskChampion ক্রেডেনশিয়াল'; @override String get deleteTaskConfirmation => 'টাস্ক মুছুন'; @@ -661,9 +662,9 @@ class BengaliSentences extends Sentences { @override String get encryptionSecret => 'এনক্রিপশন সিক্রেট'; @override - String get ccsyncBackendUrl => 'CCSync ব্যাকএন্ড URL'; + String get syncServerBackendUrl => 'TaskChampion ব্যাকএন্ড URL'; @override - String get ccsyncClientId => 'ক্লায়েন্ট আইডি'; + String get syncServerClientId => 'ক্লায়েন্ট আইডি'; @override String get success => 'সফল হয়েছে'; @override @@ -686,6 +687,4 @@ class BengaliSentences extends Sentences { String get storageAndData => 'স্টোরেজ এবং ডাটা'; @override String get advanced => 'উন্নত'; - @override - String get taskchampionBackendUrl => 'Taskchampion ব্যাকএন্ড URL'; } diff --git a/lib/app/utils/language/english_sentences.dart b/lib/app/utils/language/english_sentences.dart index a6b0fb00..9a25b426 100644 --- a/lib/app/utils/language/english_sentences.dart +++ b/lib/app/utils/language/english_sentences.dart @@ -2,17 +2,17 @@ import 'package:taskwarrior/app/utils/language/sentences.dart'; class EnglishSentences extends Sentences { @override - String get ccsyncLoginInstruction => - 'Login to CCSync, copy your credentials, and paste them above.'; + String get syncServerLoginInstruction => + 'Login to TaskChampion, copy your credentials, and paste them above.'; @override - String get ccsyncEasySyncTitle => 'Use CCSync for Easy Sync'; + String get syncServerEasySyncTitle => 'Use TaskChampion for Easy Sync'; @override - String get ccsyncOpenButton => 'Open CCSync'; + String get syncServerOpenButton => 'Open TaskChampion'; @override - String get ccsyncIntro => - 'CCSync uses TaskChampion to sync your tasks across multiple devices seamlessly. You also get a web dashboard to manage your tasks from any browser.'; + String get syncServerIntro => + 'TaskChampion syncs your tasks across multiple devices seamlessly. You also get a web dashboard to manage your tasks from any browser.'; @override - String get ccsyncSelfHosted => + String get syncServerSelfHosted => 'Or bring your own credentials from a self-hosted TaskChampion sync server.'; @override String get helloWorld => 'Hello, World!'; @@ -152,6 +152,7 @@ class EnglishSentences extends Sentences { String get filterDrawerPending => 'Pending'; @override String get filterDrawerCompleted => 'Completed'; + String get filterDrawerDeleted => 'Deleted'; @override String get filterDrawerFilterTagBy => 'Filter Tag By'; @override @@ -219,12 +220,12 @@ class EnglishSentences extends Sentences { @override String get taskchampionTileDescription => - 'Switch to Taskwarrior sync with CCSync or Taskchampion Sync Server'; + 'Switch to Taskwarrior sync with a TaskChampion sync server'; @override String get taskchampionTileTitle => 'Taskchampion sync'; @override - String get ccsyncCredentials => 'CCync credentials'; + String get syncServerCredentials => 'TaskChampion credentials'; @override String get deleteTaskConfirmation => 'Delete Tasks'; @@ -650,9 +651,9 @@ class EnglishSentences extends Sentences { @override String get encryptionSecret => 'Encryption Secret'; @override - String get ccsyncBackendUrl => 'CCSync Backend URL'; + String get syncServerBackendUrl => 'TaskChampion Backend URL'; @override - String get ccsyncClientId => 'Client ID'; + String get syncServerClientId => 'Client ID'; @override String get success => 'Success'; @override @@ -675,6 +676,4 @@ class EnglishSentences extends Sentences { String get storageAndData => 'Storage and Data'; @override String get advanced => 'Advanced'; - @override - String get taskchampionBackendUrl => 'Taskchampion URL'; } diff --git a/lib/app/utils/language/french_sentences.dart b/lib/app/utils/language/french_sentences.dart index 788a0cb6..5b746e7a 100644 --- a/lib/app/utils/language/french_sentences.dart +++ b/lib/app/utils/language/french_sentences.dart @@ -2,18 +2,18 @@ import 'package:taskwarrior/app/utils/language/sentences.dart'; class FrenchSentences extends Sentences { @override - String get ccsyncLoginInstruction => - 'Connectez-vous à CCSync, copiez vos identifiants et collez-les ci-dessus.'; + String get syncServerLoginInstruction => + 'Connectez-vous à TaskChampion, copiez vos identifiants et collez-les ci-dessus.'; @override - String get ccsyncEasySyncTitle => - 'Utilisez CCSync pour une synchronisation facile'; + String get syncServerEasySyncTitle => + 'Utilisez TaskChampion pour une synchronisation facile'; @override - String get ccsyncOpenButton => 'Ouvrir CCSync'; + String get syncServerOpenButton => 'Ouvrir TaskChampion'; @override - String get ccsyncIntro => - 'CCSync utilise TaskChampion pour synchroniser vos tâches sur plusieurs appareils sans effort. Vous bénéficiez également d’un tableau de bord web pour gérer vos tâches depuis n’importe quel navigateur.'; + String get syncServerIntro => + 'TaskChampion synchronise vos tâches sur plusieurs appareils sans effort. Vous bénéficiez également d’un tableau de bord web pour gérer vos tâches depuis n’importe quel navigateur.'; @override - String get ccsyncSelfHosted => + String get syncServerSelfHosted => 'Ou utilisez vos propres identifiants depuis un serveur TaskChampion auto-hébergé.'; @override String get helloWorld => 'Bonjour, le monde!'; @@ -139,6 +139,7 @@ class FrenchSentences extends Sentences { String get filterDrawerPending => 'En attente'; @override String get filterDrawerCompleted => 'Complété'; + String get filterDrawerDeleted => 'Supprimé'; @override String get filterDrawerFilterTagBy => 'Filtrer par tag'; @override @@ -208,13 +209,13 @@ class FrenchSentences extends Sentences { @override String get taskchampionTileDescription => - 'Basculez la synchronisation de Taskwarrior vers le serveur de synchronisation CCSync ou Taskchampion'; + 'Basculez la synchronisation de Taskwarrior vers le serveur de synchronisation TaskChampion'; @override String get taskchampionTileTitle => 'Synchronisation Taskchampion'; @override - String get ccsyncCredentials => 'Identifiants CCSync'; + String get syncServerCredentials => 'Identifiants TaskChampion'; @override String get deleteTaskConfirmation => 'Supprimer la tâche'; @@ -677,9 +678,9 @@ class FrenchSentences extends Sentences { @override String get encryptionSecret => 'Secret de chiffrement'; @override - String get ccsyncBackendUrl => 'URL du backend CCSync'; + String get syncServerBackendUrl => 'URL du backend TaskChampion'; @override - String get ccsyncClientId => 'ID client'; + String get syncServerClientId => 'ID client'; @override String get success => 'Succès'; @override @@ -705,6 +706,4 @@ class FrenchSentences extends Sentences { String get storageAndData => 'Stockage et données'; @override String get advanced => 'Avancé'; - @override - String get taskchampionBackendUrl => 'URL de Taskchampion'; } diff --git a/lib/app/utils/language/german_sentences.dart b/lib/app/utils/language/german_sentences.dart index c38ad4cb..50e0236c 100644 --- a/lib/app/utils/language/german_sentences.dart +++ b/lib/app/utils/language/german_sentences.dart @@ -2,17 +2,17 @@ import 'package:taskwarrior/app/utils/language/sentences.dart'; class GermanSentences extends Sentences { @override - String get ccsyncLoginInstruction => - 'Melde dich bei CCSync an, kopiere deine Anmeldedaten und füge sie oben ein.'; + String get syncServerLoginInstruction => + 'Melde dich bei TaskChampion an, kopiere deine Anmeldedaten und füge sie oben ein.'; @override - String get ccsyncEasySyncTitle => 'CCSync nutzen für einfachen Sync'; + String get syncServerEasySyncTitle => 'TaskChampion nutzen für einfachen Sync'; @override - String get ccsyncOpenButton => 'CCSync öffnen'; + String get syncServerOpenButton => 'TaskChampion öffnen'; @override - String get ccsyncIntro => - 'CCSync nutzt TaskChampion, um Aufgaben nahtlos über mehrere Geräte hinweg zu synchronisieren. Außerdem erhälst du ein Web-Dashboard, über das du deine Aufgaben von jedem Browser aus verwalten kannst.'; + String get syncServerIntro => + 'TaskChampion synchronisiert deine Aufgaben nahtlos über mehrere Geräte hinweg. Außerdem erhälst du ein Web-Dashboard, über das du deine Aufgaben von jedem Browser aus verwalten kannst.'; @override - String get ccsyncSelfHosted => + String get syncServerSelfHosted => 'Oder bringe deine eigenen Anmeldedaten von einem selbst gehosteten TaskChampion-Synchronisierungsserver mit.'; @override String get helloWorld => 'Hallo Welt!'; @@ -152,6 +152,7 @@ class GermanSentences extends Sentences { String get filterDrawerPending => 'Bevorstehend'; @override String get filterDrawerCompleted => 'Erledigt'; + String get filterDrawerDeleted => 'Gelöscht'; @override String get filterDrawerFilterTagBy => 'Tag filtern nach'; @override @@ -219,12 +220,12 @@ class GermanSentences extends Sentences { @override String get taskchampionTileDescription => - 'Wechsel zu Taskwarrior Sync mit CCSync oder Taskchampion Sync Server'; + 'Wechsel zu Taskwarrior Sync mit einem TaskChampion Sync Server'; @override String get taskchampionTileTitle => 'Taskchampion Sync'; @override - String get ccsyncCredentials => 'CCync Anmeldedaten'; + String get syncServerCredentials => 'TaskChampion Anmeldedaten'; @override String get deleteTaskConfirmation => 'Aufgaben löschen'; @@ -650,9 +651,9 @@ class GermanSentences extends Sentences { @override String get encryptionSecret => 'Encryption Secret'; @override - String get ccsyncBackendUrl => 'CCSync Backend URL'; + String get syncServerBackendUrl => 'TaskChampion Backend URL'; @override - String get ccsyncClientId => 'Client ID'; + String get syncServerClientId => 'Client ID'; @override String get success => 'Erfolg'; @override @@ -675,6 +676,4 @@ class GermanSentences extends Sentences { String get storageAndData => 'Speicher und Daten'; @override String get advanced => 'Fortgeschritten'; - @override - String get taskchampionBackendUrl => 'Taskchampion URL'; } diff --git a/lib/app/utils/language/hindi_sentences.dart b/lib/app/utils/language/hindi_sentences.dart index 6b5c428b..f320ea3b 100644 --- a/lib/app/utils/language/hindi_sentences.dart +++ b/lib/app/utils/language/hindi_sentences.dart @@ -2,17 +2,17 @@ import 'package:taskwarrior/app/utils/language/sentences.dart'; class HindiSentences extends Sentences { @override - String get ccsyncLoginInstruction => - 'CCSync में लॉगिन करें, अपनी क्रेडेंशियल्स कॉपी करें, और उन्हें ऊपर पेस्ट करें।'; + String get syncServerLoginInstruction => + 'TaskChampion में लॉगिन करें, अपनी क्रेडेंशियल्स कॉपी करें, और उन्हें ऊपर पेस्ट करें।'; @override - String get ccsyncEasySyncTitle => 'आसान सिंक के लिए CCSync का उपयोग करें'; + String get syncServerEasySyncTitle => 'आसान सिंक के लिए TaskChampion का उपयोग करें'; @override - String get ccsyncOpenButton => 'CCSync खोलें'; + String get syncServerOpenButton => 'TaskChampion खोलें'; @override - String get ccsyncIntro => - 'CCSync आपके कार्यों को कई डिवाइसों पर TaskChampion के माध्यम से निर्बाध रूप से सिंक करता है। आपको किसी भी ब्राउज़र से अपने कार्यों को प्रबंधित करने के लिए एक वेब डैशबोर्ड भी मिलता है।'; + String get syncServerIntro => + 'TaskChampion आपके कार्यों को कई डिवाइसों पर निर्बाध रूप से सिंक करता है। आपको किसी भी ब्राउज़र से अपने कार्यों को प्रबंधित करने के लिए एक वेब डैशबोर्ड भी मिलता है।'; @override - String get ccsyncSelfHosted => + String get syncServerSelfHosted => 'या अपने स्वयं के TaskChampion सिंक सर्वर से क्रेडेंशियल्स लाएँ।'; @override String get helloWorld => 'नमस्ते दुनिया!'; @@ -152,6 +152,7 @@ class HindiSentences extends Sentences { String get filterDrawerPending => 'अपूर्ण'; @override String get filterDrawerCompleted => 'पूर्ण'; + String get filterDrawerDeleted => 'हटाए गए'; @override String get filterDrawerFilterTagBy => 'टैग से फ़िल्टर करें'; @override @@ -220,13 +221,13 @@ class HindiSentences extends Sentences { @override String get taskchampionTileDescription => - 'CCSync या Taskchampion सिंक सर्वर के साथ Taskwarrior सिंक पर स्विच करें'; + 'TaskChampion सिंक सर्वर के साथ Taskwarrior सिंक पर स्विच करें'; @override String get taskchampionTileTitle => 'Taskchampion सिंक'; @override - String get ccsyncCredentials => 'CCync क्रेडेन्शियल'; + String get syncServerCredentials => 'TaskChampion क्रेडेन्शियल'; @override String get deleteTaskConfirmation => 'कार्य हटाएं'; @@ -638,9 +639,9 @@ class HindiSentences extends Sentences { @override String get encryptionSecret => 'एन्क्रिप्शन सीक्रेट'; @override - String get ccsyncBackendUrl => 'CCSync बैकएंड URL'; + String get syncServerBackendUrl => 'TaskChampion बैकएंड URL'; @override - String get ccsyncClientId => 'क्लाइंट आईडी'; + String get syncServerClientId => 'क्लाइंट आईडी'; @override String get success => 'सफलता'; @override @@ -664,6 +665,4 @@ class HindiSentences extends Sentences { String get storageAndData => 'स्टोरेज और डेटा'; @override String get advanced => 'अड्वांस्ड'; - @override - String get taskchampionBackendUrl => 'Taskchampion URL'; } diff --git a/lib/app/utils/language/marathi_sentences.dart b/lib/app/utils/language/marathi_sentences.dart index b7045742..b0469a9f 100644 --- a/lib/app/utils/language/marathi_sentences.dart +++ b/lib/app/utils/language/marathi_sentences.dart @@ -2,18 +2,18 @@ import 'package:taskwarrior/app/utils/language/sentences.dart'; class MarathiSentences extends Sentences { @override - String get ccsyncLoginInstruction => - 'CCSync मध्ये लॉगिन करा, तुमची क्रेडेन्शियल्स कॉपी करा आणि वर पेस्ट करा.'; + String get syncServerLoginInstruction => + 'TaskChampion मध्ये लॉगिन करा, तुमची क्रेडेन्शियल्स कॉपी करा आणि वर पेस्ट करा.'; @override - String get ccsyncEasySyncTitle => 'सोप्या सिंकसाठी CCSync वापरा'; + String get syncServerEasySyncTitle => 'सोप्या सिंकसाठी TaskChampion वापरा'; @override - String get ccsyncOpenButton => 'CCSync उघडा'; + String get syncServerOpenButton => 'TaskChampion उघडा'; @override - String get ccsyncIntro => - 'CCSync TaskChampion वापरून तुमची कामे अनेक उपकरणांवर सहजपणे सिंक करते. तुम्हाला कोणत्याही ब्राउझरमधून तुमची कामे व्यवस्थापित करण्यासाठी वेब डॅशबोर्ड देखील मिळतो.'; + String get syncServerIntro => + 'TaskChampion वापरून तुमची कामे अनेक उपकरणांवर सहजपणे सिंक करते. तुम्हाला कोणत्याही ब्राउझरमधून तुमची कामे व्यवस्थापित करण्यासाठी वेब डॅशबोर्ड देखील मिळतो.'; @override - String get ccsyncSelfHosted => + String get syncServerSelfHosted => 'किंवा स्वतःच्या TaskChampion सिंक सर्व्हरमधून तुमची क्रेडेन्शियल्स वापरा.'; @override String get helloWorld => 'नमस्कार, जग!'; @@ -140,6 +140,7 @@ class MarathiSentences extends Sentences { String get filterDrawerPending => 'प्रलंबित'; @override String get filterDrawerCompleted => 'पूर्ण'; + String get filterDrawerDeleted => 'हटवलेले'; @override String get filterDrawerFilterTagBy => 'टॅगवर फिल्टर करा'; @override @@ -206,13 +207,13 @@ class MarathiSentences extends Sentences { @override String get taskchampionTileDescription => - 'CCSync किंवा Taskchampion Sync Server सह Taskwarrior सिंक वर स्विच करा'; + 'TaskChampion Sync Server सह Taskwarrior सिंक वर स्विच करा'; @override String get taskchampionTileTitle => 'Taskchampion सिंक'; @override - String get ccsyncCredentials => 'CCync क्रेडेन्शियल'; + String get syncServerCredentials => 'TaskChampion क्रेडेन्शियल'; @override String get deleteTaskConfirmation => 'कार्य हटवा'; @@ -661,9 +662,9 @@ class MarathiSentences extends Sentences { @override String get encryptionSecret => 'एन्क्रिप्शन गुपित'; @override - String get ccsyncBackendUrl => 'CCSync बॅकएंड URL'; + String get syncServerBackendUrl => 'TaskChampion बॅकएंड URL'; @override - String get ccsyncClientId => 'क्लायंट आयडी'; + String get syncServerClientId => 'क्लायंट आयडी'; @override String get success => 'यशस्वी'; @override @@ -688,6 +689,4 @@ class MarathiSentences extends Sentences { String get storageAndData => 'स्टोरेज आणि डेटा'; @override String get advanced => 'अड्वांस्ड'; - @override - String get taskchampionBackendUrl => 'Taskchampion URL'; } diff --git a/lib/app/utils/language/sentences.dart b/lib/app/utils/language/sentences.dart index 891f3e30..3a7e3c7c 100644 --- a/lib/app/utils/language/sentences.dart +++ b/lib/app/utils/language/sentences.dart @@ -1,12 +1,12 @@ abstract class Sentences { - /// CCSync UI additional sentences - String get ccsyncLoginInstruction; - String get ccsyncEasySyncTitle; - String get ccsyncOpenButton; - - /// CCSync intro and self-hosted sentences - String get ccsyncIntro; - String get ccsyncSelfHosted; + /// TaskChampion UI additional sentences + String get syncServerLoginInstruction; + String get syncServerEasySyncTitle; + String get syncServerOpenButton; + + /// TaskChampion intro and self-hosted sentences + String get syncServerIntro; + String get syncServerSelfHosted; String get helloWorld; String get homePageTitle; @@ -64,7 +64,7 @@ abstract class Sentences { String get navDrawerReports; String get navDrawerAbout; String get navDrawerSettings; - String get ccsyncCredentials; + String get syncServerCredentials; String get deleteTaskTitle; String get deleteTaskConfirmation; String get deleteTaskWarning; @@ -92,6 +92,7 @@ abstract class Sentences { String get filterDrawerShowWaiting; String get filterDrawerPending; String get filterDrawerCompleted; + String get filterDrawerDeleted; String get filterDrawerFilterTagBy; String get filterDrawerAND; String get filterDrawerOR; @@ -344,12 +345,11 @@ abstract class Sentences { String get add; String get change; String get dateCanNotBeInPast; - // ccsync credentials page + // sync server credentials page String get configureTaskchampion; String get encryptionSecret; - String get ccsyncBackendUrl; - String get taskchampionBackendUrl; - String get ccsyncClientId; + String get syncServerBackendUrl; + String get syncServerClientId; String get success; String get credentialsSavedSuccessfully; String get tip; diff --git a/lib/app/utils/language/spanish_sentences.dart b/lib/app/utils/language/spanish_sentences.dart index 94d6a460..f870dcfd 100644 --- a/lib/app/utils/language/spanish_sentences.dart +++ b/lib/app/utils/language/spanish_sentences.dart @@ -2,17 +2,17 @@ import 'package:taskwarrior/app/utils/language/sentences.dart'; class SpanishSentences extends Sentences { @override - String get ccsyncLoginInstruction => - 'Inicia sesión en CCSync, copia tus credenciales y pégalas arriba.'; + String get syncServerLoginInstruction => + 'Inicia sesión en TaskChampion, copia tus credenciales y pégalas arriba.'; @override - String get ccsyncEasySyncTitle => 'Usa CCSync para una sincronización fácil'; + String get syncServerEasySyncTitle => 'Usa TaskChampion para una sincronización fácil'; @override - String get ccsyncOpenButton => 'Abrir CCSync'; + String get syncServerOpenButton => 'Abrir TaskChampion'; @override - String get ccsyncIntro => - 'CCSync utiliza TaskChampion para sincronizar tus tareas en múltiples dispositivos sin problemas. También obtienes un panel web para gestionar tus tareas desde cualquier navegador.'; + String get syncServerIntro => + 'TaskChampion sincroniza tus tareas en múltiples dispositivos sin problemas. También obtienes un panel web para gestionar tus tareas desde cualquier navegador.'; @override - String get ccsyncSelfHosted => + String get syncServerSelfHosted => 'O utiliza tus propias credenciales de un servidor de sincronización TaskChampion autohospedado.'; @override String get helloWorld => '¡Hola, mundo!'; @@ -140,6 +140,7 @@ class SpanishSentences extends Sentences { String get filterDrawerPending => 'Pendiente'; @override String get filterDrawerCompleted => 'Completado'; + String get filterDrawerDeleted => 'Eliminado'; @override String get filterDrawerFilterTagBy => 'Filtrar por etiqueta'; @override @@ -206,13 +207,13 @@ class SpanishSentences extends Sentences { @override String get taskchampionTileDescription => - 'Cambia la sincronización de Taskwarrior al servidor de sincronización CCSync o Taskchampion'; + 'Cambia la sincronización de Taskwarrior al servidor de sincronización TaskChampion'; @override String get taskchampionTileTitle => 'Sincronización Taskchampion'; @override - String get ccsyncCredentials => 'Credenciales de CCSync'; + String get syncServerCredentials => 'Credenciales de TaskChampion'; @override String get deleteTaskConfirmation => 'Eliminar tarea'; @@ -665,9 +666,9 @@ class SpanishSentences extends Sentences { @override String get encryptionSecret => 'Secreto de cifrado'; @override - String get ccsyncBackendUrl => 'URL del backend de CCSync'; + String get syncServerBackendUrl => 'URL del backend de TaskChampion'; @override - String get ccsyncClientId => 'ID de cliente'; + String get syncServerClientId => 'ID de cliente'; @override String get success => 'Éxito'; @override @@ -692,6 +693,4 @@ class SpanishSentences extends Sentences { String get storageAndData => 'Almacenamiento y datos'; @override String get advanced => 'Avanzado'; - @override - String get taskchampionBackendUrl => 'Taskchampion URL'; } diff --git a/lib/app/utils/language/urdu_sentences.dart b/lib/app/utils/language/urdu_sentences.dart index e78c37de..34b7b017 100644 --- a/lib/app/utils/language/urdu_sentences.dart +++ b/lib/app/utils/language/urdu_sentences.dart @@ -2,17 +2,17 @@ import 'package:taskwarrior/app/utils/language/sentences.dart'; class UrduSentences extends Sentences { @override - String get ccsyncLoginInstruction => - 'CCSync میں لاگ ان کریں، اپنی اسناد کاپی کریں، اور اوپر پیسٹ کریں۔'; + String get syncServerLoginInstruction => + 'TaskChampion میں لاگ ان کریں، اپنی اسناد کاپی کریں، اور اوپر پیسٹ کریں۔'; @override - String get ccsyncEasySyncTitle => 'آسان سینک کے لیے CCSync کا używaj'; + String get syncServerEasySyncTitle => 'آسان سینک کے لیے TaskChampion کا استعمال کریں'; @override - String get ccsyncOpenButton => 'CCSync کھولیں'; + String get syncServerOpenButton => 'TaskChampion کھولیں'; @override - String get ccsyncIntro => - 'CCSync آپ کے کاموں کو کئی آلاتوں میں ہموار طور پر سینک کرنے کے لیے TaskChampion کا gebruikt۔ آپ کو اپنے کاموں کو کسی بھی براؤزر سے manage کرنے کے لیے ویب ڈیش بورد بھی ملتی ہے۔'; + String get syncServerIntro => + 'TaskChampion آپ کے کاموں کو کئی آلاتوں میں ہموار طور پر سینک کرتا ہے۔ آپ کو اپنے کاموں کو کسی بھی براؤزر سے manage کرنے کے لیے ویب ڈیش بورد بھی ملتی ہے۔'; @override - String get ccsyncSelfHosted => + String get syncServerSelfHosted => 'یا اپنے سیلف ہوسٹڈ TaskChampion sync سرور سے اپنی اسناد لائیں۔'; @override String get helloWorld => 'ہیلو، دنیا!'; @@ -152,6 +152,7 @@ class UrduSentences extends Sentences { String get filterDrawerPending => 'زیر التواء'; @override String get filterDrawerCompleted => 'مکمل'; + String get filterDrawerDeleted => 'حذف شدہ'; @override String get filterDrawerFilterTagBy => 'ٹیگ کے لحاظ سے فلٹر کریں'; @override @@ -221,12 +222,12 @@ class UrduSentences extends Sentences { @override String get taskchampionTileDescription => - 'CCSync یا Taskchampion Sync Server کے ساتھ ٹاسکواریر sync پر سوئچ کریں'; + 'TaskChampion Sync Server کے ساتھ ٹاسکواریر sync پر سوئچ کریں'; @override String get taskchampionTileTitle => 'Taskchampion sync'; @override - String get ccsyncCredentials => 'CCync اسناد'; + String get syncServerCredentials => 'TaskChampion اسناد'; @override String get deleteTaskConfirmation => 'کام حذف کریں'; @@ -653,9 +654,9 @@ class UrduSentences extends Sentences { @override String get encryptionSecret => 'انکرپشن سیکریٹ'; @override - String get ccsyncBackendUrl => 'CCSync بیک اینڈ یو آر ایل'; + String get syncServerBackendUrl => 'TaskChampion بیک اینڈ یو آر ایل'; @override - String get ccsyncClientId => 'کلائنٹ آئی ڈی'; + String get syncServerClientId => 'کلائنٹ آئی ڈی'; @override String get success => 'کامیابی'; @override @@ -679,6 +680,4 @@ class UrduSentences extends Sentences { String get storageAndData => 'اسٹوریج اور ڈیٹا'; @override String get advanced => 'ایڈوانس'; - @override - String get taskchampionBackendUrl => 'Taskchampion یو آر ایل'; } diff --git a/lib/app/utils/taskc/impl/codec.dart b/lib/app/utils/taskchampion/impl/codec.dart similarity index 100% rename from lib/app/utils/taskc/impl/codec.dart rename to lib/app/utils/taskchampion/impl/codec.dart diff --git a/lib/app/utils/taskc/impl/message.dart b/lib/app/utils/taskchampion/impl/message.dart similarity index 100% rename from lib/app/utils/taskc/impl/message.dart rename to lib/app/utils/taskchampion/impl/message.dart diff --git a/lib/app/utils/taskc/message.dart b/lib/app/utils/taskchampion/message.dart similarity index 100% rename from lib/app/utils/taskc/message.dart rename to lib/app/utils/taskchampion/message.dart diff --git a/lib/app/utils/taskc/payload.dart b/lib/app/utils/taskchampion/payload.dart similarity index 100% rename from lib/app/utils/taskc/payload.dart rename to lib/app/utils/taskchampion/payload.dart diff --git a/lib/app/utils/taskc/response.dart b/lib/app/utils/taskchampion/response.dart similarity index 89% rename from lib/app/utils/taskc/response.dart rename to lib/app/utils/taskchampion/response.dart index 5e5edf7e..dd31bd05 100644 --- a/lib/app/utils/taskc/response.dart +++ b/lib/app/utils/taskchampion/response.dart @@ -1,4 +1,4 @@ -import 'package:taskwarrior/app/utils/taskc/payload.dart'; +import 'package:taskwarrior/app/utils/taskchampion/payload.dart'; class Response { Response({required this.header, required this.payload}); diff --git a/lib/app/utils/taskchampion/taskrc_parser.dart b/lib/app/utils/taskchampion/taskrc_parser.dart new file mode 100644 index 00000000..3db912eb --- /dev/null +++ b/lib/app/utils/taskchampion/taskrc_parser.dart @@ -0,0 +1,54 @@ +import 'package:taskwarrior/app/models/report.dart'; + +/// Parses a Taskwarrior `.taskrc` file and extracts user-defined report +/// definitions (Issue #418). Only lines of the form `key=value` are read; +/// comments (`#…`) and blanks are ignored. A report is recognised when a +/// `report..sort` key is present — matching Taskwarrior's own rule for a +/// "real" report — and its `.description`, `.filter`, and `.columns` siblings +/// are pulled in when available. +class TaskrcParser { + final Map _entries = {}; + + /// Ingests raw `.taskrc` text. Safe to call more than once (later values for + /// the same key win, mirroring Taskwarrior's last-wins semantics). + void parse(String content) { + for (final String rawLine in content.split('\n')) { + final String line = rawLine.trim(); + if (line.isEmpty || line.startsWith('#')) continue; + final int eq = line.indexOf('='); + if (eq <= 0) continue; + final String key = line.substring(0, eq).trim(); + final String value = line.substring(eq + 1).trim(); + if (key.isEmpty) continue; + _entries[key] = value; + } + } + + /// The user-defined reports found in the parsed config. Each has a `.sort`; + /// missing pieces fall back to sensible defaults (id,description columns, + /// urgency- sort, the name as its own description). + List customReports() { + final RegExp reportSort = RegExp(r'^report\.([^.]+)\.sort$'); + final List names = []; + for (final String key in _entries.keys) { + final RegExpMatch? m = reportSort.firstMatch(key); + if (m != null) names.add(m.group(1)!); + } + + return names.map((String name) { + return ReportDefinition( + name: name, + description: _entries['report.$name.description'] ?? name, + columns: ColumnSpec.parseList( + _entries['report.$name.columns'] ?? 'id,description'), + sortCriteria: SortCriterion.parseList( + _entries['report.$name.sort'] ?? 'urgency-'), + filterExpression: _entries['report.$name.filter'], + isCustom: true, + ); + }).toList(); + } + + /// Read-only view of all parsed key/value pairs (useful for diagnostics). + Map get entries => Map.unmodifiable(_entries); +} diff --git a/lib/app/utils/taskchampion/virtual_filter_engine.dart b/lib/app/utils/taskchampion/virtual_filter_engine.dart new file mode 100644 index 00000000..e267c05c --- /dev/null +++ b/lib/app/utils/taskchampion/virtual_filter_engine.dart @@ -0,0 +1,109 @@ +import 'package:taskwarrior/app/models/task_like.dart'; + +/// Evaluates Taskwarrior-style filter expressions against tasks. +/// +/// Supports the virtual tags that drive the default reports (Issue #418) — +/// `+ACTIVE`, `+READY`, `+BLOCKED`, `+BLOCKING`, `+OVERDUE`, `+WAITING`, +/// `+PENDING`, `+COMPLETED`, `+DELETED` — plus attribute filters +/// (`status:`, `project:`, `priority:`) and negation (`-TAG`). Tokens are +/// combined with AND, so `"status:pending +ACTIVE project:work"` keeps tasks +/// that satisfy every token. +/// +/// Written against [TaskLike] so it works for every task model / sync mode. +class VirtualFilterEngine { + /// Returns whether [task] satisfies a single virtual/real tag like `+READY` + /// or `+home`. [now] anchors time-relative tags (`+OVERDUE`). + static bool evaluateTag(TaskLike task, String tag, {DateTime? now}) { + final DateTime clock = (now ?? DateTime.now()).toUtc(); + final String bare = tag.replaceFirst('+', ''); + switch (bare.toUpperCase()) { + case 'ACTIVE': + return task.status == 'pending' && _isSet(task.start); + case 'READY': + return task.status == 'pending' && + !(task.isBlocked ?? false) && + !_isFutureWait(task, clock); + case 'BLOCKED': + return task.isBlocked ?? false; + case 'BLOCKING': + return task.isBlocking ?? false; + case 'OVERDUE': + // Taskwarrior defines +OVERDUE as pending tasks whose due date has + // passed; a completed/deleted task is never "overdue" even if its due + // date lapsed before it was closed. + final DateTime? due = parseTaskDate(task.due); + return task.status == 'pending' && due != null && due.isBefore(clock); + case 'WAITING': + return task.status == 'waiting' || _isFutureWait(task, clock); + case 'PENDING': + return task.status == 'pending'; + case 'COMPLETED': + return task.status == 'completed'; + case 'DELETED': + return task.status == 'deleted'; + default: + // A real user tag. + return task.tags?.contains(bare) ?? false; + } + } + + /// Applies a compound filter [expression] to [tasks]. An empty/blank + /// expression matches everything. The element type is preserved, so callers + /// keep their concrete model type. + static List applyFilter( + List tasks, + String? expression, { + DateTime? now, + }) { + final String expr = (expression ?? '').trim(); + if (expr.isEmpty) return List.from(tasks); + + final List tokens = + expr.split(RegExp(r'\s+')).where((t) => t.isNotEmpty).toList(); + final DateTime clock = (now ?? DateTime.now()).toUtc(); + + return tasks.where((task) { + return tokens.every((tok) { + if (tok.startsWith('+')) return evaluateTag(task, tok, now: clock); + if (tok.startsWith('-')) { + return !evaluateTag(task, '+${tok.substring(1)}', now: clock); + } + final int colon = tok.indexOf(':'); + if (colon > 0) { + return _matchAttribute( + task, tok.substring(0, colon), tok.substring(colon + 1)); + } + // Bare word → substring match on the description. + return (task.description ?? '') + .toLowerCase() + .contains(tok.toLowerCase()); + }); + }).toList(); + } + + static bool _matchAttribute(TaskLike task, String attr, String value) { + switch (attr) { + case 'status': + return (task.status ?? '') == value; + case 'project': + // Taskwarrior treats project as a hierarchy match: "work" matches + // "work" and its children ("work.sub"), but a raw prefix match would + // also wrongly match an unrelated sibling like "workshop" — require a + // dot boundary after the prefix. + final String proj = task.project ?? ''; + return proj == value || proj.startsWith('$value.'); + case 'priority': + return (task.priority ?? '') == value; + default: + // Unknown attribute → don't exclude the task. + return true; + } + } + + static bool _isSet(String? v) => v != null && v.isNotEmpty; + + static bool _isFutureWait(TaskLike task, DateTime clock) { + final DateTime? wait = parseTaskDate(task.wait); + return wait != null && wait.isAfter(clock); + } +} diff --git a/lib/app/utils/taskfunctions/profiles.dart b/lib/app/utils/taskfunctions/profiles.dart index 67e5f74a..a2fb7576 100644 --- a/lib/app/utils/taskfunctions/profiles.dart +++ b/lib/app/utils/taskfunctions/profiles.dart @@ -119,8 +119,9 @@ class Profiles { Future deleteDatabase(String profile) async { String dbPath = await getDatabasesPath(); if (getMode(profile) == 'TW3') { - if (File(Path.join(dbPath, '$profile.db')).existsSync()) { - File('${base.path}/current-profile').deleteSync(); + final dbFile = File(Path.join(dbPath, '$profile.db')); + if (dbFile.existsSync()) { + dbFile.deleteSync(); } } } diff --git a/lib/app/utils/taskfunctions/query.dart b/lib/app/utils/taskfunctions/query.dart index 3040830c..e0e2167b 100644 --- a/lib/app/utils/taskfunctions/query.dart +++ b/lib/app/utils/taskfunctions/query.dart @@ -8,6 +8,7 @@ class Query { File get _selectedSort => File('${_queryStorage.path}/selectedSort'); File get _pendingFilter => File('${_queryStorage.path}/pendingFilter'); + File get _statusFilter => File('${_queryStorage.path}/statusFilter'); File get _waitingFilter => File('${_queryStorage.path}/waitingFilter'); File get _projectFilter => File('${_queryStorage.path}/projectFilter'); File get _tagUnion => File('${_queryStorage.path}/tagUnion'); @@ -29,6 +30,59 @@ class Query { return _selectedSort.readAsStringSync(); } + /// The statuses the task list can be filtered to, in cycle order. + static const String statusPending = 'pending'; + static const String statusCompleted = 'completed'; + static const String statusDeleted = 'deleted'; + + /// The status the task list is currently filtered to. + /// + /// Supersedes the older boolean `pendingFilter`, which could only express + /// pending-vs-completed. On first read this migrates the persisted boolean + /// so an existing profile keeps whichever of the two it was already showing. + String getStatusFilter() { + if (!_statusFilter.existsSync()) { + final String migrated = + getPendingFilter() ? statusPending : statusCompleted; + _statusFilter + ..createSync(recursive: true) + ..writeAsStringSync(migrated); + return migrated; + } + final String value = _statusFilter.readAsStringSync().trim(); + // Guard against a corrupt/unknown value rather than filtering to nothing. + return const [statusPending, statusCompleted, statusDeleted].contains(value) + ? value + : statusPending; + } + + void setStatusFilter(String status) { + if (!_statusFilter.existsSync()) { + _statusFilter.createSync(recursive: true); + } + _statusFilter.writeAsStringSync(status); + // Keep the legacy boolean in step: call sites that still read it (the + // local/Taskserver list, the home widget) then behave sensibly, treating + // "deleted" as not-pending. + _pendingFilter + ..createSync(recursive: true) + ..writeAsStringSync(json.encode(status == statusPending)); + } + + /// Advances to the next status in the cycle. [includeDeleted] is false for + /// sync modes with no deleted view, so those keep the original two-way + /// pending/completed toggle. + void cycleStatusFilter({bool includeDeleted = false}) { + final String current = getStatusFilter(); + final List cycle = includeDeleted + ? const [statusPending, statusCompleted, statusDeleted] + : const [statusPending, statusCompleted]; + final int index = cycle.indexOf(current); + // A value outside this cycle (e.g. "deleted" while in a two-way mode) + // falls back to the start rather than getting stuck. + setStatusFilter(index == -1 ? cycle.first : cycle[(index + 1) % cycle.length]); + } + void togglePendingFilter() { _pendingFilter.writeAsStringSync( json.encode(!getPendingFilter()), diff --git a/lib/app/v3/champion/models/task_for_replica.dart b/lib/app/v3/champion/models/task_for_replica.dart index c1e966c8..54b475b7 100644 --- a/lib/app/v3/champion/models/task_for_replica.dart +++ b/lib/app/v3/champion/models/task_for_replica.dart @@ -1,20 +1,49 @@ import 'dart:convert'; -class TaskForReplica { +import 'package:taskwarrior/app/models/task_like.dart'; +import 'package:taskwarrior/app/models/task_urgency.dart'; +import 'package:taskwarrior/app/v3/models/annotation.dart'; + +/// The TaskChampion-path task model. Stores `entry`/`modified` as epoch +/// seconds; see [TaskLike] for the normalized cross-model accessors. +class TaskForReplica implements TaskLike { final int? modified; + final int? entry; + @override final String? due; + @override final String? start; + @override final String? wait; + @override final String? status; + @override final String? description; + @override final List? tags; + @override final String uuid; + @override final String? priority; + @override final String? project; + // Attributes surfaced from the TaskChampion Rust serializer. + @override + final bool? isBlocked; + @override + final bool? isBlocking; + @override + final List? depends; + @override + final String? recur; + @override + final List? annotations; + TaskForReplica({ this.modified, + this.entry, this.due, this.start, this.wait, @@ -24,13 +53,26 @@ class TaskForReplica { required this.uuid, this.priority, this.project, + this.isBlocked, + this.isBlocking, + this.depends, + this.recur, + this.annotations, }); + static bool _parseBool(dynamic value) { + if (value is bool) return value; + return value?.toString().toLowerCase() == 'true'; + } + factory TaskForReplica.fromJson(Map json) { return TaskForReplica( modified: json['modified'] is int ? json['modified'] as int : int.tryParse('${json['modified']}'), + entry: json['entry'] is int + ? json['entry'] as int + : int.tryParse('${json['entry']}'), due: json['due'] != null ? DateTime.fromMillisecondsSinceEpoch( (int.tryParse(json['due'].toString()) ?? 0) * 1000, @@ -62,12 +104,28 @@ class TaskForReplica { uuid: json['uuid']?.toString() ?? '', priority: json['priority']?.toString(), project: json['project']?.toString(), + isBlocked: + json['is_blocked'] != null ? _parseBool(json['is_blocked']) : null, + isBlocking: + json['is_blocking'] != null ? _parseBool(json['is_blocking']) : null, + depends: (json['depends'] is List) + ? (json['depends'] as List).map((e) => e.toString()).toList() + : null, + recur: (json['recur'] != null && json['recur'].toString().isNotEmpty) + ? json['recur'].toString() + : null, + annotations: (json['annotations'] is List) + ? (json['annotations'] as List) + .map((e) => Annotation.fromJson(Map.from(e))) + .toList() + : null, ); } Map toJson() { return { if (modified != null) 'modified': modified, + if (entry != null) 'entry': entry, if (due != null) 'due': due, if (start != null) 'start': start, if (wait != null) 'wait': wait, @@ -77,11 +135,18 @@ class TaskForReplica { 'uuid': uuid, if (priority != null) 'priority': priority, if (project != null) 'project': project, + if (isBlocked != null) 'is_blocked': isBlocked, + if (isBlocking != null) 'is_blocking': isBlocking, + if (depends != null) 'depends': depends, + if (recur != null) 'recur': recur, + if (annotations != null) + 'annotations': annotations!.map((a) => a.toJson()).toList(), }; } TaskForReplica copyWith({ int? modified, + int? entry, String? due, String? start, String? wait, @@ -90,9 +155,16 @@ class TaskForReplica { List? tags, String? uuid, String? priority, + String? project, + bool? isBlocked, + bool? isBlocking, + List? depends, + String? recur, + List? annotations, }) { return TaskForReplica( modified: modified ?? this.modified, + entry: entry ?? this.entry, due: due ?? this.due, start: start ?? this.start, wait: wait ?? this.wait, @@ -101,10 +173,29 @@ class TaskForReplica { tags: tags ?? this.tags, uuid: uuid ?? this.uuid, priority: priority ?? this.priority, - project: project ?? project, + project: project ?? this.project, + isBlocked: isBlocked ?? this.isBlocked, + isBlocking: isBlocking ?? this.isBlocking, + depends: depends ?? this.depends, + recur: recur ?? this.recur, + annotations: annotations ?? this.annotations, ); } + /// Normalized creation time. This model stores `entry` as epoch seconds. + @override + DateTime? get entryDate => epochToDate(entry); + + /// Normalized last-modified time, stored as epoch seconds. + @override + DateTime? get modifiedDate => epochToDate(modified); + + /// Computes this task's urgency with Taskwarrior's standard algorithm. + /// The formula and its coefficients live in [computeTaskUrgency], shared + /// with every other task model via [TaskLike]. + double computeUrgency({DateTime? clock}) => + computeTaskUrgency(this, clock: clock); + @override String toString() => 'TaskForReplica(${jsonEncode(toJson())})'; @@ -120,7 +211,11 @@ class TaskForReplica { other.description == description && _listEquals(other.tags, tags) && other.uuid == uuid && - other.priority == priority; + other.priority == priority && + other.isBlocked == isBlocked && + other.isBlocking == isBlocking && + _listEquals(other.depends, depends) && + other.recur == recur; } @override diff --git a/lib/app/v3/db/task_database.dart b/lib/app/v3/db/task_database.dart index dc1b97b2..fcf56804 100644 --- a/lib/app/v3/db/task_database.dart +++ b/lib/app/v3/db/task_database.dart @@ -81,7 +81,7 @@ class TaskDatabase { Future openForProfile(String profile) async { String path = await getDatabasePathForProfile(profile); - _open(path); + await _open(path); } Future ensureDatabaseIsOpen() async { @@ -169,7 +169,7 @@ class TaskDatabase { List taskTags = task.tags?.map((e) => e.toString()).toList() ?? []; debugPrint("Database update $taskTags"); List taskDepends = - task.tags?.map((e) => e.toString()).toList() ?? []; + task.depends?.map((e) => e.toString()).toList() ?? []; debugPrint("Database update $taskDepends"); List> taskAnnotations = task.annotations != null ? task.annotations! @@ -335,7 +335,7 @@ class TaskDatabase { // Get tags using a composite key Future> getTagsForTask(String uuid, int id) async { - ensureDatabaseIsOpen(); + await ensureDatabaseIsOpen(); final db = _database; if (db == null) { return []; @@ -355,7 +355,7 @@ class TaskDatabase { Future setTagsForTask(String uuid, int id, List tags) async { debugPrint('Setting tags for task $uuid: $tags'); try { - ensureDatabaseIsOpen(); + await ensureDatabaseIsOpen(); final db = _database; if (db == null) { return; @@ -384,7 +384,7 @@ class TaskDatabase { // depends methods Future> getDependsForTask(String uuid, int id) async { - ensureDatabaseIsOpen(); + await ensureDatabaseIsOpen(); final db = _database; if (db == null) { return []; @@ -403,7 +403,7 @@ class TaskDatabase { Future setDependsForTask( String uuid, int id, List depends) async { try { - ensureDatabaseIsOpen(); + await ensureDatabaseIsOpen(); final db = _database; if (db == null) { return; @@ -431,7 +431,7 @@ class TaskDatabase { // annotations methods Future>> getAnnotationsForTask( String uuid, int id) async { - ensureDatabaseIsOpen(); + await ensureDatabaseIsOpen(); final db = _database; if (db == null) { return >[]; @@ -453,7 +453,7 @@ class TaskDatabase { Future setAnnotationsForTask( String uuid, int id, List> annotations) async { try { - ensureDatabaseIsOpen(); + await ensureDatabaseIsOpen(); final db = _database; if (db == null) { return; diff --git a/lib/app/v3/db/update.dart b/lib/app/v3/db/update.dart deleted file mode 100644 index 7d0f549f..00000000 --- a/lib/app/v3/db/update.dart +++ /dev/null @@ -1,87 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:taskwarrior/app/v3/db/task_database.dart'; -import 'package:taskwarrior/app/v3/models/task.dart'; -import 'package:taskwarrior/app/v3/net/add_task.dart'; -import 'package:taskwarrior/app/v3/net/complete.dart'; -import 'package:taskwarrior/app/v3/net/delete.dart'; -import 'package:taskwarrior/app/v3/net/modify.dart'; -import 'package:timezone/timezone.dart'; - -Future updateTasksInDatabase(List tasks) async { - debugPrint( - "Updating tasks in database... Total tasks from server: ${tasks.length}"); - var taskDatabase = TaskDatabase(); - await taskDatabase.open(); - // find tasks without UUID - List tasksWithoutUUID = await taskDatabase.findTasksWithoutUUIDs(); - - //add tasks without UUID to the server and delete them from database - for (var task in tasksWithoutUUID) { - try { - await addTaskAndDeleteFromDatabase( - task.description, - task.project != null ? task.project! : '', - task.due!, - task.priority!, - task.tags != null ? task.tags! : []); - } catch (e) { - debugPrint( - 'Failed to add task without UUID to server: $e ${task.tags} ${task.project}'); - } - } - - // update existing tasks in db - for (var task in tasks) { - var existingTask = await taskDatabase.getTaskByUuid(task.uuid!); - if (existingTask != null) { - if (task.modified!.compareTo(existingTask.modified!) > 0) { - await taskDatabase.updateTask(task); - } - } else { - // add new tasks to db - await taskDatabase.insertTask(task); - } - } - - var localTasks = await taskDatabase.fetchTasksFromDatabase(); - var localTasksMap = {for (var task in localTasks) task.uuid: task}; - - for (var serverTask in tasks) { - var localTask = localTasksMap[serverTask.uuid]; - - if (localTask == null) { - // Task doesn't exist in the local database, insert it - debugPrint( - 'Inserting new task from server: ${serverTask.description}, modified: ${serverTask.modified}'); - await taskDatabase.insertTask(serverTask); - } else { - var serverTaskModifiedDate = DateTime.parse(serverTask.modified!); - var localTaskModifiedDate = DateTime.parse(localTask.modified!); - - if (serverTaskModifiedDate.isAfter(localTaskModifiedDate)) { - // Server task is newer, update local database - await taskDatabase.updateTask(serverTask); - } else if (serverTaskModifiedDate.isBefore(localTaskModifiedDate)) { - // local task is newer, update server - debugPrint( - 'Updating task on server: ${localTask.description}, modified: ${localTask.modified}'); - await modifyTaskOnTaskwarrior( - localTask.description, - localTask.project!, - localTask.due!, - localTask.priority!, - localTask.status, - localTask.uuid!, - localTask.id.toString(), - localTask.tags != null - ? localTask.tags!.map((e) => e.toString()).toList() - : []); - if (localTask.status == 'completed') { - completeTask('email', localTask.uuid!); - } else if (localTask.status == 'deleted') { - deleteTask('email', localTask.uuid!); - } - } - } - } -} diff --git a/lib/app/v3/models/task.dart b/lib/app/v3/models/task.dart index 91c64045..f13fc418 100644 --- a/lib/app/v3/models/task.dart +++ b/lib/app/v3/models/task.dart @@ -1,25 +1,44 @@ import 'package:flutter/material.dart'; +import 'package:taskwarrior/app/models/task_like.dart'; import "./annotation.dart"; -class TaskForC { +/// The local/Taskserver task model. Stores `entry`/`modified` as strings; see +/// [TaskLike] for the normalized cross-model accessors. +class TaskForC implements TaskLike { final int id; + @override final String description; + @override final String? project; + @override final String status; + @override final String? uuid; + + /// Urgency as supplied by the server for this path. The TaskChampion path + /// has no stored urgency and computes it instead — use [computeUrgency] when + /// a value is needed regardless of sync mode. final double? urgency; + @override final String? priority; + @override final String? due; final String? end; final String entry; final String? modified; + @override final List? tags; - // newer feilds in CCSync Model + // newer fields in the TaskChampion model + @override final String? start; + @override final String? wait; final String? rtype; + @override final String? recur; + @override final List? depends; + @override final List? annotations; TaskForC({ @@ -51,7 +70,7 @@ class TaskForC { project: json['project'], status: json['status'], uuid: json['uuid'], - urgency: json['urgency'].toDouble(), + urgency: (json['urgency'] as num?)?.toDouble(), priority: json['priority'], due: json['due'], end: json['end'], @@ -64,7 +83,10 @@ class TaskForC { recur: json['recur'], depends: json['depends']?.map((d) => d.toString()).toList() ?? [], - annotations: []); + annotations: (json['annotations'] as List?) + ?.map((a) => Annotation.fromJson(Map.from(a))) + .toList() ?? + []); } Map toJson() { @@ -93,6 +115,24 @@ class TaskForC { }; } + /// Normalized creation time. This model stores `entry` as a string, in + /// either ISO-8601 or Taskwarrior's compact form. + @override + DateTime? get entryDate => parseTaskDate(entry); + + /// Normalized last-modified time, stored as a string like [entry]. + @override + DateTime? get modifiedDate => parseTaskDate(modified); + + /// Unknown on this path: deciding whether a dependency is still *unresolved* + /// requires the full task set, which this model does not carry. Only the + /// TaskChampion path reports a definite value (see [TaskLike.isBlocked]). + @override + bool? get isBlocked => null; + + @override + bool? get isBlocking => null; + @override String toString() { return "TaskForC(${toJson().toString()})"; diff --git a/lib/app/v3/net/add_task.dart b/lib/app/v3/net/add_task.dart deleted file mode 100644 index 370a5c27..00000000 --- a/lib/app/v3/net/add_task.dart +++ /dev/null @@ -1,37 +0,0 @@ -import 'dart:convert'; -import 'package:http/http.dart' as http; -import 'package:flutter/material.dart'; -import 'package:taskwarrior/app/utils/taskchampion/credentials_storage.dart'; -import 'package:taskwarrior/app/v3/db/task_database.dart'; - -Future addTaskAndDeleteFromDatabase(String description, String project, - String due, String priority, List tags) async { - var baseUrl = await CredentialsStorage.getApiUrl(); - String apiUrl = '$baseUrl/add-task'; - var c = await CredentialsStorage.getClientId(); - var e = await CredentialsStorage.getEncryptionSecret(); - debugPrint("Database Adding Tags $tags $description"); - debugPrint(c); - debugPrint(e); - var res = await http.post( - Uri.parse(apiUrl), - headers: { - 'Content-Type': 'text/plain', - }, - body: jsonEncode({ - 'email': 'email', - 'encryptionSecret': e, - 'UUID': c, - 'description': description, - 'project': project, - 'due': due, - 'priority': priority, - 'tags': tags - }), - ); - debugPrint('Database res ${res.body}'); - var taskDatabase = TaskDatabase(); - await taskDatabase.open(); - await taskDatabase.deleteTask( - description: description, due: due, project: project, priority: priority); -} diff --git a/lib/app/v3/net/complete.dart b/lib/app/v3/net/complete.dart deleted file mode 100644 index b3718146..00000000 --- a/lib/app/v3/net/complete.dart +++ /dev/null @@ -1,41 +0,0 @@ -import 'dart:convert'; -import 'package:http/http.dart' as http; -import 'package:flutter/material.dart'; -import 'package:taskwarrior/app/utils/taskchampion/credentials_storage.dart'; -import 'package:path/path.dart'; - -Future completeTask(String email, String taskUuid) async { - var c = await CredentialsStorage.getClientId(); - var e = await CredentialsStorage.getEncryptionSecret(); - var baseUrl = await CredentialsStorage.getApiUrl(); - final url = Uri.parse('$baseUrl/complete-task'); - final body = jsonEncode({ - 'email': email, - 'encryptionSecret': e, - 'UUID': c, - 'taskuuid': taskUuid, - }); - - try { - final response = await http.post( - url, - headers: { - 'Content-Type': 'application/json', - }, - body: body, - ); - - if (response.statusCode == 200) { - debugPrint('Task completed successfully on server'); - } else { - debugPrint('Failed to complete task: ${response.statusCode}'); - ScaffoldMessenger.of(context as BuildContext).showSnackBar(const SnackBar( - content: Text( - "Failed to complete task!", - style: TextStyle(color: Colors.red), - ))); - } - } catch (e) { - debugPrint('Error completing task: $e'); - } -} diff --git a/lib/app/v3/net/delete.dart b/lib/app/v3/net/delete.dart deleted file mode 100644 index 8873377b..00000000 --- a/lib/app/v3/net/delete.dart +++ /dev/null @@ -1,35 +0,0 @@ -import 'dart:convert'; -import 'package:http/http.dart' as http; -import 'package:flutter/material.dart'; -import 'package:taskwarrior/app/utils/taskchampion/credentials_storage.dart'; - -Future deleteTask(String email, String taskUuid) async { - var baseUrl = await CredentialsStorage.getApiUrl(); - var c = await CredentialsStorage.getClientId(); - var e = await CredentialsStorage.getEncryptionSecret(); - final url = Uri.parse('$baseUrl/delete-task'); - final body = jsonEncode({ - 'email': email, - 'encryptionSecret': e, - 'UUID': c, - 'taskuuid': taskUuid, - }); - - try { - final response = await http.post( - url, - headers: { - 'Content-Type': 'application/json', - }, - body: body, - ); - - if (response.statusCode == 200) { - debugPrint('Task deleted successfully on server'); - } else { - debugPrint('Failed to delete task: ${response.statusCode}'); - } - } catch (e) { - debugPrint('Error deleting task: $e'); - } -} diff --git a/lib/app/v3/net/fetch.dart b/lib/app/v3/net/fetch.dart deleted file mode 100644 index 54adde77..00000000 --- a/lib/app/v3/net/fetch.dart +++ /dev/null @@ -1,32 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter/material.dart'; -import 'package:taskwarrior/app/utils/taskchampion/credentials_storage.dart'; -import 'package:taskwarrior/app/v3/models/task.dart'; -import 'package:taskwarrior/app/v3/net/origin.dart'; -import 'package:http/http.dart' as http; - -Future> fetchTasks(String uuid, String encryptionSecret) async { - var baseUrl = await CredentialsStorage.getApiUrl(); - try { - String url = - '$baseUrl/tasks?email=email&origin=$origin&UUID=$uuid&encryptionSecret=$encryptionSecret'; - - var response = await http.get(Uri.parse(url), headers: { - "Content-Type": "application/json", - }).timeout(const Duration(milliseconds: 10000)); - debugPrint("Fetch tasks response: ${response.statusCode}"); - debugPrint("Fetch tasks body: ${response.body}"); - if (response.statusCode == 200) { - List allTasks = jsonDecode(response.body); - debugPrint(allTasks.toString()); - return allTasks.map((task) => TaskForC.fromJson(task)).toList(); - } else { - throw Exception('Failed to load tasks'); - } - } catch (e, s) { - debugPrint('Error fetching tasks: $e\n $s'); - - return []; - } -} diff --git a/lib/app/v3/net/modify.dart b/lib/app/v3/net/modify.dart deleted file mode 100644 index 1d32976a..00000000 --- a/lib/app/v3/net/modify.dart +++ /dev/null @@ -1,73 +0,0 @@ -import 'dart:convert'; -import 'package:get/get.dart'; -import 'package:http/http.dart' as http; -import 'package:path/path.dart'; -import 'package:flutter/material.dart'; -import 'package:taskwarrior/app/utils/taskchampion/credentials_storage.dart'; -import 'package:taskwarrior/app/v3/db/task_database.dart'; - -Future modifyTaskOnTaskwarrior( - String description, - String project, - String due, - String priority, - String status, - String taskuuid, - String id, - List newTags) async { - var baseUrl = await CredentialsStorage.getApiUrl(); - var c = await CredentialsStorage.getClientId(); - var e = await CredentialsStorage.getEncryptionSecret(); - String apiUrl = '$baseUrl/modify-task'; - debugPrint(c); - debugPrint(e); - debugPrint("modifyTaskOnTaskwarrior called"); - debugPrint("description: $description project: $project due: $due " - "priority: $priority status: $status taskuuid: $taskuuid id: $id tags: $newTags" - "body: ${jsonEncode({ - "email": "e", - "encryptionSecret": e, - "UUID": c, - "description": description, - "priority": priority, - "project": project, - "due": due, - "status": status, - "taskuuid": taskuuid, - "taskId": id, - "tags": newTags.isNotEmpty ? newTags : null - })}"); - final response = await http.post( - Uri.parse(apiUrl), - headers: { - 'Content-Type': 'text/plain', - }, - body: jsonEncode({ - "email": "e", - "encryptionSecret": e, - "UUID": c, - "description": description, - "priority": priority, - "project": project, - "due": due, - "status": status, - "taskuuid": taskuuid, - "taskId": id, - "tags": newTags.isNotEmpty ? newTags : null - }), - ); - debugPrint('Modify task response body: ${response.body}'); - if (response.statusCode < 200 || response.statusCode >= 300) { - Get.showSnackbar(GetSnackBar( - title: 'Error', - message: - 'Failed to modify task on Taskwarrior server. ${response.statusCode}', - duration: Duration(seconds: 3), - )); - } - - var taskDatabase = TaskDatabase(); - await taskDatabase.open(); - await taskDatabase.deleteTask( - description: description, due: due, project: project, priority: priority); -} diff --git a/lib/app/v3/net/origin.dart b/lib/app/v3/net/origin.dart deleted file mode 100644 index 4cc70540..00000000 --- a/lib/app/v3/net/origin.dart +++ /dev/null @@ -1 +0,0 @@ -String origin = 'http://localhost:8080'; diff --git a/lib/rust_bridge/api.dart b/lib/rust_bridge/api.dart index 8788ef8b..429c6d9c 100644 --- a/lib/rust_bridge/api.dart +++ b/lib/rust_bridge/api.dart @@ -6,29 +6,35 @@ import 'frb_generated.dart'; import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; -// These functions are ignored because they are not marked as `pub`: `get_all_tasks`, `parse_datetime` +// These functions are ignored because they are not marked as `pub`: `add_task_impl`, `delete_task_impl`, `get_all_tasks_json_impl`, `parse_datetime`, `sync_impl`, `update_task_impl` +/// Return every task in the replica as a JSON array string. Future getAllTasksJson({required String taskdbDirPath}) => RustLib.instance.api.crateApiGetAllTasksJson(taskdbDirPath: taskdbDirPath); -Future deleteTask( +/// Delete the task with the given UUID. A no-op if the task does not exist. +Future deleteTask( {required String uuidSt, required String taskdbDirPath}) => RustLib.instance.api .crateApiDeleteTask(uuidSt: uuidSt, taskdbDirPath: taskdbDirPath); -Future updateTask( +/// Update the mutable fields of an existing task from the supplied key/value map. +Future updateTask( {required String uuidSt, required String taskdbDirPath, required Map map}) => RustLib.instance.api.crateApiUpdateTask( uuidSt: uuidSt, taskdbDirPath: taskdbDirPath, map: map); -Future addTask( +/// Create a new task from the supplied key/value map. The map must contain a +/// `uuid` entry. +Future addTask( {required String taskdbDirPath, required Map map}) => RustLib.instance.api .crateApiAddTask(taskdbDirPath: taskdbDirPath, map: map); -Future sync_( +/// Synchronise the local replica with a remote TaskChampion sync server. +Future sync_( {required String taskdbDirPath, required String url, required String clientId, diff --git a/lib/rust_bridge/frb_generated.dart b/lib/rust_bridge/frb_generated.dart index 2dbdeccc..1c4a7038 100644 --- a/lib/rust_bridge/frb_generated.dart +++ b/lib/rust_bridge/frb_generated.dart @@ -79,21 +79,21 @@ class RustLib extends BaseEntrypoint { } abstract class RustLibApi extends BaseApi { - Future crateApiAddTask( + Future crateApiAddTask( {required String taskdbDirPath, required Map map}); - Future crateApiDeleteTask( + Future crateApiDeleteTask( {required String uuidSt, required String taskdbDirPath}); Future crateApiGetAllTasksJson({required String taskdbDirPath}); - Future crateApiSync( + Future crateApiSync( {required String taskdbDirPath, required String url, required String clientId, required String encryptionSecret}); - Future crateApiUpdateTask( + Future crateApiUpdateTask( {required String uuidSt, required String taskdbDirPath, required Map map}); @@ -108,7 +108,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { }); @override - Future crateApiAddTask( + Future crateApiAddTask( {required String taskdbDirPath, required Map map}) { return handler.executeNormal(NormalTask( callFfi: (port_) { @@ -119,8 +119,8 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { funcId: 1, port: port_); }, codec: SseCodec( - decodeSuccessData: sse_decode_i_8, - decodeErrorData: null, + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_String, ), constMeta: kCrateApiAddTaskConstMeta, argValues: [taskdbDirPath, map], @@ -134,7 +134,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { ); @override - Future crateApiDeleteTask( + Future crateApiDeleteTask( {required String uuidSt, required String taskdbDirPath}) { return handler.executeNormal(NormalTask( callFfi: (port_) { @@ -145,8 +145,8 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { funcId: 2, port: port_); }, codec: SseCodec( - decodeSuccessData: sse_decode_i_8, - decodeErrorData: null, + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_String, ), constMeta: kCrateApiDeleteTaskConstMeta, argValues: [uuidSt, taskdbDirPath], @@ -170,7 +170,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { }, codec: SseCodec( decodeSuccessData: sse_decode_String, - decodeErrorData: sse_decode_AnyhowException, + decodeErrorData: sse_decode_String, ), constMeta: kCrateApiGetAllTasksJsonConstMeta, argValues: [taskdbDirPath], @@ -184,7 +184,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { ); @override - Future crateApiSync( + Future crateApiSync( {required String taskdbDirPath, required String url, required String clientId, @@ -200,8 +200,8 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { funcId: 4, port: port_); }, codec: SseCodec( - decodeSuccessData: sse_decode_i_8, - decodeErrorData: null, + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_String, ), constMeta: kCrateApiSyncConstMeta, argValues: [taskdbDirPath, url, clientId, encryptionSecret], @@ -215,7 +215,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { ); @override - Future crateApiUpdateTask( + Future crateApiUpdateTask( {required String uuidSt, required String taskdbDirPath, required Map map}) { @@ -229,8 +229,8 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { funcId: 5, port: port_); }, codec: SseCodec( - decodeSuccessData: sse_decode_i_8, - decodeErrorData: null, + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_String, ), constMeta: kCrateApiUpdateTaskConstMeta, argValues: [uuidSt, taskdbDirPath, map], @@ -243,12 +243,6 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { argNames: ["uuidSt", "taskdbDirPath", "map"], ); - @protected - AnyhowException dco_decode_AnyhowException(dynamic raw) { - // Codec=Dco (DartCObject based), see doc to use other codecs - return AnyhowException(raw as String); - } - @protected Map dco_decode_Map_String_String_None(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs @@ -262,12 +256,6 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return raw as String; } - @protected - int dco_decode_i_8(dynamic raw) { - // Codec=Dco (DartCObject based), see doc to use other codecs - return raw as int; - } - @protected Uint8List dco_decode_list_prim_u_8_strict(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs @@ -305,13 +293,6 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return; } - @protected - AnyhowException sse_decode_AnyhowException(SseDeserializer deserializer) { - // Codec=Sse (Serialization based), see doc to use other codecs - var inner = sse_decode_String(deserializer); - return AnyhowException(inner); - } - @protected Map sse_decode_Map_String_String_None( SseDeserializer deserializer) { @@ -327,12 +308,6 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return utf8.decoder.convert(inner); } - @protected - int sse_decode_i_8(SseDeserializer deserializer) { - // Codec=Sse (Serialization based), see doc to use other codecs - return deserializer.buffer.getInt8(); - } - @protected Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer) { // Codec=Sse (Serialization based), see doc to use other codecs @@ -385,13 +360,6 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return deserializer.buffer.getUint8() != 0; } - @protected - void sse_encode_AnyhowException( - AnyhowException self, SseSerializer serializer) { - // Codec=Sse (Serialization based), see doc to use other codecs - sse_encode_String(self.message, serializer); - } - @protected void sse_encode_Map_String_String_None( Map self, SseSerializer serializer) { @@ -406,12 +374,6 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_list_prim_u_8_strict(utf8.encoder.convert(self), serializer); } - @protected - void sse_encode_i_8(int self, SseSerializer serializer) { - // Codec=Sse (Serialization based), see doc to use other codecs - serializer.buffer.putInt8(self); - } - @protected void sse_encode_list_prim_u_8_strict( Uint8List self, SseSerializer serializer) { diff --git a/lib/rust_bridge/frb_generated.io.dart b/lib/rust_bridge/frb_generated.io.dart index 77034b1d..0877d754 100644 --- a/lib/rust_bridge/frb_generated.io.dart +++ b/lib/rust_bridge/frb_generated.io.dart @@ -18,18 +18,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { required super.portManager, }); - @protected - AnyhowException dco_decode_AnyhowException(dynamic raw); - @protected Map dco_decode_Map_String_String_None(dynamic raw); @protected String dco_decode_String(dynamic raw); - @protected - int dco_decode_i_8(dynamic raw); - @protected Uint8List dco_decode_list_prim_u_8_strict(dynamic raw); @@ -45,9 +39,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected void dco_decode_unit(dynamic raw); - @protected - AnyhowException sse_decode_AnyhowException(SseDeserializer deserializer); - @protected Map sse_decode_Map_String_String_None( SseDeserializer deserializer); @@ -55,9 +46,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected String sse_decode_String(SseDeserializer deserializer); - @protected - int sse_decode_i_8(SseDeserializer deserializer); - @protected Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer); @@ -81,10 +69,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected bool sse_decode_bool(SseDeserializer deserializer); - @protected - void sse_encode_AnyhowException( - AnyhowException self, SseSerializer serializer); - @protected void sse_encode_Map_String_String_None( Map self, SseSerializer serializer); @@ -92,9 +76,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected void sse_encode_String(String self, SseSerializer serializer); - @protected - void sse_encode_i_8(int self, SseSerializer serializer); - @protected void sse_encode_list_prim_u_8_strict( Uint8List self, SseSerializer serializer); diff --git a/lib/rust_bridge/frb_generated.web.dart b/lib/rust_bridge/frb_generated.web.dart index 34fa9bca..7d7b51f6 100644 --- a/lib/rust_bridge/frb_generated.web.dart +++ b/lib/rust_bridge/frb_generated.web.dart @@ -26,14 +26,10 @@ import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated_web.dart'; - @protected AnyhowException dco_decode_AnyhowException(dynamic raw); - -@protected Map dco_decode_Map_String_String_None(dynamic raw); + @protected Map dco_decode_Map_String_String_None(dynamic raw); @protected String dco_decode_String(dynamic raw); -@protected int dco_decode_i_8(dynamic raw); - @protected Uint8List dco_decode_list_prim_u_8_strict(dynamic raw); @protected List<(String,String)> dco_decode_list_record_string_string(dynamic raw); @@ -44,14 +40,10 @@ import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated_web.dart'; @protected void dco_decode_unit(dynamic raw); -@protected AnyhowException sse_decode_AnyhowException(SseDeserializer deserializer); - @protected Map sse_decode_Map_String_String_None(SseDeserializer deserializer); @protected String sse_decode_String(SseDeserializer deserializer); -@protected int sse_decode_i_8(SseDeserializer deserializer); - @protected Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer); @protected List<(String,String)> sse_decode_list_record_string_string(SseDeserializer deserializer); @@ -66,14 +58,10 @@ import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated_web.dart'; @protected bool sse_decode_bool(SseDeserializer deserializer); -@protected void sse_encode_AnyhowException(AnyhowException self, SseSerializer serializer); - @protected void sse_encode_Map_String_String_None(Map self, SseSerializer serializer); @protected void sse_encode_String(String self, SseSerializer serializer); -@protected void sse_encode_i_8(int self, SseSerializer serializer); - @protected void sse_encode_list_prim_u_8_strict(Uint8List self, SseSerializer serializer); @protected void sse_encode_list_record_string_string(List<(String,String)> self, SseSerializer serializer); diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt index 573a8199..c69fc81a 100644 --- a/linux/CMakeLists.txt +++ b/linux/CMakeLists.txt @@ -77,6 +77,26 @@ target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) # Run the Flutter tool portions of the build. This must not be removed. add_dependencies(${BINARY_NAME} flutter_assemble) +# Rebuild the tc_helper Rust library from rust/ as part of the build, so the +# native library cannot drift from its source. flutter_rust_bridge's desktop +# loader reads rust/target/release (see ioDirectory in frb_generated.dart), so +# a plain `cargo build --release` is all that is required here. +# +# Intentionally non-fatal: if cargo is absent the target prints a warning and +# succeeds, leaving any existing library in place, so contributors without a +# Rust toolchain can still build the app. +find_program(CARGO_EXECUTABLE cargo HINTS "$ENV{HOME}/.cargo/bin") +if(CARGO_EXECUTABLE) + add_custom_target(tc_helper_rust ALL + COMMAND "${CARGO_EXECUTABLE}" build --release + WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/../rust" + COMMENT "Building tc_helper Rust library" + ) + add_dependencies(${BINARY_NAME} tc_helper_rust) +else() + message(WARNING "cargo not found; skipping the tc_helper rebuild and using any existing library") +endif() + # Only the install-generated bundle's copy of the executable will launch # correctly, since the resources must in the right relative locations. To avoid # people trying to run the unbundled copy, put it in a subdirectory instead of diff --git a/macos/Podfile b/macos/Podfile index c795730d..e68409c6 100644 --- a/macos/Podfile +++ b/macos/Podfile @@ -31,6 +31,14 @@ target 'Runner' do use_modular_headers! flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) + + # Rebuild the tc_helper Rust library from rust/ on every build, so the native + # library can never drift from its source. The script is intentionally + # non-fatal: without a Rust toolchain it warns and the build proceeds using + # the existing binary, so contributors without Rust are unaffected. + script_phase :name => 'Build tc_helper (Rust)', + :script => '"$PODS_TARGET_SRCROOT"/../scripts/build_tc_helper_apple.sh macos', + :execution_position => :before_compile target 'RunnerTests' do inherit! :search_paths end diff --git a/pubspec.lock b/pubspec.lock index c7182030..0659d451 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,18 +5,18 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f + sha256: c209688d9f5a5f26b2fb47a188131a6fb9e876ae9e47af3737c0b4f58a93470d url: "https://pub.dev" source: hosted - version: "85.0.0" + version: "91.0.0" analyzer: dependency: transitive description: name: analyzer - sha256: "974859dc0ff5f37bc4313244b3218c791810d03ab3470a579580279ba971a48d" + sha256: f51c8499b35f9b26820cfe914828a6a98a94efd5cc78b37bb7d03debae3a1d08 url: "https://pub.dev" source: hosted - version: "7.7.1" + version: "8.4.1" ansicolor: dependency: transitive description: @@ -25,6 +25,38 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.3" + app_links: + dependency: "direct main" + description: + name: app_links + sha256: "5f88447519add627fe1cbcab4fd1da3d4fed15b9baf29f28b22535c95ecee3e8" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + app_links_linux: + dependency: transitive + description: + name: app_links_linux + sha256: f5f7173a78609f3dfd4c2ff2c95bd559ab43c80a87dc6a095921d96c05688c81 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + app_links_platform_interface: + dependency: transitive + description: + name: app_links_platform_interface + sha256: "05f5379577c513b534a29ddea68176a4d4802c46180ee8e2e966257158772a3f" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + app_links_web: + dependency: transitive + description: + name: app_links_web + sha256: af060ed76183f9e2b87510a9480e56a5352b6c249778d07bd2c95fc35632a555 + url: "https://pub.dev" + source: hosted + version: "1.0.4" archive: dependency: transitive description: @@ -65,6 +97,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.0" + build_cli_annotations: + dependency: transitive + description: + name: build_cli_annotations + sha256: e563c2e01de8974566a1998410d3f6f03521788160a02503b0b1f1a46c7b3d95 + url: "https://pub.dev" + source: hosted + version: "2.1.1" build_config: dependency: transitive description: @@ -125,10 +165,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" checked_yaml: dependency: transitive description: @@ -253,10 +293,10 @@ packages: dependency: transitive description: name: dart_style - sha256: "8a0e5fba27e8ee025d2ffb4ee820b4e6e2cf5e4246a6b1a477eb66866947e0bb" + sha256: a9c30492da18ff84efe2422ba2d319a89942d93e58eb0b73d32abe822ef54b7b url: "https://pub.dev" source: hosted - version: "3.1.1" + version: "3.1.3" dartx: dependency: transitive description: @@ -519,6 +559,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.32" + flutter_rust_bridge: + dependency: "direct main" + description: + name: flutter_rust_bridge + sha256: "37ef40bc6f863652e865f0b2563ea07f0d3c58d8efad803cc01933a4b2ee067e" + url: "https://pub.dev" + source: hosted + version: "2.11.1" flutter_slidable: dependency: "direct main" description: @@ -601,6 +649,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.2" + gtk: + dependency: transitive + description: + name: gtk + sha256: "4ff85b2a16724029dd9e5bbb5a94b6918f9973f74ba571c949d2002801879cf5" + url: "https://pub.dev" + source: hosted + version: "2.2.0" hashcodes: dependency: transitive description: @@ -721,14 +777,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" - js: - dependency: transitive - description: - name: js - sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 - url: "https://pub.dev" - source: hosted - version: "0.6.7" json_annotation: dependency: transitive description: @@ -797,18 +845,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: @@ -829,10 +877,10 @@ packages: dependency: "direct main" description: name: mockito - sha256: "2314cbe9165bcd16106513df9cf3c3224713087f09723b128928dc11a4379f99" + sha256: eff30d002f0c8bf073b6f929df4483b543133fcafce056870163587b03f1d422 url: "https://pub.dev" source: hosted - version: "5.5.0" + version: "5.6.4" nm: dependency: transitive description: @@ -1141,10 +1189,10 @@ packages: dependency: transitive description: name: shelf_web_socket - sha256: "9ca081be41c60190ebcb4766b2486a7d50261db7bd0f5d9615f2d653637a84c1" + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" url: "https://pub.dev" source: hosted - version: "1.0.4" + version: "3.0.0" sizer: dependency: "direct main" description: @@ -1330,26 +1378,26 @@ packages: dependency: "direct main" description: name: test - sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7" + sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7" url: "https://pub.dev" source: hosted - version: "1.26.3" + version: "1.30.0" test_api: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.10" test_core: dependency: transitive description: name: test_core - sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0" + sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51" url: "https://pub.dev" source: hosted - version: "0.6.12" + version: "0.6.16" textfield_tags: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 12e80726..1a45a235 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -66,7 +66,7 @@ dependencies: built_collection: ^5.1.1 textfield_tags: ^3.0.1 path_provider: ^2.1.5 - flutter_rust_bridge: ^2.11.1 + flutter_rust_bridge: 2.11.1 ffi: any # Required for FFI app_links: ^6.4.1 diff --git a/rust/Cargo.lock b/rust/Cargo.lock index e77d3b10..560330a9 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -49,12 +49,6 @@ dependencies = [ "backtrace", ] -[[package]] -name = "allocator-api2" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" - [[package]] name = "android-tzdata" version = "0.1.1" @@ -146,487 +140,18 @@ dependencies = [ "backtrace", ] -[[package]] -name = "async-stream" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" -dependencies = [ - "async-stream-impl", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-stream-impl" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.106", -] - -[[package]] -name = "async-trait" -version = "0.1.89" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.106", -] - [[package]] name = "atomic" version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c59bdb34bc650a32731b31bd8f0829cc15d24a708ee31559e0bb34f2bc320cba" -[[package]] -name = "atomic-waker" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - [[package]] name = "autocfg" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" -[[package]] -name = "aws-config" -version = "1.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bc1b40fb26027769f16960d2f4a6bc20c4bb755d403e552c8c1a73af433c246" -dependencies = [ - "aws-credential-types", - "aws-runtime", - "aws-sdk-sso", - "aws-sdk-ssooidc", - "aws-sdk-sts", - "aws-smithy-async", - "aws-smithy-http", - "aws-smithy-json", - "aws-smithy-runtime", - "aws-smithy-runtime-api", - "aws-smithy-types", - "aws-types", - "bytes", - "fastrand", - "hex", - "http 1.3.1", - "ring", - "time", - "tokio", - "tracing", - "url", - "zeroize", -] - -[[package]] -name = "aws-credential-types" -version = "1.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d025db5d9f52cbc413b167136afb3d8aeea708c0d8884783cf6253be5e22f6f2" -dependencies = [ - "aws-smithy-async", - "aws-smithy-runtime-api", - "aws-smithy-types", - "zeroize", -] - -[[package]] -name = "aws-lc-rs" -version = "1.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c953fe1ba023e6b7730c0d4b031d06f267f23a46167dcbd40316644b10a17ba" -dependencies = [ - "aws-lc-sys", - "zeroize", -] - -[[package]] -name = "aws-lc-sys" -version = "0.30.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbfd150b5dbdb988bcc8fb1fe787eb6b7ee6180ca24da683b61ea5405f3d43ff" -dependencies = [ - "bindgen", - "cc", - "cmake", - "dunce", - "fs_extra", -] - -[[package]] -name = "aws-runtime" -version = "1.5.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c034a1bc1d70e16e7f4e4caf7e9f7693e4c9c24cd91cf17c2a0b21abaebc7c8b" -dependencies = [ - "aws-credential-types", - "aws-sigv4", - "aws-smithy-async", - "aws-smithy-eventstream", - "aws-smithy-http", - "aws-smithy-runtime", - "aws-smithy-runtime-api", - "aws-smithy-types", - "aws-types", - "bytes", - "fastrand", - "http 0.2.12", - "http-body 0.4.6", - "percent-encoding", - "pin-project-lite", - "tracing", - "uuid", -] - -[[package]] -name = "aws-sdk-s3" -version = "1.104.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38c488cd6abb0ec9811c401894191932e941c5f91dc226043edacd0afa1634bc" -dependencies = [ - "aws-credential-types", - "aws-runtime", - "aws-sigv4", - "aws-smithy-async", - "aws-smithy-checksums", - "aws-smithy-eventstream", - "aws-smithy-http", - "aws-smithy-json", - "aws-smithy-runtime", - "aws-smithy-runtime-api", - "aws-smithy-types", - "aws-smithy-xml", - "aws-types", - "bytes", - "fastrand", - "hex", - "hmac", - "http 0.2.12", - "http 1.3.1", - "http-body 0.4.6", - "lru", - "percent-encoding", - "regex-lite", - "sha2", - "tracing", - "url", -] - -[[package]] -name = "aws-sdk-sso" -version = "1.83.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "643cd43af212d2a1c4dedff6f044d7e1961e5d9e7cfe773d70f31d9842413886" -dependencies = [ - "aws-credential-types", - "aws-runtime", - "aws-smithy-async", - "aws-smithy-http", - "aws-smithy-json", - "aws-smithy-runtime", - "aws-smithy-runtime-api", - "aws-smithy-types", - "aws-types", - "bytes", - "fastrand", - "http 0.2.12", - "regex-lite", - "tracing", -] - -[[package]] -name = "aws-sdk-ssooidc" -version = "1.84.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20ec4a95bd48e0db7a424356a161f8d87bd6a4f0af37204775f0da03d9e39fc3" -dependencies = [ - "aws-credential-types", - "aws-runtime", - "aws-smithy-async", - "aws-smithy-http", - "aws-smithy-json", - "aws-smithy-runtime", - "aws-smithy-runtime-api", - "aws-smithy-types", - "aws-types", - "bytes", - "fastrand", - "http 0.2.12", - "regex-lite", - "tracing", -] - -[[package]] -name = "aws-sdk-sts" -version = "1.85.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "410309ad0df4606bc721aff0d89c3407682845453247213a0ccc5ff8801ee107" -dependencies = [ - "aws-credential-types", - "aws-runtime", - "aws-smithy-async", - "aws-smithy-http", - "aws-smithy-json", - "aws-smithy-query", - "aws-smithy-runtime", - "aws-smithy-runtime-api", - "aws-smithy-types", - "aws-smithy-xml", - "aws-types", - "fastrand", - "http 0.2.12", - "regex-lite", - "tracing", -] - -[[package]] -name = "aws-sigv4" -version = "1.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "084c34162187d39e3740cb635acd73c4e3a551a36146ad6fe8883c929c9f876c" -dependencies = [ - "aws-credential-types", - "aws-smithy-eventstream", - "aws-smithy-http", - "aws-smithy-runtime-api", - "aws-smithy-types", - "bytes", - "crypto-bigint 0.5.5", - "form_urlencoded", - "hex", - "hmac", - "http 0.2.12", - "http 1.3.1", - "p256", - "percent-encoding", - "ring", - "sha2", - "subtle", - "time", - "tracing", - "zeroize", -] - -[[package]] -name = "aws-smithy-async" -version = "1.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e190749ea56f8c42bf15dd76c65e14f8f765233e6df9b0506d9d934ebef867c" -dependencies = [ - "futures-util", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "aws-smithy-checksums" -version = "0.63.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56d2df0314b8e307995a3b86d44565dfe9de41f876901a7d71886c756a25979f" -dependencies = [ - "aws-smithy-http", - "aws-smithy-types", - "bytes", - "crc-fast", - "hex", - "http 0.2.12", - "http-body 0.4.6", - "md-5", - "pin-project-lite", - "sha1", - "sha2", - "tracing", -] - -[[package]] -name = "aws-smithy-eventstream" -version = "0.60.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "182b03393e8c677347fb5705a04a9392695d47d20ef0a2f8cfe28c8e6b9b9778" -dependencies = [ - "aws-smithy-types", - "bytes", - "crc32fast", -] - -[[package]] -name = "aws-smithy-http" -version = "0.62.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c4dacf2d38996cf729f55e7a762b30918229917eca115de45dfa8dfb97796c9" -dependencies = [ - "aws-smithy-eventstream", - "aws-smithy-runtime-api", - "aws-smithy-types", - "bytes", - "bytes-utils", - "futures-core", - "http 0.2.12", - "http 1.3.1", - "http-body 0.4.6", - "percent-encoding", - "pin-project-lite", - "pin-utils", - "tracing", -] - -[[package]] -name = "aws-smithy-http-client" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147e8eea63a40315d704b97bf9bc9b8c1402ae94f89d5ad6f7550d963309da1b" -dependencies = [ - "aws-smithy-async", - "aws-smithy-runtime-api", - "aws-smithy-types", - "h2 0.3.27", - "h2 0.4.12", - "http 0.2.12", - "http 1.3.1", - "http-body 0.4.6", - "hyper 0.14.32", - "hyper 1.7.0", - "hyper-rustls 0.24.2", - "hyper-rustls 0.27.7", - "hyper-util", - "pin-project-lite", - "rustls 0.21.12", - "rustls 0.23.31", - "rustls-native-certs 0.8.1", - "rustls-pki-types", - "tokio", - "tokio-rustls 0.26.2", - "tower", - "tracing", -] - -[[package]] -name = "aws-smithy-json" -version = "0.61.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eaa31b350998e703e9826b2104dd6f63be0508666e1aba88137af060e8944047" -dependencies = [ - "aws-smithy-types", -] - -[[package]] -name = "aws-smithy-observability" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9364d5989ac4dd918e5cc4c4bdcc61c9be17dcd2586ea7f69e348fc7c6cab393" -dependencies = [ - "aws-smithy-runtime-api", -] - -[[package]] -name = "aws-smithy-query" -version = "0.60.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2fbd61ceb3fe8a1cb7352e42689cec5335833cd9f94103a61e98f9bb61c64bb" -dependencies = [ - "aws-smithy-types", - "urlencoding", -] - -[[package]] -name = "aws-smithy-runtime" -version = "1.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3946acbe1ead1301ba6862e712c7903ca9bb230bdf1fbd1b5ac54158ef2ab1f" -dependencies = [ - "aws-smithy-async", - "aws-smithy-http", - "aws-smithy-http-client", - "aws-smithy-observability", - "aws-smithy-runtime-api", - "aws-smithy-types", - "bytes", - "fastrand", - "http 0.2.12", - "http 1.3.1", - "http-body 0.4.6", - "http-body 1.0.1", - "pin-project-lite", - "pin-utils", - "tokio", - "tracing", -] - -[[package]] -name = "aws-smithy-runtime-api" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07f5e0fc8a6b3f2303f331b94504bbf754d85488f402d6f1dd7a6080f99afe56" -dependencies = [ - "aws-smithy-async", - "aws-smithy-types", - "bytes", - "http 0.2.12", - "http 1.3.1", - "pin-project-lite", - "tokio", - "tracing", - "zeroize", -] - -[[package]] -name = "aws-smithy-types" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d498595448e43de7f4296b7b7a18a8a02c61ec9349128c80a368f7c3b4ab11a8" -dependencies = [ - "base64-simd", - "bytes", - "bytes-utils", - "futures-core", - "http 0.2.12", - "http 1.3.1", - "http-body 0.4.6", - "http-body 1.0.1", - "http-body-util", - "itoa", - "num-integer", - "pin-project-lite", - "pin-utils", - "ryu", - "serde", - "time", - "tokio", - "tokio-util", -] - -[[package]] -name = "aws-smithy-xml" -version = "0.60.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3db87b96cb1b16c024980f133968d52882ca0daaee3a086c6decc500f6c99728" -dependencies = [ - "xmlparser", -] - -[[package]] -name = "aws-types" -version = "1.3.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b069d19bf01e46298eaedd7c6f283fe565a59263e53eebec945f3e6398f42390" -dependencies = [ - "aws-credential-types", - "aws-smithy-async", - "aws-smithy-runtime-api", - "aws-smithy-types", - "rustc_version", - "tracing", -] - [[package]] name = "backtrace" version = "0.3.75" @@ -642,63 +167,12 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "base16ct" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "349a06037c7bf932dd7e7d1f653678b2038b9ad46a74102f1fc7bd7872678cce" - -[[package]] -name = "base64" -version = "0.21.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" - [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "base64-simd" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" -dependencies = [ - "outref", - "vsimd", -] - -[[package]] -name = "base64ct" -version = "1.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" - -[[package]] -name = "bindgen" -version = "0.69.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088" -dependencies = [ - "bitflags 2.9.4", - "cexpr", - "clang-sys", - "itertools 0.12.1", - "lazy_static", - "lazycell", - "log", - "prettyplease", - "proc-macro2", - "quote", - "regex", - "rustc-hash 1.1.0", - "shlex", - "syn 2.0.106", - "which", -] - [[package]] name = "bitflags" version = "1.3.2" @@ -750,16 +224,6 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" -[[package]] -name = "bytes-utils" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35" -dependencies = [ - "bytes", - "either", -] - [[package]] name = "camino" version = "1.1.12" @@ -826,32 +290,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5252b3d2648e5eedbc1a6f501e3c795e07025c1e93bbf8bbdd6eef7f447a6d54" dependencies = [ "find-msvc-tools", - "jobserver", - "libc", "shlex", ] -[[package]] -name = "cexpr" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" -dependencies = [ - "nom", -] - [[package]] name = "cfg-if" version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" -[[package]] -name = "cfg_aliases" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" - [[package]] name = "chrono" version = "0.4.41" @@ -867,17 +314,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "clang-sys" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" -dependencies = [ - "glob", - "libc", - "libloading", -] - [[package]] name = "clap" version = "4.5.47" @@ -918,15 +354,6 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" -[[package]] -name = "cmake" -version = "0.1.54" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7caa3f9de89ddbe2c607f4101924c5abec803763ae9534e4f4d7d8f84aa81f0" -dependencies = [ - "cc", -] - [[package]] name = "colorchoice" version = "1.0.4" @@ -967,38 +394,12 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "const-oid" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" - [[package]] name = "convert_case" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fb4a24b1aaf0fd0ce8b45161144d6f42cd91677fd5940fd431183eb023b3a2b8" -[[package]] -name = "core-foundation" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -1014,34 +415,6 @@ dependencies = [ "libc", ] -[[package]] -name = "crc" -version = "3.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675" -dependencies = [ - "crc-catalog", -] - -[[package]] -name = "crc-catalog" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" - -[[package]] -name = "crc-fast" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bf62af4cc77d8fe1c22dde4e721d87f2f54056139d8c412e1366b740305f56f" -dependencies = [ - "crc", - "digest", - "libc", - "rand", - "regex", -] - [[package]] name = "crc32fast" version = "1.5.0" @@ -1066,28 +439,6 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" -[[package]] -name = "crypto-bigint" -version = "0.4.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef2b4b23cddf68b89b8f8069890e8c270d54e2d5fe1b143820234805e4cb17ef" -dependencies = [ - "generic-array", - "rand_core 0.6.4", - "subtle", - "zeroize", -] - -[[package]] -name = "crypto-bigint" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" -dependencies = [ - "rand_core 0.6.4", - "subtle", -] - [[package]] name = "crypto-common" version = "0.1.6" @@ -1131,37 +482,6 @@ dependencies = [ "syn 2.0.106", ] -[[package]] -name = "der" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1a467a65c5e759bce6e65eaf91cc29f466cdc57cb65777bd646872a8a1fd4de" -dependencies = [ - "const-oid", - "zeroize", -] - -[[package]] -name = "der" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" -dependencies = [ - "const-oid", - "pem-rfc7468", - "zeroize", -] - -[[package]] -name = "deranged" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d630bccd429a5bb5a64b5e94f693bfc48c9f8566418fda4c494cc94f911f87cc" -dependencies = [ - "powerfmt", - "serde", -] - [[package]] name = "derivative" version = "2.2.0" @@ -1181,7 +501,6 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", - "subtle", ] [[package]] @@ -1195,64 +514,17 @@ dependencies = [ "syn 2.0.106", ] -[[package]] -name = "dunce" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" - -[[package]] -name = "ecdsa" -version = "0.14.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413301934810f597c1d19ca71c8710e99a3f1ba28a0d2ebc01551a2daeea3c5c" -dependencies = [ - "der 0.6.1", - "elliptic-curve", - "rfc6979", - "signature", -] - [[package]] name = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" -[[package]] -name = "elliptic-curve" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7bb888ab5300a19b8e5bceef25ac745ad065f3c9f7efc6de1b91958110891d3" -dependencies = [ - "base16ct", - "crypto-bigint 0.4.9", - "der 0.6.1", - "digest", - "ff", - "generic-array", - "group", - "pkcs8 0.9.0", - "rand_core 0.6.4", - "sec1", - "subtle", - "zeroize", -] - [[package]] name = "encode_unicode" version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" - -[[package]] -name = "encoding_rs" -version = "0.8.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" -dependencies = [ - "cfg-if", -] +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" [[package]] name = "enum-iterator" @@ -1341,16 +613,6 @@ dependencies = [ "log", ] -[[package]] -name = "ff" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d013fc25338cc558c5c2cfbad646908fb23591e2404481826742b651c9af7160" -dependencies = [ - "rand_core 0.6.4", - "subtle", -] - [[package]] name = "filetime" version = "0.2.26" @@ -1430,7 +692,7 @@ dependencies = [ "include_dir", "indicatif", "indicatif-log-bridge", - "itertools 0.10.5", + "itertools", "lazy_static", "log", "notify", @@ -1466,18 +728,6 @@ dependencies = [ "syn 2.0.106", ] -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1487,12 +737,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "fs_extra" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" - [[package]] name = "fsevent-sys" version = "4.1.0" @@ -1608,10 +852,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" dependencies = [ "cfg-if", - "js-sys", "libc", "wasi 0.11.1+wasi-snapshot-preview1", - "wasm-bindgen", ] [[package]] @@ -1621,11 +863,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" dependencies = [ "cfg-if", - "js-sys", "libc", "r-efi", "wasi 0.14.4+wasi-0.2.4", - "wasm-bindgen", ] [[package]] @@ -1640,130 +880,6 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" -[[package]] -name = "google-cloud-auth" -version = "0.17.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e57a13fbacc5e9c41ded3ad8d0373175a6b7a6ad430d99e89d314ac121b7ab06" -dependencies = [ - "async-trait", - "base64 0.21.7", - "google-cloud-metadata", - "google-cloud-token", - "home", - "jsonwebtoken", - "reqwest", - "serde", - "serde_json", - "thiserror 1.0.69", - "time", - "tokio", - "tracing", - "urlencoding", -] - -[[package]] -name = "google-cloud-metadata" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d901aeb453fd80e51d64df4ee005014f6cf39f2d736dd64f7239c132d9d39a6a" -dependencies = [ - "reqwest", - "thiserror 1.0.69", - "tokio", -] - -[[package]] -name = "google-cloud-storage" -version = "0.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34a73d9e94d35665909050f02e035d8bdc82e419241b1b027ebf1ea51dc8a470" -dependencies = [ - "anyhow", - "async-stream", - "async-trait", - "base64 0.21.7", - "bytes", - "futures-util", - "google-cloud-auth", - "google-cloud-metadata", - "google-cloud-token", - "hex", - "once_cell", - "percent-encoding", - "pkcs8 0.10.2", - "regex", - "reqwest", - "reqwest-middleware", - "ring", - "serde", - "serde_json", - "sha2", - "thiserror 1.0.69", - "time", - "tokio", - "tracing", - "url", -] - -[[package]] -name = "google-cloud-token" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f49c12ba8b21d128a2ce8585955246977fbce4415f680ebf9199b6f9d6d725f" -dependencies = [ - "async-trait", -] - -[[package]] -name = "group" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dfbfb3a6cfbd390d5c9564ab283a0349b9b9fcd46a706c1eb10e0db70bfbac7" -dependencies = [ - "ff", - "rand_core 0.6.4", - "subtle", -] - -[[package]] -name = "h2" -version = "0.3.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" -dependencies = [ - "bytes", - "fnv", - "futures-core", - "futures-sink", - "futures-util", - "http 0.2.12", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "h2" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http 1.3.1", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", -] - [[package]] name = "hashbrown" version = "0.14.5" @@ -1778,11 +894,6 @@ name = "hashbrown" version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash", -] [[package]] name = "hashlink" @@ -1817,196 +928,6 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" -[[package]] -name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" -dependencies = [ - "digest", -] - -[[package]] -name = "home" -version = "0.5.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589533453244b0995c858700322199b2becb13b627df2851f64a2775d024abcf" -dependencies = [ - "windows-sys 0.59.0", -] - -[[package]] -name = "http" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" -dependencies = [ - "bytes", - "fnv", - "itoa", -] - -[[package]] -name = "http" -version = "1.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" -dependencies = [ - "bytes", - "fnv", - "itoa", -] - -[[package]] -name = "http-body" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" -dependencies = [ - "bytes", - "http 0.2.12", - "pin-project-lite", -] - -[[package]] -name = "http-body" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" -dependencies = [ - "bytes", - "http 1.3.1", -] - -[[package]] -name = "http-body-util" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" -dependencies = [ - "bytes", - "futures-core", - "http 1.3.1", - "http-body 1.0.1", - "pin-project-lite", -] - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - -[[package]] -name = "hyper" -version = "0.14.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" -dependencies = [ - "bytes", - "futures-channel", - "futures-core", - "futures-util", - "h2 0.3.27", - "http 0.2.12", - "http-body 0.4.6", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "socket2 0.5.10", - "tokio", - "tower-service", - "tracing", - "want", -] - -[[package]] -name = "hyper" -version = "1.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e" -dependencies = [ - "atomic-waker", - "bytes", - "futures-channel", - "futures-core", - "h2 0.4.12", - "http 1.3.1", - "http-body 1.0.1", - "httparse", - "itoa", - "pin-project-lite", - "pin-utils", - "smallvec", - "tokio", - "want", -] - -[[package]] -name = "hyper-rustls" -version = "0.24.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" -dependencies = [ - "futures-util", - "http 0.2.12", - "hyper 0.14.32", - "log", - "rustls 0.21.12", - "rustls-native-certs 0.6.3", - "tokio", - "tokio-rustls 0.24.1", -] - -[[package]] -name = "hyper-rustls" -version = "0.27.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" -dependencies = [ - "http 1.3.1", - "hyper 1.7.0", - "hyper-util", - "rustls 0.23.31", - "rustls-native-certs 0.8.1", - "rustls-pki-types", - "tokio", - "tokio-rustls 0.26.2", - "tower-service", - "webpki-roots 1.0.2", -] - -[[package]] -name = "hyper-util" -version = "0.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d9b05277c7e8da2c93a568989bb6207bef0112e8d17df7a6eda4a3cf143bc5e" -dependencies = [ - "base64 0.22.1", - "bytes", - "futures-channel", - "futures-core", - "futures-util", - "http 1.3.1", - "http-body 1.0.1", - "hyper 1.7.0", - "ipnet", - "libc", - "percent-encoding", - "pin-project-lite", - "socket2 0.6.0", - "tokio", - "tower-service", - "tracing", -] - [[package]] name = "iana-time-zone" version = "0.1.63" @@ -2221,22 +1142,6 @@ dependencies = [ "libc", ] -[[package]] -name = "ipnet" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" - -[[package]] -name = "iri-string" -version = "0.7.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2" -dependencies = [ - "memchr", - "serde", -] - [[package]] name = "is-terminal" version = "0.4.16" @@ -2263,31 +1168,12 @@ dependencies = [ "either", ] -[[package]] -name = "itertools" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" -dependencies = [ - "either", -] - [[package]] name = "itoa" version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" -[[package]] -name = "jobserver" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" -dependencies = [ - "getrandom 0.3.3", - "libc", -] - [[package]] name = "js-sys" version = "0.3.78" @@ -2298,21 +1184,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "jsonwebtoken" -version = "9.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" -dependencies = [ - "base64 0.22.1", - "js-sys", - "pem", - "ring", - "serde", - "serde_json", - "simple_asn1", -] - [[package]] name = "kqueue" version = "1.1.1" @@ -2339,27 +1210,11 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -[[package]] -name = "lazycell" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" - [[package]] name = "libc" -version = "0.2.175" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" - -[[package]] -name = "libloading" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07033963ba89ebaf1584d767badaa2e8fcec21aedea6b8c0346d487d49c28667" -dependencies = [ - "cfg-if", - "windows-targets 0.53.3", -] +version = "0.2.175" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" [[package]] name = "libredox" @@ -2383,12 +1238,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "linux-raw-sys" -version = "0.4.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" - [[package]] name = "linux-raw-sys" version = "0.9.4" @@ -2417,21 +1266,6 @@ version = "0.4.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" -[[package]] -name = "lru" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" -dependencies = [ - "hashbrown 0.15.5", -] - -[[package]] -name = "lru-slab" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" - [[package]] name = "md-5" version = "0.10.6" @@ -2448,28 +1282,6 @@ version = "2.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - -[[package]] -name = "mime_guess" -version = "2.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" -dependencies = [ - "mime", - "unicase", -] - -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2502,16 +1314,6 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "nom" -version = "7.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" -dependencies = [ - "memchr", - "minimal-lexical", -] - [[package]] name = "notify" version = "6.1.1" @@ -2542,31 +1344,6 @@ dependencies = [ "notify", ] -[[package]] -name = "num-bigint" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" -dependencies = [ - "num-integer", - "num-traits", -] - -[[package]] -name = "num-conv" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" - -[[package]] -name = "num-integer" -version = "0.1.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" -dependencies = [ - "num-traits", -] - [[package]] name = "num-traits" version = "0.2.19" @@ -2607,12 +1384,6 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" -[[package]] -name = "openssl-probe" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" - [[package]] name = "oslog" version = "0.2.0" @@ -2624,23 +1395,6 @@ dependencies = [ "log", ] -[[package]] -name = "outref" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" - -[[package]] -name = "p256" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51f44edd08f51e2ade572f141051021c5af22677e42b7dd28a88155151c33594" -dependencies = [ - "ecdsa", - "elliptic-curve", - "sha2", -] - [[package]] name = "parking_lot" version = "0.12.4" @@ -2676,25 +1430,6 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" -[[package]] -name = "pem" -version = "3.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38af38e8470ac9dee3ce1bae1af9c1671fffc44ddfd8bd1d0a3445bf349a8ef3" -dependencies = [ - "base64 0.22.1", - "serde", -] - -[[package]] -name = "pem-rfc7468" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" -dependencies = [ - "base64ct", -] - [[package]] name = "percent-encoding" version = "2.3.2" @@ -2713,26 +1448,6 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" -[[package]] -name = "pkcs8" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9eca2c590a5f85da82668fa685c09ce2888b9430e83299debf1f34b65fd4a4ba" -dependencies = [ - "der 0.6.1", - "spki 0.6.0", -] - -[[package]] -name = "pkcs8" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" -dependencies = [ - "der 0.7.10", - "spki 0.7.3", -] - [[package]] name = "pkg-config" version = "0.3.32" @@ -2754,31 +1469,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn 2.0.106", -] - [[package]] name = "proc-macro2" version = "1.0.101" @@ -2788,61 +1478,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "quinn" -version = "0.11.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" -dependencies = [ - "bytes", - "cfg_aliases", - "pin-project-lite", - "quinn-proto", - "quinn-udp", - "rustc-hash 2.1.1", - "rustls 0.23.31", - "socket2 0.6.0", - "thiserror 2.0.16", - "tokio", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-proto" -version = "0.11.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" -dependencies = [ - "bytes", - "getrandom 0.3.3", - "lru-slab", - "rand", - "ring", - "rustc-hash 2.1.1", - "rustls 0.23.31", - "rustls-pki-types", - "slab", - "thiserror 2.0.16", - "tinyvec", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-udp" -version = "0.5.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" -dependencies = [ - "cfg_aliases", - "libc", - "once_cell", - "socket2 0.6.0", - "tracing", - "windows-sys 0.60.2", -] - [[package]] name = "quote" version = "1.0.40" @@ -2858,44 +1493,6 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" -[[package]] -name = "rand" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" -dependencies = [ - "rand_chacha", - "rand_core 0.9.3", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.3", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.16", -] - -[[package]] -name = "rand_core" -version = "0.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" -dependencies = [ - "getrandom 0.3.3", -] - [[package]] name = "redox_syscall" version = "0.5.17" @@ -2928,88 +1525,12 @@ dependencies = [ "regex-syntax", ] -[[package]] -name = "regex-lite" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "943f41321c63ef1c92fd763bfe054d2668f7f225a5c29f0105903dc2fc04ba30" - [[package]] name = "regex-syntax" version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" -[[package]] -name = "reqwest" -version = "0.12.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb" -dependencies = [ - "base64 0.22.1", - "bytes", - "encoding_rs", - "futures-core", - "futures-util", - "http 1.3.1", - "http-body 1.0.1", - "http-body-util", - "hyper 1.7.0", - "hyper-rustls 0.27.7", - "hyper-util", - "js-sys", - "log", - "mime", - "mime_guess", - "percent-encoding", - "pin-project-lite", - "quinn", - "rustls 0.23.31", - "rustls-pki-types", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tokio-rustls 0.26.2", - "tokio-util", - "tower", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-streams", - "web-sys", - "webpki-roots 1.0.2", -] - -[[package]] -name = "reqwest-middleware" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57f17d28a6e6acfe1733fe24bcd30774d13bffa4b8a22535b4c8c98423088d4e" -dependencies = [ - "anyhow", - "async-trait", - "http 1.3.1", - "reqwest", - "serde", - "thiserror 1.0.69", - "tower-service", -] - -[[package]] -name = "rfc6979" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7743f17af12fa0b03b803ba12cd6a8d9483a587e89c69445e3909655c0b9fabb" -dependencies = [ - "crypto-bigint 0.4.9", - "hmac", - "zeroize", -] - [[package]] name = "ring" version = "0.17.14" @@ -3044,40 +1565,6 @@ version = "0.1.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" -[[package]] -name = "rustc-hash" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" - -[[package]] -name = "rustc-hash" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" - -[[package]] -name = "rustc_version" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver", -] - -[[package]] -name = "rustix" -version = "0.38.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" -dependencies = [ - "bitflags 2.9.4", - "errno", - "libc", - "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", -] - [[package]] name = "rustix" version = "1.0.8" @@ -3087,69 +1574,23 @@ dependencies = [ "bitflags 2.9.4", "errno", "libc", - "linux-raw-sys 0.9.4", + "linux-raw-sys", "windows-sys 0.60.2", ] [[package]] -name = "rustls" -version = "0.21.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" -dependencies = [ - "log", - "ring", - "rustls-webpki 0.101.7", - "sct", -] - -[[package]] -name = "rustls" -version = "0.23.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0ebcbd2f03de0fc1122ad9bb24b127a5a6cd51d72604a3f3c50ac459762b6cc" -dependencies = [ - "aws-lc-rs", - "log", - "once_cell", - "ring", - "rustls-pki-types", - "rustls-webpki 0.103.4", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-native-certs" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9aace74cb666635c918e9c12bc0d348266037aa8eb599b5cba565709a8dff00" -dependencies = [ - "openssl-probe", - "rustls-pemfile", - "schannel", - "security-framework 2.11.1", -] - -[[package]] -name = "rustls-native-certs" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fcff2dd52b58a8d98a70243663a0d234c4e2b79235637849d15913394a247d3" -dependencies = [ - "openssl-probe", - "rustls-pki-types", - "schannel", - "security-framework 3.4.0", -] - -[[package]] -name = "rustls-pemfile" -version = "1.0.4" +name = "rustls" +version = "0.23.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +checksum = "c0ebcbd2f03de0fc1122ad9bb24b127a5a6cd51d72604a3f3c50ac459762b6cc" dependencies = [ - "base64 0.21.7", + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", ] [[package]] @@ -3158,27 +1599,15 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" dependencies = [ - "web-time", "zeroize", ] -[[package]] -name = "rustls-webpki" -version = "0.101.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" -dependencies = [ - "ring", - "untrusted", -] - [[package]] name = "rustls-webpki" version = "0.103.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0a17884ae0c1b773f1ccd2bd4a8c72f16da897310a98b0e84bf349ad5ead92fc" dependencies = [ - "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -3205,81 +1634,12 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "schannel" -version = "0.1.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" -dependencies = [ - "windows-sys 0.59.0", -] - [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "sct" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" -dependencies = [ - "ring", - "untrusted", -] - -[[package]] -name = "sec1" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3be24c1842290c45df0a7bf069e0c268a747ad05a192f2fd7dcfdbc1cba40928" -dependencies = [ - "base16ct", - "der 0.6.1", - "generic-array", - "pkcs8 0.9.0", - "subtle", - "zeroize", -] - -[[package]] -name = "security-framework" -version = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" -dependencies = [ - "bitflags 2.9.4", - "core-foundation 0.9.4", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework" -version = "3.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60b369d18893388b345804dc0007963c99b7d665ae71d275812d828c6f089640" -dependencies = [ - "bitflags 2.9.4", - "core-foundation 0.10.1", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "semver" version = "1.0.26" @@ -3330,18 +1690,6 @@ dependencies = [ "serde", ] -[[package]] -name = "serde_urlencoded" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" -dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", -] - [[package]] name = "serde_yaml" version = "0.9.34+deprecated" @@ -3391,17 +1739,6 @@ dependencies = [ "digest", ] -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - [[package]] name = "shlex" version = "1.3.0" @@ -3417,28 +1754,6 @@ dependencies = [ "libc", ] -[[package]] -name = "signature" -version = "1.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74233d3b3b2f6d4b006dc19dee745e73e2a6bfb6f93607cd3b02bd5b00797d7c" -dependencies = [ - "digest", - "rand_core 0.6.4", -] - -[[package]] -name = "simple_asn1" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" -dependencies = [ - "num-bigint", - "num-traits", - "thiserror 2.0.16", - "time", -] - [[package]] name = "slab" version = "0.4.11" @@ -3451,16 +1766,6 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" -[[package]] -name = "socket2" -version = "0.5.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" -dependencies = [ - "libc", - "windows-sys 0.52.0", -] - [[package]] name = "socket2" version = "0.6.0" @@ -3471,26 +1776,6 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "spki" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67cf02bbac7a337dc36e4f5a693db6c21e7863f45070f7064577eb4367a3212b" -dependencies = [ - "base64ct", - "der 0.6.1", -] - -[[package]] -name = "spki" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" -dependencies = [ - "base64ct", - "der 0.7.10", -] - [[package]] name = "stable_deref_trait" version = "1.2.0" @@ -3568,15 +1853,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "sync_wrapper" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" -dependencies = [ - "futures-core", -] - [[package]] name = "synstructure" version = "0.13.2" @@ -3595,13 +1871,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b010f5ebe51e88ae490691ed2a43b699e3468c8e3838e244accd8526aca7751b" dependencies = [ "anyhow", - "aws-config", - "aws-credential-types", - "aws-sdk-s3", "byteorder", "chrono", "flate2", - "google-cloud-storage", "log", "ring", "rusqlite", @@ -3610,7 +1882,6 @@ dependencies = [ "strum 0.27.2", "strum_macros 0.27.2", "thiserror 2.0.16", - "tokio", "ureq", "url", "uuid", @@ -3627,6 +1898,7 @@ dependencies = [ "serde", "serde_json", "taskchampion", + "thiserror 1.0.69", "tokio", "uuid", ] @@ -3640,7 +1912,7 @@ dependencies = [ "fastrand", "getrandom 0.3.3", "once_cell", - "rustix 1.0.8", + "rustix", "windows-sys 0.60.2", ] @@ -3693,36 +1965,6 @@ dependencies = [ "num_cpus", ] -[[package]] -name = "time" -version = "0.3.43" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83bde6f1ec10e72d583d91623c939f623002284ef622b87de38cfd546cbf2031" -dependencies = [ - "deranged", - "num-conv", - "powerfmt", - "serde", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" - -[[package]] -name = "time-macros" -version = "0.2.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" -dependencies = [ - "num-conv", - "time-core", -] - [[package]] name = "tinystr" version = "0.8.1" @@ -3733,21 +1975,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "tinyvec" -version = "1.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - [[package]] name = "tokio" version = "1.47.1" @@ -3763,7 +1990,7 @@ dependencies = [ "pin-project-lite", "signal-hook-registry", "slab", - "socket2 0.6.0", + "socket2", "tokio-macros", "windows-sys 0.59.0", ] @@ -3779,39 +2006,6 @@ dependencies = [ "syn 2.0.106", ] -[[package]] -name = "tokio-rustls" -version = "0.24.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" -dependencies = [ - "rustls 0.21.12", - "tokio", -] - -[[package]] -name = "tokio-rustls" -version = "0.26.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b" -dependencies = [ - "rustls 0.23.31", - "tokio", -] - -[[package]] -name = "tokio-util" -version = "0.7.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "pin-project-lite", - "tokio", -] - [[package]] name = "toml" version = "0.5.11" @@ -3868,100 +2062,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea68304e134ecd095ac6c3574494fc62b909f416c4fca77e440530221e549d3d" -[[package]] -name = "tower" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" -dependencies = [ - "futures-core", - "futures-util", - "pin-project-lite", - "sync_wrapper", - "tokio", - "tower-layer", - "tower-service", -] - -[[package]] -name = "tower-http" -version = "0.6.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" -dependencies = [ - "bitflags 2.9.4", - "bytes", - "futures-util", - "http 1.3.1", - "http-body 1.0.1", - "iri-string", - "pin-project-lite", - "tower", - "tower-layer", - "tower-service", -] - -[[package]] -name = "tower-layer" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" - -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - -[[package]] -name = "tracing" -version = "0.1.41" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" -dependencies = [ - "pin-project-lite", - "tracing-attributes", - "tracing-core", -] - -[[package]] -name = "tracing-attributes" -version = "0.1.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.106", -] - -[[package]] -name = "tracing-core" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" -dependencies = [ - "once_cell", -] - -[[package]] -name = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" - [[package]] name = "typenum" version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" -[[package]] -name = "unicase" -version = "2.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" - [[package]] name = "unicode-ident" version = "1.0.18" @@ -3998,11 +2104,11 @@ version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" dependencies = [ - "base64 0.22.1", + "base64", "flate2", "log", "once_cell", - "rustls 0.23.31", + "rustls", "rustls-pki-types", "url", "webpki-roots 0.26.11", @@ -4020,12 +2126,6 @@ dependencies = [ "serde", ] -[[package]] -name = "urlencoding" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" - [[package]] name = "utf8_iter" version = "1.0.4" @@ -4062,12 +2162,6 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" -[[package]] -name = "vsimd" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" - [[package]] name = "walkdir" version = "2.5.0" @@ -4078,15 +2172,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "want" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" -dependencies = [ - "try-lock", -] - [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -4174,19 +2259,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-streams" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" -dependencies = [ - "futures-util", - "js-sys", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - [[package]] name = "web-sys" version = "0.3.78" @@ -4225,18 +2297,6 @@ dependencies = [ "rustls-pki-types", ] -[[package]] -name = "which" -version = "4.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" -dependencies = [ - "either", - "home", - "once_cell", - "rustix 0.38.44", -] - [[package]] name = "winapi" version = "0.3.9" @@ -4570,12 +2630,6 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" -[[package]] -name = "xmlparser" -version = "0.13.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" - [[package]] name = "yoke" version = "0.8.0" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 934ed553..80228819 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -1,19 +1,26 @@ [package] name = "tc_helper" version = "0.1.0" -edition = "2024" +edition = "2021" [lib] name="tc_helper" crate-type=["staticlib","cdylib"] [dependencies] -taskchampion = "2.0.3" +# Only the "server-sync" backend (the remote TaskChampion sync server, used by +# our sync_() via ServerConfig::Remote) — NOT server-aws / server-gcp. Those +# cloud backends dragged in the AWS + Google-Cloud SDKs → aws-lc-rs/aws-lc-sys, +# which (a) bloats the binary, (b) needs bindgen for 32-bit ARM, and (c) fails +# to cross-compile for iOS. server-sync uses ureq + ring, which build cleanly +# on every target. `bundled` keeps SQLite compiled in. +taskchampion = { version = "2.0.3", default-features = false, features = ["server-sync", "bundled"] } anyhow = "1.0" tokio = { version = "1.40", features = ["full"] } uuid = "1.0" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +thiserror = "1.0" # Structured, typed error handling flutter_rust_bridge = "=2.11.1" # Rust runtime bridge flutter_rust_bridge_macros = "2.11.1" # Procedural macros support diff --git a/rust/build.rs b/rust/build.rs new file mode 100644 index 00000000..a66a62ab --- /dev/null +++ b/rust/build.rs @@ -0,0 +1,53 @@ +use std::env; +use std::process::Command; + +/// Build script for `tc_helper`. +/// +/// * Rebuilds whenever anything under `src/` changes so the FFI stays in sync. +/// * Detects the target platform and exposes it as a build-time note. +/// * Optionally regenerates the flutter_rust_bridge bindings when the +/// `FRB_CODEGEN=1` environment variable is set, so contributors can refresh +/// bindings from a plain `cargo build` without memorising the CLI flags. +/// (It is opt-in so ordinary/CI builds never shell out to the codegen tool.) +fn main() { + println!("cargo:rerun-if-changed=src/"); + println!("cargo:rerun-if-env-changed=FRB_CODEGEN"); + + let target = env::var("TARGET").unwrap_or_default(); + let platform = if target.contains("android") { + "android" + } else if target.contains("apple-ios") { + "ios" + } else if target.contains("apple-darwin") { + "macos" + } else if target.contains("linux") { + "linux" + } else if target.contains("windows") { + "windows" + } else { + "unknown" + }; + // Expose the detected platform as a compile-time env var (readable via + // env!("TC_HELPER_PLATFORM")) instead of a per-build warning, so ordinary + // builds stay quiet. + println!("cargo:rustc-env=TC_HELPER_PLATFORM={platform}"); + + if env::var("FRB_CODEGEN").as_deref() == Ok("1") { + let status = Command::new("flutter_rust_bridge_codegen") + .args([ + "generate", + "--rust-input", + "crate::api", + "--rust-root", + ".", + "--dart-output", + "../lib/rust_bridge", + ]) + .status(); + match status { + Ok(s) if s.success() => println!("cargo:warning=flutter_rust_bridge bindings regenerated"), + Ok(s) => println!("cargo:warning=flutter_rust_bridge_codegen exited with {s}"), + Err(e) => println!("cargo:warning=could not run flutter_rust_bridge_codegen: {e}"), + } + } +} diff --git a/rust/src/api.rs b/rust/src/api.rs index 5ee13205..c0c729b9 100644 --- a/rust/src/api.rs +++ b/rust/src/api.rs @@ -1,11 +1,14 @@ use flutter_rust_bridge::frb; +use std::{collections::HashMap, str::FromStr}; use taskchampion::{ chrono::{DateTime, Utc}, - Operations, Replica, ServerConfig, StorageConfig, Tag, + Operations, Tag, ServerConfig, }; use uuid::Uuid; -use std::{collections::HashMap, path::PathBuf, str::FromStr}; -use serde_json; + +use crate::serialize::task_to_json; +use crate::storage::open_replica; +use crate::utils::error::TcHelperError; fn parse_datetime(input: &str) -> Option> { if input.trim().is_empty() { @@ -14,91 +17,81 @@ fn parse_datetime(input: &str) -> Option> { input.parse::>().ok() } +/// Return every task in the replica as a JSON array string. #[frb] -pub fn get_all_tasks_json(taskdb_dir_path: String) -> Result { - let tasks = get_all_tasks(taskdb_dir_path); // your Vec> - let json = serde_json::to_string(&tasks) - .map_err(|e| taskchampion::Error::Other(anyhow::anyhow!(e)))?; - Ok(json) +pub fn get_all_tasks_json(taskdb_dir_path: String) -> Result { + get_all_tasks_json_impl(&taskdb_dir_path).map_err(|e| e.to_string()) } -fn get_all_tasks(taskdb_dir_path: String) -> Vec> { - let taskdb_dir = PathBuf::from(taskdb_dir_path); - let storage = StorageConfig::OnDisk { - taskdb_dir, - create_if_missing: true, - access_mode: taskchampion::storage::AccessMode::ReadWrite, - } - .into_storage() - .unwrap(); - - let mut replica = Replica::new(storage); - let mut vector: Vec> = Vec::new(); +fn get_all_tasks_json_impl(taskdb_dir_path: &str) -> Result { + let mut replica = open_replica(taskdb_dir_path)?; + let tasks = replica + .all_tasks() + .map_err(|e| TcHelperError::Champion(e.to_string()))?; - for (_, value) in replica.all_tasks().unwrap() { - let mut map: HashMap = HashMap::new(); - let mut tags = "".to_string(); - - for (k, v) in value.get_taskmap() { - if k.contains("tag_") { - if let Some(stripped) = k.strip_prefix("tag_") { - tags.push_str(stripped); - tags.push(' '); - } - } else { - map.insert(k.into(), v.into()); - } - } - map.insert("tags".into(), tags.trim().into()); - map.insert("uuid".into(), value.get_uuid().to_string()); - vector.push(map); - } - vector + let json_tasks: Vec = tasks.values().map(task_to_json).collect(); + Ok(serde_json::to_string(&json_tasks)?) } +/// Delete the task with the given UUID. A no-op if the task does not exist. +/// +/// This is a *soft* delete, matching what `task delete` does in the Taskwarrior +/// CLI: the task's status becomes `deleted` but the record is preserved, so it +/// still syncs, remains auditable, and can be restored (`task undelete`). +/// Previously this purged the task from the replica outright via +/// `TaskData::delete()`, which is the equivalent of `task purge` — the data was +/// unrecoverable and never appeared in a "deleted" view on any client. #[frb] -pub fn delete_task(uuid_st: String, taskdb_dir_path: String) -> i8 { - let taskdb_dir = PathBuf::from(taskdb_dir_path); - let storage = StorageConfig::OnDisk { - taskdb_dir, - create_if_missing: true, - access_mode: taskchampion::storage::AccessMode::ReadWrite, - } - .into_storage() - .unwrap(); +pub fn delete_task(uuid_st: String, taskdb_dir_path: String) -> Result<(), String> { + delete_task_impl(&uuid_st, &taskdb_dir_path).map_err(|e| e.to_string()) +} - let mut replica = Replica::new(storage); +fn delete_task_impl(uuid_st: &str, taskdb_dir_path: &str) -> Result<(), TcHelperError> { + let mut replica = open_replica(taskdb_dir_path)?; let mut ops = Operations::new(); - let uuid = Uuid::parse_str(&uuid_st).unwrap(); + let uuid = Uuid::parse_str(uuid_st).map_err(|_| TcHelperError::InvalidUuid(uuid_st.to_string()))?; - if let Some(mut t) = replica.get_task_data(uuid).unwrap() { - t.delete(&mut ops); + if let Some(mut t) = replica + .get_task(uuid) + .map_err(|e| TcHelperError::Champion(e.to_string()))? + { + t.set_status(taskchampion::Status::Deleted, &mut ops) + .map_err(|e| TcHelperError::Champion(e.to_string()))?; } - replica.commit_operations(ops).unwrap(); - 0 + replica + .commit_operations(ops) + .map_err(|e| TcHelperError::Commit(e.to_string()))?; + Ok(()) } +/// Update the mutable fields of an existing task from the supplied key/value map. #[frb] pub fn update_task( uuid_st: String, taskdb_dir_path: String, map: HashMap, -) -> i8 { - let taskdb_dir = PathBuf::from(taskdb_dir_path); - let storage = StorageConfig::OnDisk { - taskdb_dir, - create_if_missing: true, - access_mode: taskchampion::storage::AccessMode::ReadWrite, - } - .into_storage() - .unwrap(); +) -> Result<(), String> { + update_task_impl(&uuid_st, &taskdb_dir_path, map).map_err(|e| e.to_string()) +} - let mut replica = Replica::new(storage); +#[allow(deprecated)] // `get_taskmap` is deprecated upstream; used to enumerate existing tags. +fn update_task_impl( + uuid_st: &str, + taskdb_dir_path: &str, + map: HashMap, +) -> Result<(), TcHelperError> { + let mut replica = open_replica(taskdb_dir_path)?; let mut ops = Operations::new(); - let uuid = Uuid::parse_str(&uuid_st).unwrap(); + let uuid = Uuid::parse_str(uuid_st).map_err(|_| TcHelperError::InvalidUuid(uuid_st.to_string()))?; - if let Some(mut t) = replica.get_task(uuid).unwrap() { - let _ = t.set_status(taskchampion::Status::Pending, &mut ops); + if let Some(mut t) = replica + .get_task(uuid) + .map_err(|e| TcHelperError::Champion(e.to_string()))? + { + // NOTE: do not force the status here. This used to unconditionally set + // Pending before applying the map, so any update that didn't carry an + // explicit "status" silently resurrected a completed or deleted task. + // Status is applied below only when the caller actually supplies it. for (key, value) in map { match key.as_str() { "description" => { @@ -121,21 +114,20 @@ pub fn update_task( let _ = t.set_priority(value, &mut ops); } "tags" => { - let existing_tags: Vec = t - .get_taskmap() - .iter() - .filter_map(|(k, _)| k.strip_prefix("tag_").map(|s| s.to_string())) - .collect(); - for tag_name in existing_tags { - println!("removing tag at rust side {}", tag_name); - let mut tag = Tag::from_str(&tag_name).unwrap(); - let _ = t.remove_tag(&mut tag, &mut ops); - } - - for part in value.split_whitespace() { - println!("tag at rust side {}", part); - let mut tag = Tag::from_str(part).unwrap(); - let _ = t.add_tag(&mut tag, &mut ops); + let existing_tags: Vec = t + .get_taskmap() + .iter() + .filter_map(|(k, _)| k.strip_prefix("tag_").map(|s| s.to_string())) + .collect(); + for tag_name in existing_tags { + if let Ok(mut tag) = Tag::from_str(&tag_name) { + let _ = t.remove_tag(&mut tag, &mut ops); + } + } + for part in value.split_whitespace() { + if let Ok(mut tag) = Tag::from_str(part) { + let _ = t.add_tag(&mut tag, &mut ops); + } } } "project" => { @@ -148,35 +140,37 @@ pub fn update_task( "deleted" => taskchampion::Status::Deleted, _ => taskchampion::Status::Pending, }; - // print!("status at rust side {}", value); - println!("status at rust side {}", value); let _ = t.set_status(status, &mut ops); } _ => {} } } - replica.commit_operations(ops).unwrap(); + replica + .commit_operations(ops) + .map_err(|e| TcHelperError::Commit(e.to_string()))?; } - 0 + Ok(()) } +/// Create a new task from the supplied key/value map. The map must contain a +/// `uuid` entry. #[frb] -pub fn add_task(taskdb_dir_path: String, map: HashMap) -> i8 { - let taskdb_dir = PathBuf::from(taskdb_dir_path); - let storage = StorageConfig::OnDisk { - taskdb_dir, - create_if_missing: true, - access_mode: taskchampion::storage::AccessMode::ReadWrite, - } - .into_storage() - .unwrap(); +pub fn add_task(taskdb_dir_path: String, map: HashMap) -> Result<(), String> { + add_task_impl(&taskdb_dir_path, map).map_err(|e| e.to_string()) +} - let mut replica = Replica::new(storage); +fn add_task_impl(taskdb_dir_path: &str, map: HashMap) -> Result<(), TcHelperError> { + let mut replica = open_replica(taskdb_dir_path)?; let mut ops = Operations::new(); - if let Some(uuid_str) = map.get("uuid") { - let uuid = Uuid::parse_str(&uuid_str).unwrap(); - let mut t = replica.create_task(uuid, &mut ops).unwrap(); + let uuid_str = map + .get("uuid") + .ok_or_else(|| TcHelperError::InvalidUuid("".to_string()))?; + let uuid = Uuid::parse_str(uuid_str).map_err(|_| TcHelperError::InvalidUuid(uuid_str.clone()))?; + + let mut t = replica + .create_task(uuid, &mut ops) + .map_err(|e| TcHelperError::Champion(e.to_string()))?; let _ = t.set_status(taskchampion::Status::Pending, &mut ops); for (key, value) in map { @@ -198,8 +192,9 @@ pub fn add_task(taskdb_dir_path: String, map: HashMap) -> i8 { } "tags" => { for part in value.split_whitespace() { - let mut tag = Tag::from_str(part).unwrap(); - let _ = t.add_tag(&mut tag, &mut ops); + if let Ok(mut tag) = Tag::from_str(part) { + let _ = t.add_tag(&mut tag, &mut ops); + } } } "project" => { @@ -208,43 +203,53 @@ pub fn add_task(taskdb_dir_path: String, map: HashMap) -> i8 { _ => {} } } - replica.commit_operations(ops).unwrap(); - return 0; -} - 1 + replica + .commit_operations(ops) + .map_err(|e| TcHelperError::Commit(e.to_string()))?; + Ok(()) } +/// Synchronise the local replica with a remote TaskChampion sync server. #[frb] pub async fn sync( taskdb_dir_path: String, url: String, client_id: String, encryption_secret: String, -) -> i8 { - let taskdb_dir = PathBuf::from(taskdb_dir_path); - let storage = StorageConfig::OnDisk { - taskdb_dir, - create_if_missing: true, - access_mode: taskchampion::storage::AccessMode::ReadWrite, - } - .into_storage() - .unwrap(); +) -> Result<(), String> { + sync_impl(&taskdb_dir_path, url, &client_id, encryption_secret).map_err(|e| e.to_string()) +} + +fn sync_impl( + taskdb_dir_path: &str, + url: String, + client_id: &str, + encryption_secret: String, +) -> Result<(), TcHelperError> { + let mut replica = open_replica(taskdb_dir_path)?; + let client_uuid = + Uuid::parse_str(client_id).map_err(|_| TcHelperError::InvalidUuid(client_id.to_string()))?; - let mut replica = Replica::new(storage); let config = ServerConfig::Remote { url: url.into(), - client_id: Uuid::parse_str(&client_id).unwrap(), + client_id: client_uuid, encryption_secret: encryption_secret.into(), }; - let mut server = config.into_server().unwrap(); - replica.sync(&mut server, false).unwrap(); - 0 + let mut server = config + .into_server() + .map_err(|e| TcHelperError::Sync(e.to_string()))?; + replica + .sync(&mut server, false) + .map_err(|e| TcHelperError::Sync(e.to_string()))?; + Ok(()) } #[test] fn test_add_task_with_tags() { use std::{collections::HashMap, env, fs}; + use serde_json::Value; + // create unique temporary directory for taskdb let tmp = env::temp_dir().join(format!("taskdb_test_{}", Uuid::new_v4())); let taskdb_path = tmp.to_string_lossy().into_owned(); @@ -258,19 +263,181 @@ fn test_add_task_with_tags() { map.insert("tags".to_string(), "tag1 tag2".to_string()); // add task - let res = add_task(taskdb_path.clone(), map); - assert_eq!(res, 0); + add_task(taskdb_path.clone(), map).expect("add_task"); - // read tasks as json and verify tags are present + // read tasks as json and verify the surfaced attributes let json = get_all_tasks_json(taskdb_path.clone()).expect("get_all_tasks_json"); - let tasks: Vec> = serde_json::from_str(&json).expect("parse json"); - let found = tasks.into_iter().find(|m| m.get("uuid").map(|s| s == &uuid).unwrap_or(false)); - assert!(found.is_some(), "task with uuid not found"); - let task = found.unwrap(); - let tags = task.get("tags").map(|s| s.as_str()).unwrap_or(""); + let tasks: Vec = serde_json::from_str(&json).expect("parse json"); + let task = tasks + .into_iter() + .find(|t| t.get("uuid").and_then(|u| u.as_str()) == Some(uuid.as_str())) + .expect("task with uuid not found"); + + let tags = task.get("tags").and_then(|t| t.as_str()).unwrap_or(""); assert!(tags.contains("tag1"), "tag1 missing in tags: {}", tags); assert!(tags.contains("tag2"), "tag2 missing in tags: {}", tags); + // newly surfaced attributes should be present with sensible defaults + assert!(task.get("annotations").map(|v| v.is_array()).unwrap_or(false)); + assert!(task.get("depends").map(|v| v.is_array()).unwrap_or(false)); + assert_eq!(task.get("is_blocked").and_then(|v| v.as_bool()), Some(false)); + assert_eq!(task.get("is_blocking").and_then(|v| v.as_bool()), Some(false)); + // cleanup fs::remove_dir_all(&tmp).ok(); } + +#[test] +fn test_dependencies_and_annotations_surface() { + // Exercises the POPULATED cases of the enriched serializer: a real + // dependency (A depends on B) must surface as depends[]/is_blocked/is_blocking, + // and an annotation must surface as {entry (RFC3339), description}. + use std::{env, fs}; + use serde_json::Value; + use taskchampion::{chrono::{DateTime, Utc}, Annotation, Operations, Status}; + + let tmp = env::temp_dir().join(format!("taskdb_deptest_{}", Uuid::new_v4())); + let taskdb_path = tmp.to_string_lossy().into_owned(); + fs::create_dir_all(&tmp).expect("create temp taskdb dir"); + + let uuid_a = Uuid::new_v4(); // dependent task + let uuid_b = Uuid::new_v4(); // blocker task + + { + let mut replica = open_replica(&taskdb_path).expect("open replica"); + let mut ops = Operations::new(); + + let mut b = replica.create_task(uuid_b, &mut ops).expect("create B"); + let _ = b.set_status(Status::Pending, &mut ops); + let _ = b.set_description("blocker".to_string(), &mut ops); + + let mut a = replica.create_task(uuid_a, &mut ops).expect("create A"); + let _ = a.set_status(Status::Pending, &mut ops); + let _ = a.set_description("dependent".to_string(), &mut ops); + a.add_dependency(uuid_b, &mut ops).expect("add dependency A->B"); + a.add_annotation( + Annotation { entry: Utc::now(), description: "note-one".to_string() }, + &mut ops, + ).expect("add annotation"); + + replica.commit_operations(ops).expect("commit"); + } + + // Read back through the SAME path the app uses (fresh replica + all_tasks()). + let json = get_all_tasks_json(taskdb_path.clone()).expect("get_all_tasks_json"); + let tasks: Vec = serde_json::from_str(&json).expect("parse json"); + let find = |u: &Uuid| { + tasks + .iter() + .find(|t| t.get("uuid").and_then(|v| v.as_str()) == Some(u.to_string().as_str())) + .cloned() + .expect("task present") + }; + let a = find(&uuid_a); + let b = find(&uuid_b); + + // A depends on B → depends[] carries B, and A is blocked (unresolved dep). + let deps: Vec = a["depends"].as_array().unwrap() + .iter().map(|v| v.as_str().unwrap().to_string()).collect(); + assert!(deps.contains(&uuid_b.to_string()), "A.depends must contain B: {:?}", deps); + assert_eq!(a["is_blocked"].as_bool(), Some(true), "A must be blocked"); + assert_eq!(a["is_blocking"].as_bool(), Some(false), "A must not be blocking"); + + // A's annotation surfaces with description + RFC3339 entry. + let anns = a["annotations"].as_array().unwrap(); + assert_eq!(anns.len(), 1, "A must have one annotation"); + assert_eq!(anns[0]["description"].as_str(), Some("note-one")); + let entry = anns[0]["entry"].as_str().unwrap(); + assert!(DateTime::parse_from_rfc3339(entry).is_ok(), "entry must be RFC3339: {}", entry); + + // B is depended-upon → B is blocking, not blocked. + assert_eq!(b["is_blocking"].as_bool(), Some(true), "B must be blocking"); + assert_eq!(b["is_blocked"].as_bool(), Some(false), "B must not be blocked"); + + fs::remove_dir_all(&tmp).ok(); +} + +#[test] +fn test_delete_task_is_a_soft_delete() { + // `task delete` in the Taskwarrior CLI is a *soft* delete: the record + // survives with status=deleted so it still syncs and can be restored. + // This previously used TaskData::delete(), which purged the task outright + // (the `task purge` equivalent) and left nothing to recover or display. + use std::{collections::HashMap, env, fs}; + use serde_json::Value; + + let tmp = env::temp_dir().join(format!("taskdb_deltest_{}", Uuid::new_v4())); + let taskdb_path = tmp.to_string_lossy().into_owned(); + fs::create_dir_all(&tmp).expect("create temp taskdb dir"); + + let uuid = Uuid::new_v4().to_string(); + let mut map: HashMap = HashMap::new(); + map.insert("uuid".to_string(), uuid.clone()); + map.insert("description".to_string(), "doomed task".to_string()); + add_task(taskdb_path.clone(), map).expect("add_task"); + + delete_task(uuid.clone(), taskdb_path.clone()).expect("delete_task"); + + let json = get_all_tasks_json(taskdb_path.clone()).expect("get_all_tasks_json"); + let tasks: Vec = serde_json::from_str(&json).expect("parse json"); + let task = tasks + .into_iter() + .find(|t| t.get("uuid").and_then(|u| u.as_str()) == Some(uuid.as_str())); + + // The task must still exist... + let task = task.expect("deleted task was purged from the replica, not soft-deleted"); + // ...and be marked deleted rather than left pending. + assert_eq!( + task.get("status").and_then(|v| v.as_str()), + Some("deleted"), + "expected status=deleted after delete_task" + ); + + fs::remove_dir_all(&tmp).ok(); +} + +#[test] +fn test_update_preserves_status_when_not_supplied() { + // Regression: update_task_impl used to force Status::Pending before + // applying the caller's map, so editing (say) a description on a deleted + // or completed task silently resurrected it as pending — which would also + // quietly undo a soft delete. + use std::{collections::HashMap, env, fs}; + use serde_json::Value; + + let tmp = env::temp_dir().join(format!("taskdb_statustest_{}", Uuid::new_v4())); + let taskdb_path = tmp.to_string_lossy().into_owned(); + fs::create_dir_all(&tmp).expect("create temp taskdb dir"); + + let uuid = Uuid::new_v4().to_string(); + let mut map: HashMap = HashMap::new(); + map.insert("uuid".to_string(), uuid.clone()); + map.insert("description".to_string(), "will be deleted".to_string()); + add_task(taskdb_path.clone(), map).expect("add_task"); + delete_task(uuid.clone(), taskdb_path.clone()).expect("delete_task"); + + // Edit only the description — no "status" key in the map. + let mut edit: HashMap = HashMap::new(); + edit.insert("description".to_string(), "renamed".to_string()); + update_task(uuid.clone(), taskdb_path.clone(), edit).expect("update_task"); + + let json = get_all_tasks_json(taskdb_path.clone()).expect("get_all_tasks_json"); + let tasks: Vec = serde_json::from_str(&json).expect("parse json"); + let task = tasks + .into_iter() + .find(|t| t.get("uuid").and_then(|u| u.as_str()) == Some(uuid.as_str())) + .expect("task missing"); + + assert_eq!( + task.get("description").and_then(|v| v.as_str()), + Some("renamed"), + "the description edit should have applied" + ); + assert_eq!( + task.get("status").and_then(|v| v.as_str()), + Some("deleted"), + "editing a deleted task must not resurrect it to pending" + ); + + fs::remove_dir_all(&tmp).ok(); +} diff --git a/rust/src/frb_generated.rs b/rust/src/frb_generated.rs index 9f690200..8590af35 100644 --- a/rust/src/frb_generated.rs +++ b/rust/src/frb_generated.rs @@ -72,9 +72,8 @@ fn wire__crate__api__add_task_impl( >::sse_decode(&mut deserializer); deserializer.end(); move |context| { - transform_result_sse::<_, ()>((move || { - let output_ok = - Result::<_, ()>::Ok(crate::api::add_task(api_taskdb_dir_path, api_map))?; + transform_result_sse::<_, String>((move || { + let output_ok = crate::api::add_task(api_taskdb_dir_path, api_map)?; Ok(output_ok) })()) } @@ -107,11 +106,8 @@ fn wire__crate__api__delete_task_impl( let api_taskdb_dir_path = ::sse_decode(&mut deserializer); deserializer.end(); move |context| { - transform_result_sse::<_, ()>((move || { - let output_ok = Result::<_, ()>::Ok(crate::api::delete_task( - api_uuid_st, - api_taskdb_dir_path, - ))?; + transform_result_sse::<_, String>((move || { + let output_ok = crate::api::delete_task(api_uuid_st, api_taskdb_dir_path)?; Ok(output_ok) })()) } @@ -143,12 +139,10 @@ fn wire__crate__api__get_all_tasks_json_impl( let api_taskdb_dir_path = ::sse_decode(&mut deserializer); deserializer.end(); move |context| { - transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( - (move || { - let output_ok = crate::api::get_all_tasks_json(api_taskdb_dir_path)?; - Ok(output_ok) - })(), - ) + transform_result_sse::<_, String>((move || { + let output_ok = crate::api::get_all_tasks_json(api_taskdb_dir_path)?; + Ok(output_ok) + })()) } }, ) @@ -181,17 +175,15 @@ fn wire__crate__api__sync_impl( let api_encryption_secret = ::sse_decode(&mut deserializer); deserializer.end(); move |context| async move { - transform_result_sse::<_, ()>( + transform_result_sse::<_, String>( (move || async move { - let output_ok = Result::<_, ()>::Ok( - crate::api::sync( - api_taskdb_dir_path, - api_url, - api_client_id, - api_encryption_secret, - ) - .await, - )?; + let output_ok = crate::api::sync( + api_taskdb_dir_path, + api_url, + api_client_id, + api_encryption_secret, + ) + .await?; Ok(output_ok) })() .await, @@ -228,12 +220,9 @@ fn wire__crate__api__update_task_impl( >::sse_decode(&mut deserializer); deserializer.end(); move |context| { - transform_result_sse::<_, ()>((move || { - let output_ok = Result::<_, ()>::Ok(crate::api::update_task( - api_uuid_st, - api_taskdb_dir_path, - api_map, - ))?; + transform_result_sse::<_, String>((move || { + let output_ok = + crate::api::update_task(api_uuid_st, api_taskdb_dir_path, api_map)?; Ok(output_ok) })()) } @@ -243,14 +232,6 @@ fn wire__crate__api__update_task_impl( // Section: dart2rust -impl SseDecode for flutter_rust_bridge::for_generated::anyhow::Error { - // Codec=Sse (Serialization based), see doc to use other codecs - fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { - let mut inner = ::sse_decode(deserializer); - return flutter_rust_bridge::for_generated::anyhow::anyhow!("{}", inner); - } -} - impl SseDecode for std::collections::HashMap { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { @@ -267,13 +248,6 @@ impl SseDecode for String { } } -impl SseDecode for i8 { - // Codec=Sse (Serialization based), see doc to use other codecs - fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { - deserializer.cursor.read_i8().unwrap() - } -} - impl SseDecode for Vec { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { @@ -365,13 +339,6 @@ fn pde_ffi_dispatcher_sync_impl( // Section: rust2dart -impl SseEncode for flutter_rust_bridge::for_generated::anyhow::Error { - // Codec=Sse (Serialization based), see doc to use other codecs - fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { - ::sse_encode(format!("{:?}", self), serializer); - } -} - impl SseEncode for std::collections::HashMap { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { @@ -386,13 +353,6 @@ impl SseEncode for String { } } -impl SseEncode for i8 { - // Codec=Sse (Serialization based), see doc to use other codecs - fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { - serializer.cursor.write_i8(self).unwrap(); - } -} - impl SseEncode for Vec { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { diff --git a/rust/src/lib.rs b/rust/src/lib.rs index b07ba846..a4db1e3d 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -1,2 +1,5 @@ mod frb_generated; /* AUTO INJECTED BY flutter_rust_bridge. This line may not be accurate, and you can change it according to your needs. */ -mod api; \ No newline at end of file +mod api; +mod serialize; +mod storage; +mod utils; \ No newline at end of file diff --git a/rust/src/serialize.rs b/rust/src/serialize.rs new file mode 100644 index 00000000..18b6b4f3 --- /dev/null +++ b/rust/src/serialize.rs @@ -0,0 +1,66 @@ +use serde_json::{json, Value}; +use taskchampion::Task; + +/// Serialise a TaskChampion [`Task`] into the JSON object the Flutter layer +/// consumes. +/// +/// Beyond the flat properties the previous serialiser emitted, this now +/// surfaces attributes the Dart model already anticipated but never received: +/// +/// * `tags` — space-joined user tags (synthetic tags are excluded) +/// * `annotations` — array of `{ entry, description }` objects +/// * `depends` — array of dependency UUID strings +/// * `is_blocked` — whether the task has at least one unresolved dependency +/// * `is_blocking` — whether at least one other task depends on this one +/// * `recur` — recurrence rule, when present +/// +/// `urgency` is intentionally omitted: TaskChampion 2.0.3 does not compute or +/// store an urgency value on [`Task`], so there is nothing authoritative to +/// surface here. +#[allow(deprecated)] // `get_taskmap` is deprecated upstream; retained for the raw property view. +pub fn task_to_json(task: &Task) -> Value { + let mut map = serde_json::Map::new(); + let mut tags: Vec = Vec::new(); + + for (key, value) in task.get_taskmap() { + if let Some(tag) = key.strip_prefix("tag_") { + // User tags are stored as `tag_` properties. + tags.push(tag.to_string()); + } else if key.starts_with("dep_") || key.starts_with("annotation_") { + // Raw dependency/annotation properties are surfaced below as + // structured arrays, so skip their flat representation here. + continue; + } else { + // Flat properties (description, status, due, priority, project, + // recur, ...) pass straight through as strings. + map.insert(key.clone(), Value::String(value.clone())); + } + } + + let annotations: Vec = task + .get_annotations() + .map(|a| { + json!({ + // RFC 3339 / ISO-8601 so consumers get an unambiguous, + // directly-parseable timestamp (not a bare epoch number). + "entry": a.entry.to_rfc3339(), + "description": a.description, + }) + }) + .collect(); + + let depends: Vec = task + .get_dependencies() + .map(|uuid| Value::String(uuid.to_string())) + .collect(); + + map.insert("uuid".into(), Value::String(task.get_uuid().to_string())); + map.insert("tags".into(), Value::String(tags.join(" "))); + map.insert("annotations".into(), Value::Array(annotations)); + map.insert("depends".into(), Value::Array(depends)); + map.insert("is_blocked".into(), Value::Bool(task.is_blocked())); + map.insert("is_blocking".into(), Value::Bool(task.is_blocking())); + // `recur` is already carried through the flat-property loop above. + + Value::Object(map) +} diff --git a/rust/src/storage.rs b/rust/src/storage.rs new file mode 100644 index 00000000..ecee1037 --- /dev/null +++ b/rust/src/storage.rs @@ -0,0 +1,25 @@ +use std::path::PathBuf; +use taskchampion::{Replica, StorageConfig}; + +use crate::utils::error::TcHelperError; + +/// Open (creating if necessary) the on-disk TaskChampion replica at +/// `taskdb_dir_path` in read/write mode. +/// +/// This centralises the storage-configuration boilerplate that was previously +/// duplicated across every FFI entry point. +pub fn open_replica(taskdb_dir_path: &str) -> Result { + let taskdb_dir = PathBuf::from(taskdb_dir_path); + let storage = StorageConfig::OnDisk { + taskdb_dir, + create_if_missing: true, + access_mode: taskchampion::storage::AccessMode::ReadWrite, + } + .into_storage() + .map_err(|e| TcHelperError::ReplicaOpen { + path: taskdb_dir_path.to_string(), + message: e.to_string(), + })?; + + Ok(Replica::new(storage)) +} diff --git a/rust/src/utils/error.rs b/rust/src/utils/error.rs new file mode 100644 index 00000000..a032803b --- /dev/null +++ b/rust/src/utils/error.rs @@ -0,0 +1,29 @@ +use thiserror::Error; + +/// Errors raised by the `tc_helper` FFI layer. +/// +/// Each FFI entry point in [`crate::api`] converts one of these into a plain +/// error string, which flutter_rust_bridge surfaces to the Dart side as a +/// thrown exception. Modelling the failure modes as a typed enum keeps the +/// Rust code free of `.unwrap()` panics and makes the source of a failure +/// explicit at the call site. +#[derive(Error, Debug)] +pub enum TcHelperError { + #[error("cannot open replica at '{path}': {message}")] + ReplicaOpen { path: String, message: String }, + + #[error("invalid UUID '{0}'")] + InvalidUuid(String), + + #[error("sync failed: {0}")] + Sync(String), + + #[error("task operation failed: {0}")] + Commit(String), + + #[error("taskchampion error: {0}")] + Champion(String), + + #[error("JSON serialization error: {0}")] + Serialization(#[from] serde_json::Error), +} diff --git a/rust/src/utils/mod.rs b/rust/src/utils/mod.rs new file mode 100644 index 00000000..a91e7351 --- /dev/null +++ b/rust/src/utils/mod.rs @@ -0,0 +1 @@ +pub mod error; diff --git a/scripts/build_tc_helper_apple.sh b/scripts/build_tc_helper_apple.sh new file mode 100755 index 00000000..e2a59e8b --- /dev/null +++ b/scripts/build_tc_helper_apple.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash +# +# Compiles the tc_helper Rust library for Apple platforms during a Flutter +# build, so the native library always matches rust/ instead of relying on a +# checked-in binary. +# +# Invoked from a CocoaPods script_phase (see ios/Podfile and macos/Podfile). +# +# ./build_tc_helper_apple.sh ios -> rebuilds ios/tc_helper.xcframework +# (device arm64 + simulator fat) +# ./build_tc_helper_apple.sh macos -> builds rust/target/release/, which is +# where flutter_rust_bridge's desktop +# loader looks (ioDirectory in +# frb_generated.dart) +# +# DELIBERATELY NON-FATAL: if the Rust toolchain (or a required target) is +# missing, this warns and exits 0 so the build carries on with whatever library +# is already present. A contributor without Rust installed must still be able +# to build the app; this only *upgrades* the build when the toolchain is there. +set -uo pipefail + +PLATFORM="${1:-}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +RUST_DIR="${REPO_ROOT}/rust" + +warn() { echo "warning: [tc_helper] $*" >&2; } + +if [ ! -d "${RUST_DIR}" ]; then + warn "no rust/ directory at ${RUST_DIR}; skipping native rebuild" + exit 0 +fi + +# Xcode's build environment does not inherit a login shell, so cargo installed +# via rustup is typically not on PATH here. +export PATH="${HOME}/.cargo/bin:/opt/homebrew/bin:/usr/local/bin:${PATH}" + +if ! command -v cargo >/dev/null 2>&1; then + warn "cargo not found on PATH; skipping native rebuild (using the existing binary)" + exit 0 +fi + +ensure_target() { + local target="$1" + if command -v rustup >/dev/null 2>&1; then + rustup target add "${target}" >/dev/null 2>&1 || true + fi +} + +cd "${RUST_DIR}" || { warn "cannot enter ${RUST_DIR}"; exit 0; } + +case "${PLATFORM}" in + macos) + # The desktop loader reads rust/target/release/libtc_helper.dylib. + if ! cargo build --release; then + warn "cargo build failed; leaving any existing library in place" + exit 0 + fi + echo "[tc_helper] built rust/target/release for macOS" + ;; + + ios) + OUT_FRAMEWORK="${REPO_ROOT}/ios/tc_helper.xcframework" + + if ! command -v xcodebuild >/dev/null 2>&1 \ + || ! xcodebuild -version >/dev/null 2>&1; then + warn "xcodebuild unavailable (full Xcode required); keeping existing xcframework" + exit 0 + fi + + ensure_target aarch64-apple-ios + ensure_target aarch64-apple-ios-sim + ensure_target x86_64-apple-ios + + if ! cargo build --release --target aarch64-apple-ios; then + warn "device build failed; keeping existing xcframework" + exit 0 + fi + + # The simulator slice must cover both Apple-silicon and Intel hosts. + SIM_LIBS=() + if cargo build --release --target aarch64-apple-ios-sim; then + SIM_LIBS+=("target/aarch64-apple-ios-sim/release/libtc_helper.a") + fi + if cargo build --release --target x86_64-apple-ios; then + SIM_LIBS+=("target/x86_64-apple-ios/release/libtc_helper.a") + fi + if [ ${#SIM_LIBS[@]} -eq 0 ]; then + warn "no simulator slice built; keeping existing xcframework" + exit 0 + fi + + SIM_FAT="target/libtc_helper_sim.a" + if ! lipo -create "${SIM_LIBS[@]}" -output "${SIM_FAT}"; then + warn "lipo failed; keeping existing xcframework" + exit 0 + fi + + # -create-xcframework refuses to overwrite an existing bundle. + TMP_FRAMEWORK="${REPO_ROOT}/ios/.tc_helper.xcframework.new" + rm -rf "${TMP_FRAMEWORK}" + if ! xcodebuild -create-xcframework \ + -library "target/aarch64-apple-ios/release/libtc_helper.a" \ + -library "${SIM_FAT}" \ + -output "${TMP_FRAMEWORK}"; then + warn "create-xcframework failed; keeping existing xcframework" + rm -rf "${TMP_FRAMEWORK}" + exit 0 + fi + + # Swap in only once the new bundle is known-good, so a failure part-way + # through can never leave the project without a linkable framework. + rm -rf "${OUT_FRAMEWORK}" + mv "${TMP_FRAMEWORK}" "${OUT_FRAMEWORK}" + echo "[tc_helper] rebuilt ${OUT_FRAMEWORK}" + ;; + + *) + warn "unknown platform '${PLATFORM}' (expected ios|macos); skipping" + ;; +esac + +exit 0 diff --git a/scripts/update_build_log.py b/scripts/update_build_log.py new file mode 100644 index 00000000..a2542037 --- /dev/null +++ b/scripts/update_build_log.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +"""Record a nightly build in the website's build-log data file. + +Prepends an entry describing the current commit to +``website/data/nightly_builds.json`` (newest first) and keeps only the most +recent ``MAX_ENTRIES``. The Hugo ``nightly-builds`` shortcode renders this file. + +Run from the repository root (as the CI workflow does): + + python3 scripts/update_build_log.py + +All arguments are optional: + status "success" (default) or "failure" + artifact_url link to the built APK ("" if none) + build_number CI run number ("" if unknown) +""" + +import json +import subprocess +import sys +from datetime import datetime, timezone +from pathlib import Path + +LOG = Path("website/data/nightly_builds.json") +MAX_ENTRIES = 90 + + +def _git(*args: str) -> str: + return subprocess.check_output(["git", *args]).decode().strip() + + +def main(status: str = "success", artifact: str = "", build_number: str = "") -> None: + entry = { + "ts": datetime.now(timezone.utc).isoformat(timespec="seconds"), + "sha": _git("rev-parse", "HEAD")[:8], + "msg": _git("log", "-1", "--pretty=%s"), + "status": status, + "artifact": artifact, + "build": build_number, + } + + entries = [] + if LOG.exists(): + try: + entries = json.loads(LOG.read_text() or "[]") + except json.JSONDecodeError: + entries = [] + + entries.insert(0, entry) + entries = entries[:MAX_ENTRIES] + + LOG.parent.mkdir(parents=True, exist_ok=True) + LOG.write_text(json.dumps(entries, indent=2) + "\n") + print( + f"Recorded nightly build {entry['sha']} ({status}); " + f"{len(entries)} entr{'y' if len(entries) == 1 else 'ies'} in {LOG}." + ) + + +if __name__ == "__main__": + main(*sys.argv[1:]) diff --git a/scripts/verify_apk_native_libs.sh b/scripts/verify_apk_native_libs.sh new file mode 100755 index 00000000..cfed6776 --- /dev/null +++ b/scripts/verify_apk_native_libs.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# +# Fail if a built APK is missing libtc_helper.so for any ABI it should carry. +# +# Why this exists: the Gradle hook that compiles the Rust library is deliberately +# non-fatal, so a contributor without a Rust toolchain can still build. The cost of +# that choice is that a *misconfigured CI runner* also silently skips the compile — +# and the resulting APK installs perfectly, launches, and then dies inside +# RustLib.init() because there is no native library to bind to. A build that fails +# loudly is safe; one that emits a broken artifact is not. This script converts +# that silent failure into a red build. +# +# This checks the APK itself rather than jniLibs/, so it catches both "cargo never +# ran" and "cargo ran but Gradle didn't package the result". +# +# Usage: scripts/verify_apk_native_libs.sh [ ...] +# +# Expected ABIs are inferred from the filename: a split APK +# (app-arm64-v8a-release.apk) must contain exactly its own ABI; anything else is +# treated as a universal APK and must contain all three. + +set -euo pipefail + +ALL_ABIS="arm64-v8a armeabi-v7a x86_64" +LIB="libtc_helper.so" + +if [ "$#" -eq 0 ]; then + echo "usage: $0 [ ...]" >&2 + exit 2 +fi + +# Prefer unzip; fall back to Python where the runner lacks it. +list_entries() { + if command -v unzip >/dev/null 2>&1; then + unzip -Z1 "$1" + else + python3 -c 'import sys,zipfile;print("\n".join(zipfile.ZipFile(sys.argv[1]).namelist()))' "$1" + fi +} + +fail=0 + +for apk in "$@"; do + if [ ! -f "$apk" ]; then + echo "::error::APK not found: $apk" + fail=1 + continue + fi + + expected="" + for abi in $ALL_ABIS; do + case "$(basename "$apk")" in + *"$abi"*) expected="$abi" ;; + esac + done + [ -n "$expected" ] || expected="$ALL_ABIS" + + entries="$(list_entries "$apk")" + echo "== $apk" + echo " expecting: $expected" + + for abi in $expected; do + if printf '%s\n' "$entries" | grep -qx "lib/$abi/$LIB"; then + echo " ok lib/$abi/$LIB" + else + echo "::error file=$apk::missing lib/$abi/$LIB — this APK installs cleanly and then crashes at RustLib.init()" + fail=1 + fi + done +done + +if [ "$fail" -ne 0 ]; then + echo "::error::native library verification FAILED — do not publish these artifacts" + exit 1 +fi + +echo "All APKs carry $LIB for every expected ABI." diff --git a/test/api_service_test.dart b/test/api_service_test.dart index 85d84391..1429ccae 100644 --- a/test/api_service_test.dart +++ b/test/api_service_test.dart @@ -1,33 +1,17 @@ -import 'dart:convert'; - import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:mockito/annotations.dart'; -import 'package:mockito/mockito.dart'; -import 'package:http/http.dart' as http; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; -import 'package:taskwarrior/app/utils/taskchampion/credentials_storage.dart'; import 'package:taskwarrior/app/v3/db/task_database.dart'; import 'package:taskwarrior/app/v3/models/task.dart'; -import 'package:taskwarrior/app/v3/net/fetch.dart'; -import 'package:taskwarrior/app/v3/net/origin.dart'; - -import 'api_service_test.mocks.dart'; - -class MockCredentialsStorage extends Mock implements CredentialsStorage {} - -class MockMethodChannel extends Mock implements MethodChannel {} -@GenerateMocks([MockMethodChannel, http.Client]) void main() { TestWidgetsFlutterBinding.ensureInitialized(); databaseFactory = databaseFactoryFfi; - MockClient mockClient = MockClient(); setUpAll(() { sqfliteFfiInit(); - + // Mock SharedPreferences plugin const MethodChannel('plugins.flutter.io/shared_preferences') .setMockMethodCallHandler((MethodCall methodCall) async { @@ -102,30 +86,6 @@ void main() { }); }); - group('fetchTasks', () { - test('Fetch data successfully', () async { - final responseJson = jsonEncode({'data': 'Mock data'}); - var baseUrl = await CredentialsStorage.getApiUrl(); - when(mockClient.get( - Uri.parse( - '$baseUrl/tasks?email=email&origin=$origin&UUID=123&encryptionSecret=secret'), - headers: { - "Content-Type": "application/json", - })).thenAnswer((_) async => http.Response(responseJson, 200)); - - final result = await fetchTasks('123', 'secret'); - - expect(result, isA>()); - }); - - test('fetchTasks returns empty array', () async { - const uuid = '123'; - const encryptionSecret = 'secret'; - - expect(await fetchTasks(uuid, encryptionSecret), isEmpty); - }); - }); - group('TaskDatabase', () { late TaskDatabase taskDatabase; diff --git a/test/api_service_test.mocks.dart b/test/api_service_test.mocks.dart deleted file mode 100644 index c23b5109..00000000 --- a/test/api_service_test.mocks.dart +++ /dev/null @@ -1,401 +0,0 @@ -// Mocks generated by Mockito 5.4.4 from annotations -// in taskwarrior/test/api_service_test.dart. -// Do not manually edit this file. - -// ignore_for_file: no_leading_underscores_for_library_prefixes -import 'dart:async' as _i6; -import 'dart:convert' as _i7; -import 'dart:typed_data' as _i8; - -import 'package:flutter/services.dart' as _i2; -import 'package:http/http.dart' as _i3; -import 'package:mockito/mockito.dart' as _i1; -import 'package:mockito/src/dummies.dart' as _i5; - -import 'api_service_test.dart' as _i4; - -// ignore_for_file: type=lint -// ignore_for_file: avoid_redundant_argument_values -// ignore_for_file: avoid_setters_without_getters -// ignore_for_file: comment_references -// ignore_for_file: deprecated_member_use -// ignore_for_file: deprecated_member_use_from_same_package -// ignore_for_file: implementation_imports -// ignore_for_file: invalid_use_of_visible_for_testing_member -// ignore_for_file: prefer_const_constructors -// ignore_for_file: unnecessary_parenthesis -// ignore_for_file: camel_case_types -// ignore_for_file: subtype_of_sealed_class - -class _FakeMethodCodec_0 extends _i1.SmartFake implements _i2.MethodCodec { - _FakeMethodCodec_0( - Object parent, - Invocation parentInvocation, - ) : super( - parent, - parentInvocation, - ); -} - -class _FakeBinaryMessenger_1 extends _i1.SmartFake - implements _i2.BinaryMessenger { - _FakeBinaryMessenger_1( - Object parent, - Invocation parentInvocation, - ) : super( - parent, - parentInvocation, - ); -} - -class _FakeResponse_2 extends _i1.SmartFake implements _i3.Response { - _FakeResponse_2( - Object parent, - Invocation parentInvocation, - ) : super( - parent, - parentInvocation, - ); -} - -class _FakeStreamedResponse_3 extends _i1.SmartFake - implements _i3.StreamedResponse { - _FakeStreamedResponse_3( - Object parent, - Invocation parentInvocation, - ) : super( - parent, - parentInvocation, - ); -} - -/// A class which mocks [MockMethodChannel]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockMockMethodChannel extends _i1.Mock implements _i4.MockMethodChannel { - MockMockMethodChannel() { - _i1.throwOnMissingStub(this); - } - - @override - String get name => (super.noSuchMethod( - Invocation.getter(#name), - returnValue: _i5.dummyValue( - this, - Invocation.getter(#name), - ), - ) as String); - - @override - _i2.MethodCodec get codec => (super.noSuchMethod( - Invocation.getter(#codec), - returnValue: _FakeMethodCodec_0( - this, - Invocation.getter(#codec), - ), - ) as _i2.MethodCodec); - - @override - _i2.BinaryMessenger get binaryMessenger => (super.noSuchMethod( - Invocation.getter(#binaryMessenger), - returnValue: _FakeBinaryMessenger_1( - this, - Invocation.getter(#binaryMessenger), - ), - ) as _i2.BinaryMessenger); - - @override - _i6.Future invokeMethod( - String? method, [ - dynamic arguments, - ]) => - (super.noSuchMethod( - Invocation.method( - #invokeMethod, - [ - method, - arguments, - ], - ), - returnValue: _i6.Future.value(), - ) as _i6.Future); - - @override - _i6.Future?> invokeListMethod( - String? method, [ - dynamic arguments, - ]) => - (super.noSuchMethod( - Invocation.method( - #invokeListMethod, - [ - method, - arguments, - ], - ), - returnValue: _i6.Future?>.value(), - ) as _i6.Future?>); - - @override - _i6.Future?> invokeMapMethod( - String? method, [ - dynamic arguments, - ]) => - (super.noSuchMethod( - Invocation.method( - #invokeMapMethod, - [ - method, - arguments, - ], - ), - returnValue: _i6.Future?>.value(), - ) as _i6.Future?>); - - @override - void setMethodCallHandler( - _i6.Future Function(_i2.MethodCall)? handler) => - super.noSuchMethod( - Invocation.method( - #setMethodCallHandler, - [handler], - ), - returnValueForMissingStub: null, - ); -} - -/// A class which mocks [Client]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockClient extends _i1.Mock implements _i3.Client { - MockClient() { - _i1.throwOnMissingStub(this); - } - - @override - _i6.Future<_i3.Response> head( - Uri? url, { - Map? headers, - }) => - (super.noSuchMethod( - Invocation.method( - #head, - [url], - {#headers: headers}, - ), - returnValue: _i6.Future<_i3.Response>.value(_FakeResponse_2( - this, - Invocation.method( - #head, - [url], - {#headers: headers}, - ), - )), - ) as _i6.Future<_i3.Response>); - - @override - _i6.Future<_i3.Response> get( - Uri? url, { - Map? headers, - }) => - (super.noSuchMethod( - Invocation.method( - #get, - [url], - {#headers: headers}, - ), - returnValue: _i6.Future<_i3.Response>.value(_FakeResponse_2( - this, - Invocation.method( - #get, - [url], - {#headers: headers}, - ), - )), - ) as _i6.Future<_i3.Response>); - - @override - _i6.Future<_i3.Response> post( - Uri? url, { - Map? headers, - Object? body, - _i7.Encoding? encoding, - }) => - (super.noSuchMethod( - Invocation.method( - #post, - [url], - { - #headers: headers, - #body: body, - #encoding: encoding, - }, - ), - returnValue: _i6.Future<_i3.Response>.value(_FakeResponse_2( - this, - Invocation.method( - #post, - [url], - { - #headers: headers, - #body: body, - #encoding: encoding, - }, - ), - )), - ) as _i6.Future<_i3.Response>); - - @override - _i6.Future<_i3.Response> put( - Uri? url, { - Map? headers, - Object? body, - _i7.Encoding? encoding, - }) => - (super.noSuchMethod( - Invocation.method( - #put, - [url], - { - #headers: headers, - #body: body, - #encoding: encoding, - }, - ), - returnValue: _i6.Future<_i3.Response>.value(_FakeResponse_2( - this, - Invocation.method( - #put, - [url], - { - #headers: headers, - #body: body, - #encoding: encoding, - }, - ), - )), - ) as _i6.Future<_i3.Response>); - - @override - _i6.Future<_i3.Response> patch( - Uri? url, { - Map? headers, - Object? body, - _i7.Encoding? encoding, - }) => - (super.noSuchMethod( - Invocation.method( - #patch, - [url], - { - #headers: headers, - #body: body, - #encoding: encoding, - }, - ), - returnValue: _i6.Future<_i3.Response>.value(_FakeResponse_2( - this, - Invocation.method( - #patch, - [url], - { - #headers: headers, - #body: body, - #encoding: encoding, - }, - ), - )), - ) as _i6.Future<_i3.Response>); - - @override - _i6.Future<_i3.Response> delete( - Uri? url, { - Map? headers, - Object? body, - _i7.Encoding? encoding, - }) => - (super.noSuchMethod( - Invocation.method( - #delete, - [url], - { - #headers: headers, - #body: body, - #encoding: encoding, - }, - ), - returnValue: _i6.Future<_i3.Response>.value(_FakeResponse_2( - this, - Invocation.method( - #delete, - [url], - { - #headers: headers, - #body: body, - #encoding: encoding, - }, - ), - )), - ) as _i6.Future<_i3.Response>); - - @override - _i6.Future read( - Uri? url, { - Map? headers, - }) => - (super.noSuchMethod( - Invocation.method( - #read, - [url], - {#headers: headers}, - ), - returnValue: _i6.Future.value(_i5.dummyValue( - this, - Invocation.method( - #read, - [url], - {#headers: headers}, - ), - )), - ) as _i6.Future); - - @override - _i6.Future<_i8.Uint8List> readBytes( - Uri? url, { - Map? headers, - }) => - (super.noSuchMethod( - Invocation.method( - #readBytes, - [url], - {#headers: headers}, - ), - returnValue: _i6.Future<_i8.Uint8List>.value(_i8.Uint8List(0)), - ) as _i6.Future<_i8.Uint8List>); - - @override - _i6.Future<_i3.StreamedResponse> send(_i3.BaseRequest? request) => - (super.noSuchMethod( - Invocation.method( - #send, - [request], - ), - returnValue: - _i6.Future<_i3.StreamedResponse>.value(_FakeStreamedResponse_3( - this, - Invocation.method( - #send, - [request], - ), - )), - ) as _i6.Future<_i3.StreamedResponse>); - - @override - void close() => super.noSuchMethod( - Invocation.method( - #close, - [], - ), - returnValueForMissingStub: null, - ); -} diff --git a/test/models/json/task_for_c_test.dart b/test/models/json/task_for_c_test.dart new file mode 100644 index 00000000..e9ceddb0 --- /dev/null +++ b/test/models/json/task_for_c_test.dart @@ -0,0 +1,60 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:taskwarrior/app/v3/models/task.dart'; + +/// Regression tests for TaskForC.fromJson(), covering two bugs found in a +/// broader audit: annotations were hardcoded to an empty list regardless of +/// the JSON payload, and a nullable `urgency` field was force-called with +/// `.toDouble()` without a null check. +void main() { + Map baseJson() => { + 'id': 1, + 'description': 'desc', + 'project': null, + 'status': 'pending', + 'uuid': 'u1', + 'urgency': 5.5, + 'priority': null, + 'due': null, + 'end': null, + 'entry': '20240101T000000Z', + 'modified': null, + 'tags': null, + 'start': null, + 'wait': null, + 'rtype': null, + 'recur': null, + 'depends': null, + }; + + group('TaskForC.fromJson', () { + test('deserializes annotations from the payload', () { + final json = baseJson() + ..['annotations'] = [ + {'entry': '20240102T000000Z', 'description': 'first note'}, + {'entry': '20240103T000000Z', 'description': 'second note'}, + ]; + final task = TaskForC.fromJson(json); + expect(task.annotations, isNotNull); + expect(task.annotations!.length, 2); + expect(task.annotations![0].description, 'first note'); + expect(task.annotations![1].description, 'second note'); + }); + + test('an absent annotations key yields an empty list, not a crash', () { + final task = TaskForC.fromJson(baseJson()); + expect(task.annotations, isEmpty); + }); + + test('a null urgency does not throw', () { + final json = baseJson()..['urgency'] = null; + final task = TaskForC.fromJson(json); + expect(task.urgency, isNull); + }); + + test('a numeric urgency is parsed correctly', () { + final json = baseJson()..['urgency'] = 12; + final task = TaskForC.fromJson(json); + expect(task.urgency, 12.0); + }); + }); +} diff --git a/test/models/task_like_test.dart b/test/models/task_like_test.dart new file mode 100644 index 00000000..8483dd1b --- /dev/null +++ b/test/models/task_like_test.dart @@ -0,0 +1,314 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:taskwarrior/app/models/report.dart'; +import 'package:taskwarrior/app/models/task_like.dart'; +import 'package:taskwarrior/app/models/task_urgency.dart'; +import 'package:taskwarrior/app/services/report_service.dart'; +import 'package:taskwarrior/app/utils/taskchampion/virtual_filter_engine.dart'; +import 'package:taskwarrior/app/v3/champion/models/task_for_replica.dart'; +import 'package:taskwarrior/app/v3/models/annotation.dart'; +import 'package:taskwarrior/app/v3/models/task.dart'; + +/// Edge-case coverage for the unified task model (the [TaskLike] contract that +/// both TaskForC and TaskForReplica implement), the shared date normalization, +/// and the shared logic now generic over both models. +void main() { + final DateTime now = DateTime.utc(2024, 6, 1, 12); + int epoch(DateTime d) => d.millisecondsSinceEpoch ~/ 1000; + + /// Builds a local/Taskserver task. Every field is required on this model, so + /// the helper supplies neutral defaults. + TaskForC forC({ + int id = 1, + String description = 'desc', + String? project, + String status = 'pending', + String? uuid = 'u1', + double? urgency, + String? priority, + String? due, + String? end, + String entry = '20240101T000000Z', + String? modified, + List? tags, + String? start, + String? wait, + String? rtype, + String? recur, + List? depends, + List? annotations, + }) => + TaskForC( + id: id, + description: description, + project: project, + status: status, + uuid: uuid, + urgency: urgency, + priority: priority, + due: due, + end: end, + entry: entry, + modified: modified, + tags: tags, + start: start, + wait: wait, + rtype: rtype, + recur: recur, + depends: depends, + annotations: annotations, + ); + + group('parseTaskDate — format handling', () { + test('parses ISO-8601 and normalizes to UTC', () { + expect(parseTaskDate('2024-06-01T12:00:00Z'), DateTime.utc(2024, 6, 1, 12)); + }); + + test('converts a non-UTC ISO offset to UTC', () { + // 12:00+02:00 is 10:00 UTC. + expect(parseTaskDate('2024-06-01T12:00:00+02:00'), + DateTime.utc(2024, 6, 1, 10)); + }); + + test("parses Taskwarrior's compact stamp", () { + expect(parseTaskDate('20240701T161718Z'), + DateTime.utc(2024, 7, 1, 16, 17, 18)); + }); + + test('parses the compact stamp without a trailing Z', () { + expect(parseTaskDate('20240701T161718'), + DateTime.utc(2024, 7, 1, 16, 17, 18)); + }); + + test('parses epoch seconds given as a string', () { + final d = DateTime.utc(2024, 6, 1, 12); + expect(parseTaskDate('${epoch(d)}'), d); + }); + + test('a short numeric string is not mistaken for epoch seconds', () { + // Regression guard: without the minimum-digit check, '2024' would be + // read as epoch seconds and silently become 1970-01-01T00:33:44Z. + // Dart's ISO parser rejects a bare year, so the honest answer is null — + // we never invent a date from an ambiguous value. + expect(parseTaskDate('2024'), isNull); + expect(parseTaskDate('2024'), isNot(DateTime.utc(1970, 1, 1, 0, 33, 44))); + }); + + test('surrounding whitespace is tolerated', () { + expect(parseTaskDate(' 2024-06-01T12:00:00Z '), + DateTime.utc(2024, 6, 1, 12)); + }); + + test('returns null for null, empty, and unparseable input', () { + expect(parseTaskDate(null), isNull); + expect(parseTaskDate(''), isNull); + expect(parseTaskDate('not-a-date'), isNull); + expect(parseTaskDate(' '), isNull); + }); + + test('epochToDate handles null and zero', () { + expect(epochToDate(null), isNull); + expect(epochToDate(0), DateTime.utc(1970)); + }); + }); + + group('TaskLike — both models satisfy the contract', () { + test('TaskForReplica normalizes its epoch entry/modified', () { + final d = DateTime.utc(2024, 3, 4, 5, 6, 7); + final t = TaskForReplica(uuid: 'u', entry: epoch(d), modified: epoch(d)); + expect(t.entryDate, d); + expect(t.modifiedDate, d); + }); + + test('TaskForC normalizes its string entry/modified', () { + final t = forC(entry: '20240304T050607Z', modified: '20240304T050607Z'); + expect(t.entryDate, DateTime.utc(2024, 3, 4, 5, 6, 7)); + expect(t.modifiedDate, DateTime.utc(2024, 3, 4, 5, 6, 7)); + }); + + test('TaskForC reports blocking state as unknown (null), not false', () { + // The local path cannot resolve dependencies, so it must not claim a + // definite answer — consumers treat null as "not blocked". + final t = forC(depends: ['other-uuid']); + expect(t.isBlocked, isNull); + expect(t.isBlocking, isNull); + }); + + test('a null modified yields a null modifiedDate on both models', () { + expect(forC(modified: null).modifiedDate, isNull); + expect(TaskForReplica(uuid: 'u').modifiedDate, isNull); + }); + + test('both models are usable through the TaskLike contract', () { + final List mixed = [ + forC(uuid: 'c1', description: 'from local'), + TaskForReplica(uuid: 'r1', description: 'from replica'), + ]; + expect(mixed.map((t) => t.description), + ['from local', 'from replica']); + }); + }); + + group('Cross-model equivalence — the point of the consolidation', () { + // The same logical task, expressed in each model's own storage format. + final DateTime entryAt = now.subtract(const Duration(days: 10)); + final DateTime dueAt = now.subtract(const Duration(days: 2)); + + final replica = TaskForReplica( + uuid: 'same', + description: 'shared task', + status: 'pending', + project: 'work', + priority: 'H', + tags: ['a', 'b'], + entry: epoch(entryAt), + due: dueAt.toIso8601String(), + ); + final local = forC( + uuid: 'same', + description: 'shared task', + status: 'pending', + project: 'work', + priority: 'H', + tags: ['a', 'b'], + entry: entryAt.toIso8601String(), + due: dueAt.toIso8601String(), + ); + + test('identical tasks get identical urgency across models', () { + expect(computeTaskUrgency(local, clock: now), + closeTo(computeTaskUrgency(replica, clock: now), 1e-9)); + }); + + test('identical tasks match the same filters across models', () { + for (final expr in [ + 'status:pending', + '+PENDING', + '+OVERDUE', + 'project:work', + 'priority:H', + '+a', + '-BLOCKED', + 'status:pending +OVERDUE project:work', + ]) { + expect(VirtualFilterEngine.applyFilter([local], expr, now: now).length, + VirtualFilterEngine.applyFilter([replica], expr, now: now).length, + reason: 'filter "$expr" disagreed across models'); + } + }); + + test('a report yields the same verdict for either model', () { + final overdue = + ReportService.defaultReports.firstWhere((r) => r.name == 'overdue'); + expect(ReportService.execute(overdue, [local], clock: now).length, 1); + expect(ReportService.execute(overdue, [replica], clock: now).length, 1); + }); + }); + + group('Shared logic now works for the local model', () { + test('reports run over TaskForC and preserve its concrete type', () { + final next = + ReportService.defaultReports.firstWhere((r) => r.name == 'next'); + final List result = ReportService.execute( + next, + [forC(uuid: 'a'), forC(uuid: 'b', status: 'completed')], + clock: now, + ); + // Generic execute() must return List, not List. + expect(result, isA>()); + expect(result.map((t) => t.uuid), ['a']); + }); + + test('+BLOCKED excludes local tasks, whose blocking state is unknown', () { + final result = VirtualFilterEngine.applyFilter( + [forC(depends: ['x'])], '+BLOCKED', now: now); + expect(result, isEmpty); + }); + + test('+READY includes a local pending task (unknown block == not blocked)', + () { + expect( + VirtualFilterEngine.applyFilter([forC()], '+READY', now: now).length, + 1); + }); + + test('urgency ranks local tasks by priority', () { + final high = forC(priority: 'H'); + final low = forC(priority: 'L'); + expect(computeTaskUrgency(high, clock: now), + greaterThan(computeTaskUrgency(low, clock: now))); + }); + }); + + group('Sorting edge cases', () { + ReportDefinition sortBy(String spec) => ReportDefinition( + name: 'x', + description: 'x', + sortCriteria: SortCriterion.parseList(spec), + ); + + test('entry sort works across both storage formats', () { + final older = TaskForReplica( + uuid: 'older', entry: epoch(now.subtract(const Duration(days: 5)))); + final newer = TaskForReplica(uuid: 'newer', entry: epoch(now)); + final asc = + ReportService.execute(sortBy('entry+'), [newer, older], clock: now); + expect(asc.map((t) => t.uuid), ['older', 'newer']); + }); + + test('tasks with a missing date sort before those that have one', () { + final withDate = TaskForReplica(uuid: 'has', entry: epoch(now)); + final without = TaskForReplica(uuid: 'none'); + final asc = ReportService.execute( + sortBy('entry+'), [withDate, without], clock: now); + expect(asc.first.uuid, 'none'); + }); + + test('a task with a null uuid does not break urgency caching', () { + // The cache is keyed by uuid; null uuids must not collide with each + // other, or two distinct tasks would share one urgency value. + final a = forC(uuid: null, priority: 'H'); + final b = forC(uuid: null, priority: 'L'); + final sorted = + ReportService.execute(sortBy('urgency-'), [b, a], clock: now); + expect(sorted.first.priority, 'H'); + }); + + test('an empty task list is handled everywhere', () { + expect(ReportService.execute(sortBy('urgency-'), [], clock: now), + isEmpty); + expect(VirtualFilterEngine.applyFilter([], '+READY', now: now), + isEmpty); + }); + + test('an unknown sort field leaves the order untouched', () { + final t1 = TaskForReplica(uuid: 'first'); + final t2 = TaskForReplica(uuid: 'second'); + final r = ReportService.execute(sortBy('nosuchfield+'), [t1, t2], + clock: now); + expect(r.map((t) => t.uuid), ['first', 'second']); + }); + }); + + group('Degenerate / all-null tasks', () { + test('an all-null replica task has zero urgency and no dates', () { + final t = TaskForReplica(uuid: 'bare'); + expect(computeTaskUrgency(t, clock: now), 0.0); + expect(t.entryDate, isNull); + expect(t.modifiedDate, isNull); + }); + + test('a task with an unparseable date is treated as having none', () { + final t = forC(entry: 'garbage', due: 'also-garbage'); + expect(t.entryDate, isNull); + // No due date parsed → no due term contributed to urgency. + expect(computeTaskUrgency(t, clock: now), 0.0); + }); + + test('filters do not throw on tasks with null collections', () { + final t = TaskForReplica(uuid: 'n'); + expect(() => VirtualFilterEngine.applyFilter([t], '+sometag', now: now), + returnsNormally); + expect(VirtualFilterEngine.applyFilter([t], '+sometag', now: now), isEmpty); + }); + }); +} diff --git a/test/modules/reports/burn_down_data_test.dart b/test/modules/reports/burn_down_data_test.dart new file mode 100644 index 00000000..8c82b1ce --- /dev/null +++ b/test/modules/reports/burn_down_data_test.dart @@ -0,0 +1,125 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:taskwarrior/app/modules/reports/burn_down_data.dart'; + +/// Covers the bucketing shared by every burndown chart. This replaced nine +/// near-duplicate widgets that each re-implemented it inline, so the grouping +/// keys here must match what those produced or the x-axis labels would shift. +void main() { + BurnDownEntry e(DateTime d, String status) => + BurnDownEntry(date: d, status: status); + + group('bucketBurnDown — counting', () { + test('counts pending and completed separately within a bucket', () { + final buckets = bucketBurnDown([ + e(DateTime(2024, 6, 1), 'pending'), + e(DateTime(2024, 6, 1), 'pending'), + e(DateTime(2024, 6, 1), 'completed'), + ], BurnDownPeriod.daily); + + expect(buckets.length, 1); + expect(buckets['06-01'], {'pending': 2, 'completed': 1}); + }); + + test('separates tasks that fall in different buckets', () { + final buckets = bucketBurnDown([ + e(DateTime(2024, 6, 1), 'pending'), + e(DateTime(2024, 6, 2), 'completed'), + ], BurnDownPeriod.daily); + + expect(buckets.keys.toSet(), {'06-01', '06-02'}); + expect(buckets['06-01']!['pending'], 1); + expect(buckets['06-02']!['completed'], 1); + }); + + test('ignores statuses other than pending/completed', () { + // Deleted and recurring tasks were never plotted; with soft delete now + // preserving deleted records, this matters more than it used to. + final buckets = bucketBurnDown([ + e(DateTime(2024, 6, 1), 'deleted'), + e(DateTime(2024, 6, 1), 'recurring'), + e(DateTime(2024, 6, 1), 'pending'), + ], BurnDownPeriod.daily); + + expect(buckets['06-01'], {'pending': 1, 'completed': 0}); + }); + + test('an empty input produces no buckets', () { + expect(bucketBurnDown([], BurnDownPeriod.daily), isEmpty); + }); + + test('a bucket with only completed tasks still reports pending: 0', () { + final buckets = bucketBurnDown( + [e(DateTime(2024, 6, 1), 'completed')], BurnDownPeriod.daily); + expect(buckets['06-01'], {'pending': 0, 'completed': 1}); + }); + }); + + group('bucketBurnDown — ordering', () { + test('buckets come out oldest-first regardless of input order', () { + final buckets = bucketBurnDown([ + e(DateTime(2024, 6, 3), 'pending'), + e(DateTime(2024, 6, 1), 'pending'), + e(DateTime(2024, 6, 2), 'pending'), + ], BurnDownPeriod.daily); + + // Dart maps preserve insertion order, which is what the chart plots. + expect(buckets.keys.toList(), ['06-01', '06-02', '06-03']); + }); + }); + + group('burnDownBucketKey — grouping granularity', () { + test('daily keys are MM-dd', () { + expect(burnDownBucketKey(DateTime(2024, 7, 1), BurnDownPeriod.daily), + '07-01'); + }); + + test('monthly keys are MonthName YYYY', () { + expect(burnDownBucketKey(DateTime(2024, 7, 15), BurnDownPeriod.monthly), + 'July 2024'); + }); + + test('days in the same month share a monthly bucket', () { + final buckets = bucketBurnDown([ + e(DateTime(2024, 7, 1), 'pending'), + e(DateTime(2024, 7, 28), 'completed'), + ], BurnDownPeriod.monthly); + + expect(buckets.length, 1); + expect(buckets['July 2024'], {'pending': 1, 'completed': 1}); + }); + + test('days in the same 7-day window share a weekly bucket', () { + // Days 183 and 184 of 2024 both fall in window 27. + final buckets = bucketBurnDown([ + e(DateTime(2024, 7, 2), 'pending'), + e(DateTime(2024, 7, 3), 'completed'), + ], BurnDownPeriod.weekly); + + expect(buckets.length, 1); + expect(buckets.values.single, {'pending': 1, 'completed': 1}); + }); + + test('weekly buckets are 7-day windows from Jan 1, not calendar weeks', () { + // Documents a PRE-EXISTING quirk carried over unchanged by this + // refactor: Utils.getWeekNumbertoInt is ceil(daysSinceJan1 / 7), so the + // window boundaries ignore the day of week. 2024-07-01 is a Monday and + // 2024-07-03 is the same Mon-Sun week, yet they land in windows 26 and + // 27. Asserted so the behaviour is visible rather than hidden — if the + // charts should follow real calendar weeks, that is a deliberate + // behaviour change to make in Utils, not here. + expect(burnDownBucketKey(DateTime(2024, 7, 1), BurnDownPeriod.weekly), + '26'); + expect(burnDownBucketKey(DateTime(2024, 7, 3), BurnDownPeriod.weekly), + '27'); + }); + + test('different months land in different monthly buckets', () { + final buckets = bucketBurnDown([ + e(DateTime(2024, 6, 30), 'pending'), + e(DateTime(2024, 7, 1), 'pending'), + ], BurnDownPeriod.monthly); + + expect(buckets.keys.toList(), ['June 2024', 'July 2024']); + }); + }); +} diff --git a/test/utils/language/bengali_sentences_test.dart b/test/utils/language/bengali_sentences_test.dart index 413c587c..8d8a429c 100644 --- a/test/utils/language/bengali_sentences_test.dart +++ b/test/utils/language/bengali_sentences_test.dart @@ -103,9 +103,9 @@ void main() { expect(bengali.reportsPageAddTasksToSeeReports, 'রিপোর্ট দেখতে টাস্ক যোগ করুন'); expect(bengali.taskchampionTileDescription, - 'Taskwarrior সিঙ্কিং CCSync বা Taskchampion সিঙ্ক সার্ভারে পরিবর্তন করুন'); + 'Taskwarrior সিঙ্কিং TaskChampion সিঙ্ক সার্ভারে পরিবর্তন করুন'); expect(bengali.taskchampionTileTitle, 'Taskchampion সিঙ্ক'); - expect(bengali.ccsyncCredentials, 'CCSync ক্রেডেনশিয়াল'); + expect(bengali.syncServerCredentials, 'TaskChampion ক্রেডেনশিয়াল'); expect(bengali.deleteTaskConfirmation, 'টাস্ক মুছুন'); expect(bengali.deleteTaskTitle, 'সব টাস্ক মুছুন?'); expect(bengali.deleteTaskWarning, diff --git a/test/utils/language/english_sentences_test.dart b/test/utils/language/english_sentences_test.dart index a2e14071..c898749b 100644 --- a/test/utils/language/english_sentences_test.dart +++ b/test/utils/language/english_sentences_test.dart @@ -101,9 +101,9 @@ void main() { expect(english.reportsPageNoTasksFound, 'No Tasks Found'); expect(english.reportsPageAddTasksToSeeReports, 'Add Tasks To See Reports'); expect(english.taskchampionTileDescription, - 'Switch to Taskwarrior sync with CCSync or Taskchampion Sync Server'); + 'Switch to Taskwarrior sync with a TaskChampion sync server'); expect(english.taskchampionTileTitle, 'Taskchampion sync'); - expect(english.ccsyncCredentials, 'CCync credentials'); + expect(english.syncServerCredentials, 'TaskChampion credentials'); expect(english.deleteTaskConfirmation, 'Delete Tasks'); expect(english.deleteTaskTitle, 'Delete All Tasks?'); expect(english.deleteTaskWarning, diff --git a/test/utils/language/french_sentences_test.dart b/test/utils/language/french_sentences_test.dart index 023647ac..51885971 100644 --- a/test/utils/language/french_sentences_test.dart +++ b/test/utils/language/french_sentences_test.dart @@ -107,9 +107,9 @@ void main() { expect(french.reportsPageAddTasksToSeeReports, 'Ajoutez des tâches pour voir les rapports'); expect(french.taskchampionTileDescription, - 'Basculez la synchronisation de Taskwarrior vers le serveur de synchronisation CCSync ou Taskchampion'); + 'Basculez la synchronisation de Taskwarrior vers le serveur de synchronisation TaskChampion'); expect(french.taskchampionTileTitle, 'Synchronisation Taskchampion'); - expect(french.ccsyncCredentials, 'Identifiants CCSync'); + expect(french.syncServerCredentials, 'Identifiants TaskChampion'); expect(french.deleteTaskConfirmation, 'Supprimer la tâche'); expect(french.deleteTaskTitle, 'Supprimer toutes les tâches ?'); expect(french.deleteTaskWarning, diff --git a/test/utils/language/hindi_sentences_test.dart b/test/utils/language/hindi_sentences_test.dart index 45c41702..e6ddd15b 100644 --- a/test/utils/language/hindi_sentences_test.dart +++ b/test/utils/language/hindi_sentences_test.dart @@ -104,9 +104,9 @@ void main() { expect(hindi.reportsPageAddTasksToSeeReports, 'रिपोर्ट देखने के लिए कार्य जोड़ें'); expect(hindi.taskchampionTileDescription, - 'CCSync या Taskchampion सिंक सर्वर के साथ Taskwarrior सिंक पर स्विच करें'); + 'TaskChampion सिंक सर्वर के साथ Taskwarrior सिंक पर स्विच करें'); expect(hindi.taskchampionTileTitle, 'Taskchampion सिंक'); - expect(hindi.ccsyncCredentials, 'CCync क्रेडेन्शियल'); + expect(hindi.syncServerCredentials, 'TaskChampion क्रेडेन्शियल'); expect(hindi.deleteTaskConfirmation, 'कार्य हटाएं'); expect(hindi.deleteTaskTitle, 'सभी कार्य हटाएं?'); expect(hindi.deleteTaskWarning, diff --git a/test/utils/language/marathi_sentences_test.dart b/test/utils/language/marathi_sentences_test.dart index 57e8104c..1e2c4f6e 100644 --- a/test/utils/language/marathi_sentences_test.dart +++ b/test/utils/language/marathi_sentences_test.dart @@ -103,9 +103,9 @@ void main() { expect( marathi.reportsPageAddTasksToSeeReports, 'अहवाल पाहण्यासाठी काम जोडा'); expect(marathi.taskchampionTileDescription, - 'CCSync किंवा Taskchampion Sync Server सह Taskwarrior सिंक वर स्विच करा'); + 'TaskChampion Sync Server सह Taskwarrior सिंक वर स्विच करा'); expect(marathi.taskchampionTileTitle, 'Taskchampion सिंक'); - expect(marathi.ccsyncCredentials, 'CCync क्रेडेन्शियल'); + expect(marathi.syncServerCredentials, 'TaskChampion क्रेडेन्शियल'); expect(marathi.deleteTaskConfirmation, 'कार्य हटवा'); expect(marathi.deleteTaskTitle, 'सर्व कार्य हटवायचे का?'); expect(marathi.deleteTaskWarning, diff --git a/test/utils/language/sentences_test.dart b/test/utils/language/sentences_test.dart index 7c073bdf..68e5655d 100644 --- a/test/utils/language/sentences_test.dart +++ b/test/utils/language/sentences_test.dart @@ -60,7 +60,7 @@ void main() { expect(sentences.navDrawerReports, isA()); expect(sentences.navDrawerAbout, isA()); expect(sentences.navDrawerSettings, isA()); - expect(sentences.ccsyncCredentials, isA()); + expect(sentences.syncServerCredentials, isA()); expect(sentences.deleteTaskTitle, isA()); expect(sentences.deleteTaskConfirmation, isA()); expect(sentences.deleteTaskWarning, isA()); diff --git a/test/utils/language/spanish_sentences_test.dart b/test/utils/language/spanish_sentences_test.dart index f51722c4..2b97a8b9 100644 --- a/test/utils/language/spanish_sentences_test.dart +++ b/test/utils/language/spanish_sentences_test.dart @@ -105,9 +105,9 @@ void main() { expect(spanish.reportsPageAddTasksToSeeReports, 'Agrega tareas para ver informes'); expect(spanish.taskchampionTileDescription, - 'Cambia la sincronización de Taskwarrior al servidor de sincronización CCSync o Taskchampion'); + 'Cambia la sincronización de Taskwarrior al servidor de sincronización TaskChampion'); expect(spanish.taskchampionTileTitle, 'Sincronización Taskchampion'); - expect(spanish.ccsyncCredentials, 'Credenciales de CCSync'); + expect(spanish.syncServerCredentials, 'Credenciales de TaskChampion'); expect(spanish.deleteTaskConfirmation, 'Eliminar tarea'); expect(spanish.deleteTaskTitle, '¿Eliminar todas las tareas?'); expect(spanish.deleteTaskWarning, diff --git a/test/utils/taskc/impl/codec_test.dart b/test/utils/taskchampion/impl/codec_test.dart similarity index 94% rename from test/utils/taskc/impl/codec_test.dart rename to test/utils/taskchampion/impl/codec_test.dart index 835fb8db..d7a68347 100644 --- a/test/utils/taskc/impl/codec_test.dart +++ b/test/utils/taskchampion/impl/codec_test.dart @@ -1,6 +1,6 @@ import 'dart:typed_data'; import 'package:flutter_test/flutter_test.dart'; -import 'package:taskwarrior/app/utils/taskc/impl/codec.dart'; +import 'package:taskwarrior/app/utils/taskchampion/impl/codec.dart'; void main() { group('Codec', () { diff --git a/test/utils/taskc/impl/message_test.dart b/test/utils/taskchampion/impl/message_test.dart similarity index 95% rename from test/utils/taskc/impl/message_test.dart rename to test/utils/taskchampion/impl/message_test.dart index 327a4af0..19f76524 100644 --- a/test/utils/taskc/impl/message_test.dart +++ b/test/utils/taskchampion/impl/message_test.dart @@ -1,5 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:taskwarrior/app/utils/taskc/impl/message.dart'; +import 'package:taskwarrior/app/utils/taskchampion/impl/message.dart'; void main() { group('TaskserverResponseException', () { diff --git a/test/utils/taskc/message_test.dart b/test/utils/taskchampion/message_test.dart similarity index 96% rename from test/utils/taskc/message_test.dart rename to test/utils/taskchampion/message_test.dart index f7c69330..d9d0bcd9 100644 --- a/test/utils/taskc/message_test.dart +++ b/test/utils/taskchampion/message_test.dart @@ -1,5 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:taskwarrior/app/utils/taskc/message.dart'; +import 'package:taskwarrior/app/utils/taskchampion/message.dart'; import 'package:taskwarrior/app/utils/taskserver/credentials.dart'; void main() { diff --git a/test/utils/taskc/payload_test.dart b/test/utils/taskchampion/payload_test.dart similarity index 96% rename from test/utils/taskc/payload_test.dart rename to test/utils/taskchampion/payload_test.dart index c6ebfc01..25573ceb 100644 --- a/test/utils/taskc/payload_test.dart +++ b/test/utils/taskchampion/payload_test.dart @@ -1,5 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:taskwarrior/app/utils/taskc/payload.dart'; +import 'package:taskwarrior/app/utils/taskchampion/payload.dart'; void main() { group('Payload', () { diff --git a/test/utils/taskchampion/reporting_engine_test.dart b/test/utils/taskchampion/reporting_engine_test.dart new file mode 100644 index 00000000..d89781d6 --- /dev/null +++ b/test/utils/taskchampion/reporting_engine_test.dart @@ -0,0 +1,265 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:taskwarrior/app/models/report.dart'; +import 'package:taskwarrior/app/services/report_service.dart'; +import 'package:taskwarrior/app/utils/taskchampion/taskrc_parser.dart'; +import 'package:taskwarrior/app/utils/taskchampion/virtual_filter_engine.dart'; +import 'package:taskwarrior/app/v3/champion/models/task_for_replica.dart'; + +void main() { + final DateTime now = DateTime.utc(2024, 6, 1, 12); + + TaskForReplica task( + String uuid, { + String status = 'pending', + String? start, + String? wait, + String? due, + String? priority, + String? project, + bool? isBlocked, + bool? isBlocking, + List? tags, + }) => + TaskForReplica( + uuid: uuid, + status: status, + start: start, + wait: wait, + due: due, + priority: priority, + project: project, + isBlocked: isBlocked, + isBlocking: isBlocking, + tags: tags, + ); + + final active = task('active', start: now.toIso8601String(), priority: 'H'); + final ready = task('ready', priority: 'M'); + final blocked = task('blocked', isBlocked: true); + final overdue = + task('overdue', due: now.subtract(const Duration(days: 2)).toIso8601String()); + final waiting = + task('waiting', wait: now.add(const Duration(days: 5)).toIso8601String()); + // Also carries a past due date to prove +OVERDUE excludes non-pending tasks. + final completed = task('completed', + status: 'completed', + due: now.subtract(const Duration(days: 3)).toIso8601String()); + final recurring = task('recurring', status: 'recurring'); + final work = task('work', project: 'work', tags: ['office']); + // Deleted (so it doesn't affect any pending-based assertions) sibling + // project, to prove project: requires a dot boundary, not a raw prefix. + final workshop = + task('workshop-item', status: 'deleted', project: 'workshop'); + + final all = [ + active, + ready, + blocked, + workshop, + overdue, + waiting, + completed, + recurring, + work + ]; + + Set ids(List list) => + list.map((t) => t.uuid).toSet(); + + group('VirtualFilterEngine — virtual tags', () { + test('+ACTIVE = pending with a start date', () { + expect(ids(VirtualFilterEngine.applyFilter(all, '+ACTIVE', now: now)), + {'active'}); + }); + + test('+BLOCKED = has unresolved dependencies', () { + expect(ids(VirtualFilterEngine.applyFilter(all, '+BLOCKED', now: now)), + {'blocked'}); + }); + + test('+OVERDUE = due date in the past', () { + expect(ids(VirtualFilterEngine.applyFilter(all, '+OVERDUE', now: now)), + {'overdue'}); + }); + + test('+OVERDUE excludes non-pending tasks even with a past due date', () { + // 'completed' has a due date 3 days in the past but is completed, not + // pending — Taskwarrior never calls a closed task "overdue". + final result = VirtualFilterEngine.applyFilter(all, '+OVERDUE', now: now); + expect(ids(result).contains('completed'), isFalse); + }); + + test('+WAITING = a future wait date', () { + expect(ids(VirtualFilterEngine.applyFilter(all, '+WAITING', now: now)), + {'waiting'}); + }); + + test('+READY = pending, not blocked, not waiting', () { + // active/ready/overdue/work are all pending, unblocked, no future wait. + expect(ids(VirtualFilterEngine.applyFilter(all, '+READY', now: now)), + {'active', 'ready', 'overdue', 'work'}); + }); + }); + + group('VirtualFilterEngine — attributes, negation, compound', () { + test('status: attribute', () { + expect( + ids(VirtualFilterEngine.applyFilter(all, 'status:completed', now: now)), + {'completed'}); + }); + + test('project: prefix match', () { + expect(ids(VirtualFilterEngine.applyFilter(all, 'project:work', now: now)), + {'work'}); + }); + + test('project: requires a dot boundary, not a raw prefix', () { + // 'workshop-item' is in project "workshop" — a raw startsWith("work") + // would wrongly include it when filtering project:work. + final result = + ids(VirtualFilterEngine.applyFilter(all, 'project:work', now: now)); + expect(result, {'work'}); + expect(result.contains('workshop-item'), isFalse); + }); + + test('priority: attribute', () { + expect( + ids(VirtualFilterEngine.applyFilter(all, 'priority:H', now: now)), + {'active'}); + }); + + test('negation excludes matches', () { + expect( + VirtualFilterEngine.applyFilter(all, '-BLOCKED', now: now) + .any((t) => t.uuid == 'blocked'), + isFalse); + }); + + test('compound expression is AND of all tokens', () { + expect( + ids(VirtualFilterEngine.applyFilter(all, 'status:pending +ACTIVE', + now: now)), + {'active'}); + }); + + test('a real user tag matches', () { + expect(ids(VirtualFilterEngine.applyFilter(all, '+office', now: now)), + {'work'}); + }); + + test('empty/blank filter matches everything', () { + expect(VirtualFilterEngine.applyFilter(all, '', now: now).length, + all.length); + expect(VirtualFilterEngine.applyFilter(all, null, now: now).length, + all.length); + }); + }); + + group('ReportService.execute', () { + ReportDefinition report(String name) => + ReportService.defaultReports.firstWhere((r) => r.name == name); + + test('next → all pending tasks', () { + final result = ReportService.execute(report('next'), all, clock: now); + // 'waiting' has status:pending (a future wait, not a separate status), so + // it is included by the status:pending filter. + expect(ids(result), + {'active', 'ready', 'blocked', 'overdue', 'waiting', 'work'}); + }); + + test('overdue → only past-due pending tasks', () { + final result = ReportService.execute(report('overdue'), all, clock: now); + expect(ids(result), {'overdue'}); + }); + + test('completed → only completed tasks', () { + final result = ReportService.execute(report('completed'), all, clock: now); + expect(ids(result), {'completed'}); + }); + + test('all → every task, sorted urgency- (highest first)', () { + final result = ReportService.execute(report('all'), all, clock: now); + expect(result.length, all.length); + // urgency descending: each task's urgency >= the next. + for (var i = 0; i + 1 < result.length; i++) { + expect(result[i].computeUrgency(clock: now), + greaterThanOrEqualTo(result[i + 1].computeUrgency(clock: now))); + } + }); + + test('multi-key sort respects criterion order', () { + final r = ReportDefinition( + name: 'x', + description: 'x', + filterExpression: 'status:pending', + sortCriteria: SortCriterion.parseList('priority-,description+'), + ); + final result = ReportService.execute(r, all, clock: now); + // 'active' is the only H-priority pending task → sorts first. + expect(result.first.uuid, 'active'); + }); + }); + + group('ReportService.availableReports', () { + test('custom reports come first', () { + final custom = [ + const ReportDefinition( + name: 'mine', description: 'Mine', isCustom: true), + ]; + final list = ReportService.availableReports(custom); + expect(list.first.name, 'mine'); + expect(list.length, ReportService.defaultReports.length + 1); + }); + + test('a custom report overrides a same-named default', () { + final custom = [ + const ReportDefinition( + name: 'next', description: 'Custom next', isCustom: true), + ]; + final list = ReportService.availableReports(custom); + final nexts = list.where((r) => r.name == 'next').toList(); + expect(nexts.length, 1); + expect(nexts.first.isCustom, isTrue); + expect(list.length, ReportService.defaultReports.length); + }); + }); + + group('TaskrcParser', () { + test('extracts reports that have a .sort, ignores those without', () { + final parser = TaskrcParser(); + parser.parse(''' +# a comment +report.mine.description=My active work +report.mine.filter=status:pending +ACTIVE +report.mine.sort=due+,urgency- +report.mine.columns=id,description,due + +report.nosort.filter=status:pending +'''); + final reports = parser.customReports(); + expect(reports.length, 1); + final mine = reports.single; + expect(mine.name, 'mine'); + expect(mine.description, 'My active work'); + expect(mine.filterExpression, 'status:pending +ACTIVE'); + expect(mine.isCustom, isTrue); + expect(mine.sortCriteria.map((s) => '${s.field}${s.ascending ? '+' : '-'}'), + ['due+', 'urgency-']); + expect(mine.columns.map((c) => c.field), ['id', 'description', 'due']); + }); + + test('ignores comments and blank lines', () { + final parser = TaskrcParser(); + parser.parse('\n\n#only comments\n# report.x.sort=due\n'); + expect(parser.customReports(), isEmpty); + }); + }); + + group('SortCriterion.parseList', () { + test('parses direction suffixes and chains', () { + final list = SortCriterion.parseList('urgency-,due+,project'); + expect(list.map((s) => s.field), ['urgency', 'due', 'project']); + expect(list.map((s) => s.ascending), [false, true, true]); + }); + }); +} diff --git a/test/utils/taskc/response_test.dart b/test/utils/taskchampion/response_test.dart similarity index 94% rename from test/utils/taskc/response_test.dart rename to test/utils/taskchampion/response_test.dart index e2c694d4..12970779 100644 --- a/test/utils/taskc/response_test.dart +++ b/test/utils/taskchampion/response_test.dart @@ -1,6 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:taskwarrior/app/utils/taskc/response.dart'; -import 'package:taskwarrior/app/utils/taskc/payload.dart'; +import 'package:taskwarrior/app/utils/taskchampion/response.dart'; +import 'package:taskwarrior/app/utils/taskchampion/payload.dart'; void main() { group('Response', () { diff --git a/test/utils/taskchampion/task_for_replica_urgency_test.dart b/test/utils/taskchampion/task_for_replica_urgency_test.dart new file mode 100644 index 00000000..59e9791d --- /dev/null +++ b/test/utils/taskchampion/task_for_replica_urgency_test.dart @@ -0,0 +1,99 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:taskwarrior/app/v3/champion/models/task_for_replica.dart'; +import 'package:taskwarrior/app/v3/models/annotation.dart'; + +/// Verifies [TaskForReplica.computeUrgency] against Taskwarrior's documented +/// default urgency coefficients. A fixed [now] makes the age/due/waiting terms +/// deterministic. +void main() { + final DateTime now = DateTime.utc(2024, 6, 1, 12); + int epoch(DateTime d) => d.millisecondsSinceEpoch ~/ 1000; + double u(TaskForReplica t) => t.computeUrgency(clock: now); + + group('TaskForReplica.computeUrgency', () { + test('an empty task has zero urgency', () { + expect(u(TaskForReplica(uuid: 'u')), 0.0); + }); + + test('priority H / M / L → 6.0 / 3.9 / 1.8', () { + expect(u(TaskForReplica(uuid: 'u', priority: 'H')), closeTo(6.0, 1e-9)); + expect(u(TaskForReplica(uuid: 'u', priority: 'M')), closeTo(3.9, 1e-9)); + expect(u(TaskForReplica(uuid: 'u', priority: 'L')), closeTo(1.8, 1e-9)); + }); + + test('belonging to a project adds 1.0', () { + expect(u(TaskForReplica(uuid: 'u', project: 'Home')), closeTo(1.0, 1e-9)); + }); + + test('an active (started) task adds 4.0', () { + expect(u(TaskForReplica(uuid: 'u', start: now.toIso8601String())), + closeTo(4.0, 1e-9)); + }); + + test('tag counts 1 / 2 / 3 → 0.8 / 0.9 / 1.0', () { + expect(u(TaskForReplica(uuid: 'u', tags: ['a'])), closeTo(0.8, 1e-9)); + expect(u(TaskForReplica(uuid: 'u', tags: ['a', 'b'])), closeTo(0.9, 1e-9)); + expect( + u(TaskForReplica(uuid: 'u', tags: ['a', 'b', 'c'])), closeTo(1.0, 1e-9)); + }); + + test('the "next" tag adds 15.0 on top of its tag-count term', () { + expect(u(TaskForReplica(uuid: 'u', tags: ['next'])), closeTo(15.8, 1e-9)); + }); + + test('annotation counts 1 / 2 / 3 → 0.8 / 0.9 / 1.0', () { + Annotation a(String d) => Annotation(description: d); + expect(u(TaskForReplica(uuid: 'u', annotations: [a('1')])), + closeTo(0.8, 1e-9)); + expect(u(TaskForReplica(uuid: 'u', annotations: [a('1'), a('2')])), + closeTo(0.9, 1e-9)); + expect(u(TaskForReplica(uuid: 'u', annotations: [a('1'), a('2'), a('3')])), + closeTo(1.0, 1e-9)); + }); + + test('blocking adds 8.0, blocked subtracts 5.0', () { + expect(u(TaskForReplica(uuid: 'u', isBlocking: true)), closeTo(8.0, 1e-9)); + expect(u(TaskForReplica(uuid: 'u', isBlocked: true)), closeTo(-5.0, 1e-9)); + }); + + test('a future wait date subtracts 3.0', () { + expect( + u(TaskForReplica( + uuid: 'u', + wait: now.add(const Duration(days: 1)).toIso8601String())), + closeTo(-3.0, 1e-9)); + }); + + test('due ≥7 days overdue → full 12.0', () { + final due = now.subtract(const Duration(days: 10)).toIso8601String(); + expect(u(TaskForReplica(uuid: 'u', due: due)), closeTo(12.0, 1e-9)); + }); + + test('due exactly now → 8.8', () { + expect(u(TaskForReplica(uuid: 'u', due: now.toIso8601String())), + closeTo(8.8, 1e-9)); + }); + + test('due more than 14 days out → 2.4', () { + final due = now.add(const Duration(days: 20)).toIso8601String(); + expect(u(TaskForReplica(uuid: 'u', due: due)), closeTo(2.4, 1e-9)); + }); + + test('entry 365 days ago → full age term 2.0', () { + final e = epoch(now.subtract(const Duration(days: 365))); + expect(u(TaskForReplica(uuid: 'u', entry: e)), closeTo(2.0, 1e-9)); + }); + + test('terms combine additively (H + project + 2 tags + overdue)', () { + final due = now.subtract(const Duration(days: 8)).toIso8601String(); + final task = TaskForReplica( + uuid: 'u', + priority: 'H', // 6.0 + project: 'X', // 1.0 + tags: ['a', 'b'], // 0.9 + due: due, // 12.0 + ); + expect(u(task), closeTo(19.9, 1e-9)); + }); + }); +} diff --git a/test/utils/taskchampion/taskchampion_test.dart b/test/utils/taskchampion/taskchampion_test.dart index 0e0fddb1..9dda50a8 100644 --- a/test/utils/taskchampion/taskchampion_test.dart +++ b/test/utils/taskchampion/taskchampion_test.dart @@ -20,7 +20,7 @@ void main() { expect(controller.encryptionSecretController.text, ''); expect(controller.clientIdController.text, ''); - expect(controller.ccsyncBackendUrlController.text, ''); + expect(controller.syncServerUrlController.text, ''); }); test('should load existing credentials', () async { @@ -34,13 +34,13 @@ void main() { await controller.loadCredentials(); expect(controller.encryptionSecretController.text, 'mysecret'); expect(controller.clientIdController.text, 'client123'); - expect(controller.ccsyncBackendUrlController.text, 'https://example.com'); + expect(controller.syncServerUrlController.text, 'https://example.com'); }); test('should save credentials', () async { controller.encryptionSecretController.text = 'secret123'; controller.clientIdController.text = 'clientABC'; - controller.ccsyncBackendUrlController.text = 'https://backend.url'; + controller.syncServerUrlController.text = 'https://backend.url'; await controller.saveCredentials(); diff --git a/website/.gitignore b/website/.gitignore new file mode 100644 index 00000000..454de3e4 --- /dev/null +++ b/website/.gitignore @@ -0,0 +1,4 @@ +# Hugo build output and caches +/public/ +/resources/ +.hugo_build.lock diff --git a/website/README.md b/website/README.md new file mode 100644 index 00000000..0240d6ba --- /dev/null +++ b/website/README.md @@ -0,0 +1,74 @@ +# TaskWarrior Mobile — Project Website + +A self-contained [Hugo](https://gohugo.io) static site for the TaskWarrior Mobile +app: landing page, downloads (with an auto-updated **nightly build log**), and a +documentation section. It has **no external theme** — all layouts live in +`layouts/`, so it builds from the single Hugo binary with no submodules or network +fetches. + +## Local development + +Requires Hugo **extended** (pinned to the version in +[`.github/workflows/deploy-website.yml`](../.github/workflows/deploy-website.yml)): + +```bash +# from this directory +hugo server # live-reload dev server at http://localhost:1313 +hugo --minify # production build into ./public +``` + +## Layout + +``` +website/ +├── hugo.toml # site config (baseURL, menus, params) +├── content/ # Markdown pages (_index, downloads, docs) +├── layouts/ # self-contained templates (no theme) +│ ├── _default/ # baseof / single / list +│ ├── index.html # homepage +│ └── shortcodes/ +│ └── nightly-builds.html # renders data/nightly_builds.json +├── data/ +│ └── nightly_builds.json # build log (populated by CI; ships empty) +└── static/ + ├── css/style.css + └── CNAME # custom domain +``` + +## Nightly build log + +[`scripts/update_build_log.py`](../scripts/update_build_log.py) prepends an entry +`{ts, sha, msg, status, artifact, build}` to `data/nightly_builds.json` (keeping the +90 most recent). The `{{}}` shortcode on the downloads page +renders it. The file ships as `[]`; CI fills it in. + +## CI + +- **`build-nightly.yml`** — daily at 02:00 UTC (and on demand): builds the signed + nightly APK, records the result via `update_build_log.py`, and commits the updated + log to `main`. +- **`deploy-website.yml`** — on any push touching `website/**` (including the nightly + log commit): builds the site with Hugo and deploys it to GitHub Pages. +- `nightlydepolyci.yml` now ignores `website/**` and `scripts/**` so a build-log + commit does not trigger a redundant APK rebuild. + +## One-time infrastructure setup (needs repo-admin / DNS access) + +These cannot be done from code and must be configured by a maintainer: + +1. **Enable GitHub Pages** → repo *Settings → Pages → Build and deployment → + Source: **GitHub Actions***. +2. **Custom domain / DNS** — point `taskwarrior.ccextractor.org` at GitHub Pages + with a `CNAME` DNS record to `.github.io`. The site already ships a + `static/CNAME`, so Pages will pick up the domain on first deploy. +3. **Branch protection** — if `main` is protected, allow `github-actions[bot]` to + push the nightly build-log commit (or point that commit at an unprotected branch). +4. The nightly signing secrets (`NIGHTLY_KEYSTORE_B64`, `NIGHTLY_PROPERTIES_B64`) are + the same ones the existing F-Droid workflow already uses. + +> **Note on the deploy target.** The proposal described renaming the `fdroid-repo` +> branch to `public-site` and serving from a branch. This implementation instead uses +> the modern **GitHub Actions Pages deployment** (`actions/deploy-pages`), which keeps +> the site source in `website/` on `main` and needs no dedicated deploy branch — fewer +> moving parts and no history juggling. The F-Droid APK repo on `fdroid-repo` is +> untouched. diff --git a/website/content/_index.md b/website/content/_index.md new file mode 100644 index 00000000..dd48717b --- /dev/null +++ b/website/content/_index.md @@ -0,0 +1,12 @@ +--- +title: "TaskWarrior Mobile" +--- + +**TaskWarrior Mobile** is the cross-platform [Flutter](https://flutter.dev) client for +[TaskWarrior](https://taskwarrior.org), maintained by [CCExtractor](https://ccextractor.org). +It brings TaskWarrior's fast, unobtrusive task management to Android, iOS, Windows, +macOS, and Linux, backed by [TaskChampion](https://github.com/GothenburgBitFactory/taskchampion) +for native, offline-first sync. + +New here? Head to the [downloads page](downloads/) to install the latest build, or browse +the [nightly build history](downloads/#nightly-builds) to see what has changed recently. diff --git a/website/content/docs/_index.md b/website/content/docs/_index.md new file mode 100644 index 00000000..d6268b0f --- /dev/null +++ b/website/content/docs/_index.md @@ -0,0 +1,18 @@ +--- +title: "Documentation" +description: "Developer and user documentation for TaskWarrior Mobile." +--- + +Documentation for TaskWarrior Mobile — a living set of guides that grows alongside the +app. New here? Start with [Getting started](getting-started/), then +[set up sync](sync-setup/) when you are ready to connect a server. Curious how it all +fits together? See the [architecture overview](architecture/). + +## Contributing + +Contributions are welcome. Start with the +[CONTRIBUTING guide](https://github.com/CCExtractor/taskwarrior-flutter/blob/main/CONTRIBUTING.md) +in the repository, and join the [CCExtractor community](https://ccextractor.org/) on Slack +or Zulip. + +## Guides diff --git a/website/content/docs/architecture.md b/website/content/docs/architecture.md new file mode 100644 index 00000000..394dc050 --- /dev/null +++ b/website/content/docs/architecture.md @@ -0,0 +1,42 @@ +--- +title: "Architecture" +description: "How TaskWarrior Mobile is built — Flutter UI over a native Rust core." +weight: 3 +--- + +TaskWarrior Mobile is a Flutter app sitting on top of a native Rust core, so the same +task engine that powers TaskWarrior on the desktop runs unchanged on your phone. + +## Layers + +- **UI — Flutter (Dart).** Screens, navigation, and state management use + [GetX](https://pub.dev/packages/get). This layer never talks to the network for task + data; it reads and writes through the core. +- **Core — Rust (`tc_helper`).** A thin wrapper around + [TaskChampion](https://github.com/GothenburgBitFactory/taskchampion) exposed to Dart + through [`flutter_rust_bridge`](https://github.com/fzyzcjy/flutter_rust_bridge). It + owns task storage, mutations, and sync. +- **Storage & sync — TaskChampion.** Tasks live in a local replica on the device. + Syncing reconciles that replica with a TaskChampion sync server; there is no + bespoke HTTP API in between. + +## Offline-first data flow + +``` +tap "complete" ─▶ Flutter ─▶ Rust FFI ─▶ local replica (instant, no network) + │ + (later, when online) + ▼ + TaskChampion sync server +``` + +Every create, edit, and complete is committed to the local replica synchronously, so the +UI never waits on the network. Synchronisation is a separate, best-effort step that runs +when connectivity is available. + +## Why a Rust core? + +Reusing TaskChampion means the mobile app shares TaskWarrior's battle-tested task model — +recurrence, dependencies, annotations, tags, and the sync protocol — instead of +reimplementing them. The Rust bridge surfaces those capabilities to the Flutter UI with a +small, typed FFI surface. diff --git a/website/content/docs/faq.md b/website/content/docs/faq.md new file mode 100644 index 00000000..c308c347 --- /dev/null +++ b/website/content/docs/faq.md @@ -0,0 +1,39 @@ +--- +title: "FAQ" +description: "Frequently asked questions about TaskWarrior Mobile." +weight: 4 +--- + +## Do I need a sync server to use the app? + +No. TaskWarrior Mobile is fully usable offline — tasks are stored in a local replica on +your device. A sync server is only needed if you want your tasks mirrored to another +device or backed up remotely. + +## Is my data encrypted? + +Yes. When you sync, your tasks are encrypted locally with your encryption secret before +being uploaded. The server stores only ciphertext; only clients that hold the secret can +read your tasks. + +## Which platforms are supported? + +The app targets Android, iOS, Windows, macOS, and Linux from a single Flutter codebase. +Android builds are published as APKs on the [downloads page](../../downloads/). + +## Does it work with my existing TaskWarrior setup? + +If your TaskWarrior 3.x desktop syncs through a TaskChampion sync server, point the app at +the same server, client ID, and encryption secret and your tasks will sync between them. +See [setting up sync](../sync-setup/). + +## What's the difference between stable and nightly builds? + +Stable builds are tagged releases meant for everyday use. Nightly builds are automatic +snapshots of the `main` branch — useful for trying the latest changes, but less tested. + +## Where do I report a bug or request a feature? + +Open an issue on the +[GitHub repository](https://github.com/CCExtractor/taskwarrior-flutter/issues). Pull +requests are welcome too — see the [contributing notes](../../docs/#contributing). diff --git a/website/content/docs/getting-started.md b/website/content/docs/getting-started.md new file mode 100644 index 00000000..cc9b29b9 --- /dev/null +++ b/website/content/docs/getting-started.md @@ -0,0 +1,31 @@ +--- +title: "Getting started" +description: "Install TaskWarrior Mobile and create your first task." +weight: 1 +--- + +## Install the app + +Grab a build from the [downloads page](../../downloads/): + +- **Stable** — the latest signed APK from the GitHub [releases page](https://github.com/CCExtractor/taskwarrior-flutter/releases). +- **Nightly** — an automatically-built snapshot from the `main` branch, for testing the newest changes. + +On Android you may need to allow installation from your browser or file manager the +first time you sideload an APK. + +## Create your first task + +1. Open the app — you land on the task list. +2. Tap the **+** button. +3. Give the task a description, and optionally a project, tags, priority, and due date. +4. Save. The task is written to the local database immediately — no network required. + +Everything you do works fully offline. Sync is optional and layered on top; set it up +whenever you want your tasks mirrored to a server or another device. + +## Next steps + +- [Set up sync](../sync-setup/) with a TaskChampion server. +- Learn how the app is [put together](../architecture/). +- Browse the [FAQ](../faq/) for common questions. diff --git a/website/content/docs/sync-setup.md b/website/content/docs/sync-setup.md new file mode 100644 index 00000000..535328b6 --- /dev/null +++ b/website/content/docs/sync-setup.md @@ -0,0 +1,40 @@ +--- +title: "Setting up sync" +description: "Connect TaskWarrior Mobile to a TaskChampion sync server." +weight: 2 +--- + +TaskWarrior Mobile syncs through a [TaskChampion](https://github.com/GothenburgBitFactory/taskchampion) +sync server — the same protocol used by TaskWarrior 3.x on the desktop. Sync is +**optional and offline-first**: your tasks always live in a local replica, and syncing +simply reconciles that replica with the server when you are online. + +## What you need + +Three values from your TaskChampion sync server: + +| Field | Description | +|---|---| +| **Sync server URL** | The base URL of the TaskChampion server, e.g. `https://example.org/taskchampion/`. | +| **Client ID** | A UUID identifying your replica on the server. | +| **Encryption secret** | The secret used to encrypt your data end-to-end. Only clients that share it can read your tasks. | + +The server never sees your task data in the clear — everything is encrypted locally with +your encryption secret before it is uploaded. + +## Connecting + +1. Open **Profile** → change the sync server → choose **Taskchampion**. +2. Tap **Configure** and paste the three values above. +3. Tap **Save**. The app performs a live sync against the server to confirm the + credentials work *before* it stores them — if anything is wrong, you find out + immediately instead of silently failing later. + +Once connected, changes flow both ways whenever you have connectivity. You can keep +working offline at any time; the next sync catches the server up. + +## Self-hosting a server + +Any TaskChampion-compatible sync server works. See the upstream +[TaskChampion sync-server](https://github.com/GothenburgBitFactory/taskchampion-sync-server) +project to run your own. diff --git a/website/content/downloads.md b/website/content/downloads.md new file mode 100644 index 00000000..5029cd30 --- /dev/null +++ b/website/content/downloads.md @@ -0,0 +1,20 @@ +--- +title: "Downloads" +description: "Install TaskWarrior Mobile — stable releases and automatically-built nightly APKs." +--- + +## Stable releases + +Tagged releases are published on GitHub. Download the latest signed APK from the +[releases page](https://github.com/CCExtractor/taskwarrior-flutter/releases). + +## Nightly builds {#nightly-builds} + +A signed nightly APK is built automatically from the `main` branch every night and +published to the project's F-Droid repository. Each entry below links to the APK produced +by that build, newest first. + +{{< nightly-builds limit="20" >}} + +_Nightly builds are unstable snapshots intended for testing. For everyday use, prefer a +stable release above._ diff --git a/website/content/features.md b/website/content/features.md new file mode 100644 index 00000000..6a0daf04 --- /dev/null +++ b/website/content/features.md @@ -0,0 +1,44 @@ +--- +title: "Features" +description: "What TaskWarrior Mobile can do — offline-first task management with native TaskChampion sync." +--- + +TaskWarrior Mobile brings the full TaskWarrior task model to your phone and desktop, +without giving up speed or working offline. + +## Task management + +- **Rich tasks** — descriptions, projects, tags, priorities, and due dates. +- **Recurring tasks** — repeat tasks on a schedule. +- **Dependencies** — mark tasks as blocking or blocked by others so you always know + what is actionable. +- **Annotations** — attach timestamped notes to any task. +- **Fast capture and edit** — add or change a task in a couple of taps. + +## Offline-first + +- Every create, edit, and complete is written to a **local replica instantly** — no + spinner, no waiting on the network. +- The app is fully usable with no connectivity at all; sync is optional. + +## Native TaskChampion sync + +- Syncs through a [TaskChampion](https://github.com/GothenburgBitFactory/taskchampion) + sync server — the same protocol used by TaskWarrior 3.x on the desktop. +- **End-to-end encrypted**: your tasks are encrypted locally before upload, so the server + only ever stores ciphertext. +- Credentials are **validated against the server before they are saved**, so a bad URL or + secret is caught immediately. + +## Cross-platform + +- One Flutter codebase targeting **Android, iOS, Windows, macOS, and Linux**. +- A shared native Rust core keeps behaviour consistent everywhere. + +## Open source + +- Developed in the open on [GitHub](https://github.com/CCExtractor/taskwarrior-flutter) + and maintained by [CCExtractor](https://ccextractor.org/). +- Automated [nightly builds](../downloads/#nightly-builds) let you try the latest changes. + +Ready to try it? Head to the [downloads page](../downloads/). diff --git a/website/data/nightly_builds.json b/website/data/nightly_builds.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/website/data/nightly_builds.json @@ -0,0 +1 @@ +[] diff --git a/website/hugo.toml b/website/hugo.toml new file mode 100644 index 00000000..ca8ebeb3 --- /dev/null +++ b/website/hugo.toml @@ -0,0 +1,34 @@ +baseURL = "https://taskwarrior.ccextractor.org/" +locale = "en-us" +title = "TaskWarrior Mobile" +enableGitInfo = true + +# Self-contained site: layouts live in ./layouts, so no external theme (and thus +# no submodule / network fetch) is required — Hugo builds it from its single +# binary alone. + +[params] + description = "The cross-platform TaskWarrior mobile app by CCExtractor — offline-first task management with native TaskChampion sync." + repo = "https://github.com/CCExtractor/taskwarrior-flutter" + org = "https://ccextractor.org/" + +[markup.goldmark.renderer] + unsafe = true + +[menu] + [[menu.main]] + name = "Home" + url = "/" + weight = 1 + [[menu.main]] + name = "Features" + url = "/features/" + weight = 2 + [[menu.main]] + name = "Downloads" + url = "/downloads/" + weight = 3 + [[menu.main]] + name = "Docs" + url = "/docs/" + weight = 4 diff --git a/website/layouts/_default/baseof.html b/website/layouts/_default/baseof.html new file mode 100644 index 00000000..0f314d08 --- /dev/null +++ b/website/layouts/_default/baseof.html @@ -0,0 +1,29 @@ + + + + + + {{ if .IsHome }}{{ .Site.Title }}{{ else }}{{ .Title }} · {{ .Site.Title }}{{ end }} + + + + + + +
+ {{ block "main" . }}{{ end }} +
+ +
+

{{ .Site.Title }} · maintained by CCExtractor · built with Hugo

+
+ + diff --git a/website/layouts/_default/list.html b/website/layouts/_default/list.html new file mode 100644 index 00000000..8932309c --- /dev/null +++ b/website/layouts/_default/list.html @@ -0,0 +1,13 @@ +{{ define "main" }} +
+

{{ .Title }}

+ {{ .Content }} + {{ with .Pages }} + + {{ end }} +
+{{ end }} diff --git a/website/layouts/_default/single.html b/website/layouts/_default/single.html new file mode 100644 index 00000000..2eee85e2 --- /dev/null +++ b/website/layouts/_default/single.html @@ -0,0 +1,6 @@ +{{ define "main" }} +
+

{{ .Title }}

+ {{ .Content }} +
+{{ end }} diff --git a/website/layouts/index.html b/website/layouts/index.html new file mode 100644 index 00000000..92dd3063 --- /dev/null +++ b/website/layouts/index.html @@ -0,0 +1,45 @@ +{{ define "main" }} +
+

Your tasks, everywhere.

+

{{ .Site.Params.description }}

+

+ Get the app + View source +

+
+ +
+
+

Offline-first

+

Every create, edit, and complete is written to a local database instantly — no spinner, no network wait.

+
+
+

Native sync

+

Synchronise with a TaskChampion sync server through the native Rust bridge whenever you are online.

+
+
+

Cross-platform

+

One app across Android, iOS, Windows, macOS, and Linux, built with Flutter.

+
+
+

End-to-end encrypted

+

Your tasks are encrypted on-device before they sync — the server only ever stores ciphertext it cannot read.

+
+
+

Full task model

+

Projects, tags, priorities, due dates, recurrence, dependencies, and annotations — the complete TaskWarrior model.

+
+
+

Open source

+

Developed in the open on GitHub and maintained by CCExtractor, with automated nightly builds.

+
+
+ +
+ Explore all features → +
+ +
+ {{ .Content }} +
+{{ end }} diff --git a/website/layouts/shortcodes/nightly-builds.html b/website/layouts/shortcodes/nightly-builds.html new file mode 100644 index 00000000..3788f4ea --- /dev/null +++ b/website/layouts/shortcodes/nightly-builds.html @@ -0,0 +1,37 @@ +{{/* + nightly-builds shortcode — renders the build log collected by + scripts/update_build_log.py into website/data/nightly_builds.json. + Usage in content: {{< nightly-builds >}} (optionally {{< nightly-builds limit="15" >}}) +*/}} +{{ $builds := hugo.Data.nightly_builds }} +{{ $limit := int (.Get "limit" | default "20") }} +
+ {{ if $builds }} +
+ + + + + + + + + + + + {{ range first $limit $builds }} + + + + + + + + {{ end }} + +
Date (UTC)CommitMessageStatusAPK
{{ .ts }}{{ .sha }}{{ .msg }}{{ .status }}{{ with .artifact }}download{{ else }}—{{ end }}
+
+ {{ else }} +

No nightly builds have been recorded yet. Check back after the next scheduled build.

+ {{ end }} +
diff --git a/website/static/CNAME b/website/static/CNAME new file mode 100644 index 00000000..f71f76ef --- /dev/null +++ b/website/static/CNAME @@ -0,0 +1 @@ +taskwarrior.ccextractor.org diff --git a/website/static/css/style.css b/website/static/css/style.css new file mode 100644 index 00000000..37f1d885 --- /dev/null +++ b/website/static/css/style.css @@ -0,0 +1,165 @@ +:root { + --bg: #0f1419; + --surface: #171d26; + --text: #e6e9ee; + --muted: #9aa4b2; + --accent: #7c5cff; + --accent-2: #4dd0e1; + --border: #263041; + --ok: #3fb950; + --fail: #f85149; + --radius: 12px; + --maxw: 900px; +} + +* { box-sizing: border-box; } + +html { -webkit-text-size-adjust: 100%; } + +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + background: var(--bg); + color: var(--text); + line-height: 1.6; +} + +a { color: var(--accent-2); text-decoration: none; } +a:hover { text-decoration: underline; } + +code { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 6px; + padding: 0.1em 0.4em; + font-size: 0.9em; +} + +/* Header */ +.site-header { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 1rem 1.5rem; + border-bottom: 1px solid var(--border); + background: var(--surface); +} +.brand { font-weight: 700; font-size: 1.15rem; color: var(--text); } +.brand span { color: var(--accent); } +.site-header nav { display: flex; flex-wrap: wrap; gap: 1.1rem; } +.site-header nav a { color: var(--muted); font-weight: 500; } +.site-header nav a:hover { color: var(--text); text-decoration: none; } + +/* Layout */ +.content { max-width: var(--maxw); margin: 0 auto; padding: 2.5rem 1.5rem 4rem; } + +/* Hero */ +.hero { text-align: center; padding: 2rem 0 1rem; } +.hero h1 { font-size: clamp(2rem, 6vw, 3rem); margin: 0 0 0.5rem; } +.tagline { color: var(--muted); font-size: 1.15rem; max-width: 640px; margin: 0 auto 1.5rem; } +.cta { display: flex; gap: 0.75rem; justify-content: center; flex-wrap: wrap; } + +.button { + display: inline-block; + background: var(--accent); + color: #fff; + padding: 0.7rem 1.4rem; + border-radius: var(--radius); + font-weight: 600; +} +.button:hover { filter: brightness(1.1); text-decoration: none; } +.button-ghost { background: transparent; color: var(--text); border: 1px solid var(--border); } + +/* Features */ +.features { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 1rem; + margin: 2.5rem 0; +} +.feature { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1.25rem; +} +.feature h3 { margin: 0 0 0.4rem; color: var(--accent-2); } +.feature p { margin: 0; color: var(--muted); } + +/* Pages */ +.page h1 { border-bottom: 1px solid var(--border); padding-bottom: 0.5rem; } +.page h2 { margin-top: 2rem; } +.page-list { padding-left: 1.2rem; } + +/* Content tables (markdown) */ +.page table { + width: 100%; + border-collapse: collapse; + margin: 1.25rem 0; + font-size: 0.95rem; +} +.page th, .page td { + text-align: left; + padding: 0.55rem 0.7rem; + border-bottom: 1px solid var(--border); + vertical-align: top; +} +.page th { color: var(--muted); font-weight: 600; } +.page thead tr { border-bottom: 2px solid var(--border); } + +/* Code blocks */ +.page pre { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1rem; + overflow-x: auto; +} +.page pre code { background: none; border: none; padding: 0; } +.page blockquote { + margin: 1.25rem 0; + padding: 0.25rem 1rem; + border-left: 3px solid var(--accent); + color: var(--muted); +} + +/* Nightly build table */ +.table-scroll { overflow-x: auto; } +.nightly-builds table { + width: 100%; + border-collapse: collapse; + margin: 1rem 0; + font-size: 0.92rem; +} +.nightly-builds th, .nightly-builds td { + text-align: left; + padding: 0.55rem 0.7rem; + border-bottom: 1px solid var(--border); + vertical-align: top; +} +.nightly-builds th { color: var(--muted); font-weight: 600; } +.nowrap { white-space: nowrap; } +.empty { color: var(--muted); font-style: italic; } + +.status { + display: inline-block; + padding: 0.1em 0.6em; + border-radius: 999px; + font-size: 0.8em; + font-weight: 600; + text-transform: capitalize; +} +.status-success { background: rgba(63,185,80,0.15); color: var(--ok); } +.status-failure { background: rgba(248,81,73,0.15); color: var(--fail); } + +/* Footer */ +.site-footer { + border-top: 1px solid var(--border); + padding: 1.5rem; + text-align: center; + color: var(--muted); + font-size: 0.9rem; +} diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt index 4a18b3d6..151a087f 100644 --- a/windows/CMakeLists.txt +++ b/windows/CMakeLists.txt @@ -52,6 +52,26 @@ add_subdirectory(${FLUTTER_MANAGED_DIR}) # Application build; see runner/CMakeLists.txt. add_subdirectory("runner") +# Rebuild the tc_helper Rust library from rust/ as part of the build, so the +# native library cannot drift from its source. flutter_rust_bridge's desktop +# loader reads rust/target/release (see ioDirectory in frb_generated.dart), so +# a plain `cargo build --release` is all that is required here. +# +# Intentionally non-fatal: if cargo is absent the target prints a warning and +# succeeds, leaving any existing library in place, so contributors without a +# Rust toolchain can still build the app. +find_program(CARGO_EXECUTABLE cargo HINTS "$ENV{HOME}/.cargo/bin") +if(CARGO_EXECUTABLE) + add_custom_target(tc_helper_rust ALL + COMMAND "${CARGO_EXECUTABLE}" build --release + WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/../rust" + COMMENT "Building tc_helper Rust library" + ) + add_dependencies(${BINARY_NAME} tc_helper_rust) +else() + message(WARNING "cargo not found; skipping the tc_helper rebuild and using any existing library") +endif() + # Generated plugin build rules, which manage building the plugins and adding # them to the application.