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..6fbf4177 --- /dev/null +++ b/.github/workflows/build-nightly.yml @@ -0,0 +1,69 @@ +name: Nightly Build & Log + +# Scheduled canary: builds the signed nightly APK so a break is caught even on a +# day with no commits. It deliberately publishes nothing — the F-Droid deploy and +# the website build log are handled by nightlydepolyci.yml, which runs on push and +# builds the site from the deploy branch where its source now lives. + +on: + schedule: + - cron: '0 2 * * *' # 02:00 UTC daily + workflow_dispatch: + +permissions: + contents: read # nothing is committed; this only proves the build still works + +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 + + # A missing native library produces an APK that installs and then crashes + # at RustLib.init(), so fail the canary on it. + - name: Verify native libraries are in the APK + run: ./scripts/verify_apk_native_libs.sh build/app/outputs/flutter-apk/app-nightly-release.apk 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/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..a5250d8a 100644 --- a/.github/workflows/nightlydepolyci.yml +++ b/.github/workflows/nightlydepolyci.yml @@ -4,6 +4,10 @@ on: push: branches: - main + # The website lives on the deploy branch now, so nothing here needs to be + # excluded to avoid rebuilding the APK for a site-only change. + paths-ignore: + - '.github/workflows/build-nightly.yml' jobs: build-and-deploy: @@ -35,6 +39,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,10 +66,26 @@ 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 + # Step 7b: Capture which commit is being released. + # + # It has to be captured now: the job switches this work tree to the deploy + # branch further down, and from that point git HEAD is that branch's own + # orphan commit rather than the app commit. The site source lives on the + # deploy branch, so the site itself is built after the switch. + - name: Capture the deployed commit + run: | + echo "DEPLOY_SHA=${{ github.sha }}" >> "$GITHUB_ENV" + echo "DEPLOY_MSG=$(git log -1 --pretty=%s)" >> "$GITHUB_ENV" + # Step 8: Configure git, fetch existing repo, and place new APK # We fetch the repo so we can see existing APKs to prune them in the next step. - name: Configure and Prepare Files @@ -109,6 +133,36 @@ jobs: # Run the update command fdroid update -c + # Step 10b: Record the deploy and rebuild the site from this branch's own + # source, then place it at the root. Additions only — repo/, metadata/ and + # assets/ are untouched, so F-Droid clients are unaffected. + - name: Record deploy and rebuild the website + run: | + set -e + # website/ and scripts/ live on this branch, so they are on disk now. + python3 scripts/update_build_log.py success \ + "https://github.com/${{ github.repository }}/raw/fdroid-repo/repo/nightly.${{ github.run_number }}.apk" \ + "${{ github.run_number }}" "$DEPLOY_SHA" "$DEPLOY_MSG" + + # Pinned Hugo rather than a floating action, so a site build cannot + # start failing because an upstream default moved. + HUGO_VERSION=0.164.0 + curl -sSL -o /tmp/hugo.tar.gz \ + "https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_extended_${HUGO_VERSION}_linux-amd64.tar.gz" + tar -xzf /tmp/hugo.tar.gz -C /tmp hugo + + /tmp/hugo --source website --destination /tmp/site --gc --minify \ + --baseURL "https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}/" + + # The generated CNAME is dropped on purpose: no custom domain is + # configured, and committing one would make Pages claim a domain with + # no DNS behind it — taking the site down and breaking the F-Droid repo + # URL that clients already poll. + rm -f /tmp/site/CNAME + + cp -R /tmp/site/. . + touch .nojekyll + # Step 11: Push a fresh history with NO bloat # This creates a temporary orphan branch (no parents) containing ONLY # the files currently on disk, then force pushes it to replace fdroid-repo. 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..a387a8e6 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..feac6010 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..dce91b96 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/detailRoute/controllers/detail_route_controller.dart b/lib/app/modules/detailRoute/controllers/detail_route_controller.dart index 8b65bc1c..d29c15a3 100644 --- a/lib/app/modules/detailRoute/controllers/detail_route_controller.dart +++ b/lib/app/modules/detailRoute/controllers/detail_route_controller.dart @@ -11,6 +11,7 @@ import 'package:taskwarrior/app/utils/taskfunctions/urgency.dart'; import 'package:taskwarrior/app/utils/app_settings/app_settings.dart'; import 'package:tutorial_coach_mark/tutorial_coach_mark.dart'; import 'package:taskwarrior/app/utils/language/sentence_manager.dart'; +import 'package:taskwarrior/app/tour/safe_tour.dart'; class DetailRouteController extends GetxController { late String uuid; @@ -190,10 +191,14 @@ class DetailRouteController extends GetxController { void showDetailsPageTour(BuildContext context) { Future.delayed( const Duration(milliseconds: 500), - () { - SaveTourStatus.getDetailsTourStatus().then((value) => { - if (!value) {tutorialCoachMark.show(context: context)} - }); + () async { + if (await SaveTourStatus.getDetailsTourStatus()) return; + await safeShowTour( + tutorialCoachMark: tutorialCoachMark, + context: context, + targetKeys: [dueKey, waitKey, untilKey, priorityKey], + markSeen: () => SaveTourStatus.saveDetailsTourStatus(true), + ); }, ); } diff --git a/lib/app/modules/home/controllers/home_controller.dart b/lib/app/modules/home/controllers/home_controller.dart index 3cf342f8..9e3aeb71 100644 --- a/lib/app/modules/home/controllers/home_controller.dart +++ b/lib/app/modules/home/controllers/home_controller.dart @@ -33,17 +33,20 @@ 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'; +import 'package:taskwarrior/app/tour/safe_tour.dart'; 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 +54,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 +94,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 +114,7 @@ class HomeController extends GetxController { }); everAll([ pendingFilter, + statusFilter, waitingFilter, projectFilter, tagUnion, @@ -168,16 +184,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 +230,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 +352,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 +514,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 +533,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 +556,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 +599,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 +696,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 +711,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; @@ -739,19 +767,17 @@ class HomeController extends GetxController { void showInAppTour(BuildContext context) { Future.delayed( const Duration(milliseconds: 500), - () { - SaveTourStatus.getInAppTourStatus().then((value) => { - if (value == false) - { - tutorialCoachMark.show(context: context), - } - else - { - // ignore: avoid_print - debugPrint('User has seen this page'), - // User has seen this page - } - }); + () async { + if (await SaveTourStatus.getInAppTourStatus()) { + debugPrint('User has seen this page'); + return; + } + await safeShowTour( + tutorialCoachMark: tutorialCoachMark, + context: context, + targetKeys: [addKey, searchKey1, filterKey, menuKey, refreshKey], + markSeen: () => SaveTourStatus.saveInAppTourStatus(true), + ); }, ); } @@ -762,6 +788,12 @@ class HomeController extends GetxController { final GlobalKey filterTagKey = GlobalKey(); final GlobalKey sortByKey = GlobalKey(); + /// Which projects column the filter drawer is currently showing. The two sit + /// behind mutually exclusive `Visibility` widgets, so only one of + /// [projectsKey] / [projectsKeyTaskc] is ever laid out. + bool get usesTaskchampionProjects => + taskchampion.value || taskReplica.value; + void initFilterDrawerTour() { tutorialCoachMark = TutorialCoachMark( targets: filterDrawer( @@ -770,6 +802,7 @@ class HomeController extends GetxController { projectsKeyTaskc: projectsKeyTaskc, filterTagKey: filterTagKey, sortByKey: sortByKey, + useTaskchampionProjects: usesTaskchampionProjects, ), colorShadow: TaskWarriorColors.black, paddingFocus: 10, @@ -784,18 +817,24 @@ class HomeController extends GetxController { void showFilterDrawerTour(BuildContext context) { Future.delayed( const Duration(milliseconds: 500), - () { - SaveTourStatus.getFilterTourStatus().then((value) => { - if (value == false) - { - tutorialCoachMark.show(context: context), - } - else - { - // ignore: avoid_print - print('User has seen this page'), - } - }); + () async { + if (await SaveTourStatus.getFilterTourStatus()) { + debugPrint('User has seen this page'); + return; + } + await safeShowTour( + tutorialCoachMark: tutorialCoachMark, + context: context, + targetKeys: [ + statusKey, + // Only the column actually on screen; the other key is never laid + // out, so requiring it would suppress the tour permanently. + usesTaskchampionProjects ? projectsKeyTaskc : projectsKey, + filterTagKey, + sortByKey, + ], + markSeen: () => SaveTourStatus.saveFilterTourStatus(true), + ); }, ); } @@ -816,15 +855,20 @@ class HomeController extends GetxController { } void showTaskSwipeTutorial(BuildContext context) { - SaveTourStatus.getTaskSwipeTutorialStatus().then((value) { - print("value is $value"); - print("tasks is ${tasks.isNotEmpty}"); - if (value == false) { - initTaskSwipeTutorial(); - tutorialCoachMark.show(context: context); - } else { + SaveTourStatus.getTaskSwipeTutorialStatus().then((value) async { + if (value) { debugPrint('User has already seen the task swipe tutorial'); + return; } + initTaskSwipeTutorial(); + // This tour points at a task row, so it has nothing to highlight on an + // empty list — the guard turns that into a skip rather than a throw. + await safeShowTour( + tutorialCoachMark: tutorialCoachMark, + context: context, + targetKeys: [taskItemKey], + markSeen: () => SaveTourStatus.saveTaskSwipeTutorialStatus(true), + ); }); } 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..06b9efdd --- /dev/null +++ b/lib/app/modules/report_engine/controllers/report_engine_controller.dart @@ -0,0 +1,120 @@ +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/utils/taskchampion/virtual_filter_engine.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(); + } + + /// Save a user-built report and refresh the catalogue. + /// + /// Returns null on success, or a message explaining why it was refused — + /// the caller shows that rather than a generic failure. + Future saveReport(ReportDefinition report) async { + final String? nameError = TaskrcService.validateName(report.name); + if (nameError != null) return nameError; + + // A custom report may deliberately override a default of the same name + // (that is how Taskwarrior behaves), so a clash is allowed — but silently + // shadowing a built-in would be surprising, so say so. + try { + await TaskrcService.saveReport(report); + await loadReports(); + return null; + } catch (e) { + return 'Could not save the report: $e'; + } + } + + /// Delete a user-built report. Returns null on success, or a message. + Future deleteReport(String name) async { + try { + await TaskrcService.deleteReport(name); + if (selectedReport.value?.name == name) clearSelection(); + await loadReports(); + return null; + } catch (e) { + return 'Could not delete the report: $e'; + } + } + + /// True when [name] would shadow one of the built-in reports. + bool shadowsDefault(String name) => + ReportService.defaultReports.any((r) => r.name == name.trim()); + + /// How many tasks a filter currently matches. + /// + /// This is the practical check on a filter expression. The engine ignores an + /// attribute it does not recognise instead of failing, so a typo such as + /// `statuss:pending` quietly matches everything — a count shown while typing + /// makes that visible immediately, which validation alone cannot do. + Future previewMatchCount(String? filterExpression) async { + final List tasks = await Replica.getAllTasksFromReplica(); + return VirtualFilterEngine.applyFilter(tasks, filterExpression).length; + } +} diff --git a/lib/app/modules/report_engine/views/report_builder_sheet.dart b/lib/app/modules/report_engine/views/report_builder_sheet.dart new file mode 100644 index 00000000..6a0ced5b --- /dev/null +++ b/lib/app/modules/report_engine/views/report_builder_sheet.dart @@ -0,0 +1,357 @@ +import 'dart:async'; + +import 'package:flutter/material.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/utils/constants/taskwarrior_fonts.dart'; +import 'package:taskwarrior/app/utils/taskchampion/virtual_filter_engine.dart'; +import 'package:taskwarrior/app/utils/themes/theme_extension.dart'; + +/// Build or edit a custom report. +/// +/// The filter expression is the hard part of this screen. Its syntax is not +/// guessable, and the engine ignores a token it does not understand rather than +/// rejecting it — so a typo produces a report that quietly matches everything. +/// Two things address that: tappable chips insert correct tokens, and a live +/// count of matching tasks shows the actual effect of whatever is typed. +class ReportBuilderSheet extends StatefulWidget { + const ReportBuilderSheet({ + super.key, + required this.controller, + this.existing, + }); + + final ReportEngineController controller; + + /// The report being edited, or null when creating a new one. + final ReportDefinition? existing; + + @override + State createState() => _ReportBuilderSheetState(); +} + +class _ReportBuilderSheetState extends State { + late final TextEditingController _name; + late final TextEditingController _description; + late final TextEditingController _filter; + late final TextEditingController _columns; + + String _sortField = 'urgency'; + bool _sortAscending = false; + String? _error; + + int? _matchCount; + bool _counting = false; + Timer? _debounce; + + static const List _sortFields = [ + 'urgency', + 'due', + 'entry', + 'modified', + 'priority', + 'project', + 'description', + 'status', + ]; + + bool get _isEditing => widget.existing != null; + + @override + void initState() { + super.initState(); + final ReportDefinition? e = widget.existing; + _name = TextEditingController(text: e?.name ?? ''); + _description = TextEditingController(text: e?.description ?? ''); + _filter = TextEditingController(text: e?.filterExpression ?? ''); + _columns = TextEditingController( + text: (e?.columns.isNotEmpty ?? false) + ? e!.columns.map((c) => c.field).join(',') + : 'id,description', + ); + if (e != null && e.sortCriteria.isNotEmpty) { + _sortField = e.sortCriteria.first.field; + _sortAscending = e.sortCriteria.first.ascending; + } + _refreshCount(); + } + + @override + void dispose() { + _debounce?.cancel(); + _name.dispose(); + _description.dispose(); + _filter.dispose(); + _columns.dispose(); + super.dispose(); + } + + /// Recount after a short pause so every keystroke does not read the replica. + void _scheduleCount() { + _debounce?.cancel(); + _debounce = Timer(const Duration(milliseconds: 400), _refreshCount); + } + + Future _refreshCount() async { + setState(() => _counting = true); + try { + final int n = await widget.controller.previewMatchCount(_filter.text); + if (mounted) setState(() => _matchCount = n); + } catch (_) { + if (mounted) setState(() => _matchCount = null); + } finally { + if (mounted) setState(() => _counting = false); + } + } + + void _insertToken(String token) { + final String current = _filter.text.trimRight(); + final String next = current.isEmpty ? token : '$current $token'; + _filter.text = next; + _filter.selection = TextSelection.collapsed(offset: next.length); + setState(() {}); + _scheduleCount(); + } + + Future _save() async { + final ReportDefinition draft = ReportDefinition( + name: _name.text.trim(), + description: _description.text.trim().isEmpty + ? _name.text.trim() + : _description.text.trim(), + columns: ColumnSpec.parseList(_columns.text), + sortCriteria: [ + SortCriterion(_sortField, ascending: _sortAscending) + ], + filterExpression: + _filter.text.trim().isEmpty ? null : _filter.text.trim(), + isCustom: true, + ); + + final String? error = await widget.controller.saveReport(draft); + if (error != null) { + setState(() => _error = error); + return; + } + if (mounted) Navigator.of(context).pop(true); + } + + @override + Widget build(BuildContext context) { + final TaskwarriorColorTheme c = + Theme.of(context).extension()!; + final List issues = VirtualFilterEngine.validate(_filter.text); + final bool shadows = !_isEditing && + _name.text.trim().isNotEmpty && + widget.controller.shadowsDefault(_name.text); + + return Padding( + padding: EdgeInsets.only( + left: 16, + right: 16, + top: 16, + bottom: MediaQuery.of(context).viewInsets.bottom + 16, + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _isEditing ? 'Edit report' : 'New report', + style: GoogleFonts.poppins( + fontSize: TaskWarriorFonts.fontSizeLarge, + fontWeight: TaskWarriorFonts.bold, + color: c.primaryTextColor, + ), + ), + const SizedBox(height: 12), + + _field(c, _name, 'Name', 'e.g. work-today', + enabled: !_isEditing, onChanged: (_) => setState(() {})), + if (shadows) + _hint(c, + 'A built-in report is also called this. Yours will replace it.', + warn: true), + + _field(c, _description, 'Description', + 'What this report shows', onChanged: (_) => setState(() {})), + + const SizedBox(height: 8), + Text('Filter', + style: GoogleFonts.poppins( + fontWeight: FontWeight.w600, color: c.primaryTextColor)), + const SizedBox(height: 4), + _field(c, _filter, null, 'e.g. status:pending +READY', + onChanged: (_) { + setState(() {}); + _scheduleCount(); + }), + + // Tokens are offered rather than typed: the vocabulary is small and + // fixed, and tapping cannot misspell it. + const SizedBox(height: 8), + Wrap( + spacing: 6, + runSpacing: 4, + children: [ + for (final String t in VirtualFilterEngine.virtualTags) + _chip(c, '+$t', () => _insertToken('+$t')), + for (final String a in VirtualFilterEngine.attributes) + _chip(c, '$a:', () => _insertToken('$a:')), + ], + ), + + const SizedBox(height: 10), + _matchLine(c), + for (final String issue in issues) _hint(c, issue, warn: true), + + const SizedBox(height: 16), + Text('Sort by', + style: GoogleFonts.poppins( + fontWeight: FontWeight.w600, color: c.primaryTextColor)), + const SizedBox(height: 6), + Row( + children: [ + Expanded( + child: DropdownButtonFormField( + // `value:` not `initialValue:` — the latter only exists in + // Flutter newer than the 3.29.2 this project pins in CI, so + // it compiles on a current local SDK and fails the build. + value: _sortField, + isDense: true, + dropdownColor: c.secondaryBackgroundColor, + decoration: const InputDecoration( + isDense: true, border: OutlineInputBorder()), + style: GoogleFonts.poppins(color: c.primaryTextColor), + items: [ + for (final String f in _sortFields) + DropdownMenuItem(value: f, child: Text(f)), + ], + onChanged: (v) => + setState(() => _sortField = v ?? _sortField), + ), + ), + const SizedBox(width: 10), + ToggleButtons( + isSelected: [!_sortAscending, _sortAscending], + onPressed: (i) => setState(() => _sortAscending = i == 1), + borderRadius: BorderRadius.circular(6), + constraints: + const BoxConstraints(minHeight: 38, minWidth: 58), + children: const [Text('High→low'), Text('Low→high')], + ), + ], + ), + + const SizedBox(height: 16), + _field(c, _columns, 'Columns', 'id,description,due'), + + if (_error != null) _hint(c, _error!, warn: true), + + const SizedBox(height: 18), + Row( + children: [ + if (_isEditing) + TextButton.icon( + onPressed: () async { + final String? err = await widget.controller + .deleteReport(widget.existing!.name); + if (err == null && context.mounted) { + Navigator.of(context).pop(true); + } else if (context.mounted) { + setState(() => _error = err); + } + }, + icon: const Icon(Icons.delete_outline, size: 18), + label: const Text('Delete'), + style: TextButton.styleFrom( + foregroundColor: c.primaryTextColor), + ), + const Spacer(), + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text('Cancel', + style: GoogleFonts.poppins(color: c.primaryTextColor)), + ), + const SizedBox(width: 8), + FilledButton( + onPressed: _name.text.trim().isEmpty ? null : _save, + child: Text(_isEditing ? 'Save' : 'Create'), + ), + ], + ), + ], + ), + ), + ); + } + + Widget _field( + TaskwarriorColorTheme c, + TextEditingController controller, + String? label, + String hint, { + bool enabled = true, + ValueChanged? onChanged, + }) => + Padding( + padding: const EdgeInsets.only(bottom: 10), + child: TextField( + controller: controller, + enabled: enabled, + onChanged: onChanged, + style: GoogleFonts.poppins(color: c.primaryTextColor), + decoration: InputDecoration( + isDense: true, + labelText: label, + hintText: hint, + border: const OutlineInputBorder(), + labelStyle: GoogleFonts.poppins(color: c.secondaryTextColor), + hintStyle: GoogleFonts.poppins(color: c.primaryDisabledTextColor), + ), + ), + ); + + Widget _chip(TaskwarriorColorTheme c, String label, VoidCallback onTap) => + ActionChip( + label: Text(label, + style: GoogleFonts.poppins( + fontSize: 11, color: c.primaryTextColor)), + backgroundColor: c.secondaryBackgroundColor, + onPressed: onTap, + visualDensity: VisualDensity.compact, + ); + + Widget _matchLine(TaskwarriorColorTheme c) { + if (_counting) { + return Text('Checking…', + style: GoogleFonts.poppins( + fontSize: 12, color: c.secondaryTextColor)); + } + if (_matchCount == null) { + return const SizedBox.shrink(); + } + return Text( + _matchCount == 1 ? 'Matches 1 task' : 'Matches $_matchCount tasks', + style: GoogleFonts.poppins( + fontSize: 12, + fontWeight: FontWeight.w600, + color: c.primaryTextColor, + ), + ); + } + + Widget _hint(TaskwarriorColorTheme c, String text, {bool warn = false}) => + Padding( + padding: const EdgeInsets.only(top: 4, bottom: 4), + child: Text( + text, + style: GoogleFonts.poppins( + fontSize: 11, + color: warn ? Colors.orange.shade700 : c.secondaryTextColor, + ), + ), + ); +} 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..11bab425 --- /dev/null +++ b/lib/app/modules/report_engine/views/report_engine_view.dart @@ -0,0 +1,313 @@ +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/modules/report_engine/views/report_builder_sheet.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); + }), + ), + ); + } + + + /// Opens the report builder. [existing] is null when creating. + Future _openBuilder(BuildContext context, ReportDefinition? existing) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Theme.of(context) + .extension()! + .primaryBackgroundColor, + builder: (_) => ReportBuilderSheet( + controller: controller, + existing: existing, + ), + ); + } + + // --- 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, 8), + child: OutlinedButton.icon( + onPressed: () => _openBuilder(context, null), + icon: const Icon(Icons.add, size: 18), + label: const Text('New report'), + style: OutlinedButton.styleFrom( + foregroundColor: tColors.primaryTextColor), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 24), + child: Text( + 'Reports you create are saved as Taskwarrior config, so the same ' + 'file works on desktop:\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: r.isCustom + ? IconButton( + tooltip: 'Edit report', + icon: Icon(Icons.edit_outlined, + size: 20, color: tColors.secondaryTextColor), + onPressed: () => _openBuilder(context, r), + ) + : 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..57a815da 100644 --- a/lib/app/modules/reports/controllers/reports_controller.dart +++ b/lib/app/modules/reports/controllers/reports_controller.dart @@ -1,21 +1,16 @@ 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'; import 'package:tutorial_coach_mark/tutorial_coach_mark.dart'; +import 'package:taskwarrior/app/tour/safe_tour.dart'; class ReportsController extends GetxController with GetTickerProviderStateMixin { @@ -30,7 +25,6 @@ class ReportsController extends GetxController var selectedIndex = 0.obs; var allData = [].obs; late Storage storage; - var storageWidget; // void _initReportsTour() { // tutorialCoachMark = TutorialCoachMark( @@ -88,18 +82,17 @@ class ReportsController extends GetxController void showReportsTour(BuildContext context) { Future.delayed( const Duration(milliseconds: 500), - () { - SaveTourStatus.getReportsTourStatus().then((value) => { - if (value == false) - { - tutorialCoachMark.show(context: context), - } - else - { - // ignore: avoid_print - print('User has seen this page'), - } - }); + () async { + if (await SaveTourStatus.getReportsTourStatus()) { + debugPrint('User has seen this page'); + return; + } + await safeShowTour( + tutorialCoachMark: tutorialCoachMark, + context: context, + targetKeys: [daily, weekly, monthly], + markSeen: () => SaveTourStatus.saveReportsTourStatus(true), + ); }, ); } @@ -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..985cddbf 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; } } @@ -159,6 +168,238 @@ class TaskcDetailsController extends GetxController { } } + /// Whether this task's notes can be edited. + /// + /// Only replica tasks: the annotation write path is the TaskChampion FFI, and + /// the legacy SQLite model has no equivalent. The view hides the editor + /// entirely rather than offering a control that would silently do nothing. + bool get canEditAnnotations => isReplicaTask; + + /// True while an annotation write is in flight, so the view can disable its + /// controls instead of allowing a second write to race the first. + final annotationBusy = false.obs; + + /// Backing field for the "add a note" input. Owned by the controller rather + /// than the view so its text survives rebuilds, and so it is disposed exactly + /// once when the page is torn down. + final TextEditingController annotationInput = TextEditingController(); + + @override + void onClose() { + annotationInput.dispose(); + super.onClose(); + } + + /// Add a note to this task. + /// + /// Unlike the field editors, this writes through immediately rather than + /// joining the draft that [saveTask] commits. An annotation is its own + /// record in TaskChampion, added and removed by dedicated operations — there + /// is no "whole task" write that would carry it along, so deferring it would + /// mean inventing a pending-notes buffer for no benefit. It therefore does + /// not set [hasChanges]; leaving the page after adding a note loses nothing. + /// + /// Returns null on success, or a message describing why the write failed. + Future addAnnotationToTask(String description) async { + if (!canEditAnnotations) return 'Notes can only be edited on synced tasks.'; + if (annotationBusy.value) return null; + + final String uuid = initialTaskUuidDisplay(); + if (uuid == 'None') return 'This task has no identifier yet.'; + + annotationBusy.value = true; + try { + final String entry = + await Replica.addAnnotationToReplica(uuid, description); + // Append locally rather than re-reading every task from the replica: the + // entry the FFI returns is authoritative, so the list stays in step. + annotations.add(Annotation(entry: entry, description: description.trim())); + await _refreshHomeTasks(); + return null; + } catch (e) { + return _annotationErrorMessage(e); + } finally { + annotationBusy.value = false; + } + } + + /// Remove a note. Returns null on success, or a message on failure. + Future removeAnnotationFromTask(Annotation annotation) async { + if (!canEditAnnotations) return 'Notes can only be edited on synced tasks.'; + if (annotationBusy.value) return null; + + final String uuid = initialTaskUuidDisplay(); + final String? entry = annotation.entry; + if (uuid == 'None' || entry == null || entry.isEmpty) { + return 'This note cannot be identified, so it cannot be removed.'; + } + + annotationBusy.value = true; + try { + await Replica.removeAnnotationFromReplica(uuid, entry); + annotations.removeWhere((a) => a.entry == entry); + await _refreshHomeTasks(); + return null; + } catch (e) { + return _annotationErrorMessage(e); + } finally { + annotationBusy.value = false; + } + } + + /// Recurrence options the picker offers. Taskwarrior accepts far more, but + /// these cover the ordinary cases and cannot be mistyped. + static const List recurrenceOptions = [ + 'None', + 'daily', + 'weekly', + 'monthly', + 'quarterly', + 'yearly', + ]; + + /// Whether a due date is currently set. Recurrence depends on it: Taskwarrior + /// deletes a recurring task that has no due date, so the control stays + /// unavailable until there is one. + bool get hasDueDate { + final String d = due.value.trim(); + return d.isNotEmpty && d != 'None'; + } + + /// Whether recurrence can be edited: replica tasks with a due date. + bool get canEditRecurrence => isReplicaTask && hasDueDate; + + /// Why the due date cannot be cleared right now, or null if it can. + String? get dueRemovalBlockedReason => + (isReplicaTask && recur.value.trim().isNotEmpty) + ? 'Clear the repeat first — a repeating task needs a due date.' + : null; + + /// Whether this task's dependencies can be edited. Replica tasks only, for + /// the same reason as annotations: the write path is the TaskChampion FFI. + bool get canEditDependencies => isReplicaTask; + + /// True while a dependency write is in flight. + final dependencyBusy = false.obs; + + /// Tasks that can be picked as a dependency: everything in the replica except + /// this task and the ones it already depends on. + /// + /// Cycles are rejected by the Rust layer rather than filtered out here — the + /// check needs the whole graph, and doing it in one place keeps the answer + /// consistent no matter which client asks. + List availableDependencyCandidates() { + if (!canEditDependencies) return []; + final String self = initialTaskUuidDisplay(); + final Set already = depends.toSet(); + try { + return Get.find() + .tasksFromReplica + .where((t) => t.uuid != self && !already.contains(t.uuid)) + .toList(); + } catch (e) { + debugPrint('Could not list dependency candidates: $e'); + return []; + } + } + + /// A dependency is stored as a bare UUID, which means nothing to a reader. + /// Resolve it to the task's description, falling back to a short UUID prefix + /// when the task is not in the local replica. + String describeDependency(String uuid) { + try { + final matches = Get.find() + .tasksFromReplica + .where((t) => t.uuid == uuid); + if (matches.isNotEmpty) { + final String? description = matches.first.description; + if (description != null && description.trim().isNotEmpty) { + return description.trim(); + } + } + } catch (_) { + // fall through to the UUID form + } + return uuid.length > 8 ? '${uuid.substring(0, 8)}…' : uuid; + } + + /// Add a dependency. Returns null on success, or a message on failure. + Future addDependencyToTask(String dependsOnUuid) async { + if (!canEditDependencies) { + return 'Dependencies can only be edited on synced tasks.'; + } + if (dependencyBusy.value) return null; + + final String uuid = initialTaskUuidDisplay(); + if (uuid == 'None') return 'This task has no identifier yet.'; + + dependencyBusy.value = true; + try { + await Replica.addDependencyToReplica(uuid, dependsOnUuid); + depends.add(dependsOnUuid); + // Adding a dependency makes this task blocked; the depended-on task + // becomes blocking. Reflect the half we are showing. + isBlocked.value = true; + await _refreshHomeTasks(); + return null; + } catch (e) { + return _annotationErrorMessage(e); + } finally { + dependencyBusy.value = false; + } + } + + /// Remove a dependency. Returns null on success, or a message on failure. + Future removeDependencyFromTask(String dependsOnUuid) async { + if (!canEditDependencies) { + return 'Dependencies can only be edited on synced tasks.'; + } + if (dependencyBusy.value) return null; + + final String uuid = initialTaskUuidDisplay(); + if (uuid == 'None') return 'This task has no identifier yet.'; + + dependencyBusy.value = true; + try { + await Replica.removeDependencyFromReplica(uuid, dependsOnUuid); + depends.remove(dependsOnUuid); + // Only the last remaining dependency clears the blocked flag. + if (depends.isEmpty) isBlocked.value = false; + await _refreshHomeTasks(); + return null; + } catch (e) { + return _annotationErrorMessage(e); + } finally { + dependencyBusy.value = false; + } + } + + /// Reload the home list after a write. + /// + /// The detail page renders the `TaskForReplica` it was handed from that list, + /// so without this the cached copy goes stale the moment anything is written. + /// It matters most for dependencies: adding one changes the *other* task's + /// computed `is_blocking`, and opening that task would otherwise still show + /// the value from before the edge existed. + Future _refreshHomeTasks() async { + try { + await Get.find().refreshReplicaTasks(); + } catch (e) { + debugPrint('Could not refresh tasks after write: $e'); + } + } + + /// The Rust layer returns typed, already-readable messages ("annotation text + /// cannot be empty", "no task with UUID ..."). Surface those rather than a + /// generic failure, but strip the exception wrapper Dart adds around them. + String _annotationErrorMessage(Object error) { + final String raw = error.toString(); + final int marker = raw.indexOf(': '); + final String message = + marker >= 0 && marker + 2 < raw.length ? raw.substring(marker + 2) : raw; + return message.trim().isEmpty ? 'Could not save the note.' : message.trim(); + } + // Safe accessors for fields on the initial task so views don't attempt to // read properties that don't exist on TaskForReplica (which is a different // model shape than TaskForC). @@ -249,16 +490,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(", ")}'); @@ -314,17 +545,45 @@ class TaskcDetailsController extends GetxController { } } }(), - status: status.string.isNotEmpty ? status.string : null, + // Setting a repeat turns the task into a recurrence *template*, which + // Taskwarrior marks with status `recurring`. Verified against the CLI: + // with `recur` alone it reads the value but generates nothing; only a + // task whose status is `recurring` gets instances created. Clearing the + // repeat turns it back into an ordinary pending task. + status: recur.string.trim().isNotEmpty + ? 'recurring' + : (status.string.isNotEmpty + ? (status.string == 'recurring' ? 'pending' : status.string) + : null), description: description.string.isNotEmpty ? description.string : null, tags: tags.isNotEmpty ? tags.toList() : null, uuid: initialTask.uuid ?? '', priority: priority.string.isNotEmpty ? priority.string : null, project: project.string != 'None' ? project.string : null, + // Sent as part of the same edit rather than as its own write, because + // the FFI validates recurrence against the due date — and the user may + // legitimately set both in one go. + // + // Cleared as an EMPTY STRING, never null. modifyTaskInReplica skips null + // fields, so a null here means "leave it alone" rather than "remove it": + // turning the repeat off would revert the status but strand `recur` on + // the task, and a pending task that still carries `recur` is promoted + // back to recurring by the desktop CLI. The repeat would silently come + // back. An empty string reaches update_task, which clears the property. + recur: recur.string.trim(), ); debugPrint('Modified replica task: $modifiedTask'); hasChanges.value = false; processTagsLists(); - await Replica.modifyTaskInReplica(modifiedTask); + final String? error = await Replica.modifyTaskInReplica(modifiedTask); + if (error != null) { + // The edit was rejected outright (e.g. recurrence without a due date), + // so the draft is still unsaved — say why rather than silently losing it. + hasChanges.value = true; + Get.snackbar('Not saved', error, + snackPosition: SnackPosition.BOTTOM, duration: const Duration(seconds: 4)); + return; + } try { final HomeController homeController = Get.find(); await homeController.refreshReplicaTasks(); 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..6dcea1b5 100644 --- a/lib/app/modules/taskc_details/views/taskc_details_view.dart +++ b/lib/app/modules/taskc_details/views/taskc_details_view.dart @@ -11,6 +11,7 @@ import 'package:taskwarrior/app/utils/constants/taskwarrior_fonts.dart'; import 'package:taskwarrior/app/utils/home_path/impl/home.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 '../controllers/taskc_details_controller.dart'; class TaskcDetailsView extends GetView { @@ -111,6 +112,25 @@ class TaskcDetailsView extends GetView { controller.tags.join(', '), (value) => controller.updateListField(controller.tags, value), ), + // Attributes surfaced by the enriched Rust serializer (D2), + // replica tasks only. Dependencies, annotations and recurrence + // are editable; Blocked/Blocking are computed from the + // dependency graph, so they stay read-only. + if (controller.isReplicaTask) ...[ + _buildDetail( + context, + 'Blocked:', + controller.isBlocked.value ? 'Yes' : 'No', + ), + _buildDetail( + context, + 'Blocking:', + controller.isBlocking.value ? 'Yes' : 'No', + ), + _buildDependencyEditor(context, controller), + _buildRecurrenceDetail(context, controller), + _buildAnnotationEditor(context, controller), + ], if (controller.isLocalTask) ...[ _buildDetail( context, @@ -240,6 +260,390 @@ class TaskcDetailsView extends GetView { ); } + /// Tasks this one is waiting on, with add and remove. + /// + /// Dependencies are stored as bare UUIDs, so each is resolved to its task + /// description — a raw UUID tells the reader nothing. Whether an edge is + /// legal (no self-reference, no missing task, no loop) is decided by the Rust + /// layer, which can see the whole graph; this only reports what it says. + Widget _buildDependencyEditor( + BuildContext context, TaskcDetailsController controller) { + final TaskwarriorColorTheme tColors = + Theme.of(context).extension()!; + + Future pick() async { + final candidates = controller.availableDependencyCandidates(); + if (candidates.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('No other tasks available to depend on.')), + ); + return; + } + final String? chosen = await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: tColors.primaryBackgroundColor, + builder: (sheetContext) => _DependencyPicker( + candidates: candidates, + colors: tColors, + ), + ); + if (chosen == null) return; + final String? error = await controller.addDependencyToTask(chosen); + if (error != null && context.mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(error))); + } + } + + return Container( + width: double.infinity, + decoration: BoxDecoration( + color: tColors.secondaryBackgroundColor, + borderRadius: BorderRadius.circular(8.0), + boxShadow: const [ + BoxShadow(color: Colors.black12, blurRadius: 4.0, offset: Offset(0, 2)), + ], + ), + padding: const EdgeInsets.all(16.0), + margin: const EdgeInsets.symmetric(vertical: 8.0), + child: Obx( + () => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Depends:', + style: GoogleFonts.poppins( + fontWeight: TaskWarriorFonts.bold, + fontSize: TaskWarriorFonts.fontSizeMedium, + color: tColors.primaryTextColor, + ), + ), + const SizedBox(height: 8), + if (controller.depends.isEmpty) + Text( + 'None', + style: GoogleFonts.poppins( + fontSize: TaskWarriorFonts.fontSizeMedium, + color: tColors.primaryTextColor, + ), + ) + else + ...controller.depends.map( + (uuid) => Padding( + padding: const EdgeInsets.only(bottom: 4.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + controller.describeDependency(uuid), + style: GoogleFonts.poppins( + fontSize: TaskWarriorFonts.fontSizeMedium, + color: tColors.primaryTextColor, + ), + ), + ), + if (controller.canEditDependencies) + IconButton( + tooltip: 'Remove dependency', + icon: const Icon(Icons.close, size: 18), + color: tColors.primaryTextColor, + onPressed: controller.dependencyBusy.value + ? null + : () async { + final String? error = await controller + .removeDependencyFromTask(uuid); + if (error != null && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(error))); + } + }, + ), + ], + ), + ), + ), + if (controller.canEditDependencies) ...[ + const SizedBox(height: 4), + Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + onPressed: controller.dependencyBusy.value ? null : pick, + icon: const Icon(Icons.add, size: 18), + label: Text( + 'Add dependency', + style: GoogleFonts.poppins( + fontSize: TaskWarriorFonts.fontSizeMedium, + ), + ), + style: TextButton.styleFrom( + foregroundColor: tColors.primaryTextColor, + padding: EdgeInsets.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ), + ), + ], + ], + ), + ), + ); + } + + + /// Recurrence. + /// + /// This app cannot generate the repeats itself — TaskChampion has no + /// recurrence engine, so the value written here is acted on by the desktop + /// Taskwarrior CLI the next time it opens the same database. The caption says + /// so, because a control that looks self-contained but is not would be worse + /// than none. + /// + /// It also requires a due date. Taskwarrior *deletes* a recurring task that + /// has none, so offering the option without one would let the app destroy a + /// task on the user's next desktop sync. + Widget _buildRecurrenceDetail( + BuildContext context, TaskcDetailsController controller) { + final TaskwarriorColorTheme c = + Theme.of(context).extension()!; + final bool enabled = controller.canEditRecurrence; + final String current = controller.recur.value.trim(); + + return Container( + width: double.infinity, + decoration: BoxDecoration( + color: c.secondaryBackgroundColor, + borderRadius: BorderRadius.circular(8.0), + boxShadow: const [ + BoxShadow(color: Colors.black12, blurRadius: 4.0, offset: Offset(0, 2)), + ], + ), + padding: const EdgeInsets.all(16.0), + margin: const EdgeInsets.symmetric(vertical: 8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Repeats:', + style: GoogleFonts.poppins( + fontWeight: TaskWarriorFonts.bold, + fontSize: TaskWarriorFonts.fontSizeMedium, + color: enabled + ? c.primaryTextColor + : c.primaryDisabledTextColor, + ), + ), + const SizedBox(width: 8), + Expanded( + child: GestureDetector( + onTap: enabled + ? () => _pickRecurrence(context, controller) + : null, + child: Text( + current.isEmpty ? 'None' : current, + textAlign: TextAlign.end, + style: GoogleFonts.poppins( + fontSize: TaskWarriorFonts.fontSizeMedium, + color: enabled + ? c.primaryTextColor + : c.primaryDisabledTextColor, + ), + ), + ), + ), + ], + ), + const SizedBox(height: 6), + Text( + enabled + ? 'Repeats are created by Taskwarrior on desktop, not on the phone.' + : 'Set a due date first — a repeating task needs one.', + style: GoogleFonts.poppins( + fontSize: TaskWarriorFonts.fontSizeSmall, + color: c.primaryDisabledTextColor, + ), + ), + ], + ), + ); + } + + Future _pickRecurrence( + BuildContext context, TaskcDetailsController controller) async { + final TaskwarriorColorTheme c = + Theme.of(context).extension()!; + final String? chosen = await showModalBottomSheet( + context: context, + backgroundColor: c.primaryBackgroundColor, + builder: (sheetContext) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final String option + in TaskcDetailsController.recurrenceOptions) + ListTile( + title: Text(option, + style: GoogleFonts.poppins(color: c.primaryTextColor)), + onTap: () => Navigator.of(sheetContext).pop(option), + ), + ], + ), + ), + ); + if (chosen == null) return; + controller.updateField( + controller.recur, chosen == 'None' ? '' : chosen); + } + + /// Notes on a task, with add and remove. + /// + /// Notes are written straight through to the replica rather than joining the + /// draft the Save button commits, because each one is its own record in + /// TaskChampion. The list therefore reflects what is stored, not what is + /// pending, and leaving the page never discards a note. + Widget _buildAnnotationEditor( + BuildContext context, TaskcDetailsController controller) { + final TaskwarriorColorTheme tColors = + Theme.of(context).extension()!; + final TextEditingController input = controller.annotationInput; + + Future submit() async { + final String text = input.text.trim(); + if (text.isEmpty) return; + final String? error = await controller.addAnnotationToTask(text); + if (error == null) { + input.clear(); + } else if (context.mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(error))); + } + } + + return Container( + width: double.infinity, + decoration: BoxDecoration( + color: tColors.secondaryBackgroundColor, + borderRadius: BorderRadius.circular(8.0), + boxShadow: const [ + BoxShadow(color: Colors.black12, blurRadius: 4.0, offset: Offset(0, 2)), + ], + ), + padding: const EdgeInsets.all(16.0), + margin: const EdgeInsets.symmetric(vertical: 8.0), + child: Obx( + () => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Annotations:', + style: GoogleFonts.poppins( + fontWeight: TaskWarriorFonts.bold, + fontSize: TaskWarriorFonts.fontSizeMedium, + color: tColors.primaryTextColor, + ), + ), + const SizedBox(height: 8), + if (controller.annotations.isEmpty) + Text( + 'None', + style: GoogleFonts.poppins( + fontSize: TaskWarriorFonts.fontSizeMedium, + color: tColors.primaryTextColor, + ), + ) + else + ...controller.annotations.map( + (annotation) => Padding( + padding: const EdgeInsets.only(bottom: 4.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + annotation.description ?? '', + style: GoogleFonts.poppins( + fontSize: TaskWarriorFonts.fontSizeMedium, + color: tColors.primaryTextColor, + ), + ), + if (annotation.entry != null && + annotation.entry!.isNotEmpty) + Text( + annotation.entry!, + style: GoogleFonts.poppins( + fontSize: TaskWarriorFonts.fontSizeSmall, + color: tColors.primaryDisabledTextColor, + ), + ), + ], + ), + ), + if (controller.canEditAnnotations) + IconButton( + tooltip: 'Remove note', + icon: const Icon(Icons.close, size: 18), + color: tColors.primaryTextColor, + onPressed: controller.annotationBusy.value + ? null + : () async { + final String? error = await controller + .removeAnnotationFromTask(annotation); + if (error != null && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(error))); + } + }, + ), + ], + ), + ), + ), + if (controller.canEditAnnotations) ...[ + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: TextField( + controller: input, + enabled: !controller.annotationBusy.value, + textInputAction: TextInputAction.done, + onSubmitted: (_) => submit(), + style: GoogleFonts.poppins( + fontSize: TaskWarriorFonts.fontSizeMedium, + color: tColors.primaryTextColor, + ), + decoration: InputDecoration( + isDense: true, + hintText: 'Add a note', + hintStyle: GoogleFonts.poppins( + fontSize: TaskWarriorFonts.fontSizeMedium, + color: tColors.primaryDisabledTextColor, + ), + ), + ), + ), + IconButton( + tooltip: 'Add note', + icon: const Icon(Icons.add), + color: tColors.primaryTextColor, + onPressed: controller.annotationBusy.value ? null : submit, + ), + ], + ), + ], + ], + ), + ), + ); + } + Widget _buildDetail(BuildContext context, String label, String value, {bool disabled = false}) { TaskwarriorColorTheme tColors = @@ -290,3 +694,131 @@ class TaskcDetailsView extends GetView { ); } } + +/// Bottom sheet for choosing a task to depend on. +/// +/// Stateful purely for the filter field: a replica can hold hundreds of tasks, +/// so an unfiltered list is not usable. Selecting pops the chosen UUID; the +/// caller decides whether the edge is legal. +class _DependencyPicker extends StatefulWidget { + const _DependencyPicker({ + required this.candidates, + required this.colors, + }); + + final List candidates; + final TaskwarriorColorTheme colors; + + @override + State<_DependencyPicker> createState() => _DependencyPickerState(); +} + +class _DependencyPickerState extends State<_DependencyPicker> { + final TextEditingController _query = TextEditingController(); + + @override + void dispose() { + _query.dispose(); + super.dispose(); + } + + List get _visible { + final String q = _query.text.trim().toLowerCase(); + if (q.isEmpty) return widget.candidates; + return widget.candidates + .where((t) => (t.description ?? '').toLowerCase().contains(q)) + .toList(); + } + + @override + Widget build(BuildContext context) { + final TaskwarriorColorTheme colors = widget.colors; + final List visible = _visible; + + return SafeArea( + child: Padding( + // Keep the field above the keyboard. + padding: EdgeInsets.only( + left: 16, + right: 16, + top: 16, + bottom: MediaQuery.of(context).viewInsets.bottom + 16, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Depends on', + style: GoogleFonts.poppins( + fontWeight: TaskWarriorFonts.bold, + fontSize: TaskWarriorFonts.fontSizeLarge, + color: colors.primaryTextColor, + ), + ), + const SizedBox(height: 12), + TextField( + controller: _query, + autofocus: false, + onChanged: (_) => setState(() {}), + style: GoogleFonts.poppins(color: colors.primaryTextColor), + decoration: InputDecoration( + isDense: true, + prefixIcon: const Icon(Icons.search, size: 20), + hintText: 'Search tasks', + hintStyle: + GoogleFonts.poppins(color: colors.primaryDisabledTextColor), + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.45, + ), + child: visible.isEmpty + ? Padding( + padding: const EdgeInsets.symmetric(vertical: 24), + child: Text( + 'No tasks match that search.', + style: GoogleFonts.poppins( + color: colors.primaryDisabledTextColor, + ), + ), + ) + : ListView.builder( + shrinkWrap: true, + itemCount: visible.length, + itemBuilder: (context, index) { + final TaskForReplica task = visible[index]; + final String description = + (task.description ?? '').trim(); + return ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + title: Text( + description.isEmpty ? task.uuid : description, + style: GoogleFonts.poppins( + color: colors.primaryTextColor, + ), + ), + subtitle: task.status == null + ? null + : Text( + task.status!, + style: GoogleFonts.poppins( + fontSize: TaskWarriorFonts.fontSizeSmall, + color: colors.primaryDisabledTextColor, + ), + ), + onTap: () => Navigator.of(context).pop(task.uuid), + ); + }, + ), + ), + ], + ), + ), + ); + } +} 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..548beac6 --- /dev/null +++ b/lib/app/services/taskrc_service.dart @@ -0,0 +1,135 @@ +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'; + +/// Reads and writes the user `.taskrc` that holds custom reports (Issue #418). +/// +/// Reports created in the app are stored in exactly the format Taskwarrior +/// itself uses — `report..filter` and friends — rather than in a private +/// store. That keeps one source of truth (a report typed by hand and one built +/// in the app are indistinguishable) and means the file can be copied to a +/// desktop Taskwarrior and still work. +class TaskrcService { + static Future _file() async { + final Directory dir = await getApplicationDocumentsDirectory(); + return File('${dir.path}/.taskrc'); + } + + /// Absolute path where the `.taskrc` lives (shown in the UI). + 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 TaskrcParser parser = TaskrcParser()..parse(await file.readAsString()); + return parser.customReports(); + } catch (_) { + return []; + } + } + + /// A report name Taskwarrior can address: `report..sort` is parsed by + /// splitting on dots, so a name containing a dot, whitespace or `=` would + /// produce a key that can never be read back. + static String? validateName(String name) { + final String trimmed = name.trim(); + if (trimmed.isEmpty) return 'Give the report a name.'; + if (!RegExp(r'^[A-Za-z0-9_-]+$').hasMatch(trimmed)) { + return 'Use only letters, numbers, hyphens and underscores.'; + } + return null; + } + + /// Write [report] into `.taskrc`, replacing any report already stored under + /// the same name. + /// + /// The file is edited line by line rather than rewritten: a user may have put + /// their own settings, comments or reports in it, and none of that should be + /// lost because the app saved something. Only lines whose key begins with + /// `report..` are dropped before the new block is appended. + static Future saveReport(ReportDefinition report) async { + final File file = await _file(); + final String existing = + await file.exists() ? await file.readAsString() : ''; + await file.writeAsString(mergeReport(existing, report)); + } + + /// Pure form of [saveReport]: returns what the file should contain. + /// + /// Separated from the file so it can be tested directly — this is the part + /// that must not lose a user's hand-written settings, and it is not worth + /// trusting to a plugin-dependent integration test. + static String mergeReport(String content, ReportDefinition report) { + final String? nameError = validateName(report.name); + if (nameError != null) throw ArgumentError(nameError); + final String name = report.name.trim(); + + final List kept = _linesExcludingReport(content, name); + + // `.sort` is what marks a block as a real report — Taskwarrior's own rule, + // and what TaskrcParser looks for — so it must always be written, even when + // the user left the sort field empty. + final String sort = report.sortCriteria.isEmpty + ? 'urgency-' + : report.sortCriteria + .map((c) => '${c.field}${c.ascending ? '+' : '-'}') + .join(','); + final String columns = report.columns.isEmpty + ? 'id,description' + : report.columns.map((c) => c.field).join(','); + + final List block = [ + 'report.$name.description=${report.description.trim()}', + 'report.$name.columns=$columns', + 'report.$name.sort=$sort', + if ((report.filterExpression ?? '').trim().isNotEmpty) + 'report.$name.filter=${report.filterExpression!.trim()}', + ]; + + return [ + ...kept, + if (kept.isNotEmpty) '', + ...block, + '', + ].join('\n'); + } + + /// Remove the report called [name], leaving the rest of the file untouched. + static Future deleteReport(String name) async { + final File file = await _file(); + if (!await file.exists()) return; + await file.writeAsString(removeReport(await file.readAsString(), name)); + } + + /// Pure form of [deleteReport]. + static String removeReport(String content, String name) => + _linesExcludingReport(content, name.trim()).join('\n'); + + /// Every line of [file] except those defining `report..*`. + /// + /// Comments and blanks are preserved verbatim; only assignment lines are + /// inspected, and only for this one report. + static List _linesExcludingReport(String content, String name) { + final String prefix = 'report.$name.'; + final List kept = []; + for (final String raw in content.split('\n')) { + final String line = raw.trim(); + if (line.isNotEmpty && !line.startsWith('#')) { + final int eq = line.indexOf('='); + if (eq > 0 && line.substring(0, eq).trim().startsWith(prefix)) { + continue; // superseded by the block we are about to write + } + } + kept.add(raw); + } + // Trailing blanks would otherwise accumulate on every save. + while (kept.isNotEmpty && kept.last.trim().isEmpty) { + kept.removeLast(); + } + return kept; + } +} diff --git a/lib/app/tour/filter_drawer_tour.dart b/lib/app/tour/filter_drawer_tour.dart index 9ad6913e..6961403d 100644 --- a/lib/app/tour/filter_drawer_tour.dart +++ b/lib/app/tour/filter_drawer_tour.dart @@ -9,6 +9,9 @@ List filterDrawer({ required GlobalKey statusKey, required GlobalKey projectsKey, required GlobalKey projectsKeyTaskc, + // The drawer shows either the legacy projects column or the TaskChampion + // one, never both, so only the matching key is ever laid out. + required bool useTaskchampionProjects, required GlobalKey filterTagKey, required GlobalKey sortByKey, }) { @@ -50,10 +53,13 @@ List filterDrawer({ ), ); - // projectsKey + // Projects. Registering BOTH keys guaranteed one target with no render + // box — each column sits behind a mutually exclusive Visibility, so only + // one is ever mounted. That is the "could not obtain target position + // (null)" failure, by construction rather than by race. targets.add( TargetFocus( - keyTarget: projectsKey, + keyTarget: useTaskchampionProjects ? projectsKeyTaskc : projectsKey, alignSkip: Alignment.topRight, radius: 10, shape: ShapeLightFocus.RRect, @@ -86,41 +92,6 @@ List filterDrawer({ ), ); - // projectsKeyTaskc - targets.add( - TargetFocus( - keyTarget: projectsKeyTaskc, - alignSkip: Alignment.topRight, - radius: 10, - shape: ShapeLightFocus.RRect, - contents: [ - TargetContent( - align: ContentAlign.top, - builder: (context, controller) { - return Container( - alignment: Alignment.center, - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - SentenceManager( - currentLanguage: AppSettings.selectedLanguage) - .sentences - .tourFilterProjects, - textAlign: TextAlign.center, - style: GoogleFonts.poppins( - color: TaskWarriorColors.white, - ), - ), - ], - ), - ); - }, - ), - ], - ), - ); // filterTagByKey targets.add( 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..9f479100 --- /dev/null +++ b/lib/app/utils/taskchampion/virtual_filter_engine.dart @@ -0,0 +1,162 @@ +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 { + /// The virtual tags this engine understands, for filter-building UI and + /// validation. Anything else after `+` is treated as a real user tag, which + /// is legitimate — so an unrecognised tag is never an error. + static const List virtualTags = [ + 'ACTIVE', + 'READY', + 'BLOCKED', + 'BLOCKING', + 'OVERDUE', + 'WAITING', + 'PENDING', + 'COMPLETED', + 'DELETED', + ]; + + /// Attributes usable as `name:value`. Unlike tags, an unknown attribute is a + /// genuine mistake — see [validate]. + static const List attributes = [ + 'status', + 'project', + 'priority', + ]; + + /// Human-readable problems with [expression], or an empty list if it is fine. + /// + /// This exists because of an asymmetry in how unmatched tokens behave. + /// [_matchAttribute] returns `true` for an attribute it does not recognise, + /// so a typo like `statuss:pending` excludes nothing and the report silently + /// returns *every* task rather than failing. A bare word is a description + /// search and a `+word` is a user tag, so neither can be wrong — only an + /// unknown `name:value` can, and that is what this reports. + static List validate(String? expression) { + final String expr = (expression ?? '').trim(); + if (expr.isEmpty) return const []; + + final List issues = []; + for (final String tok in expr.split(RegExp(r'\s+'))) { + if (tok.isEmpty || tok.startsWith('+') || tok.startsWith('-')) continue; + final int colon = tok.indexOf(':'); + if (colon <= 0) continue; // bare word: description search + final String attr = tok.substring(0, colon); + if (!attributes.contains(attr)) { + issues.add( + '"$attr:" is not a filter attribute, so it matches every task. ' + 'Use one of: ${attributes.join(', ')}.', + ); + } else if (tok.substring(colon + 1).isEmpty) { + issues.add('"$tok" has no value after the colon.'); + } + } + return issues; + } + + /// 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/champion/replica.dart b/lib/app/v3/champion/replica.dart index ea19ba45..25987975 100644 --- a/lib/app/v3/champion/replica.dart +++ b/lib/app/v3/champion/replica.dart @@ -20,7 +20,10 @@ class Replica { "wait", "priority", "project", - "status" + "status", + // Written like project — an opaque string TaskChampion stores but never + // interprets. The desktop CLI is what acts on it. + "recur", ]; static Future addTaskToReplica( HashMap newTask) async { @@ -53,11 +56,16 @@ class Replica { return "scc"; } - static Future modifyTaskInReplica(TaskForReplica newTask) async { + /// Apply an edit to a replica task. + /// + /// Returns null on success, or the reason the write was refused — the FFI + /// rejects some combinations outright (a repeating task with no due date, for + /// one), and that explanation has to reach the user rather than be logged. + static Future modifyTaskInReplica(TaskForReplica newTask) async { var taskdbDirPath = await getReplicaPath(); HashMap map = HashMap(); if (newTask.uuid.isEmpty) { - return "err"; + return "This task has no identifier yet."; } String tags = ""; if (newTask.tags != null) { @@ -76,9 +84,65 @@ class Replica { } catch (e, s) { debugPrint(e.toString()); debugPrint(s.toString()); - return "err"; + final String raw = e.toString(); + final int marker = raw.indexOf(': '); + return marker >= 0 && marker + 2 < raw.length + ? raw.substring(marker + 2) + : raw; } - return "scc"; + return null; + } + + /// Attach a note to a task, returning the entry timestamp that identifies it. + /// + /// Unlike the older helpers here, this deliberately lets the FFI error + /// propagate instead of collapsing it to `"err"`. The Rust side reports why a + /// write was refused — empty text, unknown task, malformed UUID — and the + /// caller shows that reason to the user, which a sentinel string cannot do. + static Future addAnnotationToReplica( + String uuid, String description) async { + final taskdbDirPath = await getReplicaPath(); + return addAnnotation( + uuidSt: uuid, + description: description, + taskdbDirPath: taskdbDirPath, + ); + } + + /// Remove the note identified by [entryRfc3339] — the `entry` value the + /// serializer reported for it. Removing one that is already gone is a no-op. + static Future removeAnnotationFromReplica( + String uuid, String entryRfc3339) async { + final taskdbDirPath = await getReplicaPath(); + return removeAnnotation( + uuidSt: uuid, + entryRfc3339: entryRfc3339, + taskdbDirPath: taskdbDirPath, + ); + } + + /// Make [uuid] depend on [dependsOn], so it stays blocked until that task is + /// done. Refused, with a reason, if it would be self-referential, point at a + /// task that does not exist, or close a dependency loop. + static Future addDependencyToReplica( + String uuid, String dependsOn) async { + final taskdbDirPath = await getReplicaPath(); + return addDependency( + uuidSt: uuid, + dependsOnSt: dependsOn, + taskdbDirPath: taskdbDirPath, + ); + } + + /// Drop [uuid]'s dependency on [dependsOn]. A no-op if it is not there. + static Future removeDependencyFromReplica( + String uuid, String dependsOn) async { + final taskdbDirPath = await getReplicaPath(); + return removeDependency( + uuidSt: uuid, + dependsOnSt: dependsOn, + taskdbDirPath: taskdbDirPath, + ); } static Future deleteTaskFromReplica(String uuid) async { 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..615e3993 100644 --- a/lib/rust_bridge/api.dart +++ b/lib/rust_bridge/api.dart @@ -6,29 +6,42 @@ 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_annotation_impl`, `add_dependency_impl`, `add_task_impl`, `delete_task_impl`, `dependency_path_exists`, `get_all_tasks_json_impl`, `parse_datetime`, `remove_annotation_impl`, `remove_dependency_impl`, `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. +/// +/// 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. +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, @@ -38,3 +51,72 @@ Future sync_( url: url, clientId: clientId, encryptionSecret: encryptionSecret); + +/// Attach a timestamped note (annotation) to a task, returning the entry +/// timestamp that identifies it. +/// +/// TaskChampion stores an annotation as an `annotation_` +/// property, so **the entry time is the annotation's primary key** — two notes +/// on the same task in the same second would collide and the later one would +/// silently replace the earlier. The Taskwarrior CLI has that behaviour too, +/// but a phone makes it far easier to hit (two quick taps on Add). Rather than +/// destroy a note, this advances to the next free second. The result is still +/// an ordinary annotation that any Taskwarrior client reads normally; only the +/// recorded time differs, by a second or two. +/// +/// The returned RFC 3339 string is what [`remove_annotation`] expects, so a +/// caller can delete the note it just created without re-reading the task. +Future addAnnotation( + {required String uuidSt, + required String description, + required String taskdbDirPath}) => + RustLib.instance.api.crateApiAddAnnotation( + uuidSt: uuidSt, description: description, taskdbDirPath: taskdbDirPath); + +/// Remove the annotation identified by `entry_rfc3339` from a task. +/// +/// The timestamp must be one returned by the serializer (or by +/// [`add_annotation`]); it is matched at whole-second resolution, which is how +/// TaskChampion keys annotations. Removing an annotation that is not present is +/// a no-op rather than an error, so a double-tap on delete cannot fail. +Future removeAnnotation( + {required String uuidSt, + required String entryRfc3339, + required String taskdbDirPath}) => + RustLib.instance.api.crateApiRemoveAnnotation( + uuidSt: uuidSt, + entryRfc3339: entryRfc3339, + taskdbDirPath: taskdbDirPath); + +/// Make `uuid_st` depend on `depends_on_st`, so the first is blocked until the +/// second is done. +/// +/// TaskChampion's own `add_dependency` validates nothing at all — it writes a +/// `dep_` property and returns. It will accept a task depending on itself, +/// on a UUID that is not a task, or on something that already depends on it. +/// None of those crash, but a cycle leaves both tasks permanently blocked and +/// never "ready", with nothing to explain why. So the checks live here: +/// +/// * a task may not depend on itself +/// * both tasks must exist +/// * the edge must not close a loop +/// +/// Adding a dependency that is already present is a no-op, not an error. +Future addDependency( + {required String uuidSt, + required String dependsOnSt, + required String taskdbDirPath}) => + RustLib.instance.api.crateApiAddDependency( + uuidSt: uuidSt, dependsOnSt: dependsOnSt, taskdbDirPath: taskdbDirPath); + +/// Drop a dependency of `uuid_st` on `depends_on_st`. +/// +/// Removing one that is not there is a no-op, and the depended-on task need not +/// exist — that is deliberate, so a dependency left dangling by another client +/// can still be cleared. +Future removeDependency( + {required String uuidSt, + required String dependsOnSt, + required String taskdbDirPath}) => + RustLib.instance.api.crateApiRemoveDependency( + uuidSt: uuidSt, dependsOnSt: dependsOnSt, taskdbDirPath: taskdbDirPath); diff --git a/lib/rust_bridge/frb_generated.dart b/lib/rust_bridge/frb_generated.dart index 2dbdeccc..1c2375f9 100644 --- a/lib/rust_bridge/frb_generated.dart +++ b/lib/rust_bridge/frb_generated.dart @@ -68,7 +68,7 @@ class RustLib extends BaseEntrypoint { String get codegenVersion => '2.11.1'; @override - int get rustContentHash => -2049867087; + int get rustContentHash => -1358106344; static const kDefaultExternalLibraryLoaderConfig = ExternalLibraryLoaderConfig( @@ -79,21 +79,41 @@ class RustLib extends BaseEntrypoint { } abstract class RustLibApi extends BaseApi { - Future crateApiAddTask( + Future crateApiAddAnnotation( + {required String uuidSt, + required String description, + required String taskdbDirPath}); + + Future crateApiAddDependency( + {required String uuidSt, + required String dependsOnSt, + required String taskdbDirPath}); + + 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 crateApiRemoveAnnotation( + {required String uuidSt, + required String entryRfc3339, + required String taskdbDirPath}); + + Future crateApiRemoveDependency( + {required String uuidSt, + required String dependsOnSt, + required String taskdbDirPath}); + + 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 +128,65 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { }); @override - Future crateApiAddTask( + Future crateApiAddAnnotation( + {required String uuidSt, + required String description, + required String taskdbDirPath}) { + return handler.executeNormal(NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(uuidSt, serializer); + sse_encode_String(description, serializer); + sse_encode_String(taskdbDirPath, serializer); + pdeCallFfi(generalizedFrbRustBinding, serializer, + funcId: 1, port: port_); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_String, + decodeErrorData: sse_decode_String, + ), + constMeta: kCrateApiAddAnnotationConstMeta, + argValues: [uuidSt, description, taskdbDirPath], + apiImpl: this, + )); + } + + TaskConstMeta get kCrateApiAddAnnotationConstMeta => const TaskConstMeta( + debugName: "add_annotation", + argNames: ["uuidSt", "description", "taskdbDirPath"], + ); + + @override + Future crateApiAddDependency( + {required String uuidSt, + required String dependsOnSt, + required String taskdbDirPath}) { + return handler.executeNormal(NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(uuidSt, serializer); + sse_encode_String(dependsOnSt, serializer); + sse_encode_String(taskdbDirPath, serializer); + pdeCallFfi(generalizedFrbRustBinding, serializer, + funcId: 2, port: port_); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_String, + ), + constMeta: kCrateApiAddDependencyConstMeta, + argValues: [uuidSt, dependsOnSt, taskdbDirPath], + apiImpl: this, + )); + } + + TaskConstMeta get kCrateApiAddDependencyConstMeta => const TaskConstMeta( + debugName: "add_dependency", + argNames: ["uuidSt", "dependsOnSt", "taskdbDirPath"], + ); + + @override + Future crateApiAddTask( {required String taskdbDirPath, required Map map}) { return handler.executeNormal(NormalTask( callFfi: (port_) { @@ -116,11 +194,11 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_String(taskdbDirPath, serializer); sse_encode_Map_String_String_None(map, serializer); pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 1, port: port_); + funcId: 3, 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 +212,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { ); @override - Future crateApiDeleteTask( + Future crateApiDeleteTask( {required String uuidSt, required String taskdbDirPath}) { return handler.executeNormal(NormalTask( callFfi: (port_) { @@ -142,11 +220,11 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_String(uuidSt, serializer); sse_encode_String(taskdbDirPath, serializer); pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 2, port: port_); + funcId: 4, port: port_); }, codec: SseCodec( - decodeSuccessData: sse_decode_i_8, - decodeErrorData: null, + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_String, ), constMeta: kCrateApiDeleteTaskConstMeta, argValues: [uuidSt, taskdbDirPath], @@ -166,11 +244,11 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_String(taskdbDirPath, serializer); pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 3, port: port_); + funcId: 5, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_String, - decodeErrorData: sse_decode_AnyhowException, + decodeErrorData: sse_decode_String, ), constMeta: kCrateApiGetAllTasksJsonConstMeta, argValues: [taskdbDirPath], @@ -184,7 +262,65 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { ); @override - Future crateApiSync( + Future crateApiRemoveAnnotation( + {required String uuidSt, + required String entryRfc3339, + required String taskdbDirPath}) { + return handler.executeNormal(NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(uuidSt, serializer); + sse_encode_String(entryRfc3339, serializer); + sse_encode_String(taskdbDirPath, serializer); + pdeCallFfi(generalizedFrbRustBinding, serializer, + funcId: 6, port: port_); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_String, + ), + constMeta: kCrateApiRemoveAnnotationConstMeta, + argValues: [uuidSt, entryRfc3339, taskdbDirPath], + apiImpl: this, + )); + } + + TaskConstMeta get kCrateApiRemoveAnnotationConstMeta => const TaskConstMeta( + debugName: "remove_annotation", + argNames: ["uuidSt", "entryRfc3339", "taskdbDirPath"], + ); + + @override + Future crateApiRemoveDependency( + {required String uuidSt, + required String dependsOnSt, + required String taskdbDirPath}) { + return handler.executeNormal(NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(uuidSt, serializer); + sse_encode_String(dependsOnSt, serializer); + sse_encode_String(taskdbDirPath, serializer); + pdeCallFfi(generalizedFrbRustBinding, serializer, + funcId: 7, port: port_); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_String, + ), + constMeta: kCrateApiRemoveDependencyConstMeta, + argValues: [uuidSt, dependsOnSt, taskdbDirPath], + apiImpl: this, + )); + } + + TaskConstMeta get kCrateApiRemoveDependencyConstMeta => const TaskConstMeta( + debugName: "remove_dependency", + argNames: ["uuidSt", "dependsOnSt", "taskdbDirPath"], + ); + + @override + Future crateApiSync( {required String taskdbDirPath, required String url, required String clientId, @@ -197,11 +333,11 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_String(clientId, serializer); sse_encode_String(encryptionSecret, serializer); pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 4, port: port_); + funcId: 8, 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 +351,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { ); @override - Future crateApiUpdateTask( + Future crateApiUpdateTask( {required String uuidSt, required String taskdbDirPath, required Map map}) { @@ -226,11 +362,11 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_String(taskdbDirPath, serializer); sse_encode_Map_String_String_None(map, serializer); pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 5, port: port_); + funcId: 9, 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 +379,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 +392,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 +429,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 +444,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 +496,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 +510,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..7a42a3d5 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, + utc_timestamp, Annotation, Operations, ServerConfig, Tag, }; 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,105 @@ 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(); - - for (_, value) in replica.all_tasks().unwrap() { - let mut map: HashMap = HashMap::new(); - let mut tags = "".to_string(); +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 (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()))? + { + // Recurrence is only valid alongside a due date, and the consequence of + // breaking that is destructive rather than inert: the Taskwarrior CLI + // deletes a recurring task that has no due date the next time it runs. + // Since this app cannot generate instances itself, the value it writes is + // acted on by that CLI — so the invariant is enforced here, before + // anything is committed, rather than trusted to the UI. + let recur_after = match map.get("recur") { + Some(v) => v.trim().to_string(), + None => t.get_value("recur").unwrap_or("").trim().to_string(), + }; + if !recur_after.is_empty() { + let due_after = match map.get("due") { + Some(v) => parse_datetime(v), + None => t.get_due(), + }; + if due_after.is_none() { + return Err(TcHelperError::InvalidInput( + "a repeating task needs a due date — Taskwarrior deletes a \ + recurring task that has none" + .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,62 +138,85 @@ 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" => { let _ = t.set_value("project", Some(value), &mut ops); } + // Stored like `project`: an opaque string TaskChampion keeps but + // never interprets. Unlike `project`, nothing in this app acts on + // it — instances are generated by the desktop Taskwarrior CLI when + // it next opens the same database. An empty value clears it. + "recur" => { + let trimmed = value.trim(); + let _ = t.set_value( + "recur", + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + }, + &mut ops, + ); + } "status" => { let status = match value.as_str() { "pending" => taskchampion::Status::Pending, "completed" => taskchampion::Status::Completed, "deleted" => taskchampion::Status::Deleted, + // A recurrence template. Without this arm "recurring" + // fell into the catch-all below and was silently + // downgraded to Pending, so the status could not be set + // at all — and the Taskwarrior CLI only generates + // instances for a task whose status is `recurring`. + "recurring" => taskchampion::Status::Recurring, _ => 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 +238,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 +249,328 @@ 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(()) +} + +/// Attach a timestamped note (annotation) to a task, returning the entry +/// timestamp that identifies it. +/// +/// TaskChampion stores an annotation as an `annotation_` +/// property, so **the entry time is the annotation's primary key** — two notes +/// on the same task in the same second would collide and the later one would +/// silently replace the earlier. The Taskwarrior CLI has that behaviour too, +/// but a phone makes it far easier to hit (two quick taps on Add). Rather than +/// destroy a note, this advances to the next free second. The result is still +/// an ordinary annotation that any Taskwarrior client reads normally; only the +/// recorded time differs, by a second or two. +/// +/// The returned RFC 3339 string is what [`remove_annotation`] expects, so a +/// caller can delete the note it just created without re-reading the task. +#[frb] +pub fn add_annotation( + uuid_st: String, + description: String, + taskdb_dir_path: String, +) -> Result { + add_annotation_impl(&uuid_st, &description, &taskdb_dir_path).map_err(|e| e.to_string()) +} + +fn add_annotation_impl( + uuid_st: &str, + description: &str, + taskdb_dir_path: &str, +) -> Result { + let description = description.trim(); + if description.is_empty() { + return Err(TcHelperError::InvalidInput( + "annotation text cannot be empty".to_string(), + )); + } + + let uuid = + Uuid::parse_str(uuid_st).map_err(|_| TcHelperError::InvalidUuid(uuid_st.to_string()))?; + let mut replica = open_replica(taskdb_dir_path)?; + let mut ops = Operations::new(); + + let mut task = replica + .get_task(uuid) + .map_err(|e| TcHelperError::Champion(e.to_string()))? + .ok_or_else(|| TcHelperError::TaskNotFound(uuid_st.to_string()))?; + + // Whole seconds only: get_annotations() rebuilds each entry from the + // integer in the property key, so any sub-second precision is discarded on + // read anyway. Comparing at the same resolution is what makes the + // collision check meaningful. + let taken: std::collections::HashSet = + task.get_annotations().map(|a| a.entry.timestamp()).collect(); + + let mut secs = Utc::now().timestamp(); + // Bounded so a pathological replica can never spin here. A day of + // consecutively-occupied seconds is not a real state; failing loudly beats + // looping. + let limit = secs + 86_400; + while taken.contains(&secs) { + secs += 1; + if secs > limit { + return Err(TcHelperError::InvalidInput( + "could not find a free annotation timestamp".to_string(), + )); + } + } + let entry = utc_timestamp(secs); + + task.add_annotation( + Annotation { + entry, + description: description.to_string(), + }, + &mut ops, + ) + .map_err(|e| TcHelperError::Champion(e.to_string()))?; + + replica + .commit_operations(ops) + .map_err(|e| TcHelperError::Commit(e.to_string()))?; + + Ok(entry.to_rfc3339()) +} + +/// Remove the annotation identified by `entry_rfc3339` from a task. +/// +/// The timestamp must be one returned by the serializer (or by +/// [`add_annotation`]); it is matched at whole-second resolution, which is how +/// TaskChampion keys annotations. Removing an annotation that is not present is +/// a no-op rather than an error, so a double-tap on delete cannot fail. +#[frb] +pub fn remove_annotation( + uuid_st: String, + entry_rfc3339: String, + taskdb_dir_path: String, +) -> Result<(), String> { + remove_annotation_impl(&uuid_st, &entry_rfc3339, &taskdb_dir_path).map_err(|e| e.to_string()) +} + +fn remove_annotation_impl( + uuid_st: &str, + entry_rfc3339: &str, + taskdb_dir_path: &str, +) -> Result<(), TcHelperError> { + let uuid = + Uuid::parse_str(uuid_st).map_err(|_| TcHelperError::InvalidUuid(uuid_st.to_string()))?; + + let entry = DateTime::parse_from_rfc3339(entry_rfc3339) + .map_err(|_| { + TcHelperError::InvalidInput(format!( + "annotation entry '{entry_rfc3339}' is not a valid RFC 3339 timestamp" + )) + })? + .with_timezone(&Utc); + + let mut replica = open_replica(taskdb_dir_path)?; + let mut ops = Operations::new(); + + let mut task = replica + .get_task(uuid) + .map_err(|e| TcHelperError::Champion(e.to_string()))? + .ok_or_else(|| TcHelperError::TaskNotFound(uuid_st.to_string()))?; + + // Normalise to whole seconds so a caller passing a timestamp with a + // fractional part still targets the right property key. + task.remove_annotation(utc_timestamp(entry.timestamp()), &mut ops) + .map_err(|e| TcHelperError::Champion(e.to_string()))?; + + replica + .commit_operations(ops) + .map_err(|e| TcHelperError::Commit(e.to_string()))?; + Ok(()) +} + +/// Does a dependency path lead from `from` to `target`? +/// +/// Used to reject an edge that would close a loop. Iterative rather than +/// recursive so a long chain cannot overflow the stack, and `seen` means a cycle +/// already present in the data — one another client could have written, since +/// nothing in TaskChampion prevents it — terminates the walk instead of hanging. +fn dependency_path_exists( + tasks: &HashMap, + from: Uuid, + target: Uuid, +) -> bool { + let mut stack = vec![from]; + let mut seen: std::collections::HashSet = std::collections::HashSet::new(); + while let Some(current) = stack.pop() { + if current == target { + return true; + } + if !seen.insert(current) { + continue; + } + if let Some(task) = tasks.get(¤t) { + stack.extend(task.get_dependencies()); + } + } + false +} + +/// Make `uuid_st` depend on `depends_on_st`, so the first is blocked until the +/// second is done. +/// +/// TaskChampion's own `add_dependency` validates nothing at all — it writes a +/// `dep_` property and returns. It will accept a task depending on itself, +/// on a UUID that is not a task, or on something that already depends on it. +/// None of those crash, but a cycle leaves both tasks permanently blocked and +/// never "ready", with nothing to explain why. So the checks live here: +/// +/// * a task may not depend on itself +/// * both tasks must exist +/// * the edge must not close a loop +/// +/// Adding a dependency that is already present is a no-op, not an error. +#[frb] +pub fn add_dependency( + uuid_st: String, + depends_on_st: String, + taskdb_dir_path: String, +) -> Result<(), String> { + add_dependency_impl(&uuid_st, &depends_on_st, &taskdb_dir_path).map_err(|e| e.to_string()) +} + +fn add_dependency_impl( + uuid_st: &str, + depends_on_st: &str, + taskdb_dir_path: &str, +) -> Result<(), TcHelperError> { + let uuid = + Uuid::parse_str(uuid_st).map_err(|_| TcHelperError::InvalidUuid(uuid_st.to_string()))?; + let depends_on = Uuid::parse_str(depends_on_st) + .map_err(|_| TcHelperError::InvalidUuid(depends_on_st.to_string()))?; + + if uuid == depends_on { + return Err(TcHelperError::InvalidInput( + "a task cannot depend on itself".to_string(), + )); + } + + let mut replica = open_replica(taskdb_dir_path)?; + let tasks = replica + .all_tasks() + .map_err(|e| TcHelperError::Champion(e.to_string()))?; + + let task = tasks + .get(&uuid) + .ok_or_else(|| TcHelperError::TaskNotFound(uuid_st.to_string()))?; + if !tasks.contains_key(&depends_on) { + return Err(TcHelperError::TaskNotFound(depends_on_st.to_string())); + } + + if task.get_dependencies().any(|d| d == depends_on) { + return Ok(()); + } + + // The new edge is uuid -> depends_on, so it closes a loop exactly when + // depends_on can already reach uuid. + if dependency_path_exists(&tasks, depends_on, uuid) { + return Err(TcHelperError::InvalidInput( + "that would create a circular dependency".to_string(), + )); + } + + let mut ops = Operations::new(); + let mut task = replica + .get_task(uuid) + .map_err(|e| TcHelperError::Champion(e.to_string()))? + .ok_or_else(|| TcHelperError::TaskNotFound(uuid_st.to_string()))?; + task.add_dependency(depends_on, &mut ops) + .map_err(|e| TcHelperError::Champion(e.to_string()))?; + replica + .commit_operations(ops) + .map_err(|e| TcHelperError::Commit(e.to_string()))?; + Ok(()) +} + +/// Drop a dependency of `uuid_st` on `depends_on_st`. +/// +/// Removing one that is not there is a no-op, and the depended-on task need not +/// exist — that is deliberate, so a dependency left dangling by another client +/// can still be cleared. +#[frb] +pub fn remove_dependency( + uuid_st: String, + depends_on_st: String, + taskdb_dir_path: String, +) -> Result<(), String> { + remove_dependency_impl(&uuid_st, &depends_on_st, &taskdb_dir_path).map_err(|e| e.to_string()) +} + +fn remove_dependency_impl( + uuid_st: &str, + depends_on_st: &str, + taskdb_dir_path: &str, +) -> Result<(), TcHelperError> { + let uuid = + Uuid::parse_str(uuid_st).map_err(|_| TcHelperError::InvalidUuid(uuid_st.to_string()))?; + let depends_on = Uuid::parse_str(depends_on_st) + .map_err(|_| TcHelperError::InvalidUuid(depends_on_st.to_string()))?; + + let mut replica = open_replica(taskdb_dir_path)?; + let mut ops = Operations::new(); + let mut task = replica + .get_task(uuid) + .map_err(|e| TcHelperError::Champion(e.to_string()))? + .ok_or_else(|| TcHelperError::TaskNotFound(uuid_st.to_string()))?; + + task.remove_dependency(depends_on, &mut ops) + .map_err(|e| TcHelperError::Champion(e.to_string()))?; + replica + .commit_operations(ops) + .map_err(|e| TcHelperError::Commit(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 +584,653 @@ 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(); +} + +#[cfg(test)] +mod annotation_tests { + use super::*; + use serde_json::Value; + use std::{collections::HashMap, env, fs}; + + /// Create a temp replica holding one task, returning (dir, path, uuid). + fn task_fixture() -> (std::path::PathBuf, String, String) { + let tmp = env::temp_dir().join(format!("taskdb_ann_{}", Uuid::new_v4())); + fs::create_dir_all(&tmp).expect("create temp taskdb dir"); + let path = tmp.to_string_lossy().into_owned(); + + 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(), "annotated task".to_string()); + add_task(path.clone(), map).expect("add_task"); + + (tmp, path, uuid) + } + + /// Read back the annotations of `uuid` as (entry, description) pairs. + fn annotations_of(path: &str, uuid: &str) -> Vec<(String, String)> { + let json = get_all_tasks_json(path.to_string()).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)) + .expect("task not found"); + task.get("annotations") + .and_then(|a| a.as_array()) + .expect("annotations array") + .iter() + .map(|a| { + ( + a["entry"].as_str().unwrap_or_default().to_string(), + a["description"].as_str().unwrap_or_default().to_string(), + ) + }) + .collect() + } + + #[test] + fn add_then_read_back() { + let (tmp, path, uuid) = task_fixture(); + + let entry = add_annotation(uuid.clone(), " bought the paint ".to_string(), path.clone()) + .expect("add_annotation"); + + let anns = annotations_of(&path, &uuid); + assert_eq!(anns.len(), 1); + // Surrounding whitespace is trimmed before storing. + assert_eq!(anns[0].1, "bought the paint"); + // The returned entry is exactly what the serializer reports, so a caller + // can delete the note it just made without re-reading the task. + assert_eq!(anns[0].0, entry); + + fs::remove_dir_all(&tmp).ok(); + } + + #[test] + fn two_annotations_in_the_same_second_both_survive() { + // The regression this guards: TaskChampion keys an annotation by its + // entry time in whole seconds, so a naive implementation would let the + // second call overwrite the first when both land in the same second — + // which is exactly what two quick taps on "Add" produce. + let (tmp, path, uuid) = task_fixture(); + + let first = add_annotation(uuid.clone(), "first note".to_string(), path.clone()) + .expect("add first"); + let second = add_annotation(uuid.clone(), "second note".to_string(), path.clone()) + .expect("add second"); + + assert_ne!(first, second, "the two notes must not share an entry key"); + + let anns = annotations_of(&path, &uuid); + assert_eq!(anns.len(), 2, "both notes must survive: {anns:?}"); + let mut descriptions: Vec<&str> = anns.iter().map(|(_, d)| d.as_str()).collect(); + descriptions.sort_unstable(); + assert_eq!(descriptions, vec!["first note", "second note"]); + + fs::remove_dir_all(&tmp).ok(); + } + + #[test] + fn remove_deletes_only_the_targeted_note() { + let (tmp, path, uuid) = task_fixture(); + + let keep = add_annotation(uuid.clone(), "keep me".to_string(), path.clone()).unwrap(); + let drop = add_annotation(uuid.clone(), "drop me".to_string(), path.clone()).unwrap(); + + remove_annotation(uuid.clone(), drop, path.clone()).expect("remove_annotation"); + + let anns = annotations_of(&path, &uuid); + assert_eq!(anns.len(), 1, "exactly one note should remain: {anns:?}"); + assert_eq!(anns[0].1, "keep me"); + assert_eq!(anns[0].0, keep); + + fs::remove_dir_all(&tmp).ok(); + } + + #[test] + fn removing_a_missing_annotation_is_a_no_op() { + // A double-tap on delete must not surface an error. + let (tmp, path, uuid) = task_fixture(); + let entry = add_annotation(uuid.clone(), "only note".to_string(), path.clone()).unwrap(); + + remove_annotation(uuid.clone(), entry.clone(), path.clone()).expect("first remove"); + remove_annotation(uuid.clone(), entry, path.clone()).expect("second remove must not error"); + + assert!(annotations_of(&path, &uuid).is_empty()); + + fs::remove_dir_all(&tmp).ok(); + } + + #[test] + fn empty_text_is_rejected() { + let (tmp, path, uuid) = task_fixture(); + + let err = add_annotation(uuid.clone(), " ".to_string(), path.clone()) + .expect_err("whitespace-only text must be rejected"); + assert!(err.contains("empty"), "unhelpful error: {err}"); + assert!(annotations_of(&path, &uuid).is_empty()); + + fs::remove_dir_all(&tmp).ok(); + } + + #[test] + fn unknown_task_and_bad_input_report_clearly() { + let (tmp, path, uuid) = task_fixture(); + + let missing = Uuid::new_v4().to_string(); + let err = add_annotation(missing.clone(), "note".to_string(), path.clone()) + .expect_err("unknown task must error"); + assert!(err.contains("no task with UUID"), "unhelpful error: {err}"); + + let err = add_annotation("not-a-uuid".to_string(), "note".to_string(), path.clone()) + .expect_err("malformed uuid must error"); + assert!(err.contains("invalid UUID"), "unhelpful error: {err}"); + + let err = remove_annotation(uuid, "yesterday".to_string(), path.clone()) + .expect_err("malformed timestamp must error"); + assert!(err.contains("RFC 3339"), "unhelpful error: {err}"); + + fs::remove_dir_all(&tmp).ok(); + } +} + +#[cfg(test)] +mod dependency_tests { + use super::*; + use serde_json::Value; + use std::{collections::HashMap, env, fs}; + + /// A temp replica with `n` tasks, returning (dir, path, uuids). + fn tasks_fixture(n: usize) -> (std::path::PathBuf, String, Vec) { + let tmp = env::temp_dir().join(format!("taskdb_dep_{}", Uuid::new_v4())); + fs::create_dir_all(&tmp).expect("create temp taskdb dir"); + let path = tmp.to_string_lossy().into_owned(); + + let mut uuids = Vec::new(); + for i in 0..n { + 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(), format!("task {i}")); + add_task(path.clone(), map).expect("add_task"); + uuids.push(uuid); + } + (tmp, path, uuids) + } + + fn task_json(path: &str, uuid: &str) -> Value { + let json = get_all_tasks_json(path.to_string()).expect("get_all_tasks_json"); + let tasks: Vec = serde_json::from_str(&json).expect("parse json"); + tasks + .into_iter() + .find(|t| t.get("uuid").and_then(|u| u.as_str()) == Some(uuid)) + .expect("task not found") + } + + #[test] + fn add_then_surfaces_as_depends_and_blocking() { + let (tmp, path, u) = tasks_fixture(2); + let (a, b) = (u[0].clone(), u[1].clone()); + + add_dependency(a.clone(), b.clone(), path.clone()).expect("add_dependency"); + + let ja = task_json(&path, &a); + let deps: Vec<&str> = ja["depends"] + .as_array() + .unwrap() + .iter() + .map(|d| d.as_str().unwrap()) + .collect(); + assert_eq!(deps, vec![b.as_str()]); + assert_eq!(ja["is_blocked"].as_bool(), Some(true), "A depends on B"); + assert_eq!(ja["is_blocking"].as_bool(), Some(false)); + + // The reverse view updates with no extra work, because each FFI call + // opens a fresh replica and so rebuilds the dependency map. + let jb = task_json(&path, &b); + assert_eq!(jb["is_blocking"].as_bool(), Some(true), "B blocks A"); + assert_eq!(jb["is_blocked"].as_bool(), Some(false)); + + fs::remove_dir_all(&tmp).ok(); + } + + #[test] + fn a_task_cannot_depend_on_itself() { + let (tmp, path, u) = tasks_fixture(1); + let err = add_dependency(u[0].clone(), u[0].clone(), path.clone()) + .expect_err("self-dependency must be refused"); + assert!(err.contains("cannot depend on itself"), "unhelpful: {err}"); + fs::remove_dir_all(&tmp).ok(); + } + + #[test] + fn the_other_task_must_exist() { + let (tmp, path, u) = tasks_fixture(1); + let ghost = Uuid::new_v4().to_string(); + let err = add_dependency(u[0].clone(), ghost, path.clone()) + .expect_err("depending on a non-task must be refused"); + assert!(err.contains("no task with UUID"), "unhelpful: {err}"); + fs::remove_dir_all(&tmp).ok(); + } + + #[test] + fn a_direct_cycle_is_refused() { + // A -> B is fine; B -> A would leave both permanently blocked and never + // ready, which TaskChampion itself does nothing to prevent. + let (tmp, path, u) = tasks_fixture(2); + let (a, b) = (u[0].clone(), u[1].clone()); + + add_dependency(a.clone(), b.clone(), path.clone()).expect("A -> B"); + let err = add_dependency(b.clone(), a.clone(), path.clone()) + .expect_err("B -> A must be refused"); + assert!(err.contains("circular"), "unhelpful: {err}"); + + // and the refusal must not have written anything + let jb = task_json(&path, &b); + assert!(jb["depends"].as_array().unwrap().is_empty()); + + fs::remove_dir_all(&tmp).ok(); + } + + #[test] + fn an_indirect_cycle_is_refused() { + // A -> B -> C, then C -> A closes the loop three edges later. + let (tmp, path, u) = tasks_fixture(3); + let (a, b, c) = (u[0].clone(), u[1].clone(), u[2].clone()); + + add_dependency(a.clone(), b.clone(), path.clone()).expect("A -> B"); + add_dependency(b.clone(), c.clone(), path.clone()).expect("B -> C"); + let err = add_dependency(c.clone(), a.clone(), path.clone()) + .expect_err("C -> A must be refused"); + assert!(err.contains("circular"), "unhelpful: {err}"); + + fs::remove_dir_all(&tmp).ok(); + } + + #[test] + fn a_diamond_is_allowed() { + // Not every repeated path is a cycle: A -> B, A -> C, B -> D, C -> D is + // a diamond and perfectly legal. A naive "have I seen D twice" check + // would wrongly reject it. + let (tmp, path, u) = tasks_fixture(4); + let (a, b, c, d) = (u[0].clone(), u[1].clone(), u[2].clone(), u[3].clone()); + + add_dependency(a.clone(), b.clone(), path.clone()).expect("A -> B"); + add_dependency(a.clone(), c.clone(), path.clone()).expect("A -> C"); + add_dependency(b.clone(), d.clone(), path.clone()).expect("B -> D"); + add_dependency(c, d, path.clone()).expect("C -> D must be allowed"); + + assert_eq!(task_json(&path, &a)["depends"].as_array().unwrap().len(), 2); + fs::remove_dir_all(&tmp).ok(); + } + + #[test] + fn adding_twice_is_a_no_op() { + let (tmp, path, u) = tasks_fixture(2); + let (a, b) = (u[0].clone(), u[1].clone()); + + add_dependency(a.clone(), b.clone(), path.clone()).expect("first"); + add_dependency(a.clone(), b.clone(), path.clone()).expect("second must not error"); + + assert_eq!(task_json(&path, &a)["depends"].as_array().unwrap().len(), 1); + fs::remove_dir_all(&tmp).ok(); + } + + #[test] + fn remove_clears_the_edge_and_is_idempotent() { + let (tmp, path, u) = tasks_fixture(2); + let (a, b) = (u[0].clone(), u[1].clone()); + + add_dependency(a.clone(), b.clone(), path.clone()).expect("add"); + remove_dependency(a.clone(), b.clone(), path.clone()).expect("remove"); + remove_dependency(a.clone(), b.clone(), path.clone()).expect("second remove must not error"); + + let ja = task_json(&path, &a); + assert!(ja["depends"].as_array().unwrap().is_empty()); + assert_eq!(ja["is_blocked"].as_bool(), Some(false)); + assert_eq!( + task_json(&path, &b)["is_blocking"].as_bool(), + Some(false), + "B should no longer block anything" + ); + + fs::remove_dir_all(&tmp).ok(); + } +} + +#[cfg(test)] +mod recurrence_tests { + use super::*; + use serde_json::Value; + use std::{collections::HashMap, env, fs}; + + fn fixture(due: Option<&str>) -> (std::path::PathBuf, String, String) { + let tmp = env::temp_dir().join(format!("taskdb_recur_{}", Uuid::new_v4())); + fs::create_dir_all(&tmp).expect("create temp taskdb dir"); + let path = tmp.to_string_lossy().into_owned(); + + 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(), "chore".to_string()); + if let Some(d) = due { + map.insert("due".to_string(), d.to_string()); + } + add_task(path.clone(), map).expect("add_task"); + (tmp, path, uuid) + } + + fn field(path: &str, uuid: &str, key: &str) -> Option { + let json = get_all_tasks_json(path.to_string()).expect("get_all_tasks_json"); + let tasks: Vec = serde_json::from_str(&json).expect("parse json"); + tasks + .into_iter() + .find(|t| t.get("uuid").and_then(|u| u.as_str()) == Some(uuid)) + .and_then(|t| t.get(key).and_then(|v| v.as_str()).map(|s| s.to_string())) + } + + fn update(path: &str, uuid: &str, pairs: &[(&str, &str)]) -> Result<(), String> { + let map: HashMap = pairs + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + update_task(uuid.to_string(), path.to_string(), map) + } + + #[test] + fn recur_is_stored_when_the_task_has_a_due_date() { + let (tmp, path, uuid) = fixture(Some("2026-09-01T09:00:00Z")); + + update(&path, &uuid, &[("recur", "weekly")]).expect("should be allowed"); + + assert_eq!(field(&path, &uuid, "recur").as_deref(), Some("weekly")); + fs::remove_dir_all(&tmp).ok(); + } + + #[test] + fn recur_is_refused_without_a_due_date() { + // The consequence of allowing this is not a broken field but a destroyed + // task: the Taskwarrior CLI deletes a recurring task with no due date. + let (tmp, path, uuid) = fixture(None); + + let err = update(&path, &uuid, &[("recur", "weekly")]) + .expect_err("must be refused"); + assert!(err.contains("needs a due date"), "unhelpful: {err}"); + assert_eq!(field(&path, &uuid, "recur"), None, "nothing may be written"); + + fs::remove_dir_all(&tmp).ok(); + } + + #[test] + fn recur_and_due_may_be_set_in_one_call() { + // The check must look at the state *after* the update, not before, or + // setting both at once would be wrongly rejected. + let (tmp, path, uuid) = fixture(None); + + update( + &path, + &uuid, + &[("recur", "monthly"), ("due", "2026-09-01T09:00:00Z")], + ) + .expect("setting both together should be allowed"); + + assert_eq!(field(&path, &uuid, "recur").as_deref(), Some("monthly")); + fs::remove_dir_all(&tmp).ok(); + } + + #[test] + fn the_due_date_cannot_be_stripped_while_recur_is_set() { + let (tmp, path, uuid) = fixture(Some("2026-09-01T09:00:00Z")); + update(&path, &uuid, &[("recur", "weekly")]).expect("set recur"); + + let err = update(&path, &uuid, &[("due", "")]) + .expect_err("removing due must be refused while recurring"); + assert!(err.contains("needs a due date"), "unhelpful: {err}"); + + // the due date must survive the refusal + assert!(field(&path, &uuid, "due").is_some()); + fs::remove_dir_all(&tmp).ok(); + } + + #[test] + fn clearing_recur_then_releases_the_due_date() { + let (tmp, path, uuid) = fixture(Some("2026-09-01T09:00:00Z")); + update(&path, &uuid, &[("recur", "weekly")]).expect("set recur"); + + update(&path, &uuid, &[("recur", "")]).expect("clearing recur is allowed"); + assert_eq!(field(&path, &uuid, "recur"), None); + + update(&path, &uuid, &[("due", "")]).expect("due may now be removed"); + fs::remove_dir_all(&tmp).ok(); + } + + #[test] + fn an_unrelated_edit_is_unaffected_by_the_rule() { + // A task with no due date and no recurrence must still be editable. + let (tmp, path, uuid) = fixture(None); + update(&path, &uuid, &[("description", "renamed")]).expect("plain edit"); + assert_eq!(field(&path, &uuid, "description").as_deref(), Some("renamed")); + fs::remove_dir_all(&tmp).ok(); + } +} + +#[cfg(test)] +mod recurring_status_tests { + use super::*; + use serde_json::Value; + use std::{collections::HashMap, env, fs}; + + /// The `recurring` status must be settable. + /// + /// It previously fell into update_task's catch-all and was silently + /// downgraded to Pending, which meant the app could not create a recurrence + /// template at all: the Taskwarrior CLI only generates instances for a task + /// whose status is `recurring`, so with `recur` alone nothing ever happened. + #[test] + fn recurring_status_round_trips() { + let tmp = env::temp_dir().join(format!("taskdb_recstat_{}", Uuid::new_v4())); + fs::create_dir_all(&tmp).unwrap(); + let path = tmp.to_string_lossy().into_owned(); + + let uuid = Uuid::new_v4().to_string(); + let mut add: HashMap = HashMap::new(); + add.insert("uuid".into(), uuid.clone()); + add.insert("description".into(), "template".into()); + add.insert("due".into(), "2026-09-01T09:00:00Z".into()); + add_task(path.clone(), add).unwrap(); + + let mut upd: HashMap = HashMap::new(); + upd.insert("recur".into(), "weekly".into()); + upd.insert("status".into(), "recurring".into()); + update_task(uuid.clone(), path.clone(), upd).unwrap(); + + let json = get_all_tasks_json(path.clone()).unwrap(); + let tasks: Vec = serde_json::from_str(&json).unwrap(); + let t = tasks + .into_iter() + .find(|t| t["uuid"].as_str() == Some(uuid.as_str())) + .unwrap(); + + assert_eq!(t["status"].as_str(), Some("recurring"), "status must stick"); + assert_eq!(t["recur"].as_str(), Some("weekly")); + + fs::remove_dir_all(&tmp).ok(); + } +} diff --git a/rust/src/frb_generated.rs b/rust/src/frb_generated.rs index 9f690200..484d4d9d 100644 --- a/rust/src/frb_generated.rs +++ b/rust/src/frb_generated.rs @@ -37,7 +37,7 @@ flutter_rust_bridge::frb_generated_boilerplate!( default_rust_auto_opaque = RustAutoOpaqueMoi, ); pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.11.1"; -pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -2049867087; +pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -1358106344; // Section: executor @@ -45,6 +45,84 @@ flutter_rust_bridge::frb_generated_default_handler!(); // Section: wire_funcs +fn wire__crate__api__add_annotation_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "add_annotation", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_uuid_st = ::sse_decode(&mut deserializer); + let api_description = ::sse_decode(&mut deserializer); + let api_taskdb_dir_path = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| { + transform_result_sse::<_, String>((move || { + let output_ok = crate::api::add_annotation( + api_uuid_st, + api_description, + api_taskdb_dir_path, + )?; + Ok(output_ok) + })()) + } + }, + ) +} +fn wire__crate__api__add_dependency_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "add_dependency", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_uuid_st = ::sse_decode(&mut deserializer); + let api_depends_on_st = ::sse_decode(&mut deserializer); + let api_taskdb_dir_path = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| { + transform_result_sse::<_, String>((move || { + let output_ok = crate::api::add_dependency( + api_uuid_st, + api_depends_on_st, + api_taskdb_dir_path, + )?; + Ok(output_ok) + })()) + } + }, + ) +} fn wire__crate__api__add_task_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -72,9 +150,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 +184,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 +217,88 @@ 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) + })()) + } + }, + ) +} +fn wire__crate__api__remove_annotation_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "remove_annotation", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_uuid_st = ::sse_decode(&mut deserializer); + let api_entry_rfc3339 = ::sse_decode(&mut deserializer); + let api_taskdb_dir_path = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| { + transform_result_sse::<_, String>((move || { + let output_ok = crate::api::remove_annotation( + api_uuid_st, + api_entry_rfc3339, + api_taskdb_dir_path, + )?; + Ok(output_ok) + })()) + } + }, + ) +} +fn wire__crate__api__remove_dependency_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "remove_dependency", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_uuid_st = ::sse_decode(&mut deserializer); + let api_depends_on_st = ::sse_decode(&mut deserializer); + let api_taskdb_dir_path = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| { + transform_result_sse::<_, String>((move || { + let output_ok = crate::api::remove_dependency( + api_uuid_st, + api_depends_on_st, + api_taskdb_dir_path, + )?; + Ok(output_ok) + })()) } }, ) @@ -181,17 +331,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 +376,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 +388,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 +404,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 { @@ -342,11 +472,15 @@ fn pde_ffi_dispatcher_primary_impl( ) { // Codec=Pde (Serialization + dispatch), see doc to use other codecs match func_id { - 1 => wire__crate__api__add_task_impl(port, ptr, rust_vec_len, data_len), - 2 => wire__crate__api__delete_task_impl(port, ptr, rust_vec_len, data_len), - 3 => wire__crate__api__get_all_tasks_json_impl(port, ptr, rust_vec_len, data_len), - 4 => wire__crate__api__sync_impl(port, ptr, rust_vec_len, data_len), - 5 => wire__crate__api__update_task_impl(port, ptr, rust_vec_len, data_len), + 1 => wire__crate__api__add_annotation_impl(port, ptr, rust_vec_len, data_len), + 2 => wire__crate__api__add_dependency_impl(port, ptr, rust_vec_len, data_len), + 3 => wire__crate__api__add_task_impl(port, ptr, rust_vec_len, data_len), + 4 => wire__crate__api__delete_task_impl(port, ptr, rust_vec_len, data_len), + 5 => wire__crate__api__get_all_tasks_json_impl(port, ptr, rust_vec_len, data_len), + 6 => wire__crate__api__remove_annotation_impl(port, ptr, rust_vec_len, data_len), + 7 => wire__crate__api__remove_dependency_impl(port, ptr, rust_vec_len, data_len), + 8 => wire__crate__api__sync_impl(port, ptr, rust_vec_len, data_len), + 9 => wire__crate__api__update_task_impl(port, ptr, rust_vec_len, data_len), _ => unreachable!(), } } @@ -365,13 +499,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 +513,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..7fd600ec --- /dev/null +++ b/rust/src/utils/error.rs @@ -0,0 +1,35 @@ +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("no task with UUID '{0}'")] + TaskNotFound(String), + + #[error("invalid input: {0}")] + InvalidInput(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/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/services/taskrc_service_test.dart b/test/services/taskrc_service_test.dart new file mode 100644 index 00000000..f2884290 --- /dev/null +++ b/test/services/taskrc_service_test.dart @@ -0,0 +1,179 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:taskwarrior/app/models/report.dart'; +import 'package:taskwarrior/app/services/taskrc_service.dart'; +import 'package:taskwarrior/app/utils/taskchampion/taskrc_parser.dart'; +import 'package:taskwarrior/app/utils/taskchampion/virtual_filter_engine.dart'; + +ReportDefinition report({ + String name = 'mine', + String description = 'My report', + String? filter = 'status:pending +READY', + String sort = 'urgency-', + String columns = 'id,description', +}) => + ReportDefinition( + name: name, + description: description, + filterExpression: filter, + sortCriteria: SortCriterion.parseList(sort), + columns: ColumnSpec.parseList(columns), + isCustom: true, + ); + +void main() { + group('report name validation', () { + test('accepts names Taskwarrior can address', () { + for (final String ok in ['mine', 'work-today', 'a_b', 'r2']) { + expect(TaskrcService.validateName(ok), isNull, reason: ok); + } + }); + + test('rejects names that would produce an unreadable key', () { + // `report..sort` is parsed by splitting on dots, so a dotted or + // spaced name yields a key that can never be read back. + for (final String bad in ['', ' ', 'my.report', 'my report', 'a=b']) { + expect(TaskrcService.validateName(bad), isNotNull, + reason: 'should reject "$bad"'); + } + }); + }); + + group('writing a report into .taskrc', () { + test('round-trips through the parser', () { + final String content = TaskrcService.mergeReport('', report()); + final List parsed = + (TaskrcParser()..parse(content)).customReports(); + + expect(parsed, hasLength(1)); + final ReportDefinition r = parsed.single; + expect(r.name, 'mine'); + expect(r.description, 'My report'); + expect(r.filterExpression, 'status:pending +READY'); + expect(r.sortCriteria.single.field, 'urgency'); + expect(r.sortCriteria.single.ascending, isFalse); + expect(r.columns.map((c) => c.field), ['id', 'description']); + }); + + test('always writes a sort key, since that is what marks a report', () { + // TaskrcParser only recognises a block that has `.sort`; omitting it + // would silently produce a report that never appears again. + final String content = TaskrcService.mergeReport( + '', report(sort: '')); + expect(content, contains('report.mine.sort=')); + expect((TaskrcParser()..parse(content)).customReports(), hasLength(1)); + }); + + test('omits the filter line when there is no filter', () { + final String content = + TaskrcService.mergeReport('', report(filter: null)); + expect(content, isNot(contains('report.mine.filter'))); + expect((TaskrcParser()..parse(content)).customReports().single + .filterExpression, isNull); + }); + + test('preserves everything the user wrote by hand', () { + const String existing = ''' +# my own settings +data.location=/somewhere +report.other.sort=due+ +report.other.description=Someone else's report +'''; + final String content = TaskrcService.mergeReport(existing, report()); + + expect(content, contains('# my own settings')); + expect(content, contains('data.location=/somewhere')); + expect(content, contains('report.other.sort=due+')); + expect(content, contains('report.mine.sort=urgency-')); + + // and both reports are still readable + final names = (TaskrcParser()..parse(content)) + .customReports() + .map((r) => r.name) + .toSet(); + expect(names, {'other', 'mine'}); + }); + + test('replaces the same report instead of duplicating it', () { + String content = TaskrcService.mergeReport('', report()); + content = TaskrcService.mergeReport( + content, report(description: 'Updated', filter: '+OVERDUE')); + + expect('report.mine.sort='.allMatches(content).length, 1, + reason: 'saving twice must not leave two blocks'); + final ReportDefinition r = + (TaskrcParser()..parse(content)).customReports().single; + expect(r.description, 'Updated'); + expect(r.filterExpression, '+OVERDUE'); + }); + + test('repeated saves do not accumulate blank lines', () { + String content = TaskrcService.mergeReport('', report()); + for (int i = 0; i < 5; i++) { + content = TaskrcService.mergeReport(content, report()); + } + expect(content, isNot(contains('\n\n\n'))); + }); + + test('a name that cannot be addressed is refused, not written', () { + expect(() => TaskrcService.mergeReport('', report(name: 'bad.name')), + throwsArgumentError); + }); + }); + + group('deleting a report', () { + test('removes only the named report', () { + String content = TaskrcService.mergeReport('', report(name: 'keep')); + content = TaskrcService.mergeReport(content, report(name: 'drop')); + + final String after = TaskrcService.removeReport(content, 'drop'); + final names = (TaskrcParser()..parse(after)) + .customReports() + .map((r) => r.name) + .toSet(); + expect(names, {'keep'}); + }); + + test('leaves unrelated settings and comments intact', () { + const String existing = '# keep me\ndata.location=/x\n'; + final String content = TaskrcService.mergeReport(existing, report()); + final String after = TaskrcService.removeReport(content, 'mine'); + + expect(after, contains('# keep me')); + expect(after, contains('data.location=/x')); + expect(after, isNot(contains('report.mine'))); + }); + + test('deleting something that is not there changes nothing', () { + final String content = TaskrcService.mergeReport('', report()); + expect(TaskrcService.removeReport(content, 'absent').trim(), + content.trim()); + }); + }); + + group('filter validation', () { + test('accepts the documented vocabulary', () { + expect(VirtualFilterEngine.validate('status:pending +READY'), isEmpty); + expect(VirtualFilterEngine.validate('project:work priority:H'), isEmpty); + expect(VirtualFilterEngine.validate(null), isEmpty); + expect(VirtualFilterEngine.validate(' '), isEmpty); + }); + + test('bare words and tags are never errors', () { + // A bare word searches the description and +anything is a user tag, so + // neither can be a mistake — only an unknown attribute can. + expect(VirtualFilterEngine.validate('groceries +home -ACTIVE'), isEmpty); + }); + + test('catches an attribute typo, which would otherwise match everything', + () { + final List issues = + VirtualFilterEngine.validate('statuss:pending'); + expect(issues, hasLength(1)); + expect(issues.single, contains('matches every task')); + }); + + test('catches an attribute with no value', () { + expect(VirtualFilterEngine.validate('status:'), hasLength(1)); + }); + }); +} diff --git a/test/tour/filter_drawer_tour_test.dart b/test/tour/filter_drawer_tour_test.dart index e04fed92..eceb91df 100644 --- a/test/tour/filter_drawer_tour_test.dart +++ b/test/tour/filter_drawer_tour_test.dart @@ -24,146 +24,114 @@ void main() { sortByKey = GlobalKey(); }); - test('should return a list of TargetFocus with correct properties', () { - final targets = filterDrawer( - statusKey: statusKey, - projectsKey: projectsKey, - projectsKeyTaskc: projectsKeyTaskc, - filterTagKey: filterTagKey, - sortByKey: sortByKey, - ); - - expect(targets.length, 5); - - expect(targets[0].keyTarget, statusKey); - expect(targets[0].alignSkip, Alignment.topRight); - expect(targets[0].shape, ShapeLightFocus.RRect); - - expect(targets[1].keyTarget, projectsKey); - expect(targets[1].alignSkip, Alignment.topRight); - expect(targets[1].shape, ShapeLightFocus.RRect); - - expect(targets[2].keyTarget, projectsKeyTaskc); - expect(targets[2].alignSkip, Alignment.topRight); - expect(targets[2].shape, ShapeLightFocus.RRect); - - expect(targets[3].keyTarget, filterTagKey); - expect(targets[3].alignSkip, Alignment.topRight); - expect(targets[3].shape, ShapeLightFocus.RRect); - - expect(targets[4].keyTarget, sortByKey); - expect(targets[4].alignSkip, Alignment.topRight); - expect(targets[4].shape, ShapeLightFocus.RRect); + List build({required bool useTaskchampionProjects}) => + filterDrawer( + statusKey: statusKey, + projectsKey: projectsKey, + projectsKeyTaskc: projectsKeyTaskc, + filterTagKey: filterTagKey, + sortByKey: sortByKey, + useTaskchampionProjects: useTaskchampionProjects, + ); + + // Indices after the two projects targets were merged into one. + const int statusIndex = 0; + const int projectsIndex = 1; + const int filterTagIndex = 2; + const int sortByIndex = 3; + + test('exposes one target per visible control', () { + final targets = build(useTaskchampionProjects: false); + + // Four, not five: the drawer shows either the legacy projects column or + // the TaskChampion one, never both. + expect(targets.length, 4); + + expect(targets[statusIndex].keyTarget, statusKey); + expect(targets[filterTagIndex].keyTarget, filterTagKey); + expect(targets[sortByIndex].keyTarget, sortByKey); + + for (final target in targets) { + expect(target.alignSkip, Alignment.topRight); + expect(target.shape, ShapeLightFocus.RRect); + } }); - testWidgets('should render correct text for statusKey TargetContent', - (WidgetTester tester) async { - final targets = filterDrawer( - statusKey: statusKey, - projectsKey: projectsKey, - projectsKeyTaskc: projectsKeyTaskc, - filterTagKey: filterTagKey, - sortByKey: sortByKey, + // The regression this guards: both project keys used to be registered as + // targets unconditionally, but each column sits behind a mutually exclusive + // Visibility, so one of them was never laid out. tutorial_coach_mark then + // threw "It was not possible to obtain target position (null)" every time + // the tour ran — in either mode, by construction rather than by timing. + test('targets only the projects column that is actually on screen', () { + final legacy = build(useTaskchampionProjects: false); + expect(legacy[projectsIndex].keyTarget, projectsKey); + expect( + legacy.map((t) => t.keyTarget).contains(projectsKeyTaskc), + isFalse, + reason: 'the TaskChampion column is not mounted in legacy mode', ); - final content = targets[0].contents!.first; - - await tester.pumpWidget(MaterialApp( - home: Builder( - builder: (context) => content.builder!(context, controller), - ), - )); - - expect(find.text("Filter tasks based on their completion status"), - findsOneWidget); - }); - - testWidgets('should render correct text for projectsKey TargetContent', - (WidgetTester tester) async { - final targets = filterDrawer( - statusKey: statusKey, - projectsKey: projectsKey, - projectsKeyTaskc: projectsKeyTaskc, - filterTagKey: filterTagKey, - sortByKey: sortByKey, + final taskchampion = build(useTaskchampionProjects: true); + expect(taskchampion[projectsIndex].keyTarget, projectsKeyTaskc); + expect( + taskchampion.map((t) => t.keyTarget).contains(projectsKey), + isFalse, + reason: 'the legacy column is not mounted in TaskChampion mode', ); + }); - final content = targets[1].contents!.first; - + Future expectContent( + WidgetTester tester, + TargetFocus target, + String expected, + ) async { + final content = target.contents!.first; await tester.pumpWidget(MaterialApp( home: Builder( builder: (context) => content.builder!(context, controller), ), )); - - expect(find.text("Filter tasks based on the projects"), findsOneWidget); + expect(find.text(expected), findsOneWidget); + } + + testWidgets('renders the status copy', (tester) async { + await expectContent( + tester, + build(useTaskchampionProjects: false)[statusIndex], + 'Filter tasks based on their completion status', + ); }); - testWidgets('should render correct text for projectsKeyTaskc TargetContent', - (WidgetTester tester) async { - final targets = filterDrawer( - statusKey: statusKey, - projectsKey: projectsKey, - projectsKeyTaskc: projectsKeyTaskc, - filterTagKey: filterTagKey, - sortByKey: sortByKey, + testWidgets('renders the projects copy in legacy mode', (tester) async { + await expectContent( + tester, + build(useTaskchampionProjects: false)[projectsIndex], + 'Filter tasks based on the projects', ); - - final content = targets[2].contents!.first; - - await tester.pumpWidget(MaterialApp( - home: Builder( - builder: (context) => content.builder!(context, controller), - ), - )); - - expect(find.text("Filter tasks based on the projects"), findsOneWidget); }); - testWidgets('should render correct text for filterTagKey TargetContent', - (WidgetTester tester) async { - final targets = filterDrawer( - statusKey: statusKey, - projectsKey: projectsKey, - projectsKeyTaskc: projectsKeyTaskc, - filterTagKey: filterTagKey, - sortByKey: sortByKey, + testWidgets('renders the same projects copy in TaskChampion mode', + (tester) async { + await expectContent( + tester, + build(useTaskchampionProjects: true)[projectsIndex], + 'Filter tasks based on the projects', ); - - final content = targets[3].contents!.first; - - await tester.pumpWidget(MaterialApp( - home: Builder( - builder: (context) => content.builder!(context, controller), - ), - )); - - expect(find.text("Toggle between AND and OR tag union types"), - findsOneWidget); }); - testWidgets('should render correct text for sortByKey TargetContent', - (WidgetTester tester) async { - final targets = filterDrawer( - statusKey: statusKey, - projectsKey: projectsKey, - projectsKeyTaskc: projectsKeyTaskc, - filterTagKey: filterTagKey, - sortByKey: sortByKey, + testWidgets('renders the tag-union copy', (tester) async { + await expectContent( + tester, + build(useTaskchampionProjects: false)[filterTagIndex], + 'Toggle between AND and OR tag union types', ); + }); - final content = targets[4].contents!.first; - - await tester.pumpWidget(MaterialApp( - home: Builder( - builder: (context) => content.builder!(context, controller), - ), - )); - - expect( - find.text( - "Sort tasks based on time of creation, urgency, due date, start date, etc."), - findsOneWidget, + testWidgets('renders the sort copy', (tester) async { + await expectContent( + tester, + build(useTaskchampionProjects: false)[sortByIndex], + 'Sort tasks based on time of creation, urgency, due date, start date, etc.', ); }); }); 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/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.